try strip desktop

This commit is contained in:
Vincent van der Wal
2026-08-01 10:32:22 +02:00
parent 2f86d86165
commit 99c1a89537
6 changed files with 442 additions and 305 deletions
+31
View File
@@ -86,6 +86,37 @@ export const defaultVariablePrefs: VariablePrefs = {
export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs); export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs);
/** Order of the hourly-table rows (visibility is the separate toggle above). */
export const defaultTableRowOrder: string[] = [
'icons',
'temperature',
'feels',
'dew_point',
'wind',
'gusts',
'humidity',
'clouds',
'pressure',
'uv',
'visibility',
'precipitation',
'snowfall'
];
export const storedTableRowOrder = persisted<string[]>('table_row_order_v1', defaultTableRowOrder);
/**
* Normalizes a stored row order against the known rows: drops keys that no
* longer exist and appends new rows (added after the user last saved) at the
* end, so stored orders survive app updates.
*/
export function mergeTableRowOrder(stored: string[]): string[] {
return [
...stored.filter((k) => defaultTableRowOrder.includes(k)),
...defaultTableRowOrder.filter((k) => !stored.includes(k))
];
}
/** Hourly table interval: 3-hourly (default) or 1-hourly. */ /** Hourly table interval: 3-hourly (default) or 1-hourly. */
export const storedHourlyInterval = persisted<1 | 3>('hourly_interval', 3); export const storedHourlyInterval = persisted<1 | 3>('hourly_interval', 3);
@@ -231,12 +231,12 @@
/> />
</div> </div>
<!-- Mobile: a compact day strip and the hourly table share this wrapper, so <!-- The sticky day strip, the hourly table AND the meteograms share this
the strip stays stuck (collapsing as it goes) while scrolling the table wrapper, so the strip stays stuck for the entire page (it collapses
and then releases exactly at the table's bottom, freeing the meteograms. as it sticks on mobile; on md+ it's a slim always-compact bar under
The strip itself is hidden on md+ (the desktop cards above take over). the topbar, complementing the full cards above). timeline-scope
timeline-scope hoists the strip's sentinel view-timeline so the sticky hoists the strip's sentinel view-timeline so the sticky strip (a
strip (a sibling of the sentinel) can scrub its collapse from it. --> sibling of the sentinel) can scrub its collapse from it. -->
<div style="timeline-scope: --daystrip-sentinel"> <div style="timeline-scope: --daystrip-sentinel">
{#if fetchedDaily} {#if fetchedDaily}
<DailyStripSticky <DailyStripSticky
@@ -267,7 +267,6 @@
class="h-107.5 animate-pulse rounded-2xl border border-border/70 bg-card" class="h-107.5 animate-pulse rounded-2xl border border-border/70 bg-card"
></div> ></div>
{/if} {/if}
</div>
{#if fetchedHourly} {#if fetchedHourly}
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} /> <MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
@@ -278,4 +277,5 @@
</section> </section>
{/if} {/if}
</div> </div>
</div>
</div> </div>
@@ -116,7 +116,7 @@
{#if canExtendPast && onExtendPast} {#if canExtendPast && onExtendPast}
<button <button
type="button" type="button"
class="mr-4 flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground" class="day-side mr-4 flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
onclick={onExtendPast} onclick={onExtendPast}
aria-label="Load recent past days" aria-label="Load recent past days"
> >
@@ -347,7 +347,7 @@
{#if daily && canExtend && onExtend} {#if daily && canExtend && onExtend}
<button <button
type="button" type="button"
class="mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:mt-5 md:mb-11 md:flex md:w-24" class="day-side mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:mt-5 md:mb-11 md:flex md:w-24"
onclick={onExtend} onclick={onExtend}
aria-label="Load the longer-range forecast" aria-label="Load the longer-range forecast"
> >
@@ -409,10 +409,18 @@
} }
} }
/* Mobile: keep the exact desktop layout, just scale the whole card down. */ /* Keep the exact same layout at every size, just scale it: small screens get
the compact scale, and mid-size desktops (anything below ~1440p) a gentler
one so the cards don't look oversized on 1080p displays. */
@media (max-width: 768px) { @media (max-width: 768px) {
.day-card { .day-card {
zoom: 0.72; zoom: 0.72;
} }
} }
@media (min-width: 768px) and (max-width: 2200px) {
.day-card,
.day-side {
zoom: 0.85;
}
}
</style> </style>
@@ -30,10 +30,9 @@
onExtendPast onExtendPast
}: Props = $props(); }: Props = $props();
// ─── Scroll-driven collapse (full → compact) ──────────────────────────────── // ─── Scroll-driven collapse (full → compact, mobile only) ───────────────────
// The strip collapses from full cards to a compact strip as it sticks. All // All sizing derives from a single registered custom property `--strip-p`
// sizing derives from a single registered custom property `--strip-p` (0 = // (0 = full cards, 1 = compact strip):
// full, 1 = compact):
// //
// * Browsers with CSS scroll-driven animations (Chrome/Edge 115+, Safari 26+) // * Browsers with CSS scroll-driven animations (Chrome/Edge 115+, Safari 26+)
// scrub `--strip-p` natively from a view-timeline on the sentinel below — // scrub `--strip-p` natively from a view-timeline on the sentinel below —
@@ -41,6 +40,9 @@
// * Everything else (Firefox, older Safari) snaps between the two states // * Everything else (Firefox, older Safari) snaps between the two states
// with a short CSS transition instead: an IntersectionObserver on the same // with a short CSS transition instead: an IntersectionObserver on the same
// sentinel toggles `.compact`. No per-frame scroll handler anywhere. // sentinel toggles `.compact`. No per-frame scroll handler anywhere.
//
// On md+ the strip is pinned to the compact state (no animation) and docks
// under the topbar as a slim always-sticky day picker.
let sentinelEl = $state<HTMLDivElement>(); let sentinelEl = $state<HTMLDivElement>();
let stripScrollEl = $state<HTMLDivElement>(); let stripScrollEl = $state<HTMLDivElement>();
let daysWrapEl = $state<HTMLDivElement>(); let daysWrapEl = $state<HTMLDivElement>();
@@ -83,19 +85,19 @@
}); });
</script> </script>
<!-- progress sentinel: an invisible 120px band (taking no layout space) just <!-- progress sentinel: an invisible band (taking no layout space) just above
above the sticky strip. Its exit across the scrollport top drives the the sticky strip. Its exit across the scrollport top drives the collapse —
collapse — via view-timeline where supported, IntersectionObserver via view-timeline where supported, IntersectionObserver otherwise. It sits
otherwise. It sits above the strip so the strip shrinking (which reflows above the strip so the strip shrinking never feeds back into the
the table below) never feeds back into the measurement. --> measurement. -->
<div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div> <div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div>
<div <div
class="daystrip sticky -top-3 z-30 -mx-3 md:hidden" class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8"
class:js-snap={needsSnapFallback} class:js-snap={needsSnapFallback}
class:compact class:compact
> >
<div class="strip-row flex overflow-x-auto px-3 py-2" bind:this={stripScrollEl}> <div class="strip-row flex overflow-x-auto px-3 py-2 md:py-1 lg:px-8" bind:this={stripScrollEl}>
{#if daily} {#if daily}
{#if canExtendPast && onExtendPast} {#if canExtendPast && onExtendPast}
<button <button
@@ -105,7 +107,7 @@
aria-label="Load recent past days" aria-label="Load recent past days"
> >
<svg <svg
class="h-5 w-5" class="h-5 w-5 md:h-4 md:w-4"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -133,21 +135,26 @@
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)} {#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
<button <button
type="button" type="button"
class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 pb-1.5 {selected class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 pb-1.5 md:gap-0 md:pb-0 {selected
? 'border-primary bg-primary/10 ring-1 ring-primary/50' ? 'border-primary bg-primary/10 ring-1 ring-primary/50'
: 'border-border/60 bg-card'}" : 'border-border/60 bg-card'}"
aria-pressed={selected} aria-pressed={selected}
onclick={() => onSelectDay(time, index)} onclick={() => onSelectDay(time, index)}
> >
<!-- weekday drifts to the left edge and the date fades in at the
right edge as the cards collapse (flex spacers driven by --rel) -->
<span <span
class="text-[11px] font-semibold tracking-wide whitespace-nowrap {selected class="dow-row flex w-full items-baseline px-0.5 text-[11px] font-semibold tracking-wide whitespace-nowrap md:text-[10px] {selected
? 'text-primary' ? 'text-primary'
: ''}" : ''}"
> >
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}<span <span class="dow-spacer"></span>
class="date-inline align-baseline font-medium tabular-nums text-muted-foreground" <span>{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}</span>
>&hairsp;{formatZoned(time, daily.timezone, 'd')}</span <span class="dow-mid"></span>
<span class="date-inline text-right font-medium tabular-nums text-muted-foreground"
>{formatZoned(time, daily.timezone, 'd')}</span
> >
<span class="dow-spacer"></span>
</span> </span>
<span <span
@@ -156,7 +163,7 @@
{getRelativeDayLabel(time, daily.timezone)} {getRelativeDayLabel(time, daily.timezone)}
</span> </span>
<div class="relative"> <div class="icon-wrap relative">
<svg class="day-icon fill-foreground"> <svg class="day-icon fill-foreground">
<use <use
xlink:href="/images/weather-icons/{getWeatherIconName( xlink:href="/images/weather-icons/{getWeatherIconName(
@@ -180,7 +187,7 @@
<div class="flex items-baseline gap-1 leading-none"> <div class="flex items-baseline gap-1 leading-none">
<span <span
class="temp-max rounded-md py-0.5 font-extrabold tabular-nums" class="temp-max rounded-md py-0.5 font-extrabold tabular-nums md:py-px"
style="background-color:{maxStyle.bg};color:{maxStyle.fg}" style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
> >
{tempMax.toFixed(0)}° {tempMax.toFixed(0)}°
@@ -218,7 +225,7 @@
aria-label="Load the longer-range forecast" aria-label="Load the longer-range forecast"
> >
<svg <svg
class="h-5 w-5" class="h-5 w-5 md:h-4 md:w-4"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -246,27 +253,32 @@
initial-value: 0; initial-value: 0;
} }
/* Invisible 120px collapse band: the distance over which the collapse plays /* Tunables, shared by the strip and its sentinel: the collapse plays out
out. The negative margin removes it from layout so nothing shifts. */ over exactly the cell-height difference (see .daystrip height below). */
.sentinel { .sentinel,
height: 120px;
margin-bottom: -120px;
pointer-events: none;
}
.daystrip { .daystrip {
/* tunables */
--cell-w-full: 76px; --cell-w-full: 76px;
--cell-w-min: 56px; --cell-w-min: 56px;
--cell-h-full: 146px; --cell-h-full: 140px;
--cell-h-min: 66px; --cell-h-min: 62px;
--icon-full: 44px; --icon-full: 44px;
--icon-min: 21px; --icon-min: 21px;
--pt-full: 7px; --pt-full: 7px;
--pt-min: 2px; --pt-min: 2px;
--gap-full: 8px; --gap-full: 8px;
--gap-min: 4px; --gap-min: 4px;
--collapse: calc(var(--cell-h-full) - var(--cell-h-min));
}
/* Invisible collapse band: the scroll distance over which the collapse
plays. The negative margin removes it from layout so nothing shifts. */
.sentinel {
height: var(--collapse);
margin-bottom: calc(-1 * var(--collapse));
pointer-events: none;
}
.daystrip {
--strip-p: 0; --strip-p: 0;
/* progress-derived (--k is the "fullness": 1 when full, 0 when compact) */ /* progress-derived (--k is the "fullness": 1 when full, 0 when compact) */
@@ -278,12 +290,29 @@
--gap: calc(var(--gap-min) + (var(--gap-full) - var(--gap-min)) * var(--k)); --gap: calc(var(--gap-min) + (var(--gap-full) - var(--gap-min)) * var(--k));
--rel: clamp(0, calc(1 - var(--strip-p) * 2), 1); --rel: clamp(0, calc(1 - var(--strip-p) * 2), 1);
--detail: clamp(0, calc(1 - var(--strip-p) * 1.6), 1); --detail: clamp(0, calc(1 - var(--strip-p) * 1.6), 1);
/* bar background/divider fade in once it starts collapsing */ /* bar background turns opaque almost as soon as the strip sticks, so
--chrome: clamp(0, calc(var(--strip-p) / 0.35), 1); content never shows through it */
--chrome: clamp(0, calc(var(--strip-p) * 6), 1);
/* Opaque-background fade only — no backdrop-filter blur or animated /* The sticky box keeps a CONSTANT height — only its contents shrink.
box-shadow (both are very expensive to repaint every scroll frame on The collapse therefore never resizes the document (the large table
mobile). Promote to its own compositor layer so repaints stay isolated. */ below would otherwise reflow on every scroll frame: the main source
of scroll jank on mobile Chromium), and because the collapse distance
equals the height difference, the table slides up under the shrinking
bar in exact sync. The empty lower part is click-through. */
height: calc(var(--cell-h-full) + 16px);
pointer-events: none;
contain: layout style;
/* own compositor layer: per-frame repaints stay isolated to the strip */
transform: translateZ(0);
}
/* The visible bar: hugs the (shrinking) content and carries the chrome.
No backdrop-filter blur or animated box-shadow — both are very expensive
to repaint every scroll frame on mobile. */
.strip-row {
pointer-events: auto;
gap: var(--gap);
background: color-mix( background: color-mix(
in oklab, in oklab,
var(--color-background) calc(var(--chrome) * 100%), var(--color-background) calc(var(--chrome) * 100%),
@@ -291,7 +320,6 @@
); );
border-bottom: 1px solid border-bottom: 1px solid
color-mix(in oklab, var(--color-border) calc(var(--chrome) * 100%), transparent); color-mix(in oklab, var(--color-border) calc(var(--chrome) * 100%), transparent);
transform: translateZ(0);
} }
/* Scrub path: the sentinel's exit across the scrollport top maps directly to /* Scrub path: the sentinel's exit across the scrollport top maps directly to
@@ -327,13 +355,27 @@
} }
} }
.strip-row, /* md+: no collapse — the strip is a slim, always-compact day picker that
docks under the topbar, matching its h-14 (56px) height exactly. */
@media (min-width: 768px) {
.sentinel,
.daystrip {
--cell-h-min: 48px;
--icon-min: 20px;
--gap-min: 6px;
}
.daystrip {
--strip-p: 1;
animation: none;
height: 56px;
}
}
.strip-days { .strip-days {
gap: var(--gap); gap: var(--gap);
} }
.strip-cell, .strip-cell,
.strip-side { .strip-side {
width: var(--cell-w);
height: var(--cell-h); height: var(--cell-h);
/* size tracks the collapse exactly; only the tap highlight eases */ /* size tracks the collapse exactly; only the tap highlight eases */
transition: transition:
@@ -341,9 +383,22 @@
background-color 0.15s; background-color 0.15s;
} }
.strip-cell { .strip-cell {
width: var(--cell-w);
padding-top: var(--pt); padding-top: var(--pt);
} }
/* Side buttons keep the compact width in BOTH states, so almost nothing to
the left of the first day changes size during the collapse — the first
day stays put instead of drifting off-screen. */
.strip-side {
width: var(--cell-w-min);
}
.icon-wrap {
/* tuck the icon into its line box: the glyphs carry generous built-in
padding, so pulling the neighbours in keeps the cells tight */
margin-block: -2px -3px;
}
.day-icon { .day-icon {
display: block;
width: var(--icon); width: var(--icon);
height: var(--icon); height: var(--icon);
} }
@@ -352,17 +407,17 @@
height: calc(var(--icon) * 0.44); height: calc(var(--icon) * 0.44);
opacity: var(--rel); opacity: var(--rel);
} }
/* Day-of-month slides in next to the weekday as the cards collapse /* weekday centered when full, pushed to the edges when compact */
("MON" → "MON 12"), replacing the relative label that fades out. */ .dow-spacer {
flex-grow: var(--rel);
}
.dow-mid {
flex-grow: calc(1 - var(--rel));
}
.date-inline { .date-inline {
display: inline-block;
overflow: hidden; overflow: hidden;
white-space: nowrap; /* width 0 while the cards are full so the weekday stays exactly centered */
/* overflow≠visible makes an inline-block's baseline its bottom edge, which width: calc(14px * (1 - var(--rel)));
would render the digits superscript; text-bottom restores baseline
alignment (same font size as the weekday, so descents match). */
vertical-align: text-bottom;
max-width: calc(20px * (1 - var(--rel)));
opacity: calc(1 - var(--rel)); opacity: calc(1 - var(--rel));
} }
.rel-label { .rel-label {
@@ -3,7 +3,9 @@
import { import {
defaultVariablePrefs, defaultVariablePrefs,
mergeTableRowOrder,
storedHourlyInterval, storedHourlyInterval,
storedTableRowOrder,
storedVariablePrefs storedVariablePrefs
} from '$lib/stores/settings'; } from '$lib/stores/settings';
@@ -60,6 +62,10 @@
// persisted 1h / 3h preference // persisted 1h / 3h preference
let hourlyInterval = $derived($storedHourlyInterval); let hourlyInterval = $derived($storedHourlyInterval);
// Row order, controlled from the Variables sidebar (stored orders are
// normalized so rows added in app updates still appear).
let rowOrder = $derived(mergeTableRowOrder($storedTableRowOrder));
// Row visibility, controlled from the Variables sidebar (missing keys // Row visibility, controlled from the Variables sidebar (missing keys
// from older stored prefs default to visible) // from older stored prefs default to visible)
let showRow = $derived( let showRow = $derived(
@@ -580,7 +586,8 @@
</tr> </tr>
<!-- Weather Icons --> <!-- Weather Icons -->
{#if showRow('icons')} {#each rowOrder as rowKey (rowKey)}
{#if rowKey === 'icons' && showRow('icons')}
<tr class="row"> <tr class="row">
{@render rowHeader('wi-day-cloudy')} {@render rowHeader('wi-day-cloudy')}
{#each cellData as cell, i (cell.idx)} {#each cellData as cell, i (cell.idx)}
@@ -598,10 +605,8 @@
</td> </td>
{/each} {/each}
</tr> </tr>
{/if}
<!-- Temperature --> <!-- Temperature -->
{#if showRow('temperature')} {:else if rowKey === 'temperature' && showRow('temperature')}
<tr class="row"> <tr class="row">
{@render rowHeader('wi-thermometer', tempUnit, 'Temp')} {@render rowHeader('wi-thermometer', tempUnit, 'Temp')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
@@ -615,10 +620,8 @@
</td> </td>
{/each} {/each}
</tr> </tr>
{/if}
<!-- Feels Like (short row) --> <!-- Feels Like (short row) -->
{#if showRow('feels')} {:else if rowKey === 'feels' && showRow('feels')}
<tr class="row"> <tr class="row">
{@render rowHeader(undefined, tempUnit, 'Feels')} {@render rowHeader(undefined, tempUnit, 'Feels')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
@@ -630,10 +633,8 @@
</td> </td>
{/each} {/each}
</tr> </tr>
{/if}
<!-- Dew Point (short row; off by default) --> <!-- Dew Point (short row; off by default) -->
{#if showRow('dew_point')} {:else if rowKey === 'dew_point' && showRow('dew_point')}
<tr class="row"> <tr class="row">
{@render rowHeader('wi-raindrop', tempUnit, 'Dew')} {@render rowHeader('wi-raindrop', tempUnit, 'Dew')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
@@ -645,10 +646,8 @@
</td> </td>
{/each} {/each}
</tr> </tr>
{/if}
<!-- Wind --> <!-- Wind -->
{#if showRow('wind')} {:else if rowKey === 'wind' && showRow('wind')}
<tr class="row"> <tr class="row">
{@render rowHeader('wi-strong-wind', windUnit, 'Wind')} {@render rowHeader('wi-strong-wind', windUnit, 'Wind')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
@@ -669,10 +668,8 @@
</td> </td>
{/each} {/each}
</tr> </tr>
{/if}
<!-- Wind Gusts (off by default) --> <!-- Wind Gusts (off by default) -->
{#if showRow('gusts')} {:else if rowKey === 'gusts' && showRow('gusts')}
<tr class="row"> <tr class="row">
{@render rowHeader('wi-strong-wind', windUnit, 'Gusts')} {@render rowHeader('wi-strong-wind', windUnit, 'Gusts')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
@@ -686,10 +683,8 @@
</td> </td>
{/each} {/each}
</tr> </tr>
{/if}
<!-- Humidity (short row) --> <!-- Humidity (short row) -->
{#if showRow('humidity')} {:else if rowKey === 'humidity' && showRow('humidity')}
<tr class="row"> <tr class="row">
{@render rowHeader('wi-humidity', '%', 'Humidity')} {@render rowHeader('wi-humidity', '%', 'Humidity')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
@@ -699,10 +694,8 @@
</td> </td>
{/each} {/each}
</tr> </tr>
{/if}
<!-- Cloud Cover --> <!-- Cloud Cover -->
{#if showRow('clouds')} {:else if rowKey === 'clouds' && showRow('clouds')}
<tr class="row"> <tr class="row">
{@render rowHeader('wi-cloud', '%', 'Clouds')} {@render rowHeader('wi-cloud', '%', 'Clouds')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
@@ -715,10 +708,8 @@
</td> </td>
{/each} {/each}
</tr> </tr>
{/if}
<!-- Pressure (off by default) --> <!-- Pressure (off by default) -->
{#if showRow('pressure')} {:else if rowKey === 'pressure' && showRow('pressure')}
<tr class="row"> <tr class="row">
{@render rowHeader('wi-barometer', 'hPa', 'Pressure')} {@render rowHeader('wi-barometer', 'hPa', 'Pressure')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
@@ -731,10 +722,8 @@
</td> </td>
{/each} {/each}
</tr> </tr>
{/if}
<!-- UV Index (off by default) --> <!-- UV Index (off by default) -->
{#if showRow('uv')} {:else if rowKey === 'uv' && showRow('uv')}
<tr class="row"> <tr class="row">
{@render rowHeader('wi-day-sunny', 'UV', 'UV')} {@render rowHeader('wi-day-sunny', 'UV', 'UV')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
@@ -747,10 +736,8 @@
</td> </td>
{/each} {/each}
</tr> </tr>
{/if}
<!-- Visibility (off by default) --> <!-- Visibility (off by default) -->
{#if showRow('visibility')} {:else if rowKey === 'visibility' && showRow('visibility')}
<tr class="row"> <tr class="row">
{@render rowHeader('wi-fog', 'km', 'Visibility')} {@render rowHeader('wi-fog', 'km', 'Visibility')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
@@ -760,10 +747,8 @@
</td> </td>
{/each} {/each}
</tr> </tr>
{/if}
<!-- Precipitation --> <!-- Precipitation -->
{#if showRow('precipitation')} {:else if rowKey === 'precipitation' && showRow('precipitation')}
<tr class="row"> <tr class="row">
{@render rowHeader('wi-raindrop', precipUnit, 'Precip')} {@render rowHeader('wi-raindrop', precipUnit, 'Precip')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
@@ -783,10 +768,8 @@
</td> </td>
{/each} {/each}
</tr> </tr>
{/if}
<!-- Snowfall (off by default) --> <!-- Snowfall (off by default) -->
{#if showRow('snowfall')} {:else if rowKey === 'snowfall' && showRow('snowfall')}
<tr class="row"> <tr class="row">
{@render rowHeader('wi-snow', 'cm', 'Snow')} {@render rowHeader('wi-snow', 'cm', 'Snow')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
@@ -797,6 +780,7 @@
{/each} {/each}
</tr> </tr>
{/if} {/if}
{/each}
</tbody> </tbody>
</table> </table>
@@ -1,7 +1,13 @@
<script lang="ts"> <script lang="ts">
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
import { defaultVariablePrefs, storedVariablePrefs } from '$lib/stores/settings'; import {
defaultTableRowOrder,
defaultVariablePrefs,
mergeTableRowOrder,
storedTableRowOrder,
storedVariablePrefs
} from '$lib/stores/settings';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
@@ -13,21 +19,24 @@
let { open, onClose }: Props = $props(); let { open, onClose }: Props = $props();
const tableVariables = [ const tableVariableLabels: Record<string, string> = {
{ key: 'icons', label: 'Weather icons' }, icons: 'Weather icons',
{ key: 'temperature', label: 'Temperature' }, temperature: 'Temperature',
{ key: 'feels', label: 'Feels like' }, feels: 'Feels like',
{ key: 'dew_point', label: 'Dew point' }, dew_point: 'Dew point',
{ key: 'wind', label: 'Wind' }, wind: 'Wind',
{ key: 'gusts', label: 'Wind gusts' }, gusts: 'Wind gusts',
{ key: 'humidity', label: 'Humidity' }, humidity: 'Humidity',
{ key: 'clouds', label: 'Cloud cover' }, clouds: 'Cloud cover',
{ key: 'pressure', label: 'Pressure' }, pressure: 'Pressure',
{ key: 'uv', label: 'UV index' }, uv: 'UV index',
{ key: 'visibility', label: 'Visibility' }, visibility: 'Visibility',
{ key: 'precipitation', label: 'Precipitation' }, precipitation: 'Precipitation',
{ key: 'snowfall', label: 'Snowfall' } snowfall: 'Snowfall'
]; };
// Listed in the same (customizable) order the table renders its rows.
let tableRowOrder = $derived(mergeTableRowOrder($storedTableRowOrder));
function toggle(section: 'table' | 'charts', key: string) { function toggle(section: 'table' | 'charts', key: string) {
storedVariablePrefs.update((prefs) => { storedVariablePrefs.update((prefs) => {
@@ -36,8 +45,21 @@
}); });
} }
/** Moves a table row up (-1) or down (+1) in the rendered order. */
function moveRow(key: string, dir: -1 | 1) {
storedTableRowOrder.update((stored) => {
const order = mergeTableRowOrder(stored);
const i = order.indexOf(key);
const j = i + dir;
if (i < 0 || j < 0 || j >= order.length) return order;
[order[i], order[j]] = [order[j], order[i]];
return order;
});
}
function resetDefaults() { function resetDefaults() {
storedVariablePrefs.set(structuredClone(defaultVariablePrefs)); storedVariablePrefs.set(structuredClone(defaultVariablePrefs));
storedTableRowOrder.set([...defaultTableRowOrder]);
} }
</script> </script>
@@ -88,17 +110,54 @@
Hourly table Hourly table
</h3> </h3>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
{#each tableVariables as variable (variable.key)} {#each tableRowOrder as key, i (key)}
<div class="flex items-center gap-2.5 rounded-md px-1 py-1 hover:bg-muted/60"> <div class="group flex items-center gap-2.5 rounded-md px-1 py-1 hover:bg-muted/60">
<Checkbox <Checkbox
id="table_var_{variable.key}" id="table_var_{key}"
class="cursor-pointer" class="cursor-pointer"
checked={$storedVariablePrefs.table?.[variable.key] ?? true} checked={$storedVariablePrefs.table?.[key] ?? true}
onCheckedChange={() => toggle('table', variable.key)} onCheckedChange={() => toggle('table', key)}
/> />
<Label class="flex-1 cursor-pointer text-sm" for="table_var_{variable.key}"> <Label class="flex-1 cursor-pointer text-sm" for="table_var_{key}">
{variable.label} {tableVariableLabels[key] ?? key}
</Label> </Label>
<!-- reorder: rows render in this exact order in the table -->
<div
class="flex items-center opacity-40 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
>
<button
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
onclick={() => moveRow(key, -1)}
disabled={i === 0}
aria-label="Move {tableVariableLabels[key] ?? key} up"
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.25"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 14l7-7 7 7" />
</svg>
</button>
<button
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
onclick={() => moveRow(key, 1)}
disabled={i === tableRowOrder.length - 1}
aria-label="Move {tableVariableLabels[key] ?? key} down"
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.25"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 10l-7 7-7-7" />
</svg>
</button>
</div>
</div> </div>
{/each} {/each}
</div> </div>