feat(sidebar): make the desktop rail resizable (#6148)
Signed-off-by: Johnny <johnnyjoygg@gmail.com>
This commit is contained in:
parent
3b8d2558ed
commit
abe87d9440
7 changed files with 673 additions and 4 deletions
161
web/src/components/AppSidebar/SidebarResizeHandle.tsx
Normal file
161
web/src/components/AppSidebar/SidebarResizeHandle.tsx
Normal 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;
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
70
web/src/components/AppSidebar/useSidebarWidth.ts
Normal file
70
web/src/components/AppSidebar/useSidebarWidth.ts
Normal 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;
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@
|
|||
"remember-me": "Remember me",
|
||||
"rename": "Rename",
|
||||
"reset": "Reset",
|
||||
"resize-sidebar": "Resize sidebar",
|
||||
"resources": "Resources",
|
||||
"restore": "Restore",
|
||||
"role": "Role",
|
||||
|
|
|
|||
279
web/tests/sidebar-resize-handle.test.tsx
Normal file
279
web/tests/sidebar-resize-handle.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
139
web/tests/sidebar-width.test.tsx
Normal file
139
web/tests/sidebar-width.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue