@ponchia/ui 0.6.11 → 0.7.0
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 +113 -0
- package/MIGRATIONS.json +14 -0
- package/README.md +14 -6
- package/behaviors/dialog.d.ts.map +1 -1
- package/behaviors/dialog.js +14 -0
- package/behaviors/modal.d.ts +17 -13
- package/behaviors/modal.d.ts.map +1 -1
- package/behaviors/modal.js +281 -106
- package/behaviors/popover.d.ts.map +1 -1
- package/behaviors/popover.js +50 -2
- package/behaviors/splitter.d.ts +2 -0
- package/behaviors/splitter.d.ts.map +1 -1
- package/behaviors/splitter.js +15 -1
- package/behaviors/theme.d.ts +3 -2
- package/behaviors/theme.d.ts.map +1 -1
- package/behaviors/theme.js +10 -6
- package/bin/bronto-ui-check.mjs +286 -0
- package/classes/classes.json +7 -0
- package/classes/vscode.css-custom-data.json +1 -1
- package/css/disclosure.css +10 -0
- package/css/dots.css +43 -18
- package/css/feedback.css +35 -0
- package/css/report.css +0 -40
- package/css/site.css +10 -0
- package/css/tokens.css +1 -1
- package/dist/bronto.css +1 -1
- package/dist/css/disclosure.css +1 -1
- package/dist/css/dots.css +1 -1
- package/dist/css/feedback.css +1 -1
- package/dist/css/report-kit.css +1 -1
- package/dist/css/report.css +1 -1
- package/dist/css/site.css +1 -1
- package/dist/css/tokens.css +1 -1
- package/docs/adr/0004-prune-unused-adapters.md +34 -0
- package/docs/architecture.md +15 -11
- package/docs/command.md +18 -4
- package/docs/migrations/0.6-to-0.7.md +85 -0
- package/docs/package-contract.md +10 -5
- package/docs/reference.md +1 -1
- package/docs/reporting.md +8 -8
- package/docs/stability.md +36 -10
- package/docs/theming.md +49 -5
- package/docs/usage.md +68 -18
- package/docs/workbench.md +16 -2
- package/llms.txt +7 -3
- package/package.json +13 -3
- package/qwik/index.d.ts.map +1 -1
- package/qwik/index.js +4 -0
- package/react/index.d.ts.map +1 -1
- package/react/index.js +4 -0
- package/solid/index.d.ts.map +1 -1
- package/solid/index.js +4 -0
- package/svelte/index.d.ts.map +1 -1
- package/svelte/index.js +4 -0
- 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 +2508 -399
- package/vue/index.d.ts.map +1 -1
- package/vue/index.js +4 -0
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,25 @@ 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.
|
|
288
|
+
*
|
|
289
|
+
* SSR-safe, idempotent per modal; returns a cleanup function.
|
|
81
290
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
291
|
+
* @deprecated Use a native `<dialog>` with `initDialog()`. This controlled
|
|
292
|
+
* non-dialog path remains compatible in 0.7 and is scheduled for removal no
|
|
293
|
+
* earlier than 0.8 because no real consumer adopted it.
|
|
85
294
|
*
|
|
86
295
|
* @param {import('./internal.js').DelegateOpts} [opts]
|
|
87
296
|
* @returns {import('./internal.js').Cleanup}
|
|
@@ -97,74 +306,19 @@ export function initModal({ root } = {}) {
|
|
|
97
306
|
const doc = modal.ownerDocument;
|
|
98
307
|
if (!doc) continue;
|
|
99
308
|
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
309
|
cleanups.push(
|
|
161
310
|
bindOnce(modal, 'modal', () => {
|
|
162
311
|
const attrs = snapshotAttrs(modal, ['role', 'aria-modal', 'tabindex', 'hidden']);
|
|
312
|
+
const state = modalState(doc);
|
|
313
|
+
const record = {
|
|
314
|
+
active: modal.classList.contains('is-open'),
|
|
315
|
+
isNativeDialog: modal.localName === 'dialog',
|
|
316
|
+
modal,
|
|
317
|
+
openedAt: 0,
|
|
318
|
+
opener: null,
|
|
319
|
+
state,
|
|
320
|
+
};
|
|
163
321
|
|
|
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
322
|
if (!modal.hasAttribute('role')) modal.setAttribute('role', 'dialog');
|
|
169
323
|
if (!modal.hasAttribute('aria-modal')) modal.setAttribute('aria-modal', 'true');
|
|
170
324
|
const named =
|
|
@@ -177,17 +331,38 @@ export function initModal({ root } = {}) {
|
|
|
177
331
|
);
|
|
178
332
|
}
|
|
179
333
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
334
|
+
if (record.active) {
|
|
335
|
+
record.opener = doc.activeElement;
|
|
336
|
+
record.openedAt = ++state.sequence;
|
|
337
|
+
}
|
|
338
|
+
if (!record.isNativeDialog) modal.hidden = !record.active;
|
|
339
|
+
state.records.add(record);
|
|
340
|
+
state.byModal.set(modal, record);
|
|
341
|
+
ensureDocumentObserver(state);
|
|
342
|
+
reconcileInert(state);
|
|
343
|
+
if (state.top === record) focusInto(modal);
|
|
344
|
+
|
|
345
|
+
const Observer = view?.MutationObserver;
|
|
346
|
+
const observer = Observer ? new Observer(() => syncRecord(record)) : null;
|
|
184
347
|
observer?.observe(modal, { attributes: true, attributeFilter: ['class'] });
|
|
348
|
+
const onKey = (event) => {
|
|
349
|
+
if (event.key !== 'Escape' || state.top !== record) return;
|
|
350
|
+
if (targetIsOwnedPopover(event.target, modal, state)) return;
|
|
351
|
+
const ModalCloseEvent = view?.CustomEvent ?? CustomEvent;
|
|
352
|
+
modal.dispatchEvent(
|
|
353
|
+
new ModalCloseEvent('bronto:modal:close', {
|
|
354
|
+
detail: { reason: 'escape' },
|
|
355
|
+
bubbles: true,
|
|
356
|
+
cancelable: true,
|
|
357
|
+
}),
|
|
358
|
+
);
|
|
359
|
+
};
|
|
185
360
|
doc.addEventListener('keydown', onKey, true);
|
|
186
|
-
|
|
361
|
+
|
|
187
362
|
return () => {
|
|
188
363
|
observer?.disconnect();
|
|
189
364
|
doc.removeEventListener('keydown', onKey, true);
|
|
190
|
-
|
|
365
|
+
unregisterRecord(record);
|
|
191
366
|
restoreAttrs(modal, attrs);
|
|
192
367
|
};
|
|
193
368
|
}),
|
|
@@ -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));
|
package/behaviors/splitter.d.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* (`role="separator"`). The behavior keeps `--splitter-pos` and
|
|
5
5
|
* `aria-valuenow` in sync for keyboard and pointer resizing, then dispatches
|
|
6
6
|
* `bronto:splitter:resize` with `{ value, orientation }`.
|
|
7
|
+
* Buttons inside the splitter may set `data-bronto-splitter-adjust="-10"` or
|
|
8
|
+
* `"10"` to provide the required single-pointer, non-drag resize path.
|
|
7
9
|
*
|
|
8
10
|
* Bronto owns the control affordance only. The host owns pane content,
|
|
9
11
|
* persistence, min/max policy, collapse behavior, and any saved layout state.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"splitter.d.ts","sourceRoot":"","sources":["splitter.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"splitter.d.ts","sourceRoot":"","sources":["splitter.js"],"names":[],"mappings":"AAyPA;;;;;;;;;;;;;;;GAeG;AACH,wCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAU3C;;;;;WApQa,MAAM;;;;iBACN,UAAU,GAAG,YAAY"}
|
package/behaviors/splitter.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { hasDom, resolveHost, noop, bindOnce, collectHosts } from './internal.js';
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, collectHosts, closestSafe } from './internal.js';
|
|
2
2
|
|
|
3
3
|
const SELECTOR = '[data-bronto-splitter]';
|
|
4
4
|
const HANDLE_SELECTOR = '.ui-splitter__handle';
|
|
5
|
+
const ADJUST_SELECTOR = '[data-bronto-splitter-adjust]';
|
|
5
6
|
const DEFAULT_MIN = 20;
|
|
6
7
|
const DEFAULT_MAX = 80;
|
|
7
8
|
const DEFAULT_VALUE = 50;
|
|
@@ -218,11 +219,22 @@ function wireSplitter(splitter) {
|
|
|
218
219
|
splitter.ownerDocument.addEventListener('pointercancel', onPointerUp);
|
|
219
220
|
};
|
|
220
221
|
|
|
222
|
+
const onClick = (event) => {
|
|
223
|
+
const control = closestSafe(event.target, ADJUST_SELECTOR);
|
|
224
|
+
if (!control || control.closest(SELECTOR) !== splitter) return;
|
|
225
|
+
const delta = num(control.getAttribute('data-bronto-splitter-adjust'), Number.NaN);
|
|
226
|
+
if (!Number.isFinite(delta) || delta === 0) return;
|
|
227
|
+
event.preventDefault();
|
|
228
|
+
apply(value + delta);
|
|
229
|
+
};
|
|
230
|
+
|
|
221
231
|
handle.addEventListener('keydown', onKeydown);
|
|
222
232
|
handle.addEventListener('pointerdown', onPointerDown);
|
|
233
|
+
splitter.addEventListener('click', onClick);
|
|
223
234
|
return () => {
|
|
224
235
|
handle.removeEventListener('keydown', onKeydown);
|
|
225
236
|
handle.removeEventListener('pointerdown', onPointerDown);
|
|
237
|
+
splitter.removeEventListener('click', onClick);
|
|
226
238
|
splitter.ownerDocument.removeEventListener('pointermove', onPointerMove);
|
|
227
239
|
splitter.ownerDocument.removeEventListener('pointerup', onPointerUp);
|
|
228
240
|
splitter.ownerDocument.removeEventListener('pointercancel', onPointerUp);
|
|
@@ -241,6 +253,8 @@ function wireSplitter(splitter) {
|
|
|
241
253
|
* (`role="separator"`). The behavior keeps `--splitter-pos` and
|
|
242
254
|
* `aria-valuenow` in sync for keyboard and pointer resizing, then dispatches
|
|
243
255
|
* `bronto:splitter:resize` with `{ value, orientation }`.
|
|
256
|
+
* Buttons inside the splitter may set `data-bronto-splitter-adjust="-10"` or
|
|
257
|
+
* `"10"` to provide the required single-pointer, non-drag resize path.
|
|
244
258
|
*
|
|
245
259
|
* Bronto owns the control affordance only. The host owns pane content,
|
|
246
260
|
* persistence, min/max policy, collapse behavior, and any saved layout state.
|
package/behaviors/theme.d.ts
CHANGED
|
@@ -22,8 +22,9 @@ export function applyStoredTheme({ storageKey, root }?: ApplyThemeOpts): void;
|
|
|
22
22
|
* persists to localStorage, and **always** sets `data-theme` on <html>
|
|
23
23
|
* (a theme is document-global). State is reflected via `aria-pressed`
|
|
24
24
|
* and a `bronto:themechange` CustomEvent ({ detail: { theme } }) is
|
|
25
|
-
* dispatched on <html>
|
|
26
|
-
* racing the click handler.
|
|
25
|
+
* dispatched on <html> for both explicit changes and OS preference changes,
|
|
26
|
+
* so consumers can sync their own UI without racing the click/media handler.
|
|
27
|
+
* A control may set
|
|
27
28
|
* `data-bronto-theme-toggle="dark"` to force a specific theme.
|
|
28
29
|
*
|
|
29
30
|
* `root` scopes event delegation and which controls are queried/reflected
|
package/behaviors/theme.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["theme.js"],"names":[],"mappings":"AAuBA;;;;;;;;;GASG;AAEH;;;;;;;GAOG;AACH,wDAHW,cAAc,GACZ,IAAI,CAahB;AAED
|
|
1
|
+
{"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["theme.js"],"names":[],"mappings":"AAuBA;;;;;;;;;GASG;AAEH;;;;;;;GAOG;AACH,wDAHW,cAAc,GACZ,IAAI,CAahB;AAED;;;;;;;;;;;;;;;GAeG;AACH,uDAHW,gBAAgB,GAAG,OAAO,eAAe,EAAE,YAAY,GACrD,OAAO,eAAe,EAAE,OAAO,CAmH3C;;;;;;;;;;6BA7JY,gBAAgB,GAAG;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE;;;;;WAIpC,OAAO,GAAG,MAAM"}
|
package/behaviors/theme.js
CHANGED
|
@@ -58,8 +58,9 @@ export function applyStoredTheme({ storageKey = 'bronto-theme', root } = {}) {
|
|
|
58
58
|
* persists to localStorage, and **always** sets `data-theme` on <html>
|
|
59
59
|
* (a theme is document-global). State is reflected via `aria-pressed`
|
|
60
60
|
* and a `bronto:themechange` CustomEvent ({ detail: { theme } }) is
|
|
61
|
-
* dispatched on <html>
|
|
62
|
-
* racing the click handler.
|
|
61
|
+
* dispatched on <html> for both explicit changes and OS preference changes,
|
|
62
|
+
* so consumers can sync their own UI without racing the click/media handler.
|
|
63
|
+
* A control may set
|
|
63
64
|
* `data-bronto-theme-toggle="dark"` to force a specific theme.
|
|
64
65
|
*
|
|
65
66
|
* `root` scopes event delegation and which controls are queried/reflected
|
|
@@ -98,6 +99,11 @@ export function initThemeToggle({ storageKey = 'bronto-theme', root } = {}) {
|
|
|
98
99
|
return prefersDark() ? 'dark' : 'light';
|
|
99
100
|
};
|
|
100
101
|
|
|
102
|
+
const emitThemeChange = (theme) => {
|
|
103
|
+
const ThemeEvent = doc.defaultView?.CustomEvent ?? CustomEvent;
|
|
104
|
+
docEl.dispatchEvent(new ThemeEvent('bronto:themechange', { detail: { theme }, bubbles: true }));
|
|
105
|
+
};
|
|
106
|
+
|
|
101
107
|
const reflect = () => {
|
|
102
108
|
const c = current();
|
|
103
109
|
collectHosts(host, '[data-bronto-theme-toggle]').forEach((el) => {
|
|
@@ -134,7 +140,7 @@ export function initThemeToggle({ storageKey = 'bronto-theme', root } = {}) {
|
|
|
134
140
|
clearSchemeListener();
|
|
135
141
|
return;
|
|
136
142
|
}
|
|
137
|
-
|
|
143
|
+
emitThemeChange(current());
|
|
138
144
|
}
|
|
139
145
|
|
|
140
146
|
const onThemeChange = () => {
|
|
@@ -156,9 +162,7 @@ export function initThemeToggle({ storageKey = 'bronto-theme', root } = {}) {
|
|
|
156
162
|
/* storage blocked — theme still applies for this session */
|
|
157
163
|
}
|
|
158
164
|
reflect();
|
|
159
|
-
|
|
160
|
-
new CustomEvent('bronto:themechange', { detail: { theme: next }, bubbles: true }),
|
|
161
|
-
);
|
|
165
|
+
emitThemeChange(next);
|
|
162
166
|
};
|
|
163
167
|
|
|
164
168
|
return bindOnce(host, 'themeToggle', () => {
|