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:
2026-03-13 22:28:46 +01:00
parent d9f07dcd63
commit 2b915f5a43
12 changed files with 72 additions and 119 deletions

View File

@@ -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}`;

View File

@@ -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(() => {

View File

@@ -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;}

View File

@@ -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]'))

View File

@@ -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;}