@leavepulse/ui 0.93.2 → 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 (78) 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-B7DOV_aL.js → LpMediaGallery-CTejKF2-.js} +36 -15
  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.d.ts +2 -0
  26. package/dist/components/LpMediaGallery.vue.js +1 -1
  27. package/dist/components/LpModalNav.vue.js +1 -1
  28. package/dist/components/LpRangeCalendar.vue.js +1 -1
  29. package/dist/components/LpRevealOnScroll.vue.d.ts +1 -1
  30. package/dist/components/LpScrollStrip.vue.d.ts +48 -0
  31. package/dist/components/LpScrollStrip.vue.js +4 -0
  32. package/dist/components/LpSidebar.vue.js +1 -1
  33. package/dist/components/LpTabs.vue.d.ts +4 -4
  34. package/dist/components/LpTabs.vue.js +104 -90
  35. package/dist/components/LpVideoTrim.vue.js +41 -54
  36. package/dist/composables/gesture.d.ts +141 -0
  37. package/dist/composables/gesture.js +176 -0
  38. package/dist/composables/scroll.d.ts +5 -1
  39. package/dist/composables/scroll.js +16 -9
  40. package/dist/composables/useAppUpdate.d.ts +26 -0
  41. package/dist/composables/useAppUpdate.js +91 -0
  42. package/dist/composables/useGesture.d.ts +107 -0
  43. package/dist/composables/useGesture.js +281 -0
  44. package/dist/composables/usePaintSelect.js +3 -4
  45. package/dist/composables/useScrollStrip.d.ts +27 -0
  46. package/dist/composables/useScrollStrip.js +194 -0
  47. package/dist/composables/useTouchHold.d.ts +3 -1
  48. package/dist/composables/useTouchHold.js +9 -51
  49. package/dist/composables/useZoomPan.js +4 -6
  50. package/dist/index.d.ts +9 -0
  51. package/dist/index.js +180 -152
  52. package/package.json +1 -1
  53. package/src/component-names.ts +1 -0
  54. package/src/components/AppShellContextNav.vue +3 -2
  55. package/src/components/LpAppShell.vue +10 -1
  56. package/src/components/LpChart.vue +2 -2
  57. package/src/components/LpDrawer.vue +121 -54
  58. package/src/components/LpLightbox.vue +33 -20
  59. package/src/components/LpMapPicker.vue +22 -64
  60. package/src/components/LpMediaGallery.vue +44 -11
  61. package/src/components/LpRangeCalendar.vue +4 -5
  62. package/src/components/LpScrollStrip.vue +96 -0
  63. package/src/components/LpSidebar.vue +2 -2
  64. package/src/components/LpTabs.vue +36 -17
  65. package/src/components/LpVideoTrim.vue +35 -46
  66. package/src/composables/gesture.ts +314 -0
  67. package/src/composables/scroll.ts +20 -9
  68. package/src/composables/useAppUpdate.ts +156 -0
  69. package/src/composables/useGesture.ts +451 -0
  70. package/src/composables/usePaintSelect.ts +4 -5
  71. package/src/composables/useScrollStrip.ts +249 -0
  72. package/src/composables/useTouchHold.ts +15 -73
  73. package/src/composables/useZoomPan.ts +6 -9
  74. package/src/index.ts +27 -0
  75. package/src/tokens/effects.css +40 -0
  76. package/dist/assets/LpTabs-Bh7a-Bdl.css +0 -18
  77. package/dist/assets/LpVideoTrim-CMoUw3dJ.css +0 -8
  78. package/dist/chunks/AppShellContextNav-C6Ho2mof.js +0 -49
