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
-
-
-
- | name |
- type |
- default |
- description |
-
-
-
-
- | items |
- T[] |
- [] |
- 列表数据源,虚拟滚动会基于此计算高度。 |
-
-
- | rowKey |
- React.Key | (item: T) => React.Key |
- required |
- 返回每一项的唯一标识,用于缓存高度与滚动定位。 |
-
-
- | itemRender |
- (item: T, index: number) => React.ReactNode |
- required |
- 渲染单行内容的函数。 |
-
-
- | height |
- number |
- required |
- 列表可视区域高度。 |
-
-
- | itemHeight |
- number |
- required |
- 每行的基础高度,虚拟滚动会以此做初始估算。 |
-
-
- | group |
- Group<T> |
- |
- 提供分组 key 与标题渲染,开启后会生成组头。 |
-
-
- | sticky |
- boolean |
- false |
- 为分组头启用粘性悬停效果。 |
-
-
- | virtual |
- boolean |
- true |
- 是否启用虚拟列表模式,可根据需要关闭。 |
-
-
- | onEndReached |
- () => void |
- |
- 滚动触达底部时触发,常用于触发下一页加载。 |
-
-
- | prefixCls |
- string |
- rc-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);
- });
- });
-});