mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Backfill interactive-control accessibility against the council (APG)
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
onClick={() => selectSnippet(s.id)}
|
row carries a delete button. */}
|
||||||
>
|
<button
|
||||||
<div className={styles.itemMain}>
|
type="button"
|
||||||
|
className={styles.itemMain}
|
||||||
|
aria-current={s.id === activeId ? 'true' : undefined}
|
||||||
|
onClick={() => selectSnippet(s.id)}
|
||||||
|
>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />}
|
||||||
|
|||||||
Reference in New Issue
Block a user