mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add resizable panes with drag handles, min widths, and persistence
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
.handle {
|
||||
flex: 0 0 6px;
|
||||
position: relative;
|
||||
cursor: col-resize;
|
||||
background: var(--border);
|
||||
touch-action: none; /* let pointer drags own the gesture, not scroll */
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.handle:hover,
|
||||
.handle:focus-visible {
|
||||
background: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* A short centered grip line, so the handle reads as a draggable divider. */
|
||||
.grip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
height: 24px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.handle:hover .grip,
|
||||
.handle:focus-visible .grip {
|
||||
background: var(--accent-contrast);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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. Keyboard accessible: focus and use ←/→ to nudge.
|
||||
*
|
||||
* The handle reads the panes-row width from its own parent at interaction time,
|
||||
* so it needs no layout props — it just controls the `side` it is told to.
|
||||
*/
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { clampSideWidth, 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);
|
||||
|
||||
/** Full panes-row width — the handle's parent (`.panes`). */
|
||||
const containerWidth = (): 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, containerWidth(), other));
|
||||
};
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return; // primary button only
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth =
|
||||
side === 'library'
|
||||
? usePanesStore.getState().libraryWidth
|
||||
: usePanesStore.getState().previewWidth;
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
// 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 desired = side === 'library' ? startWidth + delta : startWidth - delta;
|
||||
applyWidth(desired);
|
||||
};
|
||||
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);
|
||||
// While dragging, force the resize cursor and suppress text selection.
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
e.preventDefault();
|
||||
const dir = e.key === 'ArrowRight' ? 1 : -1;
|
||||
const current =
|
||||
side === 'library'
|
||||
? usePanesStore.getState().libraryWidth
|
||||
: usePanesStore.getState().previewWidth;
|
||||
const delta = side === 'library' ? dir * KEY_STEP : -dir * KEY_STEP;
|
||||
applyWidth(current + delta);
|
||||
};
|
||||
|
||||
return (
|
||||
// TODO: a focusable window-splitter should also expose aria-valuenow/min/max
|
||||
// (the side pane's current/min/max width) so assistive tech can announce the
|
||||
// size as it changes. Wire it when the pane toggle strip lands in M6.
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.handle}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={label}
|
||||
tabIndex={0}
|
||||
onPointerDown={onPointerDown}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<span className={styles.grip} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user