@leavepulse/ui 0.22.20 → 0.22.22

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 (45) hide show
  1. package/dist/chunks/{LpAddressInput-CxKwIG6T.js → LpAddressInput-peTpNSJK.js} +3 -3
  2. package/dist/chunks/{LpAppShell-D8CAP9oF.js → LpAppShell-CaQYQKZV.js} +25 -14
  3. package/dist/chunks/{LpConfirmDialog-CmgeAdCh.js → LpConfirmDialog-DOU_zaTI.js} +1 -1
  4. package/dist/chunks/{LpFileTree-CMy2qQ1m.js → LpFileTree-Cw50eG_y.js} +3 -4
  5. package/dist/chunks/{LpFileTreeNode-Cgf68Tup.js → LpFileTreeNode-DFg4PhOK.js} +7 -9
  6. package/dist/chunks/{LpFormField-Bn-ZwC6z.js → LpFormField-CesaPWPO.js} +12 -6
  7. package/dist/chunks/{LpInput-BkomKdqQ.js → LpInput-BfLIWV-P.js} +1 -1
  8. package/dist/chunks/{LpModal-CNNSf5pv.js → LpModal-D_o-56To.js} +13 -76
  9. package/dist/chunks/{LpNotificationBell-DWDEkmdA.js → LpNotificationBell-a08cl0yY.js} +67 -61
  10. package/dist/chunks/{LpPasswordInput-DgUC2Ur7.js → LpPasswordInput-BfjDrvZq.js} +1 -1
  11. package/dist/chunks/LpShift-DXNsauQZ.js +40 -0
  12. package/dist/chunks/{LpTextarea-CRAkhemZ.js → LpTextarea-BDl7PR9U.js} +1 -1
  13. package/dist/component-names.d.ts +1 -1
  14. package/dist/components/LpAddressInput.vue.js +1 -1
  15. package/dist/components/LpAppShell.vue.d.ts +6 -6
  16. package/dist/components/LpAppShell.vue.js +1 -1
  17. package/dist/components/LpConfirmDialog.vue.js +1 -1
  18. package/dist/components/LpFileTree.vue.d.ts +0 -2
  19. package/dist/components/LpFileTree.vue.js +1 -1
  20. package/dist/components/LpFileTreeNode.vue.d.ts +0 -2
  21. package/dist/components/LpFileTreeNode.vue.js +1 -1
  22. package/dist/components/LpFormField.vue.js +1 -1
  23. package/dist/components/LpInput.vue.js +1 -1
  24. package/dist/components/LpModal.vue.js +1 -1
  25. package/dist/components/LpNotificationBell.vue.js +1 -1
  26. package/dist/components/LpPasswordInput.vue.js +1 -1
  27. package/dist/components/LpShift.vue.d.ts +36 -0
  28. package/dist/components/LpShift.vue.js +4 -0
  29. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  30. package/dist/components/LpTextarea.vue.js +1 -1
  31. package/dist/composables/useShift.d.ts +35 -0
  32. package/dist/composables/useShift.js +112 -0
  33. package/dist/index.d.ts +3 -0
  34. package/dist/index.js +45 -41
  35. package/package.json +1 -1
  36. package/src/component-names.ts +1 -0
  37. package/src/components/LpAppShell.vue +27 -7
  38. package/src/components/LpFileTree.vue +0 -4
  39. package/src/components/LpFileTreeNode.vue +0 -3
  40. package/src/components/LpFormField.vue +19 -2
  41. package/src/components/LpModal.vue +20 -128
  42. package/src/components/LpNotificationBell.vue +48 -42
  43. package/src/components/LpShift.vue +90 -0
  44. package/src/composables/useShift.ts +236 -0
  45. package/src/index.ts +3 -0
@@ -9,7 +9,8 @@ import {
9
9
  DialogTitle,
10
10
  } from "reka-ui"
11
11
  import type { ComponentPublicInstance } from "vue"
12
- import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
12
+ import { computed, ref, useSlots, watch } from "vue"
13
+ import { useShift } from "../composables/useShift"
13
14
  import { CLOSE_ICON } from "./dropdown"
14
15
  import LpIcon from "./LpIcon.vue"
15
16
  import LpScrollArea from "./LpScrollArea.vue"
