@tundralibs/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui.js ADDED
@@ -0,0 +1,2089 @@
1
+ /* shared/js/busy.js */
2
+ /* Loading state for rAPId swaps, automatically. The moment a
3
+ * [data-action] click or a form[data-action] submit is on its way, the
4
+ * region it will replace is marked `aria-busy="true"` + `data-busy`;
5
+ * skeleton.css paints a shimmer veil over it and turns pointer events
6
+ * off, and `rapid:swapped` / `rapid:error` clear it. A `data-load` region
7
+ * is left alone — it carries its own server-rendered skeleton until its
8
+ * fragment lands. Content-shaped skeletons stay the server's job (only it
9
+ * knows the shape); this is the generic "something is happening here".
10
+ * Runs against the same delegated events the runtime uses; no `rapid.*`
11
+ * call, no coupling beyond the two events it listens for. */
12
+ (() => {
13
+ const TIMEOUT = 15000;
14
+ const timers = new WeakMap();
15
+
16
+ function targetOf(el) {
17
+ const sel = el.getAttribute("data-target");
18
+ if (!sel) return el;
19
+ try {
20
+ return document.querySelector(sel);
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function clear(el) {
27
+ if (!el || el.nodeType !== 1) return;
28
+ el.removeAttribute("aria-busy");
29
+ delete el.dataset.busy;
30
+ const t = timers.get(el);
31
+ if (t) clearTimeout(t);
32
+ }
33
+
34
+ function mark(el) {
35
+ const region = targetOf(el);
36
+ if (!region || region.hasAttribute("data-load")) return;
37
+ region.setAttribute("aria-busy", "true");
38
+ region.dataset.busy = "";
39
+ // Never leave a veil behind if no reply ever comes.
40
+ timers.set(region, setTimeout(() => clear(region), TIMEOUT));
41
+ }
42
+
43
+ // The runtime's own listener runs first and calls preventDefault on the
44
+ // clicks it handles — so `defaultPrevented` is the normal case here, not
45
+ // a reason to stand down.
46
+ document.addEventListener("click", (event) => {
47
+ if (event.button !== 0) return;
48
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
49
+ const el = event.target.closest?.("[data-action]");
50
+ if (!el || el.tagName === "FORM") return;
51
+ // A real link inside a data-action container keeps its native
52
+ // navigation (runtime rule) — no swap, so nothing to mark.
53
+ const link = event.target.closest("a[href]");
54
+ if (link && link !== el && el.contains(link)) return;
55
+ mark(el);
56
+ });
57
+
58
+ document.addEventListener("submit", (event) => {
59
+ const form = event.target;
60
+ if (form?.matches?.("form[data-action]")) mark(form);
61
+ });
62
+
63
+ document.addEventListener("rapid:swapped", (event) => clear(event.target));
64
+ document.addEventListener("rapid:error", (event) => {
65
+ clear(event.target);
66
+ document.querySelectorAll("[data-busy]").forEach(clear);
67
+ });
68
+ })();
69
+
70
+ /* shared/js/collapse-sidebar.js */
71
+ /*
72
+ * Desktop "mini sidebar" collapse — distinct from shared/js/toggle.js's
73
+ * [data-toggle], which also hides the target via the `hidden` attribute
74
+ * (wrong here: a collapsed sidebar must stay visible, just narrower).
75
+ */
76
+ (() => {
77
+ const KEY = "ui-sidebar-collapsed";
78
+
79
+ try {
80
+ if (localStorage.getItem(KEY) === "1") {
81
+ document.querySelectorAll(".sidebar").forEach((el) => {
82
+ el.classList.add("sidebar--collapsed");
83
+ });
84
+ }
85
+ } catch {
86
+ // No persistence available — starts expanded, which is fine.
87
+ }
88
+
89
+ document.addEventListener("click", (event) => {
90
+ const trigger = event.target.closest?.("[data-collapse]");
91
+ if (!trigger) return;
92
+ const selector = trigger.getAttribute("data-collapse");
93
+ if (!selector || selector === "#") return;
94
+ const target = document.querySelector(selector);
95
+ if (!target) return;
96
+
97
+ const collapsed = target.classList.toggle("sidebar--collapsed");
98
+ trigger.setAttribute("aria-expanded", String(!collapsed));
99
+ try {
100
+ localStorage.setItem(KEY, collapsed ? "1" : "0");
101
+ } catch {
102
+ // Same fallback as above.
103
+ }
104
+ });
105
+ })();
106
+
107
+ /* shared/js/dismiss.js */
108
+ /* Generic dismiss: a `[data-dismiss]` button removes its closest
109
+ * `[data-dismissible]` ancestor (used by both alert and toast). */
110
+ (() => {
111
+ document.addEventListener("click", (event) => {
112
+ const trigger = event.target.closest("[data-dismiss]");
113
+ if (!trigger) return;
114
+ trigger.closest("[data-dismissible]")?.remove();
115
+ });
116
+ })();
117
+
118
+ /* shared/js/enhance.js */
119
+ /*
120
+ * A pure opt-in marker — added, never assumed pre-set — so no core/layout
121
+ * template needs to put a class on <html> or <body> (rAPId's own
122
+ * `htmlDocument()` helper doesn't expose a hook for either). Every CSS
123
+ * rule that depends on JS being available is written as `.js SELECTOR`,
124
+ * so its absence (JS disabled, or this script not yet run) always means
125
+ * the plain, functional fallback rendering — never a broken one.
126
+ */
127
+ (() => {
128
+ document.documentElement.classList.add("js");
129
+ })();
130
+
131
+ /* shared/js/filter.js */
132
+ /*
133
+ * Generic client-side search/filter: an element carrying
134
+ * `data-filter-scope` defines a filterable region. Inside it, a
135
+ * `[data-table-search]` input free-text filters, and any
136
+ * `[data-table-filter]` control narrows further (both AND together) — a
137
+ * <select>, or radio inputs (e.g. a Segmented group; only the checked
138
+ * one counts, and an empty value means "all"). Filterable items are a
139
+ * table's own `tbody tr` rows when the scope contains a `<table>`,
140
+ * otherwise any `[data-filter-item]` — so the same mechanism works for
141
+ * both a data table and a card grid. An optional `[data-filter-empty]`
142
+ * element in the scope is shown only when nothing matches.
143
+ */
144
+ (() => {
145
+ function getItems(scope) {
146
+ const table = scope.querySelector("table");
147
+ if (table) return [...table.querySelectorAll("tbody tr")];
148
+ return [...scope.querySelectorAll("[data-filter-item]")];
149
+ }
150
+
151
+ function applyFilters(scope) {
152
+ const searchInput = scope.querySelector("[data-table-search]");
153
+ const term = (searchInput?.value || "").trim().toLowerCase();
154
+ const filters = [...scope.querySelectorAll("[data-table-filter]")]
155
+ .filter((el) => !(el.type === "radio" || el.type === "checkbox") || el.checked)
156
+ .map((el) => el.value)
157
+ .filter(Boolean)
158
+ .map((v) => v.toLowerCase());
159
+
160
+ let visible = 0;
161
+ getItems(scope).forEach((item) => {
162
+ const text = item.textContent.toLowerCase();
163
+ const matches = (!term || text.includes(term)) &&
164
+ filters.every((f) => text.includes(f));
165
+ item.hidden = !matches;
166
+ if (matches) visible++;
167
+ });
168
+
169
+ const empty = scope.querySelector("[data-filter-empty]");
170
+ if (empty) empty.hidden = visible > 0;
171
+ }
172
+
173
+ document.addEventListener("input", (event) => {
174
+ if (!event.target.matches("[data-table-search]")) return;
175
+ const scope = event.target.closest("[data-filter-scope]");
176
+ if (scope) applyFilters(scope);
177
+ });
178
+
179
+ document.addEventListener("change", (event) => {
180
+ if (!event.target.matches("[data-table-filter]")) return;
181
+ const scope = event.target.closest("[data-filter-scope]");
182
+ if (scope) applyFilters(scope);
183
+ });
184
+ })();
185
+
186
+ /* shared/js/theme-toggle.js */
187
+ /*
188
+ * Persists an explicit dark/light choice as `data-theme` on <html>,
189
+ * overriding `prefers-color-scheme` in either direction (see tokens'
190
+ * dark-mode block). A `[data-theme-toggle]` element flips it.
191
+ */
192
+ (() => {
193
+ const KEY = "ui-theme";
194
+ const root = document.documentElement;
195
+
196
+ try {
197
+ const saved = localStorage.getItem(KEY);
198
+ if (saved === "dark" || saved === "light") root.dataset.theme = saved;
199
+ } catch {
200
+ // localStorage unavailable (private mode, etc.) — falls back to
201
+ // prefers-color-scheme only, which is a fine default.
202
+ }
203
+
204
+ document.addEventListener("click", (event) => {
205
+ const trigger = event.target.closest("[data-theme-toggle]");
206
+ if (!trigger) return;
207
+
208
+ const systemDark = matchMedia("(prefers-color-scheme: dark)").matches;
209
+ const current = root.dataset.theme === "dark" ||
210
+ (root.dataset.theme !== "light" && systemDark)
211
+ ? "dark"
212
+ : "light";
213
+ const next = current === "dark" ? "light" : "dark";
214
+
215
+ root.dataset.theme = next;
216
+ try {
217
+ localStorage.setItem(KEY, next);
218
+ } catch {
219
+ // Same fallback as above — nothing to persist to.
220
+ }
221
+ });
222
+ })();
223
+
224
+ /* shared/js/toggle.js */
225
+ /*
226
+ * Generic show/hide: a trigger carrying `data-toggle="<selector>"` flips
227
+ * `hidden`, `.is-open` and `aria-expanded` on the element(s) `<selector>`
228
+ * matches. Not `data-target` — that name is rAPId's own swap-target
229
+ * attribute (see CLAUDE.md §2); this is a different, unrelated attribute
230
+ * so the two mechanisms never collide on the same element.
231
+ *
232
+ * `data-toggle-class` on the trigger switches to class-only mode (no
233
+ * `hidden`): for things that must stay in the layout when "closed", like
234
+ * an off-canvas sidebar that is a normal column on desktop.
235
+ */
236
+ (() => {
237
+ const classOnly = (trigger) => trigger.hasAttribute("data-toggle-class");
238
+
239
+ document.addEventListener("click", (event) => {
240
+ const trigger = event.target.closest?.("[data-toggle]");
241
+ if (!trigger) return;
242
+ const selector = trigger.getAttribute("data-toggle");
243
+ const target = selector && selector !== "#" && document.querySelector(selector);
244
+ if (!target) return;
245
+
246
+ const wasExpanded = trigger.getAttribute("aria-expanded") === "true";
247
+ trigger.setAttribute("aria-expanded", String(!wasExpanded));
248
+ if (!classOnly(trigger)) target.toggleAttribute("hidden", wasExpanded);
249
+ target.classList.toggle("is-open", !wasExpanded);
250
+ });
251
+
252
+ document.addEventListener("click", (event) => {
253
+ const dismisser = event.target.closest?.("[data-toggle-close]");
254
+ if (!dismisser) return;
255
+ const selector = dismisser.getAttribute("data-toggle-close");
256
+ const target = selector ? document.querySelector(selector) : dismisser.closest("[data-toggle-panel]");
257
+ if (!target) return;
258
+ close(target);
259
+ });
260
+
261
+ // Escape closes an open class-only panel (the mobile drawer) and
262
+ // returns focus to whatever opened it.
263
+ document.addEventListener("keydown", (event) => {
264
+ if (event.key !== "Escape" || event.defaultPrevented) return;
265
+ document.querySelectorAll("[data-toggle-class][aria-expanded='true']").forEach((trigger) => {
266
+ const target = document.querySelector(trigger.getAttribute("data-toggle") ?? "");
267
+ if (!target) return;
268
+ close(target);
269
+ trigger.focus();
270
+ event.preventDefault();
271
+ });
272
+ });
273
+
274
+ function close(target) {
275
+ target.classList.remove("is-open");
276
+ document.querySelectorAll(`[data-toggle][aria-expanded="true"]`).forEach((trigger) => {
277
+ const sel = trigger.getAttribute("data-toggle");
278
+ if (sel && document.querySelector(sel) === target) {
279
+ trigger.setAttribute("aria-expanded", "false");
280
+ if (!classOnly(trigger)) target.setAttribute("hidden", "");
281
+ }
282
+ });
283
+ }
284
+ })();
285
+
286
+ /* shared/js/view-switch.js */
287
+ /*
288
+ * View switch: a radio (typically inside a Segmented) carrying
289
+ * `data-view-target="<selector>"` writes its value to `data-view` on the
290
+ * target when checked. CSS keyed off `[data-view="list"]` etc. does the
291
+ * rest — no layout logic here, so a theme decides what each view means.
292
+ */
293
+ (() => {
294
+ document.addEventListener("change", (event) => {
295
+ const control = event.target.closest?.("[data-view-target]");
296
+ if (!control || (control.type === "radio" && !control.checked)) return;
297
+ const target = document.querySelector(control.getAttribute("data-view-target") ?? "");
298
+ if (target) target.setAttribute("data-view", control.value);
299
+ });
300
+ })();
301
+
302
+ /* components/chart/chart.js */
303
+ /* Chart: draws every [data-chart] with ApexCharts once the (deferred,
304
+ * pinned — see chart.ts) engine is present; a silent no-op without it.
305
+ * Colours come from the library's tokens: chart.css maps ApexCharts'
306
+ * `--apx-*` design tokens onto ours, the engine reads them on every
307
+ * render, and this script re-renders on a light/dark switch (the
308
+ * `data-theme` attribute or the OS preference) so charts follow the page.
309
+ * Idempotent per element (data-chart-initialized); re-runs after a
310
+ * rAPId swap; destroys the instance when its element leaves the DOM. */
311
+ (() => {
312
+ const SELECTOR = "[data-chart]";
313
+ const live = new Set();
314
+ const media = globalThis.matchMedia?.("(prefers-color-scheme: dark)");
315
+
316
+ function mode() {
317
+ const forced = document.documentElement.dataset.theme;
318
+ if (forced === "dark" || forced === "light") return forced;
319
+ return media?.matches ? "dark" : "light";
320
+ }
321
+
322
+ function themeOptions() {
323
+ const font = getComputedStyle(document.documentElement).getPropertyValue("--font-family-base").trim();
324
+ return {
325
+ theme: { mode: mode(), tokens: true },
326
+ chart: font ? { fontFamily: font, background: "transparent" } : { background: "transparent" },
327
+ };
328
+ }
329
+
330
+ function init(el) {
331
+ if (el.dataset.chartInitialized) return;
332
+ if (typeof globalThis.ApexCharts === "undefined") return;
333
+ const config = JSON.parse(el.dataset.chart || "{}");
334
+ const theme = themeOptions();
335
+ const options = {
336
+ ...config,
337
+ theme: { ...theme.theme, ...(config.theme || {}) },
338
+ chart: { ...theme.chart, ...(config.chart || {}) },
339
+ };
340
+ const chart = new globalThis.ApexCharts(el, options);
341
+ el.dataset.chartInitialized = "true";
342
+ el.chart = chart;
343
+ live.add(el);
344
+ chart.render();
345
+ }
346
+
347
+ function destroy(el) {
348
+ if (!el.chart) return;
349
+ el.chart.destroy();
350
+ delete el.chart;
351
+ delete el.dataset.chartInitialized;
352
+ live.delete(el);
353
+ }
354
+
355
+ function retheme() {
356
+ for (const el of live) {
357
+ if (!el.isConnected) {
358
+ destroy(el);
359
+ continue;
360
+ }
361
+ el.chart.refreshTokens?.();
362
+ el.chart.updateOptions(themeOptions(), false, true);
363
+ }
364
+ }
365
+
366
+ const initAll = () => document.querySelectorAll(SELECTOR).forEach(init);
367
+ initAll();
368
+ // The engine is a `defer`red script that runs after this bundle but
369
+ // before `load` — try again then. init is idempotent.
370
+ addEventListener("load", initAll);
371
+ document.addEventListener("rapid:swapped", initAll);
372
+
373
+ new MutationObserver((mutations) => {
374
+ for (const mutation of mutations) {
375
+ for (const node of mutation.addedNodes) {
376
+ if (node.nodeType !== 1) continue;
377
+ if (node.matches?.(SELECTOR)) init(node);
378
+ node.querySelectorAll?.(SELECTOR).forEach(init);
379
+ }
380
+ for (const node of mutation.removedNodes) {
381
+ if (node.nodeType !== 1) continue;
382
+ if (node.matches?.(SELECTOR)) destroy(node);
383
+ node.querySelectorAll?.(SELECTOR).forEach(destroy);
384
+ }
385
+ }
386
+ }).observe(document.documentElement, { childList: true, subtree: true });
387
+
388
+ new MutationObserver(retheme).observe(document.documentElement, {
389
+ attributes: true,
390
+ attributeFilter: ["data-theme"],
391
+ });
392
+ media?.addEventListener?.("change", retheme);
393
+ })();
394
+
395
+ /* components/collapsible/collapsible.js */
396
+ (() => {
397
+ document.addEventListener("click", (event) => {
398
+ const trigger = event.target.closest("[data-toggle]");
399
+ if (!trigger) return;
400
+ const group = trigger.closest("[data-accordion-group]");
401
+ if (!group) return;
402
+
403
+ group.querySelectorAll('[data-toggle][aria-expanded="true"]').forEach(
404
+ (other) => {
405
+ if (other === trigger) return;
406
+ const selector = other.getAttribute("data-toggle");
407
+ const panel = selector && document.querySelector(selector);
408
+ other.setAttribute("aria-expanded", "false");
409
+ panel?.setAttribute("hidden", "");
410
+ panel?.classList.remove("is-open");
411
+ },
412
+ );
413
+ });
414
+ })();
415
+
416
+ /* components/combobox/combobox.js */
417
+ /*
418
+ * Combobox: keyboard navigation over a server-rendered listbox, open on
419
+ * focus, pick on click/Enter, dismiss on Escape/outside click. Without
420
+ * this script it is a text input plus a visible listbox — still usable.
421
+ *
422
+ * Filtering: with `data-combobox-action` on the input AND rAPId's runtime
423
+ * present, each (debounced) keystroke does
424
+ * `window.rapid.swap(action?q=<text>, #<id>-list)` and the server's
425
+ * ComboboxList fragment replaces the list. Otherwise the rendered options
426
+ * are filtered client-side — non-matches and empty groups hidden, count
427
+ * updated. (rAPId's own runtime only reacts to click/submit, so this is
428
+ * the one place a component script triggers a swap programmatically.)
429
+ *
430
+ * Value: single-select writes the picked option's `data-value` into the
431
+ * hidden `[data-combobox-value]` input and its label into the visible
432
+ * one; typing clears the hidden value until a new pick. Multi-select adds
433
+ * a token (label + hidden input named after `data-combobox-name`);
434
+ * [data-combobox-remove] drops it again.
435
+ */
436
+ (() => {
437
+ const ACTIVE = "data-active";
438
+ const X_ICON =
439
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
440
+
441
+ const inputOf = (root) => root.querySelector("input[role='combobox']");
442
+ const options = (root) => Array.from(root.querySelectorAll('[role="option"]')).filter((o) => !o.hidden);
443
+
444
+ function activeIndex(root) {
445
+ return options(root).findIndex((o) => o.hasAttribute(ACTIVE));
446
+ }
447
+
448
+ function clearActive(root) {
449
+ root.querySelectorAll(`[${ACTIVE}]`).forEach((o) => o.removeAttribute(ACTIVE));
450
+ inputOf(root)?.removeAttribute("aria-activedescendant");
451
+ }
452
+
453
+ function setActive(root, index) {
454
+ const opts = options(root);
455
+ if (!opts.length) return;
456
+ const i = (index + opts.length) % opts.length;
457
+ clearActive(root);
458
+ opts[i].setAttribute(ACTIVE, "");
459
+ const input = inputOf(root);
460
+ if (input && opts[i].id) input.setAttribute("aria-activedescendant", opts[i].id);
461
+ const list = opts[i].closest(".combobox__list");
462
+ if (!list) return;
463
+ const top = opts[i].offsetTop;
464
+ if (top < list.scrollTop) list.scrollTop = top;
465
+ const bottom = top + opts[i].offsetHeight;
466
+ if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
467
+ }
468
+
469
+ const isOpen = (root) => root.classList.contains("combobox--open");
470
+
471
+ function open(root, on) {
472
+ const list = root.querySelector(".combobox__list");
473
+ const input = inputOf(root);
474
+ if (list) list.hidden = !on;
475
+ if (input) input.setAttribute("aria-expanded", on ? "true" : "false");
476
+ root.classList.toggle("combobox--open", !!on);
477
+ if (!on) clearActive(root);
478
+ }
479
+
480
+ /* Client-side filter — only when the server is not doing it. */
481
+ function filterLocal(root) {
482
+ const input = inputOf(root);
483
+ const list = root.querySelector(".combobox__list");
484
+ if (!input || !list) return;
485
+ const q = input.value.trim().toLowerCase();
486
+
487
+ let visible = 0;
488
+ root.querySelectorAll('[role="option"]').forEach((o) => {
489
+ const label = o.querySelector(".combobox__option-label")?.textContent ?? o.textContent;
490
+ const hit = !q || label.toLowerCase().includes(q);
491
+ o.hidden = !hit;
492
+ if (hit) visible++;
493
+ });
494
+ clearActive(root);
495
+
496
+ root.querySelectorAll(".combobox__group").forEach((group) => {
497
+ let el = group.nextElementSibling;
498
+ let any = false;
499
+ while (el && !el.classList.contains("combobox__group") && !el.classList.contains("combobox__hints")) {
500
+ if (el.matches('[role="option"]') && !el.hidden) {
501
+ any = true;
502
+ break;
503
+ }
504
+ el = el.nextElementSibling;
505
+ }
506
+ group.hidden = !any;
507
+ });
508
+
509
+ const count = root.querySelector(".combobox__count");
510
+ if (count) count.textContent = visible === 1 ? "1 match" : `${visible} matches`;
511
+
512
+ let empty = list.querySelector(".combobox__empty");
513
+ if (!empty) {
514
+ empty = document.createElement("div");
515
+ empty.className = "combobox__empty";
516
+ empty.textContent = "No matches.";
517
+ list.insertBefore(empty, list.querySelector(".combobox__hints"));
518
+ }
519
+ empty.hidden = visible > 0;
520
+ }
521
+
522
+ const timers = new WeakMap();
523
+ function filter(root) {
524
+ const input = inputOf(root);
525
+ const action = input?.getAttribute("data-combobox-action");
526
+ const target = input?.getAttribute("data-combobox-target");
527
+ if (action && target && globalThis.rapid?.swap) {
528
+ clearTimeout(timers.get(root));
529
+ timers.set(
530
+ root,
531
+ setTimeout(() => {
532
+ const url = `${action}${action.includes("?") ? "&" : "?"}q=${encodeURIComponent(input.value.trim())}`;
533
+ globalThis.rapid.swap(url, target).then(() => clearActive(root));
534
+ }, 150),
535
+ );
536
+ return;
537
+ }
538
+ filterLocal(root);
539
+ }
540
+
541
+ function pickSingle(root, opt) {
542
+ const input = inputOf(root);
543
+ const hidden = root.querySelector("[data-combobox-value]");
544
+ const label = (opt.querySelector(".combobox__option-label")?.textContent ?? opt.textContent).trim();
545
+ if (input) input.value = label;
546
+ if (hidden) hidden.value = opt.getAttribute("data-value") ?? label;
547
+ root.querySelectorAll('[role="option"]').forEach((o) => o.setAttribute("aria-selected", String(o === opt)));
548
+ open(root, false);
549
+ // Wrappers (the Select) mirror the pick into their own control.
550
+ root.dispatchEvent(
551
+ new CustomEvent("combobox:pick", { bubbles: true, detail: { value: hidden?.value ?? label, label } }),
552
+ );
553
+ }
554
+
555
+ function addToken(root, opt) {
556
+ const field = root.querySelector(".combobox__field");
557
+ const input = inputOf(root);
558
+ const value = opt.getAttribute("data-value") ?? "";
559
+ const label = (opt.querySelector(".combobox__option-label")?.textContent ?? opt.textContent).trim();
560
+ const name = root.getAttribute("data-combobox-name") ?? "";
561
+ if (!field || !input) return;
562
+ if (Array.from(field.querySelectorAll(".combobox__token input")).some((h) => h.value === value)) return;
563
+
564
+ const token = document.createElement("span");
565
+ token.className = "combobox__token";
566
+ token.append(label);
567
+ const remove = document.createElement("button");
568
+ remove.type = "button";
569
+ remove.className = "combobox__token-remove";
570
+ remove.setAttribute("aria-label", `Remove ${label}`);
571
+ remove.setAttribute("data-combobox-remove", value);
572
+ remove.innerHTML = X_ICON; // constant markup, no data
573
+ const hidden = document.createElement("input");
574
+ hidden.type = "hidden";
575
+ hidden.name = name;
576
+ hidden.value = value;
577
+ token.append(remove, hidden);
578
+ field.insertBefore(token, input);
579
+
580
+ opt.setAttribute("aria-selected", "true");
581
+ input.value = "";
582
+ filter(root);
583
+ input.focus();
584
+ }
585
+
586
+ function removeToken(root, value) {
587
+ root.querySelectorAll(".combobox__token").forEach((t) => {
588
+ if (t.querySelector("input")?.value === value) t.remove();
589
+ });
590
+ root.querySelectorAll(`[role="option"][data-value="${CSS.escape(value)}"]`)
591
+ .forEach((o) => o.setAttribute("aria-selected", "false"));
592
+ }
593
+
594
+ document.addEventListener("keydown", (event) => {
595
+ const root = event.target.closest?.("[data-combobox]");
596
+ if (!root) return;
597
+
598
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
599
+ event.preventDefault();
600
+ open(root, true);
601
+ setActive(root, activeIndex(root) + (event.key === "ArrowDown" ? 1 : -1));
602
+ } else if (event.key === "Enter") {
603
+ const i = activeIndex(root);
604
+ const opts = options(root);
605
+ if (i >= 0 && opts[i]) {
606
+ // Only swallow Enter when it picks something; otherwise a form
607
+ // around the combobox still submits normally.
608
+ event.preventDefault();
609
+ opts[i].click();
610
+ }
611
+ } else if (event.key === "Escape") {
612
+ // Only claim Escape while the list is open — otherwise it must
613
+ // reach the <dialog>/popover this combobox may be sitting in.
614
+ if (!isOpen(root)) return;
615
+ event.preventDefault();
616
+ event.stopPropagation();
617
+ open(root, false);
618
+ } else if (
619
+ event.key === "Backspace" && root.classList.contains("combobox--multi") &&
620
+ event.target.matches("input[role='combobox']") && !event.target.value
621
+ ) {
622
+ const last = root.querySelector(".combobox__field .combobox__token:last-of-type input");
623
+ if (last) removeToken(root, last.value);
624
+ }
625
+ });
626
+
627
+ document.addEventListener("input", (event) => {
628
+ const root = event.target.closest?.("[data-combobox]");
629
+ if (!root || !event.target.matches("input[role='combobox']")) return;
630
+ const hidden = root.querySelector("[data-combobox-value]");
631
+ if (hidden) hidden.value = ""; // typing un-picks; a pick sets it again
632
+ filter(root);
633
+ open(root, true);
634
+ });
635
+
636
+ document.addEventListener("focusin", (event) => {
637
+ const root = event.target.closest?.("[data-combobox]");
638
+ if (root && event.target.matches("input[role='combobox']")) open(root, true);
639
+ });
640
+
641
+ document.addEventListener("click", (event) => {
642
+ // A click on the field itself (the caret, the padding) — not on the
643
+ // input or a token button — focuses the input and toggles the list.
644
+ const field = event.target.closest?.("[data-combobox] .combobox__field");
645
+ if (field && !event.target.closest("input, button")) {
646
+ const root = field.closest("[data-combobox]");
647
+ const input = inputOf(root);
648
+ if (input && !input.disabled) {
649
+ if (isOpen(root)) open(root, false);
650
+ else {
651
+ input.focus();
652
+ open(root, true);
653
+ }
654
+ }
655
+ return;
656
+ }
657
+
658
+ const opt = event.target.closest?.("[data-combobox] [role='option']");
659
+ if (opt) {
660
+ if (opt.getAttribute("aria-disabled") === "true") return;
661
+ const root = opt.closest("[data-combobox]");
662
+ if (root.classList.contains("combobox--multi")) addToken(root, opt);
663
+ else pickSingle(root, opt);
664
+ return;
665
+ }
666
+
667
+ const remove = event.target.closest?.("[data-combobox-remove]");
668
+ if (remove) {
669
+ const root = remove.closest("[data-combobox]");
670
+ if (root) removeToken(root, remove.getAttribute("data-combobox-remove") ?? "");
671
+ return;
672
+ }
673
+
674
+ document.querySelectorAll("[data-combobox].combobox--open").forEach((root) => {
675
+ if (event.target.closest?.("[data-combobox]") !== root) open(root, false);
676
+ });
677
+ });
678
+ })();
679
+
680
+ /* components/command/command.js */
681
+ /*
682
+ * Command palette: arrow-key navigation, Enter to run, Escape clears the
683
+ * query then closes, Cmd/Ctrl+K or any [data-command-open] opens the
684
+ * (non-inline) palette, overlay click closes it. Focus returns to
685
+ * whatever opened it, and Tab is kept inside the floating sheet.
686
+ *
687
+ * Filtering: with `data-command-action` on the input and rAPId's runtime
688
+ * present, each (debounced) keystroke swaps the server's CommandList
689
+ * fragment into the list. Otherwise the rendered items are filtered
690
+ * client-side — non-matches and empty groups hidden, count updated, the
691
+ * first hit made active.
692
+ */
693
+ (() => {
694
+ const ACTIVE = "data-active";
695
+ const openers = new WeakMap();
696
+
697
+ const inputOf = (root) => root.querySelector(".command__input");
698
+ const options = (root) => Array.from(root.querySelectorAll('[role="option"]')).filter((o) => !o.hidden);
699
+
700
+ function activeIndex(root) {
701
+ return options(root).findIndex((o) => o.hasAttribute(ACTIVE));
702
+ }
703
+
704
+ function clearActive(root) {
705
+ root.querySelectorAll(`[${ACTIVE}]`).forEach((o) => o.removeAttribute(ACTIVE));
706
+ inputOf(root)?.removeAttribute("aria-activedescendant");
707
+ }
708
+
709
+ function setActive(root, index) {
710
+ const opts = options(root);
711
+ if (!opts.length) return;
712
+ const i = (index + opts.length) % opts.length;
713
+ clearActive(root);
714
+ opts[i].setAttribute(ACTIVE, "");
715
+ const input = inputOf(root);
716
+ if (input && opts[i].id) input.setAttribute("aria-activedescendant", opts[i].id);
717
+ const list = opts[i].closest(".command__list");
718
+ if (!list) return;
719
+ const top = opts[i].offsetTop;
720
+ if (top < list.scrollTop) list.scrollTop = top;
721
+ const bottom = top + opts[i].offsetHeight;
722
+ if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
723
+ }
724
+
725
+ function filterLocal(root) {
726
+ const input = inputOf(root);
727
+ const list = root.querySelector(".command__list");
728
+ if (!input || !list) return;
729
+ const q = input.value.trim().toLowerCase();
730
+
731
+ let visible = 0;
732
+ root.querySelectorAll('[role="option"]').forEach((o) => {
733
+ const label = o.querySelector(".command__item-label")?.textContent ?? o.textContent;
734
+ const hit = !q || label.toLowerCase().includes(q);
735
+ o.hidden = !hit;
736
+ if (hit) visible++;
737
+ });
738
+ clearActive(root);
739
+
740
+ root.querySelectorAll(".command__group").forEach((group) => {
741
+ let el = group.nextElementSibling;
742
+ let any = false;
743
+ while (el && !el.classList.contains("command__group")) {
744
+ if (el.matches('[role="option"]') && !el.hidden) {
745
+ any = true;
746
+ break;
747
+ }
748
+ el = el.nextElementSibling;
749
+ }
750
+ group.hidden = !any;
751
+ });
752
+
753
+ const count = root.querySelector(".command__count");
754
+ if (count) count.textContent = visible === 1 ? "1 result" : `${visible} results`;
755
+
756
+ let empty = list.querySelector(".command__empty");
757
+ if (!empty) {
758
+ empty = document.createElement("div");
759
+ empty.className = "command__empty";
760
+ empty.textContent = "Nothing matches that.";
761
+ list.append(empty);
762
+ }
763
+ empty.hidden = visible > 0;
764
+ if (visible) setActive(root, 0);
765
+ }
766
+
767
+ const timers = new WeakMap();
768
+ function filter(root) {
769
+ const input = inputOf(root);
770
+ const action = input?.getAttribute("data-command-action");
771
+ const target = input?.getAttribute("data-command-target");
772
+ if (action && target && globalThis.rapid?.swap) {
773
+ clearTimeout(timers.get(root));
774
+ timers.set(
775
+ root,
776
+ setTimeout(() => {
777
+ const url = `${action}${action.includes("?") ? "&" : "?"}q=${encodeURIComponent(input.value.trim())}`;
778
+ globalThis.rapid.swap(url, target).then(() => {
779
+ clearActive(root);
780
+ if (options(root).length) setActive(root, 0);
781
+ });
782
+ }, 150),
783
+ );
784
+ return;
785
+ }
786
+ filterLocal(root);
787
+ }
788
+
789
+ const isFloating = (root) => !root.classList.contains("command--inline");
790
+
791
+ function close(root) {
792
+ if (!isFloating(root)) return;
793
+ root.hidden = true;
794
+ const opener = openers.get(root);
795
+ openers.delete(root);
796
+ if (opener?.isConnected) opener.focus();
797
+ }
798
+
799
+ /* Escape / the esc affordance: clear a query first, then close. */
800
+ function dismiss(root) {
801
+ const input = inputOf(root);
802
+ if (input && input.value) {
803
+ input.value = "";
804
+ input.dispatchEvent(new Event("input", { bubbles: true }));
805
+ } else {
806
+ close(root);
807
+ }
808
+ }
809
+
810
+ function open(palette, opener) {
811
+ openers.set(palette, opener ?? document.activeElement);
812
+ palette.hidden = false;
813
+ inputOf(palette)?.focus();
814
+ }
815
+
816
+ document.addEventListener("keydown", (event) => {
817
+ if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
818
+ const palette = document.querySelector("[data-command]:not(.command--inline)");
819
+ if (!palette) return;
820
+ event.preventDefault();
821
+ open(palette);
822
+ return;
823
+ }
824
+
825
+ const root = event.target.closest?.("[data-command]");
826
+ if (!root) return;
827
+
828
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
829
+ event.preventDefault();
830
+ setActive(root, activeIndex(root) + (event.key === "ArrowDown" ? 1 : -1));
831
+ } else if (event.key === "Enter") {
832
+ const i = activeIndex(root);
833
+ const opts = options(root);
834
+ if (i >= 0 && opts[i]) {
835
+ event.preventDefault();
836
+ opts[i].click();
837
+ }
838
+ } else if (event.key === "Escape") {
839
+ if (!isFloating(root) && !inputOf(root)?.value) return; // nothing to do; let it bubble
840
+ event.preventDefault();
841
+ event.stopPropagation();
842
+ dismiss(root);
843
+ } else if (event.key === "Tab" && isFloating(root)) {
844
+ // Keep focus inside the sheet (it is aria-modal).
845
+ const focusables = Array.from(
846
+ root.querySelectorAll("input, button, a[href], [tabindex]:not([tabindex='-1'])"),
847
+ ).filter((el) => !el.hidden && el.offsetParent !== null);
848
+ if (!focusables.length) return;
849
+ const first = focusables[0];
850
+ const last = focusables[focusables.length - 1];
851
+ if (event.shiftKey && document.activeElement === first) {
852
+ event.preventDefault();
853
+ last.focus();
854
+ } else if (!event.shiftKey && document.activeElement === last) {
855
+ event.preventDefault();
856
+ first.focus();
857
+ }
858
+ }
859
+ });
860
+
861
+ document.addEventListener("input", (event) => {
862
+ const root = event.target.closest?.("[data-command]");
863
+ if (root && event.target.matches(".command__input")) filter(root);
864
+ });
865
+
866
+ document.addEventListener("click", (event) => {
867
+ /* [data-command-open="#id"] (or bare: the page's floating palette) */
868
+ const opener = event.target.closest?.("[data-command-open]");
869
+ if (opener) {
870
+ const sel = opener.getAttribute("data-command-open");
871
+ const palette = sel
872
+ ? document.querySelector(sel)
873
+ : document.querySelector("[data-command]:not(.command--inline)");
874
+ if (palette) {
875
+ event.preventDefault();
876
+ open(palette, opener);
877
+ }
878
+ return;
879
+ }
880
+
881
+ const btn = event.target.closest?.("[data-command-dismiss]");
882
+ if (btn) {
883
+ const root = btn.closest("[data-command]");
884
+ if (root) dismiss(root);
885
+ return;
886
+ }
887
+
888
+ /* Running an item closes a floating palette. */
889
+ const item = event.target.closest?.("[data-command] [role='option']");
890
+ if (item) {
891
+ const root = item.closest("[data-command]");
892
+ if (root && isFloating(root)) close(root);
893
+ return;
894
+ }
895
+
896
+ /* Overlay click (outside the sheet) closes a floating palette. */
897
+ const overlay = event.target.closest?.("[data-command]:not(.command--inline)");
898
+ if (overlay && !event.target.closest(".command__sheet")) close(overlay);
899
+ });
900
+ })();
901
+
902
+ /* components/data-table/data-table.js */
903
+ /*
904
+ * Data table: row selection is a pure view concern until submitted, so it
905
+ * lives here rather than round-tripping. The checkboxes are the source of
906
+ * truth the server reads back on submit; the bulk bar just mirrors them.
907
+ * Runs once on load and after every rAPId swap.
908
+ */
909
+ (() => {
910
+ function refresh(table) {
911
+ const bar = table.querySelector("[data-bulk-bar]");
912
+ const rows = table.querySelectorAll("[data-select-row]");
913
+ const checked = table.querySelectorAll("[data-select-row]:checked").length;
914
+
915
+ if (bar) {
916
+ bar.hidden = checked === 0;
917
+ const count = bar.querySelector("[data-bulk-count]");
918
+ if (count) count.textContent = checked === 1 ? "1 row selected" : `${checked} rows selected`;
919
+ }
920
+
921
+ const all = table.querySelector("[data-select-all]");
922
+ if (all) {
923
+ all.checked = checked > 0 && checked === rows.length;
924
+ all.indeterminate = checked > 0 && checked < rows.length;
925
+ }
926
+ }
927
+
928
+ function setRow(box, on) {
929
+ box.checked = on;
930
+ box.closest("tr")?.classList.toggle("data-table__row--selected", on);
931
+ }
932
+
933
+ document.addEventListener("change", (event) => {
934
+ const table = event.target.closest?.(".data-table");
935
+ if (!table) return;
936
+ if (event.target.matches("[data-select-all]")) {
937
+ table.querySelectorAll("[data-select-row]").forEach((box) => setRow(box, event.target.checked));
938
+ } else if (event.target.matches("[data-select-row]")) {
939
+ setRow(event.target, event.target.checked);
940
+ } else {
941
+ return;
942
+ }
943
+ refresh(table);
944
+ });
945
+
946
+ document.addEventListener("click", (event) => {
947
+ const clear = event.target.closest?.("[data-bulk-clear]");
948
+ if (!clear) return;
949
+ const table = clear.closest(".data-table");
950
+ if (!table) return;
951
+ table.querySelectorAll("[data-select-row]").forEach((box) => setRow(box, false));
952
+ refresh(table);
953
+ });
954
+
955
+ function initAll() {
956
+ document.querySelectorAll(".data-table").forEach(refresh);
957
+ }
958
+
959
+ if (document.readyState === "loading") {
960
+ document.addEventListener("DOMContentLoaded", initAll);
961
+ } else {
962
+ initAll();
963
+ }
964
+ document.addEventListener("rapid:swapped", initAll);
965
+ })();
966
+
967
+ /* components/datepicker/datepicker.js */
968
+ /*
969
+ * Date picker.
970
+ *
971
+ * Always: toggle the panel from its trigger, close on Escape (scoped to
972
+ * this picker) or outside click, focus back to the trigger on close.
973
+ *
974
+ * Server mode (`data-datepicker-server`): month nav, day picks and
975
+ * presets are GET links carrying data-action/data-target — rAPId swaps
976
+ * the re-rendered DatePicker in; nothing here computes dates.
977
+ *
978
+ * Client mode (no build*Href): the calendar runs here — month nav
979
+ * re-renders the day grid, a pick writes the hidden input(s) and the
980
+ * trigger label, and in range mode the first pick is the start and the
981
+ * second the end. Same classes and ARIA as the server-rendered panel.
982
+ */
983
+ (() => {
984
+ const MONTHS = [
985
+ "January",
986
+ "February",
987
+ "March",
988
+ "April",
989
+ "May",
990
+ "June",
991
+ "July",
992
+ "August",
993
+ "September",
994
+ "October",
995
+ "November",
996
+ "December",
997
+ ];
998
+ const SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
999
+ const pad = (n) => String(n).padStart(2, "0");
1000
+ const iso = (y, m, d) => `${y}-${pad(m + 1)}-${pad(d)}`;
1001
+ const fmt = (v) => {
1002
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v ?? "");
1003
+ return m ? `${Number(m[3])} ${SHORT[Number(m[2]) - 1]} ${m[1]}` : "";
1004
+ };
1005
+ const todayIso = () => {
1006
+ const t = new Date();
1007
+ return iso(t.getFullYear(), t.getMonth(), t.getDate());
1008
+ };
1009
+
1010
+ function setOpen(dp, on, restoreFocus) {
1011
+ const panel = dp.querySelector(".datepicker__panel");
1012
+ const trigger = dp.querySelector("[data-datepicker-trigger]");
1013
+ if (panel) panel.hidden = !on;
1014
+ dp.classList.toggle("datepicker--open", !!on);
1015
+ if (trigger) {
1016
+ trigger.setAttribute("aria-expanded", on ? "true" : "false");
1017
+ if (!on && restoreFocus) trigger.focus();
1018
+ }
1019
+ }
1020
+
1021
+ /* ------------------------------------------------------ client mode */
1022
+ function state(dp) {
1023
+ return {
1024
+ range: dp.hasAttribute("data-datepicker-range"),
1025
+ min: dp.getAttribute("data-datepicker-min") || "",
1026
+ max: dp.getAttribute("data-datepicker-max") || "",
1027
+ today: dp.getAttribute("data-datepicker-today") || todayIso(),
1028
+ start: dp.querySelector("[data-datepicker-start]")?.value || "",
1029
+ end: dp.querySelector("[data-datepicker-end]")?.value || "",
1030
+ };
1031
+ }
1032
+
1033
+ function renderDays(dp, year, month) {
1034
+ const s = state(dp);
1035
+ const days = dp.querySelector(".datepicker__days");
1036
+ const heading = dp.querySelector(".datepicker__month");
1037
+ if (!days) return;
1038
+ days.replaceChildren();
1039
+ if (heading) heading.textContent = `${MONTHS[month]} ${year}`;
1040
+ dp.setAttribute("data-datepicker-view", `${year}-${month}`);
1041
+
1042
+ const len = new Date(year, month + 1, 0).getDate();
1043
+ const lead = (new Date(year, month, 1).getDay() + 6) % 7;
1044
+ const prevLen = new Date(year, month, 0).getDate();
1045
+ const cells = Math.ceil((lead + len) / 7) * 7;
1046
+
1047
+ for (let i = 0; i < cells; i++) {
1048
+ const offset = i - lead + 1;
1049
+ let y = year, m = month, d = offset;
1050
+ if (offset < 1) {
1051
+ m = month - 1;
1052
+ d = prevLen + offset;
1053
+ if (m < 0) {
1054
+ m = 11;
1055
+ y--;
1056
+ }
1057
+ } else if (offset > len) {
1058
+ m = month + 1;
1059
+ d = offset - len;
1060
+ if (m > 11) {
1061
+ m = 0;
1062
+ y++;
1063
+ }
1064
+ }
1065
+ const key = iso(y, m, d);
1066
+ const btn = document.createElement("button");
1067
+ btn.type = "button";
1068
+ btn.className = "datepicker__day";
1069
+ if (offset < 1 || offset > len) btn.classList.add("datepicker__day--outside");
1070
+ if (key === s.today) {
1071
+ btn.classList.add("datepicker__day--today");
1072
+ btn.setAttribute("aria-current", "date");
1073
+ }
1074
+ if (s.start && s.end && key > s.start && key < s.end) btn.classList.add("datepicker__day--in-range");
1075
+ if (key === s.start) btn.classList.add("datepicker__day--start");
1076
+ if (key === s.end) btn.classList.add("datepicker__day--end");
1077
+ if (key === s.start || key === s.end) btn.setAttribute("aria-pressed", "true");
1078
+ if ((s.min && key < s.min) || (s.max && key > s.max)) btn.disabled = true;
1079
+ btn.setAttribute("data-day", key);
1080
+ btn.setAttribute("aria-label", `${d} ${MONTHS[m]} ${y}`);
1081
+ btn.textContent = String(d);
1082
+ days.append(btn);
1083
+ }
1084
+ }
1085
+
1086
+ function currentView(dp) {
1087
+ const v = dp.getAttribute("data-datepicker-view");
1088
+ if (v) {
1089
+ const [y, m] = v.split("-").map(Number);
1090
+ return [y, m];
1091
+ }
1092
+ const s = state(dp);
1093
+ const anchor = s.start || s.today;
1094
+ return [Number(anchor.slice(0, 4)), Number(anchor.slice(5, 7)) - 1];
1095
+ }
1096
+
1097
+ function pick(dp, key) {
1098
+ const s = state(dp);
1099
+ const startInput = dp.querySelector("[data-datepicker-start]");
1100
+ const endInput = dp.querySelector("[data-datepicker-end]");
1101
+ const label = dp.querySelector("[data-datepicker-label]");
1102
+ let start = key, end = "";
1103
+ if (s.range) {
1104
+ if (s.start && !s.end && key >= s.start) {
1105
+ start = s.start;
1106
+ end = key;
1107
+ }
1108
+ }
1109
+ if (startInput) startInput.value = start;
1110
+ if (endInput) endInput.value = end;
1111
+ if (label) {
1112
+ label.textContent = s.range ? `${fmt(start) || "Start date"} – ${fmt(end) || "End date"}` : fmt(start);
1113
+ }
1114
+ startInput?.dispatchEvent(new Event("change", { bubbles: true }));
1115
+ const [y, m] = currentView(dp);
1116
+ renderDays(dp, y, m);
1117
+ if (!s.range || end) setOpen(dp, false, true);
1118
+ }
1119
+
1120
+ /* ----------------------------------------------------------- events */
1121
+ document.addEventListener("click", (event) => {
1122
+ const trigger = event.target.closest?.("[data-datepicker-trigger]");
1123
+ if (trigger) {
1124
+ const dp = trigger.closest("[data-datepicker]");
1125
+ const panel = dp?.querySelector(".datepicker__panel");
1126
+ if (panel) setOpen(dp, panel.hidden);
1127
+ return;
1128
+ }
1129
+
1130
+ const dp = event.target.closest?.("[data-datepicker]");
1131
+ if (dp && !dp.hasAttribute("data-datepicker-server")) {
1132
+ const day = event.target.closest("button[data-day]");
1133
+ if (day && !day.disabled) {
1134
+ pick(dp, day.getAttribute("data-day"));
1135
+ return;
1136
+ }
1137
+ const nav = event.target.closest("button[data-month]");
1138
+ if (nav) {
1139
+ const [y, m] = nav.getAttribute("data-month").split("-").map(Number);
1140
+ renderDays(dp, y, m);
1141
+ // Every nav button's target moves with the view.
1142
+ const targets = {
1143
+ "prev-year": [y - 1, m],
1144
+ prev: m === 0 ? [y - 1, 11] : [y, m - 1],
1145
+ next: m === 11 ? [y + 1, 0] : [y, m + 1],
1146
+ "next-year": [y + 1, m],
1147
+ };
1148
+ dp.querySelectorAll("button[data-month][data-nav]").forEach((btn) => {
1149
+ const t = targets[btn.getAttribute("data-nav")];
1150
+ if (t) btn.setAttribute("data-month", `${t[0]}-${t[1]}`);
1151
+ });
1152
+ return;
1153
+ }
1154
+ }
1155
+
1156
+ // Outside click closes floating panels only — an inline picker is
1157
+ // page content (a docs or settings page that always shows the
1158
+ // calendar); its trigger still toggles it.
1159
+ document.querySelectorAll("[data-datepicker]:not(.datepicker--inline) .datepicker__panel:not([hidden])").forEach(
1160
+ (panel) => {
1161
+ const owner = panel.closest("[data-datepicker]");
1162
+ if (owner !== dp) setOpen(owner, false);
1163
+ },
1164
+ );
1165
+ });
1166
+
1167
+ document.addEventListener("keydown", (event) => {
1168
+ if (event.key !== "Escape" || event.defaultPrevented) return;
1169
+ const dp = event.target.closest?.("[data-datepicker]");
1170
+ const panel = dp?.querySelector(".datepicker__panel");
1171
+ if (!panel || panel.hidden || dp.classList.contains("datepicker--inline")) return;
1172
+ event.preventDefault();
1173
+ setOpen(dp, false, true);
1174
+ });
1175
+ })();
1176
+
1177
+ /* components/dropdown/dropdown.js */
1178
+ /*
1179
+ * Dropdown: [data-toggle] (shared/js/toggle.js) opens the panel; this
1180
+ * closes it on outside click, on choosing an item (a link, button or
1181
+ * rAPId action inside the panel), and on Escape — scoped to the dropdown
1182
+ * that has focus, falling back to all open ones. Focus returns to the
1183
+ * trigger on Escape.
1184
+ *
1185
+ * Inside a scroll container (a table body, a card with overflow) an
1186
+ * absolutely-positioned panel gets clipped, so the panel is switched to
1187
+ * fixed positioning at the trigger's viewport rect (set through the
1188
+ * CSSOM, which strict CSP allows — unlike a style *attribute*). Any
1189
+ * scroll or resize closes it rather than trying to track the trigger.
1190
+ */
1191
+ (() => {
1192
+ const openPanels = () => document.querySelectorAll(".dropdown__panel.is-open");
1193
+ const triggerFor = (panel) => document.querySelector(`[data-toggle="#${CSS.escape(panel.id)}"]`);
1194
+ const CLIPPERS = ".data-table__scroll, [data-dropdown-clip]";
1195
+
1196
+ function place(panel, trigger) {
1197
+ if (!trigger?.closest(CLIPPERS)) return;
1198
+ const r = trigger.getBoundingClientRect();
1199
+ panel.classList.add("dropdown__panel--fixed");
1200
+ const width = panel.offsetWidth;
1201
+ const alignEnd = panel.classList.contains("dropdown__panel--end");
1202
+ let left = alignEnd ? r.right - width : r.left;
1203
+ left = Math.max(8, Math.min(left, innerWidth - width - 8));
1204
+ let top = r.bottom + 4;
1205
+ if (top + panel.offsetHeight > innerHeight - 8) top = Math.max(8, r.top - panel.offsetHeight - 4);
1206
+ panel.style.left = `${left}px`;
1207
+ panel.style.top = `${top}px`;
1208
+ }
1209
+
1210
+ function close(panel, trigger) {
1211
+ panel.setAttribute("hidden", "");
1212
+ panel.classList.remove("is-open", "dropdown__panel--fixed");
1213
+ panel.style.removeProperty("left");
1214
+ panel.style.removeProperty("top");
1215
+ trigger?.setAttribute("aria-expanded", "false");
1216
+ }
1217
+
1218
+ document.addEventListener("click", (event) => {
1219
+ openPanels().forEach((panel) => {
1220
+ const trigger = triggerFor(panel);
1221
+ if (trigger?.contains(event.target)) {
1222
+ // toggle.js just opened it (its listener runs first) — place it.
1223
+ if (!panel.classList.contains("dropdown__panel--fixed")) place(panel, trigger);
1224
+ return;
1225
+ }
1226
+ if (panel.contains(event.target)) {
1227
+ // A choice was made — close, unless the click was on something
1228
+ // that keeps the panel useful (a nested toggle, a form field).
1229
+ const item = event.target.closest("a[href], button, [data-action]");
1230
+ if (!item || item.hasAttribute("data-toggle")) return;
1231
+ }
1232
+ close(panel, trigger);
1233
+ });
1234
+ });
1235
+
1236
+ document.addEventListener("keydown", (event) => {
1237
+ if (event.key !== "Escape" || event.defaultPrevented) return;
1238
+ const own = event.target.closest?.(".dropdown");
1239
+ const panels = own ? own.querySelectorAll(".dropdown__panel.is-open") : openPanels();
1240
+ let closed = false;
1241
+ panels.forEach((panel) => {
1242
+ const trigger = triggerFor(panel);
1243
+ close(panel, trigger);
1244
+ trigger?.focus();
1245
+ closed = true;
1246
+ });
1247
+ if (closed) event.preventDefault();
1248
+ });
1249
+
1250
+ const closeFixed = () => {
1251
+ document.querySelectorAll(".dropdown__panel--fixed.is-open").forEach((panel) => close(panel, triggerFor(panel)));
1252
+ };
1253
+ addEventListener("scroll", closeFixed, { capture: true, passive: true });
1254
+ addEventListener("resize", closeFixed, { passive: true });
1255
+ })();
1256
+
1257
+ /* components/dropzone/dropzone.js */
1258
+ /*
1259
+ * Dropzone: drag-over highlight and client-side removal of a pending row
1260
+ * ([data-dropzone-remove]). The actual upload is the form's job — the
1261
+ * real <input type="file"> covers the area, so click and keyboard open
1262
+ * the picker with no script at all.
1263
+ */
1264
+ (() => {
1265
+ for (const type of ["dragenter", "dragover"]) {
1266
+ document.addEventListener(type, (event) => {
1267
+ const area = event.target.closest?.(".dropzone__area");
1268
+ if (!area) return;
1269
+ event.preventDefault();
1270
+ area.classList.add("dropzone__area--over");
1271
+ });
1272
+ }
1273
+
1274
+ for (const type of ["dragleave", "drop"]) {
1275
+ document.addEventListener(type, (event) => {
1276
+ event.target.closest?.(".dropzone__area")?.classList.remove("dropzone__area--over");
1277
+ });
1278
+ }
1279
+
1280
+ document.addEventListener("click", (event) => {
1281
+ const remove = event.target.closest?.("[data-dropzone-remove]");
1282
+ if (remove) remove.closest(".dropzone__file")?.remove();
1283
+ });
1284
+ })();
1285
+
1286
+ /* components/editor/editor.js */
1287
+ /* Editor: markdown mode toggles syntax around/before the textarea's
1288
+ * selection and swaps a server-rendered preview in through the runtime;
1289
+ * html mode drives a contenteditable surface with execCommand (every
1290
+ * command toggles, block formats included) and mirrors its HTML into the
1291
+ * textarea that submits. Toolbar buttons reflect the selection's state.
1292
+ * Delegated on document, keyed off data-editor-*; initAll re-runs after
1293
+ * a swap. */
1294
+ (() => {
1295
+ const WRAP = { bold: ["**", "**"], italic: ["_", "_"], strike: ["~~", "~~"], code: ["`", "`"] };
1296
+ const PREFIX = { heading: "## ", quote: "> ", ul: "- ", ol: "1. " };
1297
+ const BLOCK = { heading: "h2", quote: "blockquote", code: "pre" };
1298
+ const INLINE = { bold: "bold", italic: "italic", underline: "underline", strike: "strikeThrough" };
1299
+ const LIST = { ul: "insertUnorderedList", ol: "insertOrderedList" };
1300
+
1301
+ const sourceOf = (root) => root.querySelector(".editor__source");
1302
+ const surfaceOf = (root) => root.querySelector("[data-editor-surface]");
1303
+ const isHtml = (root) => root.getAttribute("data-editor-mode") === "html";
1304
+
1305
+ /* ------------------------------------------------------- markdown */
1306
+ function replace(ta, start, end, text, selStart, selEnd) {
1307
+ ta.value = ta.value.slice(0, start) + text + ta.value.slice(end);
1308
+ ta.setSelectionRange(start + selStart, start + selEnd);
1309
+ ta.focus();
1310
+ ta.dispatchEvent(new Event("input", { bubbles: true }));
1311
+ }
1312
+
1313
+ function lineRange(ta) {
1314
+ const start = ta.value.lastIndexOf("\n", ta.selectionStart - 1) + 1;
1315
+ const nl = ta.value.indexOf("\n", Math.max(ta.selectionEnd, start));
1316
+ const end = ta.selectionEnd > ta.selectionStart && ta.value[ta.selectionEnd - 1] === "\n"
1317
+ ? ta.selectionEnd - 1
1318
+ : nl === -1
1319
+ ? ta.value.length
1320
+ : nl;
1321
+ return [start, end];
1322
+ }
1323
+
1324
+ const STRIP_LINE = /^(#{1,6} |> |- |\* |\d+\. )+/;
1325
+
1326
+ function markdown(ta, cmd) {
1327
+ const start = ta.selectionStart;
1328
+ const end = ta.selectionEnd;
1329
+ const sel = ta.value.slice(start, end);
1330
+
1331
+ if (WRAP[cmd]) {
1332
+ const [a, z] = WRAP[cmd];
1333
+ const multi = cmd === "code" && sel.includes("\n");
1334
+ const open = multi ? "```\n" : a;
1335
+ const close = multi ? "\n```" : z;
1336
+ // Toggle: unwrap when the selection (or its surroundings) already carries the syntax.
1337
+ if (sel.startsWith(open) && sel.endsWith(close) && sel.length >= open.length + close.length) {
1338
+ const inner = sel.slice(open.length, sel.length - close.length);
1339
+ replace(ta, start, end, inner, 0, inner.length);
1340
+ return;
1341
+ }
1342
+ if (ta.value.slice(start - open.length, start) === open && ta.value.slice(end, end + close.length) === close) {
1343
+ replace(ta, start - open.length, end + close.length, sel, 0, sel.length);
1344
+ return;
1345
+ }
1346
+ const body = sel || cmd;
1347
+ replace(ta, start, end, open + body + close, open.length, open.length + body.length);
1348
+ return;
1349
+ }
1350
+ if (cmd === "link" || cmd === "image") {
1351
+ const text = sel || (cmd === "image" ? "alt text" : "text");
1352
+ const out = `${cmd === "image" ? "!" : ""}[${text}](url)`;
1353
+ replace(ta, start, end, out, out.length - 4, out.length - 1);
1354
+ return;
1355
+ }
1356
+ if (cmd === "hr") {
1357
+ const before = start === 0 || ta.value[start - 1] === "\n" ? "" : "\n";
1358
+ const out = `${before}\n---\n\n`;
1359
+ replace(ta, start, end, out, out.length, out.length);
1360
+ return;
1361
+ }
1362
+ const [ls, le] = lineRange(ta);
1363
+ const lines = ta.value.slice(ls, le).split("\n");
1364
+ if (cmd === "clear") {
1365
+ const out = lines.map((l) => l.replace(STRIP_LINE, "")).join("\n")
1366
+ .replace(/!\[(.*?)\]\(\S+?\)/g, "$1")
1367
+ .replace(/\[(.+?)\]\(\S+?\)/g, "$1")
1368
+ .replace(/(\*\*|__|~~|`)(.+?)\1/g, "$2")
1369
+ .replace(/(^|[^\w])_(.+?)_(?=[^\w]|$)/g, "$1$2");
1370
+ replace(ta, ls, le, out, 0, out.length);
1371
+ return;
1372
+ }
1373
+ const prefix = PREFIX[cmd];
1374
+ if (!prefix) return;
1375
+ const has = (l) => cmd === "ol" ? /^\d+\. /.test(l) : l.startsWith(prefix);
1376
+ const allOn = lines.every((l) => l.trim() === "" || has(l));
1377
+ const out = lines.map((l, n) => {
1378
+ const bare = l.replace(STRIP_LINE, "");
1379
+ if (allOn) return bare; // toggle off
1380
+ if (l.trim() === "") return l;
1381
+ return (cmd === "ol" ? `${n + 1}. ` : prefix) + bare;
1382
+ }).join("\n");
1383
+ replace(ta, ls, le, out, 0, out.length);
1384
+ }
1385
+
1386
+ function preview(root, on) {
1387
+ const panel = root.querySelector("[data-editor-preview-panel]");
1388
+ const ta = sourceOf(root);
1389
+ root.querySelectorAll("[data-editor-view]").forEach((b) => {
1390
+ b.setAttribute("aria-pressed", String(b.getAttribute("data-editor-view") === (on ? "preview" : "write")));
1391
+ });
1392
+ if (!on) {
1393
+ root.removeAttribute("data-editor-view");
1394
+ if (panel) panel.hidden = true;
1395
+ ta?.focus();
1396
+ return;
1397
+ }
1398
+ root.setAttribute("data-editor-view", "preview");
1399
+ if (!panel) return;
1400
+ panel.hidden = false;
1401
+ const action = root.getAttribute("data-editor-preview");
1402
+ if (action && globalThis.rapid?.swap) {
1403
+ panel.setAttribute("aria-busy", "true");
1404
+ globalThis.rapid.swap(action, panel, {
1405
+ method: "post",
1406
+ body: new URLSearchParams({ text: ta?.value ?? "" }).toString(),
1407
+ }).finally(() => panel.removeAttribute("aria-busy"));
1408
+ } else {
1409
+ // No server preview: say so, and show the source as written.
1410
+ panel.replaceChildren();
1411
+ const note = document.createElement("p");
1412
+ note.className = "editor__preview-note";
1413
+ note.textContent = "No preview route on this page — showing the Markdown source.";
1414
+ const pre = document.createElement("pre");
1415
+ pre.className = "editor__preview-raw";
1416
+ pre.textContent = ta?.value ?? "";
1417
+ panel.append(note, pre);
1418
+ }
1419
+ }
1420
+
1421
+ /* ----------------------------------------------------------- html */
1422
+ function sync(root) {
1423
+ const ta = sourceOf(root);
1424
+ const surface = surfaceOf(root);
1425
+ if (ta && surface) ta.value = surface.innerHTML;
1426
+ }
1427
+
1428
+ const blockOf = () => String(document.queryCommandValue("formatBlock") || "").toLowerCase().replace(/[<>]/g, "");
1429
+
1430
+ function execute(root, cmd) {
1431
+ const surface = surfaceOf(root);
1432
+ if (!surface || surface.getAttribute("contenteditable") !== "true") return;
1433
+ surface.focus();
1434
+ if (INLINE[cmd]) document.execCommand(INLINE[cmd]);
1435
+ else if (LIST[cmd]) document.execCommand(LIST[cmd]);
1436
+ else if (BLOCK[cmd]) {
1437
+ // Toggle: the same block format again returns to a paragraph.
1438
+ document.execCommand("formatBlock", false, blockOf() === BLOCK[cmd] ? "<p>" : `<${BLOCK[cmd]}>`);
1439
+ } else if (cmd === "link") {
1440
+ const url = globalThis.prompt?.("Link URL", "https://");
1441
+ if (url) document.execCommand("createLink", false, url);
1442
+ } else if (cmd === "image") {
1443
+ const url = globalThis.prompt?.("Image URL", "https://");
1444
+ if (url) document.execCommand("insertImage", false, url);
1445
+ } else if (cmd === "hr") {
1446
+ document.execCommand("insertHorizontalRule");
1447
+ } else if (cmd === "clear") {
1448
+ document.execCommand("removeFormat");
1449
+ document.execCommand("unlink");
1450
+ if (document.queryCommandState("insertUnorderedList")) document.execCommand("insertUnorderedList");
1451
+ if (document.queryCommandState("insertOrderedList")) document.execCommand("insertOrderedList");
1452
+ if (blockOf() !== "p" && blockOf() !== "div") document.execCommand("formatBlock", false, "<p>");
1453
+ }
1454
+ sync(root);
1455
+ reflect(root);
1456
+ }
1457
+
1458
+ /** Toolbar state for the selection (html mode only — the browser knows). */
1459
+ function reflect(root) {
1460
+ if (!isHtml(root)) return;
1461
+ const block = blockOf();
1462
+ root.querySelectorAll("[data-editor-cmd]").forEach((btn) => {
1463
+ const cmd = btn.getAttribute("data-editor-cmd");
1464
+ let on = false;
1465
+ if (INLINE[cmd] || LIST[cmd]) on = document.queryCommandState(INLINE[cmd] ?? LIST[cmd]);
1466
+ else if (BLOCK[cmd]) on = block === BLOCK[cmd];
1467
+ btn.setAttribute("aria-pressed", String(on));
1468
+ });
1469
+ }
1470
+
1471
+ function init(root) {
1472
+ if (root.dataset.editorReady) return;
1473
+ root.dataset.editorReady = "true";
1474
+ const ta = sourceOf(root);
1475
+ const surface = surfaceOf(root);
1476
+ if (surface && ta) {
1477
+ // The textarea holds the server's (sanitised) HTML; the surface
1478
+ // shows it. innerHTML here is the same trust as the markup itself.
1479
+ surface.innerHTML = ta.value;
1480
+ surface.setAttribute("contenteditable", ta.disabled ? "false" : "true");
1481
+ // Paragraphs, not <div>s, for new lines.
1482
+ document.execCommand("defaultParagraphSeparator", false, "p");
1483
+ }
1484
+ }
1485
+
1486
+ const initAll = () => document.querySelectorAll("[data-editor]").forEach(init);
1487
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", initAll);
1488
+ else initAll();
1489
+ document.addEventListener("rapid:swapped", initAll);
1490
+
1491
+ /* --------------------------------------------------------- events */
1492
+ document.addEventListener("click", (event) => {
1493
+ const tool = event.target.closest?.("[data-editor-cmd]");
1494
+ if (tool) {
1495
+ const root = tool.closest("[data-editor]");
1496
+ const cmd = tool.getAttribute("data-editor-cmd");
1497
+ if (isHtml(root)) execute(root, cmd);
1498
+ else {
1499
+ const ta = sourceOf(root);
1500
+ if (ta && !ta.disabled) markdown(ta, cmd);
1501
+ }
1502
+ return;
1503
+ }
1504
+ const view = event.target.closest?.("[data-editor-view]");
1505
+ if (view) preview(view.closest("[data-editor]"), view.getAttribute("data-editor-view") === "preview");
1506
+ });
1507
+
1508
+ document.addEventListener("input", (event) => {
1509
+ const surface = event.target.closest?.("[data-editor-surface]");
1510
+ if (surface) sync(surface.closest("[data-editor]"));
1511
+ });
1512
+
1513
+ document.addEventListener("selectionchange", () => {
1514
+ const root = document.activeElement?.closest?.("[data-editor]");
1515
+ if (root) reflect(root);
1516
+ });
1517
+
1518
+ document.addEventListener("keydown", (event) => {
1519
+ if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
1520
+ const root = event.target.closest?.("[data-editor]");
1521
+ if (!root) return;
1522
+ const cmd = { b: "bold", i: "italic", u: "underline", k: "link" }[event.key.toLowerCase()];
1523
+ if (!cmd || (cmd === "underline" && !isHtml(root))) return;
1524
+ event.preventDefault();
1525
+ if (isHtml(root)) execute(root, cmd);
1526
+ else markdown(sourceOf(root), cmd);
1527
+ });
1528
+ })();
1529
+
1530
+ /* components/menu/menu.js */
1531
+ /*
1532
+ * Menu: mark the link matching the *full* current URL (path + hash) as
1533
+ * the current page, and open the sublist it sits in. The server can only
1534
+ * match on the path — a link like `settings.html#tab-billing` is one of
1535
+ * several children of the same page, and only the browser knows which
1536
+ * one the user followed. Runs on load and on every hash change.
1537
+ */
1538
+ (() => {
1539
+ function sync() {
1540
+ const here = location.href;
1541
+ let matched = null;
1542
+ document.querySelectorAll(".menu__link[href]").forEach((link) => {
1543
+ if (!link.getAttribute("href")?.includes("#")) return;
1544
+ const target = new URL(link.getAttribute("href"), location.href).href;
1545
+ if (target === here) matched = link;
1546
+ });
1547
+ if (!matched) return;
1548
+
1549
+ matched.closest(".menu__list")?.querySelectorAll(".menu__link").forEach((link) => {
1550
+ if (link !== matched && link.getAttribute("href")?.includes("#")) {
1551
+ link.classList.remove("menu__link--active");
1552
+ link.removeAttribute("aria-current");
1553
+ }
1554
+ });
1555
+ matched.classList.add("menu__link--active");
1556
+ matched.setAttribute("aria-current", "page");
1557
+
1558
+ const sublist = matched.closest(".menu__sublist");
1559
+ if (sublist) {
1560
+ sublist.hidden = false;
1561
+ sublist.classList.add("is-open");
1562
+ document.querySelector(`[data-toggle="#${CSS.escape(sublist.id)}"]`)
1563
+ ?.setAttribute("aria-expanded", "true");
1564
+ }
1565
+ }
1566
+
1567
+ if (document.readyState === "loading") {
1568
+ document.addEventListener("DOMContentLoaded", sync);
1569
+ } else {
1570
+ sync();
1571
+ }
1572
+ addEventListener("hashchange", sync);
1573
+ document.addEventListener("rapid:swapped", sync);
1574
+ })();
1575
+
1576
+ /* components/modal/modal.js */
1577
+ /*
1578
+ * Modal: [data-modal-open="#id"] opens a native <dialog> with
1579
+ * showModal() (focus trap + ::backdrop for free), [data-modal-close]
1580
+ * closes it, and a click on the backdrop closes it — but only when the
1581
+ * pointer went DOWN on the backdrop too, so selecting text in a field
1582
+ * and releasing outside the dialog does not slam it shut.
1583
+ */
1584
+ (() => {
1585
+ let downOnBackdrop = false;
1586
+
1587
+ document.addEventListener("pointerdown", (event) => {
1588
+ downOnBackdrop = event.target instanceof HTMLDialogElement && event.target.open;
1589
+ });
1590
+
1591
+ document.addEventListener("click", (event) => {
1592
+ const opener = event.target.closest?.("[data-modal-open]");
1593
+ if (opener) {
1594
+ const dialog = document.querySelector(opener.getAttribute("data-modal-open") ?? "");
1595
+ if (dialog instanceof HTMLDialogElement && !dialog.open) dialog.showModal();
1596
+ return;
1597
+ }
1598
+
1599
+ const closer = event.target.closest?.("[data-modal-close]");
1600
+ if (closer) {
1601
+ closer.closest("dialog")?.close();
1602
+ return;
1603
+ }
1604
+
1605
+ if (downOnBackdrop && event.target instanceof HTMLDialogElement && event.target.open) {
1606
+ event.target.close();
1607
+ }
1608
+ downOnBackdrop = false;
1609
+ });
1610
+ })();
1611
+
1612
+ /* components/otp/otp.js */
1613
+ /*
1614
+ * One-time code cells. Everything here is an enhancement over the single
1615
+ * .otp__value input that the markup submits:
1616
+ *
1617
+ * - typing a character fills the cell and advances; Backspace on an
1618
+ * empty cell walks back and clears; Arrow keys move; Delete clears
1619
+ * - a paste, an SMS autofill or a password-manager fill that lands more
1620
+ * than one character in a cell is spread across the cells from there
1621
+ * - the carrier input is kept in sync (it is what the form submits) and
1622
+ * taken out of the tab order; on load the cells mirror a prefilled
1623
+ * carrier value (a re-render after a failed attempt)
1624
+ * - when every cell is filled: `otp:complete` bubbles from the root
1625
+ * (detail: { value }), and with [data-otp-submit] the surrounding
1626
+ * form is submitted via requestSubmit() so rAPId's submit handler
1627
+ * sees it like any other submit.
1628
+ *
1629
+ * Delegated from document; re-syncs after every rAPId swap.
1630
+ */
1631
+ (() => {
1632
+ const cellsOf = (root) => Array.from(root.querySelectorAll(".otp__cell"));
1633
+ const carrierOf = (root) => root.querySelector(".otp__value");
1634
+
1635
+ function allowed(root, text) {
1636
+ const numeric = root.getAttribute("data-otp-mode") !== "alphanumeric";
1637
+ return (text || "").replace(numeric ? /[^0-9]/g : /[^0-9a-zA-Z]/g, "");
1638
+ }
1639
+
1640
+ function sync(root, { fromCarrier = false } = {}) {
1641
+ const cells = cellsOf(root);
1642
+ const carrier = carrierOf(root);
1643
+ if (!cells.length || !carrier) return;
1644
+
1645
+ if (fromCarrier) {
1646
+ const chars = allowed(root, carrier.value).slice(0, cells.length);
1647
+ cells.forEach((c, i) => c.value = chars[i] ?? "");
1648
+ }
1649
+ const value = cells.map((c) => c.value).join("");
1650
+ carrier.value = value;
1651
+ carrier.setAttribute("tabindex", "-1");
1652
+ carrier.setAttribute("aria-hidden", "true");
1653
+ cells.forEach((c) => c.toggleAttribute("data-filled", c.value !== ""));
1654
+ // The first empty cell is the one Tab lands on.
1655
+ const firstEmpty = cells.findIndex((c) => !c.value);
1656
+ cells.forEach((c, i) => c.setAttribute("tabindex", i === (firstEmpty < 0 ? 0 : firstEmpty) ? "0" : "-1"));
1657
+
1658
+ if (value.length === cells.length && !root.hasAttribute("data-otp-completed")) {
1659
+ root.setAttribute("data-otp-completed", "");
1660
+ root.dispatchEvent(new CustomEvent("otp:complete", { bubbles: true, detail: { value } }));
1661
+ if (root.hasAttribute("data-otp-submit")) root.closest("form")?.requestSubmit();
1662
+ } else if (value.length < cells.length) {
1663
+ root.removeAttribute("data-otp-completed");
1664
+ }
1665
+ }
1666
+
1667
+ /* Write `text` starting at cell `from`, advance focus past the last written cell. */
1668
+ function fill(root, from, text) {
1669
+ const cells = cellsOf(root);
1670
+ const chars = allowed(root, text);
1671
+ if (!chars) {
1672
+ cells[from].value = "";
1673
+ sync(root);
1674
+ return;
1675
+ }
1676
+ let i = from;
1677
+ for (const ch of chars) {
1678
+ if (i >= cells.length) break;
1679
+ cells[i].value = ch;
1680
+ i++;
1681
+ }
1682
+ sync(root);
1683
+ cells[Math.min(i, cells.length - 1)].focus();
1684
+ }
1685
+
1686
+ document.addEventListener("input", (event) => {
1687
+ const cell = event.target.closest?.(".otp__cell");
1688
+ if (!cell) return;
1689
+ const root = cell.closest("[data-otp]");
1690
+ const cells = cellsOf(root);
1691
+ fill(root, cells.indexOf(cell), cell.value);
1692
+ });
1693
+
1694
+ document.addEventListener("paste", (event) => {
1695
+ const cell = event.target.closest?.(".otp__cell");
1696
+ if (!cell) return;
1697
+ event.preventDefault();
1698
+ const root = cell.closest("[data-otp]");
1699
+ fill(root, cellsOf(root).indexOf(cell), event.clipboardData?.getData("text") ?? "");
1700
+ });
1701
+
1702
+ document.addEventListener("keydown", (event) => {
1703
+ const cell = event.target.closest?.(".otp__cell");
1704
+ if (!cell) return;
1705
+ const root = cell.closest("[data-otp]");
1706
+ const cells = cellsOf(root);
1707
+ const i = cells.indexOf(cell);
1708
+
1709
+ if (event.key === "Backspace") {
1710
+ event.preventDefault();
1711
+ if (cell.value) {
1712
+ cell.value = "";
1713
+ } else if (i > 0) {
1714
+ cells[i - 1].value = "";
1715
+ cells[i - 1].focus();
1716
+ }
1717
+ sync(root);
1718
+ } else if (event.key === "Delete") {
1719
+ event.preventDefault();
1720
+ cell.value = "";
1721
+ sync(root);
1722
+ } else if (event.key === "ArrowLeft" && i > 0) {
1723
+ event.preventDefault();
1724
+ cells[i - 1].focus();
1725
+ } else if (event.key === "ArrowRight" && i < cells.length - 1) {
1726
+ event.preventDefault();
1727
+ cells[i + 1].focus();
1728
+ }
1729
+ });
1730
+
1731
+ document.addEventListener("focusin", (event) => {
1732
+ const cell = event.target.closest?.(".otp__cell");
1733
+ if (cell) cell.select();
1734
+ });
1735
+
1736
+ function initAll() {
1737
+ document.querySelectorAll("[data-otp]").forEach((root) => sync(root, { fromCarrier: true }));
1738
+ }
1739
+
1740
+ if (document.readyState === "loading") {
1741
+ document.addEventListener("DOMContentLoaded", initAll);
1742
+ } else {
1743
+ initAll();
1744
+ }
1745
+ document.addEventListener("rapid:swapped", initAll);
1746
+ })();
1747
+
1748
+ /* components/popover/popover.js */
1749
+ /*
1750
+ * Popover: toggle on [data-popover-trigger], dismiss on outside click or
1751
+ * Escape (scoped to the popover that has focus; Escape with focus
1752
+ * elsewhere closes them all). Focus returns to the trigger on close.
1753
+ */
1754
+ (() => {
1755
+ function setOpen(pop, on) {
1756
+ const panel = pop.querySelector(".popover__panel");
1757
+ const trigger = pop.querySelector("[data-popover-trigger]");
1758
+ if (panel) panel.hidden = !on;
1759
+ if (trigger) trigger.setAttribute("aria-expanded", on ? "true" : "false");
1760
+ }
1761
+
1762
+ const openPanels = () => document.querySelectorAll("[data-popover] .popover__panel:not([hidden])");
1763
+
1764
+ document.addEventListener("click", (event) => {
1765
+ const trigger = event.target.closest?.("[data-popover-trigger]");
1766
+ if (trigger) {
1767
+ const pop = trigger.closest("[data-popover]");
1768
+ const panel = pop?.querySelector(".popover__panel");
1769
+ if (panel) setOpen(pop, panel.hidden);
1770
+ return;
1771
+ }
1772
+ const inside = event.target.closest?.("[data-popover]");
1773
+ openPanels().forEach((panel) => {
1774
+ const pop = panel.closest("[data-popover]");
1775
+ if (pop !== inside) setOpen(pop, false);
1776
+ });
1777
+ });
1778
+
1779
+ document.addEventListener("keydown", (event) => {
1780
+ if (event.key !== "Escape" || event.defaultPrevented) return;
1781
+ const own = event.target.closest?.("[data-popover]");
1782
+ const targets = own ? [own] : Array.from(openPanels()).map((p) => p.closest("[data-popover]"));
1783
+ let closed = false;
1784
+ targets.forEach((pop) => {
1785
+ const panel = pop.querySelector(".popover__panel");
1786
+ if (!panel || panel.hidden) return;
1787
+ setOpen(pop, false);
1788
+ pop.querySelector("[data-popover-trigger]")?.focus();
1789
+ closed = true;
1790
+ });
1791
+ if (closed) event.preventDefault();
1792
+ });
1793
+ })();
1794
+
1795
+ /* components/segmented/segmented.js */
1796
+ /* Segmented: a thumb that glides to the checked option. The control is
1797
+ * still plain radios + labels (submits, works without JS); this only
1798
+ * measures the checked label and writes --segmented-x / --segmented-w
1799
+ * onto the root, which segmented.css turns into the moving surface.
1800
+ * Re-measured on change, on resize, after a rapid swap. */
1801
+ (() => {
1802
+ function place(root) {
1803
+ const checked = root.querySelector(".segmented__input:checked");
1804
+ const label = checked?.nextElementSibling;
1805
+ if (!label) {
1806
+ root.style.setProperty("--segmented-w", "0px");
1807
+ return;
1808
+ }
1809
+ const r = root.getBoundingClientRect();
1810
+ const l = label.getBoundingClientRect();
1811
+ const rtl = getComputedStyle(root).direction === "rtl";
1812
+ const x = rtl ? r.right - root.clientLeft - 3 - l.right : l.left - r.left - root.clientLeft - 3;
1813
+ root.style.setProperty("--segmented-x", `${rtl ? -x : x}px`);
1814
+ root.style.setProperty("--segmented-w", `${l.width}px`);
1815
+ root.setAttribute("data-segmented-ready", "");
1816
+ }
1817
+
1818
+ const initAll = () => document.querySelectorAll("[data-segmented]").forEach(place);
1819
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", initAll);
1820
+ else initAll();
1821
+ addEventListener("load", initAll);
1822
+ addEventListener("resize", initAll);
1823
+ document.addEventListener("rapid:swapped", initAll);
1824
+ document.addEventListener("change", (event) => {
1825
+ const root = event.target.closest?.("[data-segmented]");
1826
+ if (root) place(root);
1827
+ });
1828
+ })();
1829
+
1830
+ /* components/select/select.js */
1831
+ /* Select: keeps the native <select> (the submitted value) and the
1832
+ * combobox UI (what the user sees) in step. A pick in the UI sets the
1833
+ * native value and fires `change` on it, so anything listening to the
1834
+ * native control (filter.js, a form's own script) sees a normal select;
1835
+ * a programmatic/autofill change on the native control repaints the UI.
1836
+ * Typing in the read-only field jumps to the first option starting with
1837
+ * those letters, like a native select does. */
1838
+ (() => {
1839
+ function ui(select) {
1840
+ const root = select.querySelector("[data-select-ui]");
1841
+ return root
1842
+ ? {
1843
+ root,
1844
+ input: root.querySelector("input[role='combobox']"),
1845
+ hidden: root.querySelector("[data-combobox-value]"),
1846
+ options: Array.from(root.querySelectorAll("[role='option']")),
1847
+ }
1848
+ : null;
1849
+ }
1850
+
1851
+ // UI → native
1852
+ document.addEventListener("combobox:pick", (event) => {
1853
+ const select = event.target.closest?.("[data-select]");
1854
+ const native = select?.querySelector(".select__native");
1855
+ if (!native) return;
1856
+ const value = event.detail?.value ?? "";
1857
+ if (native.value !== value) {
1858
+ native.value = value;
1859
+ native.dispatchEvent(new Event("change", { bubbles: true }));
1860
+ native.dispatchEvent(new Event("input", { bubbles: true }));
1861
+ }
1862
+ });
1863
+
1864
+ // native → UI
1865
+ document.addEventListener("change", (event) => {
1866
+ if (!event.target.matches?.(".select__native")) return;
1867
+ const select = event.target.closest("[data-select]");
1868
+ const u = ui(select);
1869
+ if (!u) return;
1870
+ const value = event.target.value;
1871
+ const picked = u.options.find((o) => o.getAttribute("data-value") === value);
1872
+ const label = picked
1873
+ ? (picked.querySelector(".combobox__option-label")?.textContent ?? picked.textContent).trim()
1874
+ : "";
1875
+ if (u.hidden) u.hidden.value = value;
1876
+ if (u.input && u.input.value !== label) u.input.value = label;
1877
+ u.options.forEach((o) => o.setAttribute("aria-selected", String(o === picked)));
1878
+ });
1879
+
1880
+ // Type-ahead on the read-only field.
1881
+ let buffer = "";
1882
+ let lastKey = 0;
1883
+ document.addEventListener("keydown", (event) => {
1884
+ const input = event.target;
1885
+ if (!input.matches?.("[data-select-ui] input[role='combobox']")) return;
1886
+ if (event.key.length !== 1 || event.metaKey || event.ctrlKey || event.altKey) return;
1887
+ const now = Date.now();
1888
+ buffer = now - lastKey > 600 ? event.key : buffer + event.key;
1889
+ lastKey = now;
1890
+ const select = input.closest("[data-select]");
1891
+ const u = ui(select);
1892
+ const hit = u?.options.find((o) =>
1893
+ o.getAttribute("aria-disabled") !== "true" &&
1894
+ (o.querySelector(".combobox__option-label")?.textContent ?? o.textContent).trim().toLowerCase().startsWith(
1895
+ buffer.toLowerCase(),
1896
+ )
1897
+ );
1898
+ if (hit) hit.click();
1899
+ event.preventDefault();
1900
+ });
1901
+ })();
1902
+
1903
+ /* components/slider/slider.js */
1904
+ /*
1905
+ * Slider: sync the painted track to the native <input type="range">.
1906
+ * --slider-pct is the only channel; the CSS owns everything visual. Runs
1907
+ * on load and after every rAPId swap, because a swapped-in fragment
1908
+ * arrives with no inline state (CSP: the template never emits one).
1909
+ *
1910
+ * The <output> text is server-rendered (possibly via a `format()` the
1911
+ * browser cannot run), so it is only rewritten on user input — using
1912
+ * `data-slider-labels` for stepped sliders or `data-slider-unit` for
1913
+ * counted ones.
1914
+ */
1915
+ (() => {
1916
+ function paint(input) {
1917
+ const root = input.closest("[data-slider]");
1918
+ if (!root) return null;
1919
+ const min = Number(input.min || 0);
1920
+ const max = Number(input.max || 100);
1921
+ const pct = max === min ? 0 : ((Number(input.value) - min) / (max - min)) * 100;
1922
+ root.style.setProperty("--slider-pct", `${pct.toFixed(2)}%`);
1923
+ return root;
1924
+ }
1925
+
1926
+ function label(root, input) {
1927
+ const out = root.querySelector("[data-slider-output]");
1928
+ if (!out) return;
1929
+ const value = Number(input.value);
1930
+ const labels = root.getAttribute("data-slider-labels");
1931
+ if (labels) {
1932
+ const step = Number(input.step || 1);
1933
+ const i = Math.round((value - Number(input.min || 0)) / step);
1934
+ let parsed = [];
1935
+ try {
1936
+ parsed = JSON.parse(labels);
1937
+ } catch { /* malformed — fall through to the number */ }
1938
+ out.textContent = parsed[i] ?? input.value;
1939
+ root.querySelectorAll(".slider__scale-item").forEach((el, n) => {
1940
+ el.classList.toggle("slider__scale-item--active", n === i);
1941
+ });
1942
+ return;
1943
+ }
1944
+ const unit = root.getAttribute("data-slider-unit");
1945
+ const one = root.getAttribute("data-slider-unit-one") || unit;
1946
+ out.textContent = unit ? `${input.value} ${value === 1 ? one : unit}` : input.value;
1947
+ }
1948
+
1949
+ function initAll() {
1950
+ document.querySelectorAll("[data-slider] .slider__input").forEach(paint);
1951
+ }
1952
+
1953
+ document.addEventListener("input", (event) => {
1954
+ if (!event.target.matches?.("[data-slider] .slider__input")) return;
1955
+ const root = paint(event.target);
1956
+ if (root) label(root, event.target);
1957
+ });
1958
+
1959
+ if (document.readyState === "loading") {
1960
+ document.addEventListener("DOMContentLoaded", initAll);
1961
+ } else {
1962
+ initAll();
1963
+ }
1964
+ document.addEventListener("rapid:swapped", initAll);
1965
+ })();
1966
+
1967
+ /* components/tabs/tabs.js */
1968
+ /*
1969
+ * Tabs: click or arrow-key (Left/Right/Home/End) selection with a roving
1970
+ * tabindex, and a `#tab-<id>` deep link honoured on load, on hashchange
1971
+ * and after a rAPId swap.
1972
+ */
1973
+ (() => {
1974
+ document.addEventListener("click", (event) => {
1975
+ const tab = event.target.closest?.(".tabs__tab");
1976
+ if (tab) activate(tab);
1977
+ });
1978
+
1979
+ document.addEventListener("keydown", (event) => {
1980
+ const keys = ["ArrowRight", "ArrowLeft", "Home", "End"];
1981
+ if (!keys.includes(event.key)) return;
1982
+ const tab = event.target.closest?.(".tabs__tab");
1983
+ if (!tab) return;
1984
+ const list = [...tab.parentElement.querySelectorAll(".tabs__tab")];
1985
+ const index = list.indexOf(tab);
1986
+ let next;
1987
+ if (event.key === "Home") next = list[0];
1988
+ else if (event.key === "End") next = list[list.length - 1];
1989
+ else if (event.key === "ArrowRight") next = list[(index + 1) % list.length];
1990
+ else next = list[(index - 1 + list.length) % list.length];
1991
+ event.preventDefault();
1992
+ next.focus();
1993
+ activate(next);
1994
+ });
1995
+
1996
+ function deepLink() {
1997
+ if (location.hash.length < 2) return;
1998
+ const target = document.querySelector(`.tabs__tab#${CSS.escape(location.hash.slice(1))}`);
1999
+ if (!target) return;
2000
+ activate(target);
2001
+ // Fragment navigation focuses the target element, and since that is
2002
+ // not a pointer interaction the tab paints its :focus-visible ring —
2003
+ // which reads as a stray border to someone who just clicked a
2004
+ // sidebar link. Drop that programmatic focus; keyboard users who
2005
+ // Tab into the list still get the ring.
2006
+ const unfocus = () => {
2007
+ if (document.activeElement === target) target.blur();
2008
+ };
2009
+ unfocus();
2010
+ setTimeout(unfocus, 50);
2011
+ }
2012
+
2013
+ deepLink();
2014
+ addEventListener("load", deepLink);
2015
+ addEventListener("hashchange", deepLink);
2016
+ document.addEventListener("rapid:swapped", deepLink);
2017
+
2018
+ function activate(tab) {
2019
+ const tabs = tab.closest(".tabs");
2020
+ if (!tabs) return;
2021
+ tabs.querySelectorAll(":scope > .tabs__list > .tabs__tab").forEach((candidate) => {
2022
+ const selected = candidate === tab;
2023
+ candidate.setAttribute("aria-selected", String(selected));
2024
+ candidate.setAttribute("tabindex", selected ? "0" : "-1");
2025
+ });
2026
+ tabs.querySelectorAll(":scope > .tabs__panel").forEach((panel) => {
2027
+ panel.toggleAttribute("hidden", panel.id !== tab.getAttribute("aria-controls"));
2028
+ });
2029
+ }
2030
+ })();
2031
+
2032
+ /* components/toast/toast.js */
2033
+ /*
2034
+ * Toasts. Three things live here:
2035
+ *
2036
+ * 1. Auto-dismiss: a `.toast[data-toast-autodismiss="<ms>"]` removes
2037
+ * itself after that many ms — whether it was in the page at load or
2038
+ * appended anywhere later (a rAPId swap into any region, or 2.).
2039
+ *
2040
+ * 2. Client-side trigger: `[data-toast-open="#template-id"]` clones that
2041
+ * <template>'s content into the region (`data-toast-region`, default
2042
+ * `#toast-region`). For rAPId apps the server usually pushes toasts via
2043
+ * `data-target="#toast-region" data-swap="append"`; this is the
2044
+ * no-server path (plain HTML sites, optimistic UI).
2045
+ *
2046
+ * 3. Stack cap: a region keeps at most `data-toast-max` (default 4)
2047
+ * toasts; the oldest are dropped so a burst never buries the page.
2048
+ */
2049
+ (() => {
2050
+ const timers = new WeakMap();
2051
+
2052
+ function autoDismiss(el) {
2053
+ const ms = Number(el.dataset.toastAutodismiss);
2054
+ if (!ms || timers.has(el)) return;
2055
+ timers.set(el, setTimeout(() => el.remove(), ms));
2056
+ }
2057
+
2058
+ function cap(region) {
2059
+ const max = Number(region.dataset.toastMax) || 4;
2060
+ const toasts = region.querySelectorAll(":scope > .toast");
2061
+ for (let i = 0; i < toasts.length - max; i++) toasts[i].remove();
2062
+ }
2063
+
2064
+ function scan(root) {
2065
+ root.querySelectorAll?.(".toast[data-toast-autodismiss]").forEach(autoDismiss);
2066
+ if (root.matches?.(".toast[data-toast-autodismiss]")) autoDismiss(root);
2067
+ root.querySelectorAll?.(".toast-region").forEach(cap);
2068
+ root.closest?.(".toast-region") && cap(root.closest(".toast-region"));
2069
+ }
2070
+
2071
+ scan(document);
2072
+
2073
+ new MutationObserver((mutations) => {
2074
+ for (const mutation of mutations) {
2075
+ for (const node of mutation.addedNodes) {
2076
+ if (node.nodeType === 1) scan(node);
2077
+ }
2078
+ }
2079
+ }).observe(document.documentElement, { childList: true, subtree: true });
2080
+
2081
+ document.addEventListener("click", (event) => {
2082
+ const trigger = event.target.closest?.("[data-toast-open]");
2083
+ if (!trigger) return;
2084
+ const template = document.querySelector(trigger.getAttribute("data-toast-open") ?? "");
2085
+ const target = document.querySelector(trigger.getAttribute("data-toast-region") ?? "#toast-region");
2086
+ if (!template?.content || !target) return;
2087
+ target.append(template.content.cloneNode(true));
2088
+ });
2089
+ })();