@ponchia/ui 0.6.9 → 0.6.11

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 (145) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +38 -25
  3. package/annotations/index.d.ts +15 -15
  4. package/annotations/index.d.ts.map +1 -1
  5. package/annotations/index.js +52 -34
  6. package/behaviors/carousel.d.ts +7 -3
  7. package/behaviors/carousel.d.ts.map +1 -1
  8. package/behaviors/carousel.js +157 -27
  9. package/behaviors/combobox.d.ts +1 -1
  10. package/behaviors/combobox.d.ts.map +1 -1
  11. package/behaviors/combobox.js +46 -23
  12. package/behaviors/command.d.ts +1 -1
  13. package/behaviors/command.d.ts.map +1 -1
  14. package/behaviors/command.js +63 -23
  15. package/behaviors/connectors.d.ts.map +1 -1
  16. package/behaviors/connectors.js +126 -19
  17. package/behaviors/crosshair.d.ts.map +1 -1
  18. package/behaviors/crosshair.js +71 -8
  19. package/behaviors/dialog.d.ts.map +1 -1
  20. package/behaviors/dialog.js +20 -3
  21. package/behaviors/disclosure.d.ts.map +1 -1
  22. package/behaviors/disclosure.js +35 -6
  23. package/behaviors/dismissible.js +1 -1
  24. package/behaviors/forms.d.ts +23 -2
  25. package/behaviors/forms.d.ts.map +1 -1
  26. package/behaviors/forms.js +97 -9
  27. package/behaviors/glyph.d.ts.map +1 -1
  28. package/behaviors/glyph.js +56 -5
  29. package/behaviors/internal.d.ts.map +1 -1
  30. package/behaviors/internal.js +52 -5
  31. package/behaviors/menu.d.ts.map +1 -1
  32. package/behaviors/menu.js +2 -1
  33. package/behaviors/modal.d.ts.map +1 -1
  34. package/behaviors/modal.js +25 -9
  35. package/behaviors/popover.d.ts.map +1 -1
  36. package/behaviors/popover.js +8 -6
  37. package/behaviors/sources.d.ts.map +1 -1
  38. package/behaviors/sources.js +24 -3
  39. package/behaviors/splitter.d.ts.map +1 -1
  40. package/behaviors/splitter.js +27 -6
  41. package/behaviors/table.d.ts.map +1 -1
  42. package/behaviors/table.js +44 -7
  43. package/behaviors/tabs.d.ts.map +1 -1
  44. package/behaviors/tabs.js +51 -14
  45. package/behaviors/theme.d.ts.map +1 -1
  46. package/behaviors/theme.js +64 -4
  47. package/behaviors/toast.d.ts +6 -1
  48. package/behaviors/toast.d.ts.map +1 -1
  49. package/behaviors/toast.js +48 -12
  50. package/classes/classes.json +57 -2
  51. package/classes/index.d.ts +13 -2
  52. package/classes/index.js +88 -40
  53. package/connectors/index.d.ts +4 -4
  54. package/connectors/index.d.ts.map +1 -1
  55. package/connectors/index.js +14 -12
  56. package/css/annotations.css +1 -0
  57. package/css/app.css +7 -0
  58. package/css/base.css +3 -0
  59. package/css/bullet.css +41 -7
  60. package/css/code.css +14 -0
  61. package/css/command.css +10 -0
  62. package/css/dataviz.css +27 -0
  63. package/css/diff.css +2 -0
  64. package/css/disclosure.css +8 -0
  65. package/css/dots.css +1 -1
  66. package/css/feedback.css +9 -0
  67. package/css/interval.css +20 -2
  68. package/css/legend.css +10 -9
  69. package/css/marks.css +1 -0
  70. package/css/motion.css +2 -0
  71. package/css/overlay.css +14 -2
  72. package/css/primitives.css +1 -1
  73. package/css/report.css +3 -0
  74. package/css/sources.css +4 -4
  75. package/css/spotlight.css +6 -0
  76. package/css/table.css +19 -0
  77. package/css/term.css +4 -1
  78. package/css/tokens.css +8 -13
  79. package/css/workbench.css +128 -0
  80. package/dist/bronto.css +1 -1
  81. package/dist/css/analytical.css +1 -1
  82. package/dist/css/app.css +1 -1
  83. package/dist/css/bullet.css +1 -1
  84. package/dist/css/code.css +1 -1
  85. package/dist/css/command.css +1 -1
  86. package/dist/css/dataviz.css +1 -1
  87. package/dist/css/diff.css +1 -1
  88. package/dist/css/disclosure.css +1 -1
  89. package/dist/css/dots.css +1 -1
  90. package/dist/css/feedback.css +1 -1
  91. package/dist/css/interval.css +1 -1
  92. package/dist/css/legend.css +1 -1
  93. package/dist/css/marks.css +1 -1
  94. package/dist/css/overlay.css +1 -1
  95. package/dist/css/primitives.css +1 -1
  96. package/dist/css/report-kit.css +1 -1
  97. package/dist/css/sources.css +1 -1
  98. package/dist/css/spotlight.css +1 -1
  99. package/dist/css/table.css +1 -1
  100. package/dist/css/term.css +1 -1
  101. package/dist/css/tokens.css +1 -1
  102. package/dist/css/workbench.css +1 -1
  103. package/docs/annotations.md +27 -0
  104. package/docs/architecture.md +5 -3
  105. package/docs/bullet.md +6 -1
  106. package/docs/clamp.md +5 -0
  107. package/docs/command.md +3 -2
  108. package/docs/contrast.md +3 -3
  109. package/docs/crosshair.md +6 -0
  110. package/docs/dots.md +10 -3
  111. package/docs/figure.md +7 -0
  112. package/docs/glyphs.md +14 -2
  113. package/docs/highlights.md +9 -0
  114. package/docs/interval.md +6 -0
  115. package/docs/mermaid.md +5 -3
  116. package/docs/package-contract.md +24 -1
  117. package/docs/reference.md +21 -1
  118. package/docs/reporting.md +8 -8
  119. package/docs/selection.md +9 -0
  120. package/docs/sources.md +5 -0
  121. package/docs/state.md +6 -0
  122. package/docs/textref.md +18 -13
  123. package/docs/theming.md +18 -8
  124. package/docs/toc.md +6 -0
  125. package/docs/tree.md +9 -2
  126. package/docs/usage.md +2 -2
  127. package/docs/vega.md +5 -3
  128. package/docs/workbench.md +56 -9
  129. package/glyphs/glyphs.js +62 -8
  130. package/index.d.ts +1 -0
  131. package/llms.txt +18 -14
  132. package/package.json +98 -6
  133. package/qwik/index.d.ts +4 -3
  134. package/qwik/index.d.ts.map +1 -1
  135. package/qwik/index.js +7 -5
  136. package/react/index.d.ts +4 -3
  137. package/react/index.d.ts.map +1 -1
  138. package/react/index.js +3 -2
  139. package/solid/index.d.ts +7 -5
  140. package/solid/index.d.ts.map +1 -1
  141. package/solid/index.js +11 -7
  142. package/tokens/vega.d.ts +1 -1
  143. package/tokens/vega.js +3 -2
  144. package/vue/index.d.ts.map +1 -1
  145. package/vue/index.js +37 -3
