@leavepulse/ui 0.37.3 → 0.37.5

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-D3QxgWaT.js";
9
+ import { _ as _sfc_main$7 } from "./LpModal-Dq_qXN8-.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" };
@@ -3,7 +3,7 @@ import { _ as _sfc_main$2 } from "./LpButton-5VXXCjwF.js";
3
3
  import { _ as _sfc_main$3 } from "./LpIcon-CCnX5_2j.js";
4
4
  import { _ as _sfc_main$5 } from "./LpScrollArea-D8xyKXcF.js";
5
5
  import { _ as _sfc_main$4 } from "./LpShift-DXNsauQZ.js";
6
- import { _ as _sfc_main$1 } from "./LpSidebar-DvGfofv6.js";
6
+ import { _ as _sfc_main$1 } from "./LpSidebar-DvxMte3k.js";
7
7
  import { useMergedAttrs } from "../composables/useMergedClass.js";
8
8
  const _hoisted_1 = { class: "flex min-w-0 flex-1 flex-col" };
9
9
  const _hoisted_2 = { class: "relative flex h-16 shrink-0 items-center gap-3 border-b border-line bg-surface px-4 md:px-6" };
@@ -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-D3QxgWaT.js";
4
+ import { _ as _sfc_main$1 } from "./LpModal-Dq_qXN8-.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",
@@ -1,10 +1,9 @@
1
- import { defineComponent, computed, watch, onBeforeUnmount, ref, useSlots, openBlock, createElementBlock, Fragment, createElementVNode, normalizeStyle, normalizeClass, withDirectives, vShow, toDisplayString, createCommentVNode, createVNode, unref, withCtx, createBlock, renderSlot, createTextVNode } from "vue";
2
- import { usePointerSwipe } from "@vueuse/core";
1
+ import { ref, defineComponent, computed, watch, onBeforeUnmount, useSlots, openBlock, createElementBlock, Fragment, withDirectives, createElementVNode, normalizeStyle, unref, vShow, normalizeClass, toDisplayString, createCommentVNode, renderSlot, createVNode, withCtx, createBlock, createTextVNode } from "vue";
3
2
  import { DrawerRoot, DrawerPortal, DrawerOverlay, DrawerContent, DrawerHandle, DrawerTitle, DrawerDescription, DrawerClose } from "vaul-vue";
4
3
  import { CLOSE_ICON } from "../components/dropdown.js";
5
4
  import { _ as _sfc_main$1 } from "./LpIcon-CCnX5_2j.js";
6
5
  import { _ as _sfc_main$2 } from "./LpScrollArea-D8xyKXcF.js";
7
- import { useModalLayer } from "../components/modalDepth.js";
6
+ import { useModalLayer, anyModalOpen } from "../components/modalDepth.js";
8
7
  const _hoisted_1 = { class: "text-base font-semibold text-ink" };
9
8
  const _hoisted_2 = { class: "flex flex-col gap-1" };
10
9
  const _hoisted_3 = ["data-vaul-no-drag"];
@@ -12,7 +11,7 @@ const _hoisted_4 = {
12
11
  key: 3,
13
12
  class: "flex justify-end gap-2 px-5 pb-5 pt-4"
14
13
  };
