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]
|
[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
|
// Range-selection helper. Operates on the global photos array, not on
|
||||||
// virtualizer items.
|
// virtualizer items.
|
||||||
const selectRange = (endIndex: number) => {
|
const selectRange = (endIndex: number) => {
|
||||||
@@ -171,6 +185,32 @@ export function Timeline() {
|
|||||||
virtualizer.measure()
|
virtualizer.measure()
|
||||||
}, [items, virtualizer])
|
}, [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.
|
// Measure container width on mount and resize.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const measureWidth = () => {
|
const measureWidth = () => {
|
||||||
@@ -267,6 +307,18 @@ export function Timeline() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<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
|
<div
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
className="h-full overflow-auto bg-bg"
|
className="h-full overflow-auto bg-bg"
|
||||||
@@ -343,5 +395,6 @@ export function Timeline() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user