@ponchia/ui 0.6.0 → 0.6.3

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 (156) hide show
  1. package/CHANGELOG.md +64 -4
  2. package/README.md +1 -1
  3. package/annotations/index.d.ts.map +1 -1
  4. package/annotations/index.js +36 -33
  5. package/behaviors/carousel.d.ts +28 -0
  6. package/behaviors/carousel.d.ts.map +1 -0
  7. package/behaviors/carousel.js +3 -0
  8. package/behaviors/combobox.d.ts +40 -0
  9. package/behaviors/combobox.d.ts.map +1 -0
  10. package/behaviors/combobox.js +71 -20
  11. package/behaviors/command.d.ts +41 -0
  12. package/behaviors/command.d.ts.map +1 -0
  13. package/behaviors/command.js +9 -0
  14. package/behaviors/connectors.d.ts +17 -0
  15. package/behaviors/connectors.d.ts.map +1 -0
  16. package/behaviors/connectors.js +3 -0
  17. package/behaviors/crosshair.d.ts +42 -0
  18. package/behaviors/crosshair.d.ts.map +1 -0
  19. package/behaviors/crosshair.js +19 -1
  20. package/behaviors/dialog.d.ts +20 -0
  21. package/behaviors/dialog.d.ts.map +1 -0
  22. package/behaviors/dialog.js +3 -0
  23. package/behaviors/disclosure.d.ts +10 -0
  24. package/behaviors/disclosure.d.ts.map +1 -0
  25. package/behaviors/disclosure.js +3 -0
  26. package/behaviors/dismissible.d.ts +10 -0
  27. package/behaviors/dismissible.d.ts.map +1 -0
  28. package/behaviors/dismissible.js +3 -0
  29. package/behaviors/forms.d.ts +27 -0
  30. package/behaviors/forms.d.ts.map +1 -0
  31. package/behaviors/forms.js +18 -5
  32. package/behaviors/glyph.d.ts +14 -0
  33. package/behaviors/glyph.d.ts.map +1 -0
  34. package/behaviors/glyph.js +24 -0
  35. package/behaviors/index.d.ts +31 -237
  36. package/behaviors/index.d.ts.map +1 -0
  37. package/behaviors/index.js +17 -0
  38. package/behaviors/inert.d.ts +20 -0
  39. package/behaviors/inert.d.ts.map +1 -0
  40. package/behaviors/inert.js +46 -0
  41. package/behaviors/internal.d.ts +25 -0
  42. package/behaviors/internal.d.ts.map +1 -0
  43. package/behaviors/internal.js +30 -1
  44. package/behaviors/legend.d.ts +35 -0
  45. package/behaviors/legend.d.ts.map +1 -0
  46. package/behaviors/legend.js +9 -0
  47. package/behaviors/menu.d.ts +16 -0
  48. package/behaviors/menu.d.ts.map +1 -0
  49. package/behaviors/menu.js +3 -0
  50. package/behaviors/modal.d.ts +41 -0
  51. package/behaviors/modal.d.ts.map +1 -0
  52. package/behaviors/modal.js +124 -0
  53. package/behaviors/popover.d.ts +28 -0
  54. package/behaviors/popover.d.ts.map +1 -0
  55. package/behaviors/popover.js +17 -17
  56. package/behaviors/spotlight.d.ts +17 -0
  57. package/behaviors/spotlight.d.ts.map +1 -0
  58. package/behaviors/spotlight.js +3 -0
  59. package/behaviors/table.d.ts +36 -0
  60. package/behaviors/table.d.ts.map +1 -0
  61. package/behaviors/table.js +48 -8
  62. package/behaviors/tabs.d.ts +20 -0
  63. package/behaviors/tabs.d.ts.map +1 -0
  64. package/behaviors/tabs.js +3 -0
  65. package/behaviors/theme.d.ts +54 -0
  66. package/behaviors/theme.d.ts.map +1 -0
  67. package/behaviors/theme.js +17 -0
  68. package/behaviors/toast.d.ts +49 -0
  69. package/behaviors/toast.d.ts.map +1 -0
  70. package/behaviors/toast.js +34 -2
  71. package/classes/classes.json +683 -13
  72. package/classes/index.d.ts +106 -2
  73. package/classes/index.js +249 -65
  74. package/connectors/index.d.ts +12 -0
  75. package/connectors/index.d.ts.map +1 -1
  76. package/connectors/index.js +23 -2
  77. package/css/app.css +26 -0
  78. package/css/bullet.css +108 -0
  79. package/css/code.css +98 -0
  80. package/css/content.css +15 -2
  81. package/css/crosshair.css +7 -7
  82. package/css/diff.css +153 -0
  83. package/css/disclosure.css +18 -4
  84. package/css/dots.css +37 -7
  85. package/css/feedback.css +39 -7
  86. package/css/forms.css +71 -3
  87. package/css/legend.css +5 -2
  88. package/css/motion.css +79 -14
  89. package/css/overlay.css +59 -2
  90. package/css/primitives.css +67 -8
  91. package/css/report.css +40 -0
  92. package/css/sidenote.css +67 -0
  93. package/css/spark.css +62 -0
  94. package/css/table.css +9 -2
  95. package/css/term.css +110 -0
  96. package/css/textref.css +63 -0
  97. package/css/toc.css +91 -0
  98. package/css/tokens.css +14 -1
  99. package/css/tree.css +134 -0
  100. package/dist/bronto.css +1 -1
  101. package/dist/css/analytical.css +1 -1
  102. package/dist/css/app.css +1 -1
  103. package/dist/css/bullet.css +1 -0
  104. package/dist/css/code.css +1 -0
  105. package/dist/css/content.css +1 -1
  106. package/dist/css/crosshair.css +1 -1
  107. package/dist/css/diff.css +1 -0
  108. package/dist/css/disclosure.css +1 -1
  109. package/dist/css/dots.css +1 -1
  110. package/dist/css/feedback.css +1 -1
  111. package/dist/css/forms.css +1 -1
  112. package/dist/css/legend.css +1 -1
  113. package/dist/css/motion.css +1 -1
  114. package/dist/css/overlay.css +1 -1
  115. package/dist/css/primitives.css +1 -1
  116. package/dist/css/report.css +1 -1
  117. package/dist/css/sidenote.css +1 -0
  118. package/dist/css/spark.css +1 -0
  119. package/dist/css/table.css +1 -1
  120. package/dist/css/term.css +1 -0
  121. package/dist/css/textref.css +1 -0
  122. package/dist/css/toc.css +1 -0
  123. package/dist/css/tokens.css +1 -1
  124. package/dist/css/tree.css +1 -0
  125. package/docs/annotations.md +39 -0
  126. package/docs/architecture.md +2 -3
  127. package/docs/bullet.md +78 -0
  128. package/docs/code.md +76 -0
  129. package/docs/d2.md +4 -3
  130. package/docs/diff.md +146 -0
  131. package/docs/legends.md +8 -4
  132. package/docs/mermaid.md +21 -4
  133. package/docs/reference.md +127 -8
  134. package/docs/reporting.md +35 -14
  135. package/docs/sidenote.md +64 -0
  136. package/docs/spark.md +78 -0
  137. package/docs/stability.md +1 -0
  138. package/docs/term.md +81 -0
  139. package/docs/textref.md +78 -0
  140. package/docs/theming.md +44 -5
  141. package/docs/toc.md +83 -0
  142. package/docs/tree.md +74 -0
  143. package/docs/usage.md +264 -23
  144. package/docs/vega.md +22 -3
  145. package/glyphs/glyphs.js +7 -1
  146. package/llms.txt +159 -13
  147. package/package.json +47 -7
  148. package/qwik/index.d.ts +4 -2
  149. package/qwik/index.d.ts.map +1 -1
  150. package/qwik/index.js +10 -0
  151. package/react/index.d.ts +4 -2
  152. package/react/index.d.ts.map +1 -1
  153. package/react/index.js +6 -0
  154. package/solid/index.d.ts +6 -2
  155. package/solid/index.d.ts.map +1 -1
  156. package/solid/index.js +6 -0
