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:
@@ -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
69
src/pagination.test.ts
Normal 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>1–12 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&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>/);
|
||||||
|
});
|
||||||
2
todo.md
2
todo.md
@@ -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.
|
||||||
|
|||||||
60
views/partials/pagination.ejs
Normal file
60
views/partials/pagination.ejs
Normal 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 } → "from–to 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>
|
||||||
Reference in New Issue
Block a user