@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,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
- value: string
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
- {{ value }}
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
- {{ deltaText }}
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
- <TooltipRoot :ignore-non-keyboard-focus="!showOnPointerFocus">
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 as-child :aria-label="label ? content : undefined">
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>