@@ -0,0 +1,46 @@
1
+ import { hasDom, resolveHost, noop, bindOnce } from './internal.js';
2
+
3
+ const DISABLED = '[aria-disabled="true"]';
4
+
5
+ /**
6
+ * Make `aria-disabled="true"` controls keyboard-inert, not just pointer-inert.
7
+ *
8
+ * CSS can dim an `aria-disabled` control and set `pointer-events: none`, but it
9
+ * cannot block keyboard activation: a focused `<a aria-disabled>` or
10
+ * `<button aria-disabled>` still fires on Enter/Space, so a keyboard user can
11
+ * activate a control that looks dead. (Native `disabled` is already fully inert;
12
+ * this guard brings the ARIA path up to parity.) The element intentionally stays
13
+ * focusable and announced — the WAI-ARIA disabled pattern keeps it in the tab
14
+ * order — but it no longer *acts*.
15
+ *
16
+ * Wire once near the root, like {@link applyStoredTheme}. Capturing listeners
17
+ * intercept activation before any component handler (tabs, pagination, menus)
18
+ * sees it. (component audit C4.)
19
+ *
20
+ * @param {import('./internal.js').DelegateOpts} [opts]
21
+ * @returns {import('./internal.js').Cleanup}
22
+ */
23
+ export function initDisabledGuard({ root } = {}) {
24
+ if (!hasDom()) return noop;
25
+ const host = resolveHost(root);
26
+ if (!host) return noop;
27
+ const block = (e) => {
28
+ const el = e.target.closest?.(DISABLED);
29
+ if (el && host.contains(el)) {
30
+ e.preventDefault();
31
+ e.stopPropagation();
32
+ }
33
+ };
34
+ const onKeydown = (e) => {
35
+ // Only the activation keys; Tab/arrows must still move focus PAST the control.
36
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') block(e);
37
+ };
38
+ return bindOnce(host, 'disabled-guard', () => {
39
+ host.addEventListener('click', block, true);
40
+ host.addEventListener('keydown', onKeydown, true);
41
+ return () => {
42
+ host.removeEventListener('click', block, true);
43
+ host.removeEventListener('keydown', onKeydown, true);
44
+ };
45
+ });
46
+ }
@@ -0,0 +1,25 @@
1
+ export function resolveHost(root: any, fallback?: Document): any;
2
+ export function bindOnce(target: any, key: any, add: any): () => void;
3
+ export function byIdInHost(host: any, id: any): any;
4
+ export function closestSafe(el: any, selector: any): any;
5
+ export function collectHosts(host: any, selector: any): any[];
6
+ export function scrollIntoViewSafe(el: any, opts?: {
7
+ block: string;
8
+ }): void;
9
+ export function focusInto(container: any): void;
10
+ export function wrapIndex(cur: any, delta: any, len: any): any;
11
+ export function noop(): void;
12
+ export function hasDom(): boolean;
13
+ export function nextFieldUid(): number;
14
+ /**
15
+ * Cleanup function returned by every initializer; calling it tears down the
16
+ * behavior's listeners/observers.
17
+ */
18
+ export type Cleanup = () => void;
19
+ export type DelegateOpts = {
20
+ /**
21
+ * Event-delegation root; also scopes which controls are queried. Default: `document`.
22
+ */
23
+ root?: Document | Element | undefined;
24
+ };
25
+ //# sourceMappingURL=internal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["internal.js"],"names":[],"mappings":"AA8BA,iEAGC;AAeD,sEAUC;AAED,oDAQC;AAED,yDAMC;AAMD,8DAIC;AAID;;SAMC;AAUD,gDAQC;AAMD,+DAKC;AA9GM,6BAAqB;AAErB,kCAAoD;AAsBpD,uCAAqC;;;;;sBAjC/B,MAAM,IAAI"}
@@ -1,6 +1,17 @@
1
1
  // Shared, dependency-free DOM helpers for the behavior modules.
