From 4838e5b14d657bed87c626f9e03dad9942173a3f Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:24:48 +0000 Subject: [PATCH 1/2] palette-react: publish aria-activedescendant for the initially highlighted option (#68) Co-Authored-By: Claude Sonnet 5.5 --- .changeset/palette-activedescendant.md | 5 ++ .claude/skills/acture-palette-design/SKILL.md | 2 +- .../palette-react/src/palette.a11y.test.tsx | 46 +++++++++++++++-- packages/palette-react/src/palette.tsx | 4 +- .../src/use-active-descendant.ts | 51 +++++++++++++++++++ 5 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 .changeset/palette-activedescendant.md create mode 100644 packages/palette-react/src/use-active-descendant.ts diff --git a/.changeset/palette-activedescendant.md b/.changeset/palette-activedescendant.md new file mode 100644 index 0000000..cb5ab79 --- /dev/null +++ b/.changeset/palette-activedescendant.md @@ -0,0 +1,5 @@ +--- +"acture-palette-react": patch +--- + +Accessibility: the palette 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..9c4465d 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,44 @@ 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 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/use-active-descendant.ts b/packages/palette-react/src/use-active-descendant.ts new file mode 100644 index 0000000..7a22ed8 --- /dev/null +++ b/packages/palette-react/src/use-active-descendant.ts @@ -0,0 +1,51 @@ +/** + * Keep the combobox input's `aria-activedescendant` on the highlighted option. + * + * cmdk 1.1.1 highlights the first option on mount (`aria-selected="true"`) but + * only publishes its id on the input once the highlight *moves*, so a screen + * reader does not hear the first option until the first arrow key (#68). cmdk + * overrides any `aria-activedescendant` prop and its controlled `value` is + * applied without publishing, so neither is a seam. The one source of truth it + * does keep correct is the option's `aria-selected`; this mirrors it. + * + * Returns a callback ref for cmdk's root element. It follows the root through + * remounts (the palette swaps the list for a picker chain and back). + */ + +import { useCallback, useRef } from 'react'; + +const INPUT = '[cmdk-input]'; +const SELECTED_ITEM = '[cmdk-item][aria-selected="true"]'; + +/** Make the input's `aria-activedescendant` name the selected option. */ +export function syncActiveDescendant(root: Element): void { + const input = root.querySelector(INPUT); + if (!input) return; + const id = root.querySelector(SELECTED_ITEM)?.id; + if (id) { + if (input.getAttribute('aria-activedescendant') !== id) { + input.setAttribute('aria-activedescendant', id); + } + } else { + input.removeAttribute('aria-activedescendant'); + } +} + +export function useActiveDescendant(): (root: HTMLElement | null) => void { + const observer = useRef(null); + return useCallback((root: HTMLElement | null) => { + observer.current?.disconnect(); + observer.current = null; + if (!root) return; + 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; + }, []); +} From 104f162e3dfec19ef2d21f223cc97dfb5f954fdc Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:28:33 +0000 Subject: [PATCH 2/2] palette-react: the enum picker step publishes aria-activedescendant too (review of #68) Co-Authored-By: Claude Sonnet 5.5 --- .changeset/palette-activedescendant.md | 2 +- packages/palette-react/src/palette.a11y.test.tsx | 12 ++++++++++++ packages/palette-react/src/picker-chain.tsx | 4 +++- packages/palette-react/src/use-active-descendant.ts | 2 ++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.changeset/palette-activedescendant.md b/.changeset/palette-activedescendant.md index cb5ab79..35d7d24 100644 --- a/.changeset/palette-activedescendant.md +++ b/.changeset/palette-activedescendant.md @@ -2,4 +2,4 @@ "acture-palette-react": patch --- -Accessibility: the palette 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. +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/packages/palette-react/src/palette.a11y.test.tsx b/packages/palette-react/src/palette.a11y.test.tsx index 9c4465d..d127dc5 100644 --- a/packages/palette-react/src/palette.a11y.test.tsx +++ b/packages/palette-react/src/palette.a11y.test.tsx @@ -129,6 +129,18 @@ describe(' accessibility', () => { 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 () => {}); 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 { 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));