182 lines
5.4 KiB
JavaScript
182 lines
5.4 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
|
||
let csrfToken = '';
|
||
|
||
async function fetchCsrfToken() {
|
||
const res = await fetch('/api/csrf-token', { credentials: 'same-origin' });
|
||
if (!res.ok) throw new Error('Failed to fetch CSRF token');
|
||
const data = await res.json();
|
||
csrfToken = data.csrfToken;
|
||
return csrfToken;
|
||
}
|
||
|
||
function csrfHeaders(base = {}) {
|
||
return { ...base, 'x-csrf-token': csrfToken };
|
||
}
|
||
|
||
function showToast(message, type = 'success') {
|
||
const isError = type === 'error';
|
||
const timeoutMs = isError ? 6000 : 3500;
|
||
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast toast-${type}`;
|
||
// 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);
|
||
scheduleDismiss();
|
||
}
|
||
|
||
function formatLocalDateTime(d) {
|
||
const pad = n => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
}
|
||
|
||
const MOBILE_BREAKPOINT = 900;
|
||
|
||
function isMobileViewport() {
|
||
return window.innerWidth <= MOBILE_BREAKPOINT;
|
||
}
|
||
|
||
function setSidebarOpen(open) {
|
||
document.body.classList.toggle('sidebar-open', open);
|
||
const toggle = document.getElementById('menu-toggle');
|
||
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,
|
||
showToast,
|
||
formatLocalDateTime,
|
||
isMobileViewport,
|
||
setSidebarOpen,
|
||
openModal,
|
||
closeModal,
|
||
getFocusableElements,
|
||
MOBILE_BREAKPOINT
|
||
};
|
||
})();
|