@ponchia/ui 0.4.0 → 0.5.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.
Files changed (123) hide show
  1. package/CHANGELOG.md +352 -12
  2. package/MIGRATIONS.json +92 -0
  3. package/README.md +27 -17
  4. package/annotations/index.d.ts +280 -0
  5. package/annotations/index.js +522 -0
  6. package/behaviors/carousel.js +197 -0
  7. package/behaviors/combobox.js +195 -0
  8. package/behaviors/command.js +187 -0
  9. package/behaviors/connectors.js +96 -0
  10. package/behaviors/crosshair.js +58 -0
  11. package/behaviors/dialog.js +73 -0
  12. package/behaviors/disclosure.js +25 -0
  13. package/behaviors/dismissible.js +24 -0
  14. package/behaviors/forms.js +158 -0
  15. package/behaviors/glyph.js +109 -0
  16. package/behaviors/index.d.ts +82 -1
  17. package/behaviors/index.js +18 -1336
  18. package/behaviors/internal.js +50 -0
  19. package/behaviors/legend.js +46 -0
  20. package/behaviors/menu.js +46 -0
  21. package/behaviors/popover.js +108 -0
  22. package/behaviors/spotlight.js +53 -0
  23. package/behaviors/table.js +109 -0
  24. package/behaviors/tabs.js +103 -0
  25. package/behaviors/theme.js +82 -0
  26. package/behaviors/toast.js +152 -0
  27. package/classes/index.d.ts +318 -0
  28. package/classes/index.js +352 -0
  29. package/classes/vscode.css-custom-data.json +18 -14
  30. package/connectors/index.d.ts +71 -0
  31. package/connectors/index.js +179 -0
  32. package/css/analytical.css +21 -0
  33. package/css/annotations.css +292 -0
  34. package/css/command.css +97 -0
  35. package/css/connectors.css +93 -0
  36. package/css/crosshair.css +100 -0
  37. package/css/disclosure.css +29 -0
  38. package/css/dots.css +2 -0
  39. package/css/feedback.css +104 -0
  40. package/css/fonts.css +11 -7
  41. package/css/generated.css +117 -0
  42. package/css/legend.css +268 -0
  43. package/css/marks.css +144 -0
  44. package/css/motion.css +88 -0
  45. package/css/overlay.css +52 -1
  46. package/css/primitives.css +18 -0
  47. package/css/report.css +363 -0
  48. package/css/selection.css +46 -0
  49. package/css/sources.css +179 -0
  50. package/css/spotlight.css +104 -0
  51. package/css/state.css +121 -0
  52. package/css/tokens.css +78 -60
  53. package/css/workbench.css +83 -0
  54. package/dist/bronto.css +1 -1
  55. package/dist/css/analytical.css +1 -0
  56. package/dist/css/annotations.css +1 -0
  57. package/dist/css/command.css +1 -0
  58. package/dist/css/connectors.css +1 -0
  59. package/dist/css/crosshair.css +1 -0
  60. package/dist/css/disclosure.css +1 -1
  61. package/dist/css/dots.css +1 -1
  62. package/dist/css/feedback.css +1 -1
  63. package/dist/css/fonts.css +1 -1
  64. package/dist/css/generated.css +1 -0
  65. package/dist/css/legend.css +1 -0
  66. package/dist/css/marks.css +1 -0
  67. package/dist/css/motion.css +1 -1
  68. package/dist/css/overlay.css +1 -1
  69. package/dist/css/primitives.css +1 -1
  70. package/dist/css/report.css +1 -0
  71. package/dist/css/selection.css +1 -0
  72. package/dist/css/sources.css +1 -0
  73. package/dist/css/spotlight.css +1 -0
  74. package/dist/css/state.css +1 -0
  75. package/dist/css/tokens.css +1 -1
  76. package/dist/css/workbench.css +1 -0
  77. package/docs/adr/0001-color-system.md +26 -27
  78. package/docs/adr/0002-scope-and-2026-baseline.md +104 -0
  79. package/docs/adr/0003-theme-model.md +97 -0
  80. package/docs/annotations.md +345 -0
  81. package/docs/architecture.md +202 -0
  82. package/docs/command.md +95 -0
  83. package/docs/connectors.md +91 -0
  84. package/docs/contrast.md +42 -42
  85. package/docs/crosshair.md +63 -0
  86. package/docs/generated.md +91 -0
  87. package/docs/legends.md +168 -0
  88. package/docs/marks.md +86 -0
  89. package/docs/reference.md +418 -15
  90. package/docs/reporting.md +305 -0
  91. package/docs/selection.md +40 -0
  92. package/docs/sources.md +110 -0
  93. package/docs/spotlight.md +78 -0
  94. package/docs/stability.md +52 -0
  95. package/docs/state.md +85 -0
  96. package/docs/theming.md +18 -6
  97. package/docs/usage.md +43 -3
  98. package/docs/workbench.md +72 -0
  99. package/fonts/doto-400.woff2 +0 -0
  100. package/fonts/doto-500.woff2 +0 -0
  101. package/fonts/doto-600.woff2 +0 -0
  102. package/fonts/doto-700.woff2 +0 -0
  103. package/fonts/doto-800.woff2 +0 -0
  104. package/fonts/doto-900.woff2 +0 -0
  105. package/llms.txt +288 -8
  106. package/package.json +95 -5
  107. package/qwik/index.d.ts +60 -0
  108. package/qwik/index.js +149 -0
  109. package/react/index.d.ts +40 -14
  110. package/react/index.js +32 -5
  111. package/solid/index.d.ts +40 -14
  112. package/solid/index.js +31 -3
  113. package/tokens/index.d.ts +3 -3
  114. package/tokens/index.js +25 -19
  115. package/tokens/index.json +32 -28
  116. package/tokens/resolved.json +34 -32
  117. package/tokens/tokens.dtcg.json +22 -14
  118. package/fonts/doto-400.ttf +0 -0
  119. package/fonts/doto-500.ttf +0 -0
  120. package/fonts/doto-600.ttf +0 -0
  121. package/fonts/doto-700.ttf +0 -0
  122. package/fonts/doto-800.ttf +0 -0
  123. package/fonts/doto-900.ttf +0 -0
@@ -13,1339 +13,21 @@
13
13
  * applyStoredTheme(); // before paint, avoids theme flash
14
14
  * const stop = initThemeToggle(); // wire [data-bronto-theme-toggle]
15
15
  */
