@juspay/svelte-ui-components 4.27.6 → 4.28.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 (164) hide show
  1. package/README.md +140 -36
  2. package/dist/Animations/ModalAnimation.svelte +15 -1
  3. package/dist/Animations/OverlayAnimation.svelte +7 -1
  4. package/dist/AreaChart/AreaChart.svelte +366 -85
  5. package/dist/AspectRatio/AspectRatio.svelte +51 -0
  6. package/dist/AspectRatio/AspectRatio.svelte.d.ts +3 -0
  7. package/dist/AspectRatio/properties.d.ts +27 -0
  8. package/dist/AspectRatio/properties.js +1 -0
  9. package/dist/Banner/Banner.svelte +10 -4
  10. package/dist/Banner/properties.d.ts +8 -0
  11. package/dist/BarChart/BarChart.svelte +156 -49
  12. package/dist/Book/Book.svelte +14 -1
  13. package/dist/BrandLoader/BrandLoader.svelte +76 -12
  14. package/dist/Button/Button.svelte +56 -11
  15. package/dist/Calendar/Calendar.svelte +88 -61
  16. package/dist/Card/Card.svelte +1 -1
  17. package/dist/Carousel/Carousel.svelte +6 -3
  18. package/dist/ChatComposer/ChatComposer.svelte +54 -4
  19. package/dist/ChatComposer/dictationState.d.ts +15 -0
  20. package/dist/ChatComposer/dictationState.js +9 -0
  21. package/dist/ChatComposer/properties.d.ts +28 -1
  22. package/dist/ChatMessage/ChatMessage.svelte +27 -1
  23. package/dist/CheckListItem/CheckListItem.svelte +3 -3
  24. package/dist/Checkbox/Checkbox.svelte +84 -3
  25. package/dist/Checkbox/properties.d.ts +45 -13
  26. package/dist/ChipInput/ChipInput.svelte +70 -3
  27. package/dist/ChipInput/properties.d.ts +16 -0
  28. package/dist/Choicebox/Choicebox.svelte +156 -1
  29. package/dist/Choicebox/group.d.ts +63 -0
  30. package/dist/Choicebox/group.js +127 -0
  31. package/dist/Choicebox/properties.d.ts +46 -0
  32. package/dist/ColorPicker/ColorPicker.svelte +169 -20
  33. package/dist/ColorPicker/properties.d.ts +16 -0
  34. package/dist/Combobox/Combobox.svelte +87 -25
  35. package/dist/Combobox/properties.d.ts +23 -0
  36. package/dist/CommandMenu/CommandMenu.svelte +27 -11
  37. package/dist/ContextMenu/ContextMenu.svelte +31 -37
  38. package/dist/DateRangePicker/DateRangePicker.svelte +171 -73
  39. package/dist/DeltaIndicator/DeltaIndicator.svelte +3 -3
  40. package/dist/Draggable/Draggable.svelte +12 -7
  41. package/dist/DualAxisBarChart/DualAxisBarChart.svelte +11 -4
  42. package/dist/EmptyState/EmptyState.svelte +1 -1
  43. package/dist/FileDropzoneTrigger/FileDropzoneTrigger.svelte +1 -1
  44. package/dist/FileInput/FileInput.svelte +50 -1
  45. package/dist/FileInput/properties.d.ts +7 -0
  46. package/dist/FunnelChart/FunnelChart.svelte +40 -12
  47. package/dist/FunnelChart/properties.d.ts +7 -1
  48. package/dist/Gallery/Gallery.svelte +20 -13
  49. package/dist/Gallery/properties.d.ts +8 -0
  50. package/dist/GridItem/GridItem.svelte +31 -1
  51. package/dist/HITL/HITL.svelte +35 -9
  52. package/dist/Icon/Icon.svelte +19 -3
  53. package/dist/Input/Input.svelte +24 -10
  54. package/dist/Input/properties.d.ts +37 -1
  55. package/dist/InputButton/InputButton.svelte +86 -11
  56. package/dist/InputButton/properties.d.ts +10 -1
  57. package/dist/Label/Label.svelte +54 -0
  58. package/dist/Label/Label.svelte.d.ts +3 -0
  59. package/dist/Label/properties.d.ts +26 -0
  60. package/dist/Label/properties.js +1 -0
  61. package/dist/LineChart/LineChart.svelte +385 -94
  62. package/dist/ListItem/ListItem.svelte +121 -15
  63. package/dist/Loader/Loader.svelte +22 -2
  64. package/dist/LoadingDots/LoadingDots.svelte +21 -0
  65. package/dist/MediaUpload/MediaUpload.svelte +13 -3
  66. package/dist/Menu/Menu.svelte +56 -28
  67. package/dist/Modal/Modal.svelte +77 -42
  68. package/dist/Pagination/Pagination.svelte +9 -2
  69. package/dist/Phone/Phone.svelte +4 -0
  70. package/dist/PieChart/PieChart.svelte +282 -38
  71. package/dist/PieChart/properties.d.ts +9 -0
  72. package/dist/Pill/Pill.svelte +14 -5
  73. package/dist/Progress/Progress.svelte +20 -0
  74. package/dist/Radio/Radio.svelte +58 -1
  75. package/dist/Radio/properties.d.ts +23 -0
  76. package/dist/RatingGroup/RatingGroup.svelte +283 -0
  77. package/dist/RatingGroup/RatingGroup.svelte.d.ts +4 -0
  78. package/dist/RatingGroup/properties.d.ts +73 -0
  79. package/dist/RatingGroup/properties.js +1 -0
  80. package/dist/RelativeTime/RelativeTime.svelte +1 -1
  81. package/dist/SankeyChart/SankeyChart.svelte +272 -50
  82. package/dist/SankeyChart/properties.d.ts +9 -0
  83. package/dist/Scroller/Scroller.svelte +37 -2
  84. package/dist/Select/Select.svelte +166 -46
  85. package/dist/Select/properties.d.ts +7 -0
  86. package/dist/Separator/Separator.svelte +48 -0
  87. package/dist/Separator/Separator.svelte.d.ts +3 -0
  88. package/dist/Separator/properties.d.ts +22 -0
  89. package/dist/Separator/properties.js +1 -0
  90. package/dist/Sheet/Sheet.svelte +60 -21
  91. package/dist/Shimmer/Shimmer.svelte +15 -0
  92. package/dist/Slider/Slider.svelte +87 -6
  93. package/dist/Slider/properties.d.ts +26 -0
  94. package/dist/Snippet/Snippet.svelte +4 -0
  95. package/dist/Snippet/copyState.svelte.d.ts +13 -1
  96. package/dist/Snippet/copyState.svelte.js +27 -1
  97. package/dist/Snippet/properties.d.ts +7 -0
  98. package/dist/SoundKit/SoundKit.js +9 -8
  99. package/dist/SplitInput/SplitInput.svelte +68 -2
  100. package/dist/SplitInput/properties.d.ts +23 -0
  101. package/dist/StatCard/StatCard.svelte +6 -6
  102. package/dist/Status/Status.svelte +2 -2
  103. package/dist/Stepper/Step.svelte +19 -6
  104. package/dist/Stepper/Stepper.svelte +108 -35
  105. package/dist/Table/BuiltinCell.svelte +2 -2
  106. package/dist/Table/Table.svelte +373 -55
  107. package/dist/Table/properties.d.ts +134 -3
  108. package/dist/Tabs/Tabs.svelte +263 -65
  109. package/dist/Tabs/properties.d.ts +23 -0
  110. package/dist/TaskList/TaskList.svelte +14 -5
  111. package/dist/ThemeSwitcher/ThemeSwitcher.svelte +23 -0
  112. package/dist/ThinkingIndicator/ThinkingIndicator.svelte +13 -5
  113. package/dist/Toast/Toast.svelte +27 -2
  114. package/dist/Toggle/Toggle.svelte +79 -7
  115. package/dist/Toggle/Toggle.svelte.d.ts +1 -1
  116. package/dist/Toggle/properties.d.ts +30 -7
  117. package/dist/ToolCallLog/ToolCallLog.svelte +59 -21
  118. package/dist/ToolCallLog/properties.d.ts +10 -0
  119. package/dist/Toolbar/Toolbar.svelte +1 -1
  120. package/dist/Tooltip/Tooltip.svelte +20 -2
  121. package/dist/Tooltip/tooltip-action.js +39 -4
  122. package/dist/TypewriterText/TypewriterText.svelte +34 -2
  123. package/dist/TypewriterText/properties.d.ts +18 -5
  124. package/dist/_chart/ChartContainer.svelte +12 -0
  125. package/dist/_chart/ChartTooltip.svelte +18 -4
  126. package/dist/_chart/Legend.svelte +21 -0
  127. package/dist/_chart/Legend.svelte.d.ts +15 -0
  128. package/dist/_chart/geometry.d.ts +54 -1
  129. package/dist/_chart/geometry.js +122 -14
  130. package/dist/_chart/interactions.d.ts +5 -1
  131. package/dist/_chart/interactions.js +9 -5
  132. package/dist/_chart/scales.js +16 -0
  133. package/dist/_chart/temporal.d.ts +45 -0
  134. package/dist/_chart/temporal.js +116 -0
  135. package/dist/_field/description.d.ts +50 -0
  136. package/dist/_field/description.js +51 -0
  137. package/dist/_interaction/dismissal.d.ts +67 -0
  138. package/dist/_interaction/dismissal.js +137 -0
  139. package/dist/_interaction/focus.d.ts +73 -0
  140. package/dist/_interaction/focus.js +143 -0
  141. package/dist/assets/star.svg +5 -0
  142. package/dist/index.d.ts +13 -1
  143. package/dist/index.js +21 -1
  144. package/dist/reduced-motion.svelte.d.ts +37 -0
  145. package/dist/reduced-motion.svelte.js +44 -0
  146. package/dist/styles/theme-dark.css +778 -0
  147. package/dist/types.d.ts +4 -2
  148. package/dist/utils.d.ts +62 -1
  149. package/dist/utils.js +80 -21
  150. package/dist-codemod/{bin.js → codemod/bin.js} +13 -2
  151. package/dist-codemod/{cli.js → codemod/cli.js} +48 -3
  152. package/dist-codemod/codemod/migrate-audit.js +395 -0
  153. package/dist-codemod/codemod/migration-assets/legacy-display.css +120 -0
  154. package/dist-codemod/codemod/migration-assets/legacy-palette.css +106 -0
  155. package/dist-codemod/codemod/migration-assets/wc-display.json +492 -0
  156. package/dist-codemod/migrate/analyze.js +804 -0
  157. package/dist-codemod/migrate/chart-tooltip-selector.js +301 -0
  158. package/dist-wc/index.js +13992 -4323
  159. package/package.json +13 -6
  160. package/dist/Modal/focus-trap.d.ts +0 -42
  161. package/dist/Modal/focus-trap.js +0 -57
  162. /package/dist-codemod/{legacy-pairs.js → codemod/legacy-pairs.js} +0 -0
  163. /package/dist-codemod/{transform.js → codemod/transform.js} +0 -0
  164. /package/dist-codemod/{wc-children.js → codemod/wc-children.js} +0 -0
