Scroll the document, and let the chrome scroll with it #110
@@ -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
|
||||
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 `scrollRegion: true` for exactly this, since
|
||||
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
|
||||
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.
|
||||
silently clips one nested any deeper. `data-table` takes `scrollRegion: true` for
|
||||
exactly this, since its wrapper is host markup a page cannot put the class on itself.
|
||||
- **`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
|
||||
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
|
||||
|
||||
+4
-2
@@ -34,11 +34,13 @@ clip one nested any deeper. `data-table` takes `scrollRegion: true`, which puts
|
||||
### Upgrading a plugin
|
||||
|
||||
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
|
||||
`.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
|
||||
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.
|
||||
`examples/plugins/scheduling` shows the whole chain on its shifts list.
|
||||
|
||||
|
||||
@@ -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
|
||||
// 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 });
|
||||
// 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");
|
||||
|
||||
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
|
||||
// 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?.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();
|
||||
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);
|
||||
// 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 () => {
|
||||
|
||||
@@ -52,13 +52,14 @@ for (const [name, path, tail] of [
|
||||
await expect(page.locator(".topbar")).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) {
|
||||
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);
|
||||
expect(await page.evaluate(() => window.scrollY), "a key press cannot scroll the page while the nav is open").toBe(before);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+12
-11
@@ -112,7 +112,6 @@ html:has(#theme-light:checked) {
|
||||
|
||||
/* ---------- 2. RESET ---------------------------------------- */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body { min-height: 100%; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text);
|
||||
-webkit-font-smoothing: antialiased; }
|
||||
button { font: inherit; color: inherit; }
|
||||
@@ -149,8 +148,7 @@ summary { list-style: none; cursor: pointer; }
|
||||
.ico-sm { width: 14px; height: 14px; }
|
||||
|
||||
/* ---------- 3. APP GRID ------------------------------------- */
|
||||
/* Not a viewport-height box: the document scrolls, so a page is reachable below the fold without
|
||||
bringing a scroll region of its own. `fill` opts out — see .app-fill (AGENTS.md → UI). */
|
||||
/* The document scrolls; `fill` opts out — see .app-fill (AGENTS.md → UI). */
|
||||
.app {
|
||||
display: grid;
|
||||
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);
|
||||
}
|
||||
.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; }
|
||||
}
|
||||
@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 .content { grid-column: 1; }
|
||||
|
||||
/* `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
|
||||
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. */
|
||||
/* The shell's half of `fill: true` (AGENTS.md → UI). `auto`, not `hidden`: a page that misses a
|
||||
wrapper in its own chain then scrolls here instead of losing the overflow. */
|
||||
.app-fill { height: 100dvh; overflow: auto; }
|
||||
.app-fill .content { min-height: 0; }
|
||||
|
||||
/* 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. */
|
||||
/* The page's half: `min-height: 0` is the one everyone forgets, since `auto` floors a flex item at
|
||||
its content. */
|
||||
.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. */
|
||||
.shell-auth { flex: 1 1 auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
|
||||
|
||||
@@ -42,6 +42,8 @@ test("data-table renders sortable headers, row-select, typed cells, badges and k
|
||||
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>/);
|
||||
// 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.
|
||||
assert.match(html, /<th class="col-check" scope="col"><input type="checkbox" aria-label="Select all rows"><\/th>/);
|
||||
|
||||
@@ -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 () => {
|
||||
// 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). 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.
|
||||
// What the class does depends on the page's own flex chain (AGENTS.md → UI), 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: "" });
|
||||
assert.match(filled, /<div class="app app-fill">/);
|
||||
const bothOff = await render({ fill: true, menu: false, title: "Board", body: "<div>x</div>", nav: "" });
|
||||
|
||||
Reference in New Issue
Block a user