mirror of
https://github.com/itflow-org/itflow
synced 2026-08-28 10:25:12 +00:00
Make each page refresh feel less Jenky with Smooth Fade Page Transitions
This commit is contained in:
@@ -1380,3 +1380,122 @@ select.select2[multiple]:not(.tomselected) {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================================================
|
||||
CROSS-DOCUMENT VIEW TRANSITIONS
|
||||
===========================================================================
|
||||
Crossfades between full page loads on same-origin navigation. No JS: the
|
||||
browser snapshots the outgoing page, loads the incoming one, and animates
|
||||
between the two snapshots.
|
||||
|
||||
Both documents have to carry the at-rule for a navigation to animate, which
|
||||
is why it lives here - every ITFlow entry point loads this stylesheet, so
|
||||
agent, admin, client portal, guest, login and setup all opt in together.
|
||||
|
||||
Browsers without support ignore the entire block and navigate exactly as
|
||||
they did before. Nothing below is load-bearing.
|
||||
|
||||
Skipped automatically, no handling needed: reloads, address-bar and
|
||||
bookmark navigations, cross-origin redirects (the M365 OAuth callback, the
|
||||
Stripe return legs) and anything that never commits a navigation, such as
|
||||
an authenticated file download. A navigation slower than roughly four
|
||||
seconds is dropped back to an ordinary page load. */
|
||||
@view-transition {
|
||||
navigation: auto;
|
||||
}
|
||||
|
||||
/* Shorter than the 0.25s UA default - this should read as a soft handoff
|
||||
rather than an animation you sit through. Overriding `animation` alone
|
||||
keeps the UA stylesheet's mix-blend-mode: plus-lighter, which is what lets
|
||||
regions that are identical on both pages crossfade without visibly dipping
|
||||
through a lighter frame in the middle. */
|
||||
::view-transition-old(root) {
|
||||
animation: itflow-vt-fade-out 120ms ease both;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation: itflow-vt-fade-in 160ms ease both;
|
||||
}
|
||||
|
||||
@keyframes itflow-vt-fade-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes itflow-vt-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* The header and side nav are deliberately NOT given a view-transition-name.
|
||||
|
||||
The first version of this block did, and froze both with animation:none so
|
||||
they would sit still through the crossfade. That was a mistake. A named
|
||||
element gets its own ::view-transition-group, and a frozen group holds the
|
||||
OLD geometry for the whole transition while the incoming snapshot is
|
||||
stretched to fit it, snapping to its real size only at the end. Geometry
|
||||
here is not constant the way I assumed: .app-header and .app-sidebar are
|
||||
fixed to the viewport, and the viewport width changes by the scrollbar width
|
||||
whenever you navigate between a page long enough to scroll and one that is
|
||||
not. Hence some navigations looking fine and others not.
|
||||
|
||||
Leaving them in the root snapshot is both simpler and better. The UA
|
||||
stylesheet crossfades ::view-transition-old/new with
|
||||
mix-blend-mode: plus-lighter precisely so that regions which are identical
|
||||
on both pages hold steady rather than dipping through a lighter frame in the
|
||||
middle, and the chrome is identical apart from which nav item carries
|
||||
.active. Overriding `animation` alone above keeps that blend mode. */
|
||||
|
||||
/* Keep the layout viewport a constant width.
|
||||
|
||||
This is what was making some navigations judder and others not. AdminLTE
|
||||
sizes .app-wrapper, .app-header, .app-main and .app-footer against 100vw,
|
||||
and 100vw INCLUDES the scrollbar, so the usable width is 100vw minus
|
||||
whatever the scrollbar takes. Nothing reserves that space, so the vertical
|
||||
scrollbar - and with it the layout width - appears and disappears purely on
|
||||
whether a given page happens to be taller than the window.
|
||||
|
||||
For a view transition that is not cosmetic. ::view-transition-group(root) is
|
||||
sized to the viewport, and the UA animates the group's width and height from
|
||||
the outgoing value to the incoming one, stretching BOTH snapshots to fit for
|
||||
the whole duration and snapping at the end. Navigate between a page that
|
||||
scrolls and one that does not and every glyph on the page smears sideways by
|
||||
the scrollbar width and then jumps back.
|
||||
|
||||
The client side nav is where this shows worst, because
|
||||
agent/includes/inc_client_top_head.php renders #clientHeader expanded on
|
||||
client_overview.php and collapsed everywhere else - so moving between
|
||||
Overview and any sibling page flips the scrollbar on and off almost every
|
||||
time.
|
||||
|
||||
scrollbar-gutter reserves the space permanently, so the width never moves.
|
||||
The max-width correction has to come with it: with the gutter always
|
||||
present, an unqualified 100vw now overruns the layout viewport on EVERY
|
||||
page rather than only on tall ones, which would trade an intermittent
|
||||
horizontal jump for a permanent horizontal scrollbar. 100% is the width of
|
||||
the grid area these four already occupy, which is what the rule wanted.
|
||||
AdminLTE made the same correction upstream in 4.8.3. */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.app-wrapper,
|
||||
.app-header,
|
||||
.app-main,
|
||||
.app-footer {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* AdminLTE ships its own reduced-motion handling, but it injects a
|
||||
#adminlte-reduce-motion style block keyed on *, *::before and *::after.
|
||||
The ::view-transition pseudo tree is not a descendant of any element those
|
||||
match, so it is not covered and needs its own opt-out. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-group(*),
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,21 @@ if (basename(dirname($_SERVER['REQUEST_URI'])) === 'guest') { ?>
|
||||
|
||||
<!-- REQUIRED SCRIPTS -->
|
||||
|
||||
<?php /* Tom Select goes FIRST, ahead of Bootstrap itself. Order is load-bearing:
|
||||
until js/tom_select.js runs, the browser is showing the raw <select>
|
||||
controls it painted while parsing the body, so every byte in front of
|
||||
it is time spent looking at the wrong widget. Nothing here is needed to
|
||||
turn a <select> into a Tom Select, so nothing goes in front of it.
|
||||
includes/header.php preloads both files. See js/tom_select.js.
|
||||
|
||||
Bootstrap moving down is safe: it has always loaded in the footer, so
|
||||
no markup above this point could ever have used the `bootstrap` global
|
||||
at parse time. Every call site is inside an event handler or waits for
|
||||
DOMContentLoaded - includes/inc_alert_feedback.php says so in its own
|
||||
comment - and all of those still run well after this block. */ ?>
|
||||
<script src="/libs/tom-select/js/tom-select.complete.min.js"></script>
|
||||
<script src="/js/tom_select.js"></script>
|
||||
|
||||
<!-- Bootstrap 5 -->
|
||||
<script src="/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/js/http.js"></script>
|
||||
@@ -35,7 +50,6 @@ if (basename(dirname($_SERVER['REQUEST_URI'])) === 'guest') { ?>
|
||||
<!-- Custom js-->
|
||||
<script src="/libs/chart.js/chart.umd.min.js"></script>
|
||||
<script src="/libs/flatpickr/js/flatpickr.min.js"></script>
|
||||
<script src="/libs/tom-select/js/tom-select.complete.min.js"></script>
|
||||
<script src="/libs/imask/js/imask.min.js"></script>
|
||||
<script src="/libs/tinymce/tinymce.min.js" referrerpolicy="origin"></script>
|
||||
<script src="/libs/clipboardjs/clipboard.min.js"></script>
|
||||
|
||||
@@ -40,6 +40,16 @@ header("X-Frame-Options: DENY");
|
||||
<link rel="icon" type="image/x-icon" href="/uploads/favicon.ico">
|
||||
<?php } ?>
|
||||
|
||||
<?php /* The Tom Select pair is the first thing includes/footer.php runs,
|
||||
because until it does the browser is showing raw <select>
|
||||
controls. Preloading here starts both fetches during head parse,
|
||||
in parallel with the stylesheets, so the bytes are already warm
|
||||
when the parser reaches the tag instead of being requested only
|
||||
at that point. Hints only - the <script> tags in footer.php are
|
||||
what actually load them. */ ?>
|
||||
<link rel="preload" as="script" href="/libs/tom-select/js/tom-select.complete.min.js">
|
||||
<link rel="preload" as="script" href="/js/tom_select.js">
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="/libs/fontawesome-free/css/all.min.css">
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script src="/js/http.js"></script>
|
||||
<script src="/js/autocomplete.js"></script>
|
||||
<script src="/js/tom_select.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
|
||||
<?php
|
||||
|
||||
72
js/app.js
72
js/app.js
@@ -140,10 +140,16 @@ function itflowInit() {
|
||||
}, 5000);
|
||||
})();
|
||||
|
||||
// Initialize Tom Select (replaces Select2). Every instance is reachable
|
||||
// afterwards as element.tomselect, which is how the helpers below reach it.
|
||||
// Enhance any select that is not enhanced yet. On a normal page load
|
||||
// js/tom_select.js has already done the work and every element here is
|
||||
// guarded, so this is a no-op; it earns its place on ajax modals, whose
|
||||
// markup does not exist until includes/modal_footer.php re-executes this
|
||||
// file. Scoped to `select.select2` deliberately - a bare '.select2' also
|
||||
// matches the .ts-wrapper divs left by earlier enhancement, because Tom
|
||||
// Select copies the source element's classes onto them. See
|
||||
// js/tom_select.js.
|
||||
itflowStep('tom-select', function () {
|
||||
document.querySelectorAll('.select2').forEach(function (el) {
|
||||
document.querySelectorAll('select.select2').forEach(function (el) {
|
||||
initTomSelect(el);
|
||||
});
|
||||
});
|
||||
@@ -767,62 +773,12 @@ function flashTooltip(button, message) {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tom Select integration.
|
||||
*
|
||||
* Replaces Select2. Tom Select is vanilla JS and exposes its instance on the
|
||||
* element as `el.tomselect`, so nothing here needs jQuery.
|
||||
*
|
||||
* Select2 concepts and their equivalents, for anyone reading this later:
|
||||
* $(el).select2({tags:true}) -> create: true
|
||||
* $(el).val(null).trigger('change') -> el.tomselect.clear()
|
||||
* $(el).trigger('change.select2') -> el.tomselect.sync() (options replaced)
|
||||
* $(el).on('select2:select', fn) -> el.tomselect.on('change', fn)
|
||||
/*
|
||||
* The Tom Select layer (initTomSelect / refreshTomSelect / clearTomSelect /
|
||||
* setTomSelectValue) lives in js/tom_select.js, which footer.php loads right
|
||||
* after the library so the enhancement happens before the page's heavy libs
|
||||
* are parsed. Those functions are global; this file only calls them.
|
||||
*/
|
||||
function initTomSelect(el, options) {
|
||||
if (!el || el.tomselect) {
|
||||
return el ? el.tomselect : null;
|
||||
}
|
||||
var settings = Object.assign({
|
||||
create: false,
|
||||
allowEmptyOption: true,
|
||||
plugins: el.multiple ? ['remove_button'] : [],
|
||||
placeholder: el.getAttribute('data-placeholder') || undefined
|
||||
}, options || {});
|
||||
return new TomSelect(el, settings);
|
||||
}
|
||||
|
||||
/** Re-read the <option> list after it has been replaced server-side. */
|
||||
function refreshTomSelect(el) {
|
||||
if (el && el.tomselect) {
|
||||
el.tomselect.sync();
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear a selection (single or multiple) without firing a server round-trip. */
|
||||
function clearTomSelect(el) {
|
||||
if (el && el.tomselect) {
|
||||
el.tomselect.clear(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a select's value from code. A plain el.value = x (or jQuery .val()) does
|
||||
* not repaint a Tom Select widget - the underlying <select> changes but the
|
||||
* visible control does not. Falls back to a native change event when the
|
||||
* element was never enhanced.
|
||||
*/
|
||||
function setTomSelectValue(el, value) {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
if (el.tomselect) {
|
||||
el.tomselect.setValue(value);
|
||||
return;
|
||||
}
|
||||
el.value = value;
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a Bootstrap toast from JavaScript.
|
||||
|
||||
119
js/tom_select.js
Normal file
119
js/tom_select.js
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Tom Select integration.
|
||||
*
|
||||
* Replaces Select2. Tom Select is vanilla JS and exposes its instance on the
|
||||
* element as `el.tomselect`, so nothing here needs jQuery.
|
||||
*
|
||||
* Select2 concepts and their equivalents, for anyone reading this later:
|
||||
* $(el).select2({tags:true}) -> create: true
|
||||
* $(el).val(null).trigger('change') -> el.tomselect.clear()
|
||||
* $(el).trigger('change.select2') -> el.tomselect.sync() (options replaced)
|
||||
* $(el).on('select2:select', fn) -> el.tomselect.on('change', fn)
|
||||
*
|
||||
* WHY THIS IS ITS OWN FILE RATHER THAN PART OF js/app.js
|
||||
*
|
||||
* The visible flash on page load was a timing problem, not a styling one. The
|
||||
* browser paints the native <select> as soon as it parses it, and the swap to
|
||||
* Tom Select could not happen until app.js ran - which is dead last in
|
||||
* includes/footer.php, behind roughly a megabyte of parser-blocking library
|
||||
* code (tinymce 486K, intl-tel-input 316K, DataTables 123K, sweetalert2 49K,
|
||||
* adminlte 28K and the rest). Every byte of that had to download, parse and
|
||||
* execute first, and the native controls sat on screen for the whole of it.
|
||||
*
|
||||
* Splitting the Tom Select layer out lets footer.php load it immediately after
|
||||
* the library itself, ahead of everything that has nothing to do with it. Only
|
||||
* bootstrap.bundle and http.js are still in front of it.
|
||||
*
|
||||
* The sweep at the bottom runs synchronously rather than on DOMContentLoaded,
|
||||
* on purpose: DOMContentLoaded does not fire until every parser-blocking
|
||||
* script in the body has run, which is the exact wait this file exists to
|
||||
* avoid. Running inline is safe because this script tag sits after the closing
|
||||
* .app-wrapper div, so the page markup above it is already parsed.
|
||||
*
|
||||
* js/app.js keeps its own tom-select step. That is not redundant - it is what
|
||||
* enhances selects inside ajax modals, whose markup does not exist yet at this
|
||||
* point and whose footer (includes/modal_footer.php) re-executes app.js. On a
|
||||
* normal page load that later pass finds everything already enhanced and the
|
||||
* guard in initTomSelect makes it a no-op.
|
||||
*/
|
||||
|
||||
function initTomSelect(el, options) {
|
||||
if (!el || el.tomselect) {
|
||||
return el ? el.tomselect : null;
|
||||
}
|
||||
/*
|
||||
* Only ever enhance a real form control.
|
||||
*
|
||||
* Tom Select copies the source element's classes onto its own wrapper, so
|
||||
* an enhanced <select class="form-select select2"> leaves a sibling
|
||||
* <div class="ts-wrapper form-select select2 ..."> behind it. That means a
|
||||
* querySelectorAll('.select2') run AFTER any enhancement matches the
|
||||
* wrapper too, and the wrapper has no .tomselect of its own so the guard
|
||||
* above waves it through. Constructing Tom Select on a <div> yields a
|
||||
* second, empty control - no options, no placeholder - stacked on top of
|
||||
* the working one, which reads as "the select is blank".
|
||||
*
|
||||
* The sweeps below and in js/app.js are scoped to `select.select2` so this
|
||||
* cannot happen, but the check belongs here as well: initTomSelect is
|
||||
* called directly from agent/js/share_modal.js and anywhere else that
|
||||
* grows a call site later.
|
||||
*/
|
||||
if (el.tagName !== 'SELECT' && el.tagName !== 'INPUT') {
|
||||
return null;
|
||||
}
|
||||
var settings = Object.assign({
|
||||
create: false,
|
||||
allowEmptyOption: true,
|
||||
plugins: el.multiple ? ['remove_button'] : [],
|
||||
placeholder: el.getAttribute('data-placeholder') || undefined
|
||||
}, options || {});
|
||||
return new TomSelect(el, settings);
|
||||
}
|
||||
|
||||
/** Re-read the <option> list after it has been replaced server-side. */
|
||||
function refreshTomSelect(el) {
|
||||
if (el && el.tomselect) {
|
||||
el.tomselect.sync();
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear a selection (single or multiple) without firing a server round-trip. */
|
||||
function clearTomSelect(el) {
|
||||
if (el && el.tomselect) {
|
||||
el.tomselect.clear(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a select's value from code. A plain el.value = x (or jQuery .val()) does
|
||||
* not repaint a Tom Select widget - the underlying <select> changes but the
|
||||
* visible control does not. Falls back to a native change event when the
|
||||
* element was never enhanced.
|
||||
*/
|
||||
function setTomSelectValue(el, value) {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
if (el.tomselect) {
|
||||
el.tomselect.setValue(value);
|
||||
return;
|
||||
}
|
||||
el.value = value;
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance every .select2 already in the document.
|
||||
*
|
||||
* Wrapped in try/catch for the same reason app.js wraps its init steps: one
|
||||
* malformed select must not stop the rest of the page's scripts from running.
|
||||
*/
|
||||
(function () {
|
||||
try {
|
||||
document.querySelectorAll('select.select2').forEach(function (el) {
|
||||
initTomSelect(el);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('itflow init [tom-select-eager] failed:', e);
|
||||
}
|
||||
})();
|
||||
@@ -1451,9 +1451,10 @@ if (isset($_POST['add_telemetry'])) {
|
||||
|
||||
<!-- jQuery -->
|
||||
<!-- Bootstrap 5 -->
|
||||
<script src="/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- Custom js-->
|
||||
<script src='/libs/tom-select/js/tom-select.complete.min.js'></script>
|
||||
<script src="/js/tom_select.js"></script>
|
||||
<script src="/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- AdminLTE App -->
|
||||
<script src="/libs/adminlte/js/adminlte.min.js"></script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user