Consolidate settings normalization tests
This commit is contained in:
@@ -1,132 +1,195 @@
|
||||
@layer components {
|
||||
/* Flash notices — success/warning/error/info feedback banners with auto-dismiss timer. */
|
||||
.notice {
|
||||
/* ── Toast stack: fixed top-right overlay for all flash messages ────── */
|
||||
|
||||
.app-toast-stack {
|
||||
position: fixed;
|
||||
top: calc(var(--app-spacing) * 1.5);
|
||||
right: calc(var(--app-spacing) * 1.5);
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--app-spacing) * 0.5);
|
||||
width: 400px;
|
||||
max-width: calc(100vw - var(--app-spacing) * 3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* In admin layout: offset below topbar */
|
||||
.app-container ~ .app-toast-stack,
|
||||
body:has(.app-container) .app-toast-stack {
|
||||
top: calc(52px + var(--app-spacing));
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.app-toast-stack {
|
||||
top: calc(var(--app-spacing) * 1);
|
||||
right: calc(var(--app-spacing) * 1);
|
||||
left: calc(var(--app-spacing) * 1);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
body:has(.app-container) .app-toast-stack {
|
||||
top: calc(52px + var(--app-spacing) * 0.5);
|
||||
right: calc(var(--app-spacing) * 0.5);
|
||||
left: calc(var(--app-spacing) * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Individual toast notice ─────────────────────────────────────────── */
|
||||
|
||||
.notice {
|
||||
--app-notice-border-color: var(--app-muted-border-color);
|
||||
--app-notice-background-color: var(--app-card-background-color);
|
||||
--app-notice-accent-color: var(--app-color);
|
||||
background: var(--app-notice-background-color);
|
||||
padding: calc(var(--app-spacing) * .5) calc(var(--app-spacing) * 1);
|
||||
padding-right: calc(var(--app-spacing) * .5);
|
||||
border-left: 3px solid var(--app-notice-border-color);
|
||||
border-radius: var(--app-border-radius);
|
||||
box-shadow: var(--app-card-box-shadow, 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.06));
|
||||
padding: calc(var(--app-spacing) * 0.625) calc(var(--app-spacing) * 0.875);
|
||||
padding-right: calc(var(--app-spacing) * 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--app-spacing);
|
||||
gap: calc(var(--app-spacing) * 0.75);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: var(--app-notice-border-color);
|
||||
margin-block: calc(var(--app-spacing) * .75);
|
||||
}
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#async-messages .notice {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
padding-right: 5px;
|
||||
padding-left: 15px;
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: var(--app-notice-border-color);
|
||||
margin-block: 0;
|
||||
}
|
||||
/* ── Slide-in animation ──────────────────────────────────────────────── */
|
||||
|
||||
.notice {
|
||||
animation: toast-slide-in 250ms ease-out;
|
||||
}
|
||||
|
||||
.notice strong {
|
||||
.notice.toast-dismissing {
|
||||
animation: toast-slide-out 200ms ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-slide-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.notice,
|
||||
.notice.toast-dismissing {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Variant colors ──────────────────────────────────────────────────── */
|
||||
|
||||
.notice strong {
|
||||
color: var(--app-notice-accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.notice[data-variant=success] {
|
||||
.notice[data-variant=success] {
|
||||
--app-notice-border-color: var(--app-notice-success-border-color);
|
||||
--app-notice-background-color: var(--app-notice-success-background-color);
|
||||
--app-notice-accent-color: var(--app-notice-success-color);
|
||||
}
|
||||
}
|
||||
|
||||
.notice[data-variant=warning] {
|
||||
.notice[data-variant=warning] {
|
||||
--app-notice-border-color: var(--app-notice-warning-border-color);
|
||||
--app-notice-background-color: var(--app-notice-warning-background-color);
|
||||
--app-notice-accent-color: var(--app-notice-warning-color);
|
||||
}
|
||||
}
|
||||
|
||||
.notice[data-variant=info] {
|
||||
.notice[data-variant=info] {
|
||||
--app-notice-border-color: var(--app-notice-info-border-color);
|
||||
--app-notice-background-color: var(--app-notice-info-background-color);
|
||||
--app-notice-accent-color: var(--app-notice-info-color);
|
||||
}
|
||||
}
|
||||
|
||||
.notice[data-variant=error] {
|
||||
.notice[data-variant=error] {
|
||||
--app-notice-border-color: var(--app-notice-error-border-color);
|
||||
--app-notice-background-color: var(--app-notice-error-background-color);
|
||||
--app-notice-accent-color: var(--app-notice-error-color);
|
||||
}
|
||||
}
|
||||
|
||||
.notice.flash-timed::after {
|
||||
/* ── Progress bar timer ──────────────────────────────────────────────── */
|
||||
|
||||
.notice.flash-timed::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
background: var(--app-notice-border-color);
|
||||
transform-origin: left;
|
||||
transform: scaleX(0);
|
||||
animation: flash-progress var(--flash-timeout, 4000ms) linear forwards;
|
||||
opacity: 0.9;
|
||||
}
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.notice.flash-timed:hover::after {
|
||||
.notice.flash-timed:hover::after {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flash-progress {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
@keyframes flash-progress {
|
||||
from { transform: scaleX(0); }
|
||||
to { transform: scaleX(1); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.notice.flash-timed::after {
|
||||
animation: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notice button[type=submit] {
|
||||
border: 1px solid var(--app-notice-border-color) !important;
|
||||
background: var(--app-notice-background-color);
|
||||
/* ── Dismiss button ──────────────────────────────────────────────────── */
|
||||
|
||||
.notice button[type=submit],
|
||||
.notice button[type=button] {
|
||||
border: none !important;
|
||||
background: transparent;
|
||||
color: var(--app-notice-border-color);
|
||||
}
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notice ul {
|
||||
.notice button[type=submit]:hover,
|
||||
.notice button[type=button]:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── List formatting inside notices ──────────────────────────────────── */
|
||||
|
||||
.notice ul {
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.notice li:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-main-content .flash-stack {
|
||||
margin-bottom: var(--app-spacing);
|
||||
}
|
||||
.app-main-content:has(.app-details-container) .flash-stack {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.flash-stack .notice {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
.flash-stack .notice + .notice {
|
||||
margin-top: calc(var(--app-spacing) * .5);
|
||||
}
|
||||
|
||||
.notice li {
|
||||
.notice li {
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
.notice li:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,12 +618,7 @@
|
||||
padding-block: 1rem 2rem;
|
||||
}
|
||||
|
||||
.login-flash-container:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-content-container.container-small,
|
||||
.login-flash-container.container-small {
|
||||
.login-content-container.container-small {
|
||||
width: min(370px, calc(100vw - 2rem));
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user