@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,95 @@
1
+ import { hasDom, resolveHost, noop, bindOnce, byIdInHost, collectHosts } from './internal.js';
2
+ import { connectRects, arrowHead, dotMark } from '../connectors/index.js';
3
+
4
+ const SVGNS = 'http://www.w3.org/2000/svg';
5
+
6
+ /**
7
+ * Draw + keep leader lines in sync. Each `[data-bronto-connector]` is an
8
+ * `.ui-connector` SVG overlaying a positioned container; `data-from`/`data-to`
9
+ * are the ids of the elements to connect. Optional `data-shape`
10
+ * (`straight`|`elbow`|`curve`), `data-from-side`/`data-to-side`
11
+ * (`top`|`right`|`bottom`|`left`|`center`), and `data-end` (`arrow`|`dot`|`none`).
12
+ *
13
+ * Bronto computes the geometry (the pure `@ponchia/ui/connectors` helpers) and
14
+ * sets the path; it owns no layout. Redraws on resize/scroll via a
15
+ * ResizeObserver + listeners. SSR-safe, idempotent per host; returns a cleanup
16
+ * that disconnects everything. Re-run after adding/removing connectors.
17
+ */
18
+ export function initConnectors({ root } = {}) {
19
+ if (!hasDom()) return noop;
20
+ const host = resolveHost(root);
21
+ if (!host) return noop;
22
+ const connectors = collectHosts(host, '[data-bronto-connector]');
23
+ if (!connectors.length) return noop;
24
+
25
+ const draw = () => {
26
+ for (const svg of connectors) {
27
+ const from = byIdInHost(host, svg.dataset.from);
28
+ const to = byIdInHost(host, svg.dataset.to);
29
+ if (!from || !to) continue;
30
+ const o = svg.getBoundingClientRect();
31
+ const rel = (el) => {
32
+ const r = el.getBoundingClientRect();
33
+ return { x: r.left - o.left, y: r.top - o.top, width: r.width, height: r.height };
34
+ };
35
+ const {
36
+ d,
37
+ to: end,
38
+ angle,
39
+ } = connectRects({
40
+ fromRect: rel(from),
41
+ toRect: rel(to),
42
+ shape: svg.dataset.shape || 'straight',
43
+ fromSide: svg.dataset.fromSide || undefined,
44
+ toSide: svg.dataset.toSide || undefined,
45
+ });
46
+ let path = svg.querySelector('.ui-connector__path');
47
+ if (!path) {
48
+ path = document.createElementNS(SVGNS, 'path');
49
+ path.setAttribute('class', 'ui-connector__path');
50
+ svg.appendChild(path);
51
+ }
52
+ path.setAttribute('d', d);
53
+ // pathLength="1" normalises the draw animation, but it would also reframe
54
+ // a dashed line's user-unit dasharray — so only set it for draw connectors.
55
+ if (svg.classList.contains('ui-connector--draw')) path.setAttribute('pathLength', '1');
56
+ else path.removeAttribute('pathLength');
57
+
58
+ const kind = svg.dataset.end || 'arrow';
59
+ let cap = svg.querySelector('.ui-connector__end');
60
+ if (kind === 'none') {
61
+ cap?.remove();
62
+ continue;
63
+ }
64
+ if (!cap) {
65
+ cap = document.createElementNS(SVGNS, 'path');
66
+ cap.setAttribute('class', 'ui-connector__end');
67
+ svg.appendChild(cap);
68
+ }
69
+ cap.setAttribute('d', kind === 'dot' ? dotMark(end, 3) : arrowHead(end, angle, 8));
70
+ }
71
+ };
72
+
73
+ return bindOnce(host, 'connectors', () => {
74
+ draw();
75
+ const view = host.defaultView || host.ownerDocument?.defaultView || null;
76
+ const RO = view?.ResizeObserver;
77
+ const ro = RO ? new RO(draw) : null;
78
+ if (ro) {
79
+ for (const svg of connectors) {
80
+ if (svg.parentElement) ro.observe(svg.parentElement);
81
+ const f = byIdInHost(host, svg.dataset.from);
82
+ const t = byIdInHost(host, svg.dataset.to);
83
+ if (f) ro.observe(f);
84
+ if (t) ro.observe(t);
85
+ }
86
+ }
87
+ view?.addEventListener('resize', draw);
88
+ view?.addEventListener('scroll', draw, true);
89
+ return () => {
90
+ ro?.disconnect();
91
+ view?.removeEventListener('resize', draw);
92
+ view?.removeEventListener('scroll', draw, true);
93
+ };
94
+ });
95
+ }
@@ -0,0 +1,57 @@
1
+ import { hasDom, resolveHost, noop, bindOnce, collectHosts } from './internal.js';
2
+
3
+ /**
4
+ * Track the pointer over a plot and drive a crosshair. Each
5
+ * `[data-bronto-crosshair]` is the plot; it contains a `.ui-crosshair` overlay.
6
+ * On pointer move the behavior sets `--crosshair-x/y` (pixels within the plot)
7
+ * on the overlay, marks it `.is-active`, and dispatches
8
+ * `bronto:crosshair:move` with `{ x, y, fx, fy }` (px + 0..1 fractions);
9
+ * `bronto:crosshair:leave` on exit.
10
+ *
11
+ * Bronto reports WHERE the pointer is — it does not find the nearest datum or
12
+ * map pixels to data values (that needs the host's scales). SSR-safe,
13
+ * idempotent per plot; returns a cleanup function.
14
+ */
15
+ export function initCrosshair({ root } = {}) {
16
+ if (!hasDom()) return noop;
17
+ const host = resolveHost(root);
18
+ if (!host) return noop;
19
+ const plots = collectHosts(host, '[data-bronto-crosshair]');
20
+ if (!plots.length) return noop;
21
+
22
+ const cleanups = [];
23
+ for (const plot of plots) {
24
+ const overlay = plot.querySelector('.ui-crosshair');
25
+ if (!overlay) continue;
26
+ const onMove = (e) => {
27
+ const r = plot.getBoundingClientRect();
28
+ if (!r.width || !r.height) return;
29
+ const x = e.clientX - r.left;
30
+ const y = e.clientY - r.top;
31
+ overlay.style.setProperty('--crosshair-x', `${x}px`);
32
+ overlay.style.setProperty('--crosshair-y', `${y}px`);
33
+ overlay.classList.add('is-active');
34
+ plot.dispatchEvent(
35
+ new CustomEvent('bronto:crosshair:move', {
36
+ bubbles: true,
37
+ detail: { x, y, fx: x / r.width, fy: y / r.height },
38
+ }),
39
+ );
40
+ };
41
+ const onLeave = () => {
42
+ overlay.classList.remove('is-active');
43
+ plot.dispatchEvent(new CustomEvent('bronto:crosshair:leave', { bubbles: true }));
44
+ };
45
+ cleanups.push(
46
+ bindOnce(plot, 'crosshair', () => {
47
+ plot.addEventListener('pointermove', onMove);
48
+ plot.addEventListener('pointerleave', onLeave);
49
+ return () => {
50
+ plot.removeEventListener('pointermove', onMove);
51
+ plot.removeEventListener('pointerleave', onLeave);
52
+ };
53
+ }),
54
+ );
55
+ }
56
+ return () => cleanups.forEach((fn) => fn());
57
+ }
@@ -0,0 +1,74 @@
1
+ import { hasDom, resolveHost, noop, bindOnce, byIdInHost } from './internal.js';
2
+
3
+ /**
4
+ * Wire native <dialog> open/close glue (the one bit <dialog> can't do
5
+ * declaratively). Click `[data-bronto-open="dialogId"]` calls
6
+ * `showModal()` on `#dialogId`; click `[data-bronto-close]` closes the
7
+ * nearest enclosing <dialog>. Clicking the backdrop of a dialog that has
8
+ * `[data-bronto-dialog-light]` closes it too. On open the trigger is
9
+ * remembered and focus is returned to it on *every* close path (Esc,
10
+ * close button, backdrop light-dismiss, programmatic) via the native
11
+ * `close` event, so keyboard/SR users are never dropped at `<body>`.
12
+ * SSR-safe and idempotent; returns cleanup.
13
+ *
14
+ * `root` scopes delegated triggers (default `document`). Controlled targets are
15
+ * resolved root-first, then document-wide, so scoped islands win duplicate-id
16
+ * conflicts without breaking body/portal-mounted overlays.
17
+ */
18
+ export function initDialog({ root } = {}) {
19
+ if (!hasDom()) return noop;
20
+ const host = resolveHost(root);
21
+ if (!host) return noop;
22
+ const managedDialogs = new WeakSet();
23
+ const canManageDialog = (dlg, origin) => host.contains(origin) || managedDialogs.has(dlg);
24
+
25
+ const openFrom = (opener) => {
26
+ const dlg = byIdInHost(host, opener.getAttribute('data-bronto-open'));
27
+ if (!dlg || typeof dlg.showModal !== 'function' || dlg.open) return;
28
+ managedDialogs.add(dlg);
29
+ dlg.addEventListener(
30
+ 'close',
31
+ () => {
32
+ if (opener.isConnected && typeof opener.focus === 'function') opener.focus();
33
+ },
34
+ { once: true },
35
+ );
36
+ dlg.showModal();
37
+ };
38
+
39
+ const closeFrom = (closer) => {
40
+ const dlg = closer.closest('dialog');
41
+ if (dlg && dlg.open && canManageDialog(dlg, closer)) dlg.close();
42
+ };
43
+
44
+ const lightDismiss = (dlg) => {
45
+ if (
46
+ dlg.tagName === 'DIALOG' &&
47
+ dlg.open &&
48
+ dlg.hasAttribute('data-bronto-dialog-light') &&
49
+ canManageDialog(dlg, dlg)
50
+ ) {
51
+ dlg.close();
52
+ }
53
+ };
54
+
55
+ const onClick = (e) => {
56
+ const opener = e.target.closest('[data-bronto-open]');
57
+ if (opener && host.contains(opener)) {
58
+ openFrom(opener);
59
+ return;
60
+ }
61
+ const closer = e.target.closest('[data-bronto-close]');
62
+ if (closer) {
63
+ closeFrom(closer);
64
+ return;
65
+ }
66
+ // Light-dismiss: a click whose target is the <dialog> itself is the
67
+ // backdrop (content sits in child elements).
68
+ lightDismiss(e.target);
69
+ };
70
+ return bindOnce(host, 'dialog', () => {
71
+ document.addEventListener('click', onClick);
72
+ return () => document.removeEventListener('click', onClick);
73
+ });
74
+ }
@@ -0,0 +1,26 @@
1
+ import { hasDom, resolveHost, noop, bindOnce, byIdInHost } from './internal.js';
2
+
3
+ /**
4
+ * Disclosure: a `[data-bronto-disclosure]` trigger toggles the element
5
+ * referenced by its `aria-controls` id, keeping `aria-expanded` and the
6
+ * panel's `hidden` attribute in sync.
7
+ */
8
+ export function initDisclosure({ root } = {}) {
9
+ if (!hasDom()) return noop;
10
+ const host = resolveHost(root);
11
+ if (!host) return noop;
12
+ const onClick = (e) => {
13
+ const trigger = e.target.closest('[data-bronto-disclosure]');
14
+ if (!trigger || !host.contains(trigger)) return;
15
+ const id = trigger.getAttribute('aria-controls');
16
+ const panel = byIdInHost(host, id);
17
+ if (!panel) return;
18
+ const open = trigger.getAttribute('aria-expanded') === 'true';
19
+ trigger.setAttribute('aria-expanded', String(!open));
20
+ panel.hidden = open;
21
+ };
22
+ return bindOnce(host, 'disclosure', () => {
23
+ host.addEventListener('click', onClick);
24
+ return () => host.removeEventListener('click', onClick);
25
+ });
26
+ }
@@ -0,0 +1,25 @@
1
+ import { hasDom, resolveHost, noop, bindOnce, closestSafe } from './internal.js';
2
+
3
+ /**
4
+ * Click on `[data-bronto-dismiss]` removes the nearest ancestor matching
5
+ * `[data-bronto-dismissible]` (or the selector given as the attribute
6
+ * value). Emits a cancelable `bronto:dismiss` event first.
7
+ */
8
+ export function dismissible({ root } = {}) {
9
+ if (!hasDom()) return noop;
10
+ const host = resolveHost(root);
11
+ if (!host) return noop;
12
+ const onClick = (e) => {
13
+ const btn = e.target.closest('[data-bronto-dismiss]');
14
+ if (!btn || !host.contains(btn)) return;
15
+ const sel = btn.getAttribute('data-bronto-dismiss');
16
+ const target = sel ? closestSafe(btn, sel) : btn.closest('[data-bronto-dismissible]');
17
+ if (!target) return;
18
+ const ev = new CustomEvent('bronto:dismiss', { bubbles: true, cancelable: true });
19
+ if (target.dispatchEvent(ev)) target.remove();
20
+ };
21
+ return bindOnce(host, 'dismissible', () => {
22
+ host.addEventListener('click', onClick);
23
+ return () => host.removeEventListener('click', onClick);
24
+ });
25
+ }
@@ -0,0 +1,186 @@
1
+ import { hasDom, resolveHost, noop, bindOnce, nextFieldUid, collectHosts } from './internal.js';
2
+
3
+ /**
4
+ * Accessible form validation glue for `<form data-bronto-validate>`.
5
+ * Progressive enhancement over the native Constraint Validation API —
6
+ * the framework already ships the `[aria-invalid]` / `.ui-hint--error`
7
+ * styling; this wires the a11y plumbing every consumer would otherwise
8
+ * re-implement (and usually get wrong):
9
+ *
10
+ * - suppresses the native error bubbles (`form.noValidate`),
11
+ * - on blur and on submit sets `aria-invalid` and writes the browser's
12
+ * `validationMessage` into the field's error slot
13
+ * (`[data-bronto-error]` inside the `.ui-field`, falling back to a
14
+ * `.ui-hint`), linked via `aria-describedby`. When it falls back to a
15
+ * `.ui-hint` the original help text is snapshotted and restored once the
16
+ * field is valid again (so the error does not eat the help permanently); a
17
+ * dedicated empty `[data-bronto-error]` node is still the recommended slot,
18
+ * - on an invalid submit, fills the form's
19
+ * `[data-bronto-error-summary]` (a `.ui-error-summary`) with
20
+ * in-page links to each bad field, focuses it, and blocks submit.
21
+ *
22
+ * Pure enhancement: with JS off the form still submits and the browser
23
+ * validates natively. SSR-safe, idempotent; returns a cleanup function.
24
+ */
25
+ export function initFormValidation({ root } = {}) {
26
+ if (!hasDom()) return noop;
27
+ const host = resolveHost(root);
28
+ if (!host) return noop;
29
+
30
+ const ensureId = (el, prefix) => {
31
+ if (!el.id) el.id = `${prefix}-${nextFieldUid()}`;
32
+ return el.id;
33
+ };
34
+
35
+ // When the field has no dedicated `[data-bronto-error]` node we fall back to
36
+ // the shared `.ui-hint` help slot. Snapshot its original help text the first
37
+ // time we overwrite it with an error, so the valid branch can RESTORE the help
38
+ // rather than blanking it permanently (component-audit C8).
39
+ const hintHelp = new WeakMap();
40
+
41
+ const slotFor = (control) => {
42
+ const field = control.closest('.ui-field');
43
+ if (!field) return null;
44
+ const dedicated = field.querySelector('[data-bronto-error]');
45
+ if (dedicated) return dedicated;
46
+ return field.querySelector('.ui-hint');
47
+ };
48
+
49
+ const link = (control, slot) => {
50
+ const slotId = ensureId(slot, 'bronto-err');
51
+ const ids = (control.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
52
+ if (!ids.includes(slotId)) {
53
+ ids.push(slotId);
54
+ control.setAttribute('aria-describedby', ids.join(' '));
55
+ }
56
+ };
57
+
58
+ const unlink = (control, slot) => {
59
+ if (!slot.id) return;
60
+ const ids = (control.getAttribute('aria-describedby') || '')
61
+ .split(/\s+/)
62
+ .filter((id) => id && id !== slot.id);
63
+ if (ids.length) control.setAttribute('aria-describedby', ids.join(' '));
64
+ else control.removeAttribute('aria-describedby');
65
+ };
66
+
67
+ const validateField = (control) => {
68
+ if (!control.willValidate) return true;
69
+ const ok = control.validity.valid;
70
+ const slot = slotFor(control);
71
+ const isHint = slot?.classList.contains('ui-hint');
72
+ if (ok) {
73
+ control.removeAttribute('aria-invalid');
74
+ if (slot) {
75
+ if (isHint) {
76
+ // Restore the snapshotted help text (or clear if there was none); a
77
+ // help-bearing hint stays linked via aria-describedby (it describes
78
+ // the field in the valid state too).
79
+ slot.textContent = hintHelp.get(slot) ?? '';
80
+ slot.classList.remove('ui-hint--error');
81
+ } else {
82
+ // Dedicated error node: clear it and drop the now-stale describedby
83
+ // so AT doesn't announce an empty error association.
84
+ slot.textContent = '';
85
+ unlink(control, slot);
86
+ }
87
+ }
88
+ } else {
89
+ control.setAttribute('aria-invalid', 'true');
90
+ if (slot) {
91
+ if (isHint && !hintHelp.has(slot)) hintHelp.set(slot, slot.textContent);
92
+ slot.textContent = control.validationMessage;
93
+ if (isHint) slot.classList.add('ui-hint--error');
94
+ link(control, slot);
95
+ }
96
+ }
97
+ return ok;
98
+ };
99
+
100
+ const controlsOf = (form) =>
101
+ [...form.elements].filter(
102
+ (el) => el.willValidate && el.type !== 'submit' && el.type !== 'button',
103
+ );
104
+
105
+ const refreshSummary = (form, invalid) => {
106
+ const summary = form.querySelector('[data-bronto-error-summary]');
107
+ if (!summary) return;
108
+ if (!invalid.length) {
109
+ summary.hidden = true;
110
+ summary.replaceChildren();
111
+ return;
112
+ }
113
+ const title = document.createElement('p');
114
+ title.className = 'ui-error-summary__title';
115
+ title.textContent = 'There is a problem';
116
+ const list = document.createElement('ul');
117
+ list.className = 'ui-error-summary__list';
118
+ for (const c of invalid) {
119
+ const id = ensureId(c, 'bronto-field');
120
+ const li = document.createElement('li');
121
+ const a = document.createElement('a');
122
+ a.href = `#${id}`;
123
+ a.textContent = c.validationMessage;
124
+ a.addEventListener('click', (e) => {
125
+ e.preventDefault();
126
+ c.focus();
127
+ });
128
+ li.appendChild(a);
129
+ list.appendChild(li);
130
+ }
131
+ summary.replaceChildren(title, list);
132
+ summary.setAttribute('role', 'alert');
133
+ summary.tabIndex = -1;
134
+ summary.hidden = false;
135
+ };
136
+
137
+ const onSubmit = (e) => {
138
+ const form = e.target.closest?.('[data-bronto-validate]');
139
+ if (!form) return;
140
+ form.noValidate = true;
141
+ const invalid = controlsOf(form).filter((c) => !validateField(c));
142
+ refreshSummary(form, invalid);
143
+ if (invalid.length) {
144
+ e.preventDefault();
145
+ const summary = form.querySelector('[data-bronto-error-summary]');
146
+ (summary && !summary.hidden ? summary : invalid[0]).focus();
147
+ }
148
+ };
149
+
150
+ const onBlur = (e) => {
151
+ const control = e.target;
152
+ if (!control.willValidate) return;
153
+ const form = control.closest?.('[data-bronto-validate]');
154
+ if (!form) return;
155
+ form.noValidate = true;
156
+ validateField(control);
157
+ const summary = form.querySelector('[data-bronto-error-summary]');
158
+ if (summary && !summary.hidden)
159
+ refreshSummary(
160
+ form,
161
+ controlsOf(form).filter((c) => !c.validity.valid),
162
+ );
163
+ };
164
+
165
+ return bindOnce(host, 'formValidation', () => {
166
+ // Suppress native bubbles UP FRONT for forms present at init. The
167
+ // in-handler `noValidate = true` only fires after the first
168
+ // submit/blur, so the very first invalid real-browser submit would
169
+ // otherwise show the native UA bubble instead of the Bronto
170
+ // summary — contradicting the documented contract. (Forms added
171
+ // after init are still covered by the in-handler set.)
172
+ const forms = collectHosts(host, '[data-bronto-validate]');
173
+ const priorNoValidate = new Map();
174
+ for (const f of forms) {
175
+ priorNoValidate.set(f, f.noValidate);
176
+ f.noValidate = true;
177
+ }
178
+ host.addEventListener('submit', onSubmit, true);
179
+ host.addEventListener('focusout', onBlur);
180
+ return () => {
181
+ host.removeEventListener('submit', onSubmit, true);
182
+ host.removeEventListener('focusout', onBlur);
183
+ for (const [f, v] of priorNoValidate) f.noValidate = v;
184
+ };
185
+ });
186
+ }
@@ -0,0 +1,108 @@
1
+ import { hasDom, resolveHost, noop, collectHosts } from './internal.js';
2
+ import { GLYPH_SIZE, glyphCells } from '../glyphs/glyphs.js';
3
+
4
+ function restoreAttr(el, name, prev) {
5
+ if (prev === null) el.removeAttribute(name);
6
+ else el.setAttribute(name, prev);
7
+ }
8
+
9
+ /**
10
+ * Expand `[data-bronto-glyph="name"]` placeholders into a `.ui-dotmatrix`
11
+ * grid of GLYPH_SIZE² cells — the DOM counterpart to renderGlyph() from
12
+ * `@ponchia/ui/glyphs`, for when you'd rather drop a placeholder than inline
13
+ * the markup. Decorative by default (`aria-hidden`); add
14
+ * `data-bronto-glyph-label` to expose it as `role="img"`. An unknown glyph
15
+ * name is left untouched. Idempotent (skips an already-expanded host); the
16
+ * returned cleanup removes the cells and restores the original attributes.
17
+ */
18
+ export function initDotGlyph({ root } = {}) {
19
+ if (!hasDom()) return noop;
20
+ const host = resolveHost(root);
21
+ if (!host) return noop;
22
+ const els = collectHosts(host, '[data-bronto-glyph]');
23
+ const cleanups = [];
24
+
25
+ for (const el of els) {
26
+ // Scope to DIRECT-child cells (the ones we append) — so a placeholder that
27
+ // legitimately nests its own .ui-dotmatrix is neither mis-read as already
28
+ // expanded here nor have its inner cells removed by cleanup below.
29
+ if (el.querySelector(':scope > .ui-dotmatrix__cell')) continue; // already expanded
30
+ const cells = glyphCells(el.getAttribute('data-bronto-glyph'));
31
+ if (!cells.length) continue; // unknown glyph — leave the placeholder as-is
32
+
33
+ const label = el.getAttribute('data-bronto-glyph-label');
34
+ // `data-bronto-glyph-solid` → square, gapless pixel glyph (legible small),
35
+ // the DOM counterpart to renderGlyph's `solid` option. Implies glyph-only.
36
+ const solid = el.hasAttribute('data-bronto-glyph-solid');
37
+ // `data-bronto-glyph-anim="reveal|pulse"` → decorative animation (the DOM
38
+ // counterpart to renderGlyph's `anim`; reduced-motion-safe via CSS).
39
+ const animAttr = el.getAttribute('data-bronto-glyph-anim');
40
+ const animClass =
41
+ animAttr === 'reveal'
42
+ ? 'ui-dotmatrix--reveal'
43
+ : animAttr === 'pulse'
44
+ ? 'ui-dotmatrix--pulse'
45
+ : null;
46
+ const hadAnimClass = animClass ? el.classList.contains(animClass) : false;
47
+ const hadMatrix = el.classList.contains('ui-dotmatrix');
48
+ const hadCols = el.style.getPropertyValue('--dotmatrix-cols');
49
+ const hadRadius = el.style.getPropertyValue('--dotmatrix-dot-radius');
50
+ const hadGap = el.style.getPropertyValue('--dotmatrix-gap');
51
+ const hadAriaHidden = el.getAttribute('aria-hidden');
52
+ const hadRole = el.getAttribute('role');
53
+ const hadAriaLabel = el.getAttribute('aria-label');
54
+
55
+ el.classList.add('ui-dotmatrix');
56
+ if (animClass) el.classList.add(animClass);
57
+ el.style.setProperty('--dotmatrix-cols', String(GLYPH_SIZE));
58
+ if (solid) {
59
+ el.style.setProperty('--dotmatrix-dot-radius', '0');
60
+ el.style.setProperty('--dotmatrix-gap', '0');
61
+ }
62
+ if (label) {
63
+ el.setAttribute('role', 'img');
64
+ el.setAttribute('aria-label', label);
65
+ el.removeAttribute('aria-hidden'); // a labelled img must not also be hidden
66
+ } else {
67
+ el.setAttribute('aria-hidden', 'true');
68
+ }
69
+
70
+ const frag = document.createDocumentFragment();
71
+ cells.forEach((c, i) => {
72
+ const span = document.createElement('span');
73
+ span.className = !c.on
74
+ ? 'ui-dotmatrix__cell'
75
+ : c.tone === 'hot'
76
+ ? 'ui-dotmatrix__cell ui-dotmatrix__cell--hot'
77
+ : c.tone === 'accent'
78
+ ? 'ui-dotmatrix__cell ui-dotmatrix__cell--accent'
79
+ : 'ui-dotmatrix__cell';
80
+ if (!c.on && solid) span.style.background = 'transparent'; // glyph-only
81
+ if (animAttr === 'reveal') span.style.setProperty('--i', String(i)); // scan stagger
82
+ frag.appendChild(span);
83
+ });
84
+ el.appendChild(frag);
85
+
86
+ cleanups.push(() => {
87
+ el.querySelectorAll(':scope > .ui-dotmatrix__cell').forEach((n) => n.remove());
88
+ if (!hadMatrix) el.classList.remove('ui-dotmatrix');
89
+ if (animClass && !hadAnimClass) el.classList.remove(animClass);
90
+ if (solid) {
91
+ if (hadRadius) el.style.setProperty('--dotmatrix-dot-radius', hadRadius);
92
+ else el.style.removeProperty('--dotmatrix-dot-radius');
93
+ if (hadGap) el.style.setProperty('--dotmatrix-gap', hadGap);
94
+ else el.style.removeProperty('--dotmatrix-gap');
95
+ }
96
+ if (hadCols) el.style.setProperty('--dotmatrix-cols', hadCols);
97
+ else el.style.removeProperty('--dotmatrix-cols');
98
+ restoreAttr(el, 'aria-hidden', hadAriaHidden);
99
+ restoreAttr(el, 'role', hadRole);
100
+ restoreAttr(el, 'aria-label', hadAriaLabel);
101
+ // Don't leave behind empty class=""/style="" we ourselves created.
102
+ if (el.getAttribute('class') === '') el.removeAttribute('class');
103
+ if (el.getAttribute('style') === '') el.removeAttribute('style');
104
+ });
105
+ }
106
+
107
+ return () => cleanups.forEach((fn) => fn());
108
+ }
@@ -156,3 +156,82 @@ export declare function toast(message: string, opts?: ToastOpts): Cleanup;
156
156
  * attributes.
157
157
  */
158
158
  export declare function initDotGlyph(opts?: DelegateOpts): Cleanup;
159
+
160
+ /** `bronto:legend:toggle` CustomEvent detail. `series` is the entry's
161
+ * `data-series`, or its 0-based index when unset. `active` is the new state
162
+ * (`true` ⇒ series shown). */
163
+ export interface LegendToggleDetail {
164
+ series: string | number;
165
+ active: boolean;
166
+ }
167
+
168
+ /**
169
+ * Wire `[data-bronto-legend]` interactive legends. Each `.ui-legend__item` is a
170
+ * `<button aria-pressed>`; activating it flips `aria-pressed`, toggles
171
+ * `.is-inactive`, and dispatches `bronto:legend:toggle`
172
+ * ({@link LegendToggleDetail}) on the legend. Bronto owns the control + its
173
+ * state only — the host hides its own series and owns any `aria-live`
174
+ * announcement (`aria-pressed="true"` ⇒ series shown). SSR-safe, idempotent per
175
+ * host. Returns a cleanup function.
176
+ */
177
+ export declare function initLegend(opts?: DelegateOpts): Cleanup;
178
+
179
+ /**
180
+ * Draw + keep leader lines in sync. Each `[data-bronto-connector]` is a
181
+ * `.ui-connector` SVG overlaying a positioned container; `data-from`/`data-to`
182
+ * are the ids of the elements to connect (with optional `data-shape`,
183
+ * `data-from-side`/`data-to-side`, `data-end`). Computes geometry via the
184
+ * `@ponchia/ui/connectors` helpers and redraws on resize/scroll. Bronto owns no
185
+ * layout. SSR-safe, idempotent per host; returns a cleanup that disconnects
186
+ * observers/listeners. Re-run after adding/removing connectors.
187
+ */
188
+ export declare function initConnectors(opts?: DelegateOpts): Cleanup;
189
+
190
+ /**
191
+ * Position a spotlight cutout over a target. Each `[data-bronto-spotlight]` is a
192
+ * `.ui-spotlight` overlay; `data-target` is the id of the element to highlight.
193
+ * Sets `--spot-x/y/w/h` and re-places on resize/scroll and when `data-target`
194
+ * changes. NOT a tour engine — the host owns step order/advancing/visibility.
195
+ * SSR-safe, idempotent per host; returns a cleanup function.
196
+ */
197
+ export declare function initSpotlight(opts?: DelegateOpts): Cleanup;
198
+
199
+ /** `bronto:crosshair:move` CustomEvent detail — pointer position over the plot
200
+ * in pixels and as 0..1 fractions. Bronto reports where; mapping to data is
201
+ * the host's. */
202
+ export interface CrosshairMoveDetail {
203
+ x: number;
204
+ y: number;
205
+ fx: number;
206
+ fy: number;
207
+ }
208
+
209
+ /**
210
+ * Track the pointer over `[data-bronto-crosshair]` plots and drive a contained
211
+ * `.ui-crosshair` overlay: sets `--crosshair-x/y` (px), marks `.is-active`, and
212
+ * dispatches `bronto:crosshair:move` ({@link CrosshairMoveDetail}) /
213
+ * `bronto:crosshair:leave`. Reports the pointer position only — it does not find
214
+ * the nearest datum or map pixels to data. SSR-safe, idempotent per plot;
215
+ * returns a cleanup function.
216
+ */
217
+ export declare function initCrosshair(opts?: DelegateOpts): Cleanup;
218
+
219
+ /** `bronto:command:select` CustomEvent detail — the chosen command's value and
220
+ * visible label. The host executes it; Bronto only filters and navigates. */
221
+ export interface CommandSelectDetail {
222
+ value: string;
223
+ label: string;
224
+ }
225
+
226
+ /**
227
+ * Filter + keyboard-navigate a DOM-authored command list inside
228
+ * `[data-bronto-command]` (the `.ui-command` shell). Owns ids,
229
+ * `role=combobox/listbox/option`, `aria-activedescendant`, a roving active item,
230
+ * substring filtering (hiding empty groups), full keyboard
231
+ * (Down/Up/Home/End/Enter/Escape), and pointer select. Emits
232
+ * `bronto:command:select` ({@link CommandSelectDetail}) on choose and
233
+ * `bronto:command:close` on Escape; the host owns the action registry, routing,
234
+ * and execution. No global Cmd/Ctrl+K. SSR-safe, idempotent per instance;
235
+ * returns a cleanup function.
236
+ */
237
+ export declare function initCommand(opts?: DelegateOpts): Cleanup;