From b0199e5951b4ed862d058dc1abdd16a00ba66c42 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 8 Jun 2026 16:35:10 +0000 Subject: [PATCH] feat(ui): RequestCollapsedSidebar route-request component (PAP-10645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5 of the collapsible sidebar plan (PAP-10638, plan §7). Add the generic route-requested collapse mechanism on top of the state model from PAP-10642. is an effect-only component: while mounted it sets routeRequestsCollapsed in SidebarContext, defaulting the app sidebar to collapsed (still peek-able) — the "this route brings its own sidebar, keep the app one out of the way" case. - Precedence is enforced by SidebarContext: an explicit user pin (collapsed or expanded) always wins over the route request. - Unmounting (navigating away from the route) clears the request via the effect cleanup, restoring the global default. A user pin persists. Chose the effect-component form over route `handle` metadata because the app uses JSX / rather than object route config, so a component drops cleanly into any page/plugin route's element tree. Out of scope (follow-up): showing the plugin routeSidebar and the collapsed app rail simultaneously — Layout still shows one or the other. Component test covers: requests collapsed while mounted, user pin overrides, unmount restores default, and pin persists across navigation. Co-Authored-By: Claude Opus 4.8 --- .../RequestCollapsedSidebar.test.tsx | 114 ++++++++++++++++++ ui/src/components/RequestCollapsedSidebar.tsx | 36 ++++++ 2 files changed, 150 insertions(+) create mode 100644 ui/src/components/RequestCollapsedSidebar.test.tsx create mode 100644 ui/src/components/RequestCollapsedSidebar.tsx diff --git a/ui/src/components/RequestCollapsedSidebar.test.tsx b/ui/src/components/RequestCollapsedSidebar.test.tsx new file mode 100644 index 00000000000..a7657e5f3df --- /dev/null +++ b/ui/src/components/RequestCollapsedSidebar.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SidebarProvider, useSidebar } from "../context/SidebarContext"; +import { RequestCollapsedSidebar } from "./RequestCollapsedSidebar"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const COLLAPSED_STORAGE_KEY = "paperclip.sidebar.collapsed"; + +let capturedValue: ReturnType | null = null; + +function Capture() { + capturedValue = useSidebar(); + return null; +} + +// A tiny stand-in for "a route that brings its own sidebar". When `onRoute` +// is true it mounts ; flipping it to false models +// navigating away to a route that does not request a collapse. +function Harness({ onRoute }: { onRoute: boolean }) { + return ( + + + {onRoute ? : null} + + ); +} + +function render(onRoute: boolean): { root: Root; host: HTMLDivElement } { + const host = document.createElement("div"); + document.body.appendChild(host); + const root = createRoot(host); + act(() => root.render()); + return { root, host }; +} + +describe("RequestCollapsedSidebar", () => { + let active: { root: Root; host: HTMLDivElement } | null = null; + + beforeEach(() => { + localStorage.clear(); + capturedValue = null; + Object.defineProperty(window, "innerWidth", { + configurable: true, + writable: true, + value: 1280, // desktop: collapsed/peek are meaningful + }); + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + // Desktop, hover-capable pointer. + matches: query.includes("(hover: hover)"), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + }); + + afterEach(() => { + if (active) { + act(() => active!.root.unmount()); + active.host.remove(); + active = null; + } + localStorage.clear(); + }); + + it("requests collapsed while mounted when there is no user pin", () => { + active = render(true); + expect(capturedValue?.routeRequestsCollapsed).toBe(true); + expect(capturedValue?.collapsed).toBe(true); + }); + + it("lets an explicit user pin override the route request", () => { + active = render(true); + expect(capturedValue?.collapsed).toBe(true); + + // User explicitly pins expanded — must win over the route's request. + act(() => capturedValue?.setCollapsed(false)); + expect(capturedValue?.routeRequestsCollapsed).toBe(true); + expect(capturedValue?.collapsed).toBe(false); + }); + + it("clears the request on unmount, restoring the global default", () => { + active = render(true); + expect(capturedValue?.collapsed).toBe(true); + + // Navigate away: the route (and its ) unmounts. + act(() => active!.root.render()); + expect(capturedValue?.routeRequestsCollapsed).toBe(false); + expect(capturedValue?.collapsed).toBe(false); + }); + + it("keeps a user pin after navigating away (pin persists, request cleared)", () => { + active = render(true); + act(() => capturedValue?.setCollapsed(true)); + expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("1"); + + act(() => active!.root.render()); + // Route request gone, but the explicit collapsed pin still applies. + expect(capturedValue?.routeRequestsCollapsed).toBe(false); + expect(capturedValue?.collapsed).toBe(true); + }); +}); diff --git a/ui/src/components/RequestCollapsedSidebar.tsx b/ui/src/components/RequestCollapsedSidebar.tsx new file mode 100644 index 00000000000..259de29a4ea --- /dev/null +++ b/ui/src/components/RequestCollapsedSidebar.tsx @@ -0,0 +1,36 @@ +import { useEffect } from "react"; +import { useSidebar } from "../context/SidebarContext"; + +/** + * Effect-only component (renders nothing). While mounted, it asks the app + * sidebar to default to **collapsed** (still peek-able) — the generic + * "this route brings its own sidebar, keep the app one out of the way" case. + * + * Precedence is enforced by {@link SidebarContext}: an explicit user pin + * (collapsed *or* expanded) always wins over this route request. When the + * component unmounts — e.g. navigating away from the route that rendered it — + * the cleanup clears the request and the global default is restored. + * + * Drop it anywhere inside a route's element tree: + * + * ```tsx + * function MyPluginPage() { + * return ( + * <> + * + * … + * + * ); + * } + * ``` + */ +export function RequestCollapsedSidebar() { + const { setRouteRequestsCollapsed } = useSidebar(); + + useEffect(() => { + setRouteRequestsCollapsed(true); + return () => setRouteRequestsCollapsed(false); + }, [setRouteRequestsCollapsed]); + + return null; +}