From be3a75be80259e07911c9ffa2f62acd5f138dcb9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Jan 2025 11:17:42 +0200 Subject: [PATCH] test: add combobox tests --- .../core/src/useComboBox/useComboBox.spec.ts | 530 ++++++++++++++++++ packages/core/src/useComboBox/useComboBox.ts | 16 +- packages/core/src/useListBox/useListBox.ts | 16 +- 3 files changed, 557 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/useComboBox/useComboBox.spec.ts diff --git a/packages/core/src/useComboBox/useComboBox.spec.ts b/packages/core/src/useComboBox/useComboBox.spec.ts new file mode 100644 index 00000000..111d5310 --- /dev/null +++ b/packages/core/src/useComboBox/useComboBox.spec.ts @@ -0,0 +1,530 @@ +import { defineComponent, Ref } from 'vue'; +import { ComboBoxProps, useComboBox } from '.'; +import { useOption } from '../useOption'; +import { fireEvent, render, screen } from '@testing-library/vue'; +import { axe } from 'vitest-axe'; +import { flush } from '@test-utils/index'; +import { useDefaultFilter } from '../collections'; + +function createComboBox(fixedProps: Partial> = {}) { + const Option = defineComponent({ + props: ['label', 'value', 'disabled'], + setup(props, { attrs }) { + const all = { ...attrs, ...props } as any; + const { optionProps } = useOption(all); + + return { + optionProps, + ...all, + }; + }, + template: ` +
{{ label }}
+ `, + }); + + let exposedSelectedOption: Ref; + + const component = defineComponent({ + components: { OptionItem: Option }, + setup(props, { attrs }) { + const all = { ...attrs, ...props, ...fixedProps } as any; + const { + labelProps, + inputProps, + listBoxProps, + errorMessageProps, + descriptionProps, + buttonProps, + inputValue, + selectedOption, + } = useComboBox(all, { + filter: useDefaultFilter({ caseSensitive: false }).contains, + }); + + exposedSelectedOption = selectedOption; + + const options = all.options || null; + const getValue = (option: any) => option; + + return { + ...all, + labelProps, + inputProps, + listBoxProps, + buttonProps, + errorMessageProps, + descriptionProps, + inputValue, + getValue, + options, + selectedOption, + }; + }, + template: ` +
+ + +
+ + +
+ +
+ + + +
+ + + {{ errorMessage }} + +
+ `, + }); + + component.getExposedState = () => ({ + selectedOption: exposedSelectedOption.value, + }); + + return component; +} + +function getInput() { + return screen.getByRole('combobox'); +} + +function getButton() { + return screen.getByRole('button'); +} + +describe('should not have a11y errors', () => { + test('with options', async () => { + await render({ + components: { + MyComboBox: createComboBox(), + }, + setup() { + const options = [{ label: 'One' }, { label: 'Two' }, { label: 'Three' }]; + + return { options }; + }, + template: ` +
+ +
+ `, + }); + + await flush(); + vi.useRealTimers(); + expect(await axe(screen.getByTestId('fixture'))).toHaveNoViolations(); + vi.useFakeTimers(); + }); +}); + +describe('keyboard features', () => { + async function renderComboBox(opts?: { label: string; disabled?: boolean }[]) { + await render({ + components: { + MyComboBox: createComboBox(), + }, + setup() { + const options = opts || [{ label: 'One' }, { label: 'Two' }, { label: 'Three' }]; + + return { options }; + }, + template: ` +
+ +
+ `, + }); + + return { + async open() { + await fireEvent.click(getButton()); + await flush(); + }, + }; + } + + test('Pressing ArrowDown should open the listbox', async () => { + await renderComboBox(); + + await fireEvent.keyDown(getInput(), { code: 'ArrowDown' }); + await flush(); + expect(getInput()).toHaveAttribute('aria-expanded', 'true'); + }); + + test('Pressing ArrowUp should open the listbox', async () => { + await renderComboBox(); + + await fireEvent.keyDown(getInput(), { code: 'ArrowUp' }); + await flush(); + expect(getInput()).toHaveAttribute('aria-expanded', 'true'); + }); + + test('Clicking the button should toggle the listbox', async () => { + await renderComboBox(); + + await fireEvent.click(getButton()); + await flush(); + expect(getInput()).toHaveAttribute('aria-expanded', 'true'); + + await fireEvent.click(getButton()); + await flush(); + expect(getInput()).toHaveAttribute('aria-expanded', 'false'); + }); + + test('Pressing Escape should close the listbox', async () => { + const { open } = await renderComboBox(); + await open(); + + await fireEvent.keyDown(getInput(), { code: 'Escape' }); + await flush(); + expect(getInput()).toHaveAttribute('aria-expanded', 'false'); + }); + + test('Pressing Tab should close the listbox', async () => { + const { open } = await renderComboBox(); + await open(); + + await fireEvent.keyDown(getInput(), { code: 'Tab' }); + await flush(); + expect(getInput()).toHaveAttribute('aria-expanded', 'false'); + }); + + test('Clicking an option should select it and close the listbox', async () => { + const { open } = await renderComboBox(); + await open(); + + await fireEvent.click(screen.getAllByRole('option')[1]); + await flush(); + expect(getInput()).toHaveValue('Two'); + expect(getInput()).toHaveAttribute('aria-expanded', 'false'); + }); + + test('Pressing Enter on a focused option should select it', async () => { + const { open } = await renderComboBox(); + await open(); + + await fireEvent.keyDown(screen.getAllByRole('option')[1], { code: 'Enter' }); + await flush(); + expect(getInput()).toHaveValue('Two'); + expect(getInput()).toHaveAttribute('aria-expanded', 'false'); + }); + + test('Should not select disabled options', async () => { + const { open } = await renderComboBox([{ label: 'One' }, { label: 'Two', disabled: true }, { label: 'Three' }]); + await open(); + + await fireEvent.click(screen.getAllByRole('option')[1]); + await flush(); + expect(getInput()).not.toHaveValue('Two'); + }); + + test('Should revert to last selected value when blurred with invalid input', async () => { + const { open } = await renderComboBox(); + await open(); + + // First select an option + await fireEvent.click(screen.getAllByRole('option')[1]); + await flush(); + expect(getInput()).toHaveValue('Two'); + + // Type some random text + await fireEvent.input(getInput(), { target: { value: 'Random stuff' } }); + await flush(); + expect(getInput()).toHaveValue('Random stuff'); + + // Blur the input + await fireEvent.blur(getInput()); + await flush(); + + // Should revert back to last selected value + expect(getInput()).toHaveValue('Two'); + }); + + test('Should select option when blurred with relatedTarget being an option', async () => { + const { open } = await renderComboBox(); + await open(); + + // Get the option element we want to simulate clicking + const option = screen.getAllByRole('option')[1]; + + // Simulate blur with relatedTarget being the option + await fireEvent.blur(getInput(), { + relatedTarget: option, + }); + await flush(); + + // Should select the option that was "clicked" + expect(getInput()).toHaveValue('Two'); + expect(getInput()).toHaveAttribute('aria-expanded', 'false'); + }); + + test('ArrowDown should highlight options in sequence and stop at last option', async () => { + const { open } = await renderComboBox(); + await open(); + + const options = screen.getAllByRole('option'); + + // Initially no option should be highlighted + expect(options[0]).not.toHaveAttribute('aria-selected'); + expect(options[1]).not.toHaveAttribute('aria-selected'); + expect(options[2]).not.toHaveAttribute('aria-selected'); + + // Press arrow down to highlight first option + await fireEvent.keyDown(getInput(), { code: 'ArrowDown' }); + await flush(); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + expect(options[1]).not.toHaveAttribute('aria-selected'); + expect(options[2]).not.toHaveAttribute('aria-selected'); + + // Press arrow down to highlight second option + await fireEvent.keyDown(getInput(), { code: 'ArrowDown' }); + await flush(); + expect(options[0]).not.toHaveAttribute('aria-selected'); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); + expect(options[2]).not.toHaveAttribute('aria-selected'); + + // Press arrow down to highlight last option + await fireEvent.keyDown(getInput(), { code: 'ArrowDown' }); + await flush(); + expect(options[0]).not.toHaveAttribute('aria-selected'); + expect(options[1]).not.toHaveAttribute('aria-selected'); + expect(options[2]).toHaveAttribute('aria-selected', 'true'); + + // Press arrow down again - should stay on last option + await fireEvent.keyDown(getInput(), { code: 'ArrowDown' }); + await flush(); + expect(options[0]).not.toHaveAttribute('aria-selected'); + expect(options[1]).not.toHaveAttribute('aria-selected'); + expect(options[2]).toHaveAttribute('aria-selected', 'true'); + }); + + test('ArrowUp should highlight options in reverse sequence and stop at first option', async () => { + const { open } = await renderComboBox(); + await open(); + + const options = screen.getAllByRole('option'); + + // Move to last option first + await fireEvent.keyDown(getInput(), { code: 'End' }); + await flush(); + expect(options[2]).toHaveAttribute('aria-selected', 'true'); + + // Press arrow up to highlight second option + await fireEvent.keyDown(getInput(), { code: 'ArrowUp' }); + await flush(); + expect(options[0]).not.toHaveAttribute('aria-selected'); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); + expect(options[2]).not.toHaveAttribute('aria-selected'); + + // Press arrow up to highlight first option + await fireEvent.keyDown(getInput(), { code: 'ArrowUp' }); + await flush(); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + expect(options[1]).not.toHaveAttribute('aria-selected'); + expect(options[2]).not.toHaveAttribute('aria-selected'); + + // Press arrow up again - should stay on first option + await fireEvent.keyDown(getInput(), { code: 'ArrowUp' }); + await flush(); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + expect(options[1]).not.toHaveAttribute('aria-selected'); + expect(options[2]).not.toHaveAttribute('aria-selected'); + }); + + test('Home key should highlight first option', async () => { + const { open } = await renderComboBox(); + await open(); + + const options = screen.getAllByRole('option'); + + // First move to last option + await fireEvent.keyDown(getInput(), { code: 'End' }); + await flush(); + expect(options[2]).toHaveAttribute('aria-selected', 'true'); + + // Press Home to jump to first option + await fireEvent.keyDown(getInput(), { code: 'Home' }); + await flush(); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + expect(options[1]).not.toHaveAttribute('aria-selected'); + expect(options[2]).not.toHaveAttribute('aria-selected'); + }); + + test('Should open menu when user starts typing', async () => { + await renderComboBox(); + + // Initially menu should be closed + expect(getInput()).toHaveAttribute('aria-expanded', 'false'); + + // Type something + await fireEvent.keyDown(getInput(), { target: { code: 'T' } }); + await flush(); + + // Menu should be open + expect(getInput()).toHaveAttribute('aria-expanded', 'true'); + }); + + test('Enter key should select the highlighted option', async () => { + const { open } = await renderComboBox(); + await open(); + + const options = screen.getAllByRole('option'); + + // First highlight second option using arrow down + await fireEvent.keyDown(getInput(), { code: 'ArrowDown' }); + await fireEvent.keyDown(getInput(), { code: 'ArrowDown' }); + await flush(); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); + + // Press Enter to select the highlighted option + await fireEvent.keyDown(getInput(), { code: 'Enter' }); + await flush(); + + // Should select the highlighted option and close the menu + expect(getInput()).toHaveValue('Two'); + expect(getInput()).toHaveAttribute('aria-expanded', 'false'); + }); + + test('Escape key should clear input value when menu is closed', async () => { + const { open } = await renderComboBox(); + + // First select an option + await open(); + await fireEvent.click(screen.getAllByRole('option')[1]); + await flush(); + expect(getInput()).toHaveValue('Two'); + + // Press Escape when menu is closed + await fireEvent.keyDown(getInput(), { code: 'Escape' }); + await flush(); + + // Input value should be cleared + expect(getInput()).toHaveValue(''); + expect(getInput()).toHaveAttribute('aria-expanded', 'false'); + }); +}); + +describe('filtering', () => { + test('should filter options based on input value', async () => { + await render({ + components: { + MyComboBox: createComboBox(), + }, + setup() { + const options = [{ label: 'One' }, { label: 'Two' }, { label: 'Three' }]; + + return { + options, + filter: { + debounceMs: 0, + fn: ({ option, search }) => option.label.toLowerCase().includes(search.toLowerCase()), + }, + }; + }, + template: ` +
+ +
+ `, + }); + + const input = getInput(); + await fireEvent.input(input, { target: { value: 'tw' } }); + await flush(); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Two'); + }); +}); + +describe('selection state', () => { + test('selectedOption should reflect the currently selected option', async () => { + const MyComboBox = createComboBox(); + const options = [{ label: 'One' }, { label: 'Two' }, { label: 'Three' }]; + + await render({ + components: { MyComboBox }, + setup() { + return { options }; + }, + template: ` +
+ +
+ `, + }); + + await fireEvent.click(getButton()); + await fireEvent.click(screen.getAllByRole('option')[1]); + await flush(); + + expect(MyComboBox.getExposedState().selectedOption).toEqual({ + id: expect.any(String), + label: 'Two', + value: { label: 'Two' }, + }); + }); +}); + +test('Should use onNewValue handler instead of reverting when provided', async () => { + const onNewValueSpy = vi.fn(value => ({ + label: value + '!', + value: value + '!', + })); + + await render({ + components: { + MyComboBox: createComboBox({ onNewValue: onNewValueSpy }), + }, + setup() { + const options = [{ label: 'One' }, { label: 'Two' }, { label: 'Three' }]; + return { options }; + }, + template: ` +
+ +
+ `, + }); + + const input = getInput(); + + // Type some text that doesn't match any option + await fireEvent.input(input, { target: { value: 'Something new' } }); + await flush(); + + // Blur the input to trigger the new value handler + await fireEvent.blur(input); + await flush(); + + // Should have called the handler with the input value + expect(onNewValueSpy).toHaveBeenCalledWith('Something new'); + expect(onNewValueSpy).toHaveBeenCalledTimes(1); + + // Should show the modified value from onNewValue handler + expect(getInput()).toHaveValue('Something new!'); +}); diff --git a/packages/core/src/useComboBox/useComboBox.ts b/packages/core/src/useComboBox/useComboBox.ts index 6d664c9d..427b92b7 100644 --- a/packages/core/src/useComboBox/useComboBox.ts +++ b/packages/core/src/useComboBox/useComboBox.ts @@ -137,6 +137,8 @@ export function useComboBox( findFocusedOption, renderedOptions, isEmpty, + focusFirst: focusFirstOption, + focusLast: focusLastOption, } = useListBox({ labeledBy: () => labelledByProps.value['aria-labelledby'], focusStrategy: 'FOCUS_ATTR_SELECTED', @@ -158,9 +160,6 @@ export function useComboBox( onInput(evt) { inputValue.value = (evt.target as HTMLInputElement).value; }, - onChange(evt) { - inputValue.value = (evt.target as HTMLInputElement).value; - }, async onBlur(evt) { setTouched(true); // If an option was clicked, then it would blur the field and so we want to select the clicked option via the `relatedTarget` property. @@ -198,6 +197,7 @@ export function useComboBox( if (hasKeyCode(evt, 'Enter')) { if (isPopupOpen.value) { + evt.preventDefault(); findFocusedOption()?.toggleSelected(); } @@ -219,6 +219,16 @@ export function useComboBox( return; } + if (hasKeyCode(evt, 'End')) { + focusLastOption(); + return; + } + + if (hasKeyCode(evt, 'Home')) { + focusFirstOption(); + return; + } + if (hasKeyCode(evt, 'Tab')) { isPopupOpen.value = false; findClosestOptionAndSetValue(inputValue.value); diff --git a/packages/core/src/useListBox/useListBox.ts b/packages/core/src/useListBox/useListBox.ts index d8ea64d3..528e56fe 100644 --- a/packages/core/src/useListBox/useListBox.ts +++ b/packages/core/src/useListBox/useListBox.ts @@ -162,6 +162,16 @@ export function useListBox( }, }; + function focusFirst() { + findFocusedOption()?.unfocus(); + getSortedOptions().at(0)?.focus(); + } + + function focusLast() { + findFocusedOption()?.unfocus(); + getSortedOptions().at(-1)?.focus(); + } + function focusAndToggleIfShiftPressed(option: OptionRegistration) { if (listManager.getFocusStrategy() !== 'FOCUS_DOM') { findFocusedOption()?.unfocus(); @@ -204,7 +214,7 @@ export function useListBox( function focusNext() { const currentlyFocusedIdx = findFocusedIdx(); if (currentlyFocusedIdx === -1) { - getSortedOptions().at(0)?.focus(); + focusFirst(); return; } @@ -223,7 +233,7 @@ export function useListBox( const currentlyFocusedIdx = findFocusedIdx(); const sortedOptions = getSortedOptions(); if (currentlyFocusedIdx === -1) { - sortedOptions.at(0)?.focus(); + focusFirst(); return; } @@ -308,6 +318,8 @@ export function useListBox( isEmpty, focusNext, focusPrev, + focusFirst, + focusLast, findFocusedOption, }; }