mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
feat(ui): sidebar toggle affordances — header button, peek pin, Cmd+B cheatsheet (PAP-10647)
Task 4 of the collapsible sidebar plan (PAP-10638, plan §6).
Add the user-facing controls for the collapse/peek state model from
PAP-10642, on top of the rail+peek shell from PAP-10644.
Sidebar header control (desktop only; mobile keeps the off-canvas drawer):
- Expanded → a PanelLeftClose toggle (aria-expanded=true, "Collapse sidebar")
that calls toggleCollapsed(); the pin persists via setCollapsed's localStorage
write.
- Collapsed rail at rest → a PanelLeftOpen toggle (aria-expanded=false,
"Expand sidebar").
- While peeking (hover/focus flyout over the rail) the toggle is replaced by a
Pin button ("Keep sidebar expanded") that promotes the peek to pinned-expanded
via setCollapsed(false).
Keyboard cheatsheet: surface the re-pointed Cmd/Ctrl+B (Task 1) in the Global
section, rendered as a "+" chord rather than a "then" sequence, with a
platform-appropriate ⌘/Ctrl cap.
Deferred (optional per scope): the small expand control in BreadcrumbBar.
Tests cover collapse, expand, peek→pin promotion, the mobile hide, and the
cheatsheet chord entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
47bebe8bb8
commit
ce548a367f
4 files changed
+181
-5
No files matched your search
@@ -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,20 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
||||
interface ShortcutEntry {
|
||||
keys: string[];
|
||||
label: string;
|
||||
/** Render keys as a simultaneous chord (joined with "+") rather than a
|
||||
* "then" sequence. */
|
||||
combo?: boolean;
|
||||
}
|
||||
|
||||
// Platform-appropriate label for the Cmd/Ctrl modifier so the cheatsheet shows
|
||||
// the same key the user actually presses (re-pointed in the collapsible sidebar
|
||||
// work — Cmd/Ctrl+B toggles the rail).
|
||||
const META_KEY =
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || "")
|
||||
? "⌘"
|
||||
: "Ctrl";
|
||||
|
||||
interface ShortcutSection {
|
||||
title: string;
|
||||
shortcuts: ShortcutEntry[];
|
||||
@@ -41,6 +53,7 @@ const sections: ShortcutSection[] = [
|
||||
{ keys: ["/"], label: "Search current page or quick search" },
|
||||
{ keys: ["c"], label: "New task" },
|
||||
{ keys: ["["], label: "Toggle sidebar" },
|
||||
{ keys: [META_KEY, "B"], label: "Collapse or expand sidebar", combo: true },
|
||||
{ keys: ["]"], label: "Toggle panel" },
|
||||
{ keys: ["?"], label: "Show keyboard shortcuts" },
|
||||
],
|
||||
@@ -74,7 +87,11 @@ export function KeyboardShortcutsCheatsheetContent() {
|
||||
<div className="flex items-center gap-1">
|
||||
{shortcut.keys.map((key, i) => (
|
||||
<span key={key} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-xs text-muted-foreground">then</span>}
|
||||
{i > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{shortcut.combo ? "+" : "then"}
|
||||
</span>
|
||||
)}
|
||||
<KeyCap>{key}</KeyCap>
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -47,11 +47,17 @@ vi.mock("../context/CompanyContext", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSidebar = vi.hoisted(() => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: vi.fn(),
|
||||
collapsed: false,
|
||||
peeking: false,
|
||||
toggleCollapsed: vi.fn(),
|
||||
setCollapsed: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: vi.fn(),
|
||||
}),
|
||||
useSidebar: () => mockSidebar,
|
||||
}));
|
||||
|
||||
vi.mock("../api/heartbeats", () => ({
|
||||
@@ -125,6 +131,9 @@ describe("Sidebar", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
mockSidebar.isMobile = false;
|
||||
mockSidebar.collapsed = false;
|
||||
mockSidebar.peeking = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -284,4 +293,75 @@ describe("Sidebar", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("header toggle collapses an expanded sidebar (aria-expanded reflects state)", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
const root = await renderSidebar();
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>('button[aria-label="Collapse sidebar"]');
|
||||
expect(toggle).not.toBeNull();
|
||||
expect(toggle?.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
act(() => {
|
||||
toggle?.click();
|
||||
});
|
||||
expect(mockSidebar.toggleCollapsed).toHaveBeenCalledTimes(1);
|
||||
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("header toggle expands a collapsed rail", async () => {
|
||||
mockSidebar.collapsed = true;
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
const root = await renderSidebar();
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>('button[aria-label="Expand sidebar"]');
|
||||
expect(toggle).not.toBeNull();
|
||||
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
act(() => {
|
||||
toggle?.click();
|
||||
});
|
||||
expect(mockSidebar.toggleCollapsed).toHaveBeenCalledTimes(1);
|
||||
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("peek header shows a pin that promotes the peek to pinned-expanded", async () => {
|
||||
mockSidebar.collapsed = true;
|
||||
mockSidebar.peeking = true;
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
const root = await renderSidebar();
|
||||
|
||||
// The collapse toggle is replaced by the pin while peeking.
|
||||
expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull();
|
||||
const pin = container.querySelector<HTMLButtonElement>('button[aria-label="Keep sidebar expanded"]');
|
||||
expect(pin).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
pin?.click();
|
||||
});
|
||||
expect(mockSidebar.setCollapsed).toHaveBeenCalledWith(false);
|
||||
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the collapse affordance on mobile (drawer handles it)", async () => {
|
||||
mockSidebar.isMobile = true;
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
const root = await renderSidebar();
|
||||
|
||||
expect(container.querySelector('button[aria-label="Collapse sidebar"]')).toBeNull();
|
||||
expect(container.querySelector('button[aria-label="Keep sidebar expanded"]')).toBeNull();
|
||||
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
Package,
|
||||
Settings,
|
||||
FolderOpen,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Pin,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NavLink } from "@/lib/router";
|
||||
@@ -23,6 +26,7 @@ import { SidebarAgents } from "./SidebarAgents";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
@@ -35,6 +39,7 @@ import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
export function Sidebar() {
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { isMobile, collapsed, peeking, toggleCollapsed, setCollapsed } = useSidebar();
|
||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
@@ -76,6 +81,36 @@ export function Sidebar() {
|
||||
<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. */}
|
||||
{!isMobile ? (
|
||||
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}
|
||||
</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">
|
||||
|
||||
Reference in new issue
Block a user