@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
@@ -1,5 +1,9 @@
1
1
  <script lang="ts">
2
- import type { LineChartProperties, LineChartTooltipContext } from './properties';
2
+ import type {
3
+ LineChartDataPoint,
4
+ LineChartProperties,
5
+ LineChartTooltipContext
6
+ } from './properties';
3
7
  import { DEFAULT_CHART_MAX_HEIGHT } from '../_chart/types';
4
8
  import type { ChartHighlightAPI } from '../_chart/highlight';
5
9
  import { onMount } from 'svelte';
@@ -8,7 +12,7 @@
8
12
  import ChartTooltip from '../_chart/ChartTooltip.svelte';
9
13
  import Legend from '../_chart/Legend.svelte';
10
14
  import { createLinearScale, niceLinearDomain, computeLinearTicks } from '../_chart/scales';
11
- import { computeAutoLayout } from '../_chart/geometry';
15
+ import { computeAutoLayout, joinByX } from '../_chart/geometry';
12
16
  import { linePath, areaPath } from '../_chart/paths';
13
17
  import { getColor } from '../_chart/colors';
14
18
  import { formatNumber, defaultTickFormat } from '../_chart/format';
@@ -73,7 +77,21 @@
73
77
  let plotEl: HTMLDivElement | null = $state(null);
74
78
  let chartWidth = $state(0);
75
79
  let chartHeight = $state(0);
76
- let hovered = $state<{ si: number; pi: number } | null>(null);
80
+ // `x` is the shared column key from `joinByX` (a real x VALUE, not an array
81
+ // index), and `si` is the series that owns the hovered marker within that
82
+ // column. Joining by x (see below) is what lets two series with different
83
+ // x coverage each contribute their own point to a shared hover column
84
+ // instead of one series' array index being reused against another's data.
85
+ //
86
+ // `hovered` and `focused` are kept as two separate pieces of state rather
87
+ // than one shared by both input sources: a pointer leaving a mark must
88
+ // never erase that mark's focus, and a late/stale pointerleave -- which
89
+ // real browsers fire *after* a focus event when focusing an off-screen
90
+ // mark auto-scrolls the page under an unmoved cursor -- must not be able
91
+ // to clobber the focus highlight either. See `pointerOrFocused` below for
92
+ // the precedence between them.
93
+ let hovered = $state<{ x: number; si: number } | null>(null);
94
+ let focused = $state<{ x: number; si: number } | null>(null);
77
95
  // internalHighlight holds the index driven by the ChartHighlightAPI.highlight() call.
78
96
  // The effective highlighted index merges this with the prop-driven highlightedIndex.
79
97
  let internalHighlight = $state<number | null>(null);
@@ -246,19 +264,35 @@
246
264
  }))
247
265
  );
248
266
 
267
+ // ── Cross-series alignment ──────────────────────────────
268
+
269
+ // The ONE alignment contract for tooltip/hit-testing (and, via
270
+ // computeStackedValues, stacking) — see `$lib/_chart/geometry`. For the
271
+ // common case where every series already shares one x sequence this
272
+ // produces exactly the old positional pairing, so nothing changes there.
273
+ let joined = $derived(joinByX(series));
274
+ let joinedByX = $derived(new Map(joined.map((row) => [row.x, row])));
275
+
249
276
  // ── Tooltip ────────────────────────────────────────────────────
250
277
 
251
- let hoveredPoint = $derived(
252
- hovered === null ? null : (series[hovered.si]?.data[hovered.pi] ?? null)
253
- );
278
+ // Precedence: keyboard focus wins over pointer hover -- see the comment on
279
+ // `focused`'s declaration. Hovering series A's mark while series B's mark
280
+ // holds focus keeps B active; A regains hover feedback once focus moves
281
+ // off it. Every derivation below reads this combined value rather than
282
+ // `hovered` directly, so all of them agree on what "active" means.
283
+ let pointerOrFocused = $derived(focused ?? hovered);
254
284
 
