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