/** * 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 } from '../helpers.js?v=8'; import { att_esc } from '../helpers.js?v=8'; import { apiSubmit } from '../api.js?v=8'; import { createDom } from '../vdom.js?v=8'; /** * 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) 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 } : { id: _modalQueue.length, renderFn: (inner) => modalVNodes(inner, content) }; _modalQueue.push(entry); _renderModals(); } /** * 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 = ''; actions.forEach(a => { const btn = inner.querySelector('[data-action="' + att_esc(a.action) + '"]'); if (btn) btn.addEventListener('click', a.handler); }); } /** * Factory that returns a function to open a multi-select modal. * * @param {object} props * @param {string} props.title - Modal title * @param {string} props.url - API POST URL * @param {string[]} props.options - All selectable options * @param {string[]} props.selected - Currently selected values * @param {string} props.fieldKey - JSON key for the field * @param {string} [props.successMsg] - Success toast message * @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch * @returns {function} () => void, calls openModal */ export function MultiSelectModal(props = {}) { return () => { const selectId = 'ms-' + props.fieldKey; openModal((inner) => { formModal(inner, props.title, [{ label: props.fieldKey, id: selectId, tag: 'select', multiple: true, options: (props.options || []).map(o => [o, (props.selected || []).includes(o)]), }], [ { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }, ...apiSubmit({ url: props.url, body: () => ({ [props.fieldKey]: Array.from(document.getElementById(selectId).selectedOptions) .map(o => o.value), }), successMsg: props.successMsg || 'Updated', refresh: props.refresh, closeModal: () => closeModal(), }), ], ); }); }; } /** * 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] - Model name(s) to refresh via modelFetch * @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); }); }; }