This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
Zero-JS (sort = links, select highlight = CSS).
|
||||
Config:
|
||||
caption?, selectable?, actions? sr-only caption; toggle the check / kebab columns
|
||||
actionsId? id stem for the row-action menus (default `row-actions`);
|
||||
name it when two tables share a page
|
||||
columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[]
|
||||
rows: { name?, cells: Cell[], actions?: Action[] }[]
|
||||
Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? }
|
||||
@@ -17,6 +19,7 @@
|
||||
const columns = locals.columns || [];
|
||||
const rows = locals.rows || [];
|
||||
const emptyText = locals.emptyText || t("table.empty"); // shown when a table that has columns has no rows
|
||||
const actionsId = locals.actionsId || "row-actions";
|
||||
-%>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
@@ -44,7 +47,7 @@
|
||||
<% if (rows.length === 0 && columns.length) { -%>
|
||||
<tr><td class="table-empty" colspan="<%= columns.length + (selectable ? 1 : 0) + (withActions ? 1 : 0) %>"><%= emptyText %></td></tr>
|
||||
<% } -%>
|
||||
<% rows.forEach((row) => { -%>
|
||||
<% rows.forEach((row, i) => { -%>
|
||||
<tr>
|
||||
<% if (selectable) { -%>
|
||||
<td class="col-check"><input type="checkbox" class="row-select" aria-label="<%= t("table.select", { name: row.name || t("table.row") }) %>"></td>
|
||||
@@ -67,9 +70,14 @@
|
||||
<% if (withActions) { -%>
|
||||
<% if ((row.actions || []).length) { -%>
|
||||
<td class="col-actions"><%- include("menu", {
|
||||
id: `${actionsId}-${i + 1}`,
|
||||
kebab: true,
|
||||
trigger: { class: "", icon: "i-kebab", label: t("table.rowActions", { name: row.name || t("table.row") }) },
|
||||
items: row.actions.flatMap((a) => (a.separatorBefore ? [{ sep: true }, a] : [a])),
|
||||
// Mapped field by field, not spread: an Action and a menu Item are separate shapes.
|
||||
items: row.actions.flatMap((a) => {
|
||||
const item = { danger: a.danger, href: a.href, icon: a.icon, label: a.label };
|
||||
return a.separatorBefore ? [{ sep: true }, item] : [item];
|
||||
}),
|
||||
}) %></td>
|
||||
<% } else { -%>
|
||||
<td class="col-actions"></td>
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
so switching is a plain navigation — zero-JS, and the address bar always says which language the
|
||||
page is in. Renders nothing for a single-language deployment.
|
||||
Locals: localeSwitch (host-supplied: { current, href, label, tag }[]) · up? (open upward, default true)
|
||||
id? (the menu's id; name a second picker on the same page)
|
||||
-%>
|
||||
<% const choices = locals.localeSwitch || []; -%>
|
||||
<% if (choices.length > 1) { -%>
|
||||
<%- include("menu", {
|
||||
id: locals.id || "locale-menu",
|
||||
up: locals.up !== false,
|
||||
trigger: { class: "btn icon-btn", icon: "i-globe", label: t("locale.label") },
|
||||
items: [
|
||||
|
||||
@@ -2,28 +2,31 @@
|
||||
Popover menu: a <button popovertarget> and the [popover] panel it opens, zero-JS. The browser owns
|
||||
open/close, so clicking outside or pressing Esc dismisses it, opening one closes the others, and the
|
||||
panel sits in the top layer instead of being clipped by a scrolling ancestor.
|
||||
The panel must stay the trigger's next sibling inside the .menu wrapper: the open-state style
|
||||
(.kebab:has(+ .menu-pop:popover-open)) and the old-browser fallback both read that adjacency.
|
||||
Config:
|
||||
id string REQUIRED — the panel's id and the trigger's popovertarget. Name it for what
|
||||
the menu is (`locale-menu`); it must be unique on the page.
|
||||
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)
|
||||
kebab? boolean bare kebab trigger (adds .kebab)
|
||||
width? number|string popover min-width (number ⇒ px)
|
||||
id? string popover id; defaults to a fresh one — pass it only to address this menu
|
||||
items: Item[] popover content, top→bottom
|
||||
Item ∈ { head } · { sep } · { label, icon?, href? ⇒ <a>, hreflang?, ownLocale?, current?, danger? } (default: menu-item button)
|
||||
ownLocale: the href already states its language (the picker) — don't carry the current one onto it
|
||||
· { group: { legend?, name, control?(="checkbox"|"radio"), options:{value,label,checked?}[] } }
|
||||
%><%
|
||||
// popovertarget is an idref: without one the trigger opens nothing, so say so instead of rendering
|
||||
// a dead button.
|
||||
if (!locals.id) throw new Error("menu partial: `id` is required — it wires the trigger to its panel");
|
||||
const trigger = locals.trigger || {}; // not `t` — that name is the translator in every view
|
||||
const btnCls = [("class" in trigger ? trigger.class : "btn"), locals.kebab ? "kebab" : ""].filter(Boolean).join(" ");
|
||||
const items = locals.items || [];
|
||||
const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : "");
|
||||
const width = locals.width;
|
||||
// popovertarget is an idref, so two menus on one page must not share an id.
|
||||
const id = locals.id || "menu-" + Math.random().toString(36).slice(2, 10);
|
||||
-%>
|
||||
<button<% if (btnCls) { %> class="<%= btnCls %>"<% } %> type="button" popovertarget="<%= id %>"<% if (trigger.label) { %> aria-label="<%= trigger.label %>"<% } %>><% if (trigger.html != null) { %><%- trigger.html %><% } else { if (trigger.icon) { %><svg class="ico ico-sm"><use href="#<%= trigger.icon %>"/></svg><% } if (trigger.text) { %><%= trigger.text %><% } } %></button>
|
||||
<div id="<%= id %>" class="<%= popCls %>" popover<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>>
|
||||
<span class="menu"><button<% if (btnCls) { %> class="<%= btnCls %>"<% } %> type="button" popovertarget="<%= locals.id %>"<% if (trigger.label) { %> aria-label="<%= trigger.label %>"<% } %>><% if (trigger.html != null) { %><%- trigger.html %><% } else { if (trigger.icon) { %><svg class="ico ico-sm"><use href="#<%= trigger.icon %>"/></svg><% } if (trigger.text) { %><%= trigger.text %><% } } %></button><div id="<%= locals.id %>" class="<%= popCls %>" popover<% 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>
|
||||
@@ -37,4 +40,4 @@
|
||||
<button class="menu-item<%= it.danger ? " danger" : "" %>" type="button"><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></button>
|
||||
<% } -%>
|
||||
<% }) -%>
|
||||
</div>
|
||||
</div></span>
|
||||
|
||||
+20
-16
@@ -57,22 +57,26 @@
|
||||
|
||||
<div class="footer-actions">
|
||||
<% if (user.email) { %>
|
||||
<%# signed in: profile menu inline (the trigger composes escaped user values) %>
|
||||
<button class="profile" type="button" popovertarget="profile-menu">
|
||||
<span class="avatar" aria-hidden="true"><%= user.initials %></span>
|
||||
<span class="profile-meta">
|
||||
<span class="profile-name"><%= user.name %></span>
|
||||
<span class="profile-mail"><%= user.email %></span>
|
||||
</span>
|
||||
</button>
|
||||
<div id="profile-menu" class="menu-pop left up" popover style="min-width:220px">
|
||||
<div class="menu-head"><%= t("shell.signedInAs", { name: user.name }) %></div>
|
||||
<%# Sign out is a state change → a POST form (not a GET link), CSRF-guarded by app.ts %>
|
||||
<form class="menu-item-form" method="post" action="<%= localeHref("/logout") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= locals.csrfToken || '' %>" />
|
||||
<button class="menu-item danger" type="submit"><svg class="ico"><use href="#i-logout" /></svg><%= t("shell.signOut") %></button>
|
||||
</form>
|
||||
</div>
|
||||
<%# Signed in: the same popover block as the menu partial, hand-rolled because this one's
|
||||
trigger composes escaped user values and its item is a CSRF POST form, neither of which
|
||||
the partial's Item shapes cover (AGENTS.md). Keep the two in step. %>
|
||||
<span class="menu" style="flex:1 1 auto">
|
||||
<button class="profile" type="button" popovertarget="profile-menu">
|
||||
<span class="avatar" aria-hidden="true"><%= user.initials %></span>
|
||||
<span class="profile-meta">
|
||||
<span class="profile-name"><%= user.name %></span>
|
||||
<span class="profile-mail"><%= user.email %></span>
|
||||
</span>
|
||||
</button>
|
||||
<div id="profile-menu" class="menu-pop left up" popover style="min-width:220px">
|
||||
<div class="menu-head"><%= t("shell.signedInAs", { name: user.name }) %></div>
|
||||
<%# Sign out is a state change → a POST form (not a GET link), CSRF-guarded by app.ts %>
|
||||
<form class="menu-item-form" method="post" action="<%= localeHref("/logout") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= locals.csrfToken || '' %>" />
|
||||
<button class="menu-item danger" type="submit"><svg class="ico"><use href="#i-logout" /></svg><%= t("shell.signOut") %></button>
|
||||
</form>
|
||||
</div>
|
||||
</span>
|
||||
<% } else if (!hideSignIn) { %>
|
||||
<%# anonymous (a public page in the shell): no session to end — offer a way in instead.
|
||||
signInHref carries this page as return_to (chrome.signInHref); falls back to bare /login.
|
||||
|
||||
Reference in New Issue
Block a user