diff --git a/web/src/components/AppSidebar/SidebarResizeHandle.tsx b/web/src/components/AppSidebar/SidebarResizeHandle.tsx new file mode 100644 index 00000000..6686ac17 --- /dev/null +++ b/web/src/components/AppSidebar/SidebarResizeHandle.tsx @@ -0,0 +1,161 @@ +import { + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { cn } from "@/lib/utils"; +import { useTranslate } from "@/utils/i18n"; +import { SIDEBAR_DEFAULT_WIDTH, SIDEBAR_WIDTH_VAR } from "./useSidebarWidth"; + +/** Arrow-key nudge, matching the 16px rhythm the sidebar rows sit on. */ +const KEYBOARD_STEP = 16; + +interface Props { + width: number; + minWidth: number; + maxWidth: number; + onWidthChange: (width: number) => void; + /** Element carrying the width custom property, written to directly while dragging. */ + targetRef: RefObject; +} + +const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetRef }: Props) => { + const t = useTranslate(); + // `dragging` drives the band's styling; `draggingRef` is what the handlers and the unmount + // cleanup read, so neither depends on a state update having been flushed first. + const [dragging, setDragging] = useState(false); + const draggingRef = useRef(false); + const frameRef = useRef(null); + const pendingRef = useRef(width); + const originRef = useRef({ x: 0, width }); + + const clamp = useCallback((next: number) => Math.min(Math.max(Math.round(next), minWidth), maxWidth), [minWidth, maxWidth]); + + // The drag bypasses React: layout follows the custom property, so the rail tracks the cursor at + // frame rate while the tree — sidebar, feed, column grid — re-renders exactly once, on release. + const previewWidth = useCallback( + (next: number) => { + pendingRef.current = next; + if (frameRef.current != null) return; + frameRef.current = requestAnimationFrame(() => { + frameRef.current = null; + targetRef.current?.style.setProperty(SIDEBAR_WIDTH_VAR, `${pendingRef.current}px`); + }); + }, + [targetRef], + ); + + const stopPreview = useCallback(() => { + if (frameRef.current == null) return; + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + }, []); + + // Unmounting can interrupt a drag: the window crossing the desktop breakpoint takes the whole + // rail with it, so `endDrag` never runs. The shell outlives the rail and React will not rewrite + // a style property whose value it thinks is unchanged, so the previewed width would otherwise + // survive as layout that disagrees with both state and storage. Put the committed width back — + // it is the width the drag started from, since a drag commits nothing until release. + useEffect( + () => () => { + stopPreview(); + if (draggingRef.current) { + targetRef.current?.style.setProperty(SIDEBAR_WIDTH_VAR, `${originRef.current.width}px`); + } + }, + [stopPreview, targetRef], + ); + + // Hold the resize cursor for the whole gesture, so it does not flicker into a text caret + // whenever the pointer outruns the handle and crosses the memo list. + useEffect(() => { + if (!dragging) return; + const { body } = document; + const previousCursor = body.style.cursor; + const previousUserSelect = body.style.userSelect; + body.style.cursor = "col-resize"; + body.style.userSelect = "none"; + return () => { + body.style.cursor = previousCursor; + body.style.userSelect = previousUserSelect; + }; + }, [dragging]); + + const handlePointerDown = (event: ReactPointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + originRef.current = { x: event.clientX, width }; + pendingRef.current = width; + draggingRef.current = true; + setDragging(true); + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + if (!draggingRef.current) return; + // Track the delta rather than the raw cursor x, so grabbing anywhere in the strip + // does not snap the rail's edge to the pointer. + previewWidth(clamp(originRef.current.width + event.clientX - originRef.current.x)); + }; + + const endDrag = (event: ReactPointerEvent) => { + if (!draggingRef.current) return; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + stopPreview(); + draggingRef.current = false; + setDragging(false); + onWidthChange(pendingRef.current); + }; + + const handleKeyDown = (event: ReactKeyboardEvent) => { + const next = + event.key === "ArrowLeft" + ? width - KEYBOARD_STEP + : event.key === "ArrowRight" + ? width + KEYBOARD_STEP + : event.key === "Home" + ? minWidth + : event.key === "End" + ? maxWidth + : undefined; + if (next === undefined) return; + event.preventDefault(); + onWidthChange(next); + }; + + return ( +
onWidthChange(SIDEBAR_DEFAULT_WIDTH)} + onKeyDown={handleKeyDown} + className="group absolute inset-y-0 -right-1 z-10 flex w-2 cursor-col-resize touch-none justify-center focus-visible:outline-none" + > + {/* A 2px band centering to whole pixels inside the 8px strip, so it covers the rail's + border and stays crisp at 1x instead of antialiasing across a half-pixel seam. */} +
+
+ ); +}; + +export default SidebarResizeHandle; diff --git a/web/src/components/AppSidebar/index.ts b/web/src/components/AppSidebar/index.ts index 4c2e9b0f..3d92d57a 100644 --- a/web/src/components/AppSidebar/index.ts +++ b/web/src/components/AppSidebar/index.ts @@ -1,2 +1,4 @@ export { default, MobileAppHeader, MobileAppSidebar } from "./AppSidebar"; export { default as QuickFindDialog } from "./QuickFindDialog"; +export { default as SidebarResizeHandle } from "./SidebarResizeHandle"; +export { default as useSidebarWidth, SIDEBAR_WIDTH_VAR } from "./useSidebarWidth"; diff --git a/web/src/components/AppSidebar/useSidebarWidth.ts b/web/src/components/AppSidebar/useSidebarWidth.ts new file mode 100644 index 00000000..e889f11c --- /dev/null +++ b/web/src/components/AppSidebar/useSidebarWidth.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useState } from "react"; + +/** + * Custom property the layout shell publishes so the rail and the main padding stay in sync. + * Tailwind cannot read this constant, so `RootLayout` repeats the literal name in its + * `w-(--app-sidebar-width)` / `md:pl-(--app-sidebar-width)` classes — keep the two in step. + */ +export const SIDEBAR_WIDTH_VAR = "--app-sidebar-width"; + +/** 200px of content once `px-3` is removed, which keeps the month calendar's seven cells legible. */ +export const SIDEBAR_MIN_WIDTH = 224; +/** Wider than this only starves the feed: its columns need 260px each and the rail holds short labels. */ +export const SIDEBAR_MAX_WIDTH = 400; +export const SIDEBAR_DEFAULT_WIDTH = 256; + +/** The rail may never take more than this share of the window. */ +const MAX_VIEWPORT_SHARE = 0.4; + +const STORAGE_KEY = "memos-sidebar-width"; + +const viewportMaxWidth = (): number => { + if (typeof window === "undefined") return SIDEBAR_MAX_WIDTH; + return Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, Math.round(window.innerWidth * MAX_VIEWPORT_SHARE))); +}; + +const readStoredWidth = (): number => { + try { + const stored = Number(localStorage.getItem(STORAGE_KEY)); + if (Number.isFinite(stored) && stored > 0) return stored; + } catch (error) { + console.warn("Failed to load sidebar width from localStorage:", error); + } + return SIDEBAR_DEFAULT_WIDTH; +}; + +/** + * Desktop rail width: a persisted preference, capped by whatever the current window can spare. + * Read synchronously on mount so a stored width never flashes past the default. + */ +const useSidebarWidth = () => { + const [preferredWidth, setPreferredWidth] = useState(readStoredWidth); + const [maxWidth, setMaxWidth] = useState(viewportMaxWidth); + + useEffect(() => { + const handleResize = () => setMaxWidth(viewportMaxWidth()); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + // A narrow window caps what is rendered but leaves the stored preference alone, so widening + // the window restores the width the user actually chose. + const width = Math.min(Math.max(preferredWidth, SIDEBAR_MIN_WIDTH), maxWidth); + + const setWidth = useCallback( + (next: number) => { + const clamped = Math.min(Math.max(Math.round(next), SIDEBAR_MIN_WIDTH), maxWidth); + setPreferredWidth(clamped); + try { + localStorage.setItem(STORAGE_KEY, String(clamped)); + } catch (error) { + console.warn("Failed to persist sidebar width:", error); + } + }, + [maxWidth], + ); + + return { width, minWidth: SIDEBAR_MIN_WIDTH, maxWidth, setWidth }; +}; + +export default useSidebarWidth; diff --git a/web/src/layouts/RootLayout.tsx b/web/src/layouts/RootLayout.tsx index 9f71e256..f3666e42 100644 --- a/web/src/layouts/RootLayout.tsx +++ b/web/src/layouts/RootLayout.tsx @@ -1,6 +1,14 @@ +import type { CSSProperties } from "react"; import { useEffect, useRef } from "react"; import { Navigate, Outlet, useLocation, useSearchParams } from "react-router-dom"; -import AppSidebar, { MobileAppHeader, MobileAppSidebar, QuickFindDialog } from "@/components/AppSidebar"; +import AppSidebar, { + MobileAppHeader, + MobileAppSidebar, + QuickFindDialog, + SIDEBAR_WIDTH_VAR, + SidebarResizeHandle, + useSidebarWidth, +} from "@/components/AppSidebar"; import { AppSidebarProvider } from "@/contexts/AppSidebarContext"; import { useInstance } from "@/contexts/InstanceContext"; import { MemoFilterProvider, useMemoFilterContext } from "@/contexts/MemoFilterContext"; @@ -36,6 +44,8 @@ const RootLayoutContent = () => { const { removeFilter } = useMemoFilterContext(); const { pathname } = location; const prevPathnameRef = useRef(undefined); + const shellRef = useRef(null); + const { width: sidebarWidth, minWidth, maxWidth, setWidth: setSidebarWidth } = useSidebarWidth(); useEffect(() => { const prevPathname = prevPathnameRef.current; @@ -58,14 +68,21 @@ const RootLayoutContent = () => { return ( -
+
{md && ( -
+
+
)} -
+
{profile.demo && } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 5dde3ebd..3947a8fa 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -165,6 +165,7 @@ "remember-me": "Remember me", "rename": "Rename", "reset": "Reset", + "resize-sidebar": "Resize sidebar", "resources": "Resources", "restore": "Restore", "role": "Role", diff --git a/web/tests/sidebar-resize-handle.test.tsx b/web/tests/sidebar-resize-handle.test.tsx new file mode 100644 index 00000000..1705cc05 --- /dev/null +++ b/web/tests/sidebar-resize-handle.test.tsx @@ -0,0 +1,279 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { type CSSProperties, useRef, useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import SidebarResizeHandle from "@/components/AppSidebar/SidebarResizeHandle"; +import { SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH, SIDEBAR_WIDTH_VAR } from "@/components/AppSidebar/useSidebarWidth"; + +// The handle's contract has two halves. Keyboard and double-click go straight through +// `onWidthChange`. A pointer drag deliberately does not: it writes the width custom property +// on the layout shell every frame and commits to React exactly once, on release, so that +// dragging does not re-render the sidebar and the memo feed on every pointer move. Both +// halves are asserted here, since the drag half is invisible to a props-only test. + +vi.mock("@/utils/i18n", () => ({ + useTranslate: () => (key: string) => key, +})); + +// Mirrors how RootLayout wires the handle: the shell owns the custom property and the rail's +// handle writes to it. `railMounted` stands in for RootLayout's `md &&` gate, which unmounts the +// rail — and with it the handle — when the window drops below the desktop breakpoint, while the +// shell itself stays mounted. +const Harness = ({ onWidthChange = vi.fn(), railMounted = true }: { onWidthChange?: (width: number) => void; railMounted?: boolean }) => { + const targetRef = useRef(null); + const [width, setWidth] = useState(SIDEBAR_DEFAULT_WIDTH); + + return ( +
+ {railMounted && ( + { + setWidth(next); + onWidthChange(next); + }} + targetRef={targetRef} + /> + )} +
+ ); +}; + +const shellWidthVar = () => screen.getByTestId("shell").style.getPropertyValue(SIDEBAR_WIDTH_VAR); + +// Frames are queued rather than run inline: a synchronous requestAnimationFrame would +// misrepresent the platform, running the callback before the handle it returns is stored +// and so defeating the very coalescing these tests exercise. +let frameQueue: FrameRequestCallback[] = []; + +const flushFrames = () => { + const queued = frameQueue; + frameQueue = []; + for (const callback of queued) callback(0); +}; + +const movePointer = (clientX: number) => { + fireEvent.pointerMove(screen.getByRole("separator"), { pointerId: 1, clientX }); + flushFrames(); +}; + +const drag = (from: number, to: number) => { + const handle = screen.getByRole("separator"); + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: from }); + movePointer(to); + return handle; +}; + +describe("", () => { + beforeEach(() => { + // jsdom ships PointerEvent but not pointer capture. `vi.spyOn` cannot stand in for a method + // that does not exist at all, so these are plain assignments — undone in afterEach. + Element.prototype.setPointerCapture = vi.fn(); + Element.prototype.hasPointerCapture = vi.fn(() => true); + Element.prototype.releasePointerCapture = vi.fn(); + frameQueue = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => frameQueue.push(callback)); + vi.stubGlobal("cancelAnimationFrame", () => { + frameQueue = []; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + // Prototype assignments outlive `restoreMocks`, so drop them rather than leaning on + // Vitest's per-file isolation to hide the leak. + for (const method of ["setPointerCapture", "hasPointerCapture", "releasePointerCapture"]) { + Reflect.deleteProperty(Element.prototype, method); + } + }); + + it("exposes splitter semantics with the current width and its bounds", () => { + render(); + + const handle = screen.getByRole("separator"); + expect(handle).toHaveAttribute("aria-orientation", "vertical"); + expect(handle).toHaveAttribute("aria-valuenow", String(SIDEBAR_DEFAULT_WIDTH)); + expect(handle).toHaveAttribute("aria-valuemin", String(SIDEBAR_MIN_WIDTH)); + expect(handle).toHaveAttribute("aria-valuemax", String(SIDEBAR_MAX_WIDTH)); + expect(handle).toHaveAttribute("tabindex", "0"); + }); + + it("nudges the width with the arrow keys", () => { + const onWidthChange = vi.fn(); + render(); + const handle = screen.getByRole("separator"); + + fireEvent.keyDown(handle, { key: "ArrowRight" }); + expect(onWidthChange).toHaveBeenLastCalledWith(SIDEBAR_DEFAULT_WIDTH + 16); + + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + expect(onWidthChange).toHaveBeenLastCalledWith(SIDEBAR_DEFAULT_WIDTH); + }); + + it("jumps to each bound with Home and End", () => { + const onWidthChange = vi.fn(); + render(); + const handle = screen.getByRole("separator"); + + fireEvent.keyDown(handle, { key: "Home" }); + expect(onWidthChange).toHaveBeenLastCalledWith(SIDEBAR_MIN_WIDTH); + + fireEvent.keyDown(handle, { key: "End" }); + expect(onWidthChange).toHaveBeenLastCalledWith(SIDEBAR_MAX_WIDTH); + }); + + it("claims the arrow keys so they do not also scroll the page", () => { + render(); + + const prevented = !fireEvent.keyDown(screen.getByRole("separator"), { key: "ArrowRight" }); + + expect(prevented).toBe(true); + }); + + it("ignores keys it does not handle", () => { + const onWidthChange = vi.fn(); + render(); + + const notPrevented = fireEvent.keyDown(screen.getByRole("separator"), { key: "Enter" }); + + expect(onWidthChange).not.toHaveBeenCalled(); + expect(notPrevented).toBe(true); + }); + + it("resets to the default width on double click", () => { + const onWidthChange = vi.fn(); + render(); + const handle = screen.getByRole("separator"); + + fireEvent.keyDown(handle, { key: "End" }); + fireEvent.doubleClick(handle); + + expect(onWidthChange).toHaveBeenLastCalledWith(SIDEBAR_DEFAULT_WIDTH); + }); + + it("previews a drag through the custom property and commits once on release", () => { + const onWidthChange = vi.fn(); + render(); + + const handle = drag(SIDEBAR_DEFAULT_WIDTH, SIDEBAR_DEFAULT_WIDTH + 60); + + // Mid-drag the layout has already moved, but React has not been told. + expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH + 60}px`); + expect(onWidthChange).not.toHaveBeenCalled(); + + fireEvent.pointerUp(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 60 }); + + expect(onWidthChange).toHaveBeenCalledTimes(1); + expect(onWidthChange).toHaveBeenCalledWith(SIDEBAR_DEFAULT_WIDTH + 60); + }); + + it("tracks the pointer delta rather than its absolute position", () => { + const onWidthChange = vi.fn(); + render(); + + // Grabbing the strip off-centre must not snap the rail's edge onto the cursor. + const handle = drag(SIDEBAR_DEFAULT_WIDTH + 3, SIDEBAR_DEFAULT_WIDTH + 43); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 43 }); + + expect(onWidthChange).toHaveBeenCalledWith(SIDEBAR_DEFAULT_WIDTH + 40); + }); + + it("clamps a drag to the bounds while previewing", () => { + render(); + + drag(SIDEBAR_DEFAULT_WIDTH, SIDEBAR_DEFAULT_WIDTH + 500); + expect(shellWidthVar()).toBe(`${SIDEBAR_MAX_WIDTH}px`); + + movePointer(SIDEBAR_DEFAULT_WIDTH - 500); + expect(shellWidthVar()).toBe(`${SIDEBAR_MIN_WIDTH}px`); + }); + + it("coalesces several moves within one frame into a single write", () => { + render(); + const handle = screen.getByRole("separator"); + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH }); + + // A real pointer emits many moves per frame; only the newest position should reach the DOM. + fireEvent.pointerMove(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 10 }); + fireEvent.pointerMove(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 20 }); + fireEvent.pointerMove(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 30 }); + expect(frameQueue).toHaveLength(1); + + flushFrames(); + expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH + 30}px`); + }); + + it("holds the resize cursor for the whole gesture and restores it after", () => { + render(); + + const handle = drag(SIDEBAR_DEFAULT_WIDTH, SIDEBAR_DEFAULT_WIDTH + 20); + expect(document.body.style.cursor).toBe("col-resize"); + expect(document.body.style.userSelect).toBe("none"); + + fireEvent.pointerUp(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 20 }); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + }); + + it("commits and cleans up when a drag is cancelled", () => { + const onWidthChange = vi.fn(); + render(); + + const handle = drag(SIDEBAR_DEFAULT_WIDTH, SIDEBAR_DEFAULT_WIDTH + 24); + fireEvent.pointerCancel(handle, { pointerId: 1 }); + + expect(onWidthChange).toHaveBeenCalledWith(SIDEBAR_DEFAULT_WIDTH + 24); + expect(document.body.style.cursor).toBe(""); + }); + + it("reverts the preview when a drag is interrupted by the rail unmounting", () => { + const onWidthChange = vi.fn(); + const { rerender } = render(); + + drag(SIDEBAR_DEFAULT_WIDTH, SIDEBAR_DEFAULT_WIDTH + 60); + expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH + 60}px`); + + // The window crosses below the desktop breakpoint mid-drag, so no pointerup ever arrives. + rerender(); + + // Nothing was committed, so the shell must not keep displaying the previewed width. + expect(onWidthChange).not.toHaveBeenCalled(); + expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH}px`); + }); + + it("leaves the committed width alone when the rail unmounts outside a drag", () => { + const onWidthChange = vi.fn(); + const { rerender } = render(); + + const handle = drag(SIDEBAR_DEFAULT_WIDTH, SIDEBAR_DEFAULT_WIDTH + 60); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 60 }); + rerender(); + + expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH + 60}px`); + }); + + it("ignores pointer movement that did not start on the handle", () => { + const onWidthChange = vi.fn(); + render(); + const handle = screen.getByRole("separator"); + + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 900 }); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 900 }); + + expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH}px`); + expect(onWidthChange).not.toHaveBeenCalled(); + }); + + it("ignores non-primary buttons so a right click cannot start a drag", () => { + const onWidthChange = vi.fn(); + render(); + const handle = screen.getByRole("separator"); + + fireEvent.pointerDown(handle, { button: 2, pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH }); + fireEvent.pointerMove(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 60 }); + + expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH}px`); + expect(onWidthChange).not.toHaveBeenCalled(); + }); +}); diff --git a/web/tests/sidebar-width.test.tsx b/web/tests/sidebar-width.test.tsx new file mode 100644 index 00000000..9c8df8ad --- /dev/null +++ b/web/tests/sidebar-width.test.tsx @@ -0,0 +1,139 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import useSidebarWidth, { SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH } from "@/components/AppSidebar/useSidebarWidth"; + +// The desktop rail's width is a persisted preference that a narrow window may only *cap*, +// never overwrite. That distinction is the whole reason the hook keeps `preferredWidth` and +// the rendered `width` apart, so it is what these tests pin down. + +const STORAGE_KEY = "memos-sidebar-width"; + +// jsdom's default viewport (1024) leaves the 40% share above SIDEBAR_MAX_WIDTH, so the +// ceiling is the absolute one unless a test deliberately narrows the window. +const WIDE_VIEWPORT = 1024; + +const setViewportWidth = (width: number) => { + Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: width }); +}; + +const resizeViewportTo = (width: number) => { + act(() => { + setViewportWidth(width); + window.dispatchEvent(new Event("resize")); + }); +}; + +describe("useSidebarWidth", () => { + beforeEach(() => { + localStorage.clear(); + setViewportWidth(WIDE_VIEWPORT); + }); + + it("starts at the default width with nothing stored", () => { + const { result } = renderHook(() => useSidebarWidth()); + + expect(result.current.width).toBe(SIDEBAR_DEFAULT_WIDTH); + expect(result.current.minWidth).toBe(SIDEBAR_MIN_WIDTH); + expect(result.current.maxWidth).toBe(SIDEBAR_MAX_WIDTH); + }); + + it("restores a persisted width on the first render", () => { + localStorage.setItem(STORAGE_KEY, "312"); + + const { result } = renderHook(() => useSidebarWidth()); + + // Read synchronously in the state initializer, so the stored width is the first painted + // width rather than a correction applied after the default flashes. + expect(result.current.width).toBe(312); + }); + + it("persists a committed width", () => { + const { result } = renderHook(() => useSidebarWidth()); + + act(() => result.current.setWidth(320)); + + expect(result.current.width).toBe(320); + expect(localStorage.getItem(STORAGE_KEY)).toBe("320"); + }); + + it("clamps a commit below the floor", () => { + const { result } = renderHook(() => useSidebarWidth()); + + act(() => result.current.setWidth(80)); + + expect(result.current.width).toBe(SIDEBAR_MIN_WIDTH); + expect(localStorage.getItem(STORAGE_KEY)).toBe(String(SIDEBAR_MIN_WIDTH)); + }); + + it("clamps a commit above the ceiling", () => { + const { result } = renderHook(() => useSidebarWidth()); + + act(() => result.current.setWidth(900)); + + expect(result.current.width).toBe(SIDEBAR_MAX_WIDTH); + expect(localStorage.getItem(STORAGE_KEY)).toBe(String(SIDEBAR_MAX_WIDTH)); + }); + + it("rounds fractional widths, which a drag can produce on a scaled display", () => { + const { result } = renderHook(() => useSidebarWidth()); + + act(() => result.current.setWidth(287.6)); + + expect(result.current.width).toBe(288); + }); + + it("caps the rendered width on a narrow window without discarding the preference", () => { + localStorage.setItem(STORAGE_KEY, String(SIDEBAR_MAX_WIDTH)); + const { result } = renderHook(() => useSidebarWidth()); + expect(result.current.width).toBe(SIDEBAR_MAX_WIDTH); + + resizeViewportTo(700); + + // 40% of 700, and the stored preference is left alone so it can come back. + expect(result.current.width).toBe(280); + expect(result.current.maxWidth).toBe(280); + expect(localStorage.getItem(STORAGE_KEY)).toBe(String(SIDEBAR_MAX_WIDTH)); + }); + + it("restores the full preference once the window has room again", () => { + localStorage.setItem(STORAGE_KEY, String(SIDEBAR_MAX_WIDTH)); + const { result } = renderHook(() => useSidebarWidth()); + + resizeViewportTo(700); + expect(result.current.width).toBe(280); + + resizeViewportTo(WIDE_VIEWPORT); + expect(result.current.width).toBe(SIDEBAR_MAX_WIDTH); + }); + + it("never lets the viewport cap fall below the floor", () => { + // A window this narrow only renders the mobile sheet, but the ceiling must not invert + // past the floor and produce a clamp range that cannot be satisfied. + resizeViewportTo(320); + + const { result } = renderHook(() => useSidebarWidth()); + + expect(result.current.maxWidth).toBe(SIDEBAR_MIN_WIDTH); + expect(result.current.width).toBe(SIDEBAR_MIN_WIDTH); + }); + + it.each([ + ["a non-numeric value", "wide"], + ["an empty value", ""], + ["a negative value", "-40"], + ])("falls back to the default for %s", (_label, stored) => { + localStorage.setItem(STORAGE_KEY, stored); + + const { result } = renderHook(() => useSidebarWidth()); + + expect(result.current.width).toBe(SIDEBAR_DEFAULT_WIDTH); + }); + + it("raises a stored width that predates the current floor", () => { + localStorage.setItem(STORAGE_KEY, "120"); + + const { result } = renderHook(() => useSidebarWidth()); + + expect(result.current.width).toBe(SIDEBAR_MIN_WIDTH); + }); +});