mirror of
https://github.com/itflow-org/itflow
synced 2026-09-21 22:21:15 +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;
|
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 -->
|
<!-- 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 -->
|
<!-- Bootstrap 5 -->
|
||||||
<script src="/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="/js/http.js"></script>
|
<script src="/js/http.js"></script>
|
||||||
@@ -35,7 +50,6 @@ if (basename(dirname($_SERVER['REQUEST_URI'])) === 'guest') { ?>
|
|||||||
<!-- Custom js-->
|
<!-- Custom js-->
|
||||||
<script src="/libs/chart.js/chart.umd.min.js"></script>
|
<script src="/libs/chart.js/chart.umd.min.js"></script>
|
||||||
<script src="/libs/flatpickr/js/flatpickr.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/imask/js/imask.min.js"></script>
|
||||||
<script src="/libs/tinymce/tinymce.min.js" referrerpolicy="origin"></script>
|
<script src="/libs/tinymce/tinymce.min.js" referrerpolicy="origin"></script>
|
||||||
<script src="/libs/clipboardjs/clipboard.min.js"></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">
|
<link rel="icon" type="image/x-icon" href="/uploads/favicon.ico">
|
||||||
<?php } ?>
|
<?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 -->
|
<!-- Font Awesome -->
|
||||||
<link rel="stylesheet" href="/libs/fontawesome-free/css/all.min.css">
|
<link rel="stylesheet" href="/libs/fontawesome-free/css/all.min.css">
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script src="/js/http.js"></script>
|
<script src="/js/http.js"></script>
|
||||||
<script src="/js/autocomplete.js"></script>
|
<script src="/js/autocomplete.js"></script>
|
||||||
|
<script src="/js/tom_select.js"></script>
|
||||||
<script src="/js/app.js"></script>
|
<script src="/js/app.js"></script>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
|||||||
72
js/app.js
72
js/app.js
@@ -140,10 +140,16 @@ function itflowInit() {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Initialize Tom Select (replaces Select2). Every instance is reachable
|
// Enhance any select that is not enhanced yet. On a normal page load
|
||||||
// afterwards as element.tomselect, which is how the helpers below reach it.
|
// 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 () {
|
itflowStep('tom-select', function () {
|
||||||
document.querySelectorAll('.select2').forEach(function (el) {
|
document.querySelectorAll('select.select2').forEach(function (el) {
|
||||||
initTomSelect(el);
|
initTomSelect(el);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -767,62 +773,12 @@ function flashTooltip(button, message) {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* Tom Select integration.
|
* The Tom Select layer (initTomSelect / refreshTomSelect / clearTomSelect /
|
||||||
*
|
* setTomSelectValue) lives in js/tom_select.js, which footer.php loads right
|
||||||
* Replaces Select2. Tom Select is vanilla JS and exposes its instance on the
|
* after the library so the enhancement happens before the page's heavy libs
|
||||||
* element as `el.tomselect`, so nothing here needs jQuery.
|
* are parsed. Those functions are global; this file only calls them.
|
||||||
*
|
|
||||||
* 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)
|
|
||||||
*/
|
*/
|
||||||
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.
|
* 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 -->
|
<!-- jQuery -->
|
||||||
<!-- Bootstrap 5 -->
|
<!-- Bootstrap 5 -->
|
||||||
<script src="/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<!-- Custom js-->
|
<!-- Custom js-->
|
||||||
<script src='/libs/tom-select/js/tom-select.complete.min.js'></script>
|
<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 -->
|
<!-- AdminLTE App -->
|
||||||
<script src="/libs/adminlte/js/adminlte.min.js"></script>
|
<script src="/libs/adminlte/js/adminlte.min.js"></script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user