@ponchia/ui 0.6.10 → 0.6.12

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 (149) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +38 -25
  3. package/annotations/index.d.ts +15 -15
  4. package/annotations/index.d.ts.map +1 -1
  5. package/annotations/index.js +52 -34
  6. package/behaviors/carousel.d.ts +7 -3
  7. package/behaviors/carousel.d.ts.map +1 -1
  8. package/behaviors/carousel.js +157 -27
  9. package/behaviors/combobox.d.ts +1 -1
  10. package/behaviors/combobox.d.ts.map +1 -1
  11. package/behaviors/combobox.js +46 -23
  12. package/behaviors/command.d.ts +1 -1
  13. package/behaviors/command.d.ts.map +1 -1
  14. package/behaviors/command.js +63 -23
  15. package/behaviors/connectors.d.ts.map +1 -1
  16. package/behaviors/connectors.js +126 -19
  17. package/behaviors/crosshair.d.ts.map +1 -1
  18. package/behaviors/crosshair.js +71 -8
  19. package/behaviors/dialog.d.ts.map +1 -1
  20. package/behaviors/dialog.js +20 -3
  21. package/behaviors/disclosure.d.ts.map +1 -1
  22. package/behaviors/disclosure.js +35 -6
  23. package/behaviors/dismissible.js +1 -1
  24. package/behaviors/forms.d.ts +23 -2
  25. package/behaviors/forms.d.ts.map +1 -1
  26. package/behaviors/forms.js +97 -9
  27. package/behaviors/glyph.d.ts.map +1 -1
  28. package/behaviors/glyph.js +56 -5
  29. package/behaviors/internal.d.ts.map +1 -1
  30. package/behaviors/internal.js +52 -5
  31. package/behaviors/menu.d.ts.map +1 -1
  32. package/behaviors/menu.js +2 -1
  33. package/behaviors/modal.d.ts +13 -13
  34. package/behaviors/modal.d.ts.map +1 -1
  35. package/behaviors/modal.js +283 -96
  36. package/behaviors/popover.d.ts.map +1 -1
  37. package/behaviors/popover.js +58 -8
  38. package/behaviors/sources.d.ts.map +1 -1
  39. package/behaviors/sources.js +24 -3
  40. package/behaviors/splitter.d.ts.map +1 -1
  41. package/behaviors/splitter.js +27 -6
  42. package/behaviors/table.d.ts.map +1 -1
  43. package/behaviors/table.js +44 -7
  44. package/behaviors/tabs.d.ts.map +1 -1
  45. package/behaviors/tabs.js +51 -14
  46. package/behaviors/theme.d.ts.map +1 -1
  47. package/behaviors/theme.js +64 -4
  48. package/behaviors/toast.d.ts +6 -1
  49. package/behaviors/toast.d.ts.map +1 -1
  50. package/behaviors/toast.js +48 -12
  51. package/classes/classes.json +24 -0
  52. package/classes/index.d.ts +3 -2
  53. package/classes/index.js +77 -39
  54. package/classes/vscode.css-custom-data.json +1 -1
  55. package/connectors/index.d.ts +4 -4
  56. package/connectors/index.d.ts.map +1 -1
  57. package/connectors/index.js +14 -12
  58. package/css/annotations.css +1 -0
  59. package/css/app.css +7 -0
  60. package/css/base.css +3 -0
  61. package/css/bullet.css +41 -7
  62. package/css/code.css +14 -0
  63. package/css/command.css +10 -0
  64. package/css/dataviz.css +27 -0
  65. package/css/diff.css +2 -0
  66. package/css/disclosure.css +8 -0
  67. package/css/dots.css +1 -1
  68. package/css/feedback.css +9 -0
  69. package/css/interval.css +20 -2
  70. package/css/legend.css +10 -9
  71. package/css/marks.css +1 -0
  72. package/css/motion.css +2 -0
  73. package/css/overlay.css +14 -2
  74. package/css/primitives.css +1 -1
  75. package/css/report.css +3 -0
  76. package/css/sources.css +4 -4
  77. package/css/spotlight.css +6 -0
  78. package/css/table.css +19 -0
  79. package/css/term.css +4 -1
  80. package/css/tokens.css +9 -14
  81. package/dist/bronto.css +1 -1
  82. package/dist/css/analytical.css +1 -1
  83. package/dist/css/app.css +1 -1
  84. package/dist/css/bullet.css +1 -1
  85. package/dist/css/code.css +1 -1
  86. package/dist/css/command.css +1 -1
  87. package/dist/css/dataviz.css +1 -1
  88. package/dist/css/diff.css +1 -1
  89. package/dist/css/disclosure.css +1 -1
  90. package/dist/css/dots.css +1 -1
  91. package/dist/css/feedback.css +1 -1
  92. package/dist/css/interval.css +1 -1
  93. package/dist/css/legend.css +1 -1
  94. package/dist/css/marks.css +1 -1
  95. package/dist/css/overlay.css +1 -1
  96. package/dist/css/primitives.css +1 -1
  97. package/dist/css/report-kit.css +1 -1
  98. package/dist/css/sources.css +1 -1
  99. package/dist/css/spotlight.css +1 -1
  100. package/dist/css/table.css +1 -1
  101. package/dist/css/term.css +1 -1
  102. package/dist/css/tokens.css +1 -1
  103. package/docs/architecture.md +6 -4
  104. package/docs/bullet.md +6 -1
  105. package/docs/clamp.md +5 -0
  106. package/docs/command.md +3 -2
  107. package/docs/contrast.md +3 -3
  108. package/docs/crosshair.md +6 -0
  109. package/docs/dots.md +10 -3
  110. package/docs/figure.md +7 -0
  111. package/docs/glyphs.md +14 -2
  112. package/docs/highlights.md +9 -0
  113. package/docs/interval.md +6 -0
  114. package/docs/mermaid.md +5 -3
  115. package/docs/package-contract.md +24 -1
  116. package/docs/reference.md +1 -1
  117. package/docs/reporting.md +8 -8
  118. package/docs/selection.md +9 -0
  119. package/docs/sources.md +5 -0
  120. package/docs/stability.md +31 -6
  121. package/docs/state.md +6 -0
  122. package/docs/textref.md +18 -13
  123. package/docs/theming.md +18 -8
  124. package/docs/toc.md +6 -0
  125. package/docs/tree.md +9 -2
  126. package/docs/usage.md +12 -2
  127. package/docs/vega.md +5 -3
  128. package/glyphs/glyphs.js +62 -8
  129. package/index.d.ts +1 -0
  130. package/llms.txt +18 -14
  131. package/package.json +98 -6
  132. package/qwik/index.d.ts +4 -3
  133. package/qwik/index.d.ts.map +1 -1
  134. package/qwik/index.js +7 -5
  135. package/react/index.d.ts +4 -3
  136. package/react/index.d.ts.map +1 -1
  137. package/react/index.js +3 -2
  138. package/solid/index.d.ts +7 -5
  139. package/solid/index.d.ts.map +1 -1
  140. package/solid/index.js +11 -7
  141. package/tokens/figma.variables.json +2 -2
  142. package/tokens/index.js +1 -1
  143. package/tokens/index.json +2 -2
  144. package/tokens/resolved.json +1 -1
  145. package/tokens/tokens.dtcg.json +1 -1
  146. package/tokens/vega.d.ts +1 -1
  147. package/tokens/vega.js +3 -2
  148. package/vue/index.d.ts.map +1 -1
  149. package/vue/index.js +37 -3
