@leavepulse/ui 0.33.0 → 0.33.1

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.
@@ -6,7 +6,7 @@ import { _ as _sfc_main$3 } from "./LpButton-5VXXCjwF.js";
6
6
  import { _ as _sfc_main$1 } from "./LpFormField-CazBc2-a.js";
7
7
  import { _ as _sfc_main$4 } from "./LpIcon-CCnX5_2j.js";
8
8
  import { _ as _sfc_main$5 } from "./LpInput-9EXWUM9B.js";
9
- import { _ as _sfc_main$7 } from "./LpModal-D_o-56To.js";
9
+ import { _ as _sfc_main$7 } from "./LpModal-DAqXzsm-.js";
10
10
  import { _ as _sfc_main$6 } from "./LpSelect-ANIVtrpM.js";
11
11
  import { useMergedAttrs } from "../composables/useMergedClass.js";
12
12
  const _hoisted_1 = { class: "flex items-stretch gap-2" };
@@ -1,7 +1,7 @@
1
1
  import { defineComponent, computed, openBlock, createBlock, createSlots, withCtx, renderSlot, createVNode, createTextVNode, toDisplayString, createElementVNode, normalizeClass } from "vue";
2
2
  import { _ as _sfc_main$2 } from "./LpButton-5VXXCjwF.js";
3
3
  import { _ as _sfc_main$3 } from "./LpIcon-CCnX5_2j.js";
4
- import { _ as _sfc_main$1 } from "./LpModal-D_o-56To.js";
4
+ import { _ as _sfc_main$1 } from "./LpModal-DAqXzsm-.js";
5
5
  const _hoisted_1 = { class: "inline-flex items-center gap-2.5" };
6
6
  const _sfc_main = /* @__PURE__ */ defineComponent({
7
7
  __name: "LpConfirmDialog",
@@ -36,7 +36,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
36
36
  }
37
37
  }
38
38
  const { class: rootClass, attrs: rest } = useMergedAttrs(
39
- "overflow-hidden rounded-control border border-line bg-surface-soft"
39
+ "shrink-0 overflow-hidden rounded-control border border-line bg-surface-soft"
40
40
  );
