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