Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
126 lines
4.1 KiB
JavaScript
126 lines
4.1 KiB
JavaScript
/**
|
|
* Declarative confirm-before-action — intercepts clicks and submits on [data-confirm-message] elements.
|
|
*/
|
|
import { confirmDialog, requestSubmitWithFallback } from '../core/app-confirm-dialog.js';
|
|
import { isSubmitControl, belongsToForm } from '../core/app-form-utils.js';
|
|
|
|
const CONFIRM_ATTR = 'data-confirm-message';
|
|
const BOUND_KEY = '_confirmActionsBound';
|
|
const confirmedSubmitterByForm = new WeakMap();
|
|
const confirmedClickElements = new WeakSet();
|
|
|
|
const trimAttr = (element, attr) => String(element?.getAttribute(attr) || '').trim();
|
|
|
|
const submitterByForm = new Map();
|
|
|
|
const readConfirmOption = (primary, fallback, suffix) => {
|
|
const attr = `data-confirm-${suffix}`;
|
|
return trimAttr(primary, attr) || trimAttr(fallback, attr);
|
|
};
|
|
|
|
const buildConfirmOptions = (message, primary, fallback) => ({
|
|
message,
|
|
title: readConfirmOption(primary, fallback, 'title'),
|
|
confirmLabel: readConfirmOption(primary, fallback, 'confirm-label'),
|
|
cancelLabel: readConfirmOption(primary, fallback, 'cancel-label'),
|
|
variant: readConfirmOption(primary, fallback, 'variant'),
|
|
focus: readConfirmOption(primary, fallback, 'focus'),
|
|
describedBy: readConfirmOption(primary, fallback, 'describedby')
|
|
|| readConfirmOption(primary, fallback, 'described-by'),
|
|
actionKind: readConfirmOption(primary, fallback, 'action-kind')
|
|
});
|
|
|
|
const onClickCapture = (event) => {
|
|
const trigger = event.target instanceof HTMLElement ? event.target.closest('a, button, input') : null;
|
|
if (!(trigger instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
if (confirmedClickElements.has(trigger)) {
|
|
confirmedClickElements.delete(trigger);
|
|
return;
|
|
}
|
|
|
|
if (isSubmitControl(trigger)) {
|
|
const form = trigger.form instanceof HTMLFormElement ? trigger.form : null;
|
|
if (form) {
|
|
submitterByForm.set(form, trigger);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const confirmMessage = String(trigger.getAttribute(CONFIRM_ATTR) || '').trim();
|
|
if (confirmMessage === '') {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
void confirmDialog.confirm(buildConfirmOptions(confirmMessage, trigger, null))
|
|
.then((approved) => {
|
|
if (!approved || !document.contains(trigger)) {
|
|
return;
|
|
}
|
|
confirmedClickElements.add(trigger);
|
|
trigger.click();
|
|
});
|
|
};
|
|
|
|
const onSubmitCapture = (event) => {
|
|
const form = event.target instanceof HTMLFormElement ? event.target : null;
|
|
if (!(form instanceof HTMLFormElement)) {
|
|
return;
|
|
}
|
|
|
|
const submitter = event.submitter instanceof HTMLElement
|
|
? event.submitter
|
|
: (submitterByForm.get(form) || null);
|
|
submitterByForm.delete(form);
|
|
|
|
const confirmedSubmitter = confirmedSubmitterByForm.get(form);
|
|
if (confirmedSubmitter && (confirmedSubmitter === true || confirmedSubmitter === submitter || confirmedSubmitter === null)) {
|
|
confirmedSubmitterByForm.delete(form);
|
|
return;
|
|
}
|
|
|
|
const submitterMessage = String(submitter?.getAttribute(CONFIRM_ATTR) || '').trim();
|
|
const formMessage = String(form.getAttribute(CONFIRM_ATTR) || '').trim();
|
|
const confirmMessage = submitterMessage !== '' ? submitterMessage : formMessage;
|
|
|
|
if (confirmMessage === '') {
|
|
return;
|
|
}
|
|
|
|
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);
|
|
});
|
|
};
|
|
|
|
export function initConfirmActions(root = document) {
|
|
const host = root && typeof root.addEventListener === 'function' ? root : document;
|
|
if (host[BOUND_KEY]) {
|
|
return { destroy: () => {} };
|
|
}
|
|
host[BOUND_KEY] = true;
|
|
|
|
host.addEventListener('click', onClickCapture, true);
|
|
host.addEventListener('submit', onSubmitCapture, true);
|
|
|
|
const destroy = () => {
|
|
host.removeEventListener('click', onClickCapture, true);
|
|
host.removeEventListener('submit', onSubmitCapture, true);
|
|
delete host[BOUND_KEY];
|
|
};
|
|
return { destroy };
|
|
}
|