Add data-driven pagination partial (todo §1); rows-per-page GET form + page-number links, zero-JS, query-param driven

This commit is contained in:
2026-06-15 13:10:24 +02:00
parent cf1b74f09d
commit fcf2abdf17
4 changed files with 131 additions and 2 deletions

View File

@@ -362,7 +362,7 @@ src/context.ts RequestContext handed to handlers + buildContext()
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs) src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
src/plugin.ts definePlugin() + the host's plugin discovery/router (planned) src/plugin.ts definePlugin() + the host's plugin discovery/router (planned)
views/ Core EJS templates (index, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, icon sprite) views/ Core EJS templates (index, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, icon sprite)
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/menu.ts Central menu override + branding (planned) config/menu.ts Central menu override + branding (planned)
plugins/ Drop-in plugin folders, auto-discovered (planned) plugins/ Drop-in plugin folders, auto-discovered (planned)

69
src/pagination.test.ts Normal file
View File

@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
const pagination = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "pagination.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(pagination, data);
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
const config = {
summary: { from: "1", to: 12, total: "1,284" },
rows: {
name: "rows",
value: 25, // active option
options: [12, 25, 50, 100],
hidden: [{ name: "q", value: "ada" }, { name: "status", value: "active" }], // list state carried forward
},
prev: {}, // first page → disabled (no href)
pages: [
{ label: "1", current: true },
{ label: "2", href: "?sort=name&page=2" }, // & must be escaped
{ label: "3", href: "?page=3" },
{ ellipsis: true },
{ label: "107", href: "?page=107" },
],
next: { href: "?page=2" },
};
test("pagination renders summary, rows-per-page form, page links, current, ellipsis and prev/next", async () => {
const html = flat(await render(config));
assert.match(html, /<footer class="pager"><span>112 of <b>1,284<\/b><\/span>/);
// Rows-per-page: GET form carrying list state, active option selected, zero-JS submit.
assert.match(html, /<form class="pager-rows" method="get"><input type="hidden" name="q" value="ada"><input type="hidden" name="status" value="active">/);
assert.match(html, /<label for="pager-rows">Rows<\/label><span class="select"><select id="pager-rows" name="rows">/);
assert.match(html, /<option value="12">12<\/option><option value="25" selected>25<\/option><option value="50">50<\/option><option value="100">100<\/option><\/select><\/span>/);
assert.match(html, /<button class="page-btn" type="submit">Go<\/button><\/form>/);
assert.match(html, /<div class="spacer"><\/div><nav class="page-nums" aria-label="Pagination">/);
// Prev disabled at the first page.
assert.match(html, /<button class="page-btn" type="button" disabled aria-label="Previous page"><svg class="ico ico-sm" style="transform:rotate\(180deg\)"><use href="#i-chev"\s*\/?><\/svg><\/button>/);
// Page items: current as inert span, links for the rest (& escaped), ellipsis hidden from SR.
assert.match(html, /<span class="page-btn" aria-current="page">1<\/span>/);
assert.match(html, /<a class="page-btn" href="\?sort=name&amp;page=2">2<\/a>/);
assert.match(html, /<a class="page-btn" href="\?page=3">3<\/a>/);
assert.match(html, /<span class="page-btn" aria-hidden="true">…<\/span>/);
assert.match(html, /<a class="page-btn" href="\?page=107">107<\/a>/);
// Next is a link when a target exists.
assert.match(html, /<a class="page-btn" href="\?page=2" aria-label="Next page"><svg class="ico ico-sm"><use href="#i-chev"\s*\/?><\/svg><\/a><\/nav><\/footer>/);
});
test("pagination renders a valid empty footer and never throws on missing config", async () => {
const expected = /<footer class="pager"><div class="spacer"><\/div><nav class="page-nums" aria-label="Pagination"><\/nav><\/footer>/;
assert.match(flat(await render()), expected);
assert.match(flat(await render({})), expected);
// Object options + custom labels; value coercion (number vs string) still selects.
const html = flat(await render({
rows: { name: "rows", value: "50", options: [{ value: 50, label: "50 / page" }], label: "Per page", submitLabel: "Set" },
}));
assert.match(html, /<label for="pager-rows">Per page<\/label>/);
assert.match(html, /<option value="50" selected>50 \/ page<\/option>/);
assert.match(html, /<button class="page-btn" type="submit">Set<\/button>/);
});

