@ponchia/ui 0.5.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 (196) hide show
  1. package/CHANGELOG.md +386 -4
  2. package/MIGRATIONS.json +14 -0
  3. package/README.md +29 -6
  4. package/annotations/index.d.ts +398 -276
  5. package/annotations/index.d.ts.map +1 -0
  6. package/annotations/index.js +350 -77
  7. package/behaviors/carousel.d.ts +28 -0
  8. package/behaviors/carousel.d.ts.map +1 -0
  9. package/behaviors/carousel.js +20 -16
  10. package/behaviors/combobox.d.ts +40 -0
  11. package/behaviors/combobox.d.ts.map +1 -0
  12. package/behaviors/combobox.js +111 -29
  13. package/behaviors/command.d.ts +41 -0
  14. package/behaviors/command.d.ts.map +1 -0
  15. package/behaviors/command.js +27 -15
  16. package/behaviors/connectors.d.ts +17 -0
  17. package/behaviors/connectors.d.ts.map +1 -0
  18. package/behaviors/connectors.js +7 -5
  19. package/behaviors/crosshair.d.ts +42 -0
  20. package/behaviors/crosshair.d.ts.map +1 -0
  21. package/behaviors/crosshair.js +23 -6
  22. package/behaviors/dialog.d.ts +20 -0
  23. package/behaviors/dialog.d.ts.map +1 -0
  24. package/behaviors/dialog.js +6 -2
  25. package/behaviors/disclosure.d.ts +10 -0
  26. package/behaviors/disclosure.d.ts.map +1 -0
  27. package/behaviors/disclosure.js +6 -2
  28. package/behaviors/dismissible.d.ts +10 -0
  29. package/behaviors/dismissible.d.ts.map +1 -0
  30. package/behaviors/dismissible.js +6 -2
  31. package/behaviors/forms.d.ts +27 -0
  32. package/behaviors/forms.d.ts.map +1 -0
  33. package/behaviors/forms.js +54 -13
  34. package/behaviors/glyph.d.ts +14 -0
  35. package/behaviors/glyph.d.ts.map +1 -0
  36. package/behaviors/glyph.js +28 -5
  37. package/behaviors/index.d.ts +31 -237
  38. package/behaviors/index.d.ts.map +1 -0
  39. package/behaviors/index.js +17 -0
  40. package/behaviors/inert.d.ts +20 -0
  41. package/behaviors/inert.d.ts.map +1 -0
  42. package/behaviors/inert.js +46 -0
  43. package/behaviors/internal.d.ts +25 -0
  44. package/behaviors/internal.d.ts.map +1 -0
  45. package/behaviors/internal.js +77 -1
  46. package/behaviors/legend.d.ts +35 -0
  47. package/behaviors/legend.d.ts.map +1 -0
  48. package/behaviors/legend.js +32 -2
  49. package/behaviors/menu.d.ts +16 -0
  50. package/behaviors/menu.d.ts.map +1 -0
  51. package/behaviors/menu.js +6 -2
  52. package/behaviors/modal.d.ts +41 -0
  53. package/behaviors/modal.d.ts.map +1 -0
  54. package/behaviors/modal.js +124 -0
  55. package/behaviors/popover.d.ts +28 -0
  56. package/behaviors/popover.d.ts.map +1 -0
  57. package/behaviors/popover.js +78 -7
  58. package/behaviors/spotlight.d.ts +17 -0
  59. package/behaviors/spotlight.d.ts.map +1 -0
  60. package/behaviors/spotlight.js +7 -5
  61. package/behaviors/table.d.ts +36 -0
  62. package/behaviors/table.d.ts.map +1 -0
  63. package/behaviors/table.js +84 -17
  64. package/behaviors/tabs.d.ts +20 -0
  65. package/behaviors/tabs.d.ts.map +1 -0
  66. package/behaviors/tabs.js +17 -14
  67. package/behaviors/theme.d.ts +54 -0
  68. package/behaviors/theme.d.ts.map +1 -0
  69. package/behaviors/theme.js +22 -3
  70. package/behaviors/toast.d.ts +49 -0
  71. package/behaviors/toast.d.ts.map +1 -0
  72. package/behaviors/toast.js +47 -3
  73. package/classes/classes.json +2527 -0
  74. package/classes/index.d.ts +134 -15
  75. package/classes/index.js +280 -80
  76. package/classes/vscode.css-custom-data.json +12 -0
  77. package/connectors/index.d.ts +201 -69
  78. package/connectors/index.d.ts.map +1 -0
  79. package/connectors/index.js +142 -25
  80. package/css/app.css +69 -13
  81. package/css/base.css +15 -10
  82. package/css/bullet.css +108 -0
  83. package/css/code.css +98 -0
  84. package/css/connectors.css +17 -0
  85. package/css/content.css +22 -3
  86. package/css/crosshair.css +7 -7
  87. package/css/dataviz.css +5 -1
  88. package/css/diff.css +153 -0
  89. package/css/disclosure.css +53 -7
  90. package/css/dots.css +94 -7
  91. package/css/feedback.css +97 -7
  92. package/css/forms.css +113 -4
  93. package/css/legend.css +16 -9
  94. package/css/marks.css +38 -8
  95. package/css/motion.css +98 -53
  96. package/css/navigation.css +7 -0
  97. package/css/overlay.css +90 -3
  98. package/css/primitives.css +158 -13
  99. package/css/report.css +73 -56
  100. package/css/sidenote.css +67 -0
  101. package/css/site.css +16 -2
  102. package/css/sources.css +43 -1
  103. package/css/spark.css +62 -0
  104. package/css/spotlight.css +1 -1
  105. package/css/table.css +9 -2
  106. package/css/term.css +110 -0
  107. package/css/textref.css +63 -0
  108. package/css/toc.css +91 -0
  109. package/css/tokens.css +49 -1
  110. package/css/tree.css +134 -0
  111. package/css/workbench.css +1 -1
  112. package/dist/bronto.css +1 -1
  113. package/dist/css/analytical.css +1 -1
  114. package/dist/css/app.css +1 -1
  115. package/dist/css/base.css +1 -1
  116. package/dist/css/bullet.css +1 -0
  117. package/dist/css/code.css +1 -0
  118. package/dist/css/connectors.css +1 -1
  119. package/dist/css/content.css +1 -1
  120. package/dist/css/crosshair.css +1 -1
  121. package/dist/css/diff.css +1 -0
  122. package/dist/css/disclosure.css +1 -1
  123. package/dist/css/dots.css +1 -1
  124. package/dist/css/feedback.css +1 -1
  125. package/dist/css/forms.css +1 -1
  126. package/dist/css/legend.css +1 -1
  127. package/dist/css/marks.css +1 -1
  128. package/dist/css/motion.css +1 -1
  129. package/dist/css/navigation.css +1 -1
  130. package/dist/css/overlay.css +1 -1
  131. package/dist/css/primitives.css +1 -1
  132. package/dist/css/report.css +1 -1
  133. package/dist/css/sidenote.css +1 -0
  134. package/dist/css/site.css +1 -1
  135. package/dist/css/sources.css +1 -1
  136. package/dist/css/spark.css +1 -0
  137. package/dist/css/spotlight.css +1 -1
  138. package/dist/css/table.css +1 -1
  139. package/dist/css/term.css +1 -0
  140. package/dist/css/textref.css +1 -0
  141. package/dist/css/toc.css +1 -0
  142. package/dist/css/tokens.css +1 -1
  143. package/dist/css/tree.css +1 -0
  144. package/dist/css/workbench.css +1 -1
  145. package/docs/adr/0003-theme-model.md +1 -1
  146. package/docs/annotations.md +133 -14
  147. package/docs/architecture.md +49 -6
  148. package/docs/bullet.md +78 -0
  149. package/docs/code.md +76 -0
  150. package/docs/contrast.md +116 -92
  151. package/docs/d2.md +196 -0
  152. package/docs/diff.md +146 -0
  153. package/docs/legends.md +23 -3
  154. package/docs/marks.md +9 -2
  155. package/docs/mermaid.md +169 -0
  156. package/docs/reference.md +201 -26
  157. package/docs/reporting.md +416 -57
  158. package/docs/sidenote.md +64 -0
  159. package/docs/sources.md +27 -0
  160. package/docs/spark.md +78 -0
  161. package/docs/stability.md +10 -2
  162. package/docs/term.md +81 -0
  163. package/docs/textref.md +78 -0
  164. package/docs/theming.md +44 -5
  165. package/docs/toc.md +83 -0
  166. package/docs/tree.md +74 -0
  167. package/docs/usage.md +354 -16
  168. package/docs/vega.md +244 -0
  169. package/docs/workbench.md +7 -1
  170. package/glyphs/glyphs.js +13 -5
  171. package/llms.txt +285 -14
  172. package/package.json +95 -17
  173. package/qwik/index.d.ts +44 -59
  174. package/qwik/index.d.ts.map +1 -0
  175. package/qwik/index.js +65 -3
  176. package/react/index.d.ts +41 -61
  177. package/react/index.d.ts.map +1 -0
  178. package/react/index.js +63 -3
  179. package/solid/index.d.ts +68 -61
  180. package/solid/index.d.ts.map +1 -0
  181. package/solid/index.js +66 -3
  182. package/tokens/d2.d.ts +38 -0
  183. package/tokens/d2.js +71 -0
  184. package/tokens/d2.json +43 -0
  185. package/tokens/index.d.ts +5 -5
  186. package/tokens/index.js +15 -1
  187. package/tokens/index.json +9 -0
  188. package/tokens/mermaid.d.ts +23 -0
  189. package/tokens/mermaid.js +181 -0
  190. package/tokens/mermaid.json +163 -0
  191. package/tokens/resolved.json +45 -1
  192. package/tokens/skins.js +3 -2
  193. package/tokens/tokens.dtcg.json +26 -0
  194. package/tokens/vega.d.ts +34 -0
  195. package/tokens/vega.js +155 -0
  196. package/tokens/vega.json +179 -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,11 +1,38 @@
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
 
