Add recursive nav-tree partial (todo §1); header/leaf × clickable/static, counts + aria-current

This commit is contained in:
2026-06-15 11:59:26 +02:00
parent 672b831f8c
commit 67743cad23
4 changed files with 99 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. the app shell + icon sprite) views/ Core EJS templates (index, 403/404/500, partials/ incl. app shell, nav tree, 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)

68
src/nav-tree.test.ts Normal file
View File

@@ -0,0 +1,68 @@
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 navTree = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "nav-tree.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(navTree, data);
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
const nodes = [
{ label: "Overview", href: "/overview", icon: "i-grid" }, // leaf · clickable · icon
{
label: "Workspace",
open: true, // header · static · open
children: [
{
label: "Directory",
href: "/dir",
icon: "i-users",
count: 4,
open: true, // header · clickable · icon · count
children: [
{ label: "People", href: "/people", count: "1,284", current: true }, // leaf · clickable · current
{ label: "Webhooks (soon)" }, // leaf · static
],
},
{ label: "Roles & Access", children: [{ label: "Roles", href: "/roles" }] }, // header · static · closed
],
},
];
test("nav-tree renders the header/leaf × clickable/static matrix with counts, icons and aria-current", async () => {
const html = flat(await render({ nodes }));
// Root list vs. recursive child lists.
assert.match(html, /<ul class="nav-tree">/);
assert.match(html, /<ul class="nav-children">/);
// Leaf · clickable · icon — spacer (no toggle), <a>, inlined sprite ref.
assert.match(
html,
/<span class="nav-spacer" aria-hidden="true"><\/span><a class="nav-self" href="\/overview"><svg class="ico"><use href="#i-grid"\s*\/?><\/svg><span class="nav-label">Overview<\/span><\/a>/,
);
// Header · static · open — disclosure with [open] + escaped aria-label, <span> self.
assert.match(
html,
/<details class="nav-disc" open><summary class="nav-tog" aria-label="Toggle Workspace">.*?<\/summary><\/details><span class="nav-self"><span class="nav-label">Workspace<\/span><\/span>/,
);
// Header · clickable · icon · count.
assert.match(html, /<a class="nav-self" href="\/dir"><svg class="ico"><use href="#i-users"\s*\/?><\/svg><span class="nav-label">Directory<\/span><span class="nav-count">4<\/span><\/a>/);
// Leaf · clickable · current · count.
assert.match(html, /<a class="nav-self" href="\/people" aria-current="page"><span class="nav-label">People<\/span><span class="nav-count">1,284<\/span><\/a>/);
// Leaf · static (no href → <span>, no toggle).
assert.match(html, /<span class="nav-self"><span class="nav-label">Webhooks \(soon\)<\/span><\/span>/);
// Header · static · closed (no [open]) + label escaping in both label and aria-label.
assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Roles &amp; Access">/);
assert.match(html, /<span class="nav-label">Roles &amp; Access<\/span>/);
});
test("nav-tree renders an empty root list with no nodes and never throws", async () => {
assert.match(flat(await render()), /<ul class="nav-tree"><\/ul>/);
});

View File

@@ -28,7 +28,7 @@ everything via Docker.
- [x] Move `styles.css` + `auth.css` into `public/css/`; remove existing `style.css`. → `git mv` from `html-css-foundation/` into `public/css/`; dropped the placeholder `style.css`; views + tests now reference `styles.css`; foundation mockups repointed to `../public/css/`. - [x] Move `styles.css` + `auth.css` into `public/css/`; remove existing `style.css`. → `git mv` from `html-css-foundation/` into `public/css/`; dropped the placeholder `style.css`; views + tests now reference `styles.css`; foundation mockups repointed to `../public/css/`.
- [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").
- [ ] Nav-tree partial — recursive, header/leaf × clickable/static, counts, `aria-current`. - [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). - [ ] Filter-bar partial — GET form (search, segmented, selects, chips, daterange, applied pills).
- [ ] 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.
@@ -38,6 +38,7 @@ everything via Docker.
- [ ] Helper `parseListQuery(url)``{ q, filters, sort, page, pageSize }`. - [ ] Helper `parseListQuery(url)``{ q, filters, sort, page, pageSize }`.
- [ ] Helper `paginate(total, page, pageSize)` → page model. - [ ] Helper `paginate(total, page, pageSize)` → page model.
- [ ] Replace placeholder `index` with the app-shell dashboard. - [ ] Replace placeholder `index` with the app-shell dashboard.
- [ ] Check the full system in Playwright and make screenshots and compare to the static original design in html-css-foundation to make sure we're showing the correct graphics.
- [ ] Go over all HTML and CSS and make adjust it to be as sematic as we can, css classes, ids html elements and all, then add semantic DOM as a priority in this project. - [ ] Go over all HTML and CSS and make adjust it to be as sematic as we can, css classes, ids html elements and all, then add semantic DOM as a priority in this project.
- [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. - [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.

View File

@@ -0,0 +1,28 @@
<%#
Recursive nav tree. Each node: { label, href?, icon?, count?, current?, open?, children? }.
Shape is orthogonal: header (children → disclosure toggle + nested <ul>) vs leaf (spacer),
and clickable (href → <a>) vs static (<span>). Mirrors the html-css-foundation markup; the
CSS reveals children only when the sibling .nav-disc is [open]. `root` picks the outer class.
%><%
const nodes = locals.nodes || [];
const root = locals.root !== false;
-%>
<ul class="<%= root ? "nav-tree" : "nav-children" %>">
<% nodes.forEach((node) => { const header = node.children && node.children.length; const tag = node.href ? "a" : "span"; -%>
<li class="nav-node">
<div class="nav-row">
<% if (header) { -%>
<details class="nav-disc"<%= node.open ? " open" : "" %>>
<summary class="nav-tog" aria-label="Toggle <%= node.label %>"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<% } else { -%>
<span class="nav-spacer" aria-hidden="true"></span>
<% } -%>
<<%= tag %> class="nav-self"<% if (node.href) { %> href="<%= node.href %>"<% } %><% if (node.current) { %> aria-current="page"<% } %>><% if (node.icon) { %><svg class="ico"><use href="#<%= node.icon %>"/></svg><% } %><span class="nav-label"><%= node.label %></span><% if (node.count != null) { %><span class="nav-count"><%= node.count %></span><% } %></<%= tag %>>
</div>
<% if (header) { -%>
<%- include("nav-tree", { nodes: node.children, root: false }) %>
<% } -%>
</li>
<% }) -%>
</ul>