@leavepulse/ui 0.93.3 → 0.94.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +53 -3
  2. package/dist/assets/LpTabs-D7EyG21l.css +18 -0
  3. package/dist/assets/LpVideoTrim-D8JDg9Qn.css +8 -0
  4. package/dist/chunks/AppShellContextNav-BoH4ffnC.js +54 -0
  5. package/dist/chunks/{LpAppShell-BWaVRsDB.js → LpAppShell-xEBmGV78.js} +12 -5
  6. package/dist/chunks/{LpDateRangePicker-CAn2yGbn.js → LpDateRangePicker-DRBB8z6t.js} +1 -1
  7. package/dist/chunks/{LpDrawer-iRHwJOGn.js → LpDrawer-BkWjFdV0.js} +81 -42
  8. package/dist/chunks/{LpLightbox-a24V_0Wk.js → LpLightbox-DPgz7IsT.js} +62 -49
  9. package/dist/chunks/{LpMapPicker-DVfiy3rR.js → LpMapPicker-V0Lybi10.js} +19 -52
  10. package/dist/chunks/{LpMediaGallery-CtWA5Uab.js → LpMediaGallery-CTejKF2-.js} +1 -1
  11. package/dist/chunks/{LpModalNav-DivqT1NV.js → LpModalNav-DVXAF-DG.js} +1 -1
  12. package/dist/chunks/{LpRangeCalendar-C9gkNMYw.js → LpRangeCalendar-9BvaLZji.js} +3 -4
  13. package/dist/chunks/LpScrollStrip-D4tsOfAQ.js +78 -0
  14. package/dist/chunks/{LpSidebar-DQtCYlT4.js → LpSidebar-BIhusppV.js} +1 -1
  15. package/dist/component-names.d.ts +1 -1
  16. package/dist/components/AppShellContextNav.vue.js +1 -1
  17. package/dist/components/LpAppShell.vue.js +1 -1
  18. package/dist/components/LpChart.vue.js +3 -3
  19. package/dist/components/LpDateRangePicker.vue.js +1 -1
  20. package/dist/components/LpDrawer.vue.d.ts +10 -2
  21. package/dist/components/LpDrawer.vue.js +1 -1
  22. package/dist/components/LpDropzone.vue.d.ts +1 -1
  23. package/dist/components/LpLightbox.vue.js +1 -1
  24. package/dist/components/LpMapPicker.vue.js +1 -1
  25. package/dist/components/LpMediaGallery.vue.js +1 -1
  26. package/dist/components/LpModalNav.vue.js +1 -1
  27. package/dist/components/LpRangeCalendar.vue.js +1 -1
  28. package/dist/components/LpRevealOnScroll.vue.d.ts +1 -1
  29. package/dist/components/LpScrollStrip.vue.d.ts +48 -0
  30. package/dist/components/LpScrollStrip.vue.js +4 -0
  31. package/dist/components/LpSidebar.vue.js +1 -1
  32. package/dist/components/LpTabs.vue.d.ts +4 -4
  33. package/dist/components/LpTabs.vue.js +104 -90
  34. package/dist/components/LpVideoTrim.vue.js +41 -54
  35. package/dist/composables/gesture.d.ts +141 -0
  36. package/dist/composables/gesture.js +176 -0
  37. package/dist/composables/scroll.d.ts +5 -1
  38. package/dist/composables/scroll.js +16 -9
  39. package/dist/composables/useAppUpdate.d.ts +26 -0
  40. package/dist/composables/useAppUpdate.js +91 -0
  41. package/dist/composables/useGesture.d.ts +107 -0
  42. package/dist/composables/useGesture.js +281 -0
  43. package/dist/composables/usePaintSelect.js +3 -4
  44. package/dist/composables/useScrollStrip.d.ts +27 -0
  45. package/dist/composables/useScrollStrip.js +194 -0
  46. package/dist/composables/useTouchHold.d.ts +3 -1
  47. package/dist/composables/useTouchHold.js +9 -51
  48. package/dist/composables/useZoomPan.js +4 -6
  49. package/dist/index.d.ts +9 -0
  50. package/dist/index.js +180 -152
  51. package/package.json +1 -1
  52. package/src/component-names.ts +1 -0
  53. package/src/components/AppShellContextNav.vue +3 -2
  54. package/src/components/LpAppShell.vue +10 -1
  55. package/src/components/LpChart.vue +2 -2
  56. package/src/components/LpDrawer.vue +121 -54
  57. package/src/components/LpLightbox.vue +33 -20
  58. package/src/components/LpMapPicker.vue +22 -64
  59. package/src/components/LpRangeCalendar.vue +4 -5
  60. package/src/components/LpScrollStrip.vue +96 -0
  61. package/src/components/LpSidebar.vue +2 -2
  62. package/src/components/LpTabs.vue +36 -17
  63. package/src/components/LpVideoTrim.vue +35 -46
  64. package/src/composables/gesture.ts +314 -0
  65. package/src/composables/scroll.ts +20 -9
  66. package/src/composables/useAppUpdate.ts +156 -0
  67. package/src/composables/useGesture.ts +451 -0
  68. package/src/composables/usePaintSelect.ts +4 -5
  69. package/src/composables/useScrollStrip.ts +249 -0
  70. package/src/composables/useTouchHold.ts +15 -73
  71. package/src/composables/useZoomPan.ts +6 -9
  72. package/src/index.ts +27 -0
  73. package/src/tokens/effects.css +40 -0
  74. package/dist/assets/LpTabs-Bh7a-Bdl.css +0 -18
  75. package/dist/assets/LpVideoTrim-CMoUw3dJ.css +0 -8
  76. package/dist/chunks/AppShellContextNav-C6Ho2mof.js +0 -49
