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(
)
expect(getByText('Who?')).not.toBeNull()
})
test('can receive options as children, with altText', () => {
const { queryByText } = renderWithTheme(
)
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(
)
const trigger = getByRole('button')
expect(trigger).toHaveTextContent('No options available')
})
test('can receive options with number values', () => {
const options = [
{ label: '1', value: 1 },
{ label: 'two', value: 2 },
{ label: '3', value: 3 },
]
const { getByText } = 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(
)
const trigger = getByText('Select an option')
userEvent.click(trigger)
const topOption = getByText('yum')
expect(getByRole('listbox').getAttribute('aria-activedescendant')).toEqual(topOption.id)
})
test('does not set active descendant to the top option when it is disabled', () => {
const options = [
{ label: 'The #1 disabled option', value: 0, disabled: true },
{ label: 'First the worst, second the best', value: 1 },
]
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(
)
let trigger = getByTestId('my-select')
userEvent.click(trigger)
userEvent.click(getByText('one'))
trigger = getByTestId('my-select')
expect(trigger).toHaveTextContent('one')
rerender(
)
trigger = getByTestId('my-select')
expect(trigger).toHaveTextContent('two')
})
describe('clears value on empty string', () => {
test('single select', () => {
const { getByRole, rerender } = renderWithTheme(
)
const trigger = getByRole('button')
expect(trigger).toHaveTextContent('y')
rerender()
expect(trigger).toHaveTextContent('Select an option')
})
test('...unless there is an option with a blank value', () => {
const { getByRole, rerender } = renderWithTheme(
)
const trigger = getByRole('button')
expect(trigger).toHaveTextContent('this statement is false')
rerender(
)
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
userEvent.click(trigger) // space is actually a click on buttons
await animationRender()
const list = getByRole('listbox')
expect(document.activeElement).toBe(list)
expect(getActiveValue()).toEqual('phoenix')
})
test('listbox gets focus on UP 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(
)
const trigger = getByText('Select an option') // default placeholder
fireEvent.keyUp(trigger, { key: KeyCodes.DOWN })
await animationRender()
const list = getByRole('listbox')
expect(document.activeElement).toBe(list)
expect(getActiveValue()).toEqual('phoenix')
})
test('returns focus to button on RETURN', 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(
)
// @ts-ignore
const trigger: HTMLElement = getByText('Select an option').parentElement // default placeholder
fireEvent.keyUp(trigger, { key: KeyCodes.DOWN })
fireEvent.keyDown(getByRole('listbox'), {
key: KeyCodes.ESC,
})
await animationRender()
expect(document.activeElement).toEqual(trigger)
})
test('after open, DOWN on listbox selects second option', 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.UP })
fireEvent.keyDown(list, {
key: KeyCodes.RETURN,
})
await animationRender()
expect(onChange).toHaveBeenCalledTimes(2)
expect(box).toEqual({ name: 'city', value: 'tucson' })
})
test('does not raise duplicate 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 firstOption = getByText('first')
const lastOption = getByText('last')
fireEvent.keyDown(listbox, { key: KeyCodes.END })
expect(listbox.getAttribute('aria-activedescendant')).toBe(lastOption.id)
fireEvent.keyDown(listbox, { key: KeyCodes.HOME })
expect(listbox.getAttribute('aria-activedescendant')).toBe(firstOption.id)
})
test('sets the active descendant to the first/last non-disabled option on HOME/END keydown, respectively', () => {
const options = [
{ label: 'first disabled', value: 0, disabled: true },
{ label: 'first enabled', value: 1 },
{ label: 'middle', value: 2 },
{ label: 'last enabled', value: 3 },
{ label: 'last disabled', value: 4, disabled: true },
]
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' })
expect(getActiveValue()).toEqual('tucson')
})
test('will match multiple letters', 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(
)
// @ts-ignore
const trigger: HTMLElement = getByText('Select an option').parentElement
userEvent.click(trigger)
userEvent.click(getByText('flagstaff'))
await animationRender()
expect(document.activeElement).toEqual(trigger)
})
test('raises 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)
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)
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(getByRole('option', { name: 'flagstaff' }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(box).toEqual({ name: 'city', value: 'flagstaff' })
})
test('mouseEnter sets activedescendant', () => {
const cities = ['Apache Junction', 'Avondale', 'Benson', 'Bisbee', 'Buckeye', 'Bullhead City']
const { getByText, getByRole } = 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(