Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/catalog/ChoiceInputs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
4 changes: 2 additions & 2 deletions src/components/Select/Select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,10 @@ const [specialty, setSpecialty] = useState('');

### Limitations

- Accessibility: trigger is \`<button role="combobox" aria-haspopup="listbox" aria-expanded aria-controls>\` with \`aria-invalid\`, \`aria-required\` and \`aria-describedby\` → error or helper text; the \`<label htmlFor>\` targets the trigger's \`id\`. Options are \`<li role="option" aria-selected>\`, groups \`<li role="presentation">\` + \`<ul role="group" aria-label>\`, \`aria-multiselectable\` in \`multiple\` mode. Keyboard: ArrowUp/Down, Home/End, Enter/Space, Escape (returns focus to the trigger), typeahead when not \`searchable\`. **No \`aria-activedescendant\`** — the highlighted option is marked with \`data-highlighted\`, so screen readers are not told which option the arrow keys reached until it is selected.
- Accessibility: trigger is \`<button role="combobox" aria-haspopup="listbox" aria-expanded aria-controls>\` with \`aria-invalid\`, \`aria-required\` and \`aria-describedby\` → error or helper text; the \`<label htmlFor>\` targets the trigger's \`id\`. Options are \`<li role="option" aria-selected>\`, groups \`<li role="presentation">\` + \`<ul role="group" aria-label>\`, \`aria-multiselectable\` in \`multiple\` mode. Keyboard: ArrowUp/Down, Home/End, Enter/Space, Escape (returns focus to the trigger), typeahead when not \`searchable\`. The trigger and searchable input expose the listbox through \`aria-controls\` and its highlighted option through \`aria-activedescendant\`, so the focused control identifies the result Enter will select. The active-option reference is omitted when no selectable result exists and removed on close.
- Not a form control: there is no \`name\` and nothing is submitted. Keep the value in state and post it yourself.
- Filtering is a case-insensitive \`includes\` on \`label\` only; \`multiple\` shows selected labels joined with \`", "\` and truncates.
- The listbox is portaled to \`<body>\` with fixed positioning (\`useAnchoredPosition\`, width matches trigger, max height 300px) so it escapes \`overflow: hidden\` ancestors.
- The listbox uses a fixed-position portal (\`useAnchoredPosition\`, width matches trigger, max height 300px). Inside a dialog (including an alert dialog) it stays within that dialog's focus and accessibility scope; otherwise it portals to \`<body>\`. A manual popover places it in the browser's top layer so transformed dialog containers do not offset its viewport coordinates.
- Strings default to English but are props: \`placeholder\` ("Select an option"), \`searchPlaceholder\` ("Search..."), \`noResultsText\` ("No results found"). The search box's \`aria-label="Search options"\` and the listbox fallback \`aria-label="Options"\` are hard-coded.
- RTL: floating label uses logical \`start-3\`; the chevron sits at the flex end. Theming: \`border-input\`, \`bg-background\`, popover \`bg-card border-border\`, selected option \`bg-primary-50 dark:bg-primary-950\`. Depends on \`class-variance-authority\` and \`Input\`'s \`RequiredMark\`.`,
},
Expand Down
124 changes: 124 additions & 0 deletions src/components/Select/Select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithTheme } from '../../test/test-utils';
import { Dropdown, DropdownContent } from '../Dropdown';
import { Modal } from '../Modal';
import { Select, type SelectOption } from './Select';

