@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
@@ -12,6 +12,16 @@
12
12
  classes
13
13
  }: CheckListItemProperties = $props();
14
14
 
15
+ /* The box (`role="checkbox"`) is what assistive technology reads, and the label the row shows is a
16
+ sibling of it, not inside <Checkbox> -- so nothing tied the two together and every item exposed an
17
+ unnamed control. `aria-labelledby` points the box at the label wrapper below, so the announced name
18
+ is exactly what is on screen: the plain `text`, or everything a `checkboxLabel` snippet renders
19
+ ("Required business details Required"), which `text` alone would not carry. The id lives in the
20
+ component's own tree, so it resolves inside the shadow root `<sui-check-list-item>` renders into. */
21
+ const uid = $props.id();
22
+ const labelId = `check-list-item-label-${uid}`;
23
+ const checkboxAttributes = { 'aria-labelledby': labelId };
24
+
15
25
  function handleClick(value: boolean): void {
16
26
  checked = value;
17
27
  onclick?.(checked);
@@ -20,19 +30,25 @@
20
30
 
21
31
  <div class="container {classes ?? ''}" class:disabled data-pw={testId} testID={testId}>
22
32
  <div class="checkbox-wrapper">
33
+ <!-- `ariaLabel` is the fallback name: it applies only if the label wrapper resolves to nothing
34
+ (an empty snippet), because `aria-labelledby` outranks it whenever it yields text. -->
23
35
  <Checkbox
24
36
  text=""
37
+ ariaLabel={text}
38
+ attributes={checkboxAttributes}
25
39
  bind:checked
26
40
  {disabled}
27
41
  onclick={handleClick}
28
42
  {...typeof testId === 'string' ? { testId: `${testId}-checkbox` } : {}}
29
43
  />
30
44
  </div>
31
- {#if typeof checkboxLabel === 'function'}
32
- {@render checkboxLabel()}
33
- {:else}
34
- <span class="text" class:checked>{text}</span>
35
- {/if}
45
+ <span class="check-list-item-label" id={labelId}>
46
+ {#if typeof checkboxLabel === 'function'}
47
+ {@render checkboxLabel()}
48
+ {:else}
49
+ <span class="text" class:checked>{text}</span>
50
+ {/if}
51
+ </span>
36
52
  </div>
37
53
 
38
54
  <style>
@@ -52,6 +68,12 @@
52
68
  flex-shrink: 0;
53
69
  }
54
70
 
71
+ /* Names the checkbox but adds no box of its own: the text or snippet inside stays a direct flex item
72
+ of .container, so layout, gap and every --check-list-item-* token behave as before. */
73
+ .check-list-item-label {
74
+ display: contents;
75
+ }
76
+
55
77
  .container.disabled .checkbox-wrapper {
56
78
  --checkbox-disabled-opacity: var(--check-list-item-disabled-opacity, 0.4);
57
79
  }
@@ -21,6 +21,7 @@
21
21
  let {
22
22
  value = $bindable('#000000'),
23
23
  label,
24
+ ariaLabel,
24
25
  disabled = false,
25
26
  showValue = false,
26
27
  testId,
@@ -50,6 +51,25 @@
50
51
  );
51
52
  const describedGroup = $derived(field.describedBy !== null || field.ariaInvalid !== null);
52
53
 
54
+ /* What this picker is called: its visible `label`, else the `ariaLabel` a caller
55
+ gives a swatch-only picker that has no caption. Every control in it is named
56
+ from this, because a page with three pickers otherwise offers three identical
57
+ "Pick a color" buttons and three nameless hex fields -- indistinguishable in a
58
+ screen reader's list of controls. `null` rather than a made-up default: with
59
+ no name supplied the controls keep the plain purpose names below, which is
60
+ the most that can honestly be said. */
61
+ const pickerName = $derived(
62
+ typeof label === 'string' && label.trim() !== ''
63
+ ? label.trim()
64
+ : typeof ariaLabel === 'string' && ariaLabel.trim() !== ''
65
+ ? ariaLabel.trim()
66
+ : null
67
+ );
68
+ const triggerName = $derived(
69
+ pickerName === null ? 'Pick a color' : `Pick a color: ${pickerName}`
70
+ );
71
+ const dialogName = $derived(pickerName === null ? 'Color picker' : `${pickerName} picker`);
72
+
53
73
  // ── Internal state ──────────────────────────────────────────────
54
74
 
55
75
  let open = $state(false);
@@ -293,7 +313,7 @@
293
313
  class="color-picker-container {classes ?? ''}"
294
314
  class:disabled
295
315
  role={describedGroup ? 'group' : null}
296
- aria-label={describedGroup && typeof label === 'string' ? label : null}
316
+ aria-label={describedGroup ? pickerName : null}
297
317
  aria-describedby={field.describedBy}
298
318
  aria-invalid={field.ariaInvalid}
299
319
  data-pw={testId}
@@ -312,7 +332,7 @@
312
332
  class:standalone={showValue === false}
313
333
  bind:this={triggerWrapEl}
314
334
  >
315
- <Button onclick={togglePicker} {disabled} ariaLabel="Pick a color" ariaExpanded={open}>
335
+ <Button onclick={togglePicker} {disabled} ariaLabel={triggerName} ariaExpanded={open}>
316
336
  <span class="color-picker-checkerboard">
317
337
  <span class="color-picker-swatch" style="background-color: {value};"></span>
318
338
  </span>
@@ -322,6 +342,7 @@
322
342
  <div class="color-picker-input-wrap">
323
343
  <Input
324
344
  bind:value
345
+ ariaLabel={`${pickerName ?? 'Color'} hex value`}
325
346
  disable={disabled}
326
347
  maxLength={7}
327
348
  classes="color-picker-text-input"
@@ -333,7 +354,7 @@
333
354
 
334
355
  <!-- Popover panel -->
335
356
  {#if open}
336
- <div class="color-picker-popover" role="dialog" aria-label="Color picker" use:dismissalAction>
357
+ <div class="color-picker-popover" role="dialog" aria-label={dialogName} use:dismissalAction>
337
358
  <div
338
359
  class="cp-sat-panel"
339
360
  style="background-color: {hsvToHex(hue, 1, 1)};"
@@ -361,6 +382,7 @@
361
382
  min={0}
362
383
  max={360}
363
384
  step={1}
385
+ ariaLabel="Hue"
364
386
  {disabled}
365
387
  classes="cp-hue-track"
366
388
  oninput={handleHueInput}
@@ -375,6 +397,7 @@
375
397
  <div class="cp-field-hex">
376
398
  <Input
377
399
  value={hexInputValue}
400
+ ariaLabel="Hex value"
378
401
  maxLength={7}
379
402
  classes="cp-field-input"
380
403
  oninput={handleHexFieldInput}
@@ -384,10 +407,11 @@
384
407
  {:else if mode === 'RGB'}
385
408
  <SplitInput
386
409
  values={[String(currentRgb.r), String(currentRgb.g), String(currentRgb.b)]}
410
+ ariaLabel="RGB channels"
387
411
  fields={[
388
- { label: 'R', dataType: 'number', min: 0, max: 255 },
389
- { label: 'G', dataType: 'number', min: 0, max: 255 },
390
- { label: 'B', dataType: 'number', min: 0, max: 255 }
412
+ { label: 'R', ariaLabel: 'Red', dataType: 'number', min: 0, max: 255 },
413
+ { label: 'G', ariaLabel: 'Green', dataType: 'number', min: 0, max: 255 },
414
+ { label: 'B', ariaLabel: 'Blue', dataType: 'number', min: 0, max: 255 }
391
415
  ]}
392
416
  {disabled}
393
417
  classes="cp-split-input"
@@ -396,10 +420,17 @@
396
420
  {:else}
397
421
  <SplitInput
398
422
  values={[String(currentHsl.h), String(currentHsl.s), String(currentHsl.l)]}
423
+ ariaLabel="HSL channels"
399
424
  fields={[
400
- { label: 'H', dataType: 'number', min: 0, max: 360 },
401
- { label: 'S', dataType: 'number', min: 0, max: 100 },
402
- { label: 'L', dataType: 'number', min: 0, max: 100 }
425
+ { label: 'H', ariaLabel: 'Hue (degrees)', dataType: 'number', min: 0, max: 360 },
426
+ {
427
+ label: 'S',
428
+ ariaLabel: 'Saturation (percent)',
429
+ dataType: 'number',
430
+ min: 0,
431
+ max: 100
432
+ },
433
+ { label: 'L', ariaLabel: 'Lightness (percent)', dataType: 'number', min: 0, max: 100 }
403
434
  ]}
404
435
  {disabled}
405
436
  classes="cp-split-input"
@@ -4,6 +4,14 @@ export type MandatoryColorPickerProperties = {
4
4
  };
5
5
  export type OptionalColorPickerProperties = {
6
6
  label?: string;
7
+ /**
8
+ * Names the picker when it has no visible `label` -- a swatch-only picker, say.
9
+ * Every control inside is named from `label` if there is one, else this ("Brand
10
+ * color hex value", "Pick a color: Brand color"), so several pickers on a page
11
+ * can be told apart. Without either, the controls keep their purpose names
12
+ * ("Hex value", "Red") and the trigger stays "Pick a color".
13
+ */
14
+ ariaLabel?: string;
7
15
  disabled?: boolean;
8
16
  showValue?: boolean;
9
17
  testId?: string;
@@ -78,6 +78,26 @@
78
78
  );
79
79
  const describedGroup = $derived(field.describedBy !== null || field.ariaInvalid !== null);
80
80
 
81
+ /* What names the text field. The input is where a screen reader lands, but the
82
+ name used to stop at the listbox: `ariaLabel` was applied to the popup only,
83
+ so the combobox itself announced as "combobox, blank" (or as its placeholder,
84
+ which is not a name and disappears from a multi-select once a pill is picked).
85
+
86
+ `inputProperties.ariaLabel` is the most specific and wins, as it always could;
87
+ then `ariaLabel`; then `inputProperties.label`, which docs/Combobox.md has long
88
+ said names the field but which Input never renders here (`actionInput`
89
+ suppresses its <label>), so it is used as the name rather than dropped. The
90
+ placeholder is the last resort so a consumer who named nothing still has a name
91
+ that does not vanish when the placeholder does. */
92
+ const accessibleName = (text?: string | null): string | null =>
93
+ typeof text === 'string' && text.trim() !== '' ? text : null;
94
+ const inputName = $derived(
95
+ accessibleName(inputProperties?.ariaLabel) ??
96
+ accessibleName(ariaLabel) ??
97
+ accessibleName(inputProperties?.label) ??
98
+ accessibleName(placeholder)
99
+ );
100
+
81
101
  let containerEl: HTMLDivElement | null = $state(null);
82
102
  let inputRef: ReturnType<typeof Input> | null = $state(null);
83
103
 
@@ -368,6 +388,7 @@
368
388
  class:disabled
369
389
  bind:this={containerEl}
370
390
  role={describedGroup ? 'group' : null}
391
+ aria-label={describedGroup ? inputName : null}
371
392
  aria-describedby={field.describedBy}
372
393
  aria-invalid={field.ariaInvalid}
373
394
  data-pw={testId}
@@ -383,7 +404,13 @@
383
404
  {#if typeof pillSnippet === 'function'}
384
405
  {@render pillSnippet(id, () => !disabled && removeValue(id), disabled)}
385
406
  {:else}
386
- <Pill text={labelOf(id)} dismissible {disabled} ondismiss={() => removeValue(id)} />
407
+ <Pill
408
+ text={labelOf(id)}
409
+ dismissible
410
+ dismissLabel={`Remove ${labelOf(id)}${inputName ? ` from ${inputName}` : ''}`}
411
+ {disabled}
412
+ ondismiss={() => removeValue(id)}
413
+ />
387
414
  {/if}
388
415
  {/each}
389
416
  {/if}
@@ -393,6 +420,7 @@
393
420
  bind:value={inputValue}
394
421
  bind:this={inputRef}
395
422
  placeholder={multiple && selected.length > 0 ? '' : placeholder}
423
+ ariaLabel={inputName}
396
424
  {name}
397
425
  disable={disabled}
398
426
  autoComplete="off"
@@ -415,11 +443,15 @@
415
443
  </div>
416
444
 
417
445
  {#if open && !disabled}
446
+ <!-- tabindex="-1": Firefox gives keyboard focus to a scroll container that holds no
447
+ tabbable child, and these options are all tabindex="-1". Tab from the open input
448
+ then landed on the listbox, and the focus was lost when the Tab handler closed it. -->
418
449
  <div
419
450
  class="combobox-dropdown"
420
451
  role="listbox"
421
452
  id={listboxId}
422
- aria-label={ariaLabel}
453
+ aria-label={ariaLabel ?? inputName}
454
+ tabindex="-1"
423
455
  use:dismissalAction
424
456
  >
425
457
  {#if typeof dropdownHeader === 'function'}
@@ -1,7 +1,8 @@
1
1
  <script lang="ts">
2
2
  import type { CommandMenuProperties, CommandItem } from './properties';
3
- import { tick, onMount, onDestroy } from 'svelte';
4
- import { lockBodyScroll, unlockBodyScroll } from '../utils';
3
+ import { tick, onMount, onDestroy, untrack } from 'svelte';
4
+ import { toStore } from 'svelte/store';
5
+ import { acquireScrollLock, resolveScrollContainer } from '../utils';
5
6
  import { registerDismissible } from '../_interaction/dismissal';
6
7
  import { getActiveElement } from '../_interaction/focus';
7
8
  import { tokenizedFly } from '../Animations/tokenizedFly';
@@ -16,6 +17,7 @@
16
17
  let {
17
18
  items,
18
19
  open = $bindable(false),
20
+ scrollContainer,
19
21
  query = $bindable(''),
20
22
  shortcutEnabled = true,
21
23
  placeholder = 'Search commands...',
@@ -289,10 +291,10 @@
289
291
  }
290
292
  }
291
293
 
292
- // Lock and unlock strictly in this action so the pairing is 1:1. Svelte destroys
293
- // the action when the node goes away, including on component teardown, so the
294
- // onDestroy below must NOT unlock as well: with a shared reference count a second
295
- // decrement would release a lock another open surface still needs.
294
+ // The action owns the scroll hold until its node goes away; the logical-open
295
+ // observer below replaces that hold if an outro is reversed with a new owner.
296
+ // onDestroy must NOT release it again: a second decrement could release a
297
+ // hold another open surface still needs.
296
298
  //
297
299
  // The dismissible layer is registered here too, for the same reason: this
298
300
  // action's mount/destroy already brackets exactly the open span (the overlay
@@ -321,12 +323,35 @@
321
323
  // input logic below it. Deferred rather than shipped half-verified: a wrong
322
324
  // guess here risks a worse regression (stealing focus into the wrong
323
325
  // element on every open) than the narrow bug it would fix.
326
+ let scrollLockNode: HTMLElement | null = null;
327
+ let releaseScrollLock: (() => void) | null = null;
328
+ let scrollLockSpanEnded = false;
329
+
330
+ onMount(() =>
331
+ toStore(() => open).subscribe((isOpen) => {
332
+ if (!isOpen) {
333
+ scrollLockSpanEnded = true;
334
+ } else if (scrollLockSpanEnded && scrollLockNode !== null) {
335
+ // A same-outro reopen keeps the node/action; it still starts a new
336
+ // scroll-owner span. Changes within an open remain intentionally frozen.
337
+ scrollLockSpanEnded = false;
338
+ const releaseNext = untrack(() =>
339
+ acquireScrollLock(resolveScrollContainer(scrollLockNode, scrollContainer))
340
+ );
341
+ releaseScrollLock?.();
342
+ releaseScrollLock = releaseNext;
343
+ }
344
+ })
345
+ );
346
+
324
347
  function scrollLockAction(node: HTMLElement) {
325
348
  const opener = getActiveElement(node);
326
349
  if (openerElement === null && opener instanceof HTMLElement) {
327
350
  openerElement = opener;
328
351
  }
329
- lockBodyScroll();
352
+ scrollLockNode = node;
353
+ scrollLockSpanEnded = false;
354
+ releaseScrollLock = acquireScrollLock(resolveScrollContainer(node, scrollContainer));
330
355
  const releaseDismissible = registerDismissible({
331
356
  element: () => dialogElement,
332
357
  onEscape: close
@@ -339,7 +364,9 @@
339
364
  return {
340
365
  destroy() {
341
366
  releaseDismissible();
342
- unlockBodyScroll();
367
+ releaseScrollLock?.();
368
+ releaseScrollLock = null;
369
+ scrollLockNode = null;
343
370
  // Opt-in only (clearStateOnClose): a controlled parent can set
344
371
  // `open = false` without calling close(), and the overlay action still
345
372
  // unmounts on every close path either way -- this is the lifecycle
@@ -1,3 +1,4 @@
1
+ import type { ScrollContainer } from '../types';
1
2
  import type { Snippet } from 'svelte';
2
3
  export type CommandItem = {
3
4
  label: string;
@@ -12,6 +13,11 @@ export type MandatoryCommandMenuProperties = {
12
13
  items: CommandItem[];
13
14
  };
14
15
  export type OptionalCommandMenuProperties = {
16
+ /** Scroll owner, resolved once per open span. Omit for the overlay's ownerDocument.body.
17
+ * Supply the embedded scroll element or a getter; explicit null means no lock.
18
+ * Changing ownership while open takes effect on the next open, and teardown
19
+ * always releases the originally acquired container. */
20
+ scrollContainer?: ScrollContainer;
15
21
  open?: boolean;
16
22
  /** Bindable search text. Omit to keep the built-in uncontrolled search. */
17
23
  query?: string;
@@ -11,6 +11,7 @@
11
11
  import { focusTrapTabTarget, getFocusTrapBoundary } from '../_interaction/focus';
12
12
  import { getActiveElement } from '../_interaction/focus';
13
13
  import { registerDismissible } from '../_interaction/dismissal';
14
+ import { findInteractive } from '../_interaction/focusable';
14
15
  import { describeField } from '../_field/description';
15
16
  import chevronDownSvg from '../assets/chevron-down.svg?raw';
16
17
  import checkmarkSvg from '../assets/checkmark.svg?raw';
@@ -339,9 +340,9 @@
339
340
  }
340
341
 
341
342
  function openPicker(): void {
342
- // Save whatever had focus (the trigger button) so it can be restored on close.
343
- const panelOpener = getActiveElement(triggerRef);
344
- panelFocusReturnEl = panelOpener instanceof HTMLElement ? panelOpener : null;
343
+ // Pointer activation need not focus a button in WebKit. Return to the
344
+ // invoking control, not an unrelated previously focused field or body.
345
+ panelFocusReturnEl = triggerRef ? findInteractive(triggerRef) : null;
345
346
  // Seed draft from current committed values
346
347
  draftStart = rangeStart;
347
348
  draftEnd = rangeEnd;
@@ -506,8 +507,7 @@
506
507
  }
507
508
 
508
509
  function openComparePicker(): void {
509
- const compareOpener = getActiveElement(compareTriggerRef);
510
- compareFocusReturnEl = compareOpener instanceof HTMLElement ? compareOpener : null;
510
+ compareFocusReturnEl = compareTriggerRef ? findInteractive(compareTriggerRef) : null;
511
511
  draftCompareStart = compareStart ?? null;
512
512
  draftCompareEnd = compareEnd ?? null;
513
513
  openCompare = true;
@@ -5,6 +5,7 @@
5
5
  DualAxisTooltipContext
6
6
  } from './properties';
7
7
  import ChartContainer from '../_chart/ChartContainer.svelte';
8
+ import { defaultChartName, resolveChartName } from '../_chart/a11y';
8
9
  import Axis from '../_chart/Axis.svelte';
9
10
  import ChartTooltip from '../_chart/ChartTooltip.svelte';
10
11
  import Legend from '../_chart/Legend.svelte';
@@ -32,7 +33,8 @@
32
33
  import { SvelteSet } from 'svelte/reactivity';
33
34
 
34
35
  // ── Per-instance uid for SVG <defs> ids ────────────────────────
35
- const uid = Math.random().toString(36).slice(2, 9);
36
+ const uid = $props.id();
37
+ const emptyDescriptionId = `dual-axis-empty-desc-${uid}`;
36
38
 
37
39
  // ── Props ──────────────────────────────────────────────────────
38
40
 
@@ -55,6 +57,8 @@
55
57
  hideLegendBelow = 360,
56
58
  tooltipSnippet,
57
59
  onbarclick,
60
+ ariaLabel,
61
+ ariaDescription,
58
62
  testId,
59
63
  classes
60
64
  }: DualAxisBarChartProperties = $props();
@@ -378,8 +382,20 @@
378
382
  */
379
383
  type HoverRect = { x: number; width: number; catIdx: number };
380
384
 
385
+ const chartName = $derived(
386
+ resolveChartName(
387
+ ariaLabel,
388
+ defaultChartName('dual-axis', { seriesNames: series.map((s) => s.name) })
389
+ )
390
+ );
391
+ const chartDescription = $derived(
392
+ typeof ariaDescription === 'string' && ariaDescription.trim() !== '' ? ariaDescription : null
393
+ );
394
+ const hasMarks = $derived(
395
+ bars.length > 0 || lineSeriesData.some((line) => line.points.length > 0)
396
+ );
381
397
  const hoverRects: HoverRect[] = $derived(
382
- categories.map((_, catIdx) => ({
398
+ (hasMarks ? categories : []).map((_, catIdx) => ({
383
399
  x: catScale(categories[catIdx]),
384
400
  width: catScale.bandwidth,
385
401
  catIdx
@@ -390,10 +406,13 @@
390
406
  const parts = series
391
407
  .map((s, si) => ({ s, si }))
392
408
  .filter(({ si }) => !hiddenSeries.has(si))
393
- .map(
394
- ({ s }) =>
395
- `${s.name} ${(s.yAxisIndex === 0 ? leftFormat : rightFormat)(s.data[catIdx] ?? 0)}`
396
- );
409
+ .map(({ s }) => {
410
+ const value = s.data[catIdx];
411
+ const text = Number.isFinite(value)
412
+ ? (s.yAxisIndex === 0 ? leftFormat : rightFormat)(value)
413
+ : 'not available';
414
+ return `${s.name} ${text}`;
415
+ });
397
416
  return `${categories[catIdx]}: ${parts.join(', ')}`;
398
417
  }
399
418
 
@@ -474,6 +493,9 @@
474
493
  {aspectRatio}
475
494
  {maxHeight}
476
495
  {minHeight}
496
+ ariaLabel={chartName}
497
+ {ariaDescription}
498
+ interactive={hasMarks}
477
499
  >
478
500
  <g transform="translate({dims.margin.left}, {dims.margin.top})">
479
501
  <!-- Left Y-axis (index 0) -->
@@ -544,6 +566,7 @@
544
566
  fill={bar.color}
545
567
  aria-label="{categories[bar.categoryIndex]}: {bar.value}"
546
568
  role="img"
569
+ aria-hidden="true"
547
570
  />
548
571
  {/each}
549
572
 
@@ -570,6 +593,7 @@
570
593
  style="stroke: var(--dual-axis-dot-stroke, light-dark(#fff, #111827)); stroke-width: var(--dual-axis-dot-stroke-width, 1.5);"
571
594
  aria-label="{categories[ptIdx]}: {series[ls.seriesIndex]?.data[ptIdx] ?? 0}"
572
595
  role="img"
596
+ aria-hidden="true"
573
597
  />
574
598
  {/each}
575
599
  {/each}
@@ -640,7 +664,17 @@
640
664
  {/if}
641
665
  </div>
642
666
  {:else}
643
- <div class="chart-empty">No data available.</div>
667
+ <div
668
+ class="chart-empty"
669
+ role="img"
670
+ aria-label={chartName}
671
+ aria-describedby={chartDescription === null ? null : emptyDescriptionId}
672
+ >
673
+ No data available.
674
+ {#if chartDescription !== null}
675
+ <span id={emptyDescriptionId} hidden>{chartDescription}</span>
676
+ {/if}
677
+ </div>
644
678
  {/if}
645
679
  </div>
646
680
 
@@ -119,6 +119,10 @@ export type OptionalDualAxisBarChartProperties = {
119
119
  */
120
120
  tooltipSnippet?: Snippet<[DualAxisTooltipContext]>;
121
121
  /** Value set on `data-pw` for test targeting. */
122
+ /** Accessible name of the drawing; omit to derive one from the plotted data. */
123
+ ariaLabel?: string;
124
+ /** Longer chart description, resolved inside the drawing's own DOM/shadow root. */
125
+ ariaDescription?: string;
122
126
  testId?: string;
123
127
  /** Extra CSS class string on the root `<div>`. */
124
128
  classes?: string;
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import Button from '../Button/Button.svelte';
3
3
  import Img from '../Img/Img.svelte';
4
+ import { useFileInputOwner } from '../FileInput/context';
4
5
  import type { FileDropzoneTriggerProperties } from './properties';
5
6
 
6
7
  let {
@@ -12,8 +13,31 @@
12
13
  testId,
13
14
  classes
14
15
  }: FileDropzoneTriggerProperties = $props();
16
+
17
+ /* An upload action gets one interactive control, never two. Inside a FileInput
18
+ the region is that control (one Tab stop, Enter/Space/click open the chooser),
19
+ so a native <button> in here would be a second stop announcing the same
20
+ action. The button is therefore rendered only when this trigger is the owner:
21
+ it was handed an `onclick` and nothing around it already owns activation.
22
+ Otherwise it is the same surface as plain content. */
23
+ const owner = useFileInputOwner();
24
+ const ownsActivation = $derived(
25
+ typeof onclick === 'function' && owner?.regionOwnsActivation !== true
26
+ );
15
27
  </script>
16
28
 
29
+ {#snippet body()}
30
+ <div class="file-dropzone-trigger-icon" aria-hidden="true">
31
+ <Img inlineSvg src={icon} alt="" fallback="" classes="file-dropzone-trigger-icon-img" />
32
+ </div>
33
+ <p class="file-dropzone-trigger-heading-wrap">
34
+ <span class="file-dropzone-trigger-heading">{heading}</span>
35
+ </p>
36
+ {#if caption}
37
+ <p class="file-dropzone-trigger-caption">{caption}</p>
38
+ {/if}
39
+ {/snippet}
40
+
17
41
  {#if compact}
18
42
  <div class="file-dropzone-trigger-compact {classes ?? ''}">
19
43
  <div class="file-dropzone-trigger-icon-sm" aria-hidden="true">
@@ -21,22 +45,22 @@
21
45
  </div>
22
46
  <span class="file-dropzone-trigger-heading" data-pw={testId} testID={testId}>{heading}</span>
23
47
  </div>
24
- {:else}
48
+ {:else if ownsActivation}
25
49
  <Button
26
50
  classes={`file-dropzone-trigger-button ${classes ?? ''}`}
27
51
  {onclick}
28
52
  {...typeof testId === 'string' ? { testId } : {}}
29
53
  >
30
- <div class="file-dropzone-trigger-icon" aria-hidden="true">
31
- <Img inlineSvg src={icon} alt="" fallback="" classes="file-dropzone-trigger-icon-img" />
32
- </div>
33
- <p class="file-dropzone-trigger-heading-wrap">
34
- <span class="file-dropzone-trigger-heading">{heading}</span>
35
- </p>
36
- {#if caption}
37
- <p class="file-dropzone-trigger-caption">{caption}</p>
38
- {/if}
54
+ {@render body()}
39
55
  </Button>
56
+ {:else}
57
+ <div
58
+ class="file-dropzone-trigger-surface file-dropzone-trigger-button {classes ?? ''}"
59
+ data-pw={testId}
60
+ testID={testId}
61
+ >
62
+ {@render body()}
63
+ </div>
40
64
  {/if}
41
65
 
42
66
  <style>
@@ -47,6 +71,76 @@
47
71
  gap: var(--file-dropzone-trigger-compact-gap, 8px);
48
72
  }
49
73
 
74
+ /* The non-interactive form of the trigger. It reproduces the primary, medium Button
75
+ this component rendered before, hook for hook -- same `--button-*` tokens, same
76
+ fallbacks -- so a consumer's `classes` override and the demo's look carry over to
77
+ the one place the button is no longer rendered: inside a FileInput region, which
78
+ is already the control. Button cannot do this itself: it always renders a native
79
+ <button> (or <a>). Keep this in step with Button's `.button-el` rules. */
80
+ .file-dropzone-trigger-surface {
81
+ /* The primary variant fill, behind the same internal name Button uses, which is
82
+ how it is recorded as dark in both themes rather than as a light default. */
83
+ --_btn-color: #3a4550;
84
+
85
+ position: relative;
86
+ max-height: var(--button-max-height, none);
87
+ max-width: var(--button-max-width, none);
88
+ min-width: var(--button-min-width, auto);
89
+ font-family: var(--button-font-family, inherit);
90
+ font-weight: var(--button-font-weight, 500);
91
+ font-size: var(--button-font-size, 14px);
92
+ background-color: var(--button-color, var(--_btn-color));
93
+ background-image: var(--button-background, none);
94
+ color: var(--button-text-color, #ffffff);
95
+ height: var(--button-height, fit-content);
96
+ padding: var(--button-padding, 16px);
97
+ margin: var(--button-margin, 0);
98
+ border-radius: var(--button-border-radius, var(--radius, 6px));
99
+ width: var(--button-width, fit-content);
100
+ cursor: var(--cursor, pointer);
101
+ opacity: var(--opacity, 1);
102
+ border: var(--button-border, none);
103
+ box-sizing: border-box;
104
+ text-align: center;
105
+ text-decoration: var(--button-text-decoration, none);
106
+ line-height: var(--button-line-height, normal);
107
+ display: flex;
108
+ justify-content: var(--button-justify-content, center);
109
+ align-items: center;
110
+ flex-direction: var(--button-content-flex-direction, row);
111
+ gap: var(--button-content-gap, 16px);
112
+ visibility: var(--button-visibility, visible);
113
+ box-shadow: var(--button-box-shadow, none);
114
+ transition: var(--button-transition, none);
115
+ white-space: var(--button-white-space, nowrap);
116
+ }
117
+
118
+ .file-dropzone-trigger-surface:hover {
119
+ background: var(
120
+ --button-hover-color,
121
+ var(--button-background, var(--button-color, var(--_btn-color)))
122
+ );
123
+ color: var(--button-hover-text-color, var(--button-text-color, #ffffff));
124
+ border: var(--button-hover-border, var(--button-border, none));
125
+ transform: var(--button-hover-transform, none);
126
+ box-shadow: var(--button-hover-box-shadow, var(--button-box-shadow, none));
127
+ }
128
+
129
+ .file-dropzone-trigger-surface:active {
130
+ transform: var(--button-active-transform, none);
131
+ background: var(
132
+ --button-active-background,
133
+ var(--button-background, var(--button-color, var(--_btn-color)))
134
+ );
135
+ box-shadow: var(--button-active-box-shadow, var(--button-box-shadow, none));
136
+ }
137
+
138
+ @media (prefers-reduced-motion: reduce) {
139
+ .file-dropzone-trigger-surface {
140
+ transition: none;
141
+ }
142
+ }
143
+
50
144
  .file-dropzone-trigger-icon-sm,
51
145
  .file-dropzone-trigger-icon {
52
146
  display: flex;