Add data-driven filter-bar partial (todo §1); GET form: search/segmented/select/chips/daterange + applied pills

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

84
src/filter-bar.test.ts Normal file
View File

@@ -0,0 +1,84 @@
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 filterBar = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "filter-bar.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(filterBar, data);
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
const config = {
label: "Filter people",
rows: [
[
{ type: "search", name: "q", placeholder: "Search people…", value: "ann", label: "Search people" },
{
type: "segmented",
name: "status",
legend: "Status",
value: "active",
options: [
{ value: "all", label: "All", count: "1,284" },
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
{ type: "select", name: "team", label: "Team", value: "design", options: [{ value: "", label: "All teams" }, { value: "design", label: "Design" }] },
{ type: "spacer" },
],
[
{
type: "chips",
name: "tag",
legend: "Tags",
value: ["engineering", "oncall"],
options: [{ value: "engineering", label: "Engineering" }, { value: "design", label: "Design" }, { value: "oncall", label: "On-call" }],
},
{ type: "daterange", legend: "Joined", from: { name: "joined_from", value: "2026-01-01", label: "Joined from" }, to: { name: "joined_to", value: "2026-06-14", label: "Joined to" } },
],
],
pills: [{ label: "Team", value: "Engineering", remove: "?tag=oncall" }],
clearHref: "?",
};
test("filter-bar renders a GET form with every control type, reflecting current values", async () => {
const html = flat(await render(config));
// GET form (server-side filtering, zero-JS).
assert.match(html, /<form class="filters" method="get" aria-label="Filter people">/);
// search — icon + value reflected.
assert.match(html, /<label class="search"><span class="sr-only">Search people<\/span><svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"\s*\/?><\/svg><input type="search" name="q" placeholder="Search people…" value="ann"><\/label>/);
// segmented — current value checked, others not, optional count badge.
assert.match(html, /<input type="radio" name="status" value="all"><span>All<\/span><span class="seg-count">1,284<\/span>/);
assert.match(html, /<input type="radio" name="status" value="active" checked><span>Active<\/span>/);
// select — matching option selected.
assert.match(html, /<select id="f-team" name="team"><option value="">All teams<\/option><option value="design" selected>Design<\/option><\/select>/);
// chips — checkbox group, only current values checked.
assert.match(html, /<input type="checkbox" name="tag" value="engineering" checked>Engineering/);
assert.match(html, /<input type="checkbox" name="tag" value="oncall" checked>On-call/);
assert.match(html, /<input type="checkbox" name="tag" value="design">Design/);
// daterange — calendar icon + two date inputs with values.
assert.match(html, /<div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"\s*\/?><\/svg>.*?<input type="date" id="f-joined_from" name="joined_from" value="2026-01-01">.*?<span class="to" aria-hidden="true">to<\/span>.*?<input type="date" id="f-joined_to" name="joined_to" value="2026-06-14">/);
// spacer.
assert.match(html, /<div class="spacer"><\/div>/);
// applied pills + clear-all + Reset/Apply actions.
assert.match(html, /<div class="active-pills" aria-label="Applied filters"><span class="filter-legend">Applied<\/span><span class="pill"><b>Team:<\/b> Engineering <a class="pill-x" href="\?tag=oncall" aria-label="Remove Team filter">/);
assert.match(html, /<a class="pill-clear" href="\?">Clear all<\/a>/);
assert.match(html, /<button type="reset" class="btn">Reset<\/button>/);
assert.match(html, /<button type="submit" class="btn btn-primary"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"\s*\/?><\/svg>Apply filters<\/button>/);
});
test("filter-bar renders with defaults: form + actions, no pills, never throws", async () => {
const html = flat(await render());
assert.match(html, /<form class="filters" method="get"/);
assert.match(html, /<button type="submit" class="btn btn-primary"/);
assert.doesNotMatch(html, /active-pills/);
});

View File

@@ -29,7 +29,7 @@ everything via Docker.
- [x] Lucide icon sprite from `lucide-static` (dep added) → `views/partials/icons.ejs`; serve/inline only the icons used. → `src/icons.ts` (id→lucide map + `buildIconSprite`) generates a hidden `<symbol>` sprite of the 31 icons the mockups reference, paths sourced from pinned lucide-static; `icons.test.ts` guards provenance + only-used. Stale image rebuilt (lucide-static was missing). Wiring into the app shell is the next item. - [x] Lucide icon sprite from `lucide-static` (dep added) → `views/partials/icons.ejs`; serve/inline only the icons used. → `src/icons.ts` (id→lucide map + `buildIconSprite`) generates a hidden `<symbol>` sprite of the 31 icons the mockups reference, paths sourced from pinned lucide-static; `icons.test.ts` guards provenance + only-used. Stale image rebuilt (lucide-static was missing). Wiring into the app shell is the next item.
- [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.
- [ ] Filter-bar partial — GET form (search, segmented, selects, chips, daterange, applied pills). - [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. - [ ] Data-table partial — sortable headers, row-select, badges, kebab row actions.
- [ ] 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.

View File

@@ -0,0 +1,51 @@
<%#
Filter bar: a real GET form so filtering is server-side and zero-JS. Mirrors the
html-css-foundation markup. Config:
rows: Control[][] rows of controls, laid out left→right
pills, clearHref, label, action, applyLabel
Control.type ∈ search | segmented | select | chips | daterange | spacer.
search { name, placeholder?, value?, label? }
segmented { name, legend?, value?, options:{value,label,count?}[] } (radios)
select { name, label, value?, options:{value,label}[] }
chips { name, legend?, value?:string[], options:{value,label}[] } (checkboxes)
daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} }
%><%
const action = locals.action || "";
const label = locals.label || "Filter";
const rows = locals.rows || [];
const pills = locals.pills || [];
const clearHref = locals.clearHref || "?";
const applyLabel = locals.applyLabel || "Apply filters";
const eq = (a, b) => String(a ?? "") === String(b);
-%>
<form class="filters" method="get"<% if (action) { %> action="<%= action %>"<% } %> aria-label="<%= label %>">
<% rows.forEach((row) => { -%>
<div class="filter-row">
<% row.forEach((c) => { -%>
<% if (c.type === "search") { -%>
<label class="search"><span class="sr-only"><%= c.label || "Search" %></span><svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"/></svg><input type="search" name="<%= c.name %>" placeholder="<%= c.placeholder || "" %>"<% if (c.value) { %> value="<%= c.value %>"<% } %>></label>
<% } else if (c.type === "segmented") { -%>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || c.name %></legend><div class="segmented"><% c.options.forEach((o) => { %><label><input type="radio" name="<%= c.name %>" value="<%= o.value %>"<% if (eq(c.value, o.value)) { %> checked<% } %>><span><%= o.label %></span><% if (o.count != null) { %><span class="seg-count"><%= o.count %></span><% } %></label><% }) %></div></fieldset>
<% } else if (c.type === "select") { -%>
<span class="filter"><label class="sr-only" for="f-<%= c.name %>"><%= c.label %></label><span class="select"><select id="f-<%= c.name %>" name="<%= c.name %>"><% c.options.forEach((o) => { %><option value="<%= o.value %>"<% if (eq(c.value, o.value)) { %> selected<% } %>><%= o.label %></option><% }) %></select></span></span>
<% } else if (c.type === "chips") { -%>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || c.name %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || c.name %></span><div class="chips"><% (c.options).forEach((o) => { const on = (c.value || []).map(String).includes(String(o.value)); %><label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="<%= c.name %>" value="<%= o.value %>"<% if (on) { %> checked<% } %>><%= o.label %></label><% }) %></div></fieldset>
<% } else if (c.type === "daterange") { -%>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || "Date range" %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || "Date range" %></span><div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"/></svg><label class="sr-only" for="f-<%= c.from.name %>"><%= c.from.label || "From" %></label><input type="date" id="f-<%= c.from.name %>" name="<%= c.from.name %>"<% if (c.from.value) { %> value="<%= c.from.value %>"<% } %>><span class="to" aria-hidden="true">to</span><label class="sr-only" for="f-<%= c.to.name %>"><%= c.to.label || "To" %></label><input type="date" id="f-<%= c.to.name %>" name="<%= c.to.name %>"<% if (c.to.value) { %> value="<%= c.to.value %>"<% } %>></div></fieldset>
<% } else if (c.type === "spacer") { -%>
<div class="spacer"></div>
<% } -%>
<% }) -%>
</div>
<% }) -%>
<div class="filter-row filter-foot">
<% if (pills.length) { -%>
<div class="active-pills" aria-label="Applied filters"><span class="filter-legend">Applied</span><% pills.forEach((p) => { %><span class="pill"><b><%= p.label %>:</b> <%= p.value %> <a class="pill-x" href="<%= p.remove %>" aria-label="Remove <%= p.label %> filter"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span><% }) %><a class="pill-clear" href="<%= clearHref %>">Clear all</a></div>
<% } -%>
<div class="spacer"></div>
<div class="filter-actions">
<button type="reset" class="btn">Reset</button>
<button type="submit" class="btn btn-primary"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"/></svg><%= applyLabel %></button>
</div>
</div>
</form>