PageSourceSearch

https://otakiroku.com/shared/modal.js

js otakiroku.com collected 2026-10-02 01:38:45 UTC 13,167 bytes, 328 lines download raw bytes

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.