@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.
- package/README.md +15 -14
- package/dist/Accordion/Accordion.svelte +18 -0
- package/dist/AnimatedNumber/AnimatedNumber.svelte +776 -0
- package/dist/AnimatedNumber/AnimatedNumber.svelte.d.ts +4 -0
- package/dist/AnimatedNumber/motion.d.ts +47 -0
- package/dist/AnimatedNumber/motion.js +190 -0
- package/dist/AnimatedNumber/properties.d.ts +58 -0
- package/dist/AnimatedNumber/properties.js +1 -0
- package/dist/Badge/Badge.svelte +20 -3
- package/dist/Badge/properties.d.ts +9 -0
- package/dist/Book/Book.svelte +9 -0
- package/dist/Calendar/Calendar.svelte +34 -2
- package/dist/Carousel/Carousel.svelte +17 -2
- package/dist/ChatBubble/ChatBubble.svelte +12 -0
- package/dist/ChatComposer/ChatComposer.svelte +9 -0
- package/dist/CommandMenu/CommandMenu.svelte +13 -0
- package/dist/DateRangePicker/DateRangePicker.svelte +77 -20
- package/dist/DeltaIndicator/DeltaIndicator.svelte +12 -1
- package/dist/DeltaIndicator/properties.d.ts +9 -0
- package/dist/Gallery/Gallery.svelte +16 -0
- package/dist/Gauge/Gauge.svelte +21 -2
- package/dist/Gauge/properties.d.ts +13 -0
- package/dist/Input/Input.svelte +34 -26
- package/dist/KeyboardInput/KeyboardInput.svelte +1 -1
- package/dist/ListItem/ListItem.svelte +27 -2
- package/dist/ListItem/label.d.ts +11 -0
- package/dist/ListItem/label.js +40 -0
- package/dist/ListItem/properties.d.ts +52 -0
- package/dist/MediaUpload/MediaUpload.svelte +12 -1
- package/dist/MediaUpload/properties.d.ts +9 -0
- package/dist/Menu/Menu.svelte +11 -2
- package/dist/Modal/Modal.svelte +14 -4
- package/dist/PieChart/PieChart.svelte +16 -1
- package/dist/PieChart/properties.d.ts +13 -0
- package/dist/Progress/Progress.svelte +21 -1
- package/dist/Progress/properties.d.ts +12 -0
- package/dist/ProportionBar/ProportionBar.svelte +7 -1
- package/dist/ProportionBar/ProportionBar.svelte.d.ts +0 -19
- package/dist/ProportionBar/properties.d.ts +10 -0
- package/dist/Select/Select.svelte +23 -8
- package/dist/SplitButton/SplitButton.svelte +4 -1
- package/dist/SplitInput/SplitInput.svelte +1 -1
- package/dist/StatCard/StatCard.svelte +129 -5
- package/dist/StatCard/metric.d.ts +22 -0
- package/dist/StatCard/metric.js +74 -0
- package/dist/StatCard/properties.d.ts +44 -0
- package/dist/Table/BuiltinCell.svelte +19 -2
- package/dist/Table/cellData.js +3 -0
- package/dist/Table/properties.d.ts +15 -0
- package/dist/Tabs/Tabs.svelte +16 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/styles/theme-dark.css +27 -3
- package/dist-codemod/codemod/migration-assets/legacy-display.css +2 -1
- package/dist-codemod/codemod/migration-assets/legacy-palette.css +4 -1
- package/dist-codemod/codemod/migration-assets/wc-display.json +5 -0
- package/dist-wc/index.js +1435 -747
- 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>
|