mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
feat(ui): RequestCollapsedSidebar route-request component (PAP-10645)
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. <RequestCollapsedSidebar/> 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 <Routes>/<Route> 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ec240b2aa
commit
b0199e5951
2 files changed
+150
No files matched your search
@@ -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<typeof useSidebar> | 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 <RequestCollapsedSidebar/>; flipping it to false models
|
||||
// navigating away to a route that does not request a collapse.
|
||||
function Harness({ onRoute }: { onRoute: boolean }) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Capture />
|
||||
{onRoute ? <RequestCollapsedSidebar /> : null}
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function render(onRoute: boolean): { root: Root; host: HTMLDivElement } {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
act(() => root.render(<Harness onRoute={onRoute} />));
|
||||
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 <RequestCollapsedSidebar/>) unmounts.
|
||||
act(() => active!.root.render(<Harness onRoute={false} />));
|
||||
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(<Harness onRoute={false} />));
|
||||
// Route request gone, but the explicit collapsed pin still applies.
|
||||
expect(capturedValue?.routeRequestsCollapsed).toBe(false);
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
* <>
|
||||
* <RequestCollapsedSidebar />
|
||||
* …
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function RequestCollapsedSidebar() {
|
||||
const { setRouteRequestsCollapsed } = useSidebar();
|
||||
|
||||
useEffect(() => {
|
||||
setRouteRequestsCollapsed(true);
|
||||
return () => setRouteRequestsCollapsed(false);
|
||||
}, [setRouteRequestsCollapsed]);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user