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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user