@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
@@ -0,0 +1,776 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import type { AnimatedNumberProperties } from './properties';
4
+ import { canSpin, DELTA_PROPERTY, durationMs, trendBetween, wheelDelta } from './motion';
5
+
6
+ let {
7
+ value,
8
+ locale = 'en-US',
9
+ format,
10
+ live = 'off',
11
+ animateOnMount = false,
12
+ ariaLabel,
13
+ testId,
14
+ classes
15
+ }: AnimatedNumberProperties = $props();
16
+
17
+ /**
18
+ * Every column holds all ten glyphs for the life of its DOM node, and each one
19
+ * resolves its own position from a single scalar rather than being animated
20
+ * itself -- see motion.ts for why that distinction is the whole mechanism, and
21
+ * what animating the glyphs directly looked like instead.
22
+ *
23
+ * The component runs no timer. A value change writes one custom property per
24
+ * column and hands one interpolation to the browser; everything else is CSS
25
+ * arithmetic re-evaluated per frame.
26
+ */
27
+ type Column =
28
+ | { readonly kind: 'digit'; readonly key: string; readonly digit: number }
29
+ | { readonly kind: 'literal'; readonly key: string; readonly text: string };
30
+
31
+ /** The fallback when a locale's own numbering system cannot be resolved. */
32
+ const LATIN_DIGITS: readonly string[] = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
33
+
34
+ /**
35
+ * Integer digits are keyed by place value counted from the units digit, so
36
+ * growing a magnitude (99 -> 100) only ever ADDS a key at one end and never
37
+ * renumbers the columns already on screen -- the node survives, so a roll
38
+ * already in flight on it carries on rather than being torn down. Group separators mirror that, keyed by distance from the
39
+ * right; every other part type occurs at most once per ECMA-402 and is keyed
40
+ * by the type itself.
41
+ */
42
+ const columnsFromParts = (parts: readonly Intl.NumberFormatPart[]): readonly Column[] => {
43
+ const integerDigits = parts.reduce(
44
+ // `[...value]` counts code points. `.length` counts UTF-16 units, which
45
+ // doubles every digit of a supplementary-plane system such as `mathbold`
46
+ // and hands each column a place-value key computed from the wrong total,
47
+ // so a magnitude change renumbers columns that should have survived it.
48
+ (total, part) => (part.type === 'integer' ? total + [...part.value].length : total),
49
+ 0
50
+ );
51
+ const groups = parts.reduce((total, part) => (part.type === 'group' ? total + 1 : total), 0);
52
+ let integerSeen = 0;
53
+ let fractionSeen = 0;
54
+ let groupSeen = 0;
55
+ const literalSeen: Record<string, number> = {};
56
+ const columns: Column[] = [];
57
+ for (const part of parts) {
58
+ if (part.type === 'integer') {
59
+ for (const character of part.value) {
60
+ columns.push({
61
+ kind: 'digit',
62
+ key: `int-${integerDigits - 1 - integerSeen}`,
63
+ digit: digitOf(character)
64
+ });
65
+ integerSeen += 1;
66
+ }
67
+ } else if (part.type === 'fraction') {
68
+ for (const character of part.value) {
69
+ columns.push({
70
+ kind: 'digit',
71
+ key: `frac-${fractionSeen}`,
72
+ digit: digitOf(character)
73
+ });
74
+ fractionSeen += 1;
75
+ }
76
+ } else if (part.type === 'group') {
77
+ columns.push({ kind: 'literal', key: `group-${groups - 1 - groupSeen}`, text: part.value });
78
+ groupSeen += 1;
79
+ } else {
80
+ // Keyed by type AND an ordinal counted WITHIN that type. The ordinal is
81
+ // needed because accounting format emits TWO parts typed `literal` --
82
+ // '(' and ')' bracketing a negative -- and a duplicate key in a keyed
83
+ // {#each} is a Svelte runtime error, not a cosmetic slip. Counting the
84
+ // columns emitted so far instead made the ordinal depend on how many
85
+ // DIGITS preceded it, so growing a magnitude renumbered every literal
86
+ // after the digits and Svelte rebuilt each one: `99% -> 100%` discarded
87
+ // the `%` node and made a new one. Nothing shows, because a literal never
88
+ // animates -- but surviving the update is the whole point of keying them.
89
+ const ordinal = literalSeen[part.type] ?? 0;
90
+ literalSeen[part.type] = ordinal + 1;
91
+ columns.push({ kind: 'literal', key: `${part.type}-${ordinal}`, text: part.value });
92
+ }
93
+ }
94
+ return columns;
95
+ };
96
+
97
+ /**
98
+ * An already-formatted string has no parts to read, so it is split into a
99
+ * leading non-digit run, a numeric body and a trailing non-digit run. The
100
+ * prefix anchors from the LEFT and the body and suffix from the RIGHT, which
101
+ * is what keeps a currency symbol still when the digit count grows:
102
+ * `$99.99 -> $100.01` rolls four digits and adds one column, rather than
103
+ * re-reading the `$` column as a digit.
104
+ *
105
+ * The split asks `digitOf` what counts as a digit rather than matching
106
+ * `\D`, which is ASCII-only without the `u` flag. Every Arabic-Indic,
107
+ * Eastern-Arabic, Bengali and Devanagari numeral therefore read as a NON-digit
108
+ * and the leading run swallowed the whole string, leaving the body empty and
109
+ * every character a static literal -- with the right value, the right
110
+ * accessible name and nothing thrown, so the only symptom was that the number
111
+ * quietly stopped rolling. `locale` could not help: the numbering system was
112
+ * resolved correctly and then never consulted, because the split had already
113
+ * happened.
114
+ */
115
+ const columnsFromText = (text: string): readonly Column[] => {
116
+ const characters = [...text];
117
+ let firstDigit = -1;
118
+ let lastDigit = -1;
119
+ characters.forEach((character, index) => {
120
+ if (digitOf(character) >= 0) {
121
+ if (firstDigit < 0) {
122
+ firstDigit = index;
123
+ }
124
+ lastDigit = index;
125
+ }
126
+ });
127
+ // No digits at all means the whole string is prefix, which is what the
128
+ // greedy leading run produced for a digitless string before.
129
+ const hasDigits = firstDigit >= 0;
130
+ const prefix = hasDigits ? characters.slice(0, firstDigit) : characters;
131
+ const body = hasDigits ? characters.slice(firstDigit, lastDigit + 1) : [];
132
+ const suffix = hasDigits ? characters.slice(lastDigit + 1) : [];
133
+
134
+ const columns: Column[] = [];
135
+ prefix.forEach((character, index) => {
136
+ columns.push({ kind: 'literal', key: `pre-${index}`, text: character });
137
+ });
138
+ body.forEach((character, index) => {
139
+ const key = `body-${body.length - 1 - index}`;
140
+ const digit = digitOf(character);
141
+ columns.push(
142
+ digit >= 0 ? { kind: 'digit', key, digit } : { kind: 'literal', key, text: character }
143
+ );
144
+ });
145
+ suffix.forEach((character, index) => {
146
+ columns.push({
147
+ kind: 'literal',
148
+ key: `post-${suffix.length - 1 - index}`,
149
+ text: character
150
+ });
151
+ });
152
+ return columns;
153
+ };
154
+
155
+ // (-0).toLocaleString() renders "-0" in every engine implementing signed zero
156
+ // faithfully, which reads as wrong next to a value nobody typed as negative.
157
+ const numeric = $derived(typeof value === 'number' && Object.is(value, -0) ? 0 : value);
158
+
159
+ const formatter = $derived(new Intl.NumberFormat(locale, format));
160
+
161
+ /**
162
+ * The code point of THIS locale's zero. A numbering system's ten digits are
163
+ * contiguous, so one code point gives both the glyph list and the parse.
164
+ *
165
+ * Necessary because `Intl.NumberFormat` does not return Western digits for
166
+ * every locale: ar-EG yields '١٢٣٤', fa-IR '۱۲۳۴', bn-BD '১২৩৪', ne-NP '१२३४'.
167
+ * `Number('١')` is NaN, which reached the inline custom property as the literal
168
+ * text `NaN`, made the whole `calc()` invalid so `transform` fell back to
169
+ * `none`, and froze every column on its first glyph -- while `aria-label`, built
170
+ * from the untouched part values, still read the correct localized number. A
171
+ * sighted reader and a screen-reader reader were told different things.
172
+ */
173
+ /**
174
+ * This numbering system's ten digits, in value order, asked FROM the formatter.
175
+ *
176
+ * Derived by formatting 0-9 rather than as `zero + index`, which assumes the
177
+ * ten digits are ten consecutive code points. `hanidec` is not: its digits are
178
+ * the Han numerals 〇一二三四五六七八九, whose code points are scattered, so the
179
+ * arithmetic produced ten unrelated characters, nothing matched a real digit,
180
+ * and the number rendered as motionless literals. It is reachable with an
181
+ * ordinary locale string -- `zh-u-nu-hanidec` -- so it needs no exotic option
182
+ * to hit.
183
+ *
184
+ * Necessary for the same reason the arithmetic version was: `Intl` does not
185
+ * return Western digits for every locale. ar-EG yields '١٢٣٤', fa-IR '۱۲۳۴',
186
+ * bn-BD '১২৩৪', ne-NP '१२३४'.
187
+ */
188
+ const glyphs = $derived.by((): readonly string[] => {
189
+ try {
190
+ const numberingSystem = formatter.resolvedOptions().numberingSystem;
191
+ const digits = new Intl.NumberFormat('en', { numberingSystem, useGrouping: false });
192
+ return Array.from({ length: 10 }, (_, digit) => digits.format(digit));
193
+ } catch {
194
+ return LATIN_DIGITS;
195
+ }
196
+ });
197
+
198
+ /**
199
+ * The digit a glyph stands for in the active numbering system, or -1.
200
+ *
201
+ * A map rather than subtraction, so a system whose digits are not contiguous
202
+ * resolves as readily as one whose digits are.
203
+ */
204
+ const digitOf = $derived.by((): ((character: string) => number) => {
205
+ const lookup = new Map(glyphs.map((glyph, digit) => [glyph, digit]));
206
+ return (character: string): number => lookup.get(character) ?? -1;
207
+ });
208
+
209
+ const parts = $derived(typeof numeric === 'number' ? formatter.formatToParts(numeric) : null);
210
+
211
+ // One source of truth for "what does this actually say", so the accessible
212
+ // name can never drift from the glyphs on screen.
213
+ const plainText = $derived(
214
+ parts === null ? String(numeric) : parts.map((part) => part.value).join('')
215
+ );
216
+
217
+ /**
218
+ * Gates the roll for columns that exist on the very first paint: those are at
219
+ * rest, not arriving, so the number never counts up from zero on page load. A
220
+ * column that appears LATER, because the value grew a digit, mounts with this
221
+ * already true and does roll in -- from 0, which is what it implicitly was
222
+ * before it existed.
223
+ *
224
+ * `onMount` rather than `$effect`, which eslint.config.js bans outright:
225
+ * nothing here needs to re-run, it is a one-way latch.
226
+ */
227
+ let mounted = $state(false);
228
+ onMount(() => {
229
+ mounted = true;
230
+ });
231
+
232
+ /*
233
+ * Plain bookkeeping, not `$state` -- like ColorPicker's `lastCommittedHex` and
234
+ * Tabs' `lastSelectionKey` -- so reading it never creates a reactive
235
+ * dependency. Whichever column's attachment runs first for a given value
236
+ * computes the direction the number as a whole moved; the rest read it back.
237
+ * That shared direction is the single thing that makes every 9 in 99 -> 100
238
+ * step forward by one rather than nine backwards.
239
+ */
240
+ let lastSeen: number | string | null = null;
241
+ let trend = 0;
242
+
243
+ const trendFor = (current: number | string): number => {
244
+ if (lastSeen !== current) {
245
+ trend = lastSeen === null ? 0 : trendBetween(lastSeen, current, digitOf);
246
+ lastSeen = current;
247
+ }
248
+ return trend;
249
+ };
250
+
251
+ /** The digit each column last settled on, kept per DOM node so it survives the
252
+ * attachment being re-created when any of its arguments change. */
253
+ const settledDigits = new WeakMap<Element, number>();
254
+
255
+ /**
256
+ * Which roll is the current one for a column, so an earlier roll finishing
257
+ * cannot clear the class a later one still needs.
258
+ *
259
+ * Each roll waits on every animation running on the column when it starts, but
260
+ * an earlier roll waited on a strictly smaller set and so resolves FIRST --
261
+ * while the later one is still going. Measured during the burst demo: from
262
+ * 948ms, three columns were four animations deep and had already dropped the
263
+ * class, which sends the nine glyphs that are not current back to
264
+ * `display: none` and pops the outgoing digit out of existence mid-roll.
265
+ */
266
+ const rollGeneration = new WeakMap<Element, number>();
267
+
268
+ /**
269
+ * Turns one column's wheel to `digit`.
270
+ *
271
+ * `{@attach}` runs its callback inside a real reactive effect (see Tabs and
272
+ * ColorPicker for the same idiom), which gives the after-the-DOM-updated hook
273
+ * this needs without reaching for the banned `$effect`. The duration and
274
+ * easing are read back off the computed style rather than hardcoded, so the
275
+ * two-token chain in the stylesheet below -- and the reduced-motion block that
276
+ * collapses it -- keep working even though the motion itself is no longer a
277
+ * CSS transition.
278
+ */
279
+ const roll =
280
+ (digit: number, canRoll: boolean, current: number | string) =>
281
+ (node: HTMLElement): void => {
282
+ // Advance the shared trend before any early return: a column whose own
283
+ // digit did not change still has to let the first-runner compute it.
284
+ const direction = trendFor(current);
285
+
286
+ const seen = settledDigits.has(node);
287
+ const previous = settledDigits.get(node) ?? 0;
288
+ settledDigits.set(node, digit);
289
+
290
+ if (!canRoll || !canSpin()) {
291
+ return;
292
+ }
293
+ const delta = wheelDelta(digit, seen ? previous : 0, direction);
294
+ if (delta === 0) {
295
+ return;
296
+ }
297
+
298
+ const styles = getComputedStyle(node);
299
+ const duration = durationMs(styles.getPropertyValue('--_animated-number-spin-duration'));
300
+ if (duration <= 0) {
301
+ return;
302
+ }
303
+ const easing = styles.getPropertyValue('--_animated-number-spin-easing').trim();
304
+
305
+ try {
306
+ node.animate(
307
+ { [DELTA_PROPERTY]: [-delta, 0] },
308
+ {
309
+ duration,
310
+ easing: easing.length > 0 ? easing : 'linear',
311
+ // Overlapping rolls sum rather than replace, so a burst of changes
312
+ // mid-flight stays continuous instead of snapping to the newest one.
313
+ composite: 'accumulate'
314
+ }
315
+ );
316
+ } catch {
317
+ // `animate` rejects an easing it cannot parse, and the easing comes from
318
+ // a token a consumer controls. Changing instantly is the same graceful
319
+ // degradation an engine without `mod()` already gets -- far better than
320
+ // throwing out of an effect mid-render, which would also strand the
321
+ // class added below and leave nine glyphs stuck on screen.
322
+ return;
323
+ }
324
+
325
+ // Reveals the nine glyphs that are `display: none` at rest, so the one
326
+ // leaving is visible on its way out. Added after `animate` rather than
327
+ // before so a throw cannot leave it behind; the style flush happens at
328
+ // paint, well after this synchronous block, so the glyphs are still
329
+ // visible for the animation's very first frame.
330
+ const generation = (rollGeneration.get(node) ?? 0) + 1;
331
+ rollGeneration.set(node, generation);
332
+ node.classList.add('animated-number-digit--spinning');
333
+
334
+ const settle = (): void => {
335
+ if (rollGeneration.get(node) === generation) {
336
+ node.classList.remove('animated-number-digit--spinning');
337
+ }
338
+ };
339
+ // `then(settle, settle)`: a roll replaced mid-flight rejects rather than
340
+ // resolves, and the class still has to come off on the last one out.
341
+ Promise.all(node.getAnimations().map((running) => running.finished)).then(settle, settle);
342
+ };
343
+
344
+ const columns = $derived(
345
+ parts === null ? columnsFromText(String(numeric)) : columnsFromParts(parts)
346
+ );
347
+
348
+ /**
349
+ * What assistive technology is told this element is. An empty one used to be
350
+ * announced as `role="img"` with `aria-label=""` -- a graphic with no
351
+ * accessible name, which WCAG 1.1.1 does not allow and which readers announce
352
+ * as a bare "image" or skip. Badge renders `<AnimatedNumber value={value ?? ''} />`
353
+ * whenever `animateValue` is set, so a badge with no value yet reached exactly
354
+ * that. With nothing to say the element says nothing and stays an ordinary
355
+ * span; an explicit `ariaLabel` still makes it an image, because then it does
356
+ * have something to say.
357
+ *
358
+ * `aria-live` is deliberately NOT gated on this. A live region has to be in
359
+ * the DOM before the change it announces, so an element that starts empty and
360
+ * later fills in still has to carry it from the first render.
361
+ */
362
+ const accessibleName = $derived(ariaLabel ?? plainText);
363
+ const named = $derived(accessibleName.length > 0);
364
+ </script>
365
+
366
+ <span
367
+ class="animated-number {classes ?? ''}"
368
+ data-pw={testId ?? null}
369
+ testID={testId ?? null}
370
+ role={named ? 'img' : null}
371
+ aria-label={named ? accessibleName : null}
372
+ aria-live={live === 'off' ? null : live}
373
+ >
374
+ <!--
375
+ The glyph machinery below is ten real text nodes per column. Nine of them are
376
+ `display: none` while the column is at rest, but they all come back for the
377
+ duration of a roll, and `aria-hidden` does not help there: selection and
378
+ find-in-page work on the text layer, not the accessibility tree, so a reader
379
+ who drag-selected "1,234" mid-roll would otherwise copy every numeral with
380
+ it. The glyphs are made unselectable and the value is carried by this one
381
+ node instead, so a copy yields the number whether or not anything is moving.
382
+
383
+ Which is also why it needs `aria-hidden` of its own. Being clipped rather
384
+ than `display: none` is what keeps it in the text layer -- and that kept it
385
+ in the accessibility tree too. Sitting inside the `role="img"` root was
386
+ assumed to be enough, on the reading that `img` has presentational children;
387
+ ARIA says user agents SHOULD treat them that way and Chromium does not.
388
+ Measured inside one counter: `image "99"`, `StaticText "99"` and
389
+ `InlineTextBox "99"`, so the number was announced twice -- and for an adopter
390
+ like Badge, whose root is a `status` live region, twice on every change.
391
+ `aria-hidden` removes it from that tree without touching selection, which is
392
+ a render-tree concern and unaffected by it.
393
+ -->
394
+ <span class="animated-number-plain" aria-hidden="true">{plainText}</span>
395
+ <span class="animated-number-visual" aria-hidden="true">
396
+ {#each columns as column (column.key)}
397
+ {#if column.kind === 'digit'}
398
+ <span
399
+ class="animated-number-digit"
400
+ style="--_animated-number-digit: {column.digit};"
401
+ {@attach roll(column.digit, mounted || animateOnMount, numeric)}
402
+ >
403
+ {#each glyphs as glyph, index (glyph)}
404
+ <!--
405
+ Only the current glyph is in flow, so it alone gives the column its
406
+ height and the column never resizes mid-roll. `inert` marks the
407
+ other nine: it takes them out of the tab order and the text layer
408
+ as well as positioning them, and they stay `display: none` until a
409
+ roll is actually running.
410
+ -->
411
+ <span
412
+ class="animated-number-digit-glyph"
413
+ style="--_animated-number-glyph: {index};"
414
+ inert={index !== column.digit}>{glyph}</span
415
+ >
416
+ {/each}
417
+ </span>
418
+ {:else}
419
+ <span class="animated-number-literal">{column.text}</span>
420
+ {/if}
421
+ {/each}
422
+ </span>
423
+ </span>
424
+
425
+ <style>
426
+ .animated-number {
427
+ /*
428
+ * One box, shared by the columns, the static characters and the glyphs, and
429
+ * also the distance a step of one digit travels.
430
+ *
431
+ * Defined once and referred to as a LENGTH rather than each place computing
432
+ * its own, and rather than the glyphs positioning themselves in percentages
433
+ * of their own height. A percentage resolves against the element's used
434
+ * height, which WebKit rounds: measured at a 70.39px box, it parked a
435
+ * clamped glyph 70.0px away instead of 70.39px, leaving a 0.39px sliver of
436
+ * the wrong digit inside the window on every frame of every roll. It fell
437
+ * inside the fully transparent end of the mask so nothing showed, but it is
438
+ * the sort of near-miss that stops being invisible at another font size.
439
+ * Sharing one length makes the column and the glyph the same size by
440
+ * construction instead of by agreement. (The reference implementation meets
441
+ * the same rounding and rounds its mask height to whole pixels for it.)
442
+ */
443
+ --_animated-number-box: calc(
444
+ var(--animated-number-row-height, 1.2em) + 2 * var(--animated-number-fade-height, 0.28em)
445
+ );
446
+ position: relative;
447
+ display: inline-block;
448
+ white-space: nowrap;
449
+ font-variant-numeric: var(--animated-number-font-variant-numeric, tabular-nums);
450
+ color: var(--animated-number-color, inherit);
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
+
511
+ .animated-number-visual {
512
+ /* Decorative glyph content, same treatment Avatar, KeyboardInput, Tabs,
513
+ Calendar and Draggable already give theirs. */
514
+ user-select: none;
515
+ }
516
+
517
+ /* Carries the real value for selection, copy and find-in-page. Clipped rather
518
+ than `display: none` or `visibility: hidden`, both of which would take it out
519
+ of the text layer and defeat the point. It sits inside the `role="img"` root,
520
+ so assistive technology never reaches it -- the root's aria-label is the one
521
+ accessible name. */
522
+ .animated-number-plain {
523
+ position: absolute;
524
+ width: 1px;
525
+ height: 1px;
526
+ overflow: hidden;
527
+ clip-path: inset(50%);
528
+ white-space: nowrap;
529
+ }
530
+
531
+ /*
532
+ * Both the digit columns and the static characters use ONE box height, so they
533
+ * sit on the same line however the fade is tuned: the text row plus a fade band
534
+ * above and below. The negative margin hands those two bands back to the line
535
+ * box, so the number still occupies exactly `--animated-number-row-height` in
536
+ * the surrounding text and adding a fade never shifts the baseline.
537
+ */
538
+ /*
539
+ * Both the digit columns and the static characters use ONE box height, so they
540
+ * sit on the same line however the fade is tuned: the text row plus a fade band
541
+ * above and below. The negative margin hands those two bands back to the line
542
+ * box, so the number still occupies exactly `--animated-number-row-height` in
543
+ * the surrounding text and adding a fade never shifts the baseline. One box is
544
+ * also exactly what a step of one digit travels, so a neighbour lands fully
545
+ * outside the column rather than poking into the fade band where the mask is
546
+ * still opaque.
547
+ */
548
+ .animated-number-digit,
549
+ .animated-number-literal {
550
+ display: inline-block;
551
+ vertical-align: bottom;
552
+ height: var(--_animated-number-box, 1.76em);
553
+ line-height: var(--_animated-number-box, 1.76em);
554
+ margin-block: calc(-1 * var(--animated-number-fade-height, 0.28em));
555
+ }
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
+
572
+ .animated-number-digit {
573
+ position: relative;
574
+ width: var(--animated-number-digit-width, 1ch);
575
+ text-align: center;
576
+ /* The mask only fades what is inside this box; the clip is what removes the
577
+ glyphs parked outside it. Without it they sit in plain view above and below
578
+ the number. */
579
+ overflow: hidden;
580
+ -webkit-mask-image: linear-gradient(
581
+ to bottom,
582
+ transparent 0,
583
+ #000 var(--animated-number-fade-height, 0.28em),
584
+ #000 calc(100% - var(--animated-number-fade-height, 0.28em)),
585
+ transparent 100%
586
+ );
587
+ mask-image: linear-gradient(
588
+ to bottom,
589
+ transparent 0,
590
+ #000 var(--animated-number-fade-height, 0.28em),
591
+ #000 calc(100% - var(--animated-number-fade-height, 0.28em)),
592
+ transparent 100%
593
+ );
594
+
595
+ /*
596
+ * The scalar every glyph below resolves its position from. A token stream,
597
+ * not a value: it is substituted into the `mod()` in the glyph rule, where
598
+ * `a + b` is an ordinary calc sum. The digit jumps to its new value and the
599
+ * delta animates from `-delta` back to 0, so the sum travels from the old
600
+ * digit to the new one and no glyph ever has its own position animated.
601
+ */
602
+ --_animated-number-c: var(--_animated-number-digit, 0) + var(--_animated-number-delta, 0);
603
+
604
+ /*
605
+ * The two-token chain, resolved here and read back by the attachment rather
606
+ * than driving a `transition` -- which is why it still answers to
607
+ * `--motion-duration` and to the reduced-motion block at the end of this
608
+ * sheet, even though the motion is now one interpolated custom property.
609
+ */
610
+ --_animated-number-spin-duration: var(
611
+ --animated-number-digit-transition-duration,
612
+ var(--motion-duration, 0.9s)
613
+ );
614
+ --_animated-number-spin-easing: var(
615
+ --animated-number-digit-transition-easing,
616
+ var(
617
+ --motion-easing,
618
+ linear(
619
+ 0,
620
+ 0.005,
621
+ 0.019,
622
+ 0.039,
623
+ 0.066,
624
+ 0.096,
625
+ 0.129,
626
+ 0.165,
627
+ 0.202,
628
+ 0.24,
629
+ 0.278,
630
+ 0.316,
631
+ 0.354,
632
+ 0.39,
633
+ 0.426,
634
+ 0.461,
635
+ 0.494,
636
+ 0.526,
637
+ 0.557,
638
+ 0.586,
639
+ 0.614,
640
+ 0.64,
641
+ 0.665,
642
+ 0.689,
643
+ 0.711,
644
+ 0.731,
645
+ 0.751,
646
+ 0.769,
647
+ 0.786,
648
+ 0.802,
649
+ 0.817,
650
+ 0.831,
651
+ 0.844,
652
+ 0.856,
653
+ 0.867,
654
+ 0.877,
655
+ 0.887,
656
+ 0.896,
657
+ 0.904,
658
+ 0.912,
659
+ 0.919,
660
+ 0.925,
661
+ 0.931,
662
+ 0.937,
663
+ 0.942,
664
+ 0.947,
665
+ 0.951,
666
+ 0.955,
667
+ 0.959,
668
+ 0.962,
669
+ 0.965,
670
+ 0.968,
671
+ 0.971,
672
+ 0.973,
673
+ 0.976,
674
+ 0.978,
675
+ 0.98,
676
+ 0.981,
677
+ 0.983,
678
+ 0.984,
679
+ 0.986,
680
+ 0.987,
681
+ 0.988,
682
+ 0.989,
683
+ 0.99,
684
+ 0.991,
685
+ 0.992,
686
+ 0.992,
687
+ 0.993,
688
+ 0.994,
689
+ 0.994,
690
+ 0.995,
691
+ 0.995,
692
+ 0.996,
693
+ 0.996,
694
+ 0.9963,
695
+ 0.9967,
696
+ 0.9969,
697
+ 0.9972,
698
+ 0.9975,
699
+ 0.9977,
700
+ 0.9979,
701
+ 0.9981,
702
+ 0.9982,
703
+ 0.9984,
704
+ 0.9985,
705
+ 0.9987,
706
+ 0.9988,
707
+ 0.9989,
708
+ 1
709
+ )
710
+ )
711
+ );
712
+ }
713
+
714
+ /*
715
+ * Where the wheel is actually drawn. `--_animated-number-offset-raw` is this
716
+ * glyph's distance ahead of the current position, 0-9; the `round(down, ...)`
717
+ * line re-centres that onto -5..5 so a glyph is placed on whichever side it is
718
+ * nearer, and the clamp parks everything further than one box away at exactly
719
+ * one box away. Nothing here is transitioned: it is recomputed every frame
720
+ * from a scalar that is, which is what keeps a far glyph pinned outside the
721
+ * window instead of tweening across it.
722
+ */
723
+ .animated-number-digit-glyph {
724
+ display: block;
725
+ height: var(--_animated-number-box, 1.76em);
726
+ line-height: var(--_animated-number-box, 1.76em);
727
+ --_animated-number-offset-raw: mod(
728
+ 10 + var(--_animated-number-glyph, 0) - mod(var(--_animated-number-c, 0), 10),
729
+ 10
730
+ );
731
+ --_animated-number-offset: calc(
732
+ var(--_animated-number-offset-raw, 0) - 10 *
733
+ round(down, var(--_animated-number-offset-raw, 0) / 5, 1)
734
+ );
735
+ --_animated-number-y: clamp(
736
+ calc(-1 * var(--_animated-number-box, 1.76em)),
737
+ calc(var(--_animated-number-offset, 0) * var(--_animated-number-box, 1.76em)),
738
+ var(--_animated-number-box, 1.76em)
739
+ );
740
+ transform: translateY(var(--_animated-number-y, 0));
741
+ }
742
+
743
+ /* Only the current glyph stays in flow, so the column keeps one glyph's height
744
+ and never resizes mid-roll. */
745
+ .animated-number-digit-glyph[inert] {
746
+ position: absolute;
747
+ top: 0;
748
+ left: 0;
749
+ width: 100%;
750
+ }
751
+
752
+ /* At rest the column holds exactly the digit it reads as -- which is also what
753
+ stops the other nine reaching find-in-page. They come back for the roll. */
754
+ .animated-number-digit:not(.animated-number-digit--spinning) .animated-number-digit-glyph[inert] {
755
+ display: none;
756
+ }
757
+
758
+ /* DESIGN_PRINCIPLES.md section 1 says only an animation with no natural end
759
+ needs its own reduced-motion block, because --motion-duration: 0s would
760
+ strand it on a keyframe meaning something else. This one is finite and has
761
+ no such hazard -- but the token only stops it if something assigns it, and
762
+ nothing in this library ever does: it exists as the middle rung of a
763
+ fallback chain for consumers to reach. Left at that, a reader who asked
764
+ their OS for reduced motion still watched every digit roll.
765
+
766
+ Collapsing the duration here rather than duplicating the query in script
767
+ keeps one mechanism for both routes: the attachment reads 0s and never
768
+ starts an animation, so the digit is simply already correct. It lives in
769
+ this component's own <style> because that is the only stylesheet reaching
770
+ inside the shadow root a custom-element consumer gets. */
771
+ @media (prefers-reduced-motion: reduce) {
772
+ .animated-number-digit {
773
+ --_animated-number-spin-duration: 0s;
774
+ }
775
+ }
776
+ </style>