From cb4f8d6e98d099d93c542ce1104e5b61defae810 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 9 Sep 2026 14:18:36 +0200 Subject: [PATCH] Name the table's local after the class it applies, and let a broken chain show itself --- AGENTS.md | 6 +++--- CHANGELOG.md | 8 ++++---- README.md | 2 +- e2e-tests/full-flow.spec.ts | 13 +++++++------ examples/plugins/scheduling/views/shifts.ejs | 2 +- public/css/styles.css | 2 +- views/partials/data-table.ejs | 6 +++--- 7 files changed, 20 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9433a5a..3d38b13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 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. -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 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. @@ -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 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 + 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. - **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it diff --git a/CHANGELOG.md b/CHANGELOG.md index 121cf3b..cdc0e45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. 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/ -forward scroll restoration and find-in-page work without a page doing anything. +full height and the topbar sticks with it, so both stay put as the page flows. Keyboard paging and +back/forward scroll restoration work without a page doing anything. ### 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 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. +clip one nested any deeper. `data-table` takes `scrollRegion: true`, which puts the class on its own wrapper. ### 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 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. +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. ## 0.3.0 diff --git a/README.md b/README.md index fae5505..43dfe3a 100644 --- a/README.md +++ b/README.md @@ -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 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 +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 whole chain on its shifts list. diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index 0190dbd..bb0974d 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -210,16 +210,17 @@ test.describe.serial("authenticated admin journey", () => { const bounded = await page.evaluate(() => { 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 { - documentScrolls: document.documentElement.scrollHeight > window.innerHeight, + frameScrolls: frame.scrollHeight > frame.clientHeight, 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, ".table-wrap and .app-fill must render").not.toBeNull(); + // 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); const headTop = async () => (await page.locator("thead th").first().boundingBox())?.y ?? -1; diff --git a/examples/plugins/scheduling/views/shifts.ejs b/examples/plugins/scheduling/views/shifts.ejs index 34dcdc3..66e9625 100644 --- a/examples/plugins/scheduling/views/shifts.ejs +++ b/examples/plugins/scheduling/views/shifts.ejs @@ -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, fills: true }); + const tableHtml = include("partials/data-table", { ...table, scrollRegion: true }); const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : ""; const actions = canWrite ? '' + t("scheduling.shifts.new") + '' diff --git a/public/css/styles.css b/public/css/styles.css index f509a26..f517c6f 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -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 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: hidden; } +.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 diff --git a/views/partials/data-table.ejs b/views/partials/data-table.ejs index 317215d..f8e2c08 100644 --- a/views/partials/data-table.ejs +++ b/views/partials/data-table.ejs @@ -5,7 +5,7 @@ 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 + scrollRegion? 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? }[] @@ -23,9 +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; + const scrollRegion = locals.scrollRegion === true; -%> -
"> +
"> <% if (caption) { -%>
<%= caption %>