Skip to main content

max / balanced_breakfast

11.5 KB · 331 lines History Blame Raw
1 /**
2 * Balanced Breakfast - Mobile Interaction Wiring
3 * Connects touch.js gesture utilities to list views using event delegation.
4 * Handles swipe-to-action, pull-to-refresh, and long-press selection.
5 * All no-ops on non-touch devices.
6 */
7
8 (function() {
9 'use strict';
10
11 // F3 justified touch branch: this module wires gesture handlers
12 // (swipe, pull-to-refresh, long-press) that have no CSS equivalent.
13 // No-op on non-touch devices avoids loading listener overhead.
14 if (!BB.touch?.isTouchDevice) return;
15
16 // ============ Swipe Delegation ============
17
18 /**
19 * Add delegated swipe handling to a container. Tracks the swiped row
20 * internally so DOM recycling doesn't cause issues.
21 *
22 * @param {HTMLElement} container - Scrollable container (e.g. #items-list)
23 * @param {Object} config
24 * @param {string} config.rowSelector - CSS selector for swipeable rows
25 * @param {Function} config.getActions - (rowEl) => { left?: { action }, right?: { action } } | null
26 * @param {number} [config.threshold=80] - Pixels to trigger action
27 */
28 function addSwipeDelegate(container, config) {
29 const threshold = config.threshold || 80;
30 let activeRow = null;
31 let startX = 0;
32 let startY = 0;
33 let currentX = 0;
34 let isDragging = false;
35 let isHorizontal = null;
36 let actions = null;
37
38 container.addEventListener('touchstart', function(e) {
39 const row = e.target.closest(config.rowSelector);
40 if (!row) return;
41
42 actions = config.getActions(row);
43 if (!actions) return;
44
45 activeRow = row;
46 const touch = e.touches[0];
47 startX = touch.clientX;
48 startY = touch.clientY;
49 currentX = 0;
50 isDragging = true;
51 isHorizontal = null;
52 activeRow.style.transition = 'none';
53 }, { passive: true });
54
55 container.addEventListener('touchmove', function(e) {
56 if (!isDragging || !activeRow) return;
57
58 const touch = e.touches[0];
59 const dx = touch.clientX - startX;
60 const dy = touch.clientY - startY;
61
62 // Determine direction on first significant move
63 if (isHorizontal === null) {
64 if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
65 isHorizontal = Math.abs(dx) > Math.abs(dy);
66 }
67 if (!isHorizontal) return;
68 }
69 if (!isHorizontal) return;
70
71 e.preventDefault();
72 currentX = dx;
73
74 // Clamp to allowed directions
75 if (dx < 0 && !actions.left) currentX = 0;
76 if (dx > 0 && !actions.right) currentX = 0;
77
78 // Rubber-band past threshold
79 const maxSwipe = threshold * 1.5;
80 if (Math.abs(currentX) > threshold) {
81 const overshoot = Math.abs(currentX) - threshold;
82 const dampened = threshold + overshoot * 0.3;
83 currentX = currentX > 0 ? Math.min(dampened, maxSwipe) : Math.max(-dampened, -maxSwipe);
84 }
85
86 activeRow.style.transform = `translateX(${currentX}px)`;
87 }, { passive: false });
88
89 function onEnd() {
90 if (!isDragging || !activeRow) return;
91 isDragging = false;
92
93 activeRow.style.transition = 'transform 0.2s ease';
94
95 if (Math.abs(currentX) >= threshold) {
96 if (currentX < 0 && actions?.left?.action) {
97 actions.left.action();
98 } else if (currentX > 0 && actions?.right?.action) {
99 actions.right.action();
100 }
101 }
102
103 activeRow.style.transform = 'translateX(0)';
104 activeRow = null;
105 actions = null;
106 currentX = 0;
107 isHorizontal = null;
108 }
109
110 container.addEventListener('touchend', onEnd, { passive: true });
111 container.addEventListener('touchcancel', onEnd, { passive: true });
112 }
113
114 // ============ Long-Press Delegation ============
115
116 /**
117 * Add delegated long-press handling to a container.
118 * @param {HTMLElement} container
119 * @param {string} rowSelector - CSS selector for pressable rows
120 * @param {Function} onLongPress - (rowEl) => void
121 */
122 function addLongPressDelegate(container, rowSelector, onLongPress) {
123 let timer = null;
124 let startX = 0;
125 let startY = 0;
126 let activeRow = null;
127 const MOVE_THRESHOLD = 10;
128 const DURATION = 500;
129
130 container.addEventListener('touchstart', function(e) {
131 const row = e.target.closest(rowSelector);
132 if (!row) return;
133 activeRow = row;
134
135 const touch = e.touches[0];
136 startX = touch.clientX;
137 startY = touch.clientY;
138
139 timer = setTimeout(() => {
140 timer = null;
141 // Prevent subsequent click
142 activeRow.addEventListener('click', function prevent(ev) {
143 ev.preventDefault();
144 ev.stopPropagation();
145 }, { once: true, capture: true });
146 onLongPress(activeRow);
147 }, DURATION);
148 }, { passive: true });
149
150 container.addEventListener('touchmove', function(e) {
151 if (!timer) return;
152 const touch = e.touches[0];
153 if (Math.abs(touch.clientX - startX) > MOVE_THRESHOLD ||
154 Math.abs(touch.clientY - startY) > MOVE_THRESHOLD) {
155 clearTimeout(timer);
156 timer = null;
157 }
158 }, { passive: true });
159
160 function cancel() {
161 if (timer) {
162 clearTimeout(timer);
163 timer = null;
164 }
165 activeRow = null;
166 }
167
168 container.addEventListener('touchend', cancel, { passive: true });
169 container.addEventListener('touchcancel', cancel, { passive: true });
170 }
171
172 // ============ Wire Everything on Init ============
173
174 function init() {
175 wireItemSwipe();
176 wirePullToRefresh();
177 wireLongPress();
178 wireDetailSwipe();
179 wireModalDismiss();
180 wireSourceLongPress();
181 }
182
183 // --- Items: swipe right = star, swipe left = toggle read ---
184
185 function wireItemSwipe() {
186 const container = document.getElementById('items-list');
187 if (!container) return;
188
189 addSwipeDelegate(container, {
190 rowSelector: '.item',
191 getActions: (row) => {
192 const id = row.dataset.id;
193 if (!id) return null;
194 const item = BB.state.items.find(i => i.id === id);
195 if (!item) return null;
196 return {
197 right: { action: () => BB.items.toggleStar(id, item.isStarred) },
198 left: { action: () => BB.items.toggleRead(id, item.isRead) },
199 };
200 },
201 });
202 }
203
204 // --- Pull-to-refresh on items list and sources list ---
205
206 function wirePullToRefresh() {
207 const itemsList = document.getElementById('items-list');
208 const sourcesList = document.getElementById('sources-list');
209 if (itemsList) BB.touch.addPullToRefresh(itemsList, () => BB.feeds.refresh());
210 if (sourcesList) BB.touch.addPullToRefresh(sourcesList, () => BB.feeds.refresh());
211 }
212
213 // --- Long-press on item → action sheet ---
214
215 function wireLongPress() {
216 const container = document.getElementById('items-list');
217 if (!container) return;
218
219 addLongPressDelegate(container, '.item', (row) => {
220 const id = row.dataset.id;
221 const item = BB.state.items.find(i => i.id === id);
222 if (!item) return;
223 showItemActionSheet(item);
224 });
225 }
226
227 // --- Detail view: swipe right → back to items ---
228
229 function wireDetailSwipe() {
230 const detail = document.getElementById('detail-panel');
231 if (!detail) return;
232
233 BB.touch.addSwipeNavigation(detail, {
234 onRight: () => BB.detail.close(),
235 });
236 }
237
238 // --- Modal: swipe-down-to-dismiss ---
239
240 function wireModalDismiss() {
241 const modal = document.querySelector('.modal-content');
242 if (!modal) return;
243
244 BB.touch.addDragToDismiss(modal, () => BB.ui.closeModal());
245 }
246
247 // --- Sources: long-press → action sheet ---
248
249 function wireSourceLongPress() {
250 const container = document.getElementById('sources-list');
251 if (!container) return;
252 addLongPressDelegate(container, '.source-item', (row) => {
253 const sourceId = row.dataset.source;
254 if (!sourceId) return; // Skip "All" row
255 const source = BB.state.sources.find(s => s.id === sourceId);
256 if (!source) return;
257 showSourceActionSheet(source);
258 });
259 }
260
261 /**
262 * Show a mobile action sheet for a source (edit, tags, delete).
263 * @param {Object} source - Source object with id and name.
264 */
265 function showSourceActionSheet(source) {
266 const body = document.getElementById('modal-body');
267 const title = document.getElementById('modal-title');
268 title.textContent = source.name;
269 body.innerHTML = '';
270 const actions = [
271 { label: 'Edit Feed', action: () => BB.sources.editFeed(source) },
272 { label: 'Edit Tags', action: () => BB.sources.editTags(source) },
273 { label: 'Delete', action: () => BB.sources.deleteFeed(source), danger: true },
274 ];
275 // F4 (2026-06-02): inline styles + JS-set color retired in favor
276 // of .btn-stacked utility and .btn-danger modifier (charter rule:
277 // destructive intent expressed via class, not inline color).
278 for (const a of actions) {
279 const btn = document.createElement('button');
280 btn.className = 'btn btn-stacked' + (a.danger ? ' btn-danger' : '');
281 btn.textContent = a.label;
282 btn.addEventListener('click', () => { BB.ui.closeModal(); a.action(); });
283 body.appendChild(btn);
284 }
285 BB.ui.openModal();
286 }
287
288 // --- Action sheet for long-press on item ---
289
290 /**
291 * Show a mobile action sheet for an item (star, read, open).
292 * @param {Object} item - Item object with id, isStarred, isRead, url.
293 */
294 function showItemActionSheet(item) {
295 const body = document.getElementById('modal-body');
296 const title = document.getElementById('modal-title');
297 title.textContent = 'Actions';
298 body.innerHTML = '';
299
300 const actions = [
301 { label: item.isStarred ? 'Unstar' : 'Star', action: () => BB.items.toggleStar(item.id, item.isStarred) },
302 { label: item.isRead ? 'Mark Unread' : 'Mark Read', action: () => BB.items.toggleRead(item.id, item.isRead) },
303 ];
304 if (item.url) {
305 actions.push({ label: 'Open in Browser', action: () => BB.detail.openUrl() });
306 }
307
308 // F4 (2026-06-02): .btn-stacked utility retires the inline
309 // style.display/width/marginBottom triple.
310 for (const a of actions) {
311 const btn = document.createElement('button');
312 btn.className = 'btn btn-stacked';
313 btn.textContent = a.label;
314 btn.addEventListener('click', () => { BB.ui.closeModal(); a.action(); });
315 body.appendChild(btn);
316 }
317
318 BB.ui.openModal();
319 }
320
321 // ============ Populate Namespace ============
322
323 BB.mobile = { init };
324
325 if (document.readyState === 'loading') {
326 document.addEventListener('DOMContentLoaded', init);
327 } else {
328 setTimeout(init, 0);
329 }
330 })();
331