@juspay/svelte-ui-components 4.50.0 → 4.52.0

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 (95) 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 +12 -3
  29. package/dist/LineChart/LineChart.svelte +105 -33
  30. package/dist/LineChart/properties.d.ts +12 -0
  31. package/dist/MediaPlayer/MediaPlayer.svelte +172 -2
  32. package/dist/MediaPlayer/MediaPlayer.svelte.d.ts +1 -1
  33. package/dist/MediaPlayer/properties.d.ts +31 -0
  34. package/dist/Menu/Menu.svelte +6 -4
  35. package/dist/Modal/Modal.svelte +15 -12
  36. package/dist/Modal/properties.d.ts +29 -1
  37. package/dist/Pagination/Pagination.svelte +1 -0
  38. package/dist/PieChart/PieChart.svelte +51 -29
  39. package/dist/PieChart/properties.d.ts +4 -0
  40. package/dist/Pill/Pill.svelte +5 -0
  41. package/dist/Radio/Radio.svelte +11 -2
  42. package/dist/Radio/properties.d.ts +7 -2
  43. package/dist/SankeyChart/SankeyChart.svelte +339 -211
  44. package/dist/SankeyChart/properties.d.ts +4 -0
  45. package/dist/Scroller/Scroller.svelte +247 -4
  46. package/dist/Scroller/properties.d.ts +8 -0
  47. package/dist/Select/Select.svelte +43 -5
  48. package/dist/Select/properties.d.ts +17 -1
  49. package/dist/Sheet/Sheet.svelte +31 -4
  50. package/dist/Sheet/properties.d.ts +6 -0
  51. package/dist/SplitInput/SplitInput.svelte +17 -0
  52. package/dist/SplitInput/field-name.d.ts +29 -0
  53. package/dist/SplitInput/field-name.js +50 -0
  54. package/dist/SplitInput/properties.d.ts +18 -4
  55. package/dist/Status/Status.svelte +24 -12
  56. package/dist/Status/properties.d.ts +11 -0
  57. package/dist/Table/BuiltinCell.svelte +13 -5
  58. package/dist/Table/BuiltinCell.svelte.d.ts +1 -0
  59. package/dist/Table/Table.svelte +78 -20
  60. package/dist/Table/a11y.d.ts +7 -0
  61. package/dist/Table/a11y.js +71 -0
  62. package/dist/Table/cellData.js +3 -0
  63. package/dist/Table/properties.d.ts +13 -5
  64. package/dist/ThemeSwitcher/ThemeSwitcher.svelte +4 -0
  65. package/dist/ThinkingIndicator/ThinkingIndicator.svelte +48 -9
  66. package/dist/Toast/Toast.svelte +40 -4
  67. package/dist/Toggle/Toggle.svelte +2 -0
  68. package/dist/Toggle/properties.d.ts +2 -0
  69. package/dist/_chart/Axis.svelte +10 -0
  70. package/dist/_chart/ChartContainer.svelte +32 -1
  71. package/dist/_chart/ChartTooltip.svelte +18 -3
  72. package/dist/_chart/LabelPlate.svelte +114 -0
  73. package/dist/_chart/LabelPlate.svelte.d.ts +7 -0
  74. package/dist/_chart/a11y.d.ts +29 -0
  75. package/dist/_chart/a11y.js +44 -0
  76. package/dist/_chart/measure.d.ts +2 -0
  77. package/dist/_chart/measure.js +4 -0
  78. package/dist/_chart/tooltipPosition.d.ts +6 -0
  79. package/dist/_chart/tooltipPosition.js +30 -2
  80. package/dist/_chart/types.d.ts +22 -0
  81. package/dist/_interaction/focusable.d.ts +47 -0
  82. package/dist/_interaction/focusable.js +139 -0
  83. package/dist/assets/captions-on.svg +3 -0
  84. package/dist/assets/captions.svg +5 -0
  85. package/dist/index.d.ts +1 -1
  86. package/dist/index.js +1 -1
  87. package/dist/styles/theme-dark.css +6 -1
  88. package/dist/types.d.ts +2 -0
  89. package/dist/utils.d.ts +18 -2
  90. package/dist/utils.js +73 -28
  91. package/dist-codemod/codemod/migrate-audit.js +9 -7
  92. package/dist-codemod/codemod/migration-assets/legacy-palette.css +9 -1
  93. package/dist-codemod/codemod/migration-assets-layout.js +61 -0
  94. package/dist-wc/index.js +5653 -4148
  95. 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,11 @@
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;
29
34
 
