layout shifts
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
humanizeWeatherError
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { useHeroActions } from '../../hero.svelte';
|
||||
import { defaultParameters } from '../../options';
|
||||
import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
|
||||
import DailyStripSticky from './DailyStripSticky.svelte';
|
||||
@@ -37,6 +38,8 @@
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
useHeroActions(heroActions);
|
||||
|
||||
let params = $state({
|
||||
models: ['best_match'],
|
||||
...defaultParameters
|
||||
@@ -56,6 +59,14 @@
|
||||
// arrives (no layout shift)
|
||||
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
|
||||
|
||||
// Height the hourly table will occupy once it renders, so its placeholder
|
||||
// reserves exactly that and nothing below it jumps. The table is a header row
|
||||
// plus one row per enabled variable, so the count drives the estimate.
|
||||
const TABLE_HEADER_PX = 96;
|
||||
const TABLE_ROW_PX = 57;
|
||||
let enabledTableRows = $derived(Object.values($storedVariablePrefs.table).filter(Boolean).length);
|
||||
let tableSkeletonHeight = $derived(TABLE_HEADER_PX + enabledTableRows * TABLE_ROW_PX);
|
||||
|
||||
// Request only the hourly variables the table rows and meteograms actually
|
||||
// show, so unused variables are never fetched. weather_code is always
|
||||
// included: the day cards / strip derive their day- and night-period icons
|
||||
@@ -200,45 +211,24 @@
|
||||
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
||||
</svelte:head>
|
||||
|
||||
<!-- the model picker rides in the layout's location row (see weather/+layout) -->
|
||||
{#snippet heroActions()}
|
||||
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'best_match'}
|
||||
onModelChange={(model) => {
|
||||
params.models = [model];
|
||||
storedModel.set(model);
|
||||
// a new model may not support the extended / past range
|
||||
forecastDays = 7;
|
||||
pastDays = 0;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="week-page">
|
||||
<div class="weather-content" style="min-height: 50vh">
|
||||
<!-- Page hero: prominent location + weather model selection -->
|
||||
<div class="relative flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<img
|
||||
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
||||
src="/images/country-flags/{(
|
||||
location.country_code || 'united_nations'
|
||||
).toLowerCase()}.svg"
|
||||
alt={location.country ?? ''}
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
||||
{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
|
||||
>7-day forecast
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'best_match'}
|
||||
onModelChange={(model) => {
|
||||
params.models = [model];
|
||||
storedModel.set(model);
|
||||
// a new model may not support the extended / past range
|
||||
forecastDays = 7;
|
||||
pastDays = 0;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
|
||||
|
||||
{#if loadError}
|
||||
@@ -314,19 +304,20 @@
|
||||
the strip's sentinel view-timeline so the sticky strip (a sibling of
|
||||
the sentinel) can scrub its collapse from it. -->
|
||||
<div style="timeline-scope: --daystrip-sentinel">
|
||||
{#if fetchedDaily}
|
||||
<DailyStripSticky
|
||||
daily={fetchedDaily}
|
||||
{selectedDay}
|
||||
units={params}
|
||||
onSelectDay={switchDay}
|
||||
canExtend={forecastDays < 15}
|
||||
onExtend={() => (forecastDays = 15)}
|
||||
canExtendPast={pastDays < 3}
|
||||
onExtendPast={() => (pastDays = 3)}
|
||||
{locationRoute}
|
||||
/>
|
||||
{/if}
|
||||
<!-- rendered even before the data lands: the strip's box is a fixed
|
||||
height, so keeping it mounted reserves its space (it shows skeleton
|
||||
tiles meanwhile) instead of shoving the page down on arrival -->
|
||||
<DailyStripSticky
|
||||
daily={fetchedDaily}
|
||||
{selectedDay}
|
||||
units={params}
|
||||
onSelectDay={switchDay}
|
||||
canExtend={forecastDays < 15}
|
||||
onExtend={() => (forecastDays = 15)}
|
||||
canExtendPast={pastDays < 3}
|
||||
onExtendPast={() => (pastDays = 3)}
|
||||
{locationRoute}
|
||||
/>
|
||||
|
||||
{#if fetchedHourly && fetchedDaily}
|
||||
<HourlyTable
|
||||
@@ -338,18 +329,41 @@
|
||||
onCustomize={() => (variableSidebarOpen = true)}
|
||||
/>
|
||||
{:else}
|
||||
<!-- placeholder with the table's approximate height: no layout shift -->
|
||||
<!-- Mirrors the real table: same header bar and the same body height,
|
||||
so the heading doesn't pop in and nothing below moves.
|
||||
Placeholders only fade IN - a fade-out would keep them in the
|
||||
layout while the real content mounts below, and everything below
|
||||
would jump the moment they finally unmount. -->
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="h-107.5 animate-pulse rounded-2xl border border-border/70 bg-card"
|
||||
></div>
|
||||
in:fade={{ duration: 200 }}
|
||||
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 border-b border-border/70 bg-muted/40 px-4 py-2.5"
|
||||
>
|
||||
<div class="h-6 w-44 animate-pulse rounded bg-muted"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-8 w-24 animate-pulse rounded-lg bg-muted"></div>
|
||||
<div class="h-8 w-20 animate-pulse rounded-lg bg-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="animate-pulse bg-card" style="height: {tableSkeletonHeight}px"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if fetchedHourly}
|
||||
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
||||
{:else}
|
||||
<!-- reserve the exact chart area height before the first fetch resolves -->
|
||||
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
||||
<!-- reserve the exact chart area height before the first fetch resolves,
|
||||
header row included -->
|
||||
<section class="mt-8" in:fade={{ duration: 200 }}>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="h-7 w-52 animate-pulse rounded bg-muted"></div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-7 w-56 animate-pulse rounded-lg bg-muted"></div>
|
||||
<div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user