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:
@@ -162,13 +162,41 @@ lives in [04 · Routing & Global Events](04-routing-and-events.md).
|
|||||||
is unit-tested, not trapped in the component); the live value needs the container width,
|
is unit-tested, not trapped in the component); the live value needs the container width,
|
||||||
observed via `ResizeObserver` so it tracks window resizes, not just drags.
|
observed via `ResizeObserver` so it tracks window resizes, not just drags.
|
||||||
- `aria-controls` points at the pane it sizes (`pane-library` / `pane-preview`).
|
- `aria-controls` points at the pane it sizes (`pane-library` / `pane-preview`).
|
||||||
- **Keyboard**: ←/→ nudge; **Home** → smallest pane size, **End** → largest. **Enter-to-collapse
|
- **Keyboard**: ←/→ nudge; **Home** → smallest pane size, **End** → largest. **Enter-to-collapse**
|
||||||
is deferred to M6** with the pane-visibility / toggle strip — collapsing needs a
|
now has a home — the hidden-pane state landed in M6 with the toggle strip (below). The strip
|
||||||
hidden-pane state that doesn't exist yet, so we don't fake it.
|
owns show/hide; wiring the splitter's Enter to it is an optional convenience, not required.
|
||||||
|
|
||||||
_(Consulted via `/council` → WAI-ARIA APG `windowsplitter`. This bullet is the contract;
|
_(Consulted via `/council` → WAI-ARIA APG `windowsplitter`. This bullet is the contract;
|
||||||
cite it, not the APG file.)_
|
cite it, not the APG file.)_
|
||||||
|
|
||||||
|
**Resolved — pane toggle strip.** The persistent show/hide strip (spec §01A) is a **WAI-ARIA
|
||||||
|
APG `toolbar`** (`role="toolbar"`, `aria-orientation="vertical"`, an `aria-label` such as
|
||||||
|
"Workspace panes") — **not** a row of independently-tabbable buttons. Grouping into a toolbar
|
||||||
|
gives the cluster a **single tab stop** with a **roving tabindex**, which APG names as the way
|
||||||
|
to reduce tab stops for a control group. Vertical keyboard model: **Up/Down** move among
|
||||||
|
controls, **Home/End** jump to first/last, **Tab/Shift+Tab** move into/out and restore the
|
||||||
|
last-focused control on re-entry.
|
||||||
|
|
||||||
|
- The three pane controls are **toggle buttons** — `aria-pressed` with a **stable** accessible
|
||||||
|
name that does **not** change with state (`aria-pressed="true"` ⇔ pane visible; the name stays
|
||||||
|
"Library pane" / "Editor pane" / "Preview pane"; only the icon may swap). This matches the
|
||||||
|
`ThemeToggle` precedent and APG's toggle-button rule — _"it is critical the label on a toggle
|
||||||
|
does not change when its state changes."_ These are **independent booleans**, so toggle
|
||||||
|
buttons — never a radio/segmented group; reserve `role="switch"` for genuine single-setting
|
||||||
|
on/off.
|
||||||
|
- The **Datasets** control is a plain **command button** (no `aria-pressed`) in the _same_
|
||||||
|
toolbar — APG permits mixed control types — set off from the toggles by a visual divider (and
|
||||||
|
optionally a nested `role="group"`), but kept in the roving sequence as its last element.
|
||||||
|
- **Focus**: show/hide is only ever initiated **from the strip**, so the activating toggle
|
||||||
|
already holds focus when its pane disappears and **retains it** (the button stays, flips to
|
||||||
|
not-pressed) — no orphaned focus, no restoration logic. The strip is **never itself hidden**,
|
||||||
|
so even with **all panes hidden** it stays the always-reachable "emergency exit" (NN/g #3 user
|
||||||
|
control). The pane appearing/disappearing plus the `aria-pressed` flip is the status feedback
|
||||||
|
(NN/g #1 visibility of system status).
|
||||||
|
|
||||||
|
_(Consulted via `/council` → WAI-ARIA APG `toolbar` + `button` (toggle); NN/g #1/#3. This
|
||||||
|
bullet is the contract; cite it, not the APG files.)_
|
||||||
|
|
||||||
**Resolved — segmented (single-select) controls.** A "pick one of N" control (fit modes,
|
**Resolved — segmented (single-select) controls.** A "pick one of N" control (fit modes,
|
||||||
the Draft/Published view) is a **radio group**, never a row of `aria-pressed` toggles
|
the Draft/Published view) is a **radio group**, never a row of `aria-pressed` toggles
|
||||||
(those model N independent booleans). Use the shared `SegmentedControl`: `role="radiogroup"`
|
(those model N independent booleans). Use the shared `SegmentedControl`: `role="radiogroup"`
|
||||||
|
|||||||
+48
-24
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
|
|||||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||||
import { LivePreview } from './components/LivePreview';
|
import { LivePreview } from './components/LivePreview';
|
||||||
import { ModalShell } from './components/ModalShell';
|
import { ModalShell } from './components/ModalShell';
|
||||||
|
import { PaneToggleStrip } from './components/PaneToggleStrip';
|
||||||
import { ResizeHandle } from './components/ResizeHandle';
|
import { ResizeHandle } from './components/ResizeHandle';
|
||||||
import { SnippetLibrary } from './components/SnippetLibrary';
|
import { SnippetLibrary } from './components/SnippetLibrary';
|
||||||
import { SpecEditor } from './components/SpecEditor';
|
import { SpecEditor } from './components/SpecEditor';
|
||||||
@@ -19,13 +20,23 @@ import styles from './App.module.css';
|
|||||||
* (library · editor · preview) under a fixed header.
|
* (library · editor · preview) under a fixed header.
|
||||||
*
|
*
|
||||||
* The center editor flexes; the library and preview carry remembered widths and
|
* The center editor flexes; the library and preview carry remembered widths and
|
||||||
* are resized via the drag handles between them (spec §01A). Pane show/hide
|
* are resized via the drag handles between them (spec §01A). Each pane can be
|
||||||
* toggling, modals, routing, and shortcuts arrive in later milestones (see
|
* shown/hidden from the always-present toggle strip (the leftmost rail); a hidden
|
||||||
* docs/IMPLEMENTATION-PLAN.md).
|
* pane frees its space and the rest redistribute — when the editor is hidden the
|
||||||
|
* two side panes flex proportionally to their remembered widths.
|
||||||
*/
|
*/
|
||||||
export function App() {
|
export function App() {
|
||||||
const libraryWidth = usePanesStore((s) => s.libraryWidth);
|
const libraryWidth = usePanesStore((s) => s.libraryWidth);
|
||||||
const previewWidth = usePanesStore((s) => s.previewWidth);
|
const previewWidth = usePanesStore((s) => s.previewWidth);
|
||||||
|
const libraryVisible = usePanesStore((s) => s.libraryVisible);
|
||||||
|
const editorVisible = usePanesStore((s) => s.editorVisible);
|
||||||
|
const previewVisible = usePanesStore((s) => s.previewVisible);
|
||||||
|
|
||||||
|
// Side-pane sizing: fixed remembered width while the editor (the flex filler) is
|
||||||
|
// present; when it's hidden, the side panes grow proportionally to those widths
|
||||||
|
// so they fill the freed space (spec §01A → "redistributing proportionally").
|
||||||
|
const sideStyle = (width: number): React.CSSProperties =>
|
||||||
|
editorVisible ? { width } : { flex: `${width} 1 0` };
|
||||||
// Hidden file input driving Import — the header button proxies its click so the
|
// Hidden file input driving Import — the header button proxies its click so the
|
||||||
// browser file picker is the only chrome (spec §08 → no intermediate dialog).
|
// browser file picker is the only chrome (spec §08 → no intermediate dialog).
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -112,27 +123,40 @@ export function App() {
|
|||||||
|
|
||||||
{/* tabIndex -1 makes the landmark a focus target for the skip link. */}
|
{/* tabIndex -1 makes the landmark a focus target for the skip link. */}
|
||||||
<main id="main" className={styles.panes} tabIndex={-1}>
|
<main id="main" className={styles.panes} tabIndex={-1}>
|
||||||
<section
|
{/* Always-present rail: shows/hides panes and shortcuts to Datasets (§01A). */}
|
||||||
id="pane-library"
|
<PaneToggleStrip />
|
||||||
className={styles.pane}
|
{libraryVisible && (
|
||||||
style={{ width: libraryWidth }}
|
<section
|
||||||
aria-label="Snippet library"
|
id="pane-library"
|
||||||
>
|
className={styles.pane}
|
||||||
<SnippetLibrary />
|
style={sideStyle(libraryWidth)}
|
||||||
</section>
|
aria-label="Snippet library"
|
||||||
<ResizeHandle side="library" label="Resize snippet library" />
|
>
|
||||||
<section className={styles.paneEditor} aria-label="Spec editor">
|
<SnippetLibrary />
|
||||||
<SpecEditor />
|
</section>
|
||||||
</section>
|
)}
|
||||||
<ResizeHandle side="preview" label="Resize live preview" />
|
{/* A resize handle only sits between two visible panes that flank the editor. */}
|
||||||
<section
|
{libraryVisible && editorVisible && (
|
||||||
id="pane-preview"
|
<ResizeHandle side="library" label="Resize snippet library" />
|
||||||
className={styles.pane}
|
)}
|
||||||
style={{ width: previewWidth }}
|
{editorVisible && (
|
||||||
aria-label="Live preview"
|
<section id="pane-editor" className={styles.paneEditor} aria-label="Spec editor">
|
||||||
>
|
<SpecEditor />
|
||||||
<LivePreview />
|
</section>
|
||||||
</section>
|
)}
|
||||||
|
{editorVisible && previewVisible && (
|
||||||
|
<ResizeHandle side="preview" label="Resize live preview" />
|
||||||
|
)}
|
||||||
|
{previewVisible && (
|
||||||
|
<section
|
||||||
|
id="pane-preview"
|
||||||
|
className={styles.pane}
|
||||||
|
style={sideStyle(previewWidth)}
|
||||||
|
aria-label="Live preview"
|
||||||
|
>
|
||||||
|
<LivePreview />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* The one feature modal (Datasets / Extract / …), rendered from the
|
{/* The one feature modal (Datasets / Extract / …), rendered from the
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export type IconName =
|
|||||||
| 'delete' // delete — Carbon TrashCan
|
| 'delete' // delete — Carbon TrashCan
|
||||||
| 'add' // add / create-new — Carbon Add
|
| 'add' // add / create-new — Carbon Add
|
||||||
| 'settings' // per-pane settings disclosure (gear) — Carbon Settings
|
| '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
|
// 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
|
// 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.
|
// 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" />
|
<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: (
|
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" />
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { loadPanelLayout, savePanelLayout } from './ux-prefs';
|
import {
|
||||||
|
loadPanelLayout,
|
||||||
|
loadPaneVisibility,
|
||||||
|
savePanelLayout,
|
||||||
|
savePaneVisibility,
|
||||||
|
} from './ux-prefs';
|
||||||
|
|
||||||
const KEY = 'astrolabe:ux-prefs';
|
const KEY = 'astrolabe:ux-prefs';
|
||||||
|
|
||||||
@@ -61,3 +66,35 @@ describe('ux-prefs · panelLayout', () => {
|
|||||||
expect(stored.panelLayout.libraryWidth).toBe(260);
|
expect(stored.panelLayout.libraryWidth).toBe(260);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ux-prefs · paneVisibility', () => {
|
||||||
|
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
|
||||||
|
|
||||||
|
it('round-trips persisted visibility; absent flags stay undefined', () => {
|
||||||
|
savePaneVisibility({ library: false, editor: true });
|
||||||
|
expect(loadPaneVisibility()).toEqual({ library: false, editor: true, preview: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces only booleans — junk in storage falls back to undefined', () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
KEY,
|
||||||
|
JSON.stringify({ paneVisibility: { library: 'nope', preview: false } }),
|
||||||
|
);
|
||||||
|
expect(loadPaneVisibility()).toEqual({
|
||||||
|
library: undefined,
|
||||||
|
editor: undefined,
|
||||||
|
preview: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves panelLayout when writing visibility (separate keys)', () => {
|
||||||
|
savePanelLayout({ libraryWidth: 300 });
|
||||||
|
savePaneVisibility({ editor: false });
|
||||||
|
const stored = JSON.parse(localStorage.getItem(KEY)!) as {
|
||||||
|
panelLayout: { libraryWidth: number };
|
||||||
|
paneVisibility: { editor: boolean };
|
||||||
|
};
|
||||||
|
expect(stored.panelLayout.libraryWidth).toBe(300);
|
||||||
|
expect(stored.paneVisibility.editor).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
* record. Its own key, `astrolabe:ux-prefs`, holds the snippet sort preference
|
* record. Its own key, `astrolabe:ux-prefs`, holds the snippet sort preference
|
||||||
* (lands with M5/M6) and the panel layout (per-pane widths + visibility).
|
* (lands with M5/M6) and the panel layout (per-pane widths + visibility).
|
||||||
*
|
*
|
||||||
* This slice persists the panel **widths** only; visibility joins it when the
|
* This slice persists the panel **widths** and per-pane **visibility** (spec §01A).
|
||||||
* toggle strip lands. Read-with-fallback + write-through merge, the same
|
* Read-with-fallback + write-through merge, the same contract as the settings
|
||||||
* contract as the settings adapter, so adding fields later upgrades cleanly.
|
* adapter, so adding fields later upgrades cleanly. Widths and visibility are kept
|
||||||
|
* as separate keys: a hidden pane retains its remembered width.
|
||||||
*
|
*
|
||||||
* Per the architecture rule this is one of the only modules that may touch
|
* Per the architecture rule this is one of the only modules that may touch
|
||||||
* `localStorage`; everything else goes through these typed functions.
|
* `localStorage`; everything else goes through these typed functions.
|
||||||
@@ -22,8 +23,16 @@ export interface PanelLayout {
|
|||||||
previewWidth?: number;
|
previewWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-pane visibility. Optional — a missing field falls back to "shown". */
|
||||||
|
export interface PaneVisibilityPref {
|
||||||
|
library?: boolean;
|
||||||
|
editor?: boolean;
|
||||||
|
preview?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface StoredPrefs {
|
interface StoredPrefs {
|
||||||
panelLayout?: PanelLayout;
|
panelLayout?: PanelLayout;
|
||||||
|
paneVisibility?: PaneVisibilityPref;
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,3 +85,24 @@ export function savePanelLayout(layout: PanelLayout): void {
|
|||||||
const current = readRaw();
|
const current = readRaw();
|
||||||
writeRaw({ ...current, panelLayout: { ...current.panelLayout, ...layout } });
|
writeRaw({ ...current, panelLayout: { ...current.panelLayout, ...layout } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A boolean, or undefined — guards against junk in storage. */
|
||||||
|
function bool(v: unknown): boolean | undefined {
|
||||||
|
return typeof v === 'boolean' ? v : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The persisted per-pane visibility, with only valid booleans surfaced. */
|
||||||
|
export function loadPaneVisibility(): PaneVisibilityPref {
|
||||||
|
const stored = readRaw().paneVisibility ?? {};
|
||||||
|
return {
|
||||||
|
library: bool(stored.library),
|
||||||
|
editor: bool(stored.editor),
|
||||||
|
preview: bool(stored.preview),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist per-pane visibility, preserving every other key already in the record. */
|
||||||
|
export function savePaneVisibility(visibility: PaneVisibilityPref): void {
|
||||||
|
const current = readRaw();
|
||||||
|
writeRaw({ ...current, paneVisibility: { ...current.paneVisibility, ...visibility } });
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,28 +8,47 @@
|
|||||||
* the write to the final resting widths (the same approach as snippet auto-save).
|
* the write to the final resting widths (the same approach as snippet auto-save).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { loadPanelLayout, savePanelLayout } from '../infrastructure/ux-prefs';
|
import {
|
||||||
|
loadPanelLayout,
|
||||||
|
loadPaneVisibility,
|
||||||
|
savePanelLayout,
|
||||||
|
savePaneVisibility,
|
||||||
|
} from '../infrastructure/ux-prefs';
|
||||||
import { usePanesStore } from '../stores/PanesStore';
|
import { usePanesStore } from '../stores/PanesStore';
|
||||||
|
|
||||||
/** Delay before a settled resize is persisted. */
|
/** Delay before a settled resize is persisted. */
|
||||||
export const PANES_PERSIST_DEBOUNCE_MS = 300;
|
export const PANES_PERSIST_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
/** Hydrate persisted pane widths into the store. Call before render. */
|
/** Hydrate persisted pane widths + visibility into the store. Call before render. */
|
||||||
export function initPanes(): void {
|
export function initPanes(): void {
|
||||||
usePanesStore.getState().hydrate(loadPanelLayout());
|
usePanesStore.getState().hydrate(loadPanelLayout(), loadPaneVisibility());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist width changes (debounced). Returns a teardown that detaches the subscriber. */
|
/**
|
||||||
|
* Persist layout changes. Returns a teardown that detaches the subscriber.
|
||||||
|
* Widths are **debounced** (a drag emits an update per pointer move); visibility
|
||||||
|
* is a discrete click, so it's written **immediately**.
|
||||||
|
*/
|
||||||
export function wirePanes(): () => void {
|
export function wirePanes(): () => void {
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
return usePanesStore.subscribe((state, prev) => {
|
return usePanesStore.subscribe((state, prev) => {
|
||||||
if (state.libraryWidth === prev.libraryWidth && state.previewWidth === prev.previewWidth) {
|
if (state.libraryWidth !== prev.libraryWidth || state.previewWidth !== prev.previewWidth) {
|
||||||
return;
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
const { libraryWidth, previewWidth } = usePanesStore.getState();
|
||||||
|
savePanelLayout({ libraryWidth, previewWidth });
|
||||||
|
}, PANES_PERSIST_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
state.libraryVisible !== prev.libraryVisible ||
|
||||||
|
state.editorVisible !== prev.editorVisible ||
|
||||||
|
state.previewVisible !== prev.previewVisible
|
||||||
|
) {
|
||||||
|
savePaneVisibility({
|
||||||
|
library: state.libraryVisible,
|
||||||
|
editor: state.editorVisible,
|
||||||
|
preview: state.previewVisible,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
const { libraryWidth, previewWidth } = usePanesStore.getState();
|
|
||||||
savePanelLayout({ libraryWidth, previewWidth });
|
|
||||||
}, PANES_PERSIST_DEBOUNCE_MS);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,3 +93,48 @@ describe('usePanesStore', () => {
|
|||||||
expect(store().previewWidth).toBe(PANE_DEFAULT.preview);
|
expect(store().previewWidth).toBe(PANE_DEFAULT.preview);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('pane visibility (§01A)', () => {
|
||||||
|
test('all panes are visible by default', () => {
|
||||||
|
store().hydrate({}, undefined);
|
||||||
|
expect(store().libraryVisible).toBe(true);
|
||||||
|
expect(store().editorVisible).toBe(true);
|
||||||
|
expect(store().previewVisible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('togglePane flips only the targeted pane', () => {
|
||||||
|
store().hydrate({}, {});
|
||||||
|
store().togglePane('editor');
|
||||||
|
expect(store().editorVisible).toBe(false);
|
||||||
|
expect(store().libraryVisible).toBe(true);
|
||||||
|
expect(store().previewVisible).toBe(true);
|
||||||
|
|
||||||
|
store().togglePane('editor');
|
||||||
|
expect(store().editorVisible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hiding all panes is permitted', () => {
|
||||||
|
store().hydrate({}, {});
|
||||||
|
store().togglePane('library');
|
||||||
|
store().togglePane('editor');
|
||||||
|
store().togglePane('preview');
|
||||||
|
expect(store().libraryVisible).toBe(false);
|
||||||
|
expect(store().editorVisible).toBe(false);
|
||||||
|
expect(store().previewVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hydrate restores explicit visibility; missing flags default to shown', () => {
|
||||||
|
store().hydrate({}, { library: false });
|
||||||
|
expect(store().libraryVisible).toBe(false);
|
||||||
|
expect(store().editorVisible).toBe(true);
|
||||||
|
expect(store().previewVisible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a hidden pane keeps its remembered width (re-shows at it)', () => {
|
||||||
|
store().hydrate({ libraryWidth: 250 }, {});
|
||||||
|
store().togglePane('library'); // hide
|
||||||
|
expect(store().libraryWidth).toBe(250);
|
||||||
|
store().togglePane('library'); // re-show
|
||||||
|
expect(store().libraryWidth).toBe(250);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import { create } from 'zustand';
|
|||||||
/** Which side pane a drag handle controls. */
|
/** Which side pane a drag handle controls. */
|
||||||
export type PaneSide = 'library' | 'preview';
|
export type PaneSide = 'library' | 'preview';
|
||||||
|
|
||||||
|
/** Every pane that can be shown/hidden via the toggle strip (spec §01A). */
|
||||||
|
export type PaneName = 'library' | 'editor' | 'preview';
|
||||||
|
|
||||||
/** Minimum widths (px) enforced while resizing, so no pane collapses (§01A). */
|
/** Minimum widths (px) enforced while resizing, so no pane collapses (§01A). */
|
||||||
export const PANE_MIN = { library: 180, preview: 240, editor: 320 } as const;
|
export const PANE_MIN = { library: 180, preview: 240, editor: 320 } as const;
|
||||||
|
|
||||||
@@ -79,25 +82,56 @@ export function sideWidthValue(
|
|||||||
return Math.round(Math.min(100, Math.max(0, pct)));
|
return Math.round(Math.min(100, Math.max(0, pct)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-pane visibility (spec §01A). Widths are kept *independently* of visibility,
|
||||||
|
* so a hidden pane keeps its remembered width and re-shows at it (not a default). */
|
||||||
|
export interface PaneVisibility {
|
||||||
|
library?: boolean;
|
||||||
|
editor?: boolean;
|
||||||
|
preview?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PanesState {
|
export interface PanesState {
|
||||||
libraryWidth: number;
|
libraryWidth: number;
|
||||||
previewWidth: number;
|
previewWidth: number;
|
||||||
|
/** Whether each pane is currently shown. All visible by default (§01A). */
|
||||||
|
libraryVisible: boolean;
|
||||||
|
editorVisible: boolean;
|
||||||
|
previewVisible: boolean;
|
||||||
/** Set a side pane's width (already clamped by the caller). */
|
/** Set a side pane's width (already clamped by the caller). */
|
||||||
setWidth: (side: PaneSide, width: number) => void;
|
setWidth: (side: PaneSide, width: number) => void;
|
||||||
/** Restore persisted widths on startup; missing values keep their defaults. */
|
/** Show/hide one pane. Hiding all panes is permitted (§01A) — the strip stays. */
|
||||||
hydrate: (layout: { libraryWidth?: number; previewWidth?: number }) => void;
|
togglePane: (pane: PaneName) => void;
|
||||||
|
/** Restore persisted widths + visibility on startup; missing values keep defaults. */
|
||||||
|
hydrate: (
|
||||||
|
layout: { libraryWidth?: number; previewWidth?: number },
|
||||||
|
visibility?: PaneVisibility,
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VISIBLE_KEY = {
|
||||||
|
library: 'libraryVisible',
|
||||||
|
editor: 'editorVisible',
|
||||||
|
preview: 'previewVisible',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const usePanesStore = create<PanesState>((set) => ({
|
export const usePanesStore = create<PanesState>((set) => ({
|
||||||
libraryWidth: PANE_DEFAULT.library,
|
libraryWidth: PANE_DEFAULT.library,
|
||||||
previewWidth: PANE_DEFAULT.preview,
|
previewWidth: PANE_DEFAULT.preview,
|
||||||
|
libraryVisible: true,
|
||||||
|
editorVisible: true,
|
||||||
|
previewVisible: true,
|
||||||
|
|
||||||
setWidth: (side, width) =>
|
setWidth: (side, width) =>
|
||||||
set(side === 'library' ? { libraryWidth: width } : { previewWidth: width }),
|
set(side === 'library' ? { libraryWidth: width } : { previewWidth: width }),
|
||||||
|
|
||||||
hydrate: (layout) =>
|
togglePane: (pane) => set((s) => ({ [VISIBLE_KEY[pane]]: !s[VISIBLE_KEY[pane]] })),
|
||||||
|
|
||||||
|
hydrate: (layout, visibility) =>
|
||||||
set({
|
set({
|
||||||
libraryWidth: layout.libraryWidth ?? PANE_DEFAULT.library,
|
libraryWidth: layout.libraryWidth ?? PANE_DEFAULT.library,
|
||||||
previewWidth: layout.previewWidth ?? PANE_DEFAULT.preview,
|
previewWidth: layout.previewWidth ?? PANE_DEFAULT.preview,
|
||||||
|
libraryVisible: visibility?.library ?? true,
|
||||||
|
editorVisible: visibility?.editor ?? true,
|
||||||
|
previewVisible: visibility?.preview ?? true,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user