reka-ui
Version:
Vue port for Radix UI Primitives.
627 lines (558 loc) • 22.3 kB
text/typescript
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,
}
}