@ponchia/ui 0.6.11 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@
5
5
  |> `^0` / `*` wildcard does **not** protect you. See README → Versioning, and
6
6
  |> the deprecation policy in CONTRIBUTING.md.
7
7
 
8
+ ## 0.6.12 — 2026-07-10
9
+
10
+ A stabilization patch. It changes no public class, token name, behavior name,
11
+ or package path.
12
+
13
+ ### Changed
14
+
15
+ - **Small-text readability.** The `--text-2xs` value increases from `0.68rem`
16
+ to `0.72rem`. This raises the floor for form labels, table headings,
17
+ provenance metadata, report captions, and other dense secondary text without
18
+ changing the type scale's public names or the default CSS payload size.
19
+ - **1.0 stabilization mode.** Public catalog growth is frozen while real
20
+ consumers move onto one current minor. The stability guide now distinguishes
21
+ downstream-proven surfaces from package-only proof and unproven 1.0
22
+ candidates. New public surface needs explicit maintainer approval to reopen
23
+ the catalog.
24
+
25
+ ### Fixed
26
+
27
+ - **Controlled-modal ownership.** `initModal()` now reconciles one stack per
28
+ document instead of letting each modal own `inert` independently. Sibling
29
+ portal modals no longer inert the active top modal, nested stacks restore the
30
+ previous modal correctly, late-added background nodes join the trap, and a
31
+ popover opened from the top modal remains interactive when its panel is
32
+ portaled elsewhere. Cleanup still preserves author-owned `inert` state.
33
+ - **Maintainer documentation.** The architecture map now identifies
34
+ `css/generated.css` as authored trust-surface CSS, and the roadmap reflects
35
+ the current release and WOFF2 font payload.
36
+
37
+ ### Verified
38
+
39
+ - Focused unit coverage exercises sibling and nested modal stacks, portaled
40
+ popovers, late background nodes, focus restoration, and cleanup ownership.
41
+ The non-pixel browser suite carries the same sibling-stack, portal, and
42
+ late-node scenario.
43
+
8
44
  ## 0.6.11 — 2026-07-04
9
45
 
10
46
  A correctness, accessibility, and performance release drawn from a multi-pass
package/README.md CHANGED
@@ -80,7 +80,7 @@ npm i @ponchia/ui
80
80
  Or drop it in with no build step, straight from a CDN:
81
81
 
82
82
  ```html
83
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ponchia/ui@0.6.11/dist/bronto.css">
83
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ponchia/ui@0.6.12/dist/bronto.css">
84
84
  ```
85
85
 
86
86
  ## Quick start
