@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.
- package/dist/assets/LpNumberFlow-rZkwQwlU.css +116 -0
- package/dist/assets/LpRollingText-CR1mb0Vt.css +74 -0
- package/dist/assets/LpSparkline-0mKTq7pb.css +51 -0
- package/dist/assets/LpTooltip-B61SYd4K.css +17 -0
- package/dist/assets/LpUptimeBar-B98o9krI.css +18 -0
- package/dist/chunks/{LpAppShell-CaQYQKZV.js → LpAppShell-B5k0J3Cf.js} +1 -1
- package/dist/chunks/LpChart-tNXs1ttr.js +671 -0
- package/dist/chunks/{LpCodeBlock-CSiOSPs8.js → LpCodeBlock-rS_6mO3D.js} +1 -1
- package/dist/chunks/{LpConfirmDialog-CObRah9c.js → LpConfirmDialog-CjOGcj1e.js} +32 -4
- package/dist/chunks/{LpDisclosure-DP6yfgfM.js → LpDisclosure-BnwP4Q-b.js} +1 -1
- package/dist/chunks/{LpFileTree-nU1wRRxZ.js → LpFileTree-BaV7f0O7.js} +1 -1
- package/dist/chunks/{LpFileTreeNode-D7WgUPcJ.js → LpFileTreeNode-BzpBa7R7.js} +6 -8
- package/dist/chunks/{LpLogViewer-B1aLUVdz.js → LpLogViewer-CcQ3s3Fp.js} +2 -1
- package/dist/chunks/{LpMapPicker-DFKrd1Ne.js → LpMapPicker-CXvoTrAe.js} +2 -4
- package/dist/chunks/{LpNotificationBell-X1M30e0X.js → LpNotificationBell-Dvu-b6Vn.js} +56 -27
- package/dist/chunks/LpNumberFlow-amWYaeuA.js +221 -0
- package/dist/chunks/LpPie-BkjWlfSw.js +525 -0
- package/dist/chunks/LpProgress-DoIP4jQR.js +198 -0
- package/dist/chunks/LpRollingText-CM5gkSeF.js +50 -0
- package/dist/chunks/{LpSidebar-D3GGLSE7.js → LpSidebar-7Vpy2IJc.js} +1 -1
- package/dist/chunks/{LpSidebarNav-X14xMdPa.js → LpSidebarNav-CFX5NxGs.js} +1 -1
- package/dist/chunks/LpSparkline-C6xQ2dwv.js +175 -0
- package/dist/chunks/{LpStat-CdbEFOvn.js → LpStat-CDanpzU0.js} +43 -5
- package/dist/chunks/{LpThemeSwitcher-BgpJgrnP.js → LpThemeSwitcher-7mAK6bI7.js} +2 -4
- package/dist/chunks/LpTooltip-hCLPAqMG.js +131 -0
- package/dist/chunks/LpUptimeBar-DcQ8HikG.js +152 -0
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpChart.vue.d.ts +88 -0
- package/dist/components/LpChart.vue.js +4 -0
- package/dist/components/LpCodeBlock.vue.js +1 -1
- package/dist/components/LpConfirmDialog.vue.d.ts +18 -2
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpFileTree.vue.d.ts +1 -1
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpLightbox.vue.d.ts +1 -1
- package/dist/components/LpLogViewer.vue.d.ts +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.d.ts +12 -0
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpNumberFlow.vue.d.ts +35 -0
- package/dist/components/LpNumberFlow.vue.js +4 -0
- package/dist/components/LpPie.vue.d.ts +134 -0
- package/dist/components/LpPie.vue.js +4 -0
- package/dist/components/LpProgress.vue.d.ts +56 -2
- package/dist/components/LpProgress.vue.js +1 -1
- package/dist/components/LpRollingText.vue.d.ts +33 -0
- package/dist/components/LpRollingText.vue.js +4 -0
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpSparkline.vue.d.ts +48 -0
- package/dist/components/LpSparkline.vue.js +4 -0
- package/dist/components/LpStat.vue.d.ts +25 -2
- package/dist/components/LpStat.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/LpTooltip.vue.d.ts +24 -3
- package/dist/components/LpTooltip.vue.js +1 -1
- package/dist/components/LpUptimeBar.vue.js +1 -1
- package/dist/components/chart.d.ts +180 -0
- package/dist/components/chart.js +353 -0
- package/dist/composables/easing.d.ts +46 -0
- package/dist/composables/easing.js +23 -0
- package/dist/composables/usePillTransition.d.ts +13 -1
- package/dist/composables/usePillTransition.js +22 -2
- package/dist/composables/useReveal.d.ts +26 -0
- package/dist/composables/useReveal.js +45 -0
- package/dist/composables/useSettled.js +2 -2
- package/dist/composables/useShift.js +2 -4
- package/dist/index.d.ts +14 -0
- package/dist/index.js +126 -103
- package/package.json +2 -1
- package/src/component-names.ts +5 -0
- package/src/components/LpChart.vue +846 -0
- package/src/components/LpCodeBlock.vue +1 -1
- package/src/components/LpConfirmDialog.vue +49 -0
- package/src/components/LpDisclosure.vue +1 -1
- package/src/components/LpFileTreeNode.vue +9 -10
- package/src/components/LpLogViewer.vue +2 -3
- package/src/components/LpMapPicker.vue +2 -5
- package/src/components/LpNotificationBell.vue +76 -15
- package/src/components/LpNumberFlow.vue +524 -0
- package/src/components/LpPie.vue +778 -0
- package/src/components/LpProgress.vue +283 -6
- package/src/components/LpRollingText.vue +157 -0
- package/src/components/LpSidebarNav.vue +1 -1
- package/src/components/LpSparkline.vue +312 -0
- package/src/components/LpStat.vue +52 -4
- package/src/components/LpTooltip.vue +185 -3
- package/src/components/LpUptimeBar.vue +150 -53
- package/src/components/chart.ts +686 -0
- package/src/composables/easing.ts +88 -0
- package/src/composables/usePillTransition.ts +71 -6
- package/src/composables/useReveal.ts +113 -0
- package/src/composables/useSettled.ts +2 -4
- package/src/composables/useShift.ts +2 -8
- package/src/index.ts +19 -0
- package/src/theme/useTheme.ts +2 -8
- package/src/tokens/tokens.css +36 -0
- package/dist/chunks/LpProgress-BhI0IbjC.js +0 -37
- package/dist/chunks/LpTooltip-D1hbjQWG.js +0 -63
- 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>
|