@juspay/svelte-ui-components 4.30.0 → 4.31.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.
@@ -8,6 +8,7 @@
8
8
  locale = 'en-US',
9
9
  format,
10
10
  live = 'off',
11
+ animateOnMount = false,
11
12
  ariaLabel,
12
13
  testId,
13
14
  classes
@@ -397,7 +398,7 @@
397
398
  <span
398
399
  class="animated-number-digit"
399
400
  style="--_animated-number-digit: {column.digit};"
400
- {@attach roll(column.digit, mounted, numeric)}
401
+ {@attach roll(column.digit, mounted || animateOnMount, numeric)}
401
402
  >
402
403
  {#each glyphs as glyph, index (glyph)}
403
404
  <!--
@@ -449,6 +450,64 @@
449
450
  color: var(--animated-number-color, inherit);
450
451
  }
451
452
 
453
+ /*
454
+ * The number renders as the text it replaced, even against a host stylesheet
455
+ * that styles bare elements.
456
+ *
457
+ * Every part of this component is a `<span>`: the root, the columns, the
458
+ * glyphs and the selection-copy node. A consumer carrying a bare `span` rule
459
+ * therefore hits all of them, and a type selector beats plain inheritance, so
460
+ * the number silently stopped matching its own label -- which is a text node
461
+ * in the parent and never affected.
462
+ *
463
+ * Measured rather than imagined: Lighthouse ships a bare `span` rule in
464
+ * static/style/text.css that sets colour, font-size and font-weight from its
465
+ * own tokens, and adopting the odometer there took every headline dashboard
466
+ * metric from 24px/600 to 12px/400 in the tertiary text colour, across nine
467
+ * routes. Nothing in this repo could have shown it: our own pages carry no
468
+ * such rule.
469
+ *
470
+ * Declared on the classes rather than left to inheritance so a bare type
471
+ * selector cannot win. `line-height` is deliberately NOT inherited -- the
472
+ * column box is this component's own geometry and is set below.
473
+ */
474
+ .animated-number,
475
+ .animated-number-plain,
476
+ .animated-number-visual,
477
+ .animated-number-digit,
478
+ .animated-number-digit-glyph,
479
+ .animated-number-literal {
480
+ font-family: inherit;
481
+ font-size: inherit;
482
+ font-weight: inherit;
483
+ font-style: inherit;
484
+ letter-spacing: inherit;
485
+ }
486
+
487
+ /*
488
+ * Colour is deliberately NOT in the group above, and the root is deliberately
489
+ * not in this one.
490
+ *
491
+ * The root already resolves its colour through the token, and that
492
+ * declaration sits EARLIER in this stylesheet. A grouped rule that also
493
+ * matched `.animated-number` would set `color: inherit` at equal specificity
494
+ * and later source order, so the token lost every time and the root silently
495
+ * took the parent's colour instead -- which is indistinguishable from the
496
+ * token working, because the token's own fallback IS `inherit`. Only a caller
497
+ * that actually set `--animated-number-color` would have seen it ignored.
498
+ *
499
+ * The descendants still need the explicit declaration: a bare `span` selector
500
+ * in a host stylesheet outranks plain inheritance, which is the whole reason
501
+ * this block exists.
502
+ */
503
+ .animated-number-plain,
504
+ .animated-number-visual,
505
+ .animated-number-digit,
506
+ .animated-number-digit-glyph,
507
+ .animated-number-literal {
508
+ color: inherit;
509
+ }
510
+
452
511
  .animated-number-visual {
453
512
  /* Decorative glyph content, same treatment Avatar, KeyboardInput, Tabs,
454
513
  Calendar and Draggable already give theirs. */
@@ -495,6 +554,21 @@
495
554
  margin-block: calc(-1 * var(--animated-number-fade-height, 0.28em));
496
555
  }
497
556
 
