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
7 changed files with 36 additions and 25 deletions
Showing only changes of commit 41aa59d05d - Show all commits
+2 -4
View File
@@ -293,10 +293,8 @@ Revisit only if the stated reason stops holding.
takes that height with **`.scroll-region`** and carries the flex chain down to it, because only the 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 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 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 `scrollRegion: true` for exactly this, since silently clips one nested any deeper. `data-table` takes `scrollRegion: true` for
its wrapper is host markup a page cannot put the class on itself. The `visual.spec.ts` scroll test presses **End** rather than sending a exactly this, since its wrapper is host markup a page cannot put the class on itself.
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.
- **`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
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
+4 -2
View File
@@ -34,11 +34,13 @@ clip one nested any deeper. `data-table` takes `scrollRegion: true`, which puts
### 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. A
`data-table` on it keeps working, but its header stops sticking; see step 4.
3. A page holding a region that filled the content column passes `fill: true` to the shell, puts 3. A page holding a region that filled the content column passes `fill: true` to the shell, puts
`.scroll-region` on that region, and makes every wrapper between it and the content slot a flex `.scroll-region` on that region, and makes every wrapper between it and the content slot a flex
column (`display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0`). Miss a wrapper and 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. the region grows instead of scrolling, so the frame scrolls in its place — a second scrollbar is
the tell.
4. A table whose header must stay put passes `scrollRegion: true` to `data-table` on such a page. 4. A table whose header must stay put passes `scrollRegion: true` to `data-table` on such a page.
`examples/plugins/scheduling` shows the whole chain on its shifts list. `examples/plugins/scheduling` shows the whole chain on its shifts list.
+11 -3
View File
@@ -205,7 +205,9 @@ test.describe.serial("authenticated admin journey", () => {
// markup the shell does not own — so it is pinned here, on the reference plugin's own list, rather // 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. // 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 () => { 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 }); // The region is the viewport less ~185px of chrome, so this leaves ~75px: enough that the 3-row
// fixture overflows it by half its height, and enough to hold the header a row scrolls under.
await page.setViewportSize({ width: 1280, height: 260 });
await page.goto("/scheduling/shifts"); await page.goto("/scheduling/shifts");
const bounded = await page.evaluate(() => { const bounded = await page.evaluate(() => {
@@ -221,13 +223,19 @@ test.describe.serial("authenticated admin journey", () => {
// Both halves, and both discriminating: miss a wrapper in the page's chain and the table grows // Both halves, and both discriminating: miss a wrapper in the page's chain and the table grows
// instead of scrolling, which pushes the frame past its own height. // instead of scrolling, which pushes the frame past its own height.
expect(bounded?.frameScrolls, "a filled page must fit its frame").toBe(false); expect(bounded?.frameScrolls, "a filled page must fit its frame").toBe(false);
expect(bounded?.regionScrolls, "the table must be the thing that scrolls").toBe(true); expect(bounded?.regionScrolls, "the fixture must overflow the region, or the rest proves nothing").toBe(true);
const headTop = async () => (await page.locator("thead th").first().boundingBox())?.y ?? -1; const headTop = async () => {
const box = await page.locator("thead th").first().boundingBox();
expect(box, "the header must have a box to stay put").not.toBeNull();
return box?.y;
};
const before = await headTop(); const before = await headTop();
await page.locator(".table-wrap").evaluate((el) => el.scrollTo(0, el.scrollHeight)); await page.locator(".table-wrap").evaluate((el) => el.scrollTo(0, el.scrollHeight));
await expect(page.locator("tbody tr").last()).toBeInViewport(); await expect(page.locator("tbody tr").last()).toBeInViewport();
expect(await headTop(), "the header sticks to a scrollport that moves").toBe(before); expect(await headTop(), "the header sticks to a scrollport that moves").toBe(before);
// The journey shares one page; leaving it short would hand the next test a window it never chose.
await page.setViewportSize({ width: 1280, height: 800 });
}); });
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 () => {
+3 -2
View File
@@ -52,13 +52,14 @@ for (const [name, path, tail] of [
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. // The open nav is a fixed overlay, so a reader cannot scroll the page out from under the
// scrim. Focus can still move it, and stopping that needs script this page does not have.
if (width <= 860) { if (width <= 860) {
await page.locator(".hamburger").click(); // the label is the control; the checkbox takes no pointer await page.locator(".hamburger").click(); // the label is the control; the checkbox takes no pointer
await expect(page.locator("#nav-toggle")).toBeChecked(); await expect(page.locator("#nav-toggle")).toBeChecked();
const before = await page.evaluate(() => window.scrollY); const before = await page.evaluate(() => window.scrollY);
await page.keyboard.press("Home"); await page.keyboard.press("Home");
expect(await page.evaluate(() => window.scrollY), "the page is locked while the nav is open").toBe(before); expect(await page.evaluate(() => window.scrollY), "a key press cannot scroll the page while the nav is open").toBe(before);
} }
}); });
} }
+12 -11
View File
@@ -112,7 +112,6 @@ html:has(#theme-light:checked) {
/* ---------- 2. RESET ---------------------------------------- */ /* ---------- 2. RESET ---------------------------------------- */
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
html, body { min-height: 100%; }
body { margin: 0; background: var(--bg); color: var(--text); body { margin: 0; background: var(--bg); color: var(--text);
-webkit-font-smoothing: antialiased; } -webkit-font-smoothing: antialiased; }
button { font: inherit; color: inherit; } button { font: inherit; color: inherit; }
@@ -149,8 +148,7 @@ summary { list-style: none; cursor: pointer; }
.ico-sm { width: 14px; height: 14px; } .ico-sm { width: 14px; height: 14px; }
/* ---------- 3. APP GRID ------------------------------------- */ /* ---------- 3. APP GRID ------------------------------------- */
/* Not a viewport-height box: the document scrolls, so a page is reachable below the fold without /* The document scrolls; `fill` opts out — see .app-fill (AGENTS.md → UI). */
bringing a scroll region of its own. `fill` opts out — see .app-fill (AGENTS.md → UI). */
.app { .app {
display: grid; display: grid;
grid-template-columns: var(--nav-w) minmax(0, 1fr); grid-template-columns: var(--nav-w) minmax(0, 1fr);
@@ -695,7 +693,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
background: rgba(0,0,0,.42); background: rgba(0,0,0,.42);
} }
.search { min-width: 150px; flex: 1 1 auto; } .search { min-width: 150px; flex: 1 1 auto; }
/* The open nav is a fixed overlay: scrolling the page behind it moves what the scrim covers. */ /* The open nav is a fixed overlay: a reader scrolling would move what the scrim covers. */
body:has(#nav-toggle:checked) { overflow: hidden; } body:has(#nav-toggle:checked) { overflow: hidden; }
} }
@media (max-width: 560px) { @media (max-width: 560px) {
@@ -730,16 +728,19 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
.app-bare { grid-template-columns: minmax(0, 1fr); } .app-bare { grid-template-columns: minmax(0, 1fr); }
.app-bare .content { grid-column: 1; } .app-bare .content { grid-column: 1; }
/* `fill: true` on the shell: the viewport is the page, and a region inside it scrolls instead of the /* The shell's half of `fill: true` (AGENTS.md → UI). `auto`, not `hidden`: a page that misses a
document. For a page whose whole point is a bounded frame — a board of full-height columns, a table wrapper in its own chain then scrolls here instead of losing the overflow. */
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. What fills that height is the page's to say — `.scroll-region` below. */
.app-fill { height: 100dvh; overflow: auto; } .app-fill { height: 100dvh; overflow: auto; }
.app-fill .content { min-height: 0; } .app-fill .content { min-height: 0; }
/* Marks the one element that takes the leftover height and scrolls. Every ancestor between it and /* The page's half: `min-height: 0` is the one everyone forgets, since `auto` floors a flex item at
`.content` has to be a flex column carrying this same pair, which is the page's own business — its content. */
`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; } .scroll-region { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
@media print {
/* A bounded frame prints as one sheet otherwise, losing everything the region scrolls past. */
.app-fill { height: auto; overflow: visible; }
.scroll-region { overflow: visible; }
}
/* 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; }
+2
View File
@@ -42,6 +42,8 @@ test("data-table renders sortable headers, row-select, typed cells, badges and k
const html = flat(await render(config)); const html = flat(await render(config));
assert.match(html, /<div class="table-wrap"><table class="table"><caption class="sr-only">People in the directory<\/caption>/); assert.match(html, /<div class="table-wrap"><table class="table"><caption class="sr-only">People in the directory<\/caption>/);
// Opt-in, and only then: the class is what a filled page hands its leftover height to.
assert.match(flat(await render({ ...config, scrollRegion: true })), /<div class="table-wrap scroll-region">/);
// Row-select: header select-all + per-row checkbox with a descriptive label. // Row-select: header select-all + per-row checkbox with a descriptive label.
assert.match(html, /<th class="col-check" scope="col"><input type="checkbox" aria-label="Select all rows"><\/th>/); assert.match(html, /<th class="col-check" scope="col"><input type="checkbox" aria-label="Select all rows"><\/th>/);
+2 -3
View File
@@ -101,9 +101,8 @@ test("app shell can disable the menu: no sidebar, focused single-column layout",
}); });
test("app shell: fill is opt-in and composes with menu:false; what the class does is pinned in e2e", 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 // What the class does depends on the page's own flex chain (AGENTS.md → UI), so full-flow holds
// without knowing the topbar's own (AGENTS.md → UI). Its effect depends on the page's own flex // that; this holds only that nothing opts a page in by accident.
// 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: "" });