Skip to main content

max / goingson

19.7 KB · 619 lines History Blame Raw
1 /**
2 * GoingsOn - Components Modal Module
3 * Modal dialog, toast notifications, undo toasts, confirmation dialogs
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9
10 // ============ Modal ============
11
12 /**
13 * Open a modal dialog
14 * @param {string} title - Modal title
15 * @param {string} content - Modal HTML content
16 * @param {Object} [options] - Optional settings
17 * @param {boolean} [options.large] - Use large modal size (nearly full screen)
18 */
19 function openModal(title, content, options = {}) {
20 const overlay = document.getElementById('modal-overlay');
21 const container = overlay.querySelector('.modal-container');
22 const titleEl = document.getElementById('modal-title');
23 const contentEl = document.getElementById('modal-content');
24
25 titleEl.textContent = title;
26 contentEl.innerHTML = content;
27 overlay.classList.remove('hidden');
28
29 // Handle large modal option
30 if (options.large) {
31 container.classList.add('modal-large');
32 } else {
33 container.classList.remove('modal-large');
34 }
35
36 // Set ARIA attributes
37 overlay.setAttribute('aria-hidden', 'false');
38
39 // Focus first focusable element
40 setTimeout(() => {
41 const firstInput = contentEl.querySelector('input, textarea, select, button');
42 if (firstInput) firstInput.focus();
43 }, 100);
44
45 // Trap focus inside modal
46 trapFocus(overlay);
47 }
48
49 /**
50 * Close the modal dialog with animation
51 */
52 function closeModal() {
53 const overlay = document.getElementById('modal-overlay');
54
55 // Add closing class to trigger exit animation
56 overlay.classList.add('closing');
57
58 // Wait for animation to complete before hiding
59 setTimeout(() => {
60 overlay.classList.add('hidden');
61 overlay.classList.remove('closing');
62 overlay.setAttribute('aria-hidden', 'true');
63 releaseFocusTrap();
64 }, 150); // Match the animation duration
65 }
66
67 // Focus trap for modal accessibility
68 let focusTrapElement = null;
69 let previouslyFocusedElement = null;
70
71 function trapFocus(element) {
72 previouslyFocusedElement = document.activeElement;
73 focusTrapElement = element;
74
75 element.addEventListener('keydown', handleFocusTrap);
76 }
77
78 function releaseFocusTrap() {
79 if (focusTrapElement) {
80 focusTrapElement.removeEventListener('keydown', handleFocusTrap);
81 focusTrapElement = null;
82 }
83 if (previouslyFocusedElement) {
84 previouslyFocusedElement.focus();
85 previouslyFocusedElement = null;
86 }
87 }
88
89 function handleFocusTrap(e) {
90 if (e.key !== 'Tab') return;
91
92 const focusable = focusTrapElement.querySelectorAll(
93 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
94 );
95 const first = focusable[0];
96 const last = focusable[focusable.length - 1];
97
98 if (e.shiftKey && document.activeElement === first) {
99 e.preventDefault();
100 last.focus();
101 } else if (!e.shiftKey && document.activeElement === last) {
102 e.preventDefault();
103 first.focus();
104 }
105 }
106
107 // Close modal on Escape key
108 document.addEventListener('keydown', (e) => {
109 if (e.key === 'Escape') {
110 const overlay = document.getElementById('modal-overlay');
111 if (!overlay.classList.contains('hidden')) {
112 closeModal();
113 }
114 }
115 });
116
117 // Close modal on overlay click
118 document.getElementById('modal-overlay')?.addEventListener('click', (e) => {
119 if (e.target.id === 'modal-overlay') closeModal();
120 });
121
122 // ============ Toast Notifications ============
123
124 /**
125 * Show a toast notification
126 * @param {string} message - Message to display
127 * @param {'info'|'success'|'error'} type - Toast type
128 */
129 function showToast(message, type = 'info', opts = {}) {
130 const toast = document.createElement('div');
131 toast.className = `toast toast-${type}`;
132 toast.setAttribute('role', 'alert');
133 toast.setAttribute('aria-live', 'assertive');
134
135 const msgSpan = document.createElement('span');
136 msgSpan.textContent = message;
137 toast.appendChild(msgSpan);
138
139 if (opts.action) {
140 const btn = document.createElement('button');
141 btn.className = 'toast-action';
142 btn.textContent = opts.action.label;
143 btn.onclick = () => { toast.remove(); opts.action.fn(); };
144 toast.appendChild(btn);
145 }
146
147 document.body.appendChild(toast);
148
149 const duration = opts.duration || (type === 'error' ? 6000 : 4000);
150 setTimeout(() => {
151 toast.classList.add('toast-leaving');
152 setTimeout(() => toast.remove(), 300);
153 }, duration);
154 }
155
156 // ============ Undo Toast ============
157
158 /**
159 * Pending undo operations, keyed by a unique ID.
160 * Each entry contains: { timer, onConfirm, onUndo, element }
161 */
162 const pendingUndos = new Map();
163
164 /**
165 * Show a toast with an undo button. The action is delayed until timeout.
166 * @param {string} message - Message to display (e.g., "Task deleted")
167 * @param {Object} options - Options
168 * @param {Function} options.onConfirm - Called when timeout expires (perform actual delete)
169 * @param {Function} options.onUndo - Called when user clicks undo (restore item)
170 * @param {number} options.timeout - Undo window in milliseconds (default: 15000)
171 * @returns {string} - Undo ID (can be used to cancel programmatically)
172 */
173 function showUndoToast(message, { onConfirm, onUndo, timeout = 15000 }) {
174 const undoId = `undo-${Date.now()}-${Math.random().toString(36).slice(2)}`;
175
176 const toast = document.createElement('div');
177 toast.className = 'toast toast-undo';
178 toast.setAttribute('role', 'alert');
179 toast.setAttribute('aria-live', 'polite');
180
181 toast.innerHTML = `
182 <span class="undo-message">${esc(message)}</span>
183 <button class="btn btn-sm btn-primary" onclick="GoingsOn.ui.executeUndo('${undoId}')">Undo</button>
184 <span class="undo-countdown"></span>
185 `;
186
187 document.body.appendChild(toast);
188
189 // Countdown display
190 const countdownEl = toast.querySelector('.undo-countdown');
191 let remaining = Math.ceil(timeout / 1000);
192 countdownEl.textContent = `(${remaining}s)`;
193
194 const countdownInterval = setInterval(() => {
195 remaining--;
196 if (remaining > 0) {
197 countdownEl.textContent = `(${remaining}s)`;
198 }
199 }, 1000);
200
201 // Set timer for confirmation
202 const timer = setTimeout(() => {
203 clearInterval(countdownInterval);
204 pendingUndos.delete(undoId);
205 removeUndoToast(toast);
206 if (onConfirm) {
207 onConfirm();
208 }
209 }, timeout);
210
211 pendingUndos.set(undoId, { timer, countdownInterval, onConfirm, onUndo, element: toast });
212
213 return undoId;
214 }
215
216 /**
217 * Run a bulk operation through the undo system.
218 *
219 * Pattern: optimistically update local state immediately so the UI reflects
220 * the action; capture whatever revert needs; if the user clicks Undo within
221 * the timeout, restore state and skip the API call; otherwise commit by
222 * calling the API for every id. On commit failure, revert + surface error.
223 *
224 * Charter rule: every bulk operation must wrap its API call this way
225 * (see docs/design-system.md § Bulk operations always undoable).
226 *
227 * @param {Object} cfg
228 * @param {Array<string>|Set<string>} cfg.ids - record ids to act on
229 * @param {string} cfg.label - past-tense verb for the toast ("Completed", "Deleted", "Snoozed")
230 * @param {string} cfg.itemType - singular noun ("task", "email", "contact")
231 * @param {Function} cfg.apply - sync (ids) => preState. Optimistic UI update; return whatever revert needs.
232 * @param {Function} cfg.revert - sync (preState) => void. Restore the optimistic change on Undo or commit failure.
233 * @param {Function} cfg.commit - async (ids) => any. Run the API after the undo window expires.
234 * @param {string} [cfg.errorMessage] - prefix on commit failure (default: "Action failed")
235 * @param {number} [cfg.timeout=10000] - undo window in ms
236 * @returns {string|null} - undo id, or null if ids was empty
237 */
238 function bulkActionWithUndo(cfg) {
239 const {
240 ids,
241 label,
242 itemType,
243 apply,
244 revert,
245 commit,
246 errorMessage = 'Action failed',
247 timeout = 10000,
248 } = cfg;
249
250 const idList = Array.isArray(ids) ? ids : Array.from(ids || []);
251 const count = idList.length;
252 if (count === 0) return null;
253
254 const noun = count === 1 ? itemType : `${itemType}s`;
255 const message = `${label} ${count} ${noun}`;
256
257 // Optimistic update — capture state needed for revert.
258 let preState;
259 try {
260 preState = apply(idList);
261 } catch (err) {
262 showToast(`${errorMessage}: ${err?.message || err}`, 'error');
263 return null;
264 }
265
266 return showUndoToast(message, {
267 onConfirm: async () => {
268 try {
269 await commit(idList);
270 } catch (err) {
271 try { revert(preState); } catch (_) { /* best effort */ }
272 showToast(`${errorMessage}: ${GoingsOn.utils.getErrorMessage(err)}`, 'error');
273 }
274 },
275 onUndo: () => {
276 try { revert(preState); } catch (_) { /* best effort */ }
277 },
278 timeout,
279 });
280 }
281
282 /**
283 * Execute undo for a pending operation.
284 */
285 function executeUndo(undoId) {
286 const pending = pendingUndos.get(undoId);
287 if (!pending) return;
288
289 clearTimeout(pending.timer);
290 clearInterval(pending.countdownInterval);
291 pendingUndos.delete(undoId);
292 removeUndoToast(pending.element);
293
294 if (pending.onUndo) {
295 pending.onUndo();
296 }
297
298 showToast('Action undone', 'success');
299 }
300
301 /**
302 * Cancel a pending undo without executing either callback.
303 */
304 function cancelUndo(undoId) {
305 const pending = pendingUndos.get(undoId);
306 if (!pending) return;
307
308 clearTimeout(pending.timer);
309 clearInterval(pending.countdownInterval);
310 pendingUndos.delete(undoId);
311 removeUndoToast(pending.element);
312 }
313
314 /**
315 * Remove an undo toast with animation.
316 */
317 function removeUndoToast(toast) {
318 toast.classList.add('toast-leaving');
319 setTimeout(() => toast.remove(), 300);
320 }
321
322 // ============ Confirmation Dialog ============
323
324 /**
325 * Show a custom confirmation dialog
326 * @param {string} title - Dialog title
327 * @param {string} message - Dialog message
328 * @param {Object} options - Optional settings
329 * @param {string} options.confirmText - Text for confirm button (default: "Confirm")
330 * @param {string} options.cancelText - Text for cancel button (default: "Cancel")
331 * @param {boolean} options.danger - If true, confirm button is styled as danger
332 * @returns {Promise<boolean>} - Resolves to true if confirmed, false if cancelled
333 */
334 function showConfirmDialog(title, message, options = {}) {
335 return new Promise((resolve) => {
336 const {
337 confirmText = 'Confirm',
338 cancelText = 'Cancel',
339 danger = false
340 } = options;
341
342 const confirmBtnClass = danger ? 'btn btn-danger' : 'btn btn-primary';
343
344 const content = `
345 <div class="confirm-message-wrap">
346 <p class="confirm-message">${esc(message)}</p>
347 </div>
348 <div class="form-actions">
349 <button type="button" class="btn btn-secondary" id="confirm-dialog-cancel">${esc(cancelText)}</button>
350 <button type="button" class="${confirmBtnClass}" id="confirm-dialog-confirm">${esc(confirmText)}</button>
351 </div>
352 `;
353
354 openModal(title, content);
355
356 // Attach event handlers after modal is opened
357 setTimeout(() => {
358 const confirmBtn = document.getElementById('confirm-dialog-confirm');
359 const cancelBtn = document.getElementById('confirm-dialog-cancel');
360
361 if (confirmBtn) {
362 confirmBtn.onclick = () => {
363 closeModal();
364 resolve(true);
365 };
366 }
367
368 if (cancelBtn) {
369 cancelBtn.onclick = () => {
370 closeModal();
371 resolve(false);
372 };
373 }
374 }, 50);
375 });
376 }
377
378 // ============ Prompt Dialog ============
379
380 /**
381 * Show a prompt dialog with a text input. Replaces native window.prompt().
382 * @param {string} title - Dialog title
383 * @param {string} message - Prompt message (rendered above the input)
384 * @param {Object} options - Optional settings
385 * @param {string} options.defaultValue - Initial input value
386 * @param {string} options.placeholder - Input placeholder
387 * @param {string} options.confirmText - Text for confirm button (default: "OK")
388 * @param {string} options.cancelText - Text for cancel button (default: "Cancel")
389 * @param {Function} options.validate - Optional sync validator; return error string to block, null to allow
390 * @returns {Promise<string|null>} - Resolves to the entered value (trimmed), or null if cancelled
391 */
392 function showPromptDialog(title, message, options = {}) {
393 return new Promise((resolve) => {
394 const {
395 defaultValue = '',
396 placeholder = '',
397 confirmText = 'OK',
398 cancelText = 'Cancel',
399 validate = null,
400 } = options;
401
402 const inputId = 'prompt-dialog-input';
403 const errorId = 'prompt-dialog-error';
404
405 const content = `
406 <div class="confirm-message-wrap">
407 <p class="confirm-message">${esc(message)}</p>
408 </div>
409 <div class="form-group">
410 <input type="text" class="form-input" id="${inputId}"
411 value="${GoingsOn.utils.escapeAttr(defaultValue)}"
412 placeholder="${GoingsOn.utils.escapeAttr(placeholder)}"
413 autofocus>
414 <div id="${errorId}" class="form-error"></div>
415 </div>
416 <div class="form-actions">
417 <button type="button" class="btn btn-secondary" id="prompt-dialog-cancel">${esc(cancelText)}</button>
418 <button type="button" class="btn btn-primary" id="prompt-dialog-confirm">${esc(confirmText)}</button>
419 </div>
420 `;
421
422 openModal(title, content);
423
424 setTimeout(() => {
425 const input = document.getElementById(inputId);
426 const errorEl = document.getElementById(errorId);
427 const confirmBtn = document.getElementById('prompt-dialog-confirm');
428 const cancelBtn = document.getElementById('prompt-dialog-cancel');
429
430 const submit = () => {
431 const value = (input?.value || '').trim();
432 if (validate) {
433 const err = validate(value);
434 if (err) {
435 if (errorEl) {
436 errorEl.textContent = err;
437 errorEl.classList.add('visible');
438 }
439 return;
440 }
441 }
442 closeModal();
443 resolve(value);
444 };
445
446 if (confirmBtn) confirmBtn.onclick = submit;
447 if (cancelBtn) cancelBtn.onclick = () => { closeModal(); resolve(null); };
448 if (input) {
449 input.addEventListener('keydown', (e) => {
450 if (e.key === 'Enter') { e.preventDefault(); submit(); }
451 });
452 input.focus();
453 input.select();
454 }
455 }, 50);
456 });
457 }
458
459 // ============ Confirm Delete Helper ============
460
461 /**
462 * Show a confirmation dialog for deleting items.
463 * @param {string} itemType - Type of item ('task', 'email', 'project', 'event', etc.)
464 * @param {number} count - Number of items to delete (default: 1)
465 * @returns {Promise<boolean>} - Resolves to true if confirmed
466 */
467 async function confirmDelete(itemType, count = 1) {
468 const plural = count > 1;
469 const itemName = plural ? `${count} ${itemType}s` : `this ${itemType}`;
470 const title = plural ? `Delete ${count} ${itemType}s` : `Delete ${itemType.charAt(0).toUpperCase() + itemType.slice(1)}`;
471
472 return showConfirmDialog(
473 title,
474 `Are you sure you want to delete ${itemName}? This cannot be undone.`,
475 { confirmText: 'Delete', danger: true }
476 );
477 }
478
479 // ============ API Call Wrapper ============
480
481 /**
482 * Wrapper for API calls with standardized error handling, toasts, and modal closing.
483 * @param {Promise} promise - The API promise to execute
484 * @param {Object} options - Options for handling the call
485 * @param {string} options.successMessage - Toast message on success
486 * @param {string} options.errorMessage - Base error message (actual error appended)
487 * @param {Function} options.onSuccess - Callback on success (receives result)
488 * @param {boolean} options.closeModal - Whether to close modal on success (default: true)
489 * @param {Function|Function[]} options.reload - Function(s) to call to reload data
490 * @returns {Promise<*>} - The result of the API call, or null on error
491 */
492 async function apiCall(promise, options = {}) {
493 const {
494 successMessage,
495 errorMessage = 'Operation failed',
496 onSuccess,
497 closeModal: shouldCloseModal = true,
498 reload,
499 button,
500 retry,
501 } = options;
502
503 // Set button loading state
504 if (button) setButtonLoading(button, true);
505
506 try {
507 const result = await promise;
508
509 if (button) setButtonLoading(button, false);
510
511 if (successMessage) {
512 showToast(successMessage, 'success');
513 }
514
515 if (shouldCloseModal) {
516 closeModal();
517 }
518
519 if (onSuccess) {
520 onSuccess(result);
521 }
522
523 if (reload) {
524 const reloadFns = Array.isArray(reload) ? reload : [reload];
525 for (const fn of reloadFns) {
526 if (typeof fn === 'function') {
527 fn();
528 }
529 }
530 }
531
532 return result;
533 } catch (err) {
534 if (button) setButtonLoading(button, false);
535 const message = GoingsOn.utils.getErrorMessage(err, errorMessage);
536 const toastOpts = {};
537 if (retry) {
538 toastOpts.action = { label: 'Retry', fn: retry };
539 toastOpts.duration = 8000;
540 }
541 showToast(message, 'error', toastOpts);
542 return null;
543 }
544 }
545
546 // ============ Button Loading State ============
547
548 /**
549 * Set button loading state
550 * @param {HTMLButtonElement} button - The button element
551 * @param {boolean} loading - Whether to show loading state
552 */
553 function setButtonLoading(button, loading) {
554 if (!button) return;
555
556 if (loading) {
557 // Wrap existing children in a span so CSS can hide text during loading
558 const wrapper = document.createElement('span');
559 wrapper.className = 'btn-text';
560 while (button.firstChild) {
561 wrapper.appendChild(button.firstChild);
562 }
563 button.appendChild(wrapper);
564 button.classList.add('btn-loading');
565 button.disabled = true;
566 } else {
567 // Unwrap: move children out of the span wrapper
568 const wrapper = button.querySelector('.btn-text');
569 if (wrapper) {
570 while (wrapper.firstChild) {
571 button.insertBefore(wrapper.firstChild, wrapper);
572 }
573 wrapper.remove();
574 }
575 button.classList.remove('btn-loading');
576 button.disabled = false;
577 }
578 }
579
580 // ============ Modal Swipe-to-Dismiss (mobile) ============
581
582 function initModalSwipeDismiss() {
583 if (!GoingsOn.touch?.isTouchDevice) return;
584
585 const container = document.getElementById('modal-container');
586 if (!container) return;
587
588 GoingsOn.touch.addDragToDismiss(container, () => {
589 closeModal();
590 });
591 }
592
593 // Initialize on load
594 if (document.readyState === 'loading') {
595 document.addEventListener('DOMContentLoaded', initModalSwipeDismiss);
596 } else {
597 // Delay slightly to ensure touch.js has loaded
598 setTimeout(initModalSwipeDismiss, 0);
599 }
600
601 // ============ Populate GoingsOn.modal Namespace ============
602
603 GoingsOn.modal = {
604 openModal,
605 closeModal,
606 showToast,
607 showUndoToast,
608 bulkActionWithUndo,
609 executeUndo,
610 cancelUndo,
611 showConfirmDialog,
612 showPromptDialog,
613 confirmDelete,
614 apiCall,
615 setButtonLoading,
616 };
617
618 })();
619