feat(sidebar): make the desktop rail resizable (#6148)

Signed-off-by: Johnny <johnnyjoygg@gmail.com>
This commit is contained in:
Johnny 2026-08-06 19:18:38 +08:00 committed by GitHub
parent 3b8d2558ed
commit abe87d9440
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 673 additions and 4 deletions

View file

@ -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<HTMLElement | null>;
}
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<number | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div
role="separator"
aria-orientation="vertical"
aria-label={t("common.resize-sidebar")}
aria-valuenow={width}
aria-valuemin={minWidth}
aria-valuemax={maxWidth}
tabIndex={0}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onDoubleClick={() => 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. */}
<div
className={cn(
"h-full w-0.5 transition-colors",
dragging ? "bg-primary/70" : "bg-transparent group-hover:bg-border group-focus-visible:bg-primary/70",
)}
/>
</div>
);
};
export default SidebarResizeHandle;

View file

@ -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";

View file

@ -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;

View file

@ -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<string | undefined>(undefined);
const shellRef = useRef<HTMLDivElement>(null);
const { width: sidebarWidth, minWidth, maxWidth, setWidth: setSidebarWidth } = useSidebarWidth();
useEffect(() => {
const prevPathname = prevPathnameRef.current;
@ -58,14 +68,21 @@ const RootLayoutContent = () => {
return (
<AppSidebarProvider>
<div className="min-h-full w-full bg-background">
<div ref={shellRef} className="min-h-full w-full bg-background" style={{ [SIDEBAR_WIDTH_VAR]: `${sidebarWidth}px` } as CSSProperties}>
{md && (
<div className="fixed inset-y-0 left-0 z-30 w-64 border-r border-border/70">
<div className="fixed inset-y-0 left-0 z-30 w-(--app-sidebar-width) border-r border-border/70">
<AppSidebar />
<SidebarResizeHandle
width={sidebarWidth}
minWidth={minWidth}
maxWidth={maxWidth}
onWidthChange={setSidebarWidth}
targetRef={shellRef}
/>
</div>
)}
<MobileAppSidebar />
<main className="flex min-h-full w-full min-w-0 flex-col items-center md:pl-64">
<main className="flex min-h-full w-full min-w-0 flex-col items-center md:pl-(--app-sidebar-width)">
<MobileAppHeader />
{profile.demo && <DemoBanner />}
<Outlet />

View file

@ -165,6 +165,7 @@
"remember-me": "Remember me",
"rename": "Rename",
"reset": "Reset",
"resize-sidebar": "Resize sidebar",
"resources": "Resources",
"restore": "Restore",
"role": "Role",

View file

@ -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<HTMLDivElement>(null);
const [width, setWidth] = useState(SIDEBAR_DEFAULT_WIDTH);
return (
<div ref={targetRef} data-testid="shell" style={{ [SIDEBAR_WIDTH_VAR]: `${width}px` } as CSSProperties}>
{railMounted && (
<SidebarResizeHandle
width={width}
minWidth={SIDEBAR_MIN_WIDTH}
maxWidth={SIDEBAR_MAX_WIDTH}
onWidthChange={(next) => {
setWidth(next);
onWidthChange(next);
}}
targetRef={targetRef}
/>
)}
</div>
);
};
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("<SidebarResizeHandle />", () => {
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(<Harness />);
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(<Harness onWidthChange={onWidthChange} />);
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(<Harness onWidthChange={onWidthChange} />);
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(<Harness />);
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(<Harness onWidthChange={onWidthChange} />);
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(<Harness onWidthChange={onWidthChange} />);
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(<Harness onWidthChange={onWidthChange} />);
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(<Harness onWidthChange={onWidthChange} />);
// 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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness onWidthChange={onWidthChange} />);
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(<Harness onWidthChange={onWidthChange} />);
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(<Harness onWidthChange={onWidthChange} railMounted={false} />);
// 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(<Harness onWidthChange={onWidthChange} />);
const handle = drag(SIDEBAR_DEFAULT_WIDTH, SIDEBAR_DEFAULT_WIDTH + 60);
fireEvent.pointerUp(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 60 });
rerender(<Harness onWidthChange={onWidthChange} railMounted={false} />);
expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH + 60}px`);
});
it("ignores pointer movement that did not start on the handle", () => {
const onWidthChange = vi.fn();
render(<Harness onWidthChange={onWidthChange} />);
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(<Harness onWidthChange={onWidthChange} />);
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();
});
});

View file

@ -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);
});
});