@juspay/svelte-ui-components 4.29.1 → 4.31.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 (58) hide show
  1. package/README.md +15 -14
  2. package/dist/Accordion/Accordion.svelte +18 -0
  3. package/dist/AnimatedNumber/AnimatedNumber.svelte +776 -0
  4. package/dist/AnimatedNumber/AnimatedNumber.svelte.d.ts +4 -0
  5. package/dist/AnimatedNumber/motion.d.ts +47 -0
  6. package/dist/AnimatedNumber/motion.js +190 -0
  7. package/dist/AnimatedNumber/properties.d.ts +58 -0
  8. package/dist/AnimatedNumber/properties.js +1 -0
  9. package/dist/Badge/Badge.svelte +20 -3
  10. package/dist/Badge/properties.d.ts +9 -0
  11. package/dist/Book/Book.svelte +9 -0
  12. package/dist/Calendar/Calendar.svelte +34 -2
  13. package/dist/Carousel/Carousel.svelte +17 -2
  14. package/dist/ChatBubble/ChatBubble.svelte +12 -0
  15. package/dist/ChatComposer/ChatComposer.svelte +9 -0
  16. package/dist/CommandMenu/CommandMenu.svelte +13 -0
  17. package/dist/DateRangePicker/DateRangePicker.svelte +77 -20
  18. package/dist/DeltaIndicator/DeltaIndicator.svelte +12 -1
  19. package/dist/DeltaIndicator/properties.d.ts +9 -0
  20. package/dist/Gallery/Gallery.svelte +16 -0
  21. package/dist/Gauge/Gauge.svelte +21 -2
  22. package/dist/Gauge/properties.d.ts +13 -0
  23. package/dist/Input/Input.svelte +34 -26
  24. package/dist/KeyboardInput/KeyboardInput.svelte +1 -1
  25. package/dist/ListItem/ListItem.svelte +27 -2
  26. package/dist/ListItem/label.d.ts +11 -0
  27. package/dist/ListItem/label.js +40 -0
  28. package/dist/ListItem/properties.d.ts +52 -0
  29. package/dist/MediaUpload/MediaUpload.svelte +12 -1
  30. package/dist/MediaUpload/properties.d.ts +9 -0
  31. package/dist/Menu/Menu.svelte +11 -2
  32. package/dist/Modal/Modal.svelte +14 -4
  33. package/dist/PieChart/PieChart.svelte +16 -1
  34. package/dist/PieChart/properties.d.ts +13 -0
  35. package/dist/Progress/Progress.svelte +21 -1
  36. package/dist/Progress/properties.d.ts +12 -0
  37. package/dist/ProportionBar/ProportionBar.svelte +7 -1
  38. package/dist/ProportionBar/ProportionBar.svelte.d.ts +0 -19
  39. package/dist/ProportionBar/properties.d.ts +10 -0
  40. package/dist/Select/Select.svelte +23 -8
  41. package/dist/SplitButton/SplitButton.svelte +4 -1
  42. package/dist/SplitInput/SplitInput.svelte +1 -1
  43. package/dist/StatCard/StatCard.svelte +129 -5
  44. package/dist/StatCard/metric.d.ts +22 -0
  45. package/dist/StatCard/metric.js +74 -0
  46. package/dist/StatCard/properties.d.ts +44 -0
  47. package/dist/Table/BuiltinCell.svelte +19 -2
  48. package/dist/Table/cellData.js +3 -0
  49. package/dist/Table/properties.d.ts +15 -0
  50. package/dist/Tabs/Tabs.svelte +16 -0
  51. package/dist/index.d.ts +2 -0
  52. package/dist/index.js +1 -0
  53. package/dist/styles/theme-dark.css +27 -3
  54. package/dist-codemod/codemod/migration-assets/legacy-display.css +2 -1
  55. package/dist-codemod/codemod/migration-assets/legacy-palette.css +4 -1
  56. package/dist-codemod/codemod/migration-assets/wc-display.json +5 -0
  57. package/dist-wc/index.js +1435 -747
  58. package/package.json +4 -2
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import AnimatedNumber from '../AnimatedNumber/AnimatedNumber.svelte';
2
3
  import Button from '../Button/Button.svelte';
