UNPKG

reka-ui

Version:

Vue port for Radix UI Primitives.

627 lines (558 loc) 22.3 kB
import type { MaybeRef, Ref } from 'vue' import type { SwipeDirection } from '../utils' import { useEventListener } from '@vueuse/core' import { computed, onUnmounted, ref, toValue, watch } from 'vue' import { getDisplacement, getElementTransform } from '../utils' export interface SwipeProgressDetails { deltaX: number deltaY: number direction: SwipeDirection | undefined } export interface UseSwipeDismissOptions { enabled: MaybeRef<boolean> elementRef: Ref<HTMLElement | null | undefined> directions: MaybeRef<SwipeDirection[]> movementCssVars: { x: string, y: string } swipeThreshold?: number | ((opts: { element: HTMLElement, direction: SwipeDirection }) => number) ignoreScrollableAncestors?: boolean canStart?: () => boolean onDismiss?: () => void onProgress?: (progress: number, details?: SwipeProgressDetails) => void onCancel?: () => void onSwipeStart?: () => void /** * Fired on release with the measured velocity vector. Return `true` to signal * that the callback fully owns the open/close + transform outcome (e.g. the * snap-point release math), so `finishSwipe` skips its own dismiss-vs-cancel * decision (no `data-swipe-dismissed`, no `onDismiss`/`onCancel`). */ onRelease?: (velocity: { x: number, y: number }) => boolean | void onSwipingChange?: (swiping: boolean) => void } const DEFAULT_SWIPE_THRESHOLD = 40 const REVERSE_CANCEL_THRESHOLD = 10 const MIN_DRAG_THRESHOLD = 1 const MIN_RELEASE_VELOCITY_DURATION_MS = 16 const MAX_RELEASE_VELOCITY_AGE_MS = 80 const DEFAULT_IGNORE_SELECTOR = 'button,a,input,select,textarea,label,[role="button"]' function findScrollableAncestor( el: Element | null, axis: 'vertical' | 'horizontal', ): HTMLElement | null { if (!el || el === document.body) return null const style = window.getComputedStyle(el as HTMLElement) const overflow = axis === 'vertical' ? style.overflowY : style.overflowX if ( (overflow === 'auto' || overflow === 'scroll') && (axis === 'vertical' ? (el as HTMLElement).scrollHeight > (el as HTMLElement).clientHeight : (el as HTMLElement).scrollWidth > (el as HTMLElement).clientWidth) ) { return el as HTMLElement } return findScrollableAncestor(el.parentElement, axis) } /** * Returns whether the user can start a swipe dismiss from a scrollable element * based on the scroll position and drag direction. * Ported from BaseUI `useSwipeDismiss.ts:canSwipeFromScrollEdgeOnPendingMove`. * * Returns: * true → swipe is allowed (scroll is at the relevant edge) * false → swipe is blocked (user is scrolling, not dismissing) * null → the drag is not along a recognized axis; caller should fall through */ function canSwipeFromScrollEdge( scrollTarget: HTMLElement, deltaX: number, deltaY: number, flags: { hasVertical: boolean, hasHorizontal: boolean, allowUp: boolean, allowDown: boolean, allowLeft: boolean, allowRight: boolean }, ): boolean | null { const absDx = Math.abs(deltaX) const absDy = Math.abs(deltaY) const useVerticalAxis = flags.hasVertical && deltaY !== 0 && (!flags.hasHorizontal || absDy >= absDx) if (useVerticalAxis) { const maxScrollTop = Math.max(0, scrollTarget.scrollHeight - scrollTarget.clientHeight) const atTop = scrollTarget.scrollTop <= 0 const atBottom = scrollTarget.scrollTop >= maxScrollTop const movingDown = deltaY > 0 const movingUp = deltaY < 0 // A 'down'-dismiss drawer starts dismiss on a downward pull from the top. // An 'up'-dismiss drawer starts dismiss on an upward pull from the bottom. const canSwipeDown = movingDown && atTop && flags.allowDown const canSwipeUp = movingUp && atBottom && flags.allowUp return canSwipeDown || canSwipeUp } const useHorizontalAxis = flags.hasHorizontal && deltaX !== 0 && (!flags.hasVertical || absDx > absDy) if (useHorizontalAxis) { const maxScrollLeft = Math.max(0, scrollTarget.scrollWidth - scrollTarget.clientWidth) const atLeft = scrollTarget.scrollLeft <= 0 const atRight = scrollTarget.scrollLeft >= maxScrollLeft const movingRight = deltaX > 0 const movingLeft = deltaX < 0 const canSwipeRight = movingRight && atLeft && flags.allowRight const canSwipeLeft = movingLeft && atRight && flags.allowLeft return canSwipeRight || canSwipeLeft } return null } export function useSwipeDismiss(options: UseSwipeDismissOptions) { const { elementRef, directions, movementCssVars, swipeThreshold: swipeThresholdProp, canStart, onDismiss, onProgress, onCancel, onSwipeStart, onRelease, onSwipingChange, } = options const hasVertical = computed(() => toValue(directions).includes('up') || toValue(directions).includes('down')) const hasHorizontal = computed(() => toValue(directions).includes('left') || toValue(directions).includes('right')) const allowUp = computed(() => toValue(directions).includes('up')) const allowDown = computed(() => toValue(directions).includes('down')) const allowLeft = computed(() => toValue(directions).includes('left')) const allowRight = computed(() => toValue(directions).includes('right')) const isSwiping = ref(false) const swipeDirection = ref<SwipeDirection | undefined>(undefined) const dragOffset = ref({ x: 0, y: 0 }) // Internal state (not reactive -- use plain vars for perf) let dragStartPos = { x: 0, y: 0 } let intendedDirection: SwipeDirection | undefined let maxDisplacement = 0 let cancelledSwipe = false let isFirstMove = false let pendingSwipe = false let pendingSwipeStartPos: { x: number, y: number } | null = null let swipeFromScrollable = false let scrollableAncestor: HTMLElement | null = null let elementSize = { width: 0, height: 0 } let swipeProgress = 0 let lastDragSample: { x: number, y: number, time: number } | null = null let lastVelocity = { x: 0, y: 0 } let lockedAxis: 'horizontal' | 'vertical' | null = null let activePointerId: number | null = null let pointerStarted = false function getThreshold(el: HTMLElement, dir: SwipeDirection): number { if (typeof swipeThresholdProp === 'function') return Math.max(0, swipeThresholdProp({ element: el, direction: dir })) return typeof swipeThresholdProp === 'number' ? swipeThresholdProp : DEFAULT_SWIPE_THRESHOLD } function setSwiping(next: boolean) { if (isSwiping.value === next) return isSwiping.value = next onSwipingChange?.(next) } function recordSample(offset: { x: number, y: number }, time: number) { if (lastDragSample && time > lastDragSample.time) { const dt = Math.max(time - lastDragSample.time, MIN_RELEASE_VELOCITY_DURATION_MS) lastVelocity = { x: (offset.x - lastDragSample.x) / dt, y: (offset.y - lastDragSample.y) / dt, } } lastDragSample = { x: offset.x, y: offset.y, time } } function setCssVars(el: HTMLElement, x: number, y: number) { el.style.setProperty(movementCssVars.x, `${x}px`) el.style.setProperty(movementCssVars.y, `${y}px`) } function clearCssVars(el: HTMLElement) { el.style.setProperty(movementCssVars.x, '0px') el.style.setProperty(movementCssVars.y, '0px') } /** * Clears the per-gesture tracking state. Deliberately leaves the pointer * identity and the touch scroll-guard flags alone, so it is safe to call at * the start of a gesture as well as at the end of one. */ function resetGestureState() { setSwiping(false) swipeDirection.value = undefined dragOffset.value = { x: 0, y: 0 } dragStartPos = { x: 0, y: 0 } intendedDirection = undefined maxDisplacement = 0 cancelledSwipe = false isFirstMove = false pendingSwipe = false pendingSwipeStartPos = null elementSize = { width: 0, height: 0 } swipeProgress = 0 lastDragSample = null lastVelocity = { x: 0, y: 0 } lockedAxis = null } function reset() { resetGestureState() swipeFromScrollable = false scrollableAncestor = null activePointerId = null pointerStarted = false } function startSwipe(el: HTMLElement, pos: { x: number, y: number }) { // Start from a clean slate (BaseUI parity: `startSwipeAtPosition` resets the // gesture refs on every press). A previous gesture whose end we never saw // would otherwise leak `isSwiping`, the intended direction and the velocity // samples into this one. resetGestureState() // Capture the element's current transform so we can account for it getElementTransform(el) dragStartPos = pos pendingSwipeStartPos = pos elementSize = { width: el.offsetWidth, height: el.offsetHeight } isFirstMove = true pendingSwipe = true } /** * Applies sqrt-damping to any axis moving in a disallowed direction. * Axes moving in an allowed direction pass through linearly. * Ported from BaseUI `useSwipeDismiss.ts:applyDirectionalDamping`. */ function applyDirectionalDamping(deltaX: number, deltaY: number) { const exponent = (value: number) => (value >= 0 ? value ** 0.5 : -((Math.abs(value)) ** 0.5)) const dampAxis = (delta: number, allowNegative: boolean, allowPositive: boolean) => { if (!allowNegative && delta < 0) return exponent(delta) if (!allowPositive && delta > 0) return exponent(delta) return delta } const newDx = hasHorizontal.value ? dampAxis(deltaX, allowLeft.value, allowRight.value) : exponent(deltaX) const newDy = hasVertical.value ? dampAxis(deltaY, allowUp.value, allowDown.value) : exponent(deltaY) return { x: newDx, y: newDy } } function processMove(el: HTMLElement, pos: { x: number, y: number }, time: number) { const rawDx = pos.x - dragStartPos.x const rawDy = pos.y - dragStartPos.y // Determine direction lock on first move if (isFirstMove) { isFirstMove = false const absX = Math.abs(rawDx) const absY = Math.abs(rawDy) if (hasVertical.value && hasHorizontal.value) { lockedAxis = absX > absY ? 'horizontal' : 'vertical' } else if (hasVertical.value) { lockedAxis = 'vertical' } else { lockedAxis = 'horizontal' } } // Axis-locked deltas for direction detection const dx = lockedAxis === 'vertical' ? 0 : rawDx const dy = lockedAxis === 'horizontal' ? 0 : rawDy const dir: SwipeDirection | undefined = toValue(directions).find(d => getDisplacement(d, dx, dy) > 0) if (pendingSwipe && pendingSwipeStartPos) { // BaseUI parity (`useSwipeDismiss.ts:startSwipeAtPosition`): the drag is // tracked from the first movement whatever its direction — the gesture // does NOT need to point at a dismissable direction to begin. Movement // along a non-dismissable direction still reaches // `applyDirectionalDamping` below, which sqrt-damps it; that damped // offset is the elastic "pull" feedback you get when dragging a bottom // drawer upward. Bailing out on `!dir` instead would leave the drawer // completely frozen until the user drags the dismissable way. const pending = Math.max(Math.abs(dx), Math.abs(dy)) if (pending < MIN_DRAG_THRESHOLD) return pendingSwipe = false setSwiping(true) onSwipeStart?.() } if (!isSwiping.value) return // The intended (dismissable) direction is adopted the first time the drag // moves in one, which may be many moves after the swipe started — e.g. pull // up, then push back down on a `down` drawer. Until then the gesture has no // dismiss candidate and only rubber-bands. if (!intendedDirection && dir) { intendedDirection = dir swipeDirection.value = dir maxDisplacement = getDisplacement(dir, dx, dy) } const currentDir = intendedDirection // Detect reversal (cancel swipe) if (currentDir && !cancelledSwipe) { const displacement = getDisplacement(currentDir, dx, dy) maxDisplacement = Math.max(maxDisplacement, displacement) if ( maxDisplacement > DEFAULT_SWIPE_THRESHOLD / 2 && maxDisplacement - displacement > REVERSE_CANCEL_THRESHOLD ) { cancelledSwipe = true } } // Apply directional damping to raw deltas (BaseUI parity). // Allowed-direction axes pass through linearly; disallowed directions // sqrt-damp. No manual single-axis sign reconstruction. const damped = applyDirectionalDamping(dx, dy) dragOffset.value = { x: damped.x, y: damped.y } setCssVars(el, damped.x, damped.y) recordSample({ x: damped.x, y: damped.y }, time) // Progress: 0 = closed/start, 1 = fully dismissed. Before a dismissable // direction is adopted, measure against the primary one — a pull away from // it yields a negative displacement, which clamps to 0 (BaseUI parity: // `progressDirection = primaryDirection ?? intendedSwipeDirection`). const currentEl = elementRef.value const progressDir = currentDir ?? toValue(directions)[0] if (currentEl && progressDir) { const dim = (progressDir === 'up' || progressDir === 'down') ? elementSize.height || currentEl.offsetHeight : elementSize.width || currentEl.offsetWidth const threshold = getThreshold(currentEl, progressDir) const p = Math.min(1, Math.max(0, getDisplacement(progressDir, dx, dy) / (dim + threshold))) if (p !== swipeProgress) { swipeProgress = p onProgress?.(p, { deltaX: damped.x, deltaY: damped.y, direction: currentDir }) } } } function finishSwipe(el: HTMLElement) { if (!isSwiping.value) { reset() return } const displacement = getDisplacement( intendedDirection ?? toValue(directions)[0], dragOffset.value.x, dragOffset.value.y, ) const threshold = getThreshold(el, intendedDirection ?? toValue(directions)[0]) const now = performance.now() const velAge = lastDragSample ? now - lastDragSample.time : Infinity const velocity = velAge > MAX_RELEASE_VELOCITY_AGE_MS ? { x: 0, y: 0 } : lastVelocity // When onRelease returns true (snap-point release), it owns the open/close // and transform outcome — don't run the dismiss-vs-cancel branch, which // would otherwise set a stray `data-swipe-dismissed` on a drawer that // actually snapped to a point, or clear CSS vars onRelease already wrote. const releaseHandled = onRelease?.(velocity) === true if (!releaseHandled) { const velInDirection = getDisplacement( intendedDirection ?? toValue(directions)[0], velocity.x, velocity.y, ) const shouldDismiss = !cancelledSwipe && (displacement >= threshold || velInDirection > 0.3) if (shouldDismiss) { // BaseUI parity: on dismiss, keep the drag transform in place so the // close animation runs smoothly from the dragged position. Clearing the // CSS vars here would cause a one-frame snap-back to resting before the // closing transition begins (visible as a flicker). el.setAttribute('data-swipe-dismissed', '') onDismiss?.() } else { // On cancel, reset the drag transform so the drawer animates back to rest. clearCssVars(el) onCancel?.() } } reset() } // -- Pointer Events (mouse + pen) -- function onPointerDown(e: PointerEvent) { if (!toValue(options.enabled)) return if (e.pointerType === 'touch') return // handled by touch events if (e.button !== 0) return if (canStart && !canStart()) return const target = e.target as HTMLElement if (target?.closest(DEFAULT_IGNORE_SELECTOR)) return const el = elementRef.value if (!el) return startSwipe(el, { x: e.clientX, y: e.clientY }) activePointerId = e.pointerId pointerStarted = true try { el.setPointerCapture(e.pointerId) } catch {} } function onPointerMove(e: PointerEvent) { if (!pointerStarted || e.pointerId !== activePointerId) return const el = elementRef.value if (!el) return if ((e.buttons & 1) === 0) { // The primary button is no longer held, but we never saw the release — // it happened outside the window, over another application, or after the // browser dropped pointer capture. Treat this move as the missing // `pointerup`: without it the gesture stays wedged with `data-swiping` // set (which pins the transition to 0ms), leaving the drawer frozen // mid-pull and ignoring every subsequent drag. finishSwipe(el) return } processMove(el, { x: e.clientX, y: e.clientY }, e.timeStamp) } function onPointerUp(e: PointerEvent) { if (!pointerStarted || e.pointerId !== activePointerId) return const el = elementRef.value if (!el) { reset() return } finishSwipe(el) } /** * Ends an in-flight pointer drag that can no longer be tracked — pointer * capture was lost, or the window lost focus mid-drag (which is what happens * when the release lands over another application). The drawer settles from * wherever it was last dragged to. */ function onPointerInterrupted() { if (!pointerStarted) return const el = elementRef.value if (!el) { reset() return } finishSwipe(el) } // -- Touch Events (mobile) -- function onTouchStart(e: TouchEvent) { if (!toValue(options.enabled)) return if (canStart && !canStart()) return const target = e.target as HTMLElement if (target?.closest(DEFAULT_IGNORE_SELECTOR)) return const el = elementRef.value if (!el) return if (!options.ignoreScrollableAncestors) { const axis = hasVertical.value ? 'vertical' : 'horizontal' const scrollable = findScrollableAncestor(target, axis) if (scrollable) { swipeFromScrollable = true scrollableAncestor = scrollable } } const t = e.touches[0] if (!t) return startSwipe(el, { x: t.clientX, y: t.clientY }) } function onTouchMove(e: TouchEvent) { const el = elementRef.value if (!el || (!pendingSwipe && !isSwiping.value)) return const t = e.touches[0] if (!t) return const pos = { x: t.clientX, y: t.clientY } if (swipeFromScrollable && pendingSwipe && scrollableAncestor) { const dx = pos.x - dragStartPos.x const dy = pos.y - dragStartPos.y // BaseUI-style scroll-edge allowance: only cancel if the user is genuinely // scrolling (not at the edge in the dismiss direction). If they are at the // relevant edge, let the swipe proceed. const allowed = canSwipeFromScrollEdge(scrollableAncestor, dx, dy, { hasVertical: hasVertical.value, hasHorizontal: hasHorizontal.value, allowUp: allowUp.value, allowDown: allowDown.value, allowLeft: allowLeft.value, allowRight: allowRight.value, }) if (allowed === false) { reset() return } if (allowed === true) { // Scroll edge reached in the swipe direction → clear the guard so // subsequent moves go through the normal swipe path. swipeFromScrollable = false scrollableAncestor = null } // allowed === null → axis unclear, fall through to processMove which // will lock the axis and retry the decision on the next frame. } // Capture swiping state BEFORE processMove — that function is what sets // isSwiping.value = true on the move that crosses the drag threshold, so // we need to preventDefault on that same event (not the next one) to avoid // a visible scroll hitch on the first frame of the gesture on mobile. const wasSwiping = isSwiping.value processMove(el, pos, e.timeStamp) if (wasSwiping || isSwiping.value) e.preventDefault() } function onTouchEnd() { const el = elementRef.value if (!el) return finishSwipe(el) } // -- Attach listeners -- const cleanups: Array<() => void> = [] watch( () => elementRef.value, (el) => { cleanups.forEach(fn => fn()) cleanups.length = 0 if (!el) return // BaseUI hangs the gesture off `Drawer.Viewport`, a `position: fixed; // inset: 0` element, so the pointer can never leave the element that is // listening. Here the listeners live on the popup itself, which the // pointer leaves as soon as the drag goes past the drawer's bounds. // `setPointerCapture` covers that for an ordinary in-window drag, but a // release the popup never sees — outside the window, over another app, or // after capture is dropped — would otherwise leave the gesture wedged. // Mirror the pointer end events on the document and window so the drag // can always be finished. const doc = el.ownerDocument const win = doc.defaultView cleanups.push( useEventListener(el, 'pointerdown', onPointerDown as EventListener), useEventListener(el, 'pointermove', onPointerMove as EventListener), useEventListener(el, 'pointerup', onPointerUp as EventListener), useEventListener(el, 'pointercancel', onPointerUp as EventListener), useEventListener(el, 'lostpointercapture', onPointerInterrupted as EventListener), useEventListener(doc, 'pointerup', onPointerUp as EventListener), useEventListener(doc, 'pointercancel', onPointerUp as EventListener), useEventListener(el, 'touchstart', onTouchStart as EventListener, { passive: true }), useEventListener(el, 'touchmove', onTouchMove as EventListener, { passive: false }), useEventListener(el, 'touchend', onTouchEnd as EventListener), useEventListener(el, 'touchcancel', onTouchEnd as EventListener), ) if (win) cleanups.push(useEventListener(win, 'blur', onPointerInterrupted)) }, { immediate: true }, ) onUnmounted(() => { cleanups.forEach(fn => fn()) cleanups.length = 0 reset() }) return { isSwiping, swipeDirection, dragOffset, } }