@ponchia/ui 0.2.2 → 0.3.1

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.
Files changed (48) hide show
  1. package/README.md +108 -79
  2. package/behaviors/index.d.ts +53 -0
  3. package/behaviors/index.js +745 -34
  4. package/classes/index.d.ts +216 -3
  5. package/classes/index.js +85 -10
  6. package/classes/vscode.css-custom-data.json +407 -0
  7. package/css/app.css +53 -44
  8. package/css/base.css +14 -23
  9. package/css/content.css +37 -2
  10. package/css/core.css +5 -5
  11. package/css/disclosure.css +17 -5
  12. package/css/dots.css +1 -1
  13. package/css/feedback.css +87 -2
  14. package/css/forms.css +128 -2
  15. package/css/navigation.css +16 -85
  16. package/css/overlay.css +73 -2
  17. package/css/primitives.css +100 -3
  18. package/css/site.css +295 -0
  19. package/css/table.css +59 -0
  20. package/css/tokens.css +79 -24
  21. package/dist/bronto.css +1 -1
  22. package/dist/css/app.css +1 -0
  23. package/dist/css/base.css +1 -0
  24. package/dist/css/content.css +1 -0
  25. package/dist/css/disclosure.css +1 -0
  26. package/dist/css/dots.css +1 -0
  27. package/dist/css/feedback.css +1 -0
  28. package/dist/css/fonts.css +1 -0
  29. package/dist/css/forms.css +1 -0
  30. package/dist/css/motion.css +1 -0
  31. package/dist/css/navigation.css +1 -0
  32. package/dist/css/overlay.css +1 -0
  33. package/dist/css/primitives.css +1 -0
  34. package/dist/css/site.css +1 -0
  35. package/dist/css/table.css +1 -0
  36. package/dist/css/tokens.css +1 -0
  37. package/package.json +60 -30
  38. package/shiki/nothing.json +83 -0
  39. package/tokens/index.d.ts +18 -10
  40. package/tokens/index.js +49 -16
  41. package/tokens/index.json +98 -32
  42. package/tokens/tokens.dtcg.json +241 -22
  43. package/css/cards.css +0 -336
  44. package/css/index.css +0 -5
  45. package/css/layout.css +0 -219
  46. package/css/responsive.css +0 -157
  47. package/css/typography.css +0 -139
  48. package/dist/bronto-core.css +0 -1
@@ -18,6 +18,40 @@ const THEMES = ['light', 'dark'];
18
18
  const noop = () => {};
19
19
  const hasDom = () => typeof document !== 'undefined';
20
20
 
21
+ // Module-global so tab ids stay unique across *every* initTabs() call.
22
+ // A per-call counter makes separate islands/roots all mint `bronto-tab-1`,
23
+ // which collides aria-controls/aria-labelledby across the document.
24
+ let tabUid = 0;
25
+
26
+ // Same rationale for auto-minted form-field / error-slot ids.
27
+ let fieldUid = 0;
28
+
29
+ // First-toast deferral queue. The very first toast on a brand-new stack
30
+ // is appended next frame so AT observes the empty aria-live region
31
+ // before its first child. Any further toasts created *before* that frame
32
+ // flushes are queued behind it so call order (FIFO) is preserved instead
33
+ // of a synchronous later toast jumping ahead of the deferred first one.
34
+ const toastQueue = [];
35
+ let toastFlushScheduled = false;
36
+
37
+ // Make delegated initializers idempotent. Re-binding the same logical
38
+ // listener on the same host/element tears the previous binding down first,
39
+ // so double-init (HMR, framework re-mount, repeated calls) never stacks
40
+ // duplicate handlers (the "double-toggle" class of bug). The returned
41
+ // cleanup removes the single live binding.
42
+ const BOUND = Symbol('bronto-bound');
43
+ function bindOnce(target, key, add) {
44
+ const reg = target[BOUND] || (target[BOUND] = Object.create(null));
45
+ if (reg[key]) reg[key]();
46
+ const remove = add();
47
+ const cleanup = () => {
48
+ remove();
49
+ if (reg[key] === cleanup) delete reg[key];
50
+ };
51
+ reg[key] = cleanup;
52
+ return cleanup;
53
+ }
54
+
21
55
  /**
22
56
  * Apply the persisted theme to <html data-theme>. Call as early as
23
57
  * possible (an inline module in <head>) to avoid a flash before the
@@ -85,14 +119,16 @@ export function initThemeToggle({ storageKey = 'bronto-theme', root } = {}) {
85
119
  }
86
120
  reflect();
87
121
  docEl.dispatchEvent(
88
- new CustomEvent('bronto:themechange', { detail: { theme: next }, bubbles: true })
122
+ new CustomEvent('bronto:themechange', { detail: { theme: next }, bubbles: true }),
89
123
  );
90
124
  };
91
125
 
92
126
  applyStoredTheme({ storageKey });
93
127
  reflect();
94
- host.addEventListener('click', onClick);
95
- return () => host.removeEventListener('click', onClick);
128
+ return bindOnce(host, 'themeToggle', () => {
129
+ host.addEventListener('click', onClick);
130
+ return () => host.removeEventListener('click', onClick);
131
+ });
96
132
  }
97
133
 
98
134
  /**
@@ -112,8 +148,10 @@ export function dismissible({ root } = {}) {
112
148
  const ev = new CustomEvent('bronto:dismiss', { bubbles: true, cancelable: true });
113
149
  if (target.dispatchEvent(ev)) target.remove();
114
150
  };
115
- host.addEventListener('click', onClick);
116
- return () => host.removeEventListener('click', onClick);
151
+ return bindOnce(host, 'dismissible', () => {
152
+ host.addEventListener('click', onClick);
153
+ return () => host.removeEventListener('click', onClick);
154
+ });
117
155
  }
118
156
 
119
157
  /**
@@ -122,13 +160,19 @@ export function dismissible({ root } = {}) {
122
160
  * Tabs pattern: roving `tabindex`, `aria-selected`, Arrow/Home/End
123
161
  * navigation with automatic activation, and panel `hidden` sync. Tabs are
124
162
  * `.ui-tab[data-tab]`; panels are `.ui-tabs__panel[data-panel]` with
125
- * matching values. SSR-safe; returns a cleanup function.
163
+ * matching values. SSR-safe and idempotent (re-init replaces, never
164
+ * stacks, the per-group listeners); returns a cleanup function.
165
+ *
166
+ * Accessibility caveat: this is what makes tabs operable. Do **not**
167
+ * author `hidden` on `.ui-tabs__panel` in server-rendered markup unless
168
+ * `initTabs` is guaranteed to run client-side — without it the panels
169
+ * stay hidden with no keyboard/pointer way to reveal them. Prefer
170
+ * authoring all panels visible and letting `initTabs` add `hidden`.
126
171
  */
