From f7f4c8be09a44b2eba464c57e7dc8a18c9f5823f Mon Sep 17 00:00:00 2001 From: m5r Date: Sun, 23 Aug 2026 15:17:51 +0200 Subject: [PATCH] admin: only the content region scrolls on wide screens the shell grid grew past the viewport and scrolled the document, carrying the sidebar with it. the shell is now viewport-height at the wide breakpoint with main as the sole scroll container; the nav list scrolls inside the pinned rail; router scroll restoration targets the inner scroller so navigation resets it and back/forward restores it. --- admin/src/routes.tsx | 2 ++ admin/src/shell/AppShell.test.tsx | 9 +++++++++ admin/src/shell/AppShell.tsx | 14 ++++++++++++-- admin/vitest.setup.ts | 23 +++++++++++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/admin/src/routes.tsx b/admin/src/routes.tsx index ee5132f..b9b8e94 100644 --- a/admin/src/routes.tsx +++ b/admin/src/routes.tsx @@ -425,6 +425,8 @@ export function createAppRouter(history?: RouterHistory, queryClient: QueryClien context: { queryClient }, defaultPreload: "intent", defaultPreloadStaleTime: 0, + scrollRestoration: true, + scrollToTopSelectors: ["#main-content"], defaultPendingComponent: RoutePending, defaultErrorComponent: RouteError, }); diff --git a/admin/src/shell/AppShell.test.tsx b/admin/src/shell/AppShell.test.tsx index 33923b2..3e68f44 100644 --- a/admin/src/shell/AppShell.test.tsx +++ b/admin/src/shell/AppShell.test.tsx @@ -146,6 +146,15 @@ test("shell renders the overview route with all nav links", async () => { } }); +test("main carries the ids the router scrolls and restores", async () => { + renderShell(); + await screen.findByRole("heading", { name: "Overview" }); + + const main = screen.getByRole("main"); + expect(main.id).toBe("main-content"); + expect(main.getAttribute("data-scroll-restoration-id")).toBe("main"); +}); + test("the three configuration pages sit under a labelled group, after the rest", async () => { renderShell(); await screen.findByRole("heading", { name: "Overview" }); diff --git a/admin/src/shell/AppShell.tsx b/admin/src/shell/AppShell.tsx index a0b609f..ce1e0b5 100644 --- a/admin/src/shell/AppShell.tsx +++ b/admin/src/shell/AppShell.tsx @@ -112,6 +112,8 @@ const styles = stylex.create({ }, shell: { minHeight: "100dvh", + height: { default: null, [WIDE]: "100dvh" }, + overflow: { default: null, [WIDE]: "hidden" }, backgroundColor: colors.surface, color: colors.text, display: { default: "block", [WIDE]: "grid" }, @@ -120,6 +122,8 @@ const styles = stylex.create({ sidebar: { display: { default: "none", [WIDE]: "flex" }, flexDirection: { default: null, [WIDE]: "column" }, + minHeight: { default: null, [WIDE]: 0 }, + overflow: { default: null, [WIDE]: "hidden" }, borderRightWidth: 1, borderRightStyle: "solid", borderRightColor: colors.border, @@ -133,11 +137,14 @@ const styles = stylex.create({ }, sidebarNav: { flex: 1, + minHeight: { default: null, [WIDE]: 0 }, + overflowY: { default: null, [WIDE]: "auto" }, paddingInline: "0.5rem", }, column: { display: "flex", - minHeight: "100dvh", + minHeight: { default: "100dvh", [WIDE]: 0 }, + minWidth: { default: null, [WIDE]: 0 }, flexDirection: "column", }, header: { @@ -177,6 +184,9 @@ const styles = stylex.create({ }, main: { flex: 1, + minHeight: { default: null, [WIDE]: 0 }, + minWidth: { default: null, [WIDE]: 0 }, + overflowY: { default: null, [WIDE]: "auto" }, padding: "1rem", }, }); @@ -335,7 +345,7 @@ export default function AppShell() { )} -
+
diff --git a/admin/vitest.setup.ts b/admin/vitest.setup.ts index 1dbb3b1..5aa106f 100644 --- a/admin/vitest.setup.ts +++ b/admin/vitest.setup.ts @@ -60,3 +60,26 @@ if (globalThis.CSS === undefined) { * than per call; a test that genuinely never resolves still fails, only later. */ configure({ asyncUtilTimeout: 5000 }); + +/** + * jsdom implements no `Element.prototype.scrollTo`, and its `window.scrollTo` is + * a stub that logs "Not implemented". The router's scroll restoration calls both + * on every navigation, so without these the shell throws into its error boundary + * in tests while working in a browser. jsdom has no layout, so a scroll is a + * position assignment and nothing more. + */ +if (typeof Element.prototype.scrollTo !== "function") { + Element.prototype.scrollTo = function scrollTo(...args: unknown[]) { + const options = (typeof args[0] === "object" ? args[0] : { left: args[0], top: args[1] }) as ScrollToOptions; + if (typeof options.top === "number") this.scrollTop = options.top; + if (typeof options.left === "number") this.scrollLeft = options.left; + } as typeof Element.prototype.scrollTo; +} + +window.scrollTo = function scrollTo(...args: unknown[]) { + const options = (typeof args[0] === "object" ? args[0] : { left: args[0], top: args[1] }) as ScrollToOptions; + if (typeof options.top === "number") + Object.defineProperty(window, "scrollY", { value: options.top, configurable: true }); + if (typeof options.left === "number") + Object.defineProperty(window, "scrollX", { value: options.left, configurable: true }); +} as typeof window.scrollTo;