feat(frontend): add resizable activity split
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
@@ -6,6 +6,7 @@ import { server } from "../test/msw";
|
|||||||
import { FakeEventSource } from "../test/fakeEventSource";
|
import { FakeEventSource } from "../test/fakeEventSource";
|
||||||
import { AppShell } from "./AppShell";
|
import { AppShell } from "./AppShell";
|
||||||
import { useSessionStore } from "../store/sessionStore";
|
import { useSessionStore } from "../store/sessionStore";
|
||||||
|
import { ACTIVITY_PANEL_STORAGE_KEY } from "./useActivityPanelResize";
|
||||||
|
|
||||||
function wrap() {
|
function wrap() {
|
||||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
@@ -27,6 +28,7 @@ function deferred() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
FakeEventSource.instances = [];
|
FakeEventSource.instances = [];
|
||||||
(globalThis as any).EventSource = FakeEventSource;
|
(globalThis as any).EventSource = FakeEventSource;
|
||||||
(globalThis as any).PointerEvent = MouseEvent;
|
(globalThis as any).PointerEvent = MouseEvent;
|
||||||
@@ -779,21 +781,58 @@ test("renaming a group reassigns its members via setSessionGroup", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test("opening Model activity replaces the session rail with a 40/60 activity and chat layout", async () => {
|
test("opens an accessible resizable activity split and persists pointer width", async () => {
|
||||||
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")));
|
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")));
|
||||||
wrap();
|
wrap();
|
||||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
|
|
||||||
expect(screen.getByText("Sessions")).toBeInTheDocument();
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /show model activity/i }));
|
await userEvent.click(screen.getByRole("button", { name: /show model activity/i }));
|
||||||
|
|
||||||
const shell = screen.getByTestId("app-shell");
|
const shell = screen.getByTestId("app-shell");
|
||||||
expect(shell).toHaveAttribute("data-activity-layout", "open");
|
const separator = screen.getByRole("separator", { name: /resize model activity/i });
|
||||||
expect(screen.getByRole("heading", { name: "Model activity" }).closest("aside")).toHaveClass("w-2/5");
|
expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("384px");
|
||||||
expect(screen.queryByText("Sessions")).not.toBeInTheDocument();
|
expect(separator).toHaveAttribute("aria-orientation", "vertical");
|
||||||
|
expect(separator).toHaveAttribute("aria-valuemin", "288");
|
||||||
|
expect(separator).toHaveAttribute("aria-valuenow", "384");
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /hide model activity/i }));
|
Object.assign(separator, {
|
||||||
expect(shell).toHaveAttribute("data-activity-layout", "closed");
|
setPointerCapture: vi.fn(),
|
||||||
expect(screen.getByText("Sessions")).toBeInTheDocument();
|
releasePointerCapture: vi.fn(),
|
||||||
|
hasPointerCapture: vi.fn(() => true),
|
||||||
|
});
|
||||||
|
fireEvent.pointerDown(separator, { pointerId: 1, clientX: 384 });
|
||||||
|
fireEvent.pointerMove(separator, { pointerId: 1, clientX: 484 });
|
||||||
|
fireEvent.pointerUp(separator, { pointerId: 1, clientX: 484 });
|
||||||
|
|
||||||
|
expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("484px");
|
||||||
|
expect(localStorage.getItem(ACTIVITY_PANEL_STORAGE_KEY)).toBe("484");
|
||||||
|
expect(screen.getByTestId("conversation-column")).toHaveClass("flex-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resizes the activity split with keyboard and exposes responsive drawer classes", async () => {
|
||||||
|
localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "448");
|
||||||
|
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")));
|
||||||
|
wrap();
|
||||||
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /show model activity/i }));
|
||||||
|
|
||||||
|
const shell = screen.getByTestId("app-shell");
|
||||||
|
const separator = screen.getByRole("separator", { name: /resize model activity/i });
|
||||||
|
expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("448px");
|
||||||
|
fireEvent.keyDown(separator, { key: "ArrowLeft" });
|
||||||
|
expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("432px");
|
||||||
|
fireEvent.keyDown(separator, { key: "ArrowRight", shiftKey: true });
|
||||||
|
expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("480px");
|
||||||
|
fireEvent.keyDown(separator, { key: "Home" });
|
||||||
|
expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("288px");
|
||||||
|
|
||||||
|
const panel = screen.getByRole("heading", { name: "Model activity" }).closest("aside");
|
||||||
|
expect(panel).toHaveClass(
|
||||||
|
"fixed",
|
||||||
|
"w-[min(90vw,24rem)]",
|
||||||
|
"lg:static",
|
||||||
|
"lg:w-[var(--activity-panel-width)]",
|
||||||
|
);
|
||||||
|
expect(separator).toHaveClass("touch-none", "cursor-col-resize");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useSessionStore } from "../store/sessionStore";
|
|||||||
import { WidgetHost } from "./WidgetHost";
|
import { WidgetHost } from "./WidgetHost";
|
||||||
import { CentralStatus } from "./CentralStatus";
|
import { CentralStatus } from "./CentralStatus";
|
||||||
import { ModelActivityPanel } from "./ModelActivityPanel";
|
import { ModelActivityPanel } from "./ModelActivityPanel";
|
||||||
|
import { useActivityPanelResize } from "./useActivityPanelResize";
|
||||||
import { NavSessions } from "./NavSessions";
|
import { NavSessions } from "./NavSessions";
|
||||||
import { SessionMenu } from "./SessionMenu";
|
import { SessionMenu } from "./SessionMenu";
|
||||||
import { SessionDocumentsPanel } from "./SessionDocumentsPanel";
|
import { SessionDocumentsPanel } from "./SessionDocumentsPanel";
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
import type { SessionSummary } from "../api/types";
|
import type { SessionSummary } from "../api/types";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The page is designed to live INSIDE the Omics Portal chrome (its left sidebar
|
* The page is designed to live INSIDE the Omics Portal chrome (its left sidebar
|
||||||
@@ -31,6 +33,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
* portal's left sidebar, and the workflow phases ride a slim strip above the chat.
|
* portal's left sidebar, and the workflow phases ride a slim strip above the chat.
|
||||||
*/
|
*/
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
|
const { containerRef, panelWidth, resizing, separatorProps } = useActivityPanelResize();
|
||||||
|
const activityWidthStyle = {
|
||||||
|
"--activity-panel-width": `${panelWidth}px`,
|
||||||
|
} as CSSProperties;
|
||||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||||
const activeSessionIdRef = useRef<string | null>(null);
|
const activeSessionIdRef = useRef<string | null>(null);
|
||||||
const resumeInvocationRef = useRef(0);
|
const resumeInvocationRef = useRef(0);
|
||||||
@@ -364,9 +370,15 @@ export function AppShell() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={activityWidthStyle}
|
||||||
data-testid="app-shell"
|
data-testid="app-shell"
|
||||||
data-activity-layout={showActivity ? "open" : "closed"}
|
data-activity-layout={showActivity ? "open" : "closed"}
|
||||||
className="flex h-screen bg-background text-foreground"
|
data-activity-resizing={resizing}
|
||||||
|
className={[
|
||||||
|
"flex h-screen bg-background text-foreground",
|
||||||
|
resizing ? "select-none cursor-col-resize" : "",
|
||||||
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{panelSession && (
|
{panelSession && (
|
||||||
<SessionDocumentsPanel
|
<SessionDocumentsPanel
|
||||||
@@ -376,9 +388,21 @@ export function AppShell() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showActivity && <ModelActivityPanel onClose={() => setShowActivity(false)} />}
|
{showActivity && <ModelActivityPanel onClose={() => setShowActivity(false)} />}
|
||||||
|
{showActivity && (
|
||||||
|
<div className="relative hidden w-0 shrink-0 lg:block">
|
||||||
|
<div
|
||||||
|
{...separatorProps}
|
||||||
|
data-testid="activity-resize-handle"
|
||||||
|
data-dragging={resizing}
|
||||||
|
className="group absolute inset-y-0 left-1/2 z-20 w-3 -translate-x-1/2 touch-none cursor-col-resize outline-none"
|
||||||
|
>
|
||||||
|
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border transition-colors group-hover:bg-primary/60 group-focus-visible:bg-primary group-data-[dragging=true]:bg-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Conversation column */}
|
{/* Conversation column */}
|
||||||
<div className={["flex min-w-0 flex-col", showActivity ? "w-3/5 shrink-0" : "flex-1"].join(" ")}>
|
<div data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
|
||||||
{activeSessionId && (
|
{activeSessionId && (
|
||||||
<div className="relative shrink-0 border-b border-border/70 bg-background/80 px-6 py-2.5 backdrop-blur">
|
<div className="relative shrink-0 border-b border-border/70 bg-background/80 px-6 py-2.5 backdrop-blur">
|
||||||
{/* Arrow toggle for the left Model-activity panel: → opens it, ← closes it. */}
|
{/* Arrow toggle for the left Model-activity panel: → opens it, ← closes it. */}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function ModelActivityPanel({ onClose }: { onClose: () => void }) {
|
|||||||
}, [visibleCount, visibleTail]);
|
}, [visibleCount, visibleTail]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex w-2/5 shrink-0 flex-col border-r border-border bg-sidebar">
|
<aside className="fixed inset-y-0 left-0 z-30 flex w-[min(90vw,24rem)] shrink-0 flex-col border-r border-border bg-sidebar lg:static lg:z-auto lg:w-[var(--activity-panel-width)] lg:border-r-0">
|
||||||
<div className="flex items-center justify-between border-b border-border/60 px-3 py-2">
|
<div className="flex items-center justify-between border-b border-border/60 px-3 py-2">
|
||||||
<h2 className="font-heading text-sm font-semibold text-foreground">Model activity</h2>
|
<h2 className="font-heading text-sm font-semibold text-foreground">Model activity</h2>
|
||||||
<Button size="icon-sm" variant="ghost" aria-label="Close model activity" onClick={onClose}>
|
<Button size="icon-sm" variant="ghost" aria-label="Close model activity" onClick={onClose}>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
ACTIVITY_PANEL_DEFAULT_WIDTH,
|
||||||
|
ACTIVITY_PANEL_STORAGE_KEY,
|
||||||
|
clampActivityPanelWidth,
|
||||||
|
getActivityPanelBounds,
|
||||||
|
loadActivityPanelWidth,
|
||||||
|
resizeWidthFromKey,
|
||||||
|
saveActivityPanelWidth,
|
||||||
|
} from "./useActivityPanelResize";
|
||||||
|
|
||||||
|
describe("activity panel resize contract", () => {
|
||||||
|
test("computes desktop bounds while preserving 512px centrally", () => {
|
||||||
|
expect(getActivityPanelBounds(1200)).toEqual({ min: 288, max: 576 });
|
||||||
|
expect(getActivityPanelBounds(1024)).toEqual({ min: 288, max: 512 });
|
||||||
|
expect(getActivityPanelBounds(800)).toEqual({ min: 288, max: 288 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clamps finite values and falls back for invalid values", () => {
|
||||||
|
const bounds = { min: 288, max: 576 };
|
||||||
|
expect(clampActivityPanelWidth(200, bounds)).toBe(288);
|
||||||
|
expect(clampActivityPanelWidth(420, bounds)).toBe(420);
|
||||||
|
expect(clampActivityPanelWidth(900, bounds)).toBe(576);
|
||||||
|
expect(clampActivityPanelWidth(Number.NaN, bounds)).toBe(ACTIVITY_PANEL_DEFAULT_WIDTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps keyboard controls to exact clamped increments", () => {
|
||||||
|
const bounds = { min: 288, max: 576 };
|
||||||
|
expect(resizeWidthFromKey(384, "ArrowLeft", false, bounds)).toBe(368);
|
||||||
|
expect(resizeWidthFromKey(384, "ArrowRight", true, bounds)).toBe(432);
|
||||||
|
expect(resizeWidthFromKey(384, "Home", false, bounds)).toBe(288);
|
||||||
|
expect(resizeWidthFromKey(384, "End", false, bounds)).toBe(576);
|
||||||
|
expect(resizeWidthFromKey(384, "Enter", false, bounds)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads, persists, and rejects corrupt browser values", () => {
|
||||||
|
localStorage.clear();
|
||||||
|
expect(loadActivityPanelWidth(localStorage)).toBe(ACTIVITY_PANEL_DEFAULT_WIDTH);
|
||||||
|
localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "corrupt");
|
||||||
|
expect(loadActivityPanelWidth(localStorage)).toBe(ACTIVITY_PANEL_DEFAULT_WIDTH);
|
||||||
|
saveActivityPanelWidth(448, localStorage);
|
||||||
|
expect(loadActivityPanelWidth(localStorage)).toBe(448);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import type { KeyboardEventHandler, PointerEventHandler, RefObject } from "react";
|
||||||
|
|
||||||
|
export const ACTIVITY_PANEL_DEFAULT_WIDTH = 384;
|
||||||
|
export const ACTIVITY_PANEL_MIN_WIDTH = 288;
|
||||||
|
export const ACTIVITY_PANEL_MAX_WIDTH = 576;
|
||||||
|
export const ACTIVITY_CENTRAL_MIN_WIDTH = 512;
|
||||||
|
export const ACTIVITY_PANEL_STORAGE_KEY = "thothii:model-activity-panel-width";
|
||||||
|
|
||||||
|
export type ActivityPanelBounds = { min: number; max: number };
|
||||||
|
|
||||||
|
export function getActivityPanelBounds(containerWidth: number): ActivityPanelBounds {
|
||||||
|
return {
|
||||||
|
min: ACTIVITY_PANEL_MIN_WIDTH,
|
||||||
|
max: Math.max(
|
||||||
|
ACTIVITY_PANEL_MIN_WIDTH,
|
||||||
|
Math.min(ACTIVITY_PANEL_MAX_WIDTH, containerWidth - ACTIVITY_CENTRAL_MIN_WIDTH),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampActivityPanelWidth(width: number, bounds: ActivityPanelBounds): number {
|
||||||
|
const candidate = Number.isFinite(width) ? width : ACTIVITY_PANEL_DEFAULT_WIDTH;
|
||||||
|
return Math.min(bounds.max, Math.max(bounds.min, candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadActivityPanelWidth(storage: Pick<Storage, "getItem"> | null): number {
|
||||||
|
if (!storage) return ACTIVITY_PANEL_DEFAULT_WIDTH;
|
||||||
|
try {
|
||||||
|
const raw = storage.getItem(ACTIVITY_PANEL_STORAGE_KEY);
|
||||||
|
if (raw === null || raw.trim() === "") return ACTIVITY_PANEL_DEFAULT_WIDTH;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
return Number.isFinite(parsed) ? parsed : ACTIVITY_PANEL_DEFAULT_WIDTH;
|
||||||
|
} catch {
|
||||||
|
return ACTIVITY_PANEL_DEFAULT_WIDTH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveActivityPanelWidth(
|
||||||
|
width: number,
|
||||||
|
storage: Pick<Storage, "setItem"> | null,
|
||||||
|
): void {
|
||||||
|
if (!storage) return;
|
||||||
|
try {
|
||||||
|
storage.setItem(ACTIVITY_PANEL_STORAGE_KEY, String(Math.round(width)));
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable in privacy-restricted browser contexts.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBrowserStorage(): Storage | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
return window.localStorage;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resizeWidthFromKey(
|
||||||
|
width: number,
|
||||||
|
key: string,
|
||||||
|
shiftKey: boolean,
|
||||||
|
bounds: ActivityPanelBounds,
|
||||||
|
): number | null {
|
||||||
|
const step = shiftKey ? 48 : 16;
|
||||||
|
if (key === "ArrowLeft") return clampActivityPanelWidth(width - step, bounds);
|
||||||
|
if (key === "ArrowRight") return clampActivityPanelWidth(width + step, bounds);
|
||||||
|
if (key === "Home") return bounds.min;
|
||||||
|
if (key === "End") return bounds.max;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DragState = { pointerId: number; startX: number; startWidth: number };
|
||||||
|
|
||||||
|
export function useActivityPanelResize(): {
|
||||||
|
containerRef: RefObject<HTMLDivElement>;
|
||||||
|
panelWidth: number;
|
||||||
|
resizing: boolean;
|
||||||
|
separatorProps: {
|
||||||
|
role: "separator";
|
||||||
|
tabIndex: 0;
|
||||||
|
"aria-label": string;
|
||||||
|
"aria-orientation": "vertical";
|
||||||
|
"aria-valuemin": number;
|
||||||
|
"aria-valuemax": number;
|
||||||
|
"aria-valuenow": number;
|
||||||
|
onPointerDown: PointerEventHandler<HTMLDivElement>;
|
||||||
|
onPointerMove: PointerEventHandler<HTMLDivElement>;
|
||||||
|
onPointerUp: PointerEventHandler<HTMLDivElement>;
|
||||||
|
onPointerCancel: PointerEventHandler<HTMLDivElement>;
|
||||||
|
onKeyDown: KeyboardEventHandler<HTMLDivElement>;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
const storageRef = useRef<Storage | null>(getBrowserStorage());
|
||||||
|
const initialWidthRef = useRef(loadActivityPanelWidth(storageRef.current));
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const widthRef = useRef(initialWidthRef.current);
|
||||||
|
const boundsRef = useRef<ActivityPanelBounds>({
|
||||||
|
min: ACTIVITY_PANEL_MIN_WIDTH,
|
||||||
|
max: ACTIVITY_PANEL_MAX_WIDTH,
|
||||||
|
});
|
||||||
|
const dragRef = useRef<DragState | null>(null);
|
||||||
|
const [panelWidth, setPanelWidth] = useState(initialWidthRef.current);
|
||||||
|
const [bounds, setBounds] = useState(boundsRef.current);
|
||||||
|
const [resizing, setResizing] = useState(false);
|
||||||
|
|
||||||
|
const applyWidth = useCallback((rawWidth: number, persist: boolean) => {
|
||||||
|
const next = clampActivityPanelWidth(rawWidth, boundsRef.current);
|
||||||
|
widthRef.current = next;
|
||||||
|
setPanelWidth(next);
|
||||||
|
if (persist) saveActivityPanelWidth(next, storageRef.current);
|
||||||
|
return next;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const syncBounds = () => {
|
||||||
|
if (container.clientWidth < ACTIVITY_PANEL_MIN_WIDTH + ACTIVITY_CENTRAL_MIN_WIDTH) return;
|
||||||
|
const nextBounds = getActivityPanelBounds(container.clientWidth);
|
||||||
|
boundsRef.current = nextBounds;
|
||||||
|
setBounds(nextBounds);
|
||||||
|
applyWidth(widthRef.current, true);
|
||||||
|
};
|
||||||
|
syncBounds();
|
||||||
|
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(syncBounds);
|
||||||
|
observer?.observe(container);
|
||||||
|
window.addEventListener("resize", syncBounds);
|
||||||
|
return () => {
|
||||||
|
observer?.disconnect();
|
||||||
|
window.removeEventListener("resize", syncBounds);
|
||||||
|
};
|
||||||
|
}, [applyWidth]);
|
||||||
|
|
||||||
|
const onPointerDown: PointerEventHandler<HTMLDivElement> = (event) => {
|
||||||
|
dragRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startWidth: widthRef.current,
|
||||||
|
};
|
||||||
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||||
|
setResizing(true);
|
||||||
|
};
|
||||||
|
const onPointerMove: PointerEventHandler<HTMLDivElement> = (event) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||||
|
applyWidth(drag.startWidth + event.clientX - drag.startX, false);
|
||||||
|
};
|
||||||
|
const onPointerUp: PointerEventHandler<HTMLDivElement> = (event) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||||
|
applyWidth(drag.startWidth + event.clientX - drag.startX, true);
|
||||||
|
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
|
||||||
|
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||||
|
}
|
||||||
|
dragRef.current = null;
|
||||||
|
setResizing(false);
|
||||||
|
};
|
||||||
|
const onPointerCancel: PointerEventHandler<HTMLDivElement> = (event) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||||
|
applyWidth(drag.startWidth, false);
|
||||||
|
dragRef.current = null;
|
||||||
|
setResizing(false);
|
||||||
|
};
|
||||||
|
const onKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
|
||||||
|
const next = resizeWidthFromKey(widthRef.current, event.key, event.shiftKey, boundsRef.current);
|
||||||
|
if (next === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
applyWidth(next, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerRef,
|
||||||
|
panelWidth,
|
||||||
|
resizing,
|
||||||
|
separatorProps: {
|
||||||
|
role: "separator",
|
||||||
|
tabIndex: 0,
|
||||||
|
"aria-label": "Resize model activity",
|
||||||
|
"aria-orientation": "vertical",
|
||||||
|
"aria-valuemin": bounds.min,
|
||||||
|
"aria-valuemax": bounds.max,
|
||||||
|
"aria-valuenow": panelWidth,
|
||||||
|
onPointerDown,
|
||||||
|
onPointerMove,
|
||||||
|
onPointerUp,
|
||||||
|
onPointerCancel,
|
||||||
|
onKeyDown,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user