crossfade
This commit is contained in:
+55
-11
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
import { afterNavigate, onNavigate } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import { markPageLoading, pageContentReady } from '$lib/stores/page-transition.svelte';
|
||||
import { storedTheme } from '$lib/stores/settings';
|
||||
|
||||
import Footer from '$lib/components/navigation/footer.svelte';
|
||||
@@ -15,8 +17,6 @@
|
||||
|
||||
import { routePath } from '$lib/i18n';
|
||||
|
||||
import { pageContentReady } from '$lib/stores/page-transition.svelte';
|
||||
|
||||
import './layout.css';
|
||||
|
||||
let { children } = $props();
|
||||
@@ -61,13 +61,16 @@
|
||||
});
|
||||
|
||||
// ── Page cross-fade ───────────────────────────────────────────────────────
|
||||
// The weather pages fetch their forecast after the route swap, so tying the
|
||||
// fade to navigation alone would cross-fade one skeleton into another and
|
||||
// then cut hard to the real content. Instead the outgoing page fades out on
|
||||
// navigation and the incoming one fades in once it reports that its data has
|
||||
// landed - with a ceiling so a slow or silent page is never left hidden.
|
||||
// A real cross-fade needs the outgoing page still on screen while the
|
||||
// incoming one appears - so the view transition is opened at navigation and
|
||||
// deliberately held open until the new page reports that its data has landed.
|
||||
// The browser keeps showing the old snapshot for that whole time (rather than
|
||||
// flashing a skeleton), then fades it into the finished page.
|
||||
//
|
||||
// The wait is capped: past the ceiling we cross-fade into whatever is on
|
||||
// screen instead of freezing the UI on a slow network.
|
||||
const READY_CEILING_MS = 2200;
|
||||
const FADE_OUT_MS = 170;
|
||||
const REVEAL_CEILING_MS = 2500;
|
||||
|
||||
let contentVisible = $state(true);
|
||||
let revealTimer = 0;
|
||||
@@ -75,21 +78,62 @@
|
||||
const reducedMotion = () =>
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
onNavigate(() => {
|
||||
// Routes that fetch their own forecast after mounting. Knowing this up front
|
||||
// is what makes the wait reliable: the layout clears the ready flag before
|
||||
// the swap rather than trusting the incoming page to have done it.
|
||||
const DATA_ROUTES = new Set([
|
||||
'/weather/week/[location]',
|
||||
'/weather/14-day/[location]',
|
||||
'/weather/compare/[location]',
|
||||
'/weather/seasonal/[location]',
|
||||
'/weather/historical/[location]'
|
||||
]);
|
||||
|
||||
/** Resolves once the freshly mounted page has its data, or at the ceiling. */
|
||||
async function waitForContent(): Promise<void> {
|
||||
const deadline = Date.now() + READY_CEILING_MS;
|
||||
while (!get(pageContentReady) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
// one more frame so the page paints its data before the snapshot is taken
|
||||
await tick();
|
||||
}
|
||||
|
||||
onNavigate((navigation) => {
|
||||
if (reducedMotion()) return;
|
||||
|
||||
const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? '');
|
||||
if (loadsData) markPageLoading();
|
||||
|
||||
if (typeof document.startViewTransition === 'function') {
|
||||
return new Promise<void>((swap) => {
|
||||
document.startViewTransition(async () => {
|
||||
// hand control back so SvelteKit swaps the DOM underneath the
|
||||
// frozen snapshot of the old page
|
||||
swap();
|
||||
// A superseded navigation (a redirect, or a fast second click)
|
||||
// rejects this promise; that is not an error worth surfacing,
|
||||
// and leaving it unhandled shows up as "navigation aborted".
|
||||
await navigation.complete.catch(() => {});
|
||||
if (loadsData) await waitForContent();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// No view transitions: fall back to fading out, then back in on arrival.
|
||||
clearTimeout(revealTimer);
|
||||
contentVisible = false;
|
||||
return new Promise((resolve) => setTimeout(resolve, FADE_OUT_MS));
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
if (reducedMotion()) {
|
||||
if (typeof document.startViewTransition === 'function' || reducedMotion()) {
|
||||
contentVisible = true;
|
||||
return;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
const reveal = () => {
|
||||
if (get(pageContentReady) || Date.now() - startedAt > REVEAL_CEILING_MS) {
|
||||
if (get(pageContentReady) || Date.now() - startedAt > READY_CEILING_MS) {
|
||||
contentVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user