Files
ncd-fe/packages/pl-fe/src/components/scrollable-list.tsx
nicole mikołajczyk 8a6fb5f1a1 nicolium: enable oxlint consistent-type-imports rule
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
2026-02-26 20:38:05 +01:00

282 lines
8.8 KiB
TypeScript

import { useLocation } from '@tanstack/react-router';
import debounce from 'lodash/debounce';
import React, { useEffect, useRef, useMemo, useCallback } from 'react';
import {
Virtuoso,
type Components,
type VirtuosoProps,
type VirtuosoHandle,
type ListRange,
type IndexLocationWithAlign,
} from 'react-virtuoso';
import LoadMore from '@/components/load-more';
import Spinner from '@/components/ui/spinner';
import { useSettings } from '@/stores/settings';
import { EmptyMessage } from './empty-message';
/** Custom Viruoso component context. */
type Context = {
itemClassName?: string;
listClassName?: string;
};
/** Scroll position saved in sessionStorage. */
type SavedScrollPosition = {
index: number;
offset: number;
};
/** Custom Virtuoso Item component representing a single scrollable item. */
// NOTE: It's crucial to space lists with **padding** instead of margin!
// Pass an `itemClassName` like `pb-3`, NOT a `space-y-3` className
// https://virtuoso.dev/troubleshooting#list-does-not-scroll-to-the-bottom--items-jump-around
const Item: Components<React.JSX.Element, Context>['Item'] = ({ context, ...rest }) => (
<div className={context?.itemClassName} {...rest} />
);
/** Custom Virtuoso List component for the outer container. */
// Ensure the className winds up here
const List: Components<React.JSX.Element, Context>['List'] = React.forwardRef((props, ref) => {
const { context, ...rest } = props;
return <div ref={ref} className={context?.listClassName} {...rest} />;
});
interface IScrollableList extends VirtuosoProps<any, any> {
/** Unique key to preserve the scroll position when navigating back. */
scrollKey?: string;
/** Pagination callback when the end of the list is reached. */
onLoadMore?: () => void;
/** Whether the data is currently being fetched. */
isLoading?: boolean;
/** Whether to actually display the loading state. */
showLoading?: boolean;
/** Whether we expect an additional page of data. */
hasMore?: boolean;
/** Additional element to display at the top of the list. */
prepend?: React.ReactNode;
/** Message to display when the list is loaded but empty. */
emptyMessage?: React.ReactNode;
/** Message to display when the list is loaded but empty. */
emptyMessageText?: React.ReactNode;
/** Message to display next to the emptyMessage text. */
emptyMessageIcon?: string;
/** Scrollable content. */
children: Iterable<React.ReactNode>;
/** Callback when the list is scrolled to the top. */
onScrollToTop?: () => void;
/** Callback when the list is scrolled. */
onScroll?: () => void;
/** Placeholder component to render while loading. */
placeholderComponent?: React.ComponentType | React.NamedExoticComponent;
/** Number of placeholders to render while loading. */
placeholderCount?: number;
/** Extra class names on the Virtuoso element. */
className?: string;
/** Extra class names on the list element. */
listClassName?: string;
/** Class names on each item container. */
itemClassName?: string;
/** Extra class names on the LoadMore element */
loadMoreClassName?: string;
/** `id` attribute on the Virtuoso element. */
id?: string;
/** CSS styles on the Virtuoso element. */
style?: React.CSSProperties;
/** Whether to use the window to scroll the content instead of Virtuoso's container. */
useWindowScroll?: boolean;
}
const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(
(
{
scrollKey,
prepend = null,
children,
isLoading,
emptyMessage,
emptyMessageText,
emptyMessageIcon,
showLoading,
onScroll,
onScrollToTop,
onLoadMore,
className,
listClassName,
itemClassName,
loadMoreClassName,
hasMore,
placeholderComponent: Placeholder,
placeholderCount = 0,
initialTopMostItemIndex = 0,
useWindowScroll = true,
...params
},
ref,
) => {
const { autoloadMore } = useSettings();
const { state: locationState } = useLocation();
// Preserve scroll position
const scrollDataKey = `plfe:scrollData:${scrollKey}:${locationState.key}`;
const scrollData: SavedScrollPosition | null = useMemo(
() => JSON.parse(sessionStorage.getItem(scrollDataKey)!),
[scrollDataKey],
);
const topIndex = useRef<number>(scrollData ? scrollData.index : 0);
const topOffset = useRef<number>(scrollData ? scrollData.offset : 0);
/** Normalized children. */
const elements = Array.from(children || []);
const showPlaceholder = showLoading && Placeholder && placeholderCount > 0;
// NOTE: We are doing some trickery to load a feed of placeholders
// Virtuoso's `EmptyPlaceholder` unfortunately doesn't work for our use-case
const data = showPlaceholder ? Array(placeholderCount).fill('') : elements;
// Add a placeholder at the bottom for loading
// (Don't use Virtuoso's `Footer` component because it doesn't preserve its height)
if (hasMore && (autoloadMore || isLoading) && Placeholder) {
data.push(<Placeholder />);
} else if (hasMore && (autoloadMore || isLoading)) {
data.push(<Spinner />);
}
const handleScroll = useCallback(
debounce(
() => {
// HACK: Virtuoso has no better way to get this...
const node = document.querySelector(
`[data-virtuoso-scroller] [data-item-index="${topIndex.current}"]`,
);
if (node) {
topOffset.current = node.getBoundingClientRect().top * -1;
} else {
topOffset.current = 0;
}
},
150,
{ trailing: true },
),
[],
);
useEffect(() => {
document.addEventListener('scroll', handleScroll);
sessionStorage.removeItem(scrollDataKey);
return () => {
if (scrollKey) {
const data: SavedScrollPosition = { index: topIndex.current, offset: topOffset.current };
sessionStorage.setItem(scrollDataKey, JSON.stringify(data));
}
document.removeEventListener('scroll', handleScroll);
};
}, []);
/* Render an empty state instead of the scrollable list. */
const renderEmpty = (): React.JSX.Element => {
return isLoading ? (
<Spinner />
) : emptyMessageText ? (
<EmptyMessage text={emptyMessageText} icon={emptyMessageIcon} />
) : (
<>{emptyMessage}</>
);
};
/** Render a single item. */
const renderItem = (_i: number, element: React.JSX.Element): React.JSX.Element => {
if (showPlaceholder) {
return <Placeholder />;
} else {
return element;
}
};
const handleEndReached = () => {
if (autoloadMore && hasMore && onLoadMore) {
onLoadMore();
}
};
const loadMore = () => {
if (autoloadMore || !hasMore || !onLoadMore) {
return null;
} else {
const button = <LoadMore visible={!isLoading} onClick={onLoadMore} />;
if (loadMoreClassName) return <div className={loadMoreClassName}>{button}</div>;
return button;
}
};
const handleRangeChange = (range: ListRange) => {
// HACK: using the first index can be buggy.
// Track the second item instead, unless the endIndex comes before it (eg one 1 item in view).
topIndex.current = Math.min(range.startIndex + 1, range.endIndex);
handleScroll();
};
/** Figure out the initial index to scroll to. */
const initialIndex = useMemo<number | IndexLocationWithAlign>(() => {
if (showLoading) return 0;
if (initialTopMostItemIndex) {
if (typeof initialTopMostItemIndex === 'number') {
return {
align: 'start',
index: initialTopMostItemIndex,
offset: 60,
};
}
return initialTopMostItemIndex;
}
if (scrollData) {
return {
align: 'start',
index: scrollData.index,
offset: scrollData.offset,
};
}
return 0;
}, [showLoading, initialTopMostItemIndex]);
return (
<Virtuoso
useWindowScroll={useWindowScroll}
{...params}
overscan={window.innerHeight * 1.5}
ref={ref}
data={data}
totalCount={data.length}
startReached={onScrollToTop}
endReached={handleEndReached}
isScrolling={(isScrolling) => isScrolling && onScroll && onScroll()}
itemContent={renderItem}
initialTopMostItemIndex={initialIndex}
rangeChanged={handleRangeChange}
context={{
listClassName,
itemClassName,
}}
components={{
Header: prepend ? () => <>{prepend}</> : undefined,
ScrollSeekPlaceholder: Placeholder as any,
EmptyPlaceholder: renderEmpty,
List,
Item,
Footer: loadMore,
}}
/>
);
},
);
export { type IScrollableList, ScrollableList as default };