/** * Form submit policy for detail pages — confirm dialogs, double-submit prevention, and aria-busy. */ import { confirmDialog, requestSubmitWithFallback } from './app-confirm-dialog.js'; import { escapeAttr, belongsToForm, isSubmitControl } from './app-form-utils.js'; const toContainerElement = (value) => { if (!value) { return document.querySelector('.app-details-container') || document.body || document.documentElement; } if (typeof value === 'string') { return document.querySelector(value); } if (value instanceof HTMLElement) { return value; } if (value === document) { return document.documentElement; } return null; }; const toFormElement = (value) => { if (!value) { return null; } if (value instanceof HTMLFormElement) { return value; } if (typeof value === 'string') { const element = document.querySelector(value); return element instanceof HTMLFormElement ? element : null; } return null; }; const normalizeSubmitLock = (value) => { const normalized = String(value || '').trim().toLowerCase(); return normalized === 'none' ? 'none' : 'form'; }; const findSubmitControlsForForm = (form) => { if (!(form instanceof HTMLFormElement)) { return []; } const controls = []; controls.push( ...Array.from(form.querySelectorAll('button, input')).filter(isSubmitControl) ); const formId = String(form.id || '').trim(); if (formId !== '') { const escapedId = escapeAttr(formId); controls.push( ...Array.from(document.querySelectorAll(`button[form="${escapedId}"], input[form="${escapedId}"]`)) .filter(isSubmitControl) ); } return Array.from(new Set(controls)).filter((control) => belongsToForm(control, form)); }; const pickSubmitter = (event, form, submitterByForm) => { const eventSubmitter = event.submitter instanceof HTMLElement ? event.submitter : null; if (eventSubmitter && belongsToForm(eventSubmitter, form)) { return eventSubmitter; } const fallback = submitterByForm.get(form); if (fallback && belongsToForm(fallback, form)) { return fallback; } return null; }; const readConfirmMessage = (submitter, form) => { const fromSubmitter = String(submitter?.getAttribute('data-detail-confirm-message') || '').trim(); if (fromSubmitter !== '') { return fromSubmitter; } return String(form.getAttribute('data-detail-confirm-message') || '').trim(); }; const readConfirmOption = (submitter, form, detailSuffix, genericSuffix = detailSuffix) => { const detailAttr = `data-detail-confirm-${detailSuffix}`; const genericAttr = `data-confirm-${genericSuffix}`; return String( submitter?.getAttribute(detailAttr) || submitter?.getAttribute(genericAttr) || form.getAttribute(detailAttr) || form.getAttribute(genericAttr) || '' ).trim(); }; const readActionKind = (submitter, form) => String( submitter?.getAttribute('data-detail-action-kind') || form.getAttribute('data-detail-action-kind') || '' ).trim(); const buildConfirmOptions = (message, submitter, form) => ({ message, title: readConfirmOption(submitter, form, 'title'), confirmLabel: readConfirmOption(submitter, form, 'confirm-label'), cancelLabel: readConfirmOption(submitter, form, 'cancel-label'), variant: readConfirmOption(submitter, form, 'variant'), focus: readConfirmOption(submitter, form, 'focus'), describedBy: readConfirmOption(submitter, form, 'describedby') || readConfirmOption(submitter, form, 'described-by'), actionKind: readActionKind(submitter, form) }); const readSubmitLockMode = (submitter, form) => normalizeSubmitLock( submitter?.getAttribute('data-detail-submit-lock') || form.getAttribute('data-detail-submit-lock') || 'form' ); const disableSubmitControlsForForm = (form, submitter, options = {}) => { const controls = findSubmitControlsForForm(form); controls.forEach((control) => { if (submitter instanceof HTMLElement && control === submitter) { // Keep the clicked submitter enabled so its name/value stays part of form submission. return; } control.disabled = true; }); if (submitter instanceof HTMLElement) { submitter.setAttribute('aria-busy', 'true'); } if (options.markFormBusy === true) { form.setAttribute('aria-busy', 'true'); } }; export function initDetailActionPolicy(options = {}) { const enabled = options.enabled !== false; if (!enabled) { return { isSubmitting: () => false, destroy: () => {} }; } const container = toContainerElement(options.container); if (!(container instanceof HTMLElement)) { return { isSubmitting: () => false, destroy: () => {} }; } if (container._detailActionPolicyApi) { return container._detailActionPolicyApi; } const submittingForms = new Set(); const submitterByForm = new Map(); const confirmedSubmitterByForm = new WeakMap(); const onClickCapture = (event) => { const target = event.target instanceof HTMLElement ? event.target.closest('button, input') : null; if (!(target instanceof HTMLElement) || !isSubmitControl(target) || !container.contains(target)) { return; } const form = target.form instanceof HTMLFormElement ? target.form : null; if (!(form instanceof HTMLFormElement)) { return; } submitterByForm.set(form, target); }; const onSubmitCapture = (event) => { const form = event.target instanceof HTMLFormElement ? event.target : null; if (!(form instanceof HTMLFormElement) || !container.contains(form)) { return; } const submitter = pickSubmitter(event, form, submitterByForm); const lockMode = readSubmitLockMode(submitter, form); if (lockMode !== 'none' && submittingForms.has(form)) { event.preventDefault(); return; } const bypassSubmitter = confirmedSubmitterByForm.get(form); if (bypassSubmitter && (bypassSubmitter === true || bypassSubmitter === submitter || bypassSubmitter === null)) { confirmedSubmitterByForm.delete(form); } else { const confirmMessage = readConfirmMessage(submitter, form); if (confirmMessage !== '') { event.preventDefault(); event.stopPropagation(); void confirmDialog.confirm(buildConfirmOptions(confirmMessage, submitter, form)) .then((approved) => { if (!approved || !document.contains(form)) { return; } const submitterForSubmit = submitter && belongsToForm(submitter, form) ? submitter : null; confirmedSubmitterByForm.set(form, submitterForSubmit || true); requestSubmitWithFallback(form, submitterForSubmit); }); return; } } if (lockMode === 'none') { return; } submittingForms.add(form); disableSubmitControlsForForm(form, submitter, { markFormBusy: options.markFormBusy === true }); }; container.addEventListener('click', onClickCapture, true); container.addEventListener('submit', onSubmitCapture, true); const api = { isSubmitting: (formRef = null) => { const form = toFormElement(formRef); if (form) { return submittingForms.has(form); } return submittingForms.size > 0; }, destroy: () => { container.removeEventListener('click', onClickCapture, true); container.removeEventListener('submit', onSubmitCapture, true); submittingForms.clear(); submitterByForm.clear(); delete container._detailActionPolicyApi; } }; container._detailActionPolicyApi = api; return api; }