/** * Balanced Breakfast - Touch Interaction Module * Gesture utilities for mobile: long-press, swipe actions, pull-to-refresh, * swipe navigation, drag-to-dismiss. All no-ops on non-touch devices. */ (function() { 'use strict'; // ============ Touch Detection ============ const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); // ============ Long Press ============ /** * Add long-press handler to an element (500ms hold). * Touch-move cancels the timer. No-op on non-touch devices. * @param {HTMLElement} element * @param {Function} callback - Called with the original TouchEvent * @param {number} [duration=500] - Hold duration in ms * @returns {Function} cleanup - Call to remove listeners */ function addLongPress(element, callback, duration = 500) { if (!isTouchDevice) return () => {}; let timer = null; let startX = 0; let startY = 0; const MOVE_THRESHOLD = 10; function onTouchStart(e) { const touch = e.touches[0]; startX = touch.clientX; startY = touch.clientY; timer = setTimeout(() => { timer = null; // Prevent the subsequent click/tap element.addEventListener('click', preventClick, { once: true, capture: true }); callback(e); }, duration); } function onTouchMove(e) { if (!timer) return; const touch = e.touches[0]; const dx = Math.abs(touch.clientX - startX); const dy = Math.abs(touch.clientY - startY); if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) { clearTimeout(timer); timer = null; } } function onTouchEnd() { if (timer) { clearTimeout(timer); timer = null; } } function preventClick(e) { e.preventDefault(); e.stopPropagation(); } element.addEventListener('touchstart', onTouchStart, { passive: true }); element.addEventListener('touchmove', onTouchMove, { passive: true }); element.addEventListener('touchend', onTouchEnd, { passive: true }); element.addEventListener('touchcancel', onTouchEnd, { passive: true }); return function cleanup() { clearTimeout(timer); element.removeEventListener('touchstart', onTouchStart); element.removeEventListener('touchmove', onTouchMove); element.removeEventListener('touchend', onTouchEnd); element.removeEventListener('touchcancel', onTouchEnd); }; } // ============ Swipe Actions ============ /** * Add horizontal swipe actions to a list item element. * Reveals action buttons behind the item on swipe. * @param {HTMLElement} element - The list item element * @param {Object} config * @param {Object} [config.left] - Left swipe config { label, color, action } * @param {Object} [config.right] - Right swipe config { label, color, action } * @param {number} [config.threshold=80] - Pixels to trigger action * @returns {Function} cleanup */ function addSwipeActions(element, config) { if (!isTouchDevice) return () => {}; const threshold = config.threshold || 80; let startX = 0; let startY = 0; let currentX = 0; let isDragging = false; let isHorizontal = null; function onTouchStart(e) { const touch = e.touches[0]; startX = touch.clientX; startY = touch.clientY; currentX = 0; isDragging = true; isHorizontal = null; element.style.transition = 'none'; } function onTouchMove(e) { if (!isDragging) return; const touch = e.touches[0]; const dx = touch.clientX - startX; const dy = touch.clientY - startY; // Determine direction on first significant move if (isHorizontal === null) { if (Math.abs(dx) > 10 || Math.abs(dy) > 10) { isHorizontal = Math.abs(dx) > Math.abs(dy); } if (!isHorizontal) return; } if (!isHorizontal) return; e.preventDefault(); currentX = dx; // Clamp to allowed directions if (dx < 0 && !config.left) currentX = 0; if (dx > 0 && !config.right) currentX = 0; // Rubber-band effect past threshold const maxSwipe = threshold * 1.5; if (Math.abs(currentX) > threshold) { const overshoot = Math.abs(currentX) - threshold; const dampened = threshold + overshoot * 0.3; currentX = currentX > 0 ? Math.min(dampened, maxSwipe) : Math.max(-dampened, -maxSwipe); } element.style.transform = `translateX(${currentX}px)`; } function onTouchEnd() { if (!isDragging) return; isDragging = false; element.style.transition = 'transform 0.2s ease'; if (Math.abs(currentX) >= threshold) { // Fire action if (currentX < 0 && config.left?.action) { config.left.action(); } else if (currentX > 0 && config.right?.action) { config.right.action(); } } // Snap back element.style.transform = 'translateX(0)'; currentX = 0; isHorizontal = null; } element.addEventListener('touchstart', onTouchStart, { passive: true }); element.addEventListener('touchmove', onTouchMove, { passive: false }); element.addEventListener('touchend', onTouchEnd, { passive: true }); element.addEventListener('touchcancel', onTouchEnd, { passive: true }); return function cleanup() { element.removeEventListener('touchstart', onTouchStart); element.removeEventListener('touchmove', onTouchMove); element.removeEventListener('touchend', onTouchEnd); element.removeEventListener('touchcancel', onTouchEnd); }; } // ============ Pull to Refresh ============ /** * Add pull-to-refresh to a scrollable container. * @param {HTMLElement} container - The scrollable container * @param {Function} callback - Async function to call on refresh * @param {number} [pullThreshold=60] - Pixels to trigger refresh * @returns {Function} cleanup */ function addPullToRefresh(container, callback, pullThreshold = 60) { if (!isTouchDevice) return () => {}; let startY = 0; let pulling = false; let indicator = null; function getOrCreateIndicator() { if (!indicator) { indicator = document.createElement('div'); indicator.className = 'pull-to-refresh-indicator'; indicator.textContent = 'Pull to refresh...'; container.parentElement.insertBefore(indicator, container); } return indicator; } function onTouchStart(e) { if (container.scrollTop > 0) return; startY = e.touches[0].clientY; pulling = true; } function onTouchMove(e) { if (!pulling) return; if (container.scrollTop > 0) { pulling = false; return; } const dy = e.touches[0].clientY - startY; if (dy <= 0) return; const ind = getOrCreateIndicator(); if (dy > pullThreshold) { ind.textContent = 'Release to refresh'; ind.classList.add('visible'); } else if (dy > 10) { ind.textContent = 'Pull to refresh...'; ind.classList.add('visible'); } } async function onTouchEnd(e) { if (!pulling) return; pulling = false; const dy = e.changedTouches[0].clientY - startY; if (indicator) { indicator.classList.remove('visible'); } if (dy >= pullThreshold) { if (indicator) { indicator.textContent = 'Refreshing...'; indicator.classList.add('visible'); } try { await callback(); } finally { if (indicator) { indicator.classList.remove('visible'); } } } } container.addEventListener('touchstart', onTouchStart, { passive: true }); container.addEventListener('touchmove', onTouchMove, { passive: true }); container.addEventListener('touchend', onTouchEnd, { passive: true }); return function cleanup() { container.removeEventListener('touchstart', onTouchStart); container.removeEventListener('touchmove', onTouchMove); container.removeEventListener('touchend', onTouchEnd); if (indicator) indicator.remove(); }; } // ============ Swipe Navigation ============ /** * Add horizontal swipe navigation (e.g., swipe between views). * @param {HTMLElement} container * @param {Object} handlers - { onLeft: Function, onRight: Function } * @param {number} [threshold=50] - Minimum swipe distance * @returns {Function} cleanup */ function addSwipeNavigation(container, handlers, threshold = 50) { if (!isTouchDevice) return () => {}; let startX = 0; let startY = 0; function onTouchStart(e) { startX = e.touches[0].clientX; startY = e.touches[0].clientY; } function onTouchEnd(e) { const dx = e.changedTouches[0].clientX - startX; const dy = e.changedTouches[0].clientY - startY; // Only horizontal swipes if (Math.abs(dx) < threshold || Math.abs(dx) < Math.abs(dy)) return; if (dx < -threshold && handlers.onLeft) { handlers.onLeft(); } else if (dx > threshold && handlers.onRight) { handlers.onRight(); } } container.addEventListener('touchstart', onTouchStart, { passive: true }); container.addEventListener('touchend', onTouchEnd, { passive: true }); return function cleanup() { container.removeEventListener('touchstart', onTouchStart); container.removeEventListener('touchend', onTouchEnd); }; } // ============ Drag to Dismiss ============ /** * Add swipe-down-to-dismiss on an element (e.g., modal bottom sheet). * @param {HTMLElement} element - The draggable element * @param {Function} onDismiss - Called when dismissed * @param {number} [threshold=100] - Pixels to trigger dismiss * @returns {Function} cleanup */ function addDragToDismiss(element, onDismiss, threshold = 100) { if (!isTouchDevice) return () => {}; let startY = 0; let currentY = 0; let isDragging = false; function onTouchStart(e) { // Only from top area (drag handle region) const rect = element.getBoundingClientRect(); const touchY = e.touches[0].clientY; if (touchY - rect.top > 60) return; startY = touchY; isDragging = true; element.style.transition = 'none'; } function onTouchMove(e) { if (!isDragging) return; currentY = e.touches[0].clientY - startY; if (currentY < 0) currentY = 0; element.style.transform = `translateY(${currentY}px)`; } function onTouchEnd() { if (!isDragging) return; isDragging = false; element.style.transition = 'transform 0.2s ease'; if (currentY >= threshold) { element.style.transform = `translateY(100%)`; setTimeout(onDismiss, 200); } else { element.style.transform = 'translateY(0)'; } currentY = 0; } element.addEventListener('touchstart', onTouchStart, { passive: true }); element.addEventListener('touchmove', onTouchMove, { passive: true }); element.addEventListener('touchend', onTouchEnd, { passive: true }); element.addEventListener('touchcancel', onTouchEnd, { passive: true }); return function cleanup() { element.removeEventListener('touchstart', onTouchStart); element.removeEventListener('touchmove', onTouchMove); element.removeEventListener('touchend', onTouchEnd); element.removeEventListener('touchcancel', onTouchEnd); }; } // ============ Populate Namespace ============ BB.touch = { isTouchDevice, addLongPress, addSwipeActions, addPullToRefresh, addSwipeNavigation, addDragToDismiss, }; // Set global flag for other modules BB.state && BB.state.set ? BB.state.set('isTouchDevice', isTouchDevice) : (BB._isTouchDevice = isTouchDevice); })();