import {
type ReactNode,
type Ref,
type RefObject,
startTransition,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import { useNonReactiveCallback } from '#/lib/hooks/use-non-reactive-callback';
import * as css from '#/components/List/List.css';
import { useIsFocused } from '#/router';
import { ItemSeenObserver } from './ItemSeenObserver';
import { Row } from './Row';
import { Virtualizer, type VirtualizerMethods } from './Virtualizer';
export type ListRenderItemInfo<ItemT> = {
index: number;
item: ItemT;
};
export type ListRenderItem<ItemT> = (info: ListRenderItemInfo<ItemT>) => ReactNode;
export type ListMethods = {
scrollToEnd: (options?: { animated?: boolean }) => void;
/**
* scrolls the indexed row to the start of the list's visible viewport.
*
* @param options target index and optional pixels to leave before the row
* @returns whether a virtualized row was available to scroll to
*/
scrollToIndex: (options: { index: number; offset?: number }) => boolean;
scrollToOffset: (options: { animated?: boolean; offset: number }) => void;
scrollToTop: () => void;
};
export type ListRef = RefObject<ListMethods | null>;
export type ListProps<ItemT> = {
data: readonly ItemT[] | null | undefined;
keyExtractor: (item: ItemT, index: number) => string;
/** Shown in place of the rows when `data` is empty. */
ListEmptyComponent?: ReactNode;
/** Rendered after the rows. */
ListFooterComponent?: ReactNode;
/** Rendered before the rows. */
ListHeaderComponent?: ReactNode;
/**
* estimated row height in pixels. when set, off-screen rows are virtualized (their content unmounted),
* using this as the placeholder height until a row has been measured.
*/
estimateHeight?: number;
/** Fires when the rendered content's size changes (via `ResizeObserver`). */
onContentSizeChange?: (width: number, height: number) => void;
onEndReached?: () => void;
/** Lookahead before the end, in multiples of the viewport height. */
onEndReachedThreshold?: number;
/** Fires per row once it has been sufficiently visible long enough to count as seen. */
onItemSeen?: (item: ItemT) => void;
/** Fires when the list scrolls past (or back above) the top of its content. */
onScrolledDownChange?: (isScrolledDown: boolean) => void;
onStartReached?: () => void;
/** Lookahead before the start, in multiples of the viewport height. */
onStartReachedThreshold?: number;
/** number of extra rows rendered before and after the visible range. */
overscanCount?: number;
ref?: Ref<ListMethods>;
renderItem: ListRenderItem<ItemT>;
/**
* The scrolling element the list reads for edge detection and drives for imperative scrolls. Omit to bind
* to the viewport (document scroll); pass a bounded `overflow` container's ref (e.g. a split-view column)
* to scroll within it instead.
*/
scrollRoot?: RefObject<HTMLElement | null>;
};
/**
* renders `data` as a vertical list, calling the `on*` callbacks as rows approach the scroll edges. set
* `estimateHeight` to virtualize off-screen rows.
*/
export function List<ItemT>({
data,
keyExtractor,
ListEmptyComponent,
ListFooterComponent,
ListHeaderComponent,
estimateHeight,
onContentSizeChange,
onEndReached,
onEndReachedThreshold,
onItemSeen,
onScrolledDownChange,
onStartReached,
onStartReachedThreshold,
overscanCount = 3,
ref,
renderItem,
scrollRoot,
}: ListProps<ItemT>) {
const isFocused = useIsFocused();
const virtualizerRef = useRef<VirtualizerMethods>(null);
useImperativeHandle(
ref,
() => ({
scrollToTop() {
const root = scrollRoot ? scrollRoot.current! : window;
root.scrollTo({ top: 0 });
virtualizerRef.current?.syncViewportAfterScroll();
},
scrollToOffset({ animated, offset }) {
const root = scrollRoot ? scrollRoot.current! : window;
root.scrollTo({ behavior: animated ? 'smooth' : 'instant', left: 0, top: offset });
// a smooth scroll settles over later frames, so the sync would read a stale position; the scroll
// listener picks it up instead.
if (!animated) {
virtualizerRef.current?.syncViewportAfterScroll();
}
},
scrollToEnd({ animated = true } = {}) {
const root = scrollRoot ? scrollRoot.current! : document.documentElement;
root.scrollTo({
behavior: animated ? 'smooth' : 'instant',
left: 0,
top: root.scrollHeight,
});
if (!animated) {
virtualizerRef.current?.syncViewportAfterScroll();
}
},
scrollToIndex(options) {
return virtualizerRef.current?.scrollToIndex(options) ?? false;
},
}),
[scrollRoot],
);
const onStartVisibleChange = useNonReactiveCallback((isVisible: boolean) => {
if (isVisible) {
onStartReached?.();
}
});
const onEndVisibleChange = useNonReactiveCallback((isVisible: boolean) => {
if (isVisible) {
onEndReached?.();
}
});
const onAboveTheFoldChange = useNonReactiveCallback((isAboveTheFold: boolean) => {
// `startTransition` keeps the dependent UI (e.g. a "load new posts" button) off the scroll path.
startTransition(() => onScrolledDownChange?.(!isAboveTheFold));
});
const rows = data ?? [];
const isEmpty = rows.length === 0;
const itemCount = rows.length;
const virtualize = estimateHeight != null;
const renderRows = !isEmpty || (virtualize && !isFocused);
let children = ListEmptyComponent;
if (renderRows) {
if (virtualize) {
children = (
<Virtualizer
data={rows}
enabled={isFocused}
estimateHeight={estimateHeight}
keyExtractor={keyExtractor}
overscanCount={overscanCount}
ref={virtualizerRef}
renderItem={renderItem}
scrollRoot={scrollRoot}
/>
);
} else {
children = rows.map((item, index) => {
const key = keyExtractor(item, index);
return <Row key={key} index={index} item={item} renderItem={renderItem} />;
});
}
if (onItemSeen !== undefined) {
children = (
<ItemSeenObserver enabled={isFocused} onItemSeen={onItemSeen} root={scrollRoot}>
{children}
</ItemSeenObserver>
);
}
}
return (
<div
ref={(node) => {
if (!isFocused || onContentSizeChange === undefined || node === null) {
return;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0]!;
const rect = entry.contentRect;
onContentSizeChange(rect.width, rect.height);
});
observer.observe(node);
return () => observer.disconnect();
}}
className={css.container({ virtualized: virtualize })}
>
{onScrolledDownChange && (
<Visibility
className={css.aboveTheFold}
enabled={isFocused}
onVisibleChange={onAboveTheFoldChange}
root={scrollRoot}
/>
)}
{onStartReached && !isEmpty && (
<Visibility
key={`start-${itemCount}`}
enabled={isFocused}
onVisibleChange={onStartVisibleChange}
root={scrollRoot}
topMargin={thresholdMargin(onStartReachedThreshold)}
/>
)}
{ListHeaderComponent}
{children}
{onEndReached && !isEmpty && (
<Visibility
key={`end-${itemCount}`}
bottomMargin={thresholdMargin(onEndReachedThreshold)}
enabled={isFocused}
onVisibleChange={onEndVisibleChange}
root={scrollRoot}
/>
)}
{ListFooterComponent}
</div>
);
}
const thresholdMargin = (threshold: number | undefined) => `${(threshold ?? 0) * 100}%`;
function Visibility({
bottomMargin = '0px',
className = css.sentinel,
enabled,
onVisibleChange,
root,
topMargin = '0px',
}: {
bottomMargin?: string;
className?: string;
enabled: boolean;
onVisibleChange: (isVisible: boolean) => void;
root?: RefObject<Element | null>;
topMargin?: string;
}) {
const isIntersecting = useRef<boolean | undefined>(undefined);
const nodeRef = useRef<HTMLDivElement | null>(null);
// observe in a passive effect, not the callback ref: a custom `root` is an ancestor whose ref is still
// null during the child-first commit, so the ref would fall back to the viewport and apply the margins
// against the wrong bounds.
useEffect(() => {
const node = nodeRef.current;
if (!enabled || node === null) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]!;
const next = entry.isIntersecting;
if (isIntersecting.current !== next) {
isIntersecting.current = next;
onVisibleChange(next);
}
},
{
root: root?.current ?? null,
rootMargin: `${topMargin} 0px ${bottomMargin} 0px`,
},
);
observer.observe(node);
return () => observer.disconnect();
}, [bottomMargin, enabled, onVisibleChange, root, topMargin]);
return <div ref={nodeRef} className={className} />;
}