refactor: extract shared form utils, add aria-live regions and reduced-motion support
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>
This commit is contained in:
@@ -99,4 +99,11 @@
|
||||
left: auto;
|
||||
transform: translate(calc(-1 * (var(--tooltip-offset) - 2px)), -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-tooltip]::after,
|
||||
[data-tooltip]::before {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 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 confirmedSubmitterByForm = new WeakMap();
|
||||
@@ -9,37 +10,7 @@ const confirmedClickElements = new WeakSet();
|
||||
|
||||
const trimAttr = (element, attr) => String(element?.getAttribute(attr) || '').trim();
|
||||
|
||||
const isSubmitControl = (element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
if (element instanceof HTMLButtonElement) {
|
||||
const type = String(element.getAttribute('type') || 'submit').trim().toLowerCase();
|
||||
return type === '' || type === 'submit';
|
||||
}
|
||||
if (element instanceof HTMLInputElement) {
|
||||
const type = String(element.type || '').trim().toLowerCase();
|
||||
return type === 'submit' || type === 'image';
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const submitterByForm = new Map();
|
||||
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 = trimAttr(control, 'form');
|
||||
const formId = trimAttr(form, 'id');
|
||||
if (controlFormAttr !== '' && formId !== '') {
|
||||
return controlFormAttr === formId;
|
||||
}
|
||||
return form.contains(control);
|
||||
};
|
||||
|
||||
const readConfirmOption = (primary, fallback, suffix) => {
|
||||
const attr = `data-confirm-${suffix}`;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Global search sidebar — debounced fetch with live result counts and Ctrl+K shortcut.
|
||||
*/
|
||||
import { requireEl, optionalEl, warnOnce } from '../core/app-dom.js';
|
||||
import { isEditableTarget } from '../core/app-form-utils.js';
|
||||
|
||||
const input = requireEl('#side-search', { module: 'global-search' });
|
||||
const resultsEl = requireEl('[data-global-search-results]', { module: 'global-search' });
|
||||
@@ -191,13 +192,6 @@ if (input && resultsEl) {
|
||||
submitSearch();
|
||||
});
|
||||
|
||||
const isEditableTarget = (target) => {
|
||||
if (!target) {return false;}
|
||||
if (target.isContentEditable) {return true;}
|
||||
const tag = target.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||||
};
|
||||
|
||||
const focusSearch = () => {
|
||||
if (!input) {return;}
|
||||
window.requestAnimationFrame(() => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Browser history integration — handles back/forward navigation state.
|
||||
*/
|
||||
import { isEditableTarget } from '../core/app-form-utils.js';
|
||||
|
||||
const updateHistoryButtons = () => {
|
||||
const back = document.querySelector('#global-back');
|
||||
const forward = document.querySelector('#global-forward');
|
||||
@@ -29,13 +31,6 @@ const initHistoryButtons = () => {
|
||||
const back = document.querySelector('#global-back');
|
||||
const forward = document.querySelector('#global-forward');
|
||||
|
||||
const isEditableTarget = (target) => {
|
||||
if (!target) {return false;}
|
||||
if (target.isContentEditable) {return true;}
|
||||
const tag = target.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (!event.altKey || event.metaKey || event.ctrlKey) {return;}
|
||||
if (isEditableTarget(event.target)) {return;}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Aside panel switcher — opens/closes named sidebar panels.
|
||||
*/
|
||||
import { requireEl, warnOnce } from '../core/app-dom.js';
|
||||
import { isEditableTarget } from '../core/app-form-utils.js';
|
||||
import { initPersistedDetailsGroup } from '../core/app-details-open-state.js';
|
||||
|
||||
export function initAsidePanels(options = {}) {
|
||||
@@ -40,12 +41,6 @@ export function initAsidePanels(options = {}) {
|
||||
const getTabKey = (tab) => tab?.dataset?.asideTarget || '';
|
||||
const getPanelTools = (panel) => panel?.querySelector('[data-aside-panel-tools]') || null;
|
||||
const getTabHref = (tab) => tab?.dataset?.asideHref || '';
|
||||
const isEditableTarget = (target) => {
|
||||
if (!target) {return false;}
|
||||
if (target.isContentEditable) {return true;}
|
||||
const tag = target.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||||
};
|
||||
|
||||
const panelDetailsRegistry = new WeakMap();
|
||||
const findActiveDetailsKeys = (panel) => Array.from(panel.querySelectorAll('details[data-details-key]'))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Sidebar collapse/show toggle with localStorage persistence and Ctrl+B shortcut.
|
||||
*/
|
||||
import { requireEl, warnOnce } from '../core/app-dom.js';
|
||||
import { isEditableTarget } from '../core/app-form-utils.js';
|
||||
|
||||
export function initSidebarToggle(options = {}) {
|
||||
const {
|
||||
@@ -150,13 +151,6 @@ export function initSidebarToggle(options = {}) {
|
||||
return controller;
|
||||
}
|
||||
|
||||
const isEditableTarget = (target) => {
|
||||
if (!target) {return false;}
|
||||
if (target.isContentEditable) {return true;}
|
||||
const tag = target.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (!(event.metaKey || event.ctrlKey)) {return;}
|
||||
if (event.altKey) {return;}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { initDetailActionPolicy } from './app-details-action-policy.js';
|
||||
import { confirmDialog } from './app-confirm-dialog.js';
|
||||
import { escapeAttr, belongsToForm } from './app-form-utils.js';
|
||||
|
||||
const DEFAULT_UNSAVED_MESSAGE = 'You have unsaved changes. Leave without saving?';
|
||||
|
||||
@@ -17,14 +18,6 @@ const toFormElement = (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) => {
|
||||
@@ -83,18 +76,6 @@ const formatCountLabel = (template, 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) {
|
||||
|
||||
@@ -2,14 +2,7 @@
|
||||
* Form submit policy for detail pages — confirm dialogs, double-submit prevention, and aria-busy.
|
||||
*/
|
||||
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, '\\$&');
|
||||
};
|
||||
import { escapeAttr, belongsToForm, isSubmitControl } from './app-form-utils.js';
|
||||
|
||||
const toContainerElement = (value) => {
|
||||
if (!value) {
|
||||
@@ -46,37 +39,6 @@ const normalizeSubmitLock = (value) => {
|
||||
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 [];
|
||||
|
||||
54
web/js/core/app-form-utils.js
Normal file
54
web/js/core/app-form-utils.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Shared form utility helpers — used by confirm-actions, detail-action-policy,
|
||||
* and detail-page-factory to avoid duplicated submit/form-ownership logic.
|
||||
*/
|
||||
|
||||
/** Escape a string for safe use inside a CSS attribute selector value. */
|
||||
export const escapeAttr = (value) => {
|
||||
const raw = String(value || '');
|
||||
if (typeof CSS !== 'undefined' && CSS && typeof CSS.escape === 'function') {
|
||||
return CSS.escape(raw);
|
||||
}
|
||||
return raw.replace(/["\\]/g, '\\$&');
|
||||
};
|
||||
|
||||
/** Check whether a control belongs to a given form (by .form, form="" attr, or containment). */
|
||||
export 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);
|
||||
};
|
||||
|
||||
/** Check whether an element is a submit button or submit input. */
|
||||
export 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;
|
||||
};
|
||||
|
||||
/** Check whether the event target is an editable element (input, textarea, select, contentEditable). */
|
||||
export const isEditableTarget = (target) => {
|
||||
if (!target) { return false; }
|
||||
if (target.isContentEditable) { return true; }
|
||||
const tag = target.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||||
};
|
||||
Reference in New Issue
Block a user