Close the popup menus on an outside click, via the popover API
CI / full-gate (push) Successful in 2m40s
CI / full-gate (push) Successful in 2m40s
This commit is contained in:
@@ -168,6 +168,14 @@ them. Revisit only if the stated reason stops holding.
|
||||
workspace dir, so ci.sh's web-image build races another run's container creation on the
|
||||
`<project>-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow
|
||||
`concurrency` group if it ever bites.
|
||||
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
|
||||
owns open/close, which is the only zero-JS way to dismiss a menu by clicking outside it (the whole
|
||||
point), and the panel sits in the top layer so a row kebab is no longer clipped by `.table-wrap`'s
|
||||
`overflow`. Two things not to "fix": the panel must carry **`position-anchor: auto`** — a bare
|
||||
`anchor()` resolves to nothing in Chromium, Firefox *and* WebKit (measured in all three before
|
||||
choosing) — and no `aria-expanded` is written, because a zero-JS invoker cannot keep one truthful;
|
||||
the state is the browser's to expose. `<details>` stays where it means disclosure rather than
|
||||
popup: the nav tree. Decided 2026-08-05.
|
||||
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract.** It is
|
||||
deliberately not re-exported from `#plugin-api`, and README → Nav & permission gates already tells an
|
||||
author that using a new icon means registering it there. So the palette may narrow when the last
|
||||
|
||||
@@ -912,8 +912,12 @@ set of reusable EJS partials + TS helpers, fully styled and zero-JS:
|
||||
|
||||
## Interactivity: zero-JS spine
|
||||
|
||||
The core and all building blocks **work with zero JavaScript** — menus, theme switching,
|
||||
and filtering are pure CSS + GET forms. On the [low-end, low-bandwidth targets](#overview)
|
||||
The core and all building blocks **work with zero JavaScript** — theme switching and filtering
|
||||
are pure CSS + GET forms, and menus are the platform's own [popover
|
||||
API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API): a `<button popovertarget>`
|
||||
opens the panel and the browser dismisses it on a click outside or `Esc`. A browser too old for
|
||||
that (before mid-2024) shows each panel inline instead, so nothing is stranded behind an inert
|
||||
button. On the [low-end, low-bandwidth targets](#overview)
|
||||
we care about this is usually *faster*: a round-trip returning a small, pre-rendered HTML
|
||||
page beats a client-side runtime that must boot, fetch JSON, and re-render before anything
|
||||
shows. List state (`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is
|
||||
|
||||
@@ -88,12 +88,12 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` });
|
||||
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
||||
await page.goto(`${editHref}`);
|
||||
await expect(page.locator('summary[aria-label="Språk"]')).toHaveCount(1);
|
||||
await expect(page.locator('button[aria-label="Språk"]')).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route
|
||||
await expect(page.getByText("Återställningskod skapad")).toBeVisible();
|
||||
|
||||
// The picker is here too, and following it lands on a real page in the other language.
|
||||
await page.locator('summary[aria-label="Språk"]').click();
|
||||
await page.locator('button[aria-label="Språk"]').click();
|
||||
await page.getByRole("link", { name: /English/i }).click();
|
||||
expect(page.url()).toContain("locale=en-US");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
|
||||
@@ -120,6 +120,11 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
const row = page.locator("tr", { hasText: email });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// Row actions sit behind the kebab popover: opening it reveals them, in the top layer, so the
|
||||
// scrolling table around the row cannot clip the panel.
|
||||
await row.locator("button.kebab").click();
|
||||
await expect(row.locator('a[href^="/admin/users/"]').first()).toBeVisible();
|
||||
|
||||
// Delete through the confirm interstitial (the row's Edit link carries the id).
|
||||
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
||||
await page.goto(`${editHref}/delete`);
|
||||
@@ -186,9 +191,9 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
|
||||
test("logout: signing out ends the session and returns to the login page", async () => {
|
||||
await page.goto("/dashboard");
|
||||
await page.locator("summary.profile").click(); // open the profile dropdown
|
||||
await page.locator("button.profile").click(); // open the profile dropdown
|
||||
// Sign out is the only item in it — the menu offers nothing that goes nowhere.
|
||||
await expect(page.locator("details.menu:has(summary.profile) .menu-item")).toHaveText(["Sign out"]);
|
||||
await expect(page.locator("#profile-menu .menu-item")).toHaveText(["Sign out"]);
|
||||
await page.locator('form[action="/logout"] button[type="submit"]').click();
|
||||
await page.waitForURL(/\/login(\?|$)/);
|
||||
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
|
||||
|
||||
@@ -31,7 +31,7 @@ test("the switcher changes language, and the choice survives clicking through th
|
||||
|
||||
// The picker sits in the sidebar footer beside the theme switch; each entry is a plain link to
|
||||
// this same page in that language (zero-JS).
|
||||
await page.locator('summary[aria-label="Language"]').click();
|
||||
await page.locator('button[aria-label="Language"]').click();
|
||||
await page.getByRole("link", { name: /svenska/i }).click();
|
||||
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
@@ -58,7 +58,7 @@ test("the switcher changes language, and the choice survives clicking through th
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
|
||||
// …and back to English the same way.
|
||||
await page.locator('summary[aria-label="Språk"]').click();
|
||||
await page.locator('button[aria-label="Språk"]').click();
|
||||
await page.getByRole("link", { name: /English/i }).click();
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
|
||||
await expect(page.getByRole("heading", { name: "Shifts" })).toBeVisible();
|
||||
|
||||
@@ -70,6 +70,35 @@ test("theme switch flips the palette with no JavaScript", async ({ page }) => {
|
||||
expect(dark).not.toBe(light);
|
||||
});
|
||||
|
||||
// The menus are <button popovertarget> + [popover], so the browser dismisses them: the visitor no
|
||||
// longer has to click the trigger again to get rid of one. Driven through the language picker; the
|
||||
// profile menu is the same block. Anchoring is asserted too — without `position-anchor` the panel
|
||||
// silently detaches and lands in the middle of the viewport.
|
||||
test("a popover menu sits on its trigger and closes on an outside click or Esc — no JavaScript", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
const trigger = page.locator('button[aria-label="Language"]');
|
||||
const panel = page.locator('button[aria-label="Language"] + .menu-pop');
|
||||
|
||||
await expect(panel).toBeHidden();
|
||||
await trigger.click();
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
// Anchored to the button that opened it: directly above (.up), right edges flush.
|
||||
const t = (await trigger.boundingBox())!;
|
||||
const p = (await panel.boundingBox())!;
|
||||
expect(Math.abs(p.x + p.width - (t.x + t.width))).toBeLessThan(2);
|
||||
expect(t.y - (p.y + p.height)).toBeGreaterThanOrEqual(0);
|
||||
expect(t.y - (p.y + p.height)).toBeLessThan(12);
|
||||
|
||||
await page.getByRole("heading", { name: "Starter dashboard" }).click(); // anywhere else on the page
|
||||
await expect(panel).toBeHidden();
|
||||
|
||||
await trigger.click();
|
||||
await expect(panel).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).toBeHidden();
|
||||
});
|
||||
|
||||
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/dashboard");
|
||||
|
||||
+24
-14
@@ -126,7 +126,7 @@ summary { list-style: none; cursor: pointer; }
|
||||
border-radius: 3px;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.sidebar, .scrim, summary, .nav-item, .btn, .chip { transition: .15s ease; }
|
||||
.sidebar, .scrim, summary, .nav-item, .btn, .chip, .profile, .kebab { transition: .15s ease; }
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
@@ -475,19 +475,28 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
margin: 0; cursor: pointer; }
|
||||
.check:hover, .radio:hover { color: var(--text); }
|
||||
|
||||
/* popover menu (column settings, kebab, etc.) — pure <details> */
|
||||
.menu { position: relative; display: inline-flex; }
|
||||
.menu > summary { display: inline-flex; }
|
||||
.menu > summary::after { content: none; }
|
||||
/* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
|
||||
panel. The browser owns open/close, and the top layer keeps the panel clear of a scrolling
|
||||
ancestor's overflow. `position-anchor: auto` binds it to the button that opened it; a bare
|
||||
anchor() without it resolves to nothing in every engine. `color` is set because the UA sheet
|
||||
gives [popover] a CanvasText of its own, which would ignore the theme. */
|
||||
.menu-pop {
|
||||
position: absolute; top: calc(100% + 6px); right: 0; z-index: 40;
|
||||
position: absolute; inset: auto; margin: 6px 0 0;
|
||||
position-anchor: auto;
|
||||
position-try-fallbacks: flip-block, flip-inline;
|
||||
top: anchor(bottom); right: anchor(right);
|
||||
min-width: 210px; padding: 6px;
|
||||
background: var(--surface); border: 1px solid var(--border-2);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface); color: var(--text);
|
||||
border: 1px solid var(--border-2); border-radius: var(--radius);
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,.16);
|
||||
}
|
||||
.menu-pop.left { right: auto; left: 0; }
|
||||
.menu-pop.up { top: auto; bottom: calc(100% + 6px); }
|
||||
.menu-pop.left { right: auto; left: anchor(left); }
|
||||
.menu-pop.up { top: auto; bottom: anchor(top); margin: 0 0 6px; }
|
||||
/* A browser too old for the Popover API leaves the trigger inert, so show the panel inline instead
|
||||
of stranding Sign out and the language picker behind a button that does nothing. */
|
||||
@supports not selector([popover]:popover-open) {
|
||||
.menu-pop, .menu-pop.up { position: static; margin: 6px 0 0; }
|
||||
}
|
||||
.menu-head { font-size: var(--fz-xs); text-transform: uppercase;
|
||||
letter-spacing: .05em; color: var(--text-faint); font-weight: 600;
|
||||
padding: 5px 8px; }
|
||||
@@ -610,10 +619,11 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
||||
|
||||
/* row kebab */
|
||||
.col-actions { width: 44px; text-align: center; }
|
||||
.kebab summary { width: 26px; height: 26px; border-radius: var(--radius);
|
||||
display: grid; place-items: center; color: var(--text-faint); margin: 0 auto; }
|
||||
.kebab summary:hover { background: var(--surface-2); color: var(--text); }
|
||||
.kebab[open] summary { background: var(--surface-2); color: var(--text); }
|
||||
.kebab { width: 26px; height: 26px; border-radius: var(--radius);
|
||||
display: grid; place-items: center; color: var(--text-faint); margin: 0 auto;
|
||||
background: transparent; border: 0; padding: 0; cursor: pointer; }
|
||||
/* hovered, or holding an open panel — a trigger's panel is its next sibling */
|
||||
.kebab:hover, .kebab:has(+ .menu-pop:popover-open) { background: var(--surface-2); color: var(--text); }
|
||||
|
||||
/* ---------- 10. PAGINATION --------------------------------- */
|
||||
.pager {
|
||||
|
||||
@@ -63,8 +63,9 @@ test("data-table renders sortable headers, row-select, typed cells, badges and k
|
||||
assert.match(html, /<td><span class="badge pos"><span class="dot"><\/span>Active<\/span><\/td>/);
|
||||
assert.match(html, /<td><a href="\/x">open<\/a><\/td>/);
|
||||
|
||||
// Kebab row actions: link item, danger button, separator.
|
||||
assert.match(html, /<td class="col-actions"><details class="menu kebab"><summary aria-label="Row actions for Mara Delgado"><svg class="ico ico-sm"><use href="#i-kebab"\s*\/?><\/svg><\/summary><div class="menu-pop">/);
|
||||
// Kebab row actions: a popover trigger and the panel it opens (\1 — the ids must agree), then
|
||||
// link item, danger button, separator.
|
||||
assert.match(html, /<td class="col-actions"><button class="kebab" type="button" popovertarget="(menu-[a-z0-9]+)" aria-label="Row actions for Mara Delgado"><svg class="ico ico-sm"><use href="#i-kebab"\s*\/?><\/svg><\/button><div id="\1" class="menu-pop" popover>/);
|
||||
assert.match(html, /<a class="menu-item" href="\/people\/1\/edit"><svg class="ico"><use href="#i-edit"\s*\/?><\/svg>Edit<\/a>/);
|
||||
assert.match(html, /<div class="menu-sep"><\/div><button class="menu-item danger" type="button"><svg class="ico"><use href="#i-trash"\s*\/?><\/svg>Delete<\/button>/);
|
||||
});
|
||||
|
||||
+18
-8
@@ -11,6 +11,7 @@ const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, "
|
||||
|
||||
test("menu renders trigger, positioning, the item matrix and check groups", async () => {
|
||||
const html = flat(await render({
|
||||
id: "cols-menu", // given explicitly here; the default is a fresh one per menu (see below)
|
||||
trigger: { icon: "i-cols", text: "Columns", label: "Column settings" },
|
||||
align: "left", up: true, width: 240,
|
||||
items: [
|
||||
@@ -27,9 +28,9 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
|
||||
],
|
||||
}));
|
||||
|
||||
// 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">/);
|
||||
// Trigger: icon + text + aria-label, wired to the panel by id; popover carries align/up + width.
|
||||
assert.match(html, /<button class="btn" type="button" popovertarget="cols-menu" aria-label="Column settings"><svg class="ico ico-sm"><use href="#i-cols"\s*\/?><\/svg>Columns<\/button>/);
|
||||
assert.match(html, /<div id="cols-menu" class="menu-pop left up" popover style="min-width:240px">/);
|
||||
|
||||
// Item matrix: head, button-with-icon, link, separator, danger button.
|
||||
assert.match(html, /<div class="menu-head">Actions<\/div>/);
|
||||
@@ -44,17 +45,26 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
|
||||
});
|
||||
|
||||
test("menu supports a raw/kebab trigger, escapes labels, and renders empty by default", async () => {
|
||||
// Raw trigger HTML, no summary class, kebab + open flags.
|
||||
// Raw trigger HTML, no button class, kebab flag.
|
||||
const kebab = flat(await render({
|
||||
kebab: true, open: true,
|
||||
id: "row-menu", kebab: 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>/);
|
||||
assert.match(kebab, /<button class="kebab" type="button" popovertarget="row-menu" aria-label="Row actions"><svg class="ico ico-sm"><use href="#i-kebab"\s*\/?><\/svg><\/button>/);
|
||||
|
||||
// Labels are escaped (item text + trigger text).
|
||||
assert.match(flat(await render({ trigger: { text: "<x>" }, items: [{ label: "<y>" }] })), /<summary class="btn"><x><\/summary>.*<y>/);
|
||||
assert.match(flat(await render({ trigger: { text: "<x>" }, items: [{ label: "<y>" }] })), /<x><\/button>.*<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>');
|
||||
assert.equal(flat(await render({ id: "m" })), '<button class="btn" type="button" popovertarget="m"></button><div id="m" class="menu-pop" popover></div>');
|
||||
});
|
||||
|
||||
test("menu mints its own popover id, so two menus on one page never cross-wire", async () => {
|
||||
const idOf = (html: string): string => html.match(/popovertarget="([^"]+)"/)?.[1] ?? "";
|
||||
|
||||
const one = flat(await render());
|
||||
const two = flat(await render());
|
||||
assert.match(one, new RegExp(`<div id="${idOf(one)}" class="menu-pop" popover>`)); // trigger and panel agree
|
||||
assert.notEqual(idOf(one), idOf(two)); // …and the next menu gets its own
|
||||
});
|
||||
|
||||
@@ -39,8 +39,12 @@ test("app shell renders sidebar, topbar and the content slot", async () => {
|
||||
// Sign out is a CSRF-guarded POST form (state change, not a GET link), carrying the token.
|
||||
assert.match(html, /<form class="menu-item-form" method="post" action="\/logout">/);
|
||||
assert.match(html, /<input type="hidden" name="_csrf" value="tok\.sig" \/>/);
|
||||
// …and it is the profile menu's only control: nothing dead sits beside it.
|
||||
const profileMenu = html.slice(html.indexOf('<div class="menu-pop'), html.indexOf("</details>"));
|
||||
// The profile trigger opens that panel — the browser dismisses it on a click outside or Esc.
|
||||
assert.match(html, /<button class="profile" type="button" popovertarget="profile-menu">/);
|
||||
assert.match(html, /<div id="profile-menu" class="menu-pop left up" popover/);
|
||||
// …and Sign out is the panel's only control: nothing dead sits beside it. The panel spans from
|
||||
// its id to the close of that one form; the trigger button is before it.
|
||||
const profileMenu = html.slice(html.indexOf('id="profile-menu"'), html.indexOf("</form>"));
|
||||
assert.deepEqual(profileMenu.match(/<button/g), ["<button"]);
|
||||
|
||||
// Branding, document title, and the inlined icon sprite (so <use> resolves).
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
## Unfinnished work
|
||||
|
||||
- [ ] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something?
|
||||
- [ ] Decide whether the profile dropdown still earns a dropdown. With the dead Profile link gone it holds one item, Sign out, behind a click — and its "Signed in as X" head only repeats the name and email the trigger already shows. Either put Sign out in the footer directly, or give the menu a second reason to exist. Overlaps the outside-click item above. Raised by review 2026-08-05.
|
||||
- [ ] When copy+paste the verification code from the email, it doesn't work because it does not trim whitechars around the code in the form. It should trim automatically.
|
||||
- [ ] Guard against the double-clicked submit, without client-side JavaScript. The README's non-technical persona double-clicks a button that doesn't respond instantly, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only (no client JS — priority: zero-JS spine), and it must not break an action that is *legitimately* repeatable (an increase-by-one button is not a duplicate, it is two increments). Sketch to evaluate: a CSS-only affordance so the second click has nothing to hit (`:active`/`:focus` state, or the submit visually and semantically settling), paired with the host recognising a duplicate on the server — same session, same route, same payload, within a short window — and then logging it and dropping the second rather than replaying it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form is stronger than hashing the payload, and the CSRF plumbing already mints per-request tokens), how long the window is, where the record lives given the app is stateless (in-memory like the revoke denylist, or push it to the upstream the plugin already writes to), and how a plugin declares a route as repeatable — an opt-out on the route, or opt-in per form. Raised 2026-08-04 with the personas.
|
||||
@@ -25,6 +24,7 @@ Prioritized. Overall verdict: architecture is sound (contract-first plugin API,
|
||||
|
||||
## Finnished work
|
||||
|
||||
- [x] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something? (The modern thing is the **Popover API**. All three popup menus — language picker, profile, row kebab — are now a `<button popovertarget>` plus a `[popover]` panel instead of `<details>`/`<summary>`, so the browser owns open/close: clicking anywhere outside dismisses one, `Esc` dismisses it and returns focus to the trigger, opening one closes the others, and the panel sits in the top layer where `.table-wrap`'s `overflow` can no longer clip a row kebab. Placement is CSS anchor positioning; the panel needs `position-anchor: auto` to bind to the button that opened it — a bare `anchor()` resolves to nothing in Chromium, Firefox and WebKit alike, measured in all three before picking the approach. `data-table.ejs` stopped hand-rolling its kebab and calls the `menu` partial, so the pattern lives in one file; each menu mints its own popover id, since `popovertarget` is an idref and two menus share a page. `<details>` stays in the nav tree, where it means disclosure rather than popup. A browser older than the Popover API (before mid-2024) renders each panel inline, so Sign out is never stranded behind an inert button. `e2e-tests/visual.spec.ts` drives the whole behaviour — opens, anchored to its trigger, outside-click, Esc — and the decision is recorded in AGENTS.md.)
|
||||
- [x] Organize the files in src in to folders so it is easier to understand the structure of the code.
|
||||
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
|
||||
- [x] The plugins/scheduling is an example and shouldn't be committed to the plugins directory since that should be empty to be able to be mounted in via docker or other means for the users/develoeprs using this application/framework. Put it in the examples folder instead.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user