/** * DockRegion — a resizable, **in-flow** vertical column docked to the left or * right of the main area (ticket #22, gate G5). * * Unlike {@link FloatingWindow} (a modal overlay), a DockRegion is *not* an * overlay: it lives in the normal flex flow beside `
`, so docked views and * the terminal grid are visible at once (VS Code / JetBrains side-panel feel). * It therefore needs no z-index token — it never stacks over the main surface. * * Purely presentational: it owns only the drag mechanics of its width. The * caller controls `width` and commits new values via `onResize`, and supplies * the docked view(s) as children (each typically its own header + body). The * resize handle sits on the column's *inner* edge — the right edge for a * left-docked column, the left edge for a right-docked one — so dragging toward * the main area shrinks the column and away from it grows the column. * * Mounted at the chrome level (ProjectsView), never inside LayoutGrid/LeafView. */ import { useRef, type ReactNode } from "react"; import { cn } from "../lib/cn"; /** Which side of `
` the column is docked to. */ export type DockSide = "left" | "right"; /** Default clamp for the column width, in CSS pixels. */ const DEFAULT_MIN_WIDTH = 240; const DEFAULT_MAX_WIDTH = 720; export interface DockRegionProps { /** Side of the main area this column is docked to. */ side: DockSide; /** Current column width in pixels (controlled by the caller). */ width: number; /** Called live during the drag and on release with the clamped next width. */ onResize: (nextWidth: number) => void; /** Minimum width in pixels (default 240). */ minWidth?: number; /** Maximum width in pixels (default 720). */ maxWidth?: number; /** Accessible label for the column region. */ "aria-label"?: string; /** The docked view(s), stacked vertically. */ children: ReactNode; } /** * A resizable docked column. The width is driven from a pointer drag on the * inner-edge handle, reported back through `onResize`; the caller holds the * value in state (ephemeral in V1 — persistence is a deferred follow-up). */ export function DockRegion({ side, width, onResize, minWidth = DEFAULT_MIN_WIDTH, maxWidth = DEFAULT_MAX_WIDTH, "aria-label": ariaLabel, children, }: DockRegionProps) { // Anchor the drag on pointer-down: remember where the pointer and the column // width started, then map the signed pointer delta to a width delta. A // left-docked column grows as the pointer moves right; a right-docked one // grows as it moves left (the handle is on the opposite, inner edge). const dragRef = useRef<{ startX: number; startWidth: number } | null>(null); function clamp(next: number): number { return Math.min(maxWidth, Math.max(minWidth, next)); } function onPointerDown(e: React.PointerEvent) { e.preventDefault(); // `setPointerCapture` is absent under jsdom; guard so tests can drive drags. e.currentTarget.setPointerCapture?.(e.pointerId); dragRef.current = { startX: e.clientX, startWidth: width }; } function onPointerMove(e: React.PointerEvent) { const drag = dragRef.current; if (!drag) return; const delta = e.clientX - drag.startX; const signed = side === "left" ? delta : -delta; onResize(clamp(drag.startWidth + signed)); } function onPointerUp(e: React.PointerEvent) { if (!dragRef.current) return; const delta = e.clientX - dragRef.current.startX; const signed = side === "left" ? delta : -delta; dragRef.current = null; onResize(clamp(width + signed)); } const handle = (
); return ( ); }