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 { 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>
|
||||
|
||||
@@ -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 {
|
||||
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 <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 {
|
||||
@@ -102,6 +112,7 @@
|
||||
|
||||
.delete {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -58,15 +58,23 @@ export function SnippetLibrary() {
|
||||
</button>
|
||||
|
||||
<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) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className={`${styles.item} ${s.id === activeId ? styles.active : ''}`}
|
||||
aria-current={s.id === activeId}
|
||||
onClick={() => selectSnippet(s.id)}
|
||||
>
|
||||
<div className={styles.itemMain}>
|
||||
<li key={s.id} className={`${styles.item} ${s.id === activeId ? styles.active : ''}`}>
|
||||
{/* The row's selectable area is a real <button> so it's keyboard
|
||||
operable (Enter/Space) and focusable — not a click-only <li>.
|
||||
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)}
|
||||
>
|
||||
<span className={styles.nameRow}>
|
||||
{hasUnpublishedChanges(s) && (
|
||||
<span
|
||||
@@ -78,15 +86,12 @@ export function SnippetLibrary() {
|
||||
<span className={styles.name}>{s.name}</span>
|
||||
</span>
|
||||
<span className={styles.date}>{relativeDate(s.modified)}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
className={styles.delete}
|
||||
aria-label={`Delete ${s.name}`}
|
||||
title="Delete snippet"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleDelete(s.id, s.name);
|
||||
}}
|
||||
onClick={() => void handleDelete(s.id, s.name)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
@@ -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<SegmentedOption<EditorView>> = [
|
||||
{ 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 (
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.viewToggle} role="group" aria-label="Editor view">
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.viewOption} ${editorView === 'draft' ? styles.viewActive : ''}`}
|
||||
aria-pressed={editorView === 'draft'}
|
||||
onClick={() => setEditorView('draft')}
|
||||
>
|
||||
Draft
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.viewOption} ${editorView === 'published' ? styles.viewActive : ''}`}
|
||||
aria-pressed={editorView === 'published'}
|
||||
onClick={() => setEditorView('published')}
|
||||
>
|
||||
Published
|
||||
</button>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
label="Editor view"
|
||||
options={VIEW_OPTIONS}
|
||||
value={editorView}
|
||||
onChange={setEditorView}
|
||||
className={styles.viewToggle}
|
||||
optionClassName={styles.viewOption}
|
||||
activeClassName={styles.viewActive}
|
||||
/>
|
||||
|
||||
<span className={styles.spacer} />
|
||||
|
||||
@@ -103,6 +102,7 @@ function EditorToolbar() {
|
||||
onClick={handlePublish}
|
||||
disabled={activeId === null}
|
||||
title="Publish (⌘/Ctrl+S)"
|
||||
aria-keyshortcuts="Meta+S Control+S"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
@@ -190,7 +190,14 @@ export function SpecEditor() {
|
||||
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
|
||||
<div className={styles.editor} ref={hostRef} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' ? <SunIcon /> : <MoonIcon />}
|
||||
|
||||
Reference in New Issue
Block a user