@ponchia/ui 0.6.0 → 0.6.4

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 (162) hide show
  1. package/CHANGELOG.md +82 -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 +21 -0
  33. package/behaviors/glyph.d.ts.map +1 -0
  34. package/behaviors/glyph.js +82 -4
  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 +747 -15
  72. package/classes/index.d.ts +118 -3
  73. package/classes/index.js +264 -66
  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 +246 -9
  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 +43 -4
  92. package/css/sidenote.css +67 -0
  93. package/css/skins.css +9 -0
  94. package/css/spark.css +76 -0
  95. package/css/table.css +16 -3
  96. package/css/term.css +110 -0
  97. package/css/textref.css +63 -0
  98. package/css/toc.css +91 -0
  99. package/css/tokens.css +14 -1
  100. package/css/tree.css +134 -0
  101. package/dist/bronto.css +1 -1
  102. package/dist/css/analytical.css +1 -1
  103. package/dist/css/app.css +1 -1
  104. package/dist/css/bullet.css +1 -0
  105. package/dist/css/code.css +1 -0
  106. package/dist/css/content.css +1 -1
  107. package/dist/css/crosshair.css +1 -1
  108. package/dist/css/diff.css +1 -0
  109. package/dist/css/disclosure.css +1 -1
  110. package/dist/css/dots.css +1 -1
  111. package/dist/css/feedback.css +1 -1
  112. package/dist/css/forms.css +1 -1
  113. package/dist/css/legend.css +1 -1
  114. package/dist/css/motion.css +1 -1
  115. package/dist/css/overlay.css +1 -1
  116. package/dist/css/primitives.css +1 -1
  117. package/dist/css/report.css +1 -1
  118. package/dist/css/sidenote.css +1 -0
  119. package/dist/css/skins.css +1 -1
  120. package/dist/css/spark.css +1 -0
  121. package/dist/css/table.css +1 -1
  122. package/dist/css/term.css +1 -0
  123. package/dist/css/textref.css +1 -0
  124. package/dist/css/toc.css +1 -0
  125. package/dist/css/tokens.css +1 -1
  126. package/dist/css/tree.css +1 -0
  127. package/docs/annotations.md +39 -0
  128. package/docs/architecture.md +2 -3
  129. package/docs/bullet.md +78 -0
  130. package/docs/code.md +76 -0
  131. package/docs/d2.md +4 -3
  132. package/docs/diff.md +146 -0
  133. package/docs/dots.md +146 -0
  134. package/docs/glyphs.md +114 -0
  135. package/docs/legends.md +8 -4
  136. package/docs/mermaid.md +21 -4
  137. package/docs/reference.md +168 -8
  138. package/docs/reporting.md +49 -17
  139. package/docs/sidenote.md +64 -0
  140. package/docs/spark.md +78 -0
  141. package/docs/stability.md +1 -0
  142. package/docs/term.md +81 -0
  143. package/docs/textref.md +78 -0
  144. package/docs/theming.md +44 -5
  145. package/docs/toc.md +83 -0
  146. package/docs/tree.md +74 -0
  147. package/docs/usage.md +264 -23
  148. package/docs/vega.md +22 -3
  149. package/glyphs/glyphs.d.ts +61 -0
  150. package/glyphs/glyphs.js +600 -31
  151. package/llms.txt +169 -15
  152. package/package.json +51 -7
  153. package/qwik/index.d.ts +4 -2
  154. package/qwik/index.d.ts.map +1 -1
  155. package/qwik/index.js +10 -0
  156. package/react/index.d.ts +4 -2
  157. package/react/index.d.ts.map +1 -1
  158. package/react/index.js +6 -0
  159. package/solid/index.d.ts +6 -2
  160. package/solid/index.d.ts.map +1 -1
  161. package/solid/index.js +6 -0
  162. package/tokens/skins.js +22 -9
@@ -12,11 +12,28 @@
12
12
  * import { applyStoredTheme, initThemeToggle } from '@ponchia/ui/behaviors';
13
13
  * applyStoredTheme(); // before paint, avoids theme flash
14
14
  * const stop = initThemeToggle(); // wire [data-bronto-theme-toggle]
15
+ *
16
+ * The public option/detail types are JSDoc `@typedef`s on the modules below,
17
+ * re-exported here by name; the shipped `index.d.ts` is generated from this
18
+ * source by `tsc --emitDeclarationOnly` (so the declarations cannot drift).
19
+ *
20
+ * @typedef {import('./internal.js').Cleanup} Cleanup
21
+ * @typedef {import('./internal.js').DelegateOpts} DelegateOpts
22
+ * @typedef {import('./theme.js').ThemeStorageOpts} ThemeStorageOpts
23
+ * @typedef {import('./theme.js').ApplyThemeOpts} ApplyThemeOpts
24
+ * @typedef {import('./theme.js').ThemeChangeDetail} ThemeChangeDetail
25
+ * @typedef {import('./toast.js').ToastOpts} ToastOpts
26
+ * @typedef {import('./modal.js').ModalCloseDetail} ModalCloseDetail
27
+ * @typedef {import('./legend.js').LegendToggleDetail} LegendToggleDetail
28
+ * @typedef {import('./crosshair.js').CrosshairMoveDetail} CrosshairMoveDetail
29
+ * @typedef {import('./command.js').CommandSelectDetail} CommandSelectDetail
15
30
  */
16
31
  export { applyStoredTheme, initThemeToggle } from './theme.js';
17
32
  export { dismissible } from './dismissible.js';
33
+ export { initDisabledGuard } from './inert.js';
18
34
  export { initTabs } from './tabs.js';
19
35
  export { initDialog } from './dialog.js';
36
+ export { initModal } from './modal.js';
20
37
  export { toast } from './toast.js';
21
38
  export { initDisclosure } from './disclosure.js';
22
39
  export { initMenu } from './menu.js';
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Make `aria-disabled="true"` controls keyboard-inert, not just pointer-inert.
3
+ *
4
+ * CSS can dim an `aria-disabled` control and set `pointer-events: none`, but it
5
+ * cannot block keyboard activation: a focused `<a aria-disabled>` or
6
+ * `<button aria-disabled>` still fires on Enter/Space, so a keyboard user can
7
+ * activate a control that looks dead. (Native `disabled` is already fully inert;
8
+ * this guard brings the ARIA path up to parity.) The element intentionally stays
9
+ * focusable and announced — the WAI-ARIA disabled pattern keeps it in the tab
10
+ * order — but it no longer *acts*.
11
+ *
12
+ * Wire once near the root, like {@link applyStoredTheme}. Capturing listeners
13
+ * intercept activation before any component handler (tabs, pagination, menus)
14
+ * sees it. (component audit C4.)
15
+ *
16
+ * @param {import('./internal.js').DelegateOpts} [opts]
17
+ * @returns {import('./internal.js').Cleanup}
18
+ */
19
+ export function initDisabledGuard({ root }?: import("./internal.js").DelegateOpts): import("./internal.js").Cleanup;
20
+ //# sourceMappingURL=inert.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inert.d.ts","sourceRoot":"","sources":["inert.js"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;GAiBG;AACH,6CAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAyB3C"}
@@ -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"}