Add menu/popover + theme-switch partials (todo §1); data-driven .menu (items/check-groups/positioning), Light/Auto/Dark switch, shell reuses both
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, pagination, form field, auth card, icon sprite)
|
views/ Core EJS templates (index, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, 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)
|
||||||
|
|||||||
@@ -479,6 +479,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
box-shadow: 0 8px 28px rgba(0,0,0,.16);
|
box-shadow: 0 8px 28px rgba(0,0,0,.16);
|
||||||
}
|
}
|
||||||
.menu-pop.left { right: auto; left: 0; }
|
.menu-pop.left { right: auto; left: 0; }
|
||||||
|
.menu-pop.up { top: auto; bottom: calc(100% + 6px); }
|
||||||
.menu-head { font-size: var(--fz-xs); text-transform: uppercase;
|
.menu-head { font-size: var(--fz-xs); text-transform: uppercase;
|
||||||
letter-spacing: .05em; color: var(--text-faint); font-weight: 600;
|
letter-spacing: .05em; color: var(--text-faint); font-weight: 600;
|
||||||
padding: 5px 8px; }
|
padding: 5px 8px; }
|
||||||
|
|||||||
59
src/menu.test.ts
Normal file
59
src/menu.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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 menu = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "menu.ejs");
|
||||||
|
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, data);
|
||||||
|
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
test("menu renders trigger, positioning, the item matrix and check groups", async () => {
|
||||||
|
const html = flat(await render({
|
||||||
|
trigger: { icon: "i-cols", text: "Columns", label: "Column settings" },
|
||||||
|
align: "left", up: true, width: 240,
|
||||||
|
items: [
|
||||||
|
{ head: "Actions" },
|
||||||
|
{ label: "Profile", icon: "i-user" }, // button (default), with icon
|
||||||
|
{ label: "Docs", href: "/docs" }, // link
|
||||||
|
{ sep: true },
|
||||||
|
{ label: "Sign out", icon: "i-logout", danger: true },
|
||||||
|
{ group: { legend: "Role", name: "role", control: "radio", options: [
|
||||||
|
{ value: "", label: "Any role", checked: true },
|
||||||
|
{ value: "admin", label: "Admin" },
|
||||||
|
] } },
|
||||||
|
{ group: { name: "col", options: [{ value: "name", label: "Name", checked: true }] } }, // checkbox default, no legend
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Trigger: icon + text + aria-label; popover carries align/up classes + width.
|
||||||
|
assert.match(html, /<details class="menu"><summary class="btn" aria-label="Column settings"><svg class="ico ico-sm"><use href="#i-cols"\s*\/?><\/svg>Columns<\/summary>/);
|
||||||
|
assert.match(html, /<div class="menu-pop left up" style="min-width:240px">/);
|
||||||
|
|
||||||
|
// Item matrix: head, button-with-icon, link, separator, danger button.
|
||||||
|
assert.match(html, /<div class="menu-head">Actions<\/div>/);
|
||||||
|
assert.match(html, /<button class="menu-item" type="button"><svg class="ico"><use href="#i-user"\s*\/?><\/svg>Profile<\/button>/);
|
||||||
|
assert.match(html, /<a class="menu-item" href="\/docs">Docs<\/a>/);
|
||||||
|
assert.match(html, /<div class="menu-sep"><\/div>/);
|
||||||
|
assert.match(html, /<button class="menu-item danger" type="button"><svg class="ico"><use href="#i-logout"\s*\/?><\/svg>Sign out<\/button>/);
|
||||||
|
|
||||||
|
// Check group: radios reflect `checked`; legend optional; control defaults to checkbox.
|
||||||
|
assert.match(html, /<fieldset class="menu-field"><legend class="menu-head">Role<\/legend><label class="menu-check"><input type="radio" name="role" value="" checked>Any role<\/label><label class="menu-check"><input type="radio" name="role" value="admin">Admin<\/label><\/fieldset>/);
|
||||||
|
assert.match(html, /<fieldset class="menu-field"><label class="menu-check"><input type="checkbox" name="col" value="name" checked>Name<\/label><\/fieldset>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("menu supports a raw/kebab trigger, escapes labels, and renders empty by default", async () => {
|
||||||
|
// Raw trigger HTML, no summary class, kebab + open flags.
|
||||||
|
const kebab = flat(await render({
|
||||||
|
kebab: true, open: true,
|
||||||
|
trigger: { class: "", label: "Row actions", html: '<svg class="ico ico-sm"><use href="#i-kebab"/></svg>' },
|
||||||
|
items: [{ label: "Edit", href: "/e" }],
|
||||||
|
}));
|
||||||
|
assert.match(kebab, /<details class="menu kebab" open><summary aria-label="Row actions"><svg class="ico ico-sm"><use href="#i-kebab"\s*\/?><\/svg><\/summary>/);
|
||||||
|
|
||||||
|
// Labels are escaped (item text + trigger text).
|
||||||
|
assert.match(flat(await render({ trigger: { text: "<x>" }, items: [{ label: "<y>" }] })), /<summary class="btn"><x><\/summary>.*<y>/);
|
||||||
|
|
||||||
|
// No locals → a valid empty menu, never throws.
|
||||||
|
assert.equal(flat(await render()), '<details class="menu"><summary class="btn"></summary><div class="menu-pop"></div></details>');
|
||||||
|
});
|
||||||
25
src/theme-switch.test.ts
Normal file
25
src/theme-switch.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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 themeSwitch = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "theme-switch.ejs");
|
||||||
|
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(themeSwitch, data);
|
||||||
|
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
test("theme switch renders the Light/Auto/Dark radiogroup with CSS-coupled ids", async () => {
|
||||||
|
// ids must be theme-light/auto/dark — styles.css keys html:has(#theme-…:checked) off them.
|
||||||
|
const html = flat(await render({ value: "dark", label: "Appearance" }));
|
||||||
|
assert.match(html, /<div class="theme-switch" role="radiogroup" aria-label="Appearance">/);
|
||||||
|
assert.match(html, /<label><input type="radio" name="theme" id="theme-light">\s*<span>Light<\/span><\/label>/);
|
||||||
|
assert.match(html, /<label><input type="radio" name="theme" id="theme-auto">\s*<span>Auto<\/span><\/label>/);
|
||||||
|
assert.match(html, /<label><input type="radio" name="theme" id="theme-dark" checked>\s*<span>Dark<\/span><\/label>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("theme switch defaults to Auto checked and a default label", async () => {
|
||||||
|
const html = flat(await render());
|
||||||
|
assert.match(html, /aria-label="Color theme"/);
|
||||||
|
assert.match(html, /id="theme-auto" checked/);
|
||||||
|
assert.doesNotMatch(html, /id="theme-light" checked|id="theme-dark" checked/);
|
||||||
|
});
|
||||||
2
todo.md
2
todo.md
@@ -33,7 +33,7 @@ everything via Docker.
|
|||||||
- [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.
|
||||||
- [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.
|
- [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.
|
||||||
- [x] Form-field partials (input/label/hint/error) + auth-card partial. → `views/partials/field.ejs`: data-driven `.field` — label (+ inline `link`/`Optional`), optional icon input (`has-ico`), `hint`, server-driven `error` (string | {text} | {html}) wiring `aria-invalid` + `aria-describedby`; added one CSS rule `.field.has-error .field-error{display:flex}` so a rendered field shows its own error. `views/partials/auth-card.ejs`: the `<form class="auth-card">` shell — head (back/title/sub), optional `sso` providers (text logo or icon, link or button) + divider, `body` slot (fields + submit), `alt` footer. `field.test.ts`/`auth-card.test.ts` cover the matrix + escaping + defaults.
|
- [x] Form-field partials (input/label/hint/error) + auth-card partial. → `views/partials/field.ejs`: data-driven `.field` — label (+ inline `link`/`Optional`), optional icon input (`has-ico`), `hint`, server-driven `error` (string | {text} | {html}) wiring `aria-invalid` + `aria-describedby`; added one CSS rule `.field.has-error .field-error{display:flex}` so a rendered field shows its own error. `views/partials/auth-card.ejs`: the `<form class="auth-card">` shell — head (back/title/sub), optional `sso` providers (text logo or icon, link or button) + divider, `body` slot (fields + submit), `alt` footer. `field.test.ts`/`auth-card.test.ts` cover the matrix + escaping + defaults.
|
||||||
- [ ] Menu/popover + theme-switch partials (pure CSS `details`/`summary`).
|
- [x] Menu/popover + theme-switch partials (pure CSS `details`/`summary`). → `views/partials/menu.ejs`: data-driven `<details>` popover — `trigger` (icon/text/raw-html, `class:""` ⇒ bare kebab), `align`/`up` positioning, `width`; `items` = head · sep · link/button (icon, danger) · check-`group` (the columns/“more filters” menus filter-bar deferred here). `views/partials/theme-switch.ejs`: Light/Auto/Dark radiogroup with the fixed `theme-light/auto/dark` ids `styles.css` keys its `:has()` swaps off. Added `.menu-pop.up` (replaces the mockup's inline up-positioning); `shell.ejs` now reuses both partials. `menu.test.ts`/`theme-switch.test.ts` cover the matrix + escaping + defaults.
|
||||||
- [ ] Helper `composeNav(fragments, override, roles)` → merged, permission-filtered tree.
|
- [ ] Helper `composeNav(fragments, override, roles)` → merged, permission-filtered tree.
|
||||||
- [ ] 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.
|
||||||
|
|||||||
37
views/partials/menu.ejs
Normal file
37
views/partials/menu.ejs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<%#
|
||||||
|
Popover menu: pure <details>/<summary>, zero-JS. Mirrors html-css-foundation .menu.
|
||||||
|
Config:
|
||||||
|
trigger { class?(="btn", "" ⇒ none) · label?(aria-label) · icon? · text? · html?(raw inner, wins) }
|
||||||
|
align? "left" left-align the popover (default right)
|
||||||
|
up? boolean open upward (footer menus)
|
||||||
|
open? boolean start open
|
||||||
|
kebab? boolean bare kebab trigger (adds .kebab)
|
||||||
|
width? number|string popover min-width (number ⇒ px)
|
||||||
|
items: Item[] popover content, top→bottom
|
||||||
|
Item ∈ { head } · { sep } · { label, icon?, href? ⇒ <a>, danger? } (default: menu-item button)
|
||||||
|
· { group: { legend?, name, control?(="checkbox"|"radio"), options:{value,label,checked?}[] } }
|
||||||
|
%><%
|
||||||
|
const t = locals.trigger || {};
|
||||||
|
const sumCls = "class" in t ? t.class : "btn";
|
||||||
|
const items = locals.items || [];
|
||||||
|
const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : "");
|
||||||
|
const width = locals.width;
|
||||||
|
-%>
|
||||||
|
<details class="menu<%= locals.kebab ? " kebab" : "" %>"<%= locals.open ? " open" : "" %>>
|
||||||
|
<summary<% if (sumCls) { %> class="<%= sumCls %>"<% } %><% if (t.label) { %> aria-label="<%= t.label %>"<% } %>><% if (t.html != null) { %><%- t.html %><% } else { if (t.icon) { %><svg class="ico ico-sm"><use href="#<%= t.icon %>"/></svg><% } if (t.text) { %><%= t.text %><% } } %></summary>
|
||||||
|
<div class="<%= popCls %>"<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>>
|
||||||
|
<% items.forEach((it) => { -%>
|
||||||
|
<% if (it.head != null) { -%>
|
||||||
|
<div class="menu-head"><%= it.head %></div>
|
||||||
|
<% } else if (it.sep) { -%>
|
||||||
|
<div class="menu-sep"></div>
|
||||||
|
<% } else if (it.group) { const g = it.group; -%>
|
||||||
|
<fieldset class="menu-field"><% if (g.legend) { %><legend class="menu-head"><%= g.legend %></legend><% } %><% g.options.forEach((o) => { %><label class="menu-check"><input type="<%= g.control || "checkbox" %>" name="<%= g.name %>" value="<%= o.value %>"<%= o.checked ? " checked" : "" %>><%= o.label %></label><% }) %></fieldset>
|
||||||
|
<% } else if (it.href) { -%>
|
||||||
|
<a class="menu-item<%= it.danger ? " danger" : "" %>" href="<%= it.href %>"><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></a>
|
||||||
|
<% } else { -%>
|
||||||
|
<button class="menu-item<%= it.danger ? " danger" : "" %>" type="button"><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></button>
|
||||||
|
<% } -%>
|
||||||
|
<% }) -%>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
@@ -36,13 +36,10 @@
|
|||||||
<nav class="nav" aria-label="Main navigation"><%- nav %></nav>
|
<nav class="nav" aria-label="Main navigation"><%- nav %></nav>
|
||||||
|
|
||||||
<div class="side-footer">
|
<div class="side-footer">
|
||||||
<div class="theme-switch" role="radiogroup" aria-label="Color theme">
|
<%- include("theme-switch", { value: locals.theme }) %>
|
||||||
<label><input type="radio" name="theme" id="theme-light" /><span>Light</span></label>
|
|
||||||
<label><input type="radio" name="theme" id="theme-auto" checked /><span>Auto</span></label>
|
|
||||||
<label><input type="radio" name="theme" id="theme-dark" /><span>Dark</span></label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer-actions">
|
<div class="footer-actions">
|
||||||
|
<%# profile menu stays inline: the summary composes escaped user values %>
|
||||||
<details class="menu" style="flex:1 1 auto">
|
<details class="menu" style="flex:1 1 auto">
|
||||||
<summary class="profile">
|
<summary class="profile">
|
||||||
<span class="avatar" aria-hidden="true"><%= user.initials %></span>
|
<span class="avatar" aria-hidden="true"><%= user.initials %></span>
|
||||||
@@ -51,20 +48,18 @@
|
|||||||
<% if (user.email) { %><span class="profile-mail"><%= user.email %></span><% } %>
|
<% if (user.email) { %><span class="profile-mail"><%= user.email %></span><% } %>
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="menu-pop left" style="bottom:calc(100% + 6px); top:auto; min-width:220px">
|
<div class="menu-pop left up" style="min-width:220px">
|
||||||
<div class="menu-head">Signed in as <%= user.name %></div>
|
<div class="menu-head">Signed in as <%= user.name %></div>
|
||||||
<button class="menu-item"><svg class="ico"><use href="#i-user" /></svg>Profile</button>
|
<button class="menu-item" type="button"><svg class="ico"><use href="#i-user" /></svg>Profile</button>
|
||||||
<button class="menu-item danger"><svg class="ico"><use href="#i-logout" /></svg>Sign out</button>
|
<button class="menu-item danger" type="button"><svg class="ico"><use href="#i-logout" /></svg>Sign out</button>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="menu">
|
<%- include("menu", {
|
||||||
<summary class="btn icon-btn" aria-label="Settings"><svg class="ico"><use href="#i-gear" /></svg></summary>
|
up: true,
|
||||||
<div class="menu-pop" style="bottom:calc(100% + 6px); top:auto">
|
trigger: { class: "btn icon-btn", label: "Settings", html: '<svg class="ico"><use href="#i-gear"/></svg>' },
|
||||||
<div class="menu-head">Settings</div>
|
items: [{ head: "Settings" }, { label: "Preferences", icon: "i-gear" }],
|
||||||
<button class="menu-item"><svg class="ico"><use href="#i-gear" /></svg>Preferences</button>
|
}) %>
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
14
views/partials/theme-switch.ejs
Normal file
14
views/partials/theme-switch.ejs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<%#
|
||||||
|
Theme switcher: Light / Auto / Dark radiogroup. Zero-JS — the radios drive the
|
||||||
|
html:has(#theme-…:checked) token swaps in styles.css, so the ids are a fixed CSS
|
||||||
|
contract (one switch per page). Config: value? ∈ light|auto|dark (default auto,
|
||||||
|
rendered checked) · label?.
|
||||||
|
%><%
|
||||||
|
const value = locals.value || "auto";
|
||||||
|
const label = locals.label || "Color theme";
|
||||||
|
-%>
|
||||||
|
<div class="theme-switch" role="radiogroup" aria-label="<%= label %>">
|
||||||
|
<label><input type="radio" name="theme" id="theme-light"<%= value === "light" ? " checked" : "" %>><span>Light</span></label>
|
||||||
|
<label><input type="radio" name="theme" id="theme-auto"<%= value === "auto" ? " checked" : "" %>><span>Auto</span></label>
|
||||||
|
<label><input type="radio" name="theme" id="theme-dark"<%= value === "dark" ? " checked" : "" %>><span>Dark</span></label>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user