104 lines
3.7 KiB
JavaScript
104 lines
3.7 KiB
JavaScript
/**
|
||
* 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';
|
||
import { att_esc } from '../helpers.js';
|
||
|
||
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} renderFn – (contentEl, idx) => void, renders into contentEl
|
||
*/
|
||
export function openModal(renderFn) {
|
||
_modalQueue.push({ renderFn, id: _modalQueue.length });
|
||
_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();
|
||
}
|
||
|
||
/**
|
||
* 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] }
|
||
*
|
||
* Action shape:
|
||
* { label, cls, action, handler }
|
||
*/
|
||
export function formModal(inner, title, fields, actions) {
|
||
inner.innerHTML = '<h2 class="modal-title">' + esc(title) + '</h2><div class="modal-body">'
|
||
+ fields.map(f => {
|
||
if (f.tag === 'select')
|
||
return '<div class="form-group"><label>' + esc(f.label) + '</label><select id="' + att_esc(f.id) + '">'
|
||
+ (f.options || []).map(o =>
|
||
typeof o === 'string'
|
||
? '<option value="' + att_esc(o) + '">' + esc(o) + '</option>'
|
||
: '<option value="' + att_esc(o[0]) + '"' + (o[1] ? ' selected' : '') + '>' + esc(o[1]) + '</option>',
|
||
).join('') + '</select></div>';
|
||
|
||
const tag = f.tag || 'input';
|
||
return '<div class="form-group"><label>' + esc(f.label) + '</label><' + tag + ' id="' + att_esc(f.id) + '"'
|
||
+ (f.type !== 'text' ? ' type="' + att_esc(f.type) + '"' : '')
|
||
+ (f.value !== undefined ? ' value="' + att_esc(f.value) + '"' : '')
|
||
+ (f.placeholder ? ' placeholder="' + att_esc(f.placeholder) + '"' : '')
|
||
+ '></' + tag + '></div>';
|
||
}).join('') + '</div><div class="modal-actions">'
|
||
+ actions.map(a =>
|
||
'<button class="btn ' + att_esc(a.cls) + '" data-action="' + att_esc(a.action) + '">' + esc(a.label) + '</button>',
|
||
).join('') + '</div>';
|
||
|
||
actions.forEach(a => {
|
||
const btn = inner.querySelector('[data-action="' + att_esc(a.action) + '"]');
|
||
if (btn) btn.addEventListener('click', a.handler);
|
||
});
|
||
}
|