From 61486a503e1b0390927c8aee22a4bfc152e24f8d Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 7 Apr 2026 23:32:39 +0200 Subject: [PATCH] feat: sticky month-header overlay in Timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline date headers can't use CSS position: sticky because TanStack Virtual positions every item with transform translateY, which removes them from the document flow. Workaround: render a separate overlay above the scroll container that's absolutely positioned (left/right/top: 0) and updates its label as the user scrolls. The current label is computed from a pre-built headerOffsets array (cumulative sum of item heights up to each header) — find the latest header whose offset <= scrollTop, and that's the group containing whatever's at the top of the view. The overlay sits at z-20 above the photos with bg-bg/90 + backdrop-blur and pointer-events-none so it doesn't intercept clicks. Inline headers still render so the visual flow at group boundaries is smooth — the overlay is the persistent label. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/timeline/Timeline.tsx | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index 8d8b34b..c9dbc01 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -145,6 +145,20 @@ export function Timeline() { [photos, columns, sortBy] ) + // Pre-computed offset of every header in the virtualizer's coordinate + // space, used to drive the sticky-header overlay below. + const headerOffsets = useMemo(() => { + const result: { offset: number; label: string }[] = [] + let cumulative = 0 + for (const item of items) { + if (item.type === 'header') { + result.push({ offset: cumulative, label: item.label }) + } + cumulative += item.height + } + return result + }, [items]) + // Range-selection helper. Operates on the global photos array, not on // virtualizer items. const selectRange = (endIndex: number) => { @@ -171,6 +185,32 @@ export function Timeline() { virtualizer.measure() }, [items, virtualizer]) + // Track scroll position so we can show the current group label as a + // pinned overlay at the top of the scroll container. The virtualizer's + // items use transform translateY (so CSS position: sticky doesn't work + // on the inline headers); the overlay sidesteps that by living outside + // the virtualizer's positioned children. + const [scrollTop, setScrollTop] = useState(0) + useEffect(() => { + const el = parentRef.current + if (!el) return + const onScroll = () => setScrollTop(el.scrollTop) + el.addEventListener('scroll', onScroll, { passive: true }) + return () => el.removeEventListener('scroll', onScroll) + }, []) + + // Find the latest header whose start <= scrollTop. That's the label of + // the group containing whatever is currently at the top of the viewport. + const stickyLabel = useMemo(() => { + if (headerOffsets.length === 0) return null + let current: string | null = null + for (const h of headerOffsets) { + if (h.offset <= scrollTop) current = h.label + else break + } + return current + }, [headerOffsets, scrollTop]) + // Measure container width on mount and resize. useEffect(() => { const measureWidth = () => { @@ -267,11 +307,23 @@ export function Timeline() { } return ( -
+
+ {/* Sticky group-header overlay. Lives outside the virtualizer's + * positioned children so it isn't affected by translateY transforms. + * Updates as the user scrolls past month boundaries. */} + {stickyLabel && ( +
+

+ {stickyLabel} +

+
+ )} + +
+
) }