DRY duplicated helpers (escapeAttr, belongsToForm, isSubmitControl, isEditableTarget) into web/js/core/app-form-utils.js and update 7 consumers. Add aria-live="polite" to flash messages, async messages, and search results for screen reader announcements. Add prefers-reduced-motion support to CSS tooltips. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
239 lines
7.4 KiB
JavaScript
239 lines
7.4 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|