7
18
  export const hasDom = () => typeof document !== 'undefined';
8
19
 
20
+ // Resolve the delegation host from an init call's `root` option, distinguishing
21
+ // three cases so an unattached/null root never silently widens to whole-document
22
+ // delegation (the "scoped island hijacks every control" foot-gun):
23
+ // • root absent/undefined → no scope requested → delegate from `fallback`
24
+ // (default `document`). This is the intended global-wiring path.
25
+ // • root === null → a scope WAS requested but isn't ready yet (e.g. a
26
+ // framework ref still null at mount). Return null so the caller no-ops
27
+ // instead of hijacking the whole document.
28
+ // • root is an element → use it.
29
+ // The bindings (@ponchia/ui/{react,solid,qwik}) emit `root: null` for the
30
+ // not-ready case precisely so this distinction survives across the boundary.
31
+ export function resolveHost(root, fallback = document) {
32
+ if (root === null) return null;
33
+ return root || fallback;
34
+ }
35
+
9
36
  // Monotonic counter for auto-minted field / list ids, shared across
10
37
  // initFormValidation and initCombobox so separate calls (and separate
11
38
  // behaviors) never collide on an id.
@@ -48,3 +75,52 @@ export function closestSafe(el, selector) {
48
75
  return null;
49
76
  }
