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