settings-site: phase 6 accessibility (ARIA combobox/listbox pattern, keyboard nav, modal focus trap, toast a11y, contrast + typography fixes)

This commit is contained in:
2026-04-18 19:30:15 +00:00
parent 0f62fb9020
commit 23a02c87d9
5 changed files with 376 additions and 31 deletions

View File

@@ -16,11 +16,59 @@
}
function showToast(message, type = 'success') {
const isError = type === 'error';
const timeoutMs = isError ? 6000 : 3500;
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
// status (polite) for info/success/warning; alert (assertive) for errors.
toast.setAttribute('role', isError ? 'alert' : 'status');
toast.setAttribute('aria-live', isError ? 'assertive' : 'polite');
toast.setAttribute('aria-atomic', 'true');
const messageEl = document.createElement('span');
messageEl.className = 'toast-message';
messageEl.textContent = message;
toast.appendChild(messageEl);
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'toast-close';
closeBtn.setAttribute('aria-label', 'Dismiss');
closeBtn.textContent = '×';
toast.appendChild(closeBtn);
// Auto-dismiss with hover-to-pause.
let remaining = timeoutMs;
let startedAt = 0;
let timer = null;
function dismiss() {
if (timer) clearTimeout(timer);
timer = null;
if (toast.parentNode) toast.remove();
}
function scheduleDismiss() {
startedAt = Date.now();
timer = setTimeout(dismiss, remaining);
}
function pauseDismiss() {
if (!timer) return;
clearTimeout(timer);
timer = null;
remaining -= Date.now() - startedAt;
}
closeBtn.addEventListener('click', dismiss);
toast.addEventListener('mouseenter', pauseDismiss);
toast.addEventListener('mouseleave', scheduleDismiss);
toast.addEventListener('focusin', pauseDismiss);
toast.addEventListener('focusout', scheduleDismiss);
document.getElementById('toast-container').appendChild(toast);
setTimeout(() => toast.remove(), 3500);
scheduleDismiss();
}
function formatLocalDateTime(d) {
@@ -40,6 +88,84 @@
if (toggle) toggle.setAttribute('aria-expanded', String(open));
}
// ---------- Modal focus management ----------
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
function getFocusableElements(container) {
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR))
.filter(el => !el.hasAttribute('inert') && el.offsetParent !== null);
}
// Per-modal teardown state; WeakMap keys by modal element to support many.
const modalState = new WeakMap();
function openModal(modalEl, options = {}) {
if (!modalEl) return;
if (modalState.has(modalEl)) return; // already open
const previouslyFocused = document.activeElement;
modalEl.classList.remove('hidden');
// Find something to focus. Prefer options.initialFocus, then first field, then modal card.
const card = modalEl.querySelector('.modal-card') || modalEl;
if (!card.hasAttribute('tabindex')) card.setAttribute('tabindex', '-1');
const initial = options.initialFocus
? modalEl.querySelector(options.initialFocus)
: getFocusableElements(modalEl)[0] || card;
initial?.focus();
const onKeydown = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
closeModal(modalEl);
options.onClose?.();
return;
}
if (e.key !== 'Tab') return;
const focusables = getFocusableElements(modalEl);
if (focusables.length === 0) {
e.preventDefault();
card.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
modalEl.addEventListener('keydown', onKeydown);
modalState.set(modalEl, { previouslyFocused, onKeydown });
}
function closeModal(modalEl) {
if (!modalEl) return;
const state = modalState.get(modalEl);
modalEl.classList.add('hidden');
if (!state) return;
modalEl.removeEventListener('keydown', state.onKeydown);
modalState.delete(modalEl);
const target = state.previouslyFocused;
if (target && typeof target.focus === 'function' && document.body.contains(target)) {
target.focus();
} else {
document.body.focus?.();
}
}
window.Util = {
fetchCsrfToken,
csrfHeaders,
@@ -47,6 +173,9 @@
formatLocalDateTime,
isMobileViewport,
setSidebarOpen,
openModal,
closeModal,
getFocusableElements,
MOBILE_BREAKPOINT
};
})();