@leavepulse/ui 0.24.1 → 0.26.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 (97) hide show
  1. package/dist/assets/LpNumberFlow-rZkwQwlU.css +116 -0
  2. package/dist/assets/LpRollingText-CR1mb0Vt.css +74 -0
  3. package/dist/assets/LpSkeleton-D7xU8IPV.css +29 -0
  4. package/dist/assets/LpSparkline-0mKTq7pb.css +51 -0
  5. package/dist/assets/LpTooltip-B61SYd4K.css +17 -0
  6. package/dist/assets/LpUptimeBar-B98o9krI.css +18 -0
  7. package/dist/chunks/LpChart-tNXs1ttr.js +671 -0
  8. package/dist/chunks/{LpConfirmDialog-CObRah9c.js → LpConfirmDialog-CjOGcj1e.js} +32 -4
  9. package/dist/chunks/{LpFileTree-H7Ho8uoq.js → LpFileTree-BaV7f0O7.js} +1 -1
  10. package/dist/chunks/{LpFileTreeNode-BEZIvxHi.js → LpFileTreeNode-BzpBa7R7.js} +5 -7
  11. package/dist/chunks/{LpLogViewer-B1aLUVdz.js → LpLogViewer-CcQ3s3Fp.js} +2 -1
  12. package/dist/chunks/{LpMapPicker-DFKrd1Ne.js → LpMapPicker-CXvoTrAe.js} +2 -4
  13. package/dist/chunks/{LpNotificationBell-X1M30e0X.js → LpNotificationBell-Dvu-b6Vn.js} +56 -27
  14. package/dist/chunks/LpNumberFlow-amWYaeuA.js +221 -0
  15. package/dist/chunks/LpPie-BkjWlfSw.js +525 -0
  16. package/dist/chunks/LpProgress-DoIP4jQR.js +198 -0
  17. package/dist/chunks/LpRollingText-CM5gkSeF.js +50 -0
  18. package/dist/chunks/LpSkeleton-t5mMYl3a.js +29 -0
  19. package/dist/chunks/LpSparkline-C6xQ2dwv.js +175 -0
  20. package/dist/chunks/{LpStat-CdbEFOvn.js → LpStat-CDanpzU0.js} +43 -5
  21. package/dist/chunks/{LpThemeSwitcher-BgpJgrnP.js → LpThemeSwitcher-7mAK6bI7.js} +2 -4
  22. package/dist/chunks/LpTooltip-hCLPAqMG.js +131 -0
  23. package/dist/chunks/LpUptimeBar-DcQ8HikG.js +152 -0
  24. package/dist/component-names.d.ts +1 -1
  25. package/dist/components/LpChart.vue.d.ts +88 -0
  26. package/dist/components/LpChart.vue.js +4 -0
  27. package/dist/components/LpConfirmDialog.vue.d.ts +18 -2
  28. package/dist/components/LpConfirmDialog.vue.js +1 -1
  29. package/dist/components/LpFileTree.vue.d.ts +1 -1
  30. package/dist/components/LpFileTree.vue.js +1 -1
  31. package/dist/components/LpFileTreeNode.vue.js +1 -1
  32. package/dist/components/LpLightbox.vue.d.ts +1 -1
  33. package/dist/components/LpLogViewer.vue.d.ts +1 -1
  34. package/dist/components/LpLogViewer.vue.js +1 -1
  35. package/dist/components/LpMapPicker.vue.js +1 -1
  36. package/dist/components/LpNotificationBell.vue.d.ts +12 -0
  37. package/dist/components/LpNotificationBell.vue.js +1 -1
  38. package/dist/components/LpNumberFlow.vue.d.ts +35 -0
  39. package/dist/components/LpNumberFlow.vue.js +4 -0
  40. package/dist/components/LpPie.vue.d.ts +134 -0
  41. package/dist/components/LpPie.vue.js +4 -0
  42. package/dist/components/LpProgress.vue.d.ts +56 -2
  43. package/dist/components/LpProgress.vue.js +1 -1
  44. package/dist/components/LpRollingText.vue.d.ts +33 -0
  45. package/dist/components/LpRollingText.vue.js +4 -0
  46. package/dist/components/LpSkeleton.vue.d.ts +11 -1
  47. package/dist/components/LpSkeleton.vue.js +1 -1
  48. package/dist/components/LpSparkline.vue.d.ts +48 -0
  49. package/dist/components/LpSparkline.vue.js +4 -0
  50. package/dist/components/LpStat.vue.d.ts +25 -2
  51. package/dist/components/LpStat.vue.js +1 -1
  52. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  53. package/dist/components/LpTooltip.vue.d.ts +24 -3
  54. package/dist/components/LpTooltip.vue.js +1 -1
  55. package/dist/components/LpUptimeBar.vue.js +1 -1
  56. package/dist/components/chart.d.ts +180 -0
  57. package/dist/components/chart.js +353 -0
  58. package/dist/composables/easing.d.ts +46 -0
  59. package/dist/composables/easing.js +23 -0
  60. package/dist/composables/usePillTransition.d.ts +13 -1
  61. package/dist/composables/usePillTransition.js +22 -2
  62. package/dist/composables/useReveal.d.ts +26 -0
  63. package/dist/composables/useReveal.js +45 -0
  64. package/dist/composables/useSettled.js +2 -2
  65. package/dist/composables/useShift.js +2 -4
  66. package/dist/index.d.ts +14 -0
  67. package/dist/index.js +125 -102
  68. package/package.json +2 -1
  69. package/src/component-names.ts +5 -0
  70. package/src/components/LpChart.vue +846 -0
  71. package/src/components/LpConfirmDialog.vue +49 -0
  72. package/src/components/LpFileTreeNode.vue +5 -9
  73. package/src/components/LpLogViewer.vue +2 -3
  74. package/src/components/LpMapPicker.vue +2 -5
  75. package/src/components/LpNotificationBell.vue +76 -15
  76. package/src/components/LpNumberFlow.vue +524 -0
  77. package/src/components/LpPie.vue +778 -0
  78. package/src/components/LpProgress.vue +283 -6
  79. package/src/components/LpRollingText.vue +157 -0
  80. package/src/components/LpSkeleton.vue +81 -8
  81. package/src/components/LpSparkline.vue +312 -0
  82. package/src/components/LpStat.vue +52 -4
  83. package/src/components/LpTooltip.vue +185 -3
  84. package/src/components/LpUptimeBar.vue +150 -53
  85. package/src/components/chart.ts +686 -0
  86. package/src/composables/easing.ts +88 -0
  87. package/src/composables/usePillTransition.ts +71 -6
  88. package/src/composables/useReveal.ts +113 -0
  89. package/src/composables/useSettled.ts +2 -4
  90. package/src/composables/useShift.ts +2 -8
  91. package/src/index.ts +19 -0
  92. package/src/theme/useTheme.ts +2 -8
  93. package/src/tokens/tokens.css +36 -0
  94. package/dist/chunks/LpProgress-BhI0IbjC.js +0 -37
  95. package/dist/chunks/LpSkeleton-eSFIo4kD.js +0 -22
  96. package/dist/chunks/LpTooltip-D1hbjQWG.js +0 -63
  97. 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<{ value?: number; max?: number; tone?: "brand" | "action" | "danger" }>(),
