Split the fill seam where ownership splits: the shell bounds, the page fills
CI / full-gate (push) Successful in 3m1s
CI / full-gate (push) Successful in 3m1s
This commit is contained in:
@@ -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`
|
||||
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.
|
||||
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
|
||||
descendant), **scroll restoration** on back/forward, and find-in-page. The inverse default clipped a
|
||||
page silently in every engine — nothing in a test or a console says content is unreachable below the
|
||||
fold.
|
||||
descendant) and **scroll restoration** on back/forward. The inverse default clipped a page silently
|
||||
in every engine — nothing in a test or a console says content is unreachable below the fold.
|
||||
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
|
||||
the shell's to give, not the page's to compute: a page deriving it would have to know the topbar's
|
||||
own height, which is a reach-through, so `.table-wrap` is `overflow-x: auto` and takes its vertical
|
||||
scroll from `.app-fill`. The `visual.spec.ts` scroll test presses **End** rather than sending a
|
||||
— sets **`fill: true`** on the shell and scrolls a region inside `.app-fill` instead. **The split is
|
||||
the seam:** the shell bounds the content column and stops, because the height is the shell's to give
|
||||
and a page deriving it would have to know the topbar's own; the page marks the one element that
|
||||
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
|
||||
`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
|
||||
|
||||
+13
-7
@@ -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/
|
||||
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
|
||||
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
|
||||
only to a scrollport that moves.
|
||||
page, and a region inside it scrolls.
|
||||
|
||||
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
|
||||
|
||||
1. Set `apiVersion: "0.4.0"`.
|
||||
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
|
||||
own `overflow`) passes `fill: true` to the shell; the region then works as before.
|
||||
4. A table whose header must stay put needs `fill: true` on that page.
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
**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
|
||||
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
|
||||
needs it, since a header can only stick to a scrollport that moves.
|
||||
below the fold without adding a scroll region of its own.
|
||||
|
||||
A page that is a bounded frame instead — a board of full-height columns, a table whose header must
|
||||
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
|
||||
|
||||
|
||||
@@ -201,6 +201,34 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
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 () => {
|
||||
await page.goto("/admin/plugin-settings");
|
||||
await expect(page.locator("h1")).toHaveText("Plugin settings");
|
||||
|
||||
@@ -51,6 +51,15 @@ for (const [name, path, tail] of [
|
||||
// hamburger in the topbar is the only way back into the nav.
|
||||
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.
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
%><%
|
||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
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 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>'
|
||||
@@ -16,6 +16,7 @@
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
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,
|
||||
breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
|
||||
@@ -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
|
||||
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. */
|
||||
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 .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. */
|
||||
.shell-auth { flex: 1 1 auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
|
||||
|
||||
@@ -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>/);
|
||||
});
|
||||
|
||||
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
|
||||
// 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: "" });
|
||||
assert.match(filled, /<div class="app app-fill">/);
|
||||
const bothOff = await render({ fill: true, menu: false, title: "Board", body: "<div>x</div>", nav: "" });
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
caption?, selectable?, actions? sr-only caption; toggle the check / kebab columns
|
||||
actionsId? id stem for the row-action menus (default `row-actions`);
|
||||
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? }[]
|
||||
rows: { name?, cells: Cell[], actions?: Action[] }[]
|
||||
Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? }
|
||||
@@ -20,8 +23,9 @@
|
||||
const rows = locals.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 fills = locals.fills === true;
|
||||
-%>
|
||||
<div class="table-wrap">
|
||||
<div class="table-wrap<%= fills ? " scroll-region" : "" %>">
|
||||
<table class="table">
|
||||
<% if (caption) { -%>
|
||||
<caption class="sr-only"><%= caption %></caption>
|
||||
|
||||
Reference in New Issue
Block a user