View File

@@ -31,7 +31,7 @@ everything via Docker.
- [x] Nav-tree partial — recursive, header/leaf × clickable/static, counts, `aria-current`. → `views/partials/nav-tree.ejs`: data-driven, self-including. Node `{ label, href?, icon?, count?, current?, open?, children? }`; header (children → `.nav-disc` toggle + sibling `.nav-children`) vs leaf (spacer), clickable (`<a>`) vs static (`<span>`), orthogonal. Renders into the shell's `nav` slot. `nav-tree.test.ts` covers the full matrix + counts/icons/aria-current/escaping/empty. - [x] Nav-tree partial — recursive, header/leaf × clickable/static, counts, `aria-current`. → `views/partials/nav-tree.ejs`: data-driven, self-including. Node `{ label, href?, icon?, count?, current?, open?, children? }`; header (children → `.nav-disc` toggle + sibling `.nav-children`) vs leaf (spacer), clickable (`<a>`) vs static (`<span>`), orthogonal. Renders into the shell's `nav` slot. `nav-tree.test.ts` covers the full matrix + counts/icons/aria-current/escaping/empty.
- [x] Filter-bar partial — GET form (search, segmented, selects, chips, daterange, applied pills). → `views/partials/filter-bar.ejs`: data-driven `<form method="get">` (server-side, zero-JS). `rows: Control[][]`, `type ∈ search|segmented|select|chips|daterange|spacer`, each reflecting current value (checked/selected); plus applied `pills` (+ remove links, Clear all) and Reset/Apply actions. Columns/“more filters” menus deferred to the menu/popover item. `filter-bar.test.ts` covers every type + value reflection + pills + defaults. - [x] Filter-bar partial — GET form (search, segmented, selects, chips, daterange, applied pills). → `views/partials/filter-bar.ejs`: data-driven `<form method="get">` (server-side, zero-JS). `rows: Control[][]`, `type ∈ search|segmented|select|chips|daterange|spacer`, each reflecting current value (checked/selected); plus applied `pills` (+ remove links, Clear all) and Reset/Apply actions. Columns/“more filters” menus deferred to the menu/popover item. `filter-bar.test.ts` covers every type + value reflection + pills + defaults.
- [x] Data-table partial — sortable headers, row-select, badges, kebab row actions. → `views/partials/data-table.ejs`: data-driven, zero-JS. `columns` ({ label, sortable, sort, href, className }) render sort as `<a class="th-sort">` + `aria-sort` (links, not the mockup's inert buttons); `selectable`/`actions` toggle the check/kebab columns. `rows` carry typed `cells` (string | text+class | user/avatar | badge tone | raw html) + kebab `actions` (link or danger button, separators). `data-table.test.ts` covers the matrix + minimal/empty defaults. - [x] Data-table partial — sortable headers, row-select, badges, kebab row actions. → `views/partials/data-table.ejs`: data-driven, zero-JS. `columns` ({ label, sortable, sort, href, className }) render sort as `<a class="th-sort">` + `aria-sort` (links, not the mockup's inert buttons); `selectable`/`actions` toggle the check/kebab columns. `rows` carry typed `cells` (string | text+class | user/avatar | badge tone | raw html) + kebab `actions` (link or danger button, separators). `data-table.test.ts` covers the matrix + minimal/empty defaults.
- [ ] Pagination partial — rows-per-page + page numbers, query-param driven. - [x] Pagination partial — rows-per-page + page numbers, query-param driven.`views/partials/pagination.ejs`: data-driven, zero-JS. `summary {from,to,total}`, rows-per-page GET `<form>` (select + submit, `hidden[]` carries list state), `pages: {label,href?,current?,ellipsis?}[]` (links; current/ellipsis inert), `prev`/`next` (href ⇒ link, omit ⇒ disabled). Reuses the mockup's `.pager` CSS, no changes. `pagination.test.ts` covers the matrix + value reflection + empty defaults.
- [ ] Form-field partials (input/label/hint/error) + auth-card partial. - [ ] Form-field partials (input/label/hint/error) + auth-card partial.
- [ ] Menu/popover + theme-switch partials (pure CSS `details`/`summary`). - [ ] Menu/popover + theme-switch partials (pure CSS `details`/`summary`).
- [ ] Helper `composeNav(fragments, override, roles)` → merged, permission-filtered tree. - [ ] Helper `composeNav(fragments, override, roles)` → merged, permission-filtered tree.

