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`
|
- `commentAnnotation`
|
||||||
- `commentContextMenuItem`
|
- `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
|
## Shared host components
|
||||||
|
|
||||||
Use shared components from `@paperclipai/plugin-sdk/ui` when the plugin needs a
|
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
|
- global plugin settings
|
||||||
- company-context plugin pages
|
- 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`
|
## 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.
|
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`
|
#### `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`
|
#### `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 {
|
interface ShortcutEntry {
|
||||||
keys: string[];
|
keys: string[];
|
||||||
label: 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 {
|
interface ShortcutSection {
|
||||||
title: string;
|
title: string;
|
||||||
shortcuts: ShortcutEntry[];
|
shortcuts: ShortcutEntry[];
|
||||||
@@ -41,6 +55,7 @@ const sections: ShortcutSection[] = [
|
|||||||
{ keys: ["/"], label: "Search current page or quick search" },
|
{ keys: ["/"], label: "Search current page or quick search" },
|
||||||
{ keys: ["c"], label: "New task" },
|
{ keys: ["c"], label: "New task" },
|
||||||
{ keys: ["["], label: "Toggle sidebar" },
|
{ keys: ["["], label: "Toggle sidebar" },
|
||||||
|
{ keys: [META_KEY, "B"], label: "Collapse or expand sidebar", combo: true },
|
||||||
{ keys: ["]"], label: "Toggle panel" },
|
{ keys: ["]"], label: "Toggle panel" },
|
||||||
{ keys: ["?"], label: "Show keyboard shortcuts" },
|
{ keys: ["?"], label: "Show keyboard shortcuts" },
|
||||||
],
|
],
|
||||||
@@ -74,7 +89,11 @@ export function KeyboardShortcutsCheatsheetContent() {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{shortcut.keys.map((key, i) => (
|
{shortcut.keys.map((key, i) => (
|
||||||
<span key={key} className="flex items-center gap-1">
|
<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>
|
<KeyCap>{key}</KeyCap>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
|
|||||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||||
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
|
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
|
||||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||||
|
const mockSetForceCollapsed = vi.hoisted(() => vi.fn());
|
||||||
const mockCompanyState = vi.hoisted(() => ({
|
const mockCompanyState = vi.hoisted(() => ({
|
||||||
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
|
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
|
||||||
selectedCompany: { 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 mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||||||
const mockPluginSlotContexts = vi.hoisted(() => [] as Array<Record<string, unknown>>);
|
const mockPluginSlotContexts = vi.hoisted(() => [] as Array<Record<string, unknown>>);
|
||||||
|
const mockSetPeeking = vi.hoisted(() => vi.fn());
|
||||||
const mockSidebarState = vi.hoisted(() => ({
|
const mockSidebarState = vi.hoisted(() => ({
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
|
collapsed: false,
|
||||||
|
peeking: false,
|
||||||
}));
|
}));
|
||||||
let currentPathname = "/PAP/dashboard";
|
let currentPathname = "/PAP/dashboard";
|
||||||
|
|
||||||
@@ -167,7 +171,16 @@ vi.mock("../context/SidebarContext", () => ({
|
|||||||
sidebarOpen: mockSidebarState.sidebarOpen,
|
sidebarOpen: mockSidebarState.sidebarOpen,
|
||||||
setSidebarOpen: mockSetSidebarOpen,
|
setSidebarOpen: mockSetSidebarOpen,
|
||||||
toggleSidebar: vi.fn(),
|
toggleSidebar: vi.fn(),
|
||||||
|
toggleCollapsed: vi.fn(),
|
||||||
|
collapsed: mockSidebarState.collapsed,
|
||||||
|
collapseLocked: false,
|
||||||
|
peeking: mockSidebarState.peeking,
|
||||||
|
setPeeking: mockSetPeeking,
|
||||||
isMobile: mockSidebarState.isMobile,
|
isMobile: mockSidebarState.isMobile,
|
||||||
|
forceCollapsed: false,
|
||||||
|
setForceCollapsed: mockSetForceCollapsed,
|
||||||
|
routeRequestsCollapsed: false,
|
||||||
|
setRouteRequestsCollapsed: vi.fn(),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -236,6 +249,9 @@ describe("Layout", () => {
|
|||||||
mockPluginSlotContexts.length = 0;
|
mockPluginSlotContexts.length = 0;
|
||||||
mockSidebarState.sidebarOpen = true;
|
mockSidebarState.sidebarOpen = true;
|
||||||
mockSidebarState.isMobile = false;
|
mockSidebarState.isMobile = false;
|
||||||
|
mockSidebarState.collapsed = false;
|
||||||
|
mockSidebarState.peeking = false;
|
||||||
|
mockSetPeeking.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
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";
|
currentPathname = "/PAP/company/settings/access";
|
||||||
mockPluginSlots.slots = [
|
mockPluginSlots.slots = [
|
||||||
{
|
{
|
||||||
@@ -315,11 +405,15 @@ describe("Layout", () => {
|
|||||||
await flushReact();
|
await flushReact();
|
||||||
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("Company settings sidebar");
|
||||||
|
expect(container.textContent).toContain("Main company nav");
|
||||||
expect(container.textContent).not.toContain("Company rail");
|
expect(container.textContent).not.toContain("Company rail");
|
||||||
expect(container.textContent).not.toContain("Instance sidebar");
|
expect(container.textContent).not.toContain("Instance sidebar");
|
||||||
expect(container.textContent).not.toContain("Main company nav");
|
|
||||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
@@ -380,9 +474,10 @@ describe("Layout", () => {
|
|||||||
await flushReact();
|
await flushReact();
|
||||||
|
|
||||||
expect(container.textContent).toContain("Company settings sidebar");
|
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("Company rail");
|
||||||
expect(container.textContent).not.toContain("Main company nav");
|
|
||||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||||
|
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
@@ -430,11 +525,14 @@ describe("Layout", () => {
|
|||||||
await flushReact();
|
await flushReact();
|
||||||
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.textContent).toContain("Plugin route sidebar: Wiki Sidebar");
|
||||||
expect(container.querySelector("[data-plugin-slot-class='h-full w-full']")).not.toBeNull();
|
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("Company settings sidebar");
|
||||||
expect(container.textContent).not.toContain("Instance sidebar");
|
expect(container.textContent).not.toContain("Instance sidebar");
|
||||||
|
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
@@ -489,7 +587,7 @@ describe("Layout", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(container.textContent).toContain("Plugin route sidebar: Wiki Sidebar");
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
@@ -617,6 +715,9 @@ describe("Layout", () => {
|
|||||||
|
|
||||||
expect(container.textContent).toContain("Main company nav");
|
expect(container.textContent).toContain("Main company nav");
|
||||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
@@ -17,7 +17,8 @@ import { MobileBottomNav } from "./MobileBottomNav";
|
|||||||
import { WorktreeBanner } from "./WorktreeBanner";
|
import { WorktreeBanner } from "./WorktreeBanner";
|
||||||
import { DevRestartBanner } from "./DevRestartBanner";
|
import { DevRestartBanner } from "./DevRestartBanner";
|
||||||
import { StandaloneBrowserControls } from "./StandaloneBrowserControls";
|
import { StandaloneBrowserControls } from "./StandaloneBrowserControls";
|
||||||
import { ResizableSidebarPane } from "./ResizableSidebarPane";
|
import { SidebarShell } from "./SidebarShell";
|
||||||
|
import { SecondarySidebar } from "./SecondarySidebar";
|
||||||
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||||
import { useDialogActions } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||||
@@ -48,7 +49,17 @@ function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Layout() {
|
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 { openNewIssue, openOnboarding } = useDialogActions();
|
||||||
const { togglePanelVisible } = usePanel();
|
const { togglePanelVisible } = usePanel();
|
||||||
const {
|
const {
|
||||||
@@ -102,16 +113,21 @@ export function Layout() {
|
|||||||
}),
|
}),
|
||||||
[routeSidebarCompanyId, routeSidebarCompanyPrefix],
|
[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
|
<PluginSlotMount
|
||||||
slot={routeSidebarSlot}
|
slot={routeSidebarSlot}
|
||||||
context={sidebarContext}
|
context={sidebarContext}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
missingBehavior="placeholder"
|
missingBehavior="placeholder"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : null;
|
||||||
<Sidebar />
|
const hasSecondarySidebar = secondarySidebar != null;
|
||||||
);
|
|
||||||
const { data: health } = useQuery({
|
const { data: health } = useQuery({
|
||||||
queryKey: queryKeys.health,
|
queryKey: queryKeys.health,
|
||||||
queryFn: () => healthApi.get(),
|
queryFn: () => healthApi.get(),
|
||||||
@@ -127,6 +143,16 @@ export function Layout() {
|
|||||||
queryFn: () => instanceSettingsApi.getGeneral(),
|
queryFn: () => instanceSettingsApi.getGeneral(),
|
||||||
}).data?.keyboardShortcuts === true;
|
}).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(() => {
|
useEffect(() => {
|
||||||
if (companiesLoading || onboardingTriggered.current) return;
|
if (companiesLoading || onboardingTriggered.current) return;
|
||||||
if (health?.deploymentMode === "authenticated") return;
|
if (health?.deploymentMode === "authenticated") return;
|
||||||
@@ -178,6 +204,15 @@ export function Layout() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const togglePanel = togglePanelVisible;
|
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(() => {
|
const openSearch = useCallback(() => {
|
||||||
document.dispatchEvent(new KeyboardEvent("keydown", {
|
document.dispatchEvent(new KeyboardEvent("keydown", {
|
||||||
key: "k",
|
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();
|
useCompanyPageMemory();
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
@@ -194,6 +325,7 @@ export function Layout() {
|
|||||||
onNewIssue: () => openNewIssue(),
|
onNewIssue: () => openNewIssue(),
|
||||||
onSearch: openSearch,
|
onSearch: openSearch,
|
||||||
onToggleSidebar: toggleSidebar,
|
onToggleSidebar: toggleSidebar,
|
||||||
|
onToggleCollapse: toggleCollapse,
|
||||||
onTogglePanel: togglePanel,
|
onTogglePanel: togglePanel,
|
||||||
onShowShortcuts: () => setShortcutsOpen(true),
|
onShowShortcuts: () => setShortcutsOpen(true),
|
||||||
});
|
});
|
||||||
@@ -350,11 +482,7 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
<div className="w-60 shrink-0 overflow-hidden">
|
<div className="w-60 shrink-0 overflow-hidden">
|
||||||
{isCompanySettingsRoute ? (
|
{hasSecondarySidebar ? secondarySidebar : <Sidebar />}
|
||||||
<CompanySettingsSidebar />
|
|
||||||
) : (
|
|
||||||
companySidebar
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SidebarAccountMenu
|
<SidebarAccountMenu
|
||||||
@@ -363,23 +491,30 @@ export function Layout() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex flex-1 min-h-0">
|
||||||
<ResizableSidebarPane open={sidebarOpen} resizable className="h-full shrink-0">
|
<Sidebar />
|
||||||
{isCompanySettingsRoute ? (
|
|
||||||
<CompanySettingsSidebar />
|
|
||||||
) : (
|
|
||||||
companySidebar
|
|
||||||
)}
|
|
||||||
</ResizableSidebarPane>
|
|
||||||
</div>
|
</div>
|
||||||
<SidebarAccountMenu
|
<SidebarAccountMenu
|
||||||
deploymentMode={health?.deploymentMode}
|
deploymentMode={health?.deploymentMode}
|
||||||
version={health?.version}
|
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("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
const mockHeartbeatsApi = vi.hoisted(() => ({
|
const mockHeartbeatsApi = vi.hoisted(() => ({
|
||||||
liveRunsForCompany: vi.fn(),
|
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", () => ({
|
vi.mock("../context/SidebarContext", () => ({
|
||||||
useSidebar: () => ({
|
useSidebar: () => mockSidebar,
|
||||||
isMobile: false,
|
|
||||||
setSidebarOpen: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../api/heartbeats", () => ({
|
vi.mock("../api/heartbeats", () => ({
|
||||||
@@ -112,7 +120,9 @@ describe("Sidebar", () => {
|
|||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
root.render(
|
root.render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Sidebar />
|
<TooltipProvider>
|
||||||
|
<Sidebar />
|
||||||
|
</TooltipProvider>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -125,6 +135,9 @@ describe("Sidebar", () => {
|
|||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||||
|
mockSidebar.isMobile = false;
|
||||||
|
mockSidebar.collapsed = false;
|
||||||
|
mockSidebar.peeking = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -284,4 +297,92 @@ describe("Sidebar", () => {
|
|||||||
root.unmount();
|
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,
|
Package,
|
||||||
Settings,
|
Settings,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelLeftOpen,
|
||||||
|
Pin,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { NavLink } from "@/lib/router";
|
import { NavLink } from "@/lib/router";
|
||||||
@@ -23,11 +26,14 @@ import { SidebarAgents } from "./SidebarAgents";
|
|||||||
import { SidebarProjects } from "./SidebarProjects";
|
import { SidebarProjects } from "./SidebarProjects";
|
||||||
import { useDialogActions } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { PluginSlotOutlet } from "@/plugins/slots";
|
||||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||||
@@ -35,6 +41,8 @@ import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
|||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { openNewIssue } = useDialogActions();
|
const { openNewIssue } = useDialogActions();
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
|
const { isMobile, collapsed, collapseLocked, peeking, toggleCollapsed, setCollapsed } = useSidebar();
|
||||||
|
const rail = collapsed && !peeking;
|
||||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||||
const { data: experimentalSettings } = useQuery({
|
const { data: experimentalSettings } = useQuery({
|
||||||
queryKey: queryKeys.instance.experimentalSettings,
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
@@ -64,37 +72,92 @@ export function Sidebar() {
|
|||||||
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
|
{/* 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">
|
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
||||||
<SidebarCompanyMenu />
|
<SidebarCompanyMenu />
|
||||||
<Button
|
{/* In the collapsed rail the search/toggle controls don't fit beside the
|
||||||
asChild
|
logo — keeping them would overflow the 64px rail and squeeze the logo
|
||||||
variant="ghost"
|
out of alignment with the icon column below it (PAP-10676). They return
|
||||||
size="icon-sm"
|
as soon as the panel is expanded (pinned) or peeking. Expansion in the
|
||||||
className="text-muted-foreground shrink-0"
|
rail is still reachable via hover-peek + Pin and Cmd/Ctrl+B. */}
|
||||||
aria-label="Open search"
|
{!rail ? (
|
||||||
title="Open search"
|
<>
|
||||||
>
|
<Button
|
||||||
<NavLink to="/search">
|
asChild
|
||||||
<Search className="h-4 w-4" />
|
variant="ghost"
|
||||||
</NavLink>
|
size="icon-sm"
|
||||||
</Button>
|
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>
|
</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">
|
<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">
|
<div className="flex flex-col gap-0.5">
|
||||||
{/* New Task button aligned with nav items */}
|
{/* New Task button aligned with nav items */}
|
||||||
<button
|
{(() => {
|
||||||
onClick={() => openNewIssue()}
|
const newTaskButton = (
|
||||||
data-slot="icon-button"
|
<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"
|
onClick={() => openNewIssue()}
|
||||||
>
|
data-slot="icon-button"
|
||||||
<SquarePen className="h-4 w-4 shrink-0" />
|
aria-label={rail ? "New Task" : undefined}
|
||||||
<span className="truncate">New Task</span>
|
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"
|
||||||
</button>
|
>
|
||||||
|
<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="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
to="/inbox"
|
to="/inbox"
|
||||||
label="Inbox"
|
label="Inbox"
|
||||||
icon={Inbox}
|
icon={Inbox}
|
||||||
badge={inboxBadge.inbox}
|
badge={inboxBadge.inbox}
|
||||||
|
badgeLabel="unread"
|
||||||
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
|
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
|
||||||
alert={inboxBadge.failedRuns > 0}
|
alert={inboxBadge.failedRuns > 0}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { useSidebar } from "../context/SidebarContext";
|
|||||||
import { useTheme } from "../context/ThemeContext";
|
import { useTheme } from "../context/ThemeContext";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
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 PROFILE_SETTINGS_PATH = "/company/settings/instance/profile";
|
||||||
const DOCS_URL = "https://docs.paperclip.ing/";
|
const DOCS_URL = "https://docs.paperclip.ing/";
|
||||||
@@ -107,7 +107,8 @@ export function SidebarAccountMenu({
|
|||||||
}: SidebarAccountMenuProps) {
|
}: SidebarAccountMenuProps) {
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { isMobile, setSidebarOpen } = useSidebar();
|
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
||||||
|
const rail = collapsed && !peeking;
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const open = controlledOpen ?? internalOpen;
|
const open = controlledOpen ?? internalOpen;
|
||||||
const setOpen = onOpenChange ?? setInternalOpen;
|
const setOpen = onOpenChange ?? setInternalOpen;
|
||||||
@@ -150,7 +151,7 @@ export function SidebarAccountMenu({
|
|||||||
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
||||||
<AvatarFallback>{initials}</AvatarFallback>
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
</Avatar>
|
</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>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import type { Agent, ResourceMemberships } from "@paperclipai/shared";
|
import type { Agent, ResourceMemberships } from "@paperclipai/shared";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { SidebarAgents } from "./SidebarAgents";
|
import { SidebarAgents } from "./SidebarAgents";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
const mockAgentsApi = vi.hoisted(() => ({
|
const mockAgentsApi = vi.hoisted(() => ({
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
@@ -30,6 +31,7 @@ const mockResourceMembershipsApi = vi.hoisted(() => ({
|
|||||||
const mockOpenNewAgent = vi.hoisted(() => vi.fn());
|
const mockOpenNewAgent = vi.hoisted(() => vi.fn());
|
||||||
const mockPushToast = vi.hoisted(() => vi.fn());
|
const mockPushToast = vi.hoisted(() => vi.fn());
|
||||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||||
|
const mockSidebarState = vi.hoisted(() => ({ collapsed: false, peeking: false }));
|
||||||
|
|
||||||
vi.mock("@/lib/router", () => ({
|
vi.mock("@/lib/router", () => ({
|
||||||
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||||
@@ -75,6 +77,8 @@ vi.mock("../context/SidebarContext", () => ({
|
|||||||
useSidebar: () => ({
|
useSidebar: () => ({
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
setSidebarOpen: mockSetSidebarOpen,
|
setSidebarOpen: mockSetSidebarOpen,
|
||||||
|
collapsed: mockSidebarState.collapsed,
|
||||||
|
peeking: mockSidebarState.peeking,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -206,6 +210,8 @@ describe("SidebarAgents", () => {
|
|||||||
let memberships: ResourceMemberships;
|
let memberships: ResourceMemberships;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mockSidebarState.collapsed = false;
|
||||||
|
mockSidebarState.peeking = false;
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
root = null;
|
root = null;
|
||||||
@@ -286,6 +292,43 @@ describe("SidebarAgents", () => {
|
|||||||
await flushReact();
|
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 () => {
|
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"]));
|
localStorage.setItem("paperclip.agentOrder:company-1:user-1", JSON.stringify(["agent-b", "agent-a", "agent-c"]));
|
||||||
mockAgentsApi.list.mockResolvedValue([
|
mockAgentsApi.list.mockResolvedValue([
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { authApi } from "../api/auth";
|
|||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
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 { useAgentOrder } from "../hooks/useAgentOrder";
|
||||||
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
|
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import type { Agent } from "@paperclipai/shared";
|
import type { Agent } from "@paperclipai/shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,6 +96,7 @@ function SidebarAgentItem({
|
|||||||
leaving,
|
leaving,
|
||||||
onLeaveAgent,
|
onLeaveAgent,
|
||||||
onPauseResume,
|
onPauseResume,
|
||||||
|
rail,
|
||||||
runCount,
|
runCount,
|
||||||
setSidebarOpen,
|
setSidebarOpen,
|
||||||
}: {
|
}: {
|
||||||
@@ -106,6 +108,7 @@ function SidebarAgentItem({
|
|||||||
leaving: boolean;
|
leaving: boolean;
|
||||||
onLeaveAgent: (agent: Agent) => void;
|
onLeaveAgent: (agent: Agent) => void;
|
||||||
onPauseResume: (agent: Agent, action: "pause" | "resume") => void;
|
onPauseResume: (agent: Agent, action: "pause" | "resume") => void;
|
||||||
|
rail: boolean;
|
||||||
runCount: number;
|
runCount: number;
|
||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -126,46 +129,65 @@ function SidebarAgentItem({
|
|||||||
? "Invalid org chain"
|
? "Invalid org chain"
|
||||||
: pauseResumeLabel;
|
: 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 (
|
return (
|
||||||
<div className="group/agent relative flex items-center">
|
<div className="group/agent relative flex items-center">
|
||||||
<NavLink
|
{rail ? (
|
||||||
to={href}
|
// Anchor the tooltip to a wrapper, not the NavLink: Radix `asChild` (Slot)
|
||||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
// drops React Router's function className, which would strip `flex` off the
|
||||||
onClick={() => {
|
// <a> and let the in-flow label stack under the icon, growing the row.
|
||||||
if (isMobile) setSidebarOpen(false);
|
// Keeping the <a> out of Slot preserves a 1:1 row height with the expanded
|
||||||
}}
|
// state so the icon never moves (PAP-10676).
|
||||||
className={cn(
|
<Tooltip>
|
||||||
"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",
|
<TooltipTrigger asChild>
|
||||||
isActive
|
<div className="min-w-0 flex-1">{link}</div>
|
||||||
? "bg-accent text-foreground"
|
</TooltipTrigger>
|
||||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
|
<TooltipContent side="right">{agent.name}</TooltipContent>
|
||||||
)}
|
</Tooltip>
|
||||||
>
|
) : (
|
||||||
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
|
link
|
||||||
<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 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -219,6 +241,7 @@ function SidebarAgentItem({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -229,7 +252,8 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewAgent } = useDialogActions();
|
const { openNewAgent } = useDialogActions();
|
||||||
const { isMobile, setSidebarOpen } = useSidebar();
|
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
||||||
|
const rail = collapsed && !peeking;
|
||||||
const { pushToast } = useToastActions();
|
const { pushToast } = useToastActions();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@@ -451,24 +475,36 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
|
|||||||
leaving={agentLeaving(agent)}
|
leaving={agentLeaving(agent)}
|
||||||
onLeaveAgent={leaveAgent}
|
onLeaveAgent={leaveAgent}
|
||||||
onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })}
|
onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })}
|
||||||
|
rail={rail}
|
||||||
runCount={runCount}
|
runCount={runCount}
|
||||||
setSidebarOpen={setSidebarOpen}
|
setSidebarOpen={setSidebarOpen}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{showSeeAllLink && (
|
{showSeeAllLink && (() => {
|
||||||
<Link
|
const seeAllLink = (
|
||||||
to="/agents/all"
|
<Link
|
||||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
to="/agents/all"
|
||||||
onClick={() => {
|
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||||
if (isMobile) setSidebarOpen(false);
|
aria-label={rail ? "See all agents" : undefined}
|
||||||
}}
|
onClick={() => {
|
||||||
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"
|
if (isMobile) setSidebarOpen(false);
|
||||||
>
|
}}
|
||||||
<Users className="shrink-0 h-3.5 w-3.5" />
|
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"
|
||||||
<span>See all agents</span>
|
>
|
||||||
</Link>
|
<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>
|
</SidebarSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { useCompany } from "@/context/CompanyContext";
|
|||||||
import { useDialogActions } from "@/context/DialogContext";
|
import { useDialogActions } from "@/context/DialogContext";
|
||||||
import { useCompanyOrder } from "@/hooks/useCompanyOrder";
|
import { useCompanyOrder } from "@/hooks/useCompanyOrder";
|
||||||
import { queryKeys } from "@/lib/queryKeys";
|
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 { useSidebar } from "../context/SidebarContext";
|
||||||
import { CompanyPatternIcon } from "./CompanyPatternIcon";
|
import { CompanyPatternIcon } from "./CompanyPatternIcon";
|
||||||
|
|
||||||
@@ -134,7 +134,8 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
|
const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
|
||||||
const { openOnboarding } = useDialogActions();
|
const { openOnboarding } = useDialogActions();
|
||||||
const { isMobile, setSidebarOpen } = useSidebar();
|
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
||||||
|
const rail = collapsed && !peeking;
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const open = controlledOpen ?? internalOpen;
|
const open = controlledOpen ?? internalOpen;
|
||||||
@@ -224,16 +225,21 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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"}
|
aria-label={selectedCompany ? `Open ${selectedCompany.name} workspace switcher` : "Open workspace switcher"}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
{selectedCompany ? <WorkspaceIcon company={selectedCompany} /> : null}
|
{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"}
|
{selectedCompany?.name ?? "Select workspace"}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />
|
{!rail && <ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" sideOffset={8} className="w-64 p-1">
|
<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 { NavLink } from "@/lib/router";
|
||||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
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 { useSidebar } from "../context/SidebarContext";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import type { LucideIcon } from "lucide-react";
|
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 {
|
interface SidebarNavItemProps {
|
||||||
to: string;
|
to: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -12,6 +39,11 @@ interface SidebarNavItemProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
badge?: number;
|
badge?: number;
|
||||||
badgeTone?: "default" | "danger";
|
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;
|
textBadge?: string;
|
||||||
textBadgeTone?: "default" | "amber";
|
textBadgeTone?: "default" | "amber";
|
||||||
alert?: boolean;
|
alert?: boolean;
|
||||||
@@ -26,18 +58,42 @@ export function SidebarNavItem({
|
|||||||
className,
|
className,
|
||||||
badge,
|
badge,
|
||||||
badgeTone = "default",
|
badgeTone = "default",
|
||||||
|
badgeLabel,
|
||||||
textBadge,
|
textBadge,
|
||||||
textBadgeTone = "default",
|
textBadgeTone = "default",
|
||||||
alert = false,
|
alert = false,
|
||||||
liveCount,
|
liveCount,
|
||||||
}: SidebarNavItemProps) {
|
}: 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
|
<NavLink
|
||||||
to={to}
|
to={to}
|
||||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||||
end={end}
|
end={end}
|
||||||
|
aria-label={railAriaLabel}
|
||||||
onClick={() => { if (isMobile) setSidebarOpen(false); }}
|
onClick={() => { if (isMobile) setSidebarOpen(false); }}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
@@ -52,11 +108,28 @@ export function SidebarNavItem({
|
|||||||
<span className="relative shrink-0">
|
<span className="relative shrink-0">
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{alert && (
|
{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>
|
||||||
<span className="flex-1 truncate">{label}</span>
|
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{label}</span>
|
||||||
{textBadge && (
|
{!rail && textBadge && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none",
|
"ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none",
|
||||||
@@ -68,7 +141,7 @@ export function SidebarNavItem({
|
|||||||
{textBadge}
|
{textBadge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{liveCount != null && liveCount > 0 && (
|
{!rail && hasLive && (
|
||||||
<span className="ml-auto flex items-center gap-1.5">
|
<span className="ml-auto flex items-center gap-1.5">
|
||||||
<span className="relative flex h-2 w-2">
|
<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="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 className="text-[11px] font-medium text-blue-600 dark:text-blue-400">{liveCount} live</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{badge != null && badge > 0 && (
|
{!rail && hasBadge && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto rounded-full px-1.5 py-0.5 text-xs leading-none",
|
"ml-auto rounded-full px-1.5 py-0.5 text-xs leading-none",
|
||||||
@@ -91,4 +164,21 @@ export function SidebarNavItem({
|
|||||||
)}
|
)}
|
||||||
</NavLink>
|
</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 type { Project, ResourceMemberships } from "@paperclipai/shared";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { SidebarProjects } from "./SidebarProjects";
|
import { SidebarProjects } from "./SidebarProjects";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
const mockProjectsApi = vi.hoisted(() => ({
|
const mockProjectsApi = vi.hoisted(() => ({
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
@@ -25,7 +26,7 @@ const mockOpenNewProject = vi.hoisted(() => vi.fn());
|
|||||||
const mockPushToast = vi.hoisted(() => vi.fn());
|
const mockPushToast = vi.hoisted(() => vi.fn());
|
||||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||||
const mockPersistOrder = 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 }));
|
const mockPointerState = vi.hoisted(() => ({ fine: true }));
|
||||||
|
|
||||||
vi.mock("@/lib/router", () => ({
|
vi.mock("@/lib/router", () => ({
|
||||||
@@ -73,6 +74,8 @@ vi.mock("../context/SidebarContext", () => ({
|
|||||||
useSidebar: () => ({
|
useSidebar: () => ({
|
||||||
isMobile: mockSidebarState.isMobile,
|
isMobile: mockSidebarState.isMobile,
|
||||||
setSidebarOpen: mockSetSidebarOpen,
|
setSidebarOpen: mockSetSidebarOpen,
|
||||||
|
collapsed: mockSidebarState.collapsed,
|
||||||
|
peeking: mockSidebarState.peeking,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -232,6 +235,8 @@ describe("SidebarProjects", () => {
|
|||||||
});
|
});
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
mockSidebarState.isMobile = false;
|
mockSidebarState.isMobile = false;
|
||||||
|
mockSidebarState.collapsed = false;
|
||||||
|
mockSidebarState.peeking = false;
|
||||||
mockPointerState.fine = true;
|
mockPointerState.fine = true;
|
||||||
Object.defineProperty(window, "matchMedia", {
|
Object.defineProperty(window, "matchMedia", {
|
||||||
writable: true,
|
writable: true,
|
||||||
@@ -326,6 +331,40 @@ describe("SidebarProjects", () => {
|
|||||||
await flushReact();
|
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 () => {
|
it("keeps top mode in curated order and renders plugin project slots", async () => {
|
||||||
await renderSidebarProjects();
|
await renderSidebarProjects();
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { authApi } from "../api/auth";
|
|||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
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 { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
|
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
|
||||||
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
||||||
import {
|
import {
|
||||||
getProjectSortModeStorageKey,
|
getProjectSortModeStorageKey,
|
||||||
@@ -59,6 +60,7 @@ type ProjectItemProps = {
|
|||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
project: Project;
|
project: Project;
|
||||||
projectSidebarSlots: ProjectSidebarSlot[];
|
projectSidebarSlots: ProjectSidebarSlot[];
|
||||||
|
rail: boolean;
|
||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
onLeaveProject: (project: Project) => void;
|
onLeaveProject: (project: Project) => void;
|
||||||
leaving?: boolean;
|
leaving?: boolean;
|
||||||
@@ -113,6 +115,7 @@ function ProjectItem({
|
|||||||
isMobile,
|
isMobile,
|
||||||
project,
|
project,
|
||||||
projectSidebarSlots,
|
projectSidebarSlots,
|
||||||
|
rail,
|
||||||
setSidebarOpen,
|
setSidebarOpen,
|
||||||
onLeaveProject,
|
onLeaveProject,
|
||||||
leaving = false,
|
leaving = false,
|
||||||
@@ -120,31 +123,50 @@ function ProjectItem({
|
|||||||
}: ProjectItemProps) {
|
}: ProjectItemProps) {
|
||||||
const routeRef = projectRouteRef(project);
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<div className="group/project relative flex items-center">
|
<div className="group/project relative flex items-center">
|
||||||
<NavLink
|
{rail ? (
|
||||||
to={`/projects/${routeRef}/issues`}
|
// Anchor the tooltip to a wrapper, not the NavLink: Radix `asChild`
|
||||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
// (Slot) drops React Router's function className, which would strip
|
||||||
onClick={(e) => {
|
// `flex` off the <a> and let the in-flow label stack under the icon,
|
||||||
if (isDragging) {
|
// growing the row. Keeping the <a> out of Slot preserves a 1:1 row
|
||||||
e.preventDefault();
|
// height with the expanded state so the icon never moves (PAP-10676).
|
||||||
return;
|
<Tooltip>
|
||||||
}
|
<TooltipTrigger asChild>
|
||||||
if (isMobile) setSidebarOpen(false);
|
<div className="min-w-0 flex-1">{link}</div>
|
||||||
}}
|
</TooltipTrigger>
|
||||||
className={cn(
|
<TooltipContent side="right">{project.name}</TooltipContent>
|
||||||
"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",
|
</Tooltip>
|
||||||
activeProjectRef === routeRef || activeProjectRef === project.id
|
) : (
|
||||||
? "bg-accent text-foreground"
|
link
|
||||||
: "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 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -174,8 +196,9 @@ function ProjectItem({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{projectSidebarSlots.length > 0 && (
|
{!rail && projectSidebarSlots.length > 0 && (
|
||||||
<div className="ml-5 flex flex-col gap-0.5">
|
<div className="ml-5 flex flex-col gap-0.5">
|
||||||
{projectSidebarSlots.map((slot) => (
|
{projectSidebarSlots.map((slot) => (
|
||||||
<PluginSlotMount
|
<PluginSlotMount
|
||||||
@@ -229,7 +252,8 @@ export function SidebarProjects() {
|
|||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||||
const { openNewProject } = useDialogActions();
|
const { openNewProject } = useDialogActions();
|
||||||
const { isMobile, setSidebarOpen } = useSidebar();
|
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
||||||
|
const rail = collapsed && !peeking;
|
||||||
const fineReorderPointer = useFineReorderPointer();
|
const fineReorderPointer = useFineReorderPointer();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@@ -373,6 +397,7 @@ export function SidebarProjects() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
project={project}
|
project={project}
|
||||||
projectSidebarSlots={projectSidebarSlots}
|
projectSidebarSlots={projectSidebarSlots}
|
||||||
|
rail={rail}
|
||||||
setSidebarOpen={setSidebarOpen}
|
setSidebarOpen={setSidebarOpen}
|
||||||
onLeaveProject={leaveProject}
|
onLeaveProject={leaveProject}
|
||||||
leaving={projectLeaving(project)}
|
leaving={projectLeaving(project)}
|
||||||
@@ -420,6 +445,7 @@ export function SidebarProjects() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
project={project}
|
project={project}
|
||||||
projectSidebarSlots={projectSidebarSlots}
|
projectSidebarSlots={projectSidebarSlots}
|
||||||
|
rail={rail}
|
||||||
setSidebarOpen={setSidebarOpen}
|
setSidebarOpen={setSidebarOpen}
|
||||||
onLeaveProject={leaveProject}
|
onLeaveProject={leaveProject}
|
||||||
leaving={projectLeaving(project)}
|
leaving={projectLeaving(project)}
|
||||||
|
|||||||
@@ -5,16 +5,22 @@ import type { ReactNode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { SidebarSection } from "./SidebarSection";
|
import { SidebarSection } from "./SidebarSection";
|
||||||
|
import { SidebarNavExpandedProvider } from "./SidebarNavItem";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
const sidebarState = vi.hoisted(() => ({
|
const sidebarState = vi.hoisted(() => ({
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
|
collapsed: false,
|
||||||
|
peeking: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/router", () => ({
|
vi.mock("@/lib/router", () => ({
|
||||||
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||||
<a href={to} {...props}>{children}</a>
|
<a href={to} {...props}>{children}</a>
|
||||||
),
|
),
|
||||||
|
NavLink: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||||
|
<a href={to} {...props}>{children}</a>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../context/SidebarContext", () => ({
|
vi.mock("../context/SidebarContext", () => ({
|
||||||
@@ -53,6 +59,8 @@ describe("SidebarSection", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sidebarState.isMobile = false;
|
sidebarState.isMobile = false;
|
||||||
|
sidebarState.collapsed = false;
|
||||||
|
sidebarState.peeking = false;
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
root = null;
|
root = null;
|
||||||
@@ -155,6 +163,30 @@ describe("SidebarSection", () => {
|
|||||||
expect(staticLabelControl?.getAttribute("class")).not.toContain("focus-visible:bg-accent/50");
|
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 () => {
|
it("keeps the header action outside the label menu hit area", async () => {
|
||||||
const onAction = vi.fn();
|
const onAction = vi.fn();
|
||||||
const currentRoot = createRoot(container);
|
const currentRoot = createRoot(container);
|
||||||
@@ -259,6 +291,59 @@ describe("SidebarSection", () => {
|
|||||||
expect(onAction).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("keeps section header controls visible on mobile", async () => {
|
||||||
sidebarState.isMobile = true;
|
sidebarState.isMobile = true;
|
||||||
const currentRoot = createRoot(container);
|
const currentRoot = createRoot(container);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSidebarNavExpanded } from "./SidebarNavItem";
|
||||||
|
|
||||||
type SidebarSectionIcon = ComponentType<{ className?: string }>;
|
type SidebarSectionIcon = ComponentType<{ className?: string }>;
|
||||||
|
|
||||||
@@ -189,8 +190,34 @@ export function SidebarSection({
|
|||||||
menu,
|
menu,
|
||||||
headerAction,
|
headerAction,
|
||||||
}: SidebarSectionProps) {
|
}: 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>;
|
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) {
|
if (collapsible) {
|
||||||
return (
|
return (
|
||||||
<Collapsible open={collapsible.open} onOpenChange={collapsible.onOpenChange}>
|
<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 {
|
interface SidebarContextValue {
|
||||||
|
// Mobile drawer + back-compat (existing behavior, unchanged).
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
isMobile: boolean;
|
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 SidebarContext = createContext<SidebarContextValue | null>(null);
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768;
|
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 }) {
|
export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
|
const [isMobile, setIsMobile] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
const [sidebarOpen, setSidebarOpen] = 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(() => {
|
useEffect(() => {
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||||
const onChange = (e: MediaQueryListEvent) => {
|
const onChange = (e: MediaQueryListEvent) => {
|
||||||
@@ -25,13 +109,81 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => mql.removeEventListener("change", onChange);
|
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), []);
|
const toggleSidebar = useCallback(() => setSidebarOpen((v) => !v), []);
|
||||||
|
|
||||||
return (
|
const value = useMemo<SidebarContextValue>(
|
||||||
<SidebarContext.Provider value={{ sidebarOpen, setSidebarOpen, toggleSidebar, isMobile }}>
|
() => ({
|
||||||
{children}
|
sidebarOpen,
|
||||||
</SidebarContext.Provider>
|
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() {
|
export function useSidebar() {
|
||||||
|
|||||||
@@ -11,14 +11,17 @@ import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
|
|||||||
function TestHarness({
|
function TestHarness({
|
||||||
onNewIssue,
|
onNewIssue,
|
||||||
onSearch,
|
onSearch,
|
||||||
|
onToggleCollapse,
|
||||||
}: {
|
}: {
|
||||||
onNewIssue: () => void;
|
onNewIssue: () => void;
|
||||||
onSearch?: () => void;
|
onSearch?: () => void;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
}) {
|
}) {
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
onNewIssue,
|
onNewIssue,
|
||||||
onSearch,
|
onSearch,
|
||||||
|
onToggleCollapse,
|
||||||
});
|
});
|
||||||
|
|
||||||
return <div>keyboard shortcuts test</div>;
|
return <div>keyboard shortcuts test</div>;
|
||||||
@@ -106,4 +109,53 @@ describe("useKeyboardShortcuts", () => {
|
|||||||
root.unmount();
|
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;
|
onNewIssue?: () => void;
|
||||||
onSearch?: () => void;
|
onSearch?: () => void;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
onTogglePanel?: () => void;
|
onTogglePanel?: () => void;
|
||||||
onShowShortcuts?: () => void;
|
onShowShortcuts?: () => void;
|
||||||
}
|
}
|
||||||
@@ -19,6 +20,7 @@ export function useKeyboardShortcuts({
|
|||||||
onNewIssue,
|
onNewIssue,
|
||||||
onSearch,
|
onSearch,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
|
onToggleCollapse,
|
||||||
onTogglePanel,
|
onTogglePanel,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
}: ShortcutHandlers) {
|
}: ShortcutHandlers) {
|
||||||
@@ -67,6 +69,12 @@ export function useKeyboardShortcuts({
|
|||||||
onToggleSidebar?.();
|
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
|
// ] → Toggle Panel
|
||||||
if (e.key === "]" && !e.metaKey && !e.ctrlKey) {
|
if (e.key === "]" && !e.metaKey && !e.ctrlKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -76,5 +84,5 @@ export function useKeyboardShortcuts({
|
|||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("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));
|
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> {
|
export function asObject(value: unknown): Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
? value as Record<string, unknown>
|
? value as Record<string, unknown>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { KeyboardShortcutsCheatsheetContent } from "@/components/KeyboardShortcu
|
|||||||
import { MobileBottomNav } from "@/components/MobileBottomNav";
|
import { MobileBottomNav } from "@/components/MobileBottomNav";
|
||||||
import { PageTabBar } from "@/components/PageTabBar";
|
import { PageTabBar } from "@/components/PageTabBar";
|
||||||
import { Sidebar } from "@/components/Sidebar";
|
import { Sidebar } from "@/components/Sidebar";
|
||||||
|
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||||
import { SidebarAccountMenu } from "@/components/SidebarAccountMenu";
|
import { SidebarAccountMenu } from "@/components/SidebarAccountMenu";
|
||||||
import { SidebarCompanyMenu } from "@/components/SidebarCompanyMenu";
|
import { SidebarCompanyMenu } from "@/components/SidebarCompanyMenu";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
@@ -349,3 +350,30 @@ export default meta;
|
|||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const BoardChromeMatrix: Story = {};
|
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