50
77
  }
78
+
79
+ // Collect the hosts an initializer should wire: the descendants matching
80
+ // `selector` PLUS `host` itself when it matches (querySelectorAll only sees
81
+ // descendants, so a `root` that *is* a target would otherwise be skipped).
82
+ // Self-first, null-safe — the shape ~9 delegated behaviors hand-rolled.
83
+ export function collectHosts(host, selector) {
84
+ const out = host !== document && host.matches?.(selector) ? [host] : [];
85
+ out.push(...(host.querySelectorAll?.(selector) ?? []));
86
+ return out;
87
+ }
88
+
89
+ // scrollIntoView is a pure affordance and throws in jsdom/layout-less envs;
90
+ // never let that break a keyboard/roving handler. (combobox/command/carousel.)
91
+ export function scrollIntoViewSafe(el, opts = { block: 'nearest' }) {
92
+ try {
93
+ el?.scrollIntoView(opts);
94
+ } catch {
95
+ /* headless / no layout — the scroll is cosmetic */
96
+ }
97
+ }
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
+
117
+ // Wrap an index by `delta` within [0, len), the roving keyboard math shared by
118
+ // the combobox and command listboxes (a -1 `cur` lands on the first/last as
119
+ // before). Only this core is shared — the surrounding setActive/filter/group
120
+ // logic diverges between the two for real reasons. (code-quality audit Q12.)
121
+ export function wrapIndex(cur, delta, len) {
122
+ let next = cur + delta;
123
+ if (next < 0) next = len - 1;
124
+ if (next >= len) next = 0;
125
+ return next;
126
+ }
@@ -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,4 +1,10 @@
1
- import { hasDom, noop, bindOnce } from './internal.js';
1
+ import { hasDom, resolveHost, noop, bindOnce } from './internal.js';
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
+ */
2
8
 