@@ -1,4 +1,4 @@
1
- import { hasDom, resolveHost, noop, bindOnce, byIdInHost, collectHosts } from './internal.js';
1
+ import { hasDom, resolveHost, noop, bindOnce, collectHosts } from './internal.js';
2
2
  import { connectRects, arrowHead, dotMark } from '../connectors/index.js';
3
3
 
4
4
  const SVGNS = 'http://www.w3.org/2000/svg';
@@ -77,6 +77,46 @@ const syncConnectorEnd = (svg, end, angle) => {
77
77
  );
78
78
  };
79
79
 
80
+ const CONNECTOR_SHAPES = ['straight', 'elbow', 'curve'];
81
+ const CONNECTOR_SIDES = ['top', 'right', 'bottom', 'left', 'center'];
82
+ const CONNECTOR_SHAPE_VALUES = new Set(CONNECTOR_SHAPES);
83
+ const CONNECTOR_SIDE_VALUES = new Set(CONNECTOR_SIDES);
84
+
85
+ const clearConnectorParts = (svg) => {
86
+ svg.querySelector('.ui-connector__path')?.remove();
87
+ svg.querySelector('.ui-connector__end')?.remove();
88
+ };
89
+
90
+ const invalidConnectorOptionDetails = (svg) => {
91
+ const details = [];
92
+ const shape = svg.dataset.shape;
93
+ if (shape && !CONNECTOR_SHAPE_VALUES.has(shape)) {
94
+ details.push(`data-shape="${shape}" (allowed: ${CONNECTOR_SHAPES.join('/')})`);
95
+ }
96
+ const fromSide = svg.dataset.fromSide;
97
+ if (fromSide && !CONNECTOR_SIDE_VALUES.has(fromSide)) {
98
+ details.push(`data-from-side="${fromSide}" (allowed: ${CONNECTOR_SIDES.join('/')})`);
99
+ }
100
+ const toSide = svg.dataset.toSide;
101
+ if (toSide && !CONNECTOR_SIDE_VALUES.has(toSide)) {
102
+ details.push(`data-to-side="${toSide}" (allowed: ${CONNECTOR_SIDES.join('/')})`);
103
+ }
104
+ return details;
105
+ };
106
+
107
+ const warnInvalidConnectorOptions = (svg, warnedInvalidOptions) => {
108
+ const details = invalidConnectorOptionDetails(svg);
109
+ if (!details.length || typeof console === 'undefined') return;
110
+ const signature = details.join('|');
111
+ if (warnedInvalidOptions.get(svg) === signature) return;
112
+ warnedInvalidOptions.set(svg, signature);
113
+ console.warn(
114
+ `[bronto] initConnectors(): invalid connector option ${details.join(
115
+ ', ',
116
+ )} - skipping connector.`,
117
+ );
118
+ };
119
+
80
120
  /**
81
121
  * Draw + keep leader lines in sync. Each `[data-bronto-connector]` is an
82
122
  * `.ui-connector` SVG overlaying a positioned container; `data-from`/`data-to`
@@ -137,12 +177,29 @@ export function initConnectors({ root } = {}) {
137
177
  }
138
178
  };
139
179
 
140
- const draw = () => {
141
- const connectors = collectHosts(host, '[data-bronto-connector]');
142
- for (const svg of connectors) {
143
- const from = byIdInHost(host, svg.dataset.from);
144
- const to = byIdInHost(host, svg.dataset.to);
145
- if (!from || !to) continue;
180
+ const warnedInvalidOptions = new WeakMap();
181
+ const endpointInHost = (el) => host.nodeType === 9 || host.contains(el);
182
+ const endpointStillInHost = (el) => host.contains?.(el) ?? endpointInHost(el);
183
+ const cacheEndpoint = (endpoints, el) => {
184
+ if (el?.id && !endpoints.has(el.id) && endpointInHost(el)) endpoints.set(el.id, el);
185
+ };
186
+ const endpointCache = () => {
187
+ const endpoints = new Map();
188
+ if (host.nodeType !== 9) cacheEndpoint(endpoints, host);
189
+ for (const el of host.querySelectorAll?.('[id]') || []) cacheEndpoint(endpoints, el);
190
+ return endpoints;
191
+ };
192
+ const endpointById = (endpoints, id) => {
193
+ if (!id) return null;
194
+ const el = endpoints.get(id) || null;
195
+ return el && endpointStillInHost(el) ? el : null;
196
+ };
197
+
198
+ const measureConnector = (endpoints, svg) => {
199
+ const from = endpointById(endpoints, svg.dataset.from);
200
+ const to = endpointById(endpoints, svg.dataset.to);
201
+ if (!from || !to) return { svg, skipped: true };
202
+ try {
146
203
  const {
147
204
  d,
148
205
  to: end,
@@ -154,40 +211,90 @@ export function initConnectors({ root } = {}) {
154
211
  fromSide: svg.dataset.fromSide || undefined,
155
212
  toSide: svg.dataset.toSide || undefined,
156
213
  });
214
+ return { svg, d, end, angle };
215
+ } catch {
216
+ return { svg, invalid: true };
217
+ }
218
+ };
219
+
220
+ const draw = (endpoints, connectors) => {
221
+ const measurements = connectors.map((svg) => measureConnector(endpoints, svg));
222
+ for (const result of measurements) {
223
+ const { svg } = result;
224
+ if (result.skipped || result.invalid) {
225
+ clearConnectorParts(svg);
226
+ if (result.invalid) warnInvalidConnectorOptions(svg, warnedInvalidOptions);
227
+ continue;
228
+ }
157
229
  const path = upsertConnectorPart(svg, '.ui-connector__path', 'ui-connector__path');
158
- path.setAttribute('d', d);
230
+ path.setAttribute('d', result.d);
159
231
  syncDrawPathLength(svg, path);
160
- syncConnectorEnd(svg, end, angle);
232
+ syncConnectorEnd(svg, result.end, result.angle);
161
233
  }
162
234
  };
163
235
 
236
+ const observeEndpoint = (ro, el) => {
237
+ if (el && endpointStillInHost(el)) ro.observe(el);
238
+ };
239
+
164
240
  return bindOnce(host, 'connectors', () => {
165
241
  const connectors = collectHosts(host, '[data-bronto-connector]');
166
242
  if (!connectors.length) return noop;
243
+ const endpoints = endpointCache();
167
244
  const states = connectors.map((svg) => ({
168
245
  svg,
169
246
  path: snapshotPart(svg, '.ui-connector__path'),
170
247
  end: snapshotPart(svg, '.ui-connector__end'),
171
248
  }));
172
- draw();
173
249
  const view = host.defaultView || host.ownerDocument?.defaultView || null;
250
+ const raf =
251
+ view?.requestAnimationFrame?.bind(view) || globalThis.requestAnimationFrame?.bind(globalThis);
252
+ const caf =
253
+ view?.cancelAnimationFrame?.bind(view) || globalThis.cancelAnimationFrame?.bind(globalThis);
254
+ let frame = null;
255
+ let framePending = false;
256
+ let stopped = false;
257
+
258
+ const drawNow = () => {
259
+ if (!stopped) draw(endpoints, connectors);
260
+ };
261
+ const scheduleDraw = () => {
262
+ if (stopped || framePending) return;
263
+ if (!raf) {
264
+ drawNow();
265
+ return;
266
+ }
267
+ framePending = true;
268
+ frame = raf(() => {
269
+ framePending = false;
270
+ frame = null;
271
+ drawNow();
272
+ });
273
+ };
274
+ const cancelScheduledDraw = () => {
275
+ if (framePending && caf) caf(frame);
276
+ framePending = false;
277
+ frame = null;
278
+ };
279
+
280
+ drawNow();
174
281
  const RO = view?.ResizeObserver;
175
- const ro = RO ? new RO(draw) : null;
282
+ const ro = RO ? new RO(scheduleDraw) : null;
176
283
  if (ro) {
177
284
  for (const svg of connectors) {
178
285
  if (svg.parentElement) ro.observe(svg.parentElement);
179
- const f = byIdInHost(host, svg.dataset.from);
180
- const t = byIdInHost(host, svg.dataset.to);
181
- if (f) ro.observe(f);
182
- if (t) ro.observe(t);
286
+ observeEndpoint(ro, endpointById(endpoints, svg.dataset.from));
287
+ observeEndpoint(ro, endpointById(endpoints, svg.dataset.to));
183
288
  }
184
289
  }
185
- view?.addEventListener('resize', draw);
186
- view?.addEventListener('scroll', draw, true);
290
+ view?.addEventListener('resize', scheduleDraw);
291
+ view?.addEventListener('scroll', scheduleDraw, true);
187
292
  return () => {
293
+ stopped = true;
294
+ cancelScheduledDraw();
188
295
  ro?.disconnect();
189
- view?.removeEventListener('resize', draw);
190
- view?.removeEventListener('scroll', draw, true);
296
+ view?.removeEventListener('resize', scheduleDraw);
297
+ view?.removeEventListener('scroll', scheduleDraw, true);
191
298
  for (const state of states) {
192
299
  restorePart(state.svg, '.ui-connector__path', state.path);
193
300
  restorePart(state.svg, '.ui-connector__end', state.end);
@@ -1 +1 @@
1
- {"version":3,"file":"crosshair.d.ts","sourceRoot":"","sources":["crosshair.js"],"names":[],"mappings":"AAEA;;;;;;GAMG;AAEH;;;;;;;;;;;;;;GAcG;AACH,yCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAiG3C;;;;;OApHa,MAAM;;;;OACN,MAAM;;;;QACN,MAAM;;;;QACN,MAAM"}
1
+ {"version":3,"file":"crosshair.d.ts","sourceRoot":"","sources":["crosshair.js"],"names":[],"mappings":"AAIA;;;;;;GAMG;AAEH;;;;;;;;;;;;;;GAcG;AACH,yCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CA8J3C;;;;;OAjLa,MAAM;;;;OACN,MAAM;;;;QACN,MAAM;;;;QACN,MAAM"}
@@ -1,5 +1,7 @@
1
1
  import { hasDom, resolveHost, noop, bindOnce, collectHosts } from './internal.js';
2
2
 
3
+ const rectRight = (rect) => rect.right ?? rect.left + rect.width;
4
+
3
5
  /**
4
6
  * @typedef {object} CrosshairMoveDetail
5
7
  * @property {number} x Pointer x within the plot, in pixels.
@@ -34,6 +36,56 @@ export function initCrosshair({ root } = {}) {
34
36
  for (const plot of plots) {
35
37
  const overlay = plot.querySelector('.ui-crosshair');
36
38
  let overlayState = null;
39
+ let geometry = null;
40
+ let active = overlay?.classList.contains('is-active') ?? false;
41
+ let writtenX = null;
42
+ let writtenY = null;
43
+ let writtenInline = overlay?.dataset.readoutInline ?? null;
44
+ let writtenBlock = overlay?.dataset.readoutBlock ?? null;
45
+ const view = plot.ownerDocument?.defaultView || null;
46
+ const invalidateGeometry = () => {
47
+ geometry = null;
48
+ };
49
+ const readGeometry = () => {
50
+ const overlayRect = overlay.getBoundingClientRect();
51
+ const r =
52
+ overlayRect.width && overlayRect.height ? overlayRect : plot.getBoundingClientRect();
53
+ if (!r.width || !r.height) return null;
54
+ geometry = {
55
+ left: r.left,
56
+ right: rectRight(r),
57
+ top: r.top,
58
+ width: r.width,
59
+ height: r.height,
60
+ rtl: view?.getComputedStyle?.(overlay).direction === 'rtl',
61
+ };
62
+ return geometry;
63
+ };
64
+ const currentGeometry = () => geometry || readGeometry();
65
+ const writeOverlay = (y, logicalX, inline, block) => {
66
+ const xValue = `${logicalX}px`;
67
+ const yValue = `${y}px`;
68
+ if (writtenX !== xValue) {
69
+ overlay.style.setProperty('--crosshair-x', xValue);
70
+ writtenX = xValue;
71
+ }
72
+ if (writtenY !== yValue) {
73
+ overlay.style.setProperty('--crosshair-y', yValue);
74
+ writtenY = yValue;
75
+ }
76
+ if (writtenInline !== inline) {
77
+ overlay.dataset.readoutInline = inline;
78
+ writtenInline = inline;
79
+ }
80
+ if (writtenBlock !== block) {
81
+ overlay.dataset.readoutBlock = block;
82
+ writtenBlock = block;
83
+ }
84
+ if (!active) {
85
+ overlay.classList.add('is-active');
86
+ active = true;
87
+ }
88
+ };
37
89
  const rememberOverlay = () => {
38
90
  if (!overlay || overlayState) return;
39
91
  overlayState = {
@@ -71,13 +123,19 @@ export function initCrosshair({ root } = {}) {
71
123
  else overlay.style.removeProperty('--crosshair-y');
72
124
  restoreData('data-readout-inline', overlayState.inline);
73
125
  restoreData('data-readout-block', overlayState.block);
126
+ active = overlayState.active;
127
+ writtenX = overlayState.x.value || null;
128
+ writtenY = overlayState.y.value || null;
129
+ writtenInline = overlayState.inline.had ? overlayState.inline.value : null;
130
+ writtenBlock = overlayState.block.had ? overlayState.block.value : null;
131
+ invalidateGeometry();
74
132
  overlayState = null;
75
133
  };
76
134
  const onMove = (e) => {
77
135
  if (!overlay) return;
78
136
  rememberOverlay();
79
- const r = plot.getBoundingClientRect();
80
- if (!r.width || !r.height) return;
137
+ const r = currentGeometry();
138
+ if (!r) return;
81
139
  const x = e.clientX - r.left;
82
140
  const y = e.clientY - r.top;
83
141
  // The CSS positions the vertical rule / readout with a *logical* inset
@@ -86,12 +144,12 @@ export function initCrosshair({ root } = {}) {
86
144
  // Emitting the physical x instead made the RTL rule land off-plot. The
87
145
  // public `detail.x`/`fx` stay physical-from-left so host scale-mapping
88
146
  // keeps one stable coordinate space regardless of direction.
89
- const rtl = getComputedStyle(plot).direction === 'rtl';
90
- overlay.style.setProperty('--crosshair-x', `${rtl ? r.right - e.clientX : x}px`);
91
- overlay.style.setProperty('--crosshair-y', `${y}px`);
92
- overlay.dataset.readoutInline = x / r.width > 0.5 ? 'before' : 'after';
93
- overlay.dataset.readoutBlock = y / r.height > 0.5 ? 'above' : 'below';
94
- overlay.classList.add('is-active');
147
+ writeOverlay(
148
+ y,
149
+ r.rtl ? r.right - e.clientX : x,
150
+ x / r.width > 0.5 ? 'before' : 'after',
151
+ y / r.height > 0.5 ? 'above' : 'below',
152
+ );
95
153
  plot.dispatchEvent(
96
154
  new CustomEvent('bronto:crosshair:move', {
97
155
  bubbles: true,
@@ -102,6 +160,7 @@ export function initCrosshair({ root } = {}) {
102
160
  const onLeave = () => {
103
161
  if (!overlay) return;
104
162
  overlay.classList.remove('is-active');
163
+ active = false;
105
164
  plot.dispatchEvent(new CustomEvent('bronto:crosshair:leave', { bubbles: true }));
106
165
  };
107
166
  cleanups.push(
@@ -109,9 +168,13 @@ export function initCrosshair({ root } = {}) {
109
168
  if (!overlay) return noop;
110
169
  plot.addEventListener('pointermove', onMove);
111
170
  plot.addEventListener('pointerleave', onLeave);
171
+ view?.addEventListener('resize', invalidateGeometry);
172
+ view?.addEventListener('scroll', invalidateGeometry, true);
112
173
  return () => {
113
174
  plot.removeEventListener('pointermove', onMove);
114
175
  plot.removeEventListener('pointerleave', onLeave);
176
+ view?.removeEventListener('resize', invalidateGeometry);
177
+ view?.removeEventListener('scroll', invalidateGeometry, true);
115
178
  restoreOverlay();
116
179
  };
117
180
  }),
@@ -1 +1 @@
1
- {"version":3,"file":"dialog.d.ts","sourceRoot":"","sources":["dialog.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;GAiBG;AACH,sCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CA+E3C"}
1
+ {"version":3,"file":"dialog.d.ts","sourceRoot":"","sources":["dialog.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;GAiBG;AACH,sCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAgG3C"}
@@ -22,7 +22,9 @@ export function initDialog({ root } = {}) {
22
22
  if (!hasDom()) return noop;
23
23
  const host = resolveHost(root);
24
24
  if (!host) return noop;
25
- const managedDialogs = new WeakSet();
25
+ const doc = host.nodeType === 9 ? host : host.ownerDocument;
26
+ if (!doc) return noop;
27
+ const managedDialogs = new Set();
26
28
  const focusRestorers = new Map();
27
29
  const canManageDialog = (dlg, origin) => host.contains(origin) || managedDialogs.has(dlg);
28
30
 
@@ -33,9 +35,11 @@ export function initDialog({ root } = {}) {
33
35
  if (previous) {
34
36
  dlg.removeEventListener('close', previous);
35
37
  focusRestorers.delete(dlg);
38
+ managedDialogs.delete(dlg);
36
39
  }
37
40
  const restoreFocus = () => {
38
41
  focusRestorers.delete(dlg);
42
+ managedDialogs.delete(dlg);
39
43
  if (opener.isConnected && typeof opener.focus === 'function') opener.focus();
40
44
  };
41
45
  try {
@@ -86,13 +90,26 @@ export function initDialog({ root } = {}) {
86
90
  if (lightDismiss(target)) e.preventDefault();
87
91
  };
88
92
  return bindOnce(host, 'dialog', () => {
89
- document.addEventListener('click', onClick);
93
+ doc.addEventListener('click', onClick);
90
94
  return () => {
91
- document.removeEventListener('click', onClick);
95
+ doc.removeEventListener('click', onClick);
92
96
  for (const [dlg, restoreFocus] of focusRestorers) {
97
+ // Only pull focus back to the opener when it is currently INSIDE this
98
+ // dialog — closing would otherwise strand it on <body>. If the app has
99
+ // already moved focus elsewhere, leave it where it is.
100
+ const focusInside = dlg.contains(doc.activeElement);
93
101
  dlg.removeEventListener('close', restoreFocus);
102
+ if (dlg.open) {
103
+ try {
104
+ dlg.close();
105
+ } catch {
106
+ /* already closed */
107
+ }
108
+ }
109
+ if (focusInside) restoreFocus();
94
110
  }
