/** * Balanced Breakfast - Mobile Interaction Wiring * Connects touch.js gesture utilities to list views using event delegation. * Handles swipe-to-action, pull-to-refresh, and long-press selection. * All no-ops on non-touch devices. */ (function() { 'use strict'; // F3 justified touch branch: this module wires gesture handlers // (swipe, pull-to-refresh, long-press) that have no CSS equivalent. // No-op on non-touch devices avoids loading listener overhead. if (!BB.touch?.isTouchDevice) return; // ============ Swipe Delegation ============ /** * Add delegated swipe handling to a container. Tracks the swiped row * internally so DOM recycling doesn't cause issues. * * @param {HTMLElement} container - Scrollable container (e.g. #items-list) * @param {Object} config * @param {string} config.rowSelector - CSS selector for swipeable rows * @param {Function} config.getActions - (rowEl) => { left?: { action }, right?: { action } } | null * @param {number} [config.threshold=80] - Pixels to trigger action */ function addSwipeDelegate(container, config) { const threshold = config.threshold || 80; let activeRow = null; let startX = 0; let startY = 0; let currentX = 0; let isDragging = false; let isHorizontal = null; let actions = null; container.addEventListener('touchstart', function(e) { const row = e.target.closest(config.rowSelector); if (!row) return; actions = config.getActions(row); if (!actions) return; activeRow = row; const touch = e.touches[0]; startX = touch.clientX; startY = touch.clientY; currentX = 0; isDragging = true; isHorizontal = null; activeRow.style.transition = 'none'; }, { passive: true }); container.addEventListener('touchmove', function(e) { if (!isDragging || !activeRow) 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 && !actions.left) currentX = 0; if (dx > 0 && !actions.right) currentX = 0; // Rubber-band 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); } activeRow.style.transform = `translateX(${currentX}px)`; }, { passive: false }); function onEnd() { if (!isDragging || !activeRow) return; isDragging = false; activeRow.style.transition = 'transform 0.2s ease'; if (Math.abs(currentX) >= threshold) { if (currentX < 0 && actions?.left?.action) { actions.left.action(); } else if (currentX > 0 && actions?.right?.action) { actions.right.action(); } } activeRow.style.transform = 'translateX(0)'; activeRow = null; actions = null; currentX = 0; isHorizontal = null; } container.addEventListener('touchend', onEnd, { passive: true }); container.addEventListener('touchcancel', onEnd, { passive: true }); } // ============ Long-Press Delegation ============ /** * Add delegated long-press handling to a container. * @param {HTMLElement} container * @param {string} rowSelector - CSS selector for pressable rows * @param {Function} onLongPress - (rowEl) => void */ function addLongPressDelegate(container, rowSelector, onLongPress) { let timer = null; let startX = 0; let startY = 0; let activeRow = null; const MOVE_THRESHOLD = 10; const DURATION = 500; container.addEventListener('touchstart', function(e) { const row = e.target.closest(rowSelector); if (!row) return; activeRow = row; const touch = e.touches[0]; startX = touch.clientX; startY = touch.clientY; timer = setTimeout(() => { timer = null; // Prevent subsequent click activeRow.addEventListener('click', function prevent(ev) { ev.preventDefault(); ev.stopPropagation(); }, { once: true, capture: true }); onLongPress(activeRow); }, DURATION); }, { passive: true }); container.addEventListener('touchmove', function(e) { if (!timer) return; const touch = e.touches[0]; if (Math.abs(touch.clientX - startX) > MOVE_THRESHOLD || Math.abs(touch.clientY - startY) > MOVE_THRESHOLD) { clearTimeout(timer); timer = null; } }, { passive: true }); function cancel() { if (timer) { clearTimeout(timer); timer = null; } activeRow = null; } container.addEventListener('touchend', cancel, { passive: true }); container.addEventListener('touchcancel', cancel, { passive: true }); } // ============ Wire Everything on Init ============ function init() { wireItemSwipe(); wirePullToRefresh(); wireLongPress(); wireDetailSwipe(); wireModalDismiss(); wireSourceLongPress(); } // --- Items: swipe right = star, swipe left = toggle read --- function wireItemSwipe() { const container = document.getElementById('items-list'); if (!container) return; addSwipeDelegate(container, { rowSelector: '.item', getActions: (row) => { const id = row.dataset.id; if (!id) return null; const item = BB.state.items.find(i => i.id === id); if (!item) return null; return { right: { action: () => BB.items.toggleStar(id, item.isStarred) }, left: { action: () => BB.items.toggleRead(id, item.isRead) }, }; }, }); } // --- Pull-to-refresh on items list and sources list --- function wirePullToRefresh() { const itemsList = document.getElementById('items-list'); const sourcesList = document.getElementById('sources-list'); if (itemsList) BB.touch.addPullToRefresh(itemsList, () => BB.feeds.refresh()); if (sourcesList) BB.touch.addPullToRefresh(sourcesList, () => BB.feeds.refresh()); } // --- Long-press on item → action sheet --- function wireLongPress() { const container = document.getElementById('items-list'); if (!container) return; addLongPressDelegate(container, '.item', (row) => { const id = row.dataset.id; const item = BB.state.items.find(i => i.id === id); if (!item) return; showItemActionSheet(item); }); } // --- Detail view: swipe right → back to items --- function wireDetailSwipe() { const detail = document.getElementById('detail-panel'); if (!detail) return; BB.touch.addSwipeNavigation(detail, { onRight: () => BB.detail.close(), }); } // --- Modal: swipe-down-to-dismiss --- function wireModalDismiss() { const modal = document.querySelector('.modal-content'); if (!modal) return; BB.touch.addDragToDismiss(modal, () => BB.ui.closeModal()); } // --- Sources: long-press → action sheet --- function wireSourceLongPress() { const container = document.getElementById('sources-list'); if (!container) return; addLongPressDelegate(container, '.source-item', (row) => { const sourceId = row.dataset.source; if (!sourceId) return; // Skip "All" row const source = BB.state.sources.find(s => s.id === sourceId); if (!source) return; showSourceActionSheet(source); }); } /** * Show a mobile action sheet for a source (edit, tags, delete). * @param {Object} source - Source object with id and name. */ function showSourceActionSheet(source) { const body = document.getElementById('modal-body'); const title = document.getElementById('modal-title'); title.textContent = source.name; body.innerHTML = ''; const actions = [ { label: 'Edit Feed', action: () => BB.sources.editFeed(source) }, { label: 'Edit Tags', action: () => BB.sources.editTags(source) }, { label: 'Delete', action: () => BB.sources.deleteFeed(source), danger: true }, ]; // F4 (2026-06-02): inline styles + JS-set color retired in favor // of .btn-stacked utility and .btn-danger modifier (charter rule: // destructive intent expressed via class, not inline color). for (const a of actions) { const btn = document.createElement('button'); btn.className = 'btn btn-stacked' + (a.danger ? ' btn-danger' : ''); btn.textContent = a.label; btn.addEventListener('click', () => { BB.ui.closeModal(); a.action(); }); body.appendChild(btn); } BB.ui.openModal(); } // --- Action sheet for long-press on item --- /** * Show a mobile action sheet for an item (star, read, open). * @param {Object} item - Item object with id, isStarred, isRead, url. */ function showItemActionSheet(item) { const body = document.getElementById('modal-body'); const title = document.getElementById('modal-title'); title.textContent = 'Actions'; body.innerHTML = ''; const actions = [ { label: item.isStarred ? 'Unstar' : 'Star', action: () => BB.items.toggleStar(item.id, item.isStarred) }, { label: item.isRead ? 'Mark Unread' : 'Mark Read', action: () => BB.items.toggleRead(item.id, item.isRead) }, ]; if (item.url) { actions.push({ label: 'Open in Browser', action: () => BB.detail.openUrl() }); } // F4 (2026-06-02): .btn-stacked utility retires the inline // style.display/width/marginBottom triple. for (const a of actions) { const btn = document.createElement('button'); btn.className = 'btn btn-stacked'; btn.textContent = a.label; btn.addEventListener('click', () => { BB.ui.closeModal(); a.action(); }); body.appendChild(btn); } BB.ui.openModal(); } // ============ Populate Namespace ============ BB.mobile = { init }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { setTimeout(init, 0); } })();