@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
@@ -550,6 +550,21 @@
550
550
  min-width: 0;
551
551
  }
552
552
 
553
+ /* Contrast contract (WCAG 1.4.3, 4.5:1 for this normal-size text): every colour the
554
+ live label can paint -- the resting ink AND the shimmer's highlight -- clears 4.5:1
555
+ on the surfaces it is documented for (white, #fafafa, #f4f4f5 and the library's
556
+ tinted greys in light; #0f0f17..#2f2f45 in the dark layer). The sweep therefore
557
+ travels from the resting ink toward a STRONGER ink (#6b6b6b -> #2b2b2b in light,
558
+ #9ca3af -> #e5e7eb in dark) rather than washing out toward the page, which is what
559
+ the previous #858585 -> #bebebe sweep did (1.8:1 at its peak on #fafafa). The swing
560
+ between the two stops is as large or larger (2.7:1 light, 2.1:1 dark, against the old
561
+ 2.0:1), so the motion reads the same. Pinned in two places:
562
+ src/lib/ThinkingIndicator/thinking-indicator-contrast.test.ts (the token defaults)
563
+ and tests/thinking-indicator-contrast.spec.ts (rendered pixels, every shimmer phase).
564
+
565
+ The gradient is built from the same `--thinking-indicator-label-color` token the
566
+ static label uses, so overriding the colour retunes the shimmer's resting stops too;
567
+ `--thinking-indicator-shimmer-gradient` still replaces the whole thing. */
553
568
  .status-label {
554
569
  flex: 0 1 auto;
555
570
  min-width: 0;
@@ -559,10 +574,15 @@
559
574
  text-align: left;
560
575
  font-size: var(--thinking-indicator-font-size, 0.75rem);
561
576
  line-height: var(--thinking-indicator-line-height, 1.25rem);
562
- color: var(--thinking-indicator-label-color, #858585);
577
+ color: var(--thinking-indicator-label-color, #6b6b6b);
563
578
  background: var(
564
579
  --thinking-indicator-shimmer-gradient,
565
- linear-gradient(90deg, #858585 0%, #bebebe 50%, #858585 100%)
580
+ linear-gradient(
581
+ 90deg,
582
+ var(--thinking-indicator-label-color, #6b6b6b) 0%,
583
+ var(--thinking-indicator-shimmer-highlight, #2b2b2b) 50%,
584
+ var(--thinking-indicator-label-color, #6b6b6b) 100%
585
+ )
566
586
  );
567
587
  background-size: 200% 100%;
568
588
  background-clip: text;
@@ -571,10 +591,14 @@
571
591
  infinite;
572
592
  }
573
593
 
574
- /* A settled label (no live busy phase) holds still instead of shimmering. */
594
+ /* A settled label (no live busy phase) holds still instead of shimmering. It also drops
595
+ the gradient layer rather than just covering it: under an opaque text fill the layer
596
+ still shows at antialiased glyph edges, and now that its highlight is a dark ink, light
597
+ text on a dark recipe (the Chat tool-status example) would grow a dark fringe. */
575
598
  .static-label {
576
599
  animation: none;
577
- -webkit-text-fill-color: var(--thinking-indicator-label-color, #858585);
600
+ background-image: none;
601
+ -webkit-text-fill-color: var(--thinking-indicator-label-color, #6b6b6b);
578
602
  }
579
603
 
580
604
  /* ---- chip variant: a self-contained floating pill (no ancestor supplies
@@ -619,9 +643,17 @@
619
643
  font-size: var(--thinking-indicator-chip-font-size, 0.85rem);
620
644
  font-weight: var(--thinking-indicator-chip-font-weight, 500);
621
645
  color: var(--thinking-indicator-chip-color, #52525b);
646
+ /* Same contract as .status-label: resting ink and highlight both clear 4.5:1 on the
647
+ chip's own surface (#52525b / #18181b on #ffffff in light; #9ca3af / #f3f4f6 on
648
+ #1e1e2e in dark), and the resting stop follows the chip colour token. */
622
649
  background: var(
623
650
  --thinking-indicator-chip-shimmer-gradient,
624
- linear-gradient(90deg, #52525b 0%, #a0a0a0 50%, #52525b 100%)
651
+ linear-gradient(
652
+ 90deg,
653
+ var(--thinking-indicator-chip-color, #52525b) 0%,
654
+ var(--thinking-indicator-chip-shimmer-highlight, #18181b) 50%,
655
+ var(--thinking-indicator-chip-color, #52525b) 100%
656
+ )
625
657
  );
626
658
  background-size: 200% 100%;
627
659
  background-clip: text;
@@ -637,6 +669,7 @@
637
669
  variant's. */
638
670
  .chip-label.static-label {
639
671
  animation: none;
672
+ background-image: none;
640
673
  -webkit-text-fill-color: var(--thinking-indicator-chip-color, #52525b);
641
674
  }
642
675
 
@@ -857,17 +890,21 @@
857
890
  gap: 5px;
858
891
  }
859
892
 
893
+ /* The +N / -N stats also sit on a selectable row's hover (#f4f4f4) and selected (#ececec)
894
+ backgrounds. #1f7a5f / #c93f38 were 4.4:1 / 4.2:1 on the selected one; these clear
895
+ 4.5:1 on it and on white (thinking-indicator-contrast.test.ts pins both). */
860
896
  .row-diffstat .added {
861
- color: var(--thinking-indicator-trace-added-color, #1f7a5f);
897
+ color: var(--thinking-indicator-trace-added-color, #1b7358);
862
898
  }
863
899
 
864
900
  .row-diffstat .removed {
865
- color: var(--thinking-indicator-trace-removed-color, #c93f38);
901
+ color: var(--thinking-indicator-trace-removed-color, #c2382f);
866
902
  }
867
903
 
868
904
  .trace-more {
869
905
  font-size: var(--thinking-indicator-trace-more-font-size, 0.75rem);
870
- color: var(--thinking-indicator-trace-more-color, #9a9a9a);
906
+ /* Settled caption text at 0.75rem: #9a9a9a was 2.8:1 on white. */
907
+ color: var(--thinking-indicator-trace-more-color, #6b6b6b);
871
908
  padding: 6px 0 0 22px;
872
909
  animation: thinking-indicator-fade-in
873
910
  var(--thinking-indicator-trace-more-animation-duration, var(--motion-duration, 300ms))
@@ -890,11 +927,13 @@
890
927
 
891
928
  .status-label {
892
929
  animation: none;
893
- -webkit-text-fill-color: var(--thinking-indicator-label-color, #858585);
930
+ background-image: none;
931
+ -webkit-text-fill-color: var(--thinking-indicator-label-color, #6b6b6b);
894
932
  }
895
933
 
896
934
  .chip-label {
897
935
  animation: none;
936
+ background-image: none;
898
937
  -webkit-text-fill-color: var(--thinking-indicator-chip-color, #52525b);
899
938
  }
900
939
  }
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { untrack } from 'svelte';
2
+ import { tick, untrack } from 'svelte';
3
3
  import type { ToastDirection, ToastProperties } from './properties';
4
4
  import Img from '../Img/Img.svelte';
5
5
 
@@ -26,6 +26,12 @@
26
26
  }: ToastProperties = $props();
27
27
 
28
28
  let showToast = $state(true);
29
+ let rootEl: HTMLDivElement | null = $state(null);
30
+ // One `ontoasthide` per hide, however it is reached (the exit's transitionend,
31
+ // or the no-exit path in hideToast) and however many times hideToast runs
32
+ // (the close button after the auto-hide timer already fired).
33
+ let hideNotified = false;
34
+ let hideGeneration = 0;
29
35
 
30
36
  const rootClass = $derived(
31
37
  ['toast', type ?? '', classes ?? ''].filter((cls) => cls.length > 0).join(' ')
@@ -61,6 +67,13 @@
61
67
  return distance == null ? {} : directionalOffset(resolvedDirection, distance);
62
68
  });
63
69
 
70
+ // The offsets feed `translate(var(--toast-enter-x, 0px), var(--toast-enter-y, 0px))`
71
+ // in the stylesheet, where a unitless non-zero number is invalid at
72
+ // computed-value time: the whole `transform` then computes to `none` and the
73
+ // toast stops travelling at all. Inline custom properties carry no unit of
74
+ // their own, so the px has to be written here.
75
+ const toPx = (value?: number): string | null => (typeof value === 'number' ? `${value}px` : null);
76
+
64
77
  // A `0`/`0s` CSS transition-duration never starts a transition, so it never
65
78
  // fires `transitionend`, so `ontoasthide` (below) would never fire -- floor
66
79
  // at 50ms so the transition always runs. Same reasoning as the reduced-motion
@@ -76,8 +89,57 @@
76
89
  const openDurationStyle = $derived(toDurationStyle(inAnimationDuration));
77
90
  const closeDurationStyle = $derived(toDurationStyle(outAnimationDuration));
78
91
 
92
+ function notifyHidden() {
93
+ if (!hideNotified) {
94
+ hideNotified = true;
95
+ ontoasthide?.();
96
+ }
97
+ }
98
+
79
99
  function hideToast() {
100
+ const generation = ++hideGeneration;
80
101
  showToast = false;
102
+ void tick().then(async () => {
103
+ const element = rootEl;
104
+ if (generation !== hideGeneration || showToast || !element?.isConnected) {
105
+ return;
106
+ }
107
+ const style = getComputedStyle(element);
108
+ if (style.display === 'none') {
109
+ notifyHidden();
110
+ return;
111
+ }
112
+
113
+ // An exit can be cancelled before transitionrun/transitioncancel is dispatched.
114
+ // Its animation promise still settles, including rejection on cancellation.
115
+ // Keep transitionend as the normal path; both paths share the exactly-once guard.
116
+ const opacityExits =
117
+ element
118
+ .getAnimations?.()
119
+ .filter(
120
+ (animation) =>
121
+ 'transitionProperty' in animation && animation.transitionProperty === 'opacity'
122
+ ) ?? [];
123
+ if (opacityExits.length > 0) {
124
+ const completions = await Promise.allSettled(
125
+ opacityExits.map((animation) => animation.finished)
126
+ );
127
+ // A fulfilled promise settles before transitionend is dispatched. Preserve that
128
+ // normal event path so consumers and motion observers receive the complete exit.
129
+ if (
130
+ completions.some((completion) => completion.status === 'rejected') &&
131
+ generation === hideGeneration &&
132
+ !showToast &&
133
+ rootEl === element &&
134
+ element.isConnected
135
+ ) {
136
+ notifyHidden();
137
+ }
138
+ } else if (style.opacity === '0') {
139
+ // A coalesced first frame or consumer-disabled motion has no animation to await.
140
+ notifyHidden();
141
+ }
142
+ });
81
143
  }
82
144
 
83
145
  function handleTransitionEnd(event: TransitionEvent) {
@@ -96,7 +158,7 @@
96
158
  // correct under any reversal ordering, not just the common single-close
97
159
  // case this guard was originally written for.
98
160
  if (event.target === event.currentTarget && event.propertyName === 'opacity' && !showToast) {
99
- ontoasthide?.();
161
+ notifyHidden();
100
162
  }
101
163
  }
102
164
 
@@ -110,13 +172,16 @@
110
172
  void duration;
111
173
 
112
174
  untrack(() => {
175
+ hideGeneration++;
113
176
  showToast = true;
177
+ hideNotified = false;
114
178
  });
115
179
 
116
180
  const id = setTimeout(hideToast, duration);
117
181
 
118
182
  return () => {
119
183
  clearTimeout(id);
184
+ hideGeneration++;
120
185
  };
121
186
  });
122
187
  </script>
@@ -133,6 +198,7 @@
133
198
  aria-live off and back on around the reshow to force a re-announce,
134
199
  not a change to the CSS transition mechanics above. -->
135
200
  <div
201
+ bind:this={rootEl}
136
202
  class={rootClass}
137
203
  class:is-visible={showToast}
138
204
  class:no-page-overlap={!overlapPage}
@@ -141,8 +207,8 @@
141
207
  aria-live="assertive"
142
208
  style:--toast-open-duration={openDurationStyle}
143
209
  style:--toast-close-duration={closeDurationStyle}
144
- style:--toast-enter-x={enterOffset.x}
145
- style:--toast-enter-y={enterOffset.y}
210
+ style:--toast-enter-x={toPx(enterOffset.x)}
211
+ style:--toast-enter-y={toPx(enterOffset.y)}
146
212
  ontransitionend={handleTransitionEnd}
147
213
  data-pw={testId}
148
214
  testID={testId}
@@ -10,6 +10,7 @@
10
10
  classes,
11
11
  id,
12
12
  ariaLabel,
13
+ inputRole,
13
14
  ariaLabelledby,
14
15
  onclick,
15
16
  name,
@@ -54,6 +55,7 @@
54
55
  <label class="switch">
55
56
  <input
56
57
  id={inputId}
58
+ role={inputRole === 'switch' ? 'switch' : inputRole === 'checkbox' ? 'checkbox' : null}
57
59
  class="input-checkbox"
58
60
  type="checkbox"
59
61
  aria-describedby={field.describedBy}
@@ -30,6 +30,8 @@ export type OptionalToggleProperties = {
30
30
  id?: string;
31
31
  /** Names the switch for assistive technology when it has no visible text. */
32
32
  ariaLabel?: string;
33
+ /** Optional semantics on the native checkbox; default keeps its checkbox role. */
34
+ inputRole?: 'checkbox' | 'switch';
33
35
  /**
34
36
  * References a label in the same DOM root as the checkbox. `<sui-toggle>` exposes this as
35
37
  * `inputAriaLabelledby` (`input-aria-labelledby`); it cannot reach the host document, so use
@@ -42,6 +42,13 @@
42
42
  }
43
43
 
44
44
  const TICK_SIZE = 6;
45
+
46
+ // Tick labels are `aria-hidden`: they repeat what the chart's marks already
47
+ // say through their own accessible names ("Jan: 4.2K"), and a drawing that
48
+ // exposes interactive marks as a `group` (rather than the image it used to
49
+ // be, whose children were presentational) would otherwise read "0 2K 4K 6K
50
+ // Jan Feb Mar ..." ahead of the first mark. The axis *title* stays exposed:
51
+ // it is the one place a unit or dimension is stated.
45
52
  </script>
46
53
 
47
54
  <g
@@ -59,6 +66,7 @@
59
66
  {#if rotateTicks && orientation === 'bottom'}
60
67
  <text
61
68
  class="tick-label"
69
+ aria-hidden="true"
62
70
  transform="translate(0, {TICK_SIZE + 4}) rotate(-45)"
63
71
  text-anchor="end"
64
72
  dominant-baseline="auto"
@@ -70,6 +78,7 @@
70
78
  {:else}
71
79
  <text
72
80
  class="tick-label"
81
+ aria-hidden="true"
73
82
  y={orientation === 'bottom' ? TICK_SIZE + 4 : -(TICK_SIZE + 4)}
74
83
  text-anchor="middle"
75
84
  dominant-baseline={orientation === 'bottom' ? 'hanging' : 'auto'}
@@ -107,6 +116,7 @@
107
116
  <line class="tick-mark" x2={orientation === 'left' ? -TICK_SIZE : TICK_SIZE} />
108
117
  <text
109
118
  class="tick-label"
119
+ aria-hidden="true"
110
120
  x={orientation === 'left' ? -(TICK_SIZE + 4) : TICK_SIZE + 4}
111
121
  text-anchor={orientation === 'left' ? 'end' : 'start'}
112
122
  dominant-baseline="middle"
@@ -10,9 +10,25 @@
10
10
  maxHeight = Infinity,
11
11
  testId,
12
12
  classes,
13
+ ariaLabel,
14
+ ariaDescription,
15
+ interactive = false,
13
16
  children
14
17
  }: ChartContainerProperties = $props();
15
18
 
19
+ // Instance-scoped, hydration-safe id for the <desc> element that
20
+ // `aria-describedby` points at. Two charts on one page (or in two shadow
21
+ // roots) must never resolve each other's description.
22
+ const instanceId = $props.id();
23
+ const descriptionId = `chart-desc-${instanceId}`;
24
+
25
+ // Whitespace-only strings are treated as absent so an empty prop never
26
+ // produces `aria-label=""`, which is a name of nothing rather than no name.
27
+ let name = $derived(typeof ariaLabel === 'string' && ariaLabel.trim() !== '' ? ariaLabel : null);
28
+ let description = $derived(
29
+ typeof ariaDescription === 'string' && ariaDescription.trim() !== '' ? ariaDescription : null
30
+ );
31
+
16
32
  let containerEl: HTMLDivElement | null = $state(null);
17
33
  let isMounted = false;
18
34
 
@@ -35,6 +51,7 @@
35
51
  // changes, so the computed height stays current without waiting for a resize.
36
52
  void aspectRatio;
37
53
  void maxHeight;
54
+ void minHeight;
38
55
  if (isMounted) {
39
56
  measure();
40
57
  }
@@ -72,13 +89,27 @@
72
89
  testID={typeof testId === 'string' ? testId : null}
73
90
  >
74
91
  {#if width > 0 && height > 0}
92
+ <!--
93
+ `role="img"` makes its descendants presentational, so a drawing that holds
94
+ focusable marks must not use it: the marks are either dropped from the
95
+ accessibility tree or announced differently per browser, and axe reports
96
+ the nesting as `nested-interactive`. An interactive drawing is therefore a
97
+ named `group` whose children stay real controls; a static one stays a
98
+ named image. Either way the name sits on the <svg> itself -- a named
99
+ wrapper elsewhere does not name the image inside it.
100
+ -->
75
101
  <svg
76
102
  viewBox="0 0 {width} {height}"
77
103
  preserveAspectRatio="xMidYMid meet"
78
- role="img"
104
+ role={interactive ? 'group' : 'img'}
105
+ aria-label={name}
106
+ aria-describedby={description === null ? null : descriptionId}
79
107
  {width}
80
108
  {height}
81
109
  >
110
+ {#if description !== null}
111
+ <desc id={descriptionId}>{description}</desc>
112
+ {/if}
82
113
  {@render children()}
83
114
  </svg>
84
115
  {/if}
@@ -64,10 +64,24 @@
64
64
 
65
65
  const pos = $derived.by(() => {
66
66
  const tooltip = { width: tooltipWidth, height: tooltipHeight };
67
+ const rect = originEl?.getBoundingClientRect();
68
+ // Native rendered bounds include the label's loaded font and transforms.
69
+ // Only our opaque label plates participate; caller tooltip content stays
70
+ // intact and ordinary charts retain their existing placement.
71
+ const avoidRects = Array.from(
72
+ originEl?.querySelectorAll<SVGRectElement>('.chart-label-backdrop') ?? []
73
+ ).map((plate) => {
74
+ const bounds = plate.getBoundingClientRect();
75
+ return {
76
+ left: bounds.left - (portal ? 0 : (rect?.left ?? 0)),
77
+ top: bounds.top - (portal ? 0 : (rect?.top ?? 0)),
78
+ width: bounds.width,
79
+ height: bounds.height
80
+ };
81
+ });
67
82
  if (portal) {
68
83
  void portalTick;
69
84
  // Convert container coords to viewport coords and clamp to the viewport.
70
- const rect = originEl?.getBoundingClientRect();
71
85
  const dx = rect?.left ?? 0;
72
86
  const dy = rect?.top ?? 0;
73
87
  const container =
@@ -79,14 +93,15 @@
79
93
  mouseY: mouseY + dy,
80
94
  anchor: anchor === null ? null : { ...anchor, x: anchor.x + dx, y: anchor.y + dy },
81
95
  tooltip,
82
- container
96
+ container,
97
+ avoidRects
83
98
  });
84
99
  }
85
100
  const container = {
86
101
  width: tooltipEl?.offsetParent?.clientWidth ?? Number.POSITIVE_INFINITY,
87
102
  height: tooltipEl?.offsetParent?.clientHeight ?? Number.POSITIVE_INFINITY
88
103
  };
89
- return computeTooltipPosition({ mouseX, mouseY, anchor, tooltip, container });
104
+ return computeTooltipPosition({ mouseX, mouseY, anchor, tooltip, container, avoidRects });
90
105
  });
91
106
  </script>
92
107
 
@@ -0,0 +1,116 @@
1
+ <script lang="ts">
2
+ import { onMount, type Snippet } from 'svelte';
3
+
4
+ let { children }: { children: Snippet } = $props();
5
+ let group: SVGGElement | null = $state(null);
6
+ let bounds: { x: number; y: number; width: number; height: number } | null = $state(null);
7
+ const PADDING = 3;
8
+
9
+ onMount(() => {
10
+ const text = group?.querySelector('text');
11
+ if (group === null || text === null || typeof text === 'undefined') {
12
+ return;
13
+ }
14
+ let mounted = true;
15
+ let frame = 0;
16
+ function measure(): void {
17
+ if (
18
+ !mounted ||
19
+ group === null ||
20
+ text === null ||
21
+ typeof text === 'undefined' ||
22
+ typeof text.getBBox !== 'function'
23
+ ) {
24
+ return;
25
+ }
26
+ const box = text.getBBox();
27
+ if (box.width <= 0 || box.height <= 0) {
28
+ bounds = null;
29
+ return;
30
+ }
31
+ // getBBox is in the text's own SVG coordinates. This also handles
32
+ // transformed children, rather than assuming page/parent rectangles.
33
+ const parent = group.getCTM?.();
34
+ const child = text.getCTM?.();
35
+ const matrix = parent && child ? parent.inverse().multiply(child) : null;
36
+ const corners = [
37
+ [box.x, box.y],
38
+ [box.x + box.width, box.y],
39
+ [box.x, box.y + box.height],
40
+ [box.x + box.width, box.y + box.height]
41
+ ].map(([x, y]) =>
42
+ matrix === null
43
+ ? { x, y }
44
+ : {
45
+ x: matrix.a * x + matrix.c * y + matrix.e,
46
+ y: matrix.b * x + matrix.d * y + matrix.f
47
+ }
48
+ );
49
+ const x = Math.min(...corners.map((point) => point.x)) - PADDING;
50
+ const y = Math.min(...corners.map((point) => point.y)) - PADDING;
51
+ bounds = {
52
+ x,
53
+ y,
54
+ width: Math.max(...corners.map((point) => point.x)) - x + PADDING,
55
+ height: Math.max(...corners.map((point) => point.y)) - y + PADDING
56
+ };
57
+ }
58
+ function schedule(): void {
59
+ cancelAnimationFrame(frame);
60
+ frame = requestAnimationFrame(measure);
61
+ }
62
+ measure();
63
+ const resize = new ResizeObserver(schedule);
64
+ resize.observe(text);
65
+ const mutation = new MutationObserver(schedule);
66
+ mutation.observe(text, {
67
+ subtree: true,
68
+ childList: true,
69
+ characterData: true,
70
+ attributes: true
71
+ });
72
+ // Font readiness can change glyph bounds without changing the label's
73
+ // content or CSS family. Measure the actual loaded font, not cached canvas
74
+ // estimates, and react to subsequent font loads as well.
75
+ document.fonts?.ready.then(() => {
76
+ if (mounted) {
77
+ schedule();
78
+ }
79
+ });
80
+ document.fonts?.addEventListener('loadingdone', schedule);
81
+ return () => {
82
+ mounted = false;
83
+ cancelAnimationFrame(frame);
84
+ resize.disconnect();
85
+ mutation.disconnect();
86
+ document.fonts?.removeEventListener('loadingdone', schedule);
87
+ };
88
+ });
89
+ </script>
90
+
91
+ <g class="chart-label-plate" bind:this={group}>
92
+ {#if bounds !== null}
93
+ <rect
94
+ class="chart-label-backdrop"
95
+ x={bounds.x}
96
+ y={bounds.y}
97
+ width={bounds.width}
98
+ height={bounds.height}
99
+ rx="2"
100
+ aria-hidden="true"
101
+ />
102
+ {/if}
103
+ {@render children()}
104
+ </g>
105
+
106
+ <style>
107
+ .chart-label-plate {
108
+ pointer-events: none;
109
+ }
110
+ .chart-label-backdrop {
111
+ /* Keep the default literal: consumer CSS compilers may lower light-dark()
112
+ into private variables that their inherited theme never initializes. */
113
+ fill: var(--chart-label-background, #fff);
114
+ stroke: none;
115
+ }
116
+ </style>
@@ -0,0 +1,7 @@
1
+ import { type Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ children: Snippet;
4
+ };
5
+ declare const LabelPlate: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type LabelPlate = ReturnType<typeof LabelPlate>;
7
+ export default LabelPlate;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Chart-level accessible naming shared by the seven chart families.
3
+ *
4
+ * A chart drawing needs a name of its own: a name on some wrapper elsewhere does
5
+ * not name the `<svg>` inside it (axe still reports `svg-img-alt` on the
6
+ * unnamed image), and a screen-reader user tabbing between data points has no
7
+ * other way to learn which chart a point belongs to. The consumer's
8
+ * `ariaLabel` always wins; this supplies the name only when none was given, so
9
+ * an unlabelled chart is at least identifiable by what it plots.
10
+ */
11
+ export type ChartKind = 'line' | 'area' | 'bar' | 'pie' | 'sankey' | 'dual-axis' | 'funnel';
12
+ export type ChartNameParts = {
13
+ yAxisLabel?: string;
14
+ seriesNames?: readonly string[];
15
+ };
16
+ /**
17
+ * Name used when the consumer passes no `ariaLabel`.
18
+ *
19
+ * `"{yAxisLabel} bar chart"` is the spelling BarChart has always put on its
20
+ * wrapping region, so a chart that already had a named region keeps the same
21
+ * words. Without a y-axis title, named series are listed (`"Line chart:
22
+ * Revenue, Cost"`); with neither, only the kind of chart is known.
23
+ */
24
+ export declare function defaultChartName(kind: ChartKind, parts: ChartNameParts): string;
25
+ /**
26
+ * The consumer's non-blank `ariaLabel`, else the derived fallback. `unknown`
27
+ * because the prop is optional and an element attribute can hand over anything.
28
+ */
29
+ export declare function resolveChartName(ariaLabel: unknown, fallback: string): string;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Chart-level accessible naming shared by the seven chart families.
3
+ *
4
+ * A chart drawing needs a name of its own: a name on some wrapper elsewhere does
5
+ * not name the `<svg>` inside it (axe still reports `svg-img-alt` on the
6
+ * unnamed image), and a screen-reader user tabbing between data points has no
7
+ * other way to learn which chart a point belongs to. The consumer's
8
+ * `ariaLabel` always wins; this supplies the name only when none was given, so
9
+ * an unlabelled chart is at least identifiable by what it plots.
10
+ */
11
+ const NOUN = {
12
+ line: 'line chart',
13
+ area: 'area chart',
14
+ bar: 'bar chart',
15
+ pie: 'pie chart',
16
+ sankey: 'flow diagram',
17
+ 'dual-axis': 'dual-axis chart',
18
+ funnel: 'funnel chart'
19
+ };
20
+ /**
21
+ * Name used when the consumer passes no `ariaLabel`.
22
+ *
23
+ * `"{yAxisLabel} bar chart"` is the spelling BarChart has always put on its
24
+ * wrapping region, so a chart that already had a named region keeps the same
25
+ * words. Without a y-axis title, named series are listed (`"Line chart:
26
+ * Revenue, Cost"`); with neither, only the kind of chart is known.
27
+ */
28
+ export function defaultChartName(kind, parts) {
29
+ const noun = NOUN[kind];
30
+ const yAxisLabel = typeof parts.yAxisLabel === 'string' ? parts.yAxisLabel.trim() : '';
31
+ if (yAxisLabel !== '') {
32
+ return `${yAxisLabel} ${noun}`;
33
+ }
34
+ const names = (parts.seriesNames ?? []).map((name) => name.trim()).filter((name) => name !== '');
35
+ const title = noun.charAt(0).toUpperCase() + noun.slice(1);
36
+ return names.length > 0 ? `${title}: ${names.join(', ')}` : title;
37
+ }
38
+ /**
39
+ * The consumer's non-blank `ariaLabel`, else the derived fallback. `unknown`
40
+ * because the prop is optional and an element attribute can hand over anything.
41
+ */
42
+ export function resolveChartName(ariaLabel, fallback) {
43
+ return typeof ariaLabel === 'string' && ariaLabel.trim() !== '' ? ariaLabel : fallback;
44
+ }
@@ -8,5 +8,7 @@ export declare function measureText(text: string, font: FontSpec): {
8
8
  width: number;
9
9
  height: number;
10
10
  };
11
+ /** A loaded font can replace its fallback without changing the font cache key. */
12
+ export declare function invalidateTextMeasurements(): void;
11
13
  /** Reads a px-valued CSS custom property off an element, with an SSR-safe fallback. */
12
14
  export declare function readCssVarPx(el: Element | null, name: string, fallback: number): number;
@@ -36,6 +36,10 @@ export function measureText(text, font) {
36
36
  cache.set(key, width);
37
37
  return { width, height };
38
38
  }
39
+ /** A loaded font can replace its fallback without changing the font cache key. */
40
+ export function invalidateTextMeasurements() {
41
+ cache.clear();
42
+ }
39
43
  /** Reads a px-valued CSS custom property off an element, with an SSR-safe fallback. */
40
44
  export function readCssVarPx(el, name, fallback) {
41
45
  if (typeof window === 'undefined' || el === null) {
@@ -3,6 +3,10 @@ type Size = {
3
3
  width: number;
4
4
  height: number;
5
5
  };
6
+ type Rect = Size & {
7
+ left: number;
8
+ top: number;
9
+ };
6
10
  /**
7
11
  * Pure tooltip placement: anchor mode positions relative to a data point with
8
12
  * side-flipping; cursor mode follows the pointer with flip-then-clamp on both
@@ -15,6 +19,8 @@ export declare function computeTooltipPosition(opts: {
15
19
  tooltip: Size;
16
20
  container: Size;
17
21
  offset?: number;
22
+ /** Painted label plates in the same coordinate space as the anchor. */
23
+ avoidRects?: Rect[];
18
24
  }): {
19
25
  left: number;
20
26
  top: number;