Skip to main content

max / goingson

13.6 KB · 403 lines History Blame Raw
1 /**
2 * GoingsOn - Touch Interaction Module
3 * Gesture utilities for mobile: long-press, swipe actions, pull-to-refresh,
4 * swipe navigation, drag-to-dismiss. All no-ops on non-touch devices.
5 */
6
7 (function() {
8 'use strict';
9
10 // ============ Touch Detection ============
11
12 const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
13
14 // ============ Long Press ============
15
16 /**
17 * Add long-press handler to an element (500ms hold).
18 * Touch-move cancels the timer. No-op on non-touch devices.
19 * @param {HTMLElement} element
20 * @param {Function} callback - Called with the original TouchEvent
21 * @param {number} [duration=500] - Hold duration in ms
22 * @returns {Function} cleanup - Call to remove listeners
23 */
24 function addLongPress(element, callback, duration = 500) {
25 if (!isTouchDevice) return () => {};
26
27 let timer = null;
28 let startX = 0;
29 let startY = 0;
30 const MOVE_THRESHOLD = 10;
31
32 function onTouchStart(e) {
33 const touch = e.touches[0];
34 startX = touch.clientX;
35 startY = touch.clientY;
36
37 timer = setTimeout(() => {
38 timer = null;
39 // Prevent the subsequent click/tap
40 element.addEventListener('click', preventClick, { once: true, capture: true });
41 callback(e);
42 }, duration);
43 }
44
45 function onTouchMove(e) {
46 if (!timer) return;
47 const touch = e.touches[0];
48 const dx = Math.abs(touch.clientX - startX);
49 const dy = Math.abs(touch.clientY - startY);
50 if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) {
51 clearTimeout(timer);
52 timer = null;
53 }
54 }
55
56 function onTouchEnd() {
57 if (timer) {
58 clearTimeout(timer);
59 timer = null;
60 }
61 }
62
63 function preventClick(e) {
64 e.preventDefault();
65 e.stopPropagation();
66 }
67
68 element.addEventListener('touchstart', onTouchStart, { passive: true });
69 element.addEventListener('touchmove', onTouchMove, { passive: true });
70 element.addEventListener('touchend', onTouchEnd, { passive: true });
71 element.addEventListener('touchcancel', onTouchEnd, { passive: true });
72
73 return function cleanup() {
74 clearTimeout(timer);
75 element.removeEventListener('touchstart', onTouchStart);
76 element.removeEventListener('touchmove', onTouchMove);
77 element.removeEventListener('touchend', onTouchEnd);
78 element.removeEventListener('touchcancel', onTouchEnd);
79 };
80 }
81
82 // ============ Swipe Actions ============
83
84 /**
85 * Add horizontal swipe actions to a list item element.
86 * Reveals action buttons behind the item on swipe.
87 * @param {HTMLElement} element - The list item element
88 * @param {Object} config
89 * @param {Object} [config.left] - Left swipe config { label, color, action }
90 * @param {Object} [config.right] - Right swipe config { label, color, action }
91 * @param {number} [config.threshold=80] - Pixels to trigger action
92 * @returns {Function} cleanup
93 */
94 function addSwipeActions(element, config) {
95 if (!isTouchDevice) return () => {};
96
97 const threshold = config.threshold || 80;
98 let startX = 0;
99 let startY = 0;
100 let currentX = 0;
101 let isDragging = false;
102 let isHorizontal = null;
103
104 function onTouchStart(e) {
105 const touch = e.touches[0];
106 startX = touch.clientX;
107 startY = touch.clientY;
108 currentX = 0;
109 isDragging = true;
110 isHorizontal = null;
111 element.style.transition = 'none';
112 }
113
114 function onTouchMove(e) {
115 if (!isDragging) return;
116
117 const touch = e.touches[0];
118 const dx = touch.clientX - startX;
119 const dy = touch.clientY - startY;
120
121 // Determine direction on first significant move
122 if (isHorizontal === null) {
123 if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
124 isHorizontal = Math.abs(dx) > Math.abs(dy);
125 }
126 if (!isHorizontal) return;
127 }
128
129 if (!isHorizontal) return;
130
131 e.preventDefault();
132 currentX = dx;
133
134 // Clamp to allowed directions
135 if (dx < 0 && !config.left) currentX = 0;
136 if (dx > 0 && !config.right) currentX = 0;
137
138 // Rubber-band effect past threshold
139 const maxSwipe = threshold * 1.5;
140 if (Math.abs(currentX) > threshold) {
141 const overshoot = Math.abs(currentX) - threshold;
142 const dampened = threshold + overshoot * 0.3;
143 currentX = currentX > 0 ? Math.min(dampened, maxSwipe) : Math.max(-dampened, -maxSwipe);
144 }
145
146 element.style.transform = `translateX(${currentX}px)`;
147 }
148
149 function onTouchEnd() {
150 if (!isDragging) return;
151 isDragging = false;
152
153 element.style.transition = 'transform 0.2s ease';
154
155 if (Math.abs(currentX) >= threshold) {
156 // Fire action
157 if (currentX < 0 && config.left?.action) {
158 config.left.action();
159 } else if (currentX > 0 && config.right?.action) {
160 config.right.action();
161 }
162 }
163
164 // Snap back
165 element.style.transform = 'translateX(0)';
166 currentX = 0;
167 isHorizontal = null;
168 }
169
170 element.addEventListener('touchstart', onTouchStart, { passive: true });
171 element.addEventListener('touchmove', onTouchMove, { passive: false });
172 element.addEventListener('touchend', onTouchEnd, { passive: true });
173 element.addEventListener('touchcancel', onTouchEnd, { passive: true });
174
175 return function cleanup() {
176 element.removeEventListener('touchstart', onTouchStart);
177 element.removeEventListener('touchmove', onTouchMove);
178 element.removeEventListener('touchend', onTouchEnd);
179 element.removeEventListener('touchcancel', onTouchEnd);
180 };
181 }
182
183 // ============ Pull to Refresh ============
184
185 /**
186 * Add pull-to-refresh to a scrollable container.
187 * @param {HTMLElement} container - The scrollable container
188 * @param {Function} callback - Async function to call on refresh
189 * @param {number} [pullThreshold=60] - Pixels to trigger refresh
190 * @returns {Function} cleanup
191 */
192 function addPullToRefresh(container, callback, pullThreshold = 60) {
193 if (!isTouchDevice) return () => {};
194
195 let startY = 0;
196 let pulling = false;
197 let indicator = null;
198
199 function getOrCreateIndicator() {
200 if (!indicator) {
201 indicator = document.createElement('div');
202 indicator.className = 'pull-to-refresh-indicator';
203 indicator.textContent = 'Pull to refresh...';
204 container.parentElement.insertBefore(indicator, container);
205 }
206 return indicator;
207 }
208
209 function onTouchStart(e) {
210 if (container.scrollTop > 0) return;
211 startY = e.touches[0].clientY;
212 pulling = true;
213 }
214
215 function onTouchMove(e) {
216 if (!pulling) return;
217 if (container.scrollTop > 0) {
218 pulling = false;
219 return;
220 }
221
222 const dy = e.touches[0].clientY - startY;
223 if (dy <= 0) return;
224
225 const ind = getOrCreateIndicator();
226 if (dy > pullThreshold) {
227 ind.textContent = 'Release to refresh';
228 ind.classList.add('visible');
229 } else if (dy > 10) {
230 ind.textContent = 'Pull to refresh...';
231 ind.classList.add('visible');
232 }
233 }
234
235 async function onTouchEnd(e) {
236 if (!pulling) return;
237 pulling = false;
238
239 const dy = e.changedTouches[0].clientY - startY;
240 if (indicator) {
241 indicator.classList.remove('visible');
242 }
243
244 if (dy >= pullThreshold) {
245 if (indicator) {
246 indicator.textContent = 'Refreshing...';
247 indicator.classList.add('visible');
248 }
249 try {
250 await callback();
251 } finally {
252 if (indicator) {
253 indicator.classList.remove('visible');
254 }
255 }
256 }
257 }
258
259 container.addEventListener('touchstart', onTouchStart, { passive: true });
260 container.addEventListener('touchmove', onTouchMove, { passive: true });
261 container.addEventListener('touchend', onTouchEnd, { passive: true });
262
263 return function cleanup() {
264 container.removeEventListener('touchstart', onTouchStart);
265 container.removeEventListener('touchmove', onTouchMove);
266 container.removeEventListener('touchend', onTouchEnd);
267 if (indicator) indicator.remove();
268 };
269 }
270
271 // ============ Swipe Navigation ============
272
273 /**
274 * Add horizontal swipe navigation (e.g., swipe between days in day plan).
275 * @param {HTMLElement} container
276 * @param {Object} handlers - { onLeft: Function, onRight: Function }
277 * @param {number} [threshold=50] - Minimum swipe distance
278 * @returns {Function} cleanup
279 */
280 function addSwipeNavigation(container, handlers, threshold = 50) {
281 if (!isTouchDevice) return () => {};
282
283 let startX = 0;
284 let startY = 0;
285
286 function onTouchStart(e) {
287 startX = e.touches[0].clientX;
288 startY = e.touches[0].clientY;
289 }
290
291 function onTouchEnd(e) {
292 const dx = e.changedTouches[0].clientX - startX;
293 const dy = e.changedTouches[0].clientY - startY;
294
295 // Only horizontal swipes
296 if (Math.abs(dx) < threshold || Math.abs(dx) < Math.abs(dy)) return;
297
298 if (dx < -threshold && handlers.onLeft) {
299 handlers.onLeft();
300 } else if (dx > threshold && handlers.onRight) {
301 handlers.onRight();
302 }
303 }
304
305 container.addEventListener('touchstart', onTouchStart, { passive: true });
306 container.addEventListener('touchend', onTouchEnd, { passive: true });
307
308 return function cleanup() {
309 container.removeEventListener('touchstart', onTouchStart);
310 container.removeEventListener('touchend', onTouchEnd);
311 };
312 }
313
314 // ============ Drag to Dismiss ============
315
316 /**
317 * Add swipe-down-to-dismiss on an element (e.g., modal bottom sheet).
318 * @param {HTMLElement} element - The draggable element
319 * @param {Function} onDismiss - Called when dismissed
320 * @param {number} [threshold=100] - Pixels to trigger dismiss
321 * @returns {Function} cleanup
322 */
323 function addDragToDismiss(element, onDismiss, threshold = 100) {
324 if (!isTouchDevice) return () => {};
325
326 let startY = 0;
327 let currentY = 0;
328 let isDragging = false;
329
330 function onTouchStart(e) {
331 // Only from top area (drag handle region)
332 const rect = element.getBoundingClientRect();
333 const touchY = e.touches[0].clientY;
334 if (touchY - rect.top > 60) return;
335
336 startY = touchY;
337 isDragging = true;
338 element.style.transition = 'none';
339 }
340
341 function onTouchMove(e) {
342 if (!isDragging) return;
343
344 currentY = e.touches[0].clientY - startY;
345 if (currentY < 0) currentY = 0;
346
347 element.style.transform = `translateY(${currentY}px)`;
348 }
349
350 function onTouchEnd() {
351 if (!isDragging) return;
352 isDragging = false;
353
354 element.style.transition = 'transform 0.2s ease';
355
356 if (currentY >= threshold) {
357 element.style.transform = `translateY(100%)`;
358 setTimeout(onDismiss, 200);
359 } else {
360 element.style.transform = 'translateY(0)';
361 }
362
363 currentY = 0;
364 }
365
366 element.addEventListener('touchstart', onTouchStart, { passive: true });
367 element.addEventListener('touchmove', onTouchMove, { passive: true });
368 element.addEventListener('touchend', onTouchEnd, { passive: true });
369 element.addEventListener('touchcancel', onTouchEnd, { passive: true });
370
371 return function cleanup() {
372 element.removeEventListener('touchstart', onTouchStart);
373 element.removeEventListener('touchmove', onTouchMove);
374 element.removeEventListener('touchend', onTouchEnd);
375 element.removeEventListener('touchcancel', onTouchEnd);
376 };
377 }
378
379 // ============ Populate Namespace ============
380
381 GoingsOn.touch = {
382 isTouchDevice,
383 addLongPress,
384 addSwipeActions,
385 addPullToRefresh,
386 addSwipeNavigation,
387 addDragToDismiss,
388 };
389
390 // Set global flag for other modules
391 GoingsOn.state && GoingsOn.state.set
392 ? GoingsOn.state.set('isTouchDevice', isTouchDevice)
393 : (GoingsOn._isTouchDevice = isTouchDevice);
394
395 // Body class so CSS can target touch devices (e.g. hide hover-only affordances)
396 if (isTouchDevice) {
397 const apply = () => document.body && document.body.classList.add('is-touch');
398 if (document.body) apply();
399 else document.addEventListener('DOMContentLoaded', apply, { once: true });
400 }
401
402 })();
403