mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat(ui): sidebar rail + peek overlay shell (PAP-10644)
Task 2 of the collapsible sidebar plan (PAP-10638, plan §4/§5/§6). Replace ResizableSidebarPane with a SidebarShell that renders two layers: - an in-flow spacer reserving `reservedWidth` (rail when collapsed, expanded width otherwise) so content to the right reflows only on pin, never on peek; - an absolutely-positioned panel (`inset-y-0 left-0`) of `panelWidth` (rail at rest, expanded while peeking) that overlays content with raised z-index + shadow-lg + bg-background + 1px border when wider than the spacer. Unified width function (one code path for every pinned state): reservedWidth = collapsed ? SIDEBAR_RAIL_WIDTH : expandedWidth panelWidth = (collapsed && !peeking) ? SIDEBAR_RAIL_WIDTH : expandedWidth overlay when panelWidth > reservedWidth SIDEBAR_RAIL_WIDTH=56 (ends just past the icon). The drag resize handle is disabled while collapsed; only the expanded width is persisted. The existing `transition-[width] duration-100 ease-out` is reused; `motion-reduce` drops it. Wire Layout: the shell wraps the whole sidebar column (nav + account row) so the entire sidebar peeks as one overlay. Peek triggers live in Layout — onMouseEnter (50ms open), onMouseLeave (120ms debounce close), focus-within (immediate open), close on route change and Escape; pointer is never trapped. Context already gates effective `peeking` to desktop + collapsed + hover-capable pointers. Mobile drawer path untouched. Icon-alignment guarantee: item padding/gap/icon markup are unchanged; collapse is a pure pane-width animation + overflow-hidden label clipping. Verified in a headless browser that the first nav icon's offset is pixel-identical (24,16) across expanded/collapsed/peek, that peek does not reflow content (spacer stays 56px) while the panel overlaps content with a shadow. Tests cover width split, rail collapse, overlay-on-peek styling, handle hidden when collapsed, and the reduced-motion path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
b0199e5951
commit
47bebe8bb8
6 files changed
+499
-308
No files matched your search
@@ -167,6 +167,10 @@ vi.mock("../context/SidebarContext", () => ({
|
||||
sidebarOpen: mockSidebarState.sidebarOpen,
|
||||
setSidebarOpen: mockSetSidebarOpen,
|
||||
toggleSidebar: vi.fn(),
|
||||
toggleCollapsed: vi.fn(),
|
||||
collapsed: false,
|
||||
peeking: false,
|
||||
setPeeking: vi.fn(),
|
||||
isMobile: mockSidebarState.isMobile,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -17,7 +17,7 @@ import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
import { StandaloneBrowserControls } from "./StandaloneBrowserControls";
|
||||
import { ResizableSidebarPane } from "./ResizableSidebarPane";
|
||||
import { SidebarShell } from "./SidebarShell";
|
||||
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||
@@ -48,7 +48,16 @@ function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefi
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, toggleCollapsed, isMobile } = useSidebar();
|
||||
const {
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
toggleSidebar,
|
||||
toggleCollapsed,
|
||||
collapsed,
|
||||
peeking,
|
||||
setPeeking,
|
||||
isMobile,
|
||||
} = useSidebar();
|
||||
const { openNewIssue, openOnboarding } = useDialogActions();
|
||||
const { togglePanelVisible } = usePanel();
|
||||
const {
|
||||
@@ -196,6 +205,54 @@ 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);
|
||||
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]);
|
||||
|
||||
// Tidy up any pending peek timer on unmount.
|
||||
useEffect(() => clearPeekTimer, [clearPeekTimer]);
|
||||
|
||||
// Close peek on navigation so it never lingers over a freshly routed page.
|
||||
useEffect(() => {
|
||||
clearPeekTimer();
|
||||
setPeeking(false);
|
||||
}, [location.pathname, clearPeekTimer, setPeeking]);
|
||||
|
||||
// Escape closes an open peek without trapping the pointer.
|
||||
useEffect(() => {
|
||||
if (!peeking) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
clearPeekTimer();
|
||||
setPeeking(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [peeking, clearPeekTimer, setPeeking]);
|
||||
|
||||
useCompanyPageMemory();
|
||||
|
||||
useKeyboardShortcuts({
|
||||
@@ -373,21 +430,28 @@ export function Layout() {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col shrink-0">
|
||||
<SidebarShell
|
||||
open={sidebarOpen}
|
||||
collapsed={collapsed}
|
||||
peeking={peeking}
|
||||
resizable
|
||||
onPanelMouseEnter={collapsed ? openPeek : undefined}
|
||||
onPanelMouseLeave={collapsed ? closePeek : undefined}
|
||||
onPanelFocusCapture={collapsed ? openPeekImmediate : undefined}
|
||||
onPanelBlurCapture={collapsed ? closePeek : undefined}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<ResizableSidebarPane open={sidebarOpen} resizable className="h-full shrink-0">
|
||||
{isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
companySidebar
|
||||
)}
|
||||
</ResizableSidebarPane>
|
||||
{isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
companySidebar
|
||||
)}
|
||||
</div>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode={health?.deploymentMode}
|
||||
version={health?.version}
|
||||
/>
|
||||
</div>
|
||||
</SidebarShell>
|
||||
)}
|
||||
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
||||
|
||||
@@ -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,182 @@
|
||||
// @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("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("drops the width transition for reduced-motion users", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarShell open>
|
||||
<div>Sidebar</div>
|
||||
</SidebarShell>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(panel().className).toContain("transition-[width]");
|
||||
expect(panel().className).toContain("motion-reduce:transition-none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
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. Tuned to the mock so the rail ends just past the icon:
|
||||
// nav px-3 (12) + item px-3 (12) + icon 16 = 40px to the icon's right edge,
|
||||
// leaving a small trailing margin. The icon's left edge is unchanged from the
|
||||
// expanded layout, which is what keeps icons pixel-identical across states.
|
||||
export const SIDEBAR_RAIL_WIDTH = 56;
|
||||
|
||||
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
|
||||
* animation with `overflow-hidden` clipping the labels; item padding/icon markup
|
||||
* are never touched, so the left-aligned icon stays pixel-identical.
|
||||
*/
|
||||
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 = collapsed ? SIDEBAR_RAIL_WIDTH : expandedWidth;
|
||||
const panelWidth = 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",
|
||||
// Reuse the existing fast width animation; reduced-motion drops it.
|
||||
!isResizing && "transition-[width] duration-100 ease-out motion-reduce:transition-none",
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user