diff --git a/css/itflow_custom.css b/css/itflow_custom.css index 0f2376b85..85287e4fd 100644 --- a/css/itflow_custom.css +++ b/css/itflow_custom.css @@ -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; + } +} diff --git a/includes/footer.php b/includes/footer.php index 1331ac4a0..1c9cd6e2e 100644 --- a/includes/footer.php +++ b/includes/footer.php @@ -28,6 +28,21 @@ if (basename(dirname($_SERVER['REQUEST_URI'])) === 'guest') { ?> + + 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 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. diff --git a/js/tom_select.js b/js/tom_select.js new file mode 100644 index 000000000..038e1c633 --- /dev/null +++ b/js/tom_select.js @@ -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 leaves a sibling + *
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
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