@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/MIGRATIONS.json +14 -0
  3. package/README.md +14 -6
  4. package/behaviors/dialog.d.ts.map +1 -1
  5. package/behaviors/dialog.js +14 -0
  6. package/behaviors/modal.d.ts +17 -13
  7. package/behaviors/modal.d.ts.map +1 -1
  8. package/behaviors/modal.js +281 -106
  9. package/behaviors/popover.d.ts.map +1 -1
  10. package/behaviors/popover.js +50 -2
  11. package/behaviors/splitter.d.ts +2 -0
  12. package/behaviors/splitter.d.ts.map +1 -1
  13. package/behaviors/splitter.js +15 -1
  14. package/behaviors/theme.d.ts +3 -2
  15. package/behaviors/theme.d.ts.map +1 -1
  16. package/behaviors/theme.js +10 -6
  17. package/bin/bronto-ui-check.mjs +286 -0
  18. package/classes/classes.json +7 -0
  19. package/classes/vscode.css-custom-data.json +1 -1
  20. package/css/disclosure.css +10 -0
  21. package/css/dots.css +43 -18
  22. package/css/feedback.css +35 -0
  23. package/css/report.css +0 -40
  24. package/css/site.css +10 -0
  25. package/css/tokens.css +1 -1
  26. package/dist/bronto.css +1 -1
  27. package/dist/css/disclosure.css +1 -1
  28. package/dist/css/dots.css +1 -1
  29. package/dist/css/feedback.css +1 -1
  30. package/dist/css/report-kit.css +1 -1
  31. package/dist/css/report.css +1 -1
  32. package/dist/css/site.css +1 -1
  33. package/dist/css/tokens.css +1 -1
  34. package/docs/adr/0004-prune-unused-adapters.md +34 -0
  35. package/docs/architecture.md +15 -11
  36. package/docs/command.md +18 -4
  37. package/docs/migrations/0.6-to-0.7.md +85 -0
  38. package/docs/package-contract.md +10 -5
  39. package/docs/reference.md +1 -1
  40. package/docs/reporting.md +8 -8
  41. package/docs/stability.md +36 -10
  42. package/docs/theming.md +49 -5
  43. package/docs/usage.md +68 -18
  44. package/docs/workbench.md +16 -2
  45. package/llms.txt +7 -3
  46. package/package.json +13 -3
  47. package/qwik/index.d.ts.map +1 -1
  48. package/qwik/index.js +4 -0
  49. package/react/index.d.ts.map +1 -1
  50. package/react/index.js +4 -0
  51. package/solid/index.d.ts.map +1 -1
  52. package/solid/index.js +4 -0
  53. package/svelte/index.d.ts.map +1 -1
  54. package/svelte/index.js +4 -0
  55. package/tokens/figma.variables.json +2 -2
  56. package/tokens/index.js +1 -1
  57. package/tokens/index.json +2 -2
  58. package/tokens/resolved.json +1 -1
  59. package/tokens/tokens.dtcg.json +2508 -399
  60. package/vue/index.d.ts.map +1 -1
  61. package/vue/index.js +4 -0
@@ -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,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, 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.
288
+ *
289
+ * SSR-safe, idempotent per modal; returns a cleanup function.
81
290
  *
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.
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
- const Observer =
181
- view?.MutationObserver ??
182
- (typeof MutationObserver === 'function' ? MutationObserver : null);
183
- const observer = Observer ? new Observer(sync) : null;
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
- sync();
361
+
187
362
  return () => {
188
363
  observer?.disconnect();
189
364
  doc.removeEventListener('keydown', onKey, true);
190
- release();
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,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));
@@ -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":"AA6OA;;;;;;;;;;;;;GAaG;AACH,wCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAU3C;;;;;WAvPa,MAAM;;;;iBACN,UAAU,GAAG,YAAY"}
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"}
@@ -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.
@@ -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> so consumers can sync their own UI without
26
- * racing the click handler. A control may set
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
@@ -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;;;;;;;;;;;;;;GAcG;AACH,uDAHW,gBAAgB,GAAG,OAAO,eAAe,EAAE,YAAY,GACrD,OAAO,eAAe,EAAE,OAAO,CAgH3C;;;;;;;;;;6BAzJY,gBAAgB,GAAG;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE;;;;;WAIpC,OAAO,GAAG,MAAM"}
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"}
@@ -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> so consumers can sync their own UI without
62
- * racing the click handler. A control may set
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
- reflect();
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
- docEl.dispatchEvent(
160
- new CustomEvent('bronto:themechange', { detail: { theme: next }, bubbles: true }),
161
- );
165
+ emitThemeChange(next);
162
166
  };
163
167
 
164
168
  return bindOnce(host, 'themeToggle', () => {