/** * Hoover — components/modal.js * * Modal overlay system: openModal, closeModal, closeAllModals, formModal. * Renders directly into #modal-root using DOM manipulation (not vdom) to * avoid fighting with the main render cycle. */ import { esc, att_esc } from '../helpers.js'; import { apiSubmit } from '../api.js'; import { createDom } from '../vdom.js'; /** * Render Hoover VNodes into a modal content element. * VDOM is not diffed across modal re-render — modals are transient and * innerHTML is cleared/repainted each time (avoids lifecycle baggage). */ export function modalVNodes(inner, vnodes) { inner.innerHTML = ''; const nodes = Array.isArray(vnodes) ? vnodes : [vnodes]; for (const vnode of nodes) { if (vnode) inner.appendChild(createDom(vnode)); } } const _modalQueue = []; function _renderModals() { const root = document.getElementById('modal-root'); if (!root) return; root.innerHTML = ''; _modalQueue.forEach((m, idx) => { const wrap = document.createElement('div'); wrap.className = 'modal-overlay active'; wrap.onclick = (e) => { if (e.target === wrap) { if (m._processing) return; if (m._hasInputs && !confirm('Discard changes?')) return; closeModal(idx); } }; const content = document.createElement('div'); content.className = 'modal'; content.onclick = (e) => e.stopPropagation(); if (m.renderFn) { try { m.renderFn(content, idx); } catch (err) { content.textContent = err.message; } } wrap.appendChild(content); root.appendChild(wrap); }); } /** * Open a modal dialog. * * @param {function|object} content – Either: * - renderFn(contentEl, idx) => void (legacy innerHTML path) * - VNode / VNode[] (new VDOM path — uses modalVNodes) */ export function openModal(content) { const entry = typeof content === 'function' ? { renderFn: content, id: _modalQueue.length, _processing: false, _hasInputs: false } : { id: _modalQueue.length, renderFn: (inner) => modalVNodes(inner, content), _processing: false, _hasInputs: false }; _modalQueue.push(entry); _renderModals(); } /** * Check if the topmost modal (or specified index) has an active async operation. * * @param {number} [idx] – Modal index (defaults to topmost) * @returns {boolean} */ export function isModalProcessing(idx) { if (idx === undefined) idx = _modalQueue.length - 1; if (idx < 0 || idx >= _modalQueue.length) return false; return _modalQueue[idx]._processing; } /** * Set the processing flag on the topmost modal (or specified index). * * @param {boolean} flag – Whether the modal is currently processing * @param {number} [idx] – Modal index (defaults to topmost) */ export function setModalProcessing(flag, idx) { if (idx === undefined) idx = _modalQueue.length - 1; if (idx < 0 || idx >= _modalQueue.length) return; _modalQueue[idx]._processing = flag; } /** * Close a modal by index. Closes the topmost modal if index is omitted. * * @param {number} [idx] */ export function closeModal(idx) { if (idx === undefined) idx = _modalQueue.length - 1; if (idx >= 0 && idx < _modalQueue.length) _modalQueue.splice(idx, 1); _renderModals(); } /** * Close all open modals. */ export function closeAllModals() { _modalQueue.length = 0; _renderModals(); } /** Re-render all open modals. Used by long-lived modals that update in place. */ export function refreshModals() { _renderModals(); } /** * Render a standard modal layout: title, form fields, action buttons. * * @param {HTMLElement} inner – Modal content element to fill * @param {string} title – Modal title * @param {object[]} fields – Form field descriptors * @param {object[]} actions – Action button descriptors * * Field shape: * { label, id, [tag: 'input'|'select'|'textarea'], [type], [value], [placeholder], [options] } * - options can be string[], [value, selected][] tuples, or objects with { group, options } * for grouping. Nested options follow the same string/tuple format. * * Action shape: * { label, cls, action, handler } */ export function formModal(inner, title, fields, actions) { inner.innerHTML = ''; // Mark modal as having editable inputs const topEntry = _modalQueue[_modalQueue.length - 1]; if (topEntry) topEntry._hasInputs = true; // Build action buttons with processing-aware rendering const actionsBar = inner.querySelector('.modal-actions'); const actionBtns = []; for (const a of actions) { const actionId = 'am-' + a.action + '-' + (_modalQueue.length - 1); const btn = document.createElement('button'); btn.className = 'btn ' + a.cls; btn.id = actionId; if (a.processing && isModalProcessing(_modalQueue.length - 1)) { btn.disabled = true; btn.innerHTML = ''; } else { btn.appendChild(document.createTextNode(a.label)); } // Store reference for later re-binding actionBtns.push({ btn, action: a }); if (a.handler) { const origHandler = a.handler; btn.addEventListener('click', () => { // Only inline-disable if the handler doesn't use processing // state. apiSubmit/formAction already call // setModalProcessing + refreshModals which re-creates the // button in processing state. Without the guard the old // button is discarded before the handler even starts. if (!a.processing) { btn.disabled = true; btn.innerHTML = ''; } origHandler(); }); } actionsBar.appendChild(btn); } } /** * Factory that returns a function to open a multi-select modal. * * Renders a scrollable, filtered checkbox list (not a native * `' + '' + esc(o) + '' + (d ? '' + esc(d) + '' : '') + ''; }).join(''); body.innerHTML = '
' + (showSearch ? '
' + '' + '' + '
' : '
') + '
' + '' + '' + (hasAdv ? '' : '') + '
' + '
' + rowsHtml + '
' + '' + '
'; const rows = [...body.querySelectorAll('.ms-row')]; const countEl = body.querySelector('.ms-count'); const searchEl = body.querySelector('.ms-search'); const advEl = body.querySelector('.ms-adv-check'); const emptyEl = body.querySelector('.ms-empty'); const isVisible = (o) => { if (query && !o.toLowerCase().includes(query)) return false; if (!hasAdv || advanced) return true; return sel.has(o) || props.common.includes(o); }; const apply = () => { let visibleCount = 0; for (const row of rows) { const show = isVisible(row.dataset.msValue); row.hidden = !show; if (show) visibleCount++; } if (emptyEl) { emptyEl.hidden = visibleCount > 0; emptyEl.textContent = allOpts.length === 0 ? 'Nothing to select.' : 'No matches for "' + (query || '') + '".'; } countEl.textContent = sel.size + ' of ' + allOpts.length + ' selected'; }; const syncRowChecks = () => { for (const row of rows) { row.querySelector('.ms-check').checked = sel.has(row.dataset.msValue); } }; if (searchEl) { searchEl.addEventListener('input', () => { query = searchEl.value.trim().toLowerCase(); apply(); }); } if (advEl) { advEl.addEventListener('change', () => { advanced = advEl.checked; apply(); }); } for (const row of rows) { const cb = row.querySelector('.ms-check'); cb.addEventListener('change', () => { const o = row.dataset.msValue; if (cb.checked) sel.add(o); else sel.delete(o); apply(); }); } body.querySelector('.ms-selall').addEventListener('click', () => { for (const row of rows) if (!row.hidden) sel.add(row.dataset.msValue); syncRowChecks(); apply(); }); body.querySelector('.ms-clear').addEventListener('click', () => { for (const row of rows) if (!row.hidden) sel.delete(row.dataset.msValue); syncRowChecks(); apply(); }); apply(); }); }; } /** * Factory that returns a function to open a modal with form fields and apiSubmit. * Accepts an optional `data` argument forwarded to title, fields, submit.url, submit.body resolvers. * * @param {object} props * @param {string|function} props.title - Modal title or (data) => string * @param {object[]|function} props.fields - Form field descriptors or (data) => object[] * @param {object} props.submit - Submit configuration * @param {string|function} props.submit.url - API URL or (data) => string * @param {string} [props.submit.method] - HTTP method (default: 'POST') * @param {function} [props.submit.body] - (data) => object * @param {function} [props.submit.validate] - (body) => string|null * @param {string|function} [props.submit.successMsg] - Toast message or (data) => string * @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat) * @param {function} [props.handler] - Custom submit handler override (bypasses apiSubmit) * @param {string} [props.submitLabel] - Submit button label (default: 'Submit') * @returns {function} (data) => void, calls openModal */ export function QuickModal(props = {}) { return (data) => { const title = typeof props.title === 'function' ? props.title(data) : props.title; const fields = typeof props.fields === 'function' ? props.fields(data) : props.fields; const url = typeof props.submit.url === 'function' ? props.submit.url(data) : props.submit.url; openModal((inner) => { let actions; if (props.handler) { actions = [ { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }, { label: props.submitLabel || 'Submit', cls: 'btn-primary', action: 's', handler: () => props.handler(data, () => closeModal()), }, ]; } else { actions = [ { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }, ...apiSubmit({ url, method: props.submit.method || 'POST', body: props.submit.body ? () => props.submit.body(data) : undefined, validate: props.submit.validate, successMsg: typeof props.submit.successMsg === 'function' ? props.submit.successMsg(data) : (props.submit.successMsg || 'Done'), refresh: props.refresh || undefined, closeModal: () => closeModal(), }), ]; } formModal(inner, title, fields, actions); if (props.postRender) props.postRender(inner, data); }); }; }