Add data-driven data-table partial (todo §1); sortable header links, row-select, typed cells/badges, kebab actions

This commit is contained in:
2026-06-15 13:04:19 +02:00
parent 637d5cf66d
commit cf1b74f09d
4 changed files with 150 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, icon sprite) views/ Core EJS templates (index, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, 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)

77
src/data-table.test.ts Normal file
View File

@@ -0,0 +1,77 @@
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 dataTable = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "data-table.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(dataTable, data);
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
const config = {
caption: "People in the directory",
selectable: true,
actions: true,
columns: [
{ label: "Name", sortable: true, sort: "asc", href: "?sort=name&dir=desc" }, // active ascending
{ label: "Email", sortable: true, href: "?sort=email&dir=asc" }, // sortable, inactive
{ label: "Team" }, // not sortable
{ label: "Status" },
{ label: "Detail" },
],
rows: [
{
name: "Mara Delgado",
cells: [
{ user: { name: "Mara Delgado", initials: "MD" } },
{ text: "mara@x.io", className: "cell-muted cell-mono" },
{ text: "Engineering", className: "cell-muted" },
{ badge: { tone: "pos", label: "Active" } },
{ html: '<a href="/x">open</a>' },
],
actions: [
{ label: "Edit", icon: "i-edit", href: "/people/1/edit" },
{ label: "Delete", icon: "i-trash", danger: true, separatorBefore: true },
],
},
],
};
test("data-table renders sortable headers, row-select, typed cells, badges and kebab actions", async () => {
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>/);
// 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>/);
// Sortable header — active ascending: aria-sort + link + up icon (& escaped in href).
assert.match(html, /<th scope="col" aria-sort="ascending"><a class="th-sort" href="\?sort=name&amp;dir=desc">Name <svg class="ico ico-sm sort-ico"><use href="#i-up"\s*\/?><\/svg><\/a><\/th>/);
// Sortable header — inactive: no aria-sort, neutral sort icon.
assert.match(html, /<th scope="col"><a class="th-sort" href="\?sort=email&amp;dir=asc">Email <svg class="ico ico-sm sort-ico"><use href="#i-sort"\s*\/?><\/svg><\/a><\/th>/);
// Non-sortable header — plain text, no button/link.
assert.match(html, /<th scope="col">Team<\/th>/);
// Actions header.
assert.match(html, /<th class="col-actions" scope="col"><span class="sr-only">Actions<\/span><\/th>/);
// Typed cells: user (avatar + strong), classed text, badge tone, raw html.
assert.match(html, /<td><span class="cell-user"><span class="avatar" aria-hidden="true">MD<\/span><span class="cell-strong">Mara Delgado<\/span><\/span><\/td>/);
assert.match(html, /<td class="cell-muted cell-mono">mara@x.io<\/td>/);
assert.match(html, /<td class="cell-muted">Engineering<\/td>/);
assert.match(html, /<td><span class="badge pos"><span class="dot"><\/span>Active<\/span><\/td>/);
assert.match(html, /<td><a href="\/x">open<\/a><\/td>/);
// Kebab row actions: link item, danger button, separator.
assert.match(html, /<td class="col-actions"><details class="menu kebab"><summary aria-label="Row actions for Mara Delgado"><svg class="ico ico-sm"><use href="#i-kebab"\s*\/?><\/svg><\/summary><div class="menu-pop">/);
assert.match(html, /<a class="menu-item" href="\/people\/1\/edit"><svg class="ico"><use href="#i-edit"\s*\/?><\/svg>Edit<\/a>/);
assert.match(html, /<div class="menu-sep"><\/div><button class="menu-item danger" type="button"><svg class="ico"><use href="#i-trash"\s*\/?><\/svg>Delete<\/button>/);
});
test("data-table renders a minimal table (plain string cells, no select/actions) and never throws", async () => {
const html = flat(await render({ columns: [{ label: "Name" }], rows: [{ cells: ["Plain"] }] }));
assert.match(html, /<table class="table"><thead><tr><th scope="col">Name<\/th><\/tr><\/thead><tbody><tr><td>Plain<\/td><\/tr><\/tbody><\/table>/);
assert.doesNotMatch(html, /col-check|col-actions/);
assert.match(flat(await render()), /<table class="table"><thead><tr><\/tr><\/thead><tbody><\/tbody><\/table>/);
});

View File

