@ponchia/ui 0.3.0 → 0.3.2

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.
@@ -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
- let stack = document.querySelector('.ui-toast-stack');
329
+ // Errors must interrupt: danger toasts (or an explicit `assertive`)
330
+ // go to a SEPARATE assertive region so they announce immediately,
331
+ // while status toasts stay polite. Two regions — not a per-item
332
+ // role=alert nested in a polite parent — avoids the double
333
+ // announcement that nesting causes in some screen readers.
334
+ const isAssertive = assertive ?? tone === 'danger';
335
+ const stackSel = isAssertive
336
+ ? '.ui-toast-stack--assertive'
337
+ : '.ui-toast-stack:not(.ui-toast-stack--assertive)';
338
+ let stack = document.querySelector(stackSel);
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 already aria-live=polite; a nested
337
- // status live region risks double announcement in some SRs.
349
+ // No per-item role: the stack itself is the live region; a nested
350
+ // live region risks double announcement in some SRs.
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
+ }