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
5 changes: 5 additions & 0 deletions .changeset/palette-activedescendant.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .claude/skills/acture-palette-design/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
58 changes: 53 additions & 5 deletions packages/palette-react/src/palette.a11y.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,9 @@ describe('<CommandPalette /> 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' });
Expand All @@ -110,6 +108,56 @@ describe('<CommandPalette /> accessibility', () => {
expect(document.activeElement).toBe(input);
});

it('keeps aria-activedescendant on the highlighted option while filtering', async () => {
render(<CommandPalette registry={setup()} />);
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(<CommandPalette registry={setup()} />);
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(<CommandPalette registry={setup()} />);
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(
Expand Down
4 changes: 3 additions & 1 deletion packages/palette-react/src/palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -96,6 +97,7 @@ export function CommandPalette(props: CommandPaletteProps): React.ReactElement {

const revision = useCommandsChanged(registry);
const [view, setView] = useState<View>({ kind: 'list' });
const rootRef = useActiveDescendant();

const groups = useMemo(() => {
const list = registry.list({
Expand Down Expand Up @@ -181,7 +183,7 @@ export function CommandPalette(props: CommandPaletteProps): React.ReactElement {
}

return (
<Command className={className} label="Command palette" data-acture-palette-view="list">
<Command ref={rootRef} className={className} label="Command palette" data-acture-palette-view="list">
<Command.Input placeholder={placeholder} autoFocus />
<Command.List>
<Command.Empty>No matching commands.</Command.Empty>
Expand Down
4 changes: 3 additions & 1 deletion packages/palette-react/src/picker-chain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -125,8 +126,9 @@ function EnumPicker({
options,
coerce,
}: StepProps & { options: readonly string[]; coerce?: (s: string) => unknown }): React.ReactElement {
const rootRef = useActiveDescendant();
return (
<Command label={`Pick ${param.name}`} shouldFilter={true}>
<Command ref={rootRef} label={`Pick ${param.name}`} shouldFilter={true}>
<Command.Input
placeholder={`Pick ${param.name}…`}
autoFocus
Expand Down
53 changes: 53 additions & 0 deletions packages/palette-react/src/use-active-descendant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* 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<MutationObserver | null>(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;
}, []);
}
Loading