import { fireEvent } from '@testing-library/react' import userEvent from '@testing-library/user-event' import pick from 'lodash/pick' import * as React from 'react' import animationRender from '../../tests/helpers/animationRender' import renderWithTheme from '../../tests/helpers/renderWithTheme' import KeyCodes from '../helpers/keyCodes' import Select from './Select' function getActiveValue() { // @ts-ignore return document.getElementById(document.activeElement.getAttribute('aria-activedescendant')) .textContent } describe('component: Select', () => { test('can receive options as objects', () => { const options = [ { value: 'yum', label: 'Yum' }, { value: 'who?', label: 'Who?' }, { value: 'boss', label: 'Who is the boss?' }, ] const { getByText } = renderWithTheme( Peter Valentine Ender ) expect(queryByText('Peter')).not.toBeNull() expect(queryByText('Valentine')).not.toBeNull() expect(queryByText('Ender')).not.toBeNull() expect(queryByText('Locke')).toBeNull() expect(document.querySelector('#options-children')).toHaveTextContent('Demosthenes') }) test('can receive options as children, with disabled', () => { const onChange = jest.fn() const { getByRole, getByText } = renderWithTheme( ) userEvent.click(getByRole('button')) const enabled1 = getByText('Enabled 1') expect(getByText('Disabled 1')).toHaveAttribute('aria-disabled', 'true') expect(enabled1).toHaveAttribute('aria-disabled', 'false') expect(getByText('Enabled 2')).toHaveAttribute('aria-disabled', 'false') expect(getByText('Disabled 2')).toHaveAttribute('aria-disabled', 'true') }) test('should set placeholder when options are empty', () => { const { getByRole } = renderWithTheme( ) expect(getByText('two')).not.toBeNull() }) test('sets active descendant to the top option when value does not exist in options', async () => { const { getByText, getByRole } = renderWithTheme( ) userEvent.click(getByRole('button')) const activeDescendant = getByRole('listbox').getAttribute('aria-activedescendant') const topOption = getByText('The #1 disabled option') const bottomOption = getByText('First the worst, second the best') expect(activeDescendant).not.toEqual(topOption.id) expect(activeDescendant).toEqual(bottomOption.id) }) test('can update value through props', () => { const { getByText, getByTestId, rerender } = renderWithTheme( ) trigger = getByTestId('my-select') expect(trigger).toHaveTextContent('two') }) describe('clears value on empty string', () => { test('single select', () => { const { getByRole, rerender } = renderWithTheme( ) expect(trigger).toHaveTextContent('Select an option') }) test('...unless there is an option with a blank value', () => { const { getByRole, rerender } = renderWithTheme( ) expect(trigger).toHaveTextContent('this page is intentionally blank') }) test('multi select', () => { const { getByRole, rerender } = renderWithTheme( ) const trigger = getByRole('button') expect(trigger).toHaveTextContent('zy') rerender( ) expect(trigger).toHaveTextContent('Select an option') }) test('multi select with empty string as value', () => { const { getByRole, rerender } = renderWithTheme( ) const trigger = getByRole('button') expect(trigger).toHaveTextContent('zyBlank') rerender( ) expect(trigger).toHaveTextContent('Blank') }) }) describe('keyboard interactions', () => { test('listbox gets focus on SPACE and first option selected', async () => { const { getByText, getByRole } = renderWithTheme( ) const trigger = getByText('Select an option') // default placeholder fireEvent.keyUp(trigger, { key: KeyCodes.UP }) await animationRender() const list = getByRole('listbox') expect(document.activeElement).toBe(list) expect(getActiveValue()).toEqual('phoenix') }) test('listbox gets focus on DOWN first option selected', async () => { const { getByText, getByRole } = renderWithTheme( ) // @ts-ignore const trigger: HTMLElement = getByText('Select an option').parentElement // default placeholder fireEvent.keyUp(trigger, { key: KeyCodes.DOWN }) fireEvent.keyDown(getByRole('listbox'), { key: KeyCodes.DOWN }) fireEvent.keyDown(getByRole('listbox'), { key: KeyCodes.RETURN, }) await animationRender() expect(document.activeElement).toEqual(trigger) }) test('returns focus to button on ESC', async () => { const { getByText, getByRole } = renderWithTheme( ) const trigger = getByText('Select an option') // default placeholder fireEvent.keyUp(trigger, { key: KeyCodes.DOWN }) await animationRender() const list = getByRole('listbox') fireEvent.keyDown(list, { key: KeyCodes.DOWN }) // @ts-ignore expect(getActiveValue()).toEqual('tucson') }) test('raises onChange event on RETURN keydown', async () => { const box: any = {} const onChange = jest.fn((e) => Object.assign(box, pick(e.target, ['name', 'value']))) const { getByText, getByRole } = renderWithTheme( ) const trigger = getByText('Select an option') fireEvent.keyUp(trigger, { key: KeyCodes.DOWN }) expect(box).toEqual({}) const list = getByRole('listbox') fireEvent.keyDown(list, { key: KeyCodes.DOWN }) fireEvent.keyDown(list, { key: KeyCodes.DOWN }) fireEvent.keyDown(list, { key: KeyCodes.RETURN, }) await animationRender() expect(onChange).toHaveBeenCalledTimes(1) expect(box).toEqual({ name: 'city', value: 'flagstaff' }) const newTrigger = getByRole('button', { name: 'flagstaff' }) fireEvent.keyUp(newTrigger, { key: KeyCodes.DOWN }) fireEvent.keyDown(list, { key: KeyCodes.RETURN, }) await animationRender() expect(onChange).toHaveBeenCalledTimes(1) expect(box).toEqual({ name: 'city', value: 'flagstaff' }) }) test('skips disabled options when navigating the list with the UP/DOWN keys', () => { const { getByRole, getByText } = renderWithTheme( ) userEvent.click(getByRole('button')) const listbox = getByRole('listbox') const firstActive = getByText('First active') const secondActive = getByText('Second active') const thirdActive = getByText('Third active') expect(listbox.getAttribute('aria-activedescendant')).toBe(firstActive.id) fireEvent.keyDown(listbox, { key: KeyCodes.DOWN }) expect(listbox.getAttribute('aria-activedescendant')).toBe(secondActive.id) fireEvent.keyDown(listbox, { key: KeyCodes.DOWN }) expect(listbox.getAttribute('aria-activedescendant')).toBe(thirdActive.id) fireEvent.keyDown(listbox, { key: KeyCodes.UP }) expect(listbox.getAttribute('aria-activedescendant')).toBe(secondActive.id) fireEvent.keyDown(listbox, { key: KeyCodes.UP }) expect(listbox.getAttribute('aria-activedescendant')).toBe(firstActive.id) }) test('sets the active descendant to the first/last option on HOME/END keydown, respectively', () => { const { getByRole, getByText } = renderWithTheme( ) userEvent.click(getByRole('button')) const listbox = getByRole('listbox') const firstEnabled = getByText('first enabled') const lastEnabled = getByText('last enabled') fireEvent.keyDown(listbox, { key: KeyCodes.END }) expect(listbox.getAttribute('aria-activedescendant')).toBe(lastEnabled.id) fireEvent.keyDown(listbox, { key: KeyCodes.HOME }) expect(listbox.getAttribute('aria-activedescendant')).toBe(firstEnabled.id) }) describe('typing with open list', () => { test('will select matching first letter', async () => { const { getByText, getByRole } = renderWithTheme( ) const trigger = getByText('Select an option') // default placeholder fireEvent.keyUp(trigger, { key: KeyCodes.DOWN }) await animationRender() const list = getByRole('listbox') fireEvent.keyDown(list, { key: 't' }) fireEvent.keyDown(list, { key: 'u' }) expect(getActiveValue()).toEqual('tucson') }) }) }) describe('mouse interactions', () => { test('closes on option click', async () => { const { getByText } = renderWithTheme( ) const trigger = getByText('Click me!') userEvent.click(trigger) userEvent.click(getByText('flagstaff')) expect(onChange).toHaveBeenCalledTimes(1) expect(box).toEqual({ name: 'city', value: 'flagstaff' }) const newTrigger = getByRole('button', { name: 'flagstaff' }) userEvent.click(newTrigger) userEvent.click(getByText('tucson')) expect(onChange).toHaveBeenCalledTimes(2) expect(box).toEqual({ name: 'city', value: 'tucson' }) }) test('does not raise onChange event on click when the option is disabled', () => { const onChange = jest.fn() const { getByRole, getByText } = renderWithTheme( ) userEvent.click(getByRole('button')) userEvent.click(getByText('I never got an onChange handler!')) expect(onChange).not.toHaveBeenCalled() }) test('does not raise duplicate onChange event on click', () => { const box: any = {} const onChange = jest.fn((e) => Object.assign(box, pick(e.target, ['name', 'value']))) const { getByRole, getByText } = renderWithTheme( ) const trigger = getByText('Click me!') userEvent.click(trigger) const bensonOption = getByText('Benson') fireEvent.mouseEnter(bensonOption) expect(getByRole('listbox').getAttribute('aria-activedescendant')).toEqual(bensonOption.id) }) test('mouseEnter does not set activedescendant when the option is disabled', () => { const { getByText, getByRole } = renderWithTheme( ) userEvent.click(getByRole('button')) const notActive = getByText('not the active descendant') fireEvent.mouseEnter(notActive) expect(getByRole('listbox').getAttribute('aria-activedescendant')).not.toEqual(notActive.id) }) }) describe('nonstandard values', () => { test('can select anything as a value', () => { const values = jest.fn() const options = [ { label: 'object', value: { one: 'object' } }, { label: 'array', value: ['two'] }, { label: 'undefined', value: undefined }, { label: 'date', value: new Date() }, ] const { getByRole, getByText } = renderWithTheme( ) const trigger = getByRole('button') fireEvent.focus(trigger) fireEvent.blur(trigger) expect(onBlur).toHaveBeenCalledTimes(1) expect(box.name).toEqual('city') }) test('is NOT called when list opens', async () => { const box: any = {} const onBlur = jest.fn((e) => { box.name = e.target.name }) const { getByRole } = renderWithTheme( ) const trigger = getByRole('button') trigger.focus() userEvent.click(trigger) getByText('lose focus').focus() expect(onBlur).toHaveBeenCalledTimes(1) expect(box.name).toEqual('city') }) }) describe('onFocus()', () => { test('is called when user focuses on Select', () => { const box: any = {} const onFocus = jest.fn((e) => { box.name = e.target.name }) const { getByRole } = renderWithTheme( ) const trigger = getByRole('button') userEvent.click(trigger) expect(onFocus).toHaveBeenCalledTimes(1) expect(box.name).toEqual('city') onFocus.mockReset() fireEvent.keyDown(getByRole('listbox'), { key: KeyCodes.ESC, }) expect(onFocus).not.toHaveBeenCalled() }) }) describe('with multiple=true', () => { test('trigger is rendered with aria-multiselectable=true', () => { const startingValue = ['tucson'] const box: any = {} const onChange = jest.fn((e) => Object.assign(box, pick(e.target, ['name', 'value']))) renderWithTheme( ) userEvent.click(getByRole('button')) const options = getAllByRole('option') options.forEach((o): void => { expect(o.getAttribute('aria-selected')).toBe( String(o.getAttribute('data-value') === 'tucson') ) }) }) test('can click checkboxes to add values', () => { const box: any = {} const onChange = jest.fn((e) => Object.assign(box, pick(e.target, ['name', 'value']))) const { getByText } = renderWithTheme( ) userEvent.click(getByRole('button')) const disabledCheckbox = document.querySelector('input[type=checkbox]') as Element expect(disabledCheckbox).toBeDisabled() fireEvent.click(disabledCheckbox) expect(onChange).not.toHaveBeenCalled() }) test('can select multiple options', async () => { const startingValue = ['tucson'] const box: any = {} const onChange = jest.fn((e) => Object.assign(box, pick(e.target, ['name', 'value']))) const { getByText, getByRole } = renderWithTheme( ) const trigger = getByRole('button') userEvent.click(trigger) await animationRender() const list = getByRole('listbox') expect(document.activeElement).toBe(list) expect(getActiveValue()).toBe('tucson') // We still need fireEvent for these elements because it's a weird setup with a // readonly checkbox, and userEvent doesn't handle that well fireEvent.click(getByText('tucson', { selector: '[role="option"]' })) expect(onChange).toHaveBeenCalledTimes(1) expect(box).toEqual({ name: 'city', value: [] }) }) test('SPACE key will toggle option', async () => { const box: any = {} const onChange = jest.fn((e) => Object.assign(box, pick(e.target, ['name', 'value']))) const { getByRole } = renderWithTheme( ) const trigger = getByRole('button') userEvent.click(trigger) const listbox = getByRole('listbox') fireEvent.keyDown(listbox, { key: KeyCodes.DOWN }) fireEvent.keyDown(listbox, { key: KeyCodes.RETURN, metaKey: true, }) await animationRender() expect(document.activeElement).toBe(trigger) expect(onChange).toHaveBeenCalledTimes(1) expect(box).toEqual({ name: 'city', value: ['tucson'] }) userEvent.click(trigger) await animationRender() expect(getActiveValue()).toBe('tucson') fireEvent.keyDown(listbox, { key: KeyCodes.RETURN, metaKey: true, }) await animationRender() expect(document.activeElement).toBe(trigger) expect(onChange).toHaveBeenCalledTimes(1) expect(box).toEqual({ name: 'city', value: ['tucson'] }) }) test('CLICK will toggle option but not close', async () => { const startingValue = ['tucson'] const box: any = {} const onChange = jest.fn((e) => Object.assign(box, pick(e.target, ['name', 'value']))) const { getByText, getByRole } = renderWithTheme( ) const trigger = getByRole('button') userEvent.click(trigger) await animationRender() expect(getByRole('listbox')).toBeInTheDocument() expect(document.activeElement).toBe(getByRole('textbox')) }) test('typing should filter options', () => { const { getByRole } = renderWithTheme( ) let trigger = getByRole('button') userEvent.click(trigger) await animationRender() const searchBox: HTMLElement = document.activeElement as HTMLElement userEvent.type(searchBox, 'camp verde') userEvent.click(getByText('Create "camp verde"')) trigger = getByRole('button') expect(trigger).toHaveTextContent('camp verde') }) test('should show when no match is found', async () => { const { getByText, getByRole } = renderWithTheme( ) const trigger = getByRole('button') userEvent.click(trigger) await animationRender() const searchBox: HTMLElement = document.activeElement as HTMLElement fireEvent.keyDown(searchBox, { key: KeyCodes.DOWN }) expect(searchBox.getAttribute('aria-activedescendant')).toBe('test-id-phoenix') fireEvent.keyDown(searchBox, { key: KeyCodes.DOWN }) fireEvent.keyDown(searchBox, { key: KeyCodes.DOWN }) expect(searchBox.getAttribute('aria-activedescendant')).toBe('test-id-flagstaff') fireEvent.keyDown(searchBox, { key: KeyCodes.UP }) expect(searchBox.getAttribute('aria-activedescendant')).toBe('test-id-tucson') }) test('UP/DOWN should not set the active descendant for disabled options', async () => { const { getByRole, getByText } = renderWithTheme( ) userEvent.click(getByRole('button')) await animationRender() const searchBox = document.activeElement as HTMLElement const firstActive = getByText('No skip') const secondActive = getByText("Don't skip me") fireEvent.keyDown(searchBox, { key: KeyCodes.DOWN }) expect(searchBox.getAttribute('aria-activedescendant')).toBe(firstActive.id) fireEvent.keyDown(searchBox, { key: KeyCodes.DOWN }) expect(searchBox.getAttribute('aria-activedescendant')).toBe(secondActive.id) fireEvent.keyDown(searchBox, { key: KeyCodes.UP }) expect(searchBox.getAttribute('aria-activedescendant')).toBe(firstActive.id) fireEvent.keyDown(searchBox, { key: KeyCodes.UP }) expect(searchBox.getAttribute('aria-activedescendant')).toBe(firstActive.id) }) test('RETURN should select an option and focus on the trigger', async () => { const { getByRole } = renderWithTheme( ) let trigger = getByRole('button') userEvent.click(trigger) await animationRender() const searchBox: HTMLElement = document.activeElement as HTMLElement fireEvent.keyDown(searchBox, { key: KeyCodes.ESC }) await animationRender() trigger = getByRole('button') expect(document.activeElement).toBe(trigger) }) test('values that are not in options should be added', () => { const { getByRole } = renderWithTheme( ) rerender( ) const trigger = getByRole('button') userEvent.click(trigger) const listbox = getByRole('listbox') expect(listbox).toHaveTextContent('superior') expect(listbox).toHaveTextContent('globe') }) test('values added should not be duplicated', () => { const DelayedOptions = () => { const [options, setOptions] = React.useState([]) React.useEffect(() => setOptions(['phoenix', 'tucson', 'flagstaff']), []) return ) const trigger = getByRole('button') userEvent.click(trigger) const flagstaff = getByText('flagstaff') const phoenix = getByText('phoenix') // We still need fireEvent for these elements because it's a weird setup with a // readonly checkbox, and userEvent doesn't handle that well fireEvent.click(flagstaff) fireEvent.click(phoenix) expect(flagstaff.getAttribute('aria-selected')).toBe('true') expect(phoenix.getAttribute('aria-selected')).toBe('true') expect(getByRole('listbox')).not.toBeNull() }) test('RETURN on option should toggle it and keep the list open', async () => { const box: any = {} const onChange = jest.fn((e) => Object.assign(box, pick(e.target, ['name', 'value']))) const { getByText, getByRole } = renderWithTheme( ) let trigger = getByRole('button') userEvent.click(trigger) await animationRender() let searchBox: HTMLElement = document.activeElement as HTMLElement fireEvent.keyDown(searchBox, { key: KeyCodes.DOWN }) fireEvent.keyDown(searchBox, { key: KeyCodes.RETURN, metaKey: true, }) await animationRender() trigger = getByRole('button') expect(document.activeElement).toBe(trigger) expect(document.activeElement).toHaveTextContent('phoenix') expect(onChange).toHaveBeenCalledTimes(1) expect(box).toEqual({ name: 'city', value: ['phoenix'] }) userEvent.click(trigger) await animationRender() searchBox = document.activeElement as HTMLElement fireEvent.keyDown(searchBox, { key: KeyCodes.DOWN }) fireEvent.keyDown(searchBox, { key: KeyCodes.RETURN, metaKey: true, }) await animationRender() trigger = getByRole('button') expect(document.activeElement).toBe(trigger) expect(document.activeElement).toHaveTextContent('phoenix') expect(onChange).toHaveBeenCalledTimes(1) expect(box).toEqual({ name: 'city', value: ['phoenix'] }) }) test('blurring the list box to focus on the input should not close the list', () => { const { getByRole, getByText } = renderWithTheme( ) const trigger = getByRole('button') expect(trigger).toHaveTextContent('boolestcoolest') userEvent.click(trigger) const listbox = getByRole('listbox') expect(listbox.childNodes[0]).toHaveTextContent('boolest') expect(listbox.childNodes[1]).toHaveTextContent('coolest') }) }) describe('with theme customization', () => { test('should have 2px border', () => { const { container } = renderWithTheme( , { shape: 'intermediate' } ) const selectComponent = container.querySelector('[id="test-id"]') const styles = window.getComputedStyle(selectComponent as Element) expect(styles.borderWidth).toBe('1px') expect(styles.borderRadius).toBe('8px') }) test('should match square shape styles', () => { const { container } = renderWithTheme( , { boxShadows: false } ) const selectComponent = container.querySelector('[id="test-id"]') const styles = window.getComputedStyle(selectComponent as Element) expect(styles.boxShadow).toBe('') }) }) })