mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
efb5a9bbe0
A collapsible Data panel under the Live Preview and Chart Builder charts shows the rows the chart actually uses, switching between Input (parsed source) and Resolved (post-transform) views read from the live Vega view. Collapsed by default; the open-state and a draggable height divider persist. Rows come through a new RenderHandle.inspectData() accessor, so no component touches the view: core/result-data picks the most-upstream source and most-downstream result from the compiled dataflow, read lazily. The divider reuses the window-splitter pattern (horizontal variant). Consolidations: a shared DataTable primitive replaces the inspector's and the builder's duplicate read-only tables; useResizeDrag merges the col/row drag-gesture twins. Docs: spec 04/06 and arch 05/10 updated; the now-shipped exploration memo removed.
127 lines
4.9 KiB
TypeScript
127 lines
4.9 KiB
TypeScript
/**
|
||
* Vertical drag handle between two panes (spec §01A).
|
||
*
|
||
* Sits between a side pane and the editor; dragging resizes the side pane while
|
||
* the editor absorbs the change, so the opposite side pane is unaffected. Width
|
||
* is clamped (pure `clampSideWidth`) so neither the dragged pane nor the editor
|
||
* falls below its minimum.
|
||
*
|
||
* Accessibility follows WAI-ARIA APG → Window Splitter (see
|
||
* docs/architecture/10 §5): a focusable `separator` that reports the controlled
|
||
* pane's size as `aria-valuenow` on a 0–100 scale (0 = min, 100 = max) and is
|
||
* driven by ←/→ to nudge plus Home/End to jump to the pane's min/max. (Enter to
|
||
* collapse waits for the M6 pane-visibility model — there's nothing to collapse
|
||
* to yet.)
|
||
*
|
||
* The handle reads the panes-row width from its own parent: lazily during a
|
||
* gesture (most accurate mid-drag), and via a ResizeObserver for the reactive
|
||
* `aria-valuenow` so the announced size tracks window/container resizes too.
|
||
*/
|
||
|
||
import { useLayoutEffect, useRef, useState } from 'react';
|
||
import { useResizeDrag } from '../hooks/useResizeDrag';
|
||
import { clampSideWidth, sideWidthValue, usePanesStore, type PaneSide } from '../stores/PanesStore';
|
||
import styles from './ResizeHandle.module.css';
|
||
|
||
/** Keyboard nudge step (px) per arrow press. */
|
||
const KEY_STEP = 16;
|
||
|
||
interface ResizeHandleProps {
|
||
/** Which side pane this handle resizes. */
|
||
side: PaneSide;
|
||
/** Accessible label, e.g. "Resize snippet library". */
|
||
label: string;
|
||
}
|
||
|
||
export function ResizeHandle({ side, label }: ResizeHandleProps) {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
|
||
// This side pane's width and the opposite side pane's, reactively — so the
|
||
// reported value recomputes as either changes.
|
||
const width = usePanesStore((s) => (side === 'library' ? s.libraryWidth : s.previewWidth));
|
||
const otherWidth = usePanesStore((s) => (side === 'library' ? s.previewWidth : s.libraryWidth));
|
||
|
||
// Observe the panes-row width so aria-valuenow stays correct across window and
|
||
// container resizes, not only pane drags.
|
||
const [containerWidth, setContainerWidth] = useState(0);
|
||
useLayoutEffect(() => {
|
||
const parent = ref.current?.parentElement;
|
||
if (!parent) return;
|
||
setContainerWidth(parent.clientWidth);
|
||
if (typeof ResizeObserver === 'undefined') return;
|
||
const ro = new ResizeObserver(() => setContainerWidth(parent.clientWidth));
|
||
ro.observe(parent);
|
||
return () => ro.disconnect();
|
||
}, []);
|
||
|
||
/** Live panes-row width for imperative drag/key math (most accurate in-gesture). */
|
||
const readContainerWidth = (): number => ref.current?.parentElement?.clientWidth ?? 0;
|
||
|
||
/** Apply a desired width for this side, clamped against the current layout. */
|
||
const applyWidth = (desired: number) => {
|
||
const { libraryWidth, previewWidth, setWidth } = usePanesStore.getState();
|
||
const other = side === 'library' ? previewWidth : libraryWidth;
|
||
setWidth(side, clampSideWidth(side, desired, readContainerWidth(), other));
|
||
};
|
||
|
||
// The left handle grows its pane as it moves right; the right handle (left
|
||
// of the preview) shrinks the preview as it moves right.
|
||
const onPointerDown = useResizeDrag(
|
||
'x',
|
||
() =>
|
||
side === 'library'
|
||
? usePanesStore.getState().libraryWidth
|
||
: usePanesStore.getState().previewWidth,
|
||
(startWidth, delta) => applyWidth(side === 'library' ? startWidth + delta : startWidth - delta),
|
||
);
|
||
|
||
// Keyboard model per WAI-ARIA APG → Window Splitter: arrows nudge; Home/End jump
|
||
// to the pane's smallest/largest allowed size (clampSideWidth caps the extremes).
|
||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||
const current =
|
||
side === 'library'
|
||
? usePanesStore.getState().libraryWidth
|
||
: usePanesStore.getState().previewWidth;
|
||
switch (e.key) {
|
||
case 'ArrowLeft':
|
||
case 'ArrowRight': {
|
||
const dir = e.key === 'ArrowRight' ? 1 : -1;
|
||
applyWidth(current + (side === 'library' ? dir * KEY_STEP : -dir * KEY_STEP));
|
||
break;
|
||
}
|
||
case 'Home': // smallest primary-pane size
|
||
applyWidth(0);
|
||
break;
|
||
case 'End': // largest primary-pane size
|
||
applyWidth(Number.MAX_SAFE_INTEGER);
|
||
break;
|
||
default:
|
||
return; // not ours — let it bubble
|
||
}
|
||
e.preventDefault();
|
||
};
|
||
|
||
const valueNow = sideWidthValue(side, width, containerWidth, otherWidth);
|
||
|
||
return (
|
||
<div
|
||
ref={ref}
|
||
className={styles.handle}
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
aria-label={label}
|
||
// The splitter controls — and reports the size of — its side pane (APG).
|
||
aria-controls={`pane-${side}`}
|
||
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>
|
||
);
|
||
}
|