Files
breadcrumb-the-shire/web/js/components/app-flash-auto-dismiss.js
fs 1ba6829a1c fix: toast visibility — opaque backgrounds and flash scope matching with query strings
Two bugs in the toast redesign:

1. Variant backgrounds (success/warning/info/error) used rgba with 12-20%
   opacity, making toasts semi-transparent over page content. Fixed by
   layering the tint over a solid background via linear-gradient compositing.

2. Flash messages with scoped paths including query strings (e.g.
   edit pages with ?return=...) were not matched by Flash::peek(Request::path())
   since path() strips the query. Added fallback to pathWithQuery() matching.

Also moved flash partial before JS init scripts to ensure DOM is present
when the component runtime mounts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:58:15 +01:00

99 lines
2.6 KiB
JavaScript

/**
* 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 = '.notice[data-flash-timeout]',
defaultTimeout = 0,
} = options;
const host = resolveHost(root);
const notices = Array.from(host.querySelectorAll(selector));
if (!notices.length) {
return { destroy: () => {} };
}
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');
if (!action) {
return null;
}
const body = new URLSearchParams(new FormData(form));
return fetch(action, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'fetch',
},
body,
});
};
notices.forEach((notice) => {
const timeout = Number.parseInt(
notice.dataset.flashTimeout || `${defaultTimeout}`,
10
);
if (!timeout || timeout <= 0) {
return;
}
notice.style.setProperty('--flash-timeout', `${timeout}ms`);
notice.classList.add('flash-timed');
let 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);
}, 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 = () => {
destroyed = true;
timers.forEach((timer) => window.clearTimeout(timer));
};
return { destroy };
}