3
9
  /**
4
10
  * Wire `[data-bronto-legend]` interactive legends. Each entry is a
@@ -14,15 +20,24 @@ import { hasDom, 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;
20
- const host = root || document;
29
+ const host = resolveHost(root);
30
+ if (!host) return noop;
31
+ const isButton = (el) => el.tagName === 'BUTTON' || el.getAttribute('role') === 'button';
21
32
  const onClick = (e) => {
22
33
  const item = e.target.closest('.ui-legend__item');
23
34
  if (!item || !host.contains(item)) return;
24
35
  const legend = item.closest('[data-bronto-legend]');
25
36
  if (!legend || !host.contains(legend)) return;
37
+ // The contract requires a real `<button>` (keyboard-operable, focusable). A
38
+ // non-button item is mouse-only — refuse to toggle it rather than ship a
39
+ // pointer-only control (WCAG 2.1.1 — C11). The author is warned at bind.
40
+ if (!isButton(item)) return;
26
41
  const active = item.getAttribute('aria-pressed') !== 'false';
27
42
  const next = !active;
28
43
  item.setAttribute('aria-pressed', String(next));
@@ -40,6 +55,21 @@ export function initLegend({ root } = {}) {
40
55
  );
41
56
  };
42
57
  return bindOnce(host, 'legend', () => {
58
+ // Warn once per non-button item present at bind: it gets cursor:pointer from
59
+ // the CSS but is neither focusable nor keyboard-operable (C11).
60
+ if (typeof console !== 'undefined') {
61
+ for (const legend of host.querySelectorAll?.('[data-bronto-legend]') ?? []) {
62
+ const stray = [...legend.querySelectorAll('.ui-legend__item')].some(
63
+ (el) => el.closest('[data-bronto-legend]') === legend && !isButton(el),
64
+ );
65
+ if (stray) {
66
+ console.warn(
67
+ '[bronto] initLegend(): interactive legend entries must be <button> (or role="button") to be keyboard-operable — a non-button .ui-legend__item is ignored.',
68
+ );
69
+ break;
70
+ }
71
+ }
72
+ }
43
73
  host.addEventListener('click', onClick);
44
74
  return () => host.removeEventListener('click', onClick);
45
75
  });
@@ -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
@@ -1,4 +1,4 @@
1
- import { hasDom, noop, bindOnce } from './internal.js';
1
+ import { hasDom, resolveHost, noop, bindOnce } from './internal.js';
2
2
 
3
3
  /**
4
4
  * Dropdown-menu close affordances for a native `<details data-bronto-menu>`
@@ -10,10 +10,14 @@ import { hasDom, 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;
16
- const host = root || document;
19
+ const host = resolveHost(root);
20
+ if (!host) return noop;
17
21
  const openMenus = () => host.querySelectorAll?.('[data-bronto-menu][open]') ?? [];
18
22
  const shut = (menu) => {
19
23
  if (!menu || !menu.open) return;
@@ -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, 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]`
@@ -12,15 +12,33 @@ import { hasDom, noop, bindOnce, byIdInHost } from './internal.js';
12
12
  * `aria-expanded` / `aria-controls`, closes on Escape and outside
13
13
  * click, and re-positions on scroll/resize while open. SSR-safe,
14
14
  * idempotent; returns a cleanup function.
15
+ *
16
+ * The trigger advertises `aria-haspopup="dialog"`, so on open the panel is
17
+ * given `role="dialog"` (unless the author set a role) and focus is moved into
18
+ * it — the first focusable descendant, or the panel itself. It is a *non-modal*
19
+ * dialog: the rest of the page stays interactive and there is no focus trap.
20
+ * Author an accessible name on the panel (`aria-label` / `aria-labelledby`); a
21
+ * dev-time `console.warn` fires when it is missing.
22
+ *
23
+ * Escape returns focus to the trigger; closing via outside-click leaves focus
24
+ * where the click landed (treated as deliberate intent to move on).
25
+ *
26
+ * @param {import('./internal.js').DelegateOpts} [opts]
27
+ * @returns {import('./internal.js').Cleanup}
15
28
  */