95
111
  focusRestorers.clear();
112
+ managedDialogs.clear();
96
113
  };
97
114
  });
98
115
  }
@@ -1 +1 @@
1
- {"version":3,"file":"disclosure.d.ts","sourceRoot":"","sources":["disclosure.js"],"names":[],"mappings":"AAYA;;;;;;;GAOG;AACH,0CAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAwC3C"}
1
+ {"version":3,"file":"disclosure.d.ts","sourceRoot":"","sources":["disclosure.js"],"names":[],"mappings":"AA+BA;;;;;;;GAOG;AACH,0CAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAkD3C"}
@@ -1,4 +1,15 @@
1
- import { hasDom, resolveHost, noop, bindOnce, byIdInHost, closestSafe } from './internal.js';
1
+ import {
2
+ hasDom,
3
+ resolveHost,
4
+ noop,
5
+ bindOnce,
6
+ byIdInHost,
7
+ closestSafe,
8
+ collectHosts,
9
+ } from './internal.js';
10
+
11
+ const handledDisclosureEvents = new WeakSet();
12
+ const warnedMissingTargets = new WeakSet();
2
13
 
3
14
  const snapshotAttr = (el, name) => ({
4
15
  had: el.hasAttribute(name),
@@ -10,6 +21,14 @@ const restoreAttr = (el, name, state) => {
10
21
  else el.removeAttribute(name);
11
22
  };
12
23
 
24
+ const warnMissingTarget = (trigger, id) => {
25
+ if (warnedMissingTargets.has(trigger) || typeof console === 'undefined') return;
26
+ warnedMissingTargets.add(trigger);
27
+ console.warn(
28
+ `[bronto] initDisclosure(): no panel found for aria-controls="${id || ''}" - disclosure trigger stays inert.`,
29
+ );
30
+ };
31
+
13
32
  /**
14
33
  * Disclosure: a `[data-bronto-disclosure]` trigger toggles the element
15
34
  * referenced by its `aria-controls` id, keeping `aria-expanded` and the
@@ -35,16 +54,26 @@ export function initDisclosure({ root } = {}) {
35
54
  };
36
55
 
37
56
  const onClick = (e) => {
57
+ if (handledDisclosureEvents.has(e)) return;
38
58
  const trigger = closestSafe(e.target, '[data-bronto-disclosure]');
39
59
  if (!trigger || !host.contains(trigger)) return;
40
60
  const id = trigger.getAttribute('aria-controls');
41
61
  const panel = byIdInHost(host, id);
42
- if (!panel) return;
62
+ if (!panel) {
63
+ warnMissingTarget(trigger, id);
64
+ return;
65
+ }
66
+ handledDisclosureEvents.add(e);
43
67
  e.preventDefault();
44
- remember(trigger, panel);
45
- const open = trigger.getAttribute('aria-expanded') === 'true';
46
- trigger.setAttribute('aria-expanded', String(!open));
47
- panel.hidden = open;
68
+ const nextOpen = panel.hidden;
69
+ const triggers = collectHosts(host, '[data-bronto-disclosure]').filter(
70
+ (el) => el.getAttribute('aria-controls') === id,
71
+ );
72
+ for (const el of triggers) {
73
+ remember(el, panel);
74
+ el.setAttribute('aria-expanded', String(nextOpen));
75
+ }
76
+ panel.hidden = !nextOpen;
48
77
  };
49
78
  return bindOnce(host, 'disclosure', () => {
50
79
  host.addEventListener('click', onClick);
@@ -15,7 +15,7 @@ export function dismissible({ root } = {}) {
15
15
  const onClick = (e) => {
16
16
  const btn = closestSafe(e.target, '[data-bronto-dismiss]');
17
17
  if (!btn || !host.contains(btn)) return;
18
- const sel = btn.getAttribute('data-bronto-dismiss');
18
+ const sel = btn.getAttribute('data-bronto-dismiss')?.trim();
19
19
  const target = sel ? closestSafe(btn, sel) : closestSafe(btn, '[data-bronto-dismissible]');
20
20
  if (!target) return;
21
21
  e.preventDefault();
@@ -1,3 +1,12 @@
1
+ /**
2
+ * @typedef {object} FormValidationOpts
3
+ * @property {Document | Element | null} [root]
4
+ * Event-delegation root; default: `document`.
5
+ * @property {string} [summaryTitle]
6
+ * Localized validation-summary title. A summary/form
7
+ * `data-bronto-error-summary-title` attribute overrides it, and an authored
8
+ * `.ui-error-summary__title` child is preserved.
9
+ */
1
10
  /**
2
11
  * Accessible form validation glue for `<form data-bronto-validate>`.
3
12
  * Progressive enhancement over the native Constraint Validation API —
@@ -20,8 +29,20 @@
20
29
  * Pure enhancement: with JS off the form still submits and the browser
21
30
  * validates natively. SSR-safe, idempotent; returns a cleanup function.
22
31
  *
23
- * @param {import('./internal.js').DelegateOpts} [opts]
32
+ * @param {FormValidationOpts} [opts]
24
33
  * @returns {import('./internal.js').Cleanup}
25
34
  */
26
- export function initFormValidation({ root }?: import("./internal.js").DelegateOpts): import("./internal.js").Cleanup;
35
+ export function initFormValidation({ root, summaryTitle }?: FormValidationOpts): import("./internal.js").Cleanup;
36
+ export type FormValidationOpts = {
37
+ /**
38
+ * Event-delegation root; default: `document`.
39
+ */
40
+ root?: Document | Element | null | undefined;
41
+ /**
42
+ * Localized validation-summary title. A summary/form
43
+ * `data-bronto-error-summary-title` attribute overrides it, and an authored
44
+ * `.ui-error-summary__title` child is preserved.
45
+ */
46
+ summaryTitle?: string | undefined;
47
+ };
27
48
  //# sourceMappingURL=forms.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"forms.d.ts","sourceRoot":"","sources":["forms.js"],"names":[],"mappings":"AAiMA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,8CAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CA2D3C"}
1
+ {"version":3,"file":"forms.d.ts","sourceRoot":"","sources":["forms.js"],"names":[],"mappings":"AA8QA;;;;;;;;GAQG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,4DAHW,kBAAkB,GAChB,OAAO,eAAe,EAAE,OAAO,CA4D3C"}
@@ -59,6 +59,7 @@ function rememberSummary(summary, state) {
59
59
  attrs: snapshotAttrs(summary, ['role', 'tabindex']),
60
60
  children: [...summary.childNodes],
61
61
  hidden: summary.hidden,
62
+ title: summary.querySelector('.ui-error-summary__title')?.cloneNode(true) ?? null,
62
63
  });
63
64
  }
