Name the table's local after the class it applies, and let a broken chain show itself
CI / full-gate (push) Successful in 2m51s

This commit is contained in:
2026-09-09 14:18:36 +02:00
parent 4ae9326741
commit 098359e298
7 changed files with 20 additions and 19 deletions
+3 -3
View File
@@ -282,7 +282,7 @@ 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.
Two 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) and **scroll restoration** on back/forward. The inverse default clipped a page silently 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. in every engine — nothing in a test or a console says content is unreachable below the fold.
@@ -293,8 +293,8 @@ Two things only the document scroller gets: **keyboard paging** unconditionally
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 `fills: true` for exactly this, since its silently clips one nested any deeper. `data-table` takes `scrollRegion: true` for exactly this, since
wrapper is host markup a page cannot put a class on. The `visual.spec.ts` scroll test presses **End** rather than sending a 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 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
+4 -4
View File
@@ -16,8 +16,8 @@ own wrapper was a flex child with `overflow-y: auto`. `.table-wrap` and `.shell-
else was, and a long page in `.form-page` clipped everything past the window in every engine. else was, and a long page in `.form-page` clipped everything past the window in every engine.
Now the shell is `min-height: 100dvh` and the document scrolls. The sidebar is `position: sticky` at Now the shell is `min-height: 100dvh` and the document scrolls. The sidebar is `position: sticky` at
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 and
forward scroll restoration and find-in-page work without a page doing anything. back/forward scroll restoration work without a page doing anything.
### A bounded frame is `fill: true`, and the page says what fills it ### A bounded frame is `fill: true`, and the page says what fills it
@@ -29,7 +29,7 @@ The two halves are split on purpose. The shell bounds the content column, becaus
shell's to give and a page computing it would have to know the topbar's own. The page marks 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 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 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. clip one nested any deeper. `data-table` takes `scrollRegion: true`, which puts the class on its own wrapper.
### Upgrading a plugin ### Upgrading a plugin
@@ -39,7 +39,7 @@ clip one nested any deeper. `data-table` takes `fills: true`, which puts the cla
`.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, 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. 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.
## 0.3.0 ## 0.3.0
+1 -1
View File
@@ -940,7 +940,7 @@ A page that is a bounded frame instead — a board of full-height columns, a tab
stay put — passes **`fill: true`** to the shell, which bounds the content column. The page then says 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 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`). 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 A `data-table` takes `scrollRegion: 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 it, since a header only sticks to a scrollport that moves. `examples/plugins/scheduling` shows the
whole chain on its shifts list. whole chain on its shifts list.
+7 -6
View File
@@ -210,16 +210,17 @@ test.describe.serial("authenticated admin journey", () => {
const bounded = await page.evaluate(() => { const bounded = await page.evaluate(() => {
const wrap = document.querySelector(".table-wrap"); const wrap = document.querySelector(".table-wrap");
if (!(wrap instanceof HTMLElement)) return null; const frame = document.querySelector(".app-fill");
if (!(wrap instanceof HTMLElement) || !(frame instanceof HTMLElement)) return null;
return { return {
documentScrolls: document.documentElement.scrollHeight > window.innerHeight, frameScrolls: frame.scrollHeight > frame.clientHeight,
regionScrolls: wrap.scrollHeight > wrap.clientHeight, regionScrolls: wrap.scrollHeight > wrap.clientHeight,
}; };
}); });
expect(bounded, ".table-wrap must render").not.toBeNull(); expect(bounded, ".table-wrap and .app-fill must render").not.toBeNull();
// Both halves: the shell bounds the column, and the page's chain hands that height to the table. // Both halves, and both discriminating: miss a wrapper in the page's chain and the table grows
// Miss a wrapper and the region grows instead, which is what the bounded box would then clip. // instead of scrolling, which pushes the frame past its own height.
expect(bounded?.documentScrolls, "the document must not scroll on a filled page").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 table must be the thing that scrolls").toBe(true);
const headTop = async () => (await page.locator("thead th").first().boundingBox())?.y ?? -1; const headTop = async () => (await page.locator("thead th").first().boundingBox())?.y ?? -1;
+1 -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, fills: true }); const tableHtml = include("partials/data-table", { ...table, scrollRegion: 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>'
+1 -1
View File
@@ -734,7 +734,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
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. What fills that height is the page's to say — `.scroll-region` below. */ 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: 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 /* Marks the one element that takes the leftover height and scrolls. Every ancestor between it and
+3 -3
View File
@@ -5,7 +5,7 @@
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 scrollRegion? take the leftover height and scroll, so the header stays
put — needs the shell's `fill: true` and a flex chain put — needs the shell's `fill: true` and a flex chain
above (README → the app shell) above (README → the app shell)
columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[] columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[]
@@ -23,9 +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; const scrollRegion = locals.scrollRegion === true;
-%> -%>
<div class="table-wrap<%= fills ? " scroll-region" : "" %>"> <div class="table-wrap<%= scrollRegion ? " scroll-region" : "" %>">
<table class="table"> <table class="table">
<% if (caption) { -%> <% if (caption) { -%>
<caption class="sr-only"><%= caption %></caption> <caption class="sr-only"><%= caption %></caption>