@ponchia/ui 0.3.0 → 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 +43 -61
- package/behaviors/index.d.ts +46 -0
- package/behaviors/index.js +575 -6
- package/classes/index.d.ts +25 -0
- package/classes/index.js +25 -0
- package/classes/vscode.css-custom-data.json +407 -0
- package/css/app.css +11 -2
- package/css/base.css +0 -1
- package/css/dots.css +1 -1
- package/css/feedback.css +87 -2
- package/css/forms.css +126 -0
- package/css/overlay.css +64 -1
- package/css/primitives.css +95 -0
- package/css/site.css +2 -2
- package/css/table.css +59 -0
- package/css/tokens.css +69 -21
- package/dist/bronto.css +1 -1
- package/dist/css/app.css +1 -1
- package/dist/css/base.css +1 -1
- package/dist/css/dots.css +1 -1
- package/dist/css/feedback.css +1 -1
- package/dist/css/forms.css +1 -1
- package/dist/css/overlay.css +1 -1
- package/dist/css/primitives.css +1 -1
- package/dist/css/site.css +1 -1
- package/dist/css/table.css +1 -1
- package/dist/css/tokens.css +1 -1
- package/package.json +13 -7
- package/tokens/index.d.ts +2 -2
- package/tokens/index.js +45 -14
- package/tokens/index.json +90 -28
- package/tokens/tokens.dtcg.json +219 -14
package/behaviors/index.js
CHANGED
|
@@ -23,6 +23,9 @@ const hasDom = () => typeof document !== 'undefined';
|
|
|
23
23
|
// which collides aria-controls/aria-labelledby across the document.
|
|
24
24
|
let tabUid = 0;
|
|
25
25
|
|
|
26
|
+
// Same rationale for auto-minted form-field / error-slot ids.
|
|
27
|
+
let fieldUid = 0;
|
|
28
|
+
|
|
26
29
|
// First-toast deferral queue. The very first toast on a brand-new stack
|
|
27
30
|
// is appended next frame so AT observes the empty aria-live region
|
|
28
31
|
// before its first child. Any further toasts created *before* that frame
|
|
@@ -321,20 +324,30 @@ export function initDialog({ root } = {}) {
|
|
|
321
324
|
* it until dismissed). Returns a function that dismisses the toast
|
|
322
325
|
* early. SSR-safe (no-op).
|
|
323
326
|
*/
|
|
324
|
-
export function toast(message, { tone, title, duration = 4000 } = {}) {
|
|
327
|
+
export function toast(message, { tone, title, duration = 4000, assertive, closable } = {}) {
|
|
325
328
|
if (!hasDom()) return noop;
|
|
326
|
-
|
|
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);
|
|
327
339
|
const freshStack = !stack;
|
|
328
340
|
if (!stack) {
|
|
329
341
|
stack = document.createElement('div');
|
|
330
|
-
stack.className = 'ui-toast-stack';
|
|
331
|
-
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');
|
|
332
345
|
document.body.appendChild(stack);
|
|
333
346
|
}
|
|
334
347
|
const el = document.createElement('div');
|
|
335
348
|
el.className = tone ? `ui-toast ui-toast--${tone}` : 'ui-toast';
|
|
336
|
-
// No per-item role: the stack is
|
|
337
|
-
//
|
|
349
|
+
// No per-item role: the stack itself is the live region; a nested
|
|
350
|
+
// live region risks double announcement in some SRs.
|
|
338
351
|
if (title) {
|
|
339
352
|
const t = document.createElement('p');
|
|
340
353
|
t.className = 'ui-toast__title';
|
|
@@ -379,6 +392,18 @@ export function toast(message, { tone, title, duration = 4000 } = {}) {
|
|
|
379
392
|
// The stack is a persistent live region — never removed on drain, so
|
|
380
393
|
// the next toast does not recreate (and thus mis-announce) it.
|
|
381
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
|
+
}
|
|
382
407
|
if (duration > 0) timer = setTimeout(dismiss, duration);
|
|
383
408
|
return dismiss;
|
|
384
409
|
}
|
|
@@ -451,3 +476,547 @@ export function initMenu({ root } = {}) {
|
|
|
451
476
|
};
|
|
452
477
|
});
|
|
453
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());
|
|
1022
|
+
}
|
package/classes/index.d.ts
CHANGED
|
@@ -53,6 +53,13 @@ export declare const cls: {
|
|
|
53
53
|
readonly switchThumb: 'ui-switch__thumb';
|
|
54
54
|
readonly hint: 'ui-hint';
|
|
55
55
|
readonly hintError: 'ui-hint--error';
|
|
56
|
+
readonly inputGroup: 'ui-input-group';
|
|
57
|
+
readonly inputGroupAddon: 'ui-input-group__addon';
|
|
58
|
+
readonly file: 'ui-file';
|
|
59
|
+
readonly range: 'ui-range';
|
|
60
|
+
readonly errorSummary: 'ui-error-summary';
|
|
61
|
+
readonly errorSummaryTitle: 'ui-error-summary__title';
|
|
62
|
+
readonly errorSummaryList: 'ui-error-summary__list';
|
|
56
63
|
readonly alert: 'ui-alert';
|
|
57
64
|
readonly alertTitle: 'ui-alert__title';
|
|
58
65
|
readonly alertBody: 'ui-alert__body';
|
|
@@ -62,14 +69,17 @@ export declare const cls: {
|
|
|
62
69
|
readonly alertWarning: 'ui-alert--warning';
|
|
63
70
|
readonly alertDanger: 'ui-alert--danger';
|
|
64
71
|
readonly toastStack: 'ui-toast-stack';
|
|
72
|
+
readonly toastStackAssertive: 'ui-toast-stack--assertive';
|
|
65
73
|
readonly toast: 'ui-toast';
|
|
66
74
|
readonly toastTitle: 'ui-toast__title';
|
|
75
|
+
readonly toastClose: 'ui-toast__close';
|
|
67
76
|
readonly toastAccent: 'ui-toast--accent';
|
|
68
77
|
readonly toastSuccess: 'ui-toast--success';
|
|
69
78
|
readonly toastWarning: 'ui-toast--warning';
|
|
70
79
|
readonly toastDanger: 'ui-toast--danger';
|
|
71
80
|
readonly tooltip: 'ui-tooltip';
|
|
72
81
|
readonly tooltipBubble: 'ui-tooltip__bubble';
|
|
82
|
+
readonly popover: 'ui-popover';
|
|
73
83
|
readonly progress: 'ui-progress';
|
|
74
84
|
readonly progressBar: 'ui-progress__bar';
|
|
75
85
|
readonly progressIndeterminate: 'ui-progress--indeterminate';
|
|
@@ -85,6 +95,11 @@ export declare const cls: {
|
|
|
85
95
|
readonly menuLabel: 'ui-menu__label';
|
|
86
96
|
readonly menuItem: 'ui-menu__item';
|
|
87
97
|
readonly menuSep: 'ui-menu__sep';
|
|
98
|
+
readonly combobox: 'ui-combobox';
|
|
99
|
+
readonly comboboxInput: 'ui-combobox__input';
|
|
100
|
+
readonly comboboxList: 'ui-combobox__list';
|
|
101
|
+
readonly comboboxOption: 'ui-combobox__option';
|
|
102
|
+
readonly comboboxEmpty: 'ui-combobox__empty';
|
|
88
103
|
readonly tabs: 'ui-tabs';
|
|
89
104
|
readonly tabsList: 'ui-tabs__list';
|
|
90
105
|
readonly tab: 'ui-tab';
|
|
@@ -109,6 +124,11 @@ export declare const cls: {
|
|
|
109
124
|
readonly tableLined: 'ui-table--lined';
|
|
110
125
|
readonly tableWrap: 'ui-table-wrap';
|
|
111
126
|
readonly tableEmpty: 'ui-table__empty';
|
|
127
|
+
readonly tableSort: 'ui-table__sort';
|
|
128
|
+
readonly tableSelect: 'ui-table__select';
|
|
129
|
+
readonly tableToolbar: 'ui-table__toolbar';
|
|
130
|
+
readonly tableSelectable: 'ui-table--selectable';
|
|
131
|
+
readonly tableLoading: 'ui-table--loading';
|
|
112
132
|
readonly panel: 'ui-panel';
|
|
113
133
|
readonly panelHead: 'ui-panel__head';
|
|
114
134
|
readonly surface: 'ui-surface';
|
|
@@ -116,6 +136,11 @@ export declare const cls: {
|
|
|
116
136
|
readonly cluster: 'ui-cluster';
|
|
117
137
|
readonly clusterBetween: 'ui-cluster--between';
|
|
118
138
|
readonly grid: 'ui-grid';
|
|
139
|
+
readonly sidebar: 'ui-sidebar';
|
|
140
|
+
readonly switcher: 'ui-switcher';
|
|
141
|
+
readonly center: 'ui-center';
|
|
142
|
+
readonly ratio: 'ui-ratio';
|
|
143
|
+
readonly cq: 'ui-cq';
|
|
119
144
|
readonly divider: 'ui-divider';
|
|
120
145
|
readonly status: 'ui-status';
|
|
121
146
|
readonly eyebrow: 'ui-eyebrow';
|