2
2
  // Not part of the public @ponchia/ui/behaviors surface (the barrel
3
- // re-exports only the documented behaviors).
3
+ // re-exports only the documented behaviors' values — these shared option
4
+ // types are re-exported by name from index.js).
5
+
6
+ /**
7
+ * @typedef {() => void} Cleanup
8
+ * Cleanup function returned by every initializer; calling it tears down the
9
+ * behavior's listeners/observers.
10
+ *
11
+ * @typedef {object} DelegateOpts
12
+ * @property {Document | Element} [root]
13
+ * Event-delegation root; also scopes which controls are queried. Default: `document`.
14
+ */
4
15
 
5
16
  export const noop = () => {};
6
17
 
@@ -85,6 +96,24 @@ export function scrollIntoViewSafe(el, opts = { block: 'nearest' }) {
85
96
  }
86
97
  }
87
98
 
99
+ // The focusable-element selector + "move focus into a container" helper shared
100
+ // by the modal and popover focus paths (a dialog/modal must move focus into
101
+ // itself on open). Focus the first focusable descendant, else make the
102
+ // container programmatically focusable and focus it, so a content-only
103
+ // panel/modal still receives focus. (code-quality audit Q4.)
104
+ const FOCUSABLE =
105
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
106
+
107
+ export function focusInto(container) {
108
+ const first = container.querySelector(FOCUSABLE);
109
+ if (first) {
110
+ first.focus?.();
111
+ return;
112
+ }
113
+ if (!container.hasAttribute('tabindex')) container.setAttribute('tabindex', '-1');
114
+ container.focus?.();
115
+ }
116
+
88
117
  // Wrap an index by `delta` within [0, len), the roving keyboard math shared by
89
118
  // the combobox and command listboxes (a -1 `cur` lands on the first/last as
90
119
  // before). Only this core is shared — the surrounding setActive/filter/group
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @typedef {object} LegendToggleDetail
3
+ * @property {string | number} series The entry's `data-series`, or its 0-based index when unset.
4
+ * @property {boolean} active The new state (`true` ⇒ series shown).
5
+ */
6
+ /**
7
+ * Wire `[data-bronto-legend]` interactive legends. Each entry is a
8
+ * `.ui-legend__item` authored as a `<button aria-pressed>`; clicking it (or
9
+ * Enter/Space, native to `<button>`) flips `aria-pressed`, toggles the
10
+ * `.is-inactive` class, and dispatches `bronto:legend:toggle` on the legend
11
+ * with `{ detail: { series, active } }` (`series` is the entry's
12
+ * `data-series`, or its 0-based index if unset).
13
+ *
14
+ * Bronto owns only the control and its pressed/inactive *state*. It does not
15
+ * know the chart's series, hide anything, or announce the change: the host
16
+ * listens for the event, hides its own series, and owns any `aria-live`
17
+ * announcement. The convention is `aria-pressed="true"` ⇒ the series is shown
18
+ * (the default); the entry's label never changes on toggle (WAI-ARIA). SSR-safe
19
+ * and idempotent per host; returns a cleanup function.
20
+ *
21
+ * @param {import('./internal.js').DelegateOpts} [opts]
22
+ * @returns {import('./internal.js').Cleanup}
23
+ */
24
+ export function initLegend({ root }?: import("./internal.js").DelegateOpts): import("./internal.js").Cleanup;
25
+ export type LegendToggleDetail = {
26
+ /**
27
+ * The entry's `data-series`, or its 0-based index when unset.
28
+ */
29
+ series: string | number;
30
+ /**
31
+ * The new state (`true` ⇒ series shown).
32
+ */
33
+ active: boolean;
34
+ };
35
+ //# sourceMappingURL=legend.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"legend.d.ts","sourceRoot":"","sources":["legend.js"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,sCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAmD3C;;;;;YAvEa,MAAM,GAAG,MAAM;;;;YACf,OAAO"}
@@ -1,5 +1,11 @@
1
1
  import { hasDom, resolveHost, noop, bindOnce } from './internal.js';
