Close the popup menus on an outside click, via the popover API
CI / full-gate (push) Successful in 2m40s

This commit is contained in:
2026-08-05 01:37:58 +02:00
parent 64d1387df2
commit 5d9bdebf59
13 changed files with 142 additions and 69 deletions
+5 -3
View File
@@ -66,9 +66,11 @@
<% }) -%>
<% if (withActions) { -%>
<% if ((row.actions || []).length) { -%>
<td class="col-actions"><details class="menu kebab"><summary aria-label="<%= t("table.rowActions", { name: row.name || t("table.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="<%= localeHref(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>
<td class="col-actions"><%- include("menu", {
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])),
}) %></td>
<% } else { -%>
<td class="col-actions"></td>
<% } -%>
+15 -13
View File
@@ -1,38 +1,40 @@
<%#
Popover menu: pure <details>/<summary>, zero-JS.
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.
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)
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?}[] } }
%><%
const trigger = locals.trigger || {}; // not `t` — that name is the translator in every view
const sumCls = "class" in trigger ? trigger.class : "btn";
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);
-%>
<details class="menu<%= locals.kebab ? " kebab" : "" %>"<%= locals.open ? " open" : "" %>>
<summary<% if (sumCls) { %> class="<%= sumCls %>"<% } %><% 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 %><% } } %></summary>
<div class="<%= popCls %>"<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>>
<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 %>"<% } %>>
<% items.forEach((it) => { -%>
<% if (it.head != null) { -%>
<div class="menu-head"><%= it.head %></div>
<div class="menu-head"><%= it.head %></div>
<% } else if (it.sep) { -%>
<div class="menu-sep"></div>
<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>
<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.ownLocale ? it.href : localeHref(it.href) %>"<% if (it.hreflang) { %> hreflang="<%= it.hreflang %>" lang="<%= it.hreflang %>"<% } %><% if (it.current) { %> aria-current="true"<% } %>><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></a>
<a class="menu-item<%= it.danger ? " danger" : "" %>" href="<%= it.ownLocale ? it.href : localeHref(it.href) %>"<% if (it.hreflang) { %> hreflang="<%= it.hreflang %>" lang="<%= it.hreflang %>"<% } %><% if (it.current) { %> aria-current="true"<% } %>><% 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>
<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>
</div>
+16 -18
View File
@@ -57,24 +57,22 @@
<div class="footer-actions">
<% if (user.email) { %>
<%# signed in: profile menu inline (the summary composes escaped user values) %>
<details class="menu" style="flex:1 1 auto">
<summary class="profile">
<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>
</summary>
<div class="menu-pop left up" 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>
</details>
<%# 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>
<% } 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.