@ponchia/ui 0.4.1 → 0.6.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 (153) hide show
  1. package/CHANGELOG.md +552 -8
  2. package/MIGRATIONS.json +106 -0
  3. package/README.md +34 -8
  4. package/annotations/index.d.ts +402 -0
  5. package/annotations/index.d.ts.map +1 -0
  6. package/annotations/index.js +792 -0
  7. package/behaviors/carousel.js +198 -0
  8. package/behaviors/combobox.js +226 -0
  9. package/behaviors/command.js +190 -0
  10. package/behaviors/connectors.js +95 -0
  11. package/behaviors/crosshair.js +57 -0
  12. package/behaviors/dialog.js +74 -0
  13. package/behaviors/disclosure.js +26 -0
  14. package/behaviors/dismissible.js +25 -0
  15. package/behaviors/forms.js +186 -0
  16. package/behaviors/glyph.js +108 -0
  17. package/behaviors/index.d.ts +79 -0
  18. package/behaviors/index.js +18 -1409
  19. package/behaviors/internal.js +97 -0
  20. package/behaviors/legend.js +67 -0
  21. package/behaviors/menu.js +47 -0
  22. package/behaviors/popover.js +179 -0
  23. package/behaviors/spotlight.js +52 -0
  24. package/behaviors/table.js +136 -0
  25. package/behaviors/tabs.js +103 -0
  26. package/behaviors/theme.js +84 -0
  27. package/behaviors/toast.js +164 -0
  28. package/classes/classes.json +1857 -0
  29. package/classes/index.d.ts +306 -13
  30. package/classes/index.js +339 -12
  31. package/classes/vscode.css-custom-data.json +12 -0
  32. package/connectors/index.d.ts +191 -0
  33. package/connectors/index.d.ts.map +1 -0
  34. package/connectors/index.js +275 -0
  35. package/css/analytical.css +21 -0
  36. package/css/annotations.css +292 -0
  37. package/css/app.css +43 -13
  38. package/css/base.css +15 -10
  39. package/css/command.css +97 -0
  40. package/css/connectors.css +110 -0
  41. package/css/content.css +7 -1
  42. package/css/crosshair.css +100 -0
  43. package/css/dataviz.css +5 -1
  44. package/css/disclosure.css +38 -6
  45. package/css/dots.css +57 -0
  46. package/css/feedback.css +111 -2
  47. package/css/fonts.css +11 -7
  48. package/css/forms.css +42 -1
  49. package/css/generated.css +117 -0
  50. package/css/legend.css +272 -0
  51. package/css/marks.css +174 -0
  52. package/css/motion.css +24 -44
  53. package/css/navigation.css +7 -0
  54. package/css/overlay.css +31 -1
  55. package/css/primitives.css +109 -5
  56. package/css/report.css +39 -81
  57. package/css/selection.css +46 -0
  58. package/css/site.css +16 -2
  59. package/css/sources.css +221 -0
  60. package/css/spotlight.css +104 -0
  61. package/css/state.css +121 -0
  62. package/css/tokens.css +60 -37
  63. package/css/workbench.css +83 -0
  64. package/dist/bronto.css +1 -1
  65. package/dist/css/analytical.css +1 -0
  66. package/dist/css/annotations.css +1 -0
  67. package/dist/css/app.css +1 -1
  68. package/dist/css/base.css +1 -1
  69. package/dist/css/command.css +1 -0
  70. package/dist/css/connectors.css +1 -0
  71. package/dist/css/content.css +1 -1
  72. package/dist/css/crosshair.css +1 -0
  73. package/dist/css/disclosure.css +1 -1
  74. package/dist/css/dots.css +1 -1
  75. package/dist/css/feedback.css +1 -1
  76. package/dist/css/fonts.css +1 -1
  77. package/dist/css/forms.css +1 -1
  78. package/dist/css/generated.css +1 -0
  79. package/dist/css/legend.css +1 -0
  80. package/dist/css/marks.css +1 -0
  81. package/dist/css/motion.css +1 -1
  82. package/dist/css/navigation.css +1 -1
  83. package/dist/css/overlay.css +1 -1
  84. package/dist/css/primitives.css +1 -1
  85. package/dist/css/report.css +1 -1
  86. package/dist/css/selection.css +1 -0
  87. package/dist/css/site.css +1 -1
  88. package/dist/css/sources.css +1 -0
  89. package/dist/css/spotlight.css +1 -0
  90. package/dist/css/state.css +1 -0
  91. package/dist/css/tokens.css +1 -1
  92. package/dist/css/workbench.css +1 -0
  93. package/docs/adr/0003-theme-model.md +7 -4
  94. package/docs/annotations.md +425 -0
  95. package/docs/architecture.md +246 -0
  96. package/docs/command.md +95 -0
  97. package/docs/connectors.md +91 -0
  98. package/docs/contrast.md +116 -92
  99. package/docs/crosshair.md +63 -0
  100. package/docs/d2.md +195 -0
  101. package/docs/generated.md +91 -0
  102. package/docs/legends.md +184 -0
  103. package/docs/marks.md +93 -0
  104. package/docs/mermaid.md +152 -0
  105. package/docs/reference.md +385 -23
  106. package/docs/reporting.md +436 -63
  107. package/docs/selection.md +40 -0
  108. package/docs/sources.md +137 -0
  109. package/docs/spotlight.md +78 -0
  110. package/docs/stability.md +24 -2
  111. package/docs/state.md +85 -0
  112. package/docs/usage.md +123 -4
  113. package/docs/vega.md +225 -0
  114. package/docs/workbench.md +78 -0
  115. package/fonts/doto-400.woff2 +0 -0
  116. package/fonts/doto-500.woff2 +0 -0
  117. package/fonts/doto-600.woff2 +0 -0
  118. package/fonts/doto-700.woff2 +0 -0
  119. package/fonts/doto-800.woff2 +0 -0
  120. package/fonts/doto-900.woff2 +0 -0
  121. package/glyphs/glyphs.js +6 -4
  122. package/llms.txt +362 -14
  123. package/package.json +115 -12
  124. package/qwik/index.d.ts +42 -54
  125. package/qwik/index.d.ts.map +1 -0
  126. package/qwik/index.js +75 -3
  127. package/react/index.d.ts +39 -56
  128. package/react/index.d.ts.map +1 -0
  129. package/react/index.js +67 -3
  130. package/solid/index.d.ts +64 -56
  131. package/solid/index.d.ts.map +1 -0
  132. package/solid/index.js +70 -3
  133. package/tokens/d2.d.ts +38 -0
  134. package/tokens/d2.js +71 -0
  135. package/tokens/d2.json +43 -0
  136. package/tokens/index.d.ts +5 -5
  137. package/tokens/index.js +23 -5
  138. package/tokens/index.json +9 -0
  139. package/tokens/mermaid.d.ts +23 -0
  140. package/tokens/mermaid.js +181 -0
  141. package/tokens/mermaid.json +163 -0
  142. package/tokens/resolved.json +45 -1
  143. package/tokens/skins.js +3 -2
  144. package/tokens/tokens.dtcg.json +26 -0
  145. package/tokens/vega.d.ts +34 -0
  146. package/tokens/vega.js +155 -0
  147. package/tokens/vega.json +179 -0
  148. package/fonts/doto-400.ttf +0 -0
  149. package/fonts/doto-500.ttf +0 -0
  150. package/fonts/doto-600.ttf +0 -0
  151. package/fonts/doto-700.ttf +0 -0
  152. package/fonts/doto-800.ttf +0 -0
  153. package/fonts/doto-900.ttf +0 -0