3
4
  import Img from '../Img/Img.svelte';
4
5
  import Shimmer from '../Shimmer/Shimmer.svelte';
@@ -26,6 +27,7 @@
26
27
  fileIcon,
27
28
  errorMessages,
28
29
  files = $bindable([]),
30
+ animateCounter = false,
29
31
  onchange,
30
32
  onremove,
31
33
  onerror,
@@ -191,7 +193,16 @@
191
193
  <div class="label">{label}</div>
192
194
  {/if}
193
195
  {#if showCounter}
194
- <div class="counter">{items.length} / {maxLength}</div>
196
+ {#if animateCounter}
197
+ <!-- The whole "N / max" reads as one value to assistive technology.
198
+ Only the count changes, so only its digits roll; the separator and
199
+ the maximum sit still as literal columns. -->
200
+ <div class="counter">
201
+ <AnimatedNumber value={`${items.length} / ${maxLength}`} />
202
+ </div>
203
+ {:else}
204
+ <div class="counter">{items.length} / {maxLength}</div>
205
+ {/if}
195
206
  {/if}
196
207
  </div>
197
208
  {/if}
@@ -36,6 +36,15 @@ export type OptionalMediaUploadProperties = {
36
36
  fileIcon?: Snippet;
37
37
  errorMessages?: MediaUploadErrorMessages;
38
38
  files?: File[];
39
+ /**
40
+ * Routes the header's `N / maxLength` counter through `AnimatedNumber` so an
41
+ * add/remove rolls the count instead of swapping it as plain text. Only
42
+ * `items.length` animates -- the `/` separator and `maxLength` stay static,
43
+ * since neither of those actually changes. Off by default so an unset prop
44
+ * renders the exact static text node it always has.
45
+ * @default false
46
+ */
47
+ animateCounter?: boolean;
39
48
  testId?: string;
40
49
  classes?: string;
41
50
  };
@@ -527,8 +527,17 @@
527
527
  -webkit-tap-highlight-color: transparent;
528
528
  }
529
529
 
530
- .menu-trigger:focus {
531
- outline: var(--menu-trigger-focus-outline, none);
530
+ /* :focus-visible, not :focus: the trigger is opened by mouse click as often
531
+ as by keyboard (onclick={toggle} above), and a ring on every mouse-opened
532
+ menu would be noise. The default used to be `none` with nothing standing
533
+ in for it, so a keyboard user tabbing to (or landing back on, after
534
+ Escape closes the dropdown) the trigger had no visible sign it was
535
+ focused. SplitButton's own trigger wraps this same element and used to
536
+ set --menu-trigger-focus-outline to `none` for the identical reason --
537
+ see its .split-button-trigger, now fixed to inherit this default instead. */
538
+ .menu-trigger:focus-visible {
539
+ outline: var(--menu-trigger-focus-outline, 2px solid #2563eb);
540
+ outline-offset: var(--menu-trigger-focus-outline-offset, 2px);
532
541
  }
533
542
 
534
543
  .menu-dropdown {
@@ -445,10 +445,6 @@
445
445
  border-radius: var(--modal-border-radius, var(--radius, 4px));
446
446
  overflow: var(--modal-content-overflow, auto);
447
447
  border-top: var(--modal-content-border-top);
448
- /* tabindex="-1" makes this the focus-trap's fallback target when the modal
449
- has no focusable content of its own (see _interaction/focus.ts); it is
450
- never in the Tab order itself, so it needs no visible focus ring the way
451
- a real focusable element would. Matches Sheet's sheet-panel. */
452
448
  outline: none;
453
449
  /* Viewport containment for every size class: only .fit-content used to carry
454
450
  a max-height, so a size whose height var is overridden to fit-content (or
@@ -459,6 +455,20 @@
459
455
  max-height: var(--modal-max-height, calc(100dvh - 32px));
460
456
  }
461
457
 
458
+ /* The comment this replaced claimed tabindex="-1" meant this never needed a
459
+ ring because it's "never in the Tab order itself" -- true, but beside the
460
+ point: onMount calls focusEntryPoint(modalContent), which focuses THIS
461
+ element directly (real DOM focus, not merely reachable via Tab) whenever
462
+ the modal has no focusable content of its own. That is exactly Sheet's
463
+ sheet-panel problem, and sheet-panel got the fix this comment said it
464
+ "matches" -- this one didn't. :focus-visible so the trigger click that
465
+ opens the modal shows nothing new; offset is negative like Sheet's,
466
+ since a modal can be sized to fill nearly the whole viewport. */
467
+ .modal-content:focus-visible {
468
+ outline: var(--modal-content-focus-outline, 2px solid #2563eb);
469
+ outline-offset: var(--modal-content-focus-outline-offset, -2px);
470
+ }
471
+
462
472
  .slot-content {
463
473
  display: var(--modal-display, flex);
464
474
  /* Flex children default to min-height: auto and refuse to shrink below their
@@ -6,6 +6,7 @@
6
6
  import ChartTooltip from '../_chart/ChartTooltip.svelte';
7
7
  import Legend from '../_chart/Legend.svelte';
8
8
  import DeltaIndicator from '../DeltaIndicator/DeltaIndicator.svelte';
9
+ import AnimatedNumber from '../AnimatedNumber/AnimatedNumber.svelte';
9
10
  import { arcPath } from '../_chart/paths';
10
11
  import { computePieLayout, pieSliceValue } from '../_chart/geometry';
11
12
  import { getColor } from '../_chart/colors';
@@ -45,6 +46,7 @@
45
46
  legendPosition = 'bottom',
46
47
  legendMaxItems,
47
48
  onlegendmore,
49
+ animateLegendValues = false,
48
50
  percentDecimals = 0,
49
51
  onchartready,
50
52
  highlightedIndex = null,
@@ -123,6 +125,15 @@
123
125
  (v: number): string =>
124
126
  total === 0 ? '0%' : ((v / total) * 100).toFixed(percentDecimals) + '%'
125
127
  );
128
+ // Builds the exact text the static legend branch below prints -- value,
129
+ // then a non-breaking space, then the percentage -- as one string, so
130
+ // the animateLegendValues branch feeds AnimatedNumber literally what a
131
+ // reader already sees today rather than a re-derived approximation of it.
132
+ let legendValueText = $derived.by(
133
+ () =>
134
+ (v: number): string =>
135
+ `${format(v)}\u00A0${pctFormat(v)}`
136
+ );
126
137
  let isEmpty = $derived(data.length === 0 || total === 0);
127
138
 
128
139
  // When semiCircle is true the effective aspect ratio is driven by:
@@ -590,7 +601,11 @@
590
601
  <span class="pie-legend-swatch" style="background: {d.color ?? getColor(i)}"></span>
591
602
  <span class="pie-legend-label">{d.label}</span>
592
603
  <span class="pie-legend-value">
593
- {format(pieSliceValue(d.value))}&nbsp;{pctFormat(pieSliceValue(d.value))}
604
+ {#if animateLegendValues}
605
+ <AnimatedNumber value={legendValueText(pieSliceValue(d.value))} />
606
+ {:else}
607
+ {format(pieSliceValue(d.value))}&nbsp;{pctFormat(pieSliceValue(d.value))}
608
+ {/if}
594
609
  </span>
595
610
  </li>
596
611
  {/each}
@@ -66,6 +66,19 @@ export type OptionalPieChartProperties = {
66
66
  * the control toggles the hidden rows in place.
67
67
  */
68
68
  onlegendmore?: () => void;
69
+ /**
70
+ * Routes `.pie-legend-value` (the `legendShowValues` list) through
71
+ * `AnimatedNumber` so a changed slice value rolls instead of jumping. Off
72
+ * by default because it is new: with it left unset, the legend renders the
73
+ * same static text node it always has. The exact string the static branch
74
+ * already prints -- `valueFormat` when supplied, else the built-in
75
+ * default, plus the percentage -- is passed straight through, so a
76
+ * consumer's own formatter keeps deciding what the digits say. The on-arc
77
+ * `<text>` labels can't host per-digit spans and are unaffected either
78
+ * way.
79
+ * @default false
80
+ */
81
+ animateLegendValues?: boolean;
69
82
  percentDecimals?: number;
70
83
  /**
71
84
  * Called once on mount with the imperative highlight API. Pass the returned
@@ -1,10 +1,12 @@
1
1
  <script lang="ts">
2
2
  import type { ProgressProperties } from './properties';
3
+ import AnimatedNumber from '../AnimatedNumber/AnimatedNumber.svelte';
3
4
 
4
5
  let {
5
6
  value,
6
7
  max = 100,
7
8
  showLabel = false,
9
+ animateValue = false,
8
10
  ariaLabel,
9
11
  testId,
10
12
  classes
@@ -58,7 +60,25 @@
58
60
  ></div>
59
61
  </div>
60
62
  {#if showLabel && !isIndeterminate}
61
- <div class="label">{labelText}</div>
63
+ <div class="label">
64
+ {#if animateValue}
65
+ <!-- The rounded percentage is already the display value labelText
66
+ computes, so it is passed as a plain number rather than through
67
+ `format`: `AnimatedNumber`'s Intl percent style expects a 0-1
68
+ fraction and would re-derive a different number from this
69
+ already-0-100 one. The % is a literal beside it instead, exactly
70
+ mirroring what labelText already spells out below. -->
71
+ <!-- The unit goes INSIDE AnimatedNumber, not beside it. The odometer
72
+ carries role="img" with its own aria-label, so a unit left outside is
73
+ invisible to anyone navigating by graphic -- the rotor reads "75" with
74
+ no sign of what it measures. Passing the formatted string keeps the
75
+ unit in the accessible name; only the digits move, since the string
76
+ path rolls the characters that changed and leaves the rest. -->
77
+ <AnimatedNumber value={`${Math.round(percentage)}%`} />
78
+ {:else}
79
+ {labelText}
80
+ {/if}
81
+ </div>
62
82
  {/if}
63
83
  </div>
64
84
 
@@ -12,6 +12,18 @@ export type OptionalProgressProperties = {
12
12
  */
13
13
  max?: number;
14
14
  showLabel?: boolean;
15
+ /**
16
+ * Routes the completion percentage through `AnimatedNumber` instead of
17
+ * static text, so a consumer that already renders `showLabel` gets the
18
+ * label visibly counting to its new value for free. Kept opt-in rather
19
+ * than folded into `showLabel` itself, since `AnimatedNumber` is a new
20
+ * dependency this component didn't previously have and existing markup
21
+ * must stay untouched by default. An indeterminate bar has no percentage
22
+ * to animate towards, so this is silently inert there rather than a state
23
+ * that would need its own documentation.
24
+ * @default false
25
+ */
26
+ animateValue?: boolean;
15
27
  /**
16
28
  * Accessible label for the progress element (`role="progressbar"`). Falls
17
29
  * back to the computed percentage text (e.g. `"75%"`) when determinate, or
@@ -18,6 +18,7 @@
18
18
  *
19
19
  * @see docs/ProportionBar.md
20
20
  */
21
+ import AnimatedNumber from '../AnimatedNumber/AnimatedNumber.svelte';
21
22
  import type { ProportionBarProperties, ProportionBarSegment } from './properties';
22
23
 
23
24
  const DEFAULT_PALETTE = ['#8F49DE', '#FFC533', '#62D5C0', '#FF74CD', '#D1E7FF'];
@@ -27,6 +28,7 @@
27
28
  showLegend = true,
28
29
  valueFormat,
29
30
  trackHeight,
31
+ animateValue = false,
30
32
  testId,
31
33
  classes
32
34
  }: ProportionBarProperties = $props();
@@ -152,7 +154,11 @@
152
154
  ></span>
153
155
  <span class="proportion-bar-legend-label">{computedSegment.label}</span>
154
156
  <span class="proportion-bar-legend-value">
155
- {formatValue(computedSegment.value, computedSegment.percent)}
157
+ {#if animateValue}
158
+ <AnimatedNumber value={formatValue(computedSegment.value, computedSegment.percent)} />
159
+ {:else}
160
+ {formatValue(computedSegment.value, computedSegment.percent)}
161
+ {/if}
156
162
  </span>
157
163
  </li>
158
164
  {/each}
@@ -1,22 +1,3 @@
1
- /**
2
- * ProportionBar — a horizontal stacked bar that visualises how a total is
3
- * distributed across labelled segments. Renders proportional coloured bands in
4
- * an SVG track with an optional legend. Each band's width is derived from its
5
- * `value` relative to the sum of all segments; non-finite or negative values
6
- * are treated as zero so the rendered widths always stay within 0–100%.
7
- *
8
- * @example
9
- * ```svelte
10
- * <ProportionBar
11
- * segments={[
12
- * { label: 'UPI', value: 480 },
13
- * { label: 'Cards', value: 220 }
14
- * ]}
15
- * />
16
- * ```
17
- *
18
- * @see docs/ProportionBar.md
19
- */
20
1
  import type { ProportionBarProperties } from './properties';
21
2
  declare const ProportionBar: import("svelte").Component<ProportionBarProperties, {}, "">;
22
3
  type ProportionBar = ReturnType<typeof ProportionBar>;
@@ -27,6 +27,16 @@ export type OptionalProportionBarProperties = {
27
27
  * `--proportion-bar-track-height` CSS variable.
28
28
  */
29
29
  trackHeight?: string;
30
+ /**
31
+ * Routes the legend value column through `AnimatedNumber` so a changed
32
+ * value rolls instead of jumping. Off by default because it is new: with it
33
+ * left unset, the legend renders the same static text node it always has.
34
+ * The formatted string from `valueFormat` (or the built-in default) is
35
+ * passed straight through, never the raw `value` number, so a consumer's
36
+ * own formatter keeps deciding what the digits say.
37
+ * @default false
38
+ */
39
+ animateValue?: boolean;
30
40
  /** Test selector applied as the `data-pw` attribute on the root element. */
31
41
  testId?: string;
32
42
  /** Extra CSS class names appended to the root element. */
@@ -451,10 +451,18 @@
451
451
 
452
452
  async function moveHighlight(delta: number): Promise<void> {
453
453
  const next = highlightedIndex + delta;
454
- if (next < 0 || next >= optionRows.length) {
454
+ const inBounds = next >= 0 && next < optionRows.length;
455
+ // Off either end, wrap to the opposite edge instead of stopping -- landing
456
+ // on the nearest selectable row there the same way moveHighlightToEdge
457
+ // does for Home/End, rather than the raw first/last row which may be
458
+ // disabled. Wrapping only makes sense once the panel is open and has rows
459
+ // to land on; ArrowUp (unlike ArrowDown) reaches here even while closed,
460
+ // and closed this must stay the plain out-of-bounds no-op it always was.
461
+ const target = inBounds ? next : open ? nearestSelectableEdge(next < 0 ? 'last' : 'first') : -1;
462
+ if (target < 0) {
455
463
  return;
456
464
  }
457
- highlightedIndex = next;
465
+ highlightedIndex = target;
458
466
  await tick();
459
467
  // Query the dropdown node itself, not containerEl, so highlight-scrolling
460
468
  // keeps working once the panel is portaled out to <body>.
@@ -476,17 +484,24 @@
476
484
  return event.ctrlKey || event.metaKey || event.altKey || event.shiftKey;
477
485
  }
478
486
 
487
+ // The nearest selectable row from an edge, or -1 when nothing in that
488
+ // direction is selectable (including an empty list). Shared by Home/End
489
+ // (moveHighlightToEdge) and arrow-key wraparound (moveHighlight), which both
490
+ // need to land on a real option rather than a disabled row sitting at the
491
+ // very edge of the list.
492
+ function nearestSelectableEdge(edge: 'first' | 'last'): number {
493
+ return edge === 'first'
494
+ ? optionRows.findIndex(isRowSelectable)
495
+ : optionRows.findLastIndex(isRowSelectable);
496
+ }
497
+
479
498
  /**
480
499
  * Home/End: jump the highlight to the first/last selectable row, skipping
481
- * disabled options the same way `moveHighlight` skips nothing -- it just
482
- * targets a different index and reuses the same scroll-into-view step. A
500
+ * disabled options the same way arrow-key wraparound does at the ends. A
483
501
  * no-op when no row in that direction is selectable, including an empty list.
484
502
  */
485
503
  async function moveHighlightToEdge(edge: 'first' | 'last'): Promise<void> {
486
- const target =
487
- edge === 'first'
488
- ? optionRows.findIndex(isRowSelectable)
489
- : optionRows.findLastIndex(isRowSelectable);
504
+ const target = nearestSelectableEdge(edge);
490
505
  if (target < 0) {
491
506
  return;
492
507
  }
@@ -114,7 +114,10 @@
114
114
  .split-button-trigger {
115
115
  --menu-container-position: static;
116
116
  --menu-container-display: flex;
117
- --menu-trigger-focus-outline: none;
117
+ /* Used to force --menu-trigger-focus-outline to `none` with nothing
118
+ standing in for it (WCAG 2.4.7) -- removed so the trigger falls
119
+ through to Menu's own :focus-visible default instead of suppressing
120
+ it. See Menu.svelte's .menu-trigger:focus-visible for the fix. */
118
121
  --menu-dropdown-left: 0;
119
122
  --menu-min-width: 100%;
120
123
  border-left: var(--split-button-trigger-separator, 1px solid rgba(255, 255, 255, 0.3));
@@ -367,7 +367,7 @@
367
367
  align-items: center;
368
368
  height: var(--field-group-input-height, 36px);
369
369
  font-size: var(--field-group-separator-font-size, 16px);
370
- color: var(--field-group-separator-color, #9ca3af);
370
+ color: var(--field-group-separator-color, #6b7280);
371
371
  flex-shrink: 0;
372
372
  }
373
373
  </style>
@@ -3,6 +3,8 @@
3
3
  import DeltaIndicator from '../DeltaIndicator/DeltaIndicator.svelte';
4
4
  import Tooltip from '../Tooltip/Tooltip.svelte';
5
5
  import CheckListItem from '../CheckListItem/CheckListItem.svelte';
6
+ import AnimatedNumber from '../AnimatedNumber/AnimatedNumber.svelte';
7
+ import { isAnimatableMetric } from './metric';
6
8
 
7
9
  let {
8
10
  title,
@@ -12,6 +14,10 @@
12
14
  subtitle,
13
15
  footer,
14
16
  valueSnippet,
17
+ animateValue = false,
18
+ animatePrimary = false,
19
+ primary = 'auto',
20
+ animateOnMount = false,
15
21
  rows,
16
22
  rowsDirection = 'column',
17
23
  tooltip,
@@ -26,6 +32,99 @@
26
32
 
27
33
  const isInteractive = $derived(typeof onclick === 'function');
28
34
 
35
+ /**
36
+ * Which ONE value on this card rolls.
37
+ *
38
+ * A card has several numeric slots and they are not equally important. The
39
+ * business variant renders `subtitle` at 44px as the headline and its rows at
40
+ * 18-20px underneath, so animating "the values" animated the small ones and
41
+ * left the number the card is actually about sitting still -- backwards, and
42
+ * visible on every dashboard.
43
+ *
44
+ * `primary` resolves three ways so a caller can be as specific as it needs:
45
+ * 'auto' measure the rendered slots and pick the largest metric
46
+ * 'subtitle' | 'value' | <row index> name it outright
47
+ * 'auto' is the default because it follows the card's own type scale rather
48
+ * than a second copy of it kept in a mapper, so a card that restyles keeps
49
+ * animating the right thing. A caller that disagrees names the slot instead.
50
+ *
51
+ * Measured after mount rather than guessed from props: the sizes come from
52
+ * tokens the consuming app can override, so only the resolved style knows.
53
+ */
54
+ let autoKey = $state<string | null>(null);
55
+
56
+ /**
57
+ * Re-measured whenever the card's values change, not once at mount.
58
+ *
59
+ * A card whose figures arrive from a fetch renders empty first: the balance
60
+ * card mounted with no balance, so the measurement found no metric, and
61
+ * because an attachment only re-runs when its argument changes it never looked
62
+ * again -- the number arrived and sat there unanimated. Keying the attachment
63
+ * on the values themselves fixes that, and also re-picks the primary if the
64
+ * card's content later changes shape.
65
+ */
66
+ const valueSignature = $derived(
67
+ JSON.stringify([
68
+ value,
69
+ subtitle,
70
+ (rows ?? []).map((row) => [
71
+ row.value,
72
+ row.comparisonValue,
73
+ (row.breakdown ?? []).map((item) => item.value)
74
+ ])
75
+ ])
76
+ );
77
+
78
+ const measurePrimary =
79
+ (_signature: string) =>
80
+ (node: HTMLElement): void => {
81
+ if (!animatePrimary || primary !== 'auto') {
82
+ return;
83
+ }
84
+ let best: string | null = null;
85
+ let bestSize = -1;
86
+ for (const slot of node.querySelectorAll('[data-sc-slot]')) {
87
+ if (!(slot instanceof HTMLElement)) {
88
+ continue;
89
+ }
90
+ if (!isAnimatableMetric(slot.textContent ?? '')) {
91
+ continue;
92
+ }
93
+ const size = Number.parseFloat(getComputedStyle(slot).fontSize);
94
+ if (Number.isFinite(size) && size > bestSize) {
95
+ bestSize = size;
96
+ best = slot.dataset.scSlot ?? null;
97
+ }
98
+ }
99
+ autoKey = best;
100
+ };
101
+
102
+ /**
103
+ * A numeric `primary` names a row by index, and rows carry `data-sc-slot`
104
+ * values of `row-<index>`. Passing the number through `String` alone yielded
105
+ * `'0'`, which matches no slot at all, so `primary={0}` resolved to nothing
106
+ * and the card animated none of its values. The custom element declares the
107
+ * prop as a String, so `primary="0"` arrives the same way and needs the same
108
+ * normalisation -- hence matching the digits rather than only `typeof`.
109
+ */
110
+ const slotKeyFor = (value: 'auto' | 'subtitle' | 'value' | number | string): string =>
111
+ typeof value === 'number' || /^\d+$/.test(String(value)) ? `row-${value}` : String(value);
112
+
113
+ const primaryKey = $derived(primary === 'auto' ? autoKey : slotKeyFor(primary));
114
+
115
+ /**
116
+ * `animateValue` keeps its old meaning -- every value on the card -- but is
117
+ * now gated on the value being a metric at all, so an identifier that merely
118
+ * contains digits is never rolled. See metric.ts: this is what stopped a store
119
+ * domain and a GA4 account id spinning on the anomaly cards.
120
+ */
121
+ const animates = (key: string, text: unknown): boolean => {
122
+ if (typeof text !== 'string' || !isAnimatableMetric(text)) {
123
+ return false;
124
+ }
125
+ return animatePrimary ? key === primaryKey : animateValue;
126
+ };
127
+
29
128
  /**
30
129
  * When `deltaPositive` is explicitly provided use it; otherwise infer from
31
130
  * the leading character of `delta` — '+' → positive, '-' → negative,
@@ -82,6 +181,7 @@
82
181
 
83
182
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
84
183
  <div
184
+ {@attach measurePrimary(valueSignature)}
85
185
  class="statcard {classes ?? ''}"
86
186
  class:statcard-interactive={isInteractive}
87
187
  data-pw={typeof testId === 'string' ? testId : null}
@@ -187,10 +287,15 @@
187
287
  class="statcard-value"
188
288
  class:statcard-value-success={row.valueVariant === 'success'}
189
289
  class:statcard-value-warning={row.valueVariant === 'warning'}
290
+ data-sc-slot={`row-${rowIndex}`}
190
291
  data-pw={typeof testId === 'string' ? `${testId}-value-${rowIndex}` : null}
191
292
  testID={typeof testId === 'string' ? `${testId}-value-${rowIndex}` : null}
192
293
  >
193
- {row.value}
294
+ {#if animates(`row-${rowIndex}`, row.value)}
295
+ <AnimatedNumber value={row.value} {animateOnMount} />
296
+ {:else}
297
+ {row.value}
298
+ {/if}
194
299
  </div>
195
300
  {#if typeof row.comparisonValue === 'string' && row.comparisonValue.length > 0}
196
301
  <div
@@ -198,7 +303,10 @@
198
303
  data-pw={typeof testId === 'string' ? `${testId}-comparison-${rowIndex}` : null}
199
304
  testID={typeof testId === 'string' ? `${testId}-comparison-${rowIndex}` : null}
200
305
  >
201
- / {row.comparisonValue}
306
+ / {#if animates(`row-${rowIndex}-comparison`, row.comparisonValue)}<AnimatedNumber
307
+ {animateOnMount}
308
+ value={row.comparisonValue}
309
+ />{:else}{row.comparisonValue}{/if}
202
310
  </div>
203
311
  {/if}
204
312
  {#if typeof row.change === 'number'}
@@ -255,7 +363,13 @@
255
363
  >
256
364
  <div class="statcard-breakdown-label">{breakdownItem.label}</div>
257
365
  <div class="statcard-breakdown-value-line">
258
- <div class="statcard-breakdown-value">{breakdownItem.value}</div>
366
+ <div class="statcard-breakdown-value">
367
+ {#if animates(`breakdown-${rowIndex}-${breakdownIndex}`, breakdownItem.value)}
368
+ <AnimatedNumber value={breakdownItem.value} {animateOnMount} />
369
+ {:else}
370
+ {breakdownItem.value}
371
+ {/if}
372
+ </div>
259
373
  {#if typeof breakdownItem.change === 'number'}
260
374
  <DeltaIndicator
261
375
  value={breakdownItem.change}
@@ -283,10 +397,15 @@
283
397
  {:else if typeof value === 'string' && value.length > 0}
284
398
  <div
285
399
  class="statcard-value"
400
+ data-sc-slot="value"
286
401
  data-pw={typeof testId === 'string' ? `${testId}-value` : null}
287
402
  testID={typeof testId === 'string' ? `${testId}-value` : null}
288
403
  >
289
- {value}
404
+ {#if animates('value', value)}
405
+ <AnimatedNumber {value} {animateOnMount} />
406
+ {:else}
407
+ {value}
408
+ {/if}
290
409
  </div>
291
410
  {/if}
292
411
 
@@ -305,10 +424,15 @@
305
424
  {#if typeof subtitle === 'string' && subtitle.length > 0}
306
425
  <div
307
426
  class="statcard-subtitle"
427
+ data-sc-slot="subtitle"
308
428
  data-pw={typeof testId === 'string' ? `${testId}-subtitle` : null}
309
429
  testID={typeof testId === 'string' ? `${testId}-subtitle` : null}
310
430
  >
311
- {subtitle}
431
+ {#if animates('subtitle', subtitle)}
432
+ <AnimatedNumber value={subtitle} {animateOnMount} />
433
+ {:else}
434
+ {subtitle}
435
+ {/if}
312
436
  </div>
313
437
  {/if}
314
438
 
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Whether a displayed value is a METRIC worth rolling, or an identifier that
3
+ * merely contains digits.
4
+ *
5
+ * The distinction is not cosmetic. Adopting the odometer across a dashboard
6
+ * mapper animated `demo-store.myshopify.com` and `Shinchan Store GA4 (987654321)`
7
+ * on the anomaly surface: a store domain and a GA4 account id, rolled digit by
8
+ * digit as though they were measurements. Nothing was broken -- they went
9
+ * through the same `statcard-value` slot as `₹60k` -- which is exactly why a
10
+ * blanket "animate this card's values" is the wrong instruction.
11
+ *
12
+ * The rule is digits plus decoration. A metric is allowed to carry a currency
13
+ * symbol, a sign, a percent, group and decimal separators, and SHORT unit words
14
+ * -- `₹60k`, `90%`, `₹1.23Cr`, `12m 15s`, `-8%`. Anything else with a long
15
+ * alphabetic run, a dotted hostname or a parenthesised id is an identifier and
16
+ * is left alone.
17
+ *
18
+ * Deliberately conservative in one direction only: a metric wrongly refused
19
+ * renders exactly as it does today, while an identifier wrongly accepted is a
20
+ * number spinning in a domain name.
21
+ */
22
+ export declare const isAnimatableMetric: (text: string) => boolean;