7
- { value: 0, max: 100, tone: "brand" },
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
- const pct = computed(() => Math.max(0, Math.min(100, (props.value / props.max) * 100)))
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-[width] duration-slow ease-[var(--ease-emphasized)]"
26
- :class="bar[tone]"
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>
@@ -1,16 +1,89 @@
1
1
  <script setup lang="ts">
2
- // Loading placeholder. Sizing comes from the consumer's classes (w-/h-).
2
+ /*
3
+ * Loading placeholder, in two shapes.
4
+ *
5
+ * Bare, it is one pulsing block and the consumer's classes size it:
6
+ *
7
+ * <LpSkeleton class="h-4 w-40" />
8
+ *
9
+ * Given content, it becomes a WRAPPER instead: it stops pulsing itself and
10
+ * lends the pulse to any `.lp-skeleton-item` inside it, however deeply nested.
11
+ *
12
+ * <LpSkeleton class="space-y-3">
13
+ * <div class="flex items-center gap-3">
14
+ * <div class="lp-skeleton-item size-10 rounded-pill" />
15
+ * <div class="lp-skeleton-item h-4 w-32" />
16
+ * </div>
17
+ * </LpSkeleton>
18
+ *
19
+ * The second form exists because a real skeleton is a LAYOUT, not a stack of
20
+ * bars: a profile header is an avatar beside two lines of different widths
21
+ * above a button. Expressed as one component per block, that is a dozen
22
+ * <LpSkeleton> tags whose grid has to be rebuilt by hand and kept in step with
23
+ * the markup it stands in for. As a wrapper, the skeleton can be the real
24
+ * markup with its content swapped for plain divs — same flex, same gaps, same
25
+ * responsive classes — so it keeps matching the thing it is standing in for.
26
+ *
27
+ * Consumers had already arrived at this and built it themselves; it belongs
28
+ * here, where every app gets it.
29
+ */
30
+ import { computed, useSlots } from "vue"
31
+
3
32
  defineProps<{ rounded?: "control" | "card" | "pill" }>()
