mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
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:
@@ -28,6 +28,11 @@ export type IconName =
|
||||
| 'delete' // delete — Carbon TrashCan
|
||||
| 'add' // add / create-new — Carbon Add
|
||||
| 'settings' // per-pane settings disclosure (gear) — Carbon Settings
|
||||
// Pane-toggle sub-family (spec §01A): a panel frame with one region filled, so the
|
||||
// glyph shows *which* pane it controls by position (left / centre / right).
|
||||
| 'pane-library' // toggle the library pane (left)
|
||||
| 'pane-editor' // toggle the editor pane (centre)
|
||||
| 'pane-preview' // toggle the preview pane (right)
|
||||
// Status sub-family (arch 09 §5.2) — Carbon's FILLED notification glyphs, coloured
|
||||
// by status (not text). A redundant non-colour severity channel (WCAG 1.4.1): the
|
||||
// triangle shape-codes warning apart from the round error/success/info.
|
||||
@@ -70,6 +75,35 @@ const GLYPHS: Record<IconName, ReactNode> = {
|
||||
<path d="M27,3H5A2.0025,2.0025,0,0,0,3,5V27a2.0025,2.0025,0,0,0,2,2H27a2.0025,2.0025,0,0,0,2-2V5A2.0025,2.0025,0,0,0,27,3Zm0,2,0,4H5V5ZM5,27V11H27l0,16Z" />
|
||||
</>
|
||||
),
|
||||
// Panel frame (x4–28 / y6–26, 2px border) with one third filled. The filled bar's
|
||||
// position maps to the pane: left = library, centre = editor, right = preview.
|
||||
'pane-library': (
|
||||
<>
|
||||
<rect x="4" y="6" width="24" height="2" />
|
||||
<rect x="4" y="24" width="24" height="2" />
|
||||
<rect x="4" y="6" width="2" height="20" />
|
||||
<rect x="26" y="6" width="2" height="20" />
|
||||
<rect x="7" y="10" width="4" height="12" />
|
||||
</>
|
||||
),
|
||||
'pane-editor': (
|
||||
<>
|
||||
<rect x="4" y="6" width="24" height="2" />
|
||||
<rect x="4" y="24" width="24" height="2" />
|
||||
<rect x="4" y="6" width="2" height="20" />
|
||||
<rect x="26" y="6" width="2" height="20" />
|
||||
<rect x="14" y="10" width="4" height="12" />
|
||||
</>
|
||||
),
|
||||
'pane-preview': (
|
||||
<>
|
||||
<rect x="4" y="6" width="24" height="2" />
|
||||
<rect x="4" y="24" width="24" height="2" />
|
||||
<rect x="4" y="6" width="2" height="20" />
|
||||
<rect x="26" y="6" width="2" height="20" />
|
||||
<rect x="21" y="10" width="4" height="12" />
|
||||
</>
|
||||
),
|
||||
moon: (
|
||||
<path d="M13.5025,5.4136A15.0755,15.0755,0,0,0,25.096,23.6082a11.1134,11.1134,0,0,1-7.9749,3.3893c-.1385,0-.2782.0051-.4178,0A11.0944,11.0944,0,0,1,13.5025,5.4136M14.98,3a1.0024,1.0024,0,0,0-.1746.0156A13.0959,13.0959,0,0,0,16.63,28.9973c.1641.006.3282,0,.4909,0a13.0724,13.0724,0,0,0,10.702-5.5556,1.0094,1.0094,0,0,0-.7833-1.5644A13.08,13.08,0,0,1,15.8892,4.38,1.0149,1.0149,0,0,0,14.98,3Z" />
|
||||
),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* The pane toggle strip — a slim vertical rail at the left edge of the work area
|
||||
* (spec §01A). Always present (never hidden), so it's the way back when panes are
|
||||
* hidden. Visual look only; semantics live in PaneToggleStrip.tsx.
|
||||
*/
|
||||
.strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex: 0 0 auto;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
border-right: var(--border-width) solid var(--border);
|
||||
background: var(--layer-01);
|
||||
}
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: var(--border-width) solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
/* Not-pressed (hidden pane) reads as muted; pressed (shown) lifts to full text. */
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease),
|
||||
color var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--layer-02);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Shown pane: the toggle is "on" — full-strength glyph on a filled chip. */
|
||||
.pressed {
|
||||
color: var(--text);
|
||||
background: var(--layer-02);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 20px;
|
||||
height: var(--border-width);
|
||||
margin: var(--space-1) 0;
|
||||
background: var(--border);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { usePanesStore } from '../stores/PanesStore';
|
||||
import { PaneToggleStrip } from './PaneToggleStrip';
|
||||
|
||||
vi.mock('../modals/ModalCoordinator', () => ({ openModal: vi.fn() }));
|
||||
import { openModal } from '../modals/ModalCoordinator';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const toggleButtons = () =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>('button[aria-pressed]'));
|
||||
const datasetsButton = () =>
|
||||
container.querySelector<HTMLButtonElement>('button[aria-label="Datasets"]')!;
|
||||
|
||||
beforeEach(() => {
|
||||
usePanesStore.getState().hydrate({}, {});
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => root.render(<PaneToggleStrip />));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('PaneToggleStrip', () => {
|
||||
test('renders an APG toolbar: three pane toggles + a Datasets command button', () => {
|
||||
expect(container.querySelector('[role="toolbar"][aria-orientation="vertical"]')).not.toBeNull();
|
||||
expect(toggleButtons()).toHaveLength(3);
|
||||
// The Datasets control is a command button, not a toggle (no aria-pressed).
|
||||
expect(datasetsButton().hasAttribute('aria-pressed')).toBe(false);
|
||||
});
|
||||
|
||||
test('aria-pressed mirrors pane visibility and flips on click', () => {
|
||||
const [library] = toggleButtons();
|
||||
expect(library.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
act(() => library.click());
|
||||
|
||||
expect(usePanesStore.getState().libraryVisible).toBe(false);
|
||||
expect(library.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
test('the accessible name is stable across the pressed/not-pressed flip (APG)', () => {
|
||||
const [library] = toggleButtons();
|
||||
expect(library.getAttribute('aria-label')).toBe('Library pane');
|
||||
act(() => library.click());
|
||||
expect(library.getAttribute('aria-label')).toBe('Library pane');
|
||||
});
|
||||
|
||||
test('roving tabindex: exactly one control is in the tab order at a time', () => {
|
||||
const all = [...toggleButtons(), datasetsButton()];
|
||||
expect(all.filter((b) => b.tabIndex === 0)).toHaveLength(1);
|
||||
expect(all[0].tabIndex).toBe(0); // first control by default
|
||||
});
|
||||
|
||||
test('ArrowDown moves the roving tab stop to the next control', () => {
|
||||
const all = [...toggleButtons(), datasetsButton()];
|
||||
act(() => {
|
||||
all[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||
});
|
||||
expect(all[0].tabIndex).toBe(-1);
|
||||
expect(all[1].tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
test('End jumps to the last control (Datasets); Home returns to the first', () => {
|
||||
const all = [...toggleButtons(), datasetsButton()];
|
||||
act(() => {
|
||||
all[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
|
||||
});
|
||||
expect(datasetsButton().tabIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
datasetsButton().dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
|
||||
});
|
||||
expect(all[0].tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
test('ArrowUp from the first control wraps to the last', () => {
|
||||
const all = [...toggleButtons(), datasetsButton()];
|
||||
act(() => {
|
||||
all[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
||||
});
|
||||
expect(datasetsButton().tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
test('the Datasets button opens the Datasets manager', () => {
|
||||
act(() => datasetsButton().click());
|
||||
expect(openModal).toHaveBeenCalledWith('datasets');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user