Add pane show/hide toggle strip with persisted visibility (M6, §01A)

A persistent left-rail toolbar (APG toolbar + roving tabindex) toggles the
library / editor / preview panes; a hidden pane frees its space and the rest
redistribute proportionally to their remembered widths. Visibility persists to
astrolabe:ux-prefs (separate key from widths, so a hidden pane keeps its width).
Adds the positional pane-* glyph sub-family to Icon and a Datasets shortcut
button in the strip.
This commit is contained in:
2026-06-07 17:15:58 +03:00
parent f92118712c
commit 8c0a6b9239
11 changed files with 601 additions and 45 deletions
+149
View File
@@ -0,0 +1,149 @@
/**
* 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 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<PaneName, boolean> = {
library: libraryVisible,
editor: editorVisible,
preview: previewVisible,
};
// 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<Array<HTMLButtonElement | null>>([]);
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 (
<div
role="toolbar"
aria-orientation="vertical"
aria-label="Workspace panes"
className={styles.strip}
>
{PANES.map((item, i) => (
<button
key={item.pane}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
aria-pressed={visible[item.pane]}
aria-label={item.label}
// TODO: when a pane is hidden, App unmounts its <section>, so this
// aria-controls IDREF dangles until the pane is shown again. Harmless
// (AT ignores unresolved IDREFs) but technically invalid — consider
// keeping the section mounted-but-hidden, or dropping aria-controls.
aria-controls={item.controls}
// 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={[styles.button, visible[item.pane] && styles.pressed]
.filter(Boolean)
.join(' ')}
onClick={() => {
togglePane(item.pane);
setFocusIndex(i);
}}
onKeyDown={(e) => onKeyDown(e, i)}
>
<Icon name={item.icon} />
</button>
))}
<div className={styles.divider} aria-hidden="true" />
<button
ref={(el) => {
refs.current[datasetsIndex] = el;
}}
type="button"
aria-label="Datasets"
aria-keyshortcuts="Meta+K Control+K"
title="Datasets (⌘/Ctrl+K)"
tabIndex={focusIndex === datasetsIndex ? 0 : -1}
className={styles.button}
onClick={() => {
openModal('datasets');
setFocusIndex(datasetsIndex);
}}
onKeyDown={(e) => onKeyDown(e, datasetsIndex)}
>
<Icon name="dataset" />
</button>
</div>
);
}