/** * GoingsOn - Components Modal Module * Modal dialog, toast notifications, undo toasts, confirmation dialogs */ (function() { 'use strict'; const esc = GoingsOn.utils.escapeHtml; // ============ Modal ============ /** * Open a modal dialog * @param {string} title - Modal title * @param {string} content - Modal HTML content * @param {Object} [options] - Optional settings * @param {boolean} [options.large] - Use large modal size (nearly full screen) */ function openModal(title, content, options = {}) { const overlay = document.getElementById('modal-overlay'); const container = overlay.querySelector('.modal-container'); const titleEl = document.getElementById('modal-title'); const contentEl = document.getElementById('modal-content'); titleEl.textContent = title; contentEl.innerHTML = content; overlay.classList.remove('hidden'); // Handle large modal option if (options.large) { container.classList.add('modal-large'); } else { container.classList.remove('modal-large'); } // Set ARIA attributes overlay.setAttribute('aria-hidden', 'false'); // Focus first focusable element setTimeout(() => { const firstInput = contentEl.querySelector('input, textarea, select, button'); if (firstInput) firstInput.focus(); }, 100); // Trap focus inside modal trapFocus(overlay); } /** * Close the modal dialog with animation */ function closeModal() { const overlay = document.getElementById('modal-overlay'); // Add closing class to trigger exit animation overlay.classList.add('closing'); // Wait for animation to complete before hiding setTimeout(() => { overlay.classList.add('hidden'); overlay.classList.remove('closing'); overlay.setAttribute('aria-hidden', 'true'); releaseFocusTrap(); }, 150); // Match the animation duration } // Focus trap for modal accessibility let focusTrapElement = null; let previouslyFocusedElement = null; function trapFocus(element) { previouslyFocusedElement = document.activeElement; focusTrapElement = element; element.addEventListener('keydown', handleFocusTrap); } function releaseFocusTrap() { if (focusTrapElement) { focusTrapElement.removeEventListener('keydown', handleFocusTrap); focusTrapElement = null; } if (previouslyFocusedElement) { previouslyFocusedElement.focus(); previouslyFocusedElement = null; } } function handleFocusTrap(e) { if (e.key !== 'Tab') return; const focusable = focusTrapElement.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } // Close modal on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { const overlay = document.getElementById('modal-overlay'); if (!overlay.classList.contains('hidden')) { closeModal(); } } }); // Close modal on overlay click document.getElementById('modal-overlay')?.addEventListener('click', (e) => { if (e.target.id === 'modal-overlay') closeModal(); }); // ============ Toast Notifications ============ /** * Show a toast notification * @param {string} message - Message to display * @param {'info'|'success'|'error'} type - Toast type */ function showToast(message, type = 'info', opts = {}) { const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.setAttribute('role', 'alert'); toast.setAttribute('aria-live', 'assertive'); const msgSpan = document.createElement('span'); msgSpan.textContent = message; toast.appendChild(msgSpan); if (opts.action) { const btn = document.createElement('button'); btn.className = 'toast-action'; btn.textContent = opts.action.label; btn.onclick = () => { toast.remove(); opts.action.fn(); }; toast.appendChild(btn); } document.body.appendChild(toast); const duration = opts.duration || (type === 'error' ? 6000 : 4000); setTimeout(() => { toast.classList.add('toast-leaving'); setTimeout(() => toast.remove(), 300); }, duration); } // ============ Undo Toast ============ /** * Pending undo operations, keyed by a unique ID. * Each entry contains: { timer, onConfirm, onUndo, element } */ const pendingUndos = new Map(); /** * Show a toast with an undo button. The action is delayed until timeout. * @param {string} message - Message to display (e.g., "Task deleted") * @param {Object} options - Options * @param {Function} options.onConfirm - Called when timeout expires (perform actual delete) * @param {Function} options.onUndo - Called when user clicks undo (restore item) * @param {number} options.timeout - Undo window in milliseconds (default: 15000) * @returns {string} - Undo ID (can be used to cancel programmatically) */ function showUndoToast(message, { onConfirm, onUndo, timeout = 15000 }) { const undoId = `undo-${Date.now()}-${Math.random().toString(36).slice(2)}`; const toast = document.createElement('div'); toast.className = 'toast toast-undo'; toast.setAttribute('role', 'alert'); toast.setAttribute('aria-live', 'polite'); toast.innerHTML = ` ${esc(message)} `; document.body.appendChild(toast); // Countdown display const countdownEl = toast.querySelector('.undo-countdown'); let remaining = Math.ceil(timeout / 1000); countdownEl.textContent = `(${remaining}s)`; const countdownInterval = setInterval(() => { remaining--; if (remaining > 0) { countdownEl.textContent = `(${remaining}s)`; } }, 1000); // Set timer for confirmation const timer = setTimeout(() => { clearInterval(countdownInterval); pendingUndos.delete(undoId); removeUndoToast(toast); if (onConfirm) { onConfirm(); } }, timeout); pendingUndos.set(undoId, { timer, countdownInterval, onConfirm, onUndo, element: toast }); return undoId; } /** * Run a bulk operation through the undo system. * * Pattern: optimistically update local state immediately so the UI reflects * the action; capture whatever revert needs; if the user clicks Undo within * the timeout, restore state and skip the API call; otherwise commit by * calling the API for every id. On commit failure, revert + surface error. * * Charter rule: every bulk operation must wrap its API call this way * (see docs/design-system.md § Bulk operations always undoable). * * @param {Object} cfg * @param {Array|Set} cfg.ids - record ids to act on * @param {string} cfg.label - past-tense verb for the toast ("Completed", "Deleted", "Snoozed") * @param {string} cfg.itemType - singular noun ("task", "email", "contact") * @param {Function} cfg.apply - sync (ids) => preState. Optimistic UI update; return whatever revert needs. * @param {Function} cfg.revert - sync (preState) => void. Restore the optimistic change on Undo or commit failure. * @param {Function} cfg.commit - async (ids) => any. Run the API after the undo window expires. * @param {string} [cfg.errorMessage] - prefix on commit failure (default: "Action failed") * @param {number} [cfg.timeout=10000] - undo window in ms * @returns {string|null} - undo id, or null if ids was empty */ function bulkActionWithUndo(cfg) { const { ids, label, itemType, apply, revert, commit, errorMessage = 'Action failed', timeout = 10000, } = cfg; const idList = Array.isArray(ids) ? ids : Array.from(ids || []); const count = idList.length; if (count === 0) return null; const noun = count === 1 ? itemType : `${itemType}s`; const message = `${label} ${count} ${noun}`; // Optimistic update — capture state needed for revert. let preState; try { preState = apply(idList); } catch (err) { showToast(`${errorMessage}: ${err?.message || err}`, 'error'); return null; } return showUndoToast(message, { onConfirm: async () => { try { await commit(idList); } catch (err) { try { revert(preState); } catch (_) { /* best effort */ } showToast(`${errorMessage}: ${GoingsOn.utils.getErrorMessage(err)}`, 'error'); } }, onUndo: () => { try { revert(preState); } catch (_) { /* best effort */ } }, timeout, }); } /** * Execute undo for a pending operation. */ function executeUndo(undoId) { const pending = pendingUndos.get(undoId); if (!pending) return; clearTimeout(pending.timer); clearInterval(pending.countdownInterval); pendingUndos.delete(undoId); removeUndoToast(pending.element); if (pending.onUndo) { pending.onUndo(); } showToast('Action undone', 'success'); } /** * Cancel a pending undo without executing either callback. */ function cancelUndo(undoId) { const pending = pendingUndos.get(undoId); if (!pending) return; clearTimeout(pending.timer); clearInterval(pending.countdownInterval); pendingUndos.delete(undoId); removeUndoToast(pending.element); } /** * Remove an undo toast with animation. */ function removeUndoToast(toast) { toast.classList.add('toast-leaving'); setTimeout(() => toast.remove(), 300); } // ============ Confirmation Dialog ============ /** * Show a custom confirmation dialog * @param {string} title - Dialog title * @param {string} message - Dialog message * @param {Object} options - Optional settings * @param {string} options.confirmText - Text for confirm button (default: "Confirm") * @param {string} options.cancelText - Text for cancel button (default: "Cancel") * @param {boolean} options.danger - If true, confirm button is styled as danger * @returns {Promise} - Resolves to true if confirmed, false if cancelled */ function showConfirmDialog(title, message, options = {}) { return new Promise((resolve) => { const { confirmText = 'Confirm', cancelText = 'Cancel', danger = false } = options; const confirmBtnClass = danger ? 'btn btn-danger' : 'btn btn-primary'; const content = `

${esc(message)}

`; openModal(title, content); // Attach event handlers after modal is opened setTimeout(() => { const confirmBtn = document.getElementById('confirm-dialog-confirm'); const cancelBtn = document.getElementById('confirm-dialog-cancel'); if (confirmBtn) { confirmBtn.onclick = () => { closeModal(); resolve(true); }; } if (cancelBtn) { cancelBtn.onclick = () => { closeModal(); resolve(false); }; } }, 50); }); } // ============ Prompt Dialog ============ /** * Show a prompt dialog with a text input. Replaces native window.prompt(). * @param {string} title - Dialog title * @param {string} message - Prompt message (rendered above the input) * @param {Object} options - Optional settings * @param {string} options.defaultValue - Initial input value * @param {string} options.placeholder - Input placeholder * @param {string} options.confirmText - Text for confirm button (default: "OK") * @param {string} options.cancelText - Text for cancel button (default: "Cancel") * @param {Function} options.validate - Optional sync validator; return error string to block, null to allow * @returns {Promise} - Resolves to the entered value (trimmed), or null if cancelled */ function showPromptDialog(title, message, options = {}) { return new Promise((resolve) => { const { defaultValue = '', placeholder = '', confirmText = 'OK', cancelText = 'Cancel', validate = null, } = options; const inputId = 'prompt-dialog-input'; const errorId = 'prompt-dialog-error'; const content = `

${esc(message)}

`; openModal(title, content); setTimeout(() => { const input = document.getElementById(inputId); const errorEl = document.getElementById(errorId); const confirmBtn = document.getElementById('prompt-dialog-confirm'); const cancelBtn = document.getElementById('prompt-dialog-cancel'); const submit = () => { const value = (input?.value || '').trim(); if (validate) { const err = validate(value); if (err) { if (errorEl) { errorEl.textContent = err; errorEl.classList.add('visible'); } return; } } closeModal(); resolve(value); }; if (confirmBtn) confirmBtn.onclick = submit; if (cancelBtn) cancelBtn.onclick = () => { closeModal(); resolve(null); }; if (input) { input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }); input.focus(); input.select(); } }, 50); }); } // ============ Confirm Delete Helper ============ /** * Show a confirmation dialog for deleting items. * @param {string} itemType - Type of item ('task', 'email', 'project', 'event', etc.) * @param {number} count - Number of items to delete (default: 1) * @returns {Promise} - Resolves to true if confirmed */ async function confirmDelete(itemType, count = 1) { const plural = count > 1; const itemName = plural ? `${count} ${itemType}s` : `this ${itemType}`; const title = plural ? `Delete ${count} ${itemType}s` : `Delete ${itemType.charAt(0).toUpperCase() + itemType.slice(1)}`; return showConfirmDialog( title, `Are you sure you want to delete ${itemName}? This cannot be undone.`, { confirmText: 'Delete', danger: true } ); } // ============ API Call Wrapper ============ /** * Wrapper for API calls with standardized error handling, toasts, and modal closing. * @param {Promise} promise - The API promise to execute * @param {Object} options - Options for handling the call * @param {string} options.successMessage - Toast message on success * @param {string} options.errorMessage - Base error message (actual error appended) * @param {Function} options.onSuccess - Callback on success (receives result) * @param {boolean} options.closeModal - Whether to close modal on success (default: true) * @param {Function|Function[]} options.reload - Function(s) to call to reload data * @returns {Promise<*>} - The result of the API call, or null on error */ async function apiCall(promise, options = {}) { const { successMessage, errorMessage = 'Operation failed', onSuccess, closeModal: shouldCloseModal = true, reload, button, retry, } = options; // Set button loading state if (button) setButtonLoading(button, true); try { const result = await promise; if (button) setButtonLoading(button, false); if (successMessage) { showToast(successMessage, 'success'); } if (shouldCloseModal) { closeModal(); } if (onSuccess) { onSuccess(result); } if (reload) { const reloadFns = Array.isArray(reload) ? reload : [reload]; for (const fn of reloadFns) { if (typeof fn === 'function') { fn(); } } } return result; } catch (err) { if (button) setButtonLoading(button, false); const message = GoingsOn.utils.getErrorMessage(err, errorMessage); const toastOpts = {}; if (retry) { toastOpts.action = { label: 'Retry', fn: retry }; toastOpts.duration = 8000; } showToast(message, 'error', toastOpts); return null; } } // ============ Button Loading State ============ /** * Set button loading state * @param {HTMLButtonElement} button - The button element * @param {boolean} loading - Whether to show loading state */ function setButtonLoading(button, loading) { if (!button) return; if (loading) { // Wrap existing children in a span so CSS can hide text during loading const wrapper = document.createElement('span'); wrapper.className = 'btn-text'; while (button.firstChild) { wrapper.appendChild(button.firstChild); } button.appendChild(wrapper); button.classList.add('btn-loading'); button.disabled = true; } else { // Unwrap: move children out of the span wrapper const wrapper = button.querySelector('.btn-text'); if (wrapper) { while (wrapper.firstChild) { button.insertBefore(wrapper.firstChild, wrapper); } wrapper.remove(); } button.classList.remove('btn-loading'); button.disabled = false; } } // ============ Modal Swipe-to-Dismiss (mobile) ============ function initModalSwipeDismiss() { if (!GoingsOn.touch?.isTouchDevice) return; const container = document.getElementById('modal-container'); if (!container) return; GoingsOn.touch.addDragToDismiss(container, () => { closeModal(); }); } // Initialize on load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initModalSwipeDismiss); } else { // Delay slightly to ensure touch.js has loaded setTimeout(initModalSwipeDismiss, 0); } // ============ Populate GoingsOn.modal Namespace ============ GoingsOn.modal = { openModal, closeModal, showToast, showUndoToast, bulkActionWithUndo, executeUndo, cancelUndo, showConfirmDialog, showPromptDialog, confirmDelete, apiCall, setButtonLoading, }; })();