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 { 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<SegmentedOption<FitMode>> = [
{ value: 'default', label: 'Original' },
{ value: 'width', label: 'Width' },
{ value: 'height', label: 'Height' },
{ value: 'full', label: 'Full' },
];
/**
@@ -54,20 +55,17 @@ const FIT_CLASS: Record<FitMode, string> = {
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 (
<div className={styles.fit} role="group" aria-label="Fit chart to pane">
{FIT_MODES.map(({ mode, label }) => (
<button
key={mode}
type="button"
className={`${styles.fitOption} ${mode === fitMode ? styles.fitActive : ''}`}
aria-pressed={mode === fitMode}
onClick={() => setFitMode(mode)}
>
{label}
</button>
))}
</div>
<SegmentedControl
label="Fit chart to pane"
options={FIT_OPTIONS}
value={fitMode}
onChange={setFitMode}
className={styles.fit}
optionClassName={styles.fitOption}
activeClassName={styles.fitActive}
/>
);
}
@@ -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() {
<div className={`${styles.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}>
<div className={styles.host} ref={hostRef} />
</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>}
</div>
</div>