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;
+ }, []);
+}