mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
159 lines
6.0 KiB
TypeScript
159 lines
6.0 KiB
TypeScript
/**
|
|
* 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<PaneName, boolean> = {
|
|
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<HTMLDivElement>(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<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
|
|
ref={stripRef}
|
|
role="toolbar"
|
|
aria-orientation="vertical"
|
|
aria-label="Workspace panes"
|
|
className={styles.strip}
|
|
>
|
|
{PANES.map((item, i) => (
|
|
<IconButton
|
|
key={item.pane}
|
|
ref={(el) => {
|
|
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 <section>, 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)}
|
|
>
|
|
<Icon name={item.icon} />
|
|
</IconButton>
|
|
))}
|
|
|
|
<div className={styles.divider} aria-hidden="true" />
|
|
|
|
<IconButton
|
|
ref={(el) => {
|
|
refs.current[datasetsIndex] = el;
|
|
}}
|
|
label="Datasets"
|
|
aria-keyshortcuts="Meta+K Control+K"
|
|
title="Datasets (⌘/Ctrl+K)"
|
|
tabIndex={focusIndex === datasetsIndex ? 0 : -1}
|
|
onClick={() => {
|
|
openModal('datasets');
|
|
setFocusIndex(datasetsIndex);
|
|
}}
|
|
onKeyDown={(e) => onKeyDown(e, datasetsIndex)}
|
|
>
|
|
<Icon name="dataset" />
|
|
</IconButton>
|
|
</div>
|
|
);
|
|
}
|