mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Re-split library and preview when the editor is hidden; remember the editor's width
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Vertical drag handle between the library and preview when the editor is hidden
|
||||
* (spec §01A). With no editor between them to absorb the change, the two side
|
||||
* panes share the span: dragging re-splits it — one grows, the other shrinks
|
||||
* (zero-sum) — each kept above its minimum by the pure `splitLibraryWidth`.
|
||||
*
|
||||
* The split is read from the live DOM: the two flanking panes (this handle's
|
||||
* previous/next siblings) render proportionally (`flex: width 1 0`), so writing
|
||||
* their measured widths back makes the boundary track the pointer 1:1, at any
|
||||
* window size. Re-showing the editor later keeps whatever ratio was left here
|
||||
* (PanesStore → `shownSideWidths`).
|
||||
*
|
||||
* Accessibility mirrors the editor-flanking ResizeHandle (WAI-ARIA APG → Window
|
||||
* Splitter): a focusable `separator` reporting the library's 0–100 position via
|
||||
* `aria-valuenow`, driven by ←/→ to nudge and Home/End to jump to min/max. It
|
||||
* reuses ResizeHandle's stylesheet so the two handles look identical.
|
||||
*/
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { splitLibraryWidth, splitValue, usePanesStore } from '../stores/PanesStore';
|
||||
import styles from './ResizeHandle.module.css';
|
||||
|
||||
/** Keyboard nudge step (px) per arrow press — matches ResizeHandle. */
|
||||
const KEY_STEP = 16;
|
||||
|
||||
interface PaneSplitHandleProps {
|
||||
/** Accessible label, e.g. "Resize library and preview". */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function PaneSplitHandle({ label }: PaneSplitHandleProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The reported position recomputes from the stored split as it changes. It reads
|
||||
// the *stored* ratio (container-independent, per the file header), so unlike
|
||||
// ResizeHandle aria-valuenow can drift from the rendered position after a window
|
||||
// enlargement (worst near the extremes) — an accepted simplicity trade-off, the
|
||||
// contract in arch/10 (splitter). Drag/keyboard read live clientWidth below, so
|
||||
// resizing itself stays accurate; only the announced value drifts.
|
||||
const libraryWidth = usePanesStore((s) => s.libraryWidth);
|
||||
const previewWidth = usePanesStore((s) => s.previewWidth);
|
||||
const valueNow = splitValue(libraryWidth, previewWidth);
|
||||
|
||||
/** The panes this handle sits between: library before it, preview after it. */
|
||||
const flanks = () => ({
|
||||
lib: ref.current?.previousElementSibling as HTMLElement | null,
|
||||
prev: ref.current?.nextElementSibling as HTMLElement | null,
|
||||
});
|
||||
|
||||
/** Apply a desired (rendered) library width, clamped against the live span. */
|
||||
const applySplit = (desiredLibrary: number) => {
|
||||
const { lib, prev } = flanks();
|
||||
if (!lib || !prev) return;
|
||||
const avail = lib.clientWidth + prev.clientWidth;
|
||||
const library = splitLibraryWidth(desiredLibrary, avail);
|
||||
usePanesStore.getState().setSplit(library, avail - library);
|
||||
};
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return; // primary button only
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startLib = flanks().lib?.clientWidth ?? 0;
|
||||
|
||||
const onMove = (ev: PointerEvent) => applySplit(startLib + (ev.clientX - startX));
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
// Keyboard per WAI-ARIA APG → Window Splitter: arrows nudge the library side;
|
||||
// Home/End jump to the library's smallest/largest allowed share of the span.
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const lib = flanks().lib?.clientWidth ?? 0;
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
applySplit(lib - KEY_STEP);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
applySplit(lib + KEY_STEP);
|
||||
break;
|
||||
case 'Home': // library at its minimum
|
||||
applySplit(0);
|
||||
break;
|
||||
case 'End': // library at its maximum (preview at its minimum)
|
||||
applySplit(Number.MAX_SAFE_INTEGER);
|
||||
break;
|
||||
default:
|
||||
return; // not ours — let it bubble
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.handle}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={label}
|
||||
// The split governs both panes it sits between.
|
||||
aria-controls="pane-library pane-preview"
|
||||
aria-valuenow={valueNow ?? undefined}
|
||||
aria-valuemin={valueNow === null ? undefined : 0}
|
||||
aria-valuemax={valueNow === null ? undefined : 100}
|
||||
aria-valuetext={valueNow === null ? undefined : `${valueNow}%`}
|
||||
tabIndex={0}
|
||||
onPointerDown={onPointerDown}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<span className={styles.grip} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,16 @@ export function PaneToggleStrip() {
|
||||
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;
|
||||
@@ -88,6 +98,7 @@ export function PaneToggleStrip() {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={stripRef}
|
||||
role="toolbar"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Workspace panes"
|
||||
@@ -117,7 +128,7 @@ export function PaneToggleStrip() {
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => {
|
||||
togglePane(item.pane);
|
||||
togglePane(item.pane, panesInner());
|
||||
setFocusIndex(i);
|
||||
}}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* 6px-wide hit target with a thin visible grip. Width is part of the layout
|
||||
* budget (HANDLES_TOTAL in PanesStore); keep them in sync if this changes.
|
||||
* budget (HANDLE_WIDTH in PanesStore); keep them in sync if this changes.
|
||||
*/
|
||||
.handle {
|
||||
flex: 0 0 6px;
|
||||
|
||||
Reference in New Issue
Block a user