@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
|
@@ -1,29 +1,306 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* One determinate value, drawn as a bar or as a ring. Both are the same data —
|
|
4
|
+
* a fraction of a whole — so they live in one component rather than two: a
|
|
5
|
+
* caller switching a KPI tile from a bar to a dial changes `variant`, not which
|
|
6
|
+
* component it imports.
|
|
7
|
+
*
|
|
8
|
+
* The ring is the bar wrapped into a circle: same tones, same clamping, same
|
|
9
|
+
* reka ProgressRoot underneath (so assistive tech reads a progressbar with a
|
|
10
|
+
* value either way).
|
|
11
|
+
*/
|
|
2
12
|
import { ProgressIndicator, ProgressRoot } from "reka-ui"
|
|
3
|
-
import { computed } from "vue"
|
|
13
|
+
import { computed, onBeforeUnmount, ref, watch } from "vue"
|
|
14
|
+
import { easeOut, prefersReducedMotion } from "../composables/easing"
|
|
15
|
+
import { useReveal } from "../composables/useReveal"
|
|
16
|
+
import LpNumberFlow from "./LpNumberFlow.vue"
|
|
4
17
|
|
|
5
18
|
const props = withDefaults(
|
|
6
|
-
defineProps<{
|
|
7
|
-
|
|
19
|
+
defineProps<{
|
|
20
|
+
value?: number
|
|
21
|
+
max?: number
|
|
22
|
+
/**
|
|
23
|
+
* `"auto"` colours by how full the bar is — calm up to `warnAt`, accent
|
|
24
|
+
* past it, danger past `dangerAt`.
|
|
25
|
+
*
|
|
26
|
+
* A gauge of a finite resource (disk, memory, quota) means something
|
|
27
|
+
* different at 92% than at 12%, and consumers were each re-deriving that
|
|
28
|
+
* with their own palette — which also put raw colours back into components
|
|
29
|
+
* the theme can no longer reach.
|
|
30
|
+
*/
|
|
31
|
+
tone?: "brand" | "action" | "danger" | "accent" | "auto"
|
|
32
|
+
/** `auto` tone: the fill fraction, 0–1, where the bar turns accent. */
|
|
33
|
+
warnAt?: number
|
|
34
|
+
/** `auto` tone: the fill fraction, 0–1, where it turns danger. */
|
|
35
|
+
dangerAt?: number
|
|
36
|
+
/**
|
|
37
|
+
* Bar only: a name for what is being measured, printed above the bar. With
|
|
38
|
+
* `caption` it forms the "CPU … 42%" header a usage row wants.
|
|
39
|
+
*/
|
|
40
|
+
title?: string
|
|
41
|
+
/**
|
|
42
|
+
* Bar only: the reading, printed opposite `title`. `true` prints the
|
|
43
|
+
* rounded percentage; a string says it in the units that matter
|
|
44
|
+
* ("23 GB / 64 GB"), which a percentage alone cannot.
|
|
45
|
+
*/
|
|
46
|
+
caption?: boolean | string
|
|
47
|
+
/** `bar` (default) fills horizontally; `ring` draws a dial. */
|
|
48
|
+
variant?: "bar" | "ring"
|
|
49
|
+
/** Ring only: outer diameter in px. */
|
|
50
|
+
size?: number
|
|
51
|
+
/** Ring only: stroke width in px. */
|
|
52
|
+
thickness?: number
|
|
53
|
+
/**
|
|
54
|
+
* Ring only: print the rounded percentage in the middle. Pass a string to
|
|
55
|
+
* label it with something else ("12.4k", "3/8") — the ring shows progress,
|
|
56
|
+
* the caption says what it is progress of.
|
|
57
|
+
*/
|
|
58
|
+
label?: boolean | string
|
|
59
|
+
/**
|
|
60
|
+
* Grow from empty on first render instead of appearing already filled.
|
|
61
|
+
*
|
|
62
|
+
* A dial that arrives complete states a fact; one that fills shows a
|
|
63
|
+
* quantity being measured, and the eye follows the sweep to where it stops —
|
|
64
|
+
* which is the number the tile is actually about. Both variants already ease
|
|
65
|
+
* every later change, so this only concerns the first paint.
|
|
66
|
+
*/
|
|
67
|
+
animateOnMount?: boolean
|
|
68
|
+
/** How long the fill takes, in ms. The counter in a ring matches it. */
|
|
69
|
+
duration?: number
|
|
70
|
+
}>(),
|
|
71
|
+
{
|
|
72
|
+
value: 0,
|
|
73
|
+
max: 100,
|
|
74
|
+
tone: "brand",
|
|
75
|
+
variant: "bar",
|
|
76
|
+
size: 44,
|
|
77
|
+
thickness: 4,
|
|
78
|
+
animateOnMount: true,
|
|
79
|
+
// A resource is comfortable well past half, uncomfortable in the last
|
|
80
|
+
// fifth, and urgent in the last tenth — the thresholds a capacity gauge is
|
|
81
|
+
// read against, rather than an even split of the range.
|
|
82
|
+
warnAt: 0.8,
|
|
83
|
+
dangerAt: 0.9,
|
|
84
|
+
// Slower than the kit's `slow` step: a gauge filling is content, not state
|
|
85
|
+
// feedback, and at 320ms the sweep was over before it was noticed.
|
|
86
|
+
duration: 900,
|
|
87
|
+
},
|
|
8
88
|
)
|
|
9
89
|
|
|
10
|
-
|
|
90
|
+
/*
|
|
91
|
+
* The arc and the counter are driven by ONE animated value rather than by two
|
|
92
|
+
* animations of the same length.
|
|
93
|
+
*
|
|
94
|
+
* Matching their durations is not enough: a CSS transition and LpNumberFlow's
|
|
95
|
+
* own easing decelerate on different curves, so the ring ran ahead of the
|
|
96
|
+
* figure through the middle of the sweep — and shortening one merely moved the
|
|
97
|
+
* gap to the other end. Interpolating here and handing both the same number
|
|
98
|
+
* keeps them exactly level, because there is only one number.
|
|
99
|
+
*/
|
|
100
|
+
const eased = ref(props.animateOnMount ? 0 : 1)
|
|
101
|
+
let raf = 0
|
|
102
|
+
|
|
103
|
+
/* Where the current sweep began, so one interrupted mid-way continues from the
|
|
104
|
+
arc's present position rather than snapping back to empty. */
|
|
105
|
+
let from = props.animateOnMount ? 0 : props.value
|
|
106
|
+
|
|
107
|
+
const runSweep = (ms: number) => {
|
|
108
|
+
if (raf) cancelAnimationFrame(raf)
|
|
109
|
+
if (prefersReducedMotion() || ms <= 0) {
|
|
110
|
+
eased.value = 1
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const t0 = performance.now()
|
|
114
|
+
const step = (now: number) => {
|
|
115
|
+
const t = Math.min(1, (now - t0) / ms)
|
|
116
|
+
// The kit's shared curve — the same function LpNumberFlow eases on, so the
|
|
117
|
+
// arc and the counter agree by construction rather than by comment.
|
|
118
|
+
eased.value = easeOut(t)
|
|
119
|
+
raf = t < 1 ? requestAnimationFrame(step) : 0
|
|
120
|
+
}
|
|
121
|
+
raf = requestAnimationFrame(step)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onBeforeUnmount(() => {
|
|
125
|
+
if (raf) cancelAnimationFrame(raf)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
/*
|
|
129
|
+
* The sweep starts when the ring is looked at, not when it is built. A row of
|
|
130
|
+
* these on a dashboard mounts all at once; started on mount, every one below
|
|
131
|
+
* the fold is already sitting at its final value by the time it is scrolled to,
|
|
132
|
+
* and the sweep — the whole point of a ring filling up — is never seen.
|
|
133
|
+
*
|
|
134
|
+
* reka's ProgressRoot is a component, so the ref holds an instance; take its
|
|
135
|
+
* host element to observe.
|
|
136
|
+
*/
|
|
137
|
+
const { el: revealAnchor, revealed } = useReveal({ immediate: () => !props.animateOnMount })
|
|
138
|
+
|
|
139
|
+
const bindReveal = (instance: unknown) => {
|
|
140
|
+
revealAnchor.value = (instance as { $el?: Element } | null)?.$el ?? null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
watch(revealed, (visible) => {
|
|
144
|
+
if (visible && props.animateOnMount) runSweep(props.duration)
|
|
145
|
+
}, { immediate: true })
|
|
146
|
+
|
|
147
|
+
/* A later change re-runs the sweep from where it stands, so the ring eases to
|
|
148
|
+
its new value instead of jumping. */
|
|
149
|
+
watch(
|
|
150
|
+
() => [props.value, props.max],
|
|
151
|
+
() => {
|
|
152
|
+
from = shown.value
|
|
153
|
+
runSweep(props.duration)
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
/** The value both the arc and the counter render: one interpolation, one truth. */
|
|
158
|
+
const shown = computed(() => from + (props.value - from) * eased.value)
|
|
159
|
+
|
|
160
|
+
const pct = computed(() =>
|
|
161
|
+
Math.max(0, Math.min(100, (shown.value / props.max) * 100)),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
/*
|
|
165
|
+
* The tone actually painted. `auto` reads the fill and crosses two thresholds;
|
|
166
|
+
* every other value passes straight through.
|
|
167
|
+
*
|
|
168
|
+
* Thresholds are compared against the TARGET, not the animated position, so a
|
|
169
|
+
* bar does not change colour partway through its own sweep — it fills in the
|
|
170
|
+
* colour its reading warrants.
|
|
171
|
+
*/
|
|
172
|
+
const paintedTone = computed(() => {
|
|
173
|
+
if (props.tone !== "auto") return props.tone
|
|
174
|
+
const fraction = props.max > 0 ? props.value / props.max : 0
|
|
175
|
+
if (fraction >= props.dangerAt) return "danger"
|
|
176
|
+
if (fraction >= props.warnAt) return "accent"
|
|
177
|
+
return "action"
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const captionText = computed(() => {
|
|
181
|
+
if (props.caption === undefined || props.caption === false) return ""
|
|
182
|
+
return typeof props.caption === "string" ? props.caption : `${Math.round(pct.value)}%`
|
|
183
|
+
})
|
|
184
|
+
|
|
11
185
|
const bar: Record<string, string> = {
|
|
12
186
|
brand: "bg-brand",
|
|
13
187
|
action: "bg-action",
|
|
14
188
|
danger: "bg-danger",
|
|
189
|
+
accent: "bg-accent",
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const stroke: Record<string, string> = {
|
|
193
|
+
brand: "var(--color-brand)",
|
|
194
|
+
action: "var(--color-action)",
|
|
195
|
+
danger: "var(--color-danger)",
|
|
196
|
+
accent: "var(--color-accent)",
|
|
15
197
|
}
|
|
198
|
+
|
|
199
|
+
/*
|
|
200
|
+
* The arc is drawn by dashing the circumference: one dash as long as the filled
|
|
201
|
+
* fraction, one gap covering the rest. Rotated -90° so it starts at twelve
|
|
202
|
+
* o'clock, which is where a dial is read from.
|
|
203
|
+
*/
|
|
204
|
+
const radius = computed(() => (props.size - props.thickness) / 2)
|
|
205
|
+
const circumference = computed(() => 2 * Math.PI * radius.value)
|
|
206
|
+
|
|
207
|
+
/*
|
|
208
|
+
* Round caps add half a stroke at each end, so a nearly-full arc closes its own
|
|
209
|
+
* gap and 97% renders as a solid ring — indistinguishable from done. Hold the
|
|
210
|
+
* arc short of the seam unless the value really is complete.
|
|
211
|
+
*/
|
|
212
|
+
const dash = computed(() => {
|
|
213
|
+
const full = circumference.value
|
|
214
|
+
const raw = (pct.value / 100) * full
|
|
215
|
+
if (pct.value >= 100) return full
|
|
216
|
+
return Math.min(raw, full - props.thickness * 1.5)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
/* A string label is verbatim — it isn't a number and has nothing to count. */
|
|
220
|
+
const staticCaption = computed(() =>
|
|
221
|
+
typeof props.label === "string" ? props.label : null,
|
|
222
|
+
)
|
|
16
223
|
</script>
|
|
17
224
|
|
|
18
225
|
<template>
|
|
19
226
|
<ProgressRoot
|
|
227
|
+
v-if="variant === 'ring'"
|
|
228
|
+
:ref="bindReveal"
|
|
229
|
+
:model-value="value"
|
|
230
|
+
:max="max"
|
|
231
|
+
class="relative inline-flex shrink-0 items-center justify-center"
|
|
232
|
+
:style="{ width: `${size}px`, height: `${size}px` }"
|
|
233
|
+
>
|
|
234
|
+
<svg :width="size" :height="size" class="-rotate-90">
|
|
235
|
+
<circle
|
|
236
|
+
:cx="size / 2"
|
|
237
|
+
:cy="size / 2"
|
|
238
|
+
:r="radius"
|
|
239
|
+
fill="none"
|
|
240
|
+
stroke="var(--color-surface-soft)"
|
|
241
|
+
:stroke-width="thickness"
|
|
242
|
+
/>
|
|
243
|
+
<circle
|
|
244
|
+
:cx="size / 2"
|
|
245
|
+
:cy="size / 2"
|
|
246
|
+
:r="radius"
|
|
247
|
+
fill="none"
|
|
248
|
+
:stroke="stroke[paintedTone]"
|
|
249
|
+
:stroke-width="thickness"
|
|
250
|
+
stroke-linecap="round"
|
|
251
|
+
:stroke-dasharray="`${dash} ${circumference - dash}`"
|
|
252
|
+
/>
|
|
253
|
+
</svg>
|
|
254
|
+
<!-- Reads the same interpolated value as the arc — no animation of its own,
|
|
255
|
+
or the two would drift apart again. -->
|
|
256
|
+
<span
|
|
257
|
+
v-if="label !== false && label !== undefined"
|
|
258
|
+
class="absolute font-mono text-[11px] font-medium text-ink"
|
|
259
|
+
>
|
|
260
|
+
<template v-if="staticCaption">{{ staticCaption }}</template>
|
|
261
|
+
<LpNumberFlow
|
|
262
|
+
v-else
|
|
263
|
+
:value="pct"
|
|
264
|
+
suffix="%"
|
|
265
|
+
:duration="0"
|
|
266
|
+
:animate-on-mount="false"
|
|
267
|
+
:style="{ '--lp-nf-spin': '90ms' }"
|
|
268
|
+
/>
|
|
269
|
+
</span>
|
|
270
|
+
</ProgressRoot>
|
|
271
|
+
|
|
272
|
+
<!-- Wrapped only when there is a header to carry. A bare bar stays the single
|
|
273
|
+
element it has always been, so an existing caller's layout is untouched. -->
|
|
274
|
+
<div v-else-if="title || captionText" class="flex w-full flex-col gap-1">
|
|
275
|
+
<div class="flex items-baseline justify-between gap-2 text-xs">
|
|
276
|
+
<span class="text-muted">{{ title }}</span>
|
|
277
|
+
<!-- Tabular, so a figure ticking upward doesn't shuffle its own width. -->
|
|
278
|
+
<span class="font-mono tabular-nums text-muted-strong">{{ captionText }}</span>
|
|
279
|
+
</div>
|
|
280
|
+
<ProgressRoot
|
|
281
|
+
:ref="bindReveal"
|
|
282
|
+
:model-value="value"
|
|
283
|
+
:max="max"
|
|
284
|
+
class="h-1.5 w-full overflow-hidden rounded-pill bg-surface-soft"
|
|
285
|
+
>
|
|
286
|
+
<ProgressIndicator
|
|
287
|
+
class="h-full rounded-pill transition-colors duration-medium"
|
|
288
|
+
:class="bar[paintedTone]"
|
|
289
|
+
:style="{ width: `${pct}%` }"
|
|
290
|
+
/>
|
|
291
|
+
</ProgressRoot>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<ProgressRoot
|
|
295
|
+
v-else
|
|
296
|
+
:ref="bindReveal"
|
|
20
297
|
:model-value="value"
|
|
21
298
|
:max="max"
|
|
22
299
|
class="h-1.5 w-full overflow-hidden rounded-pill bg-surface-soft"
|
|
23
300
|
>
|
|
24
301
|
<ProgressIndicator
|
|
25
|
-
class="h-full rounded-pill transition-
|
|
26
|
-
:class="bar[
|
|
302
|
+
class="h-full rounded-pill transition-colors duration-medium"
|
|
303
|
+
:class="bar[paintedTone]"
|
|
27
304
|
:style="{ width: `${pct}%` }"
|
|
28
305
|
/>
|
|
29
306
|
</ProgressRoot>
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Text that CHANGES rather than being replaced.
|
|
4
|
+
*
|
|
5
|
+
* Swapping a label in place gives the reader nothing: they see the new value
|
|
6
|
+
* and have no idea it used to be something else. Moving the old one out while
|
|
7
|
+
* the new one arrives makes the change itself visible — which is the whole
|
|
8
|
+
* point in a spot that updates while being watched: a donut's centre following
|
|
9
|
+
* the hovered slice, a live counter's caption, a status line.
|
|
10
|
+
*
|
|
11
|
+
* Two motions, because they say different things:
|
|
12
|
+
*
|
|
13
|
+
* "slide" — the old line leaves upward, the new one arrives from below (or
|
|
14
|
+
* the reverse, per `direction`). Reads as a reel advancing: same kind of
|
|
15
|
+
* thing, next item. The default.
|
|
16
|
+
* "flip" — the box turns on its X axis, the old face rotating away and the
|
|
17
|
+
* new one turning in behind it. Reads as one object showing another side;
|
|
18
|
+
* heavier, so it suits a headline that changes rarely.
|
|
19
|
+
*
|
|
20
|
+
* <LpRollingText :value="label" />
|
|
21
|
+
* <LpRollingText :value="status" mode="flip" />
|
|
22
|
+
*/
|
|
23
|
+
import { computed } from "vue"
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(
|
|
26
|
+
defineProps<{
|
|
27
|
+
/** The text. A change to this is what triggers the transition. */
|
|
28
|
+
value: string | number
|
|
29
|
+
mode?: "slide" | "flip"
|
|
30
|
+
/** Which way "slide" travels. `up` moves the old text up and out. */
|
|
31
|
+
direction?: "up" | "down"
|
|
32
|
+
duration?: number
|
|
33
|
+
/**
|
|
34
|
+
* Alignment inside the clipped box, for when the two values differ in
|
|
35
|
+
* width — a value that grows shouldn't shove its neighbours.
|
|
36
|
+
*/
|
|
37
|
+
align?: "start" | "center" | "end"
|
|
38
|
+
}>(),
|
|
39
|
+
{ mode: "slide", direction: "up", duration: 320, align: "center" },
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
/* The key is what makes Vue treat a new value as a new element rather than a
|
|
43
|
+
text patch — without it there is nothing to transition between. */
|
|
44
|
+
const key = computed(() => String(props.value))
|
|
45
|
+
|
|
46
|
+
const name = computed(() =>
|
|
47
|
+
props.mode === "flip" ? "lp-roll-flip" : `lp-roll-${props.direction}`,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const justify = computed(
|
|
51
|
+
() =>
|
|
52
|
+
({ start: "justify-start", center: "justify-center", end: "justify-end" })[props.align],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const style = computed(() => ({
|
|
56
|
+
"--lp-roll-duration": `${props.duration}ms`,
|
|
57
|
+
// The leaving copy is taken out of flow so both can occupy the same line;
|
|
58
|
+
// without this the box would grow to hold two lines mid-transition.
|
|
59
|
+
"--lp-roll-perspective": props.mode === "flip" ? "300px" : "none",
|
|
60
|
+
}))
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<template>
|
|
64
|
+
<span
|
|
65
|
+
class="lp-roll relative inline-flex overflow-hidden align-bottom"
|
|
66
|
+
:class="justify"
|
|
67
|
+
:style="style"
|
|
68
|
+
>
|
|
69
|
+
<Transition :name="name">
|
|
70
|
+
<span :key="key" class="lp-roll__item">
|
|
71
|
+
<slot :value="value">{{ value }}</slot>
|
|
72
|
+
</span>
|
|
73
|
+
</Transition>
|
|
74
|
+
</span>
|
|
75
|
+
</template>
|
|
76
|
+
|
|
77
|
+
<style>
|
|
78
|
+
/*
|
|
79
|
+
* Unscoped on purpose: the transition classes are applied to the element Vue
|
|
80
|
+
* clones during the leave phase, which a scoped attribute selector doesn't
|
|
81
|
+
* reliably reach. Everything is namespaced under .lp-roll instead.
|
|
82
|
+
*/
|
|
83
|
+
.lp-roll {
|
|
84
|
+
perspective: var(--lp-roll-perspective);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.lp-roll__item {
|
|
88
|
+
display: inline-block;
|
|
89
|
+
white-space: nowrap;
|
|
90
|
+
backface-visibility: hidden;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* The outgoing copy leaves the flow so the incoming one keeps the baseline. */
|
|
94
|
+
.lp-roll-up-leave-active,
|
|
95
|
+
.lp-roll-down-leave-active,
|
|
96
|
+
.lp-roll-flip-leave-active {
|
|
97
|
+
position: absolute;
|
|
98
|
+
inset: 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.lp-roll-up-enter-active,
|
|
102
|
+
.lp-roll-up-leave-active,
|
|
103
|
+
.lp-roll-down-enter-active,
|
|
104
|
+
.lp-roll-down-leave-active {
|
|
105
|
+
transition:
|
|
106
|
+
transform var(--lp-roll-duration) var(--ease-emphasized),
|
|
107
|
+
opacity var(--lp-roll-duration) var(--ease-emphasized);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.lp-roll-up-enter-from {
|
|
111
|
+
transform: translateY(100%);
|
|
112
|
+
opacity: 0;
|
|
113
|
+
}
|
|
114
|
+
.lp-roll-up-leave-to {
|
|
115
|
+
transform: translateY(-100%);
|
|
116
|
+
opacity: 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.lp-roll-down-enter-from {
|
|
120
|
+
transform: translateY(-100%);
|
|
121
|
+
opacity: 0;
|
|
122
|
+
}
|
|
123
|
+
.lp-roll-down-leave-to {
|
|
124
|
+
transform: translateY(100%);
|
|
125
|
+
opacity: 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.lp-roll-flip-enter-active,
|
|
129
|
+
.lp-roll-flip-leave-active {
|
|
130
|
+
transition:
|
|
131
|
+
transform var(--lp-roll-duration) var(--ease-emphasized),
|
|
132
|
+
opacity calc(var(--lp-roll-duration) * 0.6) linear;
|
|
133
|
+
transform-origin: center center;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* Quarter turns, not halves: the two faces meet edge-on at the midpoint, so
|
|
137
|
+
the box reads as turning rather than as one label briefly vanishing. */
|
|
138
|
+
.lp-roll-flip-enter-from {
|
|
139
|
+
transform: rotateX(-90deg);
|
|
140
|
+
opacity: 0;
|
|
141
|
+
}
|
|
142
|
+
.lp-roll-flip-leave-to {
|
|
143
|
+
transform: rotateX(90deg);
|
|
144
|
+
opacity: 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@media (prefers-reduced-motion: reduce) {
|
|
148
|
+
.lp-roll-up-enter-active,
|
|
149
|
+
.lp-roll-up-leave-active,
|
|
150
|
+
.lp-roll-down-enter-active,
|
|
151
|
+
.lp-roll-down-leave-active,
|
|
152
|
+
.lp-roll-flip-enter-active,
|
|
153
|
+
.lp-roll-flip-leave-active {
|
|
154
|
+
transition: none;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
</style>
|
|
@@ -78,7 +78,7 @@ const pillTransition = usePillTransition()
|
|
|
78
78
|
:disabled="item.href ? undefined : item.disabled"
|
|
79
79
|
:aria-current="itemActive(item) ? 'page' : undefined"
|
|
80
80
|
:aria-disabled="item.disabled || undefined"
|
|
81
|
-
class="group/item relative flex items-center gap-3 rounded-control px-3 py-2 text-sm font-medium outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
|
|
81
|
+
class="group/item relative flex items-center gap-3 rounded-control px-3 py-2 text-sm font-medium outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
82
82
|
:class="item.disabled
|
|
83
83
|
? 'cursor-not-allowed text-muted/50'
|
|
84
84
|
: itemActive(item)
|