@@ -30,11 +30,62 @@ function rememberCleanup(el, cleanups, cleanup) {
30
30
  cleanups.push(wrapped);
31
31
  }
32
32
 
33
- // `dot`/`gap`/`size` land in inline CSS, so allow only length/calc syntax —
34
- // drop anything with a `;`/`{` that could open a second declaration (mirrors
35
- // glyphs.js cssLen). Used for the mask path's --icon-size.
33
+ const CSS_LENGTH_UNITS =
34
+ '(?:px|r?em|ch|ex|cap|ic|lh|rlh|vw|vh|vi|vb|vmin|vmax|' +
35
+ 'svw|svh|svi|svb|svmin|svmax|lvw|lvh|lvi|lvb|lvmin|lvmax|' +
36
+ 'dvw|dvh|dvi|dvb|dvmin|dvmax|cm|mm|q|in|pc|pt|%)';
37
+ const CSS_NUMBER = '[-+]?(?:\\d*\\.\\d+|\\d+)';
38
+ const CSS_LENGTH_RE = new RegExp(`^(?:0|${CSS_NUMBER}${CSS_LENGTH_UNITS})$`, 'i');
39
+ const CSS_CALC_VALUE_RE = new RegExp(`^${CSS_NUMBER}(?:${CSS_LENGTH_UNITS})?`, 'i');
40
+
41
+ function isCalcLength(expr) {
42
+ let rest = expr.trim();
43
+ let depth = 0;
44
+ let sawLength = false;
45
+ let expectValue = true;
46
+
47
+ while (rest) {
48
+ if (expectValue) {
49
+ if (rest[0] === '(') {
50
+ depth += 1;
51
+ rest = rest.slice(1).trim();
52
+ continue;
53
+ }
54
+ const token = rest.match(CSS_CALC_VALUE_RE)?.[0];
55
+ if (!token) return false;
56
+ if (CSS_LENGTH_RE.test(token)) sawLength = true;
57
+ rest = rest.slice(token.length).trim();
58
+ expectValue = false;
59
+ continue;
60
+ }
61
+
62
+ if (rest[0] === ')') {
63
+ depth -= 1;
64
+ if (depth < 0) return false;
65
+ rest = rest.slice(1).trim();
66
+ continue;
67
+ }
68
+
69
+ const op = rest.match(/^[+\-*/]/)?.[0];
70
+ if (!op) return false;
71
+ rest = rest.slice(op.length).trim();
72
+ expectValue = true;
73
+ }
74
+
75
+ if (!sawLength) return false;
76
+ if (expectValue) return false;
77
+ return depth === 0;
78
+ }
79
+
80
+ // `size` lands in inline CSS, so accept only concrete CSS lengths or calc()
81
+ // expressions (mirrors glyphs.js cssLen). Used for the mask path's --icon-size.
36
82
  function cssLen(v) {
37
- return v && /^[\w.%+\-*/()\s,]+$/.test(v) ? v : '';
83
+ const value = String(v ?? '').trim();
84
+ if (CSS_LENGTH_RE.test(value)) return value;
85
+ if (value.toLowerCase().startsWith('calc(') && value.endsWith(')')) {
86
+ return isCalcLength(value.slice(5, -1)) ? value : '';
87
+ }
88
+ return '';
38
89
  }