127
172
  export function initTabs({ root } = {}) {
128
173
  if (!hasDom()) return noop;
129
174
  const host = root || document;
130
175
  const cleanups = [];
131
- let uid = 0;
132
176
  // querySelectorAll only matches descendants, so a `root` that *is* a
133
177
  // tab group would be skipped — include it explicitly.
134
178
  const groups = [];
@@ -149,7 +193,7 @@ export function initTabs({ root } = {}) {
149
193
  for (const t of tabs) {
150
194
  const p = panels.find((x) => x.dataset.panel === t.dataset.tab);
151
195
  if (!p) continue;
152
- const n = ++uid;
196
+ const n = ++tabUid;
153
197
  if (!t.id) t.id = `bronto-tab-${n}`;
154
198
  if (!p.id) p.id = `bronto-tabpanel-${n}`;
155
199
  t.setAttribute('aria-controls', p.id);
@@ -183,7 +227,8 @@ export function initTabs({ root } = {}) {
183
227
  if (i < 0) return;
184
228
  let n = i;
185
229
  if (e.key === 'ArrowRight' || e.key === 'ArrowDown') n = (i + 1) % tabs.length;
186
- else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') n = (i - 1 + tabs.length) % tabs.length;
230
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
231
+ n = (i - 1 + tabs.length) % tabs.length;
187
232
  else if (e.key === 'Home') n = 0;
188
233
  else if (e.key === 'End') n = tabs.length - 1;
189
234
  else return;
@@ -191,13 +236,17 @@ export function initTabs({ root } = {}) {
191
236
  select(tabs[n]);
192
237
  tabs[n].focus();
193
238
  };
194
- group.addEventListener('click', onClick);
195
- group.addEventListener('keydown', onKey);
196
239
  select(tabs.find((t) => t.classList.contains('is-active')) || tabs[0]);
197
- cleanups.push(() => {
198
- group.removeEventListener('click', onClick);
199
- group.removeEventListener('keydown', onKey);
200
- });
240
+ cleanups.push(
241
+ bindOnce(group, 'tabs', () => {
242
+ group.addEventListener('click', onClick);
243
+ group.addEventListener('keydown', onKey);
244
+ return () => {
245
+ group.removeEventListener('click', onClick);
246
+ group.removeEventListener('keydown', onKey);
247
+ };
248
+ }),
249
+ );
201
250
  }
202
251
  return () => cleanups.forEach((fn) => fn());
203
252
  }
@@ -207,7 +256,11 @@ export function initTabs({ root } = {}) {
207
256
  * declaratively). Click `[data-bronto-open="dialogId"]` calls
208
257
  * `showModal()` on `#dialogId`; click `[data-bronto-close]` closes the
209
258
  * nearest enclosing <dialog>. Clicking the backdrop of a dialog that has
210
- * `[data-bronto-dialog-light]` closes it too. SSR-safe; returns cleanup.
259
+ * `[data-bronto-dialog-light]` closes it too. On open the trigger is
260
+ * remembered and focus is returned to it on *every* close path (Esc,
261
+ * close button, backdrop light-dismiss, programmatic) via the native
262
+ * `close` event, so keyboard/SR users are never dropped at `<body>`.
263
+ * SSR-safe and idempotent; returns cleanup.
211
264
  *
212
265
  * `root` scopes which triggers are delegated (default `document`); the
213
266
  * dialog itself is still resolved by id document-wide, because a modal
@@ -222,7 +275,16 @@ export function initDialog({ root } = {}) {
222
275
  const opener = e.target.closest('[data-bronto-open]');
223
276
  if (opener && host.contains(opener)) {
224
277
  const dlg = document.getElementById(opener.getAttribute('data-bronto-open'));
225
- if (dlg && typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
278
+ if (dlg && typeof dlg.showModal === 'function' && !dlg.open) {
279
+ dlg.addEventListener(
280
+ 'close',
281
+ () => {
282
+ if (opener.isConnected && typeof opener.focus === 'function') opener.focus();
283
+ },
284
+ { once: true },
285
+ );
286
+ dlg.showModal();
287
+ }
226
288
  return;
227
289
  }
228
290
  const closer = e.target.closest('[data-bronto-close]');
@@ -243,30 +305,49 @@ export function initDialog({ root } = {}) {
243
305
  dlg.close();
244
306
  }
245
307
  };
246
- host.addEventListener('click', onClick);
247
- return () => host.removeEventListener('click', onClick);
308
+ return bindOnce(host, 'dialog', () => {
309
+ host.addEventListener('click', onClick);
310
+ return () => host.removeEventListener('click', onClick);
311
+ });
248
312
  }
249
313
 
250
314
  /**
251
- * Push a transient toast into a shared, screen-anchored stack (created
252
- * once and appended to <body>, `aria-live="polite"`). `tone` is one of
253
- * accent/success/warning/danger; `title` is an optional uppercase label;
254
- * `duration` ms before auto-dismiss (0 keeps it until dismissed). Returns
255
- * a function that dismisses the toast early. SSR-safe (no-op).
315
+ * Push a transient toast into a shared, screen-anchored stack. The stack
316
+ * is the `aria-live="polite"` region: it is created once, appended to
317
+ * <body>, and **kept resident even when empty** so the live region is
318
+ * always present before content is inserted (a freshly created region
319
+ * that receives its first child in the same tick is not reliably
320
+ * announced by VoiceOver/NVDA). On first creation the empty region is
321
+ * inserted and the toast is appended on the next frame for the same
322
+ * reason. `tone` is accent/success/warning/danger; `title` is an
323
+ * optional uppercase label; `duration` ms before auto-dismiss (0 keeps
324
+ * it until dismissed). Returns a function that dismisses the toast
325
+ * early. SSR-safe (no-op).
256
326
  */
257
- export function toast(message, { tone, title, duration = 4000 } = {}) {
327
+ export function toast(message, { tone, title, duration = 4000, assertive, closable } = {}) {
258
328
  if (!hasDom()) return noop;
259
- let stack = document.querySelector('.ui-toast-stack');
329
+ // Errors must interrupt: danger toasts (or an explicit `assertive`)
330
+ // go to a SEPARATE assertive region so they announce immediately,
331
+ // while status toasts stay polite. Two regions — not a per-item
332
+ // role=alert nested in a polite parent — avoids the double
333
+ // announcement that nesting causes in some screen readers.
334
+ const isAssertive = assertive ?? tone === 'danger';
335
+ const stackSel = isAssertive
336
+ ? '.ui-toast-stack--assertive'
337
+ : '.ui-toast-stack:not(.ui-toast-stack--assertive)';
338
+ let stack = document.querySelector(stackSel);
339
+ const freshStack = !stack;
260
340
  if (!stack) {
261
341
  stack = document.createElement('div');
262
- stack.className = 'ui-toast-stack';
263
- stack.setAttribute('aria-live', 'polite');
342
+ stack.className = isAssertive ? 'ui-toast-stack ui-toast-stack--assertive' : 'ui-toast-stack';
343
+ stack.setAttribute('aria-live', isAssertive ? 'assertive' : 'polite');
344
+ if (isAssertive) stack.setAttribute('role', 'alert');
264
345
  document.body.appendChild(stack);
265
346
  }
266
347
  const el = document.createElement('div');
267
348
  el.className = tone ? `ui-toast ui-toast--${tone}` : 'ui-toast';
268
- // No per-item role: the stack is already aria-live=polite; a nested
269
- // status live region risks double announcement in some SRs.
349
+ // No per-item role: the stack itself is the live region; a nested
350
+ // live region risks double announcement in some SRs.
270
351
  if (title) {
271
352
  const t = document.createElement('p');
272
353
  t.className = 'ui-toast__title';
@@ -276,14 +357,53 @@ export function toast(message, { tone, title, duration = 4000 } = {}) {
276
357
  const body = document.createElement('div');
277
358
  body.textContent = message;
278
359
  el.appendChild(body);
279
- stack.appendChild(el);
360
+ // Append after a frame the *first* time so the empty live region is
361
+ // observed by AT before its first child arrives; once the region has
362
+ // been observed, later toasts append synchronously.
363
+ let dismissed = false;
364
+ // `dismissed` guard: a toast dismissed before its frame (e.g.
365
+ // duration:0 + immediate dismiss) must NOT be resurrected into the
366
+ // persistent aria-live region.
367
+ const place = () => {
368
+ if (!dismissed) stack.appendChild(el);
369
+ };
370
+ const canDefer = typeof requestAnimationFrame === 'function';
371
+ if (freshStack && canDefer) {
372
+ toastQueue.push(place);
373
+ toastFlushScheduled = true;
374
+ requestAnimationFrame(() => {
375
+ toastFlushScheduled = false;
376
+ for (const fn of toastQueue.splice(0)) fn();
377
+ });
378
+ } else if (toastFlushScheduled) {
379
+ // A first-frame deferral is in flight — queue behind it so FIFO
380
+ // order holds and the region still isn't populated synchronously.
381
+ toastQueue.push(place);
382
+ } else {
383
+ place();
384
+ }
280
385
 
281
386
  let timer;
282
387
  const dismiss = () => {
388
+ if (dismissed) return;
389
+ dismissed = true;
283
390
  if (timer) clearTimeout(timer);
284
391
  el.remove();
285
- if (stack && !stack.childElementCount) stack.remove();
392
+ // The stack is a persistent live region — never removed on drain, so
393
+ // the next toast does not recreate (and thus mis-announce) it.
286
394
  };
395
+ // A sticky toast (duration:0) is unusable without a manual close, so
396
+ // it gets a dismiss affordance by default; any toast can opt in via
397
+ // `closable`. The button carries no text node (glyph is a CSS
398
+ // ::before) so the toast's announced/textContent stays the message.
399
+ if (closable ?? duration === 0) {
400
+ const close = document.createElement('button');
401
+ close.type = 'button';
402
+ close.className = 'ui-toast__close';
403
+ close.setAttribute('aria-label', 'Dismiss');
404
+ close.addEventListener('click', dismiss);
405
+ el.appendChild(close);
406
+ }
287
407
  if (duration > 0) timer = setTimeout(dismiss, duration);
288
408
  return dismiss;
289
409
  }
@@ -306,6 +426,597 @@ export function initDisclosure({ root } = {}) {
306
426
  trigger.setAttribute('aria-expanded', String(!open));
307
427
  panel.hidden = open;
308
428
  };
309
- host.addEventListener('click', onClick);
310
- return () => host.removeEventListener('click', onClick);
429
+ return bindOnce(host, 'disclosure', () => {
430
+ host.addEventListener('click', onClick);
431
+ return () => host.removeEventListener('click', onClick);
432
+ });
433
+ }
434
+
435
+ /**
436
+ * Dropdown-menu close affordances for a native `<details data-bronto-menu>`
437
+ * holding a `.ui-menu`. `<details>` alone won't close on Escape, on an
438
+ * outside click, or when a `.ui-menu__item` is activated — this adds
439
+ * exactly those, returning focus to the `<summary>` on Esc/activate.
440
+ *
441
+ * Deliberately NOT a full WAI-ARIA menu (no arrow-key roving): the items
442
+ * are real buttons, Tab-reachable; this is a disclosure of actions, and
443
+ * over-claiming `role="menu"` semantics would be worse. SSR-safe,
444
+ * idempotent; returns a cleanup function.
445
+ */
446
+ export function initMenu({ root } = {}) {
447
+ if (!hasDom()) return noop;
448
+ const host = root || document;
449
+ const openMenus = () => host.querySelectorAll?.('[data-bronto-menu][open]') ?? [];
450
+ const shut = (menu) => {
451
+ if (!menu || !menu.open) return;
452
+ menu.open = false;
453
+ menu.querySelector('summary')?.focus();
454
+ };
455
+ const onClick = (e) => {
456
+ const menu = e.target.closest('[data-bronto-menu]');
457
+ // Activate an item → close its menu (and return focus to summary).
458
+ if (menu && e.target.closest('.ui-menu__item')) {
459
+ shut(menu);
460
+ return;
461
+ }
462
+ // Click outside any open menu → close them all (no focus move).
463
+ for (const m of openMenus()) if (!m.contains(e.target)) m.open = false;
464
+ };
465
+ const onKey = (e) => {
466
+ if (e.key !== 'Escape') return;
467
+ const menu = e.target.closest?.('[data-bronto-menu][open]') || openMenus()[0];
468
+ shut(menu);
469
+ };
470
+ return bindOnce(host, 'menu', () => {
471
+ host.addEventListener('click', onClick);
472
+ host.addEventListener('keydown', onKey);
473
+ return () => {
474
+ host.removeEventListener('click', onClick);
475
+ host.removeEventListener('keydown', onKey);
476
+ };
477
+ });
478
+ }
479
+
480
+ /**
481
+ * Accessible form validation glue for `<form data-bronto-validate>`.
482
+ * Progressive enhancement over the native Constraint Validation API —
483
+ * the framework already ships the `[aria-invalid]` / `.ui-hint--error`
484
+ * styling; this wires the a11y plumbing every consumer would otherwise
485
+ * re-implement (and usually get wrong):
486
+ *
487
+ * - suppresses the native error bubbles (`form.noValidate`),
488
+ * - on blur and on submit sets `aria-invalid` and writes the browser's
489
+ * `validationMessage` into the field's error slot
490
+ * (`[data-bronto-error]` inside the `.ui-field`, falling back to a
491
+ * `.ui-hint`), linked via `aria-describedby`,
492
+ * - on an invalid submit, fills the form's
493
+ * `[data-bronto-error-summary]` (a `.ui-error-summary`) with
494
+ * in-page links to each bad field, focuses it, and blocks submit.
495
+ *
496
+ * Pure enhancement: with JS off the form still submits and the browser
497
+ * validates natively. SSR-safe, idempotent; returns a cleanup function.
498
+ */
499
+ export function initFormValidation({ root } = {}) {
500
+ if (!hasDom()) return noop;
501
+ const host = root || document;
502
+
503
+ const ensureId = (el, prefix) => {
504
+ if (!el.id) el.id = `${prefix}-${++fieldUid}`;
505
+ return el.id;
506
+ };
507
+
508
+ const slotFor = (control) => {
509
+ const field = control.closest('.ui-field');
510
+ if (!field) return null;
511
+ return field.querySelector('[data-bronto-error]') || field.querySelector('.ui-hint');
512
+ };
513
+
514
+ const link = (control, slot) => {
515
+ const slotId = ensureId(slot, 'bronto-err');
516
+ const ids = (control.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
517
+ if (!ids.includes(slotId)) {
518
+ ids.push(slotId);
519
+ control.setAttribute('aria-describedby', ids.join(' '));
520
+ }
521
+ };
522
+
523
+ const validateField = (control) => {
524
+ if (!control.willValidate) return true;
525
+ const ok = control.validity.valid;
526
+ const slot = slotFor(control);
527
+ if (ok) {
528
+ control.removeAttribute('aria-invalid');
529
+ if (slot) {
530
+ slot.textContent = '';
531
+ if (slot.classList.contains('ui-hint')) slot.classList.remove('ui-hint--error');
532
+ }
533
+ } else {
534
+ control.setAttribute('aria-invalid', 'true');
535
+ if (slot) {
536
+ slot.textContent = control.validationMessage;
537
+ if (slot.classList.contains('ui-hint')) slot.classList.add('ui-hint--error');
538
+ link(control, slot);
539
+ }
540
+ }
541
+ return ok;
542
+ };
543
+
544
+ const controlsOf = (form) =>
545
+ [...form.elements].filter(
546
+ (el) => el.willValidate && el.type !== 'submit' && el.type !== 'button',
547
+ );
548
+
549
+ const refreshSummary = (form, invalid) => {
550
+ const summary = form.querySelector('[data-bronto-error-summary]');
551
+ if (!summary) return;
552
+ if (!invalid.length) {
553
+ summary.hidden = true;
554
+ summary.replaceChildren();
555
+ return;
556
+ }
557
+ const title = document.createElement('p');
558
+ title.className = 'ui-error-summary__title';
559
+ title.textContent = 'There is a problem';
560
+ const list = document.createElement('ul');
561
+ list.className = 'ui-error-summary__list';
562
+ for (const c of invalid) {
563
+ const id = ensureId(c, 'bronto-field');
564
+ const li = document.createElement('li');
565
+ const a = document.createElement('a');
566
+ a.href = `#${id}`;
567
+ a.textContent = c.validationMessage;
568
+ a.addEventListener('click', (e) => {
569
+ e.preventDefault();
570
+ c.focus();
571
+ });
572
+ li.appendChild(a);
573
+ list.appendChild(li);
574
+ }
575
+ summary.replaceChildren(title, list);
576
+ summary.setAttribute('role', 'alert');
577
+ summary.tabIndex = -1;
578
+ summary.hidden = false;
579
+ };
580
+
581
+ const onSubmit = (e) => {
582
+ const form = e.target.closest?.('[data-bronto-validate]');
583
+ if (!form) return;
584
+ form.noValidate = true;
585
+ const invalid = controlsOf(form).filter((c) => !validateField(c));
586
+ refreshSummary(form, invalid);
587
+ if (invalid.length) {
588
+ e.preventDefault();
589
+ const summary = form.querySelector('[data-bronto-error-summary]');
590
+ (summary && !summary.hidden ? summary : invalid[0]).focus();
591
+ }
592
+ };
593
+
594
+ const onBlur = (e) => {
595
+ const control = e.target;
596
+ if (!control.willValidate) return;
597
+ const form = control.closest?.('[data-bronto-validate]');
598
+ if (!form) return;
599
+ form.noValidate = true;
600
+ validateField(control);
601
+ const summary = form.querySelector('[data-bronto-error-summary]');
602
+ if (summary && !summary.hidden)
603
+ refreshSummary(
604
+ form,
605
+ controlsOf(form).filter((c) => !c.validity.valid),
606
+ );
607
+ };
608
+
609
+ return bindOnce(host, 'formValidation', () => {
610
+ host.addEventListener('submit', onSubmit, true);
611
+ host.addEventListener('focusout', onBlur);
612
+ return () => {
613
+ host.removeEventListener('submit', onSubmit, true);
614
+ host.removeEventListener('focusout', onBlur);
615
+ };
616
+ });
617
+ }
618
+
619
+ /**
620
+ * Editable combobox with a filtered listbox popup, implementing the
621
+ * WAI-ARIA APG combobox pattern (the widget the framework most lacked
622
+ * and consumers most often build badly). Dependency-free, no
623
+ * positioning library — the list is CSS-anchored under the input.
624
+ *
625
+ * Markup: `[data-bronto-combobox]` wrapping an `<input role="combobox">`
626
+ * (`.ui-combobox__input`) and a `<ul role="listbox">`
627
+ * (`.ui-combobox__list`) of `<li role="option">` (`.ui-combobox__option`,
628
+ * optional `data-value`). An optional `.ui-combobox__empty` shows when
629
+ * nothing matches. The behavior owns ids, `aria-expanded`,
630
+ * `aria-controls`, `aria-activedescendant`, roving active option,
631
+ * type-to-filter, full keyboard (Down/Up/Home/End/Enter/Escape/Tab),
632
+ * pointer select, and outside-click close; it emits a `bronto:change`
633
+ * CustomEvent ({ detail: { value } }) on selection. SSR-safe,
634
+ * idempotent per instance; returns a cleanup function.
635
+ */
636
+ export function initCombobox({ root } = {}) {
637
+ if (!hasDom()) return noop;
638
+ const host = root || document;
639
+ const boxes = [];
640
+ if (host !== document && host.matches?.('[data-bronto-combobox]')) boxes.push(host);
641
+ boxes.push(...(host.querySelectorAll?.('[data-bronto-combobox]') ?? []));
642
+ const cleanups = [];
643
+
644
+ for (const box of boxes) {
645
+ const input = box.querySelector('[role="combobox"], .ui-combobox__input');
646
+ const list = box.querySelector('[role="listbox"], .ui-combobox__list');
647
+ if (!input || !list) continue;
648
+ const empty = box.querySelector('.ui-combobox__empty');
649
+ const options = [...list.querySelectorAll('[role="option"], .ui-combobox__option')];
650
+
651
+ const listId = list.id || (list.id = `bronto-cb-list-${++fieldUid}`);
652
+ options.forEach((o, i) => {
653
+ if (!o.id) o.id = `${listId}-opt-${i}`;
654
+ o.setAttribute('role', 'option');
655
+ });
656
+ list.setAttribute('role', 'listbox');
657
+ input.setAttribute('role', 'combobox');
658
+ input.setAttribute('aria-controls', listId);
659
+ input.setAttribute('aria-autocomplete', 'list');
660
+ input.setAttribute('aria-expanded', 'false');
661
+ input.setAttribute('autocomplete', 'off');
662
+ list.hidden = true;
663
+
664
+ let active = -1;
665
+ const visible = () => options.filter((o) => !o.hidden);
666
+
667
+ const setActive = (opt) => {
668
+ options.forEach((o) => o.classList.remove('is-active'));
669
+ if (opt) {
670
+ opt.classList.add('is-active');
671
+ input.setAttribute('aria-activedescendant', opt.id);
672
+ // jsdom's scrollIntoView throws "Not implemented"; it is a
673
+ // pure affordance, so never let it break keyboard nav.
674
+ try {
675
+ opt.scrollIntoView({ block: 'nearest' });
676
+ } catch {
677
+ /* non-DOM/headless environment — ignore */
678
+ }
679
+ } else {
680
+ input.removeAttribute('aria-activedescendant');
681
+ }
682
+ };
683
+
684
+ const open = () => {
685
+ if (!list.hidden) return;
686
+ list.hidden = false;
687
+ input.setAttribute('aria-expanded', 'true');
688
+ };
689
+ const close = () => {
690
+ list.hidden = true;
691
+ input.setAttribute('aria-expanded', 'false');
692
+ active = -1;
693
+ setActive(null);
694
+ };
695
+
696
+ const filter = () => {
697
+ const q = input.value.trim().toLowerCase();
698
+ let any = false;
699
+ for (const o of options) {
700
+ const match = !q || o.textContent.toLowerCase().includes(q);
701
+ o.hidden = !match;
702
+ if (match) any = true;
703
+ }
704
+ if (empty) empty.hidden = any;
705
+ open();
706
+ };
707
+
708
+ const select = (opt) => {
709
+ input.value = opt.dataset.value ?? opt.textContent.trim();
710
+ options.forEach((o) => o.setAttribute('aria-selected', String(o === opt)));
711
+ close();
712
+ input.focus();
713
+ box.dispatchEvent(
714
+ new CustomEvent('bronto:change', {
715
+ detail: { value: input.value },
716
+ bubbles: true,
717
+ }),
718
+ );
719
+ };
720
+
721
+ const move = (delta) => {
722
+ const vis = visible();
723
+ if (!vis.length) return;
724
+ open();
725
+ const curIdx = vis.indexOf(options[active]);
726
+ let next = curIdx + delta;
727
+ if (next < 0) next = vis.length - 1;
728
+ if (next >= vis.length) next = 0;
729
+ active = options.indexOf(vis[next]);
730
+ setActive(options[active]);
731
+ };
732
+
733
+ const onInput = () => filter();
734
+ const onKey = (e) => {
735
+ switch (e.key) {
736
+ case 'ArrowDown':
737
+ e.preventDefault();
738
+ list.hidden ? filter() : move(1);
739
+ break;
740
+ case 'ArrowUp':
741
+ e.preventDefault();
742
+ move(-1);
743
+ break;
744
+ case 'Home':
745
+ if (!list.hidden) {
746
+ e.preventDefault();
747
+ const v = visible();
748
+ if (v.length) {
749
+ active = options.indexOf(v[0]);
750
+ setActive(options[active]);
751
+ }
752
+ }
753
+ break;
754
+ case 'End':
755
+ if (!list.hidden) {
756
+ e.preventDefault();
757
+ const v = visible();
758
+ if (v.length) {
759
+ active = options.indexOf(v[v.length - 1]);
760
+ setActive(options[active]);
761
+ }
762
+ }
763
+ break;
764
+ case 'Enter':
765
+ if (!list.hidden && active >= 0) {
766
+ e.preventDefault();
767
+ select(options[active]);
768
+ }
769
+ break;
770
+ case 'Escape':
771
+ if (!list.hidden) {
772
+ e.preventDefault();
773
+ close();
774
+ }
775
+ break;
776
+ case 'Tab':
777
+ close();
778
+ break;
779
+ default:
780
+ break;
781
+ }
782
+ };
783
+ const onOptionClick = (e) => {
784
+ const opt = e.target.closest('[role="option"], .ui-combobox__option');
785
+ if (opt) select(opt);
786
+ };
787
+ const onDocClick = (e) => {
788
+ if (!box.contains(e.target)) close();
789
+ };
790
+
791
+ const bound = bindOnce(box, 'combobox', () => {
792
+ input.addEventListener('input', onInput);
793
+ input.addEventListener('keydown', onKey);
794
+ list.addEventListener('click', onOptionClick);
795
+ document.addEventListener('click', onDocClick);
796
+ return () => {
797
+ input.removeEventListener('input', onInput);
798
+ input.removeEventListener('keydown', onKey);
799
+ list.removeEventListener('click', onOptionClick);
800
+ document.removeEventListener('click', onDocClick);
801
+ };
802
+ });
803
+ cleanups.push(bound);
804
+ }
805
+
806
+ return () => cleanups.forEach((fn) => fn());
807
+ }
808
+
809
+ /**
810
+ * Collision-aware popover, dependency-free. A `[data-bronto-popover]`
811
+ * trigger toggles the `.ui-popover` panel whose id it names. The panel
812
+ * is placed under the trigger and **flips above** when it would
813
+ * overflow the viewport, with its inline edge clamped on-screen — the
814
+ * thing the CSS-only tooltip can't do near edges / inside scroll
815
+ * containers. If the panel has the native `popover` attribute and the
816
+ * Popover API is available it is shown in the top layer (never
817
+ * clipped); otherwise an `.is-open` class is toggled. Manages
818
+ * `aria-expanded` / `aria-controls`, closes on Escape and outside
819
+ * click, and re-positions on scroll/resize while open. SSR-safe,
820
+ * idempotent; returns a cleanup function.
821
+ */
822
+ export function initPopover({ root } = {}) {
823
+ if (!hasDom()) return noop;
824
+ const host = root || document;
825
+ const view = document.defaultView;
826
+ const GAP = 8;
827
+ let openPanel = null;
828
+ let openTrigger = null;
829
+
830
+ const place = (trigger, panel) => {
831
+ const r = trigger.getBoundingClientRect();
832
+ const pw = panel.offsetWidth;
833
+ const ph = panel.offsetHeight;
834
+ const vw = view?.innerWidth ?? 0;
835
+ const vh = view?.innerHeight ?? 0;
836
+ let top = r.bottom + GAP;
837
+ if (top + ph > vh && r.top - GAP - ph >= 0) top = r.top - GAP - ph;
838
+ let left = r.left;
839
+ if (vw) left = Math.max(GAP, Math.min(left, vw - pw - GAP));
840
+ panel.style.top = `${Math.max(GAP, top)}px`;
841
+ panel.style.left = `${left}px`;
842
+ };
843
+
844
+ const close = () => {
845
+ if (!openPanel) return;
846
+ const panel = openPanel;
847
+ const trigger = openTrigger;
848
+ openPanel = openTrigger = null;
849
+ if (panel.hasAttribute('popover') && typeof panel.hidePopover === 'function') {
850
+ try {
851
+ panel.hidePopover();
852
+ } catch {
853
+ /* already hidden */
854
+ }
855
+ } else {
856
+ panel.classList.remove('is-open');
857
+ }
858
+ if (trigger) trigger.setAttribute('aria-expanded', 'false');
859
+ };
860
+
861
+ const open = (trigger, panel) => {
862
+ close();
863
+ trigger.setAttribute('aria-controls', panel.id);
864
+ trigger.setAttribute('aria-expanded', 'true');
865
+ if (panel.hasAttribute('popover') && typeof panel.showPopover === 'function') {
866
+ try {
867
+ panel.showPopover();
868
+ } catch {
869
+ panel.classList.add('is-open');
870
+ }
871
+ } else {
872
+ panel.classList.add('is-open');
873
+ }
874
+ openPanel = panel;
875
+ openTrigger = trigger;
876
+ place(trigger, panel);
877
+ };
878
+
879
+ const onClick = (e) => {
880
+ const trigger = e.target.closest?.('[data-bronto-popover]');
881
+ if (trigger) {
882
+ const panel = document.getElementById(trigger.getAttribute('data-bronto-popover'));
883
+ if (!panel) return;
884
+ e.preventDefault();
885
+ if (openPanel === panel) close();
886
+ else open(trigger, panel);
887
+ return;
888
+ }
889
+ if (openPanel && !openPanel.contains(e.target)) close();
890
+ };
891
+ const onKey = (e) => {
892
+ if (e.key === 'Escape' && openPanel) {
893
+ const t = openTrigger;
894
+ close();
895
+ t?.focus?.();
896
+ }
897
+ };
898
+ const onReflow = () => {
899
+ if (openPanel && openTrigger) place(openTrigger, openPanel);
900
+ };
901
+
902
+ return bindOnce(host, 'popover', () => {
903
+ host.addEventListener('click', onClick);
904
+ document.addEventListener('keydown', onKey);
905
+ view?.addEventListener('scroll', onReflow, true);
906
+ view?.addEventListener('resize', onReflow);
907
+ return () => {
908
+ host.removeEventListener('click', onClick);
909
+ document.removeEventListener('keydown', onKey);
910
+ view?.removeEventListener('scroll', onReflow, true);
911
+ view?.removeEventListener('resize', onReflow);
912
+ };
913
+ });
914
+ }
915
+
916
+ /**
917
+ * Client-side sortable + selectable data table. Wires
918
+ * `[data-bronto-sortable]`:
919
+ *
920
+ * - clicking a header's `.ui-table__sort` (or a `th[data-sort]`)
921
+ * sorts the tbody by that column, cycling `aria-sort`
922
+ * none → ascending → descending and clearing the other headers.
923
+ * Numeric columns (`data-sort="num"` or `.is-num` cells) sort
924
+ * numerically; everything else, locale string compare.
925
+ * - a `[data-bronto-select-all]` checkbox toggles every
926
+ * `[data-bronto-select]` row checkbox and the rows'
927
+ * `aria-selected`; toggling a row keeps the header checkbox's
928
+ * checked/indeterminate state in sync. Emits `bronto:selectionchange`
929
+ * ({ detail: { count } }) on the table.
930
+ *
931
+ * SSR-safe, idempotent per table; returns a cleanup function.
932
+ */
933
+ export function initTableSort({ root } = {}) {
934
+ if (!hasDom()) return noop;
935
+ const host = root || document;
936
+ const tables = [];
937
+ if (host !== document && host.matches?.('[data-bronto-sortable]')) tables.push(host);
938
+ tables.push(...(host.querySelectorAll?.('[data-bronto-sortable]') ?? []));
939
+ const cleanups = [];
940
+
941
+ for (const table of tables) {
942
+ const tbody = table.tBodies[0];
943
+ if (!tbody) continue;
944
+
945
+ const colIndex = (th) => [...th.parentElement.children].indexOf(th);
946
+ const cellText = (row, i) => row.children[i]?.textContent.trim() ?? '';
947
+
948
+ const sortBy = (th, numeric) => {
949
+ const headers = th.closest('tr').querySelectorAll('th');
950
+ const dir = th.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
951
+ headers.forEach((h) => h.removeAttribute('aria-sort'));
952
+ th.setAttribute('aria-sort', dir);
953
+ const i = colIndex(th);
954
+ const sign = dir === 'ascending' ? 1 : -1;
955
+ const rows = [...tbody.rows].filter((r) => !r.classList.contains('ui-table__empty'));
956
+ rows.sort((a, b) => {
957
+ const x = cellText(a, i);
958
+ const y = cellText(b, i);
959
+ const cmp = numeric
960
+ ? (parseFloat(x.replace(/[^\d.-]/g, '')) || 0) -
961
+ (parseFloat(y.replace(/[^\d.-]/g, '')) || 0)
962
+ : x.localeCompare(y);
963
+ return cmp * sign;
964
+ });
965
+ rows.forEach((r) => tbody.appendChild(r));
966
+ };
967
+
968
+ const allBox = table.querySelector('[data-bronto-select-all]');
969
+ const rowBoxes = () => [...table.querySelectorAll('[data-bronto-select]')];
970
+ const syncAll = () => {
971
+ const boxes = rowBoxes();
972
+ const on = boxes.filter((b) => b.checked).length;
973
+ if (allBox) {
974
+ allBox.checked = on > 0 && on === boxes.length;
975
+ allBox.indeterminate = on > 0 && on < boxes.length;
976
+ }
977
+ table.dispatchEvent(
978
+ new CustomEvent('bronto:selectionchange', { detail: { count: on }, bubbles: true }),
979
+ );
980
+ };
981
+ const markRow = (box) => {
982
+ const tr = box.closest('tr');
983
+ if (tr) tr.setAttribute('aria-selected', String(box.checked));
984
+ };
985
+
986
+ const onClick = (e) => {
987
+ const sorter = e.target.closest('.ui-table__sort, th[data-sort]');
988
+ if (sorter && table.contains(sorter)) {
989
+ const th = sorter.closest('th');
990
+ const numeric =
991
+ (sorter.getAttribute('data-sort') || th.getAttribute('data-sort')) === 'num' ||
992
+ th.classList.contains('is-num');
993
+ sortBy(th, numeric);
994
+ }
995
+ };
996
+ const onChange = (e) => {
997
+ const t = e.target;
998
+ if (t.matches?.('[data-bronto-select-all]')) {
999
+ rowBoxes().forEach((b) => {
1000
+ b.checked = t.checked;
1001
+ markRow(b);
1002
+ });
1003
+ syncAll();
1004
+ } else if (t.matches?.('[data-bronto-select]')) {
1005
+ markRow(t);
1006
+ syncAll();
1007
+ }
1008
+ };
1009
+
1010
+ const bound = bindOnce(table, 'tableSort', () => {
1011
+ table.addEventListener('click', onClick);
1012
+ table.addEventListener('change', onChange);
1013
+ return () => {
1014
+ table.removeEventListener('click', onClick);
1015
+ table.removeEventListener('change', onChange);
1016
+ };
1017
+ });
1018
+ cleanups.push(bound);
1019
+ }
1020
+
1021
+ return () => cleanups.forEach((fn) => fn());
311
1022
  }