@juspay/svelte-ui-components 4.27.6 → 4.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,12 +1,16 @@
1
1
  <script lang="ts">
2
- import type { AreaChartProperties, AreaChartTooltipContext } from './properties';
2
+ import type {
3
+ AreaChartDataPoint,
4
+ AreaChartProperties,
5
+ AreaChartTooltipContext
6
+ } from './properties';
3
7
  import { onMount } from 'svelte';
4
8
  import ChartContainer from '../_chart/ChartContainer.svelte';
5
9
  import Axis from '../_chart/Axis.svelte';
6
10
  import ChartTooltip from '../_chart/ChartTooltip.svelte';
7
11
  import Legend from '../_chart/Legend.svelte';
8
12
  import { createLinearScale, niceLinearDomain, computeLinearTicks } from '../_chart/scales';
9
- import { computeAutoLayout, computeStackedValues } from '../_chart/geometry';
13
+ import { computeAutoLayout, computeStackedValues, joinByX } from '../_chart/geometry';
10
14
  import { linePath, areaPath } from '../_chart/paths';
11
15
  import { getColor } from '../_chart/colors';
12
16
  import { formatNumber, formatPercent, defaultTickFormat } from '../_chart/format';
@@ -14,7 +18,7 @@
14
18
  import { resolvePointLabels } from '../_chart/labels';
15
19
  import { pointerPositionIn, dismissOnOutsidePointerDown } from '../_chart/interactions';
16
20
  import { formatSeriesAggregate } from '../_chart/aggregate';
17
- import type { LegendItem, Point } from '../_chart/types';
21
+ import type { LegendItem, Point, TooltipAnchor } from '../_chart/types';
18
22
  import { DEFAULT_CHART_MAX_HEIGHT } from '../_chart/types';
19
23
 
20
24
  // ── Props ──────────────────────────────────────────────────────
@@ -64,7 +68,19 @@
64
68
  let plotEl: HTMLDivElement | null = $state(null);
65
69
  let chartWidth = $state(0);
66
70
  let chartHeight = $state(0);
67
- let hovered = $state<{ si: number; pi: number } | null>(null);
71
+ // `x` is the shared column key from `joinByX` (a real x VALUE, not an array
72
+ // index); `si` is the series that owns the hovered marker within that
73
+ // column. See the note on `joined` below.
74
+ //
75
+ // `hovered` and `focused` are kept as two separate pieces of state rather
76
+ // than one shared by both input sources: a pointer leaving a mark must
77
+ // never erase that mark's focus, and a late/stale pointerleave -- which
78
+ // real browsers fire *after* a focus event when focusing an off-screen
79
+ // mark auto-scrolls the page under an unmoved cursor -- must not be able
80
+ // to clobber the focus highlight either. See `pointerOrFocused` below for
81
+ // the precedence between them.
82
+ let hovered = $state<{ x: number; si: number } | null>(null);
83
+ let focused = $state<{ x: number; si: number } | null>(null);
68
84
  let mouseX = $state(0);
69
85
  let mouseY = $state(0);
70
86
 
@@ -77,19 +93,49 @@
77
93
  let isStacked = $derived(stacked || stackNormalize);
78
94
  let isEmpty = $derived(series.length === 0 || series.every((s) => s.data.length === 0));
79
95
 
