feat(ui): add collapsible sidebar rail and takeover panes (#7824)
## Thinking Path > - Paperclip is the open source control plane people use to manage AI agents, work, and company context. > - The board UI sidebar is the main way operators keep orientation across companies, projects, agents, issues, and settings. > - The existing fixed expanded sidebar competes with route-specific navigation, especially company settings and plugin routes that bring their own contextual sidebar. > - A collapsible primary rail preserves global navigation while giving contextual pages more horizontal room. > - This pull request adds a persisted collapsed rail, hover/focus peek, keyboard toggle, and a secondary sidebar takeover model for settings and plugin `routeSidebar` surfaces. > - The benefit is a denser board shell that keeps the app rail available without replacing it when a route needs its own navigation. ## Linked Issues or Issue Description Paperclip issue: PAP-10638 Create collapsible sidebar branch. Related GitHub PR found during duplicate search: #3838 (`feat/collapsible-sidebar`) covers a similar sidebar area but is a different head branch and implementation. This PR intentionally packages the work from `PAP-10638-collapsable-sidebar` into one reviewable branch. Problem description: The board shell needs a first-class collapsed sidebar mode. Contextual surfaces such as company settings and plugin route sidebars should not replace the global app sidebar; they should collapse the app sidebar to a rail and render their contextual navigation beside it. ## What Changed - Added desktop collapsed/sidebar-peek state to `SidebarContext`, including persisted user pins, route collapse requests, and forced collapse for secondary-sidebar routes. - Replaced the old resizable sidebar pane with `SidebarShell`, which supports a fixed 64px rail, persisted expanded width, keyboard/pointer resizing, and hover/focus peek overlay behavior. - Updated `Sidebar`, sidebar nav items, project/agent sections, badges, and account/company menu presentation for expanded, collapsed, and peeking states. - Added `RequestCollapsedSidebar` and `SecondarySidebar` so routes and plugin `routeSidebar` slots can request contextual sidebar layouts without replacing the primary app sidebar. - Wired company settings and plugin route sidebars into the secondary-pane takeover model. - Added focused Vitest coverage for sidebar state precedence, shell sizing, nav item rail rendering, keyboard shortcuts, layout takeover behavior, and route collapse requests. - Updated plugin authoring docs/spec references for route sidebar behavior. ## Verification Targeted local verification passed: ```sh NODE_ENV=test pnpm run preflight:workspace-links && NODE_ENV=test pnpm exec vitest run ui/src/context/SidebarContext.test.tsx ui/src/components/SidebarShell.test.tsx ui/src/components/Sidebar.test.tsx ui/src/components/Layout.test.tsx ui/src/components/RequestCollapsedSidebar.test.tsx ui/src/components/SidebarNavItem.test.tsx ui/src/components/SidebarAgents.test.tsx ui/src/components/SidebarProjects.test.tsx ui/src/components/KeyboardShortcutsCheatsheet.test.tsx ui/src/hooks/useKeyboardShortcuts.test.tsx ``` Result: 10 test files passed, 88 tests passed. Additional follow-up verification passed after review fixes: ```sh NODE_ENV=test pnpm run preflight:workspace-links && NODE_ENV=test pnpm exec vitest run ui/src/components/Layout.test.tsx ui/src/context/SidebarContext.test.tsx && pnpm --filter /ui typecheck ``` Result: 2 test files passed, 28 tests passed, and UI typecheck passed. Latest PR-head remote checks: Paperclip PR workflow, Snyk, Socket, and Greptile are green; commitperclip `review` is cancelled in its security-gate step after filing a non-blocking neutral `security-review` check. Notes: - A direct run without `NODE_ENV=test` loads React's production build in this workspace, where `act` is unavailable; the command above matches the repo stable runner's test environment. - I did not run Playwright/browser e2e or full workspace build/typecheck in this PR-creation heartbeat. - QA screenshots are attached in https://github.com/paperclipai/paperclip/pull/7824#issuecomment-4661968387 for expanded, collapsed rail, hover peek, and settings secondary-sidebar states. ## Risks - Medium UI layout risk: this changes the board shell and primary sidebar composition across many routes. - Local storage migration risk is low: new collapsed state uses a new key and existing width storage remains scoped to the sidebar width. - Plugin route risk: plugin `routeSidebar` slots now render as secondary panes on desktop, so plugin authors should confirm their route sidebar content fits a 240px contextual pane. - Mobile risk appears low because mobile keeps the drawer model and gates collapsed/peek behavior to desktop. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex coding agent based on GPT-5, with local shell/git/GitHub CLI tool use. Exact service-side model identifier and context window were not exposed in this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -336,6 +336,22 @@ Mount surfaces currently wired in the host include:
|
||||
- `commentAnnotation`
|
||||
- `commentContextMenuItem`
|
||||
|
||||
### `routeSidebar` and the app sidebar
|
||||
|
||||
A `routeSidebar` slot gives a plugin page route its own contextual navigation.
|
||||
It **coexists** with the main app sidebar rather than replacing it: while your
|
||||
route is active the host collapses the app `<Sidebar/>` to its 64px icon rail
|
||||
(still hover/peek-able) and renders your sidebar in a second pane, yielding
|
||||
`[ app rail ][ your sidebar ][ content ]`.
|
||||
|
||||
Because the host drives this collapse, a plugin should **not** mount
|
||||
`RequestCollapsedSidebar` or otherwise try to collapse the app sidebar itself —
|
||||
doing so is redundant and fights the host. While your route is active the app
|
||||
rail is forced collapsed (its expand toggle is hidden), overriding any user pin
|
||||
— a secondary sidebar always collapses the primary. This force never changes the
|
||||
user's saved expanded/collapsed preference, so the host restores exactly what
|
||||
the user chose as soon as they navigate away.
|
||||
|
||||
## Shared host components
|
||||
|
||||
Use shared components from `@paperclipai/plugin-sdk/ui` when the plugin needs a
|
||||
|
||||
@@ -1131,6 +1131,27 @@ Plugins may add sidebar links to:
|
||||
- global plugin settings
|
||||
- company-context plugin pages
|
||||
|
||||
### 19.5.1 Route Sidebars (`routeSidebar`)
|
||||
|
||||
A `routeSidebar` slot supplies a contextual sidebar for a plugin page route
|
||||
(matched by `routePath`). It **coexists** with the main app sidebar rather than
|
||||
replacing it: while the route is active the host collapses the app `<Sidebar/>`
|
||||
to its 64px icon rail (still hover/peek-able) and renders the plugin's
|
||||
`routeSidebar` in a secondary pane, producing the layout
|
||||
`[ app rail ][ route sidebar ][ content ]`. The same model applies to the
|
||||
host's own company-settings sidebar.
|
||||
|
||||
The host owns the collapse. Plugins must not mount `RequestCollapsedSidebar` or
|
||||
otherwise attempt to collapse the app sidebar from a `routeSidebar` — the host
|
||||
applies the collapse while the route is mounted and restores the previous state
|
||||
on navigation away. The collapse is a **hard invariant**: while a secondary
|
||||
sidebar is shown the app rail is forced collapsed and its expand/toggle
|
||||
affordance is hidden, *overriding* any user pin. Crucially, this force is
|
||||
ephemeral — it never mutates the user's persisted expanded/collapsed preference,
|
||||
so navigating back to a normal route restores exactly what the user chose.
|
||||
Precedence is therefore: secondary-sidebar force > explicit user pin >
|
||||
route-requested collapse (`RequestCollapsedSidebar`) > default expanded.
|
||||
|
||||
## 19.6 Shared Components In `@paperclipai/plugin-sdk/ui`
|
||||
|
||||
The host SDK ships shared components that plugins can import to quickly build UIs that match the host's look and feel. These are convenience building blocks, not a requirement.
|
||||
|
||||
@@ -238,7 +238,9 @@ Adds a navigation-style entry to the main company sidebar navigation area, rende
|
||||
|
||||
#### `routeSidebar`
|
||||
|
||||
Replaces the normal company sidebar while the current route is a plugin page route with the same `routePath`. Use this for full-page plugin workspaces that need their own local navigation while keeping the company rail and account footer. Receives `PluginRouteSidebarProps` with `context.companyId` and `context.companyPrefix` set to the active company. Requires the `ui.sidebar.register` capability.
|
||||
A contextual sidebar shown while the current route is a plugin page route with the same `routePath`. Use this for full-page plugin workspaces that need their own local navigation. It does **not** replace the app sidebar: the host collapses the main `<Sidebar/>` to its 64px icon rail (still hover/peek-able) and renders your `routeSidebar` in a secondary pane beside it, producing `[ app rail ][ your sidebar ][ content ]`. Receives `PluginRouteSidebarProps` with `context.companyId` and `context.companyPrefix` set to the active company. Requires the `ui.sidebar.register` capability.
|
||||
|
||||
Do **not** mount `RequestCollapsedSidebar` (or otherwise try to collapse the app sidebar) from a `routeSidebar` plugin — the host drives the collapse automatically while your route is active and restores the user's preference when they navigate away. The collapse is a hard invariant: a secondary sidebar always forces the app rail collapsed (hiding its expand toggle), overriding any user pin, but it never mutates the user's saved expanded/collapsed preference — that is restored as soon as they leave your route.
|
||||
|
||||
#### `sidebarPanel`
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { test, expect, request as pwRequest, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* E2E: Sidebar takeover model (PAP-10695).
|
||||
*
|
||||
* Takeover routes (company settings, plugin `routeSidebar`) no longer *replace*
|
||||
* the main app sidebar. Instead the host collapses the app `<Sidebar/>` to its
|
||||
* 64px rail (still peek-able) and renders the contextual sidebar in a second
|
||||
* pane → `[ app rail ][ secondary ~240px ][ content ]`.
|
||||
*
|
||||
* These specs assert the rail + secondary pane coexist on a company settings
|
||||
* route, and that an explicit user pin (expanded) wins over the route-driven
|
||||
* collapse (pin precedence).
|
||||
*
|
||||
* The plugin `routeSidebar` half of this behavior shares the exact same Layout
|
||||
* code path (one `secondarySidebar`/`hasSecondarySidebar` resolver drives both
|
||||
* company-settings and plugin routes) and is covered by the unit tests in
|
||||
* `ui/src/components/Layout.test.tsx`. A live plugin-route e2e requires the
|
||||
* `plugin-llm-wiki` plugin to be installed in the throwaway e2e instance, which
|
||||
* is out of scope for this default local_trusted run; visual QA of both panes
|
||||
* is delegated to the QA child issue.
|
||||
*/
|
||||
|
||||
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199);
|
||||
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
||||
const COMPANY_NAME_PREFIX = "E2E-SidebarTakeover";
|
||||
const COLLAPSED_STORAGE_KEY = "paperclip.sidebar.collapsed";
|
||||
|
||||
// The sidebar header's "Open search" control only renders when the app sidebar
|
||||
// is expanded (pinned or peeking); in the collapsed rail it is hidden to fit
|
||||
// the 64px width. Its presence/absence is therefore a stable proxy for the
|
||||
// app sidebar's collapsed state (see Sidebar.tsx).
|
||||
const APP_SIDEBAR_EXPANDED_MARKER = "Open search";
|
||||
|
||||
async function createCompany(board: APIRequestContext): Promise<{ id: string; prefix: string }> {
|
||||
const healthRes = await board.get(`${BASE_URL}/api/health`);
|
||||
expect(healthRes.ok()).toBe(true);
|
||||
const health = await healthRes.json();
|
||||
expect(health.deploymentMode).toBe("local_trusted");
|
||||
|
||||
const companyRes = await board.post(`${BASE_URL}/api/companies`, {
|
||||
data: { name: `${COMPANY_NAME_PREFIX}-${Date.now()}` },
|
||||
});
|
||||
if (!companyRes.ok()) {
|
||||
throw new Error(`POST /api/companies → ${companyRes.status()}: ${await companyRes.text()}`);
|
||||
}
|
||||
const company = await companyRes.json();
|
||||
return {
|
||||
id: company.id,
|
||||
prefix: company.issuePrefix ?? company.prefix ?? company.urlKey ?? "E2E",
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Sidebar takeover (collapse + secondary pane)", () => {
|
||||
let board: APIRequestContext;
|
||||
let companyId: string;
|
||||
let prefix: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
board = await pwRequest.newContext({ baseURL: BASE_URL });
|
||||
const company = await createCompany(board);
|
||||
companyId = company.id;
|
||||
prefix = company.prefix;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await board.delete(`${BASE_URL}/api/companies/${companyId}`).catch(() => {});
|
||||
await board.dispose();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Start each test from a clean (unpinned) sidebar state so the route-driven
|
||||
// collapse is the only thing acting on it.
|
||||
await page.addInitScript((key) => {
|
||||
window.localStorage.removeItem(key);
|
||||
}, COLLAPSED_STORAGE_KEY);
|
||||
});
|
||||
|
||||
test("collapses the app sidebar to its rail and shows the settings sidebar beside it", async ({ page }) => {
|
||||
await page.goto(`/${prefix}/company/settings`);
|
||||
|
||||
// The contextual (secondary) pane is present...
|
||||
const secondary = page.locator("[data-secondary-sidebar]");
|
||||
await expect(secondary).toBeVisible();
|
||||
await expect(secondary).toHaveCount(1);
|
||||
|
||||
// ...and it is ~240px wide (w-60), distinct from the 64px app rail.
|
||||
const secondaryBox = await secondary.boundingBox();
|
||||
expect(secondaryBox).not.toBeNull();
|
||||
expect(secondaryBox!.width).toBeGreaterThan(180);
|
||||
|
||||
// The app sidebar is NOT replaced — its company nav still renders...
|
||||
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
|
||||
|
||||
// ...but it is collapsed to its rail: the expanded-only "Open search"
|
||||
// header control is hidden.
|
||||
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("renders the secondary pane nav labels at full width despite the app rail collapse", async ({ page }) => {
|
||||
// Regression (PAP-10700): the secondary pane is 240px wide, but its
|
||||
// SidebarNavItem children read the *global* collapsed state and used to
|
||||
// render icon-only (label `w-0 text-transparent`), making the settings nav
|
||||
// unreadable in the default takeover state. The pane must force full labels.
|
||||
await page.goto(`/${prefix}/company/settings`);
|
||||
|
||||
const secondary = page.locator("[data-secondary-sidebar]");
|
||||
await expect(secondary).toBeVisible();
|
||||
|
||||
// App sidebar is collapsed to its rail (default unpinned takeover state)...
|
||||
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
|
||||
|
||||
// ...yet a settings nav label renders at its full text width, not clipped to
|
||||
// zero. "Environments" is unique to the company-settings nav.
|
||||
const envLabel = secondary.getByText("Environments", { exact: true });
|
||||
await expect(envLabel).toBeVisible();
|
||||
const labelBox = await envLabel.boundingBox();
|
||||
expect(labelBox).not.toBeNull();
|
||||
expect(labelBox!.width).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
test("settings force-collapse overrides an expanded pin without mutating it", async ({ page }) => {
|
||||
// User has pinned the sidebar expanded ("0"). Company settings is a hard
|
||||
// secondary-sidebar takeover route, so forceCollapsed wins while the route is
|
||||
// active (force > pin > route request > default) but must not mutate the pin.
|
||||
await page.addInitScript(
|
||||
({ key }) => {
|
||||
window.localStorage.setItem(key, "0");
|
||||
},
|
||||
{ key: COLLAPSED_STORAGE_KEY },
|
||||
);
|
||||
|
||||
await page.goto(`/${prefix}/company/settings`);
|
||||
|
||||
// Secondary pane still shows on the takeover route.
|
||||
await expect(page.locator("[data-secondary-sidebar]")).toBeVisible();
|
||||
|
||||
// The app sidebar is hard-collapsed despite the stored expanded pin.
|
||||
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
|
||||
|
||||
await page.goto(`/${prefix}/dashboard`);
|
||||
|
||||
// Leaving the takeover route clears the force and restores the user's
|
||||
// persisted expanded pin.
|
||||
await expect(page.locator("[data-secondary-sidebar]")).toHaveCount(0);
|
||||
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toBeVisible();
|
||||
});
|
||||
|
||||
test("leaving the takeover route removes the secondary pane and restores the sidebar", async ({ page }) => {
|
||||
await page.goto(`/${prefix}/company/settings`);
|
||||
await expect(page.locator("[data-secondary-sidebar]")).toBeVisible();
|
||||
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
|
||||
|
||||
// Navigate to a plain (non-takeover) route.
|
||||
await page.goto(`/${prefix}/dashboard`);
|
||||
|
||||
// No secondary pane, and the app sidebar is no longer force-collapsed.
|
||||
await expect(page.locator("[data-secondary-sidebar]")).toHaveCount(0);
|
||||
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { KeyboardShortcutsCheatsheetContent } from "./KeyboardShortcutsCheatsheet";
|
||||
|
||||
describe("KeyboardShortcutsCheatsheet", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("lists the re-pointed Cmd/Ctrl+B sidebar collapse shortcut as a chord", () => {
|
||||
const root = createRoot(container);
|
||||
flushSync(() => {
|
||||
root.render(<KeyboardShortcutsCheatsheetContent />);
|
||||
});
|
||||
|
||||
// The collapse/expand row exists with its label.
|
||||
const row = [...container.querySelectorAll("span")].find(
|
||||
(node) => node.textContent?.trim() === "Collapse or expand sidebar",
|
||||
)?.parentElement;
|
||||
expect(row).toBeTruthy();
|
||||
|
||||
// Rendered as a "+" chord (B + a Cmd/Ctrl cap), not a "then" sequence.
|
||||
const caps = [...(row?.querySelectorAll("kbd") ?? [])].map((kbd) => kbd.textContent);
|
||||
expect(caps).toContain("B");
|
||||
expect(caps.some((cap) => cap === "⌘" || cap === "Ctrl")).toBe(true);
|
||||
expect(row?.textContent).toContain("+");
|
||||
expect(row?.textContent).not.toContain("then");
|
||||
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,22 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
||||
interface ShortcutEntry {
|
||||
keys: string[];
|
||||
label: string;
|
||||
/** Render keys as a simultaneous chord (joined with "+") rather than a
|
||||
* "then" sequence. */
|
||||
combo?: boolean;
|
||||
}
|
||||
|
||||
// Platform-appropriate label for the Cmd/Ctrl modifier so the cheatsheet shows
|
||||
// the same key the user actually presses (re-pointed in the collapsible sidebar
|
||||
// work — Cmd/Ctrl+B toggles the rail).
|
||||
function getPlatformLabel() {
|
||||
if (typeof navigator === "undefined") return "";
|
||||
const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
|
||||
return nav.userAgentData?.platform || navigator.userAgent || "";
|
||||
}
|
||||
|
||||
const META_KEY = /Mac|iPhone|iPad|iPod/.test(getPlatformLabel()) ? "⌘" : "Ctrl";
|
||||
|
||||
interface ShortcutSection {
|
||||
title: string;
|
||||
shortcuts: ShortcutEntry[];
|
||||
@@ -41,6 +55,7 @@ const sections: ShortcutSection[] = [
|
||||
{ keys: ["/"], label: "Search current page or quick search" },
|
||||
{ keys: ["c"], label: "New task" },
|
||||
{ keys: ["["], label: "Toggle sidebar" },
|
||||
{ keys: [META_KEY, "B"], label: "Collapse or expand sidebar", combo: true },
|
||||
{ keys: ["]"], label: "Toggle panel" },
|
||||
{ keys: ["?"], label: "Show keyboard shortcuts" },
|
||||
],
|
||||
@@ -74,7 +89,11 @@ export function KeyboardShortcutsCheatsheetContent() {
|
||||
<div className="flex items-center gap-1">
|
||||
{shortcut.keys.map((key, i) => (
|
||||
<span key={key} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-xs text-muted-foreground">then</span>}
|
||||
{i > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{shortcut.combo ? "+" : "then"}
|
||||
</span>
|
||||
)}
|
||||
<KeyCap>{key}</KeyCap>
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -17,6 +17,7 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
const mockSetForceCollapsed = vi.hoisted(() => vi.fn());
|
||||
const mockCompanyState = vi.hoisted(() => ({
|
||||
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
|
||||
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
||||
@@ -27,9 +28,12 @@ const mockPluginSlots = vi.hoisted(() => ({
|
||||
}));
|
||||
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||||
const mockPluginSlotContexts = vi.hoisted(() => [] as Array<Record<string, unknown>>);
|
||||
const mockSetPeeking = vi.hoisted(() => vi.fn());
|
||||
const mockSidebarState = vi.hoisted(() => ({
|
||||
sidebarOpen: true,
|
||||
isMobile: false,
|
||||
collapsed: false,
|
||||
peeking: false,
|
||||
}));
|
||||
let currentPathname = "/PAP/dashboard";
|
||||
|
||||
@@ -167,7 +171,16 @@ vi.mock("../context/SidebarContext", () => ({
|
||||
sidebarOpen: mockSidebarState.sidebarOpen,
|
||||
setSidebarOpen: mockSetSidebarOpen,
|
||||
toggleSidebar: vi.fn(),
|
||||
toggleCollapsed: vi.fn(),
|
||||
collapsed: mockSidebarState.collapsed,
|
||||
collapseLocked: false,
|
||||
peeking: mockSidebarState.peeking,
|
||||
setPeeking: mockSetPeeking,
|
||||
isMobile: mockSidebarState.isMobile,
|
||||
forceCollapsed: false,
|
||||
setForceCollapsed: mockSetForceCollapsed,
|
||||
routeRequestsCollapsed: false,
|
||||
setRouteRequestsCollapsed: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -236,6 +249,9 @@ describe("Layout", () => {
|
||||
mockPluginSlotContexts.length = 0;
|
||||
mockSidebarState.sidebarOpen = true;
|
||||
mockSidebarState.isMobile = false;
|
||||
mockSidebarState.collapsed = false;
|
||||
mockSidebarState.peeking = false;
|
||||
mockSetPeeking.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -274,7 +290,81 @@ describe("Layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the company settings sidebar on company settings routes", async () => {
|
||||
it("collapses atomically when the pointer is still over the sidebar (no re-peek) — PAP-10676", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
const renderLayout = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
};
|
||||
|
||||
// The SidebarShell overlay panel carries the peek mouse handlers.
|
||||
const panel = () =>
|
||||
[...container.querySelectorAll<HTMLElement>("div")].find(
|
||||
(el) => el.className.includes("inset-y-0") && el.className.includes("overflow-hidden"),
|
||||
);
|
||||
const hover = (el: HTMLElement) => {
|
||||
// React derives onMouseEnter from a mouseover crossing in from outside.
|
||||
el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body }));
|
||||
};
|
||||
|
||||
// Expanded, then hover the panel so the pointer is registered as inside.
|
||||
await renderLayout();
|
||||
const expandedPanel = panel();
|
||||
expect(expandedPanel).toBeTruthy();
|
||||
await act(async () => { hover(expandedPanel!); });
|
||||
|
||||
// Collapse while the pointer is still over the panel.
|
||||
mockSidebarState.collapsed = true;
|
||||
await renderLayout();
|
||||
// The peek is cancelled atomically on collapse.
|
||||
expect(mockSetPeeking).toHaveBeenCalledWith(false);
|
||||
|
||||
// A lingering/spurious hover while collapsed must NOT re-open the peek.
|
||||
mockSetPeeking.mockClear();
|
||||
const railPanel = panel();
|
||||
await act(async () => { hover(railPanel!); });
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 80)); });
|
||||
expect(mockSetPeeking).not.toHaveBeenCalledWith(true);
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
|
||||
it("opens the peek when hovering a collapsed rail (positive control for the hover sim)", async () => {
|
||||
mockSidebarState.collapsed = true;
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const panel = [...container.querySelectorAll<HTMLElement>("div")].find(
|
||||
(el) => el.className.includes("inset-y-0") && el.className.includes("overflow-hidden"),
|
||||
);
|
||||
expect(panel).toBeTruthy();
|
||||
await act(async () => {
|
||||
panel!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body }));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 80)); });
|
||||
// A normal collapsed-rail hover (not just-collapsed) opens the peek.
|
||||
expect(mockSetPeeking).toHaveBeenCalledWith(true);
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
|
||||
it("keeps the app sidebar and shows the settings sidebar in the secondary pane on settings routes", async () => {
|
||||
currentPathname = "/PAP/company/settings/access";
|
||||
mockPluginSlots.slots = [
|
||||
{
|
||||
@@ -315,11 +405,15 @@ describe("Layout", () => {
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
// Takeover model (PAP-10695): the app sidebar is kept (collapsed to its
|
||||
// rail) AND the settings sidebar renders in the secondary pane.
|
||||
expect(container.textContent).toContain("Company settings sidebar");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Company rail");
|
||||
expect(container.textContent).not.toContain("Instance sidebar");
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||
// The route asks the host to collapse the app sidebar to its rail.
|
||||
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
@@ -380,9 +474,10 @@ describe("Layout", () => {
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Company settings sidebar");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Company rail");
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
@@ -430,11 +525,14 @@ describe("Layout", () => {
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
// Takeover model (PAP-10695): the app sidebar coexists with the plugin's
|
||||
// route sidebar, which renders in the secondary pane.
|
||||
expect(container.textContent).toContain("Plugin route sidebar: Wiki Sidebar");
|
||||
expect(container.querySelector("[data-plugin-slot-class='h-full w-full']")).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Company settings sidebar");
|
||||
expect(container.textContent).not.toContain("Instance sidebar");
|
||||
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
@@ -489,7 +587,7 @@ describe("Layout", () => {
|
||||
}),
|
||||
);
|
||||
expect(container.textContent).toContain("Plugin route sidebar: Wiki Sidebar");
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
@@ -617,6 +715,9 @@ describe("Layout", () => {
|
||||
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||
// No secondary pane, so the route must not force the sidebar collapsed.
|
||||
expect(mockSetForceCollapsed).not.toHaveBeenCalledWith(true);
|
||||
expect(mockSetForceCollapsed).toHaveBeenCalledWith(false);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
||||
+156
-21
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
@@ -17,7 +17,8 @@ import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
import { StandaloneBrowserControls } from "./StandaloneBrowserControls";
|
||||
import { ResizableSidebarPane } from "./ResizableSidebarPane";
|
||||
import { SidebarShell } from "./SidebarShell";
|
||||
import { SecondarySidebar } from "./SecondarySidebar";
|
||||
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||
@@ -48,7 +49,17 @@ function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefi
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
||||
const {
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
toggleSidebar,
|
||||
toggleCollapsed,
|
||||
collapsed,
|
||||
peeking,
|
||||
setPeeking,
|
||||
isMobile,
|
||||
setForceCollapsed,
|
||||
} = useSidebar();
|
||||
const { openNewIssue, openOnboarding } = useDialogActions();
|
||||
const { togglePanelVisible } = usePanel();
|
||||
const {
|
||||
@@ -102,16 +113,21 @@ export function Layout() {
|
||||
}),
|
||||
[routeSidebarCompanyId, routeSidebarCompanyPrefix],
|
||||
);
|
||||
const companySidebar = routeSidebarSlot ? (
|
||||
// Takeover routes (company settings, plugin `routeSidebar`) no longer replace
|
||||
// the app `<Sidebar/>`. Instead the host collapses it to its rail and renders
|
||||
// the contextual sidebar in a second pane (PAP-10695). One resolver drives
|
||||
// both desktop (SecondarySidebar) and mobile (off-canvas drawer).
|
||||
const secondarySidebar = isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : routeSidebarSlot ? (
|
||||
<PluginSlotMount
|
||||
slot={routeSidebarSlot}
|
||||
context={sidebarContext}
|
||||
className="h-full w-full"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
) : (
|
||||
<Sidebar />
|
||||
);
|
||||
) : null;
|
||||
const hasSecondarySidebar = secondarySidebar != null;
|
||||
const { data: health } = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
@@ -127,6 +143,16 @@ export function Layout() {
|
||||
queryFn: () => instanceSettingsApi.getGeneral(),
|
||||
}).data?.keyboardShortcuts === true;
|
||||
|
||||
// A secondary sidebar always collapses the app sidebar to its rail (still
|
||||
// peek-able) — a hard invariant that overrides the user pin while the route
|
||||
// is active, but does NOT mutate the persisted preference. Clearing the force
|
||||
// on cleanup restores the user's expanded/collapsed choice when navigating
|
||||
// off the takeover route (PAP-10694).
|
||||
useLayoutEffect(() => {
|
||||
setForceCollapsed(hasSecondarySidebar);
|
||||
return () => setForceCollapsed(false);
|
||||
}, [hasSecondarySidebar, setForceCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (companiesLoading || onboardingTriggered.current) return;
|
||||
if (health?.deploymentMode === "authenticated") return;
|
||||
@@ -178,6 +204,15 @@ export function Layout() {
|
||||
]);
|
||||
|
||||
const togglePanel = togglePanelVisible;
|
||||
// Cmd/Ctrl+B: collapse/expand the pinned rail on desktop; on mobile keep
|
||||
// toggling the off-canvas drawer.
|
||||
const toggleCollapse = useCallback(() => {
|
||||
if (isMobile) {
|
||||
toggleSidebar();
|
||||
} else {
|
||||
toggleCollapsed();
|
||||
}
|
||||
}, [isMobile, toggleSidebar, toggleCollapsed]);
|
||||
const openSearch = useCallback(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "k",
|
||||
@@ -187,6 +222,102 @@ export function Layout() {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Peek (hover flyout) triggers for the collapsed rail. Opening has a tiny
|
||||
// delay so a pointer merely sweeping across the rail doesn't flash it open;
|
||||
// closing is debounced to avoid flicker on the rail→overlay seam. Keyboard
|
||||
// focus opens immediately so tabbing reaches the full nav. Context gates the
|
||||
// effective `peeking` to desktop + collapsed + hover-capable pointers, so
|
||||
// these handlers are inert otherwise.
|
||||
const peekTimer = useRef<number | null>(null);
|
||||
// Whether the pointer is currently over the peek panel. Used to keep the peek
|
||||
// open across focus changes (e.g. navigation steals focus to <main>) as long as
|
||||
// the user is still hovering — it should only close when they actually mouse off
|
||||
// (PAP-10676).
|
||||
const pointerInsidePanel = useRef(false);
|
||||
// When the user explicitly collapses while the pointer is still over the panel,
|
||||
// suppress re-peeking until the pointer actually leaves — otherwise the lingering
|
||||
// hover immediately re-expands the rail and the collapse "doesn't take" until the
|
||||
// mouse moves away (PAP-10676). Re-armed on the next genuine pointer-leave.
|
||||
const suppressPeekRef = useRef(false);
|
||||
const clearPeekTimer = useCallback(() => {
|
||||
if (peekTimer.current !== null) {
|
||||
window.clearTimeout(peekTimer.current);
|
||||
peekTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
const openPeek = useCallback(() => {
|
||||
clearPeekTimer();
|
||||
peekTimer.current = window.setTimeout(() => setPeeking(true), 50);
|
||||
}, [clearPeekTimer, setPeeking]);
|
||||
const openPeekImmediate = useCallback(() => {
|
||||
clearPeekTimer();
|
||||
setPeeking(true);
|
||||
}, [clearPeekTimer, setPeeking]);
|
||||
const closePeek = useCallback(() => {
|
||||
clearPeekTimer();
|
||||
peekTimer.current = window.setTimeout(() => setPeeking(false), 120);
|
||||
}, [clearPeekTimer, setPeeking]);
|
||||
// Tracked even while expanded so that, at the moment of collapse, we know
|
||||
// whether the pointer is over the panel and should suppress the re-peek.
|
||||
const handlePanelPointerEnter = useCallback(() => {
|
||||
pointerInsidePanel.current = true;
|
||||
if (collapsed && !suppressPeekRef.current) openPeek();
|
||||
}, [collapsed, openPeek]);
|
||||
const handlePanelPointerLeave = useCallback(() => {
|
||||
pointerInsidePanel.current = false;
|
||||
suppressPeekRef.current = false; // pointer left — re-arm peek for the next hover
|
||||
closePeek();
|
||||
}, [closePeek]);
|
||||
const handlePanelFocus = useCallback(() => {
|
||||
if (suppressPeekRef.current) return;
|
||||
openPeekImmediate();
|
||||
}, [openPeekImmediate]);
|
||||
// Close on focus leaving the panel only when the pointer isn't hovering it.
|
||||
// Clicking a rail/peek nav item moves focus to <main> on navigation; if the
|
||||
// mouse is still over the flyout we keep it open until the pointer leaves.
|
||||
const handlePanelBlur = useCallback(() => {
|
||||
if (pointerInsidePanel.current) return;
|
||||
closePeek();
|
||||
}, [closePeek]);
|
||||
|
||||
// Tidy up any pending peek timer on unmount.
|
||||
useEffect(() => clearPeekTimer, [clearPeekTimer]);
|
||||
|
||||
// An explicit collapse must be atomic: cancel any in-flight/active peek, and if
|
||||
// the pointer is still over the panel suppress re-peeking until it leaves, so the
|
||||
// rail doesn't immediately re-expand under the lingering hover (PAP-10676).
|
||||
const wasCollapsed = useRef(collapsed);
|
||||
useEffect(() => {
|
||||
if (collapsed !== wasCollapsed.current) {
|
||||
if (collapsed) {
|
||||
clearPeekTimer();
|
||||
setPeeking(false);
|
||||
suppressPeekRef.current = pointerInsidePanel.current;
|
||||
} else {
|
||||
suppressPeekRef.current = false;
|
||||
}
|
||||
wasCollapsed.current = collapsed;
|
||||
}
|
||||
}, [collapsed, clearPeekTimer, setPeeking]);
|
||||
|
||||
// Intentionally do NOT close the peek on navigation: clicking a nav item means
|
||||
// the pointer is still over the flyout, so it should stay open until the user
|
||||
// actually mouses off (handled by onPanelMouseLeave) or blurs out / hits Escape
|
||||
// (PAP-10676). Auto-closing here made the sidebar collapse on every page change.
|
||||
|
||||
// Escape closes an open peek without trapping the pointer.
|
||||
useEffect(() => {
|
||||
if (!peeking) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
clearPeekTimer();
|
||||
setPeeking(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [peeking, clearPeekTimer, setPeeking]);
|
||||
|
||||
useCompanyPageMemory();
|
||||
|
||||
useKeyboardShortcuts({
|
||||
@@ -194,6 +325,7 @@ export function Layout() {
|
||||
onNewIssue: () => openNewIssue(),
|
||||
onSearch: openSearch,
|
||||
onToggleSidebar: toggleSidebar,
|
||||
onToggleCollapse: toggleCollapse,
|
||||
onTogglePanel: togglePanel,
|
||||
onShowShortcuts: () => setShortcutsOpen(true),
|
||||
});
|
||||
@@ -350,11 +482,7 @@ export function Layout() {
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<div className="w-60 shrink-0 overflow-hidden">
|
||||
{isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
companySidebar
|
||||
)}
|
||||
{hasSecondarySidebar ? secondarySidebar : <Sidebar />}
|
||||
</div>
|
||||
</div>
|
||||
<SidebarAccountMenu
|
||||
@@ -363,23 +491,30 @@ export function Layout() {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col shrink-0">
|
||||
<SidebarShell
|
||||
open={sidebarOpen}
|
||||
collapsed={collapsed}
|
||||
peeking={peeking}
|
||||
resizable
|
||||
onPanelMouseEnter={handlePanelPointerEnter}
|
||||
onPanelMouseLeave={handlePanelPointerLeave}
|
||||
onPanelFocusCapture={collapsed ? handlePanelFocus : undefined}
|
||||
onPanelBlurCapture={collapsed ? handlePanelBlur : undefined}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<ResizableSidebarPane open={sidebarOpen} resizable className="h-full shrink-0">
|
||||
{isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
companySidebar
|
||||
)}
|
||||
</ResizableSidebarPane>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode={health?.deploymentMode}
|
||||
version={health?.version}
|
||||
/>
|
||||
</div>
|
||||
</SidebarShell>
|
||||
)}
|
||||
|
||||
{!isMobile && hasSecondarySidebar ? (
|
||||
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
|
||||
) : null}
|
||||
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
// @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 { ResizableSidebarPane } from "./ResizableSidebarPane";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function pointerEvent(type: string, clientX: number) {
|
||||
const event = new MouseEvent(type, { bubbles: true, clientX });
|
||||
Object.defineProperty(event, "pointerId", { value: 1 });
|
||||
return event;
|
||||
}
|
||||
|
||||
describe("ResizableSidebarPane", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
function pane() {
|
||||
return container.firstElementChild as HTMLDivElement;
|
||||
}
|
||||
|
||||
function handle() {
|
||||
return container.querySelector('[role="separator"]') as HTMLDivElement | null;
|
||||
}
|
||||
|
||||
it("uses a persisted width when open", () => {
|
||||
window.localStorage.setItem("test.sidebar.width", "320");
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ResizableSidebarPane open resizable storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</ResizableSidebarPane>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(pane().style.width).toBe("320px");
|
||||
expect(handle()?.getAttribute("aria-valuenow")).toBe("320");
|
||||
});
|
||||
|
||||
it("resizes by dragging and persists the new width", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ResizableSidebarPane open resizable storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</ResizableSidebarPane>,
|
||||
);
|
||||
});
|
||||
|
||||
const separator = handle();
|
||||
expect(separator).not.toBeNull();
|
||||
separator!.setPointerCapture = vi.fn();
|
||||
|
||||
act(() => {
|
||||
separator!.dispatchEvent(pointerEvent("pointerdown", 240));
|
||||
separator!.dispatchEvent(pointerEvent("pointermove", 320));
|
||||
separator!.dispatchEvent(pointerEvent("pointerup", 320));
|
||||
});
|
||||
|
||||
expect(pane().style.width).toBe("320px");
|
||||
expect(window.localStorage.getItem("test.sidebar.width")).toBe("320");
|
||||
});
|
||||
|
||||
it("supports keyboard resizing and clamps to the configured bounds", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ResizableSidebarPane open resizable storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</ResizableSidebarPane>,
|
||||
);
|
||||
});
|
||||
|
||||
const separator = handle();
|
||||
act(() => {
|
||||
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||
});
|
||||
expect(pane().style.width).toBe("256px");
|
||||
expect(window.localStorage.getItem("test.sidebar.width")).toBe("256");
|
||||
|
||||
act(() => {
|
||||
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
|
||||
});
|
||||
expect(pane().style.width).toBe("208px");
|
||||
|
||||
act(() => {
|
||||
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
|
||||
});
|
||||
expect(pane().style.width).toBe("420px");
|
||||
});
|
||||
|
||||
it("can render without a resize handle", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ResizableSidebarPane open resizable={false}>
|
||||
<div>Sidebar</div>
|
||||
</ResizableSidebarPane>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(handle()).toBeNull();
|
||||
expect(pane().style.width).toBe("240px");
|
||||
});
|
||||
});
|
||||
@@ -1,176 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DEFAULT_SIDEBAR_WIDTH = 240;
|
||||
const MIN_SIDEBAR_WIDTH = 208;
|
||||
const MAX_SIDEBAR_WIDTH = 420;
|
||||
const SIDEBAR_WIDTH_STEP = 16;
|
||||
|
||||
function clampSidebarWidth(width: number) {
|
||||
return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, width));
|
||||
}
|
||||
|
||||
function readStoredSidebarWidth(storageKey: string) {
|
||||
if (typeof window === "undefined") return DEFAULT_SIDEBAR_WIDTH;
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(storageKey);
|
||||
if (!stored) return DEFAULT_SIDEBAR_WIDTH;
|
||||
const parsed = Number.parseInt(stored, 10);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_SIDEBAR_WIDTH;
|
||||
return clampSidebarWidth(parsed);
|
||||
} catch {
|
||||
return DEFAULT_SIDEBAR_WIDTH;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredSidebarWidth(storageKey: string, width: number) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, String(clampSidebarWidth(width)));
|
||||
} catch {
|
||||
// Storage can be unavailable in private contexts; resizing should still work.
|
||||
}
|
||||
}
|
||||
|
||||
type ResizableSidebarPaneProps = {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
resizable?: boolean;
|
||||
storageKey?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ResizableSidebarPane({
|
||||
children,
|
||||
open,
|
||||
resizable = false,
|
||||
storageKey = "paperclip.sidebar.width",
|
||||
className,
|
||||
}: ResizableSidebarPaneProps) {
|
||||
const [width, setWidth] = useState(() => readStoredSidebarWidth(storageKey));
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const widthRef = useRef(width);
|
||||
const dragState = useRef<{ startX: number; startWidth: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const storedWidth = readStoredSidebarWidth(storageKey);
|
||||
widthRef.current = storedWidth;
|
||||
setWidth(storedWidth);
|
||||
}, [storageKey]);
|
||||
|
||||
const visibleWidth = open ? width : 0;
|
||||
const paneStyle = useMemo(
|
||||
() => ({ width: `${visibleWidth}px` }),
|
||||
[visibleWidth],
|
||||
);
|
||||
|
||||
const commitWidth = useCallback(
|
||||
(nextWidth: number) => {
|
||||
const clamped = clampSidebarWidth(nextWidth);
|
||||
widthRef.current = clamped;
|
||||
setWidth(clamped);
|
||||
writeStoredSidebarWidth(storageKey, clamped);
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!open || !resizable) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragState.current = { startX: event.clientX, startWidth: widthRef.current };
|
||||
setIsResizing(true);
|
||||
},
|
||||
[open, resizable],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragState.current) return;
|
||||
|
||||
const nextWidth = dragState.current.startWidth + event.clientX - dragState.current.startX;
|
||||
const clamped = clampSidebarWidth(nextWidth);
|
||||
widthRef.current = clamped;
|
||||
setWidth(clamped);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const endResize = useCallback(() => {
|
||||
if (!dragState.current) return;
|
||||
|
||||
dragState.current = null;
|
||||
setIsResizing(false);
|
||||
writeStoredSidebarWidth(storageKey, widthRef.current);
|
||||
}, [storageKey]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!open || !resizable) return;
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
commitWidth(width - SIDEBAR_WIDTH_STEP);
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
commitWidth(width + SIDEBAR_WIDTH_STEP);
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
commitWidth(MIN_SIDEBAR_WIDTH);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
commitWidth(MAX_SIDEBAR_WIDTH);
|
||||
}
|
||||
},
|
||||
[commitWidth, open, resizable, width],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden",
|
||||
!isResizing && "transition-[width] duration-100 ease-out",
|
||||
className,
|
||||
)}
|
||||
style={paneStyle}
|
||||
>
|
||||
{children}
|
||||
{resizable && open ? (
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize sidebar"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={MIN_SIDEBAR_WIDTH}
|
||||
aria-valuemax={MAX_SIDEBAR_WIDTH}
|
||||
aria-valuenow={width}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"absolute inset-y-0 right-0 z-20 w-3 cursor-col-resize touch-none outline-none",
|
||||
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors",
|
||||
"hover:before:bg-border focus-visible:before:bg-ring",
|
||||
isResizing && "before:bg-ring",
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={endResize}
|
||||
onPointerCancel={endResize}
|
||||
onLostPointerCapture={endResize}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SidebarNavExpandedProvider } from "./SidebarNavItem";
|
||||
|
||||
/**
|
||||
* Fixed-width contextual pane that sits between the collapsed app rail and the
|
||||
* main content on takeover routes (company settings, plugin `routeSidebar`).
|
||||
*
|
||||
* The takeover model (PAP-10695) no longer *replaces* the app `<Sidebar/>`:
|
||||
* the host collapses it to its 64px rail (still peek-able) and renders the
|
||||
* contextual sidebar here, yielding `[ rail 64px ][ secondary ~240px ][ content ]`.
|
||||
*
|
||||
* It is a dumb container — fixed `w-60`, full-height, non-shrinking, with a
|
||||
* right border and its own vertical scroll — so callers just drop the
|
||||
* contextual nav (or a plugin slot mount) inside as `children`.
|
||||
*
|
||||
* Because the pane is always 240px wide it forces the full-label presentation
|
||||
* on any `SidebarNavItem` inside it, so the contextual nav stays readable even
|
||||
* while the app sidebar is collapsed to its rail (PAP-10700).
|
||||
*/
|
||||
export function SecondarySidebar({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-secondary-sidebar=""
|
||||
className={cn(
|
||||
"h-full w-60 shrink-0 overflow-y-auto border-r border-border bg-background",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SidebarNavExpandedProvider>{children}</SidebarNavExpandedProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const mockHeartbeatsApi = vi.hoisted(() => ({
|
||||
liveRunsForCompany: vi.fn(),
|
||||
@@ -47,11 +48,18 @@ vi.mock("../context/CompanyContext", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSidebar = vi.hoisted(() => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: vi.fn(),
|
||||
collapsed: false,
|
||||
collapseLocked: false,
|
||||
peeking: false,
|
||||
toggleCollapsed: vi.fn(),
|
||||
setCollapsed: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: vi.fn(),
|
||||
}),
|
||||
useSidebar: () => mockSidebar,
|
||||
}));
|
||||
|
||||
vi.mock("../api/heartbeats", () => ({
|
||||
@@ -112,7 +120,9 @@ describe("Sidebar", () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Sidebar />
|
||||
<TooltipProvider>
|
||||
<Sidebar />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
@@ -125,6 +135,9 @@ describe("Sidebar", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
mockSidebar.isMobile = false;
|
||||
mockSidebar.collapsed = false;
|
||||
mockSidebar.peeking = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -284,4 +297,92 @@ describe("Sidebar", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("header toggle collapses an expanded sidebar (aria-expanded reflects state)", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
const root = await renderSidebar();
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>('button[aria-label="Collapse sidebar"]');
|
||||
expect(toggle).not.toBeNull();
|
||||
expect(toggle?.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
act(() => {
|
||||
toggle?.click();
|
||||
});
|
||||
expect(mockSidebar.toggleCollapsed).toHaveBeenCalledTimes(1);
|
||||
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the expand/collapse toggle while a secondary sidebar locks the rail", async () => {
|
||||
// A secondary sidebar forces the rail; the user must not be able to expand
|
||||
// the primary while it is shown (PAP-10694).
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
mockSidebar.collapseLocked = true;
|
||||
const root = await renderSidebar();
|
||||
|
||||
expect(container.querySelector('button[aria-label="Collapse sidebar"]')).toBeNull();
|
||||
expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull();
|
||||
|
||||
mockSidebar.collapseLocked = false;
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the collapsed rail top bar to just the company logo (no clipped search/toggle)", async () => {
|
||||
// In the narrow rail the search/toggle controls don't fit beside the logo and
|
||||
// would overflow/clip, shoving the logo out of the icon column (PAP-10676), so
|
||||
// they are dropped in the rail. Expansion stays reachable via hover-peek + Pin
|
||||
// and Cmd/Ctrl+B. The full controls return as soon as the panel is expanded or
|
||||
// peeking (covered by the other top-bar tests).
|
||||
mockSidebar.collapsed = true;
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
const root = await renderSidebar();
|
||||
|
||||
expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull();
|
||||
expect(container.querySelector('a[aria-label="Open search"]')).toBeNull();
|
||||
// The company menu (workspace switcher / logo) is still present in the rail.
|
||||
expect(container.textContent).toContain("Company menu");
|
||||
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("peek header shows a pin that promotes the peek to pinned-expanded", async () => {
|
||||
mockSidebar.collapsed = true;
|
||||
mockSidebar.peeking = true;
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
const root = await renderSidebar();
|
||||
|
||||
// The collapse toggle is replaced by the pin while peeking.
|
||||
expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull();
|
||||
const pin = container.querySelector<HTMLButtonElement>('button[aria-label="Keep sidebar expanded"]');
|
||||
expect(pin).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
pin?.click();
|
||||
});
|
||||
expect(mockSidebar.setCollapsed).toHaveBeenCalledWith(false);
|
||||
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the collapse affordance on mobile (drawer handles it)", async () => {
|
||||
mockSidebar.isMobile = true;
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
const root = await renderSidebar();
|
||||
|
||||
expect(container.querySelector('button[aria-label="Collapse sidebar"]')).toBeNull();
|
||||
expect(container.querySelector('button[aria-label="Keep sidebar expanded"]')).toBeNull();
|
||||
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
Package,
|
||||
Settings,
|
||||
FolderOpen,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Pin,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NavLink } from "@/lib/router";
|
||||
@@ -23,11 +26,14 @@ import { SidebarAgents } from "./SidebarAgents";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
||||
import { PluginSlotOutlet } from "@/plugins/slots";
|
||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
@@ -35,6 +41,8 @@ import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
export function Sidebar() {
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { isMobile, collapsed, collapseLocked, peeking, toggleCollapsed, setCollapsed } = useSidebar();
|
||||
const rail = collapsed && !peeking;
|
||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
@@ -64,37 +72,92 @@ export function Sidebar() {
|
||||
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
|
||||
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
||||
<SidebarCompanyMenu />
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
aria-label="Open search"
|
||||
title="Open search"
|
||||
>
|
||||
<NavLink to="/search">
|
||||
<Search className="h-4 w-4" />
|
||||
</NavLink>
|
||||
</Button>
|
||||
{/* In the collapsed rail the search/toggle controls don't fit beside the
|
||||
logo — keeping them would overflow the 64px rail and squeeze the logo
|
||||
out of alignment with the icon column below it (PAP-10676). They return
|
||||
as soon as the panel is expanded (pinned) or peeking. Expansion in the
|
||||
rail is still reachable via hover-peek + Pin and Cmd/Ctrl+B. */}
|
||||
{!rail ? (
|
||||
<>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
aria-label="Open search"
|
||||
title="Open search"
|
||||
>
|
||||
<NavLink to="/search">
|
||||
<Search className="h-4 w-4" />
|
||||
</NavLink>
|
||||
</Button>
|
||||
{/* Desktop-only collapse/expand affordance. While peeking (hover flyout
|
||||
over the collapsed rail) it becomes a Pin that promotes the peek to a
|
||||
pinned-expanded sidebar; otherwise it toggles the pinned rail. Mobile
|
||||
uses the off-canvas drawer, so this control is hidden there. It is
|
||||
also hidden while a secondary sidebar forces the rail (collapseLocked):
|
||||
the user cannot expand the primary while a secondary sidebar is shown. */}
|
||||
{!isMobile && !collapseLocked ? (
|
||||
peeking ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
aria-label="Keep sidebar expanded"
|
||||
title="Keep sidebar expanded"
|
||||
onClick={() => setCollapsed(false)}
|
||||
>
|
||||
<Pin className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
onClick={() => toggleCollapsed()}
|
||||
>
|
||||
{collapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 pointer-coarse:gap-3 px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* New Task button aligned with nav items */}
|
||||
<button
|
||||
onClick={() => openNewIssue()}
|
||||
data-slot="icon-button"
|
||||
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-foreground/80 hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<SquarePen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">New Task</span>
|
||||
</button>
|
||||
{(() => {
|
||||
const newTaskButton = (
|
||||
<button
|
||||
onClick={() => openNewIssue()}
|
||||
data-slot="icon-button"
|
||||
aria-label={rail ? "New Task" : undefined}
|
||||
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-foreground/80 hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<SquarePen className="h-4 w-4 shrink-0" />
|
||||
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "truncate"}>New Task</span>
|
||||
</button>
|
||||
);
|
||||
return rail ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{newTaskButton}</TooltipTrigger>
|
||||
<TooltipContent side="right">New Task</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
newTaskButton
|
||||
);
|
||||
})()}
|
||||
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
|
||||
<SidebarNavItem
|
||||
to="/inbox"
|
||||
label="Inbox"
|
||||
icon={Inbox}
|
||||
badge={inboxBadge.inbox}
|
||||
badgeLabel="unread"
|
||||
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
|
||||
alert={inboxBadge.failedRuns > 0}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useSidebar } from "../context/SidebarContext";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
||||
|
||||
const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile";
|
||||
const DOCS_URL = "https://docs.paperclip.ing/";
|
||||
@@ -107,7 +107,8 @@ export function SidebarAccountMenu({
|
||||
}: SidebarAccountMenuProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
||||
const rail = collapsed && !peeking;
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = onOpenChange ?? setInternalOpen;
|
||||
@@ -150,7 +151,7 @@ export function SidebarAccountMenu({
|
||||
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 truncate">{displayName}</span>
|
||||
<span className={cn("min-w-0 flex-1 truncate", rail && SIDEBAR_RAIL_HIDDEN_LABEL)}>{displayName}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Agent, ResourceMemberships } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarAgents } from "./SidebarAgents";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const mockAgentsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
@@ -30,6 +31,7 @@ const mockResourceMembershipsApi = vi.hoisted(() => ({
|
||||
const mockOpenNewAgent = vi.hoisted(() => vi.fn());
|
||||
const mockPushToast = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
const mockSidebarState = vi.hoisted(() => ({ collapsed: false, peeking: false }));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||
@@ -75,6 +77,8 @@ vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: mockSetSidebarOpen,
|
||||
collapsed: mockSidebarState.collapsed,
|
||||
peeking: mockSidebarState.peeking,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -206,6 +210,8 @@ describe("SidebarAgents", () => {
|
||||
let memberships: ResourceMemberships;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSidebarState.collapsed = false;
|
||||
mockSidebarState.peeking = false;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
@@ -286,6 +292,43 @@ describe("SidebarAgents", () => {
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
async function renderRailSidebarAgents() {
|
||||
mockSidebarState.collapsed = true;
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<SidebarAgents streamlined />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("renders icon-only agent rows with tooltips and no row actions in the rail", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" })]);
|
||||
|
||||
await renderRailSidebarAgents();
|
||||
|
||||
// The agent name is preserved in the a11y tree but kept in flow (zero-width,
|
||||
// clipped) so the row stays 1:1 tall with the expanded state (PAP-10676); the
|
||||
// row links become tooltip triggers and the per-row actions dropdown is dropped.
|
||||
const nameSpan = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Alpha");
|
||||
expect(nameSpan?.className).not.toContain("sr-only");
|
||||
expect(nameSpan?.className).toContain("w-0");
|
||||
expect(nameSpan?.className).toContain("overflow-hidden");
|
||||
const agentLink = container.querySelector('a[href^="/agents/"]:not([href="/agents/all"])');
|
||||
expect(agentLink?.parentElement?.getAttribute("data-slot")).toBe("tooltip-trigger");
|
||||
expect(container.querySelector('button[aria-label="Open actions for Alpha"]')).toBeNull();
|
||||
|
||||
// The section header collapses to a divider (no caret / section menu).
|
||||
expect(container.querySelector('button[aria-label="Agents section actions"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps top mode in stored org-aware order", async () => {
|
||||
localStorage.setItem("paperclip.agentOrder:company-1:user-1", JSON.stringify(["agent-b", "agent-a", "agent-c"]));
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
|
||||
@@ -21,7 +21,7 @@ import { authApi } from "../api/auth";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||
import { cn, agentRouteRef, agentUrl, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
||||
import { useAgentOrder } from "../hooks/useAgentOrder";
|
||||
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
|
||||
import {
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
@@ -95,6 +96,7 @@ function SidebarAgentItem({
|
||||
leaving,
|
||||
onLeaveAgent,
|
||||
onPauseResume,
|
||||
rail,
|
||||
runCount,
|
||||
setSidebarOpen,
|
||||
}: {
|
||||
@@ -106,6 +108,7 @@ function SidebarAgentItem({
|
||||
leaving: boolean;
|
||||
onLeaveAgent: (agent: Agent) => void;
|
||||
onPauseResume: (agent: Agent, action: "pause" | "resume") => void;
|
||||
rail: boolean;
|
||||
runCount: number;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
}) {
|
||||
@@ -126,46 +129,65 @@ function SidebarAgentItem({
|
||||
? "Invalid org chain"
|
||||
: pauseResumeLabel;
|
||||
|
||||
const link = (
|
||||
<NavLink
|
||||
to={href}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 pr-8 text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{agent.name}</span>
|
||||
{!rail && hasInvalidOrgChain ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
|
||||
) : null}
|
||||
{!rail && (agent.pauseReason === "budget" || runCount > 0) && (
|
||||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
{agent.pauseReason === "budget" ? (
|
||||
<BudgetSidebarMarker title="Agent paused by budget" />
|
||||
) : null}
|
||||
{runCount > 0 ? (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
) : null}
|
||||
{runCount > 0 ? (
|
||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
||||
{runCount} live
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group/agent relative flex items-center">
|
||||
<NavLink
|
||||
to={href}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 pr-8 text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{agent.name}</span>
|
||||
{hasInvalidOrgChain ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
|
||||
) : null}
|
||||
{(agent.pauseReason === "budget" || runCount > 0) && (
|
||||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
{agent.pauseReason === "budget" ? (
|
||||
<BudgetSidebarMarker title="Agent paused by budget" />
|
||||
) : null}
|
||||
{runCount > 0 ? (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
) : null}
|
||||
{runCount > 0 ? (
|
||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
||||
{runCount} live
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
{rail ? (
|
||||
// Anchor the tooltip to a wrapper, not the NavLink: Radix `asChild` (Slot)
|
||||
// drops React Router's function className, which would strip `flex` off the
|
||||
// <a> and let the in-flow label stack under the icon, growing the row.
|
||||
// Keeping the <a> out of Slot preserves a 1:1 row height with the expanded
|
||||
// state so the icon never moves (PAP-10676).
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 flex-1">{link}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{agent.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
link
|
||||
)}
|
||||
|
||||
{!rail && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -219,6 +241,7 @@ function SidebarAgentItem({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -229,7 +252,8 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewAgent } = useDialogActions();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
||||
const rail = collapsed && !peeking;
|
||||
const { pushToast } = useToastActions();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -451,24 +475,36 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
|
||||
leaving={agentLeaving(agent)}
|
||||
onLeaveAgent={leaveAgent}
|
||||
onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })}
|
||||
rail={rail}
|
||||
runCount={runCount}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showSeeAllLink && (
|
||||
<Link
|
||||
to="/agents/all"
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<Users className="shrink-0 h-3.5 w-3.5" />
|
||||
<span>See all agents</span>
|
||||
</Link>
|
||||
)}
|
||||
{showSeeAllLink && (() => {
|
||||
const seeAllLink = (
|
||||
<Link
|
||||
to="/agents/all"
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
aria-label={rail ? "See all agents" : undefined}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<Users className="shrink-0 h-3.5 w-3.5" />
|
||||
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : undefined}>See all agents</span>
|
||||
</Link>
|
||||
);
|
||||
return rail ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{seeAllLink}</TooltipTrigger>
|
||||
<TooltipContent side="right">See all agents</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
seeAllLink
|
||||
);
|
||||
})()}
|
||||
</SidebarSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ import { useCompany } from "@/context/CompanyContext";
|
||||
import { useDialogActions } from "@/context/DialogContext";
|
||||
import { useCompanyOrder } from "@/hooks/useCompanyOrder";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { CompanyPatternIcon } from "./CompanyPatternIcon";
|
||||
|
||||
@@ -134,7 +134,8 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
||||
const queryClient = useQueryClient();
|
||||
const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
|
||||
const { openOnboarding } = useDialogActions();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
||||
const rail = collapsed && !peeking;
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
@@ -224,16 +225,21 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9 flex-1 justify-start gap-2 px-2 text-left"
|
||||
// `px-3` (not px-2) so the logo's left edge lines up with the nav icon
|
||||
// column (nav px-3 + item px-3) and, crucially, stays put between states:
|
||||
// the Button's default size adds `has-[>svg]:px-3`, so with the chevron
|
||||
// svg present (expanded) it was already 12px but without it (rail) it fell
|
||||
// back to 8px — a 4px horizontal jump on collapse (PAP-10676).
|
||||
className="h-9 flex-1 justify-start gap-2 px-3 text-left"
|
||||
aria-label={selectedCompany ? `Open ${selectedCompany.name} workspace switcher` : "Open workspace switcher"}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{selectedCompany ? <WorkspaceIcon company={selectedCompany} /> : null}
|
||||
<span className="truncate text-sm font-bold text-foreground">
|
||||
<span className={cn("truncate text-sm font-bold text-foreground", rail && SIDEBAR_RAIL_HIDDEN_LABEL)}>
|
||||
{selectedCompany?.name ?? "Select workspace"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
{!rail && <ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-64 p-1">
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { SidebarNavItem, SidebarNavExpandedProvider } from "./SidebarNavItem";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const sidebarState = vi.hoisted(() => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: () => {},
|
||||
collapsed: false,
|
||||
peeking: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
NavLink: ({ children, to, className, ...props }: {
|
||||
children: ReactNode | ((state: { isActive: boolean }) => ReactNode);
|
||||
to: string;
|
||||
className?: string | ((state: { isActive: boolean }) => string);
|
||||
}) => {
|
||||
const resolvedClassName = typeof className === "function" ? className({ isActive: false }) : className;
|
||||
const resolvedChildren = typeof children === "function" ? children({ isActive: false }) : children;
|
||||
return (
|
||||
<a href={to} className={resolvedClassName} {...props}>
|
||||
{resolvedChildren}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => sidebarState,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("SidebarNavItem", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
sidebarState.collapsed = false;
|
||||
sidebarState.peeking = false;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function render(node: ReactNode) {
|
||||
act(() => {
|
||||
root.render(<TooltipProvider>{node}</TooltipProvider>);
|
||||
});
|
||||
}
|
||||
|
||||
function link() {
|
||||
return container.querySelector("a") as HTMLAnchorElement;
|
||||
}
|
||||
|
||||
it("shows the full label and numeric badge when expanded", () => {
|
||||
render(<SidebarNavItem to="/inbox" label="Inbox" icon={Inbox} badge={28} badgeLabel="unread" />);
|
||||
|
||||
const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox");
|
||||
expect(label?.className).not.toContain("sr-only");
|
||||
// The numeric badge is rendered in full (not a dot) and no rail aria-label is set.
|
||||
expect(container.textContent).toContain("28");
|
||||
expect(link().getAttribute("aria-label")).toBeNull();
|
||||
});
|
||||
|
||||
it("clips the label (kept in flow for 1:1 row height) and collapses the badge to a dot in the rail", () => {
|
||||
sidebarState.collapsed = true;
|
||||
render(<SidebarNavItem to="/inbox" label="Inbox" icon={Inbox} badge={28} badgeLabel="unread" />);
|
||||
|
||||
// The label stays in the DOM/a11y tree (not display:none) so screen readers
|
||||
// still announce it. Unlike sr-only it is kept IN FLOW (zero-width, clipped,
|
||||
// transparent) so it still contributes its line-height — that keeps the row
|
||||
// exactly as tall as the expanded state, so the icon never shifts (PAP-10676).
|
||||
const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox");
|
||||
expect(label).toBeTruthy();
|
||||
expect(label?.className).not.toContain("sr-only");
|
||||
expect(label?.className).toContain("w-0");
|
||||
expect(label?.className).toContain("overflow-hidden");
|
||||
|
||||
// The numeric count is no longer rendered as text; it is a dot with an
|
||||
// accessible text equivalent on the link.
|
||||
expect(container.textContent).not.toContain("28 ");
|
||||
expect(link().getAttribute("aria-label")).toBe("Inbox, 28 unread");
|
||||
|
||||
// Tooltip wraps the row; the trigger is the wrapper element so the NavLink's
|
||||
// own flex className is preserved (PAP-10676), with the <a> nested inside it.
|
||||
expect(link().parentElement?.getAttribute("data-slot")).toBe("tooltip-trigger");
|
||||
});
|
||||
|
||||
it("keeps the full presentation while peeking even when collapsed", () => {
|
||||
sidebarState.collapsed = true;
|
||||
sidebarState.peeking = true;
|
||||
render(<SidebarNavItem to="/inbox" label="Inbox" icon={Inbox} badge={28} badgeLabel="unread" />);
|
||||
|
||||
const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox");
|
||||
expect(label?.className).not.toContain("sr-only");
|
||||
expect(container.textContent).toContain("28");
|
||||
expect(link().getAttribute("aria-label")).toBeNull();
|
||||
});
|
||||
|
||||
it("forces the full label inside an expanded contextual pane even when globally collapsed", () => {
|
||||
// The takeover model collapses the global sidebar (collapsed=true) while the
|
||||
// 240px SecondarySidebar still needs readable labels (PAP-10700). The
|
||||
// provider must override the global rail collapse.
|
||||
sidebarState.collapsed = true;
|
||||
render(
|
||||
<SidebarNavExpandedProvider>
|
||||
<SidebarNavItem to="/inbox" label="Inbox" icon={Inbox} badge={28} badgeLabel="unread" />
|
||||
</SidebarNavExpandedProvider>,
|
||||
);
|
||||
|
||||
const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox");
|
||||
expect(label?.className).not.toContain("w-0");
|
||||
expect(label?.className).toContain("flex-1");
|
||||
// Full numeric badge, no rail aria-label, no tooltip wrapper.
|
||||
expect(container.textContent).toContain("28");
|
||||
expect(link().getAttribute("aria-label")).toBeNull();
|
||||
expect(link().parentElement?.getAttribute("data-slot")).not.toBe("tooltip-trigger");
|
||||
});
|
||||
|
||||
it("surfaces the live count in the rail aria-label", () => {
|
||||
sidebarState.collapsed = true;
|
||||
render(<SidebarNavItem to="/dashboard" label="Dashboard" icon={Inbox} liveCount={3} />);
|
||||
|
||||
expect(link().getAttribute("aria-label")).toBe("Dashboard, 3 live");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,36 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { NavLink } from "@/lib/router";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Forces the full-label (non-rail) presentation for any `SidebarNavItem`
|
||||
* rendered beneath it, regardless of the global `useSidebar().collapsed` state.
|
||||
*
|
||||
* Takeover routes (PAP-10695) collapse the app `<Sidebar/>` to its 64px rail
|
||||
* and render the contextual nav in a fixed 240px `SecondarySidebar`. That pane
|
||||
* is always wide enough for labels, but its `SidebarNavItem` children still
|
||||
* read the *global* `collapsed=true` and would otherwise render icon-only —
|
||||
* leaving the settings nav unreadable (PAP-10700). Wrapping the pane in this
|
||||
* provider decouples its items from the global rail collapse.
|
||||
*/
|
||||
const SidebarNavExpandedContext = createContext(false);
|
||||
|
||||
export function SidebarNavExpandedProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SidebarNavExpandedContext.Provider value={true}>
|
||||
{children}
|
||||
</SidebarNavExpandedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebarNavExpanded() {
|
||||
return useContext(SidebarNavExpandedContext);
|
||||
}
|
||||
|
||||
interface SidebarNavItemProps {
|
||||
to: string;
|
||||
label: string;
|
||||
@@ -12,6 +39,11 @@ interface SidebarNavItemProps {
|
||||
className?: string;
|
||||
badge?: number;
|
||||
badgeTone?: "default" | "danger";
|
||||
/**
|
||||
* Accessible noun for the numeric badge when collapsed to the rail, where the
|
||||
* count is rendered as a dot (e.g. `badgeLabel="unread"` → "Inbox, 28 unread").
|
||||
*/
|
||||
badgeLabel?: string;
|
||||
textBadge?: string;
|
||||
textBadgeTone?: "default" | "amber";
|
||||
alert?: boolean;
|
||||
@@ -26,18 +58,42 @@ export function SidebarNavItem({
|
||||
className,
|
||||
badge,
|
||||
badgeTone = "default",
|
||||
badgeLabel,
|
||||
textBadge,
|
||||
textBadgeTone = "default",
|
||||
alert = false,
|
||||
liveCount,
|
||||
}: SidebarNavItemProps) {
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
||||
// A fixed-width contextual pane (SecondarySidebar) forces full labels even
|
||||
// when the global app sidebar is collapsed to its rail (PAP-10700).
|
||||
const forceExpanded = useSidebarNavExpanded();
|
||||
// The icon-only rail presentation only applies when pinned collapsed and not
|
||||
// peeking; a peek/expanded panel — or an expanded contextual pane — restores
|
||||
// the full label + badge.
|
||||
const rail = collapsed && !peeking && !forceExpanded;
|
||||
|
||||
return (
|
||||
const hasBadge = badge != null && badge > 0;
|
||||
const hasLive = liveCount != null && liveCount > 0;
|
||||
|
||||
// Accessible text equivalent for the collapsed dot indicator. The visible
|
||||
// label is `sr-only` in the rail, so the count must be surfaced here.
|
||||
const railAriaLabel = !rail
|
||||
? undefined
|
||||
: hasLive
|
||||
? `${label}, ${liveCount} live`
|
||||
: hasBadge
|
||||
? `${label}, ${badge}${badgeLabel ? ` ${badgeLabel}` : ""}`
|
||||
: alert
|
||||
? `${label}, attention needed`
|
||||
: undefined;
|
||||
|
||||
const link = (
|
||||
<NavLink
|
||||
to={to}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
end={end}
|
||||
aria-label={railAriaLabel}
|
||||
onClick={() => { if (isMobile) setSidebarOpen(false); }}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
@@ -52,11 +108,28 @@ export function SidebarNavItem({
|
||||
<span className="relative shrink-0">
|
||||
<Icon className="h-4 w-4" />
|
||||
{alert && (
|
||||
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-red-500 shadow-[0_0_0_2px_hsl(var(--background))]" />
|
||||
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-red-500 shadow-[0_0_0_2px_hsl(var(--background))]" aria-hidden="true" />
|
||||
)}
|
||||
{/* Collapsed rail: numeric badge / live count collapse to a dot on the
|
||||
icon. The icon markup is untouched so it stays pixel-aligned. */}
|
||||
{rail && !alert && hasLive && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-2 w-2" aria-hidden="true">
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500 shadow-[0_0_0_2px_hsl(var(--background))]" />
|
||||
</span>
|
||||
)}
|
||||
{rail && !alert && !hasLive && hasBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full shadow-[0_0_0_2px_hsl(var(--background))]",
|
||||
badgeTone === "danger" ? "bg-red-600" : "bg-primary",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{textBadge && (
|
||||
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{label}</span>
|
||||
{!rail && textBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none",
|
||||
@@ -68,7 +141,7 @@ export function SidebarNavItem({
|
||||
{textBadge}
|
||||
</span>
|
||||
)}
|
||||
{liveCount != null && liveCount > 0 && (
|
||||
{!rail && hasLive && (
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
@@ -77,7 +150,7 @@ export function SidebarNavItem({
|
||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">{liveCount} live</span>
|
||||
</span>
|
||||
)}
|
||||
{badge != null && badge > 0 && (
|
||||
{!rail && hasBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto rounded-full px-1.5 py-0.5 text-xs leading-none",
|
||||
@@ -91,4 +164,21 @@ export function SidebarNavItem({
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
if (!rail) return link;
|
||||
|
||||
// The tooltip wraps a plain block element rather than the NavLink directly:
|
||||
// Radix `asChild` (Slot) drops React Router's *function* className, which would
|
||||
// strip `flex` off the <a> and render it as a block — the in-flow label would
|
||||
// then stack under the icon and the row would grow. Anchoring the tooltip to a
|
||||
// wrapper keeps the <a> rendering normally (flex), so the row stays 1:1 with
|
||||
// the expanded state and the icon never moves (PAP-10676).
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{link}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Project, ResourceMemberships } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const mockProjectsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
@@ -25,7 +26,7 @@ const mockOpenNewProject = vi.hoisted(() => vi.fn());
|
||||
const mockPushToast = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
const mockPersistOrder = vi.hoisted(() => vi.fn());
|
||||
const mockSidebarState = vi.hoisted(() => ({ isMobile: false }));
|
||||
const mockSidebarState = vi.hoisted(() => ({ isMobile: false, collapsed: false, peeking: false }));
|
||||
const mockPointerState = vi.hoisted(() => ({ fine: true }));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
@@ -73,6 +74,8 @@ vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: mockSidebarState.isMobile,
|
||||
setSidebarOpen: mockSetSidebarOpen,
|
||||
collapsed: mockSidebarState.collapsed,
|
||||
peeking: mockSidebarState.peeking,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -232,6 +235,8 @@ describe("SidebarProjects", () => {
|
||||
});
|
||||
localStorage.clear();
|
||||
mockSidebarState.isMobile = false;
|
||||
mockSidebarState.collapsed = false;
|
||||
mockSidebarState.peeking = false;
|
||||
mockPointerState.fine = true;
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
@@ -326,6 +331,40 @@ describe("SidebarProjects", () => {
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
async function renderRailSidebarProjects() {
|
||||
mockSidebarState.collapsed = true;
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<SidebarProjects />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("renders icon-only project rows with tooltips and no row actions in the rail", async () => {
|
||||
await renderRailSidebarProjects();
|
||||
|
||||
// Project names stay in the a11y tree but kept in flow (zero-width, clipped)
|
||||
// so rows stay 1:1 tall with the expanded state (PAP-10676); rows become
|
||||
// tooltip triggers and the per-row actions dropdown is dropped.
|
||||
const nameSpan = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Bravo");
|
||||
expect(nameSpan?.className).not.toContain("sr-only");
|
||||
expect(nameSpan?.className).toContain("w-0");
|
||||
expect(nameSpan?.className).toContain("overflow-hidden");
|
||||
const projectLink = container.querySelector('a[href^="/projects/"]');
|
||||
expect(projectLink?.parentElement?.getAttribute("data-slot")).toBe("tooltip-trigger");
|
||||
expect(container.querySelector('button[aria-label="Open actions for Bravo"]')).toBeNull();
|
||||
// The section header collapses to a divider (no section menu trigger).
|
||||
expect(container.querySelector('button[aria-label="Projects section actions"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps top mode in curated order and renders plugin project slots", async () => {
|
||||
await renderSidebarProjects();
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { authApi } from "../api/auth";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, projectRouteRef } from "../lib/utils";
|
||||
import { cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
|
||||
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
||||
import {
|
||||
getProjectSortModeStorageKey,
|
||||
@@ -59,6 +60,7 @@ type ProjectItemProps = {
|
||||
isMobile: boolean;
|
||||
project: Project;
|
||||
projectSidebarSlots: ProjectSidebarSlot[];
|
||||
rail: boolean;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
onLeaveProject: (project: Project) => void;
|
||||
leaving?: boolean;
|
||||
@@ -113,6 +115,7 @@ function ProjectItem({
|
||||
isMobile,
|
||||
project,
|
||||
projectSidebarSlots,
|
||||
rail,
|
||||
setSidebarOpen,
|
||||
onLeaveProject,
|
||||
leaving = false,
|
||||
@@ -120,31 +123,50 @@ function ProjectItem({
|
||||
}: ProjectItemProps) {
|
||||
const routeRef = projectRouteRef(project);
|
||||
|
||||
const link = (
|
||||
<NavLink
|
||||
to={`/projects/${routeRef}/issues`}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={(e) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pr-8 pointer-coarse:py-1 text-[13px] font-medium transition-colors",
|
||||
activeProjectRef === routeRef || activeProjectRef === project.id
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
|
||||
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{project.name}</span>
|
||||
{!rail && project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="group/project relative flex items-center">
|
||||
<NavLink
|
||||
to={`/projects/${routeRef}/issues`}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={(e) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pr-8 pointer-coarse:py-1 text-[13px] font-medium transition-colors",
|
||||
activeProjectRef === routeRef || activeProjectRef === project.id
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
|
||||
<span className="flex-1 truncate">{project.name}</span>
|
||||
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
|
||||
</NavLink>
|
||||
{rail ? (
|
||||
// Anchor the tooltip to a wrapper, not the NavLink: Radix `asChild`
|
||||
// (Slot) drops React Router's function className, which would strip
|
||||
// `flex` off the <a> and let the in-flow label stack under the icon,
|
||||
// growing the row. Keeping the <a> out of Slot preserves a 1:1 row
|
||||
// height with the expanded state so the icon never moves (PAP-10676).
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 flex-1">{link}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{project.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
link
|
||||
)}
|
||||
|
||||
{!rail && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -174,8 +196,9 @@ function ProjectItem({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{projectSidebarSlots.length > 0 && (
|
||||
{!rail && projectSidebarSlots.length > 0 && (
|
||||
<div className="ml-5 flex flex-col gap-0.5">
|
||||
{projectSidebarSlots.map((slot) => (
|
||||
<PluginSlotMount
|
||||
@@ -229,7 +252,8 @@ export function SidebarProjects() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { openNewProject } = useDialogActions();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
||||
const rail = collapsed && !peeking;
|
||||
const fineReorderPointer = useFineReorderPointer();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -373,6 +397,7 @@ export function SidebarProjects() {
|
||||
isMobile={isMobile}
|
||||
project={project}
|
||||
projectSidebarSlots={projectSidebarSlots}
|
||||
rail={rail}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
onLeaveProject={leaveProject}
|
||||
leaving={projectLeaving(project)}
|
||||
@@ -420,6 +445,7 @@ export function SidebarProjects() {
|
||||
isMobile={isMobile}
|
||||
project={project}
|
||||
projectSidebarSlots={projectSidebarSlots}
|
||||
rail={rail}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
onLeaveProject={leaveProject}
|
||||
leaving={projectLeaving(project)}
|
||||
|
||||
@@ -5,16 +5,22 @@ import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { SidebarNavExpandedProvider } from "./SidebarNavItem";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
const sidebarState = vi.hoisted(() => ({
|
||||
isMobile: false,
|
||||
collapsed: false,
|
||||
peeking: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
NavLink: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
@@ -53,6 +59,8 @@ describe("SidebarSection", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
sidebarState.isMobile = false;
|
||||
sidebarState.collapsed = false;
|
||||
sidebarState.peeking = false;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
@@ -155,6 +163,30 @@ describe("SidebarSection", () => {
|
||||
expect(staticLabelControl?.getAttribute("class")).not.toContain("focus-visible:bg-accent/50");
|
||||
});
|
||||
|
||||
it("keeps section labels visible inside an expanded contextual pane", async () => {
|
||||
sidebarState.collapsed = true;
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<SidebarNavExpandedProvider>
|
||||
<SidebarSection label="Settings">
|
||||
<a href="/settings">General</a>
|
||||
</SidebarSection>
|
||||
</SidebarNavExpandedProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const settingsLabel = Array.from(container.querySelectorAll("span"))
|
||||
.find((element) => element.textContent === "Settings");
|
||||
|
||||
expect(settingsLabel).toBeTruthy();
|
||||
expect(settingsLabel?.getAttribute("class")).toContain("uppercase");
|
||||
expect(container.querySelector(".bg-border\\/60")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the header action outside the label menu hit area", async () => {
|
||||
const onAction = vi.fn();
|
||||
const currentRoot = createRoot(container);
|
||||
@@ -259,6 +291,59 @@ describe("SidebarSection", () => {
|
||||
expect(onAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("replaces the header with a divider and drops the caret in the collapsed rail", async () => {
|
||||
sidebarState.collapsed = true;
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<SidebarSection
|
||||
label="Projects"
|
||||
collapsible={{ open: true, onOpenChange: vi.fn() }}
|
||||
menu={{
|
||||
ariaLabel: "Projects section actions",
|
||||
actions: [{ type: "item", label: "Browse projects", href: "/projects" }],
|
||||
}}
|
||||
>
|
||||
<a href="/projects">Projects</a>
|
||||
</SidebarSection>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// The clipped header text is gone; the caret/menu trigger is not rendered.
|
||||
expect(container.querySelector('button[aria-label="Collapse Projects"]')).toBeNull();
|
||||
expect(container.querySelector('button[aria-label="Projects section actions"]')).toBeNull();
|
||||
|
||||
// The label is preserved in the a11y tree (sr-only) and the items still render.
|
||||
const label = Array.from(container.querySelectorAll("span"))
|
||||
.find((element) => element.textContent === "Projects");
|
||||
expect(label?.className).toContain("sr-only");
|
||||
expect(container.querySelector('a[href="/projects"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("restores the full header while peeking even when collapsed", async () => {
|
||||
sidebarState.collapsed = true;
|
||||
sidebarState.peeking = true;
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<SidebarSection label="Projects" collapsible={{ open: true, onOpenChange: vi.fn() }}>
|
||||
<a href="/projects">Projects</a>
|
||||
</SidebarSection>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const label = Array.from(container.querySelectorAll("span"))
|
||||
.find((element) => element.textContent === "Projects");
|
||||
expect(label?.className).not.toContain("sr-only");
|
||||
expect(container.querySelector('button[aria-label="Collapse Projects"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps section header controls visible on mobile", async () => {
|
||||
sidebarState.isMobile = true;
|
||||
const currentRoot = createRoot(container);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSidebarNavExpanded } from "./SidebarNavItem";
|
||||
|
||||
type SidebarSectionIcon = ComponentType<{ className?: string }>;
|
||||
|
||||
@@ -189,8 +190,34 @@ export function SidebarSection({
|
||||
menu,
|
||||
headerAction,
|
||||
}: SidebarSectionProps) {
|
||||
const { collapsed, peeking } = useSidebar();
|
||||
const forceExpanded = useSidebarNavExpanded();
|
||||
const rail = collapsed && !peeking && !forceExpanded;
|
||||
const content = <div className="flex flex-col gap-0.5 mt-0.5">{children}</div>;
|
||||
|
||||
// Collapsed rail: the section header would only show a clipped sliver of its
|
||||
// label, so replace it with a thin divider. The label stays in the a11y tree,
|
||||
// per-section carets/menus are dropped (no room + no toggle target in the
|
||||
// rail), and the items always render so their icons stay reachable.
|
||||
//
|
||||
// The header wrapper mirrors the expanded header's vertical footprint exactly
|
||||
// (outer `px-3 py-1.5 pointer-coarse:py-1` + inner `min-h-6`) so item icons land
|
||||
// at the identical y-position in both states — no movement on collapse/expand
|
||||
// (PAP-10676). The divider is vertically centered within that same row.
|
||||
if (rail) {
|
||||
return (
|
||||
<div>
|
||||
<div className="px-3 py-1.5 pointer-coarse:py-1">
|
||||
<div className="flex min-h-6 items-center">
|
||||
<span className="sr-only">{label}</span>
|
||||
<div className="h-px w-full bg-border/60" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (collapsible) {
|
||||
return (
|
||||
<Collapsible open={collapsible.open} onOpenChange={collapsible.onOpenChange}>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// @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 { SidebarShell, SIDEBAR_RAIL_WIDTH } from "./SidebarShell";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function pointerEvent(type: string, clientX: number) {
|
||||
const event = new MouseEvent(type, { bubbles: true, clientX });
|
||||
Object.defineProperty(event, "pointerId", { value: 1 });
|
||||
return event;
|
||||
}
|
||||
|
||||
describe("SidebarShell", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
// The in-flow spacer that reserves layout width.
|
||||
function spacer() {
|
||||
return container.firstElementChild as HTMLDivElement;
|
||||
}
|
||||
|
||||
// The absolutely-positioned overlay panel that holds the sidebar content.
|
||||
function panel() {
|
||||
return spacer().firstElementChild as HTMLDivElement;
|
||||
}
|
||||
|
||||
function handle() {
|
||||
return container.querySelector('[role="separator"]') as HTMLDivElement | null;
|
||||
}
|
||||
|
||||
it("uses a persisted width when expanded", () => {
|
||||
window.localStorage.setItem("test.sidebar.width", "320");
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarShell open resizable storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</SidebarShell>,
|
||||
);
|
||||
});
|
||||
|
||||
// Both the reserved spacer and the panel match the expanded width; no overlay.
|
||||
expect(spacer().style.width).toBe("320px");
|
||||
expect(panel().style.width).toBe("320px");
|
||||
expect(panel().getAttribute("data-sidebar-overlay")).toBeNull();
|
||||
expect(handle()?.getAttribute("aria-valuenow")).toBe("320");
|
||||
});
|
||||
|
||||
it("resizes by dragging and persists the new width", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarShell open resizable storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</SidebarShell>,
|
||||
);
|
||||
});
|
||||
|
||||
const separator = handle();
|
||||
expect(separator).not.toBeNull();
|
||||
separator!.setPointerCapture = vi.fn();
|
||||
|
||||
act(() => {
|
||||
separator!.dispatchEvent(pointerEvent("pointerdown", 240));
|
||||
separator!.dispatchEvent(pointerEvent("pointermove", 320));
|
||||
separator!.dispatchEvent(pointerEvent("pointerup", 320));
|
||||
});
|
||||
|
||||
expect(panel().style.width).toBe("320px");
|
||||
expect(window.localStorage.getItem("test.sidebar.width")).toBe("320");
|
||||
});
|
||||
|
||||
it("supports keyboard resizing and clamps to the configured bounds", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarShell open resizable storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</SidebarShell>,
|
||||
);
|
||||
});
|
||||
|
||||
const separator = handle();
|
||||
act(() => {
|
||||
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||
});
|
||||
expect(panel().style.width).toBe("256px");
|
||||
expect(window.localStorage.getItem("test.sidebar.width")).toBe("256");
|
||||
|
||||
act(() => {
|
||||
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
|
||||
});
|
||||
expect(panel().style.width).toBe("208px");
|
||||
|
||||
act(() => {
|
||||
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
|
||||
});
|
||||
expect(panel().style.width).toBe("420px");
|
||||
});
|
||||
|
||||
it("can render without a resize handle", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarShell open resizable={false}>
|
||||
<div>Sidebar</div>
|
||||
</SidebarShell>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(handle()).toBeNull();
|
||||
expect(panel().style.width).toBe("240px");
|
||||
});
|
||||
|
||||
it("reserves only the rail width when collapsed and hides the resize handle", () => {
|
||||
window.localStorage.setItem("test.sidebar.width", "320");
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarShell open collapsed resizable storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</SidebarShell>,
|
||||
);
|
||||
});
|
||||
|
||||
// Reserved spacer and panel both collapse to the rail; content never reflows
|
||||
// beyond the rail, and the rail is not user-resizable.
|
||||
expect(spacer().style.width).toBe(`${SIDEBAR_RAIL_WIDTH}px`);
|
||||
expect(panel().style.width).toBe(`${SIDEBAR_RAIL_WIDTH}px`);
|
||||
expect(panel().getAttribute("data-sidebar-overlay")).toBeNull();
|
||||
expect(handle()).toBeNull();
|
||||
});
|
||||
|
||||
it("hides all sidebar width when closed, even if pinned collapsed", () => {
|
||||
window.localStorage.setItem("test.sidebar.width", "320");
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarShell open={false} collapsed resizable storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</SidebarShell>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(spacer().style.width).toBe("0px");
|
||||
expect(panel().style.width).toBe("0px");
|
||||
expect(panel().getAttribute("data-sidebar-overlay")).toBeNull();
|
||||
expect(handle()).toBeNull();
|
||||
});
|
||||
|
||||
it("overlays content while peeking without expanding the reserved spacer", () => {
|
||||
window.localStorage.setItem("test.sidebar.width", "300");
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarShell open collapsed peeking storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</SidebarShell>,
|
||||
);
|
||||
});
|
||||
|
||||
// The reserved spacer stays at the rail (no page reflow) while the panel
|
||||
// grows to the expanded width and gets overlay styling.
|
||||
expect(spacer().style.width).toBe(`${SIDEBAR_RAIL_WIDTH}px`);
|
||||
expect(panel().style.width).toBe("300px");
|
||||
expect(panel().getAttribute("data-sidebar-overlay")).toBe("");
|
||||
expect(panel().className).toContain("shadow-lg");
|
||||
expect(panel().className).toContain("z-30");
|
||||
});
|
||||
|
||||
it("opens and closes instantly with no width transition (PAP-10676)", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarShell open>
|
||||
<div>Sidebar</div>
|
||||
</SidebarShell>,
|
||||
);
|
||||
});
|
||||
|
||||
// Open/close must be instant: the panel never animates its width, so neither
|
||||
// the transition nor its reduced-motion fallback should be present.
|
||||
expect(panel().className).not.toContain("transition-[width]");
|
||||
expect(panel().className).not.toContain("motion-reduce:transition-none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FocusEventHandler,
|
||||
type KeyboardEvent,
|
||||
type MouseEventHandler,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DEFAULT_SIDEBAR_WIDTH = 240;
|
||||
const MIN_SIDEBAR_WIDTH = 208;
|
||||
const MAX_SIDEBAR_WIDTH = 420;
|
||||
const SIDEBAR_WIDTH_STEP = 16;
|
||||
|
||||
// Collapsed icon rail. Width is chosen so the icon is *centered* in the rail,
|
||||
// which keeps the active/hover highlight symmetric around it (PAP-10676):
|
||||
// the icon's left edge sits at nav px-3 (12) + item px-3 (12) = 24px, so a
|
||||
// matching 24px trailing margin (12 item px-3 + 12 nav px-3) yields
|
||||
// 24 + 16 (icon) + 24 = 64. The icon's left edge is unchanged from the expanded
|
||||
// layout, so icons stay pixel-identical across states.
|
||||
export const SIDEBAR_RAIL_WIDTH = 64;
|
||||
|
||||
function clampSidebarWidth(width: number) {
|
||||
return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, width));
|
||||
}
|
||||
|
||||
function readStoredSidebarWidth(storageKey: string) {
|
||||
if (typeof window === "undefined") return DEFAULT_SIDEBAR_WIDTH;
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(storageKey);
|
||||
if (!stored) return DEFAULT_SIDEBAR_WIDTH;
|
||||
const parsed = Number.parseInt(stored, 10);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_SIDEBAR_WIDTH;
|
||||
return clampSidebarWidth(parsed);
|
||||
} catch {
|
||||
return DEFAULT_SIDEBAR_WIDTH;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredSidebarWidth(storageKey: string, width: number) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, String(clampSidebarWidth(width)));
|
||||
} catch {
|
||||
// Storage can be unavailable in private contexts; resizing should still work.
|
||||
}
|
||||
}
|
||||
|
||||
type SidebarShellProps = {
|
||||
children: ReactNode;
|
||||
/** Whether the sidebar occupies space at all (mobile back-compat / hidden). */
|
||||
open: boolean;
|
||||
/** Pinned collapsed (rail) mode. Desktop-only; the caller gates this. */
|
||||
collapsed?: boolean;
|
||||
/** Ephemeral hover/focus peek. Only meaningful while collapsed. */
|
||||
peeking?: boolean;
|
||||
resizable?: boolean;
|
||||
storageKey?: string;
|
||||
className?: string;
|
||||
/** Forwarded to the overlay panel so Layout can wire peek triggers. */
|
||||
onPanelMouseEnter?: MouseEventHandler<HTMLDivElement>;
|
||||
onPanelMouseLeave?: MouseEventHandler<HTMLDivElement>;
|
||||
onPanelFocusCapture?: FocusEventHandler<HTMLDivElement>;
|
||||
onPanelBlurCapture?: FocusEventHandler<HTMLDivElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Layout shell for the desktop sidebar. Owns the persisted expanded width and
|
||||
* renders two layers:
|
||||
* - an in-flow spacer that reserves `reservedWidth` (rail when collapsed,
|
||||
* expanded width otherwise) so content to the right only reflows on pin, and
|
||||
* - an absolutely-positioned panel of `panelWidth` (rail at rest, expanded
|
||||
* while peeking) that overlays content — with shadow/raised z-index — when it
|
||||
* is wider than the reserved spacer.
|
||||
*
|
||||
* The icon-alignment guarantee comes for free: collapsing is a pure width
|
||||
* change with `overflow-hidden` clipping the labels; item padding/icon markup
|
||||
* are never touched, so the left-aligned icon stays pixel-identical.
|
||||
*
|
||||
* Opening/closing is intentionally instant (no width transition): the pin toggle
|
||||
* and peek snap between rail and expanded widths so the content never appears to
|
||||
* slide. Resizing the drag handle is likewise direct.
|
||||
*/
|
||||
export function SidebarShell({
|
||||
children,
|
||||
open,
|
||||
collapsed = false,
|
||||
peeking = false,
|
||||
resizable = false,
|
||||
storageKey = "paperclip.sidebar.width",
|
||||
className,
|
||||
onPanelMouseEnter,
|
||||
onPanelMouseLeave,
|
||||
onPanelFocusCapture,
|
||||
onPanelBlurCapture,
|
||||
}: SidebarShellProps) {
|
||||
const [width, setWidth] = useState(() => readStoredSidebarWidth(storageKey));
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const widthRef = useRef(width);
|
||||
const dragState = useRef<{ startX: number; startWidth: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const storedWidth = readStoredSidebarWidth(storageKey);
|
||||
widthRef.current = storedWidth;
|
||||
setWidth(storedWidth);
|
||||
}, [storageKey]);
|
||||
|
||||
// Unified width function (plan §5): one code path for every pinned state.
|
||||
const expandedWidth = open ? width : 0;
|
||||
const reservedWidth = !open ? 0 : collapsed ? SIDEBAR_RAIL_WIDTH : expandedWidth;
|
||||
const panelWidth = !open ? 0 : collapsed && !peeking ? SIDEBAR_RAIL_WIDTH : expandedWidth;
|
||||
const isOverlay = panelWidth > reservedWidth;
|
||||
|
||||
// The drag handle can only resize the expanded width, so it is disabled while
|
||||
// collapsed (the rail width is a fixed constant, not user-resizable).
|
||||
const canResize = resizable && open && !collapsed;
|
||||
|
||||
const reservedStyle = useMemo(
|
||||
() => ({ width: `${reservedWidth}px` }),
|
||||
[reservedWidth],
|
||||
);
|
||||
const panelStyle = useMemo(
|
||||
() => ({ width: `${panelWidth}px` }),
|
||||
[panelWidth],
|
||||
);
|
||||
|
||||
const commitWidth = useCallback(
|
||||
(nextWidth: number) => {
|
||||
const clamped = clampSidebarWidth(nextWidth);
|
||||
widthRef.current = clamped;
|
||||
setWidth(clamped);
|
||||
writeStoredSidebarWidth(storageKey, clamped);
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!canResize) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragState.current = { startX: event.clientX, startWidth: widthRef.current };
|
||||
setIsResizing(true);
|
||||
},
|
||||
[canResize],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragState.current) return;
|
||||
|
||||
const nextWidth = dragState.current.startWidth + event.clientX - dragState.current.startX;
|
||||
const clamped = clampSidebarWidth(nextWidth);
|
||||
widthRef.current = clamped;
|
||||
setWidth(clamped);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const endResize = useCallback(() => {
|
||||
if (!dragState.current) return;
|
||||
|
||||
dragState.current = null;
|
||||
setIsResizing(false);
|
||||
writeStoredSidebarWidth(storageKey, widthRef.current);
|
||||
}, [storageKey]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!canResize) return;
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
commitWidth(width - SIDEBAR_WIDTH_STEP);
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
commitWidth(width + SIDEBAR_WIDTH_STEP);
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
commitWidth(MIN_SIDEBAR_WIDTH);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
commitWidth(MAX_SIDEBAR_WIDTH);
|
||||
}
|
||||
},
|
||||
[canResize, commitWidth, width],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("relative h-full shrink-0", className)} style={reservedStyle}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 flex flex-col overflow-hidden",
|
||||
// Open/close is instant (PAP-10676): no width transition so the rail and
|
||||
// expanded states snap without any sliding motion.
|
||||
// Overlay styling only while the panel is wider than its reserved
|
||||
// spacer (i.e. peeking) so it floats above content without reflow.
|
||||
isOverlay
|
||||
? "z-30 border-r border-border bg-background shadow-lg"
|
||||
: "z-0",
|
||||
)}
|
||||
style={panelStyle}
|
||||
data-sidebar-overlay={isOverlay ? "" : undefined}
|
||||
onMouseEnter={onPanelMouseEnter}
|
||||
onMouseLeave={onPanelMouseLeave}
|
||||
onFocusCapture={onPanelFocusCapture}
|
||||
onBlurCapture={onPanelBlurCapture}
|
||||
>
|
||||
{children}
|
||||
{canResize ? (
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize sidebar"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={MIN_SIDEBAR_WIDTH}
|
||||
aria-valuemax={MAX_SIDEBAR_WIDTH}
|
||||
aria-valuenow={width}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"absolute inset-y-0 right-0 z-20 w-3 cursor-col-resize touch-none outline-none",
|
||||
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors",
|
||||
"hover:before:bg-border focus-visible:before:bg-ring",
|
||||
isResizing && "before:bg-ring",
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={endResize}
|
||||
onPointerCancel={endResize}
|
||||
onLostPointerCapture={endResize}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// @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 "./SidebarContext";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const COLLAPSED_STORAGE_KEY = "paperclip.sidebar.collapsed";
|
||||
|
||||
// Mutable media state driving the matchMedia mock.
|
||||
const mediaState = { mobile: false, hoverFine: true };
|
||||
|
||||
function setViewport({ mobile, hoverFine }: { mobile?: boolean; hoverFine?: boolean }) {
|
||||
if (typeof mobile === "boolean") mediaState.mobile = mobile;
|
||||
if (typeof hoverFine === "boolean") mediaState.hoverFine = hoverFine;
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: mediaState.mobile ? 500 : 1280,
|
||||
});
|
||||
}
|
||||
|
||||
let capturedValue: ReturnType<typeof useSidebar> | null = null;
|
||||
|
||||
function Capture() {
|
||||
capturedValue = useSidebar();
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderProvider(): { root: Root; host: HTMLDivElement } {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<Capture />
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
return { root, host };
|
||||
}
|
||||
|
||||
describe("SidebarContext", () => {
|
||||
let active: { root: Root; host: HTMLDivElement } | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
capturedValue = null;
|
||||
setViewport({ mobile: false, hoverFine: true });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => {
|
||||
const isHoverQuery = query.includes("(hover: hover)");
|
||||
const matches = isHoverQuery ? mediaState.hoverFine : mediaState.mobile;
|
||||
return {
|
||||
matches,
|
||||
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();
|
||||
});
|
||||
|
||||
describe("precedence: user pin > route request > default", () => {
|
||||
it("defaults to expanded (collapsed=false) with no pin and no route request", () => {
|
||||
active = renderProvider();
|
||||
expect(capturedValue?.collapsed).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the route request when there is no user pin", () => {
|
||||
active = renderProvider();
|
||||
act(() => capturedValue?.setRouteRequestsCollapsed(true));
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it("lets an explicit user pin override the route request", () => {
|
||||
active = renderProvider();
|
||||
act(() => capturedValue?.setRouteRequestsCollapsed(true));
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
|
||||
// User pins expanded — this must win over the route's collapse request.
|
||||
act(() => capturedValue?.setCollapsed(false));
|
||||
expect(capturedValue?.collapsed).toBe(false);
|
||||
|
||||
// And pinning collapsed wins too.
|
||||
act(() => capturedValue?.setCollapsed(true));
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it("toggleCollapsed flips the effective mode and records a pin", () => {
|
||||
active = renderProvider();
|
||||
expect(capturedValue?.collapsed).toBe(false);
|
||||
|
||||
act(() => capturedValue?.toggleCollapsed());
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("1");
|
||||
|
||||
act(() => capturedValue?.toggleCollapsed());
|
||||
expect(capturedValue?.collapsed).toBe(false);
|
||||
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("0");
|
||||
});
|
||||
|
||||
it("toggleCollapsed pins expanded when only a route request is active", () => {
|
||||
active = renderProvider();
|
||||
act(() => capturedValue?.setRouteRequestsCollapsed(true));
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
|
||||
// Effective is collapsed (via route); toggling should flip to expanded.
|
||||
act(() => capturedValue?.toggleCollapsed());
|
||||
expect(capturedValue?.collapsed).toBe(false);
|
||||
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("forced collapse (secondary sidebar): overrides the pin, preserves preference", () => {
|
||||
it("forces collapsed even when the user pinned expanded, without mutating the pin", () => {
|
||||
active = renderProvider();
|
||||
// User prefers expanded site-wide.
|
||||
act(() => capturedValue?.setCollapsed(false));
|
||||
expect(capturedValue?.collapsed).toBe(false);
|
||||
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("0");
|
||||
|
||||
// Entering a secondary-sidebar route forces the rail and locks it.
|
||||
act(() => capturedValue?.setForceCollapsed(true));
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
expect(capturedValue?.collapseLocked).toBe(true);
|
||||
// The persisted preference is untouched.
|
||||
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("0");
|
||||
});
|
||||
|
||||
it("restores the user's preference when the force is cleared (leaving the route)", () => {
|
||||
active = renderProvider();
|
||||
act(() => capturedValue?.setCollapsed(false));
|
||||
act(() => capturedValue?.setForceCollapsed(true));
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
|
||||
// Navigating away clears the force; the expanded preference returns.
|
||||
act(() => capturedValue?.setForceCollapsed(false));
|
||||
expect(capturedValue?.collapsed).toBe(false);
|
||||
expect(capturedValue?.collapseLocked).toBe(false);
|
||||
});
|
||||
|
||||
it("locks the toggle while forced: toggleCollapsed is a no-op and never writes the pin", () => {
|
||||
active = renderProvider();
|
||||
act(() => capturedValue?.setCollapsed(false));
|
||||
act(() => capturedValue?.setForceCollapsed(true));
|
||||
|
||||
act(() => capturedValue?.toggleCollapsed());
|
||||
expect(capturedValue?.collapsed).toBe(true); // still forced
|
||||
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("0"); // pin unchanged
|
||||
});
|
||||
|
||||
it("never forces or locks on mobile", () => {
|
||||
setViewport({ mobile: true });
|
||||
active = renderProvider();
|
||||
act(() => capturedValue?.setForceCollapsed(true));
|
||||
expect(capturedValue?.collapsed).toBe(false);
|
||||
expect(capturedValue?.collapseLocked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistence round-trip", () => {
|
||||
it("writes '1'/'0' to localStorage on setCollapsed", () => {
|
||||
active = renderProvider();
|
||||
act(() => capturedValue?.setCollapsed(true));
|
||||
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("1");
|
||||
act(() => capturedValue?.setCollapsed(false));
|
||||
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("0");
|
||||
});
|
||||
|
||||
it("reads the persisted pin synchronously on first paint (no flash)", () => {
|
||||
localStorage.setItem(COLLAPSED_STORAGE_KEY, "1");
|
||||
active = renderProvider();
|
||||
// Collapsed is already true on the very first captured render.
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a missing/garbage value as unpinned", () => {
|
||||
localStorage.setItem(COLLAPSED_STORAGE_KEY, "yes");
|
||||
active = renderProvider();
|
||||
expect(capturedValue?.collapsed).toBe(false);
|
||||
// Unpinned, so a route request still applies.
|
||||
act(() => capturedValue?.setRouteRequestsCollapsed(true));
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobile gating", () => {
|
||||
it("never reports collapsed on mobile even with a collapsed pin", () => {
|
||||
localStorage.setItem(COLLAPSED_STORAGE_KEY, "1");
|
||||
setViewport({ mobile: true });
|
||||
active = renderProvider();
|
||||
expect(capturedValue?.isMobile).toBe(true);
|
||||
expect(capturedValue?.collapsed).toBe(false);
|
||||
});
|
||||
|
||||
it("never reports peeking on mobile", () => {
|
||||
localStorage.setItem(COLLAPSED_STORAGE_KEY, "1");
|
||||
setViewport({ mobile: true });
|
||||
active = renderProvider();
|
||||
act(() => capturedValue?.setPeeking(true));
|
||||
expect(capturedValue?.peeking).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("peek gating", () => {
|
||||
it("peeks when collapsed on a hover-capable pointer", () => {
|
||||
localStorage.setItem(COLLAPSED_STORAGE_KEY, "1");
|
||||
setViewport({ mobile: false, hoverFine: true });
|
||||
active = renderProvider();
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
act(() => capturedValue?.setPeeking(true));
|
||||
expect(capturedValue?.peeking).toBe(true);
|
||||
});
|
||||
|
||||
it("does not peek when expanded", () => {
|
||||
setViewport({ mobile: false, hoverFine: true });
|
||||
active = renderProvider();
|
||||
expect(capturedValue?.collapsed).toBe(false);
|
||||
act(() => capturedValue?.setPeeking(true));
|
||||
expect(capturedValue?.peeking).toBe(false);
|
||||
});
|
||||
|
||||
it("does not peek on a coarse/non-hover pointer", () => {
|
||||
localStorage.setItem(COLLAPSED_STORAGE_KEY, "1");
|
||||
setViewport({ mobile: false, hoverFine: false });
|
||||
active = renderProvider();
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
act(() => capturedValue?.setPeeking(true));
|
||||
expect(capturedValue?.peeking).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("back-compat", () => {
|
||||
it("retains sidebarOpen/toggleSidebar for the drawer", () => {
|
||||
active = renderProvider();
|
||||
const initial = capturedValue?.sidebarOpen;
|
||||
expect(typeof initial).toBe("boolean");
|
||||
act(() => capturedValue?.toggleSidebar());
|
||||
expect(capturedValue?.sidebarOpen).toBe(!initial);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,104 @@
|
||||
import { createContext, useCallback, useContext, useState, useEffect, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
interface SidebarContextValue {
|
||||
// Mobile drawer + back-compat (existing behavior, unchanged).
|
||||
sidebarOpen: boolean;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
toggleSidebar: () => void;
|
||||
isMobile: boolean;
|
||||
// Pinned desktop mode: expanded | collapsed. Desktop-only.
|
||||
collapsed: boolean;
|
||||
setCollapsed: (next: boolean) => void;
|
||||
toggleCollapsed: () => void;
|
||||
// True while a secondary sidebar forces the rail: the collapse is locked, so
|
||||
// the expand/toggle affordance must be hidden/inert. Desktop-only.
|
||||
collapseLocked: boolean;
|
||||
// Ephemeral peek (hover flyout). Only meaningful on desktop, collapsed,
|
||||
// hover-capable pointer. Never persisted.
|
||||
peeking: boolean;
|
||||
setPeeking: (next: boolean) => void;
|
||||
// Hard, ephemeral collapse forced by an active secondary sidebar (settings,
|
||||
// plugin `routeSidebar`, …). HIGHER precedence than the user pin — the rule
|
||||
// is "a secondary sidebar always collapses the primary" — but it never
|
||||
// mutates the persisted pin, so leaving the route restores the preference.
|
||||
// Wired by Layout (PAP-10694).
|
||||
forceCollapsed: boolean;
|
||||
setForceCollapsed: (next: boolean) => void;
|
||||
// Route-requested collapse: a route may *default* the app sidebar to
|
||||
// collapsed. LOWER precedence than an explicit user pin. Wired by routes via
|
||||
// RequestCollapsedSidebar.
|
||||
routeRequestsCollapsed: boolean;
|
||||
setRouteRequestsCollapsed: (next: boolean) => void;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextValue | null>(null);
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const COLLAPSED_STORAGE_KEY = "paperclip.sidebar.collapsed";
|
||||
const PEEK_POINTER_QUERY = "(hover: hover) and (pointer: fine)";
|
||||
|
||||
// Tri-state read of the persisted user pin:
|
||||
// true → pinned collapsed ("1")
|
||||
// false → pinned expanded ("0")
|
||||
// null → no pin (fall through to route request, then global default)
|
||||
// Read synchronously in the state initializer so first paint matches the
|
||||
// persisted mode (mirrors the `paperclip.sidebar.width` pattern in
|
||||
// ResizableSidebarPane and avoids an expand→collapse flash).
|
||||
function readStoredCollapsed(): boolean | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(COLLAPSED_STORAGE_KEY);
|
||||
if (stored === "1") return true;
|
||||
if (stored === "0") return false;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredCollapsed(value: boolean) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(COLLAPSED_STORAGE_KEY, value ? "1" : "0");
|
||||
} catch {
|
||||
// Storage can be unavailable in private contexts; pinning should still
|
||||
// work for the current session.
|
||||
}
|
||||
}
|
||||
|
||||
function readPointerCanPeek(): boolean {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.matchMedia(PEEK_POINTER_QUERY).matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth >= MOBILE_BREAKPOINT);
|
||||
|
||||
// `null` = unpinned; an explicit user pin takes precedence over route request.
|
||||
const [userCollapsed, setUserCollapsed] = useState<boolean | null>(() => readStoredCollapsed());
|
||||
const [routeRequestsCollapsed, setRouteRequestsCollapsed] = useState(false);
|
||||
const [forceCollapsed, setForceCollapsed] = useState(false);
|
||||
const [rawPeeking, setRawPeeking] = useState(false);
|
||||
const [pointerCanPeek, setPointerCanPeek] = useState(() => readPointerCanPeek());
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = (e: MediaQueryListEvent) => {
|
||||
@@ -25,13 +109,81 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") return;
|
||||
const mql = window.matchMedia(PEEK_POINTER_QUERY);
|
||||
const onChange = (e: MediaQueryListEvent) => setPointerCanPeek(e.matches);
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
// Precedence (highest wins): forced (active secondary sidebar) > explicit user
|
||||
// pin > route request > default expanded. The force is ephemeral and never
|
||||
// touches the persisted pin, so dropping it restores the user's preference.
|
||||
const pinnedOrRequested = userCollapsed !== null ? userCollapsed : routeRequestsCollapsed;
|
||||
const desktopCollapsed = forceCollapsed || pinnedOrRequested;
|
||||
// Collapsed/peek are desktop-only; mobile always uses the drawer. The user
|
||||
// pin is preserved across the breakpoint and reapplies on the desktop side.
|
||||
const collapsed = isMobile ? false : desktopCollapsed;
|
||||
// While forced, the pin is locked: the expand/toggle affordance is inert.
|
||||
const collapseLocked = !isMobile && forceCollapsed;
|
||||
// Peek only applies when collapsed on a hover-capable pointer.
|
||||
const peeking = rawPeeking && collapsed && pointerCanPeek;
|
||||
|
||||
const setCollapsed = useCallback((next: boolean) => {
|
||||
setUserCollapsed(next);
|
||||
writeStoredCollapsed(next);
|
||||
}, []);
|
||||
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
// While a secondary sidebar forces the rail, the toggle is locked: it must
|
||||
// neither expand the rail nor mutate the persisted preference.
|
||||
if (forceCollapsed) return;
|
||||
setCollapsed(!pinnedOrRequested);
|
||||
}, [forceCollapsed, pinnedOrRequested, setCollapsed]);
|
||||
|
||||
const setPeeking = useCallback((next: boolean) => {
|
||||
setRawPeeking(next);
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = useCallback(() => setSidebarOpen((v) => !v), []);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ sidebarOpen, setSidebarOpen, toggleSidebar, isMobile }}>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
const value = useMemo<SidebarContextValue>(
|
||||
() => ({
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
toggleSidebar,
|
||||
isMobile,
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
toggleCollapsed,
|
||||
collapseLocked,
|
||||
peeking,
|
||||
setPeeking,
|
||||
forceCollapsed,
|
||||
setForceCollapsed,
|
||||
routeRequestsCollapsed,
|
||||
setRouteRequestsCollapsed,
|
||||
}),
|
||||
[
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
toggleSidebar,
|
||||
isMobile,
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
toggleCollapsed,
|
||||
collapseLocked,
|
||||
peeking,
|
||||
setPeeking,
|
||||
forceCollapsed,
|
||||
setForceCollapsed,
|
||||
routeRequestsCollapsed,
|
||||
setRouteRequestsCollapsed,
|
||||
],
|
||||
);
|
||||
|
||||
return <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSidebar() {
|
||||
|
||||
@@ -11,14 +11,17 @@ import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
|
||||
function TestHarness({
|
||||
onNewIssue,
|
||||
onSearch,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
onNewIssue: () => void;
|
||||
onSearch?: () => void;
|
||||
onToggleCollapse?: () => void;
|
||||
}) {
|
||||
useKeyboardShortcuts({
|
||||
enabled: true,
|
||||
onNewIssue,
|
||||
onSearch,
|
||||
onToggleCollapse,
|
||||
});
|
||||
|
||||
return <div>keyboard shortcuts test</div>;
|
||||
@@ -106,4 +109,53 @@ describe("useKeyboardShortcuts", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("fires onToggleCollapse on Cmd/Ctrl+B", () => {
|
||||
const root = createRoot(container);
|
||||
const onToggleCollapse = vi.fn();
|
||||
|
||||
act(() => {
|
||||
root.render(<TestHarness onNewIssue={vi.fn()} onToggleCollapse={onToggleCollapse} />);
|
||||
});
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "b",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
expect(onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "b",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
expect(onToggleCollapse).toHaveBeenCalledTimes(2);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fire onToggleCollapse for a bare 'b' keypress", () => {
|
||||
const root = createRoot(container);
|
||||
const onToggleCollapse = vi.fn();
|
||||
|
||||
act(() => {
|
||||
root.render(<TestHarness onNewIssue={vi.fn()} onToggleCollapse={onToggleCollapse} />);
|
||||
});
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "b",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
expect(onToggleCollapse).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ShortcutHandlers {
|
||||
onNewIssue?: () => void;
|
||||
onSearch?: () => void;
|
||||
onToggleSidebar?: () => void;
|
||||
onToggleCollapse?: () => void;
|
||||
onTogglePanel?: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
}
|
||||
@@ -19,6 +20,7 @@ export function useKeyboardShortcuts({
|
||||
onNewIssue,
|
||||
onSearch,
|
||||
onToggleSidebar,
|
||||
onToggleCollapse,
|
||||
onTogglePanel,
|
||||
onShowShortcuts,
|
||||
}: ShortcutHandlers) {
|
||||
@@ -67,6 +69,12 @@ export function useKeyboardShortcuts({
|
||||
onToggleSidebar?.();
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+B → Collapse/expand sidebar (desktop) or toggle drawer (mobile)
|
||||
if ((e.key === "b" || e.key === "B") && (e.metaKey || e.ctrlKey) && !e.altKey) {
|
||||
e.preventDefault();
|
||||
onToggleCollapse?.();
|
||||
}
|
||||
|
||||
// ] → Toggle Panel
|
||||
if (e.key === "]" && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
@@ -76,5 +84,5 @@ export function useKeyboardShortcuts({
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [enabled, onNewIssue, onSearch, onToggleSidebar, onTogglePanel, onShowShortcuts]);
|
||||
}, [enabled, onNewIssue, onSearch, onToggleSidebar, onToggleCollapse, onTogglePanel, onShowShortcuts]);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,18 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Classes for a sidebar row label when the sidebar is collapsed to the icon rail
|
||||
* (PAP-10676). Unlike `sr-only` (which is `position: absolute` and therefore
|
||||
* removes the label from flow), this keeps the label in flow so it still
|
||||
* contributes its line-height to the row. That guarantees a row is the *exact*
|
||||
* same height collapsed as expanded, so the icons never shift vertically between
|
||||
* states. The label is clipped to zero visible width and rendered transparent,
|
||||
* but stays in the DOM and the a11y tree as the link's accessible name.
|
||||
*/
|
||||
export const SIDEBAR_RAIL_HIDDEN_LABEL =
|
||||
"block w-0 min-w-0 overflow-hidden whitespace-nowrap text-transparent select-none";
|
||||
|
||||
export function asObject(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { KeyboardShortcutsCheatsheetContent } from "@/components/KeyboardShortcu
|
||||
import { MobileBottomNav } from "@/components/MobileBottomNav";
|
||||
import { PageTabBar } from "@/components/PageTabBar";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { SidebarAccountMenu } from "@/components/SidebarAccountMenu";
|
||||
import { SidebarCompanyMenu } from "@/components/SidebarCompanyMenu";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
@@ -349,3 +350,30 @@ export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const BoardChromeMatrix: Story = {};
|
||||
|
||||
// PAP-10676 verification harness: renders the real Sidebar at a fixed width so a
|
||||
// screenshot of the expanded state and a screenshot of the pinned-collapsed rail
|
||||
// can be overlaid. The icon column must be pixel-identical between the two — the
|
||||
// only difference should be the labels (sr-only in the rail). Playwright toggles
|
||||
// collapse via the in-sidebar control, so this exercises the real context path.
|
||||
function SidebarIconAlignmentHarness() {
|
||||
return (
|
||||
<PluginLauncherProvider>
|
||||
<div className="paperclip-story">
|
||||
<RouteSetter to="/PAP/projects/board-ui/issues" />
|
||||
<div className="flex min-h-[760px] items-start justify-center bg-muted/30 p-8">
|
||||
<div
|
||||
data-testid="sidebar-align-frame"
|
||||
className="h-[700px] w-60 overflow-hidden border border-border bg-background"
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PluginLauncherProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const SidebarIconAlignment: Story = {
|
||||
render: () => <SidebarIconAlignmentHarness />,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user