feat(ui): anchor de views — primitive de docking DockRegion + modèle ViewPlacement (#22)

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>
This commit is contained in:
2026-07-07 11:45:46 +02:00
parent 2f8467e2dd
commit 419e9b8498
6 changed files with 592 additions and 50 deletions

View File

@ -0,0 +1,125 @@
/**
* 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>
);
}