@@ -30,7 +30,7 @@ everything via Docker.
- [x] App-shell partial (sidebar + topbar + content slot). → `views/partials/shell.ejs`: full document wrapping `.app` → sidebar (brand + `nav` slot + theme/profile footer) · `.scrim` · `.content` (`.topbar` + `body` slot); reuses the mockup's classes (styled by `styles.css`), inlines the icon sprite. Slots `nav`/`actions`/`body` are HTML locals, `title`/`brand`/`user`/`breadcrumbs` text; defaults render standalone. `shell.test.ts` covers landmarks, slots, escaping, defaults. Not yet routed (that's "replace placeholder index"). - [x] App-shell partial (sidebar + topbar + content slot). → `views/partials/shell.ejs`: full document wrapping `.app` → sidebar (brand + `nav` slot + theme/profile footer) · `.scrim` · `.content` (`.topbar` + `body` slot); reuses the mockup's classes (styled by `styles.css`), inlines the icon sprite. Slots `nav`/`actions`/`body` are HTML locals, `title`/`brand`/`user`/`breadcrumbs` text; defaults render standalone. `shell.test.ts` covers landmarks, slots, escaping, defaults. Not yet routed (that's "replace placeholder index").
- [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.
- [ ] Data-table partial — sortable headers, row-select, badges, kebab row actions. - [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. - [ ] Pagination partial — rows-per-page + page numbers, query-param driven.
- [ ] 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`).

View File

@@ -0,0 +1,71 @@
<%#
Data table: sortable headers, row-select, typed cells, badges, kebab row actions.
Mirrors the html-css-foundation markup; zero-JS (sort = links, select highlight = CSS).
Config:
caption?, selectable?, actions? sr-only caption; toggle the check / kebab columns
columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[]
rows: { name?, cells: Cell[], actions?: Action[] }[]
Cell ∈ string | { text, className? } | { user:{name,initials} } | { badge:{tone,label} } | { html, className? }
Action = { label, icon?, href?, danger?, separatorBefore? }
%><%
const caption = locals.caption;
const selectable = !!locals.selectable;
const withActions = !!locals.actions;
const columns = locals.columns || [];
const rows = locals.rows || [];
-%>
<div class="table-wrap">
<table class="table">
<% if (caption) { -%>
<caption class="sr-only"><%= caption %></caption>
<% } -%>
<thead>
<tr>
<% if (selectable) { -%>
<th class="col-check" scope="col"><input type="checkbox" aria-label="Select all rows"></th>
<% } -%>
<% columns.forEach((col) => { -%>
<% if (col.sortable) { -%>
<th scope="col"<% if (col.sort === "asc") { %> aria-sort="ascending"<% } else if (col.sort === "desc") { %> aria-sort="descending"<% } %><% if (col.className) { %> class="<%= col.className %>"<% } %>><a class="th-sort" href="<%= col.href %>"><%= col.label %> <svg class="ico ico-sm sort-ico"><use href="#<%= col.sort ? "i-up" : "i-sort" %>"/></svg></a></th>
<% } else { -%>
<th scope="col"<% if (col.className) { %> class="<%= col.className %>"<% } %>><%= col.label %></th>
<% } -%>
<% }) -%>
<% if (withActions) { -%>
<th class="col-actions" scope="col"><span class="sr-only">Actions</span></th>
<% } -%>
</tr>
</thead>
<tbody>
<% rows.forEach((row) => { -%>
<tr>
<% if (selectable) { -%>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select <%= row.name || "row" %>"></td>
<% } -%>
<% (row.cells || []).forEach((cell) => { -%>
<% if (typeof cell === "string") { -%>
<td><%= cell %></td>
<% } else if (cell.user) { -%>
<td><span class="cell-user"><span class="avatar" aria-hidden="true"><%= cell.user.initials %></span><span class="cell-strong"><%= cell.user.name %></span></span></td>
<% } else if (cell.badge) { -%>
<td><span class="badge <%= cell.badge.tone %>"><span class="dot"></span><%= cell.badge.label %></span></td>
<% } else if (cell.html != null) { -%>
<td<% if (cell.className) { %> class="<%= cell.className %>"<% } %>><%- cell.html %></td>
<% } else { -%>
<td<% if (cell.className) { %> class="<%= cell.className %>"<% } %>><%= cell.text %></td>
<% } -%>
<% }) -%>
<% if (withActions) { -%>
<% if ((row.actions || []).length) { -%>
<td class="col-actions"><details class="menu kebab"><summary aria-label="Row actions for <%= row.name || "row" %>"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary><div class="menu-pop"><% row.actions.forEach((a) => { -%>
<% if (a.separatorBefore) { %><div class="menu-sep"></div><% } %><% if (a.href) { %><a class="menu-item<% if (a.danger) { %> danger<% } %>" href="<%= a.href %>"><% if (a.icon) { %><svg class="ico"><use href="#<%= a.icon %>"/></svg><% } %><%= a.label %></a><% } else { %><button class="menu-item<% if (a.danger) { %> danger<% } %>" type="button"><% if (a.icon) { %><svg class="ico"><use href="#<%= a.icon %>"/></svg><% } %><%= a.label %></button><% } %><% }) -%>
</div></details></td>
<% } else { -%>
<td class="col-actions"></td>
<% } -%>
<% } -%>
</tr>
<% }) -%>
</tbody>
</table>
</div>