39
90
 
40
91
  function applyGlyphA11y(el, label) {
@@ -214,7 +265,7 @@ export function initDotGlyph({ root } = {}) {
214
265
  const label = el.getAttribute('data-bronto-glyph-label');
215
266
 
216
267
  // One-node mask path — the icon-at-scale counterpart to the 256-cell grid.
217
- if (el.getAttribute('data-bronto-glyph-render') === 'mask') {
268
+ if (el.getAttribute('data-bronto-glyph-render')?.trim() === 'mask') {
218
269
  expandMaskGlyph(el, name, label, cleanups);
219
270
  continue;
220
271
  }
@@ -1 +1 @@
1
- {"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["internal.js"],"names":[],"mappings":"AAiDA,iEAIC;AAeD,sEAUC;AAED,oDAQC;AAED,yDAOC;AAMD,8DAIC;AAID;;SAMC;AAUD,gDAQC;AAMD,+DAKC;AAlIM,6BAAqB;AAErB,kCAAoD;AAyCpD,uCAAqC;;;;;sBArD/B,MAAM,IAAI"}
1
+ {"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["internal.js"],"names":[],"mappings":"AAiDA,iEAIC;AAeD,sEAaC;AAED,oDAUC;AAED,yDAOC;AAMD,8DAIC;AAID;;SAMC;AAkDD,gDAUC;AAMD,+DAKC;AAjLM,6BAAqB;AAErB,kCAAoD;AAyCpD,uCAAqC;;;;;sBArD/B,MAAM,IAAI"}
@@ -70,7 +70,10 @@ export function bindOnce(target, key, add) {
70
70
  const reg = target[BOUND] || (target[BOUND] = Object.create(null));
71
71
  if (reg[key]) reg[key]();
72
72
  const remove = add();
73
+ let done = false;
73
74
  const cleanup = () => {
75
+ if (done) return;
76
+ done = true;
74
77
  remove();
75
78
  if (reg[key] === cleanup) delete reg[key];
76
79
  };
@@ -80,11 +83,13 @@ export function bindOnce(target, key, add) {
80
83
 
81
84
  export function byIdInHost(host, id) {
82
85
  if (!id) return null;
83
- if (host === document) return document.getElementById(id);
86
+ const doc = host.nodeType === 9 ? host : host.ownerDocument;
87
+ if (host === doc) return doc.getElementById(id);
84
88
  if (host.id === id) return host;
85
89
  return (
86
90
  Array.from(host.querySelectorAll?.('[id]') || []).find((el) => el.id === id) ||
87
- document.getElementById(id)
91
+ doc?.getElementById(id) ||
92
+ null
88
93
  );
89
94
  }
90
95
 
@@ -122,11 +127,53 @@ export function scrollIntoViewSafe(el, opts = { block: 'nearest' }) {
122
127
  // itself on open). Focus the first focusable descendant, else make the
123
128
  // container programmatically focusable and focus it, so a content-only
124
129
  // panel/modal still receives focus.
125
- const FOCUSABLE =
126
- 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
130
+ const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]';
131
+
132
+ function isInsideDisabledFieldset(el) {
133
+ return Boolean(el.closest?.('fieldset[disabled]'));
134
+ }
135
+
136
+ function isRendered(el) {
137
+ try {
138
+ if (
139
+ typeof el.checkVisibility === 'function' &&
140
+ !el.checkVisibility({ checkVisibilityCSS: true, visibilityProperty: true })
141
+ ) {
142
+ return false;
143
+ }
144
+ } catch {
145
+ /* fall through to conservative checks */
146
+ }
147
+
148
+ const view = el.ownerDocument?.defaultView;
149
+ const style = view?.getComputedStyle?.(el);
150
+ if (style?.display === 'none') return false;
151
+ if (style?.visibility === 'hidden' || style?.visibility === 'collapse') return false;
152
+
153
+ const docHasLayout = Boolean(el.ownerDocument?.documentElement?.getClientRects?.().length);
154
+ if (
155
+ docHasLayout &&
156
+ 'offsetParent' in el &&
157
+ el.offsetParent === null &&
158
+ el.getClientRects?.().length === 0
159
+ ) {
160
+ return false;
161
+ }
162
+ return true;
163
+ }
164
+
165
+ function isFocusableCandidate(el) {
166
+ if (el.closest?.('[hidden], [inert]')) return false;
167
+ if ('disabled' in el && el.disabled) return false;
168
+ if (isInsideDisabledFieldset(el)) return false;
169
+ if (el.tabIndex < 0) return false;
170
+ return isRendered(el);
171
+ }
127
172
 
128
173
  export function focusInto(container) {
129
- const first = container.querySelector(FOCUSABLE);
174
+ const first = Array.from(container.querySelectorAll?.(FOCUSABLE) || []).find(
175
+ isFocusableCandidate,
176
+ );
130
177
  if (first) {
131
178
  first.focus?.();
132
179
  return;
@@ -1 +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,CAwC3C"}
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,CAyC3C"}
package/behaviors/menu.js CHANGED
@@ -18,7 +18,8 @@ export function initMenu({ root } = {}) {
18
18
  if (!hasDom()) return noop;
19
19
  const host = resolveHost(root);
20
20
  if (!host) return noop;
21
- const doc = host.nodeType === 9 ? host : host.ownerDocument || document;
21
+ const doc = host.nodeType === 9 ? host : host.ownerDocument;
22
+ if (!doc) return noop;
22
23
  const openMenus = () => collectHosts(host, '[data-bronto-menu][open]');
23
24
  const shut = (menu) => {
24
25
  if (!menu || !menu.open) return;
@@ -12,21 +12,21 @@
12
12
  *
13
13
  * Mark the overlay `[data-bronto-modal]` (opt-in). On bind it gives the modal a
14
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`
15
+ * dev-warns when it has no accessible name. While open, a document-level stack
16
+ * reconciler keeps only the top controlled modal interactive, marks the rest of
17
+ * the page `inert`, admits an open popover owned by the top modal even when the
18
+ * panel is portaled elsewhere, and applies the trap to background nodes added
19
+ * after open. Nested and sibling portal modals therefore share one ownership
20
+ * model instead of independently inverting each other's `inert` state.
21
+ *
22
+ * Bronto owns focus only: the **consumer still owns open/close state** (the
23
+ * `is-open` class). Escape dispatches a cancelable `bronto:modal:close`
24
24
  * ({@link ModalCloseDetail}) on the modal so the consumer can drop `is-open` in
25
- * response; the behavior never changes visibility itself.
25
+ * response; the behavior never changes visibility itself. Closing a parent
26
+ * modal temporarily suspends any still-`is-open` controlled descendants; they
27
+ * resume at the top of the stack if the parent reopens.
26
28
  *
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.
29
+ * SSR-safe, idempotent per modal; returns a cleanup function.
30
30
  *
31
31
  * @param {import('./internal.js').DelegateOpts} [opts]
32
32
  * @returns {import('./internal.js').Cleanup}
@@ -1 +1 @@
1
- {"version":3,"file":"modal.d.ts","sourceRoot":"","sources":["modal.js"],"names":[],"mappings":"AAsDA;;;GAGG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CA+F3C;;;;;YA7Ha,QAAQ"}
1
+ {"version":3,"file":"modal.d.ts","sourceRoot":"","sources":["modal.js"],"names":[],"mappings":"AAmQA;;;GAGG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CA6E3C;;;;;YA3Ga,QAAQ"}
@@ -3,25 +3,13 @@ import {
3
3
  resolveHost,
4
4
  noop,
5
5
  bindOnce,
6
+ byIdInHost,
6
7
  collectHosts,
7
8
  focusInto,
8
9
  closestSafe,
9
10
  } from './internal.js';
10
11
 
11
- function insideOpenPopover(target, modal) {
12
- const classPanel = closestSafe(target, '.ui-popover.is-open');
13
- if (classPanel && modal.contains(classPanel)) return true;
14
-
15
- const nativePanel = closestSafe(target, '[popover]');
16
- if (!nativePanel || !modal.contains(nativePanel)) return false;
17
- try {
18
- return nativePanel.matches(':popover-open');
19
- } catch {
20
- return false;
21
- }
22
- }
23
-
24
- const activeModals = [];
12
+ const states = new WeakMap();
25
13
 
26
14
  const snapshotAttrs = (el, names) => {
27
15
  const attrs = {};
@@ -41,16 +29,233 @@ const restoreAttrs = (el, attrs) => {
41
29
  }
42
30
  };
43
31
 
44
- const pushActiveModal = (modal) => {
45
- const index = activeModals.indexOf(modal);
46
- if (index !== -1) activeModals.splice(index, 1);
47
- activeModals.push(modal);
48
- };
32
+ function modalState(doc) {
33
+ let state = states.get(doc);
34
+ if (state) return state;
35
+ state = {
36
+ doc,
37
+ records: new Set(),
38
+ byModal: new Map(),
39
+ ownedInert: new Set(),
40
+ pendingOverlays: new Set(),
41
+ observer: null,
42
+ observing: false,
43
+ sequence: 0,
44
+ scheduled: false,
45
+ top: null,
46
+ };
47
+ states.set(doc, state);
48
+ return state;
49
+ }
49
50
 
50
- const removeActiveModal = (modal) => {
51
- const index = activeModals.indexOf(modal);
52
- if (index !== -1) activeModals.splice(index, 1);
53
- };
51
+ function isEffectivelyActive(record, state) {
52
+ if (!record.active || !record.modal.isConnected) return false;
53
+ for (let node = record.modal.parentElement; node; node = node.parentElement) {
54
+ const ancestor = state.byModal.get(node);
55
+ if (ancestor && (!ancestor.active || !ancestor.modal.isConnected)) return false;
56
+ }
57
+ return true;
58
+ }
59
+
60
+ function activeTop(state) {
61
+ let top = null;
62
+ for (const record of state.records) {
63
+ if (!isEffectivelyActive(record, state)) continue;
64
+ if (!top || top.modal.contains(record.modal)) {
65
+ top = record;
66
+ continue;
67
+ }
68
+ if (!record.modal.contains(top.modal) && record.openedAt > top.openedAt) top = record;
69
+ }
70
+ return top;
71
+ }
72
+
73
+ function popoverIsOpen(panel, state) {
74
+ if (state.pendingOverlays.has(panel) || panel.classList.contains('is-open')) return true;
75
+ try {
76
+ return panel.matches(':popover-open');
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ function modalOwnsPopover(modal, panel) {
83
+ if (modal.contains(panel)) return true;
84
+ if (!panel.id) return false;
85
+ return collectHosts(modal, '[data-bronto-popover]').some(
86
+ (trigger) => trigger.getAttribute('data-bronto-popover') === panel.id,
87
+ );
88
+ }
89
+
90
+ function liveRoots(state, top) {
91
+ const roots = new Set([top.modal]);
92
+ for (const trigger of collectHosts(top.modal, '[data-bronto-popover]')) {
93
+ const panel = byIdInHost(top.modal, trigger.getAttribute('data-bronto-popover'));
94
+ if (panel && popoverIsOpen(panel, state)) roots.add(panel);
95
+ }
96
+ for (const panel of state.pendingOverlays) {
97
+ if (modalOwnsPopover(top.modal, panel)) roots.add(panel);
98
+ }
99
+ return roots;
100
+ }
101
+
102
+ function desiredInert(state, top) {
103
+ const body = state.doc.body;
104
+ if (!body || !top) return new Set();
105
+ const roots = liveRoots(state, top);
106
+ const livePath = new Set([body]);
107
+ for (const root of roots) {
108
+ for (let node = root; node && node !== body; node = node.parentElement) {
109
+ livePath.add(node);
110
+ }
111
+ }
112
+
113
+ const desired = new Set();
114
+ for (const parent of livePath) {
115
+ if (roots.has(parent)) continue;
116
+ for (const child of parent.children || []) {
117
+ if (!livePath.has(child)) desired.add(child);
118
+ }
119
+ }
120
+ return desired;
121
+ }
122
+
123
+ function reconcileInert(state) {
124
+ const previousTop = state.top;
125
+ const nextTop = activeTop(state);
126
+ const desired = desiredInert(state, nextTop);
127
+
128
+ for (const element of [...state.ownedInert]) {
129
+ if (desired.has(element)) {
130
+ if (!element.inert) element.inert = true;
131
+ continue;
132
+ }
133
+ element.inert = false;
134
+ state.ownedInert.delete(element);
135
+ }
136
+ for (const element of desired) {
137
+ if (state.ownedInert.has(element) || element.inert) continue;
138
+ element.inert = true;
139
+ state.ownedInert.add(element);
140
+ }
141
+ state.top = nextTop;
142
+ return { previousTop, nextTop };
143
+ }
144
+
145
+ function scheduleReconcile(state) {
146
+ if (state.scheduled) return;
147
+ state.scheduled = true;
148
+ queueMicrotask(() => {
149
+ state.scheduled = false;
150
+ reconcileInert(state);
151
+ });
152
+ }
153
+
154
+ function ensureDocumentObserver(state) {
155
+ if (state.observing) return;
156
+ state.observing = true;
157
+ const view = state.doc.defaultView;
158
+ const Observer = view?.MutationObserver;
159
+ if (Observer && state.doc.body) {
160
+ state.observer = new Observer((mutations) => {
161
+ const relevant = mutations.some(
162
+ (mutation) =>
163
+ mutation.type === 'childList' || mutation.target.matches?.('.ui-popover, [popover]'),
164
+ );
165
+ if (relevant && state.top) scheduleReconcile(state);
166
+ });
167
+ state.observer.observe(state.doc.body, {
168
+ attributes: true,
169
+ attributeFilter: ['class'],
170
+ childList: true,
171
+ subtree: true,
172
+ });
173
+ }
174
+
175
+ state.onBeforeToggle = (event) => {
176
+ const panel = event.target;
177
+ if (!panel?.matches?.('[popover]')) return;
178
+ if (event.newState === 'open') state.pendingOverlays.add(panel);
179
+ else state.pendingOverlays.delete(panel);
180
+ reconcileInert(state);
181
+ };
182
+ state.onToggle = (event) => {
183
+ state.pendingOverlays.delete(event.target);
184
+ scheduleReconcile(state);
185
+ };
186
+ state.doc.addEventListener('beforetoggle', state.onBeforeToggle, true);
187
+ state.doc.addEventListener('toggle', state.onToggle, true);
188
+ }
189
+
190
+ function releaseDocumentState(state) {
191
+ if (state.records.size) return;
192
+ state.observer?.disconnect();
193
+ state.observer = null;
194
+ state.observing = false;
195
+ state.doc.removeEventListener('beforetoggle', state.onBeforeToggle, true);
196
+ state.doc.removeEventListener('toggle', state.onToggle, true);
197
+ state.pendingOverlays.clear();
198
+ reconcileInert(state);
199
+ states.delete(state.doc);
200
+ }
201
+
202
+ function restoreAfterClose(record, nextTop) {
203
+ const back = record.opener;
204
+ record.opener = null;
205
+ if (nextTop) {
206
+ if (back?.isConnected && nextTop.modal.contains(back)) back.focus?.();
207
+ else focusInto(nextTop.modal);
208
+ return;
209
+ }
210
+ if (back?.isConnected && typeof back.focus === 'function') back.focus();
211
+ }
212
+
213
+ function syncRecord(record) {
214
+ const { modal, state } = record;
215
+ const nextActive = modal.classList.contains('is-open');
216
+ if (nextActive === record.active) {
217
+ if (!record.isNativeDialog) modal.hidden = !nextActive;
218
+ scheduleReconcile(state);
219
+ return;
220
+ }
221
+
222
+ const oldTop = state.top ?? activeTop(state);
223
+ record.active = nextActive;
224
+ if (nextActive) {
225
+ record.opener = state.doc.activeElement;
226
+ record.openedAt = ++state.sequence;
227
+ if (!record.isNativeDialog) modal.hidden = false;
228
+ const { nextTop } = reconcileInert(state);
229
+ if (nextTop && (nextTop === record || modal.contains(nextTop.modal))) {
230
+ focusInto(nextTop.modal);
231
+ }
232
+ return;
233
+ }
234
+
235
+ if (!record.isNativeDialog) modal.hidden = true;
236
+ const closedOwnedTop = oldTop && (oldTop === record || modal.contains(oldTop.modal));
237
+ const { nextTop } = reconcileInert(state);
238
+ if (closedOwnedTop) restoreAfterClose(record, nextTop);
239
+ else record.opener = null;
240
+ }
241
+
242
+ function unregisterRecord(record) {
243
+ const { state, modal } = record;
244
+ const oldTop = state.top ?? activeTop(state);
245
+ const removedOwnedTop = oldTop && (oldTop === record || modal.contains(oldTop.modal));
246
+ record.active = false;
247
+ state.records.delete(record);
248
+ state.byModal.delete(modal);
249
+ const { nextTop } = reconcileInert(state);
250
+ if (removedOwnedTop) restoreAfterClose(record, nextTop);
251
+ else record.opener = null;
252
+ releaseDocumentState(state);
253
+ }
254
+
255
+ function targetIsOwnedPopover(target, modal, state) {
256
+ const panel = closestSafe(target, '.ui-popover, [popover]');
257
+ return Boolean(panel && popoverIsOpen(panel, state) && modalOwnsPopover(modal, panel));
258
+ }
54
259
 
55
260
  /**
56
261
  * @typedef {object} ModalCloseDetail
@@ -67,21 +272,21 @@ const removeActiveModal = (modal) => {
67
272
  *
68
273
  * Mark the overlay `[data-bronto-modal]` (opt-in). On bind it gives the modal a
69
274
  * `role="dialog"` + `aria-modal="true"` (unless the author set a role) and
70
- * dev-warns when it has no accessible name, so it announces as a named modal
71
- * dialog parity with `initPopover`. The behavior watches its
72
- * `class` for `is-open`: on open it remembers the focused element, moves focus
73
- * into the modal (first focusable, else the panel itself), and **traps focus by
74
- * marking every sibling at each ancestor level `inert`** so the rest of the page
75
- * is non-focusable and non-interactive the modern, robust trap. On close it
76
- * un-inerts exactly what it inerted and returns focus to the opener. Bronto owns
77
- * focus only: the **consumer still owns open/close state** (the `is-open`
78
- * class). Escape dispatches a cancelable `bronto:modal:close`
275
+ * dev-warns when it has no accessible name. While open, a document-level stack
276
+ * reconciler keeps only the top controlled modal interactive, marks the rest of
277
+ * the page `inert`, admits an open popover owned by the top modal even when the
278
+ * panel is portaled elsewhere, and applies the trap to background nodes added
279
+ * after open. Nested and sibling portal modals therefore share one ownership
280
+ * model instead of independently inverting each other's `inert` state.
281
+ *
282
+ * Bronto owns focus only: the **consumer still owns open/close state** (the
283
+ * `is-open` class). Escape dispatches a cancelable `bronto:modal:close`
79
284
  * ({@link ModalCloseDetail}) on the modal so the consumer can drop `is-open` in
80
- * response; the behavior never changes visibility itself.
285
+ * response; the behavior never changes visibility itself. Closing a parent
286
+ * modal temporarily suspends any still-`is-open` controlled descendants; they
287
+ * resume at the top of the stack if the parent reopens.
81
288
  *
82
- * Best suited to a body-/portal-level overlay (the documented `.is-open` use
83
- * case); a deeply-nested modal still gets focus-into, focus-return, and the
84
- * Escape signal. SSR-safe, idempotent per modal; returns a cleanup function.
289
+ * SSR-safe, idempotent per modal; returns a cleanup function.
85
290
  *
86
291
  * @param {import('./internal.js').DelegateOpts} [opts]
87
292
  * @returns {import('./internal.js').Cleanup}
@@ -94,64 +299,22 @@ export function initModal({ root } = {}) {
94
299
  const cleanups = [];
95
300
 
96
301
  for (const modal of modals) {
97
- let opener = null;
98
- let inerted = [];
99
-
100
- // Inert every sibling at each ancestor level up to <body>: the rest of the
101
- // page becomes non-focusable/non-interactive while the modal subtree stays
102
- // live. Skip already-inert nodes so release() can't un-inert something the
103
- // app inerted for its own reasons.
104
- const trap = () => {
105
- if (opener) return; // already trapped
106
- opener = document.activeElement;
107
- pushActiveModal(modal);
108
- let el = modal;
109
- while (el && el.parentElement && el !== document.body) {
110
- for (const sib of el.parentElement.children) {
111
- if (sib !== el && !sib.inert) {
112
- sib.inert = true;
113
- inerted.push(sib);
114
- }
115
- }
116
- el = el.parentElement;
117
- }
118
- focusInto(modal);
119
- };
120
-
121
- const release = () => {
122
- if (!opener) return;
123
- removeActiveModal(modal);
124
- for (const el of inerted) el.inert = false;
125
- inerted = [];
126
- const back = opener;
127
- opener = null;
128
- if (back?.isConnected && typeof back.focus === 'function') back.focus();
129
- };
130
-
131
- const sync = () => (modal.classList.contains('is-open') ? trap() : release());
132
-
133
- const onKey = (e) => {
134
- if (e.key === 'Escape' && opener) {
135
- if (activeModals.at(-1) !== modal) return;
136
- if (insideOpenPopover(e.target, modal)) return;
137
- modal.dispatchEvent(
138
- new CustomEvent('bronto:modal:close', {
139
- detail: { reason: 'escape' },
140
- bubbles: true,
141
- cancelable: true,
142
- }),
143
- );
144
- }
145
- };
146
-
302
+ const doc = modal.ownerDocument;
303
+ if (!doc) continue;
304
+ const view = doc.defaultView;
147
305
  cleanups.push(
148
306
  bindOnce(modal, 'modal', () => {
149
- const attrs = snapshotAttrs(modal, ['role', 'aria-modal', 'tabindex']);
307
+ const attrs = snapshotAttrs(modal, ['role', 'aria-modal', 'tabindex', 'hidden']);
308
+ const state = modalState(doc);
309
+ const record = {
310
+ active: modal.classList.contains('is-open'),
311
+ isNativeDialog: modal.localName === 'dialog',
312
+ modal,
313
+ openedAt: 0,
314
+ opener: null,
315
+ state,
316
+ };
150
317
 
151
- // A controlled modal must announce AS a modal dialog, not a generic group —
152
- // parity with initPopover. Apply a dialog role + aria-modal (unless the
153
- // author set a role), and dev-warn on a missing accessible name since we
154
- // can't invent a good one.
155
318
  if (!modal.hasAttribute('role')) modal.setAttribute('role', 'dialog');
156
319
  if (!modal.hasAttribute('aria-modal')) modal.setAttribute('aria-modal', 'true');
157
320
  const named =
@@ -164,14 +327,38 @@ export function initModal({ root } = {}) {
164
327
  );
165
328
  }
166
329
 
167
- const observer = typeof MutationObserver === 'function' ? new MutationObserver(sync) : null;
330
+ if (record.active) {
331
+ record.opener = doc.activeElement;
332
+ record.openedAt = ++state.sequence;
333
+ }
334
+ if (!record.isNativeDialog) modal.hidden = !record.active;
335
+ state.records.add(record);
336
+ state.byModal.set(modal, record);
337
+ ensureDocumentObserver(state);
338
+ reconcileInert(state);
339
+ if (state.top === record) focusInto(modal);
340
+
341
+ const Observer = view?.MutationObserver;
342
+ const observer = Observer ? new Observer(() => syncRecord(record)) : null;
168
343
  observer?.observe(modal, { attributes: true, attributeFilter: ['class'] });
169
- document.addEventListener('keydown', onKey, true);
170
- if (modal.classList.contains('is-open')) trap(); // already open at init
344
+ const onKey = (event) => {
345
+ if (event.key !== 'Escape' || state.top !== record) return;
346
+ if (targetIsOwnedPopover(event.target, modal, state)) return;
347
+ const ModalCloseEvent = view?.CustomEvent ?? CustomEvent;
348
+ modal.dispatchEvent(
349
+ new ModalCloseEvent('bronto:modal:close', {
350
+ detail: { reason: 'escape' },
351
+ bubbles: true,
352
+ cancelable: true,
353
+ }),
354
+ );
355
+ };
356
+ doc.addEventListener('keydown', onKey, true);
357
+
171
358
  return () => {
172
359
  observer?.disconnect();
173
- document.removeEventListener('keydown', onKey, true);
174
- release();
360
+ doc.removeEventListener('keydown', onKey, true);
361
+ unregisterRecord(record);
175
362
  restoreAttrs(modal, attrs);
176
363
  };
177
364
  }),
@@ -1 +1 @@
1
- {"version":3,"file":"popover.d.ts","sourceRoot":"","sources":["popover.js"],"names":[],"mappings":"AAuCA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,uCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAkM3C"}
1
+ {"version":3,"file":"popover.d.ts","sourceRoot":"","sources":["popover.js"],"names":[],"mappings":"AAuCA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,uCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAoP3C"}