@@ -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+G3C;;;;;YA7Ia,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}
@@ -97,74 +302,19 @@ export function initModal({ root } = {}) {
97
302
  const doc = modal.ownerDocument;
98
303
  if (!doc) continue;
99
304
  const view = doc.defaultView;
100
- const isNativeDialog = modal.localName === 'dialog';
101
- let opener = null;
102
- let inerted = [];
103
-
104
- // Inert every sibling at each ancestor level up to <body>: the rest of the
105
- // page becomes non-focusable/non-interactive while the modal subtree stays
106
- // live. Skip already-inert nodes so release() can't un-inert something the
107
- // app inerted for its own reasons.
108
- const trap = () => {
109
- if (opener) return; // already trapped
110
- opener = doc.activeElement;
111
- pushActiveModal(modal);
112
- let el = modal;
113
- while (el && el.parentElement && el !== doc.body) {
114
- for (const sib of el.parentElement.children) {
115
- if (sib !== el && !sib.inert) {
116
- sib.inert = true;
117
- inerted.push(sib);
118
- }
119
- }
120
- el = el.parentElement;
121
- }
122
- focusInto(modal);
123
- };
124
-
125
- const release = () => {
126
- if (!opener) return;
127
- removeActiveModal(modal);
128
- for (const el of inerted) el.inert = false;
129
- inerted = [];
130
- const back = opener;
131
- opener = null;
132
- if (back?.isConnected && typeof back.focus === 'function') back.focus();
133
- };
134
-
135
- const sync = () => {
136
- if (modal.classList.contains('is-open')) {
137
- if (!isNativeDialog) modal.hidden = false;
138
- trap();
139
- return;
140
- }
141
- release();
142
- if (!isNativeDialog) modal.hidden = true;
143
- };
144
-
145
- const onKey = (e) => {
146
- if (e.key === 'Escape' && opener) {
147
- if (activeModals.at(-1) !== modal) return;
148
- if (insideOpenPopover(e.target, modal)) return;
149
- const ModalCloseEvent = view?.CustomEvent ?? CustomEvent;
150
- modal.dispatchEvent(
151
- new ModalCloseEvent('bronto:modal:close', {
152
- detail: { reason: 'escape' },
153
- bubbles: true,
154
- cancelable: true,
155
- }),
156
- );
157
- }
158
- };
159
-
160
305
  cleanups.push(
161
306
  bindOnce(modal, 'modal', () => {
162
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
+ };
163
317
 
164
- // A controlled modal must announce AS a modal dialog, not a generic group —
165
- // parity with initPopover. Apply a dialog role + aria-modal (unless the
166
- // author set a role), and dev-warn on a missing accessible name since we
167
- // can't invent a good one.
168
318
  if (!modal.hasAttribute('role')) modal.setAttribute('role', 'dialog');
169
319
  if (!modal.hasAttribute('aria-modal')) modal.setAttribute('aria-modal', 'true');
170
320
  const named =
@@ -177,17 +327,38 @@ export function initModal({ root } = {}) {
177
327
  );
178
328
  }
179
329
 
180
- const Observer =
181
- view?.MutationObserver ??
182
- (typeof MutationObserver === 'function' ? MutationObserver : null);
183
- const observer = Observer ? new Observer(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;
184
343
  observer?.observe(modal, { attributes: true, attributeFilter: ['class'] });
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
+ };
185
356
  doc.addEventListener('keydown', onKey, true);
186
- sync();
357
+
187
358
  return () => {
188
359
  observer?.disconnect();
189
360
  doc.removeEventListener('keydown', onKey, true);
190
- release();
361
+ unregisterRecord(record);
191
362
  restoreAttrs(modal, attrs);
192
363
  };
193
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,CAoM3C"}
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"}
@@ -74,9 +74,53 @@ export function initPopover({ root } = {}) {
74
74
  const GAP = 8;
75
75
  let openPanel = null;
76
76
  let openTrigger = null;
77
+ let pendingFocusObserver = null;
77
78
  const triggerStates = new Map();
78
79
  const panelStates = new Map();
79
80
 
81
+ const cancelPendingFocus = () => {
82
+ pendingFocusObserver?.disconnect();
83
+ pendingFocusObserver = null;
84
+ };
85
+
86
+ const focusWhenInteractive = (panel) => {
87
+ cancelPendingFocus();
88
+ if (!panel.inert) {
89
+ focusInto(panel);
90
+ return;
91
+ }
92
+
93
+ // A panel portaled out of a controlled modal starts in the trapped
94
+ // background. Wait for initModal to admit that specific panel instead of
95
+ // guessing how many microtasks its MutationObserver reconciliation needs.
96
+ const Observer = view?.MutationObserver;
97
+ if (!Observer) {
98
+ queueMicrotask(() => {
99
+ if (openPanel === panel && !panel.inert) focusInto(panel);
100
+ });
101
+ return;
102
+ }
103
+ const focusIfReady = () => {
104
+ if (openPanel !== panel) {
105
+ cancelPendingFocus();
106
+ return;
107
+ }
108
+ if (panel.inert) return;
109
+ cancelPendingFocus();
110
+ focusInto(panel);
111
+ };
112
+ pendingFocusObserver = new Observer(focusIfReady);
113
+ pendingFocusObserver.observe(panel, { attributes: true, attributeFilter: ['inert'] });
114
+ // Some DOM implementations model `inert` as a property without reflecting
115
+ // its attribute, so no observer record is delivered. Cover that test-DOM
116
+ // path across the modal reconciler's queued microtask; real browsers still
117
+ // use the attribute observer as the source of truth.
118
+ queueMicrotask(() => {
119
+ focusIfReady();
120
+ if (openPanel === panel && panel.inert) queueMicrotask(focusIfReady);
121
+ });
122
+ };
123
+
80
124
  const rememberTrigger = (trigger) => {
81
125
  if (!triggerStates.has(trigger)) {
82
126
  triggerStates.set(
@@ -123,6 +167,7 @@ export function initPopover({ root } = {}) {
123
167
  };
124
168
 
125
169
  const close = () => {
170
+ cancelPendingFocus();
126
171
  if (!openPanel) return;
127
172
  const panel = openPanel;
128
173
  const trigger = openTrigger;
@@ -166,7 +211,7 @@ export function initPopover({ root } = {}) {
166
211
  openPanel = panel;
167
212
  openTrigger = trigger;
168
213
  place(trigger, panel);
169
- focusInto(panel);
214
+ focusWhenInteractive(panel);
170
215
  };
171
216
 
172
217
  const onClick = (e) => {
@@ -227,7 +272,10 @@ export function initPopover({ root } = {}) {
227
272
  const onToggle = (e) => {
228
273
  const isOpen = e.newState === 'open';
229
274
  trigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
230
- if (!isOpen && openPanel === panel) openPanel = openTrigger = null;
275
+ if (!isOpen && openPanel === panel) {
276
+ cancelPendingFocus();
277
+ openPanel = openTrigger = null;
278
+ }
231
279
  };
232
280
  panel.addEventListener('toggle', onToggle);
233
281
  seedTeardowns.push(() => panel.removeEventListener('toggle', onToggle));
@@ -347,7 +347,7 @@
347
347
  },
348
348
  {
349
349
  "name": "--text-2xs",
350
- "description": "Global scale token. Value: `0.68rem`"
350
+ "description": "Global scale token. Value: `0.72rem`"
351
351
  },
352
352
  {
353
353
  "name": "--text-base",
package/css/tokens.css CHANGED
@@ -33,7 +33,7 @@
33
33
  --display: var(--dot-font);
34
34
  --display-weight: 700;
35
35
  --display-weight-strong: 800;
36
- --text-2xs: 0.68rem;
36
+ --text-2xs: 0.72rem;
37
37
  --text-xs: 0.76rem;
38
38
  --text-sm: 0.86rem;
39
39
  --text-base: 0.95rem;