1/** 2 * Shared Modal System 3 * Provides consistent alert, confirm, and custom modals across all pages 4 */ 5const Modal = (function() { 6 let modalContainer = null; 7 let currentResolve = null; 8 let currentReject = null; 9 10 // Track if current modal allows closing 11 let currentAllowClose = true; 12 let currentCloseOnOverlayClick = true; 13 14 // Guard against duplicate initialization (prevents duplicate event listeners) 15 let _initialized = false; 16 17 // Save scroll position before modal opens (position:fixed on body resets scroll) 18 let savedScrollY = 0; 19 20 // Initialize the modal container 21 function init() { 22 if (_initialized) return; 23 _initialized = true; 24 if (modalContainer) return; 25 26 modalContainer = document.createElement('div'); 27 modalContainer.id = 'sharedModalContainer'; 28 modalContainer.innerHTML = ` 29 <div class="modal-overlay" id="sharedModal"> 30 <div class="modal shared-modal"> 31 <div class="modal-header"> 32 <div class="modal-icon" id="sharedModalIcon"></div> 33 <h3 id="sharedModalTitle">Modal</h3> 34 <button class="modal-close" id="sharedModalClose"> 35 <svg fill="none" viewBox="0 0 24 24" stroke="currentColor"> 36 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> 37 </svg> 38 </button> 39 </div> 40 <div class="modal-body" id="sharedModalBody"></div> 41 <div class="modal-footer" id="sharedModalFooter"></div> 42 </div> 43 </div> 44 `; 45 document.body.appendChild(modalContainer); 46 47 // Close button handler 48 const closeBtn = modalContainer.querySelector('#sharedModalClose'); 49 closeBtn.addEventListener('click', () => { 50 if (currentAllowClose) { 51 close({ action: 'close', value: false }); 52 } 53 }); 54 55 // Close on overlay click (only if closeOnOverlayClick is enabled) 56 const overlay = modalContainer.querySelector('.modal-overlay'); 57 overlay.addEventListener('click', (e) => { 58 if (e.target === overlay && currentAllowClose && currentCloseOnOverlayClick) { 59 close({ action: 'close', value: false }); 60 } 61 }); 62 63 // Close on Escape key 64 document.addEventListener('keydown', (e) => { 65 if (e.key === 'Escape' && overlay.classList.contains('open') && currentAllowClose) { 66 close({ action: 'close', value: false }); 67 } 68 }); 69 } 70 71 // Icons for different modal types 72 const icons = { 73 info: `<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`, 74 success: `<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`, 75 warning: `<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>`, 76 danger: `<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>`, 77 question: `<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>` 78 }; 79 80 /** 81 * Show a simple alert modal 82 * @param {string} message - The message to display 83 * @param {Object} options - Optional settings 84 * @returns {Promise} - Resolves when closed 85 */ 86 function alert(message, options = {}) { 87 const type = options.type || 'info'; 88 const toastType = type === 'danger' ? 'error' : type; 89 if (window.Toast) return window.Toast.show(message, toastType); 90 return Promise.resolve({ action: 'close', value: true }); 91 } 92 93 /** 94 * Show a confirmation modal 95 * @param {string} message - The message to display 96 * @param {Object} options - Optional settings 97 * @returns {Promise<boolean>} - Resolves to true if confirmed, false if cancelled 98 */ 99 function confirm(message, options = {}) { 100 const { 101 title = 'Confirm', 102 type = 'question', 103 confirmText = 'Confirm', 104 cancelText = 'Cancel', 105 confirmClass = 'btn-primary', 106 danger = false 107 } = options; 108 109 return show({ 110 title, 111 type: danger ? 'danger' : type, 112 body: `<p class="modal-message">${message}</p>`, 113 buttons: [ 114 { text: cancelText, class: 'btn-cancel', action: 'cancel' }, 115 { text: confirmText, class: danger ? 'btn-danger' : confirmClass, action: 'confirm', primary: true } 116 ] 117 }); 118 } 119 120 /** 121 * Show a delete confirmation modal with extra warning 122 * @param {string} itemName - Name of item being deleted 123 * @param {Object} options - Optional settings 124 * @returns {Promise<boolean>} 125 */ 126 function confirmDelete(itemName, options = {}) { 127 const { 128 title = 'Delete ' + itemName + '?', 129 message = `Are you sure you want to delete this ${itemName.toLowerCase()}? This action cannot be undone.`, 130 confirmText = 'Delete', 131 cancelText = 'Cancel' 132 } = options; 133 134 return show({ 135 title, 136 type: 'danger', 137 body: ` 138 <p class="modal-message">${message}</p> 139 `, 140 buttons: [ 141 { text: cancelText, class: 'btn-cancel', action: 'cancel' }, 142 { text: confirmText, class: 'btn-danger', action: 'confirm', primary: true } 143 ] 144 }); 145 } 146 147 /** 148 * Show error modal 149 * @param {string} message - Error message 150 * @param {Object} options - Optional settings 151 */ 152 function error(message, options = {}) { 153 if (window.Toast) return window.Toast.error(message); 154 return Promise.resolve({ action: 'close', value: true }); 155 } 156 157 function success(message, options = {}) { 158 if (window.Toast) return window.Toast.success(message); 159 return Promise.resolve({ action: 'close', value: true }); 160 } 161 162 /** 163 * Show a custom modal with full control 164 * @param {Object} config - Modal configuration 165 * @param {string} config.title - Modal title 166 * @param {string} config.type - Icon type (info, success, warning, danger, question) 167 * @param {string} config.body - HTML content for modal body 168 * @param {Array} config.buttons - Array of button configs {text, class, action, url, primary} 169 * @param {string} config.customClass - Additional CSS class 170 * @param {boolean} config.closeable - Show/hide close button (default: true) 171 * @param {boolean} config.allowClose - Allow closing via X, overlay, or Escape (default: true) 172 * @param {boolean} config.closeOnOverlayClick - Allow closing by clicking overlay (default: true) 173 * @returns {Promise} 174 */ 175 function show(config) { 176 init(); 177 178 const { 179 title = 'Modal', 180 type = 'info', 181 body = '', 182 buttons = [{ text: 'OK', class: 'btn-primary', action: 'close', primary: true }], 183 customClass = '', 184 closeable = true, 185 allowClose = true, 186 closeOnOverlayClick = true 187 } = config; 188 189 // Store allowClose state for event handlers 190 currentAllowClose = allowClose; 191 currentCloseOnOverlayClick = closeOnOverlayClick; 192 193 const modal = document.getElementById('sharedModal'); 194 const modalEl = modal.querySelector('.modal'); 195 const titleEl = document.getElementById('sharedModalTitle'); 196 const iconEl = document.getElementById('sharedModalIcon'); 197 const bodyEl = document.getElementById('sharedModalBody'); 198 const footerEl = document.getElementById('sharedModalFooter'); 199 const closeBtn = document.getElementById('sharedModalClose'); 200 201 // Set modal type class 202 modalEl.className = `modal shared-modal modal-${type} ${customClass}`; 203 204 // Show/hide close button 205 closeBtn.style.display = closeable ? '' : 'none'; 206 207 // Set icon 208 iconEl.innerHTML = icons[type] || icons.info; 209 iconEl.className = `modal-icon icon-${type}`; 210 211 // Set title and body 212 titleEl.textContent = title; 213 bodyEl.innerHTML = body; 214 215 // Build buttons with data attributes for url 216 footerEl.innerHTML = buttons.map((btn, i) => ` 217 <button class="${btn.class || 'btn-secondary'}
217" data-action="${btn.action || 'close'}" ${btn.url ? `data-url="${btn.url}"` : ''} ${btn.primary ? 'data-primary="true"' : ''}> 218 ${btn.text} 219 </button> 220 `).join(''); 221 222 // Add button click handlers 223 footerEl.querySelectorAll('button').forEach(btn => { 224 btn.addEventListener('click', () => { 225 const action = btn.dataset.action; 226 const url = btn.dataset.url; 227 228 // Build result object with action info 229 const result = { 230 action: action, 231 url: url || null 232 }; 233 234 if (action === 'confirm') { 235 result.value = true; 236 close(result); 237 } else if (action === 'cancel') { 238 result.value = false; 239 close(result); 240 } else { 241 // For custom actions like 'dismiss', 'snooze', 'link', 'complete' 242 close(result); 243 } 244 }); 245 }); 246 247 // Show modal and lock body scroll 248 // Skip adding modal-open if another modal (like list modal) already has scroll locked 249 // This prevents the position:fixed jump when body.overflow is already hidden 250 const scrollAlreadyLocked = document.body.style.overflow === 'hidden'; 251 modal.classList.add('open'); 252 if (!scrollAlreadyLocked) { 253 savedScrollY = window.scrollY; 254 document.body.dataset.scrollY = savedScrollY; 255 document.body.classList.add('modal-open'); 256 document.body.style.top = `-${savedScrollY}px`; 257 } 258 259 // Focus primary button (preventScroll to avoid jumping to top) 260 const primaryBtn = footerEl.querySelector('[data-primary="true"]'); 261 if (primaryBtn) { 262 setTimeout(() => primaryBtn.focus({ preventScroll: true }), 100); 263 } 264 265 return new Promise((resolve, reject) => { 266 currentResolve = resolve; 267 currentReject = reject; 268 }); 269 } 270 271 /** 272 * Close the modal 273 * @param {*} result - Value to resolve the promise with 274 */ 275 function close(result = null) { 276 const modal = document.getElementById('sharedModal'); 277 if (modal) { 278 modal.classList.remove('open'); 279 } 280 281 // Only remove modal-open if no other modal overlays are active 282 // Exclude list modals (addListModal, editListModal) - they use their own scroll lock system 283 const otherActiveModals = document.querySelectorAll('.modal-overlay.active, .modal-overlay.open'); 284 const hasOtherModals = Array.from(otherActiveModals).some(m => 285 m.id !== 'sharedModal' && m.id !== 'addListModal' && m.id !== 'editListModal' 286 ); 287 if (!hasOtherModals && document.body.classList.contains('modal-open')) { 288 document.body.classList.remove('modal-open'); 289 document.body.style.top = ''; 290 window.scrollTo(0, savedScrollY); 291 } 292 293 if (currentResolve) { 294 currentResolve(result); 295 currentResolve = null; 296 currentReject = null; 297 } 298 } 299 300 /** 301 * Check if modal is currently open 302 * @returns {boolean} 303 */ 304 function isOpen() { 305 const modal = document.getElementById('sharedModal'); 306 return modal && modal.classList.contains('open'); 307 } 308 309 // Public API 310 return { 311 init, 312 alert, 313 confirm, 314 confirmDelete, 315 error, 316 success, 317 show, 318 close, 319 isOpen 320 }; 321})(); 322 323// Auto-initialize when DOM is ready 324if (document.readyState === 'loading') {
325 document.addEventListener('DOMContentLoaded', Modal.init); 326} else { 327 Modal.init(); 328}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.