Consolidate settings normalization tests

This commit is contained in:
2026-03-19 19:43:55 +01:00
parent 83aadb3535
commit bc72fa50a6
14 changed files with 424 additions and 320 deletions

View File

@@ -2,8 +2,9 @@ import { warnOnce } from '../core/app-dom.js';
/**
* Async Flash Messages & Loading State
* Renders flash messages into #async-messages for JS-triggered notifications
* Provides loading cursor state management
* Renders flash messages into the .app-toast-stack for JS-triggered notifications.
* Creates the stack container on demand if it doesn't exist yet.
* Provides loading cursor state management.
*/
/**
@@ -33,24 +34,67 @@ export async function withLoading(fn) {
}
const defaultTimeouts = {
success: 10000,
info: 10000,
warning: 10000,
error: 0
success: 5000,
info: 6000,
warning: 7000,
error: 0,
};
const MAX_VISIBLE_TOASTS = 5;
/**
* Get or create the toast stack container.
* @returns {HTMLElement}
*/
function getToastStack() {
let stack = document.querySelector('.app-toast-stack');
if (!stack) {
stack = document.createElement('div');
stack.className = 'app-toast-stack';
stack.setAttribute('aria-live', 'polite');
document.body.appendChild(stack);
}
return stack;
}
/**
* Dismiss a toast with slide-out animation.
* @param {HTMLElement} notice
*/
function dismissToast(notice) {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
notice.remove();
return;
}
notice.classList.add('toast-dismissing');
notice.addEventListener('animationend', () => notice.remove(), { once: true });
// Fallback removal if animation doesn't fire
setTimeout(() => { if (notice.parentNode) notice.remove(); }, 300);
}
/**
* Enforce max visible toasts — dismiss oldest when limit exceeded.
* @param {HTMLElement} stack
*/
function enforceMaxVisible(stack) {
const notices = stack.querySelectorAll('.notice:not(.toast-dismissing)');
if (notices.length > MAX_VISIBLE_TOASTS) {
for (let i = 0; i < notices.length - MAX_VISIBLE_TOASTS; i++) {
dismissToast(notices[i]);
}
}
}
/**
* Show an async flash message
* @param {string} type - Message type: 'success', 'info', 'warning', 'error'
* @param {string} message - The message text
* @param {number} [timeout] - Auto-dismiss timeout in ms (0 = no auto-dismiss)
* @returns {HTMLElement|null}
*/
export function showAsyncFlash(type, message, timeout) {
const container = document.getElementById('async-messages');
if (!container) {
warnOnce('UI_EL_MISSING', 'Missing #async-messages container', { module: 'async-flash' });
return null;
}
const stack = getToastStack();
const effectiveTimeout = timeout ?? defaultTimeouts[type] ?? 5000;
@@ -68,17 +112,27 @@ export function showAsyncFlash(type, message, timeout) {
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'contrast outline small';
closeButton.innerHTML = '<i class="bi bi-x"></i>';
closeButton.addEventListener('click', () => notice.remove());
closeButton.setAttribute('aria-label', 'Dismiss');
closeButton.innerHTML = '<i class="bi bi-x-lg"></i>';
closeButton.addEventListener('click', () => dismissToast(notice));
notice.appendChild(closeButton);
container.appendChild(notice);
stack.appendChild(notice);
enforceMaxVisible(stack);
if (effectiveTimeout > 0) {
notice.style.setProperty('--flash-timeout', `${effectiveTimeout}ms`);
notice.classList.add('flash-timed');
setTimeout(() => notice.remove(), effectiveTimeout);
let timer = setTimeout(() => dismissToast(notice), effectiveTimeout);
// Pause timer on hover
notice.addEventListener('mouseenter', () => {
clearTimeout(timer);
});
notice.addEventListener('mouseleave', () => {
timer = setTimeout(() => dismissToast(notice), 1000);
});
}
return notice;
@@ -88,8 +142,8 @@ export function showAsyncFlash(type, message, timeout) {
* Clear all async flash messages
*/
export function clearAsyncFlash() {
const container = document.getElementById('async-messages');
if (container) {
container.innerHTML = '';
const stack = document.querySelector('.app-toast-stack');
if (stack) {
stack.querySelectorAll('.notice').forEach((n) => dismissToast(n));
}
}

View File

@@ -1,11 +1,12 @@
/**
* Auto-dismisses flash notices after their data-flash-timeout expires.
* Supports slide-out animation and hover-pause.
*/
import { resolveHost } from '../core/app-dom.js';
export function initFlashAutoDismiss(root = document, options = {}) {
const {
selector = '.flash-stack .notice[data-flash-timeout]',
selector = '.app-toast-stack .notice[data-flash-timeout]',
defaultTimeout = 0,
} = options;
const host = resolveHost(root);
@@ -16,6 +17,17 @@ export function initFlashAutoDismiss(root = document, options = {}) {
const timers = [];
let destroyed = false;
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const dismissNotice = (notice) => {
if (prefersReducedMotion) {
notice.remove();
return;
}
notice.classList.add('toast-dismissing');
notice.addEventListener('animationend', () => notice.remove(), { once: true });
setTimeout(() => { if (notice.parentNode) notice.remove(); }, 300);
};
const postForm = async (form) => {
const action = form.getAttribute('action');
@@ -44,7 +56,7 @@ export function initFlashAutoDismiss(root = document, options = {}) {
notice.style.setProperty('--flash-timeout', `${timeout}ms`);
notice.classList.add('flash-timed');
const timer = window.setTimeout(async () => {
let timer = window.setTimeout(async () => {
if (destroyed) {
return;
}
@@ -55,9 +67,26 @@ export function initFlashAutoDismiss(root = document, options = {}) {
return;
}
}
notice.remove();
dismissNotice(notice);
}, timeout);
timers.push(timer);
// Pause auto-dismiss on hover
notice.addEventListener('mouseenter', () => {
window.clearTimeout(timer);
});
notice.addEventListener('mouseleave', () => {
timer = window.setTimeout(async () => {
if (destroyed) return;
const form = notice.querySelector('form');
if (form) {
const response = await postForm(form);
if (!response || !response.ok) return;
}
dismissNotice(notice);
}, 1000);
timers.push(timer);
});
});
const destroy = () => {