96
+ // ── Cross-series alignment ──────────────────────────────
97
+
98
+ // The ONE alignment contract for tooltip/hit-testing/normalization — see
99
+ // `$lib/_chart/geometry`. For the common case where every series already
100
+ // shares one x sequence this reproduces the old positional pairing.
101
+ let joined = $derived(joinByX(series));
102
+ let joinedByX = $derived(new Map(joined.map((row) => [row.x, row])));
103
+
104
+ // Sum of every series' (clamped-non-negative) value actually present at x —
105
+ // used by both stackNormalize's percent conversion and its tooltip/label
106
+ // total. Built on `joined` rather than a per-index sum, so a shorter/offset
107
+ // series never has its neighbour's total column attributed to it
108
+ // (index-based totals produced a >100% overshoot because two unrelated
109
+ // x's got summed together).
110
+ function columnTotalAtX(x: number): number {
111
+ const row = joinedByX.get(x);
112
+ if (!row) {
113
+ return 0;
114
+ }
115
+ return row.values.reduce(
116
+ (sum, entry) =>
117
+ sum + (entry && Number.isFinite(entry.point.y) ? Math.max(0, entry.point.y) : 0),
118
+ 0
119
+ );
120
+ }
121
+
80
122
  let normalizedSeries = $derived.by(() => {
81
123
  if (!stackNormalize) {
82
124
  return series.map((s) => s.data);
83
125
  }
84
- const totals = series[0]?.data.map((_, i) =>
85
- series.reduce((sum, s) => sum + Math.max(0, s.data[i]?.y ?? 0), 0)
86
- );
87
126
  return series.map((s) =>
88
- s.data.map((d, i) => ({
89
- x: d.x,
90
- y: totals && totals[i] > 0 ? (Math.max(0, d.y) / totals[i]) * 100 : 0,
91
- label: d.label
92
- }))
127
+ s.data.map((d) => {
128
+ const total = columnTotalAtX(d.x);
129
+ return {
130
+ x: d.x,
131
+ // A gap (non-finite y) stays non-finite rather than being coerced
132
+ // to a fabricated 0% — "absent" and "zero" are not the same
133
+ // value, and computeStackedValues (below) already treats a
134
+ // non-finite y as "no segment here", not "an empty segment".
135
+ y: !Number.isFinite(d.y) ? NaN : total > 0 ? (Math.max(0, d.y) / total) * 100 : 0,
136
+ label: d.label
137
+ };
138
+ })
93
139
  );
94
140
  });
95
141
 
@@ -97,7 +143,9 @@
97
143
  if (xDomain) {
98
144
  return xDomain;
99
145
  }
100
- const allX = series.flatMap((s) => s.data.map((d) => d.x));
146
+ // Non-finite x/y mark gap points; they must not poison the
147
+ // auto-domain the way a bare Math.min/max over every value would.
148
+ const allX = series.flatMap((s) => s.data.map((d) => d.x)).filter((x) => Number.isFinite(x));
101
149
  if (allX.length === 0) {
102
150
  return [0, 1];
103
151
  }
@@ -111,13 +159,15 @@
111
159
  return [0, 100];
112
160
  }
113
161
  if (isStacked) {
114
- const topValues = computeStackedValues(normalizedSeries).flatMap((s) => s.map((p) => p.y1));
162
+ const topValues = computeStackedValues(normalizedSeries)
163
+ .flatMap((s) => s.map((p) => p.y1))
164
+ .filter((y) => Number.isFinite(y));
115
165
  if (topValues.length === 0) {
116
166
  return [0, 1];
117
167
  }
118
168
  return niceLinearDomain(0, Math.max(...topValues));
119
169
  }
120
- const allY = series.flatMap((s) => s.data.map((d) => d.y));
170
+ const allY = series.flatMap((s) => s.data.map((d) => d.y)).filter((y) => Number.isFinite(y));
121
171
  if (allY.length === 0) {
122
172
  return [0, 1];
123
173
  }
@@ -156,7 +206,18 @@
156
206
  const botD = linePath(bot, curve);
157
207
  const areaD =
158
208
  topD + ` L ${bot[0].x} ${bot[0].y}` + botD.replace(/^M [^ ]+ [^ ]+/, '') + ' Z';
159
- return { color, points: top, areaD, lineD: topD };
209
+ // computeStackedValues compacts OUT any x where this series has no
210
+ // finite sample, so `seg`'s own array index no longer lines up with
211
+ // series[si].data once a series has a gap or different x coverage
212
+ // than its neighbours (see joinByX's doc comment above `joined`). A
213
+ // focusable mark's aria-label and its hover/click payload both need
214
+ // the REAL series[si].data element, not this compacted array's
215
+ // position -- recover it through the same by-x join every other
216
+ // lookup in this file already goes through, rather than inventing a
217
+ // second index scheme that could drift out of sync with it.
218
+ const xs = seg.map((p) => p.x);
219
+ const dataIndices = xs.map((x) => joinedByX.get(x)?.values[si]?.index ?? -1);
220
+ return { color, points: top, xs, dataIndices, areaD, lineD: topD };
160
221
  });
161
222
  }
