settings-site: phase 6 accessibility (ARIA combobox/listbox pattern, keyboard nav, modal focus trap, toast a11y, contrast + typography fixes)
This commit is contained in:
@@ -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
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user