const LOCATION_TYPES: SelectOption[] = [
Expand All @@ -16,6 +17,53 @@ const LOCATION_TYPES: SelectOption[] = [
];

describe('Select portals', () => {
it('contains Tab navigation after opening search in the real Modal', async () => {
const user = userEvent.setup();
renderWithTheme(
<>
<button>Outside</button>
<Modal open onOpenChange={vi.fn()} aria-label="Compose">
<Select label="Category" searchable options={LOCATION_TYPES} />
</Modal>
</>
);
const trigger = screen.getByRole('combobox', { name: 'Category' });
await user.click(trigger);
const search = screen.getByRole('textbox', { name: 'Search options' });
await user.type(search, 'no such category');
await user.tab();
expect(trigger).toHaveFocus();
await user.tab({ shift: true });
expect(search).toHaveFocus();
expect(screen.getByRole('button', { name: 'Outside' })).not.toHaveFocus();
});

it('keeps searchable popups inside alert dialogs', async () => {
const user = userEvent.setup();
renderWithTheme(
<div role="alertdialog" aria-modal="true" aria-label="Confirm">
<Select label="Category" searchable options={LOCATION_TYPES} />
</div>
);
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(
<div role="dialog" aria-modal="true" aria-label="Compose">
<Select label="Category" searchable options={LOCATION_TYPES} />
</div>
);
await user.click(screen.getByRole('combobox', { name: 'Category' }));
const search = screen.getByRole('textbox', { name: 'Search options' });
expect(screen.getByRole('dialog')).toContainElement(search);
expect(search).toHaveFocus();
});

it('selects an option when nested inside a Dropdown portal', async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
Expand Down Expand Up @@ -91,6 +139,82 @@ describe('Select portals', () => {
});

describe('Select typeahead', () => {
it.each([false, true])(
'announces the active option from the focused control (searchable: %s)',
async (searchable) => {
const user = userEvent.setup();
renderWithTheme(
<Select
label="Category"
searchable={searchable}
options={[
{
label: 'Locations',
options: [
{ value: 'disabled', label: 'Disabled', disabled: true },
...LOCATION_TYPES.slice(0, 2),
],
},
...LOCATION_TYPES.slice(2),
]}
/>
);
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(
<Select
label="Category"
searchable
options={LOCATION_TYPES}
onValueChange={onValueChange}
/>
);
await user.click(screen.getByRole('combobox', { name: 'Category' }));
await user.type(screen.getByRole('textbox'), 'no such category');
await user.keyboard(`{${key}}{Enter}`);
expect(onValueChange).not.toHaveBeenCalled();
expect(screen.getByText('No results found')).toBeVisible();
}
);

it.each(['click', 'keyboard'])(
'focuses search after positioning on %s open so typing needs no extra click',
async (method) => {
Expand Down
42 changes: 35 additions & 7 deletions src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,10 @@ function Select(props: SelectProps) {
}
return result;
}, [filteredOptions]);
const optionId = (option: SelectOption) =>
`${listboxId}-option-${flatOptions.indexOf(option)}`;
const activeOption = filteredFlatOptions[highlightedIndex];
const activeOptionId = activeOption ? optionId(activeOption) : undefined;

// Get selected options (in selection order)
const selectedOptions = selectedValues
Expand Down Expand Up @@ -469,7 +473,11 @@ function Select(props: SelectProps) {
setIsOpen(true);
} else {
setHighlightedIndex((prev) =>
prev < filteredFlatOptions.length - 1 ? prev + 1 : 0
filteredFlatOptions.length === 0
? -1
: prev < filteredFlatOptions.length - 1
? prev + 1
: 0
);
}
break;
Expand All @@ -487,14 +495,15 @@ function Select(props: SelectProps) {
case ' ':
e.preventDefault();
if (isOpen && highlightedIndex >= 0) {
handleValueChange(filteredFlatOptions[highlightedIndex].value);
const option = filteredFlatOptions[highlightedIndex];
if (option) handleValueChange(option.value);
} else if (!isOpen) {
setIsOpen(true);
}
break;
case 'Home':
e.preventDefault();
setHighlightedIndex(0);
setHighlightedIndex(filteredFlatOptions.length > 0 ? 0 : -1);
break;
case 'End':
e.preventDefault();
Expand Down Expand Up @@ -608,6 +617,7 @@ function Select(props: SelectProps) {
// Only reference the listbox while it exists in the DOM (it is
// portaled and unmounted when closed) so the ID is always valid.
aria-controls={isOpen ? listboxId : undefined}
aria-activedescendant={isOpen ? activeOptionId : undefined}
Comment thread
lattln marked this conversation as resolved.
Resolved
aria-invalid={hasError || !!error}
aria-required={required || undefined}
aria-label={!label ? ariaLabel : undefined}
Expand Down Expand Up @@ -662,18 +672,26 @@ function Select(props: SelectProps) {
</label>
)}

{/* Dropdown (portaled to body to avoid overflow clipping) */}
{/* Keep modal popups within their focus and accessibility scope. */}
{isOpen &&
createPortal(
<div
data-slot="select-dropdown"
ref={(node) => {
dropdownRef.current = node;
floatingRef.current = node;
// The top layer keeps viewport positioning valid inside transformed dialogs.
node?.showPopover?.();
}}
style={dropdownStyle}
popover={
typeof HTMLElement !== 'undefined' &&
'showPopover' in HTMLElement.prototype
? 'manual'
: undefined
}
style={{ inset: 'auto', margin: 0, padding: 0, ...dropdownStyle }}
className={cn(
'border-border bg-card rounded-lg border shadow-lg',
'border-border bg-card text-card-foreground rounded-lg border shadow-lg',
'flex flex-col overflow-hidden',
'animate-in fade-in zoom-in-95 duration-100'
)}
Expand All @@ -697,6 +715,9 @@ function Select(props: SelectProps) {
'focus:ring-ring focus:ring-2 focus:outline-none'
)}
aria-label="Search options"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeOptionId}
/>
</div>
)}
Expand Down Expand Up @@ -731,6 +752,7 @@ function Select(props: SelectProps) {
{item.options.map((option) => (
<SelectOptionItem
key={option.value}
id={optionId(option)}
option={option}
isSelected={selectedValues.includes(
option.value
Expand All @@ -757,6 +779,7 @@ function Select(props: SelectProps) {
return (
<SelectOptionItem
key={item.value}
id={optionId(item)}
option={item}
isSelected={selectedValues.includes(item.value)}
isHighlighted={
Expand All @@ -776,7 +799,9 @@ function Select(props: SelectProps) {
)}
</ul>
</div>,
document.body
triggerRef.current?.closest(
'[role="dialog"], [role="alertdialog"], dialog'
) ?? document.body
)}
</div>

Expand Down Expand Up @@ -819,6 +844,7 @@ function toValueArray(value: string | string[] | undefined): string[] {
// ============================================================================

interface SelectOptionItemProps {
id: string;
option: SelectOption;
isSelected: boolean;
isHighlighted: boolean;
Expand All @@ -827,6 +853,7 @@ interface SelectOptionItemProps {
}

function SelectOptionItem({
id,
option,
isSelected,
isHighlighted,
Expand All @@ -848,6 +875,7 @@ function SelectOptionItem({

return (
<li
id={id}
data-slot="select-option"
role="option"
aria-selected={isSelected}
Expand Down
19 changes: 10 additions & 9 deletions src/hooks/useFocusTrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,22 @@ export function useFocusTrap<T extends HTMLElement>(
if (!enabled || !containerRef.current || isStorybookDocsMode()) return;

const container = containerRef.current;
const focusableElements = container.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);

if (focusableElements.length === 0) return;

const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const getFocusableElements = () =>
container.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);

// Focus the first element when trap is enabled
firstElement.focus();
getFocusableElements()[0]?.focus();

const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key !== 'Tab') return;

const focusableElements = getFocusableElements();
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (!firstElement || !lastElement) return;

if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
Expand Down
Loading
Loading