import { ref, onBeforeUnmount } from 'vue' /** * Composable for draggable bottom-sheet panels with snap points. * * Default snap stops (in dvh): 100, 75, 50 * Close threshold: below 25dvh * * @param {Function} onClose - called when panel is dragged below threshold * @param {Object} options - drag/snap behavior overrides * @returns {{ panelHeight, handleListeners, resetHeight }} */ export function usePanelDrag(onClose, options = {}) { const SNAP_POINTS = options.snapPoints ?? [100, 75, 50, 25] // dvh values const CLOSE_THRESHOLD = options.closeThreshold ?? 15 // below this → close const INITIAL_DVH = options.initialDvh ?? 75 const MIN_DVH = options.minDvh ?? 10 const MAX_DVH = options.maxDvh ?? 100 // Current panel height in dvh (null = use CSS default) const panelHeight = ref(null) const isDragging = ref(false) let dragging = false let startY = 0 let startHeight = 0 function getViewportHeight() { return window.innerHeight } function pxToDvh(px) { return (px / getViewportHeight()) * 100 } function findNearestSnap(dvh) { let nearest = SNAP_POINTS[0] let minDist = Infinity for (const snap of SNAP_POINTS) { const dist = Math.abs(dvh - snap) if (dist < minDist) { minDist = dist nearest = snap } } return nearest } function onPointerDown(e) { // Only primary button / single touch if (e.button && e.button !== 0) return dragging = true isDragging.value = true const clientY = e.touches ? e.touches[0].clientY : e.clientY startY = clientY // Current height: if panelHeight is set use it, else measure from CSS const currentDvh = panelHeight.value ?? INITIAL_DVH startHeight = currentDvh document.addEventListener('pointermove', onPointerMove, { passive: false }) document.addEventListener('pointerup', onPointerUp) document.addEventListener('touchmove', onTouchMove, { passive: false }) document.addEventListener('touchend', onTouchEnd) // Prevent text selection e.preventDefault() } function onPointerMove(e) { if (!dragging) return const clientY = e.clientY handleMove(clientY) } function onTouchMove(e) { if (!dragging) return if (e.touches.length !== 1) return handleMove(e.touches[0].clientY) e.preventDefault() } function handleMove(clientY) { const deltaY = clientY - startY const deltaDvh = pxToDvh(deltaY) const newHeight = Math.max(MIN_DVH, Math.min(MAX_DVH, startHeight - deltaDvh)) panelHeight.value = newHeight } function onPointerUp() { finishDrag() } function onTouchEnd() { finishDrag() } function finishDrag() { if (!dragging) return dragging = false isDragging.value = false cleanup() const currentHeight = panelHeight.value ?? INITIAL_DVH if (currentHeight < CLOSE_THRESHOLD) { panelHeight.value = null onClose() } else { // Snap to nearest point panelHeight.value = findNearestSnap(currentHeight) } } function cleanup() { document.removeEventListener('pointermove', onPointerMove) document.removeEventListener('pointerup', onPointerUp) document.removeEventListener('touchmove', onTouchMove) document.removeEventListener('touchend', onTouchEnd) } function resetHeight() { panelHeight.value = null } onBeforeUnmount(cleanup) // Event listeners to bind on the handle element const handleListeners = { pointerdown: onPointerDown, touchstart: onPointerDown, } return { panelHeight, isDragging, handleListeners, resetHeight, } }