557
+ /*
558
+ * `inline-block` above makes every column its own inline formatting context,
559
+ * and collapsible whitespace at the edges of one of those is dropped -- so a
560
+ * literal column holding exactly one space measured zero wide and "12m 15s"
561
+ * painted as "12m15s". `pre` is the narrowest fix that keeps the space: it
562
+ * changes nothing for a literal that is not whitespace, and a column is a
563
+ * single character, so there is no wrapping behaviour to lose.
564
+ *
565
+ * Digits are exempt because they are never whitespace and their column has
566
+ * its own width.
567
+ */
568
+ .animated-number-literal {
569
+ white-space: pre;
570
+ }
571
+
498
572
  .animated-number-digit {
499
573
  position: relative;
500
574
  width: var(--animated-number-digit-width, 1ch);
@@ -33,6 +33,22 @@ export type OptionalAnimatedNumberProperties = {
33
33
  * @default 'off'
34
34
  */
35
35
  live?: AnimatedNumberLive;
36
+ /**
37
+ * Rolls every column up from zero when the number first appears, instead of
38
+ * painting it outright.
39
+ *
40
+ * Off by default, because a number that was always going to be on screen
41
+ * should not make the reader wait for it. Turn it on where the figure ARRIVES
42
+ * -- a dashboard whose metrics land after a fetch, a total that appears at the
43
+ * end of a flow -- and especially where the value then never changes again:
44
+ * without this, such a number is static for its whole life and the odometer
45
+ * does nothing at all.
46
+ *
47
+ * Independent of the rolls that follow. A later change animates either way,
48
+ * and this only governs the first paint.
49
+ * @default false
50
+ */
51
+ animateOnMount?: boolean;
36
52
  /** Overrides the accessible name, which otherwise mirrors the visible text. */
37
53
  ariaLabel?: string;
38
54
  /** Value for the `data-pw` attribute on the root element for E2E selection. */
@@ -4,6 +4,7 @@
4
4
  import Tooltip from '../Tooltip/Tooltip.svelte';
5
5
  import CheckListItem from '../CheckListItem/CheckListItem.svelte';
6
6
  import AnimatedNumber from '../AnimatedNumber/AnimatedNumber.svelte';
7
+ import { isAnimatableMetric } from './metric';
7
8
 
8
9
  let {
9
10
  title,
@@ -14,6 +15,9 @@
14
15
  footer,
15
16
  valueSnippet,
16
17
  animateValue = false,
18
+ animatePrimary = false,
19
+ primary = 'auto',
20
+ animateOnMount = false,
17
21
  rows,
18
22
  rowsDirection = 'column',
19
23
  tooltip,
@@ -28,6 +32,99 @@
28
32
 
29
33
  const isInteractive = $derived(typeof onclick === 'function');
30
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
+
31
128
  /**
32
129
  * When `deltaPositive` is explicitly provided use it; otherwise infer from
33
130
  * the leading character of `delta` — '+' → positive, '-' → negative,
@@ -84,6 +181,7 @@
84
181
 
85
182
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
86
183
  <div
184
+ {@attach measurePrimary(valueSignature)}
87
185
  class="statcard {classes ?? ''}"
88
186
  class:statcard-interactive={isInteractive}
89
187
  data-pw={typeof testId === 'string' ? testId : null}
@@ -189,11 +287,12 @@
189
287
  class="statcard-value"
190
288
  class:statcard-value-success={row.valueVariant === 'success'}
191
289
  class:statcard-value-warning={row.valueVariant === 'warning'}
290
+ data-sc-slot={`row-${rowIndex}`}
192
291
  data-pw={typeof testId === 'string' ? `${testId}-value-${rowIndex}` : null}
193
292
  testID={typeof testId === 'string' ? `${testId}-value-${rowIndex}` : null}
194
293
  >
195
- {#if animateValue}
196
- <AnimatedNumber value={row.value} />
294
+ {#if animates(`row-${rowIndex}`, row.value)}
295
+ <AnimatedNumber value={row.value} {animateOnMount} />
197
296
  {:else}
198
297
  {row.value}
199
298
  {/if}
@@ -204,7 +303,8 @@
204
303
  data-pw={typeof testId === 'string' ? `${testId}-comparison-${rowIndex}` : null}
205
304
  testID={typeof testId === 'string' ? `${testId}-comparison-${rowIndex}` : null}
206
305
  >
207
- / {#if animateValue}<AnimatedNumber
306
+ / {#if animates(`row-${rowIndex}-comparison`, row.comparisonValue)}<AnimatedNumber
307
+ {animateOnMount}
208
308
  value={row.comparisonValue}
209
309
  />{:else}{row.comparisonValue}{/if}
210
310
  </div>
@@ -264,8 +364,8 @@
264
364
  <div class="statcard-breakdown-label">{breakdownItem.label}</div>
265
365
  <div class="statcard-breakdown-value-line">
266
366
  <div class="statcard-breakdown-value">
267
- {#if animateValue}
268
- <AnimatedNumber value={breakdownItem.value} />
367
+ {#if animates(`breakdown-${rowIndex}-${breakdownIndex}`, breakdownItem.value)}
368
+ <AnimatedNumber value={breakdownItem.value} {animateOnMount} />
269
369
  {:else}
270
370
  {breakdownItem.value}
271
371
  {/if}
@@ -297,11 +397,12 @@
297
397
  {:else if typeof value === 'string' && value.length > 0}
298
398
  <div
299
399
  class="statcard-value"
400
+ data-sc-slot="value"
300
401
  data-pw={typeof testId === 'string' ? `${testId}-value` : null}
301
402
  testID={typeof testId === 'string' ? `${testId}-value` : null}
302
403
  >
303
- {#if animateValue}
304
- <AnimatedNumber {value} />
404
+ {#if animates('value', value)}
405
+ <AnimatedNumber {value} {animateOnMount} />
305
406
  {:else}
306
407
  {value}
307
408
  {/if}
@@ -323,10 +424,15 @@
323
424
  {#if typeof subtitle === 'string' && subtitle.length > 0}
324
425
  <div
325
426
  class="statcard-subtitle"
427
+ data-sc-slot="subtitle"
326
428
  data-pw={typeof testId === 'string' ? `${testId}-subtitle` : null}
327
429
  testID={typeof testId === 'string' ? `${testId}-subtitle` : null}
328
430
  >
329
- {subtitle}
431
+ {#if animates('subtitle', subtitle)}
432
+ <AnimatedNumber value={subtitle} {animateOnMount} />
433
+ {:else}
434
+ {subtitle}
435
+ {/if}
330
436
  </div>
331
437
  {/if}
332
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;
@@ -0,0 +1,74 @@
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
+ /** Unit fragments a metric may carry. Matched case-insensitively, whole-run. */
23
+ const UNITS = new Set([
24
+ 'k',
25
+ 'l',
26
+ 'm',
27
+ 'b',
28
+ 't',
29
+ 'cr',
30
+ 'lac',
31
+ 'lakh',
32
+ 'mn',
33
+ 'bn',
34
+ 's',
35
+ 'ms',
36
+ 'h',
37
+ 'hr',
38
+ 'hrs',
39
+ 'd',
40
+ 'day',
41
+ 'days',
42
+ 'min',
43
+ 'mins',
44
+ 'sec',
45
+ 'secs',
46
+ 'x',
47
+ 'pt',
48
+ 'pts',
49
+ 'px'
50
+ ]);
51
+ /** Decoration that carries no meaning for this decision. */
52
+ const DECORATION = /[\p{Nd}\s,.:+%()[\]]|\p{Sc}|\p{Pd}|\p{Sm}/gu;
53
+ export const isAnimatableMetric = (text) => {
54
+ if (typeof text !== 'string') {
55
+ return false;
56
+ }
57
+ const value = text.trim();
58
+ if (value.length === 0 || !/\p{Nd}/u.test(value)) {
59
+ return false;
60
+ }
61
+ // A dotted hostname or an email is an identifier however many digits it has.
62
+ if (/\.[a-z]{2,}(?:\/|$)|@|\/\//i.test(value)) {
63
+ return false;
64
+ }
65
+ // A parenthesised run of digits is an id in a label, not a measurement:
66
+ // "Shinchan Store GA4 (987654321)".
67
+ if (/\([^)]*\p{Nd}[^)]*\)/u.test(value)) {
68
+ return false;
69
+ }
70
+ // What remains once digits and decoration are removed must be unit words only.
71
+ const remainder = value.replace(DECORATION, ' ');
72
+ const words = remainder.split(/\s+/).filter((word) => word.length > 0);
73
+ return words.every((word) => UNITS.has(word.toLowerCase()));
74
+ };
@@ -123,6 +123,38 @@ export type OptionalStatCardProperties = {
123
123
  * @default false
124
124
  */
125
125
  animateValue?: boolean;
126
+ /**
127
+ * Rolls the animated values up from zero as the card appears, rather than
128
+ * painting them outright. Requires an animation mode to be active -- either
129
+ * `animateValue`, or `animatePrimary` having resolved a slot to animate.
130
+ * Ignored when neither applies, because there is then nothing to roll.
131
+ *
132
+ * Worth turning on for a card whose figures arrive once with a fetch and then
133
+ * do not change, which is the ordinary dashboard case -- otherwise the
134
+ * odometer has nothing to react to for the life of the page.
135
+ * @default false
136
+ */
137
+ animateOnMount?: boolean;
138
+ /**
139
+ * Animate exactly ONE value on the card instead of all of them.
140
+ *
141
+ * Prefer this to `animateValue` on a dashboard. A card carries several numeric
142
+ * slots at different sizes, and animating all of them puts motion on the small
143
+ * supporting figures as loudly as on the headline -- which reads as noise, and
144
+ * on the business variant actually animated the 18px rows while the 44px
145
+ * subtitle above them sat still.
146
+ * @default false
147
+ */
148
+ animatePrimary?: boolean;
149
+ /**
150
+ * Which slot `animatePrimary` animates.
151
+ *
152
+ * `'auto'` measures the rendered slots and picks the largest one holding a
153
+ * metric, so the card follows its own type scale. Name a slot to override:
154
+ * `'subtitle'`, `'value'`, or a row index. Ignored unless `animatePrimary`.
155
+ * @default 'auto'
156
+ */
157
+ primary?: 'auto' | 'subtitle' | 'value' | number;
126
158
  /**
127
159
  * Multiple metric rows. When provided, replaces the single value/delta row with
128
160
  * a column of rows separated by dividers.