diff --git a/.prettierrc b/.prettierrc index fb49bf5..c7aef46 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,5 +4,13 @@ "singleQuote": true, "tabWidth": 2, "trailingComma": "all", - "jsxSingleQuote": false + "jsxSingleQuote": false, + "overrides": [ + { + "files": "**/*.md", + "options": { + "requirePragma": true + } + } + ] } diff --git a/README.md b/README.md index 4dccda8..4d51b44 100644 --- a/README.md +++ b/README.md @@ -78,78 +78,18 @@ npm start ### props - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
nametypedefaultdescription
itemsT[][]列表数据源,虚拟滚动会基于此计算高度。
rowKeyReact.Key | (item: T) => React.Keyrequired返回每一项的唯一标识,用于缓存高度与滚动定位。
itemRender(item: T, index: number) => React.ReactNoderequired渲染单行内容的函数。
heightnumberrequired列表可视区域高度。
itemHeightnumberrequired每行的基础高度,虚拟滚动会以此做初始估算。
groupGroup<T>提供分组 key 与标题渲染,开启后会生成组头。
stickybooleanfalse为分组头启用粘性悬停效果。
virtualbooleantrue是否启用虚拟列表模式,可根据需要关闭。
onEndReached() => void滚动触达底部时触发,常用于触发下一页加载。
prefixClsstringrc-listy组件样式前缀,方便自定义样式隔离。
+| name | type | default | description | +| --- | --- | --- | --- | +| items | `T[]` | `[]` | 列表数据源,虚拟滚动会基于此计算高度。 | +| rowKey | `keyof T \| (item: T) => React.Key` | required | 返回每一项的唯一标识,用于缓存高度与滚动定位。 | +| itemRender | `(item: T, index: number) => React.ReactNode` | required | 渲染单行内容的函数。 | +| height | `number` | - | 列表可视区域高度。 | +| itemHeight | `number` | - | 每行的基础高度,虚拟滚动会以此做初始估算。 | +| group | `{ key: ((item: T) => K) \| K; title: (groupKey: K, items: T[]) => React.ReactNode }` | - | 提供分组 key 与标题渲染,开启后会生成组头。 | +| sticky | `boolean` | `false` | 为分组头启用粘性悬停效果。 | +| virtual | `boolean` | `true` | 是否启用虚拟列表模式,可根据需要关闭。 | +| onScroll | `React.UIEventHandler` | - | 滚动时触发,透传内部滚动容器的滚动事件。 | +| prefixCls | `string` | `rc-listy` | 组件样式前缀,方便自定义样式隔离。 | ### ListyRef diff --git a/docs/examples/endless-scrolling.tsx b/docs/examples/endless-scrolling.tsx index e6c8ba4..69c8673 100644 --- a/docs/examples/endless-scrolling.tsx +++ b/docs/examples/endless-scrolling.tsx @@ -23,7 +23,9 @@ export default () => { const listRef = useRef(null); const nextIdRef = useRef(BATCH_SIZE + 1); - const [items, setItems] = useState(() => createBatch(1, BATCH_SIZE)); + const [items, setItems] = useState(() => + createBatch(1, BATCH_SIZE), + ); const [loading, setLoading] = useState(false); const loadMore = useCallback(() => { @@ -45,6 +47,17 @@ export default () => { }, LOAD_DELAY); }, [loading]); + const handleScroll = useCallback>( + (event) => { + const { scrollTop, clientHeight, scrollHeight } = event.currentTarget; + + if (scrollHeight - (scrollTop + clientHeight) <= 0) { + loadMore(); + } + }, + [loadMore], + ); + const itemStyle = useMemo( () => ({ padding: '0 12px', @@ -64,10 +77,8 @@ export default () => { itemHeight={32} items={items} rowKey="id" - itemRender={(item) => ( -
{item.name}
- )} - onEndReached={loadMore} + itemRender={(item) =>
{item.name}
} + onScroll={handleScroll} />
@@ -87,4 +98,4 @@ export default () => {
); -}; \ No newline at end of file +}; diff --git a/src/List.tsx b/src/List.tsx index a39522f..bf96e8f 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -1,12 +1,11 @@ import * as React from 'react'; import VirtualList, { type ListRef } from 'rc-virtual-list'; -import type { GetKey, ListyProps, ListyRef } from './interface'; +import type { ListyProps, ListyRef } from './interface'; import { useImperativeHandle, forwardRef } from 'react'; import useGroupSegments from './hooks/useGroupSegments'; import useFlattenRows from './hooks/useFlattenRows'; import type { Row } from './hooks/useFlattenRows'; import useStickyGroupHeader from './hooks/useStickyGroupHeader'; -import useOnEndReached from './hooks/useOnEndReached'; import { isGroupScrollConfig } from './util'; import clsx from 'clsx'; import { useEvent } from '@rc-component/util'; @@ -15,11 +14,12 @@ function Listy( props: ListyProps, ref: React.Ref, ) { + // ============================== Props ============================== const { items, itemRender, group, - onEndReached, + onScroll, rowKey, height, itemHeight, @@ -28,11 +28,14 @@ function Listy( prefixCls = 'rc-listy', } = props; + // =============================== Data =============================== const data = React.useMemo(() => items || [], [items]); + // =============================== Refs =============================== const listRef = React.useRef(null); const containerRef = React.useRef(null); + // ========================== Imperative API ========================== useImperativeHandle(ref, () => ({ scrollTo: (config) => { if (isGroupScrollConfig(config)) { @@ -48,9 +51,10 @@ function Listy( }, })); + // ============================= Grouping ============================= const groupSegments = useGroupSegments(data, group); - // =================================== Keys =================================== + // ============================= Row Keys ============================= const getKey = useEvent((row: Row): React.Key => { if (row.type === 'header') { return row.groupKey; @@ -59,17 +63,17 @@ function Listy( if (typeof rowKey === 'function') { return rowKey(row.item); } - return row.item?.[rowKey as string]; + return row.item[rowKey] as React.Key; }); - // ======================= Flatten rows (header + item) ======================= + // ============================= Flat Rows ============================= const { rows, headerRows, groupKeyToSeg } = useFlattenRows( data, group, groupSegments, ); - // Pre-compute each group's items to simplify header rendering + // ============================ Group Items ============================ const groupKeyToItems = React.useMemo(() => { const map = new Map(); if (!group) { @@ -81,7 +85,7 @@ function Listy( return map; }, [group, groupKeyToSeg, data]); - // Sticky header overlay via Portal (anchored on header rows) + // =========================== Sticky Header =========================== const extraRender = useStickyGroupHeader({ enabled: !!(sticky && group), group, @@ -92,8 +96,13 @@ function Listy( prefixCls, }); + // ============================= Row Render ============================ const renderHeaderRow = React.useCallback( (groupKey: K) => { + if (!group) { + return null; + } + const groupItems = groupKeyToItems.get(groupKey) || []; const headerClassName = clsx(`${prefixCls}-group-header`, { [`${prefixCls}-group-header-sticky`]: sticky && !virtual, @@ -105,14 +114,10 @@ function Listy( ); }, - [group, groupKeyToItems, prefixCls, virtual], + [group, groupKeyToItems, prefixCls, sticky, virtual], ); - const handleOnScroll = useOnEndReached({ - enabled: !!onEndReached, - onEndReached, - }); - + // ============================== Render =============================== return (
( itemKey={getKey} height={height} extraRender={extraRender} - onScroll={handleOnScroll} + onScroll={onScroll} prefixCls={prefixCls} > {(row: Row) => @@ -137,6 +142,7 @@ function Listy( ); } +// Const to support generic with forwardRef const ListyWithForwardRef = forwardRef(Listy) as < T, K extends React.Key = React.Key, diff --git a/src/hooks/index.ts b/src/hooks/index.ts index c5a8b8a..67314fb 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,11 +1,5 @@ import useGroupSegments from './useGroupSegments'; import useStickyGroupHeader from './useStickyGroupHeader'; import useFlattenRows from './useFlattenRows'; -import useOnEndReached from './useOnEndReached'; -export { - useGroupSegments, - useStickyGroupHeader, - useFlattenRows, - useOnEndReached, -}; +export { useGroupSegments, useStickyGroupHeader, useFlattenRows }; diff --git a/src/hooks/useOnEndReached.ts b/src/hooks/useOnEndReached.ts deleted file mode 100644 index d1fe95b..0000000 --- a/src/hooks/useOnEndReached.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from 'react'; -import { useEvent } from '@rc-component/util'; - -interface UseOnEndReachedParams { - enabled: boolean; - onEndReached?: () => void; -} - -export default function useOnEndReached(params: UseOnEndReachedParams) { - const { enabled, onEndReached } = params; - - const lastTriggeredScrollHeightRef = React.useRef(null); - - const onScroll = useEvent>((e) => { - if (!enabled) { - lastTriggeredScrollHeightRef.current = null; - return; - } - - const target = e.currentTarget; - - const { scrollTop, clientHeight, scrollHeight } = target; - const distanceToBottom = scrollHeight - (scrollTop + clientHeight); - - if (distanceToBottom <= 0) { - if (lastTriggeredScrollHeightRef.current !== scrollHeight) { - onEndReached(); - lastTriggeredScrollHeightRef.current = scrollHeight; - } - } - }); - - return onScroll; -} diff --git a/src/hooks/useStickyGroupHeader.tsx b/src/hooks/useStickyGroupHeader.tsx index a9d5127..f65ee6e 100644 --- a/src/hooks/useStickyGroupHeader.tsx +++ b/src/hooks/useStickyGroupHeader.tsx @@ -9,8 +9,8 @@ export interface StickyHeaderParams { group: Group | undefined; headerRows: { groupKey: K; rowIndex: number }[]; groupKeyToItems: Map; - containerRef: React.RefObject; - listRef: React.RefObject; + containerRef: React.RefObject; + listRef: React.RefObject; prefixCls: string; } @@ -34,23 +34,27 @@ export default function useStickyGroupHeader< (info: ExtraRenderInfo) => { const { virtual } = info; - if (!enabled || !headerRows.length || !virtual) { + if (!enabled || !group || !headerRows.length || !virtual) { lastHeaderIdxRef.current = 0; return null; } + const container = containerRef.current; + if (!container) { + return null; + } + // maybe rc-virtual-list will expose scrollTop in the future const getHolderScrollTop = () => { - const container = containerRef.current; const holder = - container?.querySelector(`.${prefixCls}-holder`) || + container.querySelector(`.${prefixCls}-holder`) || listRef.current?.nativeElement?.querySelector?.( `.${prefixCls}-holder`, ); if (holder) { return holder.scrollTop; } - const infoScrollTop = listRef.current?.getScrollInfo?.().y; + const infoScrollTop = listRef.current?.getScrollInfo?.().y ?? 0; return infoScrollTop; }; @@ -105,7 +109,7 @@ export default function useStickyGroupHeader< ); return ( - containerRef.current}> + container}> {headerNode} ); diff --git a/src/interface.ts b/src/interface.ts index 50a18cf..aff5c63 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -32,7 +32,7 @@ export interface ListyProps { virtual?: boolean; prefixCls?: string; rowKey: RowKey; - onEndReached?: () => void; + onScroll?: React.UIEventHandler; itemRender: (item: T, index: number) => React.ReactNode; } diff --git a/tests/onEndReached.test.tsx b/tests/onEndReached.test.tsx deleted file mode 100644 index 06ddb7f..0000000 --- a/tests/onEndReached.test.tsx +++ /dev/null @@ -1,571 +0,0 @@ -import React, { useState } from 'react'; -import { render, fireEvent, act, waitFor } from '@testing-library/react'; -import Listy, { type ListyRef } from '@rc-component/listy'; - -const DEFAULT_HEIGHT = 200; -const DEFAULT_ITEM_HEIGHT = 30; -const PREFIX_CLS = 'rc-listy'; - -const createItems = (count: number) => - Array.from({ length: count }, (_, i) => ({ id: i })); - -const mockScroll = ( - element: Element, - scrollTop: number, - scrollHeight: number, - clientHeight: number, -) => { - Object.defineProperty(element, 'scrollTop', { - value: scrollTop, - writable: true, - }); - Object.defineProperty(element, 'scrollHeight', { - value: scrollHeight, - writable: true, - }); - Object.defineProperty(element, 'clientHeight', { - value: clientHeight, - writable: true, - }); -}; - -const scrollToBottom = ( - element: Element, - itemCount: number, - itemHeight = DEFAULT_ITEM_HEIGHT, - clientHeight = DEFAULT_HEIGHT, -) => { - const scrollHeight = itemCount * itemHeight; - const scrollTop = Math.max(scrollHeight - clientHeight, 0); - mockScroll(element, scrollTop, scrollHeight, clientHeight); - fireEvent.scroll(element); -}; - -describe('Listy - onEndReached', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('Basic functionality', () => { - it('should trigger onEndReached when scrolled to bottom', () => { - const onEndReached = jest.fn(); - const items = createItems(20); - - const { container } = render( -
{item.id}
} - onEndReached={onEndReached} - />, - ); - - const scrollContainer = container.querySelector( - `.${PREFIX_CLS}-holder`, - )!; - - scrollToBottom(scrollContainer, items.length); - - expect(onEndReached).toHaveBeenCalledTimes(1); - }); - - it('should not trigger onEndReached when not at bottom', () => { - const onEndReached = jest.fn(); - const items = createItems(20); - - const { container } = render( -
{item.id}
} - onEndReached={onEndReached} - />, - ); - - const scrollContainer = container.querySelector( - `.${PREFIX_CLS}-holder`, - )!; - - // Not at bottom: scrollTop + clientHeight < scrollHeight - mockScroll( - scrollContainer, - 100, - items.length * DEFAULT_ITEM_HEIGHT, - DEFAULT_HEIGHT, - ); - fireEvent.scroll(scrollContainer); - - expect(onEndReached).not.toHaveBeenCalled(); - }); - - it('should not trigger when onEndReached is not provided', () => { - const items = createItems(20); - - const { container } = render( -
{item.id}
} - />, - ); - - const scrollContainer = container.querySelector( - `.${PREFIX_CLS}-holder`, - )!; - - // Should not throw error - mockScroll( - scrollContainer, - 400, - items.length * DEFAULT_ITEM_HEIGHT, - DEFAULT_HEIGHT, - ); - expect(() => { - fireEvent.scroll(scrollContainer); - }).not.toThrow(); - }); - }); - - describe('Prevent duplicate triggers', () => { - it('should not trigger multiple times when staying at bottom with same scrollHeight', () => { - const onEndReached = jest.fn(); - const items = createItems(20); - - const { container } = render( -
{item.id}
} - onEndReached={onEndReached} - />, - ); - - const scrollContainer = container.querySelector( - `.${PREFIX_CLS}-holder`, - )!; - - // Scroll to bottom - scrollToBottom(scrollContainer, items.length); - expect(onEndReached).toHaveBeenCalledTimes(1); - - // Trigger scroll event again with same scrollHeight - fireEvent.scroll(scrollContainer); - expect(onEndReached).toHaveBeenCalledTimes(1); // Still only called once - - // Multiple scroll events with same scrollHeight - fireEvent.scroll(scrollContainer); - fireEvent.scroll(scrollContainer); - expect(onEndReached).toHaveBeenCalledTimes(1); // Still only called once - }); - - it('should trigger again when scrollHeight changes (new data loaded)', () => { - const onEndReached = jest.fn(); - const itemHeight = DEFAULT_ITEM_HEIGHT; - const clientHeight = DEFAULT_HEIGHT; - let itemCount = 20; - const renderList = (count: number) => ( -
{item.id}
} - onEndReached={onEndReached} - /> - ); - - const { container, rerender } = render(renderList(itemCount)); - const scrollContainer = container.querySelector( - `.${PREFIX_CLS}-holder`, - )!; - - const scrollToBottomWithCount = () => { - scrollToBottom(scrollContainer, itemCount, itemHeight, clientHeight); - }; - - // First trigger at bottom - scrollToBottomWithCount(); - expect(onEndReached).toHaveBeenCalledTimes(1); - - // Simulate new data loaded: scrollHeight increases - itemCount = 40; - act(() => { - rerender(renderList(itemCount)); - }); - scrollToBottomWithCount(); - expect(onEndReached).toHaveBeenCalledTimes(2); - - // Load more data again - itemCount = 60; - act(() => { - rerender(renderList(itemCount)); - }); - scrollToBottomWithCount(); - expect(onEndReached).toHaveBeenCalledTimes(3); - }); - - it('should trigger again after scrollHeight decreases (data removed)', () => { - const onEndReached = jest.fn(); - const itemHeight = DEFAULT_ITEM_HEIGHT; - const clientHeight = DEFAULT_HEIGHT; - let itemCount = 20; - const renderList = (count: number) => ( -
{item.id}
} - onEndReached={onEndReached} - /> - ); - - const { container, rerender } = render(renderList(itemCount)); - const scrollContainer = container.querySelector( - `.${PREFIX_CLS}-holder`, - )!; - - const scrollToBottomWithCount = () => { - scrollToBottom(scrollContainer, itemCount, itemHeight, clientHeight); - }; - - // First trigger - scrollToBottomWithCount(); - expect(onEndReached).toHaveBeenCalledTimes(1); - - // Simulate data removed: scrollHeight decreases - itemCount = 10; - act(() => { - rerender(renderList(itemCount)); - }); - scrollToBottomWithCount(); - expect(onEndReached).toHaveBeenCalledTimes(2); - }); - }); - - describe('State management with dynamic data', () => { - it('should work correctly with dynamic item loading', () => { - const LoadMoreComponent = () => { - const [items, setItems] = useState(createItems(10)); - const [callCount, setCallCount] = useState(0); - - const handleEndReached = () => { - setCallCount((prev) => prev + 1); - // Simulate loading more items - setItems((prev) => [ - ...prev, - ...Array.from({ length: 10 }, (_, i) => ({ id: prev.length + i })), - ]); - }; - - return ( -
-
{callCount}
- ( -
{item.id}
- )} - onEndReached={handleEndReached} - /> -
- ); - }; - - const { container, getByTestId } = render(); - const scrollContainer = container.querySelector( - `.${PREFIX_CLS}-holder`, - )!; - - // Initial state - expect(getByTestId('call-count').textContent).toBe('0'); - - // Scroll to bottom - should trigger load - scrollToBottom(scrollContainer, 10); - - expect(getByTestId('call-count').textContent).toBe('1'); - - // Stay at bottom with same scroll position - should not trigger again - fireEvent.scroll(scrollContainer); - expect(getByTestId('call-count').textContent).toBe('1'); - }); - - it('should trigger when scrollTo repeatedly jumps to the end', async () => { - const ScrollToEndComponent = () => { - const listRef = React.useRef(null); - const [items, setItems] = useState(createItems(20)); - const [callCount, setCallCount] = useState(0); - - const handleEndReached = () => { - setCallCount((prev) => prev + 1); - setItems((prev) => [ - ...prev, - ...Array.from({ length: 10 }, (_, i) => ({ - id: prev.length + i, - })), - ]); - }; - - const handleScrollToEnd = () => { - const lastItem = items[items.length - 1]; - if (lastItem) { - listRef.current?.scrollTo({ - key: lastItem.id, - align: 'bottom', - }); - } - }; - - return ( -
- -
{callCount}
-
{items.length}
- ( -
{item.id}
- )} - onEndReached={handleEndReached} - /> -
- ); - }; - - const { container, getByTestId, getByRole } = render( - , - ); - const scrollContainer = container.querySelector( - `.${PREFIX_CLS}-holder`, - )!; - const scrollButton = getByRole('button', { name: /scroll to end/i }); - - const getCallCount = () => Number(getByTestId('call-count').textContent); - const getItemCount = () => Number(getByTestId('item-count').textContent); - - const triggerScrollToEnd = async () => { - const prevItemCount = getItemCount(); - const prevCallCount = getCallCount(); - - act(() => { - fireEvent.click(scrollButton); - }); - - act(() => { - scrollToBottom(scrollContainer, prevItemCount); - }); - - await waitFor(() => expect(getCallCount()).toBe(prevCallCount + 1)); - await waitFor(() => - expect(getItemCount()).toBeGreaterThan(prevItemCount), - ); - }; - - await triggerScrollToEnd(); - await triggerScrollToEnd(); - await triggerScrollToEnd(); - - expect(getCallCount()).toBe(3); - }); - }); - - describe('Edge cases', () => { - it('should handle initial position at bottom', () => { - const onEndReached = jest.fn(); - const items = createItems(5); // Few items - - const { container } = render( -
{item.id}
} - onEndReached={onEndReached} - />, - ); - - const scrollContainer = container.querySelector( - `.${PREFIX_CLS}-holder`, - )!; - - // Content height equals container height (already at bottom) - mockScroll( - scrollContainer, - 0, - items.length * DEFAULT_ITEM_HEIGHT, - DEFAULT_HEIGHT, - ); - fireEvent.scroll(scrollContainer); - - expect(onEndReached).toHaveBeenCalledTimes(1); - }); - - it('should handle zero scrollHeight', () => { - const onEndReached = jest.fn(); - - const { container } = render( -
{item.id}
} - onEndReached={onEndReached} - />, - ); - - const scrollContainer = container.querySelector( - `.${PREFIX_CLS}-holder`, - )!; - - mockScroll(scrollContainer, 0, 0, DEFAULT_HEIGHT); - fireEvent.scroll(scrollContainer); - - // Should trigger when scrollHeight is 0 (empty list at bottom) - expect(onEndReached).toHaveBeenCalledTimes(1); - }); - - it('should handle slightly past bottom (distanceToBottom < 0)', () => { - const onEndReached = jest.fn(); - const items = createItems(20); - - const { container } = render( -
{item.id}
} - onEndReached={onEndReached} - />, - ); - - const scrollContainer = container.querySelector( - `.${PREFIX_CLS}-holder`, - )!; - - // Slightly past bottom (can happen during animation) - const scrollHeight = items.length * DEFAULT_ITEM_HEIGHT; - mockScroll( - scrollContainer, - Math.max(scrollHeight - DEFAULT_HEIGHT, 0) + 5, - scrollHeight, - DEFAULT_HEIGHT, - ); // scrollTop + clientHeight > scrollHeight - fireEvent.scroll(scrollContainer); - - expect(onEndReached).toHaveBeenCalledTimes(1); - }); - }); - - describe('Scroll up and down scenarios', () => { - it('should trigger again after scrolling up and then back to bottom with new data', () => { - const onEndReached = jest.fn(); - const itemHeight = DEFAULT_ITEM_HEIGHT; - const clientHeight = DEFAULT_HEIGHT; - let itemCount = 20; - const renderList = (count: number) => ( -
{item.id}
} - onEndReached={onEndReached} - /> - ); - - const { container, rerender } = render(renderList(itemCount)); - const scrollContainer = container.querySelector( - `.${PREFIX_CLS}-holder`, - )!; - - const scrollTo = (scrollTop: number) => { - const scrollHeight = itemCount * itemHeight; - mockScroll(scrollContainer, scrollTop, scrollHeight, clientHeight); - fireEvent.scroll(scrollContainer); - }; - const scrollToBottom = () => { - scrollTo(Math.max(itemCount * itemHeight - clientHeight, 0)); - }; - - // 1. Scroll to bottom - scrollToBottom(); - expect(onEndReached).toHaveBeenCalledTimes(1); - - // 2. Scroll up (away from bottom) - scrollTo(100); - expect(onEndReached).toHaveBeenCalledTimes(1); // No new call - - // 3. Scroll back to bottom (same scrollHeight) - scrollToBottom(); - expect(onEndReached).toHaveBeenCalledTimes(1); // No new call (same scrollHeight) - - // 4. New data loaded (scrollHeight increases) - itemCount = 40; - act(() => { - rerender(renderList(itemCount)); - }); - scrollTo(400); - expect(onEndReached).toHaveBeenCalledTimes(1); // Still no call (not at bottom) - - // 5. Scroll to new bottom - scrollToBottom(); - expect(onEndReached).toHaveBeenCalledTimes(2); // Now triggers! - }); - }); - - describe('Integration with group feature', () => { - it('should work correctly with grouped items', () => { - const onEndReached = jest.fn(); - const items = createItems(20).map((item) => ({ - ...item, - group: `Group ${Math.floor(item.id / 5)}`, - })); - - const { container } = render( -
{item.id}
} - onEndReached={onEndReached} - group={{ - key: (item) => item.group, - title: (key) =>
{key}
, - }} - />, - ); - - const scrollContainer = container.querySelector( - `.${PREFIX_CLS}-holder`, - )!; - - // Scroll to bottom with groups (items + headers) - const groupCount = new Set(items.map((item) => item.group)).size; - scrollToBottom(scrollContainer, items.length + groupCount); - fireEvent.scroll(scrollContainer); - - expect(onEndReached).toHaveBeenCalledTimes(1); - }); - }); -});