@ponchia/ui 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +230 -8
  2. package/MIGRATIONS.json +92 -0
  3. package/README.md +9 -6
  4. package/annotations/index.d.ts +280 -0
  5. package/annotations/index.js +522 -0
  6. package/behaviors/carousel.js +197 -0
  7. package/behaviors/combobox.js +195 -0
  8. package/behaviors/command.js +187 -0
  9. package/behaviors/connectors.js +96 -0
  10. package/behaviors/crosshair.js +58 -0
  11. package/behaviors/dialog.js +73 -0
  12. package/behaviors/disclosure.js +25 -0
  13. package/behaviors/dismissible.js +24 -0
  14. package/behaviors/forms.js +158 -0
  15. package/behaviors/glyph.js +109 -0
  16. package/behaviors/index.d.ts +79 -0
  17. package/behaviors/index.js +18 -1409
  18. package/behaviors/internal.js +50 -0
  19. package/behaviors/legend.js +46 -0
  20. package/behaviors/menu.js +46 -0
  21. package/behaviors/popover.js +108 -0
  22. package/behaviors/spotlight.js +53 -0
  23. package/behaviors/table.js +109 -0
  24. package/behaviors/tabs.js +103 -0
  25. package/behaviors/theme.js +82 -0
  26. package/behaviors/toast.js +152 -0
  27. package/classes/index.d.ts +280 -2
  28. package/classes/index.js +313 -2
  29. package/connectors/index.d.ts +71 -0
  30. package/connectors/index.js +179 -0
  31. package/css/analytical.css +21 -0
  32. package/css/annotations.css +292 -0
  33. package/css/command.css +97 -0
  34. package/css/connectors.css +93 -0
  35. package/css/crosshair.css +100 -0
  36. package/css/feedback.css +51 -0
  37. package/css/fonts.css +11 -7
  38. package/css/generated.css +117 -0
  39. package/css/legend.css +268 -0
  40. package/css/marks.css +144 -0
  41. package/css/primitives.css +18 -0
  42. package/css/report.css +12 -31
  43. package/css/selection.css +46 -0
  44. package/css/sources.css +179 -0
  45. package/css/spotlight.css +104 -0
  46. package/css/state.css +121 -0
  47. package/css/tokens.css +25 -37
  48. package/css/workbench.css +83 -0
  49. package/dist/bronto.css +1 -1
  50. package/dist/css/analytical.css +1 -0
  51. package/dist/css/annotations.css +1 -0
  52. package/dist/css/command.css +1 -0
  53. package/dist/css/connectors.css +1 -0
  54. package/dist/css/crosshair.css +1 -0
  55. package/dist/css/feedback.css +1 -1
  56. package/dist/css/fonts.css +1 -1
  57. package/dist/css/generated.css +1 -0
  58. package/dist/css/legend.css +1 -0
  59. package/dist/css/marks.css +1 -0
  60. package/dist/css/primitives.css +1 -1
  61. package/dist/css/report.css +1 -1
  62. package/dist/css/selection.css +1 -0
  63. package/dist/css/sources.css +1 -0
  64. package/dist/css/spotlight.css +1 -0
  65. package/dist/css/state.css +1 -0
  66. package/dist/css/workbench.css +1 -0
  67. package/docs/adr/0003-theme-model.md +7 -4
  68. package/docs/annotations.md +345 -0
  69. package/docs/architecture.md +202 -0
  70. package/docs/command.md +95 -0
  71. package/docs/connectors.md +91 -0
  72. package/docs/crosshair.md +63 -0
  73. package/docs/generated.md +91 -0
  74. package/docs/legends.md +168 -0
  75. package/docs/marks.md +86 -0
  76. package/docs/reference.md +309 -3
  77. package/docs/reporting.md +49 -14
  78. package/docs/selection.md +40 -0
  79. package/docs/sources.md +110 -0
  80. package/docs/spotlight.md +78 -0
  81. package/docs/stability.md +16 -1
  82. package/docs/state.md +85 -0
  83. package/docs/usage.md +22 -0
  84. package/docs/workbench.md +72 -0
  85. package/fonts/doto-400.woff2 +0 -0
  86. package/fonts/doto-500.woff2 +0 -0
  87. package/fonts/doto-600.woff2 +0 -0
  88. package/fonts/doto-700.woff2 +0 -0
  89. package/fonts/doto-800.woff2 +0 -0
  90. package/fonts/doto-900.woff2 +0 -0
  91. package/llms.txt +229 -6
  92. package/package.json +69 -4
  93. package/qwik/index.d.ts +5 -0
  94. package/qwik/index.js +20 -0
  95. package/react/index.d.ts +5 -0
  96. package/react/index.js +10 -0
  97. package/solid/index.d.ts +5 -0
  98. package/solid/index.js +10 -0
  99. package/tokens/index.js +9 -5
  100. package/fonts/doto-400.ttf +0 -0
  101. package/fonts/doto-500.ttf +0 -0
  102. package/fonts/doto-600.ttf +0 -0
  103. package/fonts/doto-700.ttf +0 -0
  104. package/fonts/doto-800.ttf +0 -0
  105. package/fonts/doto-900.ttf +0 -0