@@ -115,7 +115,7 @@ function fill(template: string, n: number, total = 0): string {
115
115
  */
116
116
  function menuTitle(item: LightboxItem, i: number): string {
117
117
  if (isHidden(item)) return text.value.spoiler
118
- return item.title || fill(text.value.image, i + 1, props.items.length)
118
+ return item.title || fill(text.value.image, i + 1, shown.value.length)
119
119
  }
120
120
 
121
121
  const emit = defineEmits<{
@@ -123,6 +123,8 @@ const emit = defineEmits<{
123
123
  (e: "select", item: LightboxItem, index: number): void
124
124
  /** A spoiler was uncovered. */
125
125
  (e: "reveal", item: LightboxItem): void
126
+ /** An image failed to load from both its thumbnail and its source and left the grid. */
127
+ (e: "broken", item: LightboxItem): void
126
128
  (e: "update:open", value: boolean): void
127
129
  }>()
128
130
 
@@ -142,12 +144,39 @@ watch(open, (value, was) => {
142
144
  if (!value && was) emit("update:open", false)
143
145
  })
144
146
 
147
+ /*
148
+ * Images that failed to load. A thumbnail that fails falls back to the full
149
+ * source once; when that fails too the image leaves the grid and the lightbox
150
+ * and `broken` says so. A cell of grey with a broken-picture glyph tells the
151
+ * viewer nothing they can act on, and paging the lightbox onto it is worse.
152
+ * Keyed by `src`, like `revealed`, so a reordered set keeps the right ones out.
153
+ */
154
+ const fellBack = ref(new Set<string>())
155
+ const broken = ref(new Set<string>())
156
+ const shown = computed(() =>
157
+ broken.value.size ? props.items.filter((item) => !broken.value.has(item.src)) : props.items,
158
+ )
159
+
160
+ function thumbOf(item: LightboxItem): string {
161
+ return item.thumb && !fellBack.value.has(item.src) ? item.thumb : item.src
162
+ }
163
+
164
+ function onImageError(item: LightboxItem) {
165
+ if (item.thumb && item.thumb !== item.src && !fellBack.value.has(item.src)) {
166
+ fellBack.value = new Set(fellBack.value).add(item.src)
167
+ return
168
+ }
169
+ if (broken.value.has(item.src)) return
170
+ broken.value = new Set(broken.value).add(item.src)
171
+ emit("broken", item)
172
+ }
173
+
145
174
  // `max` counts what is SHOWN, so the badge reads "+N more" rather than "N
146
175
  // total". A set of 7 capped at 4 shows 4 cells, the last one badged +3.
147
176
  const visible = computed(() =>
148
- props.max && props.items.length > props.max ? props.items.slice(0, props.max) : props.items,
177
+ props.max && shown.value.length > props.max ? shown.value.slice(0, props.max) : shown.value,
149
178
  )
150
- const hiddenCount = computed(() => Math.max(props.items.length - visible.value.length, 0))
179
+ const hiddenCount = computed(() => Math.max(shown.value.length - visible.value.length, 0))
151
180
 
152
181
  /*
153
182
  * Column classes are written out rather than interpolated. Tailwind scans
@@ -231,11 +260,11 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
231
260
  )
232
261
 
233
262
  function activate(i: number) {
234
- const item = props.items[i]
263
+ const item = shown.value[i]
235
264
  if (!item) return
236
265
  // `select` reports the position in the caller's own array, which is the one
237
266
  // they can do anything with — the lightbox's filtered index is internal.
238
- emit("select", item, i)
267
+ emit("select", item, props.items.indexOf(item))
239
268
  if (!props.openable) return
240
269
  // Covered images are not in the lightbox's list, so the index has to be
241
270
  // translated into it.
@@ -285,17 +314,20 @@ function reveal(item: LightboxItem) {
285
314
  * pass through untouched, so opening the one you just uncovered still lands on
286
315
  * it.
287
316
  */
288
- const openItems = computed(() => props.items.filter((item) => !isHidden(item)))
317
+ const openItems = computed(() => shown.value.filter((item) => !isHidden(item)))
289
318
 
290
319
  /** Index within `openItems` of the grid item at `i`, or -1 when it is covered. */
291
320
  function openIndexOf(i: number): number {
292
- const item = props.items[i]
321
+ const item = shown.value[i]
293
322
  return item ? openItems.value.indexOf(item) : -1
294
323
  }
295
324
 
296
325
  defineExpose({
297
326
  /** Open the lightbox at an index — for a "view all" button outside the grid. */
298
- openAt: (i: number) => activate(i),
327
+ openAt: (i: number) => {
328
+ const item = props.items[i]
329
+ if (item) activate(shown.value.indexOf(item))
330
+ },
299
331
  /** Uncover every spoiler in the set. */
300
332
  revealAll: () => {
301
333
  const next = new Set(revealed.value)
@@ -306,7 +338,7 @@ defineExpose({
306
338
  </script>
307
339
 
308
340
  <template>
309
- <div v-if="!items.length">
341
+ <div v-if="!shown.length">
310
342
  <slot name="empty" />
311
343
  </div>
312
344
 
@@ -316,7 +348,7 @@ defineExpose({
316
348
  :key="item.src + i"
317
349
  :items="itemMenu?.(item, i) ?? []"
318
350
  :title="menuTitle(item, i)"
319
- :title-image="isHidden(item) ? undefined : isVideoItem(item) ? item.poster ?? item.thumb : item.thumb || item.src"
351
+ :title-image="isHidden(item) ? undefined : isVideoItem(item) ? item.poster ?? item.thumb : thumbOf(item)"
320
352
  :subtitle="isHidden(item) ? undefined : item.description"
321
353
  >
322
354
  <button
@@ -370,10 +402,11 @@ defineExpose({
370
402
  />
371
403
  <img
372
404
  v-else
373
- :src="item.thumb || item.src"
405
+ :src="thumbOf(item)"
374
406
  :alt="isHidden(item) ? '' : item.title || ''"
375
407
  loading="lazy"
376
408
  decoding="async"
409
+ @error="onImageError(item)"
377
410
  class="size-full object-cover transition-transform duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] group-hover:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
378
411
  :class="[
379
412
  isHidden(item)
@@ -24,6 +24,7 @@ export type RangeChange = { via: "pick" | "drag" | "keyboard" }
24
24
  * "keyboard"), so a popover around the calendar can stay open while the user
25
25
  * fine-tunes instead of closing on the first nudge.
26
26
  */
27
+ import { HOLD_TO_DRAG_MS, TOUCH_SLOP } from "../composables/gesture"
27
28
  import { type DateValue, getLocalTimeZone, today } from "@internationalized/date"
28
29
  import {
29
30
  type DateRange as RekaRange,
@@ -119,12 +120,10 @@ const root = ref<HTMLElement | null>(null)
119
120
  * Press-and-drag, the way a phone selects text. A press on either end of the
120
121
  * range drags that end; a press anywhere else starts a new range that follows
121
122
  * the pointer. A mouse or pen drags at once. A finger has to hold still for
122
- * HOLD_MS first, so an ordinary swipe still scrolls the page; after that the
123
+ * HOLD_TO_DRAG_MS first, so an ordinary swipe still scrolls the page; after that the
123
124
  * page stops scrolling and the finger selects. A press that never moves stays
124
125
  * an ordinary click for reka.
125
126
  */
126
- const HOLD_MS = 320
127
- const SLOP_PX = 8
128
127
 
129
128
  /** The day the range pivots around while the pointer moves. */
130
129
  let anchor: string | null = null
@@ -183,14 +182,14 @@ function onPointerDown(e: PointerEvent) {
183
182
  dragging.value = true
184
183
  draft.value = toReka({ start: day, end: day })
185
184
  navigator.vibrate?.(8)
186
- }, HOLD_MS)
185
+ }, HOLD_TO_DRAG_MS)
187
186
  }
188
187
 
189
188
  function onPointerMove(e: PointerEvent) {
190
189
  if (anchor === null || e.pointerId !== pointerId) return
191
190
  if (!armed) {
192
191
  // Moved before the hold landed: that is a scroll, not a selection.
193
- if (Math.hypot(e.clientX - startX, e.clientY - startY) > SLOP_PX) reset()
192
+ if (Math.hypot(e.clientX - startX, e.clientY - startY) > TOUCH_SLOP) reset()
194
193
  return
195
194
  }
196
195
  const day = cellAt(e.clientX, e.clientY)?.dataset.value
@@ -0,0 +1,96 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * A row that scrolls sideways and can be reached with any input: tabs, chips,
4
+ * branch pills, a filmstrip. The scrollbar is hidden, so useScrollStrip gives
5
+ * the mouse its way in (the wheel and drag-to-scroll), the edges with items
6
+ * beyond them fade, and on a device with a fine pointer a hover shows a
7
+ * previous and a next button on whichever side still has something to show.
8
+ * Touch keeps its native scrolling and any scroll snap the row sets.
9
+ *
10
+ * The default slot is the row's content; `contentClass` styles the scrolling
11
+ * row itself (gap, padding, snap), the class on the component its frame. The
12
+ * buttons are a pointer's shortcut and stay out of the tab order: the
13
+ * keyboard reaches the items, and the focused one is scrolled into view.
14
+ * Class and style go on the frame; any other attribute (an aria-label, a
15
+ * role, listeners) lands on the row, which is the element the items sit in.
16
+ */
17
+ import { computed, ref, useAttrs } from "vue"
18
+ import { useMediaQuery } from "../composables/useMediaQuery"
19
+ import { useScrollStrip } from "../composables/useScrollStrip"
20
+ import LpIcon from "./LpIcon.vue"
21
+
22
+ defineOptions({ inheritAttrs: false })
23
+ const attrs = useAttrs()
24
+ const rowAttrs = computed(() => {
25
+ const { class: _class, style: _style, ...rest } = attrs
26
+ return rest
27
+ })
28
+
29
+ const props = withDefaults(
30
+ defineProps<{
31
+ /** Classes for the scrolling row (layout, gap, padding, snap). */
32
+ contentClass?: unknown
33
+ /** Fade the edges that have items beyond them. */
34
+ fade?: boolean
35
+ /** Previous/next buttons: `auto` shows them on hover where the pointer is fine (a mouse), true on any device, false never. */
36
+ buttons?: boolean | "auto"
37
+ /** Map a vertical wheel to sideways scrolling while the row can move. */
38
+ wheel?: boolean
39
+ /** Drag to scroll with a mouse or pen. */
40
+ drag?: boolean
41
+ /** The row element's tag. */
42
+ as?: string
43
+ /** Size to the content up to the parent's width (inline-flex), rather than fill the line. */
44
+ inline?: boolean
45
+ }>(),
46
+ { fade: true, buttons: "auto", wheel: true, drag: true, as: "div", inline: false },
47
+ )
48
+
49
+ const scroller = ref<HTMLElement | null>(null)
50
+ const strip = useScrollStrip(scroller, { wheel: () => props.wheel, drag: () => props.drag })
51
+ const fine = useMediaQuery("(hover: hover) and (pointer: fine)")
52
+ const withButtons = computed(() => props.buttons === true || (props.buttons === "auto" && fine.value))
53
+
54
+ defineExpose({ scroller, page: strip.page, scrollBy: strip.scrollBy, reveal: strip.reveal, measure: strip.measure, overflowing: strip.overflowing, atStart: strip.atStart, atEnd: strip.atEnd })
55
+
56
+ const BUTTON =
57
+ "absolute top-1/2 z-10 grid size-7 -translate-y-1/2 place-items-center rounded-full border border-line bg-surface-raised text-muted shadow-sm outline-none transition-[opacity,color] duration-(--duration-fast) ease-(--ease-emphasized) hover:text-ink motion-reduce:transition-none"
58
+ </script>
59
+
60
+ <template>
61
+ <div class="group/strip relative min-w-0" :class="[inline ? 'inline-flex max-w-full' : 'flex', attrs.class]" :style="attrs.style as never" data-lp-strip>
62
+ <component
63
+ :is="as"
64
+ v-bind="rowAttrs"
65
+ ref="scroller"
66
+ class="lp-scrollbar-none flex min-w-0 flex-1 overflow-x-auto"
67
+ :class="[contentClass, fade ? 'lp-strip-fade' : '', strip.dragging.value ? 'cursor-grabbing select-none' : '']"
68
+ :data-lp-more-start="strip.overflowing.value && !strip.atStart.value ? '' : undefined"
69
+ :data-lp-more-end="strip.overflowing.value && !strip.atEnd.value ? '' : undefined"
70
+ data-lp-strip-row
71
+ >
72
+ <slot />
73
+ </component>
74
+ <template v-if="withButtons">
75
+ <button
76
+ v-for="side in ([-1, 1] as const)"
77
+ :key="side"
78
+ type="button"
79
+ tabindex="-1"
80
+ aria-hidden="true"
81
+ :class="[
82
+ BUTTON,
83
+ side < 0 ? 'left-0.5' : 'right-0.5',
84
+ strip.overflowing.value && !(side < 0 ? strip.atStart.value : strip.atEnd.value)
85
+ ? (buttons === true ? 'opacity-100' : 'opacity-0 group-hover/strip:opacity-100')
86
+ : 'pointer-events-none opacity-0',
87
+ ]"
88
+ :data-lp-strip-button="side < 0 ? 'prev' : 'next'"
89
+ @pointerdown.prevent
90
+ @click="strip.page(side)"
91
+ >
92
+ <LpIcon :name="side < 0 ? 'lucide:chevron-left' : 'lucide:chevron-right'" :size="16" />
93
+ </button>
94
+ </template>
95
+ </div>
96
+ </template>
@@ -8,7 +8,7 @@
8
8
  * v-model. Each item can route via href (rendered as <a>) or just emit select.
9
9
  *
10
10
  * Phones: set `responsive` and the rail hides below the `mobileBreakpoint` and
11
- * is replaced by a swipeable LpDrawer (vaul-vue gives drag-to-close for free).
11
+ * is replaced by a swipeable LpDrawer (pulled in from the edge, swiped shut).
12
12
  * The host owns the burger button and binds v-model:open; selecting an item
13
13
  * auto-closes the drawer. With `responsive` off the component renders exactly
14
14
  * the static <nav> it always has — existing call sites are unaffected.
@@ -659,7 +659,7 @@ const searchField = computed(() => ({
659
659
  </div>
660
660
  </nav>
661
661
 
662
- <!-- Mobile: same nav inside a swipeable drawer (drag-to-close via vaul). -->
662
+ <!-- Mobile: same nav inside a swipeable drawer (LpDrawer's edge pull and swipe to close). -->
663
663
  <LpDrawer
664
664
  v-if="responsive"
665
665
  :open="open"
@@ -3,13 +3,13 @@ import { isHTMLElement } from "../composables/useView"
3
3
  import { usePaletteCommands, usePaletteScope } from "../composables/usePaletteCommands"
4
4
  import { Motion } from "motion-v"
5
5
  import { TabsContent, TabsList, TabsRoot, TabsTrigger } from "reka-ui"
6
- import { computed, nextTick, ref, useId, watch } from "vue"
6
+ import { computed, nextTick, ref, useId, watch, type FunctionalComponent } from "vue"
7
7
  import { usePillTransition } from "../composables/usePillTransition"
8
8
  import { useStepHistory } from "../composables/useOverlayHistory"
9
- import { prefersReducedMotion } from "../composables/easing"
10
9
  import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
11
10
  import LpSkeleton from "./LpSkeleton.vue"
12
11
  import LpTabFace from "./LpTabFace.vue"
12
+ import LpScrollStrip from "./LpScrollStrip.vue"
13
13
  import { BOTTOM_BAR, BOTTOM_ITEM, BOTTOM_MARKER, pickTab } from "./tabs"
14
14
 
15
15
  export interface TabItem {
@@ -104,22 +104,31 @@ watch(
104
104
  )
105
105
 
106
106
  // A row wider than its container scrolls sideways (a phone, a narrow card)
107
- // instead of wrapping each label onto two lines; the active tab is brought
108
- // into view whenever it changes, including from outside.
107
+ // instead of wrapping each label onto two lines. LpScrollStrip lets a mouse
108
+ // reach the tabs off to the side (wheel, drag, edge buttons), and the active
109
+ // tab is brought into view whenever it changes, including from outside.
110
+ const strip = ref<InstanceType<typeof LpScrollStrip> | null>(null)
111
+ const Passthrough: FunctionalComponent = (_, { slots }) => slots.default?.()
109
112
  const list = ref<{ $el: HTMLElement } | null>(null)
110
113
  watch(current, () => {
111
- void nextTick(() => {
112
- const row = list.value?.$el
113
- const active = row?.querySelector<HTMLElement>('[data-state="active"]')
114
- if (!row || !active || row.scrollWidth <= row.clientWidth) return
115
- const start = active.offsetLeft - row.offsetLeft
116
- const end = start + active.offsetWidth
117
- if (start >= row.scrollLeft && end <= row.scrollLeft + row.clientWidth) return
118
- const left = start < row.scrollLeft ? start : end - row.clientWidth
119
- row.scrollTo({ left, behavior: prefersReducedMotion() ? "auto" : "smooth" })
120
- })
114
+ void nextTick(() => strip.value?.reveal(list.value?.$el?.querySelector('[data-state="active"]')))
121
115
  })
122
116
 
117
+ /*
118
+ * reka selects a tab on mousedown, so a press meant to drag the row would
119
+ * pick whichever tab it started on. Mouse picks wait for the click instead,
120
+ * which a drag swallows; the keyboard still selects as focus moves.
121
+ */
122
+ function holdMousePick(event: MouseEvent) {
123
+ if (event.button !== 0 || event.ctrlKey || bottom.value) return
124
+ if (!isHTMLElement(event.target) || !event.target.closest('[role="tab"]')) return
125
+ event.stopPropagation()
126
+ event.preventDefault()
127
+ }
128
+ function clickPick(item: TabItem) {
129
+ if (!item.disabled && !bottom.value) select(item.value)
130
+ }
131
+
123
132
  function show(value: string) {
124
133
  if (props.modelValue === undefined) current.value = value
125
134
  emit("update:modelValue", value)
@@ -195,18 +204,26 @@ usePaletteCommands(() => props.items.map(item => ({
195
204
  :model-value="current"
196
205
  @update:model-value="(v) => select(v as string)"
197
206
  >
207
+ <component
208
+ :is="bottom ? Passthrough : LpScrollStrip"
209
+ ref="strip"
210
+ v-bind="bottom ? {} : {
211
+ inline: !block,
212
+ class: variant === 'contained' ? 'rounded-control border border-line bg-surface-soft' : undefined,
213
+ contentClass: variant === 'contained' ? 'p-1' : undefined,
214
+ }"
215
+ >
198
216
  <TabsList
199
217
  ref="list"
200
218
  :aria-label="label"
201
219
  :class="bottom
202
220
  ? BOTTOM_BAR
203
221
  : [
204
- 'max-w-full gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
205
- block ? 'flex w-full' : 'inline-flex',
206
- variant === 'contained' ? 'rounded-control border border-line bg-surface-soft p-1' : '',
222
+ 'flex w-max min-w-full gap-1',
207
223
  variant === 'underline' ? 'border-b border-line' : '',
208
224
  ]"
209
225
  @pointerleave="hovered = null"
226
+ @mousedown.capture="holdMousePick"
210
227
  >
211
228
  <!-- Passthrough when the tab has no menu, so it keeps the native one. -->
212
229
  <LpContextMenu
@@ -233,6 +250,7 @@ usePaletteCommands(() => props.items.map(item => ({
233
250
  ]"
234
251
  @pointerenter="hoverFollow && !item.disabled && (hovered = item.value)"
235
252
  @pointerup="releaseFocusAfterPointer"
253
+ @click="clickPick(item)"
236
254
  >
237
255
  <!-- Active marker: a bar under the tab in "underline", over it in
238
256
  "bottom", a filled pill otherwise. All slide between tabs via the
@@ -268,6 +286,7 @@ usePaletteCommands(() => props.items.map(item => ({
268
286
  </span>
269
287
  </template>
270
288
  </TabsList>
289
+ </component>
271
290
 
272
291
  <!-- Animated panels: only rendered if a per-item #panel slot is provided.
273
292
  The Motion wrapper layout-animates its height so switching to a taller
@@ -29,6 +29,7 @@ export default { inheritAttrs: false }
29
29
  */
30
30
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
31
31
  import { startPointerDrag } from "../composables/pointerDrag"
32
+ import { createGesture } from "../composables/useGesture"
32
33
  import { isHTMLElement, nodeWindow } from "../composables/realm"
33
34
  import { useLabels } from "../composables/useLpConfig"
34
35
  import { useMergedAttrs } from "../composables/useMergedClass"
@@ -367,8 +368,6 @@ const markDigits = computed(() => tickStep(span.value, trackWidth.value) < 1)
367
368
 
368
369
  /* ---------- timeline pointers ---------- */
369
370
 
370
- const pointers = new Map<number, number>()
371
- let pinch: { distance: number; anchor: number } | null = null
372
371
  const pinching = ref(false)
373
372
  let edgeFrame = 0
374
373
  let lastX = 0
@@ -420,48 +419,41 @@ function edgeScroll() {
420
419
  })
421
420
  }
422
421
 
423
- function onTrackDown(event: PointerEvent) {
424
- if (off.value || (event.pointerType === "mouse" && event.button !== 0)) return
425
- ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
426
- pointers.set(event.pointerId, event.clientX)
427
- if (pointers.size === 2) {
422
+ /* The track on the kit's gesture engine: a press scrubs at once, a drag keeps scrubbing, two fingers zoom about where they started. */
423
+ let pinchDistance = 0
424
+ let pinchAnchor = 0
425
+ const track = createGesture({
426
+ threshold: 0,
427
+ onPress(event) {
428
+ if (off.value || (event.pointerType === "mouse" && event.button !== 0)) return false
429
+ event.preventDefault()
430
+ trackEl.value?.querySelector<HTMLElement>("[data-lp-playhead]")?.focus({ preventScroll: true })
431
+ pause()
432
+ scrubbing.value = true
433
+ lastX = event.clientX
434
+ seek(timeAt(event.clientX))
435
+ },
436
+ onDrag({ point }) {
437
+ if (!scrubbing.value) return
438
+ lastX = point.x
439
+ seek(timeAt(point.x))
440
+ edgeScroll()
441
+ },
442
+ onDragEnd: () => (scrubbing.value = false),
443
+ onTap: () => (scrubbing.value = false),
444
+ onPinchStart(pinch) {
428
445
  scrubbing.value = false
429
- const [a, b] = [...pointers.values()] as [number, number]
430
- pinch = { distance: Math.max(8, Math.abs(a - b)), anchor: timeAt((a + b) / 2) }
431
446
  pinching.value = true
432
- return
433
- }
434
- if (pointers.size > 2) return
435
- event.preventDefault()
436
- trackEl.value?.querySelector<HTMLElement>("[data-lp-playhead]")?.focus({ preventScroll: true })
437
- pause()
438
- scrubbing.value = true
439
- lastX = event.clientX
440
- seek(timeAt(event.clientX))
441
- }
442
- function onTrackMove(event: PointerEvent) {
443
- if (!pointers.has(event.pointerId)) return
444
- pointers.set(event.pointerId, event.clientX)
445
- if (pinch && pointers.size === 2) {
446
- const [a, b] = [...pointers.values()] as [number, number]
447
- const distance = Math.max(8, Math.abs(a - b))
448
- zoomBy(distance / pinch.distance, pinch.anchor)
449
- pinch.distance = distance
450
- return
451
- }
452
- if (!scrubbing.value) return
453
- lastX = event.clientX
454
- seek(timeAt(event.clientX))
455
- edgeScroll()
456
- }
457
- function onTrackUp(event: PointerEvent) {
458
- pointers.delete(event.pointerId)
459
- if (pointers.size < 2) {
460
- pinch = null
461
- pinching.value = false
462
- }
463
- if (!pointers.size) scrubbing.value = false
464
- }
447
+ pinchDistance = pinch.distance
448
+ pinchAnchor = timeAt(pinch.origin.x)
449
+ },
450
+ onPinch(pinch) {
451
+ zoomBy(pinch.distance / Math.max(8, pinchDistance), pinchAnchor)
452
+ pinchDistance = pinch.distance
453
+ },
454
+ onPinchEnd: () => (pinching.value = false),
455
+ })
456
+ onBeforeUnmount(track.dispose)
465
457
 
466
458
  /* The overview: press or drag to centre the view there. */
467
459
  function onOverviewDown(event: PointerEvent) {
@@ -948,10 +940,7 @@ const widestLeft = computed(() => leftOf(widestClipTime(limits.value.maxClip), w
948
940
  :class="off ? 'opacity-60' : 'cursor-text'"
949
941
  :aria-label="text.timeline"
950
942
  role="group"
951
- @pointerdown="onTrackDown"
952
- @pointermove="onTrackMove"
953
- @pointerup="onTrackUp"
954
- @pointercancel="onTrackUp"
943
+ v-bind="track.handlers"
955
944
  >
956
945
  <div class="absolute inset-0 overflow-hidden rounded-control">
957
946
  <TransitionGroup v-bind="FADE">