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:
DottaandClaude Opus 4.8 committed 2026-06-08 16:46:02 +00:00
1 parent b0199e5951
commit 47bebe8bb8
6 files changed
+499 -308

No files matched your search

+4
View File
@@ -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,
}),
}));
+75 -11
View File
@@ -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");
});
});
-176
View File
@@ -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>
);
}
+182
View File
@@ -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");
});
});
+238
View File
@@ -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>
);
}