home loading bug and flashes fixes
This commit is contained in:
@@ -261,6 +261,7 @@
|
|||||||
"table_now": "Jetzt",
|
"table_now": "Jetzt",
|
||||||
"interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln",
|
"interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln",
|
||||||
"page_loading": "Wird geladen…",
|
"page_loading": "Wird geladen…",
|
||||||
|
"page_loading_dismiss": "Schließen",
|
||||||
"charts_loading": "Diagramme werden geladen…",
|
"charts_loading": "Diagramme werden geladen…",
|
||||||
"chart_download": "Meteogramm als PNG-Bild herunterladen",
|
"chart_download": "Meteogramm als PNG-Bild herunterladen",
|
||||||
"chart_credit_viz": "Visualisierung von",
|
"chart_credit_viz": "Visualisierung von",
|
||||||
|
|||||||
@@ -261,6 +261,7 @@
|
|||||||
"table_now": "Now",
|
"table_now": "Now",
|
||||||
"interval_toggle": "Toggle between 1-hour and 3-hour intervals",
|
"interval_toggle": "Toggle between 1-hour and 3-hour intervals",
|
||||||
"page_loading": "Loading…",
|
"page_loading": "Loading…",
|
||||||
|
"page_loading_dismiss": "Dismiss",
|
||||||
"charts_loading": "Loading charts...",
|
"charts_loading": "Loading charts...",
|
||||||
"chart_download": "Download meteogram as PNG image",
|
"chart_download": "Download meteogram as PNG image",
|
||||||
"chart_credit_viz": "visualisation by",
|
"chart_credit_viz": "visualisation by",
|
||||||
|
|||||||
@@ -261,6 +261,7 @@
|
|||||||
"table_now": "Ahora",
|
"table_now": "Ahora",
|
||||||
"interval_toggle": "Alternar entre intervalos de 1 y 3 horas",
|
"interval_toggle": "Alternar entre intervalos de 1 y 3 horas",
|
||||||
"page_loading": "Cargando…",
|
"page_loading": "Cargando…",
|
||||||
|
"page_loading_dismiss": "Descartar",
|
||||||
"charts_loading": "Cargando gráficos…",
|
"charts_loading": "Cargando gráficos…",
|
||||||
"chart_download": "Descargar el meteograma como imagen PNG",
|
"chart_download": "Descargar el meteograma como imagen PNG",
|
||||||
"chart_credit_viz": "visualización de",
|
"chart_credit_viz": "visualización de",
|
||||||
|
|||||||
@@ -261,6 +261,7 @@
|
|||||||
"table_now": "Maintenant",
|
"table_now": "Maintenant",
|
||||||
"interval_toggle": "Basculer entre les intervalles de 1 h et 3 h",
|
"interval_toggle": "Basculer entre les intervalles de 1 h et 3 h",
|
||||||
"page_loading": "Chargement…",
|
"page_loading": "Chargement…",
|
||||||
|
"page_loading_dismiss": "Fermer",
|
||||||
"charts_loading": "Chargement des graphiques…",
|
"charts_loading": "Chargement des graphiques…",
|
||||||
"chart_download": "Télécharger le météogramme en PNG",
|
"chart_download": "Télécharger le météogramme en PNG",
|
||||||
"chart_credit_viz": "visualisation par",
|
"chart_credit_viz": "visualisation par",
|
||||||
|
|||||||
@@ -261,6 +261,7 @@
|
|||||||
"table_now": "Ora",
|
"table_now": "Ora",
|
||||||
"interval_toggle": "Alterna tra intervalli di 1 e 3 ore",
|
"interval_toggle": "Alterna tra intervalli di 1 e 3 ore",
|
||||||
"page_loading": "Caricamento…",
|
"page_loading": "Caricamento…",
|
||||||
|
"page_loading_dismiss": "Chiudi",
|
||||||
"charts_loading": "Caricamento dei grafici…",
|
"charts_loading": "Caricamento dei grafici…",
|
||||||
"chart_download": "Scarica il meteogramma come immagine PNG",
|
"chart_download": "Scarica il meteogramma come immagine PNG",
|
||||||
"chart_credit_viz": "visualizzazione di",
|
"chart_credit_viz": "visualizzazione di",
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ export function markPageLoading(): void {
|
|||||||
pageContentReady.set(false);
|
pageContentReady.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The counterpart, for a route that has nothing to wait for (the maps page, the
|
||||||
|
* legal pages). Without it a navigation away from a page that never resolved
|
||||||
|
* would leave the flag stuck on "loading", and the layout's overlay would sit on
|
||||||
|
* top of a page that is perfectly finished.
|
||||||
|
*/
|
||||||
|
export function markPageReady(): void {
|
||||||
|
pageContentReady.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Declare a page's readiness. Pass a getter for "my data has arrived". Only
|
* Declare a page's readiness. Pass a getter for "my data has arrived". Only
|
||||||
* ever sets the flag - clearing it is the layout's job (see above).
|
* ever sets the flag - clearing it is the layout's job (see above).
|
||||||
|
|||||||
+13
-15
@@ -1,7 +1,11 @@
|
|||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
const prefersReducedMotion = () =>
|
import {
|
||||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
isViewTransitionActive,
|
||||||
|
prefersReducedMotion,
|
||||||
|
startViewTransition,
|
||||||
|
supportsViewTransitions
|
||||||
|
} from './view-transition';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs a day change inside a view transition, so the outgoing day is still on
|
* Runs a day change inside a view transition, so the outgoing day is still on
|
||||||
@@ -12,9 +16,13 @@ const prefersReducedMotion = () =>
|
|||||||
* `.day-region-*` in routes/layout.css); everything else - the strip, the
|
* `.day-region-*` in routes/layout.css); everything else - the strip, the
|
||||||
* header, the page chrome - is pinned by the `day-switch` class so it stays
|
* header, the page chrome - is pinned by the `day-switch` class so it stays
|
||||||
* completely still.
|
* completely still.
|
||||||
|
*
|
||||||
|
* A day switch that lands while a navigation transition is still on screen just
|
||||||
|
* applies: starting a rival transition would skip the running one and flash the
|
||||||
|
* page (see view-transition.ts).
|
||||||
*/
|
*/
|
||||||
export async function runDayTransition(update: () => void): Promise<void> {
|
export async function runDayTransition(update: () => void): Promise<void> {
|
||||||
if (prefersReducedMotion() || !document.startViewTransition) {
|
if (!supportsViewTransitions() || prefersReducedMotion() || isViewTransitionActive()) {
|
||||||
update();
|
update();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -24,20 +32,10 @@ export async function runDayTransition(update: () => void): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
// Svelte applies the change on the next tick; the transition has to wait
|
// Svelte applies the change on the next tick; the transition has to wait
|
||||||
// for that before it snapshots the new state.
|
// for that before it snapshots the new state.
|
||||||
const transition = document.startViewTransition(async () => {
|
await startViewTransition(async () => {
|
||||||
update();
|
update();
|
||||||
await tick();
|
await tick();
|
||||||
});
|
});
|
||||||
// A throw inside the callback rejects `updateCallbackDone` as well as
|
|
||||||
// `finished`. Nothing awaits the former, and that unhandled rejection is
|
|
||||||
// what turns one bad render into an "Uncaught" error on the page - so
|
|
||||||
// report it here instead and let `finished` drive the flow.
|
|
||||||
transition.updateCallbackDone.catch((error: unknown) => {
|
|
||||||
console.error('day transition failed to apply', error);
|
|
||||||
});
|
|
||||||
await transition.finished;
|
|
||||||
} catch {
|
|
||||||
/* a superseded transition is fine - the DOM is already up to date */
|
|
||||||
} finally {
|
} finally {
|
||||||
root.classList.remove('day-switch');
|
root.classList.remove('day-switch');
|
||||||
}
|
}
|
||||||
@@ -53,7 +51,7 @@ export function daySwap(node: HTMLElement, key: unknown) {
|
|||||||
|
|
||||||
const play = () => {
|
const play = () => {
|
||||||
// view transitions handle it properly where they exist
|
// view transitions handle it properly where they exist
|
||||||
if (typeof document.startViewTransition === 'function' || prefersReducedMotion()) return;
|
if (supportsViewTransitions() || prefersReducedMotion()) return;
|
||||||
node.animate([{ opacity: 0.1 }, { opacity: 1 }], {
|
node.animate([{ opacity: 0.1 }, { opacity: 1 }], {
|
||||||
duration: 460,
|
duration: 460,
|
||||||
easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)'
|
easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)'
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* One view transition at a time.
|
||||||
|
*
|
||||||
|
* The browser runs exactly one: starting a second while the first is still on
|
||||||
|
* screen *skips* the first, which drops its frozen snapshot on the spot and
|
||||||
|
* pops the live DOM into view. That is the whole-screen flash, and overlapping
|
||||||
|
* transitions are the normal case here rather than an edge case:
|
||||||
|
*
|
||||||
|
* - `/` and every bare `/weather/<view>/` page is a redirect stub that calls
|
||||||
|
* `goto` from `onMount`, so one click on a nav entry produces two (from the
|
||||||
|
* root, three) navigations back to back - and the stub renders nothing, so
|
||||||
|
* the frame it exposes is an empty page;
|
||||||
|
* - a theme toggle or a day switch can land while a navigation is still
|
||||||
|
* waiting for its forecast.
|
||||||
|
*
|
||||||
|
* Letting a second update ride along inside the transition that is already
|
||||||
|
* running keeps one continuous cross-fade across the whole chain.
|
||||||
|
*/
|
||||||
|
type UpdateCallback = () => void | Promise<void>;
|
||||||
|
|
||||||
|
let active: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export const supportsViewTransitions = (): boolean =>
|
||||||
|
typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
|
||||||
|
|
||||||
|
export const prefersReducedMotion = (): boolean =>
|
||||||
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
/** True while a transition is on screen, from capture until its animation ends. */
|
||||||
|
export const isViewTransitionActive = (): boolean => active !== null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `update` inside a view transition, or straight away when one cannot (or
|
||||||
|
* must not) be started. Resolves once the transition has finished animating -
|
||||||
|
* or as soon as the update is done, when it ran on its own.
|
||||||
|
*
|
||||||
|
* Callers that need to know which of the two happened should check
|
||||||
|
* `isViewTransitionActive()` first; the point of this function is that they
|
||||||
|
* mostly should not care.
|
||||||
|
*/
|
||||||
|
export function startViewTransition(update: UpdateCallback): Promise<void> {
|
||||||
|
if (!supportsViewTransitions() || prefersReducedMotion() || active) {
|
||||||
|
return Promise.resolve(update()).then(
|
||||||
|
() => {},
|
||||||
|
(error: unknown) => {
|
||||||
|
console.error('view transition update failed', error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transition = document.startViewTransition(update);
|
||||||
|
|
||||||
|
// A throw inside the callback rejects `updateCallbackDone` as well as
|
||||||
|
// `finished`. Nothing awaits the former, and an unhandled rejection there is
|
||||||
|
// what turns one bad render into an "Uncaught" error on the page.
|
||||||
|
transition.updateCallbackDone.catch((error: unknown) => {
|
||||||
|
console.error('view transition update failed', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A superseded transition rejects `finished`; the DOM is already up to date,
|
||||||
|
// so that is not an error worth surfacing - but the slot has to be freed.
|
||||||
|
const done = transition.finished.then(
|
||||||
|
() => {},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
active = done;
|
||||||
|
void done.then(() => {
|
||||||
|
if (active === done) active = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return done;
|
||||||
|
}
|
||||||
+134
-76
@@ -6,9 +6,20 @@
|
|||||||
import { afterNavigate, onNavigate } from '$app/navigation';
|
import { afterNavigate, onNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
import { markPageLoading, pageContentReady } from '$lib/stores/page-transition.svelte';
|
import {
|
||||||
|
markPageLoading,
|
||||||
|
markPageReady,
|
||||||
|
pageContentReady
|
||||||
|
} from '$lib/stores/page-transition.svelte';
|
||||||
import { storedTheme } from '$lib/stores/settings';
|
import { storedTheme } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isViewTransitionActive,
|
||||||
|
prefersReducedMotion,
|
||||||
|
startViewTransition,
|
||||||
|
supportsViewTransitions
|
||||||
|
} from '$lib/utils/view-transition';
|
||||||
|
|
||||||
import Footer from '$lib/components/navigation/footer.svelte';
|
import Footer from '$lib/components/navigation/footer.svelte';
|
||||||
import Header from '$lib/components/navigation/header.svelte';
|
import Header from '$lib/components/navigation/header.svelte';
|
||||||
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
|
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
|
||||||
@@ -30,7 +41,9 @@
|
|||||||
const apply = () => {
|
const apply = () => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const dark = theme === 'dark' || (theme === 'system' && mq.matches);
|
const dark = theme === 'dark' || (theme === 'system' && mq.matches);
|
||||||
const paint = () => root.classList.toggle('dark', dark);
|
const paint = () => {
|
||||||
|
root.classList.toggle('dark', dark);
|
||||||
|
};
|
||||||
|
|
||||||
// The very first application is just painting the stored theme - only
|
// The very first application is just painting the stored theme - only
|
||||||
// an actual switch afterwards is worth cross-fading.
|
// an actual switch afterwards is worth cross-fading.
|
||||||
@@ -41,9 +54,17 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.startViewTransition) {
|
if (supportsViewTransitions() && !isViewTransitionActive()) {
|
||||||
// one cross-fade of the whole document; component transitions untouched
|
// one cross-fade of the whole document; component transitions untouched
|
||||||
document.startViewTransition(paint);
|
void startViewTransition(paint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A navigation transition already owns the screen: repaint under it
|
||||||
|
// rather than skipping it (which would flash), and fall back to the
|
||||||
|
// colour transition below when the browser has none at all.
|
||||||
|
if (supportsViewTransitions()) {
|
||||||
|
paint();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,43 +93,67 @@
|
|||||||
// yet. Note the order: the overlay has to be in the DOM *before* the
|
// yet. Note the order: the overlay has to be in the DOM *before* the
|
||||||
// transition captures the incoming state, because a running view transition
|
// transition captures the incoming state, because a running view transition
|
||||||
// freezes the page and nothing painted after that point can appear.
|
// freezes the page and nothing painted after that point can appear.
|
||||||
|
//
|
||||||
|
// There is deliberately no fade for browsers without view transitions. The
|
||||||
|
// old fallback dimmed the outgoing page to nothing and brought the new one
|
||||||
|
// back up, which on a single layer is a flash of the bare background rather
|
||||||
|
// than a cross-fade. Swapping outright and letting the overlay carry the
|
||||||
|
// "loading" message is quieter and honest.
|
||||||
const READY_HOLD_MS = 350;
|
const READY_HOLD_MS = 350;
|
||||||
const FADE_OUT_MS = 170;
|
|
||||||
// Nothing reports ready when a fetch fails outright, so the overlay needs its
|
// Nothing reports ready when a fetch fails outright, so the overlay needs its
|
||||||
// own way out rather than sitting on top of an error message forever.
|
// own way out rather than sitting on top of an error message forever.
|
||||||
const OVERLAY_CEILING_MS = 15000;
|
const OVERLAY_CEILING_MS = 15000;
|
||||||
|
|
||||||
let contentVisible = $state(true);
|
|
||||||
let revealTimer = 0;
|
|
||||||
|
|
||||||
let loadingOverlay = $state(false);
|
let loadingOverlay = $state(false);
|
||||||
let overlayHoldTimer = 0;
|
|
||||||
let overlayCeilingTimer = 0;
|
let overlayCeilingTimer = 0;
|
||||||
|
// A view transition freezes the page, so a Svelte in-transition started under
|
||||||
|
// one cannot play - the overlay would be captured at opacity 0 and pop in
|
||||||
|
// afterwards. Inside a transition the cross-fade does the fading instead.
|
||||||
|
let overlayFadesIn = $state(true);
|
||||||
|
|
||||||
function showOverlay(): void {
|
function showOverlay(animate: boolean): void {
|
||||||
|
overlayFadesIn = animate;
|
||||||
loadingOverlay = true;
|
loadingOverlay = true;
|
||||||
clearTimeout(overlayCeilingTimer);
|
clearTimeout(overlayCeilingTimer);
|
||||||
overlayCeilingTimer = window.setTimeout(() => (loadingOverlay = false), OVERLAY_CEILING_MS);
|
overlayCeilingTimer = window.setTimeout(() => (loadingOverlay = false), OVERLAY_CEILING_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideOverlay(): void {
|
function hideOverlay(): void {
|
||||||
clearTimeout(overlayHoldTimer);
|
|
||||||
clearTimeout(overlayCeilingTimer);
|
clearTimeout(overlayCeilingTimer);
|
||||||
loadingOverlay = false;
|
loadingOverlay = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual dismissal. The overlay reports on a fetch it does not control, so
|
||||||
|
* "stuck" is always a possibility (a page that never reports ready, a request
|
||||||
|
* that neither resolves nor rejects) - and the page behind it still works.
|
||||||
|
* Whatever was loading carries on; only the veil goes.
|
||||||
|
*/
|
||||||
|
function dismissOverlay(): void {
|
||||||
|
hideOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape' && loadingOverlay) dismissOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
/** The page that just mounted has its data: whatever we were waiting for is in. */
|
/** The page that just mounted has its data: whatever we were waiting for is in. */
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($pageContentReady) hideOverlay();
|
if ($pageContentReady) hideOverlay();
|
||||||
});
|
});
|
||||||
|
|
||||||
const reducedMotion = () =>
|
// Routes that are not finished on arrival: the five that fetch a forecast
|
||||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
// after mounting, and the redirect stubs, which render nothing at all and
|
||||||
|
// bounce to a located URL from `onMount`. Knowing this up front is what makes
|
||||||
// Routes that fetch their own forecast after mounting. Knowing this up front
|
// the wait reliable - the layout clears the ready flag before the swap rather
|
||||||
// is what makes the wait reliable: the layout clears the ready flag before
|
// than trusting the incoming page to have done it.
|
||||||
// the swap rather than trusting the incoming page to have done it.
|
const PENDING_ROUTES = new Set([
|
||||||
const DATA_ROUTES = new Set([
|
'/',
|
||||||
|
'/weather/week',
|
||||||
|
'/weather/14-day',
|
||||||
|
'/weather/compare',
|
||||||
|
'/weather/seasonal',
|
||||||
|
'/weather/historical',
|
||||||
'/weather/week/[location]',
|
'/weather/week/[location]',
|
||||||
'/weather/14-day/[location]',
|
'/weather/14-day/[location]',
|
||||||
'/weather/compare/[location]',
|
'/weather/compare/[location]',
|
||||||
@@ -121,50 +166,63 @@
|
|||||||
* period is up - in which case the overlay goes up first, so it is part of
|
* period is up - in which case the overlay goes up first, so it is part of
|
||||||
* the state the transition is about to snapshot.
|
* the state the transition is about to snapshot.
|
||||||
*/
|
*/
|
||||||
async function waitForContent(): Promise<void> {
|
async function waitForContent(underTransition: boolean): Promise<void> {
|
||||||
const deadline = Date.now() + READY_HOLD_MS;
|
const deadline = Date.now() + READY_HOLD_MS;
|
||||||
while (!get(pageContentReady) && Date.now() < deadline) {
|
while (!get(pageContentReady) && Date.now() < deadline) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
}
|
}
|
||||||
if (!get(pageContentReady)) showOverlay();
|
if (!get(pageContentReady)) showOverlay(!underTransition);
|
||||||
// one more frame so the page paints its data before the snapshot is taken
|
// one more frame so the page paints its data before the snapshot is taken
|
||||||
await tick();
|
await tick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the navigation lands on the route and params the page is already
|
||||||
|
* showing - the sidebar's home link from the 7-day page it points at, or a
|
||||||
|
* link that differs only in the query string.
|
||||||
|
*
|
||||||
|
* SvelteKit keeps the page component mounted for those, and nothing it holds
|
||||||
|
* changes: its forecast is already fetched, so the readiness effect it
|
||||||
|
* registered never re-runs and never re-announces. Clearing the flag for such
|
||||||
|
* a navigation strands it cleared, and the overlay sits there until its
|
||||||
|
* ceiling. There is genuinely nothing to wait for, so don't clear it.
|
||||||
|
*/
|
||||||
|
function landsOnCurrentPage(navigation: {
|
||||||
|
from: { route: { id: string | null }; params: Record<string, string> | null } | null;
|
||||||
|
to: { route: { id: string | null }; params: Record<string, string> | null } | null;
|
||||||
|
}): boolean {
|
||||||
|
const from = navigation.from;
|
||||||
|
const to = navigation.to;
|
||||||
|
if (!from?.route.id || from.route.id !== to?.route.id) return false;
|
||||||
|
return JSON.stringify(from.params ?? {}) === JSON.stringify(to.params ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
onNavigate((navigation) => {
|
onNavigate((navigation) => {
|
||||||
const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? '');
|
const pending =
|
||||||
if (loadsData) markPageLoading();
|
PENDING_ROUTES.has(navigation.to?.route?.id ?? '') && !landsOnCurrentPage(navigation);
|
||||||
|
// Either way the flag is set explicitly: leaving a page that never resolved
|
||||||
|
// for one that has nothing to load would otherwise strand the overlay.
|
||||||
|
if (pending) markPageLoading();
|
||||||
|
else markPageReady();
|
||||||
|
|
||||||
if (reducedMotion()) {
|
// `startViewTransition` decides whether a transition is possible at all
|
||||||
// No transition to hang the wait on, so the overlay gets its own timer.
|
// (support, reduced motion, one already on screen) and runs the update
|
||||||
if (loadsData) {
|
// inline when it is not - so there is exactly one path from here down.
|
||||||
clearTimeout(overlayHoldTimer);
|
const underTransition =
|
||||||
overlayHoldTimer = window.setTimeout(() => {
|
supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionActive();
|
||||||
if (!get(pageContentReady)) showOverlay();
|
|
||||||
}, READY_HOLD_MS);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof document.startViewTransition === 'function') {
|
return new Promise<void>((swap) => {
|
||||||
return new Promise<void>((swap) => {
|
void startViewTransition(async () => {
|
||||||
document.startViewTransition(async () => {
|
// hand control back so SvelteKit swaps the DOM underneath the
|
||||||
// hand control back so SvelteKit swaps the DOM underneath the
|
// frozen snapshot of the old page
|
||||||
// frozen snapshot of the old page
|
swap();
|
||||||
swap();
|
// A superseded navigation (a redirect, or a fast second click)
|
||||||
// A superseded navigation (a redirect, or a fast second click)
|
// rejects this promise; that is not an error worth surfacing,
|
||||||
// rejects this promise; that is not an error worth surfacing,
|
// and leaving it unhandled shows up as "navigation aborted".
|
||||||
// and leaving it unhandled shows up as "navigation aborted".
|
await navigation.complete.catch(() => {});
|
||||||
await navigation.complete.catch(() => {});
|
if (pending) await waitForContent(underTransition);
|
||||||
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));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let mainEl = $state<HTMLElement | null>(null);
|
let mainEl = $state<HTMLElement | null>(null);
|
||||||
@@ -176,26 +234,6 @@
|
|||||||
if (navigation.type !== 'popstate' && !navigation.to?.url.hash) {
|
if (navigation.type !== 'popstate' && !navigation.to?.url.hash) {
|
||||||
mainEl?.scrollTo({ top: 0 });
|
mainEl?.scrollTo({ top: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof document.startViewTransition === 'function' || reducedMotion()) {
|
|
||||||
contentVisible = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const startedAt = Date.now();
|
|
||||||
const reveal = () => {
|
|
||||||
if (get(pageContentReady)) {
|
|
||||||
contentVisible = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Date.now() - startedAt > READY_HOLD_MS) {
|
|
||||||
// same trade as above: show the skeleton, and say why it is empty
|
|
||||||
contentVisible = true;
|
|
||||||
showOverlay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
revealTimer = window.setTimeout(reveal, 20);
|
|
||||||
};
|
|
||||||
reveal();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// the maps page embeds a full-bleed map: no padding, no scrolling
|
// the maps page embeds a full-bleed map: no padding, no scrolling
|
||||||
@@ -225,15 +263,38 @@
|
|||||||
|
|
||||||
<!-- Page-wide loading veil. Deliberately `pointer-events-none`: it is a status
|
<!-- Page-wide loading veil. Deliberately `pointer-events-none`: it is a status
|
||||||
indicator, not a modal, so the nav and the search stay usable while a slow
|
indicator, not a modal, so the nav and the search stay usable while a slow
|
||||||
forecast is still on its way. -->
|
forecast is still on its way. It is also always dismissable - Escape or the
|
||||||
|
close button - because a veil nobody can get rid of is worse than no veil,
|
||||||
|
and the page underneath is perfectly usable either way. -->
|
||||||
|
<svelte:window onkeydown={onWindowKeydown} />
|
||||||
|
|
||||||
{#if loadingOverlay}
|
{#if loadingOverlay}
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none fixed inset-0 z-60 flex items-center justify-center bg-background/55 backdrop-blur-[2px]"
|
class="pointer-events-none fixed inset-0 z-60 flex items-center justify-center bg-background/55 backdrop-blur-[2px]"
|
||||||
in:fade={{ duration: 120 }}
|
in:fade={{ duration: overlayFadesIn ? 120 : 0 }}
|
||||||
out:fade={{ duration: 280 }}
|
out:fade={{ duration: 280 }}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pointer-events-auto absolute top-3 right-3 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-lg transition-colors hover:bg-muted hover:text-foreground md:top-4 md:right-4"
|
||||||
|
onclick={dismissOverlay}
|
||||||
|
aria-label={m.page_loading_dismiss()}
|
||||||
|
title={m.page_loading_dismiss()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2.5 rounded-full border border-border bg-card px-4 py-2 shadow-lg"
|
class="flex items-center gap-2.5 rounded-full border border-border bg-card px-4 py-2 shadow-lg"
|
||||||
>
|
>
|
||||||
@@ -297,10 +358,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- cap the content width on very large screens; the footer below
|
<!-- cap the content width on very large screens; the footer below
|
||||||
gives the page its ending, so only modest bottom room is needed -->
|
gives the page its ending, so only modest bottom room is needed -->
|
||||||
<div
|
<div class="mx-auto w-full max-w-[1536px] flex-1 pb-24">
|
||||||
class="page-fade mx-auto w-full max-w-[1536px] flex-1 pb-24"
|
|
||||||
class:page-fade-hidden={!contentVisible}
|
|
||||||
>
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
<!-- full-bleed footer inside the scroll area (its own inner max-w),
|
<!-- full-bleed footer inside the scroll area (its own inner max-w),
|
||||||
|
|||||||
+22
-24
@@ -133,23 +133,6 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Page cross-fade: driven from routes/+layout.svelte, revealed when the new
|
|
||||||
page reports its data has arrived. */
|
|
||||||
.page-fade {
|
|
||||||
transition: opacity 320ms ease;
|
|
||||||
}
|
|
||||||
.page-fade-hidden {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.page-fade {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
.page-fade-hidden {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Day switching ────────────────────────────────────────────────────────
|
/* ── Day switching ────────────────────────────────────────────────────────
|
||||||
Only the three regions whose content depends on the selected day take part
|
Only the three regions whose content depends on the selected day take part
|
||||||
in the cross-fade. Everything else keeps its pixels: the `day-switch`
|
in the cross-fade. Everything else keeps its pixels: the `day-switch`
|
||||||
@@ -210,15 +193,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Theme changes cross-fade the whole document in one pass (see
|
/* Whole-document cross-fade, for both a page swap and a theme change (see
|
||||||
routes/+layout.svelte). Doing it as a view transition instead of a blanket
|
routes/+layout.svelte). Doing the theme as a view transition instead of a
|
||||||
`* { transition }` matters: that blanket rule also stretched every
|
blanket `* { transition }` matters: that blanket rule also stretched every
|
||||||
component's own hover and focus transitions to 400ms for the duration of
|
component's own hover and focus transitions to 400ms for the duration of
|
||||||
the switch, which read as lag on interactive controls. */
|
the switch, which read as lag on interactive controls.
|
||||||
::view-transition-old(root),
|
|
||||||
|
The default cross-fade is NOT opacity-neutral. Both snapshots are opaque
|
||||||
|
and the browser fades one out while fading the other in, so halfway
|
||||||
|
through, the two together cover only ~75% of the screen and the page
|
||||||
|
background shows through everything: the whole screen visibly dips, which
|
||||||
|
reads as a flash. Holding the outgoing snapshot at full opacity and fading
|
||||||
|
only the incoming one in on top of it (it is painted above) keeps every
|
||||||
|
frame fully covered. */
|
||||||
|
::view-transition-old(root) {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
::view-transition-new(root) {
|
::view-transition-new(root) {
|
||||||
animation-duration: 400ms;
|
animation: root-fade-in 400ms ease;
|
||||||
animation-timing-function: ease;
|
}
|
||||||
|
@keyframes root-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fallback for browsers without view transitions: fade the colours only. */
|
/* Fallback for browsers without view transitions: fade the colours only. */
|
||||||
|
|||||||
Reference in New Issue
Block a user