Scroll the document, and let the chrome scroll with it #110

Merged
lilleman merged 10 commits from document-scroll into main 2026-09-10 16:24:03 +02:00
10 changed files with 95 additions and 25 deletions
Showing only changes of commit 696da397e7 - Show all commits
+11 -8
View File
@@ -282,16 +282,19 @@ Revisit only if the stated reason stops holding.
`min-height: 100dvh`, not a `100dvh` box with `overflow: hidden`: the sidebar is `position: sticky` `min-height: 100dvh`, not a `100dvh` box with `overflow: hidden`: the sidebar is `position: sticky`
at full height and the topbar sticks with it, so the nav stays reachable — on a narrow screen the at full height and the topbar sticks with it, so the nav stays reachable — on a narrow screen the
hamburger is the only way into it, and with no script a long page would otherwise strand the reader. hamburger is the only way into it, and with no script a long page would otherwise strand the reader.
Three things only the document scroller gets: **keyboard paging** unconditionally (Space, PgDn and Two things only the document scroller gets: **keyboard paging** unconditionally (Space, PgDn and
End reach a bounded region only once focus is inside it, which without script needs a focusable End reach a bounded region only once focus is inside it, which without script needs a focusable
descendant), **scroll restoration** on back/forward, and find-in-page. The inverse default clipped a descendant) and **scroll restoration** on back/forward. The inverse default clipped a page silently
page silently in every engine — nothing in a test or a console says content is unreachable below the in every engine — nothing in a test or a console says content is unreachable below the fold.
fold.
A page that is a bounded frame — a board of full-height columns, a table whose header must stay put A page that is a bounded frame — a board of full-height columns, a table whose header must stay put
— sets **`fill: true`** on the shell and scrolls a region inside `.app-fill` instead. The height is — sets **`fill: true`** on the shell and scrolls a region inside `.app-fill` instead. **The split is
the shell's to give, not the page's to compute: a page deriving it would have to know the topbar's the seam:** the shell bounds the content column and stops, because the height is the shell's to give
own height, which is a reach-through, so `.table-wrap` is `overflow-x: auto` and takes its vertical and a page deriving it would have to know the topbar's own; the page marks the one element that
scroll from `.app-fill`. The `visual.spec.ts` scroll test presses **End** rather than sending a takes that height with **`.scroll-region`** and carries the flex chain down to it, because only the
page knows its own tree. The shell must never go looking through a page for a component it
recognises — a rule keyed on `.table-wrap` works for a table the content slot holds directly and
silently clips one nested any deeper. `data-table` takes `fills: true` for exactly this, since its
wrapper is host markup a page cannot put a class on. The `visual.spec.ts` scroll test presses **End** rather than sending a
wheel event (Firefox's synthetic wheel does not reach the document) and rather than wheel event (Firefox's synthetic wheel does not reach the document) and rather than
`scrollIntoView`, which a script can apply to an overflow-hidden box that no reader can scroll. `scrollIntoView`, which a script can apply to an overflow-hidden box that no reader can scroll.
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it - **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
+13 -7
View File
@@ -19,22 +19,28 @@ Now the shell is `min-height: 100dvh` and the document scrolls. The sidebar is `
full height and the topbar sticks with it, so both stay put as the page flows. Keyboard paging, back/ full height and the topbar sticks with it, so both stay put as the page flows. Keyboard paging, back/
forward scroll restoration and find-in-page work without a page doing anything. forward scroll restoration and find-in-page work without a page doing anything.
### A bounded frame is `fill: true` ### A bounded frame is `fill: true`, and the page says what fills it
A page whose whole point is a frame — a board of full-height columns, a table whose header must stay A page whose whole point is a frame — a board of full-height columns, a table whose header must stay
put — passes `fill: true` to the shell. `.app-fill` restores the previous model: the viewport is the put — passes `fill: true` to the shell. `.app-fill` restores the previous model: the viewport is the
page, and a region inside it scrolls. `data-table`'s sticky `thead` needs it, since a header sticks page, and a region inside it scrolls.
only to a scrollport that moves.
The height is the shell's to give: a page computing it would have to know the topbar's own height. The two halves are split on purpose. The shell bounds the content column, because the height is the
shell's to give and a page computing it would have to know the topbar's own. The page marks the
element that takes that height with `.scroll-region`, because only the page knows its own tree — a
host rule keyed on a component would work for a table held directly by the content slot and silently
clip one nested any deeper. `data-table` takes `fills: true`, which puts the class on its own wrapper.
### Upgrading a plugin ### Upgrading a plugin
1. Set `apiVersion: "0.4.0"`. 1. Set `apiVersion: "0.4.0"`.
2. A page that scrolled the whole window needs no change — it now scrolls the document. 2. A page that scrolled the whole window needs no change — it now scrolls the document.
3. A page holding a region that filled the content column (`flex: 1 1 auto; min-height: 0` with its 3. A page holding a region that filled the content column passes `fill: true` to the shell, puts
own `overflow`) passes `fill: true` to the shell; the region then works as before. `.scroll-region` on that region, and makes every wrapper between it and the content slot a flex
4. A table whose header must stay put needs `fill: true` on that page. column (`display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0`). Miss a wrapper and
the region grows instead of scrolling, and `fill`'s bounded box clips it.
4. A table whose header must stay put passes `fills: true` to `data-table` on such a page.
`examples/plugins/scheduling` shows the whole chain on its shifts list.
## 0.3.0 ## 0.3.0
+9 -4
View File
@@ -934,10 +934,15 @@ anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page
chrome-free layout opts out with the shell's `menu: false`. chrome-free layout opts out with the shell's `menu: false`.
**The document scrolls.** The sidebar and topbar stay put on their own, and a page is reachable **The document scrolls.** The sidebar and topbar stay put on their own, and a page is reachable
below the fold without adding a scroll region of its own. A page that is a bounded frame instead — a below the fold without adding a scroll region of its own.
board of full-height columns, a table whose header must stay put — passes **`fill: true`** to the
shell: the viewport becomes the page, and a region inside it scrolls. `data-table`'s sticky header A page that is a bounded frame instead — a board of full-height columns, a table whose header must
needs it, since a header can only stick to a scrollport that moves. stay put — passes **`fill: true`** to the shell, which bounds the content column. The page then says
what fills it: put **`.scroll-region`** on that element, and make every wrapper between it and the
content slot a flex column (`display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0`).
A `data-table` takes `fills: true`, which puts the class on its own wrapper — its sticky header needs
it, since a header only sticks to a scrollport that moves. `examples/plugins/scheduling` shows the
whole chain on its shifts list.
## Building blocks ## Building blocks
+28
View File
@@ -201,6 +201,34 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("table")).not.toContainText("Morning — Front desk"); await expect(page.locator("table")).not.toContainText("Morning — Front desk");
}); });
// `fill: true` is a contract addition whose whole behaviour is CSS, and whose effect depends on
// markup the shell does not own — so it is pinned here, on the reference plugin's own list, rather
// than by asserting the class name the shell emits.
test("plugin page: a filled page scrolls its table, not the document, and the header stays put", async () => {
await page.setViewportSize({ width: 1280, height: 300 });
await page.goto("/scheduling/shifts");
const bounded = await page.evaluate(() => {
const wrap = document.querySelector(".table-wrap");
if (!(wrap instanceof HTMLElement)) return null;
return {
documentScrolls: document.documentElement.scrollHeight > window.innerHeight,
regionScrolls: wrap.scrollHeight > wrap.clientHeight,
};
});
expect(bounded, ".table-wrap must render").not.toBeNull();
// Both halves: the shell bounds the column, and the page's chain hands that height to the table.
// Miss a wrapper and the region grows instead, which is what the bounded box would then clip.
expect(bounded?.documentScrolls, "the document must not scroll on a filled page").toBe(false);
expect(bounded?.regionScrolls, "the table must be the thing that scrolls").toBe(true);
const headTop = async () => (await page.locator("thead th").first().boundingBox())?.y ?? -1;
const before = await headTop();
await page.locator(".table-wrap").evaluate((el) => el.scrollTo(0, el.scrollHeight));
await expect(page.locator("tbody tr").last()).toBeInViewport();
expect(await headTop(), "the header sticks to a scrollport that moves").toBe(before);
});
test("plugin settings: the screen names the variable that sets each declared key", async () => { test("plugin settings: the screen names the variable that sets each declared key", async () => {
await page.goto("/admin/plugin-settings"); await page.goto("/admin/plugin-settings");
await expect(page.locator("h1")).toHaveText("Plugin settings"); await expect(page.locator("h1")).toHaveText("Plugin settings");
+9
View File
@@ -51,6 +51,15 @@ for (const [name, path, tail] of [
// hamburger in the topbar is the only way back into the nav. // hamburger in the topbar is the only way back into the nav.
await expect(page.locator(".topbar")).toBeInViewport(); await expect(page.locator(".topbar")).toBeInViewport();
if (width > 860) await expect(page.locator(".brand-name")).toBeInViewport(); if (width > 860) await expect(page.locator(".brand-name")).toBeInViewport();
// The open nav is a fixed overlay: the page must not slide out from under the scrim.
if (width <= 860) {
await page.locator(".hamburger").click(); // the label is the control; the checkbox takes no pointer
await expect(page.locator("#nav-toggle")).toBeChecked();
const before = await page.evaluate(() => window.scrollY);
await page.keyboard.press("Home");
expect(await page.evaluate(() => window.scrollY), "the page is locked while the nav is open").toBe(before);
}
}); });
} }
} }
@@ -4,3 +4,12 @@
.scheduling-page .table-wrap { .scheduling-page .table-wrap {
margin-top: var(--space-3, 0.75rem); margin-top: var(--space-3, 0.75rem);
} }
/* The chain the shell cannot supply: `fill` bounds the content column, and every wrapper between it
and the table's own `.scroll-region` has to pass that height down. */
.app-fill .scheduling-page {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
+2 -1
View File
@@ -7,7 +7,7 @@
%><% %><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const filtersHtml = include("partials/filter-bar", filterBar); const filtersHtml = include("partials/filter-bar", filterBar);
const tableHtml = include("partials/data-table", table); const tableHtml = include("partials/data-table", { ...table, fills: true });
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : ""; const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
const actions = canWrite const actions = canWrite
? '<a class="btn btn-primary" href="' + localeHref(newHref) + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("scheduling.shifts.new") + '</a>' ? '<a class="btn btn-primary" href="' + localeHref(newHref) + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("scheduling.shifts.new") + '</a>'
@@ -16,6 +16,7 @@
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>', body: '<div class="scheduling-page">' + alertHtml + filtersHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>',
fill: true, // a long list scrolls under its own header rather than taking the page with it
brand: chrome.brand, brand: chrome.brand,
breadcrumbs, breadcrumbs,
csrfToken: chrome.csrfToken, csrfToken: chrome.csrfToken,
+6 -2
View File
@@ -733,9 +733,13 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
/* `fill: true` on the shell: the viewport is the page, and a region inside it scrolls instead of the /* `fill: true` on the shell: the viewport is the page, and a region inside it scrolls instead of the
document. For a page whose whole point is a bounded frame — a board of full-height columns, a table document. For a page whose whole point is a bounded frame — a board of full-height columns, a table
whose header must stay put. The height is the shell's to give: a page computing it would have to whose header must stay put. The height is the shell's to give: a page computing it would have to
know the topbar's own. */ know the topbar's own. What fills that height is the page's to say — `.scroll-region` below. */
.app-fill { height: 100dvh; overflow: hidden; } .app-fill { height: 100dvh; overflow: hidden; }
.app-fill .content { min-height: 0; } .app-fill .content { min-height: 0; }
.app-fill .table-wrap { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
/* Marks the one element that takes the leftover height and scrolls. Every ancestor between it and
`.content` has to be a flex column carrying this same pair, which is the page's own business —
`min-height: 0` is the half everyone forgets, since `auto` floors a flex item at its content. */
.scroll-region { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
/* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */ /* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */
.shell-auth { flex: 1 1 auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; } .shell-auth { flex: 1 1 auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
+3 -2
View File
@@ -100,9 +100,10 @@ test("app shell can disable the menu: no sidebar, focused single-column layout",
assert.match(bare, /<section id="b">x<\/section>/); assert.match(bare, /<section id="b">x<\/section>/);
}); });
test("app shell: fill:true bounds the viewport, for a page whose own region scrolls", async () => { test("app shell: fill is opt-in and composes with menu:false; what the class does is pinned in e2e", async () => {
// The opt-out from document scrolling is the shell's to give — a page cannot derive the height // The opt-out from document scrolling is the shell's to give — a page cannot derive the height
// without knowing the topbar's own (AGENTS.md → UI). // without knowing the topbar's own (AGENTS.md → UI). Its effect depends on the page's own flex
// chain, so full-flow holds that; this holds only that nothing opts a page in by accident.
const filled = await render({ fill: true, title: "Board", body: "<div>x</div>", nav: "" }); const filled = await render({ fill: true, title: "Board", body: "<div>x</div>", nav: "" });
assert.match(filled, /<div class="app app-fill">/); assert.match(filled, /<div class="app app-fill">/);
const bothOff = await render({ fill: true, menu: false, title: "Board", body: "<div>x</div>", nav: "" }); const bothOff = await render({ fill: true, menu: false, title: "Board", body: "<div>x</div>", nav: "" });
+5 -1
View File
@@ -5,6 +5,9 @@
caption?, selectable?, actions? sr-only caption; toggle the check / kebab columns caption?, selectable?, actions? sr-only caption; toggle the check / kebab columns
actionsId? id stem for the row-action menus (default `row-actions`); actionsId? id stem for the row-action menus (default `row-actions`);
name it when two tables share a page name it when two tables share a page
fills? take the leftover height and scroll, so the header stays
put — needs the shell's `fill: true` and a flex chain
above (README → the app shell)
columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[] columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[]
rows: { name?, cells: Cell[], actions?: Action[] }[] rows: { name?, cells: Cell[], actions?: Action[] }[]
Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? } Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? }
@@ -20,8 +23,9 @@
const rows = locals.rows || []; const rows = locals.rows || [];
const emptyText = locals.emptyText || t("table.empty"); // shown when a table that has columns has no rows const emptyText = locals.emptyText || t("table.empty"); // shown when a table that has columns has no rows
const actionsId = locals.actionsId || "row-actions"; const actionsId = locals.actionsId || "row-actions";
const fills = locals.fills === true;
-%> -%>
<div class="table-wrap"> <div class="table-wrap<%= fills ? " scroll-region" : "" %>">
<table class="table"> <table class="table">
<% if (caption) { -%> <% if (caption) { -%>
<caption class="sr-only"><%= caption %></caption> <caption class="sr-only"><%= caption %></caption>