1
0

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

@@ -18,7 +18,7 @@ $timeouts = [
];
?>
<div class="flash-stack">
<div class="flash-stack" role="status" aria-live="polite">
<?php foreach ($messages as $message) : ?>
<?php $type = $message['type'] ?? 'info'; ?>
<?php $timeout = $timeouts[$type] ?? 5000; ?>

View File

@@ -480,7 +480,7 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
<small class="muted" data-search-empty-hint-template="<?php e(t('Tip: Use {shortcut} to focus search quickly.')); ?>"></small>
</div>
<nav id="global-search">
<ul class="app-search-results" data-global-search-results>
<ul class="app-search-results" data-global-search-results aria-live="polite">
<?php if ($canViewUsers): ?>
<?php $usersSearch = navActive('admin/users', true); ?>
<li data-search-key="users" data-search-base="<?php e(lurl('admin/users')); ?>">

View File

@@ -32,7 +32,7 @@ $allowUserTheme = allowUserTheme();
<li>
<a id="global-forward" data-tooltip-pos="bottom" data-tooltip="<?php e(t('Forward')); ?> (Alt+→)" title="<?php e(t('Forward')); ?> (Alt+→)" aria-label="<?php e(t('Forward')); ?> (Alt+→)" href="admin"><i class="bi bi-arrow-right"></i></a>
</li>
<li id="async-messages"></li>
<li id="async-messages" role="status" aria-live="polite"></li>
</ul>
<div class="app-topbar-center" aria-hidden="true"></div>
<ul class="app-topbar-right">

View File

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

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

View File

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

View File

@@ -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 [];

View 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';
};