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;