162
223
  return series.map((s, si) => {
@@ -165,6 +226,8 @@
165
226
  return {
166
227
  color,
167
228
  points,
229
+ xs: s.data.map((d) => d.x),
230
+ dataIndices: s.data.map((_d, pi) => pi),
168
231
  areaD: areaPath(points, dims.innerHeight, curve),
169
232
  lineD: linePath(points, curve)
170
233
  };
@@ -185,45 +248,88 @@
185
248
 
186
249
  // ── Tooltip ────────────────────────────────────────────────────
187
250
 
188
- let hoverLineX = $derived(
189
- hovered === null ? null : (areas[hovered.si]?.points[hovered.pi]?.x ?? null)
251
+ // Precedence: keyboard focus wins over pointer hover -- see the comment on
252
+ // `focused`'s declaration. Hovering series A's mark while series B's mark
253
+ // holds focus keeps B active; A regains hover feedback once focus moves
254
+ // off it. Every derivation below (row lookup, hover line, anchor, tooltip)
255
+ // reads this combined value rather than `hovered` directly, so all of them
256
+ // agree on what "active" means.
257
+ let pointerOrFocused = $derived(focused ?? hovered);
258
+
259
+ let hoveredRow = $derived(
260
+ pointerOrFocused === null ? null : (joinedByX.get(pointerOrFocused.x) ?? null)
190
261
  );
191
262
 
192
- let tooltipContext = $derived.by<AreaChartTooltipContext | null>(() => {
193
- if (hovered === null) {
263
+ let hoverLineX = $derived.by<number | null>(() => {
264
+ if (pointerOrFocused === null) {
265
+ return null;
266
+ }
267
+ const x = xScale(pointerOrFocused.x);
268
+ return Number.isFinite(x) ? x : null;
269
+ });
270
+
271
+ // Anchors the tooltip to the focused/hovered mark's own rendered position
272
+ // (area.points), not a re-derivation of it -- in `stacked` mode that is
273
+ // the TOP of that series' segment (where its dot/focus-ring actually
274
+ // sits), not the raw un-stacked value's own y. This is the same
275
+ // deliberate stacked-mode approximation docs/AreaChart.md already
276
+ // documents for hover tie-breaks: the tooltip's VALUE is unaffected
277
+ // either way, only where the tooltip visually anchors.
278
+ let anchor = $derived.by<TooltipAnchor | null>(() => {
279
+ if (pointerOrFocused === null) {
194
280
  return null;
195
281
  }
196
- const hp = series[hovered.si]?.data[hovered.pi];
197
- if (!hp) {
282
+ const area = areas[pointerOrFocused.si];
283
+ const pi = area?.xs.indexOf(pointerOrFocused.x) ?? -1;
284
+ if (!area || pi === -1) {
198
285
  return null;
199
286
  }
287
+ const point = area.points[pi];
288
+ return Number.isFinite(point.x) && Number.isFinite(point.y)
289
+ ? { x: point.x + dims.margin.left, y: point.y + dims.margin.top, side: 'top' }
290
+ : null;
291
+ });
292
+
293
+ let tooltipContext = $derived.by<AreaChartTooltipContext | null>(() => {
294
+ if (pointerOrFocused === null || hoveredRow === null) {
295
+ return null;
296
+ }
297
+ const row = hoveredRow;
200
298
  return {
201
- x: hp.x,
299
+ x: pointerOrFocused.x,
202
300
  points: series.map((s, si) => {
203
- const p = s.data[hovered!.pi];
301
+ // Each series contributes ITS OWN sample at this x column — not
302
+ // whatever sits at the hovered/anchor series' array index.
303
+ const entry = row.values[si];
204
304
  return {
205
305
  name: s.name,
206
- y: p?.y ?? 0,
306
+ y: entry?.point.y ?? 0,
207
307
  color: s.color ?? getColor(si),
208
- label: p?.label
308
+ label: entry?.point.label
209
309
  };
210
310
  })
211
311
  };
212
312
  });
213
313
 
214
314
  let tooltipData = $derived.by(() => {
215
- if (tooltipContext === null) {
315
+ if (tooltipContext === null || hoveredRow === null) {
216
316
  return null;
217
317
  }
318
+ const row = hoveredRow;
218
319
  const title = xTickFormat
219
320
  ? xTickFormat(tooltipContext.x)
220
321
  : `x: ${formatNumber(tooltipContext.x)}`;
322
+ // A series absent at this x, or with a gap (non-finite y) here, has
323
+ // nothing to report — showing "0" would read as a measured zero rather
324
+ // than "no data" (absent/zero/invalid are not synonyms).
325
+ const visible = tooltipContext.points
326
+ .map((p, si) => ({ p, entry: row.values[si] }))
327
+ .filter(({ entry }) => entry !== null && Number.isFinite(entry.point.y));
221
328
  if (stackNormalize) {
222
- const pi = hovered?.pi ?? 0;
223
- const columnTotal = series.reduce((sum, s) => sum + Math.max(0, s.data[pi]?.y ?? 0), 0);
329
+ const columnTotal = columnTotalAtX(tooltipContext.x);
224
330
  return {
225
331
  title,
226
- items: tooltipContext.points.map((p) => ({
332
+ items: visible.map(({ p }) => ({
227
333
  label: p.name,
228
334
  value: formatPercent(Math.max(0, p.y), columnTotal),
229
335
  color: p.color
@@ -232,7 +338,7 @@
232
338
  }
233
339
  return {
234
340
  title,
235
- items: tooltipContext.points.map((p) => ({
341
+ items: visible.map(({ p }) => ({
236
342
  label: p.name,
237
343
  value: formatNumber(p.y),
238
344
  color: p.color
@@ -243,9 +349,10 @@
243
349
  // ── Point labels ───────────────────────────────────────────────
244
350
 
245
351
  function pointDisplayValue(si: number, pi: number): string {
246
- const y = series[si]?.data[pi]?.y ?? 0;
352
+ const point = series[si]?.data[pi];
353
+ const y = point?.y ?? 0;
247
354
  if (stackNormalize) {
248
- const columnTotal = series.reduce((sum, s) => sum + Math.max(0, s.data[pi]?.y ?? 0), 0);
355
+ const columnTotal = point ? columnTotalAtX(point.x) : 0;
249
356
  return formatPercent(Math.max(0, y), columnTotal);
250
357
  }
251
358
  return formatNumber(y);
@@ -266,6 +373,34 @@
266
373
  );
267
374
  });
268
375
 
376
+ // ── Keyboard access (family-wide contract, see docs/PieChart.md#keyboard-access) ─
377
+
378
+ // AreaChart/LineChart are continuous series, not discrete categories like
379
+ // Bar/Funnel/Pie/Sankey/DualAxisBar -- there is no fixed set of "marks" to
380
+ // Tab through independent of data shape. The deliberate call here: each
381
+ // rendered (series, point) pair is its own Tab stop, in series-then-point
382
+ // DOM order, mirroring exactly what pointer hover already lands on via
383
+ // findNearest -- so keyboard and mouse users reach the same set of
384
+ // addressable data points, just via a different input.
385
+ function focusPointLabel(point: AreaChartDataPoint, x: number): string {
386
+ if (point.label) {
387
+ return point.label;
388
+ }
389
+ return xTickFormat ? xTickFormat(x) : formatNumber(x);
390
+ }
391
+
392
+ // Matches the family's per-mark "{name}: {value}" convention (Bar/Funnel/
393
+ // Pie), extended with the "{name} — {series}: {value}" em-dash form for
394
+ // multi-series charts -- BarChart's own tooltip already disambiguates
395
+ // multi-series marks this way, but its aria-label omits the series name
396
+ // (a gap found while extracting this contract, not repeated here).
397
+ function focusAriaLabel(si: number, dataIndex: number, x: number): string {
398
+ const point = series[si]?.data[dataIndex];
399
+ const name = point ? focusPointLabel(point, x) : formatNumber(x);
400
+ const value = pointDisplayValue(si, dataIndex);
401
+ return series.length > 1 ? `${name} — ${series[si].name}: ${value}` : `${name}: ${value}`;
402
+ }
403
+
269
404
  // ── Interactions ───────────────────────────────────────────────
270
405
 
271
406
  function trackMouse(e: PointerEvent) {
@@ -276,40 +411,94 @@
276
411
  }
277
412
  }
278
413
 
279
- function findNearest(plotX: number, plotY: number): { si: number; pi: number } | null {
280
- if (series.length === 0) {
281
- return null;
282
- }
283
- // Use the longest series as the index reference so hover still works when
284
- // the first series is empty or shorter than the others.
285
- const reference = series.reduce((a, b) => (b.data.length > a.data.length ? b : a)).data;
286
- if (reference.length === 0) {
287
- return null;
288
- }
289
- let nearestPi = 0;
414
+ // Nearest hit-test over the SAME `joined` columns used for tooltip/
415
+ // normalization — one alignment contract, not a second position-based
416
+ // path. A column is only eligible if some series actually has a finite
417
+ // value there (a shared gap column is not hoverable).
418
+ function findNearest(plotX: number, plotY: number): { x: number; si: number } | null {
419
+ let nearestRow: (typeof joined)[number] | null = null;
290
420
  let nearestXDist = Infinity;
291
- for (let i = 0; i < reference.length; i++) {
292
- const px = xScale(reference[i].x);
421
+ for (const row of joined) {
422
+ const hasValue = row.values.some((entry) => entry !== null && Number.isFinite(entry.point.y));
423
+ if (!hasValue) {
424
+ continue;
425
+ }
426
+ const px = xScale(row.x);
293
427
  const dist = Math.abs(px - plotX);
294
428
  if (dist < nearestXDist) {
295
429
  nearestXDist = dist;
296
- nearestPi = i;
430
+ nearestRow = row;
297
431
  }
298
432
  }
299
- let nearestSi = 0;
433
+ if (nearestRow === null) {
434
+ return null;
435
+ }
436
+ // Nearest by each series' OWN (non-cumulative) value in y-space. In
437
+ // stacked mode this is a deliberate simplification of the previous
438
+ // "nearest by rendered/stacked pixel" tie-break — see docs/AreaChart.md —
439
+ // the value shown for the hovered x is unaffected either way; only which
440
+ // series' marker wins a close proximity call can differ.
441
+ let nearestSi = -1;
300
442
  let nearestYDist = Infinity;
301
- for (let si = 0; si < areas.length; si++) {
302
- const p = areas[si].points[nearestPi];
303
- if (!p) {
443
+ for (let si = 0; si < series.length; si++) {
444
+ const entry = nearestRow.values[si];
445
+ if (!entry || !Number.isFinite(entry.point.y)) {
304
446
  continue;
305
447
  }
306
- const dist = Math.abs(p.y - plotY);
448
+ const py = yScale(entry.point.y);
449
+ const dist = Math.abs(py - plotY);
307
450
  if (dist < nearestYDist) {
308
451
  nearestYDist = dist;
309
452
  nearestSi = si;
310
453
  }
311
454
  }
312
- return { si: nearestSi, pi: nearestPi };
455
+ return nearestSi === -1 ? null : { x: nearestRow.x, si: nearestSi };
456
+ }
457
+
458
+ function sameMark(a: { x: number; si: number } | null, b: { x: number; si: number } | null) {
459
+ return a !== null && b !== null && a.x === b.x && a.si === b.si;
460
+ }
461
+
462
+ // onpointhover mirrors the combined pointer-or-focus mark (pointerOrFocused),
463
+ // once per actual change -- e.g. a pointer leaving a mark that is still
464
+ // keyboard-focused must not report a hover-cleared event for a highlight
465
+ // that never went away. Shared by activatePointer/activateFocus/
466
+ // handlePointerLeave/handleBlur so all four write paths agree on the same
467
+ // dedup.
468
+ let lastNotified: { x: number; si: number } | null = null;
469
+ function notifyPointHover() {
470
+ const next = pointerOrFocused;
471
+ if (sameMark(next, lastNotified)) {
472
+ return;
473
+ }
474
+ lastNotified = next;
475
+ if (next === null) {
476
+ onpointhover?.(null);
477
+ return;
478
+ }
479
+ const entry = joinedByX.get(next.x)?.values[next.si];
480
+ if (entry) {
481
+ // pointIndex is this series' own array index, matching the
482
+ // documented event contract even when series' x coverage differs.
483
+ onpointhover?.({ seriesIndex: next.si, pointIndex: entry.index, point: entry.point });
484
+ }
485
+ }
486
+
487
+ // Pointer half of activation, used by handleOverlayMove. Skips the write
488
+ // (not just the notify) when the mark hasn't changed, since
489
+ // handleOverlayMove fires on every pointermove over the same mark.
490
+ function activatePointer(next: { x: number; si: number }) {
491
+ if (!sameMark(hovered, next)) {
492
+ hovered = next;
493
+ }
494
+ notifyPointHover();
495
+ }
496
+
497
+ // Focus half of activation, used by handleMarkFocus. See `focused`'s
498
+ // declaration for why this writes separate state from activatePointer.
499
+ function activateFocus(next: { x: number; si: number }) {
500
+ focused = next;
501
+ notifyPointHover();
313
502
  }
314
503
 
315
504
  function handleOverlayMove(e: PointerEvent) {
@@ -321,27 +510,57 @@
321
510
  hovered = null;
322
511
  return;
323
512
  }
324
- if (hovered === null || hovered.si !== next.si || hovered.pi !== next.pi) {
325
- hovered = next;
326
- const point = series[next.si].data[next.pi];
327
- onpointhover?.({ seriesIndex: next.si, pointIndex: next.pi, point });
328
- }
513
+ activatePointer(next);
329
514
  }
330
515
 
331
- function handleLeave() {
516
+ // Wired to the hover-overlay's pointerleave. Clears ONLY the hover half of
517
+ // the state -- never `focused` -- so a late, stale pointerleave (see
518
+ // `focused`'s declaration comment) cannot erase a mark's focus highlight.
519
+ function handlePointerLeave() {
332
520
  if (hovered !== null) {
333
521
  hovered = null;
334
- onpointhover?.(null);
522
+ notifyPointHover();
523
+ }
524
+ }
525
+
526
+ // Keyboard mirror of pointer hover/click: focusing a mark activates it
527
+ // exactly like the nearest-point hover search would (same tooltip/
528
+ // highlight/anchor state, via pointerOrFocused), blur mirrors pointer-leave
529
+ // for the focus half, and Enter/Space invoke the same click callback a
530
+ // pointer click would -- the Enter/Space-only contract every one of the
531
+ // five existing charts uses (none of Bar/DualAxisBar/Funnel/Pie/Sankey
532
+ // wires arrow keys, Home, End or Escape, so none are added here either).
533
+ function handleMarkFocus(x: number, si: number) {
534
+ activateFocus({ x, si });
535
+ }
536
+
537
+ // Wired to the .focus-target circle's blur. Clears ONLY the focus half; a
538
+ // pointer that happens to still be over the mark keeps it hovered.
539
+ function handleBlur() {
540
+ if (focused !== null) {
541
+ focused = null;
542
+ notifyPointHover();
543
+ }
544
+ }
545
+
546
+ function handleMarkKeydown(e: KeyboardEvent) {
547
+ if (e.key === 'Enter' || e.key === ' ') {
548
+ e.preventDefault();
549
+ handleClick();
335
550
  }
336
551
  }
337
552
 
338
553
  function handleClick() {
339
- if (hovered === null) {
554
+ if (pointerOrFocused === null) {
340
555
  return;
341
556
  }
342
- const point = series[hovered.si]?.data[hovered.pi];
343
- if (point) {
344
- onpointclick?.({ seriesIndex: hovered.si, pointIndex: hovered.pi, point });
557
+ const entry = joinedByX.get(pointerOrFocused.x)?.values[pointerOrFocused.si];
558
+ if (entry) {
559
+ onpointclick?.({
560
+ seriesIndex: pointerOrFocused.si,
561
+ pointIndex: entry.index,
562
+ point: entry.point
563
+ });
345
564
  }
346
565
  }
347
566
 
@@ -351,7 +570,7 @@
351
570
  if (hovered === null) {
352
571
  return;
353
572
  }
354
- return dismissOnOutsidePointerDown(containerEl, handleLeave);
573
+ return dismissOnOutsidePointerDown(containerEl, handlePointerLeave);
355
574
  });
356
575
  </script>
357
576
 
@@ -398,7 +617,7 @@
398
617
  offset="0%"
399
618
  stop-color={area.color}
400
619
  stop-opacity={Math.min(
401
- (hovered?.si === si ? fillOpacity + 0.2 : fillOpacity) + 0.3,
620
+ (pointerOrFocused?.si === si ? fillOpacity + 0.2 : fillOpacity) + 0.3,
402
621
  1
403
622
  )}
404
623
  />
@@ -437,25 +656,29 @@
437
656
  {#each areas as area, si (si)}
438
657
  <path
439
658
  class="area-fill"
440
- class:dimmed={hovered !== null && hovered.si !== si}
659
+ class:dimmed={pointerOrFocused !== null && pointerOrFocused.si !== si}
441
660
  d={area.areaD}
442
661
  fill={gradientFill ? `url(#area-grad-${uid}-${si})` : area.color}
443
- fill-opacity={gradientFill ? 1 : hovered?.si === si ? fillOpacity + 0.2 : fillOpacity}
662
+ fill-opacity={gradientFill
663
+ ? 1
664
+ : pointerOrFocused?.si === si
665
+ ? fillOpacity + 0.2
666
+ : fillOpacity}
444
667
  />
445
668
  {#if showLine}
446
669
  <path
447
670
  class="area-line"
448
- class:dimmed={hovered !== null && hovered.si !== si}
671
+ class:dimmed={pointerOrFocused !== null && pointerOrFocused.si !== si}
449
672
  d={area.lineD}
450
673
  stroke={area.color}
451
674
  stroke-width={strokeWidth}
452
675
  fill="none"
453
676
  />
454
677
  {/if}
455
- {#if area.points.length === 1 && !showDots}
678
+ {#if area.points.length === 1 && !showDots && Number.isFinite(area.points[0].x) && Number.isFinite(area.points[0].y)}
456
679
  <circle
457
680
  class="single-point"
458
- class:dimmed={hovered !== null && hovered.si !== si}
681
+ class:dimmed={pointerOrFocused !== null && pointerOrFocused.si !== si}
459
682
  cx={area.points[0].x}
460
683
  cy={area.points[0].y}
461
684
  r={6}
@@ -464,19 +687,27 @@
464
687
  {/if}
465
688
  {#if showDots}
466
689
  {#each area.points as point, pi (pi)}
467
- <circle
468
- class="dot"
469
- cx={point.x}
470
- cy={point.y}
471
- r={hovered?.si === si && hovered?.pi === pi ? 6 : 3}
472
- fill={area.color}
473
- />
690
+ <!-- Gap points (non-finite) render no marker. -->
691
+ {#if Number.isFinite(point.x) && Number.isFinite(point.y)}
692
+ <circle
693
+ class="dot"
694
+ cx={point.x}
695
+ cy={point.y}
696
+ r={pointerOrFocused !== null &&
697
+ pointerOrFocused.si === si &&
698
+ hoverLineX !== null &&
699
+ point.x === hoverLineX
700
+ ? 6
701
+ : 3}
702
+ fill={area.color}
703
+ />
704
+ {/if}
474
705
  {/each}
475
706
  {/if}
476
707
  {#if showValues && pointLabelPlacements[si]}
477
708
  {#each area.points as _point, pi (pi)}
478
709
  {@const pl = pointLabelPlacements[si][pi]}
479
- {#if pl?.visible}
710
+ {#if pl?.visible && Number.isFinite(pl.x) && Number.isFinite(pl.y)}
480
711
  <text
481
712
  class="point-value"
482
713
  x={pl.x}
@@ -487,6 +718,41 @@
487
718
  {/if}
488
719
  {/each}
489
720
  {/if}
721
+
722
+ <!-- Always rendered (independent of showDots): one Tab stop per
723
+ data point, transparent until :focus-visible. Sits under the
724
+ hover-overlay rect below (paint order = DOM order here), so
725
+ pointer hit-testing is untouched -- this layer exists only
726
+ to give keyboard users the same per-point targets pointer
727
+ hover already reaches via findNearest. -->
728
+ {#each area.points as point, pi (pi)}
729
+ {#if Number.isFinite(point.x) && Number.isFinite(point.y)}
730
+ <!-- `onclick` as well as `onkeydown`, matching all five sibling
731
+ charts, which put both on the same element. Assistive
732
+ technology commonly realises its "activate control" gesture
733
+ as a click DISPATCHED AT the focused node rather than as a
734
+ raw keydown, so a mark carrying only `onkeydown` is
735
+ reachable and announceable and still cannot be activated by
736
+ the very users the focus target exists for.
737
+ `pointer-events: none` does not block this: it suppresses
738
+ hit-testing for real pointer input -- which is what keeps
739
+ mouse hover flowing to the overlay underneath -- while a
740
+ dispatched click still fires a listener on the element. -->
741
+ <circle
742
+ class="focus-target"
743
+ cx={point.x}
744
+ cy={point.y}
745
+ r={6}
746
+ tabindex="0"
747
+ role="button"
748
+ aria-label={focusAriaLabel(si, area.dataIndices[pi], area.xs[pi])}
749
+ onfocus={() => handleMarkFocus(area.xs[pi], si)}
750
+ onblur={handleBlur}
751
+ onkeydown={handleMarkKeydown}
752
+ onclick={handleClick}
753
+ />
754
+ {/if}
755
+ {/each}
490
756
  {/each}
491
757
 
492
758
  {#if hoverLineX !== null}
@@ -503,7 +769,7 @@
503
769
  height={dims.innerHeight}
504
770
  fill="transparent"
505
771
  onpointermove={handleOverlayMove}
506
- onpointerleave={handleLeave}
772
+ onpointerleave={handlePointerLeave}
507
773
  onclick={handleClick}
508
774
  />
509
775
  </g>
@@ -514,6 +780,7 @@
514
780
  data={tooltipData}
515
781
  {mouseX}
516
782
  {mouseY}
783
+ {anchor}
517
784
  portal={tooltipPortal}
518
785
  originEl={plotEl}
519
786
  unstyled
@@ -529,6 +796,7 @@
529
796
  data={tooltipData}
530
797
  {mouseX}
531
798
  {mouseY}
799
+ {anchor}
532
800
  portal={tooltipPortal}
533
801
  originEl={plotEl}
534
802
  />
@@ -547,15 +815,18 @@
547
815
  }
548
816
  .area-fill {
549
817
  transition:
550
- fill-opacity var(--chart-transition-duration, 0.2s) ease,
551
- opacity var(--chart-transition-duration, 0.2s) ease;
818
+ fill-opacity var(--chart-transition-duration, var(--motion-duration, 0.2s))
819
+ var(--chart-transition-easing, var(--motion-easing, ease)),
820
+ opacity var(--chart-transition-duration, var(--motion-duration, 0.2s))
821
+ var(--chart-transition-easing, var(--motion-easing, ease));
552
822
  pointer-events: none;
553
823
  }
554
824
  .area-fill.dimmed {
555
825
  opacity: var(--areachart-dimmed-opacity, 0.1);
556
826
  }
557
827
  .area-line {
558
- transition: opacity var(--chart-transition-duration, 0.2s) ease;
828
+ transition: opacity var(--chart-transition-duration, var(--motion-duration, 0.2s))
829
+ var(--chart-transition-easing, var(--motion-easing, ease));
559
830
  stroke-linecap: round;
560
831
  stroke-linejoin: round;
561
832
  pointer-events: none;
@@ -571,12 +842,22 @@
571
842
  }
572
843
  .dot {
573
844
  transition:
574
- r var(--chart-transition-duration, 0.2s) ease,
575
- opacity var(--chart-transition-duration, 0.2s) ease;
845
+ r var(--chart-transition-duration, var(--motion-duration, 0.2s))
846
+ var(--chart-transition-easing, var(--motion-easing, ease)),
847
+ opacity var(--chart-transition-duration, var(--motion-duration, 0.2s))
848
+ var(--chart-transition-easing, var(--motion-easing, ease));
576
849
  stroke: var(--chart-dot-stroke, light-dark(#fff, #111827));
577
850
  stroke-width: 2;
578
851
  pointer-events: none;
579
852
  }
853
+ .focus-target {
854
+ fill: transparent;
855
+ pointer-events: none;
856
+ }
857
+ .focus-target:focus-visible {
858
+ outline: 2px solid var(--chart-axis-label-color, light-dark(#333, #e5e7eb));
859
+ outline-offset: 1px;
860
+ }
580
861
  .point-value {
581
862
  fill: var(--areachart-value-color, light-dark(#333, #e5e7eb));
582
863
  font-size: var(--areachart-value-font-size, 11px);