From 80e4bdccf60d04042696dc39cccb9fd1397f1d83 Mon Sep 17 00:00:00 2001 From: Luan Nguyen Date: Thu, 12 Nov 2026 00:18:55 +1100 Subject: [PATCH] feat(navigation): add accessible sidebar resizing --- docs/architecture.md | 14 ++ web/src/styles/app.css | 75 ++++++- web/src/ui/SidebarResize.tsx | 173 ++++++++++++++++ web/src/ui/Workspace.tsx | 34 +++- .../integration/sidebar-resize-browser.mjs | 190 ++++++++++++++++++ web/tests/ui/library.test.tsx | 2 +- web/tests/ui/sidebar-desktop.test.tsx | 76 +++++++ web/tests/ui/sidebar-resize.test.tsx | 147 ++++++++++++++ web/tests/ui/sidebar-workspace.test.tsx | 113 +++++++++++ 9 files changed, 812 insertions(+), 12 deletions(-) create mode 100644 web/src/ui/SidebarResize.tsx create mode 100644 web/tests/integration/sidebar-resize-browser.mjs create mode 100644 web/tests/ui/sidebar-desktop.test.tsx create mode 100644 web/tests/ui/sidebar-resize.test.tsx create mode 100644 web/tests/ui/sidebar-workspace.test.tsx diff --git a/docs/architecture.md b/docs/architecture.md index b13009a..e40bd2b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -215,3 +215,17 @@ agent integrations, public sharing, other native platforms and billing. The local module slices above do not qualify every approved design frame. Unsupported integration states must remain explicit; fixtures and generated API types do not count as working services. + + +## Sidebar width + +Wide browser and native layouts (at least 900px) share a live sidebar resize +separator. Width is 180–420px, default 224px, stored under `notes.sidebar-width` +in device-local storage; storage denial keeps an in-memory preference usable. +Narrow drawers remain 224px and have no resize rail. Hiding/showing the wide +sidebar retains its width. Pointer capture is retired on cancellation, lost +capture, blur, disable and unmount without changing document/body styles. +The separator supports 16px Left/Right steps; double-click or Enter resets 224px. +This is a layout preference and does not navigate, save content or remount actors. +The design source is revision 95bd47e899600c0f038a7d4622a40cfc61d063e2, +Notes `sidebar-dragging` and Components `window-title-row`. diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 5117800..523d2d5 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -162,8 +162,9 @@ h3 { font-size: 14px; } .app { + position: relative; display: grid; - grid-template-columns: 224px minmax(0, 1fr); + grid-template-columns: var(--sidebar-width, 224px) minmax(0, 1fr); height: 100dvh; min-height: 400px; font-size: 13px; @@ -972,3 +973,75 @@ dialog p { .link-popover-lock { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line2); } .link-popover-lock small { flex: 1; } + +/* orbyn-designs 95bd47e, sidebar-dragging / window-title-row. */ +.sidebar-resize { + position: absolute; + top: 0; + bottom: 0; + width: 16px; + z-index: 7; + cursor: col-resize; + touch-action: none; + user-select: none; + outline: none; +} +.sidebar-resize::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 7px; + width: 2px; + border-radius: 1px; + background: color-mix(in srgb, var(--t1) 60%, transparent); + opacity: 0; + transition: opacity 150ms; +} +.sidebar-resize:hover::after, +.sidebar-resize:focus-visible::after, +.sidebar-resize.dragging::after { + opacity: 1; +} +.sidebar-width-pill { + position: absolute; + top: 50%; + left: 8px; + transform: translate(-50%, -50%); + z-index: 8; + pointer-events: none; + font: + 11px/1 ui-monospace, + SFMono-Regular, + Consolas, + monospace; + padding: 4px 7px; + border-radius: 7px; + background: var(--t1); + color: var(--main); + white-space: nowrap; +} +.sidebar .nav > svg, +.sidebar .nav > span:not(.nav-label), +.sidebar .search > svg, +.sidebar .search > kbd { + flex-shrink: 0; +} +.sidebar .nav .nav-label { + flex: 1; + min-width: 0; + margin-left: 0; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: inherit; + font-size: inherit; +} +.sidebar .group, +.sidebar .search > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/web/src/ui/SidebarResize.tsx b/web/src/ui/SidebarResize.tsx new file mode 100644 index 0000000..d1d8914 --- /dev/null +++ b/web/src/ui/SidebarResize.tsx @@ -0,0 +1,173 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; + +export const SIDEBAR_MIN = 180; +export const SIDEBAR_MAX = 420; +export const SIDEBAR_DEFAULT = 224; +const WIDTH_KEY = "notes.sidebar-width"; + +function validWidth(value: number) { + return Number.isFinite(value) && value >= SIDEBAR_MIN && value <= SIDEBAR_MAX; +} +function clamp(value: number) { + return Math.round(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, value))); +} + +/** Device-local presentation preference; unavailable storage leaves resizing usable. */ +export function useSidebarWidth() { + const [width, setWidth] = useState(() => { + try { + const raw = localStorage.getItem(WIDTH_KEY); + const value = raw === null || raw.trim() === "" ? NaN : Number(raw); + return validWidth(value) ? clamp(value) : SIDEBAR_DEFAULT; + } catch { + /* Optional storage cannot prevent rendering. */ + } + return SIDEBAR_DEFAULT; + }); + const changeWidth = useCallback((value: number) => { + if (!Number.isFinite(value)) return; + const next = clamp(value); + setWidth(next); + try { + localStorage.setItem(WIDTH_KEY, String(next)); + } catch { + /* Optional preference. */ + } + }, []); + return [width, changeWidth] as const; +} + +export function SidebarResize({ + value, + onChange, + controls = "notes-sidebar", + disabled = false, +}: { + value: number; + onChange: (value: number) => void; + controls?: string; + disabled?: boolean; +}) { + const rail = useRef(null); + const active = useRef<{ + id: number; + x: number; + width: number; + element: HTMLDivElement; + } | null>(null); + const [dragging, setDragging] = useState(false); + const current = useRef({ disabled, onChange }); + current.current = { disabled, onChange }; + const width = validWidth(value) ? clamp(value) : SIDEBAR_DEFAULT; + + const release = useCallback(() => { + const pointer = active.current; + active.current = null; + if (pointer?.element.hasPointerCapture?.(pointer.id)) { + pointer.element.releasePointerCapture(pointer.id); + } + }, []); + const stop = useCallback(() => { + release(); + setDragging(false); + }, [release]); + useLayoutEffect(() => { + if (disabled) stop(); + }, [disabled, stop]); + useEffect(() => { + window.addEventListener("blur", stop); + return () => { + window.removeEventListener("blur", stop); + release(); + }; + }, [stop, release]); + if (disabled) return null; + return ( +
{ + if (event.button !== 0 || event.isPrimary === false || active.current) + return; + event.preventDefault(); + event.currentTarget.focus(); + active.current = { + id: event.pointerId, + x: event.clientX, + width, + element: event.currentTarget, + }; + event.currentTarget.setPointerCapture(event.pointerId); + setDragging(true); + }} + onPointerMove={(event) => { + const pointer = active.current; + if ( + !pointer || + event.pointerId !== pointer.id || + current.current.disabled + ) + return; + current.current.onChange( + clamp(pointer.width + event.clientX - pointer.x), + ); + }} + onPointerUp={(event) => { + if (event.pointerId === active.current?.id) stop(); + }} + onPointerCancel={(event) => { + if (event.pointerId === active.current?.id) stop(); + }} + onLostPointerCapture={(event) => { + if (event.pointerId === active.current?.id) stop(); + }} + onBlur={stop} + onDoubleClick={() => { + stop(); + onChange(SIDEBAR_DEFAULT); + }} + onKeyDown={(event) => { + if ( + event.key !== "ArrowLeft" && + event.key !== "ArrowRight" && + event.key !== "Enter" + ) + return; + event.preventDefault(); + stop(); + onChange( + event.key === "Enter" + ? SIDEBAR_DEFAULT + : clamp(width + (event.key === "ArrowLeft" ? -16 : 16)), + ); + }} + > + {dragging && ( + + )} +
+ ); +} diff --git a/web/src/ui/Workspace.tsx b/web/src/ui/Workspace.tsx index 96e148c..b7fd42b 100644 --- a/web/src/ui/Workspace.tsx +++ b/web/src/ui/Workspace.tsx @@ -1,5 +1,6 @@ import { lazy, + type CSSProperties, Suspense, useEffect, useLayoutEffect, @@ -29,6 +30,7 @@ import { import { projectNotes, plainText, message } from "./model"; import { Dialog, usePreferences } from "./primitives"; import { SettingsView } from "./SettingsView"; +import { SidebarResize, useSidebarWidth } from "./SidebarResize"; import type { SettingsLeaveGuard } from "./studySettingsTypes"; import { NoteEditor } from "./NoteEditor"; import { buildDownload } from "./download"; @@ -396,6 +398,7 @@ export function Workspace({ const [drawer, setDrawer] = useState(false); const desktop = useDesktop(); const [sidebarHidden, setSidebarHidden] = useState(false); + const [sidebarWidth, setSidebarWidth] = useSidebarWidth(); const [wide, setWide] = useState( () => matchMedia("(min-width: 900px)").matches, ); @@ -1517,6 +1520,7 @@ export function Workspace({ className={ desktop && wide && sidebarHidden ? "app desktop-sidebar-hidden" : "app" } + style={{ "--sidebar-width": `${sidebarWidth}px` } as CSSProperties} > Skip to notes @@ -1594,7 +1598,8 @@ export function Workspace({ onClick={() => navigate()} > - All notes {notes.filter((n) => !n.trashedAt).length} + All notes{" "} + {notes.filter((n) => !n.trashedAt).length} {library && ( <> @@ -1677,7 +1682,7 @@ export function Workspace({ }} > - Pinned + Pinned {snapshot?.metadata.filter( (meta) => @@ -1705,7 +1710,7 @@ export function Workspace({ setDrawer(false); }} > - Linked + Linked )} )} @@ -1753,7 +1758,9 @@ export function Workspace({ : undefined } /> - {t} + + {t} + {count} ); @@ -1763,7 +1770,8 @@ export function Workspace({ onClick={() => navigate(true)} > - Trash{notes.filter((n) => n.trashedAt).length} + Trash + {notes.filter((n) => n.trashedAt).length}
@@ -1782,10 +1790,16 @@ export function Workspace({ }} > - Settings + Settings
+
errors.push(error.message)); +const rail = page.getByRole("separator", { name: "Resize sidebar" }); +const sidebar = page.getByLabel("Notes navigation", { exact: true }); +async function width(value) { + await expect(rail).toHaveAttribute("aria-valuenow", String(value)); + await expect + .poll(async () => Math.round((await sidebar.boundingBox()).width)) + .toBe(value); +} +async function dragTo(value) { + const box = await rail.boundingBox(); + if (!box) throw Error("No resize rail"); + await page.mouse.move(box.x + box.width / 2, box.y + 100); + await page.mouse.down(); + await page.mouse.move(value, box.y + 100, { steps: 8 }); + await width(value); +} +try { + await page.goto(`${base}/tests/integration/library-browser.html`); + await expect( + page.getByRole("heading", { name: "All notes", exact: true }), + ).toBeVisible(); + await width(224); + await page + .getByRole("button", { name: "New note", exact: true }) + .first() + .click(); + await page + .getByLabel("Note title", { exact: true }) + .fill("Sidebar retained note"); + await page + .getByLabel("Note content", { exact: true }) + .fill("Writing survives sidebar gestures."); + await expect( + page.getByText("Saved in this browser", { exact: true }), + ).toBeVisible(); + const longTag = + "A deliberately long sidebar tag whose full accessible name survives resizing"; + await page.getByLabel("New tag", { exact: true }).fill(longTag); + await page.getByRole("button", { name: "Add tag", exact: true }).click(); + await expect( + page.getByRole("button", { name: `${longTag}, 1 note`, exact: true }), + ).toBeVisible(); + for (const theme of ["light", "dark", "dim"]) { + await page.evaluate((theme) => { + document.documentElement.dataset.theme = theme; + }, theme); + await dragTo(300); + await page.screenshot({ path: `${output}/dragging-${theme}.png` }); + await page.mouse.up(); + await rail.dblclick(); + await width(224); + } + await rail.focus(); + await page.keyboard.press("ArrowLeft"); + await width(208); + await page.keyboard.press("Enter"); + await width(224); + await dragTo(420); + await page.mouse.move(800, 150); + await width(420); + await page.mouse.up(); + await dragTo(180); + await page.mouse.move(50, 150); + await width(180); + const tagButton = page.getByRole("button", { + name: `${longTag}, 1 note`, + exact: true, + }); + await expect(tagButton).toBeVisible(); + const label = tagButton.locator(".nav-label"); + expect( + await label.evaluate( + (element) => element.scrollWidth > element.clientWidth, + ), + ).toBe(true); + await page.screenshot({ path: `${output}/minimum-long-tag-dim.png` }); + await page.mouse.up(); + await dragTo(300); + await page.mouse.up(); + await expect(page.getByLabel("Note content", { exact: true })).toContainText( + "Writing survives sidebar gestures.", + ); + await page.reload(); + await width(300); + await page + .getByRole("button", { name: /Sidebar retained note/ }) + .first() + .click(); + await expect(page.getByLabel("Note content", { exact: true })).toContainText( + "Writing survives sidebar gestures.", + ); + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.evaluate(() => { + document.documentElement.dataset.theme = "dim"; + }); + await dragTo(320); + await expect(rail).toHaveCSS("width", "16px"); + expect( + await rail.evaluate( + (element) => getComputedStyle(element, "::after").width, + ), + ).toBe("2px"); + await page.screenshot({ path: `${output}/dragging-reduced-dim.png` }); + await page.mouse.up(); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(rail).toHaveCount(0); + await page + .getByRole("button", { name: "Open navigation", exact: true }) + .click(); + await expect( + page.getByRole("dialog", { name: "Notes navigation" }), + ).toBeVisible(); + await expect(rail).toHaveCount(0); + await page.screenshot({ path: `${output}/narrow-drawer-dim.png` }); + await page.keyboard.press("Escape"); + await page.setViewportSize({ width: 1280, height: 850 }); + await width(320); + const denied = await browser.newContext({ + viewport: { width: 1280, height: 850 }, + }); + try { + await denied.addInitScript(() => { + const get = Storage.prototype.getItem, + set = Storage.prototype.setItem; + Storage.prototype.getItem = function (key) { + if (key === "notes.sidebar-width") + throw new DOMException("Denied", "SecurityError"); + return get.call(this, key); + }; + Storage.prototype.setItem = function (key, value) { + if (key === "notes.sidebar-width") + throw new DOMException("Denied", "SecurityError"); + return set.call(this, key, value); + }; + }); + const deniedPage = await denied.newPage(); + deniedPage.on("pageerror", (error) => errors.push(error.message)); + await deniedPage.goto(`${base}/tests/integration/library-browser.html`); + const deniedRail = deniedPage.getByRole("separator", { + name: "Resize sidebar", + }); + await expect(deniedRail).toHaveAttribute("aria-valuenow", "224"); + await deniedRail.focus(); + await deniedPage.keyboard.press("ArrowRight"); + await expect(deniedRail).toHaveAttribute("aria-valuenow", "240"); + } finally { + await denied.close(); + } + expect(errors).toEqual([]); + await writeFile( + `${output}/result.json`, + JSON.stringify( + { + qualification: + "Real production Workspace, WASM and IndexedDB in disposable synthetic-account browser profiles; no native physical-input/FPS/backend-auth claim", + checks: [ + "live pointer clamp180..420", + "doubleclick224 reset", + "keyboard16px and Enter reset", + "preference reload", + "encrypted note retained", + "Light Dark Dim", + "OS reduced motion", + "narrow drawer no rail", + "storage denial retains in-memory resizing", + ], + pageErrors: errors, + }, + null, + 2, + ), + ); + console.log("Sidebar resize genuine encrypted browser journey passed"); +} finally { + await context.close(); + await browser.close(); +} diff --git a/web/tests/ui/library.test.tsx b/web/tests/ui/library.test.tsx index 04cc390..065e967 100644 --- a/web/tests/ui/library.test.tsx +++ b/web/tests/ui/library.test.tsx @@ -354,7 +354,7 @@ it("keeps legacy read/export available but rejects editing without a fabricated workspace(api); await screen.findByText(/Library metadata exceeds/); expect(screen.getByText("New note")).toBeDisabled(); - expect(screen.getByText("Manage tags")).toBeDisabled(); + expect(screen.getByRole("button", { name: "Manage tags" })).toBeDisabled(); fireEvent.click(screen.getByRole("button", { name: /A note/ })); expect(screen.getByLabelText("Note title")).toHaveAttribute("readonly"); fireEvent.click(screen.getByRole("button", { name: "Note menu" })); diff --git a/web/tests/ui/sidebar-desktop.test.tsx b/web/tests/ui/sidebar-desktop.test.tsx new file mode 100644 index 0000000..ee64475 --- /dev/null +++ b/web/tests/ui/sidebar-desktop.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { DesktopShell } from "../../src/desktop/DesktopShell"; +import { connectWindow, isDesktop } from "../../src/desktop/window"; +import { Workspace } from "../../src/ui/Workspace"; +import type { NotesStore } from "../../src/domain/contracts"; + +vi.mock("../../src/desktop/window", () => ({ + isDesktop: vi.fn(), + connectWindow: vi.fn(), +})); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +it("removes the resize rail when native navigation is collapsed and retains its width on reopen", async () => { + localStorage.clear(); + vi.stubGlobal("matchMedia", () => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + vi.mocked(isDesktop).mockReturnValue(true); + const drag = vi.fn(async () => {}); + vi.mocked(connectWindow).mockResolvedValue({ + minimize: vi.fn(async () => {}), + toggleMaximize: vi.fn(async () => {}), + isMaximized: async () => false, + close: vi.fn(async () => {}), + startDragging: drag, + showSystemMenu: vi.fn(async () => {}), + finishClose: vi.fn(async () => {}), + dispose: vi.fn(), + }); + const mutation = vi.fn(async () => { + throw new Error("Unexpected domain write"); + }); + const store: NotesStore = { + list: async () => [], + get: async () => null, + create: mutation, + update: mutation, + trash: mutation, + restore: mutation, + erase: mutation, + subscribe: () => () => {}, + persistence: () => ({ status: "ready" }), + close: vi.fn(), + }; + render( + + {}} + /> + , + ); + const rail = await screen.findByRole("separator", { name: "Resize sidebar" }); + fireEvent.keyDown(rail, { key: "ArrowRight" }); + expect(rail).toHaveAttribute("aria-valuenow", "240"); + fireEvent.click(screen.getByRole("button", { name: "Hide sidebar" })); + expect(screen.queryByRole("separator")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Show sidebar" })); + expect(await screen.findByRole("separator")).toHaveAttribute( + "aria-valuenow", + "240", + ); + expect(drag).not.toHaveBeenCalled(); + expect(mutation).not.toHaveBeenCalled(); +}); diff --git a/web/tests/ui/sidebar-resize.test.tsx b/web/tests/ui/sidebar-resize.test.tsx new file mode 100644 index 0000000..b31c6a8 --- /dev/null +++ b/web/tests/ui/sidebar-resize.test.tsx @@ -0,0 +1,147 @@ +import { useState } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SidebarResize } from "../../src/ui/SidebarResize"; + +function fixture(disabled = false) { + const changed = vi.fn(); + function Controlled({ disabled }: { disabled: boolean }) { + const [value, setValue] = useState(224); + return ( + { + changed(next); + setValue(next); + }} + /> + ); + } + const view = render(); + return { changed, view, Controlled }; +} +function pointer(target: Element, type: string, x: number, id = 1, button = 0) { + const event = new MouseEvent(type, { bubbles: true, clientX: x, button }); + Object.defineProperties(event, { + pointerId: { value: id }, + isPrimary: { value: true }, + }); + fireEvent(target, event); +} +function capture(rail: HTMLElement) { + const captured = new Set(); + Object.assign(rail, { + setPointerCapture: vi.fn((id: number) => captured.add(id)), + releasePointerCapture: vi.fn((id: number) => captured.delete(id)), + hasPointerCapture: (id: number) => captured.has(id), + }); +} +describe("sidebar resize", () => { + it("exposes the vertical keyboard separator and its current bounded width", () => { + fixture(); + const rail = screen.getByRole("separator", { name: "Resize sidebar" }); + expect(rail).toHaveAttribute("aria-orientation", "vertical"); + expect(rail).toHaveAttribute("aria-controls", "notes-sidebar"); + expect(rail).toHaveAttribute("aria-valuemin", "180"); + expect(rail).toHaveAttribute("aria-valuemax", "420"); + expect(rail).toHaveAttribute("aria-valuenow", "224"); + expect(rail).toHaveAttribute("tabindex", "0"); + }); + it("steps by sixteen, clamps both ends, and resets with Enter", () => { + fixture(); + const rail = screen.getByRole("separator"); + fireEvent.keyDown(rail, { key: "ArrowRight" }); + expect(rail).toHaveAttribute("aria-valuenow", "240"); + for (let i = 0; i < 30; i++) fireEvent.keyDown(rail, { key: "ArrowRight" }); + expect(rail).toHaveAttribute("aria-valuenow", "420"); + for (let i = 0; i < 30; i++) fireEvent.keyDown(rail, { key: "ArrowLeft" }); + expect(rail).toHaveAttribute("aria-valuenow", "180"); + fireEvent.keyDown(rail, { key: "Enter" }); + expect(rail).toHaveAttribute("aria-valuenow", "224"); + }); + it("leaves unrelated keyboard commands alone", () => { + const { changed } = fixture(); + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "ArrowDown", + }); + fireEvent(screen.getByRole("separator"), event); + expect(changed).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + it("updates continuously for its captured pointer and resets on double click", () => { + fixture(); + const rail = screen.getByRole("separator"); + capture(rail); + pointer(rail, "pointerdown", 224); + pointer(rail, "pointermove", 300); + expect(rail).toHaveAttribute("aria-valuenow", "300"); + pointer(rail, "pointermove", 1000); + expect(rail).toHaveAttribute("aria-valuenow", "420"); + pointer(rail, "pointerup", 1000); + pointer(rail, "pointermove", 180); + expect(rail).toHaveAttribute("aria-valuenow", "420"); + fireEvent.doubleClick(rail); + expect(rail).toHaveAttribute("aria-valuenow", "224"); + }); + it("ignores secondary buttons and other pointer identities", () => { + const { changed } = fixture(); + const rail = screen.getByRole("separator"); + capture(rail); + pointer(rail, "pointerdown", 224, 1, 2); + pointer(rail, "pointermove", 300); + expect(changed).not.toHaveBeenCalled(); + pointer(rail, "pointerdown", 224, 1); + pointer(rail, "pointermove", 350, 2); + pointer(rail, "pointerup", 350, 2); + expect(changed).not.toHaveBeenCalled(); + pointer(rail, "pointermove", 280, 1); + expect(rail).toHaveAttribute("aria-valuenow", "280"); + }); + it.each(["pointercancel", "lostpointercapture"])( + "retires a gesture on %s", + (type) => { + fixture(); + const rail = screen.getByRole("separator"); + capture(rail); + pointer(rail, "pointerdown", 224); + pointer(rail, "pointermove", 280); + pointer(rail, type, 280); + pointer(rail, "pointermove", 350); + expect(rail).toHaveAttribute("aria-valuenow", "280"); + }, + ); + it("removes the rail and retires a captured gesture when disabled", () => { + const { view, Controlled, changed } = fixture(); + const rail = screen.getByRole("separator"); + capture(rail); + pointer(rail, "pointerdown", 224); + view.rerender(); + expect(screen.queryByRole("separator")).not.toBeInTheDocument(); + pointer(rail, "pointermove", 300); + expect(changed).not.toHaveBeenCalled(); + expect(rail.releasePointerCapture).toHaveBeenCalledWith(1); + }); + it("releases capture on unmount and admits no late pointer update", () => { + const { view, changed } = fixture(); + const rail = screen.getByRole("separator"); + capture(rail); + pointer(rail, "pointerdown", 224); + view.unmount(); + expect(rail.releasePointerCapture).toHaveBeenCalledWith(1); + pointer(rail, "pointermove", 300); + expect(changed).not.toHaveBeenCalled(); + }); + it("retires capture when the window loses focus", () => { + const { changed } = fixture(); + const rail = screen.getByRole("separator"); + capture(rail); + pointer(rail, "pointerdown", 224); + fireEvent.blur(window); + pointer(rail, "pointermove", 300); + expect(changed).not.toHaveBeenCalled(); + expect(rail.releasePointerCapture).toHaveBeenCalledWith(1); + }); +}); diff --git a/web/tests/ui/sidebar-workspace.test.tsx b/web/tests/ui/sidebar-workspace.test.tsx new file mode 100644 index 0000000..3d8c8d8 --- /dev/null +++ b/web/tests/ui/sidebar-workspace.test.tsx @@ -0,0 +1,113 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { Workspace } from "../../src/ui/Workspace"; +import type { NotesStore } from "../../src/domain/contracts"; + +let wide = true; +const mediaListeners = new Set<() => void>(); +beforeEach(() => { + localStorage.clear(); + wide = true; + mediaListeners.clear(); + vi.stubGlobal("matchMedia", () => ({ + get matches() { + return wide; + }, + addEventListener: (_: string, listener: () => void) => + mediaListeners.add(listener), + removeEventListener: (_: string, listener: () => void) => + mediaListeners.delete(listener), + })); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +function workspace() { + const unsupported = vi.fn(async () => { + throw new Error("Unexpected domain mutation"); + }); + const store: NotesStore = { + list: async () => [], + get: async () => null, + create: unsupported, + update: unsupported, + trash: unsupported, + restore: unsupported, + erase: unsupported, + subscribe: () => () => {}, + persistence: () => ({ status: "ready" }), + close: vi.fn(), + }; + const view = render( + {}} + />, + ); + return { view, unsupported }; +} +it("persists only the width preference and restores it on remount without a domain write", async () => { + const { view, unsupported } = workspace(); + let rail = await screen.findByRole("separator", { name: "Resize sidebar" }); + fireEvent.keyDown(rail, { key: "ArrowRight" }); + expect(rail).toHaveAttribute("aria-valuenow", "240"); + expect(localStorage.getItem("notes.sidebar-width")).toBe("240"); + view.unmount(); + workspace(); + rail = await screen.findByRole("separator", { name: "Resize sidebar" }); + expect(rail).toHaveAttribute("aria-valuenow", "240"); + expect(unsupported).not.toHaveBeenCalled(); +}); +it.each(["NaN", "Infinity", "179", "421", "", "-10", "999999", "240px"])( + "rejects invalid stored width %j", + async (value) => { + localStorage.setItem("notes.sidebar-width", value); + workspace(); + expect(await screen.findByRole("separator")).toHaveAttribute( + "aria-valuenow", + "224", + ); + }, +); +it("keeps resizing usable when preference storage is denied", async () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new DOMException("Denied", "SecurityError"); + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("Denied", "SecurityError"); + }); + workspace(); + const rail = await screen.findByRole("separator"); + fireEvent.keyDown(rail, { key: "ArrowRight" }); + expect(rail).toHaveAttribute("aria-valuenow", "240"); +}); +it("removes resizing on narrow layouts and restores the wide width when returning", async () => { + workspace(); + const rail = await screen.findByRole("separator"); + fireEvent.keyDown(rail, { key: "ArrowRight" }); + act(() => { + wide = false; + mediaListeners.forEach((listener) => listener()); + }); + expect(screen.queryByRole("separator")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Open navigation" })); + expect( + screen.getByRole("dialog", { name: "Notes navigation" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("separator")).not.toBeInTheDocument(); + act(() => { + wide = true; + mediaListeners.forEach((listener) => listener()); + }); + expect(await screen.findByRole("separator")).toHaveAttribute( + "aria-valuenow", + "240", + ); +});