/** * PaneToggleStrip — the persistent show/hide strip (spec §01A). * * Per the interaction contract (docs/architecture/10 → "Resolved — pane toggle * strip", consulted via /council → WAI-ARIA APG `toolbar` + `button`): * * - It is an APG **toolbar** (`role="toolbar"`, vertical) — a **single tab stop** * with a **roving tabindex**, so the whole strip is one stop in the tab order * and Up/Down (Left/Right duplicate) move among its controls; Home/End jump to * the ends. Re-entry restores the last-focused control (the `focusIndex` state). * - The three pane controls are **toggle buttons** (`aria-pressed`) with a * **stable** accessible name ("Library pane" …) that never changes with state; * `aria-pressed=true` ⇔ pane visible. The positional glyph shows which pane. * - The **Datasets** control is a plain command button (no `aria-pressed`) — APG * permits mixed control types in one toolbar — set off by a divider, kept last * in the roving order. * - Focus is never orphaned: hiding is only ever initiated *from here*, so the * activating toggle keeps focus (it stays mounted, flips to not-pressed). The * strip is never itself hidden, so it stays reachable even with all panes hidden. */ import { useRef, useState } from 'react'; import { openModal } from '../modals/ModalCoordinator'; import { usePanesStore, type PaneName } from '../stores/PanesStore'; import { Icon, type IconName } from './Icon'; import { IconButton } from './IconButton'; import styles from './PaneToggleStrip.module.css'; interface PaneItem { pane: PaneName; icon: IconName; /** Stable accessible name (APG: never changes with the pressed state). */ label: string; /** The id of the pane section this toggle controls. */ controls: string; } const PANES: readonly PaneItem[] = [ { pane: 'library', icon: 'pane-library', label: 'Library pane', controls: 'pane-library' }, { pane: 'editor', icon: 'pane-editor', label: 'Editor pane', controls: 'pane-editor' }, { pane: 'preview', icon: 'pane-preview', label: 'Preview pane', controls: 'pane-preview' }, ]; export function PaneToggleStrip() { const libraryVisible = usePanesStore((s) => s.libraryVisible); const editorVisible = usePanesStore((s) => s.editorVisible); const previewVisible = usePanesStore((s) => s.previewVisible); const togglePane = usePanesStore((s) => s.togglePane); const visible: Record = { library: libraryVisible, editor: editorVisible, preview: previewVisible, }; // The strip's own element — its parent is the panes row, so we can measure the // width available to the panes (row minus this strip) when toggling the editor, // which needs it to remember/restore the editor's width (PanesStore.togglePane). const stripRef = useRef(null); const panesInner = (): number => { const strip = stripRef.current; const row = strip?.parentElement; return row ? row.clientWidth - strip.offsetWidth : 0; }; // Roving tabindex over the toggles + the trailing Datasets action (the divider // is not a control). One tab stop; the focused index is the only `tabIndex=0`. const count = PANES.length + 1; const datasetsIndex = PANES.length; const refs = useRef>([]); const [focusIndex, setFocusIndex] = useState(0); const focusAt = (i: number) => { const idx = ((i % count) + count) % count; setFocusIndex(idx); refs.current[idx]?.focus(); }; const onKeyDown = (e: React.KeyboardEvent, index: number) => { switch (e.key) { case 'ArrowDown': case 'ArrowRight': focusAt(index + 1); break; case 'ArrowUp': case 'ArrowLeft': focusAt(index - 1); break; case 'Home': focusAt(0); break; case 'End': focusAt(count - 1); break; default: return; // not ours — let it bubble } e.preventDefault(); }; return (
{PANES.map((item, i) => ( { refs.current[i] = el; }} aria-pressed={visible[item.pane]} label={item.label} // Only point at the pane while it's actually mounted: App unmounts a // hidden pane's
, so emitting aria-controls then would leave a // dangling IDREF (invalid, even if AT ignores it). The association is // meaningful only when the target exists, and the aria-pressed state // already conveys hidden/shown either way (contract: arch/10 → toggle // strip — the bullet lists aria-pressed + a stable name, not controls). aria-controls={visible[item.pane] ? item.controls : undefined} // Description (not the name): hints what activating does. The name stays // stable (aria-label) per APG's toggle-button rule. title={`${visible[item.pane] ? 'Hide' : 'Show'} ${item.label.toLowerCase()}`} tabIndex={focusIndex === i ? 0 : -1} className={visible[item.pane] ? styles.pressed : undefined} onClick={() => { togglePane(item.pane, panesInner()); setFocusIndex(i); }} onKeyDown={(e) => onKeyDown(e, i)} > ))} ); }