41
41
  return (_ctx, _cache) => {
42
42
  return openBlock(), createBlock(unref(CollapsibleRoot), mergeProps({
@@ -1,16 +1,16 @@
1
- import { defineComponent, computed, useSlots, ref, watch, openBlock, createBlock, unref, withCtx, createVNode, createElementVNode, mergeProps, createElementBlock, renderSlot, createTextVNode, toDisplayString, createCommentVNode, normalizeClass } from "vue";
1
+ import { defineComponent, computed, useSlots, watch, onBeforeUnmount, ref, openBlock, createBlock, unref, withCtx, createVNode, normalizeStyle, createElementVNode, mergeProps, createElementBlock, renderSlot, createTextVNode, toDisplayString, createCommentVNode, normalizeClass } from "vue";
2
2
  import { DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose } from "reka-ui";
3
3
  import { useShift } from "../composables/useShift.js";
4
+ import { useModalLayer } from "../components/modalDepth.js";
4
5
  import { CLOSE_ICON } from "../components/dropdown.js";
5
6
  import { _ as _sfc_main$1 } from "./LpIcon-CCnX5_2j.js";
6
7
  import { _ as _sfc_main$2 } from "./LpScrollArea-D8xyKXcF.js";
7
- const _hoisted_1 = { class: "fixed inset-0 z-(--z-modal) flex items-center justify-center pointer-events-none" };
8
- const _hoisted_2 = {
8
+ const _hoisted_1 = {
9
9
  key: 0,
10
10
  class: "flex shrink-0 items-start justify-between gap-4 p-5 pb-3"
11
11
  };
12
- const _hoisted_3 = { class: "flex flex-col gap-1" };
13
- const _hoisted_4 = {
12
+ const _hoisted_2 = { class: "flex flex-col gap-1" };
13
+ const _hoisted_3 = {
14
14
  key: 3,
15
15
  class: "flex shrink-0 justify-end gap-2 p-5 pt-4"
16
16
  };
@@ -27,6 +27,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
27
27
  },
28
28
  emits: ["update:open"],
29
29
  setup(__props) {
30
+ const { claim, release, layer } = useModalLayer();
30
31
  const props = __props;
31
32
  const describedByAttrs = computed(
32
33
  () => props.description ? {} : { "aria-describedby": void 0 }
@@ -44,6 +45,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
44
45
  }[props.size];
45
46
  });
46
47
  const slots = useSlots();
48
+ watch(() => props.open, (open) => open ? claim() : release(), { immediate: true });
49
+ onBeforeUnmount(release);
47
50
  const bodyPad = computed(
48
51
  () => [
49
52
  "px-5",
@@ -103,8 +106,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
103
106
  default: withCtx(() => [
104
107
  createVNode(unref(DialogPortal), null, {
105
108
  default: withCtx(() => [
106
- createVNode(unref(DialogOverlay), { class: "lp-scrim fixed inset-0 z-(--z-overlay) data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]" }),
107
- createElementVNode("div", _hoisted_1, [
109
+ createVNode(unref(DialogOverlay), {
110
+ class: "lp-scrim fixed inset-0 data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]",
111
+ style: normalizeStyle({ zIndex: unref(layer).scrim })
112
+ }, null, 8, ["style"]),
113
+ createElementVNode("div", {
114
+ class: "fixed inset-0 flex items-center justify-center pointer-events-none",
115
+ style: normalizeStyle({ zIndex: unref(layer).panel })
116
+ }, [
108
117
  createVNode(unref(DialogContent), mergeProps({
109
118
  ref_key: "panelRef",
110
119
  ref: panelRef,
@@ -112,8 +121,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
112
121
  style: __props.width ? { width: __props.width } : void 0
113
122
  }, describedByAttrs.value, { onOpenAutoFocus }), {
114
123
  default: withCtx(() => [
115
- __props.title || _ctx.$slots.title ? (openBlock(), createElementBlock("header", _hoisted_2, [
116
- createElementVNode("div", _hoisted_3, [
124
+ __props.title || _ctx.$slots.title ? (openBlock(), createElementBlock("header", _hoisted_1, [
125
+ createElementVNode("div", _hoisted_2, [
117
126
  createVNode(unref(DialogTitle), { class: "text-base font-semibold text-ink" }, {
118
127
  default: withCtx(() => [
119
128
  renderSlot(_ctx.$slots, "title", {}, () => [
@@ -162,13 +171,13 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
162
171
  ]),
163
172
  _: 3
164
173
  }, 8, ["class", "content-class"])),
165
- _ctx.$slots.footer ? (openBlock(), createElementBlock("footer", _hoisted_4, [
174
+ _ctx.$slots.footer ? (openBlock(), createElementBlock("footer", _hoisted_3, [
166
175
  renderSlot(_ctx.$slots, "footer")
167
176
  ])) : createCommentVNode("", true)
168
177
  ]),
169
178
  _: 3
170
179
  }, 16, ["class", "style"])
171
- ])
180
+ ], 4)
172
181
  ]),
173
182
  _: 3
174
183
  })
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpAddressInput-C35TsJXt.js";
1
+ import { _ as _sfc_main } from "../chunks/LpAddressInput-FZXfljWV.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpConfirmDialog-CjOGcj1e.js";
1
+ import { _ as _sfc_main } from "../chunks/LpConfirmDialog-CQBVaLpo.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpDisclosure-y7wDBgLa.js";
1
+ import { _ as _sfc_main } from "../chunks/LpDisclosure-ChQYmjlR.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpModal-D_o-56To.js";
1
+ import { _ as _sfc_main } from "../chunks/LpModal-DAqXzsm-.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,5 +1,5 @@
1
1
  import { defineComponent, openBlock, createBlock, withCtx, createElementVNode, createElementBlock, Fragment, renderList, toDisplayString, unref } from "vue";
2
- import { _ as _sfc_main$1 } from "../chunks/LpModal-D_o-56To.js";
2
+ import { _ as _sfc_main$1 } from "../chunks/LpModal-DAqXzsm-.js";
3
3
  import { appleModifierLabel } from "../composables/useHotkeys.js";
4
4
  import { _ as _export_sfc } from "../chunks/_plugin-vue_export-helper-1tPrXgE0.js";
5
5
  import '../assets/LpShortcutsDialog-B7H-RZLQ.css';const _hoisted_1 = { class: "shortcut-groups" };
@@ -0,0 +1,13 @@
1
+ import { ComputedRef } from 'vue';
2
+ /**
3
+ * Register a dialog as open and get back the z-indexes it should paint with.
4
+ * The result is reactive: opening another dialog moves this one down a step.
5
+ */
6
+ export declare function useModalLayer(): {
7
+ claim: () => void;
8
+ release: () => void;
9
+ layer: ComputedRef<{
10
+ scrim: string;
11
+ panel: string;
12
+ }>;
13
+ };
@@ -0,0 +1,29 @@
1
+ import { ref, computed } from "vue";
2
+ const STEP = 10;
3
+ const stack = ref([]);
4
+ let nextId = 0;
5
+ function useModalLayer() {
6
+ const id = nextId++;
7
+ const fromTop = computed(() => {
8
+ const at = stack.value.indexOf(id);
9
+ return at === -1 ? 0 : stack.value.length - 1 - at;
10
+ });
11
+ return {
12
+ claim: () => {
13
+ if (!stack.value.includes(id)) stack.value = [...stack.value, id];
14
+ },
15
+ release: () => {
16
+ stack.value = stack.value.filter((x) => x !== id);
17
+ },
18
+ layer: computed(() => {
19
+ const drop = fromTop.value * STEP;
20
+ return {
21
+ scrim: `calc(var(--z-overlay) - ${drop})`,
22
+ panel: `calc(var(--z-modal) - ${drop})`
23
+ };
24
+ })
25
+ };
26
+ }
27
+ export {
28
+ useModalLayer
29
+ };
@@ -1,6 +1,7 @@
1
1
  import { ref, watch, toValue, onBeforeUnmount } from "vue";
2
2
  import { prefersReducedMotion } from "./easing.js";
3
3
  const TWEEN_MS = 160;
4
+ const RETARGET_AFTER_MS = 40;
4
5
  function useShift(options = {}) {
5
6
  const el = ref(null);
6
7
  const tweening = ref(false);
@@ -12,13 +13,19 @@ function useShift(options = {}) {
12
13
  let lastHeight = 0;
13
14
  let lastWidth = 0;
14
15
  let inFlight = false;
16
+ let tweenStart = 0;
15
17
  const axisOf = () => toValue(options.axis) ?? "height";
16
18
  const wantsHeight = () => axisOf() !== "width";
17
19
  const wantsWidth = () => axisOf() !== "height";
18
20
  function retune() {
19
21
  const node = el.value;
20
22
  if (!node) return;
21
- if (inFlight) return;
23
+ if (inFlight) {
24
+ if (performance.now() - tweenStart < RETARGET_AFTER_MS) return;
25
+ const live = node.getBoundingClientRect();
26
+ lastHeight = Math.round(live.height);
27
+ lastWidth = Math.round(live.width);
28
+ }
22
29
  const fromHeight = lastHeight;
23
30
  const fromWidth = lastWidth;
24
31
  if (wantsHeight()) {
@@ -44,6 +51,7 @@ function useShift(options = {}) {
44
51
  if (movesHeight) node.style.height = `${toHeight}px`;
45
52
  if (movesWidth) node.style.width = `${toWidth}px`;
46
53
  inFlight = true;
54
+ tweenStart = performance.now();
47
55
  resizing.value = true;
48
56
  clearTimeout(settleTimer);
49
57
  const wait = prefersReducedMotion() ? 0 : toValue(options.duration) ?? TWEEN_MS;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { _ } from "./chunks/LayoutCanvas-Dd20boUG.js";
2
- import { _ as _2 } from "./chunks/LpAddressInput-C35TsJXt.js";
2
+ import { _ as _2 } from "./chunks/LpAddressInput-FZXfljWV.js";
3
3
  import { _ as _3 } from "./chunks/LpMapPicker-ul4oYno6.js";
4
4
  import { OSM_TILES, createNominatimProvider, emptyAddress, formatAddress, project, tileUrl, unproject } from "./components/address.js";
5
5
  import { _ as _4 } from "./chunks/LpActionBar-BlvtGB2P.js";
@@ -19,10 +19,10 @@ import { _ as _15 } from "./chunks/LpCodeBlock-Bo0B4TFR.js";
19
19
  import { _ as _16 } from "./chunks/LpColorPicker-CFC6iaNK.js";
20
20
  import { DEFAULT_SWATCHES, clampHsv, formatColor, hsvToHsl, hsvToRgb, isLight, normalizeHue, opaqueCss, parseColor, parseHex, rgbToHex, rgbToHsv } from "./components/color.js";
21
21
  import { default as default3 } from "./components/LpCommandPalette.vue.js";
22
- import { _ as _17 } from "./chunks/LpConfirmDialog-CjOGcj1e.js";
22
+ import { _ as _17 } from "./chunks/LpConfirmDialog-CQBVaLpo.js";
23
23
  import { _ as _18 } from "./chunks/LpContextMenu-B6WBLleS.js";
24
24
  import { _ as _19 } from "./chunks/LpDatePicker-6JFXllj5.js";
25
- import { _ as _20 } from "./chunks/LpDisclosure-y7wDBgLa.js";
25
+ import { _ as _20 } from "./chunks/LpDisclosure-ChQYmjlR.js";
26
26
  import { _ as _21 } from "./chunks/LpDivider-CUX46KR0.js";
27
27
  import { _ as _22 } from "./chunks/LpDrawer-DAJN5rGK.js";
28
28
  import { _ as _23 } from "./chunks/LpDropdownMenu-DJpoqRCb.js";
@@ -40,7 +40,7 @@ import { fileNameOf } from "./components/lightbox.js";
40
40
  import { _ as _31 } from "./chunks/LpLink-DCND-zyu.js";
41
41
  import { _ as _32 } from "./chunks/LpLogViewer-y53ZqSVq.js";
42
42
  import { _ as _33 } from "./chunks/LpMediaGallery-Cid7PGYx.js";
43
- import { _ as _34 } from "./chunks/LpModal-D_o-56To.js";
43
+ import { _ as _34 } from "./chunks/LpModal-DAqXzsm-.js";
44
44
  import { _ as _35 } from "./chunks/LpNotificationBell-lHePf89W.js";
45
45
  import { _ as _36 } from "./chunks/LpNumberField-CWuX08iX.js";
46
46
  import { _ as _37 } from "./chunks/LpNumberFlow-amWYaeuA.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.33.0",
3
+ "version": "0.33.1",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -75,8 +75,23 @@ function onToggle(value: boolean) {
75
75
  }
76
76
 
77
77
  // The disclosure carries a bordered control frame; stacking several into one list means removing the per-item border from the outside.
78
+ /*
79
+ * shrink-0 is load-bearing inside a flex column.
80
+ *
81
+ * A disclosure is nearly always in one — the body of a dialog, a settings
82
+ * column, a form. A flex item defaults to `flex-shrink: 1`, so when that column
83
+ * runs out of room every child is squeezed, and this one hides the evidence
84
+ * with its own `overflow-hidden`: measured in a dialog whose content overflows,
85
+ * the root sat at 22px while the trigger inside it was 40px — a header cut in
86
+ * half, spilling past the panel, and snapping back and forth by 20px as the
87
+ * surrounding layout settled.
88
+ *
89
+ * A collapsible owns its height: closed it is exactly its header, open it is
90
+ * the header plus the animating body. Nothing outside gets to shorten that —
91
+ * the column it sits in is the thing that should scroll instead.
92
+ */
78
93
  const { class: rootClass, attrs: rest } = useMergedAttrs(
79
- "overflow-hidden rounded-control border border-line bg-surface-soft",
94
+ "shrink-0 overflow-hidden rounded-control border border-line bg-surface-soft",
80
95
  )
81
96
  </script>
82
97
 
@@ -9,12 +9,32 @@ import {
9
9
  DialogTitle,
10
10
  } from "reka-ui"
11
11
  import type { ComponentPublicInstance } from "vue"
12
- import { computed, ref, useSlots, watch } from "vue"
12
+ import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
13
13
  import { useShift } from "../composables/useShift"
14
+ import { useModalLayer } from "./modalDepth"
14
15
  import { CLOSE_ICON } from "./dropdown"
15
16
  import LpIcon from "./LpIcon.vue"
16
17
  import LpScrollArea from "./LpScrollArea.vue"
17
18
 
19
+ /*
20
+ * Stacking depth, so a dialog opened FROM a dialog sits above the one that
21
+ * opened it — scrim included.
22
+ *
23
+ * The fixed rungs cannot express this on their own. Every modal took
24
+ * --z-overlay for its scrim and --z-modal for its panel, so a nested pair
25
+ * landed at exactly the heights of the outer pair: the second scrim, at 100,
26
+ * sat UNDER the first panel at 110. It still darkened the page, which is why
27
+ * this reads as "the blur is missing" rather than as a stacking bug — a
28
+ * backdrop-filter only blurs what is painted beneath it, and the panel it was
29
+ * supposed to blur was above.
30
+ *
31
+ * Each level adds a step, keeping the scrim one below its own panel and both
32
+ * above everything the level beneath put on screen. The same problem, and the
33
+ * same fix, as the lightbox's own rungs in tokens.css — only the depth here is
34
+ * not known until a dialog finds itself inside another.
35
+ */
36
+ const { claim, release, layer } = useModalLayer()
37
+
18
38
  const props = withDefaults(
19
39
  defineProps<{
20
40
  open?: boolean
@@ -75,6 +95,17 @@ const widthClass = computed(() => {
75
95
  const slots = useSlots()
76
96
  // The body clips overflow and focus rings are drawn outside their control, so a
77
97
  // field flush against the top edge lost its ring; pt-1 clears the ring's width.
98
+ /*
99
+ * Claim a level as this dialog opens, give it back as it closes. `immediate`
100
+ * matters: a dialog that starts out open has to have its rung before the first
101
+ * paint, or its scrim lands on the default one for a frame.
102
+ */
103
+ watch(() => props.open, (open) => (open ? claim() : release()), { immediate: true })
104
+
105
+ // A dialog torn down while still open would otherwise stay on the stack, holding
106
+ // every dialog under it a step lower than it should be.
107
+ onBeforeUnmount(release)
108
+
78
109
  const bodyPad = computed(() =>
79
110
  [
80
111
  "px-5",
@@ -178,7 +209,8 @@ watch(
178
209
  <DialogRoot :open="open" @update:open="(v) => $emit('update:open', v)">
179
210
  <DialogPortal>
180
211
  <DialogOverlay
181
- class="lp-scrim fixed inset-0 z-(--z-overlay) data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]"
212
+ class="lp-scrim fixed inset-0 data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]"
213
+ :style="{ zIndex: layer.scrim }"
182
214
  />
183
215
  <!-- Centred by a full-screen flex wrapper rather than by translating the
184
216
  panel off its own centre. With `top:50% / -translate-y-1/2` the panel
@@ -193,7 +225,10 @@ watch(
193
225
  this wrapper: `max-h-full` on a centred flex child resolves against a
194
226
  box it is free to overflow, so a tall body stopped handing its
195
227
  overflow to the scroll area below and simply ran off-screen. -->
196
- <div class="fixed inset-0 z-(--z-modal) flex items-center justify-center pointer-events-none">
228
+ <div
229
+ class="fixed inset-0 flex items-center justify-center pointer-events-none"
230
+ :style="{ zIndex: layer.panel }"
231
+ >
197
232
  <!-- Without a description reka-ui warns on every open, and the opt-out it
198
233
  checks for is an ABSENT `aria-describedby` — despite the message
199
234
  naming the string "undefined" (a leftover from Radix, where
@@ -0,0 +1,72 @@
1
+ /*
2
+ * Stacking order for dialogs that are open at the same time.
3
+ *
4
+ * Not provide/inject, which was the first shape tried: it only works when the
5
+ * second dialog is rendered INSIDE the first one's subtree, and the ordinary
6
+ * way to write a dialog opened from a dialog is to put both at the top level of
7
+ * the same component and toggle them with separate flags. Those two are
8
+ * siblings, so the inner one injects nothing and lands on the same rungs as the
9
+ * outer — which is the bug.
10
+ *
11
+ * What decides the order is the sequence of OPENING: a dialog opened while
12
+ * another is up belongs above it, wherever it sits in the markup.
13
+ *
14
+ * The newest dialog keeps the scale's own --z-overlay/--z-modal and the ones
15
+ * beneath are pushed DOWN, rather than each new dialog climbing higher. Climbing
16
+ * runs out of room immediately: --z-popover is only 10 above --z-modal, so the
17
+ * second nested dialog would tie with every popover and the third with every
18
+ * tooltip — and a select inside a dialog is ordinary. Descending has no such
19
+ * ceiling, and it keeps the frontmost dialog exactly where a single one has
20
+ * always been.
21
+ */
22
+ import { computed, ref, type ComputedRef } from "vue"
23
+
24
+ /*
25
+ * Rungs per level. Has to clear a whole level, not just its scrim: --z-overlay
26
+ * is 100 and --z-modal 110, so a gap of 2 left the upper scrim under the lower
27
+ * PANEL — it darkened that panel and could not blur it, a backdrop-filter
28
+ * having nothing of it underneath to work on.
29
+ */
30
+ const STEP = 10
31
+
32
+ /** Levels currently claimed, oldest first. */
33
+ const stack = ref<number[]>([])
34
+ let nextId = 0
35
+
36
+ /**
37
+ * Register a dialog as open and get back the z-indexes it should paint with.
38
+ * The result is reactive: opening another dialog moves this one down a step.
39
+ */
40
+ export function useModalLayer(): {
41
+ claim: () => void
42
+ release: () => void
43
+ layer: ComputedRef<{ scrim: string; panel: string }>
44
+ } {
45
+ const id = nextId++
46
+
47
+ /*
48
+ * How far from the TOP of the stack, so the newest dialog is 0 and keeps the
49
+ * unmodified rungs. A dialog that is not in the stack reads 0 too: it is
50
+ * either closed, or opening this very frame and about to claim.
51
+ */
52
+ const fromTop = computed(() => {
53
+ const at = stack.value.indexOf(id)
54
+ return at === -1 ? 0 : stack.value.length - 1 - at
55
+ })
56
+
57
+ return {
58
+ claim: () => {
59
+ if (!stack.value.includes(id)) stack.value = [...stack.value, id]
60
+ },
61
+ release: () => {
62
+ stack.value = stack.value.filter((x) => x !== id)
63
+ },
64
+ layer: computed(() => {
65
+ const drop = fromTop.value * STEP
66
+ return {
67
+ scrim: `calc(var(--z-overlay) - ${drop})`,
68
+ panel: `calc(var(--z-modal) - ${drop})`,
69
+ }
70
+ }),
71
+ }
72
+ }
@@ -65,6 +65,14 @@ export interface UseShift {
65
65
  /** Kept in step with the `duration-fast` token the transition utilities use. */
66
66
  const TWEEN_MS = 160
67
67
 
68
+ /*
69
+ * How long after a tween starts a second observer call still counts as the same
70
+ * move rather than a new one. Both observers report one swap within a frame or
71
+ * two; anything later is content that is still changing size and has to be
72
+ * followed. Two frames at 60Hz, with room to spare for a slow one.
73
+ */
74
+ const RETARGET_AFTER_MS = 40
75
+
68
76
  export function useShift(
69
77
  options: UseShiftOptions = {},
70
78
  ): UseShift {
@@ -81,6 +89,8 @@ export function useShift(
81
89
  let lastWidth = 0
82
90
  /** True while a tween owns the size, so observers don't cancel it midway. */
83
91
  let inFlight = false
92
+ /** When the in-flight tween started, to tell a duplicate from a follow-up. */
93
+ let tweenStart = 0
84
94
 
85
95
  const axisOf = () => toValue(options.axis) ?? "height"
86
96
  const wantsHeight = () => axisOf() !== "width"
@@ -96,9 +106,29 @@ export function useShift(
96
106
  function retune() {
97
107
  const node = el.value
98
108
  if (!node) return
99
- // One swap wakes both observers — the mutation as content lands, the resize
100
- // as it reflows — and the second call would clear the pin the first wrote.
101
- if (inFlight) return
109
+
110
+ /*
111
+ * A tween already owns the size. Where it came from decides what to do:
112
+ *
113
+ * - One swap wakes both observers — the mutation as content lands, the
114
+ * resize as it reflows — and the second call arrives within a frame or
115
+ * two of the first, describing the same move. Re-running it would clear
116
+ * the pin the first one wrote, so it is dropped.
117
+ * - Content that animates its OWN height keeps reporting new sizes for as
118
+ * long as it runs. A collapsible body opening inside a dialog fires the
119
+ * observer every frame for 260ms, and dropping all of those pinned the
120
+ * panel at the size the body happened to have one frame in — measured,
121
+ * 212px held for 170ms and then 135px arriving in a single frame, which
122
+ * is the "modal snaps open" report. Those retunes must RETARGET the
123
+ * tween instead: start from where the box is painted right now and ease
124
+ * on to the new figure.
125
+ */
126
+ if (inFlight) {
127
+ if (performance.now() - tweenStart < RETARGET_AFTER_MS) return
128
+ const live = node.getBoundingClientRect()
129
+ lastHeight = Math.round(live.height)
130
+ lastWidth = Math.round(live.width)
131
+ }
102
132
 
103
133
  // Remembered, not measured: by the time either observer fires the box has
104
134
  // already reflowed, so the element can only report where it is going.
@@ -145,6 +175,7 @@ export function useShift(
145
175
  if (movesWidth) node.style.width = `${toWidth}px`
146
176
 
147
177
  inFlight = true
178
+ tweenStart = performance.now()
148
179
  resizing.value = true
149
180
  clearTimeout(settleTimer)
150
181
  const wait = prefersReducedMotion() ? 0 : (toValue(options.duration) ?? TWEEN_MS)