15
- const NO_DRAG_SELECTOR = "input, textarea, select, button, a[href], label, [contenteditable=''], [contenteditable='true']";
14
+ const edgeOwner = ref(null);
16
15
  const _sfc_main = /* @__PURE__ */ defineComponent({
17
16
  __name: "LpDrawer",
18
17
  props: {
@@ -104,6 +103,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
104
103
  return "p-5";
105
104
  });
106
105
  const headerClass = computed(() => showHandle.value ? "px-5 pb-4 pt-1" : "px-5 pb-4 pt-5");
106
+ const NO_DRAG_SELECTOR = "input, textarea, select, button, a[href], label, [contenteditable=''], [contenteditable='true']";
107
107
  function tagNoDrag(el, { value }) {
108
108
  if (!value) return;
109
109
  for (const node of el.querySelectorAll(NO_DRAG_SELECTOR)) {
@@ -114,97 +114,183 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
114
114
  mounted: tagNoDrag,
115
115
  updated: tagNoDrag
116
116
  };
117
- const edgeStrip = ref(null);
117
+ const EDGE_SLOP = 10;
118
+ const FLICK_SPEED = 0.45;
119
+ const BREAKPOINT_PX = { sm: 640, md: 768, lg: 1024, xl: 1280 };
120
+ const EDGE_IGNORE = "input, textarea, select, [contenteditable=''], [contenteditable='true'], [data-vaul-no-drag], [data-lp-no-edge-pull]";
118
121
  const peek = ref(0);
119
122
  const peeking = ref(false);
120
123
  const handingOff = ref(false);
124
+ const skipOpenSlide = ref(false);
125
+ watch(
126
+ () => props.open,
127
+ (open) => {
128
+ if (!open) skipOpenSlide.value = false;
129
+ }
130
+ );
121
131
  function panelPx() {
122
132
  const vw = typeof window === "undefined" ? 0 : window.innerWidth;
123
133
  const cap = props.width ? Number.parseFloat(props.width) : SIZES[props.size] * 16;
124
134
  return Math.min(vw * 0.94, Number.isFinite(cap) ? cap : SIZES[props.size] * 16);
125
135
  }
126
- usePointerSwipe(edgeStrip, {
127
- threshold: 0,
128
- pointerTypes: ["touch", "pen"],
129
- onSwipeStart() {
130
- if (!props.edgeOpen || props.open) return;
136
+ function edgeEnabled() {
137
+ if (!props.edgeOpen || !isHorizontal.value || props.open || handingOff.value) return false;
138
+ const bp = props.edgeBreakpoint ? BREAKPOINT_PX[props.edgeBreakpoint] : null;
139
+ return bp === null || window.innerWidth < bp;
140
+ }
141
+ let track = null;
142
+ const owner = /* @__PURE__ */ Symbol("edge-pull");
143
+ function reducedMotion() {
144
+ return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
145
+ }
146
+ function scrollsWithPull(target) {
147
+ let el = target instanceof Element ? target : null;
148
+ const pullsRight = dir.value === "left";
149
+ while (el && el !== document.body) {
150
+ if (el.closest(EDGE_IGNORE) === el) return true;
151
+ if (el.scrollWidth > el.clientWidth) {
152
+ const overflow = getComputedStyle(el).overflowX;
153
+ if (overflow === "auto" || overflow === "scroll") {
154
+ if (pullsRight ? el.scrollLeft > 0 : el.scrollLeft + el.clientWidth < el.scrollWidth) return true;
155
+ }
156
+ }
157
+ el = el.parentElement;
158
+ }
159
+ return false;
160
+ }
161
+ function inward(x) {
162
+ return dir.value === "right" ? window.innerWidth - x : x;
163
+ }
164
+ function onTouchStart(e) {
165
+ if (track) dropTrack();
166
+ if (e.touches.length !== 1 || !edgeEnabled() || anyModalOpen() || edgeOwner.value) return;
167
+ const t = e.touches[0];
168
+ if (inward(t.clientX) > props.edgeSize || scrollsWithPull(e.target)) return;
169
+ track = { id: t.identifier, x: t.clientX, y: t.clientY, t: e.timeStamp, lastX: t.clientX, lastT: e.timeStamp, speed: 0 };
170
+ }
171
+ function onTouchMove(e) {
172
+ if (!track) return;
173
+ const t = Array.from(e.changedTouches).find((x) => x.identifier === track.id);
174
+ if (!t) return;
175
+ const sign = dir.value === "right" ? -1 : 1;
176
+ const dx = (t.clientX - track.x) * sign;
177
+ const dy = t.clientY - track.y;
178
+ if (!peeking.value) {
179
+ if (Math.abs(dy) > EDGE_SLOP && Math.abs(dy) > Math.abs(dx)) {
180
+ track = null;
181
+ return;
182
+ }
183
+ if (dx < EDGE_SLOP || Math.abs(dx) < Math.abs(dy)) return;
184
+ if (edgeOwner.value && edgeOwner.value !== owner) {
185
+ track = null;
186
+ return;
187
+ }
188
+ edgeOwner.value = owner;
131
189
  peeking.value = true;
132
- },
133
- onSwipe(e) {
134
- if (!peeking.value) return;
135
- const fromRight = dir.value === "right";
136
- const dx = fromRight ? window.innerWidth - e.clientX : e.clientX;
137
- peek.value = Math.max(0, Math.min(1, dx / panelPx()));
138
- },
139
- onSwipeEnd() {
140
- if (!peeking.value) return;
141
- peeking.value = false;
142
- if (peek.value >= props.edgeOpenThreshold) {
143
- peek.value = 1;
144
- handingOff.value = true;
190
+ }
191
+ if (e.cancelable) e.preventDefault();
192
+ const dt = e.timeStamp - track.lastT;
193
+ if (dt > 0) track.speed = (t.clientX - track.lastX) * sign / dt * 0.6 + track.speed * 0.4;
194
+ track.lastX = t.clientX;
195
+ track.lastT = e.timeStamp;
196
+ peek.value = Math.max(0, Math.min(1, inward(t.clientX) / panelPx()));
197
+ }
198
+ function dropTrack() {
199
+ track = null;
200
+ if (edgeOwner.value === owner) edgeOwner.value = null;
201
+ }
202
+ function settle() {
203
+ const flick = track !== null && track.speed > FLICK_SPEED && peek.value > 0.05;
204
+ dropTrack();
205
+ if (!peeking.value) return;
206
+ peeking.value = false;
207
+ if (peek.value >= props.edgeOpenThreshold || flick) {
208
+ peek.value = 1;
209
+ handingOff.value = true;
210
+ skipOpenSlide.value = true;
211
+ timers.push(
145
212
  window.setTimeout(() => {
146
213
  emit("update:open", true);
147
- window.setTimeout(() => {
148
- handingOff.value = false;
149
- peek.value = 0;
150
- }, 60);
151
- }, 160);
152
- } else {
153
- peek.value = 0;
154
- }
214
+ timers.push(
215
+ window.setTimeout(() => {
216
+ handingOff.value = false;
217
+ peek.value = 0;
218
+ }, 60)
219
+ );
220
+ }, reducedMotion() ? 0 : 180)
221
+ );
222
+ } else {
223
+ peek.value = 0;
155
224
  }
225
+ }
226
+ const timers = [];
227
+ onBeforeUnmount(() => {
228
+ for (const id of timers) window.clearTimeout(id);
229
+ if (edgeOwner.value === owner) edgeOwner.value = null;
156
230
  });
231
+ function onTouchEnd(e) {
232
+ if (track && !Array.from(e.changedTouches).some((x) => x.identifier === track.id)) return;
233
+ settle();
234
+ }
235
+ if (typeof document !== "undefined") {
236
+ watch(
237
+ () => props.edgeOpen && isHorizontal.value,
238
+ (on, _, onCleanup) => {
239
+ if (!on) return;
240
+ const passive = { passive: true };
241
+ document.addEventListener("touchstart", onTouchStart, passive);
242
+ document.addEventListener("touchmove", onTouchMove, { passive: false });
243
+ document.addEventListener("touchend", onTouchEnd, passive);
244
+ document.addEventListener("touchcancel", onTouchEnd, passive);
245
+ onCleanup(() => {
246
+ document.removeEventListener("touchstart", onTouchStart);
247
+ document.removeEventListener("touchmove", onTouchMove);
248
+ document.removeEventListener("touchend", onTouchEnd);
249
+ document.removeEventListener("touchcancel", onTouchEnd);
250
+ });
251
+ },
252
+ { immediate: true }
253
+ );
254
+ }
157
255
  const previewStyle = computed(() => {
158
256
  if (!props.edgeOpen || props.open && !handingOff.value) return { display: "none" };
159
257
  const off = (1 - peek.value) * 100;
160
258
  const axis = dir.value === "right" ? off : -off;
161
259
  return {
162
260
  transform: `translateX(${axis}%)`,
163
- transition: peeking.value ? "none" : "transform 0.16s ease-out",
261
+ transition: peeking.value || reducedMotion() ? "none" : "transform 0.18s cubic-bezier(0.32, 0.72, 0, 1)",
164
262
  pointerEvents: "none"
165
263
  };
166
264
  });
167
- const edgeStripStyle = computed(() => {
168
- const w = `${props.edgeSize}px`;
169
- return dir.value === "right" ? { right: "0", width: w } : { left: "0", width: w };
170
- });
171
- const EDGE_STRIP_HIDDEN = {
172
- sm: "sm:hidden",
173
- md: "md:hidden",
174
- lg: "lg:hidden",
175
- xl: "xl:hidden"
176
- };
177
- const edgeStripClass = computed(
178
- () => props.edgeBreakpoint ? EDGE_STRIP_HIDDEN[props.edgeBreakpoint] : ""
179
- );
180
265
  return (_ctx, _cache) => {
181
266
  return openBlock(), createElementBlock(Fragment, null, [
182
267
  __props.edgeOpen && isHorizontal.value ? (openBlock(), createElementBlock(Fragment, { key: 0 }, [
183
- createElementVNode("div", {
184
- ref_key: "edgeStrip",
185
- ref: edgeStrip,
186
- class: normalizeClass(["fixed inset-y-0 z-(--z-overlay) touch-none", edgeStripClass.value]),
187
- style: normalizeStyle(edgeStripStyle.value),
188
- "aria-hidden": "true"
189
- }, null, 6),
190
268
  withDirectives(createElementVNode("div", {
191
- class: "pointer-events-none fixed inset-0 z-(--z-overlay) bg-black/50",
192
- style: normalizeStyle({ opacity: peek.value })
269
+ class: "lp-scrim pointer-events-none fixed inset-0",
270
+ style: normalizeStyle({ zIndex: unref(layer).scrim, opacity: peek.value, transition: peeking.value || reducedMotion() ? "none" : "opacity 0.18s ease-out" })
193
271
  }, null, 4), [
194
272
  [vShow, peeking.value || peek.value > 0]
195
273
  ]),
196
- createElementVNode("div", {
274
+ withDirectives(createElementVNode("div", {
197
275
  class: normalizeClass(contentClass.value),
198
- style: normalizeStyle([sizeStyle.value, previewStyle.value]),
199
- "aria-hidden": "true"
276
+ style: normalizeStyle([sizeStyle.value, previewStyle.value, { zIndex: unref(layer).panel }]),
277
+ "aria-hidden": "true",
278
+ inert: ""
200
279
  }, [
201
- hasHeader.value ? (openBlock(), createElementBlock("div", {
280
+ hasHeader.value ? (openBlock(), createElementBlock("header", {
202
281
  key: 0,
203
282
  class: normalizeClass(["flex items-start justify-between gap-4", headerClass.value])
204
283
  }, [
205
284
  createElementVNode("span", _hoisted_1, toDisplayString(__props.title), 1)
206
- ], 2)) : createCommentVNode("", true)
207
- ], 6)
285
+ ], 2)) : createCommentVNode("", true),
286
+ createElementVNode("div", {
287
+ class: normalizeClass(["flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden", padClass.value])
288
+ }, [
289
+ peeking.value || peek.value > 0 ? renderSlot(_ctx.$slots, "default", { key: 0 }) : createCommentVNode("", true)
290
+ ], 2)
291
+ ], 6), [
292
+ [vShow, peeking.value || peek.value > 0]
293
+ ])
208
294
  ], 64)) : createCommentVNode("", true),
209
295
  createVNode(unref(DrawerRoot), {
210
296
  open: __props.open,
@@ -226,7 +312,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
226
312
  createVNode(unref(DrawerContent), {
227
313
  class: normalizeClass(contentClass.value),
228
314
  style: normalizeStyle([sizeStyle.value, { zIndex: unref(layer).panel }]),
229
- "data-vaul-animate": handingOff.value ? "false" : void 0
315
+ "data-vaul-animate": handingOff.value || skipOpenSlide.value ? "false" : void 0
230
316
  }, {
231
317
  default: withCtx(() => [
232
318
  __props.resizable && isHorizontal.value ? (openBlock(), createElementBlock("div", {
@@ -1,4 +1,4 @@
1
- import { defineComponent, computed, useSlots, watch, onBeforeUnmount, ref, openBlock, createBlock, unref, withCtx, createVNode, normalizeStyle, createElementVNode, createElementBlock, renderSlot, createCommentVNode, mergeProps, createTextVNode, toDisplayString, normalizeClass } from "vue";
1
+ import { defineComponent, computed, useSlots, watch, onBeforeUnmount, ref, openBlock, createBlock, unref, withCtx, createVNode, normalizeStyle, createElementVNode, mergeProps, createElementBlock, renderSlot, createCommentVNode, normalizeClass, createTextVNode, toDisplayString } from "vue";
2
2
  import { DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose } from "reka-ui";
3
3
  import { useShift } from "../composables/useShift.js";
4
4
  import { useModalLayer } from "../components/modalDepth.js";
@@ -8,7 +8,7 @@ import { _ as _sfc_main$2 } from "./LpScrollArea-D8xyKXcF.js";
8
8
  const _hoisted_1 = {
9
9
  key: 0,
10
10
  "data-lp-modal-aside": "",
11
- class: "pointer-events-auto flex min-h-0 flex-col"
11
+ class: "flex min-h-0 flex-col"
12
12
  };
13
13
  const _hoisted_2 = {
14
14
  key: 0,
@@ -22,7 +22,7 @@ const _hoisted_4 = {
22
22
  const _hoisted_5 = {
23
23
  key: 1,
24
24
  "data-lp-modal-aside": "",
25
- class: "pointer-events-auto flex min-h-0 flex-col"
25
+ class: "flex min-h-0 flex-col"
26
26
  };
27
27
  const _sfc_main = /* @__PURE__ */ defineComponent({
28
28
  __name: "LpModal",
@@ -83,8 +83,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
83
83
  ];
84
84
  function onOpenAutoFocus(event) {
85
85
  if (props.autoFocus === "none") return;
86
- const panel = panelRef.value?.$el;
87
- if (!(panel instanceof HTMLElement)) return;
86
+ const panel = panelRef.value;
87
+ if (!panel) return;
88
88
  const selectors = props.autoFocus === "auto" ? AUTO_FOCUS_ORDER : [props.autoFocus];
89
89
  for (const selector of selectors) {
90
90
  const target = panel.querySelector(selector);
@@ -110,8 +110,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
110
110
  watch(
111
111
  () => props.open ? panelRef.value : null,
112
112
  (panel) => {
113
- const el = panel?.$el;
114
- sizedEl.value = el instanceof HTMLElement ? el : null;
113
+ sizedEl.value = panel;
115
114
  },
116
115
  { flush: "post" }
117
116
  );
@@ -131,24 +130,24 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
131
130
  class: "fixed inset-0 flex items-center justify-center pointer-events-none",
132
131
  style: normalizeStyle({ zIndex: unref(layer).panel })
133
132
  }, [
134
- createElementVNode("div", {
135
- class: "flex items-start",
136
- style: normalizeStyle(hasAside.value ? { maxHeight: "min(90vh, calc(100dvh - 2rem))" } : void 0)
137
- }, [
138
- __props.open && _ctx.$slots.aside ? (openBlock(), createElementBlock("aside", _hoisted_1, [
139
- renderSlot(_ctx.$slots, "aside")
140
- ])) : createCommentVNode("", true),
141
- createVNode(unref(DialogContent), mergeProps({
142
- ref_key: "panelRef",
143
- ref: panelRef,
144
- class: ["pointer-events-auto flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col overflow-hidden rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[rise-out_120ms_cubic-bezier(0.4,0,1,1)]", [widthClass.value, unref(tweening) ? "transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none" : ""]],
145
- style: { ...__props.width ? { width: __props.width } : {}, ...hasAside.value ? { alignSelf: "stretch" } : {} }
146
- }, describedByAttrs.value, {
147
- onOpenAutoFocus,
148
- onInteractOutside,
149
- onPointerDownOutside: onInteractOutside
150
- }), {
151
- default: withCtx(() => [
133
+ createVNode(unref(DialogContent), mergeProps({
134
+ class: "pointer-events-auto flex min-h-0 items-start outline-none data-[state=open]:animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[rise-out_120ms_cubic-bezier(0.4,0,1,1)]",
135
+ style: { maxHeight: "min(90vh, calc(100dvh - 2rem))" }
136
+ }, describedByAttrs.value, {
137
+ onOpenAutoFocus,
138
+ onInteractOutside,
139
+ onPointerDownOutside: onInteractOutside
140
+ }), {
141
+ default: withCtx(() => [
142
+ _ctx.$slots.aside ? (openBlock(), createElementBlock("aside", _hoisted_1, [
143
+ renderSlot(_ctx.$slots, "aside")
144
+ ])) : createCommentVNode("", true),
145
+ createElementVNode("div", {
146
+ ref_key: "panelRef",
147
+ ref: panelRef,
148
+ class: normalizeClass(["flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col overflow-hidden rounded-card border border-line bg-surface-raised shadow-panel", [widthClass.value, unref(tweening) ? "transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none" : ""]]),
149
+ style: normalizeStyle({ ...__props.width ? { width: __props.width } : {}, ...hasAside.value ? { alignSelf: "stretch" } : {} })
150
+ }, [
152
151
  __props.title || _ctx.$slots.title ? (openBlock(), createElementBlock("header", _hoisted_2, [
153
152
  createElementVNode("div", _hoisted_3, [
154
153
  createVNode(unref(DialogTitle), { class: "text-base font-semibold text-ink" }, {
@@ -202,13 +201,13 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
202
201
  _ctx.$slots.footer ? (openBlock(), createElementBlock("footer", _hoisted_4, [
203
202
  renderSlot(_ctx.$slots, "footer")
204
203
  ])) : createCommentVNode("", true)
205
- ]),
206
- _: 3
207
- }, 16, ["class", "style"]),
208
- __props.open && _ctx.$slots.asideEnd ? (openBlock(), createElementBlock("aside", _hoisted_5, [
209
- renderSlot(_ctx.$slots, "asideEnd")
210
- ])) : createCommentVNode("", true)
211
- ], 4)
204
+ ], 6),
205
+ _ctx.$slots.asideEnd ? (openBlock(), createElementBlock("aside", _hoisted_5, [
206
+ renderSlot(_ctx.$slots, "asideEnd")
207
+ ])) : createCommentVNode("", true)
208
+ ]),
209
+ _: 3
210
+ }, 16)
212
211
  ], 4)
213
212
  ]),
214
213
  _: 3
@@ -1,8 +1,8 @@
1
1
  import { defineComponent, ref, computed, openBlock, createBlock, createSlots, withCtx, renderSlot, createElementVNode, normalizeClass, createVNode, createCommentVNode, createTextVNode, toDisplayString } 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-D3QxgWaT.js";
5
- import { _ as _sfc_main$4 } from "./LpSidebar-DvGfofv6.js";
4
+ import { _ as _sfc_main$1 } from "./LpModal-Dq_qXN8-.js";
5
+ import { _ as _sfc_main$4 } from "./LpSidebar-DvxMte3k.js";
6
6
  const _hoisted_1 = { class: "flex items-center gap-2" };
7
7
  const _sfc_main = /* @__PURE__ */ defineComponent({
8
8
  __name: "LpModalNav",
@@ -1,5 +1,5 @@
1
1
  import { defineComponent, computed, ref, useId, openBlock, createElementBlock, Fragment, createElementVNode, mergeProps, normalizeClass, renderSlot, createCommentVNode, createBlock, createVNode, createSlots, withCtx, normalizeProps, guardReactiveProps } from "vue";
2
- import { _ as _sfc_main$3 } from "./LpDrawer-CslZtgUm.js";
2
+ import { _ as _sfc_main$3 } from "./LpDrawer-E67O8fFP.js";
3
3
  import { _ as _sfc_main$1 } from "./LpInput-9EXWUM9B.js";
4
4
  import { _ as _sfc_main$2 } from "./LpSidebarNav-CqwvT4H8.js";
5
5
  const _hoisted_1 = {
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpAddressInput-C8NZWkH_.js";
1
+ import { _ as _sfc_main } from "../chunks/LpAddressInput-DOVpUONY.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpAppShell-Cs9jDoqJ.js";
1
+ import { _ as _sfc_main } from "../chunks/LpAppShell-1rTB24lI.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpConfirmDialog-B6RAvDES.js";
1
+ import { _ as _sfc_main } from "../chunks/LpConfirmDialog-BH7az_E7.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,8 +1,9 @@
1
- type Direction = "top" | "bottom" | "left" | "right";
2
- type __VLS_Props = {
1
+ declare const _default: typeof __VLS_export;
2
+ export default _default;
3
+ declare const __VLS_export: __VLS_WithSlots<import('vue').DefineComponent<{
3
4
  open?: boolean;
4
5
  /** Edge the drawer attaches to. `side` is a back-compat alias (left/right). */
5
- direction?: Direction;
6
+ direction?: "left" | "right" | "top" | "bottom";
6
7
  side?: "left" | "right";
7
8
  title?: string;
8
9
  description?: string;
@@ -35,21 +36,20 @@ type __VLS_Props = {
35
36
  */
36
37
  noDragContent?: boolean;
37
38
  /**
38
- * Open by dragging in from the screen edge (Discord-style). Renders an
39
- * invisible edge strip; a pull from it drags a preview panel that follows
40
- * the finger, and a release past `edgeOpenThreshold` opens the drawer. Only
41
- * meaningful for left/right drawers.
39
+ * Open by dragging in from the screen edge (Discord-style). A touch that
40
+ * starts within `edgeSize` of the edge and travels inward drags a preview of
41
+ * the panel (with its content) under the finger; a release past
42
+ * `edgeOpenThreshold`, or a quick flick, opens the drawer. Nothing is laid
43
+ * over the edge, so taps there still reach the page. Left/right drawers only.
42
44
  */
43
45
  edgeOpen?: boolean;
44
46
  /**
45
- * Tailwind breakpoint above which the edge strip is disabled. Edge
46
- * swipe-to-open only makes sense on mobile: the strip listens for
47
- * touch/pen pointers only, so on a desktop it can never open the drawer
48
- * but still overlays the screen edge and swallows clicks. Pass the same
49
- * breakpoint at which the host switches to the drawer (LpSidebar does).
47
+ * Tailwind breakpoint at and above which the edge pull is ignored. Pass the
48
+ * same breakpoint at which the host switches to the drawer (LpSidebar
49
+ * does), so a wide touch screen with the rail visible doesn't pull it too.
50
50
  */
51
51
  edgeBreakpoint?: "sm" | "md" | "lg" | "xl";
52
- /** Width (px) of the edge grab strip. */
52
+ /** Distance (px) from the edge within which a touch can start a pull. */
53
53
  edgeSize?: number;
54
54
  /** Fraction (0–1) of the panel width the pull must reach to open on release. */
55
55
  edgeOpenThreshold?: number;
@@ -63,18 +63,72 @@ type __VLS_Props = {
63
63
  minSize?: string;
64
64
  /** Max main-axis size while resizing (any CSS length). Default 96vw. */
65
65
  maxSize?: string;
66
- };
67
- declare var __VLS_37: {}, __VLS_62: {}, __VLS_64: {};
68
- type __VLS_Slots = {} & {
69
- title?: (props: typeof __VLS_37) => any;
70
- } & {
71
- default?: (props: typeof __VLS_62) => any;
72
- } & {
73
- footer?: (props: typeof __VLS_64) => any;
74
- };
75
- declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {} & {
66
+ }, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {} & {
76
67
  "update:open": (value: boolean) => any;
77
- }, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
68
+ }, string, import('vue').PublicProps, Readonly<{
69
+ open?: boolean;
70
+ /** Edge the drawer attaches to. `side` is a back-compat alias (left/right). */
71
+ direction?: "left" | "right" | "top" | "bottom";
72
+ side?: "left" | "right";
73
+ title?: string;
74
+ description?: string;
75
+ /** Size preset along the drawer's main axis (width for left/right, height for top/bottom). */
76
+ size?: "sm" | "md" | "lg" | "xl";
77
+ /** Explicit size override (any CSS length), wins over `size`. */
78
+ width?: string;
79
+ /** Drag-to-dismiss + click-outside. Set false to force a controlled close. */
80
+ dismissible?: boolean;
81
+ /** Fractions (0–1) or px strings the drawer snaps between, nearest edge first. */
82
+ snapPoints?: (number | string)[];
83
+ /** Show the drag handle (auto-on for bottom/top sheets). */
84
+ handle?: boolean;
85
+ /** Only the handle initiates a drag — frees inner content to scroll. */
86
+ handleOnly?: boolean;
87
+ /** Scale the [vaul-drawer-wrapper] background while open. */
88
+ scaleBackground?: boolean;
89
+ /** Drag fraction (0–1) past which a release dismisses. */
90
+ closeThreshold?: number;
91
+ /**
92
+ * Auto-tag interactive descendants (input, textarea, select, button, links,
93
+ * [contenteditable]) with data-vaul-no-drag so a drag can't start on them —
94
+ * you can select text / use controls without dragging the drawer. On by
95
+ * default; the drawer still drags from empty space, the handle and header.
96
+ */
97
+ noDragControls?: boolean;
98
+ /**
99
+ * Mark the entire body as non-draggable: the drawer then only drags from the
100
+ * handle/header. Stronger than noDragControls — use for form-heavy drawers.
101
+ */
102
+ noDragContent?: boolean;
103
+ /**
104
+ * Open by dragging in from the screen edge (Discord-style). A touch that
105
+ * starts within `edgeSize` of the edge and travels inward drags a preview of
106
+ * the panel (with its content) under the finger; a release past
107
+ * `edgeOpenThreshold`, or a quick flick, opens the drawer. Nothing is laid
108
+ * over the edge, so taps there still reach the page. Left/right drawers only.
109
+ */
110
+ edgeOpen?: boolean;
111
+ /**
112
+ * Tailwind breakpoint at and above which the edge pull is ignored. Pass the
113
+ * same breakpoint at which the host switches to the drawer (LpSidebar
114
+ * does), so a wide touch screen with the rail visible doesn't pull it too.
115
+ */
116
+ edgeBreakpoint?: "sm" | "md" | "lg" | "xl";
117
+ /** Distance (px) from the edge within which a touch can start a pull. */
118
+ edgeSize?: number;
119
+ /** Fraction (0–1) of the panel width the pull must reach to open on release. */
120
+ edgeOpenThreshold?: number;
121
+ /**
122
+ * Let the user drag the inner edge to resize the panel (horizontal drawers
123
+ * only — e.g. a logs panel whose long lines overflow). The new size is kept
124
+ * for the session. Min/max bound the drag.
125
+ */
126
+ resizable?: boolean;
127
+ /** Min main-axis size while resizing (any CSS length). Default 22rem. */
128
+ minSize?: string;
129
+ /** Max main-axis size while resizing (any CSS length). Default 96vw. */
130
+ maxSize?: string;
131
+ }> & Readonly<{
78
132
  "onUpdate:open"?: ((value: boolean) => any) | undefined;
79
133
  }>, {
80
134
  size: "sm" | "md" | "lg" | "xl";
@@ -85,10 +139,15 @@ declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {}, {}, {},
85
139
  edgeOpenThreshold: number;
86
140
  minSize: string;
87
141
  maxSize: string;
88
- }, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
89
- declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
90
- declare const _default: typeof __VLS_export;
91
- export default _default;
142
+ }, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>, {
143
+ default?: (props: {}) => any;
144
+ } & {
145
+ title?: (props: {}) => any;
146
+ } & {
147
+ default?: (props: {}) => any;
148
+ } & {
149
+ footer?: (props: {}) => any;
150
+ }>;
92
151
  type __VLS_WithSlots<T, S> = T & {
93
152
  new (): {
94
153
  $slots: S;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpDrawer-CslZtgUm.js";
1
+ import { _ as _sfc_main } from "../chunks/LpDrawer-E67O8fFP.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -26,19 +26,19 @@ type __VLS_Props = {
26
26
  */
27
27
  autoFocus?: "auto" | "none" | string;
28
28
  };
29
- declare var __VLS_21: {}, __VLS_41: {}, __VLS_60: {}, __VLS_68: {}, __VLS_70: {}, __VLS_72: {};
29
+ declare var __VLS_31: {}, __VLS_39: {}, __VLS_58: {}, __VLS_66: {}, __VLS_68: {}, __VLS_70: {};
30
30
  type __VLS_Slots = {} & {
31
- aside?: (props: typeof __VLS_21) => any;
31
+ aside?: (props: typeof __VLS_31) => any;
32
32
  } & {
33
- title?: (props: typeof __VLS_41) => any;
33
+ title?: (props: typeof __VLS_39) => any;
34
34
  } & {
35
- default?: (props: typeof __VLS_60) => any;
35
+ default?: (props: typeof __VLS_58) => any;
36
36
  } & {
37
- default?: (props: typeof __VLS_68) => any;
37
+ default?: (props: typeof __VLS_66) => any;
38
38
  } & {
39
- footer?: (props: typeof __VLS_70) => any;
39
+ footer?: (props: typeof __VLS_68) => any;
40
40
  } & {
41
- asideEnd?: (props: typeof __VLS_72) => any;
41
+ asideEnd?: (props: typeof __VLS_70) => any;
42
42
  };
43
43
  declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {} & {
44
44
  "update:open": (value: boolean) => any;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpModal-D3QxgWaT.js";
1
+ import { _ as _sfc_main } from "../chunks/LpModal-Dq_qXN8-.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpModalNav-Dkogc3O_.js";
1
+ import { _ as _sfc_main } from "../chunks/LpModalNav-B42a7R-3.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-D3QxgWaT.js";
2
+ import { _ as _sfc_main$1 } from "../chunks/LpModal-Dq_qXN8-.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" };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpSidebar-DvGfofv6.js";
1
+ import { _ as _sfc_main } from "../chunks/LpSidebar-DvxMte3k.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -11,3 +11,5 @@ export declare function useModalLayer(): {
11
11
  panel: string;
12
12
  }>;
13
13
  };
14
+ /** True while any dialog or drawer holds a level — e.g. to keep a gesture from opening another one over it. */
15
+ export declare function anyModalOpen(): boolean;
@@ -24,6 +24,10 @@ function useModalLayer() {
24
24
  })
25
25
  };
26
26
  }
27
+ function anyModalOpen() {
28
+ return stack.value.length > 0;
29
+ }
27
30
  export {
31
+ anyModalOpen,
28
32
  useModalLayer
29
33
  };
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { _ } from "./chunks/LayoutCanvas-Dd20boUG.js";
2
- import { _ as _2 } from "./chunks/LpAddressInput-C8NZWkH_.js";
2
+ import { _ as _2 } from "./chunks/LpAddressInput-DOVpUONY.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";
6
6
  import { _ as _5 } from "./chunks/LpAlert-BAYHDSS1.js";
7
- import { _ as _6 } from "./chunks/LpAppShell-Cs9jDoqJ.js";
7
+ import { _ as _6 } from "./chunks/LpAppShell-1rTB24lI.js";
8
8
  import { _ as _7 } from "./chunks/LpAutocomplete-DExlIZe_.js";
9
9
  import { _ as _8 } from "./chunks/LpAvatar-D3l4nabg.js";
10
10
  import { _ as _9 } from "./chunks/LpBadge-Fe0D1bYx.js";
@@ -19,12 +19,12 @@ 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-B6RAvDES.js";
22
+ import { _ as _17 } from "./chunks/LpConfirmDialog-BH7az_E7.js";
23
23
  import { _ as _18 } from "./chunks/LpContextMenu-B6WBLleS.js";
24
24
  import { _ as _19 } from "./chunks/LpDatePicker-BPbvu3cZ.js";
25
25
  import { _ as _20 } from "./chunks/LpDisclosure-ChQYmjlR.js";
26
26
  import { _ as _21 } from "./chunks/LpDivider-CUX46KR0.js";
27
- import { _ as _22 } from "./chunks/LpDrawer-CslZtgUm.js";
27
+ import { _ as _22 } from "./chunks/LpDrawer-E67O8fFP.js";
28
28
  import { _ as _23 } from "./chunks/LpDropdownMenu-DJpoqRCb.js";
29
29
  import { default as default4 } from "./components/LpDropzone.vue.js";
30
30
  import { _ as _24 } from "./chunks/LpEmptyState-Dsdqz1Wj.js";
@@ -40,8 +40,8 @@ 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-D3QxgWaT.js";
44
- import { _ as _35 } from "./chunks/LpModalNav-Dkogc3O_.js";
43
+ import { _ as _34 } from "./chunks/LpModal-Dq_qXN8-.js";
44
+ import { _ as _35 } from "./chunks/LpModalNav-B42a7R-3.js";
45
45
  import { _ as _36 } from "./chunks/LpNotificationBell-lHePf89W.js";
46
46
  import { _ as _37 } from "./chunks/LpNumberField-CWuX08iX.js";
47
47
  import { _ as _38 } from "./chunks/LpNumberFlow-amWYaeuA.js";
@@ -63,7 +63,7 @@ import { _ as _52 } from "./chunks/LpSelect-ANIVtrpM.js";
63
63
  import { _ as _53 } from "./chunks/LpServiceNode-D8uJ8sGk.js";
64
64
  import { _ as _54 } from "./chunks/LpShift-DXNsauQZ.js";
65
65
  import { default as default6 } from "./components/LpShortcutsDialog.vue.js";
66
- import { _ as _55 } from "./chunks/LpSidebar-DvGfofv6.js";
66
+ import { _ as _55 } from "./chunks/LpSidebar-DvxMte3k.js";
67
67
  import { _ as _56 } from "./chunks/LpSkeleton-CTI-vBgW.js";
68
68
  import { _ as _57 } from "./chunks/LpSlider-BwNIBkAK.js";
69
69
  import { _ as _58 } from "./chunks/LpSparkline-C6xQ2dwv.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.37.3",
3
+ "version": "0.37.5",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -1,3 +1,10 @@
1
+ <script lang="ts">
2
+ import { ref as sharedRef } from "vue"
3
+
4
+ // One pull at a time across every edge-open drawer on the page.
5
+ const edgeOwner = sharedRef<symbol | null>(null)
6
+ </script>
7
+
1
8
  <script setup lang="ts">
2
9
  /*
3
10
  * Side / bottom panel built on vaul-vue (drag-driven drawer over reka's Dialog
@@ -12,7 +19,6 @@
12
19
  * solid background) for vaul to find and transform. Without it the drawer still
13
20
  * works — the background just doesn't scale.
14
21
  */
15
- import { usePointerSwipe } from "@vueuse/core"
16
22
  import {
17
23
  DrawerClose,
18
24
  DrawerContent,
@@ -27,7 +33,7 @@ import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
27
33
  import { CLOSE_ICON } from "./dropdown"
28
34
  import LpIcon from "./LpIcon.vue"
29
35
  import LpScrollArea from "./LpScrollArea.vue"
30
- import { useModalLayer } from "./modalDepth"
36
+ import { anyModalOpen, useModalLayer } from "./modalDepth"
31
37
 
32
38
  type Direction = "top" | "bottom" | "left" | "right"
33
39
 
@@ -68,21 +74,20 @@ const props = withDefaults(
68
74
  */
69
75
  noDragContent?: boolean
70
76
  /**
71
- * Open by dragging in from the screen edge (Discord-style). Renders an
72
- * invisible edge strip; a pull from it drags a preview panel that follows
73
- * the finger, and a release past `edgeOpenThreshold` opens the drawer. Only
74
- * meaningful for left/right drawers.
77
+ * Open by dragging in from the screen edge (Discord-style). A touch that
78
+ * starts within `edgeSize` of the edge and travels inward drags a preview of
79
+ * the panel (with its content) under the finger; a release past
80
+ * `edgeOpenThreshold`, or a quick flick, opens the drawer. Nothing is laid
81
+ * over the edge, so taps there still reach the page. Left/right drawers only.
75
82
  */
76
83
  edgeOpen?: boolean
77
84
  /**
78
- * Tailwind breakpoint above which the edge strip is disabled. Edge
79
- * swipe-to-open only makes sense on mobile: the strip listens for
80
- * touch/pen pointers only, so on a desktop it can never open the drawer
81
- * but still overlays the screen edge and swallows clicks. Pass the same
82
- * breakpoint at which the host switches to the drawer (LpSidebar does).
85
+ * Tailwind breakpoint at and above which the edge pull is ignored. Pass the
86
+ * same breakpoint at which the host switches to the drawer (LpSidebar
87
+ * does), so a wide touch screen with the rail visible doesn't pull it too.
83
88
  */
84
89
  edgeBreakpoint?: "sm" | "md" | "lg" | "xl"
85
- /** Width (px) of the edge grab strip. */
90
+ /** Distance (px) from the edge within which a touch can start a pull. */
86
91
  edgeSize?: number
87
92
  /** Fraction (0–1) of the panel width the pull must reach to open on release. */
88
93
  edgeOpenThreshold?: number
@@ -226,19 +231,40 @@ const vNoDragControls = {
226
231
  }
227
232
 
228
233
  // ── Edge-drag-to-open (Discord-style) ──────────────────────────────────────
229
- // vaul opens/closes programmatically and only drags an *already-open* drawer,
230
- // so the pull-from-edge gesture is ours: an invisible edge strip captures the
231
- // pointer, we translate a preview panel that follows the finger, and a release
232
- // past the threshold flips `open` (vaul then animates the rest). Horizontal
233
- // drawers only.
234
- const edgeStrip = ref<HTMLElement | null>(null)
235
- // 0 (closed) … 1 (fully pulled in). Drives the preview transform + overlay.
234
+ // vaul only drags an *already-open* drawer, so the pull-from-edge gesture is
235
+ // ours. It is detected on the document rather than on an overlay strip: a strip
236
+ // covering the screen edge swallowed every tap there, and the app burger lives
237
+ // exactly in that corner. A touch near the edge is only a candidate; it becomes
238
+ // a pull once the finger travels inward past EDGE_SLOP and mostly sideways, so a
239
+ // tap still reaches the control under it and a vertical scroll stays a scroll.
240
+ // While pulling, a preview of the real panel content follows the finger; on
241
+ // release we open if the pull passed the threshold or was a quick flick, then
242
+ // hand off to the real drawer without a second slide.
243
+ const EDGE_SLOP = 10
244
+ const FLICK_SPEED = 0.45
245
+ const BREAKPOINT_PX = { sm: 640, md: 768, lg: 1024, xl: 1280 } as const
246
+ // Controls whose own drag must win over the pull, plus explicit opt-outs.
247
+ const EDGE_IGNORE =
248
+ "input, textarea, select, [contenteditable=''], [contenteditable='true'], [data-vaul-no-drag], [data-lp-no-edge-pull]"
249
+
250
+ // 0 (closed) … 1 (fully pulled in). Drives the preview transform + scrim.
236
251
  const peek = ref(0)
237
252
  const peeking = ref(false)
238
253
  // True while we hand off from the finger-preview to the real drawer, so vaul's
239
254
  // own open animation is suppressed — the preview has already slid it in, a
240
255
  // second slide would look like a double-open.
241
256
  const handingOff = ref(false)
257
+ // vaul plays slideFromLeft whenever data-vaul-animate is absent, so lifting it
258
+ // right after the hand-off restarted the open slide from off-screen — the panel
259
+ // visibly opened twice. It stays on for as long as the drawer is open and goes
260
+ // with the close, which keeps vaul's close animation.
261
+ const skipOpenSlide = ref(false)
262
+ watch(
263
+ () => props.open,
264
+ (open) => {
265
+ if (!open) skipOpenSlide.value = false
266
+ },
267
+ )
242
268
 
243
269
  // Resolve the panel's main-axis size in px to map drag distance → progress.
244
270
  function panelPx(): number {
@@ -248,44 +274,140 @@ function panelPx(): number {
248
274
  return Math.min(vw * 0.94, Number.isFinite(cap) ? cap : SIZES[props.size] * 16)
249
275
  }
250
276
 
251
- usePointerSwipe(edgeStrip, {
252
- threshold: 0,
253
- pointerTypes: ["touch", "pen"],
254
- onSwipeStart() {
255
- if (!props.edgeOpen || props.open) return
277
+ function edgeEnabled(): boolean {
278
+ if (!props.edgeOpen || !isHorizontal.value || props.open || handingOff.value) return false
279
+ const bp = props.edgeBreakpoint ? BREAKPOINT_PX[props.edgeBreakpoint] : null
280
+ return bp === null || window.innerWidth < bp
281
+ }
282
+
283
+ let track: { id: number; x: number; y: number; t: number; lastX: number; lastT: number; speed: number } | null = null
284
+ const owner = Symbol("edge-pull")
285
+
286
+ function reducedMotion(): boolean {
287
+ return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
288
+ }
289
+
290
+ // A horizontally scrollable ancestor that can still move the way the finger
291
+ // pulls (a carousel, a wide table) keeps the gesture: the pull only claims
292
+ // what nothing under the finger wants.
293
+ function scrollsWithPull(target: EventTarget | null): boolean {
294
+ let el = target instanceof Element ? target : null
295
+ const pullsRight = dir.value === "left"
296
+ while (el && el !== document.body) {
297
+ if (el.closest(EDGE_IGNORE) === el) return true
298
+ if (el.scrollWidth > el.clientWidth) {
299
+ const overflow = getComputedStyle(el).overflowX
300
+ if (overflow === "auto" || overflow === "scroll") {
301
+ if (pullsRight ? el.scrollLeft > 0 : el.scrollLeft + el.clientWidth < el.scrollWidth) return true
302
+ }
303
+ }
304
+ el = el.parentElement
305
+ }
306
+ return false
307
+ }
308
+
309
+ function inward(x: number): number {
310
+ return dir.value === "right" ? window.innerWidth - x : x
311
+ }
312
+
313
+ function onTouchStart(e: TouchEvent) {
314
+ if (track) dropTrack()
315
+ if (e.touches.length !== 1 || !edgeEnabled() || anyModalOpen() || edgeOwner.value) return
316
+ const t = e.touches[0]
317
+ if (inward(t.clientX) > props.edgeSize || scrollsWithPull(e.target)) return
318
+ track = { id: t.identifier, x: t.clientX, y: t.clientY, t: e.timeStamp, lastX: t.clientX, lastT: e.timeStamp, speed: 0 }
319
+ }
320
+
321
+ function onTouchMove(e: TouchEvent) {
322
+ if (!track) return
323
+ const t = Array.from(e.changedTouches).find((x) => x.identifier === track!.id)
324
+ if (!t) return
325
+ const sign = dir.value === "right" ? -1 : 1
326
+ const dx = (t.clientX - track.x) * sign
327
+ const dy = t.clientY - track.y
328
+ if (!peeking.value) {
329
+ if (Math.abs(dy) > EDGE_SLOP && Math.abs(dy) > Math.abs(dx)) {
330
+ track = null
331
+ return
332
+ }
333
+ if (dx < EDGE_SLOP || Math.abs(dx) < Math.abs(dy)) return
334
+ if (edgeOwner.value && edgeOwner.value !== owner) {
335
+ track = null
336
+ return
337
+ }
338
+ edgeOwner.value = owner
256
339
  peeking.value = true
257
- },
258
- onSwipe(e) {
259
- if (!peeking.value) return
260
- // Inward pull distance: from the right edge we move left (−x), from the
261
- // left edge we move right (+x). Normalise to 0…1 of the panel width.
262
- const fromRight = dir.value === "right"
263
- const dx = fromRight ? window.innerWidth - e.clientX : e.clientX
264
- peek.value = Math.max(0, Math.min(1, dx / panelPx()))
265
- },
266
- onSwipeEnd() {
267
- if (!peeking.value) return
268
- peeking.value = false
269
- if (peek.value >= props.edgeOpenThreshold) {
270
- // Open: let the preview finish sliding to fully-in, then swap in the real
271
- // drawer *without* a vaul slide (handingOff) so there's no second anim.
272
- peek.value = 1
273
- handingOff.value = true
340
+ }
341
+ if (e.cancelable) e.preventDefault()
342
+ const dt = e.timeStamp - track.lastT
343
+ if (dt > 0) track.speed = (((t.clientX - track.lastX) * sign) / dt) * 0.6 + track.speed * 0.4
344
+ track.lastX = t.clientX
345
+ track.lastT = e.timeStamp
346
+ peek.value = Math.max(0, Math.min(1, inward(t.clientX) / panelPx()))
347
+ }
348
+
349
+ function dropTrack() {
350
+ track = null
351
+ if (edgeOwner.value === owner) edgeOwner.value = null
352
+ }
353
+
354
+ function settle() {
355
+ const flick = track !== null && track.speed > FLICK_SPEED && peek.value > 0.05
356
+ dropTrack()
357
+ if (!peeking.value) return
358
+ peeking.value = false
359
+ if (peek.value >= props.edgeOpenThreshold || flick) {
360
+ peek.value = 1
361
+ handingOff.value = true
362
+ skipOpenSlide.value = true
363
+ timers.push(
274
364
  window.setTimeout(() => {
275
365
  emit("update:open", true)
276
- // Clear after the real drawer has mounted in place.
277
- window.setTimeout(() => {
278
- handingOff.value = false
279
- peek.value = 0
280
- }, 60)
281
- }, 160)
282
- } else {
283
- // Below threshold: snap the preview back out.
284
- peek.value = 0
285
- }
286
- },
366
+ timers.push(
367
+ window.setTimeout(() => {
368
+ handingOff.value = false
369
+ peek.value = 0
370
+ }, 60),
371
+ )
372
+ }, reducedMotion() ? 0 : 180),
373
+ )
374
+ } else {
375
+ peek.value = 0
376
+ }
377
+ }
378
+
379
+ const timers: number[] = []
380
+ onBeforeUnmount(() => {
381
+ for (const id of timers) window.clearTimeout(id)
382
+ if (edgeOwner.value === owner) edgeOwner.value = null
287
383
  })
288
384
 
385
+ function onTouchEnd(e: TouchEvent) {
386
+ if (track && !Array.from(e.changedTouches).some((x) => x.identifier === track!.id)) return
387
+ settle()
388
+ }
389
+
390
+ if (typeof document !== "undefined") {
391
+ watch(
392
+ () => props.edgeOpen && isHorizontal.value,
393
+ (on, _, onCleanup) => {
394
+ if (!on) return
395
+ const passive = { passive: true }
396
+ document.addEventListener("touchstart", onTouchStart, passive)
397
+ document.addEventListener("touchmove", onTouchMove, { passive: false })
398
+ document.addEventListener("touchend", onTouchEnd, passive)
399
+ document.addEventListener("touchcancel", onTouchEnd, passive)
400
+ onCleanup(() => {
401
+ document.removeEventListener("touchstart", onTouchStart)
402
+ document.removeEventListener("touchmove", onTouchMove)
403
+ document.removeEventListener("touchend", onTouchEnd)
404
+ document.removeEventListener("touchcancel", onTouchEnd)
405
+ })
406
+ },
407
+ { immediate: true },
408
+ )
409
+ }
410
+
289
411
  // The preview panel slides with the finger: translate from fully-off (100%)
290
412
  // toward 0 as peek → 1. Kept visible through the hand-off so the real drawer
291
413
  // can appear underneath without a flash.
@@ -295,51 +417,34 @@ const previewStyle = computed(() => {
295
417
  const axis = dir.value === "right" ? off : -off
296
418
  return {
297
419
  transform: `translateX(${axis}%)`,
298
- transition: peeking.value ? "none" : "transform 0.16s ease-out",
420
+ transition: peeking.value || reducedMotion() ? "none" : "transform 0.18s cubic-bezier(0.32, 0.72, 0, 1)",
299
421
  pointerEvents: "none" as const,
300
422
  }
301
423
  })
302
-
303
- const edgeStripStyle = computed(() => {
304
- const w = `${props.edgeSize}px`
305
- return dir.value === "right"
306
- ? { right: "0", width: w }
307
- : { left: "0", width: w }
308
- })
309
-
310
- // The strip must only exist below the host's mobile breakpoint. It is hidden
311
- // with the same Tailwind breakpoint classes the rest of the mobile logic
312
- // (LpSidebar rail, burger) uses — no JS media query needed.
313
- const EDGE_STRIP_HIDDEN: Record<NonNullable<typeof props.edgeBreakpoint>, string> = {
314
- sm: "sm:hidden",
315
- md: "md:hidden",
316
- lg: "lg:hidden",
317
- xl: "xl:hidden",
318
- }
319
- const edgeStripClass = computed(() =>
320
- props.edgeBreakpoint ? EDGE_STRIP_HIDDEN[props.edgeBreakpoint] : "",
321
- )
322
424
  </script>
323
425
 
324
426
  <template>
325
- <!-- Edge-drag-to-open: an invisible grab strip + a finger-following preview
326
- of the panel, shown only while closed. Horizontal drawers only. -->
427
+ <!-- Edge-drag-to-open: a finger-following preview of the panel (with its
428
+ real content, inert) and a scrim that darkens with the pull. Nothing
429
+ overlays the screen edge, so taps there reach the page. -->
327
430
  <template v-if="edgeOpen && isHorizontal">
328
431
  <div
329
- ref="edgeStrip"
330
- class="fixed inset-y-0 z-(--z-overlay) touch-none"
331
- :class="edgeStripClass"
332
- :style="edgeStripStyle"
333
- aria-hidden="true"
432
+ v-show="peeking || peek > 0"
433
+ class="lp-scrim pointer-events-none fixed inset-0"
434
+ :style="{ zIndex: layer.scrim, opacity: peek, transition: peeking || reducedMotion() ? 'none' : 'opacity 0.18s ease-out' }"
334
435
  />
335
436
  <div
336
437
  v-show="peeking || peek > 0"
337
- class="pointer-events-none fixed inset-0 z-(--z-overlay) bg-black/50"
338
- :style="{ opacity: peek }"
339
- />
340
- <div :class="contentClass" :style="[sizeStyle, previewStyle]" aria-hidden="true">
341
- <div v-if="hasHeader" class="flex items-start justify-between gap-4" :class="headerClass">
438
+ :class="contentClass"
439
+ :style="[sizeStyle, previewStyle, { zIndex: layer.panel }]"
440
+ aria-hidden="true"
441
+ inert
442
+ >
443
+ <header v-if="hasHeader" class="flex items-start justify-between gap-4" :class="headerClass">
342
444
  <span class="text-base font-semibold text-ink">{{ title }}</span>
445
+ </header>
446
+ <div class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden" :class="padClass">
447
+ <slot v-if="peeking || peek > 0" />
343
448
  </div>
344
449
  </div>
345
450
  </template>
@@ -362,7 +467,7 @@ const edgeStripClass = computed(() =>
362
467
  <DrawerContent
363
468
  :class="contentClass"
364
469
  :style="[sizeStyle, { zIndex: layer.panel }]"
365
- :data-vaul-animate="handingOff ? 'false' : undefined"
470
+ :data-vaul-animate="handingOff || skipOpenSlide ? 'false' : undefined"
366
471
  >
367
472
  <!-- Resize grip on the inner edge (horizontal drawers). data-vaul-no-drag
368
473
  so grabbing it resizes the panel instead of starting a vaul drag. -->
@@ -8,7 +8,6 @@ import {
8
8
  DialogRoot,
9
9
  DialogTitle,
10
10
  } from "reka-ui"
11
- import type { ComponentPublicInstance } from "vue"
12
11
  import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
13
12
  import { useShift } from "../composables/useShift"
14
13
  import { useModalLayer } from "./modalDepth"
@@ -143,7 +142,7 @@ const bodyPad = computed(() =>
143
142
  * scroll area exactly as before. A panel whose size never moves is pinned once
144
143
  * and never transitions.
145
144
  */
146
- const panelRef = ref<ComponentPublicInstance | null>(null)
145
+ const panelRef = ref<HTMLElement | null>(null)
147
146
 
148
147
  /**
149
148
  * Controls that are worth landing on when the dialog opens, in order of
@@ -171,8 +170,8 @@ const AUTO_FOCUS_ORDER = [
171
170
  */
172
171
  function onOpenAutoFocus(event: Event) {
173
172
  if (props.autoFocus === "none") return
174
- const panel = panelRef.value?.$el
175
- if (!(panel instanceof HTMLElement)) return
173
+ const panel = panelRef.value
174
+ if (!panel) return
176
175
 
177
176
  const selectors =
178
177
  props.autoFocus === "auto" ? AUTO_FOCUS_ORDER : [props.autoFocus]
@@ -210,13 +209,10 @@ const {
210
209
  resizing,
211
210
  } = useShift({ axis: "height", enabled: () => props.open })
212
211
 
213
- // reka renders a real element but exposes it as a component instance, so the
214
- // sized element is handed over through $el once the panel has mounted.
215
212
  watch(
216
213
  () => (props.open ? panelRef.value : null),
217
214
  (panel) => {
218
- const el = panel?.$el
219
- sizedEl.value = el instanceof HTMLElement ? el : null
215
+ sizedEl.value = panel
220
216
  },
221
217
  { flush: "post" },
222
218
  )
@@ -265,48 +261,38 @@ watch(
265
261
  class="fixed inset-0 flex items-center justify-center pointer-events-none"
266
262
  :style="{ zIndex: layer.panel }"
267
263
  >
268
- <!-- The dialog and its aside stand in a row of their own, and that row is
269
- what the wrapper centres. Aligning them on the full-screen wrapper
270
- instead would pin them to the top of the WINDOW, and the dialog would
271
- be free to end up shorter than the aside beside it.
272
- `items-start` inside the row: the row collapses to the taller of the
273
- two, both hang from its top edge, and neither is stretched. -->
274
- <div
275
- class="flex items-start"
276
- :style="hasAside ? { maxHeight: 'min(90vh, calc(100dvh - 2rem))' } : undefined"
264
+ <!-- The dialog and its asides stand in one row, and that row IS the
265
+ DialogContent. An aside rendered as a sibling of DialogContent sat
266
+ outside the modal: reka marks everything outside aria-hidden and traps
267
+ focus inside, so a section list beside the dialog could be neither
268
+ tabbed to nor read by a screen reader. Inside DialogContent it belongs
269
+ to the dialog, while the visible card is the inner panel, so nothing
270
+ moves on screen.
271
+ `items-start`: the row collapses to the taller of the two, both hang
272
+ from its top edge, and neither is stretched. Without a description
273
+ reka-ui warns on every open, and the opt-out it checks for is an
274
+ ABSENT `aria-describedby`; a caller that passes a description keeps
275
+ the link to DialogDescription. -->
276
+ <DialogContent
277
+ class="pointer-events-auto flex min-h-0 items-start outline-none data-[state=open]:animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[rise-out_120ms_cubic-bezier(0.4,0,1,1)]"
278
+ :style="{ maxHeight: 'min(90vh, calc(100dvh - 2rem))' }"
279
+ v-bind="describedByAttrs"
280
+ @open-auto-focus="onOpenAutoFocus"
281
+ @interact-outside="onInteractOutside"
282
+ @pointer-down-outside="onInteractOutside"
277
283
  >
278
- <!-- Bound to `open`, not to the slot alone: DialogContent unmounts itself
279
- with the dialog, but an aside is an ordinary node in the portal and
280
- stayed on screen after the dialog closed — a section list floating
281
- over the page with nothing behind it.
282
-
283
- `pointer-events-auto` because the wrapper lets clicks through to the
284
- overlay, and the aside is not the overlay. `data-lp-modal-aside` is
285
- what tells `interact-outside` a click here is not a click on the
286
- scrim — without it the dialog closes under the pointer. -->
287
284
  <aside
288
- v-if="open && $slots.aside"
285
+ v-if="$slots.aside"
289
286
  data-lp-modal-aside
290
- class="pointer-events-auto flex min-h-0 flex-col"
287
+ class="flex min-h-0 flex-col"
291
288
  >
292
289
  <slot name="aside" />
293
290
  </aside>
294
- <!-- Without a description reka-ui warns on every open, and the opt-out it
295
- checks for is an ABSENT `aria-describedby` — despite the message
296
- naming the string "undefined" (a leftover from Radix, where
297
- `aria-describedby={undefined}` is how you drop the attribute).
298
- Dropping it here is what silences the warning; a caller that does
299
- pass a description keeps the generated id and the link to
300
- DialogDescription. -->
301
- <DialogContent
291
+ <div
302
292
  ref="panelRef"
303
- class="pointer-events-auto flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col overflow-hidden rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[rise-out_120ms_cubic-bezier(0.4,0,1,1)]"
293
+ class="flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col overflow-hidden rounded-card border border-line bg-surface-raised shadow-panel"
304
294
  :class="[widthClass, tweening ? 'transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none' : '']"
305
295
  :style="{ ...(width ? { width } : {}), ...(hasAside ? { alignSelf: 'stretch' } : {}) }"
306
- v-bind="describedByAttrs"
307
- @open-auto-focus="onOpenAutoFocus"
308
- @interact-outside="onInteractOutside"
309
- @pointer-down-outside="onInteractOutside"
310
296
  >
311
297
  <header v-if="title || $slots.title" class="flex shrink-0 items-start justify-between gap-4 p-5 pb-3">
312
298
  <div class="flex flex-col gap-1">
@@ -357,16 +343,15 @@ watch(
357
343
  <footer v-if="$slots.footer" class="flex shrink-0 justify-end gap-2 p-5 pt-4">
358
344
  <slot name="footer" />
359
345
  </footer>
360
- </DialogContent>
361
-
346
+ </div>
362
347
  <aside
363
- v-if="open && $slots.asideEnd"
348
+ v-if="$slots.asideEnd"
364
349
  data-lp-modal-aside
365
- class="pointer-events-auto flex min-h-0 flex-col"
350
+ class="flex min-h-0 flex-col"
366
351
  >
367
352
  <slot name="asideEnd" />
368
353
  </aside>
369
- </div>
354
+ </DialogContent>
370
355
  </div>
371
356
  </DialogPortal>
372
357
  </DialogRoot>
@@ -77,3 +77,8 @@ export function useModalLayer(): {
77
77
  }),
78
78
  }
79
79
  }
80
+
81
+ /** True while any dialog or drawer holds a level — e.g. to keep a gesture from opening another one over it. */
82
+ export function anyModalOpen(): boolean {
83
+ return stack.value.length > 0
84
+ }