Introduit la primitive de docking DockRegion et le modèle ViewPlacement pour ancrer les vues dans le chrome, câblés dans ProjectsView. Tests unitaires DockRegion et test d'intégration docking de ProjectsView. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
126 lines
4.5 KiB
TypeScript
126 lines
4.5 KiB
TypeScript
/**
|
|
* 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 `<main>`, 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 `<main>` 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<HTMLDivElement>) {
|
|
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<HTMLDivElement>) {
|
|
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<HTMLDivElement>) {
|
|
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 = (
|
|
<div
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label={`resize ${side} dock`}
|
|
onPointerDown={onPointerDown}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={onPointerUp}
|
|
className="shrink-0 cursor-col-resize bg-border hover:bg-border-strong"
|
|
style={{ flex: "0 0 4px", touchAction: "none" }}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<aside
|
|
aria-label={ariaLabel ?? `${side} dock`}
|
|
className={cn(
|
|
"flex h-full shrink-0 bg-surface",
|
|
// Children come first in the DOM; the flex direction places the handle
|
|
// on the inner edge: right of a left dock, left of a right dock.
|
|
side === "left"
|
|
? "flex-row border-r border-border"
|
|
: "flex-row-reverse border-l border-border",
|
|
)}
|
|
style={{ width }}
|
|
>
|
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
{children}
|
|
</div>
|
|
{handle}
|
|
</aside>
|
|
);
|
|
}
|