2
2
 
3
+ /**
4
+ * @typedef {object} LegendToggleDetail
5
+ * @property {string | number} series The entry's `data-series`, or its 0-based index when unset.
6
+ * @property {boolean} active The new state (`true` ⇒ series shown).
7
+ */
8
+
3
9
  /**
4
10
  * Wire `[data-bronto-legend]` interactive legends. Each entry is a
5
11
  * `.ui-legend__item` authored as a `<button aria-pressed>`; clicking it (or
@@ -14,6 +20,9 @@ import { hasDom, resolveHost, noop, bindOnce } from './internal.js';
14
20
  * announcement. The convention is `aria-pressed="true"` ⇒ the series is shown
15
21
  * (the default); the entry's label never changes on toggle (WAI-ARIA). SSR-safe
16
22
  * and idempotent per host; returns a cleanup function.
23
+ *
24
+ * @param {import('./internal.js').DelegateOpts} [opts]
25
+ * @returns {import('./internal.js').Cleanup}
17
26
  */
18
27
  export function initLegend({ root } = {}) {
19
28
  if (!hasDom()) return noop;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Dropdown-menu close affordances for a native `<details data-bronto-menu>`
3
+ * holding a `.ui-menu`. `<details>` alone won't close on Escape, on an
4
+ * outside click, or when a `.ui-menu__item` is activated — this adds
5
+ * exactly those, returning focus to the `<summary>` on Esc/activate.
6
+ *
7
+ * Deliberately NOT a full WAI-ARIA menu (no arrow-key roving): the items
8
+ * are real buttons, Tab-reachable; this is a disclosure of actions, and
9
+ * over-claiming `role="menu"` semantics would be worse. SSR-safe,
10
+ * idempotent; returns a cleanup function.
11
+ *
12
+ * @param {import('./internal.js').DelegateOpts} [opts]
13
+ * @returns {import('./internal.js').Cleanup}
14
+ */
15
+ export function initMenu({ root }?: import("./internal.js").DelegateOpts): import("./internal.js").Cleanup;
16
+ //# sourceMappingURL=menu.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"menu.d.ts","sourceRoot":"","sources":["menu.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;GAaG;AACH,oCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAmC3C"}
package/behaviors/menu.js CHANGED
@@ -10,6 +10,9 @@ import { hasDom, resolveHost, noop, bindOnce } from './internal.js';
10
10
  * are real buttons, Tab-reachable; this is a disclosure of actions, and
11
11
  * over-claiming `role="menu"` semantics would be worse. SSR-safe,
12
12
  * idempotent; returns a cleanup function.
13
+ *
14
+ * @param {import('./internal.js').DelegateOpts} [opts]
15
+ * @returns {import('./internal.js').Cleanup}
13
16
  */
14
17
  export function initMenu({ root } = {}) {
15
18
  if (!hasDom()) return noop;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @typedef {object} ModalCloseDetail
3
+ * @property {'escape'} reason What asked the modal to close (currently only Escape).
4
+ */
5
+ /**
6
+ * Focus management for the **controlled, non-`<dialog>` modal** — the
7
+ * `.ui-modal.is-open` path a portal/React overlay uses when it genuinely can't
8
+ * be a native `<dialog>`. The native `<dialog>` path gets a focus trap, Escape,
9
+ * and the top layer for free (use `initDialog`); this supplies the equivalent
10
+ * for the `.is-open` path, which otherwise leaves focus management to the
11
+ * consumer.
12
+ *
13
+ * Mark the overlay `[data-bronto-modal]` (opt-in). On bind it gives the modal a
14
+ * `role="dialog"` + `aria-modal="true"` (unless the author set a role) and
15
+ * dev-warns when it has no accessible name, so it announces as a named modal
16
+ * dialog — parity with `initPopover`. The behavior watches its
17
+ * `class` for `is-open`: on open it remembers the focused element, moves focus
18
+ * into the modal (first focusable, else the panel itself), and **traps focus by
19
+ * marking every sibling at each ancestor level `inert`** so the rest of the page
20
+ * is non-focusable and non-interactive — the modern, robust trap. On close it
21
+ * un-inerts exactly what it inerted and returns focus to the opener. Bronto owns
22
+ * focus only: the **consumer still owns open/close state** (the `is-open`
23
+ * class). Escape dispatches a cancelable `bronto:modal:close`
24
+ * ({@link ModalCloseDetail}) on the modal so the consumer can drop `is-open` in
25
+ * response; the behavior never changes visibility itself.
26
+ *
27
+ * Best suited to a body-/portal-level overlay (the documented `.is-open` use
28
+ * case); a deeply-nested modal still gets focus-into, focus-return, and the
29
+ * Escape signal. SSR-safe, idempotent per modal; returns a cleanup function.
30
+ *
31
+ * @param {import('./internal.js').DelegateOpts} [opts]
32
+ * @returns {import('./internal.js').Cleanup}
33
+ */
34
+ export function initModal({ root }?: import("./internal.js").DelegateOpts): import("./internal.js").Cleanup;
35
+ export type ModalCloseDetail = {
36
+ /**
37
+ * What asked the modal to close (currently only Escape).
38
+ */
39
+ reason: "escape";
40
+ };
41
+ //# sourceMappingURL=modal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modal.d.ts","sourceRoot":"","sources":["modal.js"],"names":[],"mappings":"AAEA;;;GAGG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAyF3C;;;;;YAvHa,QAAQ"}
@@ -0,0 +1,124 @@
1
+ import { hasDom, resolveHost, noop, bindOnce, collectHosts, focusInto } from './internal.js';
2
+
3
+ /**
4
+ * @typedef {object} ModalCloseDetail
5
+ * @property {'escape'} reason What asked the modal to close (currently only Escape).
6
+ */
7
+
8
+ /**
9
+ * Focus management for the **controlled, non-`<dialog>` modal** — the
10
+ * `.ui-modal.is-open` path a portal/React overlay uses when it genuinely can't
11
+ * be a native `<dialog>`. The native `<dialog>` path gets a focus trap, Escape,
12
+ * and the top layer for free (use `initDialog`); this supplies the equivalent
13
+ * for the `.is-open` path, which otherwise leaves focus management to the
14
+ * consumer.
15
+ *
16
+ * Mark the overlay `[data-bronto-modal]` (opt-in). On bind it gives the modal a
17
+ * `role="dialog"` + `aria-modal="true"` (unless the author set a role) and
18
+ * dev-warns when it has no accessible name, so it announces as a named modal
19
+ * dialog — parity with `initPopover`. The behavior watches its
20
+ * `class` for `is-open`: on open it remembers the focused element, moves focus
21
+ * into the modal (first focusable, else the panel itself), and **traps focus by
22
+ * marking every sibling at each ancestor level `inert`** so the rest of the page
23
+ * is non-focusable and non-interactive — the modern, robust trap. On close it
24
+ * un-inerts exactly what it inerted and returns focus to the opener. Bronto owns
25
+ * focus only: the **consumer still owns open/close state** (the `is-open`
26
+ * class). Escape dispatches a cancelable `bronto:modal:close`
27
+ * ({@link ModalCloseDetail}) on the modal so the consumer can drop `is-open` in
28
+ * response; the behavior never changes visibility itself.
29
+ *
30
+ * Best suited to a body-/portal-level overlay (the documented `.is-open` use
31
+ * case); a deeply-nested modal still gets focus-into, focus-return, and the
32
+ * Escape signal. SSR-safe, idempotent per modal; returns a cleanup function.
33
+ *
34
+ * @param {import('./internal.js').DelegateOpts} [opts]
35
+ * @returns {import('./internal.js').Cleanup}
36
+ */
37
+ export function initModal({ root } = {}) {
38
+ if (!hasDom()) return noop;
39
+ const host = resolveHost(root);
40
+ if (!host) return noop;
41
+ const modals = collectHosts(host, '[data-bronto-modal]');
42
+ const cleanups = [];
43
+
44
+ for (const modal of modals) {
45
+ let opener = null;
46
+ let inerted = [];
47
+
48
+ // A controlled modal must announce AS a modal dialog, not a generic group —
49
+ // parity with initPopover. Apply a dialog role + aria-modal (unless the
50
+ // author set a role), and dev-warn on a missing accessible name since we
51
+ // can't invent a good one. (component audit C13.)
52
+ if (!modal.hasAttribute('role')) modal.setAttribute('role', 'dialog');
53
+ if (!modal.hasAttribute('aria-modal')) modal.setAttribute('aria-modal', 'true');
54
+ const named =
55
+ modal.hasAttribute('aria-label') ||
56
+ modal.hasAttribute('aria-labelledby') ||
57
+ modal.hasAttribute('title');
58
+ if (!named && typeof console !== 'undefined') {
59
+ console.warn(
60
+ `[bronto] initModal(): a [data-bronto-modal] has no accessible name — add aria-label or aria-labelledby so it is announced as a named dialog.`,
61
+ );
62
+ }
63
+
64
+ // Inert every sibling at each ancestor level up to <body>: the rest of the
65
+ // page becomes non-focusable/non-interactive while the modal subtree stays
66
+ // live. Skip already-inert nodes so release() can't un-inert something the
67
+ // app inerted for its own reasons.
68
+ const trap = () => {
69
+ if (opener) return; // already trapped
70
+ opener = document.activeElement;
71
+ let el = modal;
72
+ while (el && el.parentElement && el !== document.body) {
73
+ for (const sib of el.parentElement.children) {
74
+ if (sib !== el && !sib.inert) {
75
+ sib.inert = true;
76
+ inerted.push(sib);
77
+ }
78
+ }
79
+ el = el.parentElement;
80
+ }
81
+ focusInto(modal);
82
+ };
83
+
84
+ const release = () => {
85
+ if (!opener) return;
86
+ for (const el of inerted) el.inert = false;
87
+ inerted = [];
88
+ const back = opener;
89
+ opener = null;
90
+ if (back?.isConnected && typeof back.focus === 'function') back.focus();
91
+ };
92
+
93
+ const sync = () => (modal.classList.contains('is-open') ? trap() : release());
94
+
95
+ const onKey = (e) => {
96
+ if (e.key === 'Escape' && opener) {
97
+ modal.dispatchEvent(
98
+ new CustomEvent('bronto:modal:close', {
99
+ detail: { reason: 'escape' },
100
+ bubbles: true,
101
+ cancelable: true,
102
+ }),
103
+ );
104
+ }
105
+ };
106
+
107
+ const observer = typeof MutationObserver === 'function' ? new MutationObserver(sync) : null;
108
+
109
+ cleanups.push(
110
+ bindOnce(modal, 'modal', () => {
111
+ observer?.observe(modal, { attributes: true, attributeFilter: ['class'] });
112
+ document.addEventListener('keydown', onKey, true);
113
+ if (modal.classList.contains('is-open')) trap(); // already open at init
114
+ return () => {
115
+ observer?.disconnect();
116
+ document.removeEventListener('keydown', onKey, true);
117
+ release();
118
+ };
119
+ }),
120
+ );
121
+ }
122
+
123
+ return () => cleanups.forEach((fn) => fn());
124
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Collision-aware popover, dependency-free. A `[data-bronto-popover]`
3
+ * trigger toggles the `.ui-popover` panel whose id it names. The panel
4
+ * is placed under the trigger and **flips above** when it would
5
+ * overflow the viewport, with its inline edge clamped on-screen — the
6
+ * thing the CSS-only tooltip can't do near edges / inside scroll
7
+ * containers. If the panel has the native `popover` attribute and the
8
+ * Popover API is available it is shown in the top layer (never
9
+ * clipped); otherwise an `.is-open` class is toggled. Manages
10
+ * `aria-expanded` / `aria-controls`, closes on Escape and outside
11
+ * click, and re-positions on scroll/resize while open. SSR-safe,
12
+ * idempotent; returns a cleanup function.
13
+ *
14
+ * The trigger advertises `aria-haspopup="dialog"`, so on open the panel is
15
+ * given `role="dialog"` (unless the author set a role) and focus is moved into
16
+ * it — the first focusable descendant, or the panel itself. It is a *non-modal*
17
+ * dialog: the rest of the page stays interactive and there is no focus trap.
18
+ * Author an accessible name on the panel (`aria-label` / `aria-labelledby`); a
19
+ * dev-time `console.warn` fires when it is missing.
20
+ *
21
+ * Escape returns focus to the trigger; closing via outside-click leaves focus
22
+ * where the click landed (treated as deliberate intent to move on).
23
+ *
24
+ * @param {import('./internal.js').DelegateOpts} [opts]
25
+ * @returns {import('./internal.js').Cleanup}
26
+ */
27
+ export function initPopover({ root }?: import("./internal.js").DelegateOpts): import("./internal.js").Cleanup;
28
+ //# sourceMappingURL=popover.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"popover.d.ts","sourceRoot":"","sources":["popover.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,uCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAwJ3C"}
@@ -1,4 +1,4 @@
1
- import { hasDom, resolveHost, noop, bindOnce, byIdInHost } from './internal.js';
1
+ import { hasDom, resolveHost, noop, bindOnce, byIdInHost, focusInto } from './internal.js';
2
2
 
3
3
  /**
4
4
  * Collision-aware popover, dependency-free. A `[data-bronto-popover]`
@@ -22,6 +22,9 @@ import { hasDom, resolveHost, noop, bindOnce, byIdInHost } from './internal.js';
22
22
  *
23
23
  * Escape returns focus to the trigger; closing via outside-click leaves focus
24
24
  * where the click landed (treated as deliberate intent to move on).
25
+ *
26
+ * @param {import('./internal.js').DelegateOpts} [opts]
27
+ * @returns {import('./internal.js').Cleanup}
25
28
  */
26
29
  export function initPopover({ root } = {}) {
27
30
  if (!hasDom()) return noop;
@@ -32,22 +35,9 @@ export function initPopover({ root } = {}) {
32
35
  let openPanel = null;
33
36
  let openTrigger = null;
34
37
 
35
- const FOCUSABLE =
36
- 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
37
-
38
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
- };
39
+ // dialog: a role, an accessible name, and focus moved into it (C6) see the
40
+ // shared `focusInto` in internal.js.
51
41
 
52
42
  const place = (trigger, panel) => {
53
43
  const r = trigger.getBoundingClientRect();
@@ -122,7 +112,17 @@ export function initPopover({ root } = {}) {
122
112
  };
123
113
  const onKey = (e) => {
124
114
  // close() returns focus to the trigger because focus is inside the panel.
125
- if (e.key === 'Escape' && openPanel) close();
115
+ if (e.key !== 'Escape' || !openPanel) return;
116
+ // A popover open *inside* a <dialog>/modal owns this Escape. Without this,
117
+ // the same keypress closed BOTH: we hidePopover() the panel synchronously,
118
+ // the browser's close-request then finds the dialog as the new topmost
119
+ // element and dismisses it too. preventDefault() stops that native
120
+ // close-request and stopPropagation() keeps it off other delegated keydown
121
+ // listeners (e.g. initModal's), so only the popover closes — the documented
122
+ // "popover + dialog open together" contract.
123
+ e.preventDefault();
124
+ e.stopPropagation();
125
+ close();
126
126
  };
127
127
  const onReflow = () => {
128
128
  if (openPanel && openTrigger) place(openTrigger, openPanel);
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Position a spotlight cutout over a target element. Each
3
+ * `[data-bronto-spotlight]` is a `.ui-spotlight` overlay; `data-target` is the
4
+ * id of the element to highlight. The behavior measures the target and sets
5
+ * `--spot-x/y/w/h` (viewport coordinates) on the overlay, re-placing on
6
+ * resize/scroll and whenever `data-target` changes.
7
+ *
8
+ * Bronto owns only positioning + the visual language. It is NOT a tour engine:
9
+ * the host decides which target is current, when to advance, and whether to
10
+ * show/hide the overlay — just update `data-target` (or toggle `hidden`) and
11
+ * the cutout follows. SSR-safe, idempotent per host; returns a cleanup.
12
+ *
13
+ * @param {import('./internal.js').DelegateOpts} [opts]
14
+ * @returns {import('./internal.js').Cleanup}
15
+ */
16
+ export function initSpotlight({ root }?: import("./internal.js").DelegateOpts): import("./internal.js").Cleanup;
17
+ //# sourceMappingURL=spotlight.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spotlight.d.ts","sourceRoot":"","sources":["spotlight.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;GAcG;AACH,yCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAuC3C"}
@@ -11,6 +11,9 @@ import { hasDom, resolveHost, noop, bindOnce, byIdInHost, collectHosts } from '.
11
11
  * the host decides which target is current, when to advance, and whether to
12
12
  * show/hide the overlay — just update `data-target` (or toggle `hidden`) and
13
13
  * the cutout follows. SSR-safe, idempotent per host; returns a cleanup.
14
+ *
15
+ * @param {import('./internal.js').DelegateOpts} [opts]
16
+ * @returns {import('./internal.js').Cleanup}
14
17
  */
15
18
  export function initSpotlight({ root } = {}) {
16
19
  if (!hasDom()) return noop;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Client-side sortable + selectable data table. Wires
3
+ * `[data-bronto-sortable]`:
4
+ *
5
+ * - clicking a header's `.ui-table__sort` button sorts the tbody by
6
+ * that column. Sortable headers are seeded `aria-sort="none"`; a
7
+ * click toggles that header ascending ⇄ descending (first click =
8
+ * ascending) and resets the other sortable headers to `none`.
9
+ * Numeric columns (`data-sort="num"` or `.is-num` cells) sort
10
+ * numerically; everything else, locale string compare. Any
11
+ * `.ui-table__empty` sentinel row is kept last after a sort.
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
+ * The numeric sort parses each cell's display text after normalizing the
21
+ * common report shapes: a Unicode minus (U+2212) and en/em dashes count as a
22
+ * sign (so a "−5" loss sorts BELOW a "5" gain, not above it), accounting
23
+ * parentheses `(1,234)` read as negative, and `,` thousands separators are
24
+ * dropped. Note the consequence: a bare `,` in the *display text* is read as a
25
+ * thousands separator, so a European decimal "3,5" sorts as 35, not 3.5 — for a
26
+ * European decimal comma (or mixed units, or any ambiguous text) put the
27
+ * canonical number in a `data-sort-value` attribute on the cell. That escape
28
+ * hatch wins over the parsed text and accepts either a dot ("3.5") or a single
29
+ * decimal comma ("3,5"). It is a client-side convenience sorter, not a data
30
+ * grid. (component audit C3/C5.)
31
+ *
32
+ * @param {import('./internal.js').DelegateOpts} [opts]
33
+ * @returns {import('./internal.js').Cleanup}
34
+ */
35
+ export function initTableSort({ root }?: import("./internal.js").DelegateOpts): import("./internal.js").Cleanup;
36
+ //# sourceMappingURL=table.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"table.d.ts","sourceRoot":"","sources":["table.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,yCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CA6I3C"}
@@ -19,9 +19,20 @@ import { hasDom, resolveHost, noop, bindOnce, collectHosts } from './internal.js
19
19
  *
20
20
  * SSR-safe, idempotent per table; returns a cleanup function.
21
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.
22
+ * The numeric sort parses each cell's display text after normalizing the
23
+ * common report shapes: a Unicode minus (U+2212) and en/em dashes count as a
24
+ * sign (so a "−5" loss sorts BELOW a "5" gain, not above it), accounting
25
+ * parentheses `(1,234)` read as negative, and `,` thousands separators are
26
+ * dropped. Note the consequence: a bare `,` in the *display text* is read as a
27
+ * thousands separator, so a European decimal "3,5" sorts as 35, not 3.5 — for a
28
+ * European decimal comma (or mixed units, or any ambiguous text) put the
29
+ * canonical number in a `data-sort-value` attribute on the cell. That escape
30
+ * hatch wins over the parsed text and accepts either a dot ("3.5") or a single
31
+ * decimal comma ("3,5"). It is a client-side convenience sorter, not a data
32
+ * grid. (component audit C3/C5.)
33
+ *
34
+ * @param {import('./internal.js').DelegateOpts} [opts]
35
+ * @returns {import('./internal.js').Cleanup}
25
36
  */
26
37
  export function initTableSort({ root } = {}) {
27
38
  if (!hasDom()) return noop;
@@ -44,6 +55,38 @@ export function initTableSort({ root } = {}) {
44
55
 
45
56
  const colIndex = (th) => [...th.parentElement.children].indexOf(th);
46
57
  const cellText = (row, i) => row.children[i]?.textContent.trim() ?? '';
58
+ // Numeric value of a cell for sorting. A `data-sort-value` attribute is the
59
+ // authoritative escape hatch; otherwise normalize the display text so the
60
+ // sign survives (U+2212 / en-em dashes → minus, accounting parens →
61
+ // negative) and `,` grouping is dropped. Returns 0 for unparseable cells so
62
+ // they cluster rather than scatter. (component audit C3.)
63
+ const cellNum = (row, i) => {
64
+ const cell = row.children[i];
65
+ const explicit = cell?.getAttribute?.('data-sort-value');
66
+ if (explicit != null && explicit.trim() !== '') {
67
+ const raw = explicit.trim();
68
+ let v = Number(raw);
69
+ // The escape hatch must actually handle the case the doc names it for: a
70
+ // European decimal comma. `Number("3,5")` is NaN, which silently fell
71
+ // back to parsing the display text (where `,` is dropped as a thousands
72
+ // separator → "35"). A lone comma with no dot is a decimal point here.
73
+ if (!Number.isFinite(v) && /^[+-]?\d+,\d+$/.test(raw)) v = Number(raw.replace(',', '.'));
74
+ if (Number.isFinite(v)) return v;
75
+ }
76
+ let s = (cell?.textContent ?? '').trim();
77
+ if (!s) return 0;
78
+ let sign = 1;
79
+ const paren = /^\((.*)\)$/.exec(s); // accounting negative
80
+ if (paren) {
81
+ sign = -1;
82
+ s = paren[1];
83
+ }
84
+ s = s.replace(/[−–—]/g, '-'); // minus / en / em dash → '-'
85
+ if (/-/.test(s)) sign *= -1;
86
+ s = s.replace(/,/g, ''); // drop thousands separators
87
+ const v = parseFloat(s.replace(/[^\d.]/g, '')); // magnitude
88
+ return Number.isFinite(v) ? sign * v : 0;
89
+ };
47
90
 
48
91
  const sortBy = (th, numeric) => {
49
92
  const headers = th.closest('tr').querySelectorAll('th');
@@ -61,12 +104,9 @@ export function initTableSort({ root } = {}) {
61
104
  const emptyRows = [...tbody.rows].filter((r) => r.classList.contains('ui-table__empty'));
62
105
  const rows = [...tbody.rows].filter((r) => !r.classList.contains('ui-table__empty'));
63
106
  rows.sort((a, b) => {
64
- const x = cellText(a, i);
65
- const y = cellText(b, i);
66
107
  const cmp = numeric
67
- ? (parseFloat(x.replace(/[^\d.-]/g, '')) || 0) -
68
- (parseFloat(y.replace(/[^\d.-]/g, '')) || 0)
69
- : x.localeCompare(y);
108
+ ? cellNum(a, i) - cellNum(b, i)
109
+ : cellText(a, i).localeCompare(cellText(b, i));
70
110
  return cmp * sign;
71
111
  });
72
112
  // Re-parent in document order: sorted data rows, then any empty/sentinel
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Wire `[data-bronto-tabs]` groups for full keyboard a11y. The framework
3
+ * ships the look + the ARIA/`.is-active` contract; this adds the WAI-ARIA
4
+ * Tabs pattern: roving `tabindex`, `aria-selected`, Arrow/Home/End
5
+ * navigation with automatic activation, and panel `hidden` sync. Tabs are
6
+ * `.ui-tab[data-tab]`; panels are `.ui-tabs__panel[data-panel]` with
7
+ * matching values. SSR-safe and idempotent (re-init replaces, never
8
+ * stacks, the per-group listeners); returns a cleanup function.
9
+ *
10
+ * Accessibility caveat: this is what makes tabs operable. Do **not**
11
+ * author `hidden` on `.ui-tabs__panel` in server-rendered markup unless
12
+ * `initTabs` is guaranteed to run client-side — without it the panels
13
+ * stay hidden with no keyboard/pointer way to reveal them. Prefer
14
+ * authoring all panels visible and letting `initTabs` add `hidden`.
15
+ *
16
+ * @param {import('./internal.js').DelegateOpts} [opts]
17
+ * @returns {import('./internal.js').Cleanup}
18
+ */
19
+ export function initTabs({ root }?: import("./internal.js").DelegateOpts): import("./internal.js").Cleanup;
20
+ //# sourceMappingURL=tabs.d.ts.map