@@ -58,6 +58,7 @@ import { easingToken, durationToken, prefersReducedMotion } from "../composables
58
58
  import { useLabels } from "../composables/useLpConfig"
59
59
  import { useResizeObserver } from "../composables/useResizeObserver"
60
60
  import { useScrollMemory } from "../composables/scrollMemory"
61
+ import { useScrollStrip } from "../composables/useScrollStrip"
61
62
  import { isHTMLElement, nodeWindow, useView, viewWindow } from "../composables/useView"
62
63
  import AppShellContextNav from "./AppShellContextNav.vue"
63
64
  import {
@@ -87,6 +88,11 @@ export type { SidebarItem, SidebarSection } from "./sidebar"
87
88
  /* Past this much scrolled the way back up is worth a button. */
88
89
  const BACK_TO_TOP_AFTER_PX = 600
89
90
 
91
+ // The header actions scroll sideways on a narrow bar; a mouse reaches the ones
92
+ // past the edge with the wheel or a drag, and the clipped edge fades.
93
+ const headerActions = ref<{ $el: HTMLElement } | null>(null)
94
+ const actionStrip = useScrollStrip(() => headerActions.value?.$el)
95
+
90
96
  const props = withDefaults(
91
97
  defineProps<{
92
98
  /** Flat items, or sections for grouped nav. Forwarded to LpSidebar. */
@@ -753,10 +759,13 @@ const backToTopClass = computed(() =>
753
759
  actions now cap at two thirds of the bar and scroll within it, so
754
760
  the title keeps a third and every button stays reachable. -->
755
761
  <LpShift
762
+ ref="headerActions"
756
763
  axis="width"
757
764
  anchor="end"
758
765
  presence
759
- class="ml-auto flex max-w-[66%] shrink items-center gap-2 overflow-x-auto lp-scrollbar-none md:max-w-none md:shrink-0 md:overflow-visible"
766
+ class="lp-strip-fade ml-auto flex max-w-[66%] shrink items-center gap-2 overflow-x-auto lp-scrollbar-none md:max-w-none md:shrink-0 md:overflow-visible"
767
+ :data-lp-more-start="actionStrip.overflowing.value && !actionStrip.atStart.value ? '' : undefined"
768
+ :data-lp-more-end="actionStrip.overflowing.value && !actionStrip.atEnd.value ? '' : undefined"
760
769
  >
761
770
  <slot name="header-actions" />
762
771
  </LpShift>
@@ -24,6 +24,7 @@ import { useLabels } from "../composables/useLpConfig"
24
24
  import { easeTravel } from "../composables/easing"
25
25
  import { usePrefersReducedMotion } from "../composables/useReducedMotion"
26
26
  import { startPointerDrag } from "../composables/pointerDrag"
27
+ import { MOUSE_SLOP } from "../composables/gesture"
27
28
  import { nodeWindow } from "../composables/useView"
28
29
  import { usePillTransition } from "../composables/usePillTransition"
29
30
  import { useReveal } from "../composables/useReveal"
@@ -976,7 +977,6 @@ const crosshair = computed(() => (active.value >= 0 ? active.value : peerSlot.va
976
977
  watch(active, (slot) => publish(slot >= 0 ? (labels.value[slot] ?? null) : null))
977
978
 
978
979
  /* Past this many px a press on the plot is a selection, not a click. */
979
- const SELECT_SLOP = 4
980
980
  let swallowClick = false
981
981
  const selection = ref<{ from: number; to: number } | null>(null)
982
982
 
@@ -988,7 +988,7 @@ const onPointerDown = (e: PointerEvent) => {
988
988
  startPointerDrag(e, {
989
989
  move(ev) {
990
990
  const x = offset(ev)
991
- if (Math.abs(x - pressedAt) > SELECT_SLOP) selection.value = { from: pressedAt, to: x }
991
+ if (Math.abs(x - pressedAt) > MOUSE_SLOP) selection.value = { from: pressedAt, to: x }
992
992
  },
993
993
  end(completed) {
994
994
  const picked = selection.value
@@ -20,7 +20,8 @@ export type DrawerLabels = {
20
20
  * primitive — same base as the rest of the kit, so accessibility and the token
21
21
  * theming carry over). Over the old reka-Dialog version this adds drag-to-
22
22
  * dismiss with inertia, snap points, a draggable handle and an optional
23
- * scale-background effect. Slot/prop surface stays backwards compatible: `side`
23
+ * scale-background effect. Side drawers swipe on the kit's gesture engine
24
+ * instead (see the edge pull below); vaul drags sheets. Slot/prop surface stays backwards compatible: `side`
24
25
  * still works (mapped onto `direction`), plus `size`/`width`/title/footer.
25
26
  *
26
27
  * scale-background note: the effect scales the page behind the drawer, so the
@@ -50,6 +51,8 @@ import LpIcon from "./LpIcon.vue"
50
51
  import LpScrollArea from "./LpScrollArea.vue"
51
52
  import { anyModalOpen, useModalLayer } from "./modalDepth"
52
53
  import { startPointerDrag } from "../composables/pointerDrag"
54
+ import { createDragSession, rubberBand, shouldDismiss, TOUCH_SLOP, type DragSession } from "../composables/gesture"
55
+ import { createGesture, type GestureEnd, type GestureState } from "../composables/useGesture"
53
56
  import { isElement, nodeWindow, useOutsideGuard, usePortalTarget, useView, viewDocument, viewWindow } from "../composables/useView"
54
57
 
55
58
  type Direction = "top" | "bottom" | "left" | "right"
@@ -104,7 +107,11 @@ const props = withDefaults(
104
107
  * does), so a wide touch screen with the rail visible doesn't pull it too.
105
108
  */
106
109
  edgeBreakpoint?: "sm" | "md" | "lg" | "xl"
107
- /** Distance (px) from the edge within which a touch can start a pull. */
110
+ /**
111
+ * Distance (px) from the edge within which a touch can start a pull. Wide
112
+ * enough to start a thumb's width in, clear of the system's back gesture
113
+ * along the very edge.
114
+ */
108
115
  edgeSize?: number
109
116
  /** Fraction (0–1) of the panel width the pull must reach to open on release. */
110
117
  edgeOpenThreshold?: number
@@ -155,7 +162,7 @@ const props = withDefaults(
155
162
  size: "sm",
156
163
  dismissible: true,
157
164
  noDragControls: true,
158
- edgeSize: 44,
165
+ edgeSize: 72,
159
166
  edgeOpenThreshold: 0.3,
160
167
  minSize: "22rem",
161
168
  maxSize: "96vw",
@@ -345,22 +352,29 @@ const vNoDragControls = {
345
352
  updated: tagNoDrag,
346
353
  }
347
354
 
348
- // ── Edge-drag-to-open (Discord-style) ──────────────────────────────────────
349
- // vaul only drags an *already-open* drawer, so the pull-from-edge gesture is
350
- // ours. It is detected on the document rather than on an overlay strip: a strip
351
- // covering the screen edge swallowed every tap there, and the app burger lives
352
- // exactly in that corner. A touch near the edge is only a candidate; it becomes
353
- // a pull once the finger travels inward past EDGE_SLOP and mostly sideways, so a
354
- // tap still reaches the control under it and a vertical scroll stays a scroll.
355
- // While pulling, a preview of the real panel content follows the finger; on
356
- // release we open if the pull passed the threshold or was a quick flick, then
357
- // hand off to the real drawer without a second slide.
358
- const EDGE_SLOP = 10
359
- const FLICK_SPEED = 0.45
355
+ // ── Side drawers: pulled open from the edge, swiped shut ───────────────────
356
+ // One gesture model for both ways, on the kit's drag session (gesture.ts): a
357
+ // press becomes a pull once it travels past the slop and mostly sideways, a
358
+ // vertical start is a scroll and is let go, the panel's edge follows the
359
+ // finger 1:1, and a release completes when it is past `edgeOpenThreshold` /
360
+ // `closeThreshold` of the panel or was a flick (shouldDismiss), otherwise it
361
+ // springs back. A cancelled touch (the system took it) never completes.
362
+ //
363
+ // Opening is watched on the document with touch events rather than on an
364
+ // overlay strip: a strip over the screen edge swallowed every tap there, and
365
+ // only a non-passive touchmove can stop the page scrolling mid-gesture. The
366
+ // zone reaches `edgeSize` in from the edge, so a pull can start a thumb's
367
+ // width in, clear of the system's own back gesture at the very edge. While
368
+ // pulling, a preview of the real panel content follows the finger, then hands
369
+ // off to the real drawer without a second slide.
370
+ //
371
+ // Closing is useGesture on the panel and the scrim (touch and pen; a mouse
372
+ // closes with the scrim, Escape or the button). vaul's own drag is off for
373
+ // side drawers, so there is one behaviour, not two.
360
374
  const BREAKPOINT_PX = { sm: 640, md: 768, lg: 1024, xl: 1280 } as const
361
375
  // Controls whose own drag must win over the pull, plus explicit opt-outs.
362
376
  const EDGE_IGNORE =
363
- "input, textarea, select, [contenteditable=''], [contenteditable='true'], [data-vaul-no-drag], [data-lp-no-edge-pull]"
377
+ "input, textarea, select, [contenteditable=''], [contenteditable='true'], div[data-vaul-no-drag], [data-lp-no-edge-pull]"
364
378
 
365
379
  // 0 (closed) … 1 (fully pulled in). Drives the preview transform + scrim.
366
380
  const peek = ref(0)
@@ -377,7 +391,10 @@ const skipOpenSlide = ref(false)
377
391
  watch(
378
392
  () => props.open,
379
393
  (open) => {
380
- if (open) return
394
+ if (open) {
395
+ shut.value = 0
396
+ return
397
+ }
381
398
  skipOpenSlide.value = false
382
399
  // Closing mid-resize removes the grip, which takes its capture events along.
383
400
  endResize?.()
@@ -403,22 +420,22 @@ function edgeEnabled(): boolean {
403
420
  return bp === null || edgeWindow().innerWidth < bp
404
421
  }
405
422
 
406
- let track: { id: number; x: number; y: number; t: number; lastX: number; lastT: number; speed: number } | null = null
423
+ let track: { id: number; session: DragSession } | null = null
407
424
  const owner = Symbol("edge-pull")
408
425
 
409
426
  // A horizontally scrollable ancestor that can still move the way the finger
410
- // pulls (a carousel, a wide table) keeps the gesture: the pull only claims
411
- // what nothing under the finger wants.
412
- function scrollsWithPull(target: EventTarget | null): boolean {
427
+ // goes (a carousel, a wide table) keeps the gesture: the pull only claims
428
+ // what nothing under the finger wants. `towardOpen` is the opening direction.
429
+ function scrollsWithPull(target: EventTarget | null, towardOpen: boolean): boolean {
413
430
  let el = isElement(target) ? target : null
414
- const pullsRight = dir.value === "left"
431
+ const right = (dir.value === "left") === towardOpen
415
432
  const body = el?.ownerDocument.body
416
433
  while (el && el !== body) {
417
434
  if (el.closest(EDGE_IGNORE) === el) return true
418
435
  if (el.scrollWidth > el.clientWidth) {
419
436
  const overflow = nodeWindow(el).getComputedStyle(el).overflowX
420
437
  if (overflow === "auto" || overflow === "scroll") {
421
- if (pullsRight ? el.scrollLeft > 0 : el.scrollLeft + el.clientWidth < el.scrollWidth) return true
438
+ if (right ? el.scrollLeft > 0 : el.scrollLeft + el.clientWidth < el.scrollWidth) return true
422
439
  }
423
440
  }
424
441
  el = el.parentElement
@@ -426,31 +443,32 @@ function scrollsWithPull(target: EventTarget | null): boolean {
426
443
  return false
427
444
  }
428
445
 
446
+ /** Px from the drawer's edge of the screen, inward. */
429
447
  function inward(x: number): number {
430
448
  return dir.value === "right" ? edgeWindow().innerWidth - x : x
431
449
  }
450
+ /** A movement or velocity along x, positive toward opening. */
451
+ const opening = (x: number) => (dir.value === "right" ? -x : x)
432
452
 
433
453
  function onTouchStart(e: TouchEvent) {
434
454
  if (track) dropTrack()
435
455
  if (e.touches.length !== 1 || !edgeEnabled() || anyModalOpen() || edgeOwner.value) return
436
- const t = e.touches[0]
437
- if (inward(t.clientX) > props.edgeSize || scrollsWithPull(e.target)) return
438
- track = { id: t.identifier, x: t.clientX, y: t.clientY, t: e.timeStamp, lastX: t.clientX, lastT: e.timeStamp, speed: 0 }
456
+ const t = e.touches[0]!
457
+ if (inward(t.clientX) > props.edgeSize || scrollsWithPull(e.target, true)) return
458
+ track = { id: t.identifier, session: createDragSession({ x: t.clientX, y: t.clientY }, e.timeStamp, { slop: TOUCH_SLOP, axis: "x" }) }
439
459
  }
440
460
 
441
461
  function onTouchMove(e: TouchEvent) {
442
462
  if (!track) return
443
463
  const t = Array.from(e.changedTouches).find((x) => x.identifier === track!.id)
444
464
  if (!t) return
445
- const sign = dir.value === "right" ? -1 : 1
446
- const dx = (t.clientX - track.x) * sign
447
- const dy = t.clientY - track.y
448
- if (!peeking.value) {
449
- if (Math.abs(dy) > EDGE_SLOP && Math.abs(dy) > Math.abs(dx)) {
450
- track = null
451
- return
452
- }
453
- if (dx < EDGE_SLOP || Math.abs(dx) < Math.abs(dy)) return
465
+ const frame = track.session.move({ x: t.clientX, y: t.clientY }, e.timeStamp)
466
+ if (frame.phase === "rejected" || (frame.first && opening(frame.movement.x) <= 0)) {
467
+ dropTrack()
468
+ return
469
+ }
470
+ if (frame.phase !== "drag") return
471
+ if (frame.first) {
454
472
  if (edgeOwner.value && edgeOwner.value !== owner) {
455
473
  track = null
456
474
  return
@@ -459,10 +477,6 @@ function onTouchMove(e: TouchEvent) {
459
477
  peeking.value = true
460
478
  }
461
479
  if (e.cancelable) e.preventDefault()
462
- const dt = e.timeStamp - track.lastT
463
- if (dt > 0) track.speed = (((t.clientX - track.lastX) * sign) / dt) * 0.6 + track.speed * 0.4
464
- track.lastX = t.clientX
465
- track.lastT = e.timeStamp
466
480
  peek.value = clamp(inward(t.clientX) / panelPx(), 0, 1)
467
481
  }
468
482
 
@@ -480,12 +494,15 @@ function cancelPull() {
480
494
  peek.value = 0
481
495
  }
482
496
 
483
- function settle() {
484
- const flick = track !== null && track.speed > FLICK_SPEED && peek.value > 0.05
497
+ function settle(e: TouchEvent) {
498
+ const t = track && Array.from(e.changedTouches).find((x) => x.identifier === track!.id)
499
+ const end = track && t ? track.session.end({ x: t.clientX, y: t.clientY }, e.timeStamp) : null
485
500
  dropTrack()
486
501
  if (!peeking.value) return
487
502
  peeking.value = false
488
- if (peek.value >= props.edgeOpenThreshold || flick) {
503
+ const size = panelPx()
504
+ const opens = !!end && shouldDismiss({ offset: peek.value * size, velocity: opening(end.velocity.x), size, ratio: props.edgeOpenThreshold })
505
+ if (opens) {
489
506
  peek.value = 1
490
507
  handingOff.value = true
491
508
  skipOpenSlide.value = true
@@ -505,6 +522,52 @@ function settle() {
505
522
  }
506
523
  }
507
524
 
525
+ // How far the open panel has been swiped toward closing, in px (negative is a
526
+ // pull the other way, which only gives a little). Drives the panel and scrim.
527
+ const shut = ref(0)
528
+ const shutting = ref(false)
529
+ const closeSwipe = {
530
+ pointerTypes: ["touch", "pen"] as const,
531
+ axis: "x" as const,
532
+ ignore: EDGE_IGNORE,
533
+ enabled: () => !!props.open && isHorizontal.value && props.dismissible && !guarded.value && !handingOff.value,
534
+ onPress: (e: PointerEvent) => !scrollsWithPull(e.target, false),
535
+ onDragStart: () => (shutting.value = true),
536
+ onDrag: ({ movement }: GestureState) => {
537
+ const toward = -opening(movement.x)
538
+ shut.value = toward >= 0 ? toward : rubberBand(toward, panelPx(), 0.2)
539
+ },
540
+ onDragEnd: ({ velocity, cancelled }: GestureEnd) => {
541
+ shutting.value = false
542
+ const size = panelPx()
543
+ if (!cancelled && shouldDismiss({ offset: shut.value, velocity: -opening(velocity.x), size, ratio: props.closeThreshold ?? 0.25 })) {
544
+ emit("update:open", false)
545
+ return
546
+ }
547
+ shut.value = 0
548
+ },
549
+ }
550
+ // Bound as attributes: they fall through to the panel and scrim elements,
551
+ // which reka renders through a presence wrapper with no element of its own.
552
+ const panelSwipe = createGesture(closeSwipe)
553
+ const scrimSwipe = createGesture(closeSwipe)
554
+ onBeforeUnmount(() => {
555
+ panelSwipe.dispose()
556
+ scrimSwipe.dispose()
557
+ })
558
+
559
+ // The panel and scrim during a swipe: the panel's edge stays under the finger
560
+ // and the scrim lightens with it. On release vaul's own transition springs it
561
+ // back, and its close keyframes run from wherever the finger left it.
562
+ const shutStyle = computed(() => {
563
+ if (!shut.value && !shutting.value) return undefined
564
+ const px = dir.value === "right" ? shut.value : -shut.value
565
+ return { transform: `translate3d(${px}px, 0, 0)`, transition: shutting.value ? "none" : undefined }
566
+ })
567
+ const scrimStyle = computed(() => {
568
+ if (!shut.value && !shutting.value) return undefined
569
+ return { opacity: clamp(1 - shut.value / Math.max(1, panelPx()), 0, 1), transition: shutting.value ? "none" : "opacity 0.5s cubic-bezier(0.32, 0.72, 0, 1)" }
570
+ })
508
571
  const timers: number[] = []
509
572
  onBeforeUnmount(() => {
510
573
  for (const id of timers) window.clearTimeout(id)
@@ -513,7 +576,7 @@ onBeforeUnmount(() => {
513
576
 
514
577
  function onTouchEnd(e: TouchEvent) {
515
578
  if (track && !Array.from(e.changedTouches).some((x) => x.identifier === track!.id)) return
516
- settle()
579
+ settle(e)
517
580
  }
518
581
 
519
582
  if (typeof document !== "undefined") {
@@ -522,16 +585,18 @@ if (typeof document !== "undefined") {
522
585
  (on, _, onCleanup) => {
523
586
  if (!on) return
524
587
  const doc = viewDocument(view.value)
525
- const passive = { passive: true }
588
+ // Capture phase, so a control that stops its own touches still lets the
589
+ // pull see where the finger went.
590
+ const passive = { passive: true, capture: true }
526
591
  doc.addEventListener("touchstart", onTouchStart, passive)
527
- doc.addEventListener("touchmove", onTouchMove, { passive: false })
592
+ doc.addEventListener("touchmove", onTouchMove, { passive: false, capture: true })
528
593
  doc.addEventListener("touchend", onTouchEnd, passive)
529
594
  doc.addEventListener("touchcancel", cancelPull, passive)
530
595
  onCleanup(() => {
531
- doc.removeEventListener("touchstart", onTouchStart)
532
- doc.removeEventListener("touchmove", onTouchMove)
533
- doc.removeEventListener("touchend", onTouchEnd)
534
- doc.removeEventListener("touchcancel", cancelPull)
596
+ doc.removeEventListener("touchstart", onTouchStart, true)
597
+ doc.removeEventListener("touchmove", onTouchMove, true)
598
+ doc.removeEventListener("touchend", onTouchEnd, true)
599
+ doc.removeEventListener("touchcancel", cancelPull, true)
535
600
  cancelPull()
536
601
  })
537
602
  },
@@ -592,25 +657,26 @@ const guardOutside = useOutsideGuard()
592
657
  :direction="dir"
593
658
  :dismissible="dismissible && !guarded"
594
659
  :snap-points="snapPoints"
595
- :handle-only="handleOnly"
660
+ :handle-only="handleOnly || isHorizontal"
596
661
  :should-scale-background="scaleBackground"
597
662
  :close-threshold="closeThreshold"
598
663
  @update:open="(v: boolean) => emit('update:open', v)"
599
664
  >
600
665
  <DrawerPortal :to="portalTo">
601
666
  <DrawerOverlay
602
- :style="{ zIndex: layer.scrim }"
667
+ v-bind="scrimSwipe.handlers"
668
+ :style="[{ zIndex: layer.scrim }, scrimStyle]"
603
669
  @click="guarded && requestClose()"
604
- class="lp-scrim fixed inset-0 z-(--z-overlay) data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
670
+ class="lp-scrim fixed inset-0 z-(--z-overlay) touch-none data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
605
671
  :class="{ 'lp-scrim--light': frosted, 'lp-scrim--nested': layer.nested, 'lp-scrim--covered': layer.covered }"
606
672
  />
607
673
  <DrawerContent
608
674
  @interact-outside="guardOutside"
609
675
  @escape-key-down="guardOutside"
610
676
  ref="contentRef"
611
- v-bind="description ? {} : { 'aria-describedby': undefined }"
677
+ v-bind="{ ...(description ? {} : { 'aria-describedby': undefined }), ...panelSwipe.handlers }"
612
678
  :class="contentClass"
613
- :style="[sizeStyle, { zIndex: layer.panel }]"
679
+ :style="[sizeStyle, { zIndex: layer.panel }, shutStyle]"
614
680
  :data-vaul-animate="handingOff || skipOpenSlide ? 'false' : undefined"
615
681
  @keydown.escape="guardEscape"
616
682
  >
@@ -676,6 +742,7 @@ const guardOutside = useOutsideGuard()
676
742
  <div
677
743
  v-no-drag-controls="noDragControls"
678
744
  class="flex min-h-0 min-w-0 flex-1 flex-col [-webkit-user-select:text]! [user-select:text]!"
745
+ :class="isHorizontal ? '[&_[data-reka-scroll-area-viewport]]:touch-pan-y' : undefined"
679
746
  :data-vaul-no-drag="noDragContent ? '' : undefined"
680
747
  >
681
748
  <!-- min-w-0 on the body + scroll content so a wide child (e.g. a log
@@ -26,6 +26,8 @@ import {
26
26
  import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
27
27
  import { useLabels } from "../composables/useLpConfig"
28
28
  import { useZoomPan } from "../composables/useZoomPan"
29
+ import LpScrollStrip from "./LpScrollStrip.vue"
30
+ import { createDragSession, slopFor, type DragSession } from "../composables/gesture"
29
31
  import { clickLink } from "./exportData"
30
32
  import { fileNameOf, isVideoItem, type LightboxItem } from "./lightbox"
31
33
  import LpCopyButton from "./LpCopyButton.vue"
@@ -177,7 +179,7 @@ const loading = ref(false)
177
179
  const failed = ref(false)
178
180
  const viewport = ref<HTMLElement | null>(null)
179
181
  const stageImage = ref<HTMLImageElement | null>(null)
180
- const stripEl = ref<HTMLElement | null>(null)
182
+ const stripEl = ref<InstanceType<typeof LpScrollStrip> | null>(null)
181
183
 
182
184
  watch(viewport, (el) => zoom.setViewport(el))
183
185
 
@@ -277,8 +279,8 @@ function imageSettled(event: Event, ok: boolean) {
277
279
  }
278
280
 
279
281
  function scrollThumbIntoView() {
280
- const el = stripEl.value?.querySelector<HTMLElement>(`[data-index="${current.value}"]`)
281
- el?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" })
282
+ const el = stripEl.value?.scroller?.querySelector<HTMLElement>(`[data-index="${current.value}"]`)
283
+ stripEl.value?.reveal(el, { block: "center" })
282
284
  }
283
285
 
284
286
  function close() {
@@ -363,45 +365,54 @@ function rotate() {
363
365
 
364
366
  // Swipe to page through, but only while the image is at fit scale — once zoomed
365
367
  // a horizontal drag is a pan, and stealing it would make the image unreachable.
366
- let swipeStart: { x: number; y: number } | null = null
368
+ // The kit's drag session decides what a swipe is (far enough, or a flick, and
369
+ // mostly sideways), as for every other swipe in the kit.
370
+ let swipe: DragSession | null = null
367
371
  // Fingers on the stage. A second one makes the gesture a pinch, and a pinch
368
372
  // whose fingers part sideways is not a swipe: it zoomed and then paged away
369
373
  // from what it zoomed into.
370
374
  const fingers = new Set<number>()
371
375
 
376
+ function startSwipe(e: PointerEvent) {
377
+ swipe = createDragSession({ x: e.clientX, y: e.clientY }, e.timeStamp, { slop: slopFor(e.pointerType), axis: "x" })
378
+ }
379
+
372
380
  function onPointerDown(e: PointerEvent) {
373
381
  fingers.add(e.pointerId)
374
382
  if (fingers.size > 1) {
375
- swipeStart = null
383
+ swipe = null
376
384
  if (!isVideo.value) zoom.onPointerDown(e)
377
385
  return
378
386
  }
379
387
  if (isVideo.value) {
380
388
  // A drag on the player's bar is a seek or a volume change, never a page.
381
- swipeStart = (e.target as Element).closest?.("[data-lp-player-controls]") ? null : { x: e.clientX, y: e.clientY }
389
+ if ((e.target as Element).closest?.("[data-lp-player-controls]")) swipe = null
390
+ else startSwipe(e)
382
391
  return
383
392
  }
384
393
  zoom.onPointerDown(e)
385
- swipeStart = zoom.zoomed.value ? null : { x: e.clientX, y: e.clientY }
394
+ if (zoom.zoomed.value) swipe = null
395
+ else startSwipe(e)
396
+ }
397
+
398
+ function onPointerMove(e: PointerEvent) {
399
+ if (!isVideo.value) zoom.onPointerMove(e)
400
+ if (swipe?.move({ x: e.clientX, y: e.clientY }, e.timeStamp).phase === "rejected") swipe = null
386
401
  }
387
402
 
388
403
  function onPointerCancel(e: PointerEvent) {
389
404
  fingers.delete(e.pointerId)
390
- swipeStart = null
405
+ swipe = null
391
406
  if (!isVideo.value) zoom.onPointerUp(e)
392
407
  }
393
408
 
394
409
  function onPointerUp(e: PointerEvent) {
395
410
  fingers.delete(e.pointerId)
396
411
  if (!isVideo.value) zoom.onPointerUp(e)
397
- if (!swipeStart) return
398
- const dx = e.clientX - swipeStart.x
399
- const dy = e.clientY - swipeStart.y
400
- swipeStart = null
401
- // Mostly-horizontal and past a threshold, so a sloppy tap doesn't page.
402
- if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
403
- go(current.value + (dx < 0 ? 1 : -1))
404
- }
412
+ const ended = swipe?.end({ x: e.clientX, y: e.clientY }, e.timeStamp, { axis: "x" })
413
+ swipe = null
414
+ if (ended?.swipe === "left") go(current.value + 1)
415
+ else if (ended?.swipe === "right") go(current.value - 1)
405
416
  }
406
417
 
407
418
  function onDoubleClick(e: MouseEvent) {
@@ -620,7 +631,7 @@ const guardOutside = useOutsideGuard()
620
631
  :class="isPanning ? 'cursor-grabbing' : isZoomed ? 'cursor-grab' : ''"
621
632
  @wheel="onStageWheel"
622
633
  @pointerdown="onPointerDown"
623
- @pointermove="zoom.onPointerMove"
634
+ @pointermove="onPointerMove"
624
635
  @pointerup="onPointerUp"
625
636
  @pointercancel="onPointerCancel"
626
637
  @dblclick="onDoubleClick"
@@ -722,11 +733,12 @@ const guardOutside = useOutsideGuard()
722
733
  </div>
723
734
  </slot>
724
735
 
725
- <div
736
+ <LpScrollStrip
726
737
  v-if="showStrip && total > 1"
727
738
  ref="stripEl"
728
- class="lp-scrollbar-none flex justify-start gap-2 overflow-x-auto sm:justify-center"
739
+ :buttons="false"
729
740
  >
741
+ <div class="mx-auto flex w-max gap-2 p-1">
730
742
  <button
731
743
  v-for="(entry, i) in items"
732
744
  :key="entry.src + i"
@@ -754,7 +766,8 @@ const guardOutside = useOutsideGuard()
754
766
  draggable="false"
755
767
  />
756
768
  </button>
757
- </div>
769
+ </div>
770
+ </LpScrollStrip>
758
771
  </div>
759
772
  </DialogContent>
760
773
  </DialogPortal>
@@ -22,7 +22,9 @@ export default { inheritAttrs: false }
22
22
  */
23
23
  import { clamp } from "../composables/clamp"
24
24
  import { computed, onBeforeUnmount, ref, watch } from "vue"
25
- import { prefersReducedMotion } from "../composables/easing"
25
+ import { createDragSession, slopFor, type DragSession } from "../composables/gesture"
26
+ import { glide, type Glide } from "../composables/useGesture"
27
+ import { nodeWindow } from "../composables/realm"
26
28
  import { type LatLon, OSM_TILES, type TileSource, project, tileUrl, unproject } from "./address"
27
29
  import LpIcon from "./LpIcon.vue"
28
30
  import LpLoader from "./LpLoader.vue"
@@ -306,88 +308,44 @@ function toLatLon(at: { x: number; y: number }): LatLon {
306
308
  }
307
309
 
308
310
  const panning = ref(false)
309
- // A drag is a pan, not a pick — track whether the pointer actually travelled so
310
- // releasing after a drag doesn't also drop the pin.
311
- let moved = false
312
- let last = { x: 0, y: 0 }
313
- // Pointer velocity in px/ms, smoothed so one jittery frame at the end of a
314
- // flick doesn't decide where the map coasts to.
315
- let velocity = { x: 0, y: 0 }
316
- let lastMove = 0
317
- let glideFrame: number | undefined
311
+ // A drag is a pan, not a pick: a press that never leaves the slop drops the
312
+ // pin on release, one that does pans. The kit's drag session makes that call
313
+ // and measures the speed at release, and glide coasts on from it.
314
+ let session: DragSession | null = null
315
+ let coast: Glide | null = null
318
316
 
319
317
  function stopGlide() {
320
- if (glideFrame !== undefined) cancelAnimationFrame(glideFrame)
321
- glideFrame = undefined
322
- velocity = { x: 0, y: 0 }
318
+ coast?.stop()
319
+ coast = null
323
320
  }
324
321
 
325
322
  function onPointerDown(e: PointerEvent) {
326
323
  if (props.disabled || e.button !== 0 || zooming.value) return
327
324
  stopGlide()
328
325
  panning.value = true
329
- moved = false
330
- last = { x: e.clientX, y: e.clientY }
331
- lastMove = performance.now()
326
+ session = createDragSession({ x: e.clientX, y: e.clientY }, e.timeStamp, { slop: slopFor(e.pointerType) })
332
327
  ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
333
328
  }
334
329
 
335
330
  function onPointerMove(e: PointerEvent) {
336
- if (!panning.value) return
337
- const dx = e.clientX - last.x
338
- const dy = e.clientY - last.y
339
- if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved = true
340
-
341
- const now = performance.now()
342
- const dt = now - lastMove
343
- if (dt > 0) {
344
- velocity = {
345
- x: velocity.x * 0.7 + (dx / dt) * 0.3,
346
- y: velocity.y * 0.7 + (dy / dt) * 0.3,
347
- }
348
- }
349
- lastMove = now
350
- last = { x: e.clientX, y: e.clientY }
351
- panBy(-dx, -dy)
331
+ if (!panning.value || !session) return
332
+ const frame = session.move({ x: e.clientX, y: e.clientY }, e.timeStamp)
333
+ if (frame.phase !== "drag") return
334
+ panBy(-(frame.first ? frame.movement.x : frame.delta.x), -(frame.first ? frame.movement.y : frame.delta.y))
352
335
  }
353
336
 
354
337
  function onPointerUp(e: PointerEvent) {
355
- if (!panning.value) return
338
+ if (!panning.value || !session) return
356
339
  panning.value = false
357
340
  ;(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId)
358
- if (!moved && !props.disabled) {
359
- emit("update:modelValue", toLatLon(viewportPoint(e)))
360
- return
361
- }
362
- // Nothing to coast from if the pointer was resting when it lifted.
363
- if (performance.now() - lastMove > 90) velocity = { x: 0, y: 0 }
364
- glide()
365
- }
366
-
367
- /*
368
- * Momentum: keep panning after release and bleed the speed off exponentially,
369
- * so letting go of a flick drifts to a stop instead of stopping dead. Time-based
370
- * rather than per-frame, which keeps the feel identical at 60 and 144 Hz.
371
- */
372
- function glide() {
373
- if (prefersReducedMotion() || Math.hypot(velocity.x, velocity.y) < 0.05) {
374
- velocity = { x: 0, y: 0 }
341
+ const end = session.end({ x: e.clientX, y: e.clientY }, e.timeStamp)
342
+ session = null
343
+ if (end.phase !== "drag") {
344
+ if (!props.disabled && e.type !== "pointercancel") emit("update:modelValue", toLatLon(viewportPoint(e)))
375
345
  return
376
346
  }
377
- let previous = performance.now()
378
- const step = (now: number) => {
379
- const dt = Math.min(now - previous, 32)
380
- previous = now
381
- const friction = Math.exp(-dt / 240)
382
- velocity = { x: velocity.x * friction, y: velocity.y * friction }
383
- panBy(-velocity.x * dt, -velocity.y * dt)
384
- if (Math.hypot(velocity.x, velocity.y) > 0.015) {
385
- glideFrame = requestAnimationFrame(step)
386
- } else {
387
- stopGlide()
388
- }
389
- }
390
- glideFrame = requestAnimationFrame(step)
347
+ if (e.type === "pointercancel") return
348
+ coast = glide(end.velocity, (dx, dy) => panBy(-dx, -dy), { win: nodeWindow(e.currentTarget as Node) })
391
349
  }
392
350
 
393
351
  function panBy(dx: number, dy: number) {