Skip to main content

max / goingson

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