@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 +36 -0
- package/README.md +1 -1
- package/behaviors/modal.d.ts +13 -13
- package/behaviors/modal.d.ts.map +1 -1
- package/behaviors/modal.js +277 -106
- package/behaviors/popover.d.ts.map +1 -1
- package/behaviors/popover.js +50 -2
- package/classes/vscode.css-custom-data.json +1 -1
- package/css/tokens.css +1 -1
- package/dist/bronto.css +1 -1
- package/dist/css/tokens.css +1 -1
- package/docs/architecture.md +1 -1
- package/docs/reference.md +1 -1
- package/docs/reporting.md +8 -8
- package/docs/stability.md +31 -6
- package/docs/usage.md +10 -0
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/tokens/figma.variables.json +2 -2
- package/tokens/index.js +1 -1
- package/tokens/index.json +2 -2
- package/tokens/resolved.json +1 -1
- package/tokens/tokens.dtcg.json +1 -1
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.
|
|
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
|
package/behaviors/modal.d.ts
CHANGED
|
@@ -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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* focus only: the **consumer still owns open/close state** (the
|
|
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
|
-
*
|
|
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}
|
package/behaviors/modal.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"modal.d.ts","sourceRoot":"","sources":["modal.js"],"names":[],"mappings":"
|
|
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"}
|
package/behaviors/modal.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* focus only: the **consumer still owns open/close state** (the
|
|
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
|
-
*
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
357
|
+
|
|
187
358
|
return () => {
|
|
188
359
|
observer?.disconnect();
|
|
189
360
|
doc.removeEventListener('keydown', onKey, true);
|
|
190
|
-
|
|
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,
|
|
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"}
|
package/behaviors/popover.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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));
|