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>
568 lines
17 KiB
JavaScript
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);
|
|
}
|