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:
@ -40,3 +40,6 @@ export type { FloatingWindowProps, FloatingWindowSize } from "./ui/FloatingWindo
|
||||
|
||||
export { MenuBar } from "./ui/MenuBar";
|
||||
export type { MenuBarProps, MenuBarMenu, MenuBarItem } from "./ui/MenuBar";
|
||||
|
||||
export { DockRegion } from "./ui/DockRegion";
|
||||
export type { DockRegionProps, DockSide } from "./ui/DockRegion";
|
||||
|
||||
59
frontend/src/shared/ui/DockRegion.test.tsx
Normal file
59
frontend/src/shared/ui/DockRegion.test.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* DockRegion (#22) — the in-flow resizable side column. Covers: it renders its
|
||||
* children beside a resize handle, the handle carries the right a11y role, and a
|
||||
* pointer drag maps to a clamped width delta whose sign depends on the side
|
||||
* (left grows moving right, right grows moving left).
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
import { DockRegion } from "./DockRegion";
|
||||
|
||||
describe("DockRegion", () => {
|
||||
it("renders its children and an inner-edge resize separator", () => {
|
||||
render(
|
||||
<DockRegion side="left" width={300} onResize={() => {}}>
|
||||
<p>docked body</p>
|
||||
</DockRegion>,
|
||||
);
|
||||
expect(screen.getByText("docked body")).toBeTruthy();
|
||||
const handle = screen.getByRole("separator", { name: "resize left dock" });
|
||||
expect(handle.getAttribute("aria-orientation")).toBe("vertical");
|
||||
});
|
||||
|
||||
it("a left dock grows when the pointer drags right", () => {
|
||||
const onResize = vi.fn();
|
||||
render(
|
||||
<DockRegion side="left" width={300} onResize={onResize}>
|
||||
<p>body</p>
|
||||
</DockRegion>,
|
||||
);
|
||||
const handle = screen.getByRole("separator", { name: "resize left dock" });
|
||||
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 100 });
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 160 });
|
||||
expect(onResize).toHaveBeenLastCalledWith(360);
|
||||
});
|
||||
|
||||
it("a right dock grows when the pointer drags left, and width is clamped", () => {
|
||||
const onResize = vi.fn();
|
||||
render(
|
||||
<DockRegion
|
||||
side="right"
|
||||
width={300}
|
||||
onResize={onResize}
|
||||
minWidth={240}
|
||||
maxWidth={500}
|
||||
>
|
||||
<p>body</p>
|
||||
</DockRegion>,
|
||||
);
|
||||
const handle = screen.getByRole("separator", { name: "resize right dock" });
|
||||
// Dragging left (negative delta) grows a right dock.
|
||||
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 400 });
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 320 });
|
||||
expect(onResize).toHaveBeenLastCalledWith(380);
|
||||
// A large leftward drag is clamped to maxWidth.
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 0 });
|
||||
expect(onResize).toHaveBeenLastCalledWith(500);
|
||||
});
|
||||
});
|
||||
125
frontend/src/shared/ui/DockRegion.tsx
Normal file
125
frontend/src/shared/ui/DockRegion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user