@leavepulse/ui 0.24.0 → 0.25.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 (104) hide show
  1. package/dist/assets/LpNumberFlow-rZkwQwlU.css +116 -0
  2. package/dist/assets/LpRollingText-CR1mb0Vt.css +74 -0
  3. package/dist/assets/LpSparkline-0mKTq7pb.css +51 -0
  4. package/dist/assets/LpTooltip-B61SYd4K.css +17 -0
  5. package/dist/assets/LpUptimeBar-B98o9krI.css +18 -0
  6. package/dist/chunks/{LpAppShell-CaQYQKZV.js → LpAppShell-B5k0J3Cf.js} +1 -1
  7. package/dist/chunks/LpChart-tNXs1ttr.js +671 -0
  8. package/dist/chunks/{LpCodeBlock-CSiOSPs8.js → LpCodeBlock-rS_6mO3D.js} +1 -1
  9. package/dist/chunks/{LpConfirmDialog-CObRah9c.js → LpConfirmDialog-CjOGcj1e.js} +32 -4
  10. package/dist/chunks/{LpDisclosure-DP6yfgfM.js → LpDisclosure-BnwP4Q-b.js} +1 -1
  11. package/dist/chunks/{LpFileTree-nU1wRRxZ.js → LpFileTree-BaV7f0O7.js} +1 -1
  12. package/dist/chunks/{LpFileTreeNode-D7WgUPcJ.js → LpFileTreeNode-BzpBa7R7.js} +6 -8
  13. package/dist/chunks/{LpLogViewer-B1aLUVdz.js → LpLogViewer-CcQ3s3Fp.js} +2 -1
  14. package/dist/chunks/{LpMapPicker-DFKrd1Ne.js → LpMapPicker-CXvoTrAe.js} +2 -4
  15. package/dist/chunks/{LpNotificationBell-X1M30e0X.js → LpNotificationBell-Dvu-b6Vn.js} +56 -27
  16. package/dist/chunks/LpNumberFlow-amWYaeuA.js +221 -0
  17. package/dist/chunks/LpPie-BkjWlfSw.js +525 -0
  18. package/dist/chunks/LpProgress-DoIP4jQR.js +198 -0
  19. package/dist/chunks/LpRollingText-CM5gkSeF.js +50 -0
  20. package/dist/chunks/{LpSidebar-D3GGLSE7.js → LpSidebar-7Vpy2IJc.js} +1 -1
  21. package/dist/chunks/{LpSidebarNav-X14xMdPa.js → LpSidebarNav-CFX5NxGs.js} +1 -1
  22. package/dist/chunks/LpSparkline-C6xQ2dwv.js +175 -0
  23. package/dist/chunks/{LpStat-CdbEFOvn.js → LpStat-CDanpzU0.js} +43 -5
  24. package/dist/chunks/{LpThemeSwitcher-BgpJgrnP.js → LpThemeSwitcher-7mAK6bI7.js} +2 -4
  25. package/dist/chunks/LpTooltip-hCLPAqMG.js +131 -0
  26. package/dist/chunks/LpUptimeBar-DcQ8HikG.js +152 -0
  27. package/dist/component-names.d.ts +1 -1
  28. package/dist/components/LpAppShell.vue.js +1 -1
  29. package/dist/components/LpChart.vue.d.ts +88 -0
  30. package/dist/components/LpChart.vue.js +4 -0
  31. package/dist/components/LpCodeBlock.vue.js +1 -1
  32. package/dist/components/LpConfirmDialog.vue.d.ts +18 -2
  33. package/dist/components/LpConfirmDialog.vue.js +1 -1
  34. package/dist/components/LpDisclosure.vue.js +1 -1
  35. package/dist/components/LpFileTree.vue.d.ts +1 -1
  36. package/dist/components/LpFileTree.vue.js +1 -1
  37. package/dist/components/LpFileTreeNode.vue.js +1 -1
  38. package/dist/components/LpLightbox.vue.d.ts +1 -1
  39. package/dist/components/LpLogViewer.vue.d.ts +1 -1
  40. package/dist/components/LpLogViewer.vue.js +1 -1
  41. package/dist/components/LpMapPicker.vue.js +1 -1
  42. package/dist/components/LpNotificationBell.vue.d.ts +12 -0
  43. package/dist/components/LpNotificationBell.vue.js +1 -1
  44. package/dist/components/LpNumberFlow.vue.d.ts +35 -0
  45. package/dist/components/LpNumberFlow.vue.js +4 -0
  46. package/dist/components/LpPie.vue.d.ts +134 -0
  47. package/dist/components/LpPie.vue.js +4 -0
  48. package/dist/components/LpProgress.vue.d.ts +56 -2
  49. package/dist/components/LpProgress.vue.js +1 -1
  50. package/dist/components/LpRollingText.vue.d.ts +33 -0
  51. package/dist/components/LpRollingText.vue.js +4 -0
  52. package/dist/components/LpSidebar.vue.js +1 -1
  53. package/dist/components/LpSidebarNav.vue.js +1 -1
  54. package/dist/components/LpSparkline.vue.d.ts +48 -0
  55. package/dist/components/LpSparkline.vue.js +4 -0
  56. package/dist/components/LpStat.vue.d.ts +25 -2
  57. package/dist/components/LpStat.vue.js +1 -1
  58. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  59. package/dist/components/LpTooltip.vue.d.ts +24 -3
  60. package/dist/components/LpTooltip.vue.js +1 -1
  61. package/dist/components/LpUptimeBar.vue.js +1 -1
  62. package/dist/components/chart.d.ts +180 -0
  63. package/dist/components/chart.js +353 -0
  64. package/dist/composables/easing.d.ts +46 -0
  65. package/dist/composables/easing.js +23 -0
  66. package/dist/composables/usePillTransition.d.ts +13 -1
  67. package/dist/composables/usePillTransition.js +22 -2
  68. package/dist/composables/useReveal.d.ts +26 -0
  69. package/dist/composables/useReveal.js +45 -0
  70. package/dist/composables/useSettled.js +2 -2
  71. package/dist/composables/useShift.js +2 -4
  72. package/dist/index.d.ts +14 -0
  73. package/dist/index.js +126 -103
  74. package/package.json +2 -1
  75. package/src/component-names.ts +5 -0
  76. package/src/components/LpChart.vue +846 -0
  77. package/src/components/LpCodeBlock.vue +1 -1
  78. package/src/components/LpConfirmDialog.vue +49 -0
  79. package/src/components/LpDisclosure.vue +1 -1
  80. package/src/components/LpFileTreeNode.vue +9 -10
  81. package/src/components/LpLogViewer.vue +2 -3
  82. package/src/components/LpMapPicker.vue +2 -5
  83. package/src/components/LpNotificationBell.vue +76 -15
  84. package/src/components/LpNumberFlow.vue +524 -0
  85. package/src/components/LpPie.vue +778 -0
  86. package/src/components/LpProgress.vue +283 -6
  87. package/src/components/LpRollingText.vue +157 -0
  88. package/src/components/LpSidebarNav.vue +1 -1
  89. package/src/components/LpSparkline.vue +312 -0
  90. package/src/components/LpStat.vue +52 -4
  91. package/src/components/LpTooltip.vue +185 -3
  92. package/src/components/LpUptimeBar.vue +150 -53
  93. package/src/components/chart.ts +686 -0
  94. package/src/composables/easing.ts +88 -0
  95. package/src/composables/usePillTransition.ts +71 -6
  96. package/src/composables/useReveal.ts +113 -0
  97. package/src/composables/useSettled.ts +2 -4
  98. package/src/composables/useShift.ts +2 -8
  99. package/src/index.ts +19 -0
  100. package/src/theme/useTheme.ts +2 -8
  101. package/src/tokens/tokens.css +36 -0
  102. package/dist/chunks/LpProgress-BhI0IbjC.js +0 -37
  103. package/dist/chunks/LpTooltip-D1hbjQWG.js +0 -63
  104. package/dist/chunks/LpUptimeBar-CB366k0O.js +0 -127
