Add data-driven data-table partial (todo §1); sortable header links, row-select, typed cells/badges, kebab actions
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/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)
|
||||
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)
|
||||
config/menu.ts Central menu override + branding (planned)
|
||||
plugins/ Drop-in plugin folders, auto-discovered (planned)
|
||||
|
||||
77
src/data-table.test.ts
Normal file
77
src/data-table.test.ts
Normal 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&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&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>/);
|
||||
});
|
||||
2
todo.md
2
todo.md
@@ -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] 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.
|
||||
- [ ] 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.
|
||||
- [ ] Form-field partials (input/label/hint/error) + auth-card partial.
|
||||
- [ ] Menu/popover + theme-switch partials (pure CSS `details`/`summary`).
|
||||
|
||||
71
views/partials/data-table.ejs
Normal file
71
views/partials/data-table.ejs
Normal 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>
|
||||
Reference in New Issue
Block a user