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:
DottaandClaude Opus 4.8 committed 2026-06-08 16:35:10 +00:00
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;
}