Name the chrome's language menu and guard anchor positioning in the fallback
CI / full-gate (push) Successful in 2m44s
CI / full-gate (push) Successful in 2m44s
This commit is contained in:
@@ -916,7 +916,7 @@ The core and all building blocks **work with zero JavaScript** — theme switchi
|
||||
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`; CSS anchor positioning
|
||||
places it. On a browser too old for either the trigger is inert, so each panel falls back to
|
||||
places it. On a browser too old for the popover API the trigger is inert, so each panel falls back to
|
||||
flowing inline underneath it — cramped inside a table cell, but nothing is unreachable. That path
|
||||
is deliberately untested: no browser that supports popovers can render it. On the [low-end, low-bandwidth targets](#overview)
|
||||
we care about this is usually *faster*: a round-trip returning a small, pre-rendered HTML
|
||||
|
||||
@@ -87,7 +87,7 @@ test("a popover menu sits on its trigger and closes on an outside click or Esc
|
||||
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)).toBeGreaterThan(-1); // above the trigger, subpixel-tolerant
|
||||
expect(t.y - (p.y + p.height)).toBeLessThan(12);
|
||||
|
||||
await page.getByRole("heading", { name: "Starter dashboard" }).click(); // anywhere else on the page
|
||||
|
||||
@@ -497,8 +497,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
trigger rather than stranding Sign out and the language picker behind a button that does nothing.
|
||||
The wrapper turns block so the pair stacks instead of sitting side by side, and min-width goes —
|
||||
it would otherwise push the sidebar and a 44px action cell far past their width. Cramped but
|
||||
reachable, and unreachable by any test: a browser that supports popover cannot render this path. */
|
||||
@supports not selector([popover]:popover-open) {
|
||||
reachable, and unreachable by any test: a browser with both features cannot render this path.
|
||||
Both are guarded — an engine with popover but no anchor positioning would otherwise leave the
|
||||
panel absolutely positioned with every anchor() dropped, detached from its trigger. */
|
||||
@supports not (selector([popover]:popover-open) and (position-anchor: auto)) {
|
||||
.menu { display: block; }
|
||||
.menu-pop, .menu-pop.up { position: static; min-width: 0; margin: 6px 0 0; }
|
||||
}
|
||||
@@ -627,8 +629,10 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
||||
.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); }
|
||||
.kebab:hover { background: var(--surface-2); color: var(--text); }
|
||||
/* Held open — a trigger's panel is its next sibling. Its own rule: :has() is non-forgiving, so an
|
||||
engine that doesn't know :popover-open would drop the hover style along with it. */
|
||||
.kebab:has(+ .menu-pop:popover-open) { background: var(--surface-2); color: var(--text); }
|
||||
|
||||
/* ---------- 10. PAGINATION --------------------------------- */
|
||||
.pager {
|
||||
|
||||
@@ -65,8 +65,10 @@ test("data-table renders sortable headers, row-select, typed cells, badges and k
|
||||
|
||||
// Kebab row actions: a popover trigger and the panel it opens, named per row, then link item,
|
||||
// danger button, separator.
|
||||
assert.match(html, /<td class="col-actions"><span class="menu"><button class="kebab" type="button" popovertarget="row-actions-1" aria-label="Row actions for Mara Delgado"><svg class="ico ico-sm"><use href="#i-kebab"\s*\/?><\/svg><\/button><div id="row-actions-1" class="menu-pop" popover>/);
|
||||
assert.match(html, /<td class="col-actions"><div class="menu"><button class="kebab" type="button" popovertarget="row-actions-1" aria-label="Row actions for Mara Delgado"><svg class="ico ico-sm"><use href="#i-kebab"\s*\/?><\/svg><\/button><div id="row-actions-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>/);
|
||||
// actionsId renames the stem, so two tables can share a page without colliding.
|
||||
assert.match(flat(await render({ ...config, actionsId: "people-actions" })), /popovertarget="people-actions-1"/);
|
||||
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>/);
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -30,7 +30,7 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
|
||||
|
||||
// Trigger: icon + text + aria-label, wired to the panel by id; popover carries align/up + width.
|
||||
// The panel is the trigger's next sibling inside the wrapper — the CSS open state reads that.
|
||||
assert.match(html, /<span class="menu"><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><div id="cols-menu" class="menu-pop left up" popover style="min-width:240px">/);
|
||||
assert.match(html, /<div class="menu"><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><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>/);
|
||||
@@ -57,7 +57,7 @@ test("menu supports a raw/kebab trigger, escapes labels, and renders empty by de
|
||||
assert.match(flat(await render({ id: "esc-menu", trigger: { text: "<x>" }, items: [{ label: "<y>" }] })), /<x><\/button>.*<y>/);
|
||||
|
||||
// Only an id → a valid empty menu, never throws.
|
||||
assert.equal(flat(await render({ id: "m" })), '<span class="menu"><button class="btn" type="button" popovertarget="m"></button><div id="m" class="menu-pop" popover></div></span>');
|
||||
assert.equal(flat(await render({ id: "m" })), '<div class="menu"><button class="btn" type="button" popovertarget="m"></button><div id="m" class="menu-pop" popover></div></div>');
|
||||
});
|
||||
|
||||
test("menu demands an id — a trigger wired to nothing is a dead button, so say so", async () => {
|
||||
|
||||
@@ -42,9 +42,10 @@ test("app shell renders sidebar, topbar and the content slot", async () => {
|
||||
// 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>"));
|
||||
// …and Sign out is the panel's only control: nothing dead sits beside it. The window runs to the
|
||||
// sidebar's end so a control added *after* the form is caught too (this render has no picker —
|
||||
// ENGLISH_LOCALS ships one locale, so locale-switch emits nothing).
|
||||
const profileMenu = html.slice(html.indexOf('id="profile-menu"'), html.indexOf("</aside>"));
|
||||
assert.deepEqual(profileMenu.match(/<button/g), ["<button"]);
|
||||
|
||||
// Branding, document title, and the inlined icon sprite (so <use> resolves).
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
<% if ((row.actions || []).length) { -%>
|
||||
<td class="col-actions"><%- include("menu", {
|
||||
id: `${actionsId}-${i + 1}`,
|
||||
align: null, up: false, width: null, // explicit: EJS would otherwise inherit the page's own
|
||||
kebab: true,
|
||||
trigger: { class: "", icon: "i-kebab", label: t("table.rowActions", { name: row.name || t("table.row") }) },
|
||||
// Mapped field by field, not spread: an Action and a menu Item are separate shapes.
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
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)
|
||||
localeMenuId? (the menu's id; name a second picker on the same page. Not `id` — EJS
|
||||
merges page locals into every include, and `id` is the likeliest key in a plugin's data)
|
||||
-%>
|
||||
<% const choices = locals.localeSwitch || []; -%>
|
||||
<% if (choices.length > 1) { -%>
|
||||
<%- include("menu", {
|
||||
id: locals.id || "locale-menu",
|
||||
id: locals.localeMenuId || "locale-menu",
|
||||
align: null, kebab: false, width: null, // explicit: EJS would otherwise inherit the page's own
|
||||
up: locals.up !== false,
|
||||
trigger: { class: "btn icon-btn", icon: "i-globe", label: t("locale.label") },
|
||||
items: [
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : "");
|
||||
const width = locals.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 %>"<% } %>>
|
||||
<div 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>
|
||||
@@ -40,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></span>
|
||||
</div></div>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<%# 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">
|
||||
<div 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">
|
||||
@@ -76,7 +76,7 @@
|
||||
<button class="menu-item danger" type="submit"><svg class="ico"><use href="#i-logout" /></svg><%= t("shell.signOut") %></button>
|
||||
</form>
|
||||
</div>
|
||||
</span>
|
||||
</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.
|
||||
@@ -84,7 +84,7 @@
|
||||
<a class="btn btn-primary" href="<%= locals.signInHref || '/login' %>" style="flex:1 1 auto"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-user" /></svg><%= t("shell.signIn") %></a>
|
||||
<% } %>
|
||||
|
||||
<%- include("locale-switch") %>
|
||||
<%- include("locale-switch", { localeMenuId: "chrome-locale-menu" }) %>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user