diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index 873ceba13ad..638e46bbd92 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -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, }), })); diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 43d2c9bbd05..0144eb6d52b 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -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(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() { /> ) : ( -
+
- - {isCompanySettingsRoute ? ( - - ) : ( - companySidebar - )} - + {isCompanySettingsRoute ? ( + + ) : ( + companySidebar + )}
-
+ )}
diff --git a/ui/src/components/ResizableSidebarPane.test.tsx b/ui/src/components/ResizableSidebarPane.test.tsx deleted file mode 100644 index cb635d81035..00000000000 --- a/ui/src/components/ResizableSidebarPane.test.tsx +++ /dev/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( - -
Sidebar
-
, - ); - }); - - 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( - -
Sidebar
-
, - ); - }); - - 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( - -
Sidebar
-
, - ); - }); - - 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( - -
Sidebar
-
, - ); - }); - - expect(handle()).toBeNull(); - expect(pane().style.width).toBe("240px"); - }); -}); diff --git a/ui/src/components/ResizableSidebarPane.tsx b/ui/src/components/ResizableSidebarPane.tsx deleted file mode 100644 index 09f9c70a1f1..00000000000 --- a/ui/src/components/ResizableSidebarPane.tsx +++ /dev/null @@ -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) => { - 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) => { - 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) => { - 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 ( -
- {children} - {resizable && open ? ( -
- ) : null} -
- ); -} diff --git a/ui/src/components/SidebarShell.test.tsx b/ui/src/components/SidebarShell.test.tsx new file mode 100644 index 00000000000..264ecc1fac8 --- /dev/null +++ b/ui/src/components/SidebarShell.test.tsx @@ -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( + +
Sidebar
+
, + ); + }); + + // 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( + +
Sidebar
+
, + ); + }); + + 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( + +
Sidebar
+
, + ); + }); + + 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( + +
Sidebar
+
, + ); + }); + + 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( + +
Sidebar
+
, + ); + }); + + // 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( + +
Sidebar
+
, + ); + }); + + // 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( + +
Sidebar
+
, + ); + }); + + expect(panel().className).toContain("transition-[width]"); + expect(panel().className).toContain("motion-reduce:transition-none"); + }); +}); diff --git a/ui/src/components/SidebarShell.tsx b/ui/src/components/SidebarShell.tsx new file mode 100644 index 00000000000..41db0fc31a8 --- /dev/null +++ b/ui/src/components/SidebarShell.tsx @@ -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; + onPanelMouseLeave?: MouseEventHandler; + onPanelFocusCapture?: FocusEventHandler; + onPanelBlurCapture?: FocusEventHandler; +}; + +/** + * 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) => { + 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) => { + 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) => { + 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 ( +
+
+ {children} + {canResize ? ( +
+ ) : null} +
+
+ ); +}