285
+ let hoveredRow = $derived(
286
+ pointerOrFocused === null ? null : (joinedByX.get(pointerOrFocused.x) ?? null)
287
+ );
255
288
  let hoverLineX = $derived.by<number | null>(() => {
256
- if (hovered === null) {
289
+ if (pointerOrFocused === null) {
257
290
  return null;
258
291
  }
259
- const x = lines[hovered.si]?.points[hovered.pi]?.x ?? null;
260
- // A gap point (non-finite) anchors no crosshair.
261
- return x !== null && Number.isFinite(x) ? x : null;
292
+ const x = xScale(pointerOrFocused.x);
293
+ // A non-finite pointerOrFocused.x cannot occur (joinByX excludes it), but a
294
+ // degenerate scale (e.g. zero-width plot) can still project to NaN.
295
+ return Number.isFinite(x) ? x : null;
262
296
  });
263
297
 
264
298
  // When a highlight index is active (imperative or prop), show the vertical
@@ -283,41 +317,47 @@
283
317
  let activeLineX = $derived(hoverLineX ?? highlightLineX);
284
318
 
285
319
  let tooltipContext = $derived.by<LineChartTooltipContext | null>(() => {
286
- if (hovered === null || hoveredPoint === null) {
320
+ if (pointerOrFocused === null || hoveredRow === null) {
287
321
  return null;
288
322
  }
323
+ const row = hoveredRow;
289
324
  return {
290
- x: hoveredPoint.x,
325
+ x: pointerOrFocused.x,
291
326
  points: series.map((s, si) => {
292
- const p = s.data[hovered!.pi];
327
+ // Each series contributes ITS OWN sample at this x column — not
328
+ // whatever happens to sit at the anchor series' array index. A
329
+ // series absent at this x (row.values[si] is null) reports 0,
330
+ // matching the pre-fix fallback for a missing point.
331
+ const entry = row.values[si];
293
332
  return {
294
333
  name: s.name,
295
- y: p?.y ?? 0,
334
+ y: entry?.point.y ?? 0,
296
335
  color: s.color ?? getColor(si),
297
- label: p?.label
336
+ label: entry?.point.label
298
337
  };
299
338
  })
300
339
  };
301
340
  });
302
341
 
303
342
  let tooltipData = $derived.by(() => {
304
- if (tooltipContext === null) {
343
+ if (tooltipContext === null || hoveredRow === null) {
305
344
  return null;
306
345
  }
346
+ const row = hoveredRow;
307
347
  const xLabel = resolvedXTickFormat
308
348
  ? resolvedXTickFormat(tooltipContext.x)
309
349
  : `x: ${formatNumber(tooltipContext.x)}`;
310
350
  return {
311
351
  title: xLabel,
312
352
  items: tooltipContext.points
313
- .map((p, si) => ({ p, si }))
353
+ .map((p, si) => ({ p, si, entry: row.values[si] }))
314
354
  .filter(
315
- ({ si }) =>
355
+ ({ si, entry }) =>
316
356
  !hiddenSeries.has(si) &&
317
- (shared || si === hovered?.si) &&
318
- series[si]?.data[hovered!.pi] != null &&
357
+ (shared || si === pointerOrFocused?.si) &&
358
+ entry !== null &&
319
359
  // A gap point (non-finite y) has nothing to report in the tooltip.
320
- Number.isFinite(series[si].data[hovered!.pi].y)
360
+ Number.isFinite(entry.point.y)
321
361
  )
322
362
  .map(({ p }) => ({ label: p.name, value: formatNumber(p.y), color: p.color }))
323
363
  };
@@ -325,23 +365,29 @@
325
365
 
326
366
  // Highcharts hover halo: a translucent ring behind the active marker(s).
327
367
  let haloPoints = $derived.by(() => {
328
- if (hovered === null) {
368
+ if (pointerOrFocused === null || hoveredRow === null) {
329
369
  return [];
330
370
  }
371
+ const row = hoveredRow;
331
372
  return lines.flatMap((line, si) => {
332
- if (line.hidden || (!shared && si !== hovered!.si)) {
373
+ if (line.hidden || (!shared && si !== pointerOrFocused!.si)) {
333
374
  return [];
334
375
  }
335
- const p = line.points[hovered!.pi];
336
- // A gap point (non-finite) has no marker, so it gets no halo either.
337
- return p && Number.isFinite(p.x) && Number.isFinite(p.y)
338
- ? [{ x: p.x, y: p.y, color: line.color }]
339
- : [];
376
+ // This series' OWN entry at the hovered column, projected through the
377
+ // shared scales — not `line.points[pointerOrFocused.pi]`, which would be
378
+ // this series' point at the ANCHOR series' array index.
379
+ const entry = row.values[si];
380
+ if (!entry || !Number.isFinite(entry.point.x) || !Number.isFinite(entry.point.y)) {
381
+ return [];
382
+ }
383
+ const x = xScale(entry.point.x);
384
+ const y = yScale(entry.point.y);
385
+ return Number.isFinite(x) && Number.isFinite(y) ? [{ x, y, color: line.color }] : [];
340
386
  });
341
387
  });
342
388
 
343
389
  let anchor = $derived.by<TooltipAnchor | null>(() => {
344
- if (hovered === null || haloPoints.length === 0) {
390
+ if (pointerOrFocused === null || haloPoints.length === 0) {
345
391
  return null;
346
392
  }
347
393
  return {
@@ -351,6 +397,74 @@
351
397
  };
352
398
  });
353
399
 
400
+ // ── Keyboard access & live status (family-wide contract; see
401
+ // docs/PieChart.md#keyboard-access) ─────────────────────────
402
+
403
+ const instanceId = $props.id();
404
+ const statusId = `line-status-${instanceId}`;
405
+
406
+ // "Active" datum for announcement purposes, in the SAME precedence order
407
+ // PieChart's own activeIndex/statusText already documents: direct
408
+ // interaction (pointer hover or keyboard focus, both funnelled through
409
+ // `hovered`) ahead of the declarative highlightedIndex prop / imperative
410
+ // ChartHighlightAPI (effectiveHighlight) -- neither of which fires a focus
411
+ // event of its own, so a live region is the only way either one reaches
412
+ // assistive tech. AreaChart has no such non-focus-triggered highlight
413
+ // path (no highlightedIndex prop, no ChartHighlightAPI), so it relies on
414
+ // each mark's own aria-label alone, matching the family's majority
415
+ // (Bar/DualAxisBar/Funnel/Sankey) -- this live region exists here for the
416
+ // same reason it exists on PieChart, not as an inconsistency between the
417
+ // two new charts.
418
+ let activeDatum = $derived.by<{ si: number; point: LineChartDataPoint } | null>(() => {
419
+ if (pointerOrFocused !== null && hoveredRow !== null) {
420
+ const entry = hoveredRow.values[pointerOrFocused.si];
421
+ return entry ? { si: pointerOrFocused.si, point: entry.point } : null;
422
+ }
423
+ if (effectiveHighlight !== null) {
424
+ for (let si = 0; si < series.length; si++) {
425
+ if (hiddenSeries.has(si)) {
426
+ continue;
427
+ }
428
+ const point = series[si]?.data[effectiveHighlight];
429
+ if (point) {
430
+ return { si, point };
431
+ }
432
+ }
433
+ }
434
+ return null;
435
+ });
436
+
437
+ let statusText = $derived.by(() => {
438
+ const active = activeDatum;
439
+ if (active === null) {
440
+ return '';
441
+ }
442
+ const name = focusPointLabel(active.point, active.point.x);
443
+
444
+ // A SHARED tooltip shows every series at this x, so the live region has to
445
+ // as well. `shared` defaults to true whenever there is more than one series,
446
+ // so announcing only the focused one was the default configuration: a
447
+ // sighted user saw "Revenue: 10" and "Cost: 5" while a screen-reader user
448
+ // heard "Revenue: 10" alone -- the tooltip's whole comparison, which is the
449
+ // reason a shared tooltip exists, silently withheld from the people who
450
+ // cannot see it.
451
+ const row = joinedByX.get(active.point.x);
452
+ if (shared && series.length > 1 && row) {
453
+ const parts = series
454
+ .map((s, si) => ({ name: s.name, entry: row.values[si], si }))
455
+ .filter((candidate) => !hiddenSeries.has(candidate.si) && candidate.entry !== null)
456
+ .map((candidate) => `${candidate.name}: ${formatNumber(candidate.entry!.point.y)}`);
457
+ if (parts.length > 0) {
458
+ return `${name} — ${parts.join(', ')}`;
459
+ }
460
+ }
461
+
462
+ const value = formatNumber(active.point.y);
463
+ return series.length > 1
464
+ ? `${name} — ${series[active.si]?.name ?? ''}: ${value}`
465
+ : `${name}: ${value}`;
466
+ });
467
+
354
468
  // ── Point labels ───────────────────────────────────────────────
355
469
 
356
470
  let pointLabelPlacements = $derived.by(() => {
@@ -372,21 +486,26 @@
372
486
 
373
487
  // ── Highlight dim logic ────────────────────────────────────────
374
488
 
375
- // A point index is "dimmed" when the highlight system is active (hover or
376
- // imperative highlight) and the point is not the active one.
489
+ // A point is "dimmed" when the highlight system is active (hover or
490
+ // imperative highlight) and the point is not the active one. `pi` here is
491
+ // always the series' OWN array index (as rendered), so the hover
492
+ // comparison must go through that point's actual x value rather than
493
+ // reusing `pi` as if it meant the same column in every series.
377
494
  const isDotDimmed = (si: number, pi: number): boolean => {
378
495
  if (shared) {
379
- if (hovered !== null) {
380
- return hovered.pi !== pi;
496
+ if (pointerOrFocused !== null) {
497
+ const dataX = series[si]?.data[pi]?.x ?? null;
498
+ return dataX === null || pointerOrFocused.x !== dataX;
381
499
  }
382
500
  if (effectiveHighlight !== null) {
383
501
  return pi !== effectiveHighlight;
384
502
  }
385
503
  return false;
386
504
  }
387
- // Hover interaction takes precedence over imperative highlight.
388
- if (hovered !== null) {
389
- return hovered.si !== si || hovered.pi !== pi;
505
+ // Hover/focus interaction takes precedence over imperative highlight.
506
+ if (pointerOrFocused !== null) {
507
+ const dataX = series[si]?.data[pi]?.x ?? null;
508
+ return pointerOrFocused.si !== si || dataX === null || pointerOrFocused.x !== dataX;
390
509
  }
391
510
  if (effectiveHighlight !== null) {
392
511
  return pi !== effectiveHighlight;
@@ -398,16 +517,17 @@
398
517
  if (shared) {
399
518
  return false;
400
519
  }
401
- if (hovered !== null) {
402
- return hovered.si !== si;
520
+ if (pointerOrFocused !== null) {
521
+ return pointerOrFocused.si !== si;
403
522
  }
404
523
  // When only a point index is highlighted (no series index), dim no lines.
405
524
  return false;
406
525
  };
407
526
 
408
527
  const isHighlightedDot = (si: number, pi: number): boolean => {
409
- if (hovered !== null) {
410
- return hovered.si === si && hovered.pi === pi;
528
+ if (pointerOrFocused !== null) {
529
+ const dataX = series[si]?.data[pi]?.x ?? null;
530
+ return pointerOrFocused.si === si && dataX !== null && pointerOrFocused.x === dataX;
411
531
  }
412
532
  if (effectiveHighlight !== null) {
413
533
  return pi === effectiveHighlight;
@@ -415,6 +535,34 @@
415
535
  return false;
416
536
  };
417
537
 
538
+ // AreaChart/LineChart are continuous series, not discrete categories like
539
+ // Bar/Funnel/Pie/Sankey/DualAxisBar -- there is no fixed set of "marks" to
540
+ // Tab through independent of data shape. The deliberate call here: each
541
+ // rendered (series, point) pair is its own Tab stop, in series-then-point
542
+ // DOM order, mirroring exactly what pointer hover already lands on via
543
+ // findNearest -- so keyboard and mouse users reach the same set of
544
+ // addressable data points, just via a different input.
545
+ function focusPointLabel(point: LineChartDataPoint, x: number): string {
546
+ if (point.label) {
547
+ return point.label;
548
+ }
549
+ return resolvedXTickFormat ? resolvedXTickFormat(x) : formatNumber(x);
550
+ }
551
+
552
+ // Matches the family's per-mark "{name}: {value}" convention (Bar/Funnel/
553
+ // Pie), extended with the "{name} — {series}: {value}" em-dash form for
554
+ // multi-series charts -- BarChart's own tooltip already disambiguates
555
+ // multi-series marks this way, but its aria-label omits the series name
556
+ // (a gap found while extracting this contract, not repeated here).
557
+ function focusAriaLabel(si: number, pi: number, x: number): string {
558
+ const point = series[si]?.data[pi];
559
+ const name = point ? focusPointLabel(point, x) : formatNumber(x);
560
+ const value = formatNumber(point?.y ?? 0);
561
+ return series.length > 1
562
+ ? `${name} — ${series[si]?.name ?? ''}: ${value}`
563
+ : `${name}: ${value}`;
564
+ }
565
+
418
566
  // ── Interactions ───────────────────────────────────────────────
419
567
 
420
568
  const trackMouse = (e: PointerEvent): void => {
@@ -425,61 +573,97 @@
425
573
  }
426
574
  };
427
575
 
428
- const findNearest = (plotX: number, plotY: number): { si: number; pi: number } | null => {
429
- if (series.length === 0) {
430
- return null;
431
- }
432
- // Use the longest VISIBLE series as the index reference so hover still works
433
- // when the first series is empty or shorter than the others, and never
434
- // anchors to a series the user has toggled off via the legend.
435
- const visibleEntries = series
436
- .map((s, si) => ({ s, si }))
437
- .filter((entry) => !hiddenSeries.has(entry.si));
438
- if (visibleEntries.length === 0) {
439
- return null;
440
- }
441
- const referenceEntry = visibleEntries.reduce((a, b) =>
442
- b.s.data.length > a.s.data.length ? b : a
443
- );
444
- const reference = referenceEntry.s.data;
445
- if (reference.length === 0) {
446
- return null;
447
- }
448
- let nearestPi = 0;
576
+ // Nearest hit-test over the SAME `joined` columns used for tooltip/halo —
577
+ // one alignment contract, not a second position-based path. A column is
578
+ // only eligible if some visible series actually has a finite value there,
579
+ // so a shared gap column is skipped rather than reported as hoverable.
580
+ const findNearest = (plotX: number, plotY: number): { x: number; si: number } | null => {
581
+ let nearestRow: (typeof joined)[number] | null = null;
449
582
  let nearestXDist = Infinity;
450
- for (let i = 0; i < reference.length; i++) {
451
- // Gap points (non-finite) are not hoverable — NaN distances would never
452
- // win the comparison, but skipping keeps nearestPi from defaulting to one.
453
- if (!Number.isFinite(reference[i].x) || !Number.isFinite(reference[i].y)) {
583
+ for (const row of joined) {
584
+ const hasVisibleValue = row.values.some(
585
+ (entry, si) => entry !== null && !hiddenSeries.has(si) && Number.isFinite(entry.point.y)
586
+ );
587
+ if (!hasVisibleValue) {
454
588
  continue;
455
589
  }
456
- const px = xScale(reference[i].x);
590
+ const px = xScale(row.x);
457
591
  const dist = Math.abs(px - plotX);
458
592
  if (dist < nearestXDist) {
459
593
  nearestXDist = dist;
460
- nearestPi = i;
594
+ nearestRow = row;
461
595
  }
462
596
  }
463
- // The reference series is visible and owns nearestPi by construction, so
464
- // it's a safe default if no other visible series' point is nearer in y.
465
- let nearestSi = referenceEntry.si;
597
+ if (nearestRow === null) {
598
+ return null;
599
+ }
600
+ let nearestSi = -1;
466
601
  let nearestYDist = Infinity;
467
602
  for (let si = 0; si < series.length; si++) {
468
603
  if (hiddenSeries.has(si)) {
469
604
  continue;
470
605
  }
471
- const p = series[si].data[nearestPi];
472
- if (!p) {
606
+ const entry = nearestRow.values[si];
607
+ if (!entry || !Number.isFinite(entry.point.y)) {
473
608
  continue;
474
609
  }
475
- const py = yScale(p.y);
610
+ const py = yScale(entry.point.y);
476
611
  const dist = Math.abs(py - plotY);
477
612
  if (dist < nearestYDist) {
478
613
  nearestYDist = dist;
479
614
  nearestSi = si;
480
615
  }
481
616
  }
482
- return { si: nearestSi, pi: nearestPi };
617
+ return nearestSi === -1 ? null : { x: nearestRow.x, si: nearestSi };
618
+ };
619
+
620
+ const sameMark = (
621
+ a: { x: number; si: number } | null,
622
+ b: { x: number; si: number } | null
623
+ ): boolean => a !== null && b !== null && a.x === b.x && a.si === b.si;
624
+
625
+ // onpointhover mirrors the combined pointer-or-focus mark (pointerOrFocused),
626
+ // once per actual change -- e.g. a pointer leaving a mark that is still
627
+ // keyboard-focused must not report a hover-cleared event for a highlight
628
+ // that never went away. Shared by activatePointer/activateFocus/
629
+ // handlePointerLeave/handleBlur so all four write paths agree on the same
630
+ // dedup.
631
+ let lastNotified: { x: number; si: number } | null = null;
632
+ const notifyPointHover = (): void => {
633
+ const next = pointerOrFocused;
634
+ if (sameMark(next, lastNotified)) {
635
+ return;
636
+ }
637
+ lastNotified = next;
638
+ if (next === null) {
639
+ onpointhover?.(null);
640
+ return;
641
+ }
642
+ const entry = joinedByX.get(next.x)?.values[next.si];
643
+ if (entry) {
644
+ // pointIndex is this series' own array index (entry.index), not the
645
+ // shared column key — the documented event contract is an index into
646
+ // `series[seriesIndex].data`, which differs from other series' own
647
+ // indices whenever their x coverage differs.
648
+ onpointhover?.({ seriesIndex: next.si, pointIndex: entry.index, point: entry.point });
649
+ }
650
+ };
651
+
652
+ // Pointer half of activation, used by handleOverlayMove. Skips the write
653
+ // (not just the notify) when the mark hasn't changed, since
654
+ // handleOverlayMove fires on every pointermove over the same mark.
655
+ const activatePointer = (next: { x: number; si: number }): void => {
656
+ if (!sameMark(hovered, next)) {
657
+ hovered = next;
658
+ }
659
+ notifyPointHover();
660
+ };
661
+
662
+ // Focus half of activation, used by handleMarkFocus. See `focused`'s
663
+ // declaration for why this writes separate state from activatePointer.
664
+ const activateFocus = (next: { x: number; si: number }): void => {
665
+ focused = next;
666
+ notifyPointHover();
483
667
  };
484
668
 
485
669
  const handleOverlayMove = (e: PointerEvent): void => {
@@ -491,27 +675,61 @@
491
675
  hovered = null;
492
676
  return;
493
677
  }
494
- if (hovered === null || hovered.si !== next.si || hovered.pi !== next.pi) {
495
- hovered = next;
496
- const point = series[next.si].data[next.pi];
497
- onpointhover?.({ seriesIndex: next.si, pointIndex: next.pi, point });
498
- }
678
+ activatePointer(next);
499
679
  };
500
680
 
501
- const handleLeave = (): void => {
681
+ // Wired to the hover-overlay's pointerleave. Clears ONLY the hover half of
682
+ // the state -- never `focused` -- so a late, stale pointerleave (see
683
+ // `focused`'s declaration comment) cannot erase a mark's focus highlight.
684
+ const handlePointerLeave = (): void => {
502
685
  if (hovered !== null) {
503
686
  hovered = null;
504
- onpointhover?.(null);
687
+ notifyPointHover();
688
+ }
689
+ };
690
+
691
+ // Keyboard mirror of pointer hover/click: focusing a mark activates it
692
+ // exactly like the nearest-point hover search would (same tooltip/halo/
693
+ // anchor/live-status state, via pointerOrFocused), blur mirrors
694
+ // pointer-leave for the focus half, and Enter/Space invoke the same click
695
+ // callback a pointer click would -- the Enter/Space-only contract every
696
+ // one of the five existing charts uses (none of Bar/DualAxisBar/Funnel/
697
+ // Pie/Sankey wires arrow keys, Home, End or Escape, so none are added
698
+ // here either).
699
+ const handleMarkFocus = (si: number, pi: number): void => {
700
+ const point = series[si]?.data[pi];
701
+ if (point) {
702
+ activateFocus({ x: point.x, si });
703
+ }
704
+ };
705
+
706
+ // Wired to the .focus-target circle's blur. Clears ONLY the focus half; a
707
+ // pointer that happens to still be over the mark keeps it hovered.
708
+ const handleBlur = (): void => {
709
+ if (focused !== null) {
710
+ focused = null;
711
+ notifyPointHover();
712
+ }
713
+ };
714
+
715
+ const handleMarkKeydown = (e: KeyboardEvent): void => {
716
+ if (e.key === 'Enter' || e.key === ' ') {
717
+ e.preventDefault();
718
+ handleClick();
505
719
  }
506
720
  };
507
721
 
508
722
  const handleClick = (): void => {
509
- if (hovered === null) {
723
+ if (pointerOrFocused === null) {
510
724
  return;
511
725
  }
512
- const point = series[hovered.si]?.data[hovered.pi];
513
- if (point) {
514
- onpointclick?.({ seriesIndex: hovered.si, pointIndex: hovered.pi, point });
726
+ const entry = joinedByX.get(pointerOrFocused.x)?.values[pointerOrFocused.si];
727
+ if (entry) {
728
+ onpointclick?.({
729
+ seriesIndex: pointerOrFocused.si,
730
+ pointIndex: entry.index,
731
+ point: entry.point
732
+ });
515
733
  }
516
734
  };
517
735
 
@@ -521,7 +739,7 @@
521
739
  if (hovered === null) {
522
740
  return;
523
741
  }
524
- return dismissOnOutsidePointerDown(containerEl, handleLeave);
742
+ return dismissOnOutsidePointerDown(containerEl, handlePointerLeave);
525
743
  });
526
744
  </script>
527
745
 
@@ -534,6 +752,13 @@
534
752
  {#if isEmpty && typeof empty === 'function'}
535
753
  <div class="chart-empty">{@render empty()}</div>
536
754
  {:else}
755
+ <!-- Mirrors the tooltip's own text for every activation path (pointer,
756
+ keyboard focus, declarative highlightedIndex, imperative
757
+ ChartHighlightAPI) -- see statusText. -->
758
+ <div class="sr-only" role="status" aria-live="polite" id={statusId} data-pw="line-status">
759
+ {statusText}
760
+ </div>
761
+
537
762
  {#if showLegend && series.length > 1 && (chartWidth === 0 || hideLegendBelow === 0 || chartWidth >= hideLegendBelow)}
538
763
  {#if interactiveLegend}
539
764
  <Legend items={legendItems} position="top" onToggle={toggleSeries} />
@@ -570,7 +795,7 @@
570
795
  offset="0%"
571
796
  stop-color={line.color}
572
797
  stop-opacity={Math.min(
573
- (hovered?.si === si ? fillOpacity + 0.2 : fillOpacity) + 0.3,
798
+ (pointerOrFocused?.si === si ? fillOpacity + 0.2 : fillOpacity) + 0.3,
574
799
  1
575
800
  )}
576
801
  />
@@ -654,7 +879,7 @@
654
879
  {:else if gradientFill}
655
880
  <path
656
881
  class="line-area-fill"
657
- class:dimmed={hovered !== null && hovered.si !== si}
882
+ class:dimmed={pointerOrFocused !== null && pointerOrFocused.si !== si}
658
883
  d={line.areaD}
659
884
  fill="url(#line-grad-{uid}-{si})"
660
885
  />
@@ -729,6 +954,46 @@
729
954
  {/if}
730
955
  {/each}
731
956
 
957
+ <!-- Always rendered (independent of showDots), in natural series
958
+ order (paint order doesn't matter -- these stay transparent
959
+ until :focus-visible): one Tab stop per data point, giving
960
+ keyboard users the same per-point targets pointer hover
961
+ already reaches via findNearest. -->
962
+ {#each lines as line, si (si)}
963
+ {#if !line.hidden}
964
+ {#each line.points as point, pi (pi)}
965
+ {@const raw = series[si]?.data[pi]}
966
+ {#if raw && Number.isFinite(point.x) && Number.isFinite(point.y)}
967
+ <!-- `onclick` as well as `onkeydown`, matching all five sibling
968
+ charts, which put both on the same element. Assistive
969
+ technology commonly realises its "activate control" gesture
970
+ as a click DISPATCHED AT the focused node rather than as a
971
+ raw keydown, so a mark carrying only `onkeydown` is
972
+ reachable and announceable and still cannot be activated by
973
+ the very users the focus target exists for.
974
+ `pointer-events: none` does not block this: it suppresses
975
+ hit-testing for real pointer input -- which is what keeps
976
+ mouse hover flowing to the overlay underneath -- while a
977
+ dispatched click still fires a listener on the element. -->
978
+ <circle
979
+ class="focus-target"
980
+ cx={point.x}
981
+ cy={point.y}
982
+ r={6}
983
+ tabindex="0"
984
+ role="button"
985
+ aria-label={focusAriaLabel(si, pi, raw.x)}
986
+ aria-describedby={statusId}
987
+ onfocus={() => handleMarkFocus(si, pi)}
988
+ onblur={handleBlur}
989
+ onkeydown={handleMarkKeydown}
990
+ onclick={handleClick}
991
+ />
992
+ {/if}
993
+ {/each}
994
+ {/if}
995
+ {/each}
996
+
732
997
  {#if activeLineX !== null}
733
998
  <line
734
999
  class="hover-line"
@@ -751,7 +1016,7 @@
751
1016
  data-pw="hover-overlay"
752
1017
  testID="hover-overlay"
753
1018
  onpointermove={handleOverlayMove}
754
- onpointerleave={handleLeave}
1019
+ onpointerleave={handlePointerLeave}
755
1020
  onclick={handleClick}
756
1021
  />
757
1022
  </g>
@@ -797,15 +1062,18 @@
797
1062
  }
798
1063
  .line-area-fill {
799
1064
  transition:
800
- fill-opacity var(--chart-transition-duration, 0.2s) ease,
801
- opacity var(--chart-transition-duration, 0.2s) ease;
1065
+ fill-opacity var(--chart-transition-duration, var(--motion-duration, 0.2s))
1066
+ var(--chart-transition-easing, var(--motion-easing, ease)),
1067
+ opacity var(--chart-transition-duration, var(--motion-duration, 0.2s))
1068
+ var(--chart-transition-easing, var(--motion-easing, ease));
802
1069
  pointer-events: none;
803
1070
  }
804
1071
  .line-area-fill.dimmed {
805
1072
  opacity: var(--linechart-dimmed-opacity, 0.2);
806
1073
  }
807
1074
  .line-path {
808
- transition: opacity var(--chart-transition-duration, 0.2s) ease;
1075
+ transition: opacity var(--chart-transition-duration, var(--motion-duration, 0.2s))
1076
+ var(--chart-transition-easing, var(--motion-easing, ease));
809
1077
  stroke-linecap: round;
810
1078
  stroke-linejoin: round;
811
1079
  pointer-events: none;
@@ -821,8 +1089,10 @@
821
1089
  }
822
1090
  .dot {
823
1091
  transition:
824
- r var(--chart-transition-duration, 0.2s) ease,
825
- opacity var(--chart-transition-duration, 0.2s) ease;
1092
+ r var(--chart-transition-duration, var(--motion-duration, 0.2s))
1093
+ var(--chart-transition-easing, var(--motion-easing, ease)),
1094
+ opacity var(--chart-transition-duration, var(--motion-duration, 0.2s))
1095
+ var(--chart-transition-easing, var(--motion-easing, ease));
826
1096
  stroke: var(--chart-dot-stroke, light-dark(#fff, #111827));
827
1097
  stroke-width: 2;
828
1098
  pointer-events: none;
@@ -838,6 +1108,27 @@
838
1108
  opacity: 0.25;
839
1109
  pointer-events: none;
840
1110
  }
1111
+ .focus-target {
1112
+ fill: transparent;
1113
+ pointer-events: none;
1114
+ }
1115
+ .focus-target:focus-visible {
1116
+ outline: 2px solid var(--chart-axis-label-color, light-dark(#333, #e5e7eb));
1117
+ outline-offset: 1px;
1118
+ }
1119
+ /* Standard visually-hidden recipe (matches PieChart's own .sr-only): present
1120
+ for assistive tech, removed from layout and the visual canvas. */
1121
+ .sr-only {
1122
+ position: absolute;
1123
+ width: 1px;
1124
+ height: 1px;
1125
+ padding: 0;
1126
+ overflow: hidden;
1127
+ clip: rect(0, 0, 0, 0);
1128
+ clip-path: inset(50%);
1129
+ white-space: nowrap;
1130
+ border-width: 0;
1131
+ }
841
1132
  .point-value {
842
1133
  fill: var(--linechart-value-color, light-dark(#333, #e5e7eb));
843
1134
  font-size: var(--linechart-value-font-size, 11px);