@@ -142,7 +142,14 @@
142
142
  font-size: var(--pagination-button-font-size, 14px);
143
143
  font-weight: var(--pagination-button-font-weight, 400);
144
144
  font-family: var(--pagination-button-font-family, inherit);
145
- color: var(--pagination-button-color, #3a4550);
145
+ /* Base state (and .load-more-button below) pairs its text with a
146
+ transparent background, not one of its own -- same shape as the table
147
+ title bug. A hardcoded literal here doesn't track a dark ambient
148
+ surface, so prev/next/page buttons went unreadable on a themed dark
149
+ page even though .active and :hover are safe (each pairs its colour
150
+ with its own opaque background). Inheriting keeps it tied to whatever
151
+ text colour the surrounding surface already resolved to. */
152
+ color: var(--pagination-button-color, inherit);
146
153
  background: var(--pagination-button-background, transparent);
147
154
  border: var(--pagination-button-border, 1px solid #d1d5db);
148
155
  border-radius: var(--pagination-button-border-radius, var(--radius, 4px));
@@ -170,7 +177,7 @@
170
177
  .load-more-button {
171
178
  width: var(--pagination-load-more-width, auto);
172
179
  padding: var(--pagination-load-more-padding, 6px 14px);
173
- color: var(--pagination-load-more-color, #3a4550);
180
+ color: var(--pagination-load-more-color, inherit);
174
181
  background: var(--pagination-load-more-background, transparent);
175
182
  border-color: var(--pagination-load-more-border-color, #d1d5db);
176
183
  }
@@ -98,6 +98,10 @@
98
98
  position: relative;
99
99
  border-radius: var(--phone-screen-border-radius, 38px);
100
100
  background: var(--phone-screen-background, #000);
101
+ /* The screen is black by default, so it owns the ink on it too. Without
102
+ this, slotted content inherits the surrounding page's colour -- which on
103
+ a light page is dark text on a black screen (2.04:1). */
104
+ color: var(--phone-screen-color, #f5f5f5);
101
105
  overflow: hidden;
102
106
  display: flex;
103
107
  flex-direction: column;
@@ -7,13 +7,14 @@
7
7
  import Legend from '../_chart/Legend.svelte';
8
8
  import DeltaIndicator from '../DeltaIndicator/DeltaIndicator.svelte';
9
9
  import { arcPath } from '../_chart/paths';
10
- import { computePieLayout } from '../_chart/geometry';
10
+ import { computePieLayout, pieSliceValue } from '../_chart/geometry';
11
11
  import { getColor } from '../_chart/colors';
12
12
  import { formatNumber } from '../_chart/format';
13
13
  import { measureText, readCssVarPx } from '../_chart/measure';
14
+ import { pointerPositionIn, dismissOnOutsidePointerDown } from '../_chart/interactions';
14
15
  import { truncateToWidth, placedLabelRect, dropOverlapping } from '../_chart/labels';
15
16
  import type { LabelRect } from '../_chart/labels';
16
- import type { LegendItem } from '../_chart/types';
17
+ import type { LegendItem, TooltipAnchor } from '../_chart/types';
17
18
  import { SvelteMap } from 'svelte/reactivity';
18
19
 
19
20
  // ── Props ──────────────────────────────────────────────────────
@@ -38,6 +39,7 @@
38
39
  onslicehover,
39
40
  testId,
40
41
  classes,
42
+ tooltipPortal = false,
41
43
  semiCircle = false,
42
44
  legendShowValues = false,
43
45
  legendPosition = 'bottom',
@@ -57,6 +59,7 @@
57
59
  let chartWidth = $state(0);
58
60
  let chartHeight = $state(0);
59
61
  let hoveredIndex = $state<number | null>(null);
62
+ let focusedIndex = $state<number | null>(null);
60
63
  let programmaticIndex = $state<number | null>(null);
61
64
  let mouseX = $state(0);
62
65
  let mouseY = $state(0);
@@ -87,14 +90,34 @@
87
90
  });
88
91
 
89
92
  // ── Active index ───────────────────────────────────────────────
90
- // Precedence: mouse hover > declarative highlightedIndex prop > imperative API.
91
-
92
- let activeIndex = $derived<number | null>(hoveredIndex ?? highlightedIndex ?? programmaticIndex);
93
+ // Precedence: keyboard focus > mouse hover > declarative highlightedIndex
94
+ // prop > imperative API.
95
+ //
96
+ // hoveredIndex and focusedIndex are deliberately separate pieces of state
97
+ // (not one index shared by both input sources): a pointer leaving a slice
98
+ // must never erase that slice's focus, and a late/stale pointerleave --
99
+ // which real browsers fire *after* a focus event when focusing an
100
+ // off-screen slice auto-scrolls the page under an unmoved cursor -- must
101
+ // not be able to clobber the focus highlight either. Keeping the two
102
+ // states apart makes that impossible by construction rather than by event
103
+ // ordering. Focus wins outright when both are set: hovering slice A while
104
+ // slice B holds focus keeps B highlighted (focus is the sticky, "current"
105
+ // state a keyboard user is tracking; a stray pointer position elsewhere
106
+ // must not steal it), and A regains hover feedback the moment focus moves
107
+ // off B.
108
+ let pointerOrFocusIndex = $derived<number | null>(focusedIndex ?? hoveredIndex);
109
+ let activeIndex = $derived<number | null>(
110
+ pointerOrFocusIndex ?? highlightedIndex ?? programmaticIndex
111
+ );
93
112
 
94
113
  // ── Layout ─────────────────────────────────────────────────────
95
114
 
96
115
  let format = $derived(valueFormat ?? formatNumber);
97
- let total = $derived(data.reduce((sum, d) => sum + Math.max(0, d.value), 0));
116
+ // Mirrors computePieLayout's own total exactly (both use pieSliceValue) --
117
+ // this duplicate exists only because isEmpty/pctFormat need it before
118
+ // slices are computed; two independently-guarded reduces here is what let
119
+ // the NaN defect survive a first pass that fixed only geometry.ts.
120
+ let total = $derived(data.reduce((sum, d) => sum + pieSliceValue(d.value), 0));
98
121
  let pctFormat = $derived.by(
99
122
  () =>
100
123
  (v: number): string =>
@@ -299,15 +322,25 @@
299
322
 
300
323
  // ── Tooltip ────────────────────────────────────────────────────
301
324
 
302
- // Tooltip visibility tracks hover only. activeIndex also covers declarative/imperative
303
- // highlights, but those arrive without mouse coordinates, so a tooltip driven by them
304
- // would render at the top-left (mouseX/mouseY still 0). Highlight styling uses
305
- // activeIndex; the tooltip stays gated on hoveredIndex.
325
+ // Looks a slice up by its stable `index` field rather than array position --
326
+ // `slices` is derived from `data` and its ordering is not a contract, while
327
+ // `activeIndex`/`hoveredIndex` are always original-data indices (see the
328
+ // aria-label and legend, which key off the same index).
329
+ function sliceAt(i: number) {
330
+ return slices.find((s) => s.index === i) ?? null;
331
+ }
332
+
333
+ // Tooltip visibility tracks hover-or-focus (pointerOrFocusIndex), not the
334
+ // declarative highlightedIndex prop or the imperative API: those arrive
335
+ // without mouse coordinates, so a tooltip driven by them would render at
336
+ // the top-left (mouseX/mouseY still 0). Highlight styling uses activeIndex
337
+ // (which also covers those two lower-precedence sources); the tooltip
338
+ // stays gated on direct interaction only.
306
339
  let tooltipData = $derived.by(() => {
307
- if (hoveredIndex === null || !slices[hoveredIndex]) {
340
+ if (pointerOrFocusIndex === null || !slices[pointerOrFocusIndex]) {
308
341
  return null;
309
342
  }
310
- const s = slices[hoveredIndex];
343
+ const s = slices[pointerOrFocusIndex];
311
344
  return {
312
345
  title: s.label,
313
346
  items: [
@@ -320,27 +353,127 @@
320
353
  };
321
354
  });
322
355
 
356
+ /**
357
+ * Data-space anchor at the slice's own mid-angle, independent of pointer
358
+ * position. Legend hover and keyboard focus have no cursor coordinates, so
359
+ * an anchor keeps the tooltip landing in the same place a pointer hover
360
+ * would -- one contract for all three activation paths.
361
+ */
362
+ let anchor = $derived.by<TooltipAnchor | null>(() => {
363
+ if (pointerOrFocusIndex === null) {
364
+ return null;
365
+ }
366
+ const slice = sliceAt(pointerOrFocusIndex);
367
+ if (slice === null) {
368
+ return null;
369
+ }
370
+ const midR = (innerR + outerR) / 2;
371
+ return {
372
+ x: cx + midR * Math.cos(slice.midAngle),
373
+ y: cy + midR * Math.sin(slice.midAngle),
374
+ side: Math.sin(slice.midAngle) <= 0 ? 'top' : 'bottom'
375
+ };
376
+ });
377
+
378
+ // ── Assistive-tech status region ───────────────────────────────
379
+ // Mirrors the tooltip's exact text (value AND percentage) into a live region
380
+ // every slice is described-by, so a screen reader user gets the same detail a
381
+ // pointer hover shows without needing pointer hover -- and, because it reads
382
+ // activeIndex rather than hoveredIndex, it also announces the declarative
383
+ // highlightedIndex prop and the imperative ChartHighlightAPI, neither of
384
+ // which fires a focus event of its own.
385
+ const statusId = `pie-status-${instanceId}`;
386
+ let statusText = $derived.by(() => {
387
+ if (activeIndex === null) {
388
+ return '';
389
+ }
390
+ const s = sliceAt(activeIndex);
391
+ return s === null ? '' : `${s.label}: ${format(s.value)} (${pctFormat(s.value)})`;
392
+ });
393
+
323
394
  // ── Interactions ───────────────────────────────────────────────
395
+ // Family-wide contract shared with BarChart/FunnelChart/DualAxisBarChart:
396
+ // pointer hover and keyboard focus feed SEPARATE state (hoveredIndex /
397
+ // focusedIndex -- see activeIndex above for why), read together as one
398
+ // "direct interaction" precedence tier ahead of the declarative
399
+ // highlightedIndex prop and the imperative ChartHighlightAPI. Legend
400
+ // pointer/focus events drive the exact same two variables a slice's own
401
+ // events would, so a legend row is just another entry point into the same
402
+ // state, not a third source. Enter/Space on a focused slice invokes the
403
+ // same click callback a pointer click would.
324
404
 
325
405
  function trackMouse(e: MouseEvent) {
326
- if (containerEl === null) {
406
+ const position = pointerPositionIn(containerEl, e);
407
+ if (position !== null) {
408
+ mouseX = position.x;
409
+ mouseY = position.y;
410
+ }
411
+ }
412
+
413
+ // onslicehover mirrors the combined pointer-or-focus index, once per
414
+ // actual change -- e.g. a pointer leaving a slice that is still
415
+ // keyboard-focused must not report a hover-cleared event for a highlight
416
+ // that never went away.
417
+ let lastNotifiedIndex: number | null = null;
418
+ function notifyHoverChange(): void {
419
+ const next = pointerOrFocusIndex;
420
+ if (next === lastNotifiedIndex) {
327
421
  return;
328
422
  }
329
- const rect = containerEl.getBoundingClientRect();
330
- mouseX = e.clientX - rect.left;
331
- mouseY = e.clientY - rect.top;
423
+ lastNotifiedIndex = next;
424
+ onslicehover?.(next === null ? null : { index: next, slice: data[next] });
332
425
  }
333
426
 
334
- function handleEnter(e: MouseEvent, i: number) {
427
+ function handlePointerEnter(e: MouseEvent, i: number) {
335
428
  hoveredIndex = i;
336
429
  trackMouse(e);
337
- onslicehover?.({ index: i, slice: data[i] });
430
+ notifyHoverChange();
431
+ }
432
+
433
+ // Legend rows have no SVG-space mouse coordinates to track, unlike a slice.
434
+ function handleLegendHover(i: number) {
435
+ hoveredIndex = i;
436
+ notifyHoverChange();
338
437
  }
339
438
 
340
- function handleLeave() {
439
+ // Wired to pointerleave/mouseleave on both slices and legend rows. Clears
440
+ // ONLY the hover half of the state -- never focusedIndex -- so a late,
441
+ // stale pointerleave (see activeIndex's comment) cannot erase a slice's
442
+ // focus highlight.
443
+ function handlePointerLeave() {
341
444
  hoveredIndex = null;
342
- onslicehover?.(null);
445
+ notifyHoverChange();
446
+ }
447
+
448
+ // Wired to focus on both slices and legend rows.
449
+ function handleFocus(i: number) {
450
+ focusedIndex = i;
451
+ notifyHoverChange();
452
+ }
453
+
454
+ // Wired to blur on both slices and legend rows. Clears ONLY the focus
455
+ // half; a pointer that happens to still be over the slice keeps it
456
+ // hovered.
457
+ function handleBlur() {
458
+ focusedIndex = null;
459
+ notifyHoverChange();
343
460
  }
461
+
462
+ function handleKeydown(e: KeyboardEvent, i: number) {
463
+ if (e.key === 'Enter' || e.key === ' ') {
464
+ e.preventDefault();
465
+ onsliceclick?.({ index: i, slice: data[i] });
466
+ }
467
+ }
468
+
469
+ // Touch taps have no pointerleave/blur: dismiss when a pointerdown lands outside.
470
+ // eslint-disable-next-line no-restricted-syntax
471
+ $effect(() => {
472
+ if (hoveredIndex === null) {
473
+ return;
474
+ }
475
+ return dismissOnOutsidePointerDown(containerEl, handlePointerLeave);
476
+ });
344
477
  </script>
345
478
 
346
479
  <div
@@ -353,8 +486,40 @@
353
486
  {#if isEmpty && typeof empty === 'function'}
354
487
  <div class="chart-empty">{@render empty()}</div>
355
488
  {:else}
489
+ <!-- Mirrors the tooltip's own text for every activation path (pointer, focus,
490
+ declarative highlightedIndex, imperative ChartHighlightAPI) -- see statusText. -->
491
+ <div class="sr-only" role="status" aria-live="polite" id={statusId} data-pw="pie-status">
492
+ {statusText}
493
+ </div>
494
+
356
495
  {#if showLegend && !legendShowValues}
357
- <Legend items={legendItems} position="top" />
496
+ <Legend items={legendItems} position="top">
497
+ {#snippet customSnippet(syncItems)}
498
+ <!-- Synchronized legend recipe: hovering/focusing an item highlights its
499
+ slice via the same hoveredIndex a pointer hover over the slice itself
500
+ would set -- highlighting, not a visibility toggle (see Legend.svelte's
501
+ separate onToggle path, used by Bar/DualAxis to show/hide a series). -->
502
+ <div class="pie-legend-sync">
503
+ {#each syncItems as item, i (i)}
504
+ <button
505
+ type="button"
506
+ class="legend-item pie-legend-sync-item"
507
+ class:legend-sync-active={activeIndex === i}
508
+ data-pw={`pie-legend-sync-${i}`}
509
+ testID={`pie-legend-sync-${i}`}
510
+ onpointerenter={() => handleLegendHover(i)}
511
+ onpointerleave={handlePointerLeave}
512
+ onfocus={() => handleFocus(i)}
513
+ onblur={handleBlur}
514
+ onclick={() => onsliceclick?.({ index: i, slice: data[i] })}
515
+ >
516
+ <span class="legend-swatch" style="background: {item.color}"></span>
517
+ <span class="legend-label">{item.label}</span>
518
+ </button>
519
+ {/each}
520
+ </div>
521
+ {/snippet}
522
+ </Legend>
358
523
  {/if}
359
524
 
360
525
  <ChartContainer
@@ -366,18 +531,22 @@
366
531
  >
367
532
  <g transform="translate({cx}, {cy})">
368
533
  {#each slices as slice (slice.index)}
369
- <!-- svelte-ignore a11y_no_static_element_interactions -->
370
- <!-- svelte-ignore a11y_click_events_have_key_events -->
371
534
  <path
372
535
  class="slice"
373
536
  class:hovered={activeIndex === slice.index}
374
537
  class:dimmed={activeIndex !== null && activeIndex !== slice.index}
375
538
  d={slice.path}
376
539
  fill={slice.color}
540
+ tabindex="0"
541
+ role="button"
377
542
  aria-label="{slice.label}: {format(slice.value)}"
378
- onmouseenter={(e) => handleEnter(e, slice.index)}
543
+ aria-describedby={statusId}
544
+ onmouseenter={(e) => handlePointerEnter(e, slice.index)}
379
545
  onmousemove={trackMouse}
380
- onmouseleave={handleLeave}
546
+ onmouseleave={handlePointerLeave}
547
+ onfocus={() => handleFocus(slice.index)}
548
+ onblur={handleBlur}
549
+ onkeydown={(e) => handleKeydown(e, slice.index)}
381
550
  onclick={() => onsliceclick?.({ index: slice.index, slice: data[slice.index] })}
382
551
  />
383
552
  {#if visibleSliceLabels.has(slice.index)}
@@ -421,7 +590,7 @@
421
590
  <span class="pie-legend-swatch" style="background: {d.color ?? getColor(i)}"></span>
422
591
  <span class="pie-legend-label">{d.label}</span>
423
592
  <span class="pie-legend-value">
424
- {format(d.value)}&nbsp;{pctFormat(d.value)}
593
+ {format(pieSliceValue(d.value))}&nbsp;{pctFormat(pieSliceValue(d.value))}
425
594
  </span>
426
595
  </li>
427
596
  {/each}
@@ -440,12 +609,31 @@
440
609
  </div>
441
610
  {/if}
442
611
 
443
- {#if typeof tooltipSnippet === 'function' && hoveredIndex !== null && data[hoveredIndex]}
444
- <div class="chart-tooltip-slot" style="left: {mouseX + 12}px; top: {mouseY - 12}px;">
445
- {@render tooltipSnippet(data[hoveredIndex], hoveredIndex)}
446
- </div>
612
+ {#if typeof tooltipSnippet === 'function'}
613
+ <ChartTooltip
614
+ data={tooltipData}
615
+ {mouseX}
616
+ {mouseY}
617
+ {anchor}
618
+ portal={tooltipPortal}
619
+ originEl={containerEl}
620
+ unstyled
621
+ >
622
+ {#snippet content()}
623
+ {#if pointerOrFocusIndex !== null && data[pointerOrFocusIndex]}
624
+ {@render tooltipSnippet(data[pointerOrFocusIndex], pointerOrFocusIndex)}
625
+ {/if}
626
+ {/snippet}
627
+ </ChartTooltip>
447
628
  {:else}
448
- <ChartTooltip data={tooltipData} {mouseX} {mouseY} />
629
+ <ChartTooltip
630
+ data={tooltipData}
631
+ {mouseX}
632
+ {mouseY}
633
+ {anchor}
634
+ portal={tooltipPortal}
635
+ originEl={containerEl}
636
+ />
449
637
  {/if}
450
638
  {/if}
451
639
  </div>
@@ -459,8 +647,10 @@
459
647
  stroke: var(--piechart-stroke-color, #fff);
460
648
  stroke-width: var(--piechart-stroke-width, 2);
461
649
  transition:
462
- transform var(--chart-transition-duration, 0.2s) ease,
463
- opacity var(--chart-transition-duration, 0.2s) ease;
650
+ transform var(--chart-transition-duration, var(--motion-duration, 0.2s))
651
+ var(--chart-transition-easing, var(--motion-easing, ease)),
652
+ opacity var(--chart-transition-duration, var(--motion-duration, 0.2s))
653
+ var(--chart-transition-easing, var(--motion-easing, ease));
464
654
  transform-origin: 0 0;
465
655
  cursor: pointer;
466
656
  }
@@ -471,6 +661,10 @@
471
661
  .slice.dimmed {
472
662
  opacity: var(--piechart-dimmed-opacity, 0.3);
473
663
  }
664
+ .slice:focus-visible {
665
+ outline: var(--piechart-slice-focus-outline, 2px solid currentColor);
666
+ outline-offset: var(--piechart-slice-focus-outline-offset, 2px);
667
+ }
474
668
  .slice-label {
475
669
  fill: var(--piechart-label-color, #333);
476
670
  font-size: var(--piechart-label-font-size, 12px);
@@ -495,16 +689,66 @@
495
689
  z-index: 2;
496
690
  pointer-events: none;
497
691
  }
498
- .chart-tooltip-slot {
499
- position: absolute;
500
- z-index: 10;
501
- pointer-events: none;
502
- }
503
692
  .chart-empty {
504
693
  padding: var(--chart-empty-padding, 32px 24px);
505
694
  color: var(--chart-empty-color, #9ca3af);
506
695
  text-align: center;
507
696
  }
697
+ /* Standard visually-hidden recipe (matches ChatComposer's .sr-only): present
698
+ for assistive tech, removed from layout and the visual canvas. */
699
+ .sr-only {
700
+ position: absolute;
701
+ width: 1px;
702
+ height: 1px;
703
+ padding: 0;
704
+ overflow: hidden;
705
+ clip: rect(0, 0, 0, 0);
706
+ clip-path: inset(50%);
707
+ white-space: nowrap;
708
+ border-width: 0;
709
+ }
710
+ .pie-legend-sync {
711
+ display: flex;
712
+ flex-wrap: wrap;
713
+ align-items: center;
714
+ justify-content: center;
715
+ gap: var(--chart-legend-gap, 16px);
716
+ font-family: var(--chart-font-family, inherit);
717
+ padding: 8px 0;
718
+ }
719
+ .pie-legend-sync-item {
720
+ display: flex;
721
+ align-items: center;
722
+ gap: 6px;
723
+ background: none;
724
+ border: none;
725
+ margin: 0;
726
+ padding: var(--piechart-legend-sync-padding, 2px 4px);
727
+ font: inherit;
728
+ cursor: var(--cursor, pointer);
729
+ border-radius: var(--piechart-legend-sync-radius, var(--radius, 4px));
730
+ }
731
+ .pie-legend-sync-item .legend-swatch {
732
+ display: inline-block;
733
+ width: var(--chart-legend-swatch-size, 12px);
734
+ height: var(--chart-legend-swatch-size, 12px);
735
+ border-radius: var(--chart-swatch-radius, 2px);
736
+ flex-shrink: 0;
737
+ }
738
+ .pie-legend-sync-item .legend-label {
739
+ font-size: var(--chart-legend-font-size, 12px);
740
+ color: var(--chart-legend-color, light-dark(#333, #e5e7eb));
741
+ }
742
+ .pie-legend-sync-item.legend-sync-active {
743
+ background: var(
744
+ --piechart-legend-sync-active-background,
745
+ light-dark(rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.1))
746
+ );
747
+ }
748
+ .pie-legend-sync-item:focus-visible {
749
+ outline: var(--piechart-legend-sync-focus-outline, 2px solid currentColor);
750
+ outline-offset: 2px;
751
+ }
508
752
  .pie-legend-values {
509
753
  display: flex;
510
754
  flex-direction: column;
@@ -29,6 +29,15 @@ export type OptionalPieChartProperties = {
29
29
  minHeight?: number;
30
30
  valueFormat?: (value: number) => string;
31
31
  tooltipSnippet?: Snippet<[PieChartSlice, number]>;
32
+ /**
33
+ * Render the tooltip into the chart's own root (its shadow root when hosted as a
34
+ * custom element, `document.body` otherwise) with `position: fixed`, clamped to
35
+ * the viewport, instead of positioned inside the chart. Use it when the chart
36
+ * sits inside a container with `overflow: hidden`/`scroll` that would otherwise
37
+ * clip the tooltip. Defaults to `false`. Has no effect on `tooltipSnippet`'s
38
+ * content, only on where the tooltip wrapper renders.
39
+ */
40
+ tooltipPortal?: boolean;
32
41
  center?: Snippet;
33
42
  empty?: Snippet;
34
43
  testId?: string;
@@ -148,8 +148,11 @@
148
148
  justify-content: var(--pill-justify-content, center);
149
149
  text-align: var(--pill-text-align, center);
150
150
  gap: var(--pill-gap, 4px);
151
- background-color: var(--pill-background, var(--_pill-tone-background, #e0e0e0));
152
- color: var(--pill-color, var(--_pill-tone-color, #333333));
151
+ background-color: var(
152
+ --pill-background,
153
+ var(--_pill-tone-background, var(--pill-tone-neutral-background, #e0e0e0))
154
+ );
155
+ color: var(--pill-color, var(--_pill-tone-color, var(--pill-tone-neutral-color, #333333)));
153
156
  font-size: var(--pill-font-size, 13px);
154
157
  font-weight: var(--pill-font-weight, 500);
155
158
  font-family: var(--pill-font-family);
@@ -215,15 +218,21 @@
215
218
  }
216
219
  .tone-muted {
217
220
  --_pill-tone-background: var(--pill-tone-muted-background, #f1f1f1);
218
- --_pill-tone-color: var(--pill-tone-muted-color, #6b7280);
221
+ --_pill-tone-color: var(--pill-tone-muted-color, #5b626d);
219
222
  }
220
223
 
221
224
  .pill:hover:not(.disabled) {
222
225
  background-color: var(
223
226
  --pill-hover-background,
224
- var(--pill-background, var(--_pill-tone-background, #d0d0d0))
227
+ var(
228
+ --pill-background,
229
+ var(--_pill-tone-background, var(--pill-tone-neutral-hover-background, #d0d0d0))
230
+ )
231
+ );
232
+ color: var(
233
+ --pill-hover-color,
234
+ var(--pill-color, var(--_pill-tone-color, var(--pill-tone-neutral-hover-color, #333333)))
225
235
  );
226
- color: var(--pill-hover-color, var(--pill-color, var(--_pill-tone-color, #333333)));
227
236
  }
228
237
 
229
238
  .pill.disabled {
@@ -107,4 +107,24 @@
107
107
  transform: translateX(400%);
108
108
  }
109
109
  }
110
+ /* An indefinite animation is the case this preference exists for: it never
111
+ ends, so a user who asked the OS to minimise motion gets a permanent loop.
112
+ The animation stops, but the element must still read as "busy" -- a guard
113
+ that leaves nothing on screen, or leaves a frame that means something else,
114
+ is worse than the motion it removed. This block lives in the component's own
115
+ <style> because that is the only stylesheet that reaches inside the shadow
116
+ root a custom-element consumer gets. */
117
+ @media (prefers-reduced-motion: reduce) {
118
+ .bar.indeterminate {
119
+ /* `animation: none` alone would strand a 30%-wide bar at the left of the
120
+ track, which is indistinguishable from determinate 30% progress -- the
121
+ one reading that is actually wrong. A full-width bar at reduced opacity
122
+ says "busy, amount unknown", which is what indeterminate means and what
123
+ aria-valuetext already announces. */
124
+ width: 100%;
125
+ transform: none;
126
+ animation: none;
127
+ opacity: var(--progress-indeterminate-reduced-motion-opacity, 0.5);
128
+ }
129
+ }
110
130
  </style>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { describeField } from '../_field/description';
2
3
  import type { RadioProperties } from './properties';
3
4
 
4
5
  let {
@@ -9,9 +10,25 @@
9
10
  disabled = false,
10
11
  testId,
11
12
  onchange,
12
- classes
13
+ classes,
14
+ required = false,
15
+ form,
16
+ errorMessage,
17
+ infoMessage,
18
+ invalid = false
13
19
  }: RadioProperties = $props();
14
20
 
21
+ /* The control and the text that explains it were never linked: a screen-reader
22
+ user reaching this control heard its name and nothing about why it was
23
+ rejected. `describeField` composes the reference from the messages that are
24
+ actually rendered, so aria-describedby never points at an id that is not in
25
+ the DOM -- which passes an attribute assertion and resolves to nothing in a
26
+ real reader. */
27
+ const fieldUid = $props.id();
28
+ const field = $derived(
29
+ describeField(fieldUid, { error: errorMessage, info: infoMessage, invalid })
30
+ );
31
+
15
32
  let checked = $derived(selectedValue === value);
16
33
 
17
34
  function handleChange(): void {
@@ -31,11 +48,17 @@
31
48
  >
32
49
  <input
33
50
  type="radio"
51
+ aria-describedby={field.describedBy}
52
+ aria-invalid={field.ariaInvalid}
34
53
  class="radio-input"
35
54
  {name}
36
55
  {value}
37
56
  {checked}
38
57
  {disabled}
58
+ {required}
59
+ form={typeof form === 'string' ? form : null}
60
+ data-state={checked ? 'checked' : 'unchecked'}
61
+ data-disabled={disabled ? '' : null}
39
62
  onchange={handleChange}
40
63
  />
41
64
  <span class="radio-indicator" class:checked class:disabled>
@@ -46,6 +69,28 @@
46
69
  {/if}
47
70
  </label>
48
71
 
72
+ {#if field.showsError}
73
+ <div
74
+ id={field.errorId}
75
+ role="alert"
76
+ class="field-error"
77
+ data-pw={typeof testId === 'string' ? `${testId}-error-message` : null}
78
+ testID={typeof testId === 'string' ? `${testId}-error-message` : null}
79
+ >
80
+ {errorMessage}
81
+ </div>
82
+ {/if}
83
+ {#if field.showsInfo}
84
+ <div
85
+ id={field.infoId}
86
+ class="field-info"
87
+ data-pw={typeof testId === 'string' ? `${testId}-info-message` : null}
88
+ testID={typeof testId === 'string' ? `${testId}-info-message` : null}
89
+ >
90
+ {infoMessage}
91
+ </div>
92
+ {/if}
93
+
49
94
  <style>
50
95
  .radio-container {
51
96
  display: var(--radio-container-display, inline-flex);
@@ -126,4 +171,16 @@
126
171
  .radio-text.disabled {
127
172
  color: var(--radio-disabled-text-color, #999999);
128
173
  }
174
+
175
+ .field-error {
176
+ color: var(--field-error-color, #c5120a);
177
+ font-size: var(--field-error-font-size, 12px);
178
+ margin: var(--field-error-margin, 4px 0 0 0);
179
+ }
180
+
181
+ .field-info {
182
+ color: var(--field-info-color, #6b7280);
183
+ font-size: var(--field-info-font-size, 12px);
184
+ margin: var(--field-info-margin, 4px 0 0 0);
185
+ }
129
186
  </style>