Files
breadcrumb-the-shire/web/js/core/app-details-action-policy.js

274 lines
8.4 KiB
JavaScript

import { confirmDialog, requestSubmitWithFallback } from './app-confirm-dialog.js';
const escapeAttr = (value) => {
const raw = String(value || '');
if (typeof CSS !== 'undefined' && CSS && typeof CSS.escape === 'function') {
return CSS.escape(raw);
}
return raw.replace(/["\\]/g, '\\$&');
};
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 belongsToForm = (control, form) => {
if (!(control instanceof HTMLElement) || !(form instanceof HTMLFormElement)) {
return false;
}
const owner = control.form;
if (owner instanceof HTMLFormElement) {
return owner === form;
}
const controlFormAttr = String(control.getAttribute('form') || '').trim();
const formId = String(form.id || '').trim();
if (controlFormAttr !== '' && formId !== '') {
return controlFormAttr === formId;
}
return form.contains(control);
};
const isSubmitControl = (control) => {
if (!(control instanceof HTMLElement)) {
return false;
}
if (control instanceof HTMLButtonElement) {
const type = String(control.getAttribute('type') || 'submit').trim().toLowerCase();
return type === '' || type === 'submit';
}
if (control instanceof HTMLInputElement) {
const type = String(control.type || '').trim().toLowerCase();
return type === 'submit' || type === 'image';
}
return false;
};
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;
}