UNPKG

reka-ui

Version:

Vue port for Radix UI Primitives.

391 lines (389 loc) 13.1 kB
import { getDisplacement, getElementTransform } from "../Drawer/utils.js"; import { computed, onUnmounted, ref, toValue, watch } from "vue"; import { useEventListener } from "@vueuse/core"; //#region src/Drawer/composables/useSwipeDismiss.ts 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, axis) { if (!el || el === document.body) return null; const style = window.getComputedStyle(el); const overflow = axis === "vertical" ? style.overflowY : style.overflowX; if ((overflow === "auto" || overflow === "scroll") && (axis === "vertical" ? el.scrollHeight > el.clientHeight : el.scrollWidth > el.clientWidth)) return el; 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, deltaX, deltaY, flags) { 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; 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; } function useSwipeDismiss(options) { 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(void 0); const dragOffset = ref({ x: 0, y: 0 }); let dragStartPos = { x: 0, y: 0 }; let intendedDirection; let maxDisplacement = 0; let cancelledSwipe = false; let isFirstMove = false; let pendingSwipe = false; let pendingSwipeStartPos = null; let swipeFromScrollable = false; let scrollableAncestor = null; let elementSize = { width: 0, height: 0 }; let swipeProgress = 0; let lastDragSample = null; let lastVelocity = { x: 0, y: 0 }; let lockedAxis = null; let activePointerId = null; let pointerStarted = false; function getThreshold(el, dir) { if (typeof swipeThresholdProp === "function") return Math.max(0, swipeThresholdProp({ element: el, direction: dir })); return typeof swipeThresholdProp === "number" ? swipeThresholdProp : DEFAULT_SWIPE_THRESHOLD; } function setSwiping(next) { if (isSwiping.value === next) return; isSwiping.value = next; onSwipingChange?.(next); } function recordSample(offset, time) { 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, x, y) { el.style.setProperty(movementCssVars.x, `${x}px`); el.style.setProperty(movementCssVars.y, `${y}px`); } function clearCssVars(el) { el.style.setProperty(movementCssVars.x, "0px"); el.style.setProperty(movementCssVars.y, "0px"); } function reset() { setSwiping(false); swipeDirection.value = void 0; dragOffset.value = { x: 0, y: 0 }; dragStartPos = { x: 0, y: 0 }; intendedDirection = void 0; maxDisplacement = 0; cancelledSwipe = false; isFirstMove = false; pendingSwipe = false; pendingSwipeStartPos = null; swipeFromScrollable = false; scrollableAncestor = null; elementSize = { width: 0, height: 0 }; swipeProgress = 0; lastDragSample = null; lastVelocity = { x: 0, y: 0 }; lockedAxis = null; activePointerId = null; pointerStarted = false; } function startSwipe(el, pos) { 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, deltaY) { const exponent = (value) => value >= 0 ? value ** .5 : -(Math.abs(value) ** .5); const dampAxis = (delta, allowNegative, allowPositive) => { 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, pos, time) { const rawDx = pos.x - dragStartPos.x; const rawDy = pos.y - dragStartPos.y; 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"; } const dx = lockedAxis === "vertical" ? 0 : rawDx; const dy = lockedAxis === "horizontal" ? 0 : rawDy; const dir = toValue(directions).find((d) => getDisplacement(d, dx, dy) > 0); if (pendingSwipe && pendingSwipeStartPos) { if (!dir) return; const pending = getDisplacement(dir, pos.x - pendingSwipeStartPos.x, pos.y - pendingSwipeStartPos.y); if (Math.abs(pending) < MIN_DRAG_THRESHOLD) return; pendingSwipe = false; intendedDirection = dir; swipeDirection.value = dir; setSwiping(true); onSwipeStart?.(); } if (!isSwiping.value) return; const currentDir = intendedDirection ?? toValue(directions)[0]; const displacement = getDisplacement(currentDir, dx, dy); if (!cancelledSwipe) { maxDisplacement = Math.max(maxDisplacement, displacement); if (maxDisplacement > DEFAULT_SWIPE_THRESHOLD / 2 && maxDisplacement - displacement > REVERSE_CANCEL_THRESHOLD) cancelledSwipe = true; } 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); const currentEl = elementRef.value; if (currentEl) { const dim = currentDir === "up" || currentDir === "down" ? elementSize.height || currentEl.offsetHeight : elementSize.width || currentEl.offsetWidth; const threshold = getThreshold(currentEl, currentDir); const p = Math.min(1, Math.max(0, displacement / (dim + threshold))); if (p !== swipeProgress) { swipeProgress = p; onProgress?.(p, { deltaX: damped.x, deltaY: damped.y, direction: currentDir }); } } } function finishSwipe(el) { 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; const releaseHandled = onRelease?.(velocity) === true; if (!releaseHandled) { const velInDirection = getDisplacement(intendedDirection ?? toValue(directions)[0], velocity.x, velocity.y); const shouldDismiss = !cancelledSwipe && (displacement >= threshold || velInDirection > .3); if (shouldDismiss) { el.setAttribute("data-swipe-dismissed", ""); onDismiss?.(); } else { clearCssVars(el); onCancel?.(); } } reset(); } function onPointerDown(e) { if (!toValue(options.enabled)) return; if (e.pointerType === "touch") return; if (e.button !== 0) return; if (canStart && !canStart()) return; const target = e.target; 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) { if (!pointerStarted || e.pointerId !== activePointerId) return; const el = elementRef.value; if (!el) return; if ((e.buttons & 1) === 0) return; processMove(el, { x: e.clientX, y: e.clientY }, e.timeStamp); } function onPointerUp(e) { if (!pointerStarted || e.pointerId !== activePointerId) return; const el = elementRef.value; if (!el) return; finishSwipe(el); } function onTouchStart(e) { if (!toValue(options.enabled)) return; if (canStart && !canStart()) return; const target = e.target; 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) { 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; 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) { swipeFromScrollable = false; scrollableAncestor = null; } } 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); } const cleanups = []; watch(() => elementRef.value, (el) => { cleanups.forEach((fn) => fn()); cleanups.length = 0; if (!el) return; cleanups.push(useEventListener(el, "pointerdown", onPointerDown), useEventListener(el, "pointermove", onPointerMove), useEventListener(el, "pointerup", onPointerUp), useEventListener(el, "pointercancel", onPointerUp), useEventListener(el, "touchstart", onTouchStart, { passive: true }), useEventListener(el, "touchmove", onTouchMove, { passive: false }), useEventListener(el, "touchend", onTouchEnd), useEventListener(el, "touchcancel", onTouchEnd)); }, { immediate: true }); onUnmounted(() => { cleanups.forEach((fn) => fn()); cleanups.length = 0; reset(); }); return { isSwiping, swipeDirection, dragOffset }; } //#endregion export { useSwipeDismiss }; //# sourceMappingURL=useSwipeDismiss.js.map