overlay
This commit is contained in:
+92
-16
@@ -13,9 +13,8 @@
|
||||
import Header from '$lib/components/navigation/header.svelte';
|
||||
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
|
||||
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
import { routePath } from '$lib/i18n';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import './layout.css';
|
||||
|
||||
@@ -63,18 +62,46 @@
|
||||
// ── Page cross-fade ───────────────────────────────────────────────────────
|
||||
// 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.
|
||||
// held open for a short grace period, waiting for the new page to report that
|
||||
// its data has landed. A cached or fast response lands inside that window and
|
||||
// the old page fades straight into the finished one, no skeleton in between.
|
||||
//
|
||||
// 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;
|
||||
// Past the grace period the wait gives up and the loading overlay takes over:
|
||||
// holding the old page frozen any longer looks like a dead click, and the
|
||||
// overlay is the honest answer - something is happening, it just isn't here
|
||||
// yet. Note the order: the overlay has to be in the DOM *before* the
|
||||
// transition captures the incoming state, because a running view transition
|
||||
// freezes the page and nothing painted after that point can appear.
|
||||
const READY_HOLD_MS = 350;
|
||||
const FADE_OUT_MS = 170;
|
||||
// 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.
|
||||
const OVERLAY_CEILING_MS = 15000;
|
||||
|
||||
let contentVisible = $state(true);
|
||||
let revealTimer = 0;
|
||||
|
||||
let loadingOverlay = $state(false);
|
||||
let overlayHoldTimer = 0;
|
||||
let overlayCeilingTimer = 0;
|
||||
|
||||
function showOverlay(): void {
|
||||
loadingOverlay = true;
|
||||
clearTimeout(overlayCeilingTimer);
|
||||
overlayCeilingTimer = window.setTimeout(() => (loadingOverlay = false), OVERLAY_CEILING_MS);
|
||||
}
|
||||
|
||||
function hideOverlay(): void {
|
||||
clearTimeout(overlayHoldTimer);
|
||||
clearTimeout(overlayCeilingTimer);
|
||||
loadingOverlay = false;
|
||||
}
|
||||
|
||||
/** The page that just mounted has its data: whatever we were waiting for is in. */
|
||||
$effect(() => {
|
||||
if ($pageContentReady) hideOverlay();
|
||||
});
|
||||
|
||||
const reducedMotion = () =>
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
@@ -89,22 +116,36 @@
|
||||
'/weather/historical/[location]'
|
||||
]);
|
||||
|
||||
/** Resolves once the freshly mounted page has its data, or at the ceiling. */
|
||||
/**
|
||||
* Resolves once the freshly mounted page has its data, or once the grace
|
||||
* period is up - in which case the overlay goes up first, so it is part of
|
||||
* the state the transition is about to snapshot.
|
||||
*/
|
||||
async function waitForContent(): Promise<void> {
|
||||
const deadline = Date.now() + READY_CEILING_MS;
|
||||
const deadline = Date.now() + READY_HOLD_MS;
|
||||
while (!get(pageContentReady) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
if (!get(pageContentReady)) showOverlay();
|
||||
// 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 (reducedMotion()) {
|
||||
// No transition to hang the wait on, so the overlay gets its own timer.
|
||||
if (loadsData) {
|
||||
clearTimeout(overlayHoldTimer);
|
||||
overlayHoldTimer = window.setTimeout(() => {
|
||||
if (!get(pageContentReady)) showOverlay();
|
||||
}, READY_HOLD_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document.startViewTransition === 'function') {
|
||||
return new Promise<void>((swap) => {
|
||||
document.startViewTransition(async () => {
|
||||
@@ -142,11 +183,17 @@
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
const reveal = () => {
|
||||
if (get(pageContentReady) || Date.now() - startedAt > READY_CEILING_MS) {
|
||||
if (get(pageContentReady)) {
|
||||
contentVisible = true;
|
||||
return;
|
||||
}
|
||||
revealTimer = window.setTimeout(reveal, 60);
|
||||
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();
|
||||
});
|
||||
@@ -171,11 +218,40 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<!-- the icon itself lives in app.html, so the SPA fallback carries it too -->
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</svelte:head>
|
||||
|
||||
<!-- 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
|
||||
forecast is still on its way. -->
|
||||
{#if loadingOverlay}
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-60 flex items-center justify-center bg-background/55 backdrop-blur-[2px]"
|
||||
in:fade={{ duration: 120 }}
|
||||
out:fade={{ duration: 280 }}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2.5 rounded-full border border-border bg-card px-4 py-2 shadow-lg"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 animate-spin text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
<span class="text-sm font-semibold">{m.page_loading()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex h-screen overflow-hidden bg-background text-foreground">
|
||||
<!-- Desktop sidebar -->
|
||||
<div class="hidden h-full shrink-0 md:block">
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
let subtitle = $derived(
|
||||
SUBTITLES.find(([prefix]) => routePath($page.url.pathname).startsWith(prefix))?.[1]?.() ?? null
|
||||
);
|
||||
|
||||
// Joined here rather than in the markup: Svelte trims the whitespace around a
|
||||
// line break, so a separator written as "{admin1},\n{country}" renders as
|
||||
// "Canton of Schwyz,Switzerland".
|
||||
let region = $derived([location?.admin1, location?.country].filter(Boolean).join(', '));
|
||||
</script>
|
||||
|
||||
{#if subtitle && location}
|
||||
@@ -57,10 +62,8 @@
|
||||
{location.name}
|
||||
</h1>
|
||||
<p class="truncate text-sm text-muted-foreground">
|
||||
<span class="lg:hidden"
|
||||
>{#if location.admin1}{location.admin1},
|
||||
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
||||
>{subtitle}
|
||||
{#if region}<span class="lg:hidden">{region}<span class="mx-1 opacity-50">·</span></span
|
||||
>{/if}{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// the page cross-fade waits for this before revealing the new page
|
||||
reportPageReady(() => fetchedData != null);
|
||||
reportPageReady(() => fetchedData != null || loadError != null);
|
||||
|
||||
useHeroActions(heroActions);
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// the page cross-fade waits for this before revealing the new page
|
||||
reportPageReady(() => fetchedData != null);
|
||||
reportPageReady(() => fetchedData != null || loadError != null);
|
||||
|
||||
useHeroActions(heroActions);
|
||||
|
||||
|
||||
@@ -39,8 +39,10 @@
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// the page cross-fade waits for this before revealing the new page
|
||||
reportPageReady(() => result != null);
|
||||
// The page cross-fade waits for this before revealing the new page - and so
|
||||
// does the loading overlay, so a visitor without a key has to count as ready:
|
||||
// the paywall is the finished page here, nothing is on its way.
|
||||
reportPageReady(() => result != null || loadError != null || !$isSupporter);
|
||||
|
||||
useHeroActions(heroActions);
|
||||
|
||||
@@ -213,7 +215,9 @@
|
||||
);
|
||||
|
||||
function switchDay(date: Date) {
|
||||
selectedDay.setTime(date.getTime());
|
||||
// see the week page: an unformattable selected day breaks every consumer
|
||||
const time = date?.getTime();
|
||||
if (Number.isFinite(time)) selectedDay.setTime(time);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -33,8 +33,10 @@
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// the page cross-fade waits for this before revealing the new page
|
||||
reportPageReady(() => result != null);
|
||||
// The page cross-fade waits for this before revealing the new page - and so
|
||||
// does the loading overlay, so a visitor without a key has to count as ready:
|
||||
// the paywall is the finished page here, nothing is on its way.
|
||||
reportPageReady(() => result != null || loadError != null || !$isSupporter);
|
||||
|
||||
useHeroActions(heroActions);
|
||||
|
||||
@@ -189,33 +191,39 @@
|
||||
|
||||
<!-- the range buttons ride in the layout's location row (see weather/+layout) -->
|
||||
{#snippet heroActions()}
|
||||
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto">
|
||||
{#if result}
|
||||
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
|
||||
<div
|
||||
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
|
||||
role="group"
|
||||
aria-label={m.seasonal_range_aria()}
|
||||
>
|
||||
{#each RANGES as range, i (range.label)}
|
||||
{@const disabled = range.days !== Infinity && range.days > horizonDays}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
|
||||
i
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'} {disabled
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: ''}"
|
||||
aria-pressed={rangeIndex === i}
|
||||
{disabled}
|
||||
onclick={() => (rangeIndex = i)}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Out of flow on lg+ (the hero row is `relative`), same as the week, 14-day
|
||||
and archive pages: the controls then cannot move the heading when they
|
||||
change size. -->
|
||||
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto lg:absolute lg:top-0 lg:right-0">
|
||||
<!-- Range buttons reslice the already-fetched horizon (no refetch). Kept
|
||||
mounted and merely hidden while the forecast is on its way: mounting
|
||||
them on arrival re-flowed the row and nudged the heading. -->
|
||||
<div
|
||||
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
|
||||
class:invisible={!result}
|
||||
role="group"
|
||||
aria-label={m.seasonal_range_aria()}
|
||||
aria-hidden={!result}
|
||||
>
|
||||
{#each RANGES as range, i (range.label)}
|
||||
{@const disabled = !result || (range.days !== Infinity && range.days > horizonDays)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
|
||||
i
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'} {disabled
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: ''}"
|
||||
aria-pressed={rangeIndex === i}
|
||||
{disabled}
|
||||
tabindex={result ? undefined : -1}
|
||||
onclick={() => (rangeIndex = i)}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<ModelSelector
|
||||
selectedModel={seasonalModel}
|
||||
groups={seasonalModelGroups}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// the page cross-fade waits for this before revealing the new page
|
||||
reportPageReady(() => fetchedDaily != null && fetchedHourly != null);
|
||||
reportPageReady(() => (fetchedDaily != null && fetchedHourly != null) || loadError != null);
|
||||
|
||||
useHeroActions(heroActions);
|
||||
|
||||
@@ -211,8 +211,13 @@
|
||||
|
||||
// Charts intentionally keep their current range: they show the full week
|
||||
// unless the user narrows it via the range presets or Ctrl+scroll.
|
||||
// A model that answers with a broken timestamp must not be able to park an
|
||||
// unreadable date in `selectedDay`: everything downstream formats it, and a
|
||||
// date that cannot be formatted takes the page with it.
|
||||
const switchDay = (date: Date) => {
|
||||
runDayTransition(() => selectedDay.setTime(date.getTime()));
|
||||
const time = date?.getTime();
|
||||
if (!Number.isFinite(time)) return;
|
||||
runDayTransition(() => selectedDay.setTime(time));
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
|
||||
Reference in New Issue
Block a user