12 Commits

Author SHA1 Message Date
renovate-bot 65ddcab3d0 Update dependency lucide-static to v1.37.0
CI / full-gate (push) Failing after 17s
Release-Bump: minor
2026-08-29 10:42:17 +00:00
renovate-bot 7bde22713f Update Node.js to v24.20.0
CI / full-gate (push) Successful in 3m13s
Mirror / github-mirror (push) Successful in 4s
Release-Bump: minor
2026-08-28 04:18:08 +00:00
renovate-bot 566c286f87 Update renovate/renovate Docker tag to v44.46.4
CI / full-gate (push) Successful in 2m53s
Mirror / github-mirror (push) Successful in 4s
2026-08-27 04:18:29 +00:00
lilleman 0ad4c6b09c Say what the browser gives a popover trigger, and pin the part all engines can prove
CI / full-gate (push) Successful in 2m51s
Mirror / github-mirror (push) Successful in 3s
2026-08-26 09:10:46 +02:00
renovate-bot ebc1398906 Update renovate/renovate Docker tag to v44.45.4
CI / full-gate (push) Successful in 2m51s
Mirror / github-mirror (push) Successful in 4s
2026-08-26 04:18:07 +00:00
lilleman cf48b3014c Merge branch 'main' into renovate/renovate-renovate-44.x
CI / full-gate (push) Successful in 2m50s
Mirror / github-mirror (push) Successful in 4s
2026-08-25 15:09:02 +02:00
lilleman 43bb004ae7 Keep the control-choosing rule where a caller picks one
CI / full-gate (push) Successful in 2m50s
Mirror / github-mirror (push) Successful in 3s
2026-08-25 09:38:33 +02:00
lilleman f4693af3df Let a multi-select say what a capped list left out, inside the panel
CI / full-gate (push) Successful in 2m52s
2026-08-25 09:15:30 +02:00
lilleman c702a347dc Offer a long filter list as a popover multi-select instead of a wall of chips
CI / full-gate (push) Successful in 2m51s
2026-08-25 09:10:23 +02:00
renovate-bot fb480973b7 Update renovate/renovate Docker tag to v44.41.1
CI / full-gate (push) Successful in 2m51s
2026-08-25 04:18:24 +00:00
renovate-bot 26ba278eb3 Update dependency lucide-static to v1.34.0
CI / full-gate (push) Successful in 3m0s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
2026-08-25 04:18:15 +00:00
renovate-bot 952af3f107 Update renovate/renovate Docker tag to v44.39.3
CI / full-gate (push) Successful in 2m50s
Mirror / github-mirror (push) Successful in 2s
2026-08-24 05:41:22 +00:00
24 changed files with 83 additions and 41 deletions
+1 -1
View File
@@ -19,4 +19,4 @@ jobs:
run: | run: |
docker run --rm -v "$PWD:/repo" -w /repo \ docker run --rm -v "$PWD:/repo" -w /repo \
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \ -e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
node:24.19.0-alpine3.24 node registry-cleanup/cleanup.ts node:24.20.0-alpine3.24 node registry-cleanup/cleanup.ts
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
GIT_TAG: ${{ github.ref_name }} GIT_TAG: ${{ github.ref_name }}
run: | run: |
set -euo pipefail set -euo pipefail
docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \ docker run --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
- name: Promote the commit-hash image to semver + latest - name: Promote the commit-hash image to semver + latest
env: env:
@@ -98,9 +98,9 @@ jobs:
VERSION=${INPUT_VERSION:-${GIT_TAG#v}} VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
VERSION=${VERSION#v} VERSION=${VERSION#v}
# An empty dispatch input falls back to the branch name, so gate this like a tag. # An empty dispatch input falls back to the branch name, so gate this like a tag.
docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \ docker run --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
docker run --rm -v "$PWD:/repo" -w /repo \ docker run --rm -v "$PWD:/repo" -w /repo \
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \ -e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
node:24.19.0-alpine3.24 \ node:24.20.0-alpine3.24 \
node release-tooling/dockerhub-overview.ts "$VERSION" node release-tooling/dockerhub-overview.ts "$VERSION"
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
-e RENOVATE_PLATFORM=gitea \ -e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \ -e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \ -e RENOVATE_TOKEN \
renovate/renovate:44.39.2 renovate/renovate:44.46.4
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the # After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the # last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
@@ -58,11 +58,11 @@ jobs:
if [ -z "$BUMPS" ]; then if [ -z "$BUMPS" ]; then
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0 echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
fi fi
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \ NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
node release-tooling/next-version.ts "$LATEST" $BUMPS) node release-tooling/next-version.ts "$LATEST" $BUMPS)
# Read the constant off origin/main, not the checkout, which lags the merges this run made. # Read the constant off origin/main, not the checkout, which lags the merges this run made.
git show origin/main:src/plugin-host/plugin.ts \ git show origin/main:src/plugin-host/plugin.ts \
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \ | docker run -i --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
node release-tooling/contract-version.ts "$NEXT" - node release-tooling/contract-version.ts "$NEXT" -
echo "Releasing $LATEST -> $NEXT" echo "Releasing $LATEST -> $NEXT"
git tag "$NEXT" origin/main git tag "$NEXT" origin/main
+5 -2
View File
@@ -254,8 +254,11 @@ Revisit only if the stated reason stops holding.
open-state style and the old-browser fallback both read; the partial **requires a caller-named open-state style and the old-browser fallback both read; the partial **requires a caller-named
`id`** and fails loud without one, since that is the `popovertarget` idref (never generate one — `id`** and fails loud without one, since that is the `popovertarget` idref (never generate one —
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
`aria-haspopup` is written**, because a zero-JS invoker cannot keep the first truthful and the `aria-haspopup` is written**: every engine maintains the first itself on a declarative
second would promise `role="menu"` semantics these panels don't implement. `<details>` stays where `popovertarget` invoker, so a hand-written one replaces a live state with a static lie, and the
second would promise `role="menu"` semantics these panels don't implement. **That guarantee is the
declarative attribute's alone** — open a panel from script and no engine applies it, so
"enhancing" one of these triggers is what would cost it its accessibility. `<details>` stays where
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.
+1 -1
View File
@@ -1,5 +1,5 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag. # Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
FROM node:24.19.0-alpine3.24 FROM node:24.20.0-alpine3.24
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules # Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root). # instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
+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
@@ -49,7 +49,7 @@ services:
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service — # backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead. # stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
shifts-upstream: shifts-upstream:
image: node:24.19.0-alpine3.24 image: node:24.20.0-alpine3.24
command: node /srv/server.ts command: node /srv/server.ts
restart: unless-stopped restart: unless-stopped
volumes: volumes:
+3 -3
View File
@@ -53,7 +53,7 @@ services:
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows. # The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
shifts-upstream: shifts-upstream:
image: node:24.19.0-alpine3.24 image: node:24.20.0-alpine3.24
command: ["node", "/server.ts"] command: ["node", "/server.ts"]
volumes: volumes:
- ./examples/shifts-upstream/server.ts:/server.ts:ro - ./examples/shifts-upstream/server.ts:/server.ts:ro
@@ -66,7 +66,7 @@ services:
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos # Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos. # verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
mock-oidc: mock-oidc:
image: node:24.19.0-alpine3.24 image: node:24.20.0-alpine3.24
command: ["node", "/mock-oidc.ts"] command: ["node", "/mock-oidc.ts"]
environment: environment:
ISSUER: http://mock-oidc:9000 ISSUER: http://mock-oidc:9000
@@ -81,7 +81,7 @@ services:
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts). # Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
proxy: proxy:
image: node:24.19.0-alpine3.24 image: node:24.20.0-alpine3.24
command: ["node", "/proxy.ts"] command: ["node", "/proxy.ts"]
depends_on: depends_on:
web: web:
+1
View File
@@ -94,6 +94,7 @@ test("a popover menu sits on its trigger and closes on an outside click or Esc
await expect(panel).toBeVisible(); await expect(panel).toBeVisible();
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await expect(panel).toBeHidden(); await expect(panel).toBeHidden();
await expect(trigger).toBeFocused(); // the browser returns focus, so no trigger needs a tabindex
}); });
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => { test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
+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.
+4 -4
View File
@@ -8,7 +8,7 @@
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "6.0.1", "ejs": "6.0.1",
"lucide-static": "1.33.0", "lucide-static": "1.37.0",
"postgres": "3.4.9" "postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
@@ -399,9 +399,9 @@
} }
}, },
"node_modules/lucide-static": { "node_modules/lucide-static": {
"version": "1.33.0", "version": "1.37.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.37.0.tgz",
"integrity": "sha512-jNGgvTNcLUfVRX4N9PH9pVVTJzoph/BmYmgU838bYBQodkUJL4nAThkuymFz1x3OUYMhJxPndC7rdg1sxOPYKg==", "integrity": "sha512-uAp0oHMWE8T6XycokgjswDF3epVTFhUJQG4EdMsZOC/Jbmi5dFvdto0c6gcVQqswQDIGWguSvbJ0got6Vy3RXQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/postgres": { "node_modules/postgres": {
+1 -1
View File
@@ -18,7 +18,7 @@
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "6.0.1", "ejs": "6.0.1",
"lucide-static": "1.33.0", "lucide-static": "1.37.0",
"postgres": "3.4.9" "postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
+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";
+17
View File
@@ -37,6 +37,15 @@ 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",
note: "30 of 45",
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 +73,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, /popover><div class="menu-head">30 of 45<\/div><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>
+19 -6
View File
@@ -2,12 +2,15 @@
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)
daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} } multiselect { name, legend?, note?, value?:string[], options:{value,label}[] } (checkboxes in a popover)
daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} }
chips and multiselect are the same checkboxes on the same parameter: on the bar, or behind a
button once the list is too long to lay there.
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 +37,16 @@
<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: [
...(c.note ? [{ head: c.note }] : []),
{ 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>