Files
vacuum-wall/webui/static/hoover/components/modal.js
T

250 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 <optgroup> grouping. Nested options follow the same string/tuple format.
*
* 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.multiple ? ' multiple' : '') + '>'
+ (f.options || []).map(function(o) {
if (o === null || o === undefined) return '';
if (typeof o === 'string')
return '<option value="' + att_esc(o) + '">' + esc(o) + '</option>';
if (Array.isArray(o)) {
// Support [value, label] and [value, selectedBoolean]
if (o.length < 2)
return '<option value="' + att_esc(o[0]) + '">' + att_esc(o[0]) + '</option>';
if (typeof o[1] === 'boolean')
return '<option value="' + att_esc(o[0]) + '"' + (o[1] ? ' selected' : '') + '>' + att_esc(o[0]) + '</option>';
return '<option value="' + att_esc(o[0]) + '">' + esc(o[1]) + '</option>';
}
if ('group' in o) {
return '<optgroup label="' + att_esc(o.group) + '">'
+ o.options.map(function(item) {
return typeof item === 'string'
? '<option value="' + att_esc(item) + '">' + esc(item) + '</option>'
: '<option value="' + att_esc(item[0]) + '">' + esc(item[1]) + '</option>';
}).join('') + '</optgroup>';
}
return '';
}).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);
});
}
/**
* 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);
});
};
}