@juspay/svelte-ui-components 4.27.5 → 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
@@ -6,8 +6,13 @@
6
6
  import { getColor } from '../_chart/colors';
7
7
  import { formatNumber } from '../_chart/format';
8
8
  import { measureText, readCssVarPx } from '../_chart/measure';
9
+ import { pointerPositionIn, dismissOnOutsidePointerDown } from '../_chart/interactions';
9
10
  import { truncateToWidth } from '../_chart/labels';
10
- import { DEFAULT_CHART_CORNER_RADIUS, DEFAULT_CHART_MAX_HEIGHT } from '../_chart/types';
11
+ import {
12
+ DEFAULT_CHART_CORNER_RADIUS,
13
+ DEFAULT_CHART_MAX_HEIGHT,
14
+ type TooltipAnchor
15
+ } from '../_chart/types';
11
16
  import { SvelteMap, SvelteSet } from 'svelte/reactivity';
12
17
 
13
18
  // ── Props ──────────────────────────────────────────────────────
@@ -39,7 +44,8 @@
39
44
  disableDimOnHover = false,
40
45
  firstColumnLabelSide = 'left',
41
46
  lastColumnLabelSide = 'right',
42
- marginX = 40
47
+ marginX = 40,
48
+ tooltipPortal = false
43
49
  }: SankeyChartProperties = $props();
44
50
 
45
51
  // ── State ──────────────────────────────────────────────────────
@@ -47,11 +53,30 @@
47
53
  let containerEl: HTMLDivElement | null = $state(null);
48
54
  let chartWidth = $state(0);
49
55
  let chartHeight = $state(0);
56
+ // `hoveredNode`/`hoveredLink` (pointer) and `focusedNode`/`focusedLink`
57
+ // (keyboard) are kept as separate state rather than one pair shared by
58
+ // both input sources: a pointer leaving a node/link must never erase that
59
+ // node/link's focus, and a late/stale pointerleave -- which real browsers
60
+ // fire *after* a focus event when focusing an off-screen node/link
61
+ // auto-scrolls the page under an unmoved cursor -- must not be able to
62
+ // clobber the focus highlight either. See `activeNode`/`activeLink` below
63
+ // for the precedence between them, and `anchor` (in Interactions) for how
64
+ // the tooltip re-locates the right element off of these ids alone.
50
65
  let hoveredNode = $state<string | null>(null);
66
+ let focusedNode = $state<string | null>(null);
51
67
  let hoveredLink = $state<{ source: string; target: string } | null>(null);
68
+ let focusedLink = $state<{ source: string; target: string } | null>(null);
52
69
  let mouseX = $state(0);
53
70
  let mouseY = $state(0);
54
71
 
72
+ // Precedence: keyboard focus wins over pointer hover, on whichever of
73
+ // node/link actually holds it -- a focused node stays active even while
74
+ // the pointer hovers an unrelated link (and vice versa). Nodes and links
75
+ // are mutually exclusive DOM targets, so within one tier (focus, then
76
+ // hover) at most one of activeNode/activeLink is ever non-null.
77
+ let activeNode = $derived(focusedLink !== null ? null : (focusedNode ?? hoveredNode));
78
+ let activeLink = $derived(focusedNode !== null ? null : (focusedLink ?? hoveredLink));
79
+
55
80
  // ── Layout ─────────────────────────────────────────────────────
56
81
 
57
82
  let format = $derived(valueFormat ?? formatNumber);
@@ -310,18 +335,18 @@
310
335
  if (disableDimOnHover) {
311
336
  return null;
312
337
  }