@@ -0,0 +1,50 @@
1
+ // Shared, dependency-free DOM helpers for the behavior modules.
2
+ // Not part of the public @ponchia/ui/behaviors surface (the barrel
3
+ // re-exports only the documented behaviors).
4
+
5
+ export const noop = () => {};
6
+
7
+ export const hasDom = () => typeof document !== 'undefined';
8
+
9
+ // Monotonic counter for auto-minted field / list ids, shared across
10
+ // initFormValidation and initCombobox so separate calls (and separate
11
+ // behaviors) never collide on an id.
12
+ let fieldUid = 0;
13
+ export const nextFieldUid = () => ++fieldUid;
14
+
15
+ // Make delegated initializers idempotent. Re-binding the same logical
16
+ // listener on the same host/element tears the previous binding down first,
17
+ // so double-init (HMR, framework re-mount, repeated calls) never stacks
18
+ // duplicate handlers (the "double-toggle" class of bug). The returned
19
+ // cleanup removes the single live binding.
20
+ const BOUND = Symbol('bronto-bound');
21
+
22
+ export function bindOnce(target, key, add) {
23
+ const reg = target[BOUND] || (target[BOUND] = Object.create(null));
24
+ if (reg[key]) reg[key]();
25
+ const remove = add();
26
+ const cleanup = () => {
27
+ remove();
28
+ if (reg[key] === cleanup) delete reg[key];
29
+ };
30
+ reg[key] = cleanup;
31
+ return cleanup;
32
+ }
33
+
34
+ export function byIdInHost(host, id) {
35
+ if (!id) return null;
36
+ if (host === document) return document.getElementById(id);
37
+ if (host.id === id) return host;
38
+ return (
39
+ Array.from(host.querySelectorAll?.('[id]') || []).find((el) => el.id === id) ||
40
+ document.getElementById(id)
41
+ );
42
+ }
43
+
44
+ export function closestSafe(el, selector) {
45
+ try {
46
+ return el.closest(selector);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
@@ -0,0 +1,46 @@
1
+ import { hasDom, noop, bindOnce } from './internal.js';
2
+
3
+ /**
4
+ * Wire `[data-bronto-legend]` interactive legends. Each entry is a
5
+ * `.ui-legend__item` authored as a `<button aria-pressed>`; clicking it (or
6
+ * Enter/Space, native to `<button>`) flips `aria-pressed`, toggles the
7
+ * `.is-inactive` class, and dispatches `bronto:legend:toggle` on the legend
8
+ * with `{ detail: { series, active } }` (`series` is the entry's
9
+ * `data-series`, or its 0-based index if unset).
10
+ *
11
+ * Bronto owns only the control and its pressed/inactive *state*. It does not
12
+ * know the chart's series, hide anything, or announce the change: the host
13
+ * listens for the event, hides its own series, and owns any `aria-live`
14
+ * announcement. The convention is `aria-pressed="true"` ⇒ the series is shown
15
+ * (the default); the entry's label never changes on toggle (WAI-ARIA). SSR-safe
16
+ * and idempotent per host; returns a cleanup function.
17
+ */
18
+ export function initLegend({ root } = {}) {
19
+ if (!hasDom()) return noop;
20
+ const host = root || document;
21
+ const onClick = (e) => {
22
+ const item = e.target.closest('.ui-legend__item');
23
+ if (!item || !host.contains(item)) return;
24
+ const legend = item.closest('[data-bronto-legend]');
25
+ if (!legend || !host.contains(legend)) return;
26
+ const active = item.getAttribute('aria-pressed') !== 'false';
27
+ const next = !active;
28
+ item.setAttribute('aria-pressed', String(next));
29
+ item.classList.toggle('is-inactive', !next);
30
+ // This legend's own items only — an item inside a nested [data-bronto-legend]
31
+ // belongs to that inner legend, so it must not shift this one's indices.
32
+ const items = [...legend.querySelectorAll('.ui-legend__item')].filter(
33
+ (el) => el.closest('[data-bronto-legend]') === legend,
34
+ );
35
+ legend.dispatchEvent(
36
+ new CustomEvent('bronto:legend:toggle', {
37
+ bubbles: true,
38
+ detail: { series: item.dataset.series ?? items.indexOf(item), active: next },
39
+ }),
40
+ );
41
+ };
42
+ return bindOnce(host, 'legend', () => {
43
+ host.addEventListener('click', onClick);
44
+ return () => host.removeEventListener('click', onClick);
45
+ });
46
+ }
@@ -0,0 +1,46 @@
1
+ import { hasDom, noop, bindOnce } from './internal.js';
2
+
3
+ /**
4
+ * Dropdown-menu close affordances for a native `<details data-bronto-menu>`
5
+ * holding a `.ui-menu`. `<details>` alone won't close on Escape, on an
6
+ * outside click, or when a `.ui-menu__item` is activated — this adds
7
+ * exactly those, returning focus to the `<summary>` on Esc/activate.
8
+ *
9
+ * Deliberately NOT a full WAI-ARIA menu (no arrow-key roving): the items
10
+ * are real buttons, Tab-reachable; this is a disclosure of actions, and
11
+ * over-claiming `role="menu"` semantics would be worse. SSR-safe,
12
+ * idempotent; returns a cleanup function.
13
+ */
14
+ export function initMenu({ root } = {}) {
15
+ if (!hasDom()) return noop;
16
+ const host = root || document;
17
+ const openMenus = () => host.querySelectorAll?.('[data-bronto-menu][open]') ?? [];
18
+ const shut = (menu) => {
19
+ if (!menu || !menu.open) return;
20
+ menu.open = false;
21
+ menu.querySelector('summary')?.focus();
22
+ };
23
+ const onClick = (e) => {
24
+ const menu = e.target.closest('[data-bronto-menu]');
25
+ // Activate an item → close its menu (and return focus to summary).
26
+ if (menu && e.target.closest('.ui-menu__item')) {
27
+ shut(menu);
28
+ return;
29
+ }
30
+ // Click outside any open menu → close them all (no focus move).
31
+ for (const m of openMenus()) if (!m.contains(e.target)) m.open = false;
32
+ };
33
+ const onKey = (e) => {
34
+ if (e.key !== 'Escape') return;
35
+ const menu = e.target.closest?.('[data-bronto-menu][open]') || openMenus()[0];
36
+ shut(menu);
37
+ };
38
+ return bindOnce(host, 'menu', () => {
39
+ host.addEventListener('click', onClick);
40
+ host.addEventListener('keydown', onKey);
41
+ return () => {
42
+ host.removeEventListener('click', onClick);
43
+ host.removeEventListener('keydown', onKey);
44
+ };
45
+ });
46
+ }
@@ -0,0 +1,108 @@
1
+ import { hasDom, noop, bindOnce, byIdInHost } from './internal.js';
2
+
3
+ /**
4
+ * Collision-aware popover, dependency-free. A `[data-bronto-popover]`
5
+ * trigger toggles the `.ui-popover` panel whose id it names. The panel
6
+ * is placed under the trigger and **flips above** when it would
7
+ * overflow the viewport, with its inline edge clamped on-screen — the
8
+ * thing the CSS-only tooltip can't do near edges / inside scroll
9
+ * containers. If the panel has the native `popover` attribute and the
10
+ * Popover API is available it is shown in the top layer (never
11
+ * clipped); otherwise an `.is-open` class is toggled. Manages
12
+ * `aria-expanded` / `aria-controls`, closes on Escape and outside
13
+ * click, and re-positions on scroll/resize while open. SSR-safe,
14
+ * idempotent; returns a cleanup function.
15
+ */
16
+ export function initPopover({ root } = {}) {
17
+ if (!hasDom()) return noop;
18
+ const host = root || document;
19
+ const view = document.defaultView;
20
+ const GAP = 8;
21
+ let openPanel = null;
22
+ let openTrigger = null;
23
+
24
+ const place = (trigger, panel) => {
25
+ const r = trigger.getBoundingClientRect();
26
+ const pw = panel.offsetWidth;
27
+ const ph = panel.offsetHeight;
28
+ const vw = view?.innerWidth ?? 0;
29
+ const vh = view?.innerHeight ?? 0;
30
+ let top = r.bottom + GAP;
31
+ if (top + ph > vh && r.top - GAP - ph >= 0) top = r.top - GAP - ph;
32
+ let left = r.left;
33
+ if (vw) left = Math.max(GAP, Math.min(left, vw - pw - GAP));
34
+ panel.style.top = `${Math.max(GAP, top)}px`;
35
+ panel.style.left = `${left}px`;
36
+ };
37
+
38
+ const close = () => {
39
+ if (!openPanel) return;
40
+ const panel = openPanel;
41
+ const trigger = openTrigger;
42
+ openPanel = openTrigger = null;
43
+ if (panel.hasAttribute('popover') && typeof panel.hidePopover === 'function') {
44
+ try {
45
+ panel.hidePopover();
46
+ } catch {
47
+ /* already hidden */
48
+ }
49
+ } else {
50
+ panel.classList.remove('is-open');
51
+ }
52
+ if (trigger) trigger.setAttribute('aria-expanded', 'false');
53
+ };
54
+
55
+ const open = (trigger, panel) => {
56
+ close();
57
+ trigger.setAttribute('aria-controls', panel.id);
58
+ trigger.setAttribute('aria-expanded', 'true');
59
+ if (panel.hasAttribute('popover') && typeof panel.showPopover === 'function') {
60
+ try {
61
+ panel.showPopover();
62
+ } catch {
63
+ panel.classList.add('is-open');
64
+ }
65
+ } else {
66
+ panel.classList.add('is-open');
67
+ }
68
+ openPanel = panel;
69
+ openTrigger = trigger;
70
+ place(trigger, panel);
71
+ };
72
+
73
+ const onClick = (e) => {
74
+ const trigger = e.target.closest?.('[data-bronto-popover]');
75
+ if (trigger && host.contains(trigger)) {
76
+ const panel = byIdInHost(host, trigger.getAttribute('data-bronto-popover'));
77
+ if (!panel) return;
78
+ e.preventDefault();
79
+ if (openPanel === panel) close();
80
+ else open(trigger, panel);
81
+ return;
82
+ }
83
+ if (openPanel && !openPanel.contains(e.target)) close();
84
+ };
85
+ const onKey = (e) => {
86
+ if (e.key === 'Escape' && openPanel) {
87
+ const t = openTrigger;
88
+ close();
89
+ t?.focus?.();
90
+ }
91
+ };
92
+ const onReflow = () => {
93
+ if (openPanel && openTrigger) place(openTrigger, openPanel);
94
+ };
95
+
96
+ return bindOnce(host, 'popover', () => {
97
+ document.addEventListener('click', onClick);
98
+ document.addEventListener('keydown', onKey);
99
+ view?.addEventListener('scroll', onReflow, true);
100
+ view?.addEventListener('resize', onReflow);
101
+ return () => {
102
+ document.removeEventListener('click', onClick);
103
+ document.removeEventListener('keydown', onKey);
104
+ view?.removeEventListener('scroll', onReflow, true);
105
+ view?.removeEventListener('resize', onReflow);
106
+ };
107
+ });
108
+ }
@@ -0,0 +1,53 @@
1
+ import { hasDom, noop, bindOnce, byIdInHost } from './internal.js';
2
+
3
+ /**
4
+ * Position a spotlight cutout over a target element. Each
5
+ * `[data-bronto-spotlight]` is a `.ui-spotlight` overlay; `data-target` is the
6
+ * id of the element to highlight. The behavior measures the target and sets
7
+ * `--spot-x/y/w/h` (viewport coordinates) on the overlay, re-placing on
8
+ * resize/scroll and whenever `data-target` changes.
9
+ *
10
+ * Bronto owns only positioning + the visual language. It is NOT a tour engine:
11
+ * the host decides which target is current, when to advance, and whether to
12
+ * show/hide the overlay — just update `data-target` (or toggle `hidden`) and
13
+ * the cutout follows. SSR-safe, idempotent per host; returns a cleanup.
14
+ */
15
+ export function initSpotlight({ root } = {}) {
16
+ if (!hasDom()) return noop;
17
+ const host = root || document;
18
+ const spots = [];
19
+ if (host !== document && host.matches?.('[data-bronto-spotlight]')) spots.push(host);
20
+ spots.push(...host.querySelectorAll('[data-bronto-spotlight]'));
21
+ if (!spots.length) return noop;
22
+
23
+ const place = () => {
24
+ for (const spot of spots) {
25
+ const target = byIdInHost(host, spot.dataset.target);
26
+ if (!target) continue;
27
+ const r = target.getBoundingClientRect();
28
+ spot.style.setProperty('--spot-x', `${r.left}px`);
29
+ spot.style.setProperty('--spot-y', `${r.top}px`);
30
+ spot.style.setProperty('--spot-w', `${r.width}px`);
31
+ spot.style.setProperty('--spot-h', `${r.height}px`);
32
+ }
33
+ };
34
+
35
+ return bindOnce(host, 'spotlight', () => {
36
+ place();
37
+ const view = host.defaultView || host.ownerDocument?.defaultView || null;
38
+ const MO = view?.MutationObserver;
39
+ const mo = MO ? new MO(place) : null;
40
+ if (mo) {
41
+ for (const spot of spots) {
42
+ mo.observe(spot, { attributes: true, attributeFilter: ['data-target'] });
43
+ }
44
+ }
45
+ view?.addEventListener('resize', place);
46
+ view?.addEventListener('scroll', place, true);
47
+ return () => {
48
+ mo?.disconnect();
49
+ view?.removeEventListener('resize', place);
50
+ view?.removeEventListener('scroll', place, true);
51
+ };
52
+ });
53
+ }
@@ -0,0 +1,109 @@
1
+ import { hasDom, noop, bindOnce } from './internal.js';
2
+
3
+ /**
4
+ * Client-side sortable + selectable data table. Wires
5
+ * `[data-bronto-sortable]`:
6
+ *
7
+ * - clicking a header's `.ui-table__sort` (or a `th[data-sort]`)
8
+ * sorts the tbody by that column, cycling `aria-sort`
9
+ * none → ascending → descending and clearing the other headers.
10
+ * Numeric columns (`data-sort="num"` or `.is-num` cells) sort
11
+ * numerically; everything else, locale string compare.
12
+ * - a `[data-bronto-select-all]` checkbox toggles every
13
+ * `[data-bronto-select]` row checkbox and the rows'
14
+ * `aria-selected`; toggling a row keeps the header checkbox's
15
+ * checked/indeterminate state in sync. Emits `bronto:selectionchange`
16
+ * ({ detail: { count } }) on the table.
17
+ *
18
+ * SSR-safe, idempotent per table; returns a cleanup function.
19
+ */
20
+ export function initTableSort({ root } = {}) {
21
+ if (!hasDom()) return noop;
22
+ const host = root || document;
23
+ const tables = [];
24
+ if (host !== document && host.matches?.('[data-bronto-sortable]')) tables.push(host);
25
+ tables.push(...(host.querySelectorAll?.('[data-bronto-sortable]') ?? []));
26
+ const cleanups = [];
27
+
28
+ for (const table of tables) {
29
+ const tbody = table.tBodies[0];
30
+ if (!tbody) continue;
31
+
32
+ const colIndex = (th) => [...th.parentElement.children].indexOf(th);
33
+ const cellText = (row, i) => row.children[i]?.textContent.trim() ?? '';
34
+
35
+ const sortBy = (th, numeric) => {
36
+ const headers = th.closest('tr').querySelectorAll('th');
37
+ const dir = th.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
38
+ headers.forEach((h) => h.removeAttribute('aria-sort'));
39
+ th.setAttribute('aria-sort', dir);
40
+ const i = colIndex(th);
41
+ const sign = dir === 'ascending' ? 1 : -1;
42
+ const rows = [...tbody.rows].filter((r) => !r.classList.contains('ui-table__empty'));
43
+ rows.sort((a, b) => {
44
+ const x = cellText(a, i);
45
+ const y = cellText(b, i);
46
+ const cmp = numeric
47
+ ? (parseFloat(x.replace(/[^\d.-]/g, '')) || 0) -
48
+ (parseFloat(y.replace(/[^\d.-]/g, '')) || 0)
49
+ : x.localeCompare(y);
50
+ return cmp * sign;
51
+ });
52
+ rows.forEach((r) => tbody.appendChild(r));
53
+ };
54
+
55
+ const allBox = table.querySelector('[data-bronto-select-all]');
56
+ const rowBoxes = () => [...table.querySelectorAll('[data-bronto-select]')];
57
+ const syncAll = () => {
58
+ const boxes = rowBoxes();
59
+ const on = boxes.filter((b) => b.checked).length;
60
+ if (allBox) {
61
+ allBox.checked = on > 0 && on === boxes.length;
62
+ allBox.indeterminate = on > 0 && on < boxes.length;
63
+ }
64
+ table.dispatchEvent(
65
+ new CustomEvent('bronto:selectionchange', { detail: { count: on }, bubbles: true }),
66
+ );
67
+ };
68
+ const markRow = (box) => {
69
+ const tr = box.closest('tr');
70
+ if (tr) tr.setAttribute('aria-selected', String(box.checked));
71
+ };
72
+
73
+ const onClick = (e) => {
74
+ const sorter = e.target.closest('.ui-table__sort, th[data-sort]');
75
+ if (sorter && table.contains(sorter)) {
76
+ const th = sorter.closest('th');
77
+ const numeric =
78
+ (sorter.getAttribute('data-sort') || th.getAttribute('data-sort')) === 'num' ||
79
+ th.classList.contains('is-num');
80
+ sortBy(th, numeric);
81
+ }
82
+ };
83
+ const onChange = (e) => {
84
+ const t = e.target;
85
+ if (t.matches?.('[data-bronto-select-all]')) {
86
+ rowBoxes().forEach((b) => {
87
+ b.checked = t.checked;
88
+ markRow(b);
89
+ });
90
+ syncAll();
91
+ } else if (t.matches?.('[data-bronto-select]')) {
92
+ markRow(t);
93
+ syncAll();
94
+ }
95
+ };
96
+
97
+ const bound = bindOnce(table, 'tableSort', () => {
98
+ table.addEventListener('click', onClick);
99
+ table.addEventListener('change', onChange);
100
+ return () => {
101
+ table.removeEventListener('click', onClick);
102
+ table.removeEventListener('change', onChange);
103
+ };
104
+ });
105
+ cleanups.push(bound);
106
+ }
107
+
108
+ return () => cleanups.forEach((fn) => fn());
109
+ }
@@ -0,0 +1,103 @@
1
+ import { hasDom, noop, bindOnce } from './internal.js';
2
+
3
+ // Module-global so tab ids stay unique across *every* initTabs() call.
4
+ // A per-call counter makes separate islands/roots all mint `bronto-tab-1`,
5
+ // which collides aria-controls/aria-labelledby across the document.
6
+ let tabUid = 0;
7
+
8
+ /**
9
+ * Wire `[data-bronto-tabs]` groups for full keyboard a11y. The framework
10
+ * ships the look + the ARIA/`.is-active` contract; this adds the WAI-ARIA
11
+ * Tabs pattern: roving `tabindex`, `aria-selected`, Arrow/Home/End
12
+ * navigation with automatic activation, and panel `hidden` sync. Tabs are
13
+ * `.ui-tab[data-tab]`; panels are `.ui-tabs__panel[data-panel]` with
14
+ * matching values. SSR-safe and idempotent (re-init replaces, never
15
+ * stacks, the per-group listeners); returns a cleanup function.
16
+ *
17
+ * Accessibility caveat: this is what makes tabs operable. Do **not**
18
+ * author `hidden` on `.ui-tabs__panel` in server-rendered markup unless
19
+ * `initTabs` is guaranteed to run client-side — without it the panels
20
+ * stay hidden with no keyboard/pointer way to reveal them. Prefer
21
+ * authoring all panels visible and letting `initTabs` add `hidden`.
22
+ */
23
+ export function initTabs({ root } = {}) {
24
+ if (!hasDom()) return noop;
25
+ const host = root || document;
26
+ const cleanups = [];
27
+ // querySelectorAll only matches descendants, so a `root` that *is* a
28
+ // tab group would be skipped — include it explicitly.
29
+ const groups = [];
30
+ if (host !== document && host.matches?.('[data-bronto-tabs]')) groups.push(host);
31
+ groups.push(...host.querySelectorAll('[data-bronto-tabs]'));
32
+ for (const group of groups) {
33
+ // Own group only — a tab/panel inside a nested [data-bronto-tabs]
34
+ // belongs to that inner group, not this one.
35
+ const owned = (el) => el.closest('[data-bronto-tabs]') === group;
36
+ const tabs = [...group.querySelectorAll('.ui-tab')].filter(owned);
37
+ const panels = [...group.querySelectorAll('.ui-tabs__panel')].filter(owned);
38
+ if (!tabs.length) continue;
39
+ const list = group.querySelector('.ui-tabs__list');
40
+ if (list) list.setAttribute('role', 'tablist');
41
+
42
+ // APG: bind each tab to its panel (aria-controls) and back
43
+ // (aria-labelledby), minting stable ids only where absent.
44
+ for (const t of tabs) {
45
+ const p = panels.find((x) => x.dataset.panel === t.dataset.tab);
46
+ if (!p) continue;
47
+ const n = ++tabUid;
48
+ if (!t.id) t.id = `bronto-tab-${n}`;
49
+ if (!p.id) p.id = `bronto-tabpanel-${n}`;
50
+ t.setAttribute('aria-controls', p.id);
51
+ p.setAttribute('aria-labelledby', t.id);
52
+ }
53
+
54
+ const select = (tab) => {
55
+ for (const t of tabs) {
56
+ const on = t === tab;
57
+ t.classList.toggle('is-active', on);
58
+ t.setAttribute('role', 'tab');
59
+ t.setAttribute('aria-selected', String(on));
60
+ t.tabIndex = on ? 0 : -1;
61
+ }
62
+ for (const p of panels) {
63
+ p.setAttribute('role', 'tabpanel');
64
+ p.hidden = p.dataset.panel !== tab.dataset.tab;
65
+ }
66
+ };
67
+ const onClick = (e) => {
68
+ // `tabs` is filtered to this group, so membership (not mere DOM
69
+ // containment) is what isolates nested [data-bronto-tabs] groups.
70
+ const tab = e.target.closest('.ui-tab');
71
+ if (tab && tabs.includes(tab)) {
72
+ select(tab);
73
+ tab.focus();
74
+ }
75
+ };
76
+ const onKey = (e) => {
77
+ const i = tabs.indexOf(e.target.closest('.ui-tab'));
78
+ if (i < 0) return;
79
+ let n = i;
80
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') n = (i + 1) % tabs.length;
81
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
82
+ n = (i - 1 + tabs.length) % tabs.length;
83
+ else if (e.key === 'Home') n = 0;
84
+ else if (e.key === 'End') n = tabs.length - 1;
85
+ else return;
86
+ e.preventDefault();
87
+ select(tabs[n]);
88
+ tabs[n].focus();
89
+ };
90
+ select(tabs.find((t) => t.classList.contains('is-active')) || tabs[0]);
91
+ cleanups.push(
92
+ bindOnce(group, 'tabs', () => {
93
+ group.addEventListener('click', onClick);
94
+ group.addEventListener('keydown', onKey);
95
+ return () => {
96
+ group.removeEventListener('click', onClick);
97
+ group.removeEventListener('keydown', onKey);
98
+ };
99
+ }),
100
+ );
101
+ }
102
+ return () => cleanups.forEach((fn) => fn());
103
+ }
@@ -0,0 +1,82 @@
1
+ import { hasDom, noop, bindOnce } from './internal.js';
2
+
3
+ const THEMES = ['light', 'dark'];
4
+
5
+ /**
6
+ * Apply the persisted theme to <html data-theme>. Call as early as
7
+ * possible (an inline module in <head>) to avoid a flash before the
8
+ * toggle wires up. No stored value → leaves prefers-color-scheme to act.
9
+ */
10
+ export function applyStoredTheme({ storageKey = 'bronto-theme', root } = {}) {
11
+ if (!hasDom()) return;
12
+ const el = root || document.documentElement;
13
+ let stored = null;
14
+ try {
15
+ stored = localStorage.getItem(storageKey);
16
+ } catch {
17
+ /* storage blocked (private mode / sandbox) — fall through to OS default */
18
+ }
19
+ if (stored && THEMES.includes(stored)) el.setAttribute('data-theme', stored);
20
+ }
21
+
22
+ /**
23
+ * Wire `[data-bronto-theme-toggle]` controls. Click toggles light/dark,
24
+ * persists to localStorage, and **always** sets `data-theme` on <html>
25
+ * (a theme is document-global). State is reflected via `aria-pressed`
26
+ * and a `bronto:themechange` CustomEvent ({ detail: { theme } }) is
27
+ * dispatched on <html> so consumers can sync their own UI without
28
+ * racing the click handler. A control may set
29
+ * `data-bronto-theme-toggle="dark"` to force a specific theme.
30
+ *
31
+ * `root` scopes event delegation and which controls are queried/reflected
32
+ * (default `document`); it does not change where the theme is applied.
33
+ */
34
+ export function initThemeToggle({ storageKey = 'bronto-theme', root } = {}) {
35
+ if (!hasDom()) return noop;
36
+ const host = root || document;
37
+ const docEl = document.documentElement;
38
+
39
+ const prefersDark = () =>
40
+ typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches;
41
+
42
+ const current = () => {
43
+ const attr = docEl.getAttribute('data-theme');
44
+ if (THEMES.includes(attr)) return attr;
45
+ return prefersDark() ? 'dark' : 'light';
46
+ };
47
+
48
+ const reflect = () => {
49
+ const c = current();
50
+ host.querySelectorAll('[data-bronto-theme-toggle]').forEach((el) => {
51
+ const forced = el.getAttribute('data-bronto-theme-toggle');
52
+ // A forced control is "pressed" when its theme is the active one;
53
+ // a plain toggle reflects whether dark is active.
54
+ const pressed = THEMES.includes(forced) ? c === forced : c === 'dark';
55
+ el.setAttribute('aria-pressed', String(pressed));
56
+ });
57
+ };
58
+
59
+ const onClick = (e) => {
60
+ const trigger = e.target.closest('[data-bronto-theme-toggle]');
61
+ if (!trigger || !host.contains(trigger)) return;
62
+ const forced = trigger.getAttribute('data-bronto-theme-toggle');
63
+ const next = THEMES.includes(forced) ? forced : current() === 'dark' ? 'light' : 'dark';
64
+ docEl.setAttribute('data-theme', next);
65
+ try {
66
+ localStorage.setItem(storageKey, next);
67
+ } catch {
68
+ /* storage blocked — theme still applies for this session */
69
+ }
70
+ reflect();
71
+ docEl.dispatchEvent(
72
+ new CustomEvent('bronto:themechange', { detail: { theme: next }, bubbles: true }),
73
+ );
74
+ };
75
+
76
+ applyStoredTheme({ storageKey });
77
+ reflect();
78
+ return bindOnce(host, 'themeToggle', () => {
79
+ host.addEventListener('click', onClick);
80
+ return () => host.removeEventListener('click', onClick);
81
+ });
82
+ }