Offer a long filter list as a popover multi-select instead of a wall of chips
CI / full-gate (push) Successful in 2m51s

This commit is contained in:
2026-08-25 09:10:23 +02:00
parent 26ba278eb3
commit c702a347dc
15 changed files with 58 additions and 22 deletions
+4
View File
@@ -259,6 +259,10 @@ Revisit only if the stated reason stops holding.
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
the two in step. the two in step.
- **Two multi-selects, chosen by list length.** `chips` lays every option on the bar, which is right
for a handful and a wall past that — `multiselect` hides the same checkboxes in a popover and puts
the chosen count on the trigger. Both submit the same repeated parameter, so swapping one for the
other is a view-model edit.
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it - **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
+5 -5
View File
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "0.2.0", apiVersion: "0.3.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [ routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) }, { method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
@@ -349,7 +349,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { listThings, createThings } from "./handlers.ts"; import { listThings, createThings } from "./handlers.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "0.2.0", // semver string of the host contract this plugin was built against (see Versioning) apiVersion: "0.3.0", // semver string of the host contract this plugin was built against (see Versioning)
// Nav fragment, merged into the global menu and permission-filtered per user. // Nav fragment, merged into the global menu and permission-filtered per user.
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
@@ -470,7 +470,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { landing, board } from "./pages.ts"; import { landing, board } from "./pages.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "0.2.0", apiVersion: "0.3.0",
home: landing, // owns "/" — the public front page home: landing, // owns "/" — the public front page
dashboard: board, // owns "/dashboard" — the post-login app home dashboard: board, // owns "/dashboard" — the post-login app home
}); });
@@ -747,7 +747,7 @@ camel humps both becoming underscores — so `upstream` on the `scheduling` plug
```ts ```ts
export default definePlugin({ export default definePlugin({
apiVersion: "0.2.0", apiVersion: "0.3.0",
settings: [ settings: [
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" }, { key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
{ key: "pageSize", type: "number", default: 25 }, { key: "pageSize", type: "number", default: 25 },
@@ -801,7 +801,7 @@ import { definePlugin } from "@plainpages/plugin-api";
let sql: ReturnType<typeof postgres>; let sql: ReturnType<typeof postgres>;
export default definePlugin({ export default definePlugin({
apiVersion: "0.2.0", apiVersion: "0.3.0",
storage: true, storage: true,
hooks: { hooks: {
onBoot: async (boot) => { onBoot: async (boot) => {
+1 -1
View File
@@ -28,7 +28,7 @@ const clients = on("oauth2-clients");
const pluginSettings = on("plugin-settings"); const pluginSettings = on("plugin-settings");
export default definePlugin({ export default definePlugin({
apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.3.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV], nav: [ADMIN_NAV],
+1 -1
View File
@@ -12,7 +12,7 @@ let upstreamUrl = "";
const upstream = createUpstream(() => upstreamUrl); const upstream = createUpstream(() => upstreamUrl);
export default definePlugin({ export default definePlugin({
apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.3.0", // the host contract this was built against — a literal, never HOST_API_VERSION
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved // onBoot runs after discovery, before the server listens — where a plugin receives its resolved
// settings. A malformed URL already failed the boot by then; the host validated the declared type. // settings. A malformed URL already failed the boot by then; the host validated the declared type.
+7 -1
View File
@@ -316,6 +316,11 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.btn-primary:hover { filter: brightness(1.06); } .btn-primary:hover { filter: brightness(1.06); }
.btn-ghost { background: transparent; border-color: transparent; } .btn-ghost { background: transparent; border-color: transparent; }
.btn-ghost:hover { background: var(--surface-2); } .btn-ghost:hover { background: var(--surface-2); }
.btn-menu::after {
content: ""; width: 7px; height: 7px; margin: -2px 1px 0 1px;
border-right: 1.5px solid var(--text-faint); border-bottom: 1.5px solid var(--text-faint);
transform: rotate(45deg);
}
.icon-btn { .icon-btn {
width: 30px; height: 30px; padding: 0; justify-content: center; width: 30px; height: 30px; padding: 0; justify-content: center;
color: var(--text-muted); color: var(--text-muted);
@@ -494,7 +499,8 @@ span.nav-self { cursor: default; } /* static / non-clickable */
position-anchor: auto; position-anchor: auto;
position-try-fallbacks: flip-block, flip-inline; position-try-fallbacks: flip-block, flip-inline;
top: anchor(bottom); right: anchor(right); top: anchor(bottom); right: anchor(right);
min-width: 210px; padding: 6px; min-width: 210px; max-width: min(320px, 92vw); padding: 6px;
max-height: 60vh; overflow-y: auto; overflow-wrap: anywhere;
background: var(--surface); color: var(--text); background: var(--surface); color: var(--text);
border: 1px solid var(--border-2); border-radius: var(--radius); border: 1px solid var(--border-2); border-radius: var(--radius);
box-shadow: 0 8px 28px rgba(0,0,0,.16); box-shadow: 0 8px 28px rgba(0,0,0,.16);
+1 -1
View File
@@ -12,7 +12,7 @@ test("readHostApiVersion pulls the constant out of the real source, and returns
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => { test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that // Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
// makes an accidental edit fail here rather than at release time. // makes an accidental edit fail here rather than at release time.
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.2.0"); assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.3.0");
}); });
test("every author-facing apiVersion sample matches the shipped contract", () => { test("every author-facing apiVersion sample matches the shipped contract", () => {
+1 -1
View File
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "0.2.0", apiVersion: "0.3.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [ routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) }, { method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
+1
View File
@@ -100,6 +100,7 @@ const messages = {
"filter.remove": "Remove {{label}} filter", "filter.remove": "Remove {{label}} filter",
"filter.reset": "Reset", "filter.reset": "Reset",
"filter.search": "Search", "filter.search": "Search",
"filter.selected": "{{label}}, {{count}} selected",
"filter.to": "To", "filter.to": "To",
"filter.toSeparator": "to", "filter.toSeparator": "to",
+1
View File
@@ -91,6 +91,7 @@ const messages: CoreMessages = {
"filter.remove": "Ta bort filtret {{label}}", "filter.remove": "Ta bort filtret {{label}}",
"filter.reset": "Återställ", "filter.reset": "Återställ",
"filter.search": "Sök", "filter.search": "Sök",
"filter.selected": "{{label}}, {{count}} valda",
"filter.to": "Till", "filter.to": "Till",
"filter.toSeparator": "till", "filter.toSeparator": "till",
+1 -1
View File
@@ -10,7 +10,7 @@ import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
import type { StorageCredentials } from "./storage.ts"; import type { StorageCredentials } from "./storage.ts";
// The Plainpages release this contract ships in — see README → Contract versioning. // The Plainpages release this contract ships in — see README → Contract versioning.
export const HOST_API_VERSION = "0.2.0"; export const HOST_API_VERSION = "0.3.0";
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
+16
View File
@@ -37,6 +37,14 @@ const config = {
options: [{ value: "engineering", label: "Engineering" }, { value: "design", label: "Design" }, { value: "oncall", label: "On-call" }], options: [{ value: "engineering", label: "Engineering" }, { value: "design", label: "Design" }, { value: "oncall", label: "On-call" }],
}, },
{ type: "daterange", legend: "Joined", from: { name: "joined_from", value: "2026-01-01", label: "Joined from" }, to: { name: "joined_to", value: "2026-06-14", label: "Joined to" } }, { type: "daterange", legend: "Joined", from: { name: "joined_from", value: "2026-01-01", label: "Joined from" }, to: { name: "joined_to", value: "2026-06-14", label: "Joined to" } },
{
type: "multiselect",
name: "owner",
legend: "Owner",
value: ["ann"],
options: [{ value: "ann", label: "Ann Berg" }, { value: "bo", label: "Bo Falk" }],
},
{ type: "multiselect", name: "room", legend: "Room", options: [{ value: "lab", label: "Lab" }] },
], ],
], ],
pills: [{ label: "Team", value: "Engineering", remove: "?tag=oncall" }], pills: [{ label: "Team", value: "Engineering", remove: "?tag=oncall" }],
@@ -64,6 +72,14 @@ test("filter-bar renders a GET form with every control type, reflecting current
assert.match(html, /<input type="checkbox" name="tag" value="oncall" checked>On-call/); assert.match(html, /<input type="checkbox" name="tag" value="oncall" checked>On-call/);
assert.match(html, /<input type="checkbox" name="tag" value="design">Design/); assert.match(html, /<input type="checkbox" name="tag" value="design">Design/);
// multiselect — a disclosure: the trigger names the filter and counts what is chosen, the options
// are checkboxes in a fieldset the popover holds, so a long list costs one line of the bar.
assert.match(html, /<div class="menu"><button class="btn btn-menu" type="button" popovertarget="f-owner-menu" aria-label="Owner, 1 selected">Owner<span class="badge">1<\/span><\/button><div id="f-owner-menu" class="menu-pop left" popover>/);
assert.match(html, /<fieldset class="menu-field"><legend class="menu-head">Owner<\/legend><label class="menu-check"><input type="checkbox" name="owner" value="ann" checked>Ann Berg<\/label><label class="menu-check"><input type="checkbox" name="owner" value="bo">Bo Falk<\/label><\/fieldset>/);
// Nothing chosen — no badge and no count in the accessible name.
assert.match(html, /<button class="btn btn-menu" type="button" popovertarget="f-room-menu">Room<\/button>/);
// daterange — calendar icon + two date inputs with values. // daterange — calendar icon + two date inputs with values.
assert.match(html, /<div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"\s*\/?><\/svg>.*?<input type="date" id="f-joined_from" name="joined_from" value="2026-01-01">.*?<span class="to" aria-hidden="true">to<\/span>.*?<input type="date" id="f-joined_to" name="joined_to" value="2026-06-14">/); assert.match(html, /<div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"\s*\/?><\/svg>.*?<input type="date" id="f-joined_from" name="joined_from" value="2026-01-01">.*?<span class="to" aria-hidden="true">to<\/span>.*?<input type="date" id="f-joined_to" name="joined_to" value="2026-06-14">/);
+2 -2
View File
@@ -12,7 +12,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 () => { test("menu renders trigger, positioning, the item matrix and check groups", async () => {
const html = flat(await render({ const html = flat(await render({
id: "cols-menu", id: "cols-menu",
trigger: { icon: "i-cols", text: "Columns", label: "Column settings" }, trigger: { count: 3, icon: "i-cols", text: "Columns", label: "Column settings" },
align: "left", up: true, width: 240, align: "left", up: true, width: 240,
items: [ items: [
{ head: "Actions" }, { head: "Actions" },
@@ -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. // 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. // The panel is the trigger's next sibling inside the wrapper — the CSS open state reads that.
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">/); 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<span class="badge">3<\/span><\/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. // Item matrix: head, button-with-icon, link, separator, danger button.
assert.match(html, /<div class="menu-head">Actions<\/div>/); assert.match(html, /<div class="menu-head">Actions<\/div>/);
+1 -1
View File
@@ -14,7 +14,7 @@
<p>${t("dashboard.starter.intro")}</p> <p>${t("dashboard.starter.intro")}</p>
<p>${t("dashboard.starter.replace")}</p> <p>${t("dashboard.starter.replace")}</p>
<pre class="code-block"><code>export default definePlugin({ <pre class="code-block"><code>export default definePlugin({
apiVersion: "0.2.0", apiVersion: "0.3.0",
// view names plugins/&lt;id&gt;/views/&lt;view&gt;.ejs, rendered in this same shell // view names plugins/&lt;id&gt;/views/&lt;view&gt;.ejs, rendered in this same shell
dashboard: (ctx) =&gt; ({ view: "dashboard", data: { /* … */ } }), dashboard: (ctx) =&gt; ({ view: "dashboard", data: { /* … */ } }),
});</code></pre> });</code></pre>
+9 -1
View File
@@ -2,11 +2,12 @@
Filter bar: a real GET form so filtering is server-side and zero-JS. Config: Filter bar: a real GET form so filtering is server-side and zero-JS. Config:
rows: Control[][] rows of controls, laid out left→right rows: Control[][] rows of controls, laid out left→right
pills, clearHref, label, action, applyLabel pills, clearHref, label, action, applyLabel
Control.type ∈ search | segmented | select | chips | daterange | spacer. Control.type ∈ search | segmented | select | chips | multiselect | daterange | spacer.
search { name, placeholder?, value?, label? } search { name, placeholder?, value?, label? }
segmented { name, legend?, value?, options:{value,label,count?}[] } (radios) segmented { name, legend?, value?, options:{value,label,count?}[] } (radios)
select { name, label, value?, options:{value,label}[] } select { name, label, value?, options:{value,label}[] }
chips { name, legend?, value?:string[], options:{value,label}[] } (checkboxes) chips { name, legend?, value?:string[], options:{value,label}[] } (checkboxes)
multiselect { name, legend?, value?:string[], options:{value,label}[] } (checkboxes in a popover)
daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} } daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} }
The form is a GET, which replaces the whole query string — so the visitor's chosen language rides The form is a GET, which replaces the whole query string — so the visitor's chosen language rides
along as a hidden input, and every href here (pills, clear) is run through localeHref. along as a hidden input, and every href here (pills, clear) is run through localeHref.
@@ -34,6 +35,13 @@
<span class="filter"><label class="sr-only" for="f-<%= c.name %>"><%= c.label %></label><span class="select"><select id="f-<%= c.name %>" name="<%= c.name %>"><% c.options.forEach((o) => { %><option value="<%= o.value %>"<% if (eq(c.value, o.value)) { %> selected<% } %>><%= o.label %></option><% }) %></select></span></span> <span class="filter"><label class="sr-only" for="f-<%= c.name %>"><%= c.label %></label><span class="select"><select id="f-<%= c.name %>" name="<%= c.name %>"><% c.options.forEach((o) => { %><option value="<%= o.value %>"<% if (eq(c.value, o.value)) { %> selected<% } %>><%= o.label %></option><% }) %></select></span></span>
<% } else if (c.type === "chips") { -%> <% } else if (c.type === "chips") { -%>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || c.name %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || c.name %></span><div class="chips"><% (c.options).forEach((o) => { const on = (c.value || []).map(String).includes(String(o.value)); %><label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="<%= c.name %>" value="<%= o.value %>"<% if (on) { %> checked<% } %>><%= o.label %></label><% }) %></div></fieldset> <fieldset class="filter-field"><legend class="sr-only"><%= c.legend || c.name %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || c.name %></span><div class="chips"><% (c.options).forEach((o) => { const on = (c.value || []).map(String).includes(String(o.value)); %><label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="<%= c.name %>" value="<%= o.value %>"<% if (on) { %> checked<% } %>><%= o.label %></label><% }) %></div></fieldset>
<% } else if (c.type === "multiselect") { const legend = c.legend || c.name; const on = (c.value || []).map(String); -%>
<%- include("menu", {
id: "f-" + c.name + "-menu",
align: "left", kebab: false, up: false, width: null, // explicit: EJS would otherwise inherit the page's own
trigger: { class: "btn btn-menu", count: on.length || null, label: on.length ? t("filter.selected", { count: on.length, label: legend }) : null, text: legend },
items: [{ group: { legend, name: c.name, options: c.options.map((o) => ({ checked: on.includes(String(o.value)), label: o.label, value: o.value })) } }],
}) %>
<% } else if (c.type === "daterange") { -%> <% } else if (c.type === "daterange") { -%>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || t("filter.dateRange") %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || t("filter.dateRange") %></span><div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"/></svg><label class="sr-only" for="f-<%= c.from.name %>"><%= c.from.label || t("filter.from") %></label><input type="date" id="f-<%= c.from.name %>" name="<%= c.from.name %>"<% if (c.from.value) { %> value="<%= c.from.value %>"<% } %>><span class="to" aria-hidden="true"><%= t("filter.toSeparator") %></span><label class="sr-only" for="f-<%= c.to.name %>"><%= c.to.label || t("filter.to") %></label><input type="date" id="f-<%= c.to.name %>" name="<%= c.to.name %>"<% if (c.to.value) { %> value="<%= c.to.value %>"<% } %>></div></fieldset> <fieldset class="filter-field"><legend class="sr-only"><%= c.legend || t("filter.dateRange") %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || t("filter.dateRange") %></span><div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"/></svg><label class="sr-only" for="f-<%= c.from.name %>"><%= c.from.label || t("filter.from") %></label><input type="date" id="f-<%= c.from.name %>" name="<%= c.from.name %>"<% if (c.from.value) { %> value="<%= c.from.value %>"<% } %>><span class="to" aria-hidden="true"><%= t("filter.toSeparator") %></span><label class="sr-only" for="f-<%= c.to.name %>"><%= c.to.label || t("filter.to") %></label><input type="date" id="f-<%= c.to.name %>" name="<%= c.to.name %>"<% if (c.to.value) { %> value="<%= c.to.value %>"<% } %>></div></fieldset>
<% } else if (c.type === "spacer") { -%> <% } else if (c.type === "spacer") { -%>
+2 -2
View File
@@ -7,7 +7,7 @@
Config: Config:
id string REQUIRED — the panel's id and the trigger's popovertarget. Name it for what 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. 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) } trigger { class?(="btn", "" ⇒ none) · label?(aria-label) · icon? · text? · count?(badge) · html?(raw inner, wins) }
align? "left" left-align the popover (default right) align? "left" left-align the popover (default right)
up? boolean open upward (footer menus) up? boolean open upward (footer menus)
kebab? boolean bare kebab trigger (adds .kebab) kebab? boolean bare kebab trigger (adds .kebab)
@@ -26,7 +26,7 @@
const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : ""); const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : "");
const width = locals.width; const width = locals.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 %>"<% } %>> <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 %><% } if (trigger.count != null) { %><span class="badge"><%= trigger.count %></span><% } } %></button><div id="<%= locals.id %>" class="<%= popCls %>" popover<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>>
<% items.forEach((it) => { -%> <% items.forEach((it) => { -%>
<% if (it.head != null) { -%> <% if (it.head != null) { -%>
<div class="menu-head"><%= it.head %></div> <div class="menu-head"><%= it.head %></div>