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-a11y-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"acture-palette-react": patch
---

Accessibility: the picker chain's text step now has an accessible name (the parameter name), and its validation error is announced (`role="alert"`, `aria-describedby`, `aria-invalid`). Adds `palette.a11y.test.tsx`, which pins the APG combobox roles, `aria-activedescendant` and the focus hand-off between the list, the picker chain and a host form.
25 changes: 25 additions & 0 deletions .claude/skills/acture-palette-design/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,30 @@ Peer range is `@rjsf` `^5.20.0 || ^6.0.0`, and CI installs and tests both majors

Acture's core does not bundle a form library. Per redesign-takeaways §2.3.

## Unavailable commands: hidden or disabled? (decided, research-12 §3.2)

A command whose `when` is false is **unavailable**. What a surface does with it is a per-surface rule, not a property of the record:

| Surface | Unavailable command is… | Why |
| --- | --- | --- |
| Command palette (`acture-palette-react`) | **hidden**, when the host passes `context` (`registry.list({ context })` drops it; without `context`, `when` is not evaluated and an unavailable command fails at dispatch) | The palette is a search box; unavailable rows are noise and VS Code does the same. |
| AI tool list / MCP | **should be hidden; not implemented yet.** `acture-mcp-server` and `acture-ai-vercel` call `registry.list()` without a context, so they list every command and an unavailable one fails with `when_clause_failed` at dispatch | A tool the model cannot usefully call only invites a failed call. |
| Menu, toolbar, button, native menu | **shown, disabled** | These are spatial and persistent; a vanishing item breaks muscle memory and hides what the app can do. |

For the "shown, disabled" surfaces, build the projection from the **unfiltered** `registry.list()` and evaluate `when` yourself with `evaluateWhen(cmd.when, ctx)`. Map the result to `aria-disabled="true"` (not the `disabled` attribute, which takes the control out of the tab order, so keyboard users can no longer reach it and learn why it is off; APG menu items stay focusable), `AXEnabled: false` (macOS), `IsEnabled: false` (UIA), or Electron/Tauri `enabled: false`. A disabled command must not advertise a live shortcut (`aria-keyshortcuts`).

A palette host that wants the menu convention instead can render its own rows from the unfiltered list and pass the `disabled` prop to `Command.Item` (cmdk renders `aria-disabled` itself, overwriting any `aria-disabled` you pass, and skips disabled items in keyboard navigation). That is a host choice; the shipped palette keeps hiding.

## Accessibility contract (tested in `palette.a11y.test.tsx`)

