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)
|
||||
|
||||
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`,
|
||||
`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
|
||||
@@ -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
|
||||
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
|
||||
the two in step.
|
||||
- **The document scrolls; a bounded frame is `fill: true` on the shell.** `.app` is
|
||||
`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
|
||||
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.
|
||||
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.
|
||||
- **Nothing bounds the viewport, so there is one scroller: the document.** `.app` is
|
||||
`min-height: 100dvh`, never a `100dvh` box with `overflow: hidden`. It was the latter once, and a
|
||||
page taller than the window lost everything past it — silently, in every engine, because nothing in
|
||||
a test or a console says content is unreachable below the fold. Only the document scroller gets
|
||||
**keyboard paging** unconditionally and **scroll restoration** on back/forward; a bounded region
|
||||
gets keys only once focus is inside it, which without script needs a focusable descendant.
|
||||
A plugin that wants a full-height pane owns that in its own stylesheet — the shell offers no opt-out
|
||||
and no `.table thead` stickiness, per priority 1.
|
||||
- **`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
|
||||
|
||||
+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
|
||||
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
|
||||
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.
|
||||
|
||||
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 and
|
||||
back/forward scroll restoration work without a page doing anything.
|
||||
Now the shell is `min-height: 100dvh` and nothing bounds the viewport. The sidebar and topbar scroll
|
||||
with the page, and keyboard paging, back/forward scroll restoration and find-in-page work without a
|
||||
page doing anything.
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
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
|
||||
shell offers no opt-out, per the simplicity priority in `AGENTS.md`.
|
||||
|
||||
### 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. 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, 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.
|
||||
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`) no longer gets a bounded column to fill, so that region grows and the page scrolls.
|
||||
Either let it, or give the region its own height in the plugin's stylesheet.
|
||||
4. A `data-table` keeps working; its header no longer stays put while the rows scroll.
|
||||
|
||||
|
||||
## 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
|
||||
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, 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.
|
||||
**The document scrolls, and the chrome scrolls with it.** Nothing bounds the viewport, so a page is
|
||||
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
|
||||
pane owns that in its own stylesheet.
|
||||
|
||||
## Building blocks
|
||||
|
||||
|
||||
@@ -201,44 +201,6 @@ 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 () => {
|
||||
// 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 () => {
|
||||
await page.goto("/admin/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"],
|
||||
] as const) {
|
||||
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.goto(path);
|
||||
|
||||
@@ -44,27 +44,6 @@ for (const [name, path, tail] of [
|
||||
await page.keyboard.press("End");
|
||||
// Whole, not merely touched: a control half under the fold is not reachable either.
|
||||
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 {
|
||||
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, scrollRegion: true });
|
||||
const tableHtml = include("partials/data-table", table);
|
||||
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,7 +16,6 @@
|
||||
<%- 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,
|
||||
|
||||
+3
-25
@@ -57,7 +57,6 @@
|
||||
/* layout + density (compact) */
|
||||
--radius: 5px;
|
||||
--nav-w: 264px;
|
||||
--topbar-h: 48px;
|
||||
--row-h: 34px;
|
||||
--pad-x: 12px;
|
||||
--fz: 13px;
|
||||
@@ -148,7 +147,6 @@ summary { list-style: none; cursor: pointer; }
|
||||
.ico-sm { width: 14px; height: 14px; }
|
||||
|
||||
/* ---------- 3. APP GRID ------------------------------------- */
|
||||
/* The document scrolls; `fill` opts out — see .app-fill (AGENTS.md → UI). */
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: var(--nav-w) minmax(0, 1fr);
|
||||
@@ -158,7 +156,6 @@ summary { list-style: none; cursor: pointer; }
|
||||
/* ---------- 4. SIDEBAR -------------------------------------- */
|
||||
.sidebar {
|
||||
grid-column: 1;
|
||||
position: sticky; top: 0; height: 100dvh; align-self: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
@@ -166,7 +163,7 @@ summary { list-style: none; cursor: pointer; }
|
||||
}
|
||||
.brand {
|
||||
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);
|
||||
}
|
||||
.brand-mark {
|
||||
@@ -337,14 +334,12 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
|
||||
/* topbar (page title + hamburger on mobile) */
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
flex: 0 0 auto; height: var(--topbar-h);
|
||||
flex: 0 0 auto; height: 48px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 0 16px; border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.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-sub { color: var(--text-faint); font-size: var(--fz-sm); }
|
||||
.topbar-spacer { flex: 1 1 auto; }
|
||||
@@ -562,13 +557,12 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
|
||||
/* ---------- 9. TABLE --------------------------------------- */
|
||||
/* 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 {
|
||||
width: 100%; border-collapse: separate; border-spacing: 0;
|
||||
font-size: var(--fz); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.table thead th {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
background: var(--surface-3);
|
||||
border-bottom: 1px solid var(--border-2);
|
||||
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);
|
||||
}
|
||||
.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) {
|
||||
.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 .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. */
|
||||
.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));
|
||||
|
||||
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>/);
|
||||
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, /class="hamburger"/); // and its mobile toggle
|
||||
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, /<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 () => {
|
||||
// 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>" });
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
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
|
||||
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? }[]
|
||||
rows: { name?, cells: Cell[], actions?: Action[] }[]
|
||||
Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? }
|
||||
@@ -23,9 +20,8 @@
|
||||
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 scrollRegion = locals.scrollRegion === true;
|
||||
-%>
|
||||
<div class="table-wrap<%= scrollRegion ? " scroll-region" : "" %>">
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<% if (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`
|
||||
(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
|
||||
drop the sidebar and render a focused single-column page. `fill` (default false) — set true when the
|
||||
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`
|
||||
drop the sidebar and render a focused single-column page. `t`, `locale`, `dir` and `localeSwitch`
|
||||
come from the host with every render (README → Languages).
|
||||
%><%
|
||||
const brand = locals.brand || { name: "Plainpages" };
|
||||
const title = locals.title || ""; // topbar heading; empty ⇒ no topbar <h1> (the body owns it)
|
||||
const docTitle = locals.docTitle || title || brand.name;
|
||||
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 user = locals.user || { name: "Guest", initials: "G", email: "" };
|
||||
const breadcrumbs = locals.breadcrumbs || [];
|
||||
@@ -44,7 +41,7 @@
|
||||
<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) { %>
|
||||
<aside class="sidebar" aria-label="<%= t("shell.sidebar") %>">
|
||||
<div class="brand">
|
||||
|
||||
Reference in New Issue
Block a user