@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
@@ -43,6 +43,43 @@ const renderedStatusIndex = (status) => {
43
43
  return Number.isInteger(value) ? value - 1 : -1;
44
44
  };
45
45
 
46
+ const rectRight = (rect) => rect.right ?? rect.left + rect.width;
47
+ const rectBottom = (rect) => rect.bottom ?? rect.top + rect.height;
48
+
49
+ function intersectionRatioInViewport(viewportRect, slideRect) {
50
+ if (!slideRect.width || !slideRect.height) return 0;
51
+ const left = Math.max(viewportRect.left, slideRect.left);
52
+ const right = Math.min(rectRight(viewportRect), rectRight(slideRect));
53
+ const top = Math.max(viewportRect.top, slideRect.top);
54
+ const bottom = Math.min(rectBottom(viewportRect), rectBottom(slideRect));
55
+ const width = Math.max(0, right - left);
56
+ const height = Math.max(0, bottom - top);
57
+ return (width * height) / (slideRect.width * slideRect.height);
58
+ }
59
+
60
+ function measuredCarouselIndex(viewport, slides) {
61
+ const viewportRect = viewport.getBoundingClientRect();
62
+ if (!viewportRect.width || !viewportRect.height) return -1;
63
+ let bestIndex = -1;
64
+ let bestRatio = 0;
65
+ for (const [i, slide] of slides.entries()) {
66
+ const ratio = intersectionRatioInViewport(viewportRect, slide.getBoundingClientRect());
67
+ if (ratio > bestRatio) {
68
+ bestIndex = i;
69
+ bestRatio = ratio;
70
+ }
71
+ }
72
+ return bestRatio >= 0.6 ? bestIndex : -1;
73
+ }
74
+
75
+ function bestCarouselEntry(entries) {
76
+ let best = null;
77
+ for (const ent of entries) {
78
+ if (ent.isIntersecting && (!best || ent.intersectionRatio > best.intersectionRatio)) best = ent;
79
+ }
80
+ return best;
81
+ }
82
+
46
83
  const snapshotCarouselState = ({ viewport, slides, status, prevBtn, nextBtn, thumbs }) => ({
47
84
  viewport: snapshotNode(viewport),
48
85
  slides: slides.map((slide) => snapshotNode(slide)),
@@ -61,11 +98,38 @@ function setDefaultButtonType(button) {
61
98
  if (button?.tagName === 'BUTTON' && !button.hasAttribute('type')) button.type = 'button';
62
99
  }
63
100
 
64
- function applyCarouselA11y({ box, viewport, slides, status, prevBtn, nextBtn, thumbs, n }) {
101
+ const carouselRoleDescription = (box, viewport, roleDescription) =>
102
+ viewport.getAttribute('data-bronto-carousel-roledescription') ||
103
+ box.getAttribute('data-bronto-carousel-roledescription') ||
104
+ roleDescription ||
105
+ 'carousel';
106
+
107
+ const carouselSlideRoleDescription = (box, viewport, slide) =>
108
+ slide.getAttribute('data-bronto-carousel-slide-roledescription') ||
109
+ viewport.getAttribute('data-bronto-carousel-slide-roledescription') ||
110
+ box.getAttribute('data-bronto-carousel-slide-roledescription') ||
111
+ 'slide';
112
+
113
+ function applyCarouselA11y({
114
+ box,
115
+ viewport,
116
+ slides,
117
+ status,
118
+ prevBtn,
119
+ nextBtn,
120
+ thumbs,
121
+ n,
122
+ roleDescription,
123
+ }) {
65
124
  // ARIA scaffolding — pragmatic carousel semantics (not the full APG
66
125
  // tablist), the same restraint initMenu takes.
67
126
  viewport.setAttribute('role', 'group');
68
- viewport.setAttribute('aria-roledescription', 'carousel');
127
+ if (!viewport.hasAttribute('aria-roledescription')) {
128
+ viewport.setAttribute(
129
+ 'aria-roledescription',
130
+ carouselRoleDescription(box, viewport, roleDescription),
131
+ );
132
+ }
69
133
  if (!viewport.hasAttribute('aria-label')) {
70
134
  viewport.setAttribute(
71
135
  'aria-label',
@@ -75,7 +139,12 @@ function applyCarouselA11y({ box, viewport, slides, status, prevBtn, nextBtn, th
75
139
  if (!viewport.hasAttribute('tabindex')) viewport.tabIndex = 0;
76
140
  slides.forEach((slide, i) => {
77
141
  slide.setAttribute('role', 'group');
78
- slide.setAttribute('aria-roledescription', 'slide');
142
+ if (!slide.hasAttribute('aria-roledescription')) {
143
+ slide.setAttribute(
144
+ 'aria-roledescription',
145
+ carouselSlideRoleDescription(box, viewport, slide),
146
+ );
147
+ }
79
148
  if (!slide.hasAttribute('aria-label')) slide.setAttribute('aria-label', `${i + 1} of ${n}`);
80
149
  });
81
150
  if (status) status.setAttribute('aria-live', 'polite');
@@ -100,11 +169,26 @@ function bindCarouselLifecycle({
100
169
  io,
101
170
  holdProgrammatic,
102
171
  clearProgrammaticTimer,
172
+ onUserScrollStart,
173
+ roleDescription,
103
174
  }) {
104
175
  const state = snapshotCarouselState({ viewport, slides, status, prevBtn, nextBtn, thumbs });
105
- applyCarouselA11y({ box, viewport, slides, status, prevBtn, nextBtn, thumbs, n });
176
+ applyCarouselA11y({
177
+ box,
178
+ viewport,
179
+ slides,
180
+ status,
181
+ prevBtn,
182
+ nextBtn,
183
+ thumbs,
184
+ n,
185
+ roleDescription,
186
+ });
106
187
  render();
107
188
  viewport.addEventListener('keydown', onKey);
189
+ viewport.addEventListener('pointerdown', onUserScrollStart);
190
+ viewport.addEventListener('touchstart', onUserScrollStart, { passive: true });
191
+ viewport.addEventListener('wheel', onUserScrollStart, { passive: true });
108
192
  box.addEventListener('click', onClick);
109
193
  // Observe inside the add callback so observe/disconnect pair with the
110
194
  // binding lifecycle: a re-init tears down the prior binding (which
@@ -116,6 +200,9 @@ function bindCarouselLifecycle({
116
200
  }
117
201
  return () => {
118
202
  viewport.removeEventListener('keydown', onKey);
203
+ viewport.removeEventListener('pointerdown', onUserScrollStart);
204
+ viewport.removeEventListener('touchstart', onUserScrollStart);
205
+ viewport.removeEventListener('wheel', onUserScrollStart);
119
206
  box.removeEventListener('click', onClick);
120
207
  io?.disconnect();
121
208
  clearProgrammaticTimer();
@@ -135,7 +222,9 @@ function bindCarouselLifecycle({
135
222
  * `[data-bronto-carousel-prev]` / `[data-bronto-carousel-next]` controls,
136
223
  * a `.ui-carousel__thumbs` list of `.ui-carousel__thumb` buttons, and a
137
224
  * `.ui-carousel__status` counter slot. Add `data-bronto-carousel-loop` to
138
- * wrap at the ends, `data-bronto-carousel-label` to name the region.
225
+ * wrap at the ends, `data-bronto-carousel-label` to name the region, and
226
+ * `data-bronto-carousel-roledescription` to localize the default
227
+ * `aria-roledescription` when the viewport does not already carry one.
139
228
  *
140
229
  * A full-screen **lightbox** is the same markup inside a native
141
230
  * `<dialog class="ui-lightbox">` opened by {@link initDialog}: the
@@ -146,10 +235,10 @@ function bindCarouselLifecycle({
146
235
  * (button, key, thumbnail, or swipe). SSR-safe, idempotent per carousel;
147
236
  * returns a cleanup function.
148
237
  *
149
- * @param {import('./internal.js').DelegateOpts} [opts]
238
+ * @param {import('./internal.js').DelegateOpts & { roleDescription?: string }} [opts]
150
239
  * @returns {import('./internal.js').Cleanup}
151
240
  */
152
- export function initCarousel({ root } = {}) {
241
+ export function initCarousel({ root, roleDescription } = {}) {
153
242
  if (!hasDom()) return noop;
154
243
  const host = resolveHost(root);
155
244
  if (!host) return noop;
@@ -181,16 +270,48 @@ export function initCarousel({ root } = {}) {
181
270
  // While a button/keyboard nav is smooth-scrolling, the IntersectionObserver
182
271
  // would observe the intermediate slides crossing its threshold and re-fire
183
272
  // `bronto:change` for each — a feedback burst on a single Home→End jump.
184
- // This flag makes the IO drive the index on *user* swipes only; a timeout
185
- // (not the patchy `scrollend` event) releases it once the scroll settles.
273
+ // Instant/reduced-motion scrolls settle synchronously, so only smooth
274
+ // programmatic scrolls hold IO updates; scrollend releases early when present.
186
275
  let programmatic = false;
187
276
  let progTimer = null;
277
+ let clearScrollEnd = null;
278
+ let ignoredProgrammaticEntry = null;
279
+ let userScrolledDuringProgrammatic = false;
280
+ const releaseProgrammatic = ({ replay = true } = {}) => {
281
+ const replayIgnored = replay && userScrolledDuringProgrammatic && ignoredProgrammaticEntry;
282
+ programmatic = false;
283
+ if (progTimer) clearTimeout(progTimer);
284
+ progTimer = null;
285
+ clearScrollEnd?.();
286
+ clearScrollEnd = null;
287
+ userScrolledDuringProgrammatic = false;
288
+ const ignored = ignoredProgrammaticEntry;
289
+ ignoredProgrammaticEntry = null;
290
+ if (replayIgnored) {
291
+ const measured = measuredCarouselIndex(viewport, slides);
292
+ if (measured >= 0) syncToIndex(measured);
293
+ else syncFromEntry(ignored);
294
+ }
295
+ };
296
+ const shouldHoldProgrammatic = () => {
297
+ const view = viewport.ownerDocument?.defaultView;
298
+ if (view?.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return false;
299
+ return view?.getComputedStyle?.(viewport).scrollBehavior === 'smooth';
300
+ };
188
301
  const holdProgrammatic = () => {
302
+ if (!shouldHoldProgrammatic()) {
303
+ releaseProgrammatic({ replay: false });
304
+ return;
305
+ }
189
306
  programmatic = true;
307
+ ignoredProgrammaticEntry = null;
308
+ userScrolledDuringProgrammatic = false;
190
309
  if (progTimer) clearTimeout(progTimer);
191
- progTimer = setTimeout(() => {
192
- programmatic = false;
193
- }, 500);
310
+ clearScrollEnd?.();
311
+ const onScrollEnd = () => releaseProgrammatic();
312
+ viewport.addEventListener('scrollend', onScrollEnd, { once: true });
313
+ clearScrollEnd = () => viewport.removeEventListener('scrollend', onScrollEnd);
314
+ progTimer = setTimeout(releaseProgrammatic, 500);
194
315
  progTimer?.unref?.(); // don't keep a Node test process alive
195
316
  };
196
317
 
@@ -209,6 +330,22 @@ export function initCarousel({ root } = {}) {
209
330
 
210
331
  const reveal = (el) => scrollIntoViewSafe(el, { block: 'nearest', inline: 'center' });
211
332
 
333
+ const syncToIndex = (i) => {
334
+ if (i < 0 || i === index) return;
335
+ index = i;
336
+ render();
337
+ reveal(thumbs[index]);
338
+ emit();
339
+ };
340
+
341
+ const syncFromEntry = (entry) => syncToIndex(slides.indexOf(entry.target));
342
+
343
+ const onUserScrollStart = () => {
344
+ if (programmatic) userScrolledDuringProgrammatic = true;
345
+ };
346
+
347
+ const clearProgrammaticHold = () => releaseProgrammatic({ replay: false });
348
+
212
349
  const goTo = (i, { emitChange = true } = {}) => {
213
350
  const next = loop ? (i + n) % n : Math.max(0, Math.min(n - 1, i));
214
351
  const changed = next !== index;
@@ -254,20 +391,13 @@ export function initCarousel({ root } = {}) {
254
391
  if (typeof IntersectionObserver === 'function') {
255
392
  io = new IntersectionObserver(
256
393
  (entries) => {
257
- if (programmatic) return; // ignore the echo of a button/key-driven scroll
258
- let best = null;
259
- for (const ent of entries) {
260
- if (ent.isIntersecting && (!best || ent.intersectionRatio > best.intersectionRatio))
261
- best = ent;
262
- }
394
+ const best = bestCarouselEntry(entries);
263
395
  if (!best) return;
264
- const i = slides.indexOf(best.target);
265
- if (i >= 0 && i !== index) {
266
- index = i;
267
- render();
268
- reveal(thumbs[index]);
269
- emit();
396
+ if (programmatic) {
397
+ ignoredProgrammaticEntry = best;
398
+ return; // ignore the echo of a button/key-driven scroll until release
270
399
  }
400
+ syncFromEntry(best);
271
401
  },
272
402
  { root: viewport, threshold: 0.6 },
273
403
  );
@@ -288,9 +418,9 @@ export function initCarousel({ root } = {}) {
288
418
  onClick,
289
419
  io,
290
420
  holdProgrammatic,
291
- clearProgrammaticTimer: () => {
292
- if (progTimer) clearTimeout(progTimer);
293
- },
421
+ clearProgrammaticTimer: clearProgrammaticHold,
422
+ onUserScrollStart,
423
+ roleDescription,
294
424
  }),
295
425
  );
296
426
  cleanups.push(bound);
@@ -15,7 +15,7 @@
15
15
  * optional `data-value`). An optional `.ui-combobox__empty` (hidden at rest)
16
16
  * shows when nothing matches. The behavior owns ids, `aria-expanded`,
17
17
  * `aria-controls`, `aria-activedescendant`, roving active option,
18
- * type-to-filter, full keyboard (Down/Up/Home/End/Enter/Escape/Tab),
18
+ * type-to-filter, keyboard list navigation (Down/Up/Enter/Escape/Tab),
19
19
  * pointer select, and outside-click close. On select the **visible input shows
20
20
  * the option's text label**, while the emitted `bronto:change` CustomEvent
21
21
  * carries the option's `data-value` code: `{ detail: { value, label } }` (value
@@ -1 +1 @@
1
- {"version":3,"file":"combobox.d.ts","sourceRoot":"","sources":["combobox.js"],"names":[],"mappings":"AAmIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CA6O3C"}
1
+ {"version":3,"file":"combobox.d.ts","sourceRoot":"","sources":["combobox.js"],"names":[],"mappings":"AAkKA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAqO3C"}
@@ -12,6 +12,23 @@ import {
12
12
 
13
13
  const COMBOBOX_OPTION_SELECTOR = '[role="option"], .ui-combobox__option';
14
14
 
15
+ const localeOf = (el) => {
16
+ const locale =
17
+ closestSafe(el, '[lang]')?.getAttribute('lang')?.trim() ||
18
+ el?.ownerDocument?.documentElement?.getAttribute('lang')?.trim();
19
+ return locale || undefined;
20
+ };
21
+
22
+ const lowerForSearch = (value, locale) => {
23
+ const text = String(value ?? '');
24
+ if (!locale) return text.toLowerCase();
25
+ try {
26
+ return text.toLocaleLowerCase(locale);
27
+ } catch {
28
+ return text.toLowerCase();
29
+ }
30
+ };
31
+
15
32
  const snapshotAttrs = (el, names) => {
16
33
  const out = {};
17
34
  for (const name of names) {
@@ -30,14 +47,26 @@ const restoreAttrs = (el, attrs) => {
30
47
  }
31
48
  };
32
49
 
50
+ const labelFromIdRefs = (input) => {
51
+ const refs = input.getAttribute('aria-labelledby')?.trim();
52
+ if (!refs) return '';
53
+ const doc = input.ownerDocument;
54
+ return refs
55
+ .split(/\s+/)
56
+ .map((id) => doc.getElementById(id)?.textContent?.trim())
57
+ .filter(Boolean)
58
+ .join(' ')
59
+ .trim();
60
+ };
61
+
33
62
  const inputLabel = (input) =>
34
- input.getAttribute('aria-label') || input.labels?.[0]?.textContent?.trim();
63
+ labelFromIdRefs(input) ||
64
+ input.getAttribute('aria-label')?.trim() ||
65
+ (input.labels ? [...input.labels].map((label) => label.textContent?.trim()).join(' ') : '') ||
66
+ input.getAttribute('title')?.trim() ||
67
+ '';
35
68
 
36
- const inputHasAccessibleName = (input) =>
37
- input.hasAttribute('aria-label') ||
38
- input.hasAttribute('aria-labelledby') ||
39
- !!input.labels?.length ||
40
- input.hasAttribute('title');
69
+ const inputHasAccessibleName = (input) => !!inputLabel(input);
41
70
 
42
71
  function mirrorListboxLabel(input, list) {
43
72
  if (list.hasAttribute('aria-label') || list.hasAttribute('aria-labelledby')) return;
@@ -83,6 +112,8 @@ function bindComboboxLifecycle({
83
112
  onDocClick,
84
113
  resetActive,
85
114
  }) {
115
+ const doc = box.ownerDocument;
116
+ if (!doc) return noop;
86
117
  const state = rememberState();
87
118
  const listId = assignListId();
88
119
  syncOptions();
@@ -114,7 +145,7 @@ function bindComboboxLifecycle({
114
145
  input.addEventListener('input', onInput);
115
146
  input.addEventListener('keydown', onKey);
116
147
  list.addEventListener('click', onOptionClick);
117
- document.addEventListener('click', onDocClick);
148
+ doc.addEventListener('click', onDocClick);
118
149
  // Opt-in: keep options in sync with a list mutated after init (async /
119
150
  // remote results). Off by default so the common static case stays free.
120
151
  const observer = liveOptionObserver(box, list, relist);
@@ -123,7 +154,7 @@ function bindComboboxLifecycle({
123
154
  input.removeEventListener('input', onInput);
124
155
  input.removeEventListener('keydown', onKey);
125
156
  list.removeEventListener('click', onOptionClick);
126
- document.removeEventListener('click', onDocClick);
157
+ doc.removeEventListener('click', onDocClick);
127
158
  restoreState(state);
128
159
  resetActive();
129
160
  };
@@ -146,7 +177,7 @@ function bindComboboxLifecycle({
146
177
  * optional `data-value`). An optional `.ui-combobox__empty` (hidden at rest)
147
178
  * shows when nothing matches. The behavior owns ids, `aria-expanded`,
148
179
  * `aria-controls`, `aria-activedescendant`, roving active option,
149
- * type-to-filter, full keyboard (Down/Up/Home/End/Enter/Escape/Tab),
180
+ * type-to-filter, keyboard list navigation (Down/Up/Enter/Escape/Tab),
150
181
  * pointer select, and outside-click close. On select the **visible input shows
151
182
  * the option's text label**, while the emitted `bronto:change` CustomEvent
152
183
  * carries the option's `data-value` code: `{ detail: { value, label } }` (value
@@ -181,6 +212,8 @@ export function initCombobox({ root } = {}) {
181
212
  const empty = box.querySelector('.ui-combobox__empty');
182
213
  const optionStates = new WeakMap();
183
214
  let listId = '';
215
+ const optionIdBase = `bronto-cb-opt-${nextFieldUid()}`;
216
+ const locale = localeOf(box);
184
217
 
185
218
  const rememberOptionState = (option) => {
186
219
  if (optionStates.has(option)) return;
@@ -202,7 +235,7 @@ export function initCombobox({ root } = {}) {
202
235
  ]),
203
236
  list: {
204
237
  hidden: list.hidden,
205
- attrs: snapshotAttrs(list, ['id', 'role', 'aria-label']),
238
+ attrs: snapshotAttrs(list, ['id', 'role', 'aria-label', 'aria-labelledby']),
206
239
  },
207
240
  empty: empty
208
241
  ? {
@@ -238,7 +271,7 @@ export function initCombobox({ root } = {}) {
238
271
  options = [...list.querySelectorAll(COMBOBOX_OPTION_SELECTOR)];
239
272
  options.forEach((o, i) => {
240
273
  rememberOptionState(o);
241
- if (!o.id) o.id = `${listId}-opt-${i}`;
274
+ if (!o.id) o.id = `${optionIdBase}-${i}`;
242
275
  o.setAttribute('role', 'option');
243
276
  });
244
277
  };
@@ -270,10 +303,10 @@ export function initCombobox({ root } = {}) {
270
303
  };
271
304
 
272
305
  const filter = () => {
273
- const q = input.value.trim().toLowerCase();
306
+ const q = lowerForSearch(input.value.trim(), locale);
274
307
  let any = false;
275
308
  for (const o of options) {
276
- const match = !q || o.textContent.toLowerCase().includes(q);
309
+ const match = !q || lowerForSearch(o.textContent, locale).includes(q);
277
310
  o.hidden = !match;
278
311
  if (match) any = true;
279
312
  }
@@ -313,14 +346,6 @@ export function initCombobox({ root } = {}) {
313
346
  active = options.indexOf(vis[next]);
314
347
  setActive(options[active]);
315
348
  };
316
- const activateEdge = (which) => {
317
- if (list.hidden) return false;
318
- const v = visible();
319
- if (!v.length) return true;
320
- active = options.indexOf(which === 'first' ? v[0] : v[v.length - 1]);
321
- setActive(options[active]);
322
- return true;
323
- };
324
349
  const selectActive = () => {
325
350
  if (list.hidden || active < 0 || options[active].hidden) return false;
326
351
  select(options[active]);
@@ -356,8 +381,6 @@ export function initCombobox({ root } = {}) {
356
381
  move(-1);
357
382
  return true;
358
383
  },
359
- Home: () => activateEdge('first'),
360
- End: () => activateEdge('last'),
361
384
  Enter: () => selectActive(),
362
385
  Escape: () => closeIfOpen(),
363
386
  Tab: () => {
@@ -16,7 +16,7 @@
16
16
  * `data-value`), interleaved with `.ui-command__group` labels and an optional
17
17
  * `.ui-command__empty`. The behavior owns ids, `role=combobox/listbox/option`,
18
18
  * `aria-activedescendant`, a roving active item, substring filtering (hiding
19
- * empty groups), full keyboard (Down/Up/Home/End/Enter/Escape), and pointer
19
+ * empty groups), keyboard list navigation (Down/Up/Enter/Escape), and pointer
20
20
  * select. It emits `bronto:command:select` ({ detail: { value, label } }) on
21
21
  * choose and `bronto:command:close` on Escape. SSR-safe, idempotent per
22
22
  * instance; returns a cleanup function.
@@ -1 +1 @@
1
- {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["command.js"],"names":[],"mappings":"AAYA;;;;GAIG;AAEH;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,uCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CA0N3C;;;;;WApPa,MAAM;;;;WACN,MAAM"}
1
+ {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["command.js"],"names":[],"mappings":"AA6BA;;;;GAIG;AAEH;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,uCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAiP3C;;;;;WA3Qa,MAAM;;;;WACN,MAAM"}
@@ -10,6 +10,23 @@ import {
10
10
  closestSafe,
11
11
  } from './internal.js';
12
12
 
13
+ const localeOf = (el) => {
14
+ const locale =
15
+ closestSafe(el, '[lang]')?.getAttribute('lang')?.trim() ||
16
+ el?.ownerDocument?.documentElement?.getAttribute('lang')?.trim();
17
+ return locale || undefined;
18
+ };
19
+
20
+ const lowerForSearch = (value, locale) => {
21
+ const text = String(value ?? '');
22
+ if (!locale) return text.toLowerCase();
23
+ try {
24
+ return text.toLocaleLowerCase(locale);
25
+ } catch {
26
+ return text.toLowerCase();
27
+ }
28
+ };
29
+
13
30
  /**
14
31
  * @typedef {object} CommandSelectDetail
15
32
  * @property {string} value The chosen command's value.
@@ -29,7 +46,7 @@ import {
29
46
  * `data-value`), interleaved with `.ui-command__group` labels and an optional
30
47
  * `.ui-command__empty`. The behavior owns ids, `role=combobox/listbox/option`,
31
48
  * `aria-activedescendant`, a roving active item, substring filtering (hiding
32
- * empty groups), full keyboard (Down/Up/Home/End/Enter/Escape), and pointer
49
+ * empty groups), keyboard list navigation (Down/Up/Enter/Escape), and pointer
33
50
  * select. It emits `bronto:command:select` ({ detail: { value, label } }) on
34
51
  * choose and `bronto:command:close` on Escape. SSR-safe, idempotent per
35
52
  * instance; returns a cleanup function.
@@ -65,6 +82,31 @@ export function initCommand({ root } = {}) {
65
82
  }
66
83
  };
67
84
 
85
+ const firstTextNode = (el) => {
86
+ for (const node of el.childNodes) {
87
+ if (node.nodeType === 3 && node.nodeValue.trim()) return node;
88
+ if (node.nodeType === 1) {
89
+ const child = firstTextNode(node);
90
+ if (child) return child;
91
+ }
92
+ }
93
+ return null;
94
+ };
95
+
96
+ const refreshLiveText = (el) => {
97
+ const node = firstTextNode(el);
98
+ if (!node) return;
99
+ const text = node.nodeValue;
100
+ node.nodeValue = '';
101
+ node.nodeValue = text;
102
+ };
103
+
104
+ const prepareEmptyState = (el) => {
105
+ if (!el) return;
106
+ el.setAttribute('role', 'status');
107
+ el.setAttribute('aria-live', 'polite');
108
+ };
109
+
68
110
  for (const box of palettes) {
69
111
  const input = box.querySelector('.ui-command__input, input');
70
112
  const list = box.querySelector('.ui-command__list, [role="listbox"]');
@@ -72,6 +114,7 @@ export function initCommand({ root } = {}) {
72
114
  const empty = box.querySelector('.ui-command__empty');
73
115
  const items = [...list.querySelectorAll('.ui-command__item, [role="option"]')];
74
116
  const groups = [...list.querySelectorAll('.ui-command__group')];
117
+ const locale = localeOf(box);
75
118
 
76
119
  const rememberState = () => ({
77
120
  input: snapshotAttrs(input, [
@@ -83,7 +126,12 @@ export function initCommand({ root } = {}) {
83
126
  'autocomplete',
84
127
  ]),
85
128
  list: snapshotAttrs(list, ['id', 'role']),
86
- empty: empty ? { hidden: empty.hidden } : null,
129
+ empty: empty
130
+ ? {
131
+ hidden: empty.hidden,
132
+ attrs: snapshotAttrs(empty, ['role', 'aria-live']),
133
+ }
134
+ : null,
87
135
  groups: groups.map((g) => ({
88
136
  el: g,
89
137
  hidden: g.hidden,
@@ -100,7 +148,10 @@ export function initCommand({ root } = {}) {
100
148
  const restoreState = (state) => {
101
149
  restoreAttrs(input, state.input);
102
150
  restoreAttrs(list, state.list);
103
- if (empty && state.empty) empty.hidden = state.empty.hidden;
151
+ if (empty && state.empty) {
152
+ empty.hidden = state.empty.hidden;
153
+ restoreAttrs(empty, state.empty.attrs);
154
+ }
104
155
  for (const group of state.groups) {
105
156
  group.el.hidden = group.hidden;
106
157
  restoreAttrs(group.el, group.attrs);
@@ -146,15 +197,18 @@ export function initCommand({ root } = {}) {
146
197
  };
147
198
 
148
199
  const filter = () => {
149
- const q = input.value.trim().toLowerCase();
200
+ const q = lowerForSearch(input.value.trim(), locale);
150
201
  let any = false;
151
202
  for (const it of items) {
152
- const match = !q || it.textContent.toLowerCase().includes(q);
203
+ const match = !q || lowerForSearch(it.textContent, locale).includes(q);
153
204
  it.hidden = !match;
154
205
  if (match) any = true;
155
206
  }
156
207
  syncGroups();
157
- if (empty) empty.hidden = any;
208
+ if (empty) {
209
+ empty.hidden = any;
210
+ if (!any) refreshLiveText(empty);
211
+ }
158
212
  const vis = visible();
159
213
  setActive(vis[0] || null);
160
214
  };
@@ -191,22 +245,6 @@ export function initCommand({ root } = {}) {
191
245
  e.preventDefault();
192
246
  move(-1);
193
247
  break;
194
- case 'Home': {
195
- const v = visible();
196
- if (v.length) {
197
- setActive(v[0]);
198
- e.preventDefault();
199
- }
200
- break;
201
- }
202
- case 'End': {
203
- const v = visible();
204
- if (v.length) {
205
- setActive(v[v.length - 1]);
206
- e.preventDefault();
207
- }
208
- break;
209
- }
210
248
  case 'Enter':
211
249
  if (active >= 0 && !items[active].hidden) {
212
250
  choose(items[active]);
@@ -228,12 +266,14 @@ export function initCommand({ root } = {}) {
228
266
  const bound = bindOnce(box, 'command', () => {
229
267
  const state = rememberState();
230
268
  const listId = list.id || (list.id = `bronto-cmd-${nextFieldUid()}`);
269
+ const optionIdBase = `bronto-cmd-opt-${nextFieldUid()}`;
231
270
  items.forEach((it, i) => {
232
- if (!it.id) it.id = `${listId}-opt-${i}`;
271
+ if (!it.id) it.id = `${optionIdBase}-${i}`;
233
272
  it.setAttribute('role', 'option');
234
273
  });
235
274
  groups.forEach((g) => g.setAttribute('role', 'presentation'));
236
275
  list.setAttribute('role', 'listbox');
276
+ prepareEmptyState(empty);
237
277
  input.setAttribute('role', 'combobox');
238
278
  input.setAttribute('aria-controls', listId);
239
279
  input.setAttribute('aria-autocomplete', 'list');
@@ -1 +1 @@
1
- {"version":3,"file":"connectors.d.ts","sourceRoot":"","sources":["connectors.js"],"names":[],"mappings":"AA+EA;;;;;;;;;;;;;;GAcG;AACH,0CAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAwG3C"}
1
+ {"version":3,"file":"connectors.d.ts","sourceRoot":"","sources":["connectors.js"],"names":[],"mappings":"AAuHA;;;;;;;;;;;;;;GAcG;AACH,0CAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CA2K3C"}