From 38c2ac15c2f15387ccf6295b2a69089a72ab5584 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Fri, 5 Jun 2026 12:58:32 +0300 Subject: [PATCH] Backfill interactive-control accessibility against the council (APG) --- src/app/components/LivePreview.tsx | 41 ++++---- src/app/components/SegmentedControl.tsx | 102 +++++++++++++++++++ src/app/components/SnippetLibrary.module.css | 17 +++- src/app/components/SnippetLibrary.tsx | 31 +++--- src/app/components/SpecEditor.tsx | 45 ++++---- src/app/components/ThemeToggle.tsx | 6 +- 6 files changed, 188 insertions(+), 54 deletions(-) create mode 100644 src/app/components/SegmentedControl.tsx diff --git a/src/app/components/LivePreview.tsx b/src/app/components/LivePreview.tsx index dac1220..5953af2 100644 --- a/src/app/components/LivePreview.tsx +++ b/src/app/components/LivePreview.tsx @@ -24,17 +24,18 @@ import { renderSpec, type RenderHandle } from '../services/chart-renderer'; import { useAppStore } from '../stores/AppStore'; import { usePreviewStore } from '../stores/PreviewStore'; import { selectShownText, useSnippetStore } from '../stores/SnippetStore'; +import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import styles from './LivePreview.module.css'; /** Render debounce (ms). Becomes the configurable `renderDebounce` setting in M5. */ const RENDER_DEBOUNCE_MS = 300; /** The four fit modes in display order (spec §04 → Fit / Sizing Modes). */ -const FIT_MODES: ReadonlyArray<{ mode: FitMode; label: string }> = [ - { mode: 'default', label: 'Original' }, - { mode: 'width', label: 'Width' }, - { mode: 'height', label: 'Height' }, - { mode: 'full', label: 'Full' }, +const FIT_OPTIONS: ReadonlyArray> = [ + { value: 'default', label: 'Original' }, + { value: 'width', label: 'Width' }, + { value: 'height', label: 'Height' }, + { value: 'full', label: 'Full' }, ]; /** @@ -54,20 +55,17 @@ const FIT_CLASS: Record = { function FitControl() { const fitMode = useAppStore((s) => s.previewFitMode); const setFitMode = useAppStore((s) => s.setPreviewFitMode); + // Single-select set → a radio group, not independent toggles (APG; doc §10.5). return ( -
- {FIT_MODES.map(({ mode, label }) => ( - - ))} -
+ ); } @@ -81,6 +79,10 @@ export function LivePreview() { const error = usePreviewStore((s) => s.error); const setError = usePreviewStore((s) => s.setError); + // TODO (council backfill, doc §10.2): a render that exceeds ~1s owes a + // non-blocking busy indication (overlay + aria-busy), gated by a threshold so + // sub-1s renders show nothing. Deferred — it pairs with the heavier M3 dataset + // renders the budget flags; today's inline-data renders are effectively instant. useEffect(() => { const node = hostRef.current; if (!node) return; @@ -179,6 +181,9 @@ export function LivePreview() { diff --git a/src/app/components/SegmentedControl.tsx b/src/app/components/SegmentedControl.tsx new file mode 100644 index 0000000..c9479f3 --- /dev/null +++ b/src/app/components/SegmentedControl.tsx @@ -0,0 +1,102 @@ +/** + * SegmentedControl — a single-select button group with correct ARIA semantics. + * + * A "pick one of N" toolbar control (fit modes, draft/published view) is a + * **radio group**, not a row of independent toggle buttons. Modeled per + * WAI-ARIA APG → Radio Group (see docs/architecture/10 §5): `role="radiogroup"` + * wrapping `role="radio"` options with `aria-checked`, a **roving tabindex** + * (only the selected option is in the tab order), and Arrow/Home/End keys that + * move focus *and* select. Native ` + ); + })} + + ); +} diff --git a/src/app/components/SnippetLibrary.module.css b/src/app/components/SnippetLibrary.module.css index 7cc0274..ada9d00 100644 --- a/src/app/components/SnippetLibrary.module.css +++ b/src/app/components/SnippetLibrary.module.css @@ -42,11 +42,10 @@ .item { display: flex; - align-items: center; + align-items: stretch; gap: var(--space-3); - padding: var(--space-3) var(--space-4); + padding: 0 var(--space-4); border-left: 2px solid transparent; - cursor: pointer; transition: background var(--dur-fast) var(--ease); } @@ -68,7 +67,18 @@ min-width: 0; display: flex; flex-direction: column; + justify-content: center; gap: var(--space-1); + /* A real
    - {ordered.length === 0 &&
  • No snippets found
  • } + {ordered.length === 0 && ( +
  • + No snippets yet — create your first one with the button above. +
  • + )} {ordered.map((s) => ( -
  • selectSnippet(s.id)} - > -
    +
  • + {/* The row's selectable area is a real diff --git a/src/app/components/SpecEditor.tsx b/src/app/components/SpecEditor.tsx index 4fc2c2d..a1b8924 100644 --- a/src/app/components/SpecEditor.tsx +++ b/src/app/components/SpecEditor.tsx @@ -27,8 +27,16 @@ import { useAppStore } from '../stores/AppStore'; import { confirm } from '../stores/ConfirmStore'; import { usePreviewStore } from '../stores/PreviewStore'; import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore'; +import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; +import type { EditorView } from '../stores/SnippetStore'; import styles from './SpecEditor.module.css'; +/** The two editor views as a single-select set (spec §03D). */ +const VIEW_OPTIONS: ReadonlyArray> = [ + { value: 'draft', label: 'Draft' }, + { value: 'published', label: 'Published' }, +]; + // Register the bundled Vega-Lite schema once: resolves `$schema` locally (no // network warning) and powers validation, autocomplete, and hover docs. configureVegaLiteJson(); @@ -68,24 +76,15 @@ function EditorToolbar() { return (
    -
    - - -
    + @@ -103,6 +102,7 @@ function EditorToolbar() { onClick={handlePublish} disabled={activeId === null} title="Publish (⌘/Ctrl+S)" + aria-keyshortcuts="Meta+S Control+S" > Publish @@ -190,7 +190,14 @@ export function SpecEditor() { {activeId === null &&
    Select or create a snippet
    }
    - {error !== null &&
    {error}
    } + {/* The single live region for render/parse errors: assertive, since the + user just caused it. The preview shows the same text visually but is + not a live region, so the message is announced once (doc §10.1). */} + {error !== null && ( +
    +          {error}
    +        
    + )}
    ); } diff --git a/src/app/components/ThemeToggle.tsx b/src/app/components/ThemeToggle.tsx index 5abc7eb..b01993f 100644 --- a/src/app/components/ThemeToggle.tsx +++ b/src/app/components/ThemeToggle.tsx @@ -62,7 +62,11 @@ export function ThemeToggle() { type="button" className={styles.toggle} onClick={toggleTheme} - aria-label={`Switch to ${target} theme`} + // Toggle button with a stable name + pressed state (APG; doc §10.6): AT + // announces the *current* theme ("Dark theme, pressed"), at parity with the + // icon a sighted user sees — not just the action. `title` keeps the hover hint. + aria-pressed={uiTheme === 'dark'} + aria-label="Dark theme" title={`Switch to ${target} theme`} > {uiTheme === 'dark' ? : }