Files
breadcrumb-the-shire/web/js/core/app-detail-page-factory.js
fs d9f07dcd63 fix: web/ quick wins — CSS cleanup, ARIA tabs, and file header comments
CSS:
- Remove duplicate li::before block in app-search.css
- Fix typo: search-reuslt → search-result in app-search.css
- Remove commented-out CSS rules in app-flash.css
- Add descriptive header comment to all 27 CSS component files

JS:
- Complete WAI-ARIA Tabs pattern: generate IDs, add aria-controls on
  tab buttons and aria-labelledby on tab panels (app-tabs.js)
- Add JSDoc header comments to ~33 JS files (core + components)
- Add explanatory block comment to app-boot.js (why classic IIFE)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 22:21:37 +01:00

568 lines
17 KiB
JavaScript

/**
* Factory for standard detail pages — form dirty-tracking, unsaved indicator, validation routing, and keyboard save.
*/
import { initDetailActionPolicy } from './app-details-action-policy.js';
import { confirmDialog } from './app-confirm-dialog.js';
const DEFAULT_UNSAVED_MESSAGE = 'You have unsaved changes. Leave without saving?';
const toFormElement = (value) => {
if (!value) {return null;}
if (typeof value === 'string') {
return document.querySelector(value);
}
if (value instanceof HTMLFormElement) {
return value;
}
return null;
};
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 normalizeFieldKey = (key) => String(key || '').replace(/\[\]$/, '');
const normalizeFieldValue = (value) => {
if (value instanceof File) {
return `[file:${value.name || ''}:${Number(value.size || 0)}]`;
}
return String(value ?? '');
};
const createFormStateMap = (form) => {
const data = new FormData(form);
const state = new Map();
data.forEach((value, key) => {
const normalizedKey = normalizeFieldKey(key);
const normalizedValue = normalizeFieldValue(value);
if (!state.has(normalizedKey)) {
state.set(normalizedKey, []);
}
state.get(normalizedKey).push(normalizedValue);
});
state.forEach((values) => values.sort());
return state;
};
const countChangedFields = (initialState, currentState) => {
const keys = new Set([...initialState.keys(), ...currentState.keys()]);
let changed = 0;
keys.forEach((key) => {
const initialValues = initialState.get(key) || [];
const currentValues = currentState.get(key) || [];
if (initialValues.length !== currentValues.length) {
changed += 1;
return;
}
for (let index = 0; index < initialValues.length; index += 1) {
if (initialValues[index] !== currentValues[index]) {
changed += 1;
return;
}
}
});
return changed;
};
const formatCountLabel = (template, count) => {
const normalizedTemplate = String(template || '').trim();
if (normalizedTemplate === '') {
return String(count);
}
if (normalizedTemplate.includes('{count}')) {
return normalizedTemplate.replace('{count}', String(count));
}
if (normalizedTemplate.includes('%d')) {
return normalizedTemplate.replace('%d', String(count));
}
return `${normalizedTemplate} (${count})`;
};
const belongsToForm = (el, form) => {
if (!(el instanceof HTMLElement)) {
return false;
}
const formAttr = (el.getAttribute('form') || '').trim();
if (formAttr !== '') {
const formId = (form.id || '').trim();
return formId !== '' && formAttr === formId;
}
return form.contains(el);
};
const findPrimarySubmitter = (form, titlebar, savePrimarySelector = '[data-detail-save-primary="1"]') => {
const preferred = [];
if (titlebar) {
preferred.push(...Array.from(titlebar.querySelectorAll(savePrimarySelector)));
}
preferred.push(...Array.from(form.querySelectorAll(savePrimarySelector)));
const formId = (form.id || '').trim();
if (formId !== '') {
const escapedId = escapeAttr(formId);
preferred.push(...Array.from(document.querySelectorAll(`${savePrimarySelector}[form="${escapedId}"]`)));
}
const unique = Array.from(new Set(preferred));
const firstPreferred = unique.find((el) => belongsToForm(el, form) && !el.disabled);
if (firstPreferred) {
return firstPreferred;
}
const fallback = [];
fallback.push(...Array.from(form.querySelectorAll('button[type="submit"], input[type="submit"]')));
if (formId !== '') {
const escapedId = escapeAttr(formId);
fallback.push(...Array.from(document.querySelectorAll(`button[type="submit"][form="${escapedId}"], input[type="submit"][form="${escapedId}"]`)));
}
return fallback.find((el) => belongsToForm(el, form) && !el.disabled) || null;
};
const resolveNamedControl = (form, fieldName) => {
const normalizedName = normalizeFieldKey(fieldName).trim();
if (normalizedName === '') {
return null;
}
const escapedName = escapeAttr(normalizedName);
const controls = [
...Array.from(form.querySelectorAll(`[name="${escapedName}"]`))
];
const formId = (form.id || '').trim();
if (formId !== '') {
const escapedId = escapeAttr(formId);
controls.push(...Array.from(document.querySelectorAll(`[name="${escapedName}"][form="${escapedId}"]`)));
}
return controls.find((el) => belongsToForm(el, form) && !el.disabled) || controls[0] || null;
};
const resolveNamedControls = (form, fieldName) => {
const normalizedName = normalizeFieldKey(fieldName).trim();
if (normalizedName === '') {
return [];
}
const escapedName = escapeAttr(normalizedName);
const controls = [
...Array.from(form.querySelectorAll(`[name="${escapedName}"]`))
];
const formId = (form.id || '').trim();
if (formId !== '') {
const escapedId = escapeAttr(formId);
controls.push(...Array.from(document.querySelectorAll(`[name="${escapedName}"][form="${escapedId}"]`)));
}
return Array.from(new Set(controls))
.filter((el) => el instanceof HTMLElement)
.filter((el) => belongsToForm(el, form));
};
const revealInDetailsTree = (target) => {
if (!(target instanceof HTMLElement)) {
return;
}
let node = target.parentElement;
while (node) {
if (node instanceof HTMLDetailsElement) {
node.open = true;
}
node = node.parentElement;
}
};
const resolveValidationTarget = (form, target = {}) => {
const targetId = String(target.fieldId || '').trim();
if (targetId !== '') {
const element = document.getElementById(targetId);
if (element && belongsToForm(element, form)) {
return element;
}
}
const targetName = String(target.fieldName || '').trim();
if (targetName !== '') {
const byName = resolveNamedControl(form, targetName);
if (byName) {
return byName;
}
}
const fallbackSelectors = [
'[aria-invalid="true"]',
'.is-invalid',
'input:invalid',
'select:invalid',
'textarea:invalid'
];
for (const selector of fallbackSelectors) {
const candidate = form.querySelector(selector);
if (candidate && !candidate.disabled) {
return candidate;
}
}
return null;
};
const focusValidationTarget = (target, summary = null) => {
if (!(target instanceof HTMLElement)) {
if (summary instanceof HTMLElement) {
summary.focus();
}
return false;
}
revealInDetailsTree(target);
const focusTarget = target.matches('input, select, textarea, button, [tabindex], a[href]')
? target
: target.querySelector('input, select, textarea, button, [tabindex], a[href]');
if (!(focusTarget instanceof HTMLElement)) {
if (summary instanceof HTMLElement) {
summary.focus();
}
return false;
}
try {
focusTarget.focus({ preventScroll: true });
} catch (error) {
focusTarget.focus();
}
if (typeof focusTarget.scrollIntoView === 'function') {
focusTarget.scrollIntoView({ block: 'center', inline: 'nearest' });
}
return true;
};
const initValidationSummaryExperience = (form, container, options = {}) => {
const summary = options.summary
|| container?.querySelector('[data-validation-summary]')
|| null;
if (!(summary instanceof HTMLElement)) {
return { summary: null, destroy: () => {} };
}
const autoFocusEnabled = options.autoFocus !== false && summary.dataset.validationSummaryAutofocus !== '0';
if (summary.id === '') {
const randomSuffix = Math.random().toString(36).slice(2, 10);
summary.id = `detail-validation-summary-${randomSuffix}`;
}
const applyValidationMarker = (target) => {
if (!(target instanceof HTMLElement)) {
return;
}
target.setAttribute('aria-invalid', 'true');
const summaryId = summary.id;
if (summaryId === '') {
return;
}
const describedBy = new Set(
String(target.getAttribute('aria-describedby') || '')
.split(/\s+/)
.map((token) => token.trim())
.filter(Boolean)
);
describedBy.add(summaryId);
target.setAttribute('aria-describedby', Array.from(describedBy).join(' '));
};
const resolveTargetsFromLink = (link) => {
const targetId = String(link?.dataset?.validationTargetId || '').trim();
if (targetId !== '') {
const target = resolveValidationTarget(form, { fieldId: targetId });
return target ? [target] : [];
}
const targetName = String(link?.dataset?.validationTargetName || '').trim();
if (targetName !== '') {
return resolveNamedControls(form, targetName);
}
return [];
};
const resolveTargetFromLink = (link) => resolveValidationTarget(form, {
fieldId: link?.dataset?.validationTargetId || '',
fieldName: link?.dataset?.validationTargetName || ''
});
const onSummaryClick = (event) => {
const trigger = event.target instanceof HTMLElement
? event.target.closest('[data-validation-error-link]')
: null;
if (!(trigger instanceof HTMLElement) || !summary.contains(trigger)) {
return;
}
event.preventDefault();
const target = resolveTargetFromLink(trigger);
if (!target) {
summary.focus();
return;
}
focusValidationTarget(target, summary);
};
summary.addEventListener('click', onSummaryClick);
const links = Array.from(summary.querySelectorAll('[data-validation-error-link]'));
links.forEach((link) => {
resolveTargetsFromLink(link).forEach(applyValidationMarker);
});
if (autoFocusEnabled) {
window.requestAnimationFrame(() => {
const firstLink = summary.querySelector('[data-validation-error-link]');
const target = firstLink
? resolveTargetFromLink(firstLink)
: resolveValidationTarget(form);
if (!focusValidationTarget(target, summary)) {
if (typeof summary.scrollIntoView === 'function') {
summary.scrollIntoView({ block: 'start', inline: 'nearest' });
}
}
});
}
return {
summary,
destroy: () => {
summary.removeEventListener('click', onSummaryClick);
}
};
};
export function initStandardDetailPage(options = {}) {
const form = toFormElement(options.form || options.formSelector || '[data-standard-detail-form]');
if (!form) {
return null;
}
if (form.dataset.standardDetailBound === '1' && form._standardDetailApi) {
return form._standardDetailApi;
}
const container = options.container
|| form.closest('.app-details-container')
|| document;
const titlebar = options.titlebar
|| container.querySelector('.app-details-titlebar');
const backLinkSelector = options.backLinkSelector || '.app-details-titlebar h1 a[href]';
const backLink = options.backLink
|| (titlebar ? titlebar.querySelector('h1 a[href]') : document.querySelector(backLinkSelector));
const savePrimarySelector = options.savePrimarySelector || '[data-detail-save-primary="1"]';
const actionPolicyEnabled = options.actionPolicy === false
? false
: (options.actionPolicy === true
? true
: titlebar?.getAttribute('data-detail-action-policy') === '1');
const actionPolicy = initDetailActionPolicy({
container,
enabled: actionPolicyEnabled
});
const unsavedMessage = String(
options.unsavedMessage
|| titlebar?.getAttribute('data-detail-unsaved-message')
|| DEFAULT_UNSAVED_MESSAGE
);
const unsavedIndicatorEnabled = options.unsavedIndicator !== false;
const unsavedIndicatorLabel = String(
options.unsavedIndicatorLabel
|| titlebar?.getAttribute('data-detail-unsaved-indicator-label')
|| 'Unsaved changes'
);
const unsavedIndicatorLabelOne = String(
options.unsavedIndicatorLabelOne
|| titlebar?.getAttribute('data-detail-unsaved-indicator-label-one')
|| ''
);
const unsavedIndicatorLabelMany = String(
options.unsavedIndicatorLabelMany
|| titlebar?.getAttribute('data-detail-unsaved-indicator-label-many')
|| ''
);
const titlebarActions = titlebar?.querySelector('.app-details-titlebar-actions') || null;
const primarySubmitter = findPrimarySubmitter(form, titlebar, savePrimarySelector);
const validationSummaryExperience = initValidationSummaryExperience(form, container, {
summary: options.validationSummary || null,
autoFocus: options.validationSummaryAutoFocus
});
let unsavedIndicator = titlebar?.querySelector('[data-detail-unsaved-indicator]') || null;
if (unsavedIndicatorEnabled && titlebarActions) {
if (!unsavedIndicator) {
unsavedIndicator = document.createElement('small');
unsavedIndicator.className = 'muted app-detail-unsaved-indicator';
unsavedIndicator.setAttribute('data-detail-unsaved-indicator', '1');
}
if (primarySubmitter && titlebarActions.contains(primarySubmitter)) {
titlebarActions.insertBefore(unsavedIndicator, primarySubmitter);
} else {
titlebarActions.prepend(unsavedIndicator);
}
}
let initialState = createFormStateMap(form);
let allowNextUnload = false;
const changedFieldCount = () => countChangedFields(initialState, createFormStateMap(form));
const isDirty = () => changedFieldCount() > 0;
const syncUnsavedIndicator = () => {
if (!unsavedIndicatorEnabled || !unsavedIndicator) {
return;
}
const count = changedFieldCount();
if (count <= 0) {
unsavedIndicator.hidden = true;
unsavedIndicator.textContent = '';
return;
}
const labelTemplate = count === 1
? (unsavedIndicatorLabelOne || unsavedIndicatorLabel)
: (unsavedIndicatorLabelMany || unsavedIndicatorLabel);
unsavedIndicator.hidden = false;
unsavedIndicator.textContent = formatCountLabel(labelTemplate, count);
};
const markClean = () => {
initialState = createFormStateMap(form);
syncUnsavedIndicator();
};
const shouldWarn = () => !actionPolicy.isSubmitting() && isDirty();
const onBeforeUnload = (event) => {
if (allowNextUnload) {
allowNextUnload = false;
return undefined;
}
if (!shouldWarn()) {
return undefined;
}
event.preventDefault();
event.returnValue = '';
return '';
};
const onBackLinkClick = async (event) => {
if (!shouldWarn()) {
return;
}
event.preventDefault();
const leaveWithoutSaving = await confirmDialog.confirm({
title: unsavedIndicatorLabel,
message: unsavedMessage,
variant: 'warning',
focus: 'cancel'
});
if (!leaveWithoutSaving) {
return;
}
// Avoid a second native beforeunload prompt after explicit confirmation.
allowNextUnload = true;
if (backLink instanceof HTMLAnchorElement) {
window.location.href = backLink.href;
}
};
const onFormStateChange = () => {
if (actionPolicy.isSubmitting()) {
return;
}
syncUnsavedIndicator();
};
const onFormReset = () => {
window.setTimeout(() => {
if (!actionPolicy.isSubmitting()) {
syncUnsavedIndicator();
}
}, 0);
};
let formObserver = null;
if (unsavedIndicatorEnabled && typeof MutationObserver !== 'undefined') {
// Observe dynamic form controls (for example MultiSelect hidden inputs).
formObserver = new MutationObserver(() => {
if (!actionPolicy.isSubmitting()) {
syncUnsavedIndicator();
}
});
formObserver.observe(form, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['name', 'value', 'checked', 'selected', 'disabled']
});
}
const onKeyDown = (event) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
return;
}
if (String(event.key || '').toLowerCase() !== 's') {
return;
}
event.preventDefault();
const submitter = findPrimarySubmitter(form, titlebar, savePrimarySelector);
if (submitter && typeof form.requestSubmit === 'function') {
form.requestSubmit(submitter);
return;
}
if (submitter) {
submitter.click();
return;
}
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
return;
}
form.submit();
};
window.addEventListener('beforeunload', onBeforeUnload);
form.addEventListener('input', onFormStateChange);
form.addEventListener('change', onFormStateChange);
form.addEventListener('reset', onFormReset);
document.addEventListener('keydown', onKeyDown);
if (backLink) {
backLink.addEventListener('click', onBackLinkClick);
}
syncUnsavedIndicator();
const destroy = () => {
window.removeEventListener('beforeunload', onBeforeUnload);
form.removeEventListener('input', onFormStateChange);
form.removeEventListener('change', onFormStateChange);
form.removeEventListener('reset', onFormReset);
document.removeEventListener('keydown', onKeyDown);
if (backLink) {
backLink.removeEventListener('click', onBackLinkClick);
}
if (formObserver) {
formObserver.disconnect();
}
actionPolicy.destroy();
validationSummaryExperience.destroy();
delete form._standardDetailApi;
delete form.dataset.standardDetailBound;
};
const api = {
form,
validationSummary: validationSummaryExperience.summary,
isDirty,
markClean,
destroy
};
form.dataset.standardDetailBound = '1';
form._standardDetailApi = api;
return api;
}
export function autoInitStandardDetailPages(root = document) {
if (!root || typeof root.querySelectorAll !== 'function') {
return [];
}
const forms = Array.from(root.querySelectorAll('[data-standard-detail-form]'));
return forms
.map((form) => initStandardDetailPage({ form }))
.filter(Boolean);
}