@@ -0,0 +1,97 @@
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
+ // Resolve the delegation host from an init call's `root` option, distinguishing
10
+ // three cases so an unattached/null root never silently widens to whole-document
11
+ // delegation (the "scoped island hijacks every control" foot-gun):
12
+ // • root absent/undefined → no scope requested → delegate from `fallback`
13
+ // (default `document`). This is the intended global-wiring path.
14
+ // • root === null → a scope WAS requested but isn't ready yet (e.g. a
15
+ // framework ref still null at mount). Return null so the caller no-ops
16
+ // instead of hijacking the whole document.
17
+ // • root is an element → use it.
18
+ // The bindings (@ponchia/ui/{react,solid,qwik}) emit `root: null` for the
19
+ // not-ready case precisely so this distinction survives across the boundary.
20
+ export function resolveHost(root, fallback = document) {
21
+ if (root === null) return null;
22
+ return root || fallback;
23
+ }
24
+
25
+ // Monotonic counter for auto-minted field / list ids, shared across
26
+ // initFormValidation and initCombobox so separate calls (and separate
27
+ // behaviors) never collide on an id.
28
+ let fieldUid = 0;
29
+ export const nextFieldUid = () => ++fieldUid;
30
+
31
+ // Make delegated initializers idempotent. Re-binding the same logical
32
+ // listener on the same host/element tears the previous binding down first,
33
+ // so double-init (HMR, framework re-mount, repeated calls) never stacks
34
+ // duplicate handlers (the "double-toggle" class of bug). The returned
35
+ // cleanup removes the single live binding.
36
+ const BOUND = Symbol('bronto-bound');
37
+
38
+ export function bindOnce(target, key, add) {
39
+ const reg = target[BOUND] || (target[BOUND] = Object.create(null));
40
+ if (reg[key]) reg[key]();
41
+ const remove = add();
42
+ const cleanup = () => {
43
+ remove();
44
+ if (reg[key] === cleanup) delete reg[key];
45
+ };
46
+ reg[key] = cleanup;
47
+ return cleanup;
48
+ }
49
+
50
+ export function byIdInHost(host, id) {
51
+ if (!id) return null;
52
+ if (host === document) return document.getElementById(id);
53
+ if (host.id === id) return host;
54
+ return (
55
+ Array.from(host.querySelectorAll?.('[id]') || []).find((el) => el.id === id) ||
56
+ document.getElementById(id)
57
+ );
58
+ }
59
+
60
+ export function closestSafe(el, selector) {
61
+ try {
62
+ return el.closest(selector);
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ // Collect the hosts an initializer should wire: the descendants matching
69
+ // `selector` PLUS `host` itself when it matches (querySelectorAll only sees
70
+ // descendants, so a `root` that *is* a target would otherwise be skipped).
71
+ // Self-first, null-safe — the shape ~9 delegated behaviors hand-rolled.
72
+ export function collectHosts(host, selector) {
73
+ const out = host !== document && host.matches?.(selector) ? [host] : [];
74
+ out.push(...(host.querySelectorAll?.(selector) ?? []));
75
+ return out;
76
+ }
77
+
78
+ // scrollIntoView is a pure affordance and throws in jsdom/layout-less envs;
79
+ // never let that break a keyboard/roving handler. (combobox/command/carousel.)
80
+ export function scrollIntoViewSafe(el, opts = { block: 'nearest' }) {
81
+ try {
82
+ el?.scrollIntoView(opts);
83
+ } catch {
84
+ /* headless / no layout — the scroll is cosmetic */
85
+ }
86
+ }
87
+
88
+ // Wrap an index by `delta` within [0, len), the roving keyboard math shared by
89
+ // the combobox and command listboxes (a -1 `cur` lands on the first/last as
90
+ // before). Only this core is shared — the surrounding setActive/filter/group
91
+ // logic diverges between the two for real reasons. (code-quality audit Q12.)
92
+ export function wrapIndex(cur, delta, len) {
93
+ let next = cur + delta;
94
+ if (next < 0) next = len - 1;
95
+ if (next >= len) next = 0;
96
+ return next;
97
+ }
@@ -0,0 +1,67 @@
1
+ import { hasDom, resolveHost, 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 = resolveHost(root);
21
+ if (!host) return noop;
22
+ const isButton = (el) => el.tagName === 'BUTTON' || el.getAttribute('role') === 'button';
23
+ const onClick = (e) => {
24
+ const item = e.target.closest('.ui-legend__item');
25
+ if (!item || !host.contains(item)) return;
26
+ const legend = item.closest('[data-bronto-legend]');
27
+ if (!legend || !host.contains(legend)) return;
28
+ // The contract requires a real `<button>` (keyboard-operable, focusable). A
29
+ // non-button item is mouse-only — refuse to toggle it rather than ship a
30
+ // pointer-only control (WCAG 2.1.1 — C11). The author is warned at bind.
31
+ if (!isButton(item)) return;
32
+ const active = item.getAttribute('aria-pressed') !== 'false';
33
+ const next = !active;
34
+ item.setAttribute('aria-pressed', String(next));
35
+ item.classList.toggle('is-inactive', !next);
36
+ // This legend's own items only — an item inside a nested [data-bronto-legend]
37
+ // belongs to that inner legend, so it must not shift this one's indices.
38
+ const items = [...legend.querySelectorAll('.ui-legend__item')].filter(
39
+ (el) => el.closest('[data-bronto-legend]') === legend,
40
+ );
41
+ legend.dispatchEvent(
42
+ new CustomEvent('bronto:legend:toggle', {
43
+ bubbles: true,
44
+ detail: { series: item.dataset.series ?? items.indexOf(item), active: next },
45
+ }),
46
+ );
47
+ };
48
+ return bindOnce(host, 'legend', () => {
49
+ // Warn once per non-button item present at bind: it gets cursor:pointer from
50
+ // the CSS but is neither focusable nor keyboard-operable (C11).
51
+ if (typeof console !== 'undefined') {
52
+ for (const legend of host.querySelectorAll?.('[data-bronto-legend]') ?? []) {
53
+ const stray = [...legend.querySelectorAll('.ui-legend__item')].some(
54
+ (el) => el.closest('[data-bronto-legend]') === legend && !isButton(el),
55
+ );
56
+ if (stray) {
57
+ console.warn(
58
+ '[bronto] initLegend(): interactive legend entries must be <button> (or role="button") to be keyboard-operable — a non-button .ui-legend__item is ignored.',
59
+ );
60
+ break;
61
+ }
62
+ }
63
+ }
64
+ host.addEventListener('click', onClick);
65
+ return () => host.removeEventListener('click', onClick);
66
+ });
67
+ }
@@ -0,0 +1,47 @@
1
+ import { hasDom, resolveHost, 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 = resolveHost(root);
17
+ if (!host) return noop;
18
+ const openMenus = () => host.querySelectorAll?.('[data-bronto-menu][open]') ?? [];
19
+ const shut = (menu) => {
20
+ if (!menu || !menu.open) return;
21
+ menu.open = false;
22
+ menu.querySelector('summary')?.focus();
23
+ };
24
+ const onClick = (e) => {
25
+ const menu = e.target.closest('[data-bronto-menu]');
26
+ // Activate an item → close its menu (and return focus to summary).
27
+ if (menu && e.target.closest('.ui-menu__item')) {
28
+ shut(menu);
29
+ return;
30
+ }
31
+ // Click outside any open menu → close them all (no focus move).
32
+ for (const m of openMenus()) if (!m.contains(e.target)) m.open = false;
33
+ };
34
+ const onKey = (e) => {
35
+ if (e.key !== 'Escape') return;
36
+ const menu = e.target.closest?.('[data-bronto-menu][open]') || openMenus()[0];
37
+ shut(menu);
38
+ };
39
+ return bindOnce(host, 'menu', () => {
40
+ host.addEventListener('click', onClick);
41
+ host.addEventListener('keydown', onKey);
42
+ return () => {
43
+ host.removeEventListener('click', onClick);
44
+ host.removeEventListener('keydown', onKey);
45
+ };
46
+ });
47
+ }
@@ -0,0 +1,179 @@
1
+ import { hasDom, resolveHost, 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
+ * The trigger advertises `aria-haspopup="dialog"`, so on open the panel is
17
+ * given `role="dialog"` (unless the author set a role) and focus is moved into
18
+ * it — the first focusable descendant, or the panel itself. It is a *non-modal*
19
+ * dialog: the rest of the page stays interactive and there is no focus trap.
20
+ * Author an accessible name on the panel (`aria-label` / `aria-labelledby`); a
21
+ * dev-time `console.warn` fires when it is missing.
22
+ *
23
+ * Escape returns focus to the trigger; closing via outside-click leaves focus
24
+ * where the click landed (treated as deliberate intent to move on).
25
+ */
26
+ export function initPopover({ root } = {}) {
27
+ if (!hasDom()) return noop;
28
+ const host = resolveHost(root);
29
+ if (!host) return noop;
30
+ const view = document.defaultView;
31
+ const GAP = 8;
32
+ let openPanel = null;
33
+ let openTrigger = null;
34
+
35
+ const FOCUSABLE =
36
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
37
+
38
+ // The trigger advertises `aria-haspopup="dialog"`, so the open panel must BE a
39
+ // dialog: a role, an accessible name, and focus moved into it (C6). Focus the
40
+ // first focusable descendant, else the panel itself (made programmatically
41
+ // focusable) so a content-only panel still receives focus.
42
+ const focusInto = (panel) => {
43
+ const first = panel.querySelector(FOCUSABLE);
44
+ if (first) {
45
+ first.focus?.();
46
+ return;
47
+ }
48
+ if (!panel.hasAttribute('tabindex')) panel.setAttribute('tabindex', '-1');
49
+ panel.focus?.();
50
+ };
51
+
52
+ const place = (trigger, panel) => {
53
+ const r = trigger.getBoundingClientRect();
54
+ const pw = panel.offsetWidth;
55
+ const ph = panel.offsetHeight;
56
+ const vw = view?.innerWidth ?? 0;
57
+ const vh = view?.innerHeight ?? 0;
58
+ let top = r.bottom + GAP;
59
+ if (top + ph > vh && r.top - GAP - ph >= 0) top = r.top - GAP - ph;
60
+ let left = r.left;
61
+ if (vw) left = Math.max(GAP, Math.min(left, vw - pw - GAP));
62
+ panel.style.top = `${Math.max(GAP, top)}px`;
63
+ panel.style.left = `${left}px`;
64
+ };
65
+
66
+ const close = () => {
67
+ if (!openPanel) return;
68
+ const panel = openPanel;
69
+ const trigger = openTrigger;
70
+ // Only steal focus back to the trigger when focus is still inside the panel
71
+ // (Escape / programmatic re-toggle). An outside-click leaves focus where the
72
+ // click landed — deliberate intent to move on, per the doc contract.
73
+ const focusWasInside = panel.contains(document.activeElement);
74
+ openPanel = openTrigger = null;
75
+ if (panel.hasAttribute('popover') && typeof panel.hidePopover === 'function') {
76
+ try {
77
+ panel.hidePopover();
78
+ } catch {
79
+ /* already hidden */
80
+ }
81
+ } else {
82
+ panel.classList.remove('is-open');
83
+ }
84
+ if (trigger) trigger.setAttribute('aria-expanded', 'false');
85
+ if (focusWasInside && trigger?.isConnected) trigger.focus?.();
86
+ };
87
+
88
+ const open = (trigger, panel) => {
89
+ close();
90
+ // Live up to the advertised `aria-haspopup="dialog"`: give the panel a
91
+ // dialog role (unless the author set one) so AT announces it as the promised
92
+ // dialog rather than a generic group (C6).
93
+ if (!panel.hasAttribute('role')) panel.setAttribute('role', 'dialog');
94
+ trigger.setAttribute('aria-controls', panel.id);
95
+ trigger.setAttribute('aria-expanded', 'true');
96
+ if (panel.hasAttribute('popover') && typeof panel.showPopover === 'function') {
97
+ try {
98
+ panel.showPopover();
99
+ } catch {
100
+ panel.classList.add('is-open');
101
+ }
102
+ } else {
103
+ panel.classList.add('is-open');
104
+ }
105
+ openPanel = panel;
106
+ openTrigger = trigger;
107
+ place(trigger, panel);
108
+ focusInto(panel);
109
+ };
110
+
111
+ const onClick = (e) => {
112
+ const trigger = e.target.closest?.('[data-bronto-popover]');
113
+ if (trigger && host.contains(trigger)) {
114
+ const panel = byIdInHost(host, trigger.getAttribute('data-bronto-popover'));
115
+ if (!panel) return;
116
+ e.preventDefault();
117
+ if (openPanel === panel) close();
118
+ else open(trigger, panel);
119
+ return;
120
+ }
121
+ if (openPanel && !openPanel.contains(e.target)) close();
122
+ };
123
+ const onKey = (e) => {
124
+ // close() returns focus to the trigger because focus is inside the panel.
125
+ if (e.key === 'Escape' && openPanel) close();
126
+ };
127
+ const onReflow = () => {
128
+ if (openPanel && openTrigger) place(openTrigger, openPanel);
129
+ };
130
+
131
+ // Seed resting ARIA on every trigger and keep it in sync when the UA itself
132
+ // toggles a native popover (light-dismiss / Escape on the `popover` attribute
133
+ // never routes through close(), so aria-expanded would otherwise go stale).
134
+ const seedTeardowns = [];
135
+ const seed = () => {
136
+ for (const trigger of host.querySelectorAll('[data-bronto-popover]')) {
137
+ const panel = byIdInHost(host, trigger.getAttribute('data-bronto-popover'));
138
+ if (!panel) continue;
139
+ if (!trigger.hasAttribute('aria-haspopup')) trigger.setAttribute('aria-haspopup', 'dialog');
140
+ trigger.setAttribute('aria-controls', panel.id);
141
+ if (!trigger.hasAttribute('aria-expanded')) trigger.setAttribute('aria-expanded', 'false');
142
+ // A dialog with no accessible name is announced as just "dialog". We can't
143
+ // invent a good name, so warn the author at dev time (C6).
144
+ const named =
145
+ panel.hasAttribute('aria-label') ||
146
+ panel.hasAttribute('aria-labelledby') ||
147
+ panel.hasAttribute('title');
148
+ if (!named && typeof console !== 'undefined') {
149
+ console.warn(
150
+ `[bronto] initPopover(): panel #${panel.id} has no accessible name — add aria-label or aria-labelledby so it is announced as a named dialog.`,
151
+ );
152
+ }
153
+ if (panel.hasAttribute('popover')) {
154
+ const onToggle = (e) => {
155
+ const isOpen = e.newState === 'open';
156
+ trigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
157
+ if (!isOpen && openPanel === panel) openPanel = openTrigger = null;
158
+ };
159
+ panel.addEventListener('toggle', onToggle);
160
+ seedTeardowns.push(() => panel.removeEventListener('toggle', onToggle));
161
+ }
162
+ }
163
+ };
164
+
165
+ return bindOnce(host, 'popover', () => {
166
+ seed();
167
+ document.addEventListener('click', onClick);
168
+ document.addEventListener('keydown', onKey);
169
+ view?.addEventListener('scroll', onReflow, true);
170
+ view?.addEventListener('resize', onReflow);
171
+ return () => {
172
+ for (const t of seedTeardowns.splice(0)) t();
173
+ document.removeEventListener('click', onClick);
174
+ document.removeEventListener('keydown', onKey);
175
+ view?.removeEventListener('scroll', onReflow, true);
176
+ view?.removeEventListener('resize', onReflow);
177
+ };
178
+ });
179
+ }
@@ -0,0 +1,52 @@
1
+ import { hasDom, resolveHost, noop, bindOnce, byIdInHost, collectHosts } 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 = resolveHost(root);
18
+ if (!host) return noop;
19
+ const spots = collectHosts(host, '[data-bronto-spotlight]');
20
+ if (!spots.length) return noop;
21
+
22
+ const place = () => {
23
+ for (const spot of spots) {
24
+ const target = byIdInHost(host, spot.dataset.target);
25
+ if (!target) continue;
26
+ const r = target.getBoundingClientRect();
27
+ spot.style.setProperty('--spot-x', `${r.left}px`);
28
+ spot.style.setProperty('--spot-y', `${r.top}px`);
29
+ spot.style.setProperty('--spot-w', `${r.width}px`);
30
+ spot.style.setProperty('--spot-h', `${r.height}px`);
31
+ }
32
+ };
33
+
34
+ return bindOnce(host, 'spotlight', () => {
35
+ place();
36
+ const view = host.defaultView || host.ownerDocument?.defaultView || null;
37
+ const MO = view?.MutationObserver;
38
+ const mo = MO ? new MO(place) : null;
39
+ if (mo) {
40
+ for (const spot of spots) {
41
+ mo.observe(spot, { attributes: true, attributeFilter: ['data-target'] });
42
+ }
43
+ }
44
+ view?.addEventListener('resize', place);
45
+ view?.addEventListener('scroll', place, true);
46
+ return () => {
47
+ mo?.disconnect();
48
+ view?.removeEventListener('resize', place);
49
+ view?.removeEventListener('scroll', place, true);
50
+ };
51
+ });
52
+ }
@@ -0,0 +1,136 @@
1
+ import { hasDom, resolveHost, noop, bindOnce, collectHosts } 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` button sorts the tbody by
8
+ * that column. Sortable headers are seeded `aria-sort="none"`; a
9
+ * click toggles that header ascending ⇄ descending (first click =
10
+ * ascending) and resets the other sortable headers to `none`.
11
+ * Numeric columns (`data-sort="num"` or `.is-num` cells) sort
12
+ * numerically; everything else, locale string compare. Any
13
+ * `.ui-table__empty` sentinel row is kept last after a sort.
14
+ * - a `[data-bronto-select-all]` checkbox toggles every
15
+ * `[data-bronto-select]` row checkbox and the rows'
16
+ * `aria-selected`; toggling a row keeps the header checkbox's
17
+ * checked/indeterminate state in sync. Emits `bronto:selectionchange`
18
+ * ({ detail: { count } }) on the table.
19
+ *
20
+ * SSR-safe, idempotent per table; returns a cleanup function.
21
+ *
22
+ * The numeric sort parses each cell as display text (strips non-[0-9.-] chars),
23
+ * so it is locale-naive — group/decimal separators beyond `.`/`-` are not
24
+ * interpreted. It is a client-side convenience sorter, not a data grid.
25
+ */
26
+ export function initTableSort({ root } = {}) {
27
+ if (!hasDom()) return noop;
28
+ const host = resolveHost(root);
29
+ if (!host) return noop;
30
+ const tables = collectHosts(host, '[data-bronto-sortable]');
31
+ const cleanups = [];
32
+
33
+ for (const table of tables) {
34
+ const tbody = table.tBodies[0];
35
+ if (!tbody) continue;
36
+
37
+ // Seed the resting `aria-sort="none"` on every sortable header so AT
38
+ // announces the column as sortable from the start (it was unset until the
39
+ // first click — C10).
40
+ for (const sort of table.querySelectorAll('.ui-table__sort')) {
41
+ const th = sort.closest('th');
42
+ if (th && !th.hasAttribute('aria-sort')) th.setAttribute('aria-sort', 'none');
43
+ }
44
+
45
+ const colIndex = (th) => [...th.parentElement.children].indexOf(th);
46
+ const cellText = (row, i) => row.children[i]?.textContent.trim() ?? '';
47
+
48
+ const sortBy = (th, numeric) => {
49
+ const headers = th.closest('tr').querySelectorAll('th');
50
+ const dir = th.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
51
+ // Reset the OTHER sortable headers to `none` (not removed) so they keep
52
+ // announcing sortability; only previously-sortable headers carry aria-sort.
53
+ headers.forEach((h) => {
54
+ if (h !== th && h.hasAttribute('aria-sort')) h.setAttribute('aria-sort', 'none');
55
+ });
56
+ th.setAttribute('aria-sort', dir);
57
+ const i = colIndex(th);
58
+ const sign = dir === 'ascending' ? 1 : -1;
59
+ // Empty/sentinel rows sort out of the data set AND must re-append LAST,
60
+ // or after a sort they float above the real rows (C29).
61
+ const emptyRows = [...tbody.rows].filter((r) => r.classList.contains('ui-table__empty'));
62
+ const rows = [...tbody.rows].filter((r) => !r.classList.contains('ui-table__empty'));
63
+ rows.sort((a, b) => {
64
+ const x = cellText(a, i);
65
+ const y = cellText(b, i);
66
+ const cmp = numeric
67
+ ? (parseFloat(x.replace(/[^\d.-]/g, '')) || 0) -
68
+ (parseFloat(y.replace(/[^\d.-]/g, '')) || 0)
69
+ : x.localeCompare(y);
70
+ return cmp * sign;
71
+ });
72
+ // Re-parent in document order: sorted data rows, then any empty/sentinel
73
+ // row last. A single appendChild pass over the existing <tr> nodes (no
74
+ // markup is created — these are trusted DOM elements being moved).
75
+ for (const r of [...rows, ...emptyRows]) tbody.appendChild(r);
76
+ };
77
+
78
+ const allBox = table.querySelector('[data-bronto-select-all]');
79
+ const rowBoxes = () => [...table.querySelectorAll('[data-bronto-select]')];
80
+ const syncAll = () => {
81
+ const boxes = rowBoxes();
82
+ const on = boxes.filter((b) => b.checked).length;
83
+ if (allBox) {
84
+ allBox.checked = on > 0 && on === boxes.length;
85
+ allBox.indeterminate = on > 0 && on < boxes.length;
86
+ }
87
+ table.dispatchEvent(
88
+ new CustomEvent('bronto:selectionchange', { detail: { count: on }, bubbles: true }),
89
+ );
90
+ };
91
+ const markRow = (box) => {
92
+ const tr = box.closest('tr');
93
+ if (tr) tr.setAttribute('aria-selected', String(box.checked));
94
+ };
95
+
96
+ const onClick = (e) => {
97
+ // Only the focusable `.ui-table__sort` button is a sort trigger — it is
98
+ // keyboard-operable and carries the `::after` sort glyph. The bare
99
+ // `th[data-sort]` path was mouse-only with no affordance, so it is gone
100
+ // (C10); `data-sort="num"` is still read from the button or its th.
101
+ const sorter = e.target.closest('.ui-table__sort');
102
+ if (sorter && table.contains(sorter)) {
103
+ const th = sorter.closest('th');
104
+ const numeric =
105
+ (sorter.getAttribute('data-sort') || th.getAttribute('data-sort')) === 'num' ||
106
+ th.classList.contains('is-num');
107
+ sortBy(th, numeric);
108
+ }
109
+ };
110
+ const onChange = (e) => {
111
+ const t = e.target;
112
+ if (t.matches?.('[data-bronto-select-all]')) {
113
+ rowBoxes().forEach((b) => {
114
+ b.checked = t.checked;
115
+ markRow(b);
116
+ });
117
+ syncAll();
118
+ } else if (t.matches?.('[data-bronto-select]')) {
119
+ markRow(t);
120
+ syncAll();
121
+ }
122
+ };
123
+
124
+ const bound = bindOnce(table, 'tableSort', () => {
125
+ table.addEventListener('click', onClick);
126
+ table.addEventListener('change', onChange);
127
+ return () => {
128
+ table.removeEventListener('click', onClick);
129
+ table.removeEventListener('change', onChange);
130
+ };
131
+ });
132
+ cleanups.push(bound);
133
+ }
134
+
135
+ return () => cleanups.forEach((fn) => fn());
136
+ }