Scroll the document, and let the chrome scroll with it #110
@@ -36,6 +36,12 @@ branch, create a PR and merge it when the CI/CD turns green.
|
|||||||
## Project priorities (do not erode)
|
## Project priorities (do not erode)
|
||||||
|
|
||||||
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
|
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
|
||||||
|
**A page is a document**: it scrolls, and the chrome scrolls with it. Bounding the viewport to
|
||||||
|
hold something still — a `100dvh` box, `position: sticky`/`fixed` chrome, `overflow: hidden` on
|
||||||
|
`body` — buys an app-like look with CSS the next reader has to reverse-engineer, and every such
|
||||||
|
box is one more thing to undo before the content under it can be reached. Sticky headers and
|
||||||
|
full-height panes do not earn that. The mobile off-canvas nav is the one exception, because an
|
||||||
|
overlay has no other spelling.
|
||||||
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
|
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
|
||||||
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
|
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
|
||||||
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
|
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
|
||||||
@@ -278,23 +284,14 @@ Revisit only if the stated reason stops holding.
|
|||||||
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
||||||
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
|
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
|
||||||
the two in step.
|
the two in step.
|
||||||
- **The document scrolls; a bounded frame is `fill: true` on the shell.** `.app` is
|
- **Nothing bounds the viewport, so there is one scroller: the document.** `.app` is
|
||||||
`min-height: 100dvh`, not a `100dvh` box with `overflow: hidden`: the sidebar is `position: sticky`
|
`min-height: 100dvh`, never a `100dvh` box with `overflow: hidden`. It was the latter once, and a
|
||||||
at full height and the topbar sticks with it, so the nav stays reachable — on a narrow screen the
|
page taller than the window lost everything past it — silently, in every engine, because nothing in
|
||||||
hamburger is the only way into it, and with no script a long page would otherwise strand the reader.
|
a test or a console says content is unreachable below the fold. Only the document scroller gets
|
||||||
Two things only the document scroller gets: **keyboard paging** unconditionally (Space, PgDn and
|
**keyboard paging** unconditionally and **scroll restoration** on back/forward; a bounded region
|
||||||
End reach a bounded region only once focus is inside it, which without script needs a focusable
|
gets keys only once focus is inside it, which without script needs a focusable descendant.
|
||||||
descendant) and **scroll restoration** on back/forward. The inverse default clipped a page silently
|
A plugin that wants a full-height pane owns that in its own stylesheet — the shell offers no opt-out
|
||||||
in every engine — nothing in a test or a console says content is unreachable below the fold.
|
and no `.table thead` stickiness, per priority 1.
|
||||||
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 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 `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
|
- **`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
|
||||||
|
|||||||
+13
-24
@@ -9,40 +9,29 @@ at boot. Entries start at 0.3.0.
|
|||||||
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
|
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
|
||||||
that relied on filling it scrolls the document instead.
|
that relied on filling it scrolls the document instead.
|
||||||
|
|
||||||
### The document scrolls
|
### The document scrolls, and the chrome scrolls with it
|
||||||
|
|
||||||
`.app` was a `100dvh` box with `overflow: hidden`, so a page was only reachable below the fold if its
|
`.app` was a `100dvh` box with `overflow: hidden`, so a page was only reachable below the fold if its
|
||||||
own wrapper was a flex child with `overflow-y: auto`. `.table-wrap` and `.shell-auth` were; nothing
|
own wrapper was a flex child with `overflow-y: auto`. `.table-wrap` and `.shell-auth` were; nothing
|
||||||
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 nothing bounds the viewport. The sidebar and topbar scroll
|
||||||
full height and the topbar sticks with it, so both stay put as the page flows. Keyboard paging and
|
with the page, and keyboard paging, back/forward scroll restoration and find-in-page work without a
|
||||||
back/forward scroll restoration work without a page doing anything.
|
page doing anything.
|
||||||
|
|
||||||
### A bounded frame is `fill: true`, and the page says what fills it
|
The sticky `thead` on `data-table` goes with it: a header only sticks to a scrollport that moves, and
|
||||||
|
there is no longer one. A plugin that wants a full-height pane owns that in its own stylesheet; the
|
||||||
A page whose whole point is a frame — a board of full-height columns, a table whose header must stay
|
shell offers no opt-out, per the simplicity priority in `AGENTS.md`.
|
||||||
put — passes `fill: true` to the shell. `.app-fill` restores the previous model: the viewport is the
|
|
||||||
page, and a region inside it scrolls.
|
|
||||||
|
|
||||||
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 `scrollRegion: true`, which puts the class on its own wrapper.
|
|
||||||
|
|
||||||
### 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. A
|
2. A page that scrolled the whole window needs no change — it now scrolls the document.
|
||||||
`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 (`flex: 1 1 auto; min-height: 0` with its
|
||||||
3. A page holding a region that filled the content column passes `fill: true` to the shell, puts
|
own `overflow`) no longer gets a bounded column to fill, so that region grows and the page scrolls.
|
||||||
`.scroll-region` on that region, and makes every wrapper between it and the content slot a flex
|
Either let it, or give the region its own height in the plugin's stylesheet.
|
||||||
column (`display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0`). Miss a wrapper and
|
4. A `data-table` keeps working; its header no longer stays put while the rows scroll.
|
||||||
the region grows instead of scrolling, so the frame scrolls in its place — the scrollbar moving off
|
|
||||||
the region onto the whole content column 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.
|
|
||||||
|
|
||||||
## 0.3.0
|
## 0.3.0
|
||||||
|
|
||||||
|
|||||||
@@ -933,16 +933,10 @@ recovery / front pages — so it looks identical signed in or out and just shows
|
|||||||
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
|
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
|
||||||
chrome-free layout opts out with the shell's `menu: false`.
|
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
|
**The document scrolls, and the chrome scrolls with it.** Nothing bounds the viewport, so a page is
|
||||||
below the fold without adding a scroll region of its own.
|
reachable below the fold without adding a scroll region of its own, and browser paging, scroll
|
||||||
|
restoration and find-in-page work without a page doing anything. A plugin that wants a full-height
|
||||||
A page that is a bounded frame instead — a board of full-height columns, a table whose header must
|
pane owns that in its own stylesheet.
|
||||||
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 `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.
|
|
||||||
|
|
||||||
## Building blocks
|
## Building blocks
|
||||||
|
|
||||||
|
|||||||
@@ -201,44 +201,6 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page.locator("table")).not.toContainText("Morning — Front desk");
|
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 () => {
|
|
||||||
// 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.
|
|
||||||
const original = page.viewportSize();
|
|
||||||
await page.setViewportSize({ width: 1280, height: 260 });
|
|
||||||
await page.goto("/scheduling/shifts");
|
|
||||||
|
|
||||||
const bounded = await page.evaluate(() => {
|
|
||||||
const wrap = document.querySelector(".table-wrap");
|
|
||||||
const frame = document.querySelector(".app-fill");
|
|
||||||
if (!(wrap instanceof HTMLElement) || !(frame instanceof HTMLElement)) return null;
|
|
||||||
return {
|
|
||||||
frameScrolls: frame.scrollHeight > frame.clientHeight,
|
|
||||||
regionScrolls: wrap.scrollHeight > wrap.clientHeight,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
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 fixture must overflow the region, or the rest proves nothing").toBe(true);
|
|
||||||
|
|
||||||
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.
|
|
||||||
if (original) await page.setViewportSize(original);
|
|
||||||
});
|
|
||||||
|
|
||||||
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 () => {
|
||||||
await page.goto("/admin/plugin-settings");
|
await page.goto("/admin/plugin-settings");
|
||||||
await expect(page.locator("h1")).toHaveText("Plugin settings");
|
await expect(page.locator("h1")).toHaveText("Plugin settings");
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ for (const [name, path, tail] of [
|
|||||||
["the public landing", "/", ".landing-actions .btn"],
|
["the public landing", "/", ".landing-actions .btn"],
|
||||||
] as const) {
|
] as const) {
|
||||||
for (const width of [1280, 390]) {
|
for (const width of [1280, 390]) {
|
||||||
test(`${name} scrolls to its end at ${width}px wide, and the chrome stays put`, async ({ page }) => {
|
test(`${name} scrolls to its end at ${width}px wide`, async ({ page }) => {
|
||||||
await page.setViewportSize({ width, height: 200 });
|
await page.setViewportSize({ width, height: 200 });
|
||||||
await page.goto(path);
|
await page.goto(path);
|
||||||
|
|
||||||
@@ -44,27 +44,6 @@ for (const [name, path, tail] of [
|
|||||||
await page.keyboard.press("End");
|
await page.keyboard.press("End");
|
||||||
// Whole, not merely touched: a control half under the fold is not reachable either.
|
// Whole, not merely touched: a control half under the fold is not reachable either.
|
||||||
await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 });
|
await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 });
|
||||||
// The sticky pair is the whole reason the document may scroll: on a narrow screen the
|
|
||||||
// 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, 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();
|
|
||||||
// The lock is one CSS rule, and whether a key press moves the page with the nav open turns
|
|
||||||
// out to differ by engine — so pin the rule itself, then the behaviour it buys.
|
|
||||||
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).toBe("hidden");
|
|
||||||
// Both directions: opening the nav may leave the page at either end, and a key press toward
|
|
||||||
// the end it already sits at cannot move it whether the lock holds or not.
|
|
||||||
const before = await page.evaluate(() => window.scrollY);
|
|
||||||
for (const key of ["End", "Home"]) {
|
|
||||||
await page.keyboard.press(key);
|
|
||||||
expect(await page.evaluate(() => window.scrollY), key + " cannot scroll the page while the nav is open").toBe(before);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,3 @@
|
|||||||
.scheduling-page .table-wrap {
|
.scheduling-page .table-wrap {
|
||||||
margin-top: var(--space-3, 0.75rem);
|
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 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, scrollRegion: true });
|
const tableHtml = include("partials/data-table", table);
|
||||||
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>'
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>',
|
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,
|
brand: chrome.brand,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
csrfToken: chrome.csrfToken,
|
csrfToken: chrome.csrfToken,
|
||||||
|
|||||||
+3
-25
@@ -57,7 +57,6 @@
|
|||||||
/* layout + density (compact) */
|
/* layout + density (compact) */
|
||||||
--radius: 5px;
|
--radius: 5px;
|
||||||
--nav-w: 264px;
|
--nav-w: 264px;
|
||||||
--topbar-h: 48px;
|
|
||||||
--row-h: 34px;
|
--row-h: 34px;
|
||||||
--pad-x: 12px;
|
--pad-x: 12px;
|
||||||
--fz: 13px;
|
--fz: 13px;
|
||||||
@@ -148,7 +147,6 @@ summary { list-style: none; cursor: pointer; }
|
|||||||
.ico-sm { width: 14px; height: 14px; }
|
.ico-sm { width: 14px; height: 14px; }
|
||||||
|
|
||||||
/* ---------- 3. APP GRID ------------------------------------- */
|
/* ---------- 3. APP GRID ------------------------------------- */
|
||||||
/* The document scrolls; `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);
|
||||||
@@ -158,7 +156,6 @@ summary { list-style: none; cursor: pointer; }
|
|||||||
/* ---------- 4. SIDEBAR -------------------------------------- */
|
/* ---------- 4. SIDEBAR -------------------------------------- */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
position: sticky; top: 0; height: 100dvh; align-self: start;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -166,7 +163,7 @@ summary { list-style: none; cursor: pointer; }
|
|||||||
}
|
}
|
||||||
.brand {
|
.brand {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
height: var(--topbar-h); padding: 0 14px; flex: 0 0 auto;
|
height: 48px; padding: 0 14px; flex: 0 0 auto;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
@@ -337,14 +334,12 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
|
|
||||||
/* topbar (page title + hamburger on mobile) */
|
/* topbar (page title + hamburger on mobile) */
|
||||||
.topbar {
|
.topbar {
|
||||||
position: sticky; top: 0; z-index: 20;
|
flex: 0 0 auto; height: 48px;
|
||||||
flex: 0 0 auto; height: var(--topbar-h);
|
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
padding: 0 16px; border-bottom: 1px solid var(--border);
|
padding: 0 16px; border-bottom: 1px solid var(--border);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.hamburger { display: none; } /* shown only on narrow */
|
.hamburger { display: none; } /* shown only on narrow */
|
||||||
.content :target, .content :focus-visible { scroll-margin-top: var(--topbar-h); }
|
|
||||||
.page-title { margin: 0; font-weight: 600; letter-spacing: -.01em; font-size: 14px; }
|
.page-title { margin: 0; font-weight: 600; letter-spacing: -.01em; font-size: 14px; }
|
||||||
.page-sub { color: var(--text-faint); font-size: var(--fz-sm); }
|
.page-sub { color: var(--text-faint); font-size: var(--fz-sm); }
|
||||||
.topbar-spacer { flex: 1 1 auto; }
|
.topbar-spacer { flex: 1 1 auto; }
|
||||||
@@ -562,13 +557,12 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
|
|
||||||
/* ---------- 9. TABLE --------------------------------------- */
|
/* ---------- 9. TABLE --------------------------------------- */
|
||||||
/* A bounded region is opt-in: give this a height and the header below stays put inside it. */
|
/* A bounded region is opt-in: give this a height and the header below stays put inside it. */
|
||||||
.table-wrap { overflow: auto; }
|
.table-wrap { overflow-x: auto; }
|
||||||
table.table {
|
table.table {
|
||||||
width: 100%; border-collapse: separate; border-spacing: 0;
|
width: 100%; border-collapse: separate; border-spacing: 0;
|
||||||
font-size: var(--fz); font-variant-numeric: tabular-nums;
|
font-size: var(--fz); font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.table thead th {
|
.table thead th {
|
||||||
position: sticky; top: 0; z-index: 10;
|
|
||||||
background: var(--surface-3);
|
background: var(--surface-3);
|
||||||
border-bottom: 1px solid var(--border-2);
|
border-bottom: 1px solid var(--border-2);
|
||||||
color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs);
|
color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs);
|
||||||
@@ -693,8 +687,6 @@ 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: a reader scrolling would move what the scrim covers. */
|
|
||||||
body:has(#nav-toggle:checked) { overflow: hidden; }
|
|
||||||
}
|
}
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.crumbs, .page-sub { display: none; }
|
.crumbs, .page-sub { display: none; }
|
||||||
@@ -728,19 +720,5 @@ 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; }
|
||||||
|
|
||||||
/* 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; }
|
|
||||||
|
|
||||||
/* 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. */
|
/* 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; }
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ 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>/);
|
||||||
assert.match(html, /<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Mara Delgado"><\/td>/);
|
assert.match(html, /<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Mara Delgado"><\/td>/);
|
||||||
|
|||||||
@@ -95,20 +95,10 @@ test("app shell can disable the menu: no sidebar, focused single-column layout",
|
|||||||
assert.doesNotMatch(bare, /<aside class="sidebar"/); // sidebar dropped
|
assert.doesNotMatch(bare, /<aside class="sidebar"/); // sidebar dropped
|
||||||
assert.doesNotMatch(bare, /class="hamburger"/); // and its mobile toggle
|
assert.doesNotMatch(bare, /class="hamburger"/); // and its mobile toggle
|
||||||
assert.match(bare, /<div class="app app-bare">/); // single-column variant
|
assert.match(bare, /<div class="app app-bare">/); // single-column variant
|
||||||
assert.doesNotMatch(bare, /app-fill/); // the document scrolls unless a page says otherwise
|
|
||||||
assert.match(bare, /<main class="content" id="main-content"/); // content still renders
|
assert.match(bare, /<main class="content" id="main-content"/); // content still renders
|
||||||
assert.match(bare, /<section id="b">x<\/section>/);
|
assert.match(bare, /<section id="b">x<\/section>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("app shell: fill is opt-in and composes with menu:false; what the class does is pinned in e2e", async () => {
|
|
||||||
// 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: "" });
|
|
||||||
assert.match(bothOff, /<div class="app app-bare app-fill">/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("app shell: an empty title yields no topbar <h1> so the body owns the single heading; docTitle sets <title>", async () => {
|
test("app shell: an empty title yields no topbar <h1> so the body owns the single heading; docTitle sets <title>", async () => {
|
||||||
// Auth/landing pass title:"" (their card/hero is the <h1>) + an explicit docTitle for the tab.
|
// Auth/landing pass title:"" (their card/hero is the <h1>) + an explicit docTitle for the tab.
|
||||||
const html = await render({ title: "", docTitle: "Sign in", brand: { name: "Acme" }, body: "<h1>Sign in</h1>" });
|
const html = await render({ title: "", docTitle: "Sign in", brand: { name: "Acme" }, body: "<h1>Sign in</h1>" });
|
||||||
|
|||||||
@@ -5,9 +5,6 @@
|
|||||||
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
|
||||||
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 menu system)
|
|
||||||
columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[]
|
columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[]
|
||||||
rows: { name?, cells: Cell[], actions?: Action[] }[]
|
rows: { name?, cells: Cell[], actions?: Action[] }[]
|
||||||
Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? }
|
Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? }
|
||||||
@@ -23,9 +20,8 @@
|
|||||||
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 scrollRegion = locals.scrollRegion === true;
|
|
||||||
-%>
|
-%>
|
||||||
<div class="table-wrap<%= scrollRegion ? " scroll-region" : "" %>">
|
<div class="table-wrap">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<% if (caption) { -%>
|
<% if (caption) { -%>
|
||||||
<caption class="sr-only"><%= caption %></caption>
|
<caption class="sr-only"><%= caption %></caption>
|
||||||
|
|||||||
@@ -9,16 +9,13 @@
|
|||||||
(the <title> tag; defaults to title or the brand), `brand` ({ name, logo?, sub? }), `theme`
|
(the <title> tag; defaults to title or the brand), `brand` ({ name, logo?, sub? }), `theme`
|
||||||
(theme-switch default), `user`, `breadcrumbs`, `csrfToken` (the Sign-out form's hidden field),
|
(theme-switch default), `user`, `breadcrumbs`, `csrfToken` (the Sign-out form's hidden field),
|
||||||
`signInHref` (anonymous "Sign in" target; default /login). `menu` (default true) — set false to
|
`signInHref` (anonymous "Sign in" target; default /login). `menu` (default true) — set false to
|
||||||
drop the sidebar and render a focused single-column page. `fill` (default false) — set true when the
|
drop the sidebar and render a focused single-column page. `t`, `locale`, `dir` and `localeSwitch`
|
||||||
page is a bounded frame whose own region scrolls (a board, a table with a fixed header) rather than
|
|
||||||
the document. `t`, `locale`, `dir` and `localeSwitch`
|
|
||||||
come from the host with every render (README → Languages).
|
come from the host with every render (README → Languages).
|
||||||
%><%
|
%><%
|
||||||
const brand = locals.brand || { name: "Plainpages" };
|
const brand = locals.brand || { name: "Plainpages" };
|
||||||
const title = locals.title || ""; // topbar heading; empty ⇒ no topbar <h1> (the body owns it)
|
const title = locals.title || ""; // topbar heading; empty ⇒ no topbar <h1> (the body owns it)
|
||||||
const docTitle = locals.docTitle || title || brand.name;
|
const docTitle = locals.docTitle || title || brand.name;
|
||||||
const menu = locals.menu !== false; // sidebar shown by default; a page may opt out
|
const menu = locals.menu !== false; // sidebar shown by default; a page may opt out
|
||||||
const fill = locals.fill === true; // the document scrolls unless a page says it owns the viewport
|
|
||||||
const hideSignIn = locals.hideSignIn === true; // the auth pages are already a way in — no footer Sign-in there
|
const hideSignIn = locals.hideSignIn === true; // the auth pages are already a way in — no footer Sign-in there
|
||||||
const user = locals.user || { name: "Guest", initials: "G", email: "" };
|
const user = locals.user || { name: "Guest", initials: "G", email: "" };
|
||||||
const breadcrumbs = locals.breadcrumbs || [];
|
const breadcrumbs = locals.breadcrumbs || [];
|
||||||
@@ -44,7 +41,7 @@
|
|||||||
<input type="checkbox" id="nav-toggle" aria-hidden="true" tabindex="-1" />
|
<input type="checkbox" id="nav-toggle" aria-hidden="true" tabindex="-1" />
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<div class="app<%= menu ? "" : " app-bare" %><%= fill ? " app-fill" : "" %>">
|
<div class="app<%= menu ? "" : " app-bare" %>">
|
||||||
<% if (menu) { %>
|
<% if (menu) { %>
|
||||||
<aside class="sidebar" aria-label="<%= t("shell.sidebar") %>">
|
<aside class="sidebar" aria-label="<%= t("shell.sidebar") %>">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
|
|||||||
Reference in New Issue
Block a user