import type { Ref } from 'vue'; import { computed, nextTick, ref, watch } from 'vue'; import { setStyle } from '@robonen/platform/browsers'; import { useEventListener } from '@robonen/vue'; import { isVertical } from './helpers'; import { TRANSITIONS, VELOCITY_THRESHOLD } from './constants'; import type { DrawerDirection } from './types'; interface UseSnapPointsProps { activeSnapPoint: Ref; snapPoints: Ref | undefined>; fadeFromIndex: Ref; drawerRef: Ref; overlayRef: Ref; onSnapPointChange: (activeSnapPointIndex: number, snapPointsOffset: number[]) => void; direction: Ref; } const transition = (property: 'transform' | 'opacity') => `${property} ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`; /** * Drag/release maths for drawers configured with snap points: resolves each * snap point to a pixel offset, animates the drawer between them, and decides * which point to settle on (or whether to close) based on drag distance and * velocity. */ export function useSnapPoints({ activeSnapPoint, snapPoints, drawerRef, overlayRef, fadeFromIndex, onSnapPointChange, direction, }: UseSnapPointsProps) { const windowDimensions = ref(globalThis.window !== undefined ? { innerWidth: window.innerWidth, innerHeight: window.innerHeight } : undefined); function onResize() { const innerWidth = window.innerWidth; const innerHeight = window.innerHeight; const cur = windowDimensions.value; // Skip the ref write (and the snapPointsOffset recompute it would trigger) // when dimensions are unchanged — some resize events report identical sizes. if (!cur || cur.innerWidth !== innerWidth || cur.innerHeight !== innerHeight) windowDimensions.value = { innerWidth, innerHeight }; } // Defaults to `defaultWindow` (SSR-safe) and auto-removes on scope dispose. useEventListener('resize', onResize); const isLastSnapPoint = computed( () => (snapPoints.value && activeSnapPoint.value === snapPoints.value[snapPoints.value.length - 1]) ?? null, ); const shouldFade = computed( () => (snapPoints.value && snapPoints.value.length > 0 && (fadeFromIndex?.value || fadeFromIndex?.value === 0) && !Number.isNaN(fadeFromIndex?.value) && snapPoints.value[fadeFromIndex?.value ?? -1] === activeSnapPoint.value) || !snapPoints.value, ); const activeSnapPointIndex = computed( () => snapPoints.value?.indexOf(activeSnapPoint.value) ?? null, ); const snapPointsOffset = computed( () => snapPoints.value?.map((snapPoint) => { const isPx = typeof snapPoint === 'string'; let snapPointAsNumber = 0; if (isPx) snapPointAsNumber = Number.parseInt(snapPoint, 10); if (isVertical(direction.value)) { const height = isPx ? snapPointAsNumber : windowDimensions.value ? (snapPoint as number) * windowDimensions.value.innerHeight : 0; if (windowDimensions.value) return direction.value === 'bottom' ? windowDimensions.value.innerHeight - height : -windowDimensions.value.innerHeight + height; return height; } const width = isPx ? snapPointAsNumber : windowDimensions.value ? (snapPoint as number) * windowDimensions.value.innerWidth : 0; if (windowDimensions.value) return direction.value === 'right' ? windowDimensions.value.innerWidth - width : -windowDimensions.value.innerWidth + width; return width; }) ?? [], ); const activeSnapPointOffset = computed(() => activeSnapPointIndex.value !== null ? snapPointsOffset.value?.[activeSnapPointIndex.value] : null, ); function snapToPoint(dimension: number) { const newSnapPointIndex = snapPointsOffset.value?.indexOf(dimension) ?? null; // Wait for the element to be mounted before transforming it. nextTick(() => { onSnapPointChange(newSnapPointIndex, snapPointsOffset.value); setStyle(drawerRef.value, { transition: transition('transform'), transform: isVertical(direction.value) ? `translate3d(0, ${dimension}px, 0)` : `translate3d(${dimension}px, 0, 0)`, }); }); if ( snapPointsOffset.value && newSnapPointIndex !== snapPointsOffset.value.length - 1 && newSnapPointIndex !== fadeFromIndex?.value ) { setStyle(overlayRef.value, { transition: transition('opacity'), opacity: '0' }); } else { setStyle(overlayRef.value, { transition: transition('opacity'), opacity: '1' }); } activeSnapPoint.value = newSnapPointIndex !== null ? snapPoints.value?.[newSnapPointIndex] ?? null : null; } watch( [activeSnapPoint, snapPointsOffset, snapPoints], () => { if (activeSnapPoint.value) { const newIndex = snapPoints.value?.indexOf(activeSnapPoint.value) ?? -1; if (snapPointsOffset.value && newIndex !== -1 && typeof snapPointsOffset.value[newIndex] === 'number') snapToPoint(snapPointsOffset.value[newIndex]); } }, { immediate: true }, ); function onRelease({ draggedDistance, closeDrawer, velocity, dismissible, }: { draggedDistance: number; closeDrawer: () => void; velocity: number; dismissible: boolean; }) { if (fadeFromIndex.value === undefined) return; const currentPosition = direction.value === 'bottom' || direction.value === 'right' ? (activeSnapPointOffset.value ?? 0) - draggedDistance : (activeSnapPointOffset.value ?? 0) + draggedDistance; const isOverlaySnapPoint = activeSnapPointIndex.value === fadeFromIndex.value - 1; const isFirst = activeSnapPointIndex.value === 0; const hasDraggedUp = draggedDistance > 0; if (isOverlaySnapPoint) setStyle(overlayRef.value, { transition: transition('opacity') }); if (velocity > 2 && !hasDraggedUp) { if (dismissible) closeDrawer(); else snapToPoint(snapPointsOffset.value[0]); // snap to initial point return; } if (velocity > 2 && hasDraggedUp && snapPointsOffset.value && snapPoints.value) { snapToPoint(snapPointsOffset.value[snapPoints.value.length - 1]); return; } // Settle on the snap point closest to where the drag ended. const closestSnapPoint = snapPointsOffset.value?.reduce((prev, curr) => { if (typeof prev !== 'number' || typeof curr !== 'number') return prev; return Math.abs(curr - currentPosition) < Math.abs(prev - currentPosition) ? curr : prev; }); const dim = isVertical(direction.value) ? window.innerHeight : window.innerWidth; if (velocity > VELOCITY_THRESHOLD && Math.abs(draggedDistance) < dim * 0.4) { const dragDirection = hasDraggedUp ? 1 : -1; // 1 = up, -1 = down // Ignore an upward flick while already on the last snap point. if (dragDirection > 0 && isLastSnapPoint.value) { snapToPoint(snapPointsOffset.value[(snapPoints.value?.length ?? 0) - 1]); return; } if (isFirst && dragDirection < 0 && dismissible) closeDrawer(); if (activeSnapPointIndex.value === null) return; snapToPoint(snapPointsOffset.value[activeSnapPointIndex.value + dragDirection]); return; } snapToPoint(closestSnapPoint); } function onDrag({ draggedDistance }: { draggedDistance: number }) { if (activeSnapPointOffset.value === null) return; const newValue = direction.value === 'bottom' || direction.value === 'right' ? (activeSnapPointOffset.value ?? 0) - draggedDistance : (activeSnapPointOffset.value ?? 0) + draggedDistance; // Don't drag past the last (largest) snap point. if ((direction.value === 'bottom' || direction.value === 'right') && newValue < snapPointsOffset.value[snapPointsOffset.value.length - 1]) return; if ((direction.value === 'top' || direction.value === 'left') && newValue > snapPointsOffset.value[snapPointsOffset.value.length - 1]) return; setStyle(drawerRef.value, { transform: isVertical(direction.value) ? `translate3d(0, ${newValue}px, 0)` : `translate3d(${newValue}px, 0, 0)`, }); } function getPercentageDragged(absDraggedDistance: number, isDraggingDown: boolean) { if ( !snapPoints.value || typeof activeSnapPointIndex.value !== 'number' || !snapPointsOffset.value || fadeFromIndex.value === undefined ) return null; // Whether we're dragging toward a snap point that should show the overlay. const isOverlaySnapPoint = activeSnapPointIndex.value === fadeFromIndex.value - 1; const isOverlaySnapPointOrHigher = activeSnapPointIndex.value >= fadeFromIndex.value; if (isOverlaySnapPointOrHigher && isDraggingDown) return 0; // Don't animate, but still use this one when dragging away from the overlay snap point. if (isOverlaySnapPoint && !isDraggingDown) return 1; if (!shouldFade.value && !isOverlaySnapPoint) return null; const targetSnapPointIndex = isOverlaySnapPoint ? activeSnapPointIndex.value + 1 : activeSnapPointIndex.value - 1; // Distance between the overlay snap point and its neighbour, used to scale opacity. const snapPointDistance = isOverlaySnapPoint ? snapPointsOffset.value[targetSnapPointIndex] - snapPointsOffset.value[targetSnapPointIndex - 1] : snapPointsOffset.value[targetSnapPointIndex + 1] - snapPointsOffset.value[targetSnapPointIndex]; const percentageDragged = absDraggedDistance / Math.abs(snapPointDistance); return isOverlaySnapPoint ? 1 - percentageDragged : percentageDragged; } return { isLastSnapPoint, shouldFade, getPercentageDragged, activeSnapPointIndex, onRelease, onDrag, snapPointsOffset, }; }