@juspay/svelte-ui-components 4.51.0 → 4.52.1

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 (96) hide show
  1. package/dist/Animations/tokenizedFly.js +9 -2
  2. package/dist/AreaChart/AreaChart.svelte +41 -13
  3. package/dist/AreaChart/properties.d.ts +12 -0
  4. package/dist/AttachmentChipRow/AttachmentChipRow.svelte +7 -6
  5. package/dist/BarChart/BarChart.svelte +54 -6
  6. package/dist/BarChart/properties.d.ts +12 -0
  7. package/dist/Calendar/Calendar.svelte +124 -51
  8. package/dist/Card/Card.svelte +5 -0
  9. package/dist/ChatSuggestions/ChatSuggestions.svelte +1 -0
  10. package/dist/CheckListItem/CheckListItem.svelte +27 -5
  11. package/dist/ColorPicker/ColorPicker.svelte +40 -9
  12. package/dist/ColorPicker/properties.d.ts +8 -0
  13. package/dist/Combobox/Combobox.svelte +34 -2
  14. package/dist/CommandMenu/CommandMenu.svelte +35 -8
  15. package/dist/CommandMenu/properties.d.ts +6 -0
  16. package/dist/DateRangePicker/DateRangePicker.svelte +5 -5
  17. package/dist/DualAxisBarChart/DualAxisBarChart.svelte +41 -7
  18. package/dist/DualAxisBarChart/properties.d.ts +4 -0
  19. package/dist/FileDropzoneTrigger/FileDropzoneTrigger.svelte +104 -10
  20. package/dist/FileDropzoneTrigger/properties.d.ts +14 -4
  21. package/dist/FileInput/FileInput.svelte +38 -9
  22. package/dist/FileInput/context.d.ts +20 -0
  23. package/dist/FileInput/context.js +8 -0
  24. package/dist/FileInput/properties.d.ts +25 -1
  25. package/dist/FunnelChart/FunnelChart.svelte +20 -1
  26. package/dist/FunnelChart/properties.d.ts +4 -0
  27. package/dist/Gallery/Gallery.svelte +72 -27
  28. package/dist/HITL/HITL.svelte +64 -14
  29. package/dist/HITL/properties.d.ts +5 -0
  30. package/dist/LineChart/LineChart.svelte +105 -33
  31. package/dist/LineChart/properties.d.ts +12 -0
  32. package/dist/MediaPlayer/MediaPlayer.svelte +172 -2
  33. package/dist/MediaPlayer/MediaPlayer.svelte.d.ts +1 -1
  34. package/dist/MediaPlayer/properties.d.ts +31 -0
  35. package/dist/Menu/Menu.svelte +6 -4
  36. package/dist/Modal/Modal.svelte +15 -12
  37. package/dist/Modal/properties.d.ts +29 -1
  38. package/dist/Pagination/Pagination.svelte +1 -0
  39. package/dist/PieChart/PieChart.svelte +51 -29
  40. package/dist/PieChart/properties.d.ts +4 -0
  41. package/dist/Pill/Pill.svelte +5 -0
  42. package/dist/Radio/Radio.svelte +11 -2
  43. package/dist/Radio/properties.d.ts +7 -2
  44. package/dist/SankeyChart/SankeyChart.svelte +339 -211
  45. package/dist/SankeyChart/properties.d.ts +4 -0
  46. package/dist/Scroller/Scroller.svelte +236 -4
  47. package/dist/Scroller/properties.d.ts +8 -0
  48. package/dist/Select/Select.svelte +46 -8
  49. package/dist/Select/properties.d.ts +17 -1
  50. package/dist/Sheet/Sheet.svelte +31 -4
  51. package/dist/Sheet/properties.d.ts +6 -0
  52. package/dist/SplitInput/SplitInput.svelte +17 -0
  53. package/dist/SplitInput/field-name.d.ts +29 -0
  54. package/dist/SplitInput/field-name.js +50 -0
  55. package/dist/SplitInput/properties.d.ts +18 -4
  56. package/dist/Status/Status.svelte +24 -12
  57. package/dist/Status/properties.d.ts +11 -0
  58. package/dist/Table/BuiltinCell.svelte +13 -5
  59. package/dist/Table/BuiltinCell.svelte.d.ts +1 -0
  60. package/dist/Table/Table.svelte +78 -20
  61. package/dist/Table/a11y.d.ts +7 -0
  62. package/dist/Table/a11y.js +71 -0
  63. package/dist/Table/cellData.js +3 -0
  64. package/dist/Table/properties.d.ts +13 -5
  65. package/dist/ThemeSwitcher/ThemeSwitcher.svelte +4 -0
  66. package/dist/ThinkingIndicator/ThinkingIndicator.svelte +48 -9
  67. package/dist/Toast/Toast.svelte +70 -4
  68. package/dist/Toggle/Toggle.svelte +2 -0
  69. package/dist/Toggle/properties.d.ts +2 -0
  70. package/dist/_chart/Axis.svelte +10 -0
  71. package/dist/_chart/ChartContainer.svelte +32 -1
  72. package/dist/_chart/ChartTooltip.svelte +18 -3
  73. package/dist/_chart/LabelPlate.svelte +116 -0
  74. package/dist/_chart/LabelPlate.svelte.d.ts +7 -0
  75. package/dist/_chart/a11y.d.ts +29 -0
  76. package/dist/_chart/a11y.js +44 -0
  77. package/dist/_chart/measure.d.ts +2 -0
  78. package/dist/_chart/measure.js +4 -0
  79. package/dist/_chart/tooltipPosition.d.ts +6 -0
  80. package/dist/_chart/tooltipPosition.js +30 -2
  81. package/dist/_chart/types.d.ts +22 -0
  82. package/dist/_interaction/focusable.d.ts +47 -0
  83. package/dist/_interaction/focusable.js +139 -0
  84. package/dist/assets/captions-on.svg +3 -0
  85. package/dist/assets/captions.svg +5 -0
  86. package/dist/index.d.ts +1 -1
  87. package/dist/index.js +1 -1
  88. package/dist/styles/theme-dark.css +9 -1
  89. package/dist/types.d.ts +2 -0
  90. package/dist/utils.d.ts +18 -2
  91. package/dist/utils.js +73 -28
  92. package/dist-codemod/codemod/migrate-audit.js +9 -7
  93. package/dist-codemod/codemod/migration-assets/legacy-palette.css +9 -1
  94. package/dist-codemod/codemod/migration-assets-layout.js +61 -0
  95. package/dist-wc/index.js +5872 -4333
  96. package/package.json +4 -2