64
65
  }
@@ -141,12 +142,60 @@ function controlsOf(form) {
141
142
  );
142
143
  }
143
144
 
145
+ function textOf(el) {
146
+ return el?.textContent?.replace(/\s+/g, ' ').trim() || '';
147
+ }
148
+
149
+ function fieldsetLegend(control, field) {
150
+ return (
151
+ textOf(field?.querySelector('legend')) ||
152
+ textOf(control.closest('fieldset')?.querySelector('legend'))
153
+ );
154
+ }
155
+
156
+ function labelledbyText(control) {
157
+ const doc = control.ownerDocument || document;
158
+ return (control.getAttribute('aria-labelledby') || '')
159
+ .split(/\s+/)
160
+ .map((id) => textOf(doc.getElementById(id)))
161
+ .filter(Boolean)
162
+ .join(' ');
163
+ }
164
+
165
+ function labelsText(control) {
166
+ return control.labels
167
+ ? [...control.labels]
168
+ .map((label) => textOf(label))
169
+ .filter(Boolean)
170
+ .join(' ')
171
+ : '';
172
+ }
173
+
174
+ function controlLabel(control) {
175
+ const field = control.closest('.ui-field');
176
+ // A radio's accessible name is its group legend when present.
177
+ if (control.type === 'radio') {
178
+ const groupLegend = fieldsetLegend(control, field);
179
+ if (groupLegend) return groupLegend;
180
+ }
181
+ return (
182
+ labelledbyText(control) ||
183
+ control.getAttribute('aria-label')?.trim() ||
184
+ labelsText(control) ||
185
+ textOf(field?.querySelector('label, .ui-label')) ||
186
+ fieldsetLegend(control, field) ||
187
+ (control.getAttribute('name') || control.name || '').trim()
188
+ );
189
+ }
190
+
144
191
  function summaryItem(control) {
145
192
  const id = ensureId(control, 'bronto-field');
146
193
  const li = document.createElement('li');
147
194
  const a = document.createElement('a');
195
+ const label = controlLabel(control);
196
+ const message = control.validationMessage;
148
197
  a.href = `#${id}`;
149
- a.textContent = control.validationMessage;
198
+ a.textContent = label ? `${label}: ${message}` : message;
150
199
  a.addEventListener('click', (e) => {
151
200
  e.preventDefault();
152
201
  control.focus();
@@ -155,7 +204,37 @@ function summaryItem(control) {
155
204
  return li;
156
205
  }
157
206
 
158
- function refreshSummary(form, invalid, state) {
207
+ function summaryControls(form, invalid) {
208
+ const radioNames = new Set();
209
+ const controls = [];
210
+ for (const control of invalid) {
211
+ if (control.form === form && control.type === 'radio' && control.name) {
212
+ if (radioNames.has(control.name)) continue;
213
+ radioNames.add(control.name);
214
+ }
215
+ controls.push(control);
216
+ }
217
+ return controls;
218
+ }
219
+
220
+ function nonEmpty(value) {
221
+ return typeof value === 'string' ? value.trim() : '';
222
+ }
223
+
224
+ function summaryTitleNode(form, summary, state, fallbackTitle) {
225
+ const authored = state.summaryState.get(summary)?.title;
226
+ if (authored) return authored.cloneNode(true);
227
+ const title = document.createElement('p');
228
+ title.className = 'ui-error-summary__title';
229
+ title.textContent =
230
+ nonEmpty(summary.getAttribute('data-bronto-error-summary-title')) ||
231
+ nonEmpty(form.getAttribute('data-bronto-error-summary-title')) ||
232
+ nonEmpty(fallbackTitle) ||
233
+ 'There is a problem';
234
+ return title;
235
+ }
236
+
237
+ function refreshSummary(form, invalid, state, summaryTitle) {
159
238
  const summary = form.querySelector('[data-bronto-error-summary]');
160
239
  if (!summary) return;
161
240
  rememberSummary(summary, state);
@@ -164,12 +243,10 @@ function refreshSummary(form, invalid, state) {
164
243
  summary.replaceChildren();
165
244
  return;
166
245
  }
167
- const title = document.createElement('p');
168
- title.className = 'ui-error-summary__title';
169
- title.textContent = 'There is a problem';
246
+ const title = summaryTitleNode(form, summary, state, summaryTitle);
170
247
  const list = document.createElement('ul');
171
248
  list.className = 'ui-error-summary__list';
172
- list.append(...invalid.map(summaryItem));
249
+ list.append(...summaryControls(form, invalid).map(summaryItem));
173
250
  summary.replaceChildren(title, list);
174
251
  summary.setAttribute('role', 'alert');
175
252
  summary.tabIndex = -1;
@@ -191,6 +268,16 @@ function restoreValidationState(state) {
191
268
  for (const [control, attrs] of state.controlState) restoreAttrs(control, attrs);
192
269
  }
193
270
 
271
+ /**
272
+ * @typedef {object} FormValidationOpts
273
+ * @property {Document | Element | null} [root]
274
+ * Event-delegation root; default: `document`.
275
+ * @property {string} [summaryTitle]
276
+ * Localized validation-summary title. A summary/form
277
+ * `data-bronto-error-summary-title` attribute overrides it, and an authored
278
+ * `.ui-error-summary__title` child is preserved.
279
+ */
280
+
194
281
  /**
195
282
  * Accessible form validation glue for `<form data-bronto-validate>`.
196
283
  * Progressive enhancement over the native Constraint Validation API —
@@ -213,10 +300,10 @@ function restoreValidationState(state) {
213
300
  * Pure enhancement: with JS off the form still submits and the browser
214
301
  * validates natively. SSR-safe, idempotent; returns a cleanup function.
215
302
  *
216
- * @param {import('./internal.js').DelegateOpts} [opts]
303
+ * @param {FormValidationOpts} [opts]
217
304
  * @returns {import('./internal.js').Cleanup}
218
305
  */
219
- export function initFormValidation({ root } = {}) {
306
+ export function initFormValidation({ root, summaryTitle } = {}) {
220
307
  if (!hasDom()) return noop;
221
308
  const host = resolveHost(root);
222
309
  if (!host) return noop;
@@ -227,7 +314,7 @@ export function initFormValidation({ root } = {}) {
227
314
  if (!form) return;
228
315
  suppressNativeValidation(form, state);
229
316
  const invalid = controlsOf(form).filter((control) => !validateField(control, state));
230
- refreshSummary(form, invalid, state);
317
+ refreshSummary(form, invalid, state, summaryTitle);
231
318
  if (invalid.length) {
232
319
  event.preventDefault();
233
320
  const summary = form.querySelector('[data-bronto-error-summary]');
@@ -248,6 +335,7 @@ export function initFormValidation({ root } = {}) {
248
335
  form,
249
336
  controlsOf(form).filter((candidate) => !candidate.validity.valid),
250
337
  state,
338
+ summaryTitle,
251
339
  );
252
340
  }
253
341
  };
@@ -1 +1 @@
1
- {"version":3,"file":"glyph.d.ts","sourceRoot":"","sources":["glyph.js"],"names":[],"mappings":"AAwLA;;;;;;;;;;;;;;;;;;GAkBG;AACH,wCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAwB3C"}
1
+ {"version":3,"file":"glyph.d.ts","sourceRoot":"","sources":["glyph.js"],"names":[],"mappings":"AA2OA;;;;;;;;;;;;;;;;;;GAkBG;AACH,wCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAwB3C"}