33
+
34
+ const slots = useSlots()
35
+
36
+ /* A wrapper must not pulse as a whole — the container would flash behind its
37
+ own items. Only the bare form is a placeholder in its own right. */
38
+ const isBlock = computed(() => !slots.default)
4
39
  </script>
5
40
 
6
41
  <template>
7
42
  <div
8
43
  aria-hidden="true"
9
- class="animate-pulse bg-surface-soft"
10
- :class="{
11
- 'rounded-control': rounded === 'control' || !rounded,
12
- 'rounded-card': rounded === 'card',
13
- 'rounded-pill': rounded === 'pill',
14
- }"
15
- />
44
+ class="lp-skeleton"
45
+ :class="[
46
+ isBlock ? 'lp-skeleton-item bg-surface-soft' : '',
47
+ {
48
+ 'rounded-control': isBlock && (rounded === 'control' || !rounded),
49
+ 'rounded-card': rounded === 'card',
50
+ 'rounded-pill': rounded === 'pill',
51
+ },
52
+ ]"
53
+ >
54
+ <slot />
55
+ </div>
16
56
  </template>
57
+
58
+ <style>
59
+ /*
60
+ * Unscoped: the items are written by the CONSUMER, inside the slot, so they
61
+ * carry no scope attribute of this component — a scoped rule could never reach
62
+ * them. Namespaced instead, and only ever applied under `.lp-skeleton`.
63
+ *
64
+ * One shared animation name means every item on the page pulses in phase: CSS
65
+ * animations are timed against the document, not against when each element
66
+ * mounted, so a skeleton that fills in progressively stays in step with itself.
67
+ */
68
+ .lp-skeleton-item {
69
+ animation: lp-skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
70
+ }
71
+
72
+ /* Items nested anywhere inside a wrapper, at any depth. */
73
+ .lp-skeleton .lp-skeleton-item {
74
+ animation: lp-skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
75
+ }
76
+
77
+ @keyframes lp-skeleton-pulse {
78
+ 50% {
79
+ opacity: 0.5;
80
+ }
81
+ }
82
+
83
+ @media (prefers-reduced-motion: reduce) {
84
+ .lp-skeleton-item {
85
+ animation: none;
86
+ opacity: 0.75;
87
+ }
88
+ }
89
+ </style>