@@ -86,20 +87,14 @@ const bodyPad = computed(() =>
86
87
 
87
88
  /*
88
89
  * Height is content-derived, so a body that arrives late — a placeholder swapped
89
- * for the loaded thing — resizes the panel in a single frame, right on top of
90
- * the open animation. CSS alone can't smooth it: `height: auto` isn't
91
- * interpolable, and the keyword-interpolation escape hatches
92
- * (`interpolate-size`, `calc-size()`) are Chromium-only, so they would do
93
- * nothing in the WebKitGTK launcher. Measuring and pinning a pixel height is
94
- * what works on every engine.
90
+ * for the loaded thing — would resize the panel in a single frame, right on top
91
+ * of the open animation. The easing is set up further down.
95
92
  *
96
93
  * The pin is always the panel's own natural height, so `max-h` still clips and
97
94
  * the body keeps `flex-1 min-h-0` — an over-tall panel hands its overflow to the
98
95
  * scroll area exactly as before. A panel whose size never moves is pinned once
99
96
  * and never transitions.
100
97
  */
101
- // reka renders a real element but exposes it as a component instance, so the
102
- // panel is reached through $el.
103
98
  const panelRef = ref<ComponentPublicInstance | null>(null)
104
99
 
105
100
  /**
@@ -156,130 +151,27 @@ function onOpenAutoFocus(event: Event) {
156
151
  return
157
152
  }
158
153
  }
159
- const tweening = ref(false)
160
- const resizing = ref(false)
161
-
162
- /** Kept in step with the `duration-fast` utility on the panel. */
163
- const TWEEN_MS = 160
164
-
165
- let ro: ResizeObserver | undefined
166
- let mo: MutationObserver | undefined
167
- let raf = 0
168
- let settleTimer: ReturnType<typeof setTimeout> | undefined
169
- /** Where the panel last settled, so a retune has a height to ease FROM. */
170
- let lastHeight = 0
171
- /** True while a tween owns the height, so observers don't cancel it midway. */
172
- let inFlight = false
173
-
174
- // The height is driven imperatively rather than through a style binding. By the
175
- // time replacement content is in the DOM the panel has already reflowed to its
176
- // new size, so a bound value would only ever be written once, with no earlier
177
- // height for the transition to start from. Writing the OLD height, forcing a
178
- // reflow, then writing the new one gives the transition both ends.
179
- const retune = (el: HTMLElement) => {
180
- // One swap wakes both observers — the mutation as content lands, the resize as
181
- // it reflows — and the second call would clear the pin the first just wrote.
182
- if (inFlight) return
183
- // Remembered, not measured: by the time either observer fires the panel has
184
- // already reflowed, so the element can only report where it is going.
185
- const from = lastHeight
186
- el.style.height = ""
187
- // `max-h` comes from a class, so the panel already reports the clipped figure
188
- // once the cap bites. Pinning that would leave a short panel sitting over
189
- // content it can no longer grow to fit, so the natural height is measured with
190
- // the cap lifted and the clamp is left to CSS afterwards.
191
- el.style.maxHeight = "none"
192
- const to = el.offsetHeight
193
- el.style.maxHeight = ""
194
- lastHeight = to
195
- // Nothing moved, so there is nothing to ease — and leaving the height unset
196
- // keeps the panel free to size itself.
197
- if (to === 0 || from === 0 || to === from) return
198
- el.style.height = `${from}px`
199
- void el.offsetHeight
200
- el.style.height = `${to}px`
201
-
202
- inFlight = true
203
- // While the panel is still catching up, the body is shorter than the content
204
- // it already holds, so the scroll area would flash a bar for an overflow that
205
- // resolves itself the moment the transition lands.
206
- resizing.value = true
207
- clearTimeout(settleTimer)
208
- settleTimer = setTimeout(() => {
209
- inFlight = false
210
- resizing.value = false
211
- // Released once the panel has arrived. A height left pinned in pixels stops
212
- // being a starting point and becomes a cap: content that grows afterwards
213
- // is clipped, and `max-h` can no longer size the panel itself.
214
- el.style.height = ""
215
- // Re-read rather than trusting `to`: `max-h` may have clamped the panel
216
- // short of it, and the next tween has to start from where it really sits.
217
- lastHeight = el.offsetHeight
218
- }, TWEEN_MS)
219
- }
154
+ // The panel's height eases between content-derived sizes — see
155
+ // `useShift`, which owns the measure/pin/tween mechanism and is shared
156
+ // with LpShift. Gated on `open`, because the panel is portalled and remounts
157
+ // on every open: a remount must measure afresh rather than ease in from the
158
+ // size the previous incarnation happened to hold.
159
+ const {
160
+ el: sizedEl,
161
+ tweening,
162
+ resizing,
163
+ } = useShift({ axis: "height", enabled: () => props.open })
220
164
 
221
- // Keyed off `open` rather than the ref: the panel is portalled and remounts on
222
- // every open, and the teardown has to run on close, when the ref is already gone.
165
+ // reka renders a real element but exposes it as a component instance, so the
166
+ // sized element is handed over through $el once the panel has mounted.
223
167
  watch(
224
- () => props.open,
225
- (isOpen) => {
226
- ro?.disconnect()
227
- mo?.disconnect()
228
- ro = undefined
229
- mo = undefined
230
- cancelAnimationFrame(raf)
231
- clearTimeout(settleTimer)
232
- tweening.value = false
233
- resizing.value = false
234
-
235
- lastHeight = 0
236
- inFlight = false
237
-
238
- const el = panelRef.value?.$el
239
- if (!isOpen || !(el instanceof HTMLElement)) return
240
-
241
- lastHeight = el.offsetHeight
242
- el.style.height = `${el.offsetHeight}px`
243
- // The opening panel must not animate its height in from the pre-content box,
244
- // so the transition only arms once that first pin has painted. The pin is
245
- // dropped in the same frame: it exists to give the first `retune` something
246
- // to start from, and holding it would cap the panel at its opening size.
247
- raf = requestAnimationFrame(() => {
248
- tweening.value = true
249
- el.style.height = ""
250
- })
251
-
252
- // Two triggers, since either can move the height alone: the resize observer
253
- // catches a child growing in place (an image decoding, a list filling in),
254
- // the mutation observer catches one subtree swapped for another, where every
255
- // observed box may keep its size. Children are observed, never the panel —
256
- // that would feed the pin straight back in.
257
- const watchChildren = () => {
258
- if (!ro) return
259
- ro.disconnect()
260
- for (const child of Array.from(el.children)) ro.observe(child)
261
- }
262
- if (typeof ResizeObserver !== "undefined") {
263
- ro = new ResizeObserver(() => retune(el))
264
- watchChildren()
265
- }
266
- if (typeof MutationObserver !== "undefined") {
267
- mo = new MutationObserver(() => {
268
- watchChildren()
269
- retune(el)
270
- })
271
- mo.observe(el, { childList: true, subtree: true, characterData: true })
272
- }
168
+ () => (props.open ? panelRef.value : null),
169
+ (panel) => {
170
+ const el = panel?.$el
171
+ sizedEl.value = el instanceof HTMLElement ? el : null
273
172
  },
274
173
  { flush: "post" },
275
174
  )
276
-
277
- onBeforeUnmount(() => {
278
- ro?.disconnect()
279
- mo?.disconnect()
280
- cancelAnimationFrame(raf)
281
- clearTimeout(settleTimer)
282
- })
283
175
  </script>
284
176
 
285
177
  <template>
@@ -16,6 +16,7 @@ import LpEmptyState from "./LpEmptyState.vue"
16
16
  import LpIcon from "./LpIcon.vue"
17
17
  import LpPopover from "./LpPopover.vue"
18
18
  import LpScrollArea from "./LpScrollArea.vue"
19
+ import LpShift from "./LpShift.vue"
19
20
 
20
21
  export interface NotificationItem {
21
22
  id: string
@@ -225,52 +226,57 @@ function timeAgo(iso?: string): string {
225
226
  </button>
226
227
  </header>
227
228
 
228
- <div v-if="loading" class="space-y-1.5 px-2 pb-2">
229
- <div v-for="i in 4" :key="i" class="h-12 animate-pulse rounded-control bg-surface-soft" />
230
- </div>
229
+ <!-- Skeleton → empty → feed are three different heights, so the panel
230
+ jumped as the list loaded and again whenever it emptied. Easing the
231
+ swap keeps the popover from snapping under the bell. -->
232
+ <LpShift axis="height">
233
+ <div v-if="loading" class="space-y-1.5 px-2 pb-2">
234
+ <div v-for="i in 4" :key="i" class="h-12 animate-pulse rounded-control bg-surface-soft" />
235
+ </div>
231
236
 
232
- <LpEmptyState
233
- v-else-if="!items.length"
234
- icon="lucide:bell-off"
235
- :title="l.empty"
236
- class="px-3 py-10"
237
- />
237
+ <LpEmptyState
238
+ v-else-if="!items.length"
239
+ icon="lucide:bell-off"
240
+ :title="l.empty"
241
+ class="px-3 py-10"
242
+ />
238
243
 
239
- <!-- The feed sits in a thin gutter; rows are self-contained tiles (rounded,
240
- hover-lit) instead of full-width strips with hard dividers — lighter and
241
- easier to scan. Each row carries its own right-click action menu. -->
242
- <LpScrollArea v-else class="max-h-96" content-class="flex flex-col gap-0.5 px-2 pb-2">
243
- <LpContextMenu v-for="item in items" :key="item.id" :items="rowMenu(item)">
244
- <component
245
- :is="item.link ? 'a' : 'button'"
246
- :href="item.link || undefined"
247
- :type="item.link ? undefined : 'button'"
248
- class="group/notif flex w-full items-start gap-2.5 rounded-control px-2 py-2 text-left outline-none transition-colors hover:bg-white/[0.05] focus-visible:bg-white/[0.05]"
249
- :class="item.read ? '' : 'bg-brand-soft/35'"
250
- @click="onSelect(item)"
251
- >
252
- <span
253
- class="grid size-7 shrink-0 place-items-center rounded-control"
254
- :class="item.read ? 'bg-surface-soft text-muted' : 'bg-brand-soft text-brand'"
244
+ <!-- The feed sits in a thin gutter; rows are self-contained tiles (rounded,
245
+ hover-lit) instead of full-width strips with hard dividers — lighter and
246
+ easier to scan. Each row carries its own right-click action menu. -->
247
+ <LpScrollArea v-else class="max-h-96" content-class="flex flex-col gap-0.5 px-2 pb-2">
248
+ <LpContextMenu v-for="item in items" :key="item.id" :items="rowMenu(item)">
249
+ <component
250
+ :is="item.link ? 'a' : 'button'"
251
+ :href="item.link || undefined"
252
+ :type="item.link ? undefined : 'button'"
253
+ class="group/notif flex w-full items-start gap-2.5 rounded-control px-2 py-2 text-left outline-none transition-colors hover:bg-white/[0.05] focus-visible:bg-white/[0.05]"
254
+ :class="item.read ? '' : 'bg-brand-soft/35'"
255
+ @click="onSelect(item)"
255
256
  >
256
- <LpIcon :name="item.icon || 'lucide:bell'" :size="15" />
257
- </span>
258
- <span class="min-w-0 flex-1">
259
- <span class="flex items-center gap-1.5">
260
- <span class="truncate text-sm font-medium" :class="item.read ? 'text-muted-strong' : 'text-ink'">
261
- {{ item.title }}
262
- </span>
263
- <span v-if="!item.read" class="size-1.5 shrink-0 rounded-full bg-brand" />
257
+ <span
258
+ class="grid size-7 shrink-0 place-items-center rounded-control"
259
+ :class="item.read ? 'bg-surface-soft text-muted' : 'bg-brand-soft text-brand'"
260
+ >
261
+ <LpIcon :name="item.icon || 'lucide:bell'" :size="15" />
264
262
  </span>
265
- <span v-if="item.body" class="mt-0.5 line-clamp-2 block text-xs text-muted">
266
- {{ item.body }}
267
- </span>
268
- <span v-if="item.createdAt" class="mt-0.5 block text-[11px] text-muted/70">
269
- {{ timeAgo(item.createdAt) }}
263
+ <span class="min-w-0 flex-1">
264
+ <span class="flex items-center gap-1.5">
265
+ <span class="truncate text-sm font-medium" :class="item.read ? 'text-muted-strong' : 'text-ink'">
266
+ {{ item.title }}
267
+ </span>
268
+ <span v-if="!item.read" class="size-1.5 shrink-0 rounded-full bg-brand" />
269
+ </span>
270
+ <span v-if="item.body" class="mt-0.5 line-clamp-2 block text-xs text-muted">
271
+ {{ item.body }}
272
+ </span>
273
+ <span v-if="item.createdAt" class="mt-0.5 block text-[11px] text-muted/70">
274
+ {{ timeAgo(item.createdAt) }}
275
+ </span>
270
276
  </span>
271
- </span>
272
- </component>
273
- </LpContextMenu>
274
- </LpScrollArea>
277
+ </component>
278
+ </LpContextMenu>
279
+ </LpScrollArea>
280
+ </LpShift>
275
281
  </LpPopover>
276
282
  </template>
@@ -0,0 +1,90 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Wraps content whose size is derived from what's inside it, so that everything
4
+ * around it shifts smoothly instead of jumping.
5
+ *
6
+ * Reach for it wherever a change of content changes a box's measurements: a
7
+ * toolbar that gains or loses a button, a label swapped by a translation, a
8
+ * placeholder replaced by the loaded thing, a validation message appearing
9
+ * under a field. Those all resize in a single frame otherwise, and everything
10
+ * they push jolts with them.
11
+ *
12
+ * <LpShift axis="width">
13
+ * <slot name="actions" />
14
+ * </LpShift>
15
+ *
16
+ * Note it is the NEIGHBOURS that motivate this, not the box: a fixed-height
17
+ * header never resizes, yet its controls jump whenever one of them changes
18
+ * width. Easing that one box is what makes the row glide.
19
+ *
20
+ * Content is measured through a wrapper that is never itself pinned, so the
21
+ * slot keeps whatever layout it brings (flex row, grid, plain text). `axis`
22
+ * picks which dimension is content-derived: pin only the one that actually
23
+ * moves, so the other stays free to respond to the surrounding layout.
24
+ *
25
+ * The mechanism — measure the natural size, pin it in pixels, tween — lives in
26
+ * `useShift`, and LpModal drives its panel with the same composable.
27
+ */
28
+ import { computed } from "vue"
29
+ import { useShift, type ShiftAxis } from "../composables/useShift"
30
+
31
+ const props = withDefaults(
32
+ defineProps<{
33
+ /**
34
+ * Which dimension eases. `"width"` for a row that gains/loses controls,
35
+ * `"height"` for a stack whose content grows, `"both"` when either can.
36
+ */
37
+ axis?: ShiftAxis
38
+ /** Tween length in ms. Defaults to the kit's `--duration-fast`. */
39
+ duration?: number
40
+ /**
41
+ * Turn the easing off entirely — the wrapper stays, the box resizes
42
+ * instantly. For a box that is being driven by something else (a drag, a
43
+ * canvas), where a tween would fight the thing already animating it.
44
+ */
45
+ disabled?: boolean
46
+ }>(),
47
+ { axis: "height", disabled: false },
48
+ )
49
+
50
+ const { el, tweening, resizing } = useShift({
51
+ axis: () => props.axis,
52
+ duration: () => props.duration,
53
+ enabled: () => !props.disabled,
54
+ })
55
+
56
+ // Only the pinned axis is transitioned: naming the other one would animate a
57
+ // dimension the layout is still free to set, so a flex row settling into place
58
+ // would ease sideways for no reason.
59
+ //
60
+ // Spelled out as whole literals rather than built from `axis`. The consuming
61
+ // app's Tailwind scans this file as TEXT, so a class assembled at runtime is a
62
+ // class it never sees — the utility simply wouldn't be emitted.
63
+ const TRANSITION_CLASS: Record<ShiftAxis, string> = {
64
+ height: "transition-[height]",
65
+ width: "transition-[width]",
66
+ both: "transition-[height,width]",
67
+ }
68
+ const transitionClass = computed(() => TRANSITION_CLASS[props.axis])
69
+
70
+ defineExpose({ resizing })
71
+ </script>
72
+
73
+ <template>
74
+ <!-- `overflow-hidden` only while a tween owns the size: the box is briefly
75
+ smaller than the content it already holds, and without the clip that
76
+ content spills past the easing edge. Dropped once settled, so nothing
77
+ that legitimately overflows — a dropdown, a focus ring — is clipped at
78
+ rest. -->
79
+ <div
80
+ ref="el"
81
+ :class="[
82
+ tweening && !disabled
83
+ ? `${transitionClass} duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none`
84
+ : '',
85
+ resizing ? 'overflow-hidden' : '',
86
+ ]"
87
+ >
88
+ <slot :resizing="resizing" />
89
+ </div>
90
+ </template>
@@ -0,0 +1,236 @@
1
+ import { onBeforeUnmount, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from "vue"
2
+
3
+ /*
4
+ * "Let this box shift its neighbours smoothly instead of jolting them."
5
+ *
6
+ * A box sized by what's inside it resizes in a single frame: a button appears
7
+ * in a toolbar, a translation swaps a short word for a long one, a placeholder
8
+ * is replaced by the loaded thing — and the layout snaps. Every neighbour it
9
+ * pushes snaps with it.
10
+ *
11
+ * The easing is applied to the BOX, but the point of it is the neighbours. A
12
+ * header of fixed height never resizes, yet the controls inside it jump the
13
+ * moment one of them changes width; giving that one box its width back
14
+ * gradually is what turns the jump into a glide. So this is named for what the
15
+ * user sees — things shifting — not for the measurement that achieves it.
16
+ *
17
+ * CSS alone cannot smooth this. `width`/`height: auto` are not interpolable,
18
+ * and the keyword-interpolation escape hatches (`interpolate-size`,
19
+ * `calc-size()`) are Chromium-only, so they'd do nothing in the WebKitGTK
20
+ * launcher. Measuring the natural size and pinning it in pixels is what works
21
+ * on every engine.
22
+ *
23
+ * This was first solved inside LpModal, whose panel grows as its body arrives.
24
+ * The mechanism is the same wherever content drives size, so it lives here and
25
+ * LpModal consumes it — see also LpShift, the wrapper form.
26
+ */
27
+
28
+ export type ShiftAxis = "height" | "width" | "both"
29
+
30
+ export interface UseShiftOptions {
31
+ /** Which dimension is content-derived. Default `"height"`. */
32
+ axis?: MaybeRefOrGetter<ShiftAxis | undefined>
33
+ /** Tween length. Defaults to the kit's `--duration-fast` (160ms). */
34
+ duration?: MaybeRefOrGetter<number | undefined>
35
+ /**
36
+ * Gate: while falsy the element is left entirely alone — no observers, no
37
+ * pinning. Use it for a box that only exists while something is open, so a
38
+ * remounted element starts measuring from scratch rather than easing in from
39
+ * a size it held in a previous life. Default: always active.
40
+ */
41
+ enabled?: MaybeRefOrGetter<unknown>
42
+ }
43
+
44
+ export interface UseShift {
45
+ /** Attach to the element whose content-derived size should ease. */
46
+ el: Ref<HTMLElement | null>
47
+ /**
48
+ * True while a tween owns the size. Bind the transition utilities off this,
49
+ * so the element is only transition-enabled once it has a size to ease FROM
50
+ * (otherwise the very first pin animates in from zero).
51
+ */
52
+ tweening: Ref<boolean>
53
+ /**
54
+ * True while the element is still catching up to its new size. During the
55
+ * tween the box is briefly smaller than the content it already holds, so any
56
+ * scroll region inside it would raise a bar for an overflow that resolves
57
+ * itself as the tween lands — hide the bar (not the scrolling) on this.
58
+ */
59
+ resizing: Ref<boolean>
60
+ /** Re-measure and ease to the current natural size. Rarely needed by hand. */
61
+ retune: () => void
62
+ }
63
+
64
+ /** Kept in step with the `duration-fast` token the transition utilities use. */
65
+ const TWEEN_MS = 160
66
+
67
+ export function useShift(
68
+ options: UseShiftOptions = {},
69
+ ): UseShift {
70
+ const el = ref<HTMLElement | null>(null)
71
+ const tweening = ref(false)
72
+ const resizing = ref(false)
73
+
74
+ let ro: ResizeObserver | undefined
75
+ let mo: MutationObserver | undefined
76
+ let raf = 0
77
+ let settleTimer: ReturnType<typeof setTimeout> | undefined
78
+ /** Where the box last settled, so a retune has a size to ease FROM. */
79
+ let lastHeight = 0
80
+ let lastWidth = 0
81
+ /** True while a tween owns the size, so observers don't cancel it midway. */
82
+ let inFlight = false
83
+
84
+ const axisOf = () => toValue(options.axis) ?? "height"
85
+ const wantsHeight = () => axisOf() !== "width"
86
+ const wantsWidth = () => axisOf() !== "height"
87
+
88
+ function reducedMotion() {
89
+ return (
90
+ typeof window !== "undefined" &&
91
+ window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
92
+ )
93
+ }
94
+
95
+ /*
96
+ * The size is driven imperatively rather than through a style binding. By the
97
+ * time replacement content is in the DOM the box has already reflowed to its
98
+ * new size, so a bound value would only ever be written once, with no earlier
99
+ * size for the transition to start from. Writing the OLD size, forcing a
100
+ * reflow, then writing the new one gives the transition both ends.
101
+ */
102
+ function retune() {
103
+ const node = el.value
104
+ if (!node) return
105
+ // One swap wakes both observers — the mutation as content lands, the resize
106
+ // as it reflows — and the second call would clear the pin the first wrote.
107
+ if (inFlight) return
108
+
109
+ // Remembered, not measured: by the time either observer fires the box has
110
+ // already reflowed, so the element can only report where it is going.
111
+ const fromHeight = lastHeight
112
+ const fromWidth = lastWidth
113
+
114
+ // Measured with any cap lifted, then the clamp is left to CSS. A `max-*`
115
+ // from a class makes the element report the clipped figure once the cap
116
+ // bites, and pinning that would hold the box short of the content it now
117
+ // has to fit.
118
+ if (wantsHeight()) {
119
+ node.style.height = ""
120
+ node.style.maxHeight = "none"
121
+ }
122
+ if (wantsWidth()) {
123
+ node.style.width = ""
124
+ node.style.maxWidth = "none"
125
+ }
126
+ const toHeight = wantsHeight() ? node.offsetHeight : 0
127
+ const toWidth = wantsWidth() ? node.offsetWidth : 0
128
+ if (wantsHeight()) node.style.maxHeight = ""
129
+ if (wantsWidth()) node.style.maxWidth = ""
130
+ lastHeight = toHeight
131
+ lastWidth = toWidth
132
+
133
+ // Nothing moved on either axis, so there is nothing to ease — and leaving
134
+ // the size unset keeps the box free to size itself.
135
+ //
136
+ // Only the FROM size has to be non-zero. A target of zero is a real
137
+ // collapse — the last message cleared, the list emptied — and is exactly
138
+ // the case worth easing; an earlier guard against `to === 0` silently
139
+ // dropped it, so content vanishing still snapped. A zero FROM is the
140
+ // different thing it was meant to catch: the box was not in the layout yet,
141
+ // so there is no size to ease out of.
142
+ const movesHeight =
143
+ wantsHeight() && fromHeight !== 0 && toHeight !== fromHeight
144
+ const movesWidth = wantsWidth() && fromWidth !== 0 && toWidth !== fromWidth
145
+ if (!movesHeight && !movesWidth) return
146
+
147
+ if (movesHeight) node.style.height = `${fromHeight}px`
148
+ if (movesWidth) node.style.width = `${fromWidth}px`
149
+ void node.offsetHeight
150
+ if (movesHeight) node.style.height = `${toHeight}px`
151
+ if (movesWidth) node.style.width = `${toWidth}px`
152
+
153
+ inFlight = true
154
+ resizing.value = true
155
+ clearTimeout(settleTimer)
156
+ const wait = reducedMotion() ? 0 : (toValue(options.duration) ?? TWEEN_MS)
157
+ settleTimer = setTimeout(() => {
158
+ inFlight = false
159
+ resizing.value = false
160
+ // Released once the box has arrived. A size left pinned in pixels stops
161
+ // being a starting point and becomes a cap: content that grows afterwards
162
+ // is clipped, and `max-*` can no longer size the element itself.
163
+ node.style.height = ""
164
+ node.style.width = ""
165
+ // Re-read rather than trusting the target: a `max-*` may have clamped the
166
+ // box short of it, and the next tween has to start from where it sits.
167
+ lastHeight = node.offsetHeight
168
+ lastWidth = node.offsetWidth
169
+ }, wait)
170
+ }
171
+
172
+ function teardown() {
173
+ ro?.disconnect()
174
+ mo?.disconnect()
175
+ ro = undefined
176
+ mo = undefined
177
+ cancelAnimationFrame(raf)
178
+ clearTimeout(settleTimer)
179
+ tweening.value = false
180
+ resizing.value = false
181
+ lastHeight = 0
182
+ lastWidth = 0
183
+ inFlight = false
184
+ }
185
+
186
+ // Keyed off the element AND the gate: a portalled box remounts on every open,
187
+ // and the teardown has to run on close, when the ref is already gone.
188
+ watch(
189
+ [el, () => toValue(options.enabled) ?? true],
190
+ ([node, enabled]) => {
191
+ teardown()
192
+ if (!enabled || !node) return
193
+
194
+ lastHeight = node.offsetHeight
195
+ lastWidth = node.offsetWidth
196
+ if (wantsHeight()) node.style.height = `${lastHeight}px`
197
+ if (wantsWidth()) node.style.width = `${lastWidth}px`
198
+ // A box must not animate in from its pre-content size, so the transition
199
+ // only arms once that first pin has painted. The pin is dropped in the
200
+ // same frame: it exists to give the first `retune` something to start
201
+ // from, and holding it would cap the box at its opening size.
202
+ raf = requestAnimationFrame(() => {
203
+ tweening.value = true
204
+ node.style.height = ""
205
+ node.style.width = ""
206
+ })
207
+
208
+ // Two triggers, since either can move the size alone: the resize observer
209
+ // catches a child growing in place (an image decoding, a list filling
210
+ // in), the mutation observer catches one subtree swapped for another,
211
+ // where every observed box may keep its size. Children are observed,
212
+ // never the element itself — that would feed the pin straight back in.
213
+ const watchChildren = () => {
214
+ if (!ro) return
215
+ ro.disconnect()
216
+ for (const child of Array.from(node.children)) ro.observe(child)
217
+ }
218
+ if (typeof ResizeObserver !== "undefined") {
219
+ ro = new ResizeObserver(() => retune())
220
+ watchChildren()
221
+ }
222
+ if (typeof MutationObserver !== "undefined") {
223
+ mo = new MutationObserver(() => {
224
+ watchChildren()
225
+ retune()
226
+ })
227
+ mo.observe(node, { childList: true, subtree: true, characterData: true })
228
+ }
229
+ },
230
+ { flush: "post" },
231
+ )
232
+
233
+ onBeforeUnmount(teardown)
234
+
235
+ return { el, tweening, resizing, retune }
236
+ }
package/src/index.ts CHANGED
@@ -99,6 +99,7 @@ export { default as LpSelect } from "./components/LpSelect.vue"
99
99
  export type { SelectOption, SelectValue } from "./components/LpSelect.vue"
100
100
  export { default as LpServiceNode } from "./components/LpServiceNode.vue"
101
101
  export type { ServiceNodeData } from "./components/LpServiceNode.vue"
102
+ export { default as LpShift } from "./components/LpShift.vue"
102
103
  export { default as LpShortcutsDialog } from "./components/LpShortcutsDialog.vue"
103
104
  export type { ShortcutEntry, ShortcutGroup } from "./components/LpShortcutsDialog.vue"
104
105
  export { default as LpSidebar } from "./components/LpSidebar.vue"
@@ -192,6 +193,8 @@ export { useRovingFocus } from "./composables/useRovingFocus"
192
193
  export type { RovingItem, UseRovingFocusOptions } from "./composables/useRovingFocus"
193
194
  export { useSettled } from "./composables/useSettled"
194
195
  export type { UseSettledOptions } from "./composables/useSettled"
196
+ export { useShift } from "./composables/useShift"
197
+ export type { ShiftAxis, UseShift, UseShiftOptions } from "./composables/useShift"
195
198
  export { useTilt } from "./composables/useTilt"
196
199
  export type { UseTilt, UseTiltOptions } from "./composables/useTilt"
197
200
  export { useZoomPan } from "./composables/useZoomPan"