diff --git a/.dumirc.ts b/.dumirc.ts index efcef9c..6d575e3 100644 --- a/.dumirc.ts +++ b/.dumirc.ts @@ -3,13 +3,13 @@ import path from 'path'; export default defineConfig({ alias: { - 'rc-trigger$': path.resolve('src'), - 'rc-trigger/es': path.resolve('src'), + 'rc-listy$': path.resolve('src'), + 'rc-listy/es': path.resolve('src'), }, mfsu: false, favicons: ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'], themeConfig: { - name: 'Trigger', + name: 'Listy', logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', }, styles: [ diff --git a/README.md b/README.md index 6f8662e..4dccda8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# @rc-component/trigger +# @rc-component/Listy -React Trigger Component +React Listy Component [![NPM version][npm-image]][npm-url] [![npm download][download-image]][download-url] @@ -9,49 +9,52 @@ React Trigger Component [![bundle size][bundlephobia-image]][bundlephobia-url] [![dumi][dumi-image]][dumi-url] -[npm-image]: http://img.shields.io/npm/v/@rc-component/trigger.svg?style=flat-square -[npm-url]: http://npmjs.org/package/@rc-component/trigger -[github-actions-image]: https://github.com/react-component/trigger/workflows/CI/badge.svg -[github-actions-url]: https://github.com/react-component/trigger/actions -[codecov-image]: https://img.shields.io/codecov/c/github/react-component/trigger/master.svg?style=flat-square -[codecov-url]: https://codecov.io/gh/react-component/trigger/branch/master -[david-url]: https://david-dm.org/react-component/trigger -[david-image]: https://david-dm.org/react-component/trigger/status.svg?style=flat-square -[david-dev-url]: https://david-dm.org/react-component/trigger?type=dev -[david-dev-image]: https://david-dm.org/react-component/trigger/dev-status.svg?style=flat-square -[download-image]: https://img.shields.io/npm/dm/@rc-component/trigger.svg?style=flat-square -[download-url]: https://npmjs.org/package/@rc-component/trigger -[bundlephobia-url]: https://bundlephobia.com/result?p=@rc-component/trigger -[bundlephobia-image]: https://badgen.net/bundlephobia/minzip/@rc-component/trigger +[npm-image]: http://img.shields.io/npm/v/@rc-component/listy.svg?style=flat-square +[npm-url]: http://npmjs.org/package/@rc-component/listy +[github-actions-image]: https://github.com/react-component/listy/workflows/CI/badge.svg +[github-actions-url]: https://github.com/react-component/listy/actions +[codecov-image]: https://img.shields.io/codecov/c/github/react-component/listy/master.svg?style=flat-square +[codecov-url]: https://codecov.io/gh/react-component/listy/branch/master +[david-url]: https://david-dm.org/react-component/listy +[david-image]: https://david-dm.org/react-component/listy/status.svg?style=flat-square +[david-dev-url]: https://david-dm.org/react-component/listy?type=dev +[david-dev-image]: https://david-dm.org/react-component/listy/dev-status.svg?style=flat-square +[download-image]: https://img.shields.io/npm/dm/@rc-component/listy.svg?style=flat-square +[download-url]: https://npmjs.org/package/@rc-component/listy +[bundlephobia-url]: https://bundlephobia.com/result?p=@rc-component/listy +[bundlephobia-image]: https://badgen.net/bundlephobia/minzip/@rc-component/listy [dumi-image]: https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square [dumi-url]: https://github.com/umijs/dumi ## Install -[![@rc-component/trigger](https://nodei.co/npm/@rc-component/trigger.png)](https://npmjs.org/package/@rc-component/trigger) +[![@rc-component/listy](https://nodei.co/npm/@rc-component/listy.png)](https://npmjs.org/package/@rc-component/listy) ## Usage -Include the default [styling](https://github.com/react-component/trigger/blob/master/assets/index.less#L4:L11) and then: +Include the default [styling](https://github.com/react-component/listy/blob/master/assets/index.less#L4:L11) and then: ```js import React from 'react'; -import ReactDOM from 'react-dom'; -import Trigger from '@rc-component/trigger'; - -ReactDOM.render( - popup} - popupAlign={{ - points: ['tl', 'bl'], - offset: [0, 3], - }} - > - hover - , - container, +import ReactDOM from 'react-dom/client'; +import Listy from '@rc-component/listy'; + +const items = Array.from({ length: 100 }, (_, index) => ({ + id: index, + name: `Item ${index}`, +})); + +const App = () => ( +
{item.name}
} + /> ); + +ReactDOM.createRoot(container).render(); ``` ## Compatibility @@ -86,164 +89,73 @@ npm start - alignPoint - bool - false - Popup will align with mouse position (support action of 'click', 'hover' and 'contextMenu') - - - popupClassName - string - - additional className added to popup - - - forceRender - boolean - false - whether render popup before first show - - - destroyPopupOnHide - boolean - false - whether destroy popup when hide + items + T[] + [] + 列表数据源,虚拟滚动会基于此计算高度。 - getPopupClassNameFromAlign - getPopupClassNameFromAlign(align: Object):String - - additional className added to popup according to align + rowKey + React.Key | (item: T) => React.Key + required + 返回每一项的唯一标识,用于缓存高度与滚动定位。 - action - string[] - ['hover'] - which actions cause popup shown. enum of 'hover','click','focus','contextMenu' + itemRender + (item: T, index: number) => React.ReactNode + required + 渲染单行内容的函数。 - mouseEnterDelay + height number - 0 - delay time to show when mouse enter. unit: s. + required + 列表可视区域高度。 - mouseLeaveDelay + itemHeight number - 0.1 - delay time to hide when mouse leave. unit: s. + required + 每行的基础高度,虚拟滚动会以此做初始估算。 - popupStyle - Object + group + Group<T> - additional style of popup + 提供分组 key 与标题渲染,开启后会生成组头。 - prefixCls - String - rc-trigger-popup - prefix class name - - - popupTransitionName - String|Object - - https://github.com/react-component/animate - - - maskTransitionName - String|Object - - https://github.com/react-component/animate - - - onPopupVisibleChange - Function - - call when popup visible is changed - - - mask + sticky boolean false - whether to support mask + 为分组头启用粘性悬停效果。 - maskClosable + virtual boolean true - whether to support click mask to hide + 是否启用虚拟列表模式,可根据需要关闭。 - popupVisible - boolean + onEndReached + () => void - whether popup is visible + 滚动触达底部时触发,常用于触发下一页加载。 - zIndex - number - - popup's zIndex - - - defaultPopupVisible - boolean - - whether popup is visible initially - - - popupAlign - Object: alignConfig of [dom-align](https://github.com/yiminghe/dom-align) - - popup 's align config - - - onPopupAlign - function(popupDomNode, align) - - callback when popup node is aligned - - - popup - React.Element | function() => React.Element - - popup content - - - getPopupContainer - getPopupContainer(): HTMLElement - - function returning html node which will act as popup container - - - getDocument - getDocument(): HTMLElement - - function returning document node which will be attached click event to close trigger - - - popupPlacement - string - - use preset popup align config from builtinPlacements, can be merged by popupAlign prop - - - builtinPlacements - object - - builtin placement align map. used by placement prop - - - stretch + prefixCls string - - Let popup div stretch with trigger element. enums of 'width', 'minWidth', 'height', 'minHeight'. (You can also mixed with 'height minWidth') + rc-listy + 组件样式前缀,方便自定义样式隔离。 +### ListyRef + +- `scrollTo(config: number | { key?: React.Key; index?: number; align?: 'top' | 'bottom' | 'auto'; offset?: number; } | { groupKey: React.Key; align?: 'top' | 'bottom' | 'auto'; offset?: number; })` + - 传入 `groupKey` 时会直接滚动到对应组头(需启用 `group`) + ## Test Case ``` @@ -255,4 +167,4 @@ open coverage/ dir ## License -rc-trigger is released under the MIT license. +@rc-component/listy is released under the MIT license. diff --git a/assets/index.less b/assets/index.less new file mode 100644 index 0000000..e13a1ce --- /dev/null +++ b/assets/index.less @@ -0,0 +1,25 @@ +@listy-prefix-cls: ~'rc-listy'; + +.@{listy-prefix-cls} { + position: relative; + + &-group-header { + background-color: #fff; + + &-sticky { + position: sticky; + top: 0; + left: 0; + right: 0; + } + } + + &-sticky-header { + position: absolute; + top: 0; + left: 0; + right: 0; + transform: translateY(0); + background-color: #fff; + } +} diff --git a/docs/demos/basic.md b/docs/demos/basic.md new file mode 100644 index 0000000..824c331 --- /dev/null +++ b/docs/demos/basic.md @@ -0,0 +1,8 @@ +--- +title: Basic +nav: + title: Demo + path: /demo +--- + + \ No newline at end of file diff --git a/docs/demos/endless-scrolling.md b/docs/demos/endless-scrolling.md new file mode 100644 index 0000000..48cd1d2 --- /dev/null +++ b/docs/demos/endless-scrolling.md @@ -0,0 +1,8 @@ +--- +title: Endless Scrolling +nav: + title: Demo + path: /demo +--- + + \ No newline at end of file diff --git a/docs/demos/group.md b/docs/demos/group.md new file mode 100644 index 0000000..451d372 --- /dev/null +++ b/docs/demos/group.md @@ -0,0 +1,8 @@ +--- +title: Group +nav: + title: Demo + path: /demo +--- + + \ No newline at end of file diff --git a/docs/demos/load-on-demand.md b/docs/demos/load-on-demand.md new file mode 100644 index 0000000..14b591b --- /dev/null +++ b/docs/demos/load-on-demand.md @@ -0,0 +1,9 @@ +--- +title: Load On Demand +nav: + title: Demo + path: /demo +--- + + + diff --git a/docs/demos/no-virtual.md b/docs/demos/no-virtual.md new file mode 100644 index 0000000..7188120 --- /dev/null +++ b/docs/demos/no-virtual.md @@ -0,0 +1,9 @@ +--- +title: No Virtual +nav: + title: Demo + path: /demo +--- + + + diff --git a/docs/examples/basic.tsx b/docs/examples/basic.tsx new file mode 100644 index 0000000..fba1f81 --- /dev/null +++ b/docs/examples/basic.tsx @@ -0,0 +1,67 @@ +import React, { useRef } from 'react'; +import Listy, { type ListyRef } from '@rc-component/listy'; +import '../../assets/index.less'; + +export default () => { + const listRef = useRef(null); + const items = Array.from({ length: 200 }, (_, index) => { + const groupItemsCount = 20; + const groupIndex = Math.floor(index / groupItemsCount); + return { + id: index + 1, + name: `${index} (group ${groupIndex})`, + type: `Group ${groupIndex * groupItemsCount}`, + }; + }); + + const itemStyle: React.CSSProperties = { + padding: '0 12px', + height: 32, + lineHeight: '32px', + borderBottom: '1px solid rgb(79, 53, 53)', + }; + + return ( +
+ { + return
{item.name}
; + }} + rowKey="id" + ref={listRef} + sticky + group={{ + key: (item) => item.type, + title: (groupKey, groupItems) => ( +
+ {groupKey}------{groupItems.length} +
+ ), + }} + /> + + +
+ ); +}; diff --git a/docs/examples/endless-scrolling.tsx b/docs/examples/endless-scrolling.tsx new file mode 100644 index 0000000..e6c8ba4 --- /dev/null +++ b/docs/examples/endless-scrolling.tsx @@ -0,0 +1,90 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import Listy, { type ListyRef } from '@rc-component/listy'; +import '../../assets/index.less'; + +interface RowItem { + id: number; + name: string; +} + +const BATCH_SIZE = 40; +const LOAD_DELAY = 600; + +const createBatch = (startId: number, count: number): RowItem[] => + Array.from({ length: count }, (_, index) => { + const id = startId + index; + return { + id, + name: `Row ${id}`, + }; + }); + +export default () => { + const listRef = useRef(null); + const nextIdRef = useRef(BATCH_SIZE + 1); + + const [items, setItems] = useState(() => createBatch(1, BATCH_SIZE)); + const [loading, setLoading] = useState(false); + + const loadMore = useCallback(() => { + if (loading) { + return; + } + + setLoading(true); + + window.setTimeout(() => { + setItems((prevItems) => { + const nextItems = createBatch(nextIdRef.current, BATCH_SIZE); + nextIdRef.current += nextItems.length; + + return [...prevItems, ...nextItems]; + }); + + setLoading(false); + }, LOAD_DELAY); + }, [loading]); + + const itemStyle = useMemo( + () => ({ + padding: '0 12px', + height: 32, + lineHeight: '32px', + borderBottom: '1px solid #efefef', + background: '#fff', + }), + [], + ); + + return ( +
+ ( +
{item.name}
+ )} + onEndReached={loadMore} + /> + +
+ + Count: {items.length} + {loading && Loading…} +
+
+ ); +}; \ No newline at end of file diff --git a/docs/examples/group.tsx b/docs/examples/group.tsx new file mode 100644 index 0000000..1cf86b9 --- /dev/null +++ b/docs/examples/group.tsx @@ -0,0 +1,110 @@ +import React, { useRef } from 'react'; +import Listy, { type ListyRef } from '@rc-component/listy'; +import '../../assets/index.less'; + +export default () => { + const listRef = useRef(null); + + const groupSize = 12; + const total = 240; + const groupCount = Math.ceil(total / groupSize); + const groupKeys = React.useMemo( + () => Array.from({ length: groupCount }, (_, index) => `G${index}`), + [groupCount], + ); + + const items = Array.from({ length: total }, (_, index) => { + const groupIndex = Math.floor(index / groupSize); + return { + id: index + 1, + name: `Row ${index}`, + groupId: `G${groupIndex}`, + }; + }); + + const itemStyle: React.CSSProperties = { + padding: '0 12px', + borderBottom: '1px solid #efefef', + background: '#fff', + }; + + function renderHeader(groupKey: string, groupItems: typeof items) { + const groupIndex = Number(groupKey.slice(1)); + const heights = [32, 56, 80]; + const h = heights[groupIndex % heights.length]; + return ( +
+ Group {groupKey} (size: {groupItems.length}) +
+ ); + } + + const scrollToGroup = (groupKey: string) => { + listRef.current?.scrollTo({ groupKey, align: 'top' }); + }; + + return ( +
+
+ + +
+
+ {groupKeys.map((groupKey) => ( + + ))} +
+ { + const heights = [30, 42, 54]; + const h = heights[index % heights.length]; + return ( +
+ {item.name} · {item.groupId} +
+ ); + }} + group={{ + key: (item) => item.groupId, + title: (groupKey, groupItems) => renderHeader(groupKey, groupItems), + }} + ref={listRef} + /> +
+ ); +}; diff --git a/docs/examples/load-on-demand.tsx b/docs/examples/load-on-demand.tsx new file mode 100644 index 0000000..afb0eda --- /dev/null +++ b/docs/examples/load-on-demand.tsx @@ -0,0 +1,91 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import Listy, { type ListyRef } from '@rc-component/listy'; +import '../../assets/index.less'; + +interface RowItem { + id: number; + name: string; +} + +const BATCH_SIZE = 30; +const LOAD_DELAY = 500; + +const createBatch = (startId: number, count: number): RowItem[] => + Array.from({ length: count }, (_, index) => { + const id = startId + index; + return { + id, + name: `Row ${id}`, + }; + }); + +export default () => { + const listRef = useRef(null); + const nextIdRef = useRef(BATCH_SIZE + 1); + + const [items, setItems] = useState(() => createBatch(1, BATCH_SIZE)); + const [loading, setLoading] = useState(false); + + const appendItems = useCallback(() => { + if (loading) { + return; + } + + setLoading(true); + + window.setTimeout(() => { + setItems((prevItems) => { + const newItems = createBatch(nextIdRef.current, BATCH_SIZE); + nextIdRef.current += newItems.length; + return [...prevItems, ...newItems]; + }); + + setLoading(false); + }, LOAD_DELAY); + }, [loading]); + + const itemStyle = useMemo( + () => ({ + padding: '0 12px', + height: 32, + lineHeight: '32px', + borderBottom: '1px solid #efefef', + background: '#fff', + }), + [], + ); + + return ( +
+ ( +
{item.name}
+ )} + /> + +
+ + + Count: {items.length} +
+
+ ); +}; + diff --git a/docs/examples/no-virtual.tsx b/docs/examples/no-virtual.tsx new file mode 100644 index 0000000..cd1cd1c --- /dev/null +++ b/docs/examples/no-virtual.tsx @@ -0,0 +1,162 @@ +import React, { useCallback, useMemo, useRef } from 'react'; +import Listy, { type ListyRef } from '@rc-component/listy'; +import '../../assets/index.less'; + +const GROUP_META = { + fruits: { + title: 'Fruits', + description: 'Tropical & Seasonal', + accent: '#f6ffed', + }, + vegetables: { + title: 'Vegetables', + description: 'Leafy & Root', + accent: '#fff7e6', + }, + desserts: { + title: 'Desserts', + description: 'Sweet & Baked', + accent: '#f0f5ff', + }, +} as const; + +type GroupId = keyof typeof GROUP_META; + +interface ProduceItem { + id: string; + name: string; + groupId: GroupId; +} + +const items: ProduceItem[] = [ + { id: 'fruits-1', name: 'Mango', groupId: 'fruits' }, + { id: 'fruits-2', name: 'Pineapple', groupId: 'fruits' }, + { id: 'fruits-3', name: 'Banana', groupId: 'fruits' }, + { id: 'fruits-4', name: 'Grapes', groupId: 'fruits' }, + { id: 'fruits-5', name: 'Peach', groupId: 'fruits' }, + { id: 'fruits-6', name: 'Dragon Fruit', groupId: 'fruits' }, + { id: 'fruits-7', name: 'Papaya', groupId: 'fruits' }, + { id: 'fruits-8', name: 'Lychee', groupId: 'fruits' }, + { id: 'vegetables-1', name: 'Spinach', groupId: 'vegetables' }, + { id: 'vegetables-2', name: 'Bok Choy', groupId: 'vegetables' }, + { id: 'vegetables-3', name: 'Carrot', groupId: 'vegetables' }, + { id: 'vegetables-4', name: 'Kale', groupId: 'vegetables' }, + { id: 'vegetables-5', name: 'Sweet Potato', groupId: 'vegetables' }, + { id: 'vegetables-6', name: 'Beetroot', groupId: 'vegetables' }, + { id: 'vegetables-7', name: 'Asparagus', groupId: 'vegetables' }, + { id: 'vegetables-8', name: 'Broccoli', groupId: 'vegetables' }, + { id: 'vegetables-9', name: 'Okra', groupId: 'vegetables' }, + { id: 'desserts-1', name: 'Cheesecake', groupId: 'desserts' }, + { id: 'desserts-2', name: 'Chocolate Tart', groupId: 'desserts' }, + { id: 'desserts-3', name: 'Panna Cotta', groupId: 'desserts' }, + { id: 'desserts-4', name: 'Macaron', groupId: 'desserts' }, + { id: 'desserts-5', name: 'Brownie', groupId: 'desserts' }, + { id: 'desserts-6', name: 'Tiramisu', groupId: 'desserts' }, + { id: 'desserts-7', name: 'Apple Pie', groupId: 'desserts' }, + { id: 'desserts-8', name: 'Lemon Tart', groupId: 'desserts' }, + { id: 'desserts-9', name: 'Mousse', groupId: 'desserts' }, + { id: 'desserts-10', name: 'Creme Brulee', groupId: 'desserts' }, + { id: 'desserts-11', name: 'Eclair', groupId: 'desserts' }, + { id: 'desserts-12', name: 'Pavlova', groupId: 'desserts' }, + { id: 'desserts-13', name: 'Baklava', groupId: 'desserts' }, + { id: 'desserts-14', name: 'Donut', groupId: 'desserts' }, + { id: 'desserts-15', name: 'Cupcake', groupId: 'desserts' }, + { id: 'desserts-16', name: 'Souffle', groupId: 'desserts' }, +]; + +const GROUP_IDS = Object.keys(GROUP_META) as GroupId[]; + +export default () => { + const listRef = useRef(null); + + const itemStyle = useMemo( + () => ({ + padding: '0 16px', + height: 40, + lineHeight: '40px', + borderBottom: '1px solid #f0f0f0', + background: '#fff', + }), + [], + ); + + const getGroupMeta = useCallback( + (groupId: GroupId) => GROUP_META[groupId], + [], + ); + + const handleScrollToGroup = useCallback( + (groupId: GroupId) => { + listRef.current?.scrollTo({ groupKey: groupId, align: 'top' }); + }, + [], + ); + + return ( +
+ { + const baseHeight = 40; + const height = baseHeight + (index % 2 === 0 ? 0 : 10); + return ( +
+ {item.name} +
+ ); + }} + group={{ + key: (item) => item.groupId, + title: (groupKey, groupItems) => { + const metadata = getGroupMeta(groupKey); + const accent = metadata?.accent ?? '#fafafa'; + return ( +
+
+ {metadata?.title ?? groupKey} +
+
+ {metadata?.description ?? 'Group items'} +
+
+ {groupItems.length} items +
+
+ ); + }, + }} + /> + +
+ {GROUP_IDS.map((groupId) => { + const meta = GROUP_META[groupId]; + return ( + + ); + })} + Total Items: {items.length} +
+
+ ); +}; diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..d518949 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,7 @@ +--- +hero: + title: rc-listy + description: React Listy Component +--- + + diff --git a/index.js b/index.js index 274f820..b0ed340 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,3 @@ // export this package's api -import Trigger from './src/'; -export default Trigger; +import Listy from './src/'; +export default Listy; diff --git a/jest.config.js b/jest.config.js index 5328c18..1b72e2a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,6 @@ module.exports = { setupFiles: ['./tests/setup.js'], + moduleNameMapper: { + '^@rc-component/listy$': '/src/index.ts', + }, }; diff --git a/now.json b/now.json deleted file mode 100644 index 716cff0..0000000 --- a/now.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": 2, - "name": "rc-trigger", - "builds": [ - { - "src": "package.json", - "use": "@now/static-build", - "config": { "distDir": ".doc" } - } - ] -} diff --git a/package.json b/package.json index e24e248..1a30e0f 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,22 @@ { "name": "@rc-component/listy", "version": "1.0.0", - "description": "base abstract listy component for react", - "engines": { - "node": ">=8.x" - }, + "description": "React Listy Component", "keywords": [ "react", "react-component", - "react-trigger", - "trigger" + "list", + "high performance", + "headless ui" ], - "homepage": "https://github.com/react-component/trigger", + "homepage": "https://github.com/react-component/listy", "author": "", "repository": { "type": "git", - "url": "https://github.com/react-component/trigger.git" + "url": "https://github.com/react-component/listy.git" }, "bugs": { - "url": "https://github.com/react-component/trigger/issues" + "url": "https://github.com/react-component/listy/issues" }, "files": [ "es", @@ -44,28 +42,32 @@ "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.0.0", "@rc-component/resize-observer": "^1.0.0", - "@rc-component/util": "^1.3.0", - "clsx": "^2.1.1" + "@rc-component/util": "^1.3.1", + "classnames": "^2.5.1", + "rc-virtual-list": "^3.19.2" }, "devDependencies": { - "@rc-component/father-plugin": "^2.0.0", - "@rc-component/np": "^1.0.3", - "@testing-library/jest-dom": "^6.1.4", - "@testing-library/react": "^16.0.0", - "@types/jest": "^30.0.0", - "@types/node": "^24.0.3", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", + "@rc-component/father-plugin": "^2.1.3", + "@rc-component/np": "^1.0.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@types/classnames": "^2.3.4", + "@types/jest": "^29.5.14", + "@types/node": "^24.10.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", "@umijs/fabric": "^4.0.1", - "dumi": "^2.1.0", - "eslint": "^8.51.0", - "father": "^4.0.0", - "less": "^4.2.0", - "prettier": "^3.3.3", - "rc-test": "^7.0.13", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "typescript": "~5.9.3" + "cross-env": "^7.0.3", + "dumi": "^2.4.21", + "eslint": "^8.57.1", + "father": "^4.6.7", + "less": "^4.4.2", + "prettier": "^3.6.2", + "rc-test": "^7.1.2", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "regenerator-runtime": "^0.14.1", + "typescript": "^5.9.3" }, "peerDependencies": { "react": ">=18.0.0", diff --git a/src/List.tsx b/src/List.tsx new file mode 100644 index 0000000..e3cff7c --- /dev/null +++ b/src/List.tsx @@ -0,0 +1,156 @@ +import * as React from 'react'; +import VirtualList, { type ListRef } from 'rc-virtual-list'; +import type { + GetKey, + 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'; + +function Listy( + props: ListyProps, + ref: React.Ref, +) { + const { + items, + itemRender, + group, + onEndReached, + rowKey, + height, + itemHeight, + sticky, + virtual = true, + prefixCls = 'rc-listy', + } = props; + + const data = items || []; + + const listRef = React.useRef(null); + const containerRef = React.useRef(null); + + useImperativeHandle(ref, () => ({ + scrollTo: (config) => { + if (isGroupScrollConfig(config)) { + const { groupKey, align, offset } = config; + listRef.current?.scrollTo({ + key: groupKey, + align, + offset, + }); + return; + } + listRef.current?.scrollTo(config); + }, + })); + + const getItemKey = React.useCallback>( + (item: T) => { + if (typeof rowKey === 'function') { + return rowKey(item); + } + return item?.[rowKey as string]; + }, + [rowKey], + ); + + const groupSegments = useGroupSegments(data, group); + + // ======================= Flatten rows (header + item) ======================= + const { rows, headerRows, groupKeyToSeg } = useFlattenRows( + data, + group, + groupSegments, + ); + + const getKey = React.useCallback( + (row: Row): React.Key => { + if (row.type === 'header') { + return row.groupKey; + } + return getItemKey(row.item); + }, + [getItemKey], + ); + + // Pre-compute each group's items to simplify header rendering + const groupKeyToItems = React.useMemo(() => { + const map = new Map(); + if (!group) { + return map; + } + groupKeyToSeg.forEach(({ startIndex, endIndex }, key) => { + map.set(key, data.slice(startIndex, endIndex + 1)); + }); + return map; + }, [group, groupKeyToSeg, data]); + + // Sticky header overlay via Portal (anchored on header rows) + const extraRender = useStickyGroupHeader({ + enabled: !!(sticky && group), + group, + headerRows, + groupKeyToItems, + containerRef, + listRef, + prefixCls, + }); + + const renderHeaderRow = React.useCallback( + (groupKey: K) => { + const groupItems = groupKeyToItems.get(groupKey) || []; + const headerClassName = `${prefixCls}-group-header${ + sticky && !virtual ? ` ${prefixCls}-group-header-sticky` : '' + }`; + + return ( +
+ {group.title(groupKey, groupItems)} +
+ ); + }, + [group, groupKeyToItems, prefixCls, virtual], + ); + + const handleOnScroll = useOnEndReached({ + enabled: !!onEndReached, + onEndReached, + }); + + return ( +
+ + {(row: Row) => + row.type === 'header' + ? renderHeaderRow(row.groupKey) + : itemRender(row.item, row.index) + } + +
+ ); +} + +const ListyWithForwardRef = forwardRef(Listy) as < + T, + K extends React.Key = React.Key, +>( + props: ListyProps & { ref?: React.Ref }, +) => React.ReactElement; + +export default ListyWithForwardRef; diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..c5a8b8a --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,11 @@ +import useGroupSegments from './useGroupSegments'; +import useStickyGroupHeader from './useStickyGroupHeader'; +import useFlattenRows from './useFlattenRows'; +import useOnEndReached from './useOnEndReached'; + +export { + useGroupSegments, + useStickyGroupHeader, + useFlattenRows, + useOnEndReached, +}; diff --git a/src/hooks/useFlattenRows.ts b/src/hooks/useFlattenRows.ts new file mode 100644 index 0000000..ea1486c --- /dev/null +++ b/src/hooks/useFlattenRows.ts @@ -0,0 +1,52 @@ +import * as React from 'react'; +import type { Group } from '../interface'; +import type { GroupSegment } from './useGroupSegments'; + +export type Row = + | { type: 'header'; groupKey: K } + | { type: 'item'; item: T; index: number }; + +export interface FlattenRowsResult { + rows: Row[]; + headerRows: { groupKey: K; rowIndex: number }[]; + groupKeyToSeg: Map; +} + +export default function useFlattenRows( + items: T[], + group: Group | undefined, + segments: GroupSegment[], +): FlattenRowsResult { + return React.useMemo(() => { + const flatRows: Row[] = []; + const headerRows: { groupKey: K; rowIndex: number }[] = []; + const groupKeyToSeg = new Map< + K, + { startIndex: number; endIndex: number } + >(); + + if (!group || !segments.length) { + for (let i = 0; i < items.length; i += 1) { + flatRows.push({ type: 'item', item: items[i], index: i }); + } + return { rows: flatRows, headerRows, groupKeyToSeg }; + } + + for (let s = 0; s < segments.length; s += 1) { + const seg = segments[s]; + groupKeyToSeg.set(seg.key, { + startIndex: seg.startIndex, + endIndex: seg.endIndex, + }); + + headerRows.push({ groupKey: seg.key, rowIndex: flatRows.length }); + flatRows.push({ type: 'header', groupKey: seg.key }); + + for (let i = seg.startIndex; i <= seg.endIndex; i += 1) { + flatRows.push({ type: 'item', item: items[i], index: i }); + } + } + + return { rows: flatRows, headerRows, groupKeyToSeg }; + }, [items, group, segments]); +} diff --git a/src/hooks/useGroupSegments.ts b/src/hooks/useGroupSegments.ts new file mode 100644 index 0000000..04cd419 --- /dev/null +++ b/src/hooks/useGroupSegments.ts @@ -0,0 +1,52 @@ +import * as React from 'react'; +import type { Group } from '../interface'; + +export interface GroupSegment { + key: K; + startIndex: number; + endIndex: number; +} + +export default function useGroupSegments( + items: T[], + group?: Group, +): GroupSegment[] { + return React.useMemo(() => { + if (!group || !items?.length) { + return []; + } + + const segments: GroupSegment[] = []; + let currentKey: K | null = null; + let currentStart = -1; + + const getGroupKey = (item: T): K => + typeof group.key === 'function' ? group.key(item) : group.key; + + for (let i = 0; i < items.length; i += 1) { + const gk = getGroupKey(items[i]); + if (currentKey === null) { + currentKey = gk; + currentStart = i; + } else if (gk !== currentKey) { + segments.push({ + key: currentKey, + startIndex: currentStart, + endIndex: i - 1, + }); + currentKey = gk; + currentStart = i; + } + } + + if (currentKey !== null) { + segments.push({ + key: currentKey, + startIndex: currentStart, + endIndex: items.length - 1, + }); + } + + return segments; + }, [items, group]); +} diff --git a/src/hooks/useOnEndReached.ts b/src/hooks/useOnEndReached.ts new file mode 100644 index 0000000..d1fe95b --- /dev/null +++ b/src/hooks/useOnEndReached.ts @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..18a51a5 --- /dev/null +++ b/src/hooks/useStickyGroupHeader.tsx @@ -0,0 +1,125 @@ +import * as React from 'react'; +import Portal from '@rc-component/portal'; +import type { ListRef } from 'rc-virtual-list'; +import type { ExtraRenderInfo } from 'rc-virtual-list/lib/interface'; +import type { Group } from '../interface'; + +export interface StickyHeaderParams { + enabled: boolean; + group: Group | undefined; + headerRows: { groupKey: K; rowIndex: number }[]; + groupKeyToItems: Map; + containerRef: React.RefObject; + listRef: React.RefObject; + prefixCls: string; +} + +export default function useStickyGroupHeader< + T, + K extends React.Key = React.Key, +>(params: StickyHeaderParams) { + const { + enabled, + group, + headerRows, + groupKeyToItems, + containerRef, + listRef, + prefixCls, + } = params; + + const lastHeaderIdxRef = React.useRef(0); + + const extraRender = React.useCallback( + (info: ExtraRenderInfo) => { + const { virtual } = info; + + if (!enabled || !headerRows.length || !virtual) { + lastHeaderIdxRef.current = 0; + return null; + } + + // maybe rc-virtual-list will expose scrollTop in the future + const getHolderScrollTop = () => { + const container = containerRef.current; + const holder = + container?.querySelector('.rc-virtual-list-holder') || + listRef.current?.nativeElement?.querySelector?.( + '.rc-virtual-list-holder', + ); + if (holder) { + return holder.scrollTop; + } + const infoScrollTop = listRef.current?.getScrollInfo?.().y; + return infoScrollTop; + }; + + const resolveByScrollTop = (scrollTop: number) => { + const cachedIdx = lastHeaderIdxRef.current; + const cachedRow = headerRows[cachedIdx]; + const cachedTop = cachedRow + ? info.getSize(cachedRow.groupKey).top + : null; + const nextRow = headerRows[cachedIdx + 1]; + const nextTop = nextRow ? info.getSize(nextRow.groupKey).top : null; + + if ( + cachedRow && + cachedTop !== null && + scrollTop >= cachedTop && + (nextTop === null || scrollTop < nextTop) + ) { + return cachedIdx; + } + + let lo = 0; + let hi = headerRows.length - 1; + let candidate = 0; + + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + const { top } = info.getSize(headerRows[mid].groupKey); + if (top <= scrollTop) { + candidate = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + return candidate; + }; + + const scrollTop = getHolderScrollTop(); + const activeHeaderIdx = resolveByScrollTop(scrollTop); + + lastHeaderIdxRef.current = activeHeaderIdx; + + const currHeader = headerRows[activeHeaderIdx]; + const groupItems = groupKeyToItems.get(currHeader.groupKey) || []; + + const headerNode = ( +
+ {group.title(currHeader.groupKey, groupItems)} +
+ ); + + return ( + containerRef.current}> + {headerNode} + + ); + }, + [ + enabled, + group, + headerRows, + groupKeyToItems, + containerRef, + listRef, + prefixCls, + ], + ); + + return extraRender; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d6d300d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +import Listy from './List'; + +export type { ListyRef, ListyProps } from './interface'; + +export default Listy; diff --git a/src/index.tsx b/src/index.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/src/interface.ts b/src/interface.ts index e69de29..50a18cf 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -0,0 +1,39 @@ +import type * as React from 'react'; +import type { ScrollTo } from 'rc-virtual-list/lib/List'; +import type { GetKey } from 'rc-virtual-list/lib/interface'; + +export type ScrollAlign = 'top' | 'bottom' | 'auto'; + +export type ListyScrollToConfig = + | Parameters[0] + | { + groupKey: string; + align?: ScrollAlign; + offset?: number; + }; + +export interface ListyRef { + scrollTo: (config?: ListyScrollToConfig) => void; +} + +type RowKey = keyof T | ((item: T) => React.Key); + +export interface Group { + key: ((item: T) => K) | K; + title: (groupKey: K, items: T[]) => React.ReactNode; +} + +export interface ListyProps { + items?: T[]; + sticky?: boolean; + itemHeight?: number; + height?: number; + group?: Group; + virtual?: boolean; + prefixCls?: string; + rowKey: RowKey; + onEndReached?: () => void; + itemRender: (item: T, index: number) => React.ReactNode; +} + +export type { GetKey }; diff --git a/src/util.ts b/src/util.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/util/index.ts b/src/util/index.ts new file mode 100644 index 0000000..e1e698d --- /dev/null +++ b/src/util/index.ts @@ -0,0 +1,7 @@ +import type { ListyScrollToConfig, ScrollAlign } from '../interface'; + +export function isGroupScrollConfig( + config: ListyScrollToConfig, +): config is { groupKey: string; align?: ScrollAlign; offset?: number } { + return !!config && typeof config === 'object' && 'groupKey' in config; +} diff --git a/tests/__snapshots__/listy.test.tsx.snap b/tests/__snapshots__/listy.test.tsx.snap new file mode 100644 index 0000000..6387404 --- /dev/null +++ b/tests/__snapshots__/listy.test.tsx.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Listy should match snapshot 1`] = ` + +
+
+
+
+
+
+ 1 +
+
+
+
+
+
+
+`; diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx new file mode 100644 index 0000000..d17031e --- /dev/null +++ b/tests/hooks.test.tsx @@ -0,0 +1,325 @@ +import React from 'react'; +import { render, renderHook } from '@testing-library/react'; +import type { ListRef } from 'rc-virtual-list'; +import type { ExtraRenderInfo } from 'rc-virtual-list/lib/interface'; + +import { useGroupSegments, useStickyGroupHeader } from '../src/hooks'; +import type { StickyHeaderParams } from '../src/hooks/useStickyGroupHeader'; + +interface GroupedItem { + id: number; + group: string; +} + +const createRenderInfo = ( + overrides: Partial = {}, +): ExtraRenderInfo => ({ + start: 0, + end: 0, + virtual: true, + offsetX: 0, + offsetY: 0, + rtl: false, + getSize: () => ({ top: 0, bottom: 0 }), + ...overrides, +}); + +const StickyHeaderTester = ({ + params, + info, +}: { + params: StickyHeaderParams; + info: ExtraRenderInfo; +}) => { + const extraRender = useStickyGroupHeader(params); + return <>{extraRender(info)}; +}; + +const createRefObject = ( + element: T, +): React.RefObject => + ({ + current: element, + } as React.RefObject); + +const createListRef = ( + overrides: Partial = {}, +): React.RefObject => { + const defaultHolder = document.createElement('div'); + const base: ListRef = { + nativeElement: defaultHolder, + getScrollInfo: () => ({ x: 0, y: 0 }), + scrollTo: () => {}, + }; + + return { + current: { + ...base, + ...overrides, + nativeElement: overrides.nativeElement ?? base.nativeElement, + }, + } as React.RefObject; +}; + +describe('useGroupSegments', () => { + it('creates segments for contiguous group keys', () => { + const items: GroupedItem[] = [ + { id: 0, group: 'A' }, + { id: 1, group: 'A' }, + { id: 2, group: 'B' }, + { id: 3, group: 'B' }, + { id: 4, group: 'C' }, + ]; + + const { result } = renderHook(() => + useGroupSegments(items, { + key: (item) => item.group, + title: () => null, + }), + ); + + expect(result.current).toEqual([ + { key: 'A', startIndex: 0, endIndex: 1 }, + { key: 'B', startIndex: 2, endIndex: 3 }, + { key: 'C', startIndex: 4, endIndex: 4 }, + ]); + }); + + it('supports static group keys and empty states', () => { + const staticGroup = { key: 'static', title: () => null }; + const { result: staticResult } = renderHook(() => + useGroupSegments([{ id: 1, group: 'unused' }], staticGroup), + ); + + expect(staticResult.current).toEqual([ + { key: 'static', startIndex: 0, endIndex: 0 }, + ]); + + const { result: noGroup } = renderHook(() => + useGroupSegments([{ id: 1, group: 'A' }], undefined), + ); + expect(noGroup.current).toEqual([]); + + const { result: noItems } = renderHook(() => + useGroupSegments([], { + key: (item) => item.group, + title: () => null, + }), + ); + expect(noItems.current).toEqual([]); + }); + + it('handles inconsistent length lookups', () => { + const trickyItems: any = { 0: { id: 9, group: 'Z' }, __calls: 0 }; + Object.defineProperty(trickyItems, 'length', { + get() { + this.__calls += 1; + return this.__calls === 1 ? 1 : 0; + }, + }); + + const { result } = renderHook(() => + useGroupSegments(trickyItems as GroupedItem[], { + key: (item) => item?.group ?? 'fallback', + title: () => null, + }), + ); + + expect(result.current).toEqual([]); + }); +}); + +describe('useStickyGroupHeader', () => { + const baseItems: GroupedItem[] = [ + { id: 0, group: 'Group 1' }, + { id: 1, group: 'Group 1' }, + { id: 2, group: 'Group 1' }, + { id: 3, group: 'Group 2' }, + { id: 4, group: 'Group 2' }, + { id: 5, group: 'Group 2' }, + ]; + const headerRows = [ + { groupKey: 'Group 1', rowIndex: 0 }, + { groupKey: 'Group 2', rowIndex: 4 }, + ]; + const baseItemsMap = new Map([ + ['Group 1', baseItems.slice(0, 3)], + ['Group 2', baseItems.slice(3, 6)], + ]); + + it('renders sticky portal for the active header row', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const containerRef = createRefObject(container); + + const title = jest + .fn() + .mockImplementation((key: React.Key, groupItems: GroupedItem[]) => ( + + {String(key)}-{groupItems.length} + + )); + const info = createRenderInfo({ start: 5 }); + const params: StickyHeaderParams = { + enabled: true, + group: { + key: (item) => item.group, + title, + }, + headerRows, + groupKeyToItems: baseItemsMap, + containerRef, + listRef: createListRef({ + nativeElement: container, + getScrollInfo: () => ({ x: 0, y: info.start }), + }), + prefixCls: 'rc-listy', + }; + + const { unmount } = render( + , + ); + + const stickyHeader = container.querySelector('.rc-listy-sticky-header'); + expect(stickyHeader).not.toBeNull(); + expect(stickyHeader).toHaveTextContent('Group 2-3'); + expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); + + unmount(); + document.body.removeChild(container); + }); + + it('skips portal rendering when virtual list is disabled', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const containerRef = createRefObject(container); + + const info = createRenderInfo({ virtual: false }); + const params: StickyHeaderParams = { + enabled: true, + group: { + key: (item) => item.group, + title: () => noop, + }, + headerRows, + groupKeyToItems: baseItemsMap, + containerRef, + listRef: createListRef({ nativeElement: container }), + prefixCls: 'rc-listy', + }; + + const { unmount } = render(); + + const stickyHeader = container.querySelector('.rc-listy-sticky-header'); + expect(stickyHeader).toBeNull(); + + unmount(); + document.body.removeChild(container); + }); + + it('syncs sticky header with scrollTop even if start index is stale', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const containerRef = createRefObject(container); + const listRef = createListRef({ getScrollInfo: () => ({ x: 0, y: 80 }) }); + + const title = jest.fn().mockImplementation((key: React.Key) => ( + {String(key)} + )); + + const info = createRenderInfo({ + start: 3, + getSize: (key: React.Key) => { + if (key === 'Group 1') { + return { top: 0, bottom: 60 }; + } + if (key === 'Group 2') { + return { top: 80, bottom: 120 }; + } + return { top: 0, bottom: 0 }; + }, + }); + + const params: StickyHeaderParams = { + enabled: true, + group: { + key: (item) => item.group, + title, + }, + headerRows, + groupKeyToItems: baseItemsMap, + containerRef, + listRef, + prefixCls: 'rc-listy', + }; + + const { unmount } = render( + , + ); + + const stickyHeader = container.querySelector('.rc-listy-sticky-header'); + expect(stickyHeader).not.toBeNull(); + expect(stickyHeader).toHaveTextContent('Group 2'); + expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); + + unmount(); + document.body.removeChild(container); + }); + + it('prefers holder scrollTop over virtual start', () => { + const holder = document.createElement('div'); + holder.className = 'rc-virtual-list-holder'; + holder.scrollTop = 80; + + const container = document.createElement('div'); + container.appendChild(holder); + document.body.appendChild(container); + + const containerRef = createRefObject(container); + const listRef = createListRef({ + nativeElement: container, + getScrollInfo: () => ({ x: 0, y: 0 }), + }); + + const title = jest.fn().mockImplementation((key: React.Key) => ( + {String(key)} + )); + + const info = createRenderInfo({ + start: 3, + getSize: (key: React.Key) => { + if (key === 'Group 1') { + return { top: 0, bottom: 60 }; + } + if (key === 'Group 2') { + return { top: 80, bottom: 120 }; + } + return { top: 0, bottom: 0 }; + }, + }); + + const params: StickyHeaderParams = { + enabled: true, + group: { + key: (item) => item.group, + title, + }, + headerRows, + groupKeyToItems: baseItemsMap, + containerRef, + listRef, + prefixCls: 'rc-listy', + }; + + const { unmount } = render( + , + ); + + const stickyHeader = container.querySelector('.rc-listy-sticky-header'); + expect(stickyHeader).not.toBeNull(); + expect(stickyHeader).toHaveTextContent('Group 2'); + + unmount(); + document.body.removeChild(container); + }); +}); diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx new file mode 100644 index 0000000..a2a86de --- /dev/null +++ b/tests/listy.behavior.test.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import { act, render } from '@testing-library/react'; +import type { ExtraRenderInfo } from 'rc-virtual-list/lib/interface'; +import Listy, { type ListyRef, type ListyProps } from '@rc-component/listy'; +import type { FlattenRowsResult } from '../src/hooks/useFlattenRows'; + +jest.mock('rc-virtual-list', () => { + const React = require('react'); + let extraInfo = { + start: 0, + end: 0, + virtual: true, + offsetX: 0, + offsetY: 0, + rtl: false, + getSize: () => ({ top: 0, bottom: 0 }), + }; + let scrollHandler = (config: any) => {}; + let lastProps = null; + + const MockVirtualList = React.forwardRef((props: any, ref: any) => { + lastProps = props; + React.useImperativeHandle(ref, () => ({ + scrollTo: (config: any) => { + scrollHandler(config); + }, + })); + + return ( +
+ {props.extraRender ? ( +
{props.extraRender(extraInfo)}
+ ) : null} + {props.data.map((row: any, index: number) => ( +
{props.children(row, index)}
+ ))} +
+ ); + }); + + (MockVirtualList as any).__setExtraInfo = ( + info: Partial, + ) => { + // @ts-ignore + extraInfo = { ...extraInfo, ...info }; + }; + (MockVirtualList as any).__setScrollHandler = ( + handler: (config: any) => void, + ) => { + scrollHandler = handler; + }; + (MockVirtualList as any).__getLastProps = () => lastProps; + + return { + __esModule: true, + default: MockVirtualList, + }; +}); + +type MockedVirtualListComponent = React.ForwardRefExoticComponent & { + __setExtraInfo(info: Partial): void; + __setScrollHandler(handler: (config: any) => void): void; + __getLastProps(): any; +}; + +const MockedVirtualList = require('rc-virtual-list') + .default as MockedVirtualListComponent; + +let mockFlattenRows: FlattenRowsResult | null = null; + +jest.mock('../src/hooks/useFlattenRows', () => { + const actual = jest.requireActual('../src/hooks/useFlattenRows'); + return { + __esModule: true, + default: (items: any[], group: any, segments: any) => + mockFlattenRows ?? actual.default(items, group, segments), + }; +}); + +describe('Listy behaviors', () => { + beforeEach(() => { + MockedVirtualList.__setExtraInfo({ + start: 0, + end: 0, + virtual: true, + }); + MockedVirtualList.__setScrollHandler(() => {}); + mockFlattenRows = null; + }); + + const renderList = ( + overrideProps: Partial> & { + ref?: React.Ref; + } = {}, + ) => { + const { ref, ...rest } = overrideProps; + const resolvedItems = Object.prototype.hasOwnProperty.call(rest, 'items') + ? rest.items + : [ + { id: 1, group: 'Group A' }, + { id: 2, group: 'Group A' }, + ]; + + return render( + ( +
{item.id}
+ )} + />, + ); + }; + + it('forwards scrollTo via ref', () => { + const scrollHandler = jest.fn(); + MockedVirtualList.__setScrollHandler(scrollHandler); + + const ref = React.createRef(); + renderList({ ref }); + + act(() => { + ref.current?.scrollTo({ key: 2 }); + }); + + expect(scrollHandler).toHaveBeenCalledWith({ key: 2 }); + }); + + it('treats missing items prop as empty array', () => { + renderList({ items: undefined }); + + const lastProps = MockedVirtualList.__getLastProps(); + expect(lastProps.data).toEqual([]); + }); + + it('applies sticky class when virtual list is disabled', () => { + const title = jest.fn((key: React.Key) => Group {String(key)}); + const { container } = renderList({ + sticky: true, + virtual: false, + group: { + key: (item) => item.group, + title, + }, + }); + + const stickyHeader = container.querySelector( + '.rc-listy-group-header-sticky', + ); + expect(stickyHeader).not.toBeNull(); + expect(stickyHeader).toHaveTextContent('Group Group A'); + expect(title).toHaveBeenCalled(); + }); + + it('scroll to group', () => { + const scrollHandler = jest.fn(); + MockedVirtualList.__setScrollHandler(scrollHandler); + + const ref = React.createRef(); + renderList({ + ref, + group: { + key: (item) => item.group, + title: () => null, + }, + }); + + act(() => { + ref.current?.scrollTo({ groupKey: 'Group A', align: 'bottom', offset: 12 }); + }); + + expect(scrollHandler).toHaveBeenCalledWith({ + key: 'Group A', + align: 'bottom', + offset: 12, + }); + }); +}); diff --git a/tests/listy.test.tsx b/tests/listy.test.tsx new file mode 100644 index 0000000..278a20e --- /dev/null +++ b/tests/listy.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import Listy from '@rc-component/listy'; +import { render, screen } from '@testing-library/react'; +import ListyEntry from '../src/index'; + +const renderListy = () => + render( +
{item.id}
} + />, + ); + +describe('Listy', () => { + it('should render', () => { + renderListy(); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('should match snapshot', () => { + const { asFragment } = renderListy(); + expect(asFragment()).toMatchSnapshot(); + }); + + it('supports functional rowKey', () => { + const items = [{ value: 'foo' }, { value: 'bar' }]; + type Item = (typeof items)[number]; + const rowKey = jest.fn((item: Item) => item.value); + + render( +
{item.value}
} + />, + ); + + expect(screen.getByText('foo')).toBeInTheDocument(); + expect(screen.getByText('bar')).toBeInTheDocument(); + expect(rowKey).toHaveBeenCalledWith(items[0]); + expect(rowKey).toHaveBeenCalledWith(items[1]); + }); + + it('renders group headers with title callback', () => { + const items = [ + { id: 1, group: 'A' }, + { id: 2, group: 'A' }, + { id: 3, group: 'B' }, + ]; + + const titleMock = jest + .fn() + .mockImplementation((key: string, groupItems) => ( +
+ Group {key} ({groupItems.length}) +
+ )); + + render( + item.group, + title: titleMock, + }} + itemRender={(item) =>
Item {item.id}
} + />, + ); + + expect(titleMock).toHaveBeenCalledTimes(2); + expect(titleMock).toHaveBeenNthCalledWith(1, 'A', [items[0], items[1]]); + expect(titleMock).toHaveBeenNthCalledWith(2, 'B', [items[2]]); + + expect(screen.getByTestId('group-A')).toHaveTextContent('Group A (2)'); + expect(screen.getByTestId('group-B')).toHaveTextContent('Group B (1)'); + }); +}); + +describe('package entry point', () => { + it('re-exports the Listy implementation', () => { + expect(ListyEntry).toBe(Listy); + }); +}); diff --git a/tests/onEndReached.test.tsx b/tests/onEndReached.test.tsx new file mode 100644 index 0000000..7440374 --- /dev/null +++ b/tests/onEndReached.test.tsx @@ -0,0 +1,570 @@ +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 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( + '.rc-virtual-list-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( + '.rc-virtual-list-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( + '.rc-virtual-list-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( + '.rc-virtual-list-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( + '.rc-virtual-list-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( + '.rc-virtual-list-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( + '.rc-virtual-list-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( + '.rc-virtual-list-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( + '.rc-virtual-list-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( + '.rc-virtual-list-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( + '.rc-virtual-list-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( + '.rc-virtual-list-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( + '.rc-virtual-list-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); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 6db6d94..464824d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,8 @@ "paths": { "@/*": ["src/*"], "@@/*": [".dumi/tmp/*"], - "@rc-component/trigger": ["src/index.tsx"] - } + "@rc-component/listy": ["src/index.ts"] + }, + "types": ["@testing-library/jest-dom", "node"] } }