@@ -0,0 +1,524 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * A number whose DIGITS roll, like an odometer.
4
+ *
5
+ * Animating the value alone (count from 10k to 1M and reformat each frame) is
6
+ * the obvious approach and the wrong one: the glyphs still hard-cut on every
7
+ * frame, so it reads as a number being retyped very fast rather than as one
8
+ * quantity moving. What makes it feel mechanical is that each COLUMN moves —
9
+ * the ones place spins fast, the thousands crawl, and a digit that didn't
10
+ * change doesn't move at all.
11
+ *
12
+ * So there are two layers:
13
+ *
14
+ * value — eased over time, and the digits are re-derived from it every frame,
15
+ * which is what makes the columns spin at different rates and lets
16
+ * the number pass through plausible in-between values.
17
+ * column — each digit is a 0–9 strip translated to the digit it shows. The
18
+ * strip is duplicated (0–9 twice) so a 9→0 step rolls forward into
19
+ * the next 0 instead of rewinding the long way round.
20
+ *
21
+ * Non-digit glyphs (separators, currency, the k/M suffix) are static cells that
22
+ * simply fade in and out, so a number gaining a digit doesn't shuffle its
23
+ * commas around.
24
+ *
25
+ * <LpNumberFlow :value="online" />
26
+ * <LpNumberFlow :value="bytes" compact /> <!-- 1.2M -->
27
+ * <LpNumberFlow :value="eur" prefix="€" :decimals="2" grouped />
28
+ */
29
+ import { computed, onBeforeUnmount, ref, watch } from "vue"
30
+ import { easeOut, prefersReducedMotion } from "../composables/easing"
31
+ import { useReveal } from "../composables/useReveal"
32
+
33
+ const props = withDefaults(
34
+ defineProps<{
35
+ value: number
36
+ /** Travel time in ms. Distance changes the feel, not the duration. */
37
+ duration?: number
38
+ /** Fixed decimal places. Ignored under `compact`. */
39
+ decimals?: number
40
+ /**
41
+ * Abbreviate large numbers — 1.2k, 3.4M. Off by default: an exact figure
42
+ * should stay exact unless asked otherwise.
43
+ */
44
+ compact?: boolean
45
+ /** Thousands separators. Ignored under `compact`. */
46
+ grouped?: boolean
47
+ prefix?: string
48
+ suffix?: string
49
+ /** Custom formatter; overrides compact/decimals/grouped entirely. */
50
+ format?: (v: number) => string
51
+ /** Start from 0 on first render rather than showing the value outright. */
52
+ animateOnMount?: boolean
53
+ locale?: string
54
+ /**
55
+ * Force the roll direction: "up" and "down" make every column travel the
56
+ * same way regardless of the digit, which reads as one mechanism turning.
57
+ * "auto" lets each column take the shorter route.
58
+ */
59
+ trend?: "auto" | "up" | "down"
60
+ }>(),
61
+ { duration: 700, decimals: 0, animateOnMount: true, trend: "auto" },
62
+ )
63
+
64
+ const displayed = ref(props.animateOnMount ? 0 : props.value)
65
+ let frame = 0
66
+
67
+ /*
68
+ * While a change is in flight the row is padded to the WIDER of where it came
69
+ * from and where it is going, with leading zeros. Without this, 42 → 1,000,000
70
+ * grows a column at a time as the value climbs past each power of ten: the row
71
+ * jitters, and every column to its left restarts its roll. Holding the width
72
+ * means the new places are already there, spinning up from zero like the wheels
73
+ * of an odometer that has not reached them yet. The padding is dropped the
74
+ * moment the value settles, so a resting number is never shown as 0,015,800.
75
+ */
76
+ const padTo = ref(0)
77
+ const settled = ref(true)
78
+
79
+ const integerDigits = (v: number) => {
80
+ const abs = Math.floor(Math.abs(v))
81
+ return abs === 0 ? 1 : Math.floor(Math.log10(abs)) + 1
82
+ }
83
+
84
+ const stop = () => {
85
+ if (frame) cancelAnimationFrame(frame)
86
+ frame = 0
87
+ }
88
+
89
+ /*
90
+ * How many decimals the journey is allowed to show.
91
+ *
92
+ * A custom `format` is free to render fractions of its own — formatTick, for
93
+ * one, falls back to two places — and a travelling value is fractional on
94
+ * almost every frame. So a hop between two whole numbers (412 GB → 186 GB) grew
95
+ * a ".16" out of nowhere, churned through it for the whole flight, and dropped
96
+ * it again at the end: digits appearing and vanishing that neither endpoint
97
+ * has, and a row whose width jumps with them.
98
+ *
99
+ * Rounding the value BEFORE formatting keeps the shape the endpoints imply. The
100
+ * endpoints decide: if either is fractional the fraction is real and stays.
101
+ */
102
+ const decimalsOf = (v: number) => {
103
+ if (Number.isInteger(v)) return 0
104
+ const s = String(v)
105
+ const dot = s.indexOf(".")
106
+ if (dot === -1) return 0
107
+ return Math.min(6, s.length - dot - 1)
108
+ }
109
+
110
+ const journeyDecimals = ref(0)
111
+
112
+ /* The value last aimed at — the previous leg's destination, which is a real
113
+ endpoint, unlike the interpolated position a new leg starts from. */
114
+ let lastTarget = props.animateOnMount ? 0 : props.value
115
+
116
+ /** Snap an in-flight value to the precision the endpoints justify. */
117
+ const round = (v: number) => {
118
+ const p = journeyDecimals.value
119
+ if (p <= 0) return Math.round(v)
120
+ const f = 10 ** p
121
+ return Math.round(v * f) / f
122
+ }
123
+
124
+ const travel = (to: number) => {
125
+ stop()
126
+ const from = displayed.value
127
+ if (from === to) return
128
+
129
+ if (prefersReducedMotion() || props.duration <= 0) {
130
+ displayed.value = to
131
+ return
132
+ }
133
+
134
+ /*
135
+ * Compact notation rolls its own scale over, so it needs no padding; the plain
136
+ * and grouped forms do, because there the integer part is what grows.
137
+ *
138
+ * An interrupted flight must not SHRINK the row: a change arriving mid-travel
139
+ * starts from a value that is already wide, and recomputing the pad from
140
+ * scratch would drop columns that are still visibly spinning. The widest
141
+ * requirement so far wins until the number finally settles.
142
+ */
143
+ // Width, like precision, is measured against the endpoints — but here the
144
+ // live value counts too: a leg interrupted at 873 must not shed the column it
145
+ // is currently using just because both destinations are shorter.
146
+ padTo.value = props.compact
147
+ ? 0
148
+ : Math.max(padTo.value, integerDigits(from), integerDigits(to))
149
+ /*
150
+ * Precision comes from the DESTINATIONS, never from where the value happens
151
+ * to be right now. `from` is the live displayed value, and interrupting a
152
+ * flight mid-way — hovering a slice, flicking to empty space, back again —
153
+ * leaves it on something like 873.71. Reading its decimals then licensed two
154
+ * places for the whole next leg, so rapid hovering made fractions appear on a
155
+ * chart whose every figure is whole.
156
+ *
157
+ * The previous target is kept instead: whole → whole shows no fraction, and a
158
+ * genuinely fractional endpoint still keeps its places. An explicit `decimals`
159
+ * wins outright — a caller asking for two places wants them on every frame.
160
+ */
161
+ journeyDecimals.value = Math.max(
162
+ props.decimals,
163
+ decimalsOf(lastTarget),
164
+ decimalsOf(to),
165
+ )
166
+ lastTarget = to
167
+ settled.value = false
168
+
169
+ const started = performance.now()
170
+ const step = (now: number) => {
171
+ const t = Math.min(1, (now - started) / props.duration)
172
+ /*
173
+ * Two curves are at work and they must not both be hard. This one moves the
174
+ * VALUE, which is what makes the columns spin; the WHEELS then settle onto
175
+ * their digits on --ease-settle. A steep curve here is nearly done by the
176
+ * halfway point, so the spinning ends before it registers and the rest of
177
+ * the duration is a number that has stopped moving without having landed.
178
+ * The gentle shared curve keeps the columns turning the whole way and
179
+ * leaves the sense of arrival to the wheels, where it belongs.
180
+ */
181
+ displayed.value = from + (to - from) * easeOut(t)
182
+ if (t < 1) frame = requestAnimationFrame(step)
183
+ else {
184
+ // Land exactly: an eased fraction never quite reaches 1.
185
+ displayed.value = to
186
+ settled.value = true
187
+ padTo.value = 0
188
+ frame = 0
189
+ }
190
+ }
191
+ frame = requestAnimationFrame(step)
192
+ }
193
+
194
+ /*
195
+ * The count-up waits for the number to be on screen.
196
+ *
197
+ * `animateOnMount` says the figure should be seen ARRIVING at its value rather
198
+ * than simply being there — and a count-up that ran while the tile was below
199
+ * the fold delivers the opposite: the reader scrolls to a number that is
200
+ * already resting, having missed the one thing the prop was asking for. A stat
201
+ * tile is the common case, and a dashboard is a column of them.
202
+ *
203
+ * Later changes are not gated. A value that updates while the user is on the
204
+ * page is news, and it should roll whether or not it is in view — the reveal
205
+ * governs the FIRST reading only.
206
+ */
207
+ const { el: revealAnchor, revealed } = useReveal({ immediate: () => !props.animateOnMount })
208
+
209
+ watch(revealed, (visible) => {
210
+ if (visible && props.animateOnMount) travel(props.value)
211
+ }, { immediate: true })
212
+
213
+ watch(() => props.value, travel)
214
+ onBeforeUnmount(stop)
215
+
216
+ const UNITS = [
217
+ { limit: 1e9, suffix: "B" },
218
+ { limit: 1e6, suffix: "M" },
219
+ { limit: 1e3, suffix: "k" },
220
+ ] as const
221
+
222
+ const scaleFor = (v: number) => {
223
+ const abs = Math.abs(v)
224
+ const unit = UNITS.find((u) => abs >= u.limit)
225
+ if (!unit) return { divisor: 1, suffix: "", decimals: 0 }
226
+ // One decimal is what makes 1.2k readable; a value that lands exactly on the
227
+ // unit (5M) keeps none, matching how these are written by hand.
228
+ const scaled = v / unit.limit
229
+ return {
230
+ divisor: unit.limit,
231
+ suffix: unit.suffix,
232
+ decimals: Number.isInteger(scaled) ? 0 : 1,
233
+ }
234
+ }
235
+
236
+
237
+ const formatted = computed(() => {
238
+ // Rounded first: every branch below should render the shape the endpoints
239
+ // imply, not whatever precision an intermediate value happens to carry.
240
+ const v = round(displayed.value)
241
+ if (props.format) return props.format(v)
242
+ if (props.compact) {
243
+ /*
244
+ * The unit tracks the CURRENT value, not the destination — the way a real
245
+ * odometer rolls over into the next scale. Pinning it to the target instead
246
+ * prints "0M" for most of a climb from 42, and pinning it to the source
247
+ * prints "42000k"; following the value gives 42 → 999 → 1.0k → 999.9k →
248
+ * 1.0M, where every frame is a number someone would actually write.
249
+ *
250
+ * The decimal count is what stays fixed: one place whenever a unit is in
251
+ * play, so the row's shape only changes at a rollover, not on every frame.
252
+ */
253
+ const { divisor, suffix } = scaleFor(v)
254
+ const places = divisor === 1 ? 0 : 1
255
+ return `${(v / divisor).toFixed(places)}${suffix}`
256
+ }
257
+ const pad = settled.value ? 1 : Math.max(1, padTo.value)
258
+ if (props.grouped)
259
+ return v.toLocaleString(props.locale, {
260
+ minimumFractionDigits: props.decimals,
261
+ maximumFractionDigits: props.decimals,
262
+ minimumIntegerDigits: pad,
263
+ })
264
+
265
+ const text = v.toFixed(props.decimals)
266
+ if (pad <= 1) return text
267
+ // toFixed has no padding option; widen the integer part by hand.
268
+ const neg = text.startsWith("-")
269
+ const body = neg ? text.slice(1) : text
270
+ const dot = body.indexOf(".")
271
+ const int = dot === -1 ? body : body.slice(0, dot)
272
+ const rest = dot === -1 ? "" : body.slice(dot)
273
+ return `${neg ? "-" : ""}${int.padStart(pad, "0")}${rest}`
274
+ })
275
+
276
+ interface Cell {
277
+ /** Stable across renders so a column keeps its identity as digits shift. */
278
+ key: string
279
+ digit: number | null
280
+ char: string
281
+ /** A leading zero held open for a column the value hasn't reached yet. */
282
+ isPad: boolean
283
+ }
284
+
285
+ /*
286
+ * Cells are keyed by PLACE, not by position in the string, so that a column
287
+ * keeps its identity as the number grows: the ones place stays the ones place
288
+ * whether the value is 42 or 1,000,000, and only the columns that genuinely
289
+ * appear are animated in.
290
+ *
291
+ * The place is counted from the decimal point (or the end), skipping non-digits
292
+ * — grouping separators must not consume a place, or every column left of one
293
+ * would shift by one and re-animate on 999→1,000.
294
+ *
295
+ * Non-digits are keyed by their own character as well as their place. A compact
296
+ * suffix is the reason: 4.1k and 15.8k put "k" at different offsets, and a
297
+ * purely positional key let the old "k" inherit a digit's column — the number
298
+ * rendered as "11k.2k" for a frame mid-transition.
299
+ *
300
+ * The TRAILING run of non-digits is the exception: it is keyed by its role, not
301
+ * its text, so the unit occupies one column across a rollover. Keyed by
302
+ * character, "k" and "M" were two separate cells and both were on screen while
303
+ * one animated out — the row read "1654.3kM" crossing a million.
304
+ */
305
+ const cells = computed<Cell[]>(() => {
306
+ const chars = [...formatted.value]
307
+ const dot = chars.indexOf(".")
308
+ const intEnd = dot === -1 ? chars.length : dot
309
+
310
+ const isDigit = (c: string) => {
311
+ const code = c.charCodeAt(0)
312
+ return code >= 48 && code <= 57
313
+ }
314
+
315
+ // Where the trailing unit/suffix begins, if any.
316
+ let suffixStart = chars.length
317
+ while (suffixStart > 0 && !isDigit(chars[suffixStart - 1])) suffixStart--
318
+
319
+ // Integer places, right to left.
320
+ const keys: string[] = new Array(chars.length)
321
+ let place = 0
322
+ for (let i = intEnd - 1; i >= 0; i--) {
323
+ keys[i] = isDigit(chars[i]) ? `i${place++}` : `s${place}-${chars[i]}`
324
+ }
325
+ // Fraction places, left to right after the point.
326
+ place = 0
327
+ for (let i = intEnd; i < chars.length; i++) {
328
+ keys[i] = isDigit(chars[i]) ? `f${place++}` : `t${place}-${chars[i]}`
329
+ }
330
+ // The trailing unit keeps ONE column whatever it says, so "k" and "M" swap in
331
+ // place at a rollover instead of briefly sitting side by side.
332
+ for (let i = suffixStart; i < chars.length; i++) keys[i] = `u${i - suffixStart}`
333
+
334
+ // Padding zeros are structure, not value: they hold the column open while the
335
+ // number climbs into it, so they are dimmed rather than read as digits.
336
+ let leading = !settled.value
337
+ return chars.map((char, i) => {
338
+ const digit = isDigit(char) ? char.charCodeAt(0) - 48 : null
339
+ const isPad = leading && digit === 0 && i < intEnd - 1
340
+ if (digit !== null && digit !== 0) leading = false
341
+ return { key: keys[i], digit, char, isPad }
342
+ })
343
+ })
344
+
345
+ /*
346
+ * The strip holds 0–9 twice. Which copy a digit points at decides the travel
347
+ * direction: staying inside one copy rolls the short way, stepping into the
348
+ * second copy always rolls forward — the way an odometer wheel never spins
349
+ * backwards through the 9.
350
+ */
351
+ const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
352
+ const STRIP = [...DIGITS, ...DIGITS]
353
+
354
+ const offsetFor = (digit: number) => {
355
+ if (props.trend === "up") return digit
356
+ if (props.trend === "down") return digit + 10
357
+ return digit
358
+ }
359
+
360
+ const label = computed(
361
+ () => `${props.prefix ?? ""}${props.value}${props.suffix ?? ""}`,
362
+ )
363
+ </script>
364
+
365
+ <template>
366
+ <!-- One accessible reading of the real value; the moving parts are hidden
367
+ from assistive tech, which would otherwise announce every frame. -->
368
+ <span
369
+ ref="revealAnchor"
370
+ class="lp-nf inline-flex items-baseline tabular-nums"
371
+ :aria-label="label"
372
+ role="text"
373
+ >
374
+ <span v-if="prefix" aria-hidden="true">{{ prefix }}</span>
375
+
376
+ <span class="lp-nf__track inline-flex items-baseline" aria-hidden="true">
377
+ <TransitionGroup name="lp-nf-cell">
378
+ <span
379
+ v-for="cell in cells"
380
+ :key="cell.key"
381
+ class="lp-nf__cell"
382
+ :class="cell.isPad ? 'lp-nf__cell--pad' : ''"
383
+ >
384
+ <!-- A digit is a window onto its strip; a separator is just a glyph. -->
385
+ <template v-if="cell.digit !== null">
386
+ <span
387
+ class="lp-nf__strip"
388
+ :style="{ transform: `translateY(${-offsetFor(cell.digit) * 5}%)` }"
389
+ >
390
+ <span v-for="(d, i) in STRIP" :key="i" class="lp-nf__digit">{{ d }}</span>
391
+ </span>
392
+ <!-- Reserves the column's width without being visible: the strip is
393
+ absolutely positioned and would otherwise collapse the cell. -->
394
+ <span class="lp-nf__ghost">0</span>
395
+ </template>
396
+ <template v-else>{{ cell.char }}</template>
397
+ </span>
398
+ </TransitionGroup>
399
+ </span>
400
+
401
+ <span v-if="suffix" aria-hidden="true">{{ suffix }}</span>
402
+ </span>
403
+ </template>
404
+
405
+ <style>
406
+ /*
407
+ * Unscoped: TransitionGroup applies its classes to elements Vue moves and
408
+ * clones, which a scoped attribute selector doesn't reliably reach. Everything
409
+ * is namespaced under .lp-nf.
410
+ */
411
+ /*
412
+ * Fades the top and bottom of every column, so a digit rolls in and out of a
413
+ * window rather than appearing at a hard edge.
414
+ *
415
+ * In PIXELS, not percentages. A percentage fade scales with the row: at 18% it
416
+ * ate 2.4px off a 13.2px line, which on small text read as a shadow lying over
417
+ * the digits rather than as a window they pass through. A fixed 2px looks the
418
+ * same at every size, and only ever touches the sliver where a digit is
419
+ * genuinely entering or leaving.
420
+ */
421
+ .lp-nf__track {
422
+ --lp-nf-fade: 2px;
423
+ -webkit-mask-image: linear-gradient(
424
+ to bottom,
425
+ transparent 0,
426
+ #000 var(--lp-nf-fade),
427
+ #000 calc(100% - var(--lp-nf-fade)),
428
+ transparent 100%
429
+ );
430
+ mask-image: linear-gradient(
431
+ to bottom,
432
+ transparent 0,
433
+ #000 var(--lp-nf-fade),
434
+ #000 calc(100% - var(--lp-nf-fade)),
435
+ transparent 100%
436
+ );
437
+ }
438
+
439
+ /*
440
+ * The cell INHERITS its line box rather than declaring one. A fixed 1.1 made
441
+ * the digits 13.2px tall next to 16px text in the same row, so the two sat on
442
+ * different baselines and the number read as floating above the words beside
443
+ * it. Sharing the inherited line-height puts them on one baseline at any size.
444
+ */
445
+ .lp-nf__cell {
446
+ position: relative;
447
+ display: inline-block;
448
+ overflow: hidden;
449
+ /* A formatter may put a space before its unit ("412 GB"); inline-flex would
450
+ collapse it away, gluing the unit to the number. */
451
+ white-space: pre;
452
+ }
453
+
454
+ /*
455
+ * The strip is as tall as its 20 entries and hangs from the top of the cell —
456
+ * NOT stretched to the cell's height, or each entry would be a twentieth of a
457
+ * line tall. Percentages on `translateY` resolve against the strip's own
458
+ * height, so one entry is 5% of it: digit N sits at -N × 5%.
459
+ */
460
+ .lp-nf__strip {
461
+ position: absolute;
462
+ top: 0;
463
+ left: 0;
464
+ right: 0;
465
+ display: flex;
466
+ flex-direction: column;
467
+ /* Per-instance, so a counter whose VALUE is already being animated from
468
+ outside can shorten the wheel's own spin and stop lagging behind it. */
469
+ /* Settles rather than stops: the wheel carries a hair past its digit and
470
+ drifts back, so a column reads as a mechanism coming to rest. */
471
+ transition: transform var(--lp-nf-spin, var(--duration-slow, 320ms))
472
+ var(--ease-settle);
473
+ will-change: transform;
474
+ }
475
+
476
+ /* Each strip entry is exactly one line box tall, so translating by -N × 5%
477
+ (one twentieth of the 20-entry strip) lands digit N in the window. */
478
+ .lp-nf__digit {
479
+ line-height: inherit;
480
+ }
481
+
482
+ .lp-nf__ghost {
483
+ visibility: hidden;
484
+ }
485
+
486
+ /* Faded, so a held-open column reads as an empty wheel rather than a zero the
487
+ number actually has. */
488
+ .lp-nf__cell--pad {
489
+ opacity: 0.18;
490
+ transition: opacity var(--duration-fast, 160ms) linear;
491
+ }
492
+
493
+ /*
494
+ * Columns fade rather than grow. Animating `max-width` here was the cause of
495
+ * the row jittering whenever the digit count changed: the width tween fought
496
+ * the layout while the value was still climbing, so the whole number shuffled
497
+ * sideways and every wheel restarted. The padding above now reserves the space
498
+ * up front, which leaves these transitions with only the appearance of a
499
+ * column to handle — and that is a fade.
500
+ */
501
+ .lp-nf-cell-enter-active,
502
+ .lp-nf-cell-leave-active {
503
+ transition: opacity var(--duration-fast, 160ms) linear;
504
+ }
505
+
506
+ .lp-nf-cell-enter-from,
507
+ .lp-nf-cell-leave-to {
508
+ opacity: 0;
509
+ }
510
+
511
+ /* Out of flow while leaving, so the settled row closes up without a reflow
512
+ jump at the end of the transition. */
513
+ .lp-nf-cell-leave-active {
514
+ position: absolute;
515
+ }
516
+
517
+ @media (prefers-reduced-motion: reduce) {
518
+ .lp-nf__strip,
519
+ .lp-nf-cell-enter-active,
520
+ .lp-nf-cell-leave-active {
521
+ transition: none;
522
+ }
523
+ }
524
+ </style>