diff --git a/src/catalog/ChoiceInputs.mdx b/src/catalog/ChoiceInputs.mdx index 0c5a52c21..2d8b30744 100644 --- a/src/catalog/ChoiceInputs.mdx +++ b/src/catalog/ChoiceInputs.mdx @@ -58,8 +58,11 @@ dates go to [DateInput](?path=/docs/date-time-dateinput--docs). - Keyboard coverage is uneven: `Select` and `Autocomplete` implement arrow keys, Enter and Escape (`Select` adds Home/End and typeahead); the country pickers arrow between options once focus is inside the panel; **`Dropdown` and `PillSelect` have no arrow-key navigation** (Tab only). - `Select` and `Dropdown` mark the highlighted row visually but do not set - `aria-activedescendant`; only `Autocomplete` does. + `Select` and `Autocomplete` expose the active option through `aria-activedescendant`. + In `Select`, both the trigger and searchable input reference the listbox with + `aria-controls` and the highlighted option with `aria-activedescendant`; the active-option + reference is omitted when no selectable result exists and removed on close. + `Dropdown` marks the highlighted row visually but does not set `aria-activedescendant`. - Focus return on close: `Select` and `PillSelect` return focus to the trigger on Escape; `Dropdown`, `Autocomplete` and the country pickers do not. - Built-in English strings: `Select` (`"Select an option"`, `"Search..."`, `"No results found"` diff --git a/src/components/Select/Select.stories.tsx b/src/components/Select/Select.stories.tsx index 14c28203b..fe5d7cef4 100644 --- a/src/components/Select/Select.stories.tsx +++ b/src/components/Select/Select.stories.tsx @@ -191,10 +191,10 @@ const [specialty, setSpecialty] = useState(''); ### Limitations -- Accessibility: trigger is \` + + + + ); + await user.click(screen.getByRole('combobox', { name: 'Category' })); + expect(screen.getByRole('alertdialog')).toContainElement( + screen.getByRole('textbox', { name: 'Search options' }) + ); + }); + + it('keeps searchable popups inside the containing modal dialog', async () => { + const user = userEvent.setup(); + renderWithTheme( +
+ + ); + const trigger = screen.getByRole('combobox', { name: 'Category' }); + await user.click(trigger); + const control = searchable + ? screen.getByRole('textbox', { name: 'Search options' }) + : trigger; + const clinicId = screen.getByRole('option', { name: 'Clinic' }).id; + expect(control).toHaveFocus(); + expect(control).toHaveAttribute( + 'aria-controls', + screen.getByRole('listbox').id + ); + expect(control).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', { name: 'Cardiology' }).id + ); + await user.keyboard('{ArrowDown}'); + expect(control).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', { name: 'Chiropractic' }).id + ); + if (searchable) { + await user.type(control, 'clinic'); + expect(control).toHaveAttribute('aria-activedescendant', clinicId); + await user.clear(control); + await user.type(control, 'no such category'); + expect(control).not.toHaveAttribute('aria-activedescendant'); + } else { + await user.keyboard('cli'); + expect(control).toHaveAttribute('aria-activedescendant', clinicId); + } + await user.keyboard('{Escape}'); + expect(trigger).not.toHaveAttribute('aria-activedescendant'); + } + ); + + it.each(['ArrowDown', 'ArrowUp', 'Home', 'End'])( + 'does not select a missing option after a no-match search and %s', + async (key) => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + renderWithTheme( +