16
-
17
- import { GLYPH_SIZE, glyphCells } from '../glyphs/glyphs.js';
18
-
19
- const THEMES = ['light', 'dark'];
20
- const noop = () => {};
21
- const hasDom = () => typeof document !== 'undefined';
22
-
23
- // Module-global so tab ids stay unique across *every* initTabs() call.
24
- // A per-call counter makes separate islands/roots all mint `bronto-tab-1`,
25
- // which collides aria-controls/aria-labelledby across the document.
26
- let tabUid = 0;
27
-
28
- // Same rationale for auto-minted form-field / error-slot ids.
29
- let fieldUid = 0;
30
-
31
- // First-toast deferral queue. The very first toast on a brand-new stack
32
- // is appended next frame so AT observes the empty aria-live region
33
- // before its first child. Any further toasts created *before* that frame
34
- // flushes are queued behind it so call order (FIFO) is preserved instead
35
- // of a synchronous later toast jumping ahead of the deferred first one.
36
- const toastQueue = [];
37
- let toastFlushScheduled = false;
38
-
39
- // Make delegated initializers idempotent. Re-binding the same logical
40
- // listener on the same host/element tears the previous binding down first,
41
- // so double-init (HMR, framework re-mount, repeated calls) never stacks
42
- // duplicate handlers (the "double-toggle" class of bug). The returned
43
- // cleanup removes the single live binding.
44
- const BOUND = Symbol('bronto-bound');
45
- function bindOnce(target, key, add) {
46
- const reg = target[BOUND] || (target[BOUND] = Object.create(null));
47
- if (reg[key]) reg[key]();
48
- const remove = add();
49
- const cleanup = () => {
50
- remove();
51
- if (reg[key] === cleanup) delete reg[key];
52
- };
53
- reg[key] = cleanup;
54
- return cleanup;
55
- }
56
-
57
- /**
58
- * Apply the persisted theme to <html data-theme>. Call as early as
59
- * possible (an inline module in <head>) to avoid a flash before the
60
- * toggle wires up. No stored value → leaves prefers-color-scheme to act.
61
- */
62
- export function applyStoredTheme({ storageKey = 'bronto-theme', root } = {}) {
63
- if (!hasDom()) return;
64
- const el = root || document.documentElement;
65
- let stored = null;
66
- try {
67
- stored = localStorage.getItem(storageKey);
68
- } catch {
69
- /* storage blocked (private mode / sandbox) — fall through to OS default */
70
- }
71
- if (stored && THEMES.includes(stored)) el.setAttribute('data-theme', stored);
72
- }
73
-
74
- /**
75
- * Wire `[data-bronto-theme-toggle]` controls. Click toggles light/dark,
76
- * persists to localStorage, and **always** sets `data-theme` on <html>
77
- * (a theme is document-global). State is reflected via `aria-pressed`
78
- * and a `bronto:themechange` CustomEvent ({ detail: { theme } }) is
79
- * dispatched on <html> so consumers can sync their own UI without
80
- * racing the click handler. A control may set
81
- * `data-bronto-theme-toggle="dark"` to force a specific theme.
82
- *
83
- * `root` scopes event delegation and which controls are queried/reflected
84
- * (default `document`); it does not change where the theme is applied.
85
- */
86
- export function initThemeToggle({ storageKey = 'bronto-theme', root } = {}) {
87
- if (!hasDom()) return noop;
88
- const host = root || document;
89
- const docEl = document.documentElement;
90
-
91
- const prefersDark = () =>
92
- typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches;
93
-
94
- const current = () => {
95
- const attr = docEl.getAttribute('data-theme');
96
- if (THEMES.includes(attr)) return attr;
97
- return prefersDark() ? 'dark' : 'light';
98
- };
99
-
100
- const reflect = () => {
101
- const c = current();
102
- host.querySelectorAll('[data-bronto-theme-toggle]').forEach((el) => {
103
- const forced = el.getAttribute('data-bronto-theme-toggle');
104
- // A forced control is "pressed" when its theme is the active one;
105
- // a plain toggle reflects whether dark is active.
106
- const pressed = THEMES.includes(forced) ? c === forced : c === 'dark';
107
- el.setAttribute('aria-pressed', String(pressed));
108
- });
109
- };
110
-
111
- const onClick = (e) => {
112
- const trigger = e.target.closest('[data-bronto-theme-toggle]');
113
- if (!trigger || !host.contains(trigger)) return;
114
- const forced = trigger.getAttribute('data-bronto-theme-toggle');
115
- const next = THEMES.includes(forced) ? forced : current() === 'dark' ? 'light' : 'dark';
116
- docEl.setAttribute('data-theme', next);
117
- try {
118
- localStorage.setItem(storageKey, next);
119
- } catch {
120
- /* storage blocked — theme still applies for this session */
121
- }
122
- reflect();
123
- docEl.dispatchEvent(
124
- new CustomEvent('bronto:themechange', { detail: { theme: next }, bubbles: true }),
125
- );
126
- };
127
-
128
- applyStoredTheme({ storageKey });
129
- reflect();
130
- return bindOnce(host, 'themeToggle', () => {
131
- host.addEventListener('click', onClick);
132
- return () => host.removeEventListener('click', onClick);
133
- });
134
- }
135
-
136
- /**
137
- * Click on `[data-bronto-dismiss]` removes the nearest ancestor matching
138
- * `[data-bronto-dismissible]` (or the selector given as the attribute
139
- * value). Emits a cancelable `bronto:dismiss` event first.
140
- */
141
- export function dismissible({ root } = {}) {
142
- if (!hasDom()) return noop;
143
- const host = root || document;
144
- const onClick = (e) => {
145
- const btn = e.target.closest('[data-bronto-dismiss]');
146
- if (!btn || !host.contains(btn)) return;
147
- const sel = btn.getAttribute('data-bronto-dismiss');
148
- const target = sel ? btn.closest(sel) : btn.closest('[data-bronto-dismissible]');
149
- if (!target) return;
150
- const ev = new CustomEvent('bronto:dismiss', { bubbles: true, cancelable: true });
151
- if (target.dispatchEvent(ev)) target.remove();
152
- };
153
- return bindOnce(host, 'dismissible', () => {
154
- host.addEventListener('click', onClick);
155
- return () => host.removeEventListener('click', onClick);
156
- });
157
- }
158
-
159
- /**
160
- * Wire `[data-bronto-tabs]` groups for full keyboard a11y. The framework
161
- * ships the look + the ARIA/`.is-active` contract; this adds the WAI-ARIA
162
- * Tabs pattern: roving `tabindex`, `aria-selected`, Arrow/Home/End
163
- * navigation with automatic activation, and panel `hidden` sync. Tabs are
164
- * `.ui-tab[data-tab]`; panels are `.ui-tabs__panel[data-panel]` with
165
- * matching values. SSR-safe and idempotent (re-init replaces, never
166
- * stacks, the per-group listeners); returns a cleanup function.
167
- *
168
- * Accessibility caveat: this is what makes tabs operable. Do **not**
169
- * author `hidden` on `.ui-tabs__panel` in server-rendered markup unless
170
- * `initTabs` is guaranteed to run client-side — without it the panels
171
- * stay hidden with no keyboard/pointer way to reveal them. Prefer
172
- * authoring all panels visible and letting `initTabs` add `hidden`.
173
- */
174
- export function initTabs({ root } = {}) {
175
- if (!hasDom()) return noop;
176
- const host = root || document;
177
- const cleanups = [];
178
- // querySelectorAll only matches descendants, so a `root` that *is* a
179
- // tab group would be skipped — include it explicitly.
180
- const groups = [];
181
- if (host !== document && host.matches?.('[data-bronto-tabs]')) groups.push(host);
182
- groups.push(...host.querySelectorAll('[data-bronto-tabs]'));
183
- for (const group of groups) {
184
- // Own group only — a tab/panel inside a nested [data-bronto-tabs]
185
- // belongs to that inner group, not this one.
186
- const owned = (el) => el.closest('[data-bronto-tabs]') === group;
187
- const tabs = [...group.querySelectorAll('.ui-tab')].filter(owned);
188
- const panels = [...group.querySelectorAll('.ui-tabs__panel')].filter(owned);
189
- if (!tabs.length) continue;
190
- const list = group.querySelector('.ui-tabs__list');
191
- if (list) list.setAttribute('role', 'tablist');
192
-
193
- // APG: bind each tab to its panel (aria-controls) and back
194
- // (aria-labelledby), minting stable ids only where absent.
195
- for (const t of tabs) {
196
- const p = panels.find((x) => x.dataset.panel === t.dataset.tab);
197
- if (!p) continue;
198
- const n = ++tabUid;
199
- if (!t.id) t.id = `bronto-tab-${n}`;
200
- if (!p.id) p.id = `bronto-tabpanel-${n}`;
201
- t.setAttribute('aria-controls', p.id);
202
- p.setAttribute('aria-labelledby', t.id);
203
- }
204
-
205
- const select = (tab) => {
206
- for (const t of tabs) {
207
- const on = t === tab;
208
- t.classList.toggle('is-active', on);
209
- t.setAttribute('role', 'tab');
210
- t.setAttribute('aria-selected', String(on));
211
- t.tabIndex = on ? 0 : -1;
212
- }
213
- for (const p of panels) {
214
- p.setAttribute('role', 'tabpanel');
215
- p.hidden = p.dataset.panel !== tab.dataset.tab;
216
- }
217
- };
218
- const onClick = (e) => {
219
- // `tabs` is filtered to this group, so membership (not mere DOM
220
- // containment) is what isolates nested [data-bronto-tabs] groups.
221
- const tab = e.target.closest('.ui-tab');
222
- if (tab && tabs.includes(tab)) {
223
- select(tab);
224
- tab.focus();
225
- }
226
- };
227
- const onKey = (e) => {
228
- const i = tabs.indexOf(e.target.closest('.ui-tab'));
229
- if (i < 0) return;
230
- let n = i;
231
- if (e.key === 'ArrowRight' || e.key === 'ArrowDown') n = (i + 1) % tabs.length;
232
- else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
233
- n = (i - 1 + tabs.length) % tabs.length;
234
- else if (e.key === 'Home') n = 0;
235
- else if (e.key === 'End') n = tabs.length - 1;
236
- else return;
237
- e.preventDefault();
238
- select(tabs[n]);
239
- tabs[n].focus();
240
- };
241
- select(tabs.find((t) => t.classList.contains('is-active')) || tabs[0]);
242
- cleanups.push(
243
- bindOnce(group, 'tabs', () => {
244
- group.addEventListener('click', onClick);
245
- group.addEventListener('keydown', onKey);
246
- return () => {
247
- group.removeEventListener('click', onClick);
248
- group.removeEventListener('keydown', onKey);
249
- };
250
- }),
251
- );
252
- }
253
- return () => cleanups.forEach((fn) => fn());
254
- }
255
-
256
- /**
257
- * Wire native <dialog> open/close glue (the one bit <dialog> can't do
258
- * declaratively). Click `[data-bronto-open="dialogId"]` calls
259
- * `showModal()` on `#dialogId`; click `[data-bronto-close]` closes the
260
- * nearest enclosing <dialog>. Clicking the backdrop of a dialog that has
261
- * `[data-bronto-dialog-light]` closes it too. On open the trigger is
262
- * remembered and focus is returned to it on *every* close path (Esc,
263
- * close button, backdrop light-dismiss, programmatic) via the native
264
- * `close` event, so keyboard/SR users are never dropped at `<body>`.
265
- * SSR-safe and idempotent; returns cleanup.
266
- *
267
- * `root` scopes which triggers are delegated (default `document`); the
268
- * dialog itself is still resolved by id document-wide, because a modal
269
- * <dialog> is promoted to the top layer and is inherently document-global
270
- * (same model as `initThemeToggle`, where `root` scopes controls but the
271
- * theme applies to <html>).
272
- */
273
- export function initDialog({ root } = {}) {
274
- if (!hasDom()) return noop;
275
- const host = root || document;
276
- const onClick = (e) => {
277
- const opener = e.target.closest('[data-bronto-open]');
278
- if (opener && host.contains(opener)) {
279
- const dlg = document.getElementById(opener.getAttribute('data-bronto-open'));
280
- if (dlg && typeof dlg.showModal === 'function' && !dlg.open) {
281
- dlg.addEventListener(
282
- 'close',
283
- () => {
284
- if (opener.isConnected && typeof opener.focus === 'function') opener.focus();
285
- },
286
- { once: true },
287
- );
288
- dlg.showModal();
289
- }
290
- return;
291
- }
292
- const closer = e.target.closest('[data-bronto-close]');
293
- if (closer && host.contains(closer)) {
294
- const dlg = closer.closest('dialog');
295
- if (dlg && dlg.open) dlg.close();
296
- return;
297
- }
298
- // Light-dismiss: a click whose target is the <dialog> itself is the
299
- // backdrop (content sits in child elements).
300
- const dlg = e.target;
301
- if (
302
- dlg.tagName === 'DIALOG' &&
303
- dlg.open &&
304
- dlg.hasAttribute('data-bronto-dialog-light') &&
305
- host.contains(dlg)
306
- ) {
307
- dlg.close();
308
- }
309
- };
310
- return bindOnce(host, 'dialog', () => {
311
- host.addEventListener('click', onClick);
312
- return () => host.removeEventListener('click', onClick);
313
- });
314
- }
315
-
316
- /**
317
- * Push a transient toast into a shared, screen-anchored stack. The stack
318
- * is the `aria-live="polite"` region: it is created once, appended to
319
- * <body>, and **kept resident even when empty** so the live region is
320
- * always present before content is inserted (a freshly created region
321
- * that receives its first child in the same tick is not reliably
322
- * announced by VoiceOver/NVDA). On first creation the empty region is
323
- * inserted and the toast is appended on the next frame for the same
324
- * reason. `tone` is accent/success/warning/danger/info; `title` is an
325
- * optional uppercase label; `duration` ms before auto-dismiss (0 keeps
326
- * it until dismissed). Returns a function that dismisses the toast
327
- * early. SSR-safe (no-op).
328
- */
329
- export function toast(message, { tone, title, duration = 4000, assertive, closable } = {}) {
330
- if (!hasDom()) return noop;
331
- // Errors must interrupt: danger toasts (or an explicit `assertive`)
332
- // go to a SEPARATE assertive region so they announce immediately,
333
- // while status toasts stay polite. Two regions — not a per-item
334
- // role=alert nested in a polite parent — avoids the double
335
- // announcement that nesting causes in some screen readers.
336
- const isAssertive = assertive ?? tone === 'danger';
337
- const stackSel = isAssertive
338
- ? '.ui-toast-stack--assertive'
339
- : '.ui-toast-stack:not(.ui-toast-stack--assertive)';
340
- let stack = document.querySelector(stackSel);
341
- const freshStack = !stack;
342
- if (!stack) {
343
- stack = document.createElement('div');
344
- stack.className = isAssertive ? 'ui-toast-stack ui-toast-stack--assertive' : 'ui-toast-stack';
345
- stack.setAttribute('aria-live', isAssertive ? 'assertive' : 'polite');
346
- if (isAssertive) stack.setAttribute('role', 'alert');
347
- document.body.appendChild(stack);
348
- }
349
- const el = document.createElement('div');
350
- el.className = tone ? `ui-toast ui-toast--${tone}` : 'ui-toast';
351
- // No per-item role: the stack itself is the live region; a nested
352
- // live region risks double announcement in some SRs.
353
- if (title) {
354
- const t = document.createElement('p');
355
- t.className = 'ui-toast__title';
356
- t.textContent = title;
357
- el.appendChild(t);
358
- }
359
- const body = document.createElement('div');
360
- body.textContent = message;
361
- el.appendChild(body);
362
- // Append after a frame the *first* time so the empty live region is
363
- // observed by AT before its first child arrives; once the region has
364
- // been observed, later toasts append synchronously.
365
- let dismissed = false;
366
- // `dismissed` guard: a toast dismissed before its frame (e.g.
367
- // duration:0 + immediate dismiss) must NOT be resurrected into the
368
- // persistent aria-live region.
369
- const place = () => {
370
- if (!dismissed) stack.appendChild(el);
371
- };
372
- const canDefer = typeof requestAnimationFrame === 'function';
373
- if (freshStack && canDefer) {
374
- toastQueue.push(place);
375
- toastFlushScheduled = true;
376
- requestAnimationFrame(() => {
377
- toastFlushScheduled = false;
378
- for (const fn of toastQueue.splice(0)) fn();
379
- });
380
- } else if (toastFlushScheduled) {
381
- // A first-frame deferral is in flight — queue behind it so FIFO
382
- // order holds and the region still isn't populated synchronously.
383
- toastQueue.push(place);
384
- } else {
385
- place();
386
- }
387
-
388
- let timer;
389
- const dismiss = () => {
390
- if (dismissed) return;
391
- dismissed = true;
392
- if (timer) clearTimeout(timer);
393
- el.remove();
394
- // The stack is a persistent live region — never removed on drain, so
395
- // the next toast does not recreate (and thus mis-announce) it.
396
- };
397
- // A sticky toast (duration:0) is unusable without a manual close, so
398
- // it gets a dismiss affordance by default; any toast can opt in via
399
- // `closable`. The button carries no text node (glyph is a CSS
400
- // ::before) so the toast's announced/textContent stays the message.
401
- if (closable ?? duration === 0) {
402
- const close = document.createElement('button');
403
- close.type = 'button';
404
- close.className = 'ui-toast__close';
405
- close.setAttribute('aria-label', 'Dismiss');
406
- close.addEventListener('click', dismiss);
407
- el.appendChild(close);
408
- }
409
- if (duration > 0) timer = setTimeout(dismiss, duration);
410
- return dismiss;
411
- }
412
-
413
- /**
414
- * Disclosure: a `[data-bronto-disclosure]` trigger toggles the element
415
- * referenced by its `aria-controls` id, keeping `aria-expanded` and the
416
- * panel's `hidden` attribute in sync.
417
- */
418
- export function initDisclosure({ root } = {}) {
419
- if (!hasDom()) return noop;
420
- const host = root || document;
421
- const onClick = (e) => {
422
- const trigger = e.target.closest('[data-bronto-disclosure]');
423
- if (!trigger || !host.contains(trigger)) return;
424
- const id = trigger.getAttribute('aria-controls');
425
- const panel = id && document.getElementById(id);
426
- if (!panel) return;
427
- const open = trigger.getAttribute('aria-expanded') === 'true';
428
- trigger.setAttribute('aria-expanded', String(!open));
429
- panel.hidden = open;
430
- };
431
- return bindOnce(host, 'disclosure', () => {
432
- host.addEventListener('click', onClick);
433
- return () => host.removeEventListener('click', onClick);
434
- });
435
- }
436
-
437
- /**
438
- * Dropdown-menu close affordances for a native `<details data-bronto-menu>`
439
- * holding a `.ui-menu`. `<details>` alone won't close on Escape, on an
440
- * outside click, or when a `.ui-menu__item` is activated — this adds
441
- * exactly those, returning focus to the `<summary>` on Esc/activate.
442
- *
443
- * Deliberately NOT a full WAI-ARIA menu (no arrow-key roving): the items
444
- * are real buttons, Tab-reachable; this is a disclosure of actions, and
445
- * over-claiming `role="menu"` semantics would be worse. SSR-safe,
446
- * idempotent; returns a cleanup function.
447
- */
448
- export function initMenu({ root } = {}) {
449
- if (!hasDom()) return noop;
450
- const host = root || document;
451
- const openMenus = () => host.querySelectorAll?.('[data-bronto-menu][open]') ?? [];
452
- const shut = (menu) => {
453
- if (!menu || !menu.open) return;
454
- menu.open = false;
455
- menu.querySelector('summary')?.focus();
456
- };
457
- const onClick = (e) => {
458
- const menu = e.target.closest('[data-bronto-menu]');
459
- // Activate an item → close its menu (and return focus to summary).
460
- if (menu && e.target.closest('.ui-menu__item')) {
461
- shut(menu);
462
- return;
463
- }
464
- // Click outside any open menu → close them all (no focus move).
465
- for (const m of openMenus()) if (!m.contains(e.target)) m.open = false;
466
- };
467
- const onKey = (e) => {
468
- if (e.key !== 'Escape') return;
469
- const menu = e.target.closest?.('[data-bronto-menu][open]') || openMenus()[0];
470
- shut(menu);
471
- };
472
- return bindOnce(host, 'menu', () => {
473
- host.addEventListener('click', onClick);
474
- host.addEventListener('keydown', onKey);
475
- return () => {
476
- host.removeEventListener('click', onClick);
477
- host.removeEventListener('keydown', onKey);
478
- };
479
- });
480
- }
481
-
482
- /**
483
- * Accessible form validation glue for `<form data-bronto-validate>`.
484
- * Progressive enhancement over the native Constraint Validation API —
485
- * the framework already ships the `[aria-invalid]` / `.ui-hint--error`
486
- * styling; this wires the a11y plumbing every consumer would otherwise
487
- * re-implement (and usually get wrong):
488
- *
489
- * - suppresses the native error bubbles (`form.noValidate`),
490
- * - on blur and on submit sets `aria-invalid` and writes the browser's
491
- * `validationMessage` into the field's error slot
492
- * (`[data-bronto-error]` inside the `.ui-field`, falling back to a
493
- * `.ui-hint`), linked via `aria-describedby`,
494
- * - on an invalid submit, fills the form's
495
- * `[data-bronto-error-summary]` (a `.ui-error-summary`) with
496
- * in-page links to each bad field, focuses it, and blocks submit.
497
- *
498
- * Pure enhancement: with JS off the form still submits and the browser
499
- * validates natively. SSR-safe, idempotent; returns a cleanup function.
500
- */
501
- export function initFormValidation({ root } = {}) {
502
- if (!hasDom()) return noop;
503
- const host = root || document;
504
-
505
- const ensureId = (el, prefix) => {
506
- if (!el.id) el.id = `${prefix}-${++fieldUid}`;
507
- return el.id;
508
- };
509
-
510
- const slotFor = (control) => {
511
- const field = control.closest('.ui-field');
512
- if (!field) return null;
513
- return field.querySelector('[data-bronto-error]') || field.querySelector('.ui-hint');
514
- };
515
-
516
- const link = (control, slot) => {
517
- const slotId = ensureId(slot, 'bronto-err');
518
- const ids = (control.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
519
- if (!ids.includes(slotId)) {
520
- ids.push(slotId);
521
- control.setAttribute('aria-describedby', ids.join(' '));
522
- }
523
- };
524
-
525
- const validateField = (control) => {
526
- if (!control.willValidate) return true;
527
- const ok = control.validity.valid;
528
- const slot = slotFor(control);
529
- if (ok) {
530
- control.removeAttribute('aria-invalid');
531
- if (slot) {
532
- slot.textContent = '';
533
- if (slot.classList.contains('ui-hint')) slot.classList.remove('ui-hint--error');
534
- }
535
- } else {
536
- control.setAttribute('aria-invalid', 'true');
537
- if (slot) {
538
- slot.textContent = control.validationMessage;
539
- if (slot.classList.contains('ui-hint')) slot.classList.add('ui-hint--error');
540
- link(control, slot);
541
- }
542
- }
543
- return ok;
544
- };
545
-
546
- const controlsOf = (form) =>
547
- [...form.elements].filter(
548
- (el) => el.willValidate && el.type !== 'submit' && el.type !== 'button',
549
- );
550
-
551
- const refreshSummary = (form, invalid) => {
552
- const summary = form.querySelector('[data-bronto-error-summary]');
553
- if (!summary) return;
554
- if (!invalid.length) {
555
- summary.hidden = true;
556
- summary.replaceChildren();
557
- return;
558
- }
559
- const title = document.createElement('p');
560
- title.className = 'ui-error-summary__title';
561
- title.textContent = 'There is a problem';
562
- const list = document.createElement('ul');
563
- list.className = 'ui-error-summary__list';
564
- for (const c of invalid) {
565
- const id = ensureId(c, 'bronto-field');
566
- const li = document.createElement('li');
567
- const a = document.createElement('a');
568
- a.href = `#${id}`;
569
- a.textContent = c.validationMessage;
570
- a.addEventListener('click', (e) => {
571
- e.preventDefault();
572
- c.focus();
573
- });
574
- li.appendChild(a);
575
- list.appendChild(li);
576
- }
577
- summary.replaceChildren(title, list);
578
- summary.setAttribute('role', 'alert');
579
- summary.tabIndex = -1;
580
- summary.hidden = false;
581
- };
582
-
583
- const onSubmit = (e) => {
584
- const form = e.target.closest?.('[data-bronto-validate]');
585
- if (!form) return;
586
- form.noValidate = true;
587
- const invalid = controlsOf(form).filter((c) => !validateField(c));
588
- refreshSummary(form, invalid);
589
- if (invalid.length) {
590
- e.preventDefault();
591
- const summary = form.querySelector('[data-bronto-error-summary]');
592
- (summary && !summary.hidden ? summary : invalid[0]).focus();
593
- }
594
- };
595
-
596
- const onBlur = (e) => {
597
- const control = e.target;
598
- if (!control.willValidate) return;
599
- const form = control.closest?.('[data-bronto-validate]');
600
- if (!form) return;
601
- form.noValidate = true;
602
- validateField(control);
603
- const summary = form.querySelector('[data-bronto-error-summary]');
604
- if (summary && !summary.hidden)
605
- refreshSummary(
606
- form,
607
- controlsOf(form).filter((c) => !c.validity.valid),
608
- );
609
- };
610
-
611
- return bindOnce(host, 'formValidation', () => {
612
- // Suppress native bubbles UP FRONT for forms present at init. The
613
- // in-handler `noValidate = true` only fires after the first
614
- // submit/blur, so the very first invalid real-browser submit would
615
- // otherwise show the native UA bubble instead of the Bronto
616
- // summary — contradicting the documented contract. (Forms added
617
- // after init are still covered by the in-handler set.)
618
- // Feature-detect rather than `instanceof Element` — `Element` is not
619
- // a guaranteed global (SSR / the no-DOM test env), and `host` is
620
- // either `document` (no `.matches`) or a root Element.
621
- const selfForm =
622
- typeof host.matches === 'function' && host.matches('[data-bronto-validate]') ? [host] : [];
623
- const forms = [...selfForm, ...(host.querySelectorAll?.('[data-bronto-validate]') ?? [])];
624
- const priorNoValidate = new Map();
625
- for (const f of forms) {
626
- priorNoValidate.set(f, f.noValidate);
627
- f.noValidate = true;
628
- }
629
- host.addEventListener('submit', onSubmit, true);
630
- host.addEventListener('focusout', onBlur);
631
- return () => {
632
- host.removeEventListener('submit', onSubmit, true);
633
- host.removeEventListener('focusout', onBlur);
634
- for (const [f, v] of priorNoValidate) f.noValidate = v;
635
- };
636
- });
637
- }
638
-
639
- /**
640
- * Editable combobox with a filtered listbox popup, implementing the
641
- * WAI-ARIA APG combobox pattern (the widget the framework most lacked
642
- * and consumers most often build badly). Dependency-free, no
643
- * positioning library — the list is CSS-anchored under the input.
644
- *
645
- * Markup: `[data-bronto-combobox]` wrapping an `<input role="combobox">`
646
- * (`.ui-combobox__input`) and a `<ul role="listbox">`
647
- * (`.ui-combobox__list`) of `<li role="option">` (`.ui-combobox__option`,
648
- * optional `data-value`). An optional `.ui-combobox__empty` shows when
649
- * nothing matches. The behavior owns ids, `aria-expanded`,
650
- * `aria-controls`, `aria-activedescendant`, roving active option,
651
- * type-to-filter, full keyboard (Down/Up/Home/End/Enter/Escape/Tab),
652
- * pointer select, and outside-click close; it emits a `bronto:change`
653
- * CustomEvent ({ detail: { value } }) on selection. SSR-safe,
654
- * idempotent per instance; returns a cleanup function.
655
- */
656
- export function initCombobox({ root } = {}) {
657
- if (!hasDom()) return noop;
658
- const host = root || document;
659
- const boxes = [];
660
- if (host !== document && host.matches?.('[data-bronto-combobox]')) boxes.push(host);
661
- boxes.push(...(host.querySelectorAll?.('[data-bronto-combobox]') ?? []));
662
- const cleanups = [];
663
-
664
- for (const box of boxes) {
665
- const input = box.querySelector('[role="combobox"], .ui-combobox__input');
666
- const list = box.querySelector('[role="listbox"], .ui-combobox__list');
667
- if (!input || !list) continue;
668
- const empty = box.querySelector('.ui-combobox__empty');
669
- const options = [...list.querySelectorAll('[role="option"], .ui-combobox__option')];
670
-
671
- const listId = list.id || (list.id = `bronto-cb-list-${++fieldUid}`);
672
- options.forEach((o, i) => {
673
- if (!o.id) o.id = `${listId}-opt-${i}`;
674
- o.setAttribute('role', 'option');
675
- });
676
- list.setAttribute('role', 'listbox');
677
- input.setAttribute('role', 'combobox');
678
- input.setAttribute('aria-controls', listId);
679
- input.setAttribute('aria-autocomplete', 'list');
680
- input.setAttribute('aria-expanded', 'false');
681
- input.setAttribute('autocomplete', 'off');
682
- list.hidden = true;
683
-
684
- let active = -1;
685
- const visible = () => options.filter((o) => !o.hidden);
686
-
687
- const setActive = (opt) => {
688
- options.forEach((o) => o.classList.remove('is-active'));
689
- if (opt) {
690
- opt.classList.add('is-active');
691
- input.setAttribute('aria-activedescendant', opt.id);
692
- // jsdom's scrollIntoView throws "Not implemented"; it is a
693
- // pure affordance, so never let it break keyboard nav.
694
- try {
695
- opt.scrollIntoView({ block: 'nearest' });
696
- } catch {
697
- /* non-DOM/headless environment — ignore */
698
- }
699
- } else {
700
- input.removeAttribute('aria-activedescendant');
701
- }
702
- };
703
-
704
- const open = () => {
705
- if (!list.hidden) return;
706
- list.hidden = false;
707
- input.setAttribute('aria-expanded', 'true');
708
- };
709
- const close = () => {
710
- list.hidden = true;
711
- input.setAttribute('aria-expanded', 'false');
712
- active = -1;
713
- setActive(null);
714
- };
715
-
716
- const filter = () => {
717
- const q = input.value.trim().toLowerCase();
718
- let any = false;
719
- for (const o of options) {
720
- const match = !q || o.textContent.toLowerCase().includes(q);
721
- o.hidden = !match;
722
- if (match) any = true;
723
- }
724
- if (empty) empty.hidden = any;
725
- // The active option may have just been filtered out — drop the
726
- // stale activedescendant so Enter can't select a hidden option.
727
- if (active >= 0 && options[active]?.hidden) {
728
- active = -1;
729
- setActive(null);
730
- }
731
- open();
732
- };
733
-
734
- const select = (opt) => {
735
- input.value = opt.dataset.value ?? opt.textContent.trim();
736
- options.forEach((o) => o.setAttribute('aria-selected', String(o === opt)));
737
- close();
738
- input.focus();
739
- box.dispatchEvent(
740
- new CustomEvent('bronto:change', {
741
- detail: { value: input.value },
742
- bubbles: true,
743
- }),
744
- );
745
- };
746
-
747
- const move = (delta) => {
748
- const vis = visible();
749
- if (!vis.length) return;
750
- open();
751
- const curIdx = vis.indexOf(options[active]);
752
- let next = curIdx + delta;
753
- if (next < 0) next = vis.length - 1;
754
- if (next >= vis.length) next = 0;
755
- active = options.indexOf(vis[next]);
756
- setActive(options[active]);
757
- };
758
-
759
- const onInput = () => filter();
760
- const onKey = (e) => {
761
- switch (e.key) {
762
- case 'ArrowDown':
763
- e.preventDefault();
764
- list.hidden ? filter() : move(1);
765
- break;
766
- case 'ArrowUp':
767
- e.preventDefault();
768
- move(-1);
769
- break;
770
- case 'Home':
771
- if (!list.hidden) {
772
- e.preventDefault();
773
- const v = visible();
774
- if (v.length) {
775
- active = options.indexOf(v[0]);
776
- setActive(options[active]);
777
- }
778
- }
779
- break;
780
- case 'End':
781
- if (!list.hidden) {
782
- e.preventDefault();
783
- const v = visible();
784
- if (v.length) {
785
- active = options.indexOf(v[v.length - 1]);
786
- setActive(options[active]);
787
- }
788
- }
789
- break;
790
- case 'Enter':
791
- if (!list.hidden && active >= 0 && !options[active].hidden) {
792
- e.preventDefault();
793
- select(options[active]);
794
- }
795
- break;
796
- case 'Escape':
797
- if (!list.hidden) {
798
- e.preventDefault();
799
- close();
800
- }
801
- break;
802
- case 'Tab':
803
- close();
804
- break;
805
- default:
806
- break;
807
- }
808
- };
809
- const onOptionClick = (e) => {
810
- const opt = e.target.closest('[role="option"], .ui-combobox__option');
811
- if (opt) select(opt);
812
- };
813
- const onDocClick = (e) => {
814
- if (!box.contains(e.target)) close();
815
- };
816
-
817
- const bound = bindOnce(box, 'combobox', () => {
818
- input.addEventListener('input', onInput);
819
- input.addEventListener('keydown', onKey);
820
- list.addEventListener('click', onOptionClick);
821
- document.addEventListener('click', onDocClick);
822
- return () => {
823
- input.removeEventListener('input', onInput);
824
- input.removeEventListener('keydown', onKey);
825
- list.removeEventListener('click', onOptionClick);
826
- document.removeEventListener('click', onDocClick);
827
- };
828
- });
829
- cleanups.push(bound);
830
- }
831
-
832
- return () => cleanups.forEach((fn) => fn());
833
- }
834
-
835
- /**
836
- * Collision-aware popover, dependency-free. A `[data-bronto-popover]`
837
- * trigger toggles the `.ui-popover` panel whose id it names. The panel
838
- * is placed under the trigger and **flips above** when it would
839
- * overflow the viewport, with its inline edge clamped on-screen — the
840
- * thing the CSS-only tooltip can't do near edges / inside scroll
841
- * containers. If the panel has the native `popover` attribute and the
842
- * Popover API is available it is shown in the top layer (never
843
- * clipped); otherwise an `.is-open` class is toggled. Manages
844
- * `aria-expanded` / `aria-controls`, closes on Escape and outside
845
- * click, and re-positions on scroll/resize while open. SSR-safe,
846
- * idempotent; returns a cleanup function.
847
- */
848
- export function initPopover({ root } = {}) {
849
- if (!hasDom()) return noop;
850
- const host = root || document;
851
- const view = document.defaultView;
852
- const GAP = 8;
853
- let openPanel = null;
854
- let openTrigger = null;
855
-
856
- const place = (trigger, panel) => {
857
- const r = trigger.getBoundingClientRect();
858
- const pw = panel.offsetWidth;
859
- const ph = panel.offsetHeight;
860
- const vw = view?.innerWidth ?? 0;
861
- const vh = view?.innerHeight ?? 0;
862
- let top = r.bottom + GAP;
863
- if (top + ph > vh && r.top - GAP - ph >= 0) top = r.top - GAP - ph;
864
- let left = r.left;
865
- if (vw) left = Math.max(GAP, Math.min(left, vw - pw - GAP));
866
- panel.style.top = `${Math.max(GAP, top)}px`;
867
- panel.style.left = `${left}px`;
868
- };
869
-
870
- const close = () => {
871
- if (!openPanel) return;
872
- const panel = openPanel;
873
- const trigger = openTrigger;
874
- openPanel = openTrigger = null;
875
- if (panel.hasAttribute('popover') && typeof panel.hidePopover === 'function') {
876
- try {
877
- panel.hidePopover();
878
- } catch {
879
- /* already hidden */
880
- }
881
- } else {
882
- panel.classList.remove('is-open');
883
- }
884
- if (trigger) trigger.setAttribute('aria-expanded', 'false');
885
- };
886
-
887
- const open = (trigger, panel) => {
888
- close();
889
- trigger.setAttribute('aria-controls', panel.id);
890
- trigger.setAttribute('aria-expanded', 'true');
891
- if (panel.hasAttribute('popover') && typeof panel.showPopover === 'function') {
892
- try {
893
- panel.showPopover();
894
- } catch {
895
- panel.classList.add('is-open');
896
- }
897
- } else {
898
- panel.classList.add('is-open');
899
- }
900
- openPanel = panel;
901
- openTrigger = trigger;
902
- place(trigger, panel);
903
- };
904
-
905
- const onClick = (e) => {
906
- const trigger = e.target.closest?.('[data-bronto-popover]');
907
- if (trigger) {
908
- const panel = document.getElementById(trigger.getAttribute('data-bronto-popover'));
909
- if (!panel) return;
910
- e.preventDefault();
911
- if (openPanel === panel) close();
912
- else open(trigger, panel);
913
- return;
914
- }
915
- if (openPanel && !openPanel.contains(e.target)) close();
916
- };
917
- const onKey = (e) => {
918
- if (e.key === 'Escape' && openPanel) {
919
- const t = openTrigger;
920
- close();
921
- t?.focus?.();
922
- }
923
- };
924
- const onReflow = () => {
925
- if (openPanel && openTrigger) place(openTrigger, openPanel);
926
- };
927
-
928
- return bindOnce(host, 'popover', () => {
929
- host.addEventListener('click', onClick);
930
- document.addEventListener('keydown', onKey);
931
- view?.addEventListener('scroll', onReflow, true);
932
- view?.addEventListener('resize', onReflow);
933
- return () => {
934
- host.removeEventListener('click', onClick);
935
- document.removeEventListener('keydown', onKey);
936
- view?.removeEventListener('scroll', onReflow, true);
937
- view?.removeEventListener('resize', onReflow);
938
- };
939
- });
940
- }
941
-
942
- /**
943
- * Client-side sortable + selectable data table. Wires
944
- * `[data-bronto-sortable]`:
945
- *
946
- * - clicking a header's `.ui-table__sort` (or a `th[data-sort]`)
947
- * sorts the tbody by that column, cycling `aria-sort`
948
- * none → ascending → descending and clearing the other headers.
949
- * Numeric columns (`data-sort="num"` or `.is-num` cells) sort
950
- * numerically; everything else, locale string compare.
951
- * - a `[data-bronto-select-all]` checkbox toggles every
952
- * `[data-bronto-select]` row checkbox and the rows'
953
- * `aria-selected`; toggling a row keeps the header checkbox's
954
- * checked/indeterminate state in sync. Emits `bronto:selectionchange`
955
- * ({ detail: { count } }) on the table.
956
- *
957
- * SSR-safe, idempotent per table; returns a cleanup function.
958
- */
959
- export function initTableSort({ root } = {}) {
960
- if (!hasDom()) return noop;
961
- const host = root || document;
962
- const tables = [];
963
- if (host !== document && host.matches?.('[data-bronto-sortable]')) tables.push(host);
964
- tables.push(...(host.querySelectorAll?.('[data-bronto-sortable]') ?? []));
965
- const cleanups = [];
966
-
967
- for (const table of tables) {
968
- const tbody = table.tBodies[0];
969
- if (!tbody) continue;
970
-
971
- const colIndex = (th) => [...th.parentElement.children].indexOf(th);
972
- const cellText = (row, i) => row.children[i]?.textContent.trim() ?? '';
973
-
974
- const sortBy = (th, numeric) => {
975
- const headers = th.closest('tr').querySelectorAll('th');
976
- const dir = th.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
977
- headers.forEach((h) => h.removeAttribute('aria-sort'));
978
- th.setAttribute('aria-sort', dir);
979
- const i = colIndex(th);
980
- const sign = dir === 'ascending' ? 1 : -1;
981
- const rows = [...tbody.rows].filter((r) => !r.classList.contains('ui-table__empty'));
982
- rows.sort((a, b) => {
983
- const x = cellText(a, i);
984
- const y = cellText(b, i);
985
- const cmp = numeric
986
- ? (parseFloat(x.replace(/[^\d.-]/g, '')) || 0) -
987
- (parseFloat(y.replace(/[^\d.-]/g, '')) || 0)
988
- : x.localeCompare(y);
989
- return cmp * sign;
990
- });
991
- rows.forEach((r) => tbody.appendChild(r));
992
- };
993
-
994
- const allBox = table.querySelector('[data-bronto-select-all]');
995
- const rowBoxes = () => [...table.querySelectorAll('[data-bronto-select]')];
996
- const syncAll = () => {
997
- const boxes = rowBoxes();
998
- const on = boxes.filter((b) => b.checked).length;
999
- if (allBox) {
1000
- allBox.checked = on > 0 && on === boxes.length;
1001
- allBox.indeterminate = on > 0 && on < boxes.length;
1002
- }
1003
- table.dispatchEvent(
1004
- new CustomEvent('bronto:selectionchange', { detail: { count: on }, bubbles: true }),
1005
- );
1006
- };
1007
- const markRow = (box) => {
1008
- const tr = box.closest('tr');
1009
- if (tr) tr.setAttribute('aria-selected', String(box.checked));
1010
- };
1011
-
1012
- const onClick = (e) => {
1013
- const sorter = e.target.closest('.ui-table__sort, th[data-sort]');
1014
- if (sorter && table.contains(sorter)) {
1015
- const th = sorter.closest('th');
1016
- const numeric =
1017
- (sorter.getAttribute('data-sort') || th.getAttribute('data-sort')) === 'num' ||
1018
- th.classList.contains('is-num');
1019
- sortBy(th, numeric);
1020
- }
1021
- };
1022
- const onChange = (e) => {
1023
- const t = e.target;
1024
- if (t.matches?.('[data-bronto-select-all]')) {
1025
- rowBoxes().forEach((b) => {
1026
- b.checked = t.checked;
1027
- markRow(b);
1028
- });
1029
- syncAll();
1030
- } else if (t.matches?.('[data-bronto-select]')) {
1031
- markRow(t);
1032
- syncAll();
1033
- }
1034
- };
1035
-
1036
- const bound = bindOnce(table, 'tableSort', () => {
1037
- table.addEventListener('click', onClick);
1038
- table.addEventListener('change', onChange);
1039
- return () => {
1040
- table.removeEventListener('click', onClick);
1041
- table.removeEventListener('change', onChange);
1042
- };
1043
- });
1044
- cleanups.push(bound);
1045
- }
1046
-
1047
- return () => cleanups.forEach((fn) => fn());
1048
- }
1049
-
1050
- /**
1051
- * Image carousel / gallery, built on CSS scroll-snap so touch + trackpad
1052
- * swipe (and momentum) are the browser's, not hand-rolled. This wires the
1053
- * parts scroll-snap can't do alone: prev/next buttons, keyboard nav, a
1054
- * thumbnail strip, the position counter, and ARIA — keeping a JS index in
1055
- * sync with the scroll position both ways.
1056
- *
1057
- * Markup: `[data-bronto-carousel]` containing a `.ui-carousel__viewport`
1058
- * of `.ui-carousel__slide` children; optionally
1059
- * `[data-bronto-carousel-prev]` / `[data-bronto-carousel-next]` controls,
1060
- * a `.ui-carousel__thumbs` list of `.ui-carousel__thumb` buttons, and a
1061
- * `.ui-carousel__status` counter slot. Add `data-bronto-carousel-loop` to
1062
- * wrap at the ends, `data-bronto-carousel-label` to name the region.
1063
- *
1064
- * A full-screen **lightbox** is the same markup inside a native
1065
- * `<dialog class="ui-lightbox">` opened by {@link initDialog}: the
1066
- * `<dialog>` provides the top layer, focus-trap, Escape and focus-return,
1067
- * so this behavior never touches focus management.
1068
- *
1069
- * Emits `bronto:change` ({ detail: { index } }) on every index change
1070
- * (button, key, thumbnail, or swipe). SSR-safe, idempotent per carousel;
1071
- * returns a cleanup function.
1072
- */
1073
- export function initCarousel({ root } = {}) {
1074
- if (!hasDom()) return noop;
1075
- const host = root || document;
1076
- const boxes = [];
1077
- if (host !== document && host.matches?.('[data-bronto-carousel]')) boxes.push(host);
1078
- boxes.push(...(host.querySelectorAll?.('[data-bronto-carousel]') ?? []));
1079
- const cleanups = [];
1080
-
1081
- for (const box of boxes) {
1082
- const viewport = box.querySelector('.ui-carousel__viewport');
1083
- if (!viewport) continue;
1084
- const slides = [...viewport.children].filter((el) =>
1085
- el.classList.contains('ui-carousel__slide'),
1086
- );
1087
- if (!slides.length) continue;
1088
- const n = slides.length;
1089
- const thumbs = [...box.querySelectorAll('.ui-carousel__thumb')];
1090
- const status = box.querySelector('.ui-carousel__status');
1091
- const prevBtn = box.querySelector('[data-bronto-carousel-prev]');
1092
- const nextBtn = box.querySelector('[data-bronto-carousel-next]');
1093
- const loop = box.hasAttribute('data-bronto-carousel-loop');
1094
-
1095
- // ARIA scaffolding — pragmatic carousel semantics (not the full APG
1096
- // tablist), the same restraint initMenu takes.
1097
- viewport.setAttribute('role', 'group');
1098
- viewport.setAttribute('aria-roledescription', 'carousel');
1099
- if (!viewport.hasAttribute('aria-label'))
1100
- viewport.setAttribute(
1101
- 'aria-label',
1102
- box.getAttribute('data-bronto-carousel-label') || 'Carousel',
1103
- );
1104
- if (!viewport.hasAttribute('tabindex')) viewport.tabIndex = 0;
1105
- slides.forEach((s, i) => {
1106
- s.setAttribute('role', 'group');
1107
- s.setAttribute('aria-roledescription', 'slide');
1108
- if (!s.hasAttribute('aria-label')) s.setAttribute('aria-label', `${i + 1} of ${n}`);
1109
- });
1110
- if (status) status.setAttribute('aria-live', 'polite');
1111
- for (const b of [prevBtn, nextBtn]) {
1112
- if (!b) continue;
1113
- if (b.tagName === 'BUTTON' && !b.hasAttribute('type')) b.type = 'button';
1114
- }
1115
- if (prevBtn && !prevBtn.hasAttribute('aria-label'))
1116
- prevBtn.setAttribute('aria-label', 'Previous');
1117
- if (nextBtn && !nextBtn.hasAttribute('aria-label')) nextBtn.setAttribute('aria-label', 'Next');
1118
-
1119
- let index = Math.max(
1120
- 0,
1121
- slides.findIndex((s) => s.hasAttribute('data-bronto-carousel-current')),
1122
- );
1123
-
1124
- // While a button/keyboard nav is smooth-scrolling, the IntersectionObserver
1125
- // would observe the intermediate slides crossing its threshold and re-fire
1126
- // `bronto:change` for each — a feedback burst on a single Home→End jump.
1127
- // This flag makes the IO drive the index on *user* swipes only; a timeout
1128
- // (not the patchy `scrollend` event) releases it once the scroll settles.
1129
- let programmatic = false;
1130
- let progTimer = null;
1131
- const holdProgrammatic = () => {
1132
- programmatic = true;
1133
- if (progTimer) clearTimeout(progTimer);
1134
- progTimer = setTimeout(() => {
1135
- programmatic = false;
1136
- }, 500);
1137
- progTimer?.unref?.(); // don't keep a Node test process alive
1138
- };
1139
-
1140
- const render = () => {
1141
- if (status) status.textContent = `${index + 1} / ${n}`;
1142
- thumbs.forEach((t, i) => {
1143
- if (i === index) t.setAttribute('aria-current', 'true');
1144
- else t.removeAttribute('aria-current');
1145
- });
1146
- if (prevBtn && !loop) prevBtn.disabled = index === 0;
1147
- if (nextBtn && !loop) nextBtn.disabled = index === n - 1;
1148
- };
1149
-
1150
- const emit = () =>
1151
- box.dispatchEvent(new CustomEvent('bronto:change', { detail: { index }, bubbles: true }));
1152
-
1153
- // jsdom (and any layout-less env) has no scrollIntoView; it's a pure
1154
- // affordance, so never let it break index/aria sync — same guard as
1155
- // initCombobox.
1156
- const reveal = (el) => {
1157
- try {
1158
- el?.scrollIntoView({ block: 'nearest', inline: 'center' });
1159
- } catch {
1160
- /* no layout — ignore */
1161
- }
1162
- };
1163
-
1164
- const goTo = (i, { emitChange = true } = {}) => {
1165
- const next = loop ? (i + n) % n : Math.max(0, Math.min(n - 1, i));
1166
- const changed = next !== index;
1167
- index = next;
1168
- holdProgrammatic(); // suppress IO echo from the smooth-scroll this triggers
1169
- reveal(slides[index]);
1170
- reveal(thumbs[index]);
1171
- render();
1172
- if (changed && emitChange) emit();
1173
- };
1174
-
1175
- const onKey = (e) => {
1176
- let target = null;
1177
- if (e.key === 'ArrowRight') target = index + 1;
1178
- else if (e.key === 'ArrowLeft') target = index - 1;
1179
- else if (e.key === 'Home') target = 0;
1180
- else if (e.key === 'End') target = n - 1;
1181
- else return;
1182
- e.preventDefault();
1183
- goTo(target);
1184
- };
1185
- const onClick = (e) => {
1186
- if (prevBtn && e.target.closest('[data-bronto-carousel-prev]')) {
1187
- goTo(index - 1);
1188
- return;
1189
- }
1190
- if (nextBtn && e.target.closest('[data-bronto-carousel-next]')) {
1191
- goTo(index + 1);
1192
- return;
1193
- }
1194
- const thumb = e.target.closest('.ui-carousel__thumb');
1195
- if (thumb) {
1196
- const i = thumbs.indexOf(thumb);
1197
- if (i >= 0) goTo(i);
1198
- }
1199
- };
1200
-
1201
- // Swipe sync (enhancement): when the user scrolls the viewport, snap
1202
- // the JS index to the slide that's settled into view. Feature-detected
1203
- // so the buttons/keyboard still work where IntersectionObserver is
1204
- // absent (jsdom, older engines).
1205
- let io = null;
1206
- if (typeof IntersectionObserver === 'function') {
1207
- io = new IntersectionObserver(
1208
- (entries) => {
1209
- if (programmatic) return; // ignore the echo of a button/key-driven scroll
1210
- let best = null;
1211
- for (const ent of entries) {
1212
- if (ent.isIntersecting && (!best || ent.intersectionRatio > best.intersectionRatio))
1213
- best = ent;
1214
- }
1215
- if (!best) return;
1216
- const i = slides.indexOf(best.target);
1217
- if (i >= 0 && i !== index) {
1218
- index = i;
1219
- render();
1220
- reveal(thumbs[index]);
1221
- emit();
1222
- }
1223
- },
1224
- { root: viewport, threshold: 0.6 },
1225
- );
1226
- slides.forEach((s) => io.observe(s));
1227
- }
1228
-
1229
- render();
1230
- const bound = bindOnce(box, 'carousel', () => {
1231
- viewport.addEventListener('keydown', onKey);
1232
- box.addEventListener('click', onClick);
1233
- return () => {
1234
- viewport.removeEventListener('keydown', onKey);
1235
- box.removeEventListener('click', onClick);
1236
- io?.disconnect();
1237
- if (progTimer) clearTimeout(progTimer);
1238
- };
1239
- });
1240
- cleanups.push(bound);
1241
- }
1242
-
1243
- return () => cleanups.forEach((fn) => fn());
1244
- }
1245
-
1246
- function restoreAttr(el, name, prev) {
1247
- if (prev === null) el.removeAttribute(name);
1248
- else el.setAttribute(name, prev);
1249
- }
1250
-
1251
- /**
1252
- * Expand `[data-bronto-glyph="name"]` placeholders into a `.ui-dotmatrix`
1253
- * grid of GLYPH_SIZE² cells — the DOM counterpart to renderGlyph() from
1254
- * `@ponchia/ui/glyphs`, for when you'd rather drop a placeholder than inline
1255
- * the markup. Decorative by default (`aria-hidden`); add
1256
- * `data-bronto-glyph-label` to expose it as `role="img"`. An unknown glyph
1257
- * name is left untouched. Idempotent (skips an already-expanded host); the
1258
- * returned cleanup removes the cells and restores the original attributes.
1259
- */
1260
- export function initDotGlyph({ root } = {}) {
1261
- if (!hasDom()) return noop;
1262
- const host = root || document;
1263
- const els = [];
1264
- if (host !== document && host.matches?.('[data-bronto-glyph]')) els.push(host);
1265
- els.push(...(host.querySelectorAll?.('[data-bronto-glyph]') ?? []));
1266
- const cleanups = [];
1267
-
1268
- for (const el of els) {
1269
- // Scope to DIRECT-child cells (the ones we append) — so a placeholder that
1270
- // legitimately nests its own .ui-dotmatrix is neither mis-read as already
1271
- // expanded here nor have its inner cells removed by cleanup below.
1272
- if (el.querySelector(':scope > .ui-dotmatrix__cell')) continue; // already expanded
1273
- const cells = glyphCells(el.getAttribute('data-bronto-glyph'));
1274
- if (!cells.length) continue; // unknown glyph — leave the placeholder as-is
1275
-
1276
- const label = el.getAttribute('data-bronto-glyph-label');
1277
- // `data-bronto-glyph-solid` → square, gapless pixel glyph (legible small),
1278
- // the DOM counterpart to renderGlyph's `solid` option. Implies glyph-only.
1279
- const solid = el.hasAttribute('data-bronto-glyph-solid');
1280
- // `data-bronto-glyph-anim="reveal|pulse"` → decorative animation (the DOM
1281
- // counterpart to renderGlyph's `anim`; reduced-motion-safe via CSS).
1282
- const animAttr = el.getAttribute('data-bronto-glyph-anim');
1283
- const animClass =
1284
- animAttr === 'reveal'
1285
- ? 'ui-dotmatrix--reveal'
1286
- : animAttr === 'pulse'
1287
- ? 'ui-dotmatrix--pulse'
1288
- : null;
1289
- const hadAnimClass = animClass ? el.classList.contains(animClass) : false;
1290
- const hadMatrix = el.classList.contains('ui-dotmatrix');
1291
- const hadCols = el.style.getPropertyValue('--dotmatrix-cols');
1292
- const hadRadius = el.style.getPropertyValue('--dotmatrix-dot-radius');
1293
- const hadGap = el.style.getPropertyValue('--dotmatrix-gap');
1294
- const hadAriaHidden = el.getAttribute('aria-hidden');
1295
- const hadRole = el.getAttribute('role');
1296
- const hadAriaLabel = el.getAttribute('aria-label');
1297
-
1298
- el.classList.add('ui-dotmatrix');
1299
- if (animClass) el.classList.add(animClass);
1300
- el.style.setProperty('--dotmatrix-cols', String(GLYPH_SIZE));
1301
- if (solid) {
1302
- el.style.setProperty('--dotmatrix-dot-radius', '0');
1303
- el.style.setProperty('--dotmatrix-gap', '0');
1304
- }
1305
- if (label) {
1306
- el.setAttribute('role', 'img');
1307
- el.setAttribute('aria-label', label);
1308
- el.removeAttribute('aria-hidden'); // a labelled img must not also be hidden
1309
- } else {
1310
- el.setAttribute('aria-hidden', 'true');
1311
- }
1312
-
1313
- const frag = document.createDocumentFragment();
1314
- cells.forEach((c, i) => {
1315
- const span = document.createElement('span');
1316
- span.className = !c.on
1317
- ? 'ui-dotmatrix__cell'
1318
- : c.tone === 'hot'
1319
- ? 'ui-dotmatrix__cell ui-dotmatrix__cell--hot'
1320
- : c.tone === 'accent'
1321
- ? 'ui-dotmatrix__cell ui-dotmatrix__cell--accent'
1322
- : 'ui-dotmatrix__cell';
1323
- if (!c.on && solid) span.style.background = 'transparent'; // glyph-only
1324
- if (animAttr === 'reveal') span.style.setProperty('--i', String(i)); // scan stagger
1325
- frag.appendChild(span);
1326
- });
1327
- el.appendChild(frag);
1328
-
1329
- cleanups.push(() => {
1330
- el.querySelectorAll(':scope > .ui-dotmatrix__cell').forEach((n) => n.remove());
1331
- if (!hadMatrix) el.classList.remove('ui-dotmatrix');
1332
- if (animClass && !hadAnimClass) el.classList.remove(animClass);
1333
- if (solid) {
1334
- if (hadRadius) el.style.setProperty('--dotmatrix-dot-radius', hadRadius);
1335
- else el.style.removeProperty('--dotmatrix-dot-radius');
1336
- if (hadGap) el.style.setProperty('--dotmatrix-gap', hadGap);
1337
- else el.style.removeProperty('--dotmatrix-gap');
1338
- }
1339
- if (hadCols) el.style.setProperty('--dotmatrix-cols', hadCols);
1340
- else el.style.removeProperty('--dotmatrix-cols');
1341
- restoreAttr(el, 'aria-hidden', hadAriaHidden);
1342
- restoreAttr(el, 'role', hadRole);
1343
- restoreAttr(el, 'aria-label', hadAriaLabel);
1344
- // Don't leave behind empty class=""/style="" we ourselves created.
1345
- if (el.getAttribute('class') === '') el.removeAttribute('class');
1346
- if (el.getAttribute('style') === '') el.removeAttribute('style');
1347
- });
1348
- }
1349
-
1350
- return () => cleanups.forEach((fn) => fn());
1351
- }
16
+ export { applyStoredTheme, initThemeToggle } from './theme.js';
17
+ export { dismissible } from './dismissible.js';
18
+ export { initTabs } from './tabs.js';
19
+ export { initDialog } from './dialog.js';
20
+ export { toast } from './toast.js';
21
+ export { initDisclosure } from './disclosure.js';
22
+ export { initMenu } from './menu.js';
23
+ export { initFormValidation } from './forms.js';
24
+ export { initCombobox } from './combobox.js';
25
+ export { initPopover } from './popover.js';
26
+ export { initTableSort } from './table.js';
27
+ export { initCarousel } from './carousel.js';
28
+ export { initDotGlyph } from './glyph.js';
29
+ export { initLegend } from './legend.js';
30
+ export { initConnectors } from './connectors.js';
31
+ export { initSpotlight } from './spotlight.js';
32
+ export { initCrosshair } from './crosshair.js';
33
+ export { initCommand } from './command.js';