16
29
  export function initPopover({ root } = {}) {
17
30
  if (!hasDom()) return noop;
18
- const host = root || document;
31
+ const host = resolveHost(root);
32
+ if (!host) return noop;
19
33
  const view = document.defaultView;
20
34
  const GAP = 8;
21
35
  let openPanel = null;
22
36
  let openTrigger = null;
23
37
 
38
+ // The trigger advertises `aria-haspopup="dialog"`, so the open panel must BE a
39
+ // dialog: a role, an accessible name, and focus moved into it (C6) — see the
40
+ // shared `focusInto` in internal.js.
41
+
24
42
  const place = (trigger, panel) => {
25
43
  const r = trigger.getBoundingClientRect();
26
44
  const pw = panel.offsetWidth;
@@ -39,6 +57,10 @@ export function initPopover({ root } = {}) {
39
57
  if (!openPanel) return;
40
58
  const panel = openPanel;
41
59
  const trigger = openTrigger;
60
+ // Only steal focus back to the trigger when focus is still inside the panel
61
+ // (Escape / programmatic re-toggle). An outside-click leaves focus where the
62
+ // click landed — deliberate intent to move on, per the doc contract.
63
+ const focusWasInside = panel.contains(document.activeElement);
42
64
  openPanel = openTrigger = null;
43
65
  if (panel.hasAttribute('popover') && typeof panel.hidePopover === 'function') {
44
66
  try {
@@ -50,10 +72,15 @@ export function initPopover({ root } = {}) {
50
72
  panel.classList.remove('is-open');
51
73
  }
52
74
  if (trigger) trigger.setAttribute('aria-expanded', 'false');
75
+ if (focusWasInside && trigger?.isConnected) trigger.focus?.();
53
76
  };
54
77
 
55
78
  const open = (trigger, panel) => {
56
79
  close();
80
+ // Live up to the advertised `aria-haspopup="dialog"`: give the panel a
81
+ // dialog role (unless the author set one) so AT announces it as the promised
82
+ // dialog rather than a generic group (C6).
83
+ if (!panel.hasAttribute('role')) panel.setAttribute('role', 'dialog');
57
84
  trigger.setAttribute('aria-controls', panel.id);
58
85
  trigger.setAttribute('aria-expanded', 'true');
59
86
  if (panel.hasAttribute('popover') && typeof panel.showPopover === 'function') {
@@ -68,6 +95,7 @@ export function initPopover({ root } = {}) {
68
95
  openPanel = panel;
69
96
  openTrigger = trigger;
70
97
  place(trigger, panel);
98
+ focusInto(panel);
71
99
  };
72
100
 
73
101
  const onClick = (e) => {
@@ -83,22 +111,65 @@ export function initPopover({ root } = {}) {
83
111
  if (openPanel && !openPanel.contains(e.target)) close();
84
112
  };
85
113
  const onKey = (e) => {
86
- if (e.key === 'Escape' && openPanel) {
87
- const t = openTrigger;
88
- close();
89
- t?.focus?.();
90
- }
114
+ // close() returns focus to the trigger because focus is inside the panel.
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();
91
126
  };
92
127
  const onReflow = () => {
93
128
  if (openPanel && openTrigger) place(openTrigger, openPanel);
94
129
  };
95
130
 
131
+ // Seed resting ARIA on every trigger and keep it in sync when the UA itself
132
+ // toggles a native popover (light-dismiss / Escape on the `popover` attribute
133
+ // never routes through close(), so aria-expanded would otherwise go stale).
134
+ const seedTeardowns = [];
135
+ const seed = () => {
136
+ for (const trigger of host.querySelectorAll('[data-bronto-popover]')) {
137
+ const panel = byIdInHost(host, trigger.getAttribute('data-bronto-popover'));
138
+ if (!panel) continue;
139
+ if (!trigger.hasAttribute('aria-haspopup')) trigger.setAttribute('aria-haspopup', 'dialog');
140
+ trigger.setAttribute('aria-controls', panel.id);
141
+ if (!trigger.hasAttribute('aria-expanded')) trigger.setAttribute('aria-expanded', 'false');
142
+ // A dialog with no accessible name is announced as just "dialog". We can't
143
+ // invent a good name, so warn the author at dev time (C6).
144
+ const named =
145
+ panel.hasAttribute('aria-label') ||
146
+ panel.hasAttribute('aria-labelledby') ||
147
+ panel.hasAttribute('title');
148
+ if (!named && typeof console !== 'undefined') {
149
+ console.warn(
150
+ `[bronto] initPopover(): panel #${panel.id} has no accessible name — add aria-label or aria-labelledby so it is announced as a named dialog.`,
151
+ );
152
+ }
153
+ if (panel.hasAttribute('popover')) {
154
+ const onToggle = (e) => {
155
+ const isOpen = e.newState === 'open';
156
+ trigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
157
+ if (!isOpen && openPanel === panel) openPanel = openTrigger = null;
158
+ };
159
+ panel.addEventListener('toggle', onToggle);
160
+ seedTeardowns.push(() => panel.removeEventListener('toggle', onToggle));
161
+ }
162
+ }
163
+ };
164
+
96
165
  return bindOnce(host, 'popover', () => {
166
+ seed();
97
167
  document.addEventListener('click', onClick);
98
168
  document.addEventListener('keydown', onKey);
99
169
  view?.addEventListener('scroll', onReflow, true);
100
170
  view?.addEventListener('resize', onReflow);
101
171
  return () => {
172
+ for (const t of seedTeardowns.splice(0)) t();
102
173
  document.removeEventListener('click', onClick);
103
174
  document.removeEventListener('keydown', onKey);
104
175
  view?.removeEventListener('scroll', onReflow, true);