`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.)
- 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).

## The don't-do list (research-2 §9.5)

1. **Do not parse a single text blob into multiple parameters.** Slack's pattern. H2 hate; industry is migrating away.
Expand All @@ -122,3 +146,4 @@ The original wrapex `paletteHint` for delegating to a UI panel (Pattern D in `pa
- `docs/parameterized_command_palette_guide.md` — implementation patterns (defer to research-2 on UX conflicts)
- `acture-command-record-shape` — the `kind` field spec
- `acture-schema-bridge` — how `params` projects to forms
- `docs/research/acture_research_12 -- Accessibility APIs and Command Dispatch.md` — the accessibility alignment this section comes from
243 changes: 243 additions & 0 deletions packages/palette-react/src/palette.a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
/// <reference lib="dom" />
/**
* Accessibility contract of `<CommandPalette />` (research-12 §3 item 5, #64).
*
* The palette inherits the WAI-ARIA APG combobox pattern from cmdk. These
* tests pin that inheritance down — roles, the accessible name, the
* `aria-activedescendant` roving highlight — and the focus hand-off when the
* view switches (list → picker chain / form → back to the list), so a cmdk
* upgrade or an acture refactor cannot silently drop it.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, act } from '@testing-library/react';
import { z } from 'zod';
import { createRegistry, defineCommand, ok } from 'acture';
import { CommandPalette } from './palette.js';
import type { PaletteFormAdapter } from './palette.js';

afterEach(() => cleanup());

function setup() {
const registry = createRegistry();
registry.registerAll([
defineCommand({
id: 'app.view.zoomToFit',
title: 'Zoom to fit',
category: 'View',
execute: () => ok('zoomed'),
}),
defineCommand({
id: 'app.selection.selectAll',
title: 'Select all',
category: 'Selection',
execute: () => ok('selected'),
}),
defineCommand({
id: 'app.style.set',
title: 'Set style',
category: 'Style',
params: z.object({ variant: z.enum(['solid', 'dashed']) }),
execute: (p) => ok(p),
}),
defineCommand({
id: 'app.graph.addNode',
title: 'Add node',
category: 'Graph',
params: z.object({ label: z.string(), size: z.number() }),
execute: (p) => ok(p),
}),
]);
return registry;
}

describe('<CommandPalette /> accessibility', () => {
it('exposes the APG combobox pattern: combobox input controlling a listbox of options', () => {
render(<CommandPalette registry={setup()} />);
const input = screen.getByRole('combobox');
const listbox = screen.getByRole('listbox');
expect(input.getAttribute('aria-autocomplete')).toBe('list');
expect(input.getAttribute('aria-expanded')).toBe('true');
expect(input.getAttribute('aria-controls')).toBe(listbox.id);
expect(screen.getAllByRole('option')).toHaveLength(4);
});

it('gives the palette, the listbox and the input an accessible name', () => {
const { container } = render(<CommandPalette registry={setup()} />);
const root = container.querySelector('[cmdk-root]')!;
expect(root.getAttribute('data-acture-palette-view')).toBe('list');
// `label` on <Command> is rendered as a visually-hidden <label> naming the input.
const input = screen.getByRole('combobox');
const labelId = input.getAttribute('aria-labelledby');
expect(labelId).toBeTruthy();
expect(document.getElementById(labelId!)?.textContent).toBe('Command palette');
expect(screen.getByRole('listbox').getAttribute('aria-label')).toBeTruthy();
});

it('groups options under named groups', () => {
render(<CommandPalette registry={setup()} />);
const groups = screen.getAllByRole('group');
const names = groups.map((g) => g.getAttribute('aria-labelledby'))
.map((id) => (id ? document.getElementById(id)?.textContent : null));
expect(names).toEqual(expect.arrayContaining(['View', 'Selection', 'Style', 'Graph']));
});

it('keeps DOM focus in the input and moves aria-activedescendant with the arrow keys', async () => {
render(<CommandPalette registry={setup()} />);
// cmdk publishes the highlighted option id one scheduled flush after mount.
await act(async () => {});
const input = screen.getByRole('combobox');
expect(document.activeElement).toBe(input);

const selectedOptionId = (): string | null =>
screen.getAllByRole('option').find((o) => o.getAttribute('aria-selected') === 'true')?.id ?? null;

// 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);

await act(async () => {
fireEvent.keyDown(input, { key: 'ArrowDown' });
});
const second = selectedOptionId();
expect(second).toBeTruthy();
expect(second).not.toBe(first);
expect(input.getAttribute('aria-activedescendant')).toBe(second);
expect(document.activeElement).toBe(input);
});

it('marks the decorative icon aria-hidden and keeps the title as the option name', () => {
const registry = createRegistry();
registry.register(
defineCommand({
id: 'app.x.save',
title: 'Save file',
icon: '💾',
keybinding: '$mod+s',
execute: () => ok(0),
}),
);
render(<CommandPalette registry={registry} />);
const option = screen.getByRole('option');
expect(option.querySelector('[aria-hidden="true"]')?.textContent).toBe('💾');
// WCAG 2.5.3 Label in Name: the visible title is part of the option's text.
expect(option.textContent).toContain('Save file');
});

it('hands focus to the picker-chain input when an atomic parameterized command is chosen', async () => {
render(<CommandPalette registry={setup()} />);
await act(async () => {
fireEvent.click(screen.getByText('Set style').closest('[cmdk-item]')!);
});
const input = screen.getByRole('combobox');
expect(input.getAttribute('placeholder')).toBe('Pick variant…');
expect(document.activeElement).toBe(input);
// The step's own combobox is named for the parameter being collected.
const labelId = input.getAttribute('aria-labelledby');
expect(document.getElementById(labelId!)?.textContent).toBe('Pick variant');
});

it('focuses the text step input of a picker chain and names it', async () => {
const registry = createRegistry();
registry.register(
defineCommand({
id: 'app.graph.rename',
title: 'Rename node',
params: z.object({ label: z.string() }),
kind: 'atomic',
execute: (p) => ok(p),
}),
);
const { container } = render(<CommandPalette registry={registry} />);
await act(async () => {
fireEvent.click(screen.getByText('Rename node').closest('[cmdk-item]')!);
});
const text = container.querySelector<HTMLInputElement>('[data-acture-text-step]')!;
expect(document.activeElement).toBe(text);
expect(screen.getByRole('textbox', { name: 'label' })).toBe(text);
});

it('names a described parameter by the visible description (Label in Name)', async () => {
const registry = createRegistry();
registry.register(
defineCommand({
id: 'app.graph.relabel',
title: 'Relabel',
params: z.object({ label: z.string().describe('New node label') }),
kind: 'atomic',
execute: (p) => ok(p),
}),
);
render(<CommandPalette registry={registry} />);
await act(async () => {
fireEvent.click(screen.getByText('Relabel').closest('[cmdk-item]')!);
});
expect(screen.getByRole('textbox', { name: 'New node label' })).toBeTruthy();
});

it('announces a validation error on the text step', async () => {
const registry = createRegistry();
registry.register(
defineCommand({
id: 'app.graph.resize',
title: 'Resize',
params: z.object({ size: z.number().min(1) }),
kind: 'atomic',
execute: (p) => ok(p),
}),
);
const { container } = render(<CommandPalette registry={registry} />);
await act(async () => {
fireEvent.click(screen.getByText('Resize').closest('[cmdk-item]')!);
});
const text = container.querySelector<HTMLInputElement>('[data-acture-text-step]')!;
await act(async () => {
fireEvent.change(text, { target: { value: '0' } });
fireEvent.keyDown(text, { key: 'Enter' });
});
const alert = screen.getByRole('alert');
expect(alert.textContent).toBeTruthy();
expect(text.getAttribute('aria-invalid')).toBe('true');
expect(text.getAttribute('aria-describedby')).toBe(alert.id);
});

it('returns focus to the palette input when the picker chain is cancelled with Escape', async () => {
render(<CommandPalette registry={setup()} />);
await act(async () => {
fireEvent.click(screen.getByText('Set style').closest('[cmdk-item]')!);
});
await act(async () => {
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape' });
});
const input = screen.getByRole('combobox');
expect(input.getAttribute('placeholder')).toBe('Type a command…');
expect(document.activeElement).toBe(input);
});

it('mounts the form adapter in place of the list, then restores the list and its focus on cancel', async () => {
const FormAdapter: PaletteFormAdapter = ({ command, onCancel }) => (
<form aria-label={command.title}>
<input aria-label="Label" autoFocus />
<button type="button" onClick={onCancel}>cancel</button>
</form>
);
const { container } = render(<CommandPalette registry={setup()} formAdapter={FormAdapter} />);
await act(async () => {
fireEvent.click(screen.getByText('Add node').closest('[cmdk-item]')!);
});
// The list (and its combobox) is gone; the host form owns focus.
expect(screen.queryByRole('combobox')).toBeNull();
expect(container.querySelector('[data-acture-palette-view="form"]')).toBeTruthy();
expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Label' }));

await act(async () => {
fireEvent.click(screen.getByText('cancel'));
});
const input = screen.getByRole('combobox');
expect(document.activeElement).toBe(input);
});
});
8 changes: 6 additions & 2 deletions packages/palette-react/src/picker-chain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
/// <reference lib="dom" />

import { Command } from 'cmdk';
import { useState } from 'react';
import { useId, useState } from 'react';
import type { AnyCommandRecord } from 'acture';
import { readEnumOptions, summarizeParams, unwrap } from './derive-kind.js';
import type { ParamSummary } from './derive-kind.js';
Expand Down Expand Up @@ -159,6 +159,7 @@ function TextStep({ param, initial, onCommit, onBack }: StepProps): React.ReactE
typeof initial === 'string' ? initial : initial === undefined ? '' : String(initial),
);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const errorId = useId();

function submit(): void {
const coerced = coerceFromText(draft, param);
Expand All @@ -176,6 +177,9 @@ function TextStep({ param, initial, onCommit, onBack }: StepProps): React.ReactE
<input
data-acture-text-step
autoFocus
aria-label={paramLabel(param)}
aria-invalid={errorMsg ? true : undefined}
aria-describedby={errorMsg ? errorId : undefined}
value={draft}
placeholder={inferPlaceholder(param)}
onChange={(e) => {
Expand All @@ -201,7 +205,7 @@ function TextStep({ param, initial, onCommit, onBack }: StepProps): React.ReactE
}}
/>
{errorMsg ? (
<span style={{ color: '#c33', fontSize: '0.85em' }} data-acture-validation-error>
<span id={errorId} role="alert" style={{ color: '#c33', fontSize: '0.85em' }} data-acture-validation-error>
{errorMsg}
</span>
) : null}
Expand Down
Loading