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
@@ -30,10 +30,9 @@
onExtendPast
}: Props = $props();
// ─── Scroll-driven collapse (full → compact) ────────────────────────────────
// The strip collapses from full cards to a compact strip as it sticks. All
// sizing derives from a single registered custom property `--strip-p` (0 =
// full, 1 = compact):
// ─── Scroll-driven collapse (full → compact, mobile only) ───────────────────
// All sizing derives from a single registered custom property `--strip-p`
// (0 = full cards, 1 = compact strip):
//
// * Browsers with CSS scroll-driven animations (Chrome/Edge 115+, Safari 26+)
// 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
// with a short CSS transition instead: an IntersectionObserver on the same
// 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 stripScrollEl = $state<HTMLDivElement>();
let daysWrapEl = $state<HTMLDivElement>();
@@ -83,19 +85,19 @@
});
</script>
<!-- progress sentinel: an invisible 120px band (taking no layout space) just
above the sticky strip. Its exit across the scrollport top drives the
collapse — via view-timeline where supported, IntersectionObserver
otherwise. It sits above the strip so the strip shrinking (which reflows
the table below) never feeds back into the measurement. -->
<!-- progress sentinel: an invisible band (taking no layout space) just above
the sticky strip. Its exit across the scrollport top drives the collapse —
via view-timeline where supported, IntersectionObserver otherwise. It sits
above the strip so the strip shrinking never feeds back into the
measurement. -->
<div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></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: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 canExtendPast && onExtendPast}
<button
@@ -105,7 +107,7 @@
aria-label="Load recent past days"
>
<svg
class="h-5 w-5"
class="h-5 w-5 md:h-4 md:w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -133,21 +135,26 @@
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
<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-border/60 bg-card'}"
aria-pressed={selected}
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
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'
: ''}"
>
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}<span
class="date-inline align-baseline font-medium tabular-nums text-muted-foreground"
>&hairsp;{formatZoned(time, daily.timezone, 'd')}</span
<span class="dow-spacer"></span>
<span>{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}</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
@@ -156,7 +163,7 @@
{getRelativeDayLabel(time, daily.timezone)}
</span>
<div class="relative">
<div class="icon-wrap relative">
<svg class="day-icon fill-foreground">
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
@@ -180,7 +187,7 @@
<div class="flex items-baseline gap-1 leading-none">
<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}"
>
{tempMax.toFixed(0)}°
@@ -218,7 +225,7 @@
aria-label="Load the longer-range forecast"
>
<svg
class="h-5 w-5"
class="h-5 w-5 md:h-4 md:w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -246,27 +253,32 @@
initial-value: 0;
}
/* Invisible 120px collapse band: the distance over which the collapse plays
out. The negative margin removes it from layout so nothing shifts. */
.sentinel {
height: 120px;
margin-bottom: -120px;
pointer-events: none;
}
/* Tunables, shared by the strip and its sentinel: the collapse plays out
over exactly the cell-height difference (see .daystrip height below). */
.sentinel,
.daystrip {
/* tunables */
--cell-w-full: 76px;
--cell-w-min: 56px;
--cell-h-full: 146px;
--cell-h-min: 66px;
--cell-h-full: 140px;
--cell-h-min: 62px;
--icon-full: 44px;
--icon-min: 21px;
--pt-full: 7px;
--pt-min: 2px;
--gap-full: 8px;
--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;
/* 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));
--rel: clamp(0, calc(1 - var(--strip-p) * 2), 1);
--detail: clamp(0, calc(1 - var(--strip-p) * 1.6), 1);
/* bar background/divider fade in once it starts collapsing */
--chrome: clamp(0, calc(var(--strip-p) / 0.35), 1);
/* bar background turns opaque almost as soon as the strip sticks, so
content never shows through it */
--chrome: clamp(0, calc(var(--strip-p) * 6), 1);
/* Opaque-background fade only — no backdrop-filter blur or animated
box-shadow (both are very expensive to repaint every scroll frame on
mobile). Promote to its own compositor layer so repaints stay isolated. */
/* The sticky box keeps a CONSTANT height — only its contents shrink.
The collapse therefore never resizes the document (the large table
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(
in oklab,
var(--color-background) calc(var(--chrome) * 100%),
@@ -291,7 +320,6 @@
);
border-bottom: 1px solid
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
@@ -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 {
gap: var(--gap);
}
.strip-cell,
.strip-side {
width: var(--cell-w);
height: var(--cell-h);
/* size tracks the collapse exactly; only the tap highlight eases */
transition:
@@ -341,9 +383,22 @@
background-color 0.15s;
}
.strip-cell {
width: var(--cell-w);
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 {
display: block;
width: var(--icon);
height: var(--icon);
}
@@ -352,17 +407,17 @@
height: calc(var(--icon) * 0.44);
opacity: var(--rel);
}
/* Day-of-month slides in next to the weekday as the cards collapse
("MON" → "MON 12"), replacing the relative label that fades out. */
/* weekday centered when full, pushed to the edges when compact */
.dow-spacer {
flex-grow: var(--rel);
}
.dow-mid {
flex-grow: calc(1 - var(--rel));
}
.date-inline {
display: inline-block;
overflow: hidden;
white-space: nowrap;
/* overflow≠visible makes an inline-block's baseline its bottom edge, which
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)));
/* width 0 while the cards are full so the weekday stays exactly centered */
width: calc(14px * (1 - var(--rel)));
opacity: calc(1 - var(--rel));
}
.rel-label {