@juspay/svelte-ui-components 4.29.0 → 4.30.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 (56) hide show
  1. package/README.md +15 -14
  2. package/dist/Accordion/Accordion.svelte +18 -0
  3. package/dist/AnimatedNumber/AnimatedNumber.svelte +702 -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 +42 -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 +22 -4
  44. package/dist/StatCard/properties.d.ts +12 -0
  45. package/dist/Table/BuiltinCell.svelte +19 -2
  46. package/dist/Table/cellData.js +3 -0
  47. package/dist/Table/properties.d.ts +15 -0
  48. package/dist/Tabs/Tabs.svelte +16 -0
  49. package/dist/index.d.ts +2 -0
  50. package/dist/index.js +1 -0
  51. package/dist/styles/theme-dark.css +27 -3
  52. package/dist-codemod/codemod/migration-assets/legacy-display.css +2 -1
  53. package/dist-codemod/codemod/migration-assets/legacy-palette.css +4 -1
  54. package/dist-codemod/codemod/migration-assets/wc-display.json +5 -0
  55. package/dist-wc/index.js +1334 -754
  56. package/package.json +9 -3
@@ -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,7 @@
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';
6
7
 
7
8
  let {
8
9
  title,
@@ -12,6 +13,7 @@
12
13
  subtitle,
13
14
  footer,
14
15
  valueSnippet,
16
+ animateValue = false,
15
17
  rows,
16
18
  rowsDirection = 'column',
17
19
  tooltip,
@@ -190,7 +192,11 @@
190
192
  data-pw={typeof testId === 'string' ? `${testId}-value-${rowIndex}` : null}
191
193
  testID={typeof testId === 'string' ? `${testId}-value-${rowIndex}` : null}
192
194
  >
193
- {row.value}
195
+ {#if animateValue}
196
+ <AnimatedNumber value={row.value} />
197
+ {:else}
198
+ {row.value}
199
+ {/if}
194
200
  </div>
195
201
  {#if typeof row.comparisonValue === 'string' && row.comparisonValue.length > 0}
196
202
  <div
@@ -198,7 +204,9 @@
198
204
  data-pw={typeof testId === 'string' ? `${testId}-comparison-${rowIndex}` : null}
199
205
  testID={typeof testId === 'string' ? `${testId}-comparison-${rowIndex}` : null}
200
206
  >
201
- / {row.comparisonValue}
207
+ / {#if animateValue}<AnimatedNumber
208
+ value={row.comparisonValue}
209
+ />{:else}{row.comparisonValue}{/if}
202
210
  </div>
203
211
  {/if}
204
212
  {#if typeof row.change === 'number'}
@@ -255,7 +263,13 @@
255
263
  >
256
264
  <div class="statcard-breakdown-label">{breakdownItem.label}</div>
257
265
  <div class="statcard-breakdown-value-line">
258
- <div class="statcard-breakdown-value">{breakdownItem.value}</div>
266
+ <div class="statcard-breakdown-value">
267
+ {#if animateValue}
268
+ <AnimatedNumber value={breakdownItem.value} />
269
+ {:else}
270
+ {breakdownItem.value}
271
+ {/if}
272
+ </div>
259
273
  {#if typeof breakdownItem.change === 'number'}
260
274
  <DeltaIndicator
261
275
  value={breakdownItem.change}
@@ -286,7 +300,11 @@
286
300
  data-pw={typeof testId === 'string' ? `${testId}-value` : null}
287
301
  testID={typeof testId === 'string' ? `${testId}-value` : null}
288
302
  >
289
- {value}
303
+ {#if animateValue}
304
+ <AnimatedNumber {value} />
305
+ {:else}
306
+ {value}
307
+ {/if}
290
308
  </div>
291
309
  {/if}
292
310
 
@@ -111,6 +111,18 @@ export type OptionalStatCardProperties = {
111
111
  footer?: Snippet;
112
112
  /** Replaces the string `value` with a custom snippet for advanced value rendering. */
113
113
  valueSnippet?: Snippet;
114
+ /**
115
+ * Rolls `value`, each row's `value`/`comparisonValue`, and each breakdown
116
+ * item's `value` through AnimatedNumber's per-digit odometer instead of
117
+ * swapping the text outright. Every one of those is already a
118
+ * consumer-preformatted string, not a raw number -- AnimatedNumber diffs a
119
+ * string by character position, so `'₹1.23Cr'` -> `'₹1.45Cr'` rolls only the
120
+ * two digits that changed and leaves `₹`, the decimal point and `Cr` alone,
121
+ * with nothing here ever surfacing the number underneath. `valueSnippet`
122
+ * still wins outright when set, exactly as it does today.
123
+ * @default false
124
+ */
125
+ animateValue?: boolean;
114
126
  /**
115
127
  * Multiple metric rows. When provided, replaces the single value/delta row with
116
128
  * a column of rows separated by dividers.
@@ -25,6 +25,7 @@
25
25
  import Select from '../Select/Select.svelte';
26
26
  import Input from '../Input/Input.svelte';
27
27
  import Menu from '../Menu/Menu.svelte';
28
+ import AnimatedNumber from '../AnimatedNumber/AnimatedNumber.svelte';
28
29
  import trendUpSvg from '../assets/trend-up.svg?raw';
29
30
  import trendDownSvg from '../assets/trend-down.svg?raw';
30
31
  import copySvg from '../assets/copy.svg?raw';
@@ -258,7 +259,15 @@
258
259
  >
259
260
  <!-- eslint-disable svelte/no-at-html-tags -->
260
261
  <span class="builtin-trend-icon">{@html trendUpSvg}</span>
261
- {compare.trendPercent}%
262
+ {#if compare.animateTrend}
263
+ <!-- The string form, not the raw number: it keeps the % inside the
264
+ odometer's accessible name, and avoids Intl grouping a raw
265
+ trendPercent into "1,234.5" where the plain branch shows
266
+ "1234.5". -->
267
+ <AnimatedNumber value={`${compare.trendPercent}%`} />
268
+ {:else}
269
+ {compare.trendPercent}%
270
+ {/if}
262
271
  </span>
263
272
  {:else if compare.trendPercent < 0}
264
273
  <span
@@ -268,7 +277,15 @@
268
277
  >
269
278
  <!-- eslint-disable svelte/no-at-html-tags -->
270
279
  <span class="builtin-trend-icon">{@html trendDownSvg}</span>
271
- {compare.trendPercent}%
280
+ {#if compare.animateTrend}
281
+ <!-- The string form, not the raw number: it keeps the % inside the
282
+ odometer's accessible name, and avoids Intl grouping a raw
283
+ trendPercent into "1,234.5" where the plain branch shows
284
+ "1234.5". -->
285
+ <AnimatedNumber value={`${compare.trendPercent}%`} />
286
+ {:else}
287
+ {compare.trendPercent}%
288
+ {/if}
272
289
  </span>
273
290
  {:else}
274
291
  <span class="builtin-trend builtin-trend-flat">↔ 0%</span>
@@ -116,6 +116,9 @@ export const asCompareCellData = (value) => {
116
116
  if (typeof record.trendLabel === 'string') {
117
117
  compareData.trendLabel = record.trendLabel;
118
118
  }
119
+ if (typeof record.animateTrend === 'boolean') {
120
+ compareData.animateTrend = record.animateTrend;
121
+ }
119
122
  return compareData;
120
123
  };
121
124
  export const asLinkCellData = (value) => {
@@ -89,6 +89,21 @@ export type TableCompareCellData = {
89
89
  comparison?: string;
90
90
  trendPercent?: number;
91
91
  trendLabel?: string;
92
+ /**
93
+ * Rolls `trendPercent` through `AnimatedNumber` instead of the plain text
94
+ * it renders as today, so a comparison row that updates in place (a live
95
+ * dashboard, a polling total) visibly counts to its new value rather than
96
+ * jumping. Per-cell rather than a Table-level prop, since a single column
97
+ * can mix trend rows a consumer wants live with historical ones that don't
98
+ * need to move. `AnimatedNumber` is handed the complete string
99
+ * `` `${trendPercent}%` ``, so the unit sits inside its accessible name
100
+ * rather than beside it, where a reader navigating by graphic would never
101
+ * reach it. The `%` stays a literal character rather than an
102
+ * `Intl.NumberFormat` percent style, which would divide the value by 100
103
+ * and render the wrong figure, and it is a literal column that never rolls.
104
+ * @default false
105
+ */
106
+ animateTrend?: boolean;
92
107
  };
93
108
  /** Cell shape for `type: 'toggle'` — the handler lives on the column. */
94
109
  export type TableToggleCellData = {
@@ -638,6 +638,22 @@
638
638
  transition: var(--tabs-transition, color 0.2s ease, background 0.2s ease);
639
639
  }
640
640
 
641
+ /* Roving focus really does move (see focusedKey/focusTabAt above) -- this
642
+ just replaces the INDICATOR that outline:none above strips, which is what
643
+ was actually missing (WCAG 2.4.7). :focus-visible only, so a mouse click
644
+ that activates a tab still shows nothing new, matching the rest of this
645
+ file's pattern (Sheet's .sheet-panel, Select's .select-clear, ...).
646
+ currentColor tracks --tabs-item-color / --tabs-active-color, which are
647
+ already themed per-mode and per-state, so the ring stays visible against
648
+ --tabs-bar-background in both themes with no extra token needed here. The
649
+ offset is inset, not the usual outward one: .tabs-bar is a same-axis
650
+ overflow:auto scroller with 0px padding/gap by default, so an outward ring
651
+ on an edge tab would be clipped by the scroll container's own edge. */
652
+ .tabs-item:focus-visible {
653
+ outline: var(--tabs-item-focus-outline, 2px solid currentColor);
654
+ outline-offset: var(--tabs-item-focus-outline-offset, -2px);
655
+ }
656
+
641
657
  .tabs-item :global(.tabs-item-icon) {
642
658
  --image-width: var(--tabs-item-icon-size, 16px);
643
659
  --image-height: var(--tabs-item-icon-size, 16px);
package/dist/index.d.ts CHANGED
@@ -86,6 +86,7 @@ export { default as FileDropzoneTrigger } from './FileDropzoneTrigger/FileDropzo
86
86
  export { default as Separator } from './Separator/Separator.svelte';
87
87
  export { default as Label } from './Label/Label.svelte';
88
88
  export { default as AspectRatio } from './AspectRatio/AspectRatio.svelte';
89
+ export { default as AnimatedNumber } from './AnimatedNumber/AnimatedNumber.svelte';
89
90
  export { default as RatingGroup } from './RatingGroup/RatingGroup.svelte';
90
91
  export { default as Chat } from './Chat/Chat.svelte';
91
92
  export { default as ChatHeader } from './ChatHeader/ChatHeader.svelte';
@@ -154,6 +155,7 @@ export type * from './LoadingDots/properties';
154
155
  export type * from './Separator/properties';
155
156
  export type * from './Label/properties';
156
157
  export type * from './AspectRatio/properties';
158
+ export type * from './AnimatedNumber/properties';
157
159
  export type * from './RatingGroup/properties';
158
160
  export type * from './Menu/properties';
159
161
  export type * from './Sheet/properties';
package/dist/index.js CHANGED
@@ -86,6 +86,7 @@ export { default as FileDropzoneTrigger } from './FileDropzoneTrigger/FileDropzo
86
86
  export { default as Separator } from './Separator/Separator.svelte';
87
87
  export { default as Label } from './Label/Label.svelte';
88
88
  export { default as AspectRatio } from './AspectRatio/AspectRatio.svelte';
89
+ export { default as AnimatedNumber } from './AnimatedNumber/AnimatedNumber.svelte';
89
90
  export { default as RatingGroup } from './RatingGroup/RatingGroup.svelte';
90
91
  export { default as Chat } from './Chat/Chat.svelte';
91
92
  export { default as ChatHeader } from './ChatHeader/ChatHeader.svelte';