restore nice toast alerts

This commit is contained in:
johnnyq
2026-08-27 23:54:04 -04:00
parent 988788b7c0
commit 1119e13e5d
3 changed files with 164 additions and 33 deletions

View File

@@ -496,12 +496,107 @@ a:focus {
font-weight: 600;
}
/* --- Toast fade ---------------------------------------------------------
Bootstrap's .fade is a .15s transition, which next to toastr's old 1s
fadeOut reads as a blink. Bootstrap uses the same .showing class for both
directions, so in and out cannot be timed separately without JS - .5s is
the compromise. Change the duration here to taste. */
#itflowFlashToast.toast.fade,
/* --- Flash toast ---------------------------------------------------------
Restores the four things toastr did that a plain Bootstrap toast does not:
sits top-right rather than centred over what you are reading, carries an
icon for its type, counts its own timer down, and dismisses on a click
anywhere instead of only on the small x.
It also appears on the FIRST PAINT. includes/inc_alert_feedback.php renders
the element with .show already set and every state change here is an
animation, so nothing waits on bootstrap.Toast - which used to mean waiting
for DOMContentLoaded, and therefore for every parser-blocking script in the
document. The inline script in that file only handles the click and removes
the node; neither gates the appearance.
Durations are toastr's own: 300ms in, 5s on screen, 1s out. */
.itflow-toast {
position: relative;
overflow: hidden;
cursor: pointer;
animation:
itflow-toast-in 300ms ease-out both,
itflow-toast-out 1s ease-in 5s both;
}
/* Hovering holds it open, so a long message cannot disappear mid-sentence.
The progress bar needs its own rule - animation-play-state does not
cascade to a child's separate animation. */
.itflow-toast:hover,
.itflow-toast:hover .itflow-toast-progress {
animation-play-state: paused;
}
/* currentColor, so the bar inherits whichever contrast text-bg-* already
picked - white on success and danger, dark on warning and info - rather
than needing a rule per type. */
.itflow-toast-progress {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 3px;
background: currentColor;
opacity: .4;
transform-origin: left;
animation: itflow-toast-progress 5s linear both;
}
@keyframes itflow-toast-in {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* visibility flips on the last one percent rather than at the end, because a
discrete property switches at the MIDPOINT of its keyframe interval - put
it only on the 100% stop and the toast would vanish halfway through its own
fade. This is the fallback that matters if the inline remover never runs:
without it an invisible toast keeps sitting over the user menu, eating
clicks. */
@keyframes itflow-toast-out {
0% {
opacity: 1;
visibility: visible;
}
99% {
opacity: 0;
visibility: visible;
}
100% {
opacity: 0;
visibility: hidden;
}
}
@keyframes itflow-toast-progress {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
/* Reduced motion drops the slide and the moving bar but keeps the timed
dismissal - that is a timeout, not an animation the user is watching. */
@media (prefers-reduced-motion: reduce) {
.itflow-toast {
animation: itflow-toast-out 1s linear 5s both;
}
.itflow-toast-progress {
animation: none;
transform: scaleX(0);
}
}
/* The JS-raised toasts from itflowToast() still go through bootstrap.Toast,
so they keep the slower fade. */
.itflow-toast-js .toast.fade {
transition: opacity .5s linear;
}

View File

@@ -3,9 +3,14 @@
/**
* Flash alert rendering.
*
* Renders the session flash message as a Bootstrap 5 toast. Bootstrap's Toast
* component ships in bootstrap.bundle.min.js, which is already loaded, so this
* replaces toastr (and its jQuery dependency) without adding a library.
* Renders the session flash message as a toast, replacing toastr and its jQuery
* dependency without adding a library. It borrows Bootstrap's .toast styling
* but NOT its Toast component: the element ships with .show already on it and
* the whole appear/linger/fade cycle is a CSS animation, so the alert is on
* screen at first paint. Driving it with bootstrap.Toast meant waiting for
* DOMContentLoaded, which does not fire until every parser-blocking script has
* run - so it used to arrive well after the page had settled. See the
* .itflow-toast block in css/itflow_custom.css.
*
* SECURITY: the message is rendered into HTML here, not interpolated into a
* JavaScript string literal as it was previously. 429 of the ~653 flashAlert()
@@ -24,48 +29,79 @@ if (!empty($_SESSION['alert_message'])) {
// functions/sanitize.php. flashAlert() is called with more type names than
// Bootstrap has classes ('danger', 'alert' and a typo'd 'errpr' among
// them), and an unmapped value used to resolve to nothing at all.
$alert_style = 'text-bg-' . alertStyleClass($alert_type);
$alert_style_class = alertStyleClass($alert_type);
$alert_style = 'text-bg-' . $alert_style_class;
// text-bg-info and text-bg-warning are the two Bootstrap pairs with dark text
$alert_dark_text = in_array(alertStyleClass($alert_type), ['warning', 'info'], true);
$alert_dark_text = in_array($alert_style_class, ['warning', 'info'], true);
// Font Awesome 5 names - the vendored build is 5.15.4, so no fa-circle-*
// aliases. Keyed on alertStyleClass()' output rather than the raw type, so
// 'error', 'errpr' and 'danger' all land on the same icon the same way they
// already land on the same colour.
$alert_icons = [
'success' => 'fa-check-circle',
'info' => 'fa-info-circle',
'warning' => 'fa-exclamation-triangle',
'danger' => 'fa-times-circle',
'secondary' => 'fa-bell',
];
$alert_icon = $alert_icons[$alert_style_class] ?? 'fa-bell';
// Escaping lives in one place now - see alertMessageHtml() in functions/sanitize.php
$alert_safe_message = alertMessageHtml($_SESSION['alert_message']);
?>
<div class="toast-container position-fixed top-0 start-50 translate-middle-x p-3" style="z-index: 1090;">
<?php /* `show` is on the element from the server, and `fade` is gone: the
appearance is a CSS animation now, so the alert is on screen at
first paint instead of waiting for bootstrap.Toast. The old version
called it on DOMContentLoaded, which does not fire until every
parser-blocking script in the document has run - most of a megabyte
on a page with tinymce - so the alert turned up after you had
already started reading. toastr never had that problem because it
loaded in the header. */ ?>
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
<div id="itflowFlashToast"
class="toast fade align-items-center <?= $alert_style ?> border-0"
class="toast show itflow-toast align-items-center <?= $alert_style ?> border-0"
role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="d-flex align-items-center">
<i class="fas fa-fw <?= $alert_icon ?> fa-lg ms-3"></i>
<div class="toast-body"><?= $alert_safe_message ?></div>
<?php /* No data-bs-dismiss - the click handler below covers the
whole toast, and this button sits inside it. */ ?>
<button type="button"
class="btn-close <?= $alert_dark_text ? '' : 'btn-close-white' ?> me-2 m-auto"
data-bs-dismiss="toast" aria-label="Close"></button>
aria-label="Close"></button>
</div>
<div class="itflow-toast-progress"></div>
</div>
</div>
<script>
(function () {
// This include runs near the top of the page but bootstrap.bundle
// loads in the footer, so the toast has to wait for scripts to
// finish. toastr did not need this - it was loaded in the header.
function showFlashToast() {
var el = document.getElementById('itflowFlashToast');
if (el && window.bootstrap && bootstrap.Toast) {
bootstrap.Toast.getOrCreateInstance(el, {
animation: true,
autohide: true,
delay: 5000
}).show();
// Deliberately depends on nothing. It runs inline, right after the
// markup above, and does not wait for DOMContentLoaded - showing
// and hiding is CSS, so if this never ran the alert would still
// appear and still go away on its own.
var el = document.getElementById('itflowFlashToast');
if (!el) {
return;
}
// Click anywhere on it to dismiss, the way toastr did. The close
// button is inside the toast, so this covers it too.
el.addEventListener('click', function () {
el.remove();
});
// Remove on the fade rather than a fixed timer, so hovering to
// finish reading a long message keeps the node alive as well as
// visible.
el.addEventListener('animationend', function (event) {
if (event.animationName === 'itflow-toast-out') {
el.remove();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', showFlashToast);
} else {
showFlashToast();
}
});
})();
</script>

View File

@@ -804,7 +804,7 @@ function itflowToast(message, type) {
var container = document.querySelector('.toast-container.itflow-toast-js');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container itflow-toast-js position-fixed top-0 start-50 translate-middle-x p-3';
container.className = 'toast-container itflow-toast-js position-fixed top-0 end-0 p-3';
container.style.zIndex = '1090';
document.body.appendChild(container);
}