313
- if (hoveredNode !== null) {
314
- const connected = new SvelteSet<string>([hoveredNode]);
338
+ if (activeNode !== null) {
339
+ const connected = new SvelteSet<string>([activeNode]);
315
340
  for (const l of links) {
316
- if (l.source === hoveredNode || l.target === hoveredNode) {
341
+ if (l.source === activeNode || l.target === activeNode) {
317
342
  connected.add(l.source);
318
343
  connected.add(l.target);
319
344
  }
320
345
  }
321
346
  return connected;
322
347
  }
323
- if (hoveredLink !== null) {
324
- return new SvelteSet([hoveredLink.source, hoveredLink.target]);
348
+ if (activeLink !== null) {
349
+ return new SvelteSet([activeLink.source, activeLink.target]);
325
350
  }
326
351
  return null;
327
352
  });
@@ -334,11 +359,11 @@
334
359
  * twice on every hover state change.
335
360
  */
336
361
  let hoveredLinkCache = $derived.by(() => {
337
- if (hoveredLink === null) {
362
+ if (activeLink === null) {
338
363
  return null;
339
364
  }
340
365
  const l = links.find(
341
- (lk) => lk.source === hoveredLink!.source && lk.target === hoveredLink!.target
366
+ (lk) => lk.source === activeLink!.source && lk.target === activeLink!.target
342
367
  );
343
368
  if (!l) {
344
369
  return null;
@@ -350,8 +375,8 @@
350
375
  });
351
376
 
352
377
  let tooltipData = $derived.by(() => {
353
- if (hoveredNode !== null) {
354
- const n = layout.nodes.find((nd) => nd.id === hoveredNode);
378
+ if (activeNode !== null) {
379
+ const n = layout.nodes.find((nd) => nd.id === activeNode);
355
380
  if (!n) {
356
381
  return null;
357
382
  }
@@ -380,9 +405,9 @@
380
405
  });
381
406
 
382
407
  let tooltipContext = $derived.by<SankeyTooltipContext | null>(() => {
383
- if (hoveredNode !== null) {
384
- const n = findNode(hoveredNode);
385
- const computed = layout.nodes.find((nd) => nd.id === hoveredNode);
408
+ if (activeNode !== null) {
409
+ const n = findNode(activeNode);
410
+ const computed = layout.nodes.find((nd) => nd.id === activeNode);
386
411
  if (!n || !computed) {
387
412
  return null;
388
413
  }
@@ -402,22 +427,83 @@
402
427
  });
403
428
 
404
429
  // ── Interactions ───────────────────────────────────────────────
430
+ // Family-wide contract shared with BarChart/FunnelChart/DualAxisBarChart:
431
+ // pointer hover and keyboard focus feed SEPARATE state (see hoveredNode/
432
+ // focusedNode/hoveredLink/focusedLink above), read together as one "direct
433
+ // interaction" tier via activeNode/activeLink. Enter/Space on a focused
434
+ // node or link invokes the same click callback a pointer click would.
405
435
 
406
436
  function trackMouse(e: MouseEvent) {
437
+ const position = pointerPositionIn(containerEl, e);
438
+ if (position !== null) {
439
+ mouseX = position.x;
440
+ mouseY = position.y;
441
+ }
442
+ }
443
+
444
+ // Anchor from the live element's own rect (not SVG-space math), so the two
445
+ // nested margin/gutter transforms this chart applies never need reproducing
446
+ // here -- same technique BarChart/FunnelChart/DualAxisBarChart use.
447
+ function anchorFromElement(el: Element): TooltipAnchor | null {
407
448
  if (containerEl === null) {
408
- return;
449
+ return null;
450
+ }
451
+ const r = el.getBoundingClientRect();
452
+ const c = containerEl.getBoundingClientRect();
453
+ return { x: r.left + r.width / 2 - c.left, y: r.top - c.top, side: 'top' };
454
+ }
455
+
456
+ // Re-locates the live element for an id via its data-node-id/data-link-*
457
+ // attribute (added to the elements below) rather than a ref captured at
458
+ // enter/focus time -- `anchor` can become active through a path with no
459
+ // event of its own to capture from (e.g. focus blurring off a node while
460
+ // the pointer already rests, unmoved, over a different one), so it always
461
+ // needs to be able to look the current element up fresh.
462
+ function findNodeElement(id: string): Element | null {
463
+ if (containerEl === null) {
464
+ return null;
409
465
  }
410
- const rect = containerEl.getBoundingClientRect();
411
- mouseX = e.clientX - rect.left;
412
- mouseY = e.clientY - rect.top;
466
+ for (const el of containerEl.querySelectorAll('.sankey-node')) {
467
+ if (el.getAttribute('data-node-id') === id) {
468
+ return el;
469
+ }
470
+ }
471
+ return null;
413
472
  }
414
473
 
474
+ function findLinkElement(source: string, target: string): Element | null {
475
+ if (containerEl === null) {
476
+ return null;
477
+ }
478
+ for (const el of containerEl.querySelectorAll('.sankey-link')) {
479
+ if (
480
+ el.getAttribute('data-link-source') === source &&
481
+ el.getAttribute('data-link-target') === target
482
+ ) {
483
+ return el;
484
+ }
485
+ }
486
+ return null;
487
+ }
488
+
489
+ let anchor = $derived.by<TooltipAnchor | null>(() => {
490
+ if (activeNode !== null) {
491
+ const el = findNodeElement(activeNode);
492
+ return el ? anchorFromElement(el) : null;
493
+ }
494
+ if (activeLink !== null) {
495
+ const el = findLinkElement(activeLink.source, activeLink.target);
496
+ return el ? anchorFromElement(el) : null;
497
+ }
498
+ return null;
499
+ });
500
+
415
501
  function isLinkHighlighted(source: string, target: string): boolean {
416
- if (hoveredLink !== null) {
417
- return source === hoveredLink.source && target === hoveredLink.target;
502
+ if (activeLink !== null) {
503
+ return source === activeLink.source && target === activeLink.target;
418
504
  }
419
- if (hoveredNode !== null) {
420
- return source === hoveredNode || target === hoveredNode;
505
+ if (activeNode !== null) {
506
+ return source === activeNode || target === activeNode;
421
507
  }
422
508
  return false;
423
509
  }
@@ -430,18 +516,73 @@
430
516
  return links.find((l) => l.source === source && l.target === target);
431
517
  }
432
518
 
519
+ /** Resolved display label for a node id, from the computed layout (falls back to the id). */
520
+ function resolveNodeLabel(id: string): string {
521
+ return layout.nodes.find((n) => n.id === id)?.label ?? id;
522
+ }
523
+
524
+ // onnodehover mirrors the combined pointer-or-focus node (activeNode),
525
+ // once per actual change -- e.g. a pointer leaving a node that is still
526
+ // keyboard-focused must not report a hover-cleared event for a highlight
527
+ // that never went away.
528
+ let lastNotifiedNode: string | null = null;
529
+ function notifyNodeHover() {
530
+ const next = activeNode;
531
+ if (next === lastNotifiedNode) {
532
+ return;
533
+ }
534
+ lastNotifiedNode = next;
535
+ const orig = next === null ? null : findNode(next);
536
+ onnodehover?.(orig ? { node: orig } : null);
537
+ }
538
+
539
+ function sameLink(
540
+ a: { source: string; target: string } | null,
541
+ b: { source: string; target: string } | null
542
+ ): boolean {
543
+ if (a === null || b === null) {
544
+ return a === b;
545
+ }
546
+ return a.source === b.source && a.target === b.target;
547
+ }
548
+
549
+ // onlinkhover mirrors the combined pointer-or-focus link (activeLink),
550
+ // once per actual change -- same dedup rationale as notifyNodeHover.
551
+ let lastNotifiedLink: { source: string; target: string } | null = null;
552
+ function notifyLinkHover() {
553
+ const next = activeLink;
554
+ if (sameLink(next, lastNotifiedLink)) {
555
+ return;
556
+ }
557
+ lastNotifiedLink = next;
558
+ const orig = next === null ? null : findLink(next.source, next.target);
559
+ onlinkhover?.(orig ? { link: orig } : null);
560
+ }
561
+
433
562
  function handleNodeEnter(e: MouseEvent, id: string) {
434
563
  hoveredNode = id;
435
564
  trackMouse(e);
436
- const orig = findNode(id);
437
- if (orig) {
438
- onnodehover?.({ node: orig });
439
- }
565
+ notifyNodeHover();
440
566
  }
441
567
 
568
+ function handleNodeFocus(id: string) {
569
+ focusedNode = id;
570
+ notifyNodeHover();
571
+ }
572
+
573
+ // Wired to a node's mouseleave. Clears ONLY the hover half -- never
574
+ // `focusedNode` -- so a late, stale mouseleave (see `hoveredNode`'s
575
+ // declaration comment) cannot erase a node's focus highlight.
442
576
  function handleNodeLeave() {
443
577
  hoveredNode = null;
444
- onnodehover?.(null);
578
+ notifyNodeHover();
579
+ }
580
+
581
+ // Wired to a node's blur. Clears ONLY the focus half; a pointer that
582
+ // happens to still be over the node keeps it hovered.
583
+ function handleNodeBlur() {
584
+ focusedNode = null;
585
+ notifyNodeHover();
445
586
  }
446
587
 
447
588
  function handleNodeClick(id: string) {
@@ -451,18 +592,36 @@
451
592
  }
452
593
  }
453
594
 
595
+ function handleNodeKeydown(e: KeyboardEvent, id: string) {
596
+ if (e.key === 'Enter' || e.key === ' ') {
597
+ e.preventDefault();
598
+ handleNodeClick(id);
599
+ }
600
+ }
601
+
454
602
  function handleLinkEnter(e: MouseEvent, source: string, target: string) {
455
603
  hoveredLink = { source, target };
456
604
  trackMouse(e);
457
- const orig = findLink(source, target);
458
- if (orig) {
459
- onlinkhover?.({ link: orig });
460
- }
605
+ notifyLinkHover();
606
+ }
607
+
608
+ function handleLinkFocus(source: string, target: string) {
609
+ focusedLink = { source, target };
610
+ notifyLinkHover();
461
611
  }
462
612
 
613
+ // Wired to a link's mouseleave. Clears ONLY the hover half -- never
614
+ // `focusedLink` -- for the same reason handleNodeLeave spares focusedNode.
463
615
  function handleLinkLeave() {
464
616
  hoveredLink = null;
465
- onlinkhover?.(null);
617
+ notifyLinkHover();
618
+ }
619
+
620
+ // Wired to a link's blur. Clears ONLY the focus half; a pointer that
621
+ // happens to still be over the link keeps it hovered.
622
+ function handleLinkBlur() {
623
+ focusedLink = null;
624
+ notifyLinkHover();
466
625
  }
467
626
 
468
627
  function handleLinkClick(source: string, target: string) {
@@ -471,6 +630,31 @@
471
630
  onlinkclick?.({ link: orig });
472
631
  }
473
632
  }
633
+
634
+ function handleLinkKeydown(e: KeyboardEvent, source: string, target: string) {
635
+ if (e.key === 'Enter' || e.key === ' ') {
636
+ e.preventDefault();
637
+ handleLinkClick(source, target);
638
+ }
639
+ }
640
+
641
+ // Touch taps have no pointerleave: dismiss when a pointerdown lands
642
+ // outside. Clears only the hover half of each pair -- a real pointerdown
643
+ // outside a focused element blurs it natively (see handleNodeBlur /
644
+ // handleLinkBlur), matching PieChart/AreaChart/LineChart's identical
645
+ // hover-only scope for this effect.
646
+ // eslint-disable-next-line no-restricted-syntax
647
+ $effect(() => {
648
+ if (hoveredNode === null && hoveredLink === null) {
649
+ return;
650
+ }
651
+ return dismissOnOutsidePointerDown(containerEl, () => {
652
+ hoveredNode = null;
653
+ hoveredLink = null;
654
+ notifyNodeHover();
655
+ notifyLinkHover();
656
+ });
657
+ });
474
658
  </script>
475
659
 
476
660
  <div
@@ -503,19 +687,27 @@
503
687
  {#each layout.links as link, i (i)}
504
688
  {@const highlighted = isLinkHighlighted(link.source, link.target)}
505
689
  {@const dimmed =
506
- !disableDimOnHover && (hoveredNode !== null || hoveredLink !== null) && !highlighted}
507
- <!-- svelte-ignore a11y_no_static_element_interactions -->
508
- <!-- svelte-ignore a11y_click_events_have_key_events -->
690
+ !disableDimOnHover && (activeNode !== null || activeLink !== null) && !highlighted}
509
691
  <path
510
692
  class="sankey-link"
693
+ data-link-source={link.source}
694
+ data-link-target={link.target}
511
695
  d={link.path}
512
696
  fill="none"
513
697
  stroke={link.color ?? nodeColorMap.get(link.source) ?? getColor(0)}
514
698
  stroke-width={Math.max(minLinkWidth, link.width)}
515
699
  stroke-opacity={highlighted ? 0.7 : dimmed ? 0.08 : 0.4}
700
+ tabindex="0"
701
+ role="button"
702
+ aria-label="{resolveNodeLabel(link.source)} to {resolveNodeLabel(link.target)}: {format(
703
+ link.value
704
+ )}"
516
705
  onmouseenter={(e) => handleLinkEnter(e, link.source, link.target)}
517
706
  onmousemove={trackMouse}
518
707
  onmouseleave={handleLinkLeave}
708
+ onfocus={() => handleLinkFocus(link.source, link.target)}
709
+ onblur={handleLinkBlur}
710
+ onkeydown={(e) => handleLinkKeydown(e, link.source, link.target)}
519
711
  onclick={() => handleLinkClick(link.source, link.target)}
520
712
  />
521
713
  {/each}
@@ -523,11 +715,10 @@
523
715
  {#each layout.nodes as node, ni (ni)}
524
716
  {@const color = nodeColorMap.get(node.id) ?? getColor(ni)}
525
717
  {@const dimmed = connectedNodes !== null && !connectedNodes.has(node.id)}
526
- <!-- svelte-ignore a11y_no_static_element_interactions -->
527
- <!-- svelte-ignore a11y_click_events_have_key_events -->
528
718
  <rect
529
719
  class="sankey-node"
530
720
  class:node-dimmed={dimmed}
721
+ data-node-id={node.id}
531
722
  x={node.x}
532
723
  y={node.y}
533
724
  width={node.width}
@@ -535,9 +726,15 @@
535
726
  rx={radius}
536
727
  ry={radius}
537
728
  fill={color}
729
+ tabindex="0"
730
+ role="button"
731
+ aria-label="{node.label}: {format(node.value)}"
538
732
  onmouseenter={(e) => handleNodeEnter(e, node.id)}
539
733
  onmousemove={trackMouse}
540
734
  onmouseleave={handleNodeLeave}
735
+ onfocus={() => handleNodeFocus(node.id)}
736
+ onblur={handleNodeBlur}
737
+ onkeydown={(e) => handleNodeKeydown(e, node.id)}
541
738
  onclick={() => handleNodeClick(node.id)}
542
739
  />
543
740
  {/each}
@@ -570,12 +767,31 @@
570
767
  </g>
571
768
  </ChartContainer>
572
769
 
573
- {#if typeof tooltipSnippet === 'function' && tooltipContext !== null}
574
- <div class="chart-tooltip-slot" style="left: {mouseX + 12}px; top: {mouseY - 12}px;">
575
- {@render tooltipSnippet(tooltipContext)}
576
- </div>
770
+ {#if typeof tooltipSnippet === 'function'}
771
+ <ChartTooltip
772
+ data={tooltipData}
773
+ {mouseX}
774
+ {mouseY}
775
+ {anchor}
776
+ portal={tooltipPortal}
777
+ originEl={containerEl}
778
+ unstyled
779
+ >
780
+ {#snippet content()}
781
+ {#if tooltipContext !== null}
782
+ {@render tooltipSnippet(tooltipContext)}
783
+ {/if}
784
+ {/snippet}
785
+ </ChartTooltip>
577
786
  {:else}
578
- <ChartTooltip data={tooltipData} {mouseX} {mouseY} />
787
+ <ChartTooltip
788
+ data={tooltipData}
789
+ {mouseX}
790
+ {mouseY}
791
+ {anchor}
792
+ portal={tooltipPortal}
793
+ originEl={containerEl}
794
+ />
579
795
  {/if}
580
796
  {/if}
581
797
  </div>
@@ -590,13 +806,23 @@
590
806
  pointer-events: none;
591
807
  }
592
808
  .sankey-link {
593
- transition: stroke-opacity var(--chart-transition-duration, 0.2s) ease;
809
+ transition: stroke-opacity var(--chart-transition-duration, var(--motion-duration, 0.2s))
810
+ var(--chart-transition-easing, var(--motion-easing, ease));
594
811
  cursor: pointer;
595
812
  }
813
+ .sankey-link:focus-visible {
814
+ outline: var(--sankey-focus-outline, 2px solid currentColor);
815
+ outline-offset: var(--sankey-focus-outline-offset, 2px);
816
+ }
596
817
  .sankey-node {
597
- transition: opacity var(--chart-transition-duration, 0.2s) ease;
818
+ transition: opacity var(--chart-transition-duration, var(--motion-duration, 0.2s))
819
+ var(--chart-transition-easing, var(--motion-easing, ease));
598
820
  cursor: pointer;
599
821
  }
822
+ .sankey-node:focus-visible {
823
+ outline: var(--sankey-focus-outline, 2px solid currentColor);
824
+ outline-offset: var(--sankey-focus-outline-offset, 2px);
825
+ }
600
826
  .sankey-node.node-dimmed {
601
827
  opacity: var(--sankey-dimmed-opacity, 0.15);
602
828
  }
@@ -613,7 +839,8 @@
613
839
  stroke-width: var(--sankey-label-halo-width, 0);
614
840
  stroke-linejoin: round;
615
841
  pointer-events: none;
616
- transition: opacity var(--chart-transition-duration, 0.2s) ease;
842
+ transition: opacity var(--chart-transition-duration, var(--motion-duration, 0.2s))
843
+ var(--chart-transition-easing, var(--motion-easing, ease));
617
844
  }
618
845
  .sankey-col-label {
619
846
  fill: var(--sankey-col-label-color, #666);
@@ -625,11 +852,6 @@
625
852
  .sankey-label.node-dimmed {
626
853
  opacity: var(--sankey-dimmed-opacity, 0.15);
627
854
  }
628
- .chart-tooltip-slot {
629
- position: absolute;
630
- z-index: 10;
631
- pointer-events: none;
632
- }
633
855
  .chart-empty {
634
856
  padding: var(--chart-empty-padding, 32px 24px);
635
857
  color: var(--chart-empty-color, #9ca3af);
@@ -54,6 +54,15 @@ export type OptionalSankeyChartProperties = {
54
54
  maxHeight?: number;
55
55
  valueFormat?: (value: number) => string;
56
56
  tooltipSnippet?: Snippet<[SankeyTooltipContext]>;
57
+ /**
58
+ * Render the tooltip into the chart's own root (its shadow root when hosted as a
59
+ * custom element, `document.body` otherwise) with `position: fixed`, clamped to
60
+ * the viewport, instead of positioned inside the chart. Use it when the chart
61
+ * sits inside a container with `overflow: hidden`/`scroll` that would otherwise
62
+ * clip the tooltip. Defaults to `false`. Has no effect on `tooltipSnippet`'s
63
+ * content, only on where the tooltip wrapper renders.
64
+ */
65
+ tooltipPortal?: boolean;
57
66
  empty?: Snippet;
58
67
  testId?: string;
59
68
  classes?: string;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte';
3
+ import { prefersReducedMotion } from '../utils';
3
4
  import type { ScrollerProperties, ScrollPosition } from './properties';
4
5
  import Button from '../Button/Button.svelte';
5
6
  import chevronLeft from '../assets/chevron-left.svg?raw';
@@ -76,7 +77,7 @@
76
77
  const { clientSize } = getScrollProps(containerEl);
77
78
  const amount = scrollAmount ?? clientSize;
78
79
  const options: ScrollToOptions = {
79
- behavior: smoothScroll ? 'smooth' : 'auto'
80
+ behavior: scrollBehavior()
80
81
  };
81
82
  if (direction === 'horizontal') {
82
83
  options.left = delta * amount;
@@ -118,12 +119,35 @@
118
119
  }
119
120
  }
120
121
 
122
+ /**
123
+ * Whether a scroll should animate.
124
+ *
125
+ * `smoothScroll` is the consumer's preference and the OS setting overrides it, so
126
+ * both are consulted. Deliberately a function called at each use rather than a value
127
+ * captured once: the preference can change while the component is mounted, and the
128
+ * three call sites below run on interaction, not on render.
129
+ *
130
+ * This has to live in script. Two of the three sites are unreachable by any
131
+ * stylesheet -- a `ScrollToOptions.behavior` value is not a style at all, and an
132
+ * inline `style.scrollBehavior` write outranks every rule in this component's own
133
+ * stylesheet, media query included. A `@media (prefers-reduced-motion: reduce)`
134
+ * block here would read correctly in review and lose at runtime.
135
+ *
136
+ * (Written without the style tag spelled out: Svelte scans a script block as raw
137
+ * text looking for its closing tag, and a literal style tag inside a comment here
138
+ * ends that scan early -- the whole component then fails to compile with
139
+ * "`<script>` was left open" pointing at the last line of the file.)
140
+ */
141
+ function scrollBehavior(): ScrollBehavior {
142
+ return smoothScroll && !prefersReducedMotion() ? 'smooth' : 'auto';
143
+ }
144
+
121
145
  function handleDragEnd() {
122
146
  if (!isDragging || containerEl === null) {
123
147
  return;
124
148
  }
125
149
  isDragging = false;
126
- containerEl.style.scrollBehavior = smoothScroll ? 'smooth' : 'auto';
150
+ containerEl.style.scrollBehavior = scrollBehavior();
127
151
  containerEl.style.userSelect = '';
128
152
  }
129
153
 
@@ -256,6 +280,17 @@
256
280
  scroll-behavior: var(--scroller-scroll-behavior, smooth);
257
281
  }
258
282
 
283
+ /* The stylesheet half, for scrolls this component does not initiate -- a wheel,
284
+ a dragged scrollbar, an anchor jump. The script half above covers the three it
285
+ does initiate, and cannot be replaced by this: an inline style write and a
286
+ ScrollToOptions value both outrank or bypass every rule here. */
287
+ @media (prefers-reduced-motion: reduce) {
288
+ .scroller.horizontal .scroll-container,
289
+ .scroller.vertical .scroll-container {
290
+ scroll-behavior: auto;
291
+ }
292
+ }
293
+
259
294
  .scroller.vertical .scroll-container {
260
295
  flex-direction: column;
261
296
  overflow-y: auto;