@@ -64,6 +64,10 @@ export type OptionalSankeyChartProperties = {
64
64
  */
65
65
  tooltipPortal?: boolean;
66
66
  empty?: Snippet;
67
+ /** Accessible name of the drawing; omit to derive one from the plotted data. */
68
+ ariaLabel?: string;
69
+ /** Longer chart description, resolved inside the drawing's own DOM/shadow root. */
70
+ ariaDescription?: string;
67
71
  testId?: string;
68
72
  classes?: string;
69
73
  /** Labels rendered above each column, indexed by column position (0-based). */
@@ -3,6 +3,12 @@
3
3
  import { prefersReducedMotion } from '../utils';
4
4
  import type { ScrollerProperties, ScrollPosition } from './properties';
5
5
  import Button from '../Button/Button.svelte';
6
+ import {
7
+ contentElements,
8
+ composedParent,
9
+ hasTabbableContent,
10
+ interactiveElements
11
+ } from '../_interaction/focusable';
6
12
  import chevronLeft from '../assets/chevron-left.svg?raw';
7
13
  import chevronRight from '../assets/chevron-right.svg?raw';
8
14
  import chevronUp from '../assets/chevron-up.svg?raw';
@@ -23,7 +29,8 @@
23
29
  arrowPrevious,
24
30
  arrowNext,
25
31
  onscrollposition,
26
- classes
32
+ classes,
33
+ ariaLabel
27
34
  }: ScrollerProperties = $props();
28
35
 
29
36
  let containerEl: HTMLDivElement | null = $state(null);
@@ -31,9 +38,38 @@
31
38
  let canScrollNext = $state(false);
32
39
  let isTouchDevice = $state(false);
33
40
  let isDragging = $state(false);
41
+ let isOverflowing = $state(false);
42
+ let contentIsTabbable = $state(false);
34
43
  let dragStartPos = 0;
35
44
  let dragScrollStart = 0;
36
45
  let resizeObserver: ResizeObserver | null = null;
46
+ let mutationObserver: MutationObserver | null = null;
47
+ let refreshFrame: number | null = null;
48
+ /** Whether the frame already scheduled must also re-point the observers, not just re-scan. */
49
+ let refreshRebinds = false;
50
+
51
+ /** Pixels one arrow-key press scrolls, close to what a browser's own line scroll does. */
52
+ const KEY_SCROLL_STEP = 40;
53
+
54
+ /**
55
+ * Attributes whose change can add or remove a Tab stop inside the content. `class` and
56
+ * `style` are here because showing or hiding a control (`display: none`, `visibility`)
57
+ * is almost always done through one of them, and that changes whether Tab can reach it
58
+ * without resizing anything the resize observers watch: a control hidden inside a
59
+ * fixed-width child leaves every box around it exactly as large as before.
60
+ */
61
+ const TAB_ORDER_ATTRIBUTES = [
62
+ 'tabindex',
63
+ 'disabled',
64
+ 'hidden',
65
+ 'inert',
66
+ 'href',
67
+ 'contenteditable',
68
+ 'controls',
69
+ 'open',
70
+ 'class',
71
+ 'style'
72
+ ];
37
73
 
