@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,312 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* A trend, inline. No axes, no grid, no legend — just the shape of a series,
|
|
4
|
+
* sized to sit inside a table cell or beside a stat's value.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately NOT LpChart with everything switched off. LpChart answers "what
|
|
7
|
+
* were the values" and pays for it: a ResizeObserver, two scales, a tooltip
|
|
8
|
+
* spring, a context menu. A sparkline answers "up or down", and there are forty
|
|
9
|
+
* of them on a list page — so it measures nothing, observes nothing and holds
|
|
10
|
+
* no state. The viewBox is fixed and the browser scales the path; the geometry
|
|
11
|
+
* is the same `chart.ts` the full chart uses, so a line reads identically in
|
|
12
|
+
* both.
|
|
13
|
+
*
|
|
14
|
+
* <LpSparkline :data="cpu" />
|
|
15
|
+
* <LpSparkline :data="load" area tone="danger" :width="72" />
|
|
16
|
+
*/
|
|
17
|
+
import { computed } from "vue"
|
|
18
|
+
import { useReveal } from "../composables/useReveal"
|
|
19
|
+
import { areaPath, extent, linePath, normalise, type Box } from "./chart"
|
|
20
|
+
|
|
21
|
+
const props = withDefaults(
|
|
22
|
+
defineProps<{
|
|
23
|
+
/** Oldest → newest. `null` breaks the line, as in LpChart. */
|
|
24
|
+
data: (number | null)[]
|
|
25
|
+
/** Rendered size in px. The path scales to it; stroke width does not. */
|
|
26
|
+
width?: number
|
|
27
|
+
height?: number
|
|
28
|
+
/** Any CSS colour. Defaults to the brand token. */
|
|
29
|
+
color?: string
|
|
30
|
+
/** Named theme tones, for callers that don't want to spell a var(). */
|
|
31
|
+
tone?: "brand" | "action" | "danger" | "accent" | "muted"
|
|
32
|
+
/** Fill under the line. */
|
|
33
|
+
area?: boolean
|
|
34
|
+
/** Curve tension, as in LpChart. 0 draws a polyline. */
|
|
35
|
+
smooth?: number
|
|
36
|
+
/** Dot on the final point — "you are here" on a trend. */
|
|
37
|
+
showLast?: boolean
|
|
38
|
+
/**
|
|
39
|
+
* Pin the vertical range instead of fitting the data. Without it a flat
|
|
40
|
+
* series fills the box with noise, and two sparklines in one column can't
|
|
41
|
+
* be compared: each would be scaled to its own extremes.
|
|
42
|
+
*/
|
|
43
|
+
min?: number
|
|
44
|
+
max?: number
|
|
45
|
+
/** Accessible description; falls back to a direction + latest value. */
|
|
46
|
+
ariaLabel?: string
|
|
47
|
+
/**
|
|
48
|
+
* Draw the line on from oldest to newest when it first appears. Turn it off
|
|
49
|
+
* for a table that renders many at once — dozens of simultaneous sweeps
|
|
50
|
+
* read as flicker.
|
|
51
|
+
*/
|
|
52
|
+
animate?: boolean
|
|
53
|
+
/**
|
|
54
|
+
* Draw time in ms. The stroke travels at a near-even rate (see
|
|
55
|
+
* `--ease-travel`), so this is roughly how long the line is actually
|
|
56
|
+
* watched being drawn — not a figure mostly spent easing out.
|
|
57
|
+
*/
|
|
58
|
+
duration?: number
|
|
59
|
+
}>(),
|
|
60
|
+
{ width: 84, height: 24, tone: "brand", smooth: 0.9, animate: true, duration: 900 },
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const TONE: Record<string, string> = {
|
|
64
|
+
brand: "var(--color-brand)",
|
|
65
|
+
action: "var(--color-action)",
|
|
66
|
+
danger: "var(--color-danger)",
|
|
67
|
+
accent: "var(--color-accent)",
|
|
68
|
+
muted: "var(--color-muted)",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const stroke = computed(() => props.color ?? TONE[props.tone])
|
|
72
|
+
|
|
73
|
+
const series = computed(() => normalise([{ name: "s", data: props.data }]))
|
|
74
|
+
|
|
75
|
+
/*
|
|
76
|
+
* The path is built in the viewBox's own coordinates and the SVG scales it, so
|
|
77
|
+
* nothing here depends on layout — which is what lets this render identically
|
|
78
|
+
* on the server and in a cell that hasn't been measured yet.
|
|
79
|
+
*
|
|
80
|
+
* Inset by half a stroke so the line's own width can't be clipped at the edges,
|
|
81
|
+
* and by a little more vertically so a peak sitting exactly at the maximum
|
|
82
|
+
* keeps its round cap.
|
|
83
|
+
*/
|
|
84
|
+
const PAD = 2
|
|
85
|
+
|
|
86
|
+
const box = computed<Box>(() => ({
|
|
87
|
+
width: props.width,
|
|
88
|
+
height: props.height,
|
|
89
|
+
left: PAD,
|
|
90
|
+
right: PAD,
|
|
91
|
+
top: PAD,
|
|
92
|
+
bottom: PAD,
|
|
93
|
+
}))
|
|
94
|
+
|
|
95
|
+
const bounds = computed(() => {
|
|
96
|
+
const auto = extent(series.value, false)
|
|
97
|
+
return { min: props.min ?? auto.min, max: props.max ?? auto.max }
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const geo = computed(() => ({
|
|
101
|
+
min: bounds.value.min,
|
|
102
|
+
max: bounds.value.max,
|
|
103
|
+
box: box.value,
|
|
104
|
+
count: props.data.length,
|
|
105
|
+
banded: false,
|
|
106
|
+
}))
|
|
107
|
+
|
|
108
|
+
const d = computed(() => linePath(series.value[0].values, geo.value, props.smooth))
|
|
109
|
+
const fill = computed(() =>
|
|
110
|
+
props.area ? areaPath(series.value[0].values, geo.value, props.smooth) : "",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
/** Last point that actually has a value — the dot goes there, not on a gap. */
|
|
114
|
+
const lastPoint = computed(() => {
|
|
115
|
+
const values = series.value[0].values
|
|
116
|
+
for (let i = values.length - 1; i >= 0; i--) {
|
|
117
|
+
const v = values[i]
|
|
118
|
+
if (v == null) continue
|
|
119
|
+
const span = bounds.value.max - bounds.value.min || 1
|
|
120
|
+
const w = props.width - PAD * 2
|
|
121
|
+
const h = props.height - PAD * 2
|
|
122
|
+
return {
|
|
123
|
+
x: props.data.length <= 1 ? PAD + w / 2 : PAD + (w / (props.data.length - 1)) * i,
|
|
124
|
+
y: PAD + h - ((v - bounds.value.min) / span) * h,
|
|
125
|
+
v,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const hasData = computed(() => series.value[0].values.some((v) => v != null))
|
|
132
|
+
|
|
133
|
+
/* A bare <svg> is invisible to a screen reader; say the shape out loud. */
|
|
134
|
+
const label = computed(() => {
|
|
135
|
+
if (props.ariaLabel) return props.ariaLabel
|
|
136
|
+
const values = series.value[0].values.filter((v): v is number => v != null)
|
|
137
|
+
if (!values.length) return "No data"
|
|
138
|
+
const first = values[0]
|
|
139
|
+
const last = values[values.length - 1]
|
|
140
|
+
const dir = last > first ? "trending up" : last < first ? "trending down" : "flat"
|
|
141
|
+
return `${dir}, latest ${last}`
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
/*
|
|
145
|
+
* Dash-offset reveal: the stroke is dashed with one dash as long as the whole
|
|
146
|
+
* path and the offset animated from that length to zero, so the line appears to
|
|
147
|
+
* be drawn. The dash length only has to be an OVER-estimate — the bounding box
|
|
148
|
+
* diagonal per segment always is — which avoids measuring the path in the DOM
|
|
149
|
+
* and keeps the component render-only, as SSR needs.
|
|
150
|
+
*/
|
|
151
|
+
const pathLength = computed(() => {
|
|
152
|
+
const n = Math.max(1, props.data.length - 1)
|
|
153
|
+
const segment = Math.hypot((props.width - PAD * 2) / n, props.height - PAD * 2)
|
|
154
|
+
return Math.ceil(segment * n + 4)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
/*
|
|
158
|
+
* The sweep is held until the svg is actually on screen. Forty of these sit in
|
|
159
|
+
* a list, most of them below the fold; started on mount they would all have
|
|
160
|
+
* finished before the rows carrying them were ever scrolled to.
|
|
161
|
+
*/
|
|
162
|
+
const { el, revealed } = useReveal({ immediate: () => !props.animate })
|
|
163
|
+
|
|
164
|
+
const revealStyle = computed(() =>
|
|
165
|
+
props.animate
|
|
166
|
+
? {
|
|
167
|
+
"--lp-spark-len": `${pathLength.value}`,
|
|
168
|
+
"--lp-spark-dur": `${props.duration}ms`,
|
|
169
|
+
}
|
|
170
|
+
: undefined,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
/*
|
|
174
|
+
* Three states, not two: no animation at all (paint it), waiting to be seen
|
|
175
|
+
* (hold it back), and drawing. The waiting state has to be marked on the
|
|
176
|
+
* element, because the animation's own "from" cannot express it — a paused
|
|
177
|
+
* keyframe still needs the dash variables set, and those are exactly what a
|
|
178
|
+
* finished-looking fallback resolves to.
|
|
179
|
+
*/
|
|
180
|
+
const revealClass = computed(() => (props.animate && !revealed.value ? "lp-spark--waiting" : ""))
|
|
181
|
+
|
|
182
|
+
const gradientId = `lp-spark-${Math.random().toString(36).slice(2, 9)}`
|
|
183
|
+
</script>
|
|
184
|
+
|
|
185
|
+
<template>
|
|
186
|
+
<svg
|
|
187
|
+
v-if="hasData"
|
|
188
|
+
ref="el"
|
|
189
|
+
:width="width"
|
|
190
|
+
:height="height"
|
|
191
|
+
:viewBox="`0 0 ${width} ${height}`"
|
|
192
|
+
role="img"
|
|
193
|
+
:aria-label="label"
|
|
194
|
+
class="shrink-0 overflow-visible"
|
|
195
|
+
>
|
|
196
|
+
<defs v-if="area">
|
|
197
|
+
<linearGradient :id="gradientId" x1="0" y1="0" x2="0" y2="1">
|
|
198
|
+
<stop offset="0%" :stop-color="stroke" stop-opacity="0.3" />
|
|
199
|
+
<stop offset="100%" :stop-color="stroke" stop-opacity="0.02" />
|
|
200
|
+
</linearGradient>
|
|
201
|
+
</defs>
|
|
202
|
+
|
|
203
|
+
<!--
|
|
204
|
+
The line DRAWS ITSELF from oldest to newest, and the fill and the end dot
|
|
205
|
+
arrive behind it.
|
|
206
|
+
|
|
207
|
+
A trend has a direction, and a sparkline that simply appears states a
|
|
208
|
+
shape without it. Stroking it left to right replays the reading order the
|
|
209
|
+
data already has — and in a list of them, the eye is drawn to the row that
|
|
210
|
+
just refreshed. `animate: false` for a table that renders dozens at once,
|
|
211
|
+
where the sweep would read as flicker rather than as arrival.
|
|
212
|
+
-->
|
|
213
|
+
<path
|
|
214
|
+
v-if="area"
|
|
215
|
+
:d="fill"
|
|
216
|
+
:fill="`url(#${gradientId})`"
|
|
217
|
+
class="lp-spark__area"
|
|
218
|
+
:class="revealClass"
|
|
219
|
+
:style="revealStyle"
|
|
220
|
+
/>
|
|
221
|
+
<path
|
|
222
|
+
:d="d"
|
|
223
|
+
fill="none"
|
|
224
|
+
:stroke="stroke"
|
|
225
|
+
stroke-width="1.5"
|
|
226
|
+
stroke-linecap="round"
|
|
227
|
+
stroke-linejoin="round"
|
|
228
|
+
vector-effect="non-scaling-stroke"
|
|
229
|
+
class="lp-spark__line"
|
|
230
|
+
:class="revealClass"
|
|
231
|
+
:style="revealStyle"
|
|
232
|
+
/>
|
|
233
|
+
<circle
|
|
234
|
+
v-if="showLast && lastPoint"
|
|
235
|
+
:cx="lastPoint.x"
|
|
236
|
+
:cy="lastPoint.y"
|
|
237
|
+
r="2"
|
|
238
|
+
:fill="stroke"
|
|
239
|
+
class="lp-spark__dot"
|
|
240
|
+
:class="revealClass"
|
|
241
|
+
:style="revealStyle"
|
|
242
|
+
/>
|
|
243
|
+
</svg>
|
|
244
|
+
|
|
245
|
+
<!-- Keeps the row's rhythm when a machine has reported nothing yet. -->
|
|
246
|
+
<span
|
|
247
|
+
v-else
|
|
248
|
+
class="inline-block shrink-0 rounded-pill bg-surface-soft"
|
|
249
|
+
:style="{ width: `${width}px`, height: '2px' }"
|
|
250
|
+
role="img"
|
|
251
|
+
:aria-label="label"
|
|
252
|
+
/>
|
|
253
|
+
</template>
|
|
254
|
+
|
|
255
|
+
<style>
|
|
256
|
+
/*
|
|
257
|
+
* Unscoped and namespaced: the reveal is driven by CSS variables the component
|
|
258
|
+
* sets inline, and keyframes cannot live in a scoped block that the animated
|
|
259
|
+
* element may not carry the attribute for.
|
|
260
|
+
*
|
|
261
|
+
* Only elements that were given the variables animate — `animate: false` omits
|
|
262
|
+
* them, the `var()` fallbacks resolve to a finished state, and nothing moves.
|
|
263
|
+
*
|
|
264
|
+
* `--waiting` is the third state: the element is on the page but has not been
|
|
265
|
+
* scrolled to, so it is held at the animation's first frame. It cannot simply
|
|
266
|
+
* be left un-animated, because that is indistinguishable from finished.
|
|
267
|
+
*/
|
|
268
|
+
.lp-spark__line {
|
|
269
|
+
stroke-dasharray: var(--lp-spark-len, none);
|
|
270
|
+
stroke-dashoffset: 0;
|
|
271
|
+
animation: lp-spark-draw var(--lp-spark-dur, 0ms) var(--ease-travel) both;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.lp-spark__area,
|
|
275
|
+
.lp-spark__dot {
|
|
276
|
+
/* Behind the line: the fill and the end marker belong to a line that has
|
|
277
|
+
already been drawn, so they fade in over its last third. */
|
|
278
|
+
animation: lp-spark-fade var(--lp-spark-dur, 0ms) ease both;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.lp-spark--waiting.lp-spark__line {
|
|
282
|
+
stroke-dashoffset: var(--lp-spark-len, 0);
|
|
283
|
+
animation: none;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.lp-spark--waiting.lp-spark__area,
|
|
287
|
+
.lp-spark--waiting.lp-spark__dot {
|
|
288
|
+
opacity: 0;
|
|
289
|
+
animation: none;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
@keyframes lp-spark-draw {
|
|
293
|
+
from {
|
|
294
|
+
stroke-dashoffset: var(--lp-spark-len, 0);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
@keyframes lp-spark-fade {
|
|
299
|
+
0%,
|
|
300
|
+
60% {
|
|
301
|
+
opacity: 0;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
@media (prefers-reduced-motion: reduce) {
|
|
306
|
+
.lp-spark__line,
|
|
307
|
+
.lp-spark__area,
|
|
308
|
+
.lp-spark__dot {
|
|
309
|
+
animation: none;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
</style>
|
|
@@ -3,11 +3,24 @@
|
|
|
3
3
|
// subtitle. A product pattern, fully token-driven — re-skins with the theme.
|
|
4
4
|
import { computed } from "vue"
|
|
5
5
|
import LpIcon from "./LpIcon.vue"
|
|
6
|
+
import LpNumberFlow from "./LpNumberFlow.vue"
|
|
6
7
|
|
|
7
8
|
const props = withDefaults(
|
|
8
9
|
defineProps<{
|
|
9
10
|
label: string
|
|
10
|
-
|
|
11
|
+
/**
|
|
12
|
+
* A string is printed as given; a NUMBER counts to its new value on
|
|
13
|
+
* LpNumberFlow's digit wheels. A tile on a live dashboard is the one place
|
|
14
|
+
* a figure changes while somebody is looking at it, and a value that cuts
|
|
15
|
+
* from 41 to 43 says nothing about which way it moved.
|
|
16
|
+
*/
|
|
17
|
+
value: string | number
|
|
18
|
+
/** Formatting for a numeric `value` — see LpNumberFlow. */
|
|
19
|
+
compact?: boolean
|
|
20
|
+
grouped?: boolean
|
|
21
|
+
decimals?: number
|
|
22
|
+
prefix?: string
|
|
23
|
+
valueSuffix?: string
|
|
11
24
|
hint?: string
|
|
12
25
|
icon?: string
|
|
13
26
|
online?: boolean
|
|
@@ -22,7 +35,7 @@ const props = withDefaults(
|
|
|
22
35
|
* downward trend reads as positive (action) and upward as danger. */
|
|
23
36
|
invertTrend?: boolean
|
|
24
37
|
}>(),
|
|
25
|
-
{ trend: "auto" },
|
|
38
|
+
{ trend: "auto", decimals: 0 },
|
|
26
39
|
)
|
|
27
40
|
|
|
28
41
|
// Resolve the trend direction: explicit wins; "auto" reads a numeric delta.
|
|
@@ -61,6 +74,18 @@ const deltaText = computed(() => {
|
|
|
61
74
|
})
|
|
62
75
|
|
|
63
76
|
const showDelta = computed(() => props.delta != null)
|
|
77
|
+
|
|
78
|
+
/*
|
|
79
|
+
* Read off the delta itself rather than taken as a prop: a change is quoted at
|
|
80
|
+
* whatever precision it was measured (+2.4%, +12), and the tile's own
|
|
81
|
+
* `decimals` describes the VALUE, which is a different quantity. Rounding
|
|
82
|
+
* +0.4 to +0 would report no change at all.
|
|
83
|
+
*/
|
|
84
|
+
const deltaDecimals = computed(() => {
|
|
85
|
+
if (typeof props.delta !== "number") return 0
|
|
86
|
+
const [, fraction] = String(props.delta).split(".")
|
|
87
|
+
return Math.min(fraction?.length ?? 0, 4)
|
|
88
|
+
})
|
|
64
89
|
</script>
|
|
65
90
|
|
|
66
91
|
<template>
|
|
@@ -74,7 +99,16 @@ const showDelta = computed(() => props.delta != null)
|
|
|
74
99
|
</div>
|
|
75
100
|
<div class="flex items-baseline gap-2">
|
|
76
101
|
<span class="font-mono text-3xl font-medium tracking-tight text-ink">
|
|
77
|
-
|
|
102
|
+
<LpNumberFlow
|
|
103
|
+
v-if="typeof value === 'number'"
|
|
104
|
+
:value="value"
|
|
105
|
+
:compact="compact"
|
|
106
|
+
:grouped="grouped"
|
|
107
|
+
:decimals="decimals"
|
|
108
|
+
:prefix="prefix"
|
|
109
|
+
:suffix="valueSuffix"
|
|
110
|
+
/>
|
|
111
|
+
<template v-else>{{ value }}</template>
|
|
78
112
|
</span>
|
|
79
113
|
<span
|
|
80
114
|
v-if="showDelta"
|
|
@@ -82,7 +116,21 @@ const showDelta = computed(() => props.delta != null)
|
|
|
82
116
|
:class="trendColor"
|
|
83
117
|
>
|
|
84
118
|
<LpIcon :name="trendIcon" :size="14" />
|
|
85
|
-
|
|
119
|
+
<!-- Rolls like the value it sits beside. Two figures on one line, one
|
|
120
|
+
cutting and one counting, reads as the delta failing to update. -->
|
|
121
|
+
<LpNumberFlow
|
|
122
|
+
v-if="typeof delta === 'number'"
|
|
123
|
+
:value="delta"
|
|
124
|
+
:decimals="deltaDecimals"
|
|
125
|
+
:prefix="delta > 0 ? '+' : ''"
|
|
126
|
+
:suffix="deltaSuffix"
|
|
127
|
+
/>
|
|
128
|
+
<template v-else>{{ deltaText }}</template>
|
|
129
|
+
</span>
|
|
130
|
+
<!-- A delta says how much it moved; the shape says how it got there. Put
|
|
131
|
+
an LpSparkline (or a ring) here to answer both at once. -->
|
|
132
|
+
<span v-if="$slots.trend" class="ml-3 self-center">
|
|
133
|
+
<slot name="trend" />
|
|
86
134
|
</span>
|
|
87
135
|
</div>
|
|
88
136
|
<span v-if="hint" class="text-sm text-muted">{{ hint }}</span>
|
|
@@ -34,9 +34,15 @@ import {
|
|
|
34
34
|
TooltipRoot,
|
|
35
35
|
TooltipTrigger,
|
|
36
36
|
} from "reka-ui"
|
|
37
|
+
import { onBeforeUnmount, ref, watch } from "vue"
|
|
38
|
+
import {
|
|
39
|
+
springSettled,
|
|
40
|
+
springStep,
|
|
41
|
+
type SpringState,
|
|
42
|
+
} from "../composables/usePillTransition"
|
|
37
43
|
import { PANEL_SURFACE, TOOLTIP_ANIM } from "./dropdown"
|
|
38
44
|
|
|
39
|
-
withDefaults(
|
|
45
|
+
const props = withDefaults(
|
|
40
46
|
defineProps<{
|
|
41
47
|
content?: string
|
|
42
48
|
sideOffset?: number
|
|
@@ -55,28 +61,185 @@ withDefaults(
|
|
|
55
61
|
* pops up after every mouse click on the trigger is noise.
|
|
56
62
|
*/
|
|
57
63
|
showOnPointerFocus?: boolean
|
|
64
|
+
/**
|
|
65
|
+
* Track the pointer instead of anchoring to the trigger's edge.
|
|
66
|
+
*
|
|
67
|
+
* For a trigger that is large or oddly shaped — a chart slice, a canvas, a
|
|
68
|
+
* map region — where the element's bounding box says little about what the
|
|
69
|
+
* pointer is actually over: anchored to the box, the tip can appear far from
|
|
70
|
+
* the thing it describes. Following the cursor keeps it next to the reading.
|
|
71
|
+
*/
|
|
72
|
+
followCursor?: boolean
|
|
73
|
+
/**
|
|
74
|
+
* Suppress the tip. For a trigger whose tip only applies to part of it — a
|
|
75
|
+
* chart wrapper where the pointer may be over a mark or over empty canvas —
|
|
76
|
+
* so an empty panel never opens over the gaps.
|
|
77
|
+
*
|
|
78
|
+
* This also FORCES CLOSED a tip that is already showing. reka's own
|
|
79
|
+
* `disabled` does not: it stops managing the tooltip where it stands, so one
|
|
80
|
+
* that was open when the flag flipped stayed on screen for good — outliving
|
|
81
|
+
* the pointer, letting a second chart open its own beside it, and surviving
|
|
82
|
+
* a scroll that should have dismissed it.
|
|
83
|
+
*/
|
|
84
|
+
disabled?: boolean
|
|
58
85
|
}>(),
|
|
59
86
|
{ delay: 200, side: "top" },
|
|
60
87
|
)
|
|
88
|
+
|
|
89
|
+
/*
|
|
90
|
+
* Follow mode. reka positions the tip against the trigger's BOX and exposes no
|
|
91
|
+
* virtual anchor for tooltips (PopperAnchor isn't part of its public surface),
|
|
92
|
+
* so the tip is placed as usual and then offset to the cursor.
|
|
93
|
+
*
|
|
94
|
+
* Both axes are followed. Following x alone left the tip pinned to the vertical
|
|
95
|
+
* middle of the trigger, which for a tall target — a pie 180px across, a canvas,
|
|
96
|
+
* a map — parks it a long way from the pointer. The offset is measured from the
|
|
97
|
+
* box's centre on each axis, and reka's own side placement still decides which
|
|
98
|
+
* way the tip sits relative to that point.
|
|
99
|
+
*
|
|
100
|
+
* The tip TRAILS the cursor on a spring rather than being pinned to it: chasing
|
|
101
|
+
* the pointer exactly reads as jitter, while a slight lag reads as following.
|
|
102
|
+
* Same reasoning, and the same shared "float" feel, as LpChart's readout.
|
|
103
|
+
*/
|
|
104
|
+
const target = { x: 0, y: 0 }
|
|
105
|
+
const offset = ref({ x: 0, y: 0 })
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
* The spring constants come from the kit's shared "float" feel — the same ones
|
|
109
|
+
* LpChart hands to motion-v for its readout. Only the mechanism differs: this
|
|
110
|
+
* panel is portalled and positioned by reka, so there is no absolute target for
|
|
111
|
+
* a declarative animation to own and the spring is integrated by hand.
|
|
112
|
+
*/
|
|
113
|
+
let sx: SpringState = { value: 0, velocity: 0 }
|
|
114
|
+
let sy: SpringState = { value: 0, velocity: 0 }
|
|
115
|
+
let frame = 0
|
|
116
|
+
let lastFrame = 0
|
|
117
|
+
|
|
118
|
+
const settle = (now: number) => {
|
|
119
|
+
const dt = lastFrame ? (now - lastFrame) / 1000 : 1 / 60
|
|
120
|
+
lastFrame = now
|
|
121
|
+
frame = 0
|
|
122
|
+
|
|
123
|
+
sx = springStep(sx, target.x, "float", dt)
|
|
124
|
+
sy = springStep(sy, target.y, "float", dt)
|
|
125
|
+
|
|
126
|
+
// Close enough to have arrived: snap and stop, so an idle tooltip isn't
|
|
127
|
+
// holding a rAF loop open forever.
|
|
128
|
+
if (springSettled(sx, target.x) && springSettled(sy, target.y)) {
|
|
129
|
+
sx = { value: target.x, velocity: 0 }
|
|
130
|
+
sy = { value: target.y, velocity: 0 }
|
|
131
|
+
offset.value = { x: target.x, y: target.y }
|
|
132
|
+
lastFrame = 0
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
offset.value = { x: sx.value, y: sy.value }
|
|
137
|
+
frame = requestAnimationFrame(settle)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const onPointerMove = (e: PointerEvent) => {
|
|
141
|
+
if (!props.followCursor) return
|
|
142
|
+
const el = e.currentTarget as HTMLElement | null
|
|
143
|
+
if (!el) return
|
|
144
|
+
const rect = el.getBoundingClientRect()
|
|
145
|
+
const halfX = props.side === "left" || props.side === "right" ? rect.width / 2 : 0
|
|
146
|
+
const dirX = props.side === "right" ? -1 : 1
|
|
147
|
+
target.x = e.clientX - (rect.left + rect.width / 2) + halfX * dirX
|
|
148
|
+
/*
|
|
149
|
+
* reka clears the tip past the whole box's EDGE, not past the pointer, so on
|
|
150
|
+
* a tall trigger it ends up half the box above the cursor — measured at 96px
|
|
151
|
+
* on a 180px circle. Adding the half-height back re-references the placement
|
|
152
|
+
* to the pointer itself; `side` then keeps its meaning (the tip still sits
|
|
153
|
+
* above for "top"), now relative to the cursor rather than the box.
|
|
154
|
+
*/
|
|
155
|
+
const half = props.side === "top" || props.side === "bottom" ? rect.height / 2 : 0
|
|
156
|
+
const dir = props.side === "bottom" ? -1 : 1
|
|
157
|
+
target.y = e.clientY - (rect.top + rect.height / 2) + half * dir
|
|
158
|
+
if (!frame) frame = requestAnimationFrame(settle)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/*
|
|
162
|
+
* Open state is held here rather than left to reka, so that `disabled` can shut
|
|
163
|
+
* a tip that is already showing. Left to reka, disabling only detaches its
|
|
164
|
+
* handlers — whatever was on screen stayed there.
|
|
165
|
+
*/
|
|
166
|
+
const open = ref(false)
|
|
167
|
+
/* What reka last asked for, regardless of whether it was allowed at the time. */
|
|
168
|
+
const wanted = ref(false)
|
|
169
|
+
|
|
170
|
+
const onOpenChange = (next: boolean) => {
|
|
171
|
+
// What reka wants, kept apart from what is currently allowed.
|
|
172
|
+
wanted.value = next
|
|
173
|
+
open.value = next && !props.disabled
|
|
174
|
+
if (!open.value || !props.followCursor) return
|
|
175
|
+
// A fresh open starts at the cursor instead of flying in from wherever the
|
|
176
|
+
// last one came to rest.
|
|
177
|
+
offset.value = { x: target.x, y: target.y }
|
|
178
|
+
sx = { value: target.x, velocity: 0 }
|
|
179
|
+
sy = { value: target.y, velocity: 0 }
|
|
180
|
+
lastFrame = 0
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/*
|
|
184
|
+
* `disabled` is driven by what the pointer is over, and that is resolved a tick
|
|
185
|
+
* AFTER reka decides to open — so an open request could arrive while the flag
|
|
186
|
+
* was still true from the last gap and be dropped on the floor. The tooltip then
|
|
187
|
+
* refused to appear until the pointer moved again, which read as "hovering does
|
|
188
|
+
* nothing any more".
|
|
189
|
+
*
|
|
190
|
+
* Remembering that a request was made, and honouring it once the obstacle
|
|
191
|
+
* clears, closes that race in the only direction that matters: enabling shows
|
|
192
|
+
* what was asked for, disabling still hides immediately.
|
|
193
|
+
*/
|
|
194
|
+
watch(
|
|
195
|
+
() => props.disabled,
|
|
196
|
+
(off) => {
|
|
197
|
+
if (off) open.value = false
|
|
198
|
+
else if (wanted.value) open.value = true
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
onBeforeUnmount(() => {
|
|
203
|
+
if (frame) cancelAnimationFrame(frame)
|
|
204
|
+
})
|
|
61
205
|
</script>
|
|
62
206
|
|
|
63
207
|
<template>
|
|
64
208
|
<TooltipProvider :delay-duration="delay">
|
|
65
|
-
|
|
209
|
+
<!--
|
|
210
|
+
`disable-closing-trigger` in follow mode: reka dismisses a tip when its
|
|
211
|
+
trigger is clicked, which is right for a button (the tip explained the
|
|
212
|
+
thing you just did) and wrong for a chart, where clicking selects a mark
|
|
213
|
+
and the pointer never left it — the reading would vanish mid-interaction.
|
|
214
|
+
-->
|
|
215
|
+
<TooltipRoot
|
|
216
|
+
:open="open"
|
|
217
|
+
:ignore-non-keyboard-focus="!showOnPointerFocus"
|
|
218
|
+
:disable-hoverable-content="followCursor"
|
|
219
|
+
:disable-closing-trigger="followCursor"
|
|
220
|
+
@update:open="onOpenChange"
|
|
221
|
+
>
|
|
66
222
|
<!-- `as-child` merges these onto the caller's own element, so the label
|
|
67
223
|
lands on the real button rather than on a wrapper. -->
|
|
68
|
-
<TooltipTrigger
|
|
224
|
+
<TooltipTrigger
|
|
225
|
+
as-child
|
|
226
|
+
:aria-label="label ? content : undefined"
|
|
227
|
+
@pointermove="onPointerMove"
|
|
228
|
+
>
|
|
69
229
|
<slot />
|
|
70
230
|
</TooltipTrigger>
|
|
71
231
|
<TooltipPortal>
|
|
72
232
|
<TooltipContent
|
|
73
233
|
:side="side"
|
|
74
234
|
:side-offset="sideOffset ?? 6"
|
|
235
|
+
:collision-padding="8"
|
|
236
|
+
:data-lp-follow="followCursor ? '' : undefined"
|
|
75
237
|
:class="[
|
|
76
238
|
PANEL_SURFACE,
|
|
77
239
|
TOOLTIP_ANIM,
|
|
78
240
|
'z-(--z-tooltip) max-w-[min(20rem,90vw)] px-2.5 py-1.5 text-xs text-ink',
|
|
79
241
|
]"
|
|
242
|
+
:style="followCursor ? { translate: `${offset.x}px ${offset.y}px` } : undefined"
|
|
80
243
|
>
|
|
81
244
|
<slot name="content">{{ content }}</slot>
|
|
82
245
|
</TooltipContent>
|
|
@@ -84,3 +247,22 @@ withDefaults(
|
|
|
84
247
|
</TooltipRoot>
|
|
85
248
|
</TooltipProvider>
|
|
86
249
|
</template>
|
|
250
|
+
|
|
251
|
+
<style>
|
|
252
|
+
/*
|
|
253
|
+
* Unscoped: the element that has to be made transparent is reka's own
|
|
254
|
+
* positioning wrapper, which this component never renders and a scoped
|
|
255
|
+
* attribute therefore never reaches.
|
|
256
|
+
*
|
|
257
|
+
* In follow mode the tip is placed AT the cursor, so both the panel and that
|
|
258
|
+
* wrapper sit under the pointer. The panel alone being transparent was not
|
|
259
|
+
* enough — the wrapper still took the events, the trigger never received its
|
|
260
|
+
* pointerleave, and the tip stayed open: it lingered after the pointer left,
|
|
261
|
+
* a second chart could open its own on top, and a scroll left them stranded
|
|
262
|
+
* because the trigger had never been told the pointer had gone.
|
|
263
|
+
*/
|
|
264
|
+
:has(> [data-lp-follow]),
|
|
265
|
+
[data-lp-follow] {
|
|
266
|
+
pointer-events: none;
|
|
267
|
+
}
|
|
268
|
+
</style>
|