import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js" import VirtualItem from "./virtual-item" const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48 const USER_SCROLL_INTENT_WINDOW_MS = 600 const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"]) export interface VirtualFollowListApi { scrollToTop: (opts?: { immediate?: boolean }) => void scrollToBottom: (opts?: { immediate?: boolean; suppressAutoAnchor?: boolean }) => void scrollToKey: ( key: string, opts?: { behavior?: ScrollBehavior; block?: ScrollLogicalPosition; setAutoScroll?: boolean }, ) => void notifyContentRendered: () => void setAutoScroll: (enabled: boolean) => void getAutoScroll: () => boolean getScrollElement: () => HTMLDivElement | undefined getShellElement: () => HTMLDivElement | undefined } export interface VirtualFollowListState { autoScroll: Accessor showScrollTopButton: Accessor showScrollBottomButton: Accessor scrollButtonsCount: Accessor activeKey: Accessor } export interface VirtualFollowListProps { items: Accessor getKey: (item: T, index: number) => string renderItem: (item: T, index: number) => JSX.Element /** * Optional stable DOM id for the item wrapper. * Defaults to the key itself. */ getAnchorId?: (key: string) => string /** * Decode an item key from an observed wrapper element id. * Defaults to identity. */ getKeyFromAnchorId?: (anchorId: string) => string overscanPx?: number scrollSentinelMarginPx?: number virtualizationEnabled?: Accessor suspendMeasurements?: Accessor loading?: Accessor isActive?: Accessor /** * If this value changes and autoScroll is enabled, the list will * anchor-scroll to the bottom (unless suppressed). */ followToken?: Accessor /** * Optional hooks to render content inside the scroll container. * Useful for empty/loading states that should scroll with the list. */ renderBeforeItems?: Accessor /** * Render content inside the shell, above timeline/sidebar layers. * (Quote popovers, etc.) */ renderOverlay?: Accessor /** * Provide localized labels for built-in controls. */ scrollToTopAriaLabel?: Accessor scrollToBottomAriaLabel?: Accessor /** * Receive element refs for external logic (selection, geometry, etc.) */ onScrollElementChange?: (element: HTMLDivElement | undefined) => void onShellElementChange?: (element: HTMLDivElement | undefined) => void /** * Callbacks for consumers. */ onScroll?: () => void onMouseUp?: (event: MouseEvent) => void onActiveKeyChange?: (key: string | null) => void registerApi?: (api: VirtualFollowListApi) => void registerState?: (state: VirtualFollowListState) => void renderControls?: (state: VirtualFollowListState, api: VirtualFollowListApi) => JSX.Element } export default function VirtualFollowList(props: VirtualFollowListProps) { const getAnchorId = (key: string) => (props.getAnchorId ? props.getAnchorId(key) : key) const getKeyFromAnchorId = (anchorId: string) => (props.getKeyFromAnchorId ? props.getKeyFromAnchorId(anchorId) : anchorId) const [scrollElement, setScrollElement] = createSignal() const [shellElement, setShellElement] = createSignal() const [topSentinel, setTopSentinel] = createSignal(null) const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal(null) const bottomSentinel = () => bottomSentinelSignal() const isActive = () => (props.isActive ? props.isActive() : true) const isLoading = () => Boolean(props.loading?.()) const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true) const measurementsSuspended = () => Boolean(props.suspendMeasurements?.()) const [autoScroll, setAutoScroll] = createSignal(true) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [topSentinelVisible, setTopSentinelVisible] = createSignal(true) const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true) const [activeKey, setActiveKey] = createSignal(null) const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0)) let containerRef: HTMLDivElement | undefined let shellRef: HTMLDivElement | undefined let pendingScrollFrame: number | null = null let pendingAnchorScroll: number | null = null let pendingActiveScroll = false let suppressAutoScrollOnce = false let pendingInitialScroll = true let scrollToBottomFrame: number | null = null let scrollToBottomDelayedFrame: number | null = null let userScrollIntentUntil = 0 let detachScrollIntentListeners: (() => void) | undefined const state: VirtualFollowListState = { autoScroll, showScrollTopButton, showScrollBottomButton, scrollButtonsCount, activeKey, } function markUserScrollIntent() { const now = typeof performance !== "undefined" ? performance.now() : Date.now() userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS } function hasUserScrollIntent() { const now = typeof performance !== "undefined" ? performance.now() : Date.now() return now <= userScrollIntentUntil } function attachScrollIntentListeners(element: HTMLDivElement | undefined) { if (detachScrollIntentListeners) { detachScrollIntentListeners() detachScrollIntentListeners = undefined } if (!element) return const handlePointerIntent = () => markUserScrollIntent() const handleKeyIntent = (event: KeyboardEvent) => { if (SCROLL_INTENT_KEYS.has(event.key)) { markUserScrollIntent() } } element.addEventListener("wheel", handlePointerIntent, { passive: true }) element.addEventListener("pointerdown", handlePointerIntent) element.addEventListener("touchstart", handlePointerIntent, { passive: true }) element.addEventListener("keydown", handleKeyIntent) detachScrollIntentListeners = () => { element.removeEventListener("wheel", handlePointerIntent) element.removeEventListener("pointerdown", handlePointerIntent) element.removeEventListener("touchstart", handlePointerIntent) element.removeEventListener("keydown", handleKeyIntent) } } function updateScrollIndicatorsFromVisibility() { const hasItems = props.items().length > 0 const bottomVisible = bottomSentinelVisible() const topVisible = topSentinelVisible() setShowScrollBottomButton(hasItems && !bottomVisible) setShowScrollTopButton(hasItems && !topVisible) } function clearScrollToBottomFrames() { if (scrollToBottomFrame !== null) { cancelAnimationFrame(scrollToBottomFrame) scrollToBottomFrame = null } if (scrollToBottomDelayedFrame !== null) { cancelAnimationFrame(scrollToBottomDelayedFrame) scrollToBottomDelayedFrame = null } } function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) { if (!containerRef) return const sentinel = bottomSentinel() const behavior: ScrollBehavior = immediate ? "auto" : "smooth" const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate if (suppressAutoAnchor) { suppressAutoScrollOnce = true } sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior }) setAutoScroll(true) } function requestScrollToBottom(immediate = true) { if (!isActive()) { pendingActiveScroll = true return } if (!containerRef || !bottomSentinel()) { pendingActiveScroll = true return } pendingActiveScroll = false clearScrollToBottomFrames() scrollToBottomFrame = requestAnimationFrame(() => { scrollToBottomFrame = null scrollToBottomDelayedFrame = requestAnimationFrame(() => { scrollToBottomDelayedFrame = null scrollToBottom(immediate) }) }) } function resolvePendingActiveScroll() { if (!pendingActiveScroll) return if (!isActive()) return requestScrollToBottom(true) } function scrollToTop(immediate = false) { if (!containerRef) return const behavior: ScrollBehavior = immediate ? "auto" : "smooth" setAutoScroll(false) topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior }) } function scheduleAnchorScroll(immediate = false) { if (!autoScroll()) return if (!isActive()) { pendingActiveScroll = true return } const sentinel = bottomSentinel() if (!sentinel) { pendingActiveScroll = true return } if (pendingAnchorScroll !== null) { cancelAnimationFrame(pendingAnchorScroll) pendingAnchorScroll = null } pendingAnchorScroll = requestAnimationFrame(() => { pendingAnchorScroll = null sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior: immediate ? "auto" : "smooth" }) }) } function handleContentRendered() { if (isLoading()) return scheduleAnchorScroll() } function handleScroll() { if (!containerRef) return if (pendingScrollFrame !== null) { cancelAnimationFrame(pendingScrollFrame) } const isUserScroll = hasUserScrollIntent() pendingScrollFrame = requestAnimationFrame(() => { pendingScrollFrame = null if (!containerRef) return const atBottom = bottomSentinelVisible() if (isUserScroll) { if (atBottom) { if (!autoScroll()) setAutoScroll(true) } else if (autoScroll()) { setAutoScroll(false) } } props.onScroll?.() }) } function setContainerRef(element: HTMLDivElement | null) { containerRef = element || undefined setScrollElement(containerRef) props.onScrollElementChange?.(containerRef) attachScrollIntentListeners(containerRef) if (!containerRef) { return } resolvePendingActiveScroll() } function setShellRef(element: HTMLDivElement | null) { shellRef = element || undefined setShellElement(shellRef) props.onShellElementChange?.(shellRef) } function setBottomSentinel(element: HTMLDivElement | null) { setBottomSentinelSignal(element) resolvePendingActiveScroll() } const api: VirtualFollowListApi = { scrollToTop: (opts) => scrollToTop(Boolean(opts?.immediate)), scrollToBottom: (opts) => scrollToBottom(Boolean(opts?.immediate), { suppressAutoAnchor: opts?.suppressAutoAnchor }), scrollToKey: (key, opts) => { if (typeof document === "undefined") return const anchorId = getAnchorId(key) const behavior = opts?.behavior ?? "smooth" const block = opts?.block ?? "start" const nextAutoScroll = opts?.setAutoScroll ?? false setAutoScroll(nextAutoScroll) const first = document.getElementById(anchorId) first?.scrollIntoView({ block, behavior }) // When using virtualization, the placeholder height can be stale until the // item mounts/measures. Re-run scrollIntoView() on the next frame to // stabilize the final position. requestAnimationFrame(() => { const second = document.getElementById(anchorId) second?.scrollIntoView({ block, behavior }) }) }, notifyContentRendered: () => handleContentRendered(), setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)), getAutoScroll: () => autoScroll(), getScrollElement: () => scrollElement(), getShellElement: () => shellElement(), } createEffect(() => { props.registerApi?.(api) }) createEffect(() => { props.registerState?.(state) }) let lastActiveState = false createEffect(() => { const active = isActive() if (active) { resolvePendingActiveScroll() if (!lastActiveState && autoScroll()) { requestScrollToBottom(true) } } else if (autoScroll()) { pendingActiveScroll = true } lastActiveState = active }) createEffect(() => { const loading = isLoading() if (loading) { pendingInitialScroll = true return } if (!pendingInitialScroll) { return } const container = scrollElement() const sentinel = bottomSentinel() if (!container || !sentinel || props.items().length === 0) { return } pendingInitialScroll = false requestScrollToBottom(true) }) let previousFollowToken: string | number | undefined createEffect(() => { const token = props.followToken?.() if (isLoading() || token === undefined) { previousFollowToken = token return } if (previousFollowToken === undefined) { previousFollowToken = token return } if (token === previousFollowToken) { return } previousFollowToken = token if (suppressAutoScrollOnce) { suppressAutoScrollOnce = false return } if (autoScroll()) { scheduleAnchorScroll(true) } }) createEffect(() => { if (props.items().length === 0) { setShowScrollTopButton(false) setShowScrollBottomButton(false) setAutoScroll(true) return } updateScrollIndicatorsFromVisibility() }) createEffect(() => { const container = scrollElement() const topTarget = topSentinel() const bottomTarget = bottomSentinel() if (!container || !topTarget || !bottomTarget) return if (typeof IntersectionObserver === "undefined") return const margin = props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX const observer = new IntersectionObserver( (entries) => { let visibilityChanged = false for (const entry of entries) { if (entry.target === topTarget) { setTopSentinelVisible(entry.isIntersecting) visibilityChanged = true } else if (entry.target === bottomTarget) { setBottomSentinelVisible(entry.isIntersecting) visibilityChanged = true } } if (visibilityChanged) { updateScrollIndicatorsFromVisibility() } }, { root: container, threshold: 0, rootMargin: `${margin}px 0px ${margin}px 0px` }, ) observer.observe(topTarget) observer.observe(bottomTarget) onCleanup(() => observer.disconnect()) }) createEffect(() => { const container = scrollElement() const items = props.items() if (!container || items.length === 0) return if (typeof document === "undefined") return if (typeof IntersectionObserver === "undefined") return const observer = new IntersectionObserver( (entries) => { let best: IntersectionObserverEntry | null = null for (const entry of entries) { if (!entry.isIntersecting) continue if (!best || entry.boundingClientRect.top < best.boundingClientRect.top) { best = entry } } if (best) { const anchorId = (best.target as HTMLElement).id const key = getKeyFromAnchorId(anchorId) setActiveKey((current) => (current === key ? current : key)) } }, { root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 }, ) const anchorIds = items.map((item, idx) => getAnchorId(props.getKey(item, idx))) anchorIds.forEach((anchorId) => { const anchor = document.getElementById(anchorId) if (anchor) observer.observe(anchor) }) onCleanup(() => observer.disconnect()) }) createEffect(() => { const key = activeKey() props.onActiveKeyChange?.(key) }) onCleanup(() => { if (pendingScrollFrame !== null) { cancelAnimationFrame(pendingScrollFrame) } if (pendingAnchorScroll !== null) { cancelAnimationFrame(pendingAnchorScroll) } clearScrollToBottomFrames() if (detachScrollIntentListeners) { detachScrollIntentListeners() } }) const controls = () => { if (props.renderControls) { return props.renderControls(state, api) } // Avoid hardcoded user-visible strings; require consumers to supply // localized aria labels when using the default controls. if (!props.scrollToTopAriaLabel || !props.scrollToBottomAriaLabel) { return null } const labelTop = props.scrollToTopAriaLabel() const labelBottom = props.scrollToBottomAriaLabel() return (
) } return (
props.onMouseUp?.(event)} > ) }