View File

@@ -0,0 +1,60 @@
<%#
Pagination footer: rows-per-page (GET form) + page numbers. Query-param driven, zero-JS.
Mirrors html-css-foundation markup; page items are <a>, inert ones (current/ellipsis/disabled) aren't.
Config (all optional; never throws):
label? nav aria-label (default "Pagination")
summary? { from, to, total } → "fromto of <b>total</b>"
rows? { name, value?, options, label?, submitLabel?, action?, hidden? } rows-per-page form
options: (number | { value, label })[]; hidden: { name, value }[] carries list state
prev?, next? { href? } page step; omit href ⇒ disabled
pages? { label, href?, current?, ellipsis? }[]
%><%
const label = locals.label || "Pagination";
const summary = locals.summary;
const rows = locals.rows;
const prev = locals.prev;
const next = locals.next;
const pages = locals.pages || [];
const eq = (a, b) => String(a ?? "") === String(b);
-%>
<footer class="pager">
<% if (summary) { -%>
<span><%= summary.from %><%= summary.to %> of <b><%= summary.total %></b></span>
<% } -%>
<% if (rows) { -%>
<form class="pager-rows" method="get"<% if (rows.action) { %> action="<%= rows.action %>"<% } %>>
<% (rows.hidden || []).forEach((h) => { -%>
<input type="hidden" name="<%= h.name %>" value="<%= h.value %>">
<% }) -%>
<label for="pager-rows"><%= rows.label || "Rows" %></label>
<span class="select"><select id="pager-rows" name="<%= rows.name %>"><% (rows.options || []).forEach((o) => { const v = o && o.value != null ? o.value : o; const t = o && o.label != null ? o.label : o; %><option value="<%= v %>"<% if (eq(rows.value, v)) { %> selected<% } %>><%= t %></option><% }) %></select></span>
<button class="page-btn" type="submit"><%= rows.submitLabel || "Go" %></button>
</form>
<% } -%>
<div class="spacer"></div>
<nav class="page-nums" aria-label="<%= label %>">
<% if (prev) { -%>
<% if (prev.href) { -%>
<a class="page-btn" href="<%= prev.href %>" aria-label="Previous page"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></a>
<% } else { -%>
<button class="page-btn" type="button" disabled aria-label="Previous page"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></button>
<% } -%>
<% } -%>
<% pages.forEach((p) => { -%>
<% if (p.ellipsis) { -%>
<span class="page-btn" aria-hidden="true"><%= p.label || "…" %></span>
<% } else if (p.current) { -%>
<span class="page-btn" aria-current="page"><%= p.label %></span>
<% } else { -%>
<a class="page-btn" href="<%= p.href %>"><%= p.label %></a>
<% } -%>
<% }) -%>
<% if (next) { -%>
<% if (next.href) { -%>
<a class="page-btn" href="<%= next.href %>" aria-label="Next page"><svg class="ico ico-sm"><use href="#i-chev"/></svg></a>
<% } else { -%>
<button class="page-btn" type="button" disabled aria-label="Next page"><svg class="ico ico-sm"><use href="#i-chev"/></svg></button>
<% } -%>
<% } -%>
</nav>
</footer>