Backfill interactive-control accessibility against the council (APG)

This commit is contained in:
2026-06-05 12:58:32 +03:00
parent 8f6ac21171
commit 38c2ac15c2
6 changed files with 188 additions and 54 deletions
+23 -18
View File
@@ -24,17 +24,18 @@ import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { usePreviewStore } from '../stores/PreviewStore'; import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore'; import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import styles from './LivePreview.module.css'; import styles from './LivePreview.module.css';
/** Render debounce (ms). Becomes the configurable `renderDebounce` setting in M5. */ /** Render debounce (ms). Becomes the configurable `renderDebounce` setting in M5. */
const RENDER_DEBOUNCE_MS = 300; const RENDER_DEBOUNCE_MS = 300;
/** The four fit modes in display order (spec §04 → Fit / Sizing Modes). */ /** The four fit modes in display order (spec §04 → Fit / Sizing Modes). */
const FIT_MODES: ReadonlyArray<{ mode: FitMode; label: string }> = [ const FIT_OPTIONS: ReadonlyArray<SegmentedOption<FitMode>> = [
{ mode: 'default', label: 'Original' }, { value: 'default', label: 'Original' },
{ mode: 'width', label: 'Width' }, { value: 'width', label: 'Width' },
{ mode: 'height', label: 'Height' }, { value: 'height', label: 'Height' },
{ mode: 'full', label: 'Full' }, { value: 'full', label: 'Full' },
]; ];
/** /**
@@ -54,20 +55,17 @@ const FIT_CLASS: Record<FitMode, string> = {
function FitControl() { function FitControl() {
const fitMode = useAppStore((s) => s.previewFitMode); const fitMode = useAppStore((s) => s.previewFitMode);
const setFitMode = useAppStore((s) => s.setPreviewFitMode); const setFitMode = useAppStore((s) => s.setPreviewFitMode);
// Single-select set → a radio group, not independent toggles (APG; doc §10.5).
return ( return (
<div className={styles.fit} role="group" aria-label="Fit chart to pane"> <SegmentedControl
{FIT_MODES.map(({ mode, label }) => ( label="Fit chart to pane"
<button options={FIT_OPTIONS}
key={mode} value={fitMode}
type="button" onChange={setFitMode}
className={`${styles.fitOption} ${mode === fitMode ? styles.fitActive : ''}`} className={styles.fit}
aria-pressed={mode === fitMode} optionClassName={styles.fitOption}
onClick={() => setFitMode(mode)} activeClassName={styles.fitActive}
> />
{label}
</button>
))}
</div>
); );
} }
@@ -81,6 +79,10 @@ export function LivePreview() {
const error = usePreviewStore((s) => s.error); const error = usePreviewStore((s) => s.error);
const setError = usePreviewStore((s) => s.setError); 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(() => { useEffect(() => {
const node = hostRef.current; const node = hostRef.current;
if (!node) return; if (!node) return;
@@ -179,6 +181,9 @@ export function LivePreview() {
<div className={`${styles.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}> <div className={`${styles.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}>
<div className={styles.host} ref={hostRef} /> <div className={styles.host} ref={hostRef} />
</div> </div>
{/* Visual only — no live region. The same error is announced once by the
editor pane's role="alert" (one producer, two subscribers; doc §10.1),
so adding one here would double-announce it. */}
{error !== null && <pre className={styles.error}>{error}</pre>} {error !== null && <pre className={styles.error}>{error}</pre>}
</div> </div>
</div> </div>
+102
View File
@@ -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 `<button>` gives Enter/Space-to-select for
* free. One widget so every segmented control gets the same, documented model.
*
* Styling is injected via class props so each call site keeps its own look.
*/
import { useRef } from 'react';
export interface SegmentedOption<T extends string> {
value: T;
label: string;
}
interface SegmentedControlProps<T extends string> {
/** Accessible name for the group. */
label: string;
options: ReadonlyArray<SegmentedOption<T>>;
value: T;
onChange: (value: T) => void;
/** Class for the radiogroup container. */
className?: string;
/** Class for each option button. */
optionClassName?: string;
/** Extra class applied to the selected option. */
activeClassName?: string;
}
export function SegmentedControl<T extends string>({
label,
options,
value,
onChange,
className,
optionClassName,
activeClassName,
}: SegmentedControlProps<T>) {
const refs = useRef<Array<HTMLButtonElement | null>>([]);
/** Select the option at `index` (wrapping) and move focus to it (APG radio). */
const selectAt = (index: number) => {
const next = (index + options.length) % options.length;
onChange(options[next].value);
refs.current[next]?.focus();
};
const onKeyDown = (e: React.KeyboardEvent, index: number) => {
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
selectAt(index + 1);
break;
case 'ArrowLeft':
case 'ArrowUp':
selectAt(index - 1);
break;
case 'Home':
selectAt(0);
break;
case 'End':
selectAt(options.length - 1);
break;
default:
return; // not ours — let it bubble
}
e.preventDefault();
};
return (
<div className={className} role="radiogroup" aria-label={label}>
{options.map((opt, i) => {
const selected = opt.value === value;
return (
<button
key={opt.value}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={selected}
// Roving tabindex: only the selected option is a tab stop; arrows
// move within the group.
tabIndex={selected ? 0 : -1}
className={[optionClassName, selected ? activeClassName : ''].filter(Boolean).join(' ')}
onClick={() => onChange(opt.value)}
onKeyDown={(e) => onKeyDown(e, i)}
>
{opt.label}
</button>
);
})}
</div>
);
}
+14 -3
View File
@@ -42,11 +42,10 @@
.item { .item {
display: flex; display: flex;
align-items: center; align-items: stretch;
gap: var(--space-3); gap: var(--space-3);
padding: var(--space-3) var(--space-4); padding: 0 var(--space-4);
border-left: 2px solid transparent; border-left: 2px solid transparent;
cursor: pointer;
transition: background var(--dur-fast) var(--ease); transition: background var(--dur-fast) var(--ease);
} }
@@ -68,7 +67,18 @@
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
gap: var(--space-1); gap: var(--space-1);
/* A real <button> for keyboard selection — strip the native chrome and let
the row's own padding live here so the click target fills the row height. */
appearance: none;
border: none;
background: none;
padding: var(--space-3) 0;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
} }
.nameRow { .nameRow {
@@ -102,6 +112,7 @@
.delete { .delete {
flex: 0 0 auto; flex: 0 0 auto;
align-self: center;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
+16 -11
View File
@@ -58,15 +58,23 @@ export function SnippetLibrary() {
</button> </button>
<ul className={styles.list}> <ul className={styles.list}>
{ordered.length === 0 && <li className={styles.empty}>No snippets found</li>} {ordered.length === 0 && (
<li className={styles.empty}>
No snippets yet create your first one with the button above.
</li>
)}
{ordered.map((s) => ( {ordered.map((s) => (
<li <li key={s.id} className={`${styles.item} ${s.id === activeId ? styles.active : ''}`}>
key={s.id} {/* The row's selectable area is a real <button> so it's keyboard
className={`${styles.item} ${s.id === activeId ? styles.active : ''}`} operable (Enter/Space) and focusable — not a click-only <li>.
aria-current={s.id === activeId} Not a listbox option: APG forbids interactive children, and each
row carries a delete button. */}
<button
type="button"
className={styles.itemMain}
aria-current={s.id === activeId ? 'true' : undefined}
onClick={() => selectSnippet(s.id)} onClick={() => selectSnippet(s.id)}
> >
<div className={styles.itemMain}>
<span className={styles.nameRow}> <span className={styles.nameRow}>
{hasUnpublishedChanges(s) && ( {hasUnpublishedChanges(s) && (
<span <span
@@ -78,15 +86,12 @@ export function SnippetLibrary() {
<span className={styles.name}>{s.name}</span> <span className={styles.name}>{s.name}</span>
</span> </span>
<span className={styles.date}>{relativeDate(s.modified)}</span> <span className={styles.date}>{relativeDate(s.modified)}</span>
</div> </button>
<button <button
className={styles.delete} className={styles.delete}
aria-label={`Delete ${s.name}`} aria-label={`Delete ${s.name}`}
title="Delete snippet" title="Delete snippet"
onClick={(e) => { onClick={() => void handleDelete(s.id, s.name)}
e.stopPropagation();
void handleDelete(s.id, s.name);
}}
> >
</button> </button>
+26 -19
View File
@@ -27,8 +27,16 @@ import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore'; import { confirm } from '../stores/ConfirmStore';
import { usePreviewStore } from '../stores/PreviewStore'; import { usePreviewStore } from '../stores/PreviewStore';
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore'; 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'; import styles from './SpecEditor.module.css';
/** The two editor views as a single-select set (spec §03D). */
const VIEW_OPTIONS: ReadonlyArray<SegmentedOption<EditorView>> = [
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
];
// Register the bundled Vega-Lite schema once: resolves `$schema` locally (no // Register the bundled Vega-Lite schema once: resolves `$schema` locally (no
// network warning) and powers validation, autocomplete, and hover docs. // network warning) and powers validation, autocomplete, and hover docs.
configureVegaLiteJson(); configureVegaLiteJson();
@@ -68,24 +76,15 @@ function EditorToolbar() {
return ( return (
<div className={styles.toolbar}> <div className={styles.toolbar}>
<div className={styles.viewToggle} role="group" aria-label="Editor view"> <SegmentedControl
<button label="Editor view"
type="button" options={VIEW_OPTIONS}
className={`${styles.viewOption} ${editorView === 'draft' ? styles.viewActive : ''}`} value={editorView}
aria-pressed={editorView === 'draft'} onChange={setEditorView}
onClick={() => setEditorView('draft')} className={styles.viewToggle}
> optionClassName={styles.viewOption}
Draft activeClassName={styles.viewActive}
</button> />
<button
type="button"
className={`${styles.viewOption} ${editorView === 'published' ? styles.viewActive : ''}`}
aria-pressed={editorView === 'published'}
onClick={() => setEditorView('published')}
>
Published
</button>
</div>
<span className={styles.spacer} /> <span className={styles.spacer} />
@@ -103,6 +102,7 @@ function EditorToolbar() {
onClick={handlePublish} onClick={handlePublish}
disabled={activeId === null} disabled={activeId === null}
title="Publish (⌘/Ctrl+S)" title="Publish (⌘/Ctrl+S)"
aria-keyshortcuts="Meta+S Control+S"
> >
Publish Publish
</button> </button>
@@ -190,7 +190,14 @@ export function SpecEditor() {
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>} {activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
<div className={styles.editor} ref={hostRef} /> <div className={styles.editor} ref={hostRef} />
</div> </div>
{error !== null && <pre className={styles.error}>{error}</pre>} {/* 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 && (
<pre className={styles.error} role="alert">
{error}
</pre>
)}
</div> </div>
); );
} }
+5 -1
View File
@@ -62,7 +62,11 @@ export function ThemeToggle() {
type="button" type="button"
className={styles.toggle} className={styles.toggle}
onClick={toggleTheme} 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`} title={`Switch to ${target} theme`}
> >
{uiTheme === 'dark' ? <SunIcon /> : <MoonIcon />} {uiTheme === 'dark' ? <SunIcon /> : <MoonIcon />}