38
74
  function getScrollProps(el: HTMLElement) {
39
75
  if (direction === 'horizontal') {
@@ -57,6 +93,7 @@
57
93
  const { scrollOffset, scrollSize, clientSize } = getScrollProps(containerEl);
58
94
  canScrollPrev = scrollOffset > 1;
59
95
  canScrollNext = scrollOffset < scrollSize - clientSize - 1;
96
+ isOverflowing = scrollSize - clientSize > 1;
60
97
 
61
98
  if (typeof onscrollposition === 'function') {
62
99
  const maxScroll = scrollSize - clientSize;
@@ -95,6 +132,54 @@
95
132
  scrollBy(1);
96
133
  }
97
134
 
135
+ /**
136
+ * Arrow-key scrolling for the scroll region itself, on its own axis only.
137
+ *
138
+ * Browsers scroll a focused overflow element with the arrow keys, but not uniformly
139
+ * across engines and axes: in WebKit 26.4 a focused vertical region did not respond to
140
+ * ArrowUp/ArrowDown at all, and the distance and timing of the ones that do scroll differ.
141
+ * Handling the axis keys here makes the keyboard route do the same thing everywhere.
142
+ * Only the keys for this component's own axis are taken, so the other axis still scrolls
143
+ * the page, and only when the region itself holds focus -- a control inside the content
144
+ * keeps every key it already handles.
145
+ */
146
+ function handleRegionKeydown(event: KeyboardEvent) {
147
+ if (
148
+ containerEl === null ||
149
+ event.target !== containerEl ||
150
+ event.defaultPrevented ||
151
+ event.altKey ||
152
+ event.ctrlKey ||
153
+ event.metaKey ||
154
+ event.shiftKey
155
+ ) {
156
+ return;
157
+ }
158
+ const [backwardKey, forwardKey] =
159
+ direction === 'horizontal' ? ['ArrowLeft', 'ArrowRight'] : ['ArrowUp', 'ArrowDown'];
160
+ if (event.key !== backwardKey && event.key !== forwardKey) {
161
+ return;
162
+ }
163
+ event.preventDefault();
164
+ // A mandatory snap point swallows a 40px nudge -- the region settles back where it
165
+ // started -- so snapping regions move by the same amount the arrow buttons do.
166
+ const { clientSize } = getScrollProps(containerEl);
167
+ const step = snapToItem ? (scrollAmount ?? clientSize) : KEY_SCROLL_STEP;
168
+ const delta = event.key === forwardKey ? step : -step;
169
+ // Immediate, not animated: a smooth scrollBy measures from wherever the previous
170
+ // animation currently is, so held or rapid presses fell short of their total in
171
+ // Firefox (four presses landed at about 115px instead of 160px) while Chromium
172
+ // reached it. A jump keeps the distance exact in every engine, and is also what the
173
+ // OS reduced-motion setting asks for.
174
+ const options: ScrollToOptions = { behavior: 'instant' };
175
+ if (direction === 'horizontal') {
176
+ options.left = delta;
177
+ } else {
178
+ options.top = delta;
179
+ }
180
+ containerEl.scrollBy(options);
181
+ }
182
+
98
183
  function handleDragStart(event: MouseEvent) {
99
184
  if (!dragToScroll || containerEl === null) {
100
185
  return;
@@ -151,14 +236,131 @@
151
236
  containerEl.style.userSelect = '';
152
237
  }
153
238
 
239
+ /**
240
+ * Re-points both observers at what the region currently contains. The region's own size
241
+ * is not the only thing that decides overflow: content that grows, shrinks or is swapped
242
+ * changes `scrollWidth` without resizing the region, so each piece of content is watched
243
+ * too. Through `<sui-scroller>` the content is slotted light-DOM markup that a mutation
244
+ * observer on the region cannot see, hence `contentElements` and the per-element
245
+ * subtree observers.
246
+ */
247
+ function observeContent() {
248
+ if (containerEl === null || resizeObserver === null || mutationObserver === null) {
249
+ return;
250
+ }
251
+ resizeObserver.disconnect();
252
+ mutationObserver.disconnect();
253
+ resizeObserver.observe(containerEl);
254
+ mutationObserver.observe(containerEl, { childList: true, subtree: true, characterData: true });
255
+ // An enclosing fieldset can disable the only child control without changing
256
+ // any content box or any attribute inside the region (including slotted WCs).
257
+ for (
258
+ let ancestor = composedParent(containerEl);
259
+ ancestor;
260
+ ancestor = composedParent(ancestor)
261
+ ) {
262
+ mutationObserver.observe(ancestor, {
263
+ attributes: true,
264
+ childList: true,
265
+ attributeFilter: TAB_ORDER_ATTRIBUTES
266
+ });
267
+ }
268
+ for (const element of contentElements(containerEl)) {
269
+ resizeObserver.observe(element);
270
+ mutationObserver.observe(element, {
271
+ childList: true,
272
+ subtree: true,
273
+ characterData: true,
274
+ attributes: true,
275
+ attributeFilter: TAB_ORDER_ATTRIBUTES
276
+ });
277
+ }
278
+ // A stylesheet can hide or show a control with no write to the content and no change to
279
+ // any box above: a class on an ancestor outside this component, a media query. The
280
+ // control itself is the one thing that always changes then -- its box goes to nothing
281
+ // or comes back -- so each one is watched too, including those hidden right now.
282
+ for (const control of interactiveElements(containerEl, { includeHidden: true })) {
283
+ resizeObserver.observe(control);
284
+ }
285
+ }
286
+
287
+ /** Whether anything inside the content is reachable with Tab right now. */
288
+ function scanTabbableContent() {
289
+ if (containerEl !== null) {
290
+ contentIsTabbable = hasTabbableContent(containerEl);
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Content was added, removed or swapped: re-aim the observers at what is there now,
296
+ * then re-measure and re-scan for Tab stops.
297
+ */
298
+ function refreshContent() {
299
+ if (containerEl === null) {
300
+ return;
301
+ }
302
+ observeContent();
303
+ updateScrollState();
304
+ scanTabbableContent();
305
+ }
306
+
307
+ /**
308
+ * Content kept its structure but may have changed size or visibility -- a class or
309
+ * inline style hid or showed a control, a media query flipped, the viewport moved.
310
+ * Re-measures overflow and re-scans Tab stops without re-aiming the observers, which is
311
+ * the expensive part and unnecessary here. Deliberately does not report a scroll position:
312
+ * nothing scrolled, and a content element animating its style would otherwise make
313
+ * `onscrollposition` fire every frame.
314
+ */
315
+ function rescanTabOrder() {
316
+ if (containerEl === null) {
317
+ return;
318
+ }
319
+ const { scrollSize, clientSize } = getScrollProps(containerEl);
320
+ isOverflowing = scrollSize - clientSize > 1;
321
+ scanTabbableContent();
322
+ }
323
+
324
+ /** One refresh per frame however many mutations arrive; a structural one wins over a rescan. */
325
+ function scheduleRefresh(rebind: boolean) {
326
+ refreshRebinds ||= rebind;
327
+ if (refreshFrame !== null) {
328
+ return;
329
+ }
330
+ refreshFrame = requestAnimationFrame(() => {
331
+ refreshFrame = null;
332
+ const rebinds = refreshRebinds;
333
+ refreshRebinds = false;
334
+ if (rebinds) {
335
+ refreshContent();
336
+ } else {
337
+ rescanTabOrder();
338
+ }
339
+ });
340
+ }
341
+
342
+ const handleSlotChange = () => scheduleRefresh(true);
343
+ // A media query can show or hide a control with no change to the DOM or to any box the
344
+ // observers watch, so a viewport change is its own trigger.
345
+ const handleWindowResize = () => scheduleRefresh(false);
346
+
154
347
  onMount(() => {
155
348
  isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
156
349
  if (containerEl !== null) {
157
- updateScrollState();
158
350
  resizeObserver = new ResizeObserver(() => {
351
+ // Content that resizes can fit or overflow, and can also be a control that was
352
+ // hidden and is now back, so a resize re-scans the Tab order as well as the offsets.
159
353
  updateScrollState();
354
+ scanTabbableContent();
160
355
  });
161
- resizeObserver.observe(containerEl);
356
+ mutationObserver = new MutationObserver((records) => {
357
+ scheduleRefresh(records.some((record) => record.type !== 'attributes'));
358
+ });
359
+ refreshContent();
360
+ // Slot assignment changes (a `sui-scroller` whose slotted content is replaced)
361
+ // bubble up from the slot element that the web-component wrapper renders.
362
+ containerEl.addEventListener('slotchange', handleSlotChange);
363
+ window.addEventListener('resize', handleWindowResize);
162
364
 
163
365
  if (dragToScroll) {
164
366
  window.addEventListener('mousemove', handleDragMove);
@@ -167,6 +369,13 @@
167
369
  }
168
370
  return () => {
169
371
  resizeObserver?.disconnect();
372
+ mutationObserver?.disconnect();
373
+ containerEl?.removeEventListener('slotchange', handleSlotChange);
374
+ window.removeEventListener('resize', handleWindowResize);
375
+ if (refreshFrame !== null) {
376
+ cancelAnimationFrame(refreshFrame);
377
+ refreshFrame = null;
378
+ }
170
379
  if (dragToScroll) {
171
380
  window.removeEventListener('mousemove', handleDragMove);
172
381
  window.removeEventListener('mouseup', handleDragEnd);
@@ -176,6 +385,17 @@
176
385
 
177
386
  let showArrowControls = $derived(showArrows && !(hideArrowsOnTouch && isTouchDevice));
178
387
  let showGradientOverlay = $derived(showGradient && !(hideArrowsOnTouch && isTouchDevice));
388
+
389
+ /**
390
+ * The region is a Tab stop only while it is the sole keyboard route to its content:
391
+ * it overflows, no arrow buttons are rendered to scroll it, and nothing inside it is
392
+ * focusable already (focus moving to such a child scrolls it into view by itself).
393
+ * Anything else would add a stop that leads nowhere, or one more stop before the
394
+ * controls that already work.
395
+ */
396
+ let regionIsKeyboardRoute = $derived(isOverflowing && !showArrowControls && !contentIsTabbable);
397
+ // A focusable region must be named; a region the consumer named keeps that name always.
398
+ let regionLabel = $derived(ariaLabel ?? (regionIsKeyboardRoute ? 'Scrollable content' : null));
179
399
  </script>
180
400
 
181
401
  <div
@@ -209,6 +429,7 @@
209
429
  {/if}
210
430
 
211
431
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
432
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
212
433
  <div
213
434
  class="scroll-container"
214
435
  class:hide-scrollbar={hideScrollbar}
@@ -217,8 +438,10 @@
217
438
  bind:this={containerEl}
218
439
  onscroll={updateScrollState}
219
440
  onmousedown={dragToScroll ? handleDragStart : null}
441
+ onkeydown={handleRegionKeydown}
220
442
  role="region"
221
- tabindex="-1"
443
+ aria-label={regionLabel}
444
+ tabindex={regionIsKeyboardRoute ? 0 : -1}
222
445
  >
223
446
  {@render children()}
224
447
  </div>
@@ -348,6 +571,15 @@
348
571
  cursor: grabbing;
349
572
  }
350
573
 
574
+ /* :focus-visible so a pointer press on the region does not ring it, while the Tab stop
575
+ it becomes when nothing else can scroll it always does. Inset by default: the ring
576
+ sits inside the region's own box, so it is not cut off by an `overflow: hidden`
577
+ ancestor the way an outset ring would be. */
578
+ .scroll-container:focus-visible {
579
+ outline: var(--scroller-focus-outline, 2px solid #2563eb);
580
+ outline-offset: var(--scroller-focus-outline-offset, -2px);
581
+ }
582
+
351
583
  .arrow {
352
584
  display: flex;
353
585
  align-items: center;
@@ -24,6 +24,14 @@ export type OptionalScrollerProperties = {
24
24
  arrowPrevious?: Snippet;
25
25
  arrowNext?: Snippet;
26
26
  classes?: string;
27
+ /**
28
+ * Accessible name for the scroll region. While the region is the only keyboard route to
29
+ * overflowing content (no arrows, nothing focusable inside) it is a Tab stop and is named
30
+ * `Scrollable content` unless this says otherwise; a region given a name keeps it whether
31
+ * or not it is currently a Tab stop. Prefer a name that says what scrolls, e.g.
32
+ * `Release timeline`.
33
+ */
34
+ ariaLabel?: string;
27
35
  };
28
36
  export type ScrollerEventProperties = {
29
37
  onscrollposition?: (position: ScrollPosition) => void;
@@ -18,6 +18,7 @@
18
18
  searchPosition = 'trigger',
19
19
  placeholder = '',
20
20
  ariaLabel,
21
+ ariaLabelledby,
21
22
  disabled = false,
22
23
  error = false,
23
24
  errorMessage,
@@ -76,7 +77,7 @@
76
77
  let placementRequest: SelectPlacement = $derived(
77
78
  placement ?? (dropdownAlign === 'right' ? 'bottom-right' : 'bottom-left')
78
79
  );
79
- let placementPinned: boolean = $derived(placement != null);
80
+ let placementPinned: boolean = $derived(placement != null && placement !== 'auto');
80
81
 
81
82
  /** Corner an `'auto'` measuring pass settled on; null until it has run. */
82
83
  let autoResolved: Exclude<SelectPlacement, 'auto'> | null = $state(null);
@@ -161,6 +162,22 @@
161
162
  })
162
163
  );
163
164
 
165
+ /* The field's name, applied to every element a user can land on: the combobox
166
+ trigger, the text input that replaces its label when the filter lives in the
167
+ trigger (which is the control that actually takes focus there, and would
168
+ otherwise be named only by its placeholder), and the listbox they open.
169
+ Blank strings are dropped rather than emitted, so `aria-label=""` can never
170
+ erase the name an element would otherwise get. The component API gives
171
+ `ariaLabel` priority over `ariaLabelledby`, following Slider's contract. */
172
+ const accessibleName = $derived(
173
+ typeof ariaLabel === 'string' && ariaLabel.trim() !== '' ? ariaLabel : null
174
+ );
175
+ const accessibleLabelledby = $derived(
176
+ accessibleName === null && typeof ariaLabelledby === 'string' && ariaLabelledby.trim() !== ''
177
+ ? ariaLabelledby.trim()
178
+ : null
179
+ );
180
+
164
181
  /* Nothing selected means nothing to clear, so the control is not rendered at
165
182
  all rather than rendered inert -- a disabled × that never enables reads as
166
183
  broken. Same for `disabled`, where the whole field is inert already. */
@@ -316,8 +333,8 @@
316
333
  dropdown: { width: dropdownWidth, height: dropdownHeight },
317
334
  viewport,
318
335
  align: placementAlign,
319
- /* Unset `placement` keeps the fit-based flip this path always had; a pinned
320
- corner is honoured on both axes instead. */
336
+ /* Unset and explicit 'auto' placements keep the fit-based flip and viewport
337
+ clamp; only a caller-pinned corner overrides the vertical fit. */
321
338
  vertical: placementPinned
322
339
  ? resolvedPlacement === 'top-left' || resolvedPlacement === 'top-right'
323
340
  ? 'top'
@@ -680,7 +697,19 @@
680
697
  return;
681
698
  }
682
699
  query = event.target.value;
683
- if (!open) {
700
+ /* An input event is only a request to open when the field it came from can be
701
+ typed into while the panel is closed -- the in-trigger input, whose typing
702
+ is how a closed Select opens. The in-menu search box exists only while the
703
+ panel is open, so an event arriving from it with `open` already false is
704
+ not the user typing: it is the browser clearing the field as Escape
705
+ dismisses the panel. Firefox does that for an <input type="search"> and
706
+ dispatches the input event synchronously inside the same keydown, before
707
+ Svelte has flushed the close to the DOM -- so honouring it re-opened the
708
+ panel that Escape had just closed (and fired onopen straight after
709
+ onclose), leaving aria-expanded true and the next trigger click toggling
710
+ it shut. Chromium clears after the flush and WebKit does not clear at all,
711
+ which is why only Firefox showed it. */
712
+ if (!open && !searchInMenu) {
684
713
  openDropdown();
685
714
  }
686
715
  highlightedIndex = -1;
@@ -755,10 +784,12 @@
755
784
  onclick={handleTriggerClick}
756
785
  onkeydown={handleKeydown}
757
786
  role="combobox"
758
- aria-label={ariaLabel}
759
787
  aria-expanded={open}
760
788
  aria-haspopup="listbox"
761
789
  aria-controls={listboxId}
790
+ aria-label={accessibleName}
791
+ aria-labelledby={accessibleLabelledby}
792
+ aria-disabled={disabled ? 'true' : null}
762
793
  aria-invalid={field.ariaInvalid}
763
794
  aria-describedby={field.describedBy}
764
795
  {...highlightedOptionId !== null ? { 'aria-activedescendant': highlightedOptionId } : {}}
@@ -781,13 +812,14 @@
781
812
  <input
782
813
  class="select-search"
783
814
  type="text"
784
- aria-label={ariaLabel}
785
815
  value={query}
786
816
  oninput={handleSearchInput}
787
817
  onfocus={handleSearchFocus}
788
818
  bind:this={searchInputEl}
789
819
  placeholder={value.length === 0 ? placeholder : ''}
790
820
  {disabled}
821
+ aria-label={accessibleName}
822
+ aria-labelledby={accessibleLabelledby}
791
823
  aria-invalid={field.ariaInvalid}
792
824
  aria-describedby={field.describedBy}
793
825
  autocomplete="off"
@@ -810,13 +842,14 @@
810
842
  <input
811
843
  class="select-search"
812
844
  type="text"
813
- aria-label={ariaLabel}
814
845
  value={query}
815
846
  oninput={handleSearchInput}
816
847
  onfocus={handleSearchFocus}
817
848
  bind:this={searchInputEl}
818
849
  placeholder={value.length === 0 ? placeholder : ''}
819
850
  {disabled}
851
+ aria-label={accessibleName}
852
+ aria-labelledby={accessibleLabelledby}
820
853
  aria-invalid={field.ariaInvalid}
821
854
  aria-describedby={field.describedBy}
822
855
  autocomplete="off"
@@ -832,13 +865,14 @@
832
865
  <input
833
866
  class="select-search"
834
867
  type="text"
835
- aria-label={ariaLabel}
836
868
  value={open ? query : displayText}
837
869
  oninput={handleSearchInput}
838
870
  onfocus={handleSearchFocus}
839
871
  bind:this={searchInputEl}
840
872
  placeholder={searchPlaceholder}
841
873
  {disabled}
874
+ aria-label={accessibleName}
875
+ aria-labelledby={accessibleLabelledby}
842
876
  aria-invalid={field.ariaInvalid}
843
877
  aria-describedby={field.describedBy}
844
878
  autocomplete="off"
@@ -1059,6 +1093,8 @@
1059
1093
  class="select-menu-list"
1060
1094
  role="listbox"
1061
1095
  id={listboxId}
1096
+ aria-label={accessibleName}
1097
+ aria-labelledby={accessibleLabelledby}
1062
1098
  aria-multiselectable={multiple}
1063
1099
  tabindex="-1"
1064
1100
  >
@@ -1088,6 +1124,8 @@
1088
1124
  bind:clientHeight={dropdownHeight}
1089
1125
  role="listbox"
1090
1126
  id={listboxId}
1127
+ aria-label={accessibleName}
1128
+ aria-labelledby={accessibleLabelledby}
1091
1129
  aria-multiselectable={multiple}
1092
1130
  style={portalStyle}
1093
1131
  use:portalToRoot
@@ -55,8 +55,24 @@ export type OptionalSelectProperties = {
55
55
  */
56
56
  searchPosition?: SelectSearchPosition;
57
57
  placeholder?: string;
58
- /** Accessible name for the combobox trigger. Omitted to preserve the default unnamed behavior. */
58
+ /**
59
+ * Names the field for assistive tech, as `aria-label` on the combobox trigger
60
+ * (and on the text input inside it for `searchable` with the input in the
61
+ * trigger, which is the control that actually takes focus there, and on the
62
+ * open listbox). The placeholder and the selected option are the value, not
63
+ * the field's identity, so a Select with neither visible label text nor this
64
+ * announces only "Choose a fruit, combobox" or "Apple, combobox".
65
+ * The name does not change with the selection, so it stays meaningful when
66
+ * the Select is empty, selected, invalid or disabled.
67
+ */
59
68
  ariaLabel?: string;
69
+ /**
70
+ * Ids of the elements that name the field (`aria-labelledby`), for a label that is
71
+ * already on screen. `ariaLabel` wins if both are given. Ids resolve in the same
72
+ * DOM root as the Select; for `<sui-select>` the wrapper resolves them from the
73
+ * host's root, because an id reference does not cross a shadow boundary.
74
+ */
75
+ ariaLabelledby?: string;
60
76
  disabled?: boolean;
61
77
  /**
62
78
  * Renders the error treatment on the trigger — a red border that outranks
@@ -3,14 +3,16 @@
3
3
  import type { SheetProperties } from './properties';
4
4
  import { fade } from 'svelte/transition';
5
5
  import { tokenizedFly } from '../Animations/tokenizedFly';
6
- import { lockBodyScroll, unlockBodyScroll } from '../utils';
7
- import { tick } from 'svelte';
6
+ import { acquireScrollLock, resolveScrollContainer } from '../utils';
7
+ import { tick, onMount, untrack } from 'svelte';
8
+ import { toStore } from 'svelte/store';
8
9
  import Button from '../Button/Button.svelte';
9
10
  import { focusTrapTabTarget, getActiveElement } from '../_interaction/focus';
10
11
  import { registerDismissible } from '../_interaction/dismissal';
11
12
 
12
13
  let {
13
14
  open = $bindable(false),
15
+ scrollContainer,
14
16
  side = 'right',
15
17
  title,
16
18
  showOverlay = true,
@@ -150,6 +152,27 @@
150
152
  }
151
153
  }
152
154
 
155
+ let scrollLockNode: HTMLElement | null = null;
156
+ let releaseScrollLock: (() => void) | null = null;
157
+ let scrollLockSpanEnded = false;
158
+
159
+ onMount(() =>
160
+ toStore(() => open).subscribe((isOpen) => {
161
+ if (!isOpen) {
162
+ scrollLockSpanEnded = true;
163
+ } else if (scrollLockSpanEnded && scrollLockNode !== null) {
164
+ // Reversing an outro reuses the action. Resolve the next logical open's
165
+ // owner without subscribing to ownership changes during that open.
166
+ scrollLockSpanEnded = false;
167
+ const releaseNext = untrack(() =>
168
+ acquireScrollLock(resolveScrollContainer(scrollLockNode, scrollContainer))
169
+ );
170
+ releaseScrollLock?.();
171
+ releaseScrollLock = releaseNext;
172
+ }
173
+ })
174
+ );
175
+
153
176
  function scrollLockAction(node: HTMLElement) {
154
177
  // getActiveElement rather than document.activeElement: when the sheet is
155
178
  // used through its custom element the opener lives inside a shadow root,
@@ -157,7 +180,9 @@
157
180
  // could never be handed focus back.
158
181
  const previousFocus = getActiveElement(node);
159
182
  let active = true;
160
- lockBodyScroll();
183
+ scrollLockNode = node;
184
+ scrollLockSpanEnded = false;
185
+ releaseScrollLock = acquireScrollLock(resolveScrollContainer(node, scrollContainer));
161
186
  // Registered here rather than in an onMount/onDestroy pair: this action's
162
187
  // mount/destroy already brackets exactly the open span (the overlay only
163
188
  // exists while `open` is true, per the `{#if open}` below), the same
@@ -177,7 +202,9 @@
177
202
  destroy() {
178
203
  active = false;
179
204
  releaseDismissible();
180
- unlockBodyScroll();
205
+ releaseScrollLock?.();
206
+ releaseScrollLock = null;
207
+ scrollLockNode = null;
181
208
  // Only take focus back when it is still somewhere the sheet is
182
209
  // responsible for -- inside the panel, or nowhere at all. If the user
183
210
  // has already moved on to another control, stealing it back would be
@@ -1,3 +1,4 @@
1
+ import type { ScrollContainer } from '../types';
1
2
  import type { Snippet } from 'svelte';
2
3
  export type SheetSide = 'left' | 'right' | 'top' | 'bottom' | 'center';
3
4
  export type SheetProperties = MandatorySheetProperties & OptionalSheetProperties & SheetEventProperties;
@@ -5,6 +6,11 @@ export type MandatorySheetProperties = {
5
6
  content: Snippet;
6
7
  };
7
8
  export type OptionalSheetProperties = {
9
+ /** Scroll owner, resolved once per open span. Omit for the overlay's ownerDocument.body.
10
+ * Supply the embedded scroll element or a getter; explicit null means no lock.
11
+ * Changing ownership while open takes effect on the next open, and teardown
12
+ * always releases the originally acquired container. */
13
+ scrollContainer?: ScrollContainer;
8
14
  open?: boolean;
9
15
  side?: SheetSide;
10
16
  title?: string;
@@ -2,6 +2,7 @@
2
2
  import { tick } from 'svelte';
3
3
  import { describeField } from '../_field/description';
4
4
  import Input from '../Input/Input.svelte';
5
+ import { resolveFieldName } from './field-name';
5
6
  import type { FieldConfig, SplitInputProperties } from './properties';
6
7
 
7
8
  let {
@@ -14,6 +15,7 @@
14
15
  testId,
15
16
  classes,
16
17
  ariaLabel,
18
+ positionLabel,
17
19
  errorMessage,
18
20
  infoMessage,
19
21
  invalid = false,
@@ -43,6 +45,20 @@
43
45
 
44
46
  let fieldCount = $derived(typeof fields !== 'undefined' ? fields.length : length);
45
47
 
48
+ /* A box is named by what it is, not left to the group's name alone: see
49
+ `resolveFieldName` for the precedence. Each box is an `Input` with
50
+ `actionInput`, which never renders Input's own <label>, so `ariaLabel` on the
51
+ inner Input is the only way its name can be set. */
52
+ function fieldName(index: number, config: FieldConfig): string {
53
+ return resolveFieldName({
54
+ index,
55
+ total: fieldCount,
56
+ config,
57
+ groupLabel: ariaLabel,
58
+ positionLabel
59
+ });
60
+ }
61
+
46
62
  let fieldConfigs: FieldConfig[] = $derived(
47
63
  typeof fields !== 'undefined'
48
64
  ? fields
@@ -273,6 +289,7 @@
273
289
  validators={config.validators ?? []}
274
290
  autoComplete={config.autoComplete ?? 'on'}
275
291
  inputMode={config.inputMode}
292
+ ariaLabel={fieldName(index, config)}
276
293
  disable={disabled}
277
294
  actionInput={true}
278
295
  classes="field-group-input"
@@ -0,0 +1,29 @@
1
+ import type { FieldConfig } from './properties';
2
+ type NamedFieldConfig = Pick<FieldConfig, 'ariaLabel' | 'label' | 'dataType' | 'maxLength'>;
3
+ export type FieldNameContext = {
4
+ readonly index: number;
5
+ readonly total: number;
6
+ readonly config: NamedFieldConfig;
7
+ /** The group's own name (`SplitInput`'s `ariaLabel`), when it has one. */
8
+ readonly groupLabel?: string;
9
+ readonly positionLabel?: (position: number, total: number) => string;
10
+ };
11
+ /**
12
+ * The accessible name for one box of a SplitInput.
13
+ *
14
+ * A box on its own is meaningless -- "edit text, blank", six times -- so each one
15
+ * has to say which field it is. In order of how specific the caller was:
16
+ *
17
+ * 1. `config.ariaLabel`: the caller named this box by purpose ("Red", "Area
18
+ * code"). Used exactly as given; it already says which field it is.
19
+ * 2. `config.label`: the visible caption under the box ("R"), which is what the
20
+ * box is already called on screen -- the name has to contain it.
21
+ * 3. Otherwise the box's position: "digit 2 of 6".
22
+ *
23
+ * For 2 and 3 the group's name leads, because a screen reader's form-field list
24
+ * shows each control without its group: "One-time code, digit 2 of 6" is
25
+ * identifiable there, "digit 2 of 6" is not. With no group name the position is
26
+ * all there is, so the group should be named -- see `SplitInput`'s `ariaLabel`.
27
+ */
28
+ export declare const resolveFieldName: ({ index, total, config, groupLabel, positionLabel }: FieldNameContext) => string;
29
+ export {};