30
35
  const rootClass = $derived(
31
36
  ['toast', type ?? '', classes ?? ''].filter((cls) => cls.length > 0).join(' ')
@@ -61,6 +66,13 @@
61
66
  return distance == null ? {} : directionalOffset(resolvedDirection, distance);
62
67
  });
63
68
 
69
+ // The offsets feed `translate(var(--toast-enter-x, 0px), var(--toast-enter-y, 0px))`
70
+ // in the stylesheet, where a unitless non-zero number is invalid at
71
+ // computed-value time: the whole `transform` then computes to `none` and the
72
+ // toast stops travelling at all. Inline custom properties carry no unit of
73
+ // their own, so the px has to be written here.
74
+ const toPx = (value?: number): string | null => (typeof value === 'number' ? `${value}px` : null);
75
+
64
76
  // A `0`/`0s` CSS transition-duration never starts a transition, so it never
65
77
  // fires `transitionend`, so `ontoasthide` (below) would never fire -- floor
66
78
  // at 50ms so the transition always runs. Same reasoning as the reduced-motion
@@ -76,8 +88,30 @@
76
88
  const openDurationStyle = $derived(toDurationStyle(inAnimationDuration));
77
89
  const closeDurationStyle = $derived(toDurationStyle(outAnimationDuration));
78
90
 
91
+ function notifyHidden() {
92
+ if (!hideNotified) {
93
+ hideNotified = true;
94
+ ontoasthide?.();
95
+ }
96
+ }
97
+
79
98
  function hideToast() {
80
99
  showToast = false;
100
+ // The closed rule's `display: none` only keeps the toast on screen for its
101
+ // exit through a discrete `display` transition (`allow-discrete`). An engine
102
+ // without one -- Firefox 150, as shipped in Playwright 1.60 -- applies
103
+ // `display: none` at once, which cancels the exit before it starts, so no
104
+ // `transitionend` is ever dispatched and the consumer's `ontoasthide` cleanup
105
+ // would never run.
106
+ // If the toast is already not displayed once the closed state has applied,
107
+ // there is no exit left to wait for: report the hide now. Where the
108
+ // transition does run the toast is still displayed here, and the
109
+ // `transitionend` below remains the only path.
110
+ void tick().then(() => {
111
+ if (!showToast && rootEl !== null && getComputedStyle(rootEl).display === 'none') {
112
+ notifyHidden();
113
+ }
114
+ });
81
115
  }
82
116
 
83
117
  function handleTransitionEnd(event: TransitionEvent) {
@@ -96,7 +130,7 @@
96
130
  // correct under any reversal ordering, not just the common single-close
97
131
  // case this guard was originally written for.
98
132
  if (event.target === event.currentTarget && event.propertyName === 'opacity' && !showToast) {
99
- ontoasthide?.();
133
+ notifyHidden();
100
134
  }
101
135
  }
102
136
 
@@ -111,6 +145,7 @@
111
145
 
112
146
  untrack(() => {
113
147
  showToast = true;
148
+ hideNotified = false;
114
149
  });
115
150
 
116
151
  const id = setTimeout(hideToast, duration);
@@ -133,6 +168,7 @@
133
168
  aria-live off and back on around the reshow to force a re-announce,
134
169
  not a change to the CSS transition mechanics above. -->
135
170
  <div
171
+ bind:this={rootEl}
136
172
  class={rootClass}
137
173
  class:is-visible={showToast}
138
174
  class:no-page-overlap={!overlapPage}
@@ -141,8 +177,8 @@
141
177
  aria-live="assertive"
142
178
  style:--toast-open-duration={openDurationStyle}
143
179
  style:--toast-close-duration={closeDurationStyle}
144
- style:--toast-enter-x={enterOffset.x}
145
- style:--toast-enter-y={enterOffset.y}
180
+ style:--toast-enter-x={toPx(enterOffset.x)}
181
+ style:--toast-enter-y={toPx(enterOffset.y)}
146
182
  ontransitionend={handleTransitionEnd}
147
183
  data-pw={testId}
148
184
  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,114 @@
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
+ fill: var(--chart-label-background, light-dark(#fff, #111827));
112
+ stroke: none;
113
+ }
114
+ </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;