Sidebar: sync scroll instead of a separate inner scrollbar
The previous fix (max-height + overflow-y:auto) made every widget reachable but required scrolling the sidebar itself independently of the article column — two different scroll regions felt janky. Replaced it with a three-layer structure: a plain spacer sized to the sidebar's full natural height (reserving the right amount of page scroll room), a sticky+clipped viewport box, and a content wrapper translated upward via a scroll listener. The translation amount is driven by how far the page has scrolled past the point where the sidebar started sticking, clamped to the overflow amount — so scrolling the article feed down reveals more of the sidebar in lockstep, and scrolling back up reverses it, all through the single page scrollbar. Short sidebars that already fit the viewport are unaffected (reveal range is zero, so it behaves exactly like plain sticky-to-top as before). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import type { Weather, StockTicker, Bookmark, Poe2Data } from '$lib/types';
|
||||
import WeatherWidget from './WeatherWidget.svelte';
|
||||
import StocksWidget from './StocksWidget.svelte';
|
||||
@@ -6,28 +7,95 @@
|
||||
import Poe2Widget from './Poe2Widget.svelte';
|
||||
|
||||
let { weather, stocks, bookmarks, poe2 }: { weather: Weather; stocks: StockTicker[]; bookmarks: Bookmark[]; poe2: Poe2Data } = $props();
|
||||
|
||||
// Weather + Stocks + PoE2 + Bookmarks stacked can be taller than the viewport. Plain
|
||||
// `position: sticky` alone can only pin a box at a constant offset — it can't reveal
|
||||
// content taller than that box without either an internal scrollbar (a second,
|
||||
// separate scroll region — janky) or shifting the content within the box as the page
|
||||
// scrolls. This does the latter: `.sidebar-track` is a plain (non-positioned) spacer
|
||||
// sized to the widgets' full natural height, so the page reserves exactly enough
|
||||
// scroll room; `.sidebar-viewport` is the sticky, viewport-capped, clipped box; and
|
||||
// `.sidebar-content` is translated upward inside it in lockstep with how far the page
|
||||
// has scrolled past the point where the sidebar started sticking — one continuous
|
||||
// scroll gesture over the whole page drives it, not a separate widget-local scrollbar.
|
||||
const TOP_MARGIN = 20;
|
||||
const BOTTOM_MARGIN = 20;
|
||||
|
||||
let trackEl: HTMLElement | undefined = $state();
|
||||
let contentEl: HTMLElement | undefined = $state();
|
||||
let trackHeight = $state(0);
|
||||
let viewportHeight = $state(0);
|
||||
let progress = $state(0);
|
||||
|
||||
function update() {
|
||||
if (!trackEl || !contentEl) return;
|
||||
const naturalHeight = contentEl.offsetHeight;
|
||||
const cappedHeight = Math.min(naturalHeight, window.innerHeight - TOP_MARGIN - BOTTOM_MARGIN);
|
||||
const revealRange = naturalHeight - cappedHeight;
|
||||
|
||||
trackHeight = naturalHeight;
|
||||
viewportHeight = cappedHeight;
|
||||
|
||||
if (revealRange <= 0) {
|
||||
progress = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// trackEl is never itself positioned/sticky, so its rect always reflects genuine
|
||||
// scroll progress — how far its top has moved past TOP_MARGIN is exactly how much
|
||||
// extra scrolling has happened since the sidebar started sticking.
|
||||
const extraScroll = TOP_MARGIN - trackEl.getBoundingClientRect().top;
|
||||
progress = Math.max(0, Math.min(revealRange, extraScroll));
|
||||
}
|
||||
|
||||
async function remeasure() {
|
||||
await tick();
|
||||
update();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Widget data changing the sidebar's natural height needs a remeasure, not just a
|
||||
// scroll-position update.
|
||||
void weather;
|
||||
void stocks;
|
||||
void bookmarks;
|
||||
void poe2;
|
||||
remeasure();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
window.addEventListener('scroll', update, { passive: true });
|
||||
window.addEventListener('resize', remeasure);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', update);
|
||||
window.removeEventListener('resize', remeasure);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside class="sidebar">
|
||||
<WeatherWidget {weather} />
|
||||
<StocksWidget {stocks} />
|
||||
<Poe2Widget {poe2} />
|
||||
<BookmarksWidget {bookmarks} />
|
||||
</aside>
|
||||
<div class="sidebar-track" bind:this={trackEl} style:height="{trackHeight}px">
|
||||
<aside class="sidebar-viewport" style:height="{viewportHeight}px">
|
||||
<div class="sidebar-content" bind:this={contentEl} style:transform="translateY(-{progress}px)">
|
||||
<WeatherWidget {weather} />
|
||||
<StocksWidget {stocks} />
|
||||
<Poe2Widget {poe2} />
|
||||
<BookmarksWidget {bookmarks} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
.sidebar-track {
|
||||
position: relative;
|
||||
}
|
||||
.sidebar-viewport {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
/* Weather + Stocks + PoE2 + Bookmarks stacked can easily be taller than the
|
||||
viewport, especially on categories with a short article list. Without a height
|
||||
cap, a sticky element taller than the viewport gets its overflow glued off-screen
|
||||
below the fold for the entire scroll — capping the height and scrolling the
|
||||
sidebar internally keeps every widget reachable instead. */
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user