diff --git a/.changeset/palette-activedescendant.md b/.changeset/palette-activedescendant.md new file mode 100644 index 0000000..35d7d24 --- /dev/null +++ b/.changeset/palette-activedescendant.md @@ -0,0 +1,5 @@ +--- +"acture-palette-react": patch +--- + +Accessibility: the palette (list and enum picker step) now publishes `aria-activedescendant` for the initially highlighted option (and keeps it on the highlight while filtering). cmdk 1.1.1 only published it once the highlight moved, so a screen reader did not hear the first option until the first arrow key. diff --git a/.claude/skills/acture-palette-design/SKILL.md b/.claude/skills/acture-palette-design/SKILL.md index 05a0445..1cb5b38 100644 --- a/.claude/skills/acture-palette-design/SKILL.md +++ b/.claude/skills/acture-palette-design/SKILL.md @@ -116,7 +116,7 @@ A palette host that wants the menu convention instead can render its own rows fr `acture-palette-react` inherits the WAI-ARIA APG combobox pattern from cmdk and pins it with tests. Keep these true when changing the palette: - The input is `role="combobox"` with `aria-controls` pointing at the `listbox`, named by the `label` ("Command palette"); rows are `option`s inside named `group`s. -- DOM focus stays in the input; the highlighted row is `aria-selected` and `aria-activedescendant` follows it. (cmdk 1.1.1 only publishes `aria-activedescendant` once the highlight moves; the very first row is not announced until the first arrow key.) +- DOM focus stays in the input; the highlighted row is `aria-selected` and `aria-activedescendant` follows it. cmdk 1.1.1 only publishes `aria-activedescendant` once the highlight moves, so `acture-palette-react` mirrors the highlighted option onto the input itself (`use-active-descendant.ts`, #68); a hand-written palette on cmdk must do the same. - When the view switches, the new view's first input takes focus (`autoFocus`), and cancelling returns to the list with the input focused. A host `formAdapter` owns focus inside its form and must `autoFocus` its first field. - Every text input the palette renders has an accessible name (the parameter name), and a validation error is `role="alert"`, linked by `aria-describedby`, with `aria-invalid` on the input. - Decorative icons are `aria-hidden`; the visible title is part of the option's text (WCAG 2.5.3). diff --git a/packages/palette-react/src/palette.a11y.test.tsx b/packages/palette-react/src/palette.a11y.test.tsx index f4262fb..d127dc5 100644 --- a/packages/palette-react/src/palette.a11y.test.tsx +++ b/packages/palette-react/src/palette.a11y.test.tsx @@ -94,11 +94,9 @@ describe(' accessibility', () => { // Exactly one option is highlighted from the start. const first = selectedOptionId(); expect(first).toBeTruthy(); - // Known cmdk 1.1.1 quirk: `aria-activedescendant` is only published once the - // highlight MOVES, so the very first option is not announced until the user - // presses an arrow key. Asserted loosely here; tightened if cmdk fixes it. - const initial = input.getAttribute('aria-activedescendant'); - expect([null, first]).toContain(initial); + // cmdk 1.1.1 alone publishes the id only once the highlight moves (#68); + // the palette mirrors the highlight so the first option is announced. + expect(input.getAttribute('aria-activedescendant')).toBe(first); await act(async () => { fireEvent.keyDown(input, { key: 'ArrowDown' }); @@ -110,6 +108,56 @@ describe(' accessibility', () => { expect(document.activeElement).toBe(input); }); + it('keeps aria-activedescendant on the highlighted option while filtering', async () => { + render(); + await act(async () => {}); + const input = screen.getByRole('combobox'); + const selected = (): string | null => + screen.queryAllByRole('option').find((o) => o.getAttribute('aria-selected') === 'true')?.id ?? null; + + await act(async () => { + fireEvent.change(input, { target: { value: 'node' } }); + }); + expect(selected()).toBeTruthy(); + expect(input.getAttribute('aria-activedescendant')).toBe(selected()); + + // No match: nothing is highlighted, so nothing may be named. + await act(async () => { + fireEvent.change(input, { target: { value: 'zzzz-no-such-command' } }); + }); + expect(selected()).toBeNull(); + expect(input.getAttribute('aria-activedescendant')).toBeNull(); + }); + + it('names the highlighted option in the enum picker step too', async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByText('Set style').closest('[cmdk-item]')!); + }); + await act(async () => {}); + const input = screen.getByRole('combobox'); + const selected = screen.getAllByRole('option').find((o) => o.getAttribute('aria-selected') === 'true'); + expect(selected?.id).toBeTruthy(); + expect(input.getAttribute('aria-activedescendant')).toBe(selected?.id); + }); + + it('names the first option again when the list view comes back', async () => { + render(); + await act(async () => {}); + await act(async () => { + fireEvent.click(screen.getByText('Set style').closest('[cmdk-item]')!); + }); + // Picker chain is showing; go back to the list. + await act(async () => { + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape' }); + }); + await act(async () => {}); + const input = screen.getByRole('combobox'); + const first = screen.getAllByRole('option').find((o) => o.getAttribute('aria-selected') === 'true'); + expect(first?.id).toBeTruthy(); + expect(input.getAttribute('aria-activedescendant')).toBe(first?.id); + }); + it('marks the decorative icon aria-hidden and keeps the title as the option name', () => { const registry = createRegistry(); registry.register( diff --git a/packages/palette-react/src/palette.tsx b/packages/palette-react/src/palette.tsx index 4795cc0..f06a17e 100644 --- a/packages/palette-react/src/palette.tsx +++ b/packages/palette-react/src/palette.tsx @@ -33,6 +33,7 @@ import type { } from 'acture'; import { useCommandsChanged } from './use-commands-changed.js'; import { deriveKind } from './derive-kind.js'; +import { useActiveDescendant } from './use-active-descendant.js'; import { PickerChain } from './picker-chain.js'; export type PaletteItemRenderer = (cmd: AnyCommandRecord) => React.ReactNode; @@ -96,6 +97,7 @@ export function CommandPalette(props: CommandPaletteProps): React.ReactElement { const revision = useCommandsChanged(registry); const [view, setView] = useState({ kind: 'list' }); + const rootRef = useActiveDescendant(); const groups = useMemo(() => { const list = registry.list({ @@ -181,7 +183,7 @@ export function CommandPalette(props: CommandPaletteProps): React.ReactElement { } return ( - + No matching commands. diff --git a/packages/palette-react/src/picker-chain.tsx b/packages/palette-react/src/picker-chain.tsx index 3442586..0de6500 100644 --- a/packages/palette-react/src/picker-chain.tsx +++ b/packages/palette-react/src/picker-chain.tsx @@ -18,6 +18,7 @@ import { Command } from 'cmdk'; import { useId, useState } from 'react'; import type { AnyCommandRecord } from 'acture'; +import { useActiveDescendant } from './use-active-descendant.js'; import { readEnumOptions, summarizeParams, unwrap } from './derive-kind.js'; import type { ParamSummary } from './derive-kind.js'; @@ -125,8 +126,9 @@ function EnumPicker({ options, coerce, }: StepProps & { options: readonly string[]; coerce?: (s: string) => unknown }): React.ReactElement { + const rootRef = useActiveDescendant(); return ( - + void { + const observer = useRef(null); + return useCallback((root: HTMLElement | null) => { + observer.current?.disconnect(); + observer.current = null; + if (!root) return; + // cmdk highlights the first option in a later layout pass, so this finds + // nothing on a fresh mount; the observer below does the initial work. + syncActiveDescendant(root); + if (typeof MutationObserver === 'undefined') return; + const watcher = new MutationObserver(() => syncActiveDescendant(root)); + watcher.observe(root, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['aria-selected'], + }); + observer.current = watcher; + }, []); +}