Files
broccolini-bot/settings-site/public/js/util.js

182 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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
};
})();