feat: sticky month-header overlay in Timeline

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:32:39 +02:00
parent 5f11698907
commit 61486a503e

View File

@@ -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 (
<div
ref={parentRef}
className="h-full overflow-auto bg-bg"
style={{ padding: `${PADDING}px` }}
>
<div className="relative h-full">
{/* 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 && (
<div className="pointer-events-none absolute left-0 right-0 top-0 z-20 border-b border-border bg-bg/90 px-4 py-1 backdrop-blur-sm">
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
{stickyLabel}
</h3>
</div>
)}
<div
ref={parentRef}
className="h-full overflow-auto bg-bg"
style={{ padding: `${PADDING}px` }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
@@ -342,6 +394,7 @@ export function Timeline() {
)
})}
</div>
</div>
</div>
)
}