From 37bd76c77ef5c63445ec360af4d302e3885017b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 18 May 2026 18:02:01 +0800 Subject: [PATCH 01/25] add raw list sticky sections --- assets/index.less | 5 + src/List.tsx | 33 +++- src/RawList.tsx | 239 +++++++++++++++++++++++++++++ src/hooks/useStickyGroupHeader.tsx | 38 +++-- tests/listy.behavior.test.tsx | 45 ++++++ 5 files changed, 344 insertions(+), 16 deletions(-) create mode 100644 src/RawList.tsx diff --git a/assets/index.less b/assets/index.less index c2ddd93..dcd058f 100644 --- a/assets/index.less +++ b/assets/index.less @@ -11,6 +11,7 @@ top: 0; left: 0; right: 0; + z-index: 1; } &-fixed { @@ -22,6 +23,10 @@ } } + &-group-section { + position: relative; + } + &-scrollbar { z-index: 1; } diff --git a/src/List.tsx b/src/List.tsx index 0f70d18..36c1b7e 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -10,6 +10,7 @@ import useFlattenRows from './hooks/useFlattenRows'; import type { Row } from './hooks/useFlattenRows'; import useStickyGroupHeader from './hooks/useStickyGroupHeader'; import GroupHeader from './GroupHeader'; +import RawList from './RawList'; import { useEvent } from '@rc-component/util'; type RowKey = keyof T | ((item: T) => React.Key); @@ -137,19 +138,34 @@ function Listy( ); // ============================== Render =============================== - return ( -
+ const sharedListProps = { + ref: listRef, + height, + onScroll, + prefixCls, + }; + + const listNode = + virtual === false ? ( + + ) : ( {(row: Row) => row.type === 'header' @@ -157,6 +173,11 @@ function Listy( : itemRender(row.item, row.index) } + ); + + return ( +
+ {listNode}
); } diff --git a/src/RawList.tsx b/src/RawList.tsx new file mode 100644 index 0000000..8410e6b --- /dev/null +++ b/src/RawList.tsx @@ -0,0 +1,239 @@ +import * as React from 'react'; +import type { ListRef, ScrollTo } from '@rc-component/virtual-list'; +import GroupHeader from './GroupHeader'; +import type { Row } from './hooks/useFlattenRows'; +import type { GroupSegmentItem, Group } from './hooks/useGroupSegments'; + +export interface RawListProps { + data: T[]; + group: Group | undefined; + groupData: Map[]>; + groupKeyToItems: Map; + getKey: (row: Row) => React.Key; + height?: number; + itemRender: (item: T, index: number) => React.ReactNode; + onScroll?: React.UIEventHandler; + prefixCls: string; + sticky?: boolean; +} + +type ScrollConfig = NonNullable[0]>; +type ScrollPositionConfig = Extract; +type ScrollKeyConfig = Extract; + +function isScrollPositionConfig( + config: ScrollConfig, +): config is ScrollPositionConfig { + return typeof config === 'object' && ('left' in config || 'top' in config); +} + +function isScrollKeyConfig(config: ScrollConfig): config is ScrollKeyConfig { + return typeof config === 'object' && 'key' in config; +} + +function getElementTop(container: HTMLElement, element: HTMLElement) { + return ( + element.getBoundingClientRect().top - + container.getBoundingClientRect().top + + container.scrollTop + ); +} + +function RawList( + props: RawListProps, + ref: React.Ref, +) { + const { + data, + group, + groupData, + groupKeyToItems, + getKey, + height, + itemRender, + onScroll, + prefixCls, + sticky, + } = props; + + const holderRef = React.useRef(null); + const keyElementMapRef = React.useRef(new Map()); + const indexElementMapRef = React.useRef(new Map()); + + const registerElement = React.useCallback( + (key: React.Key, rowIndex: number, element: HTMLElement | null) => { + if (element) { + keyElementMapRef.current.set(key, element); + indexElementMapRef.current.set(rowIndex, element); + } else { + keyElementMapRef.current.delete(key); + indexElementMapRef.current.delete(rowIndex); + } + }, + [], + ); + + const scrollToElement = React.useCallback( + ( + element: HTMLElement, + align: 'top' | 'bottom' | 'auto' = 'top', + offset = 0, + ) => { + const holder = holderRef.current; + if (!holder) { + return; + } + + const elementTop = getElementTop(holder, element); + const elementBottom = elementTop + element.offsetHeight; + const scrollBottom = holder.scrollTop + holder.clientHeight; + + if (align === 'auto') { + if (elementTop < holder.scrollTop) { + holder.scrollTop = elementTop - offset; + } else if (elementBottom > scrollBottom) { + holder.scrollTop = elementBottom - holder.clientHeight + offset; + } + return; + } + + holder.scrollTop = + align === 'bottom' + ? elementBottom - holder.clientHeight + offset + : elementTop - offset; + }, + [], + ); + + const scrollTo: ScrollTo = React.useCallback( + (config) => { + const holder = holderRef.current; + if (!holder || config == null) { + return; + } + + if (typeof config === 'number') { + holder.scrollTop = config; + return; + } + + if (isScrollPositionConfig(config)) { + if (config.left !== undefined) { + holder.scrollLeft = config.left; + } + if (config.top !== undefined) { + holder.scrollTop = config.top; + } + return; + } + + const targetElement = isScrollKeyConfig(config) + ? keyElementMapRef.current.get(config.key) + : indexElementMapRef.current.get(config.index); + + if (targetElement) { + scrollToElement(targetElement, config.align, config.offset); + } + }, + [scrollToElement], + ); + + React.useImperativeHandle( + ref, + () => ({ + nativeElement: holderRef.current, + scrollTo, + getScrollInfo: () => ({ + x: holderRef.current?.scrollLeft || 0, + y: holderRef.current?.scrollTop || 0, + }), + }), + [scrollTo], + ); + + const renderItem = React.useCallback( + (item: T, index: number, rowIndex: number) => { + const row = { type: 'item', item, index } as Row; + const key = getKey(row); + const node = itemRender(item, index); + const setRef = (element: HTMLElement | null) => { + registerElement(key, rowIndex, element); + }; + + if (React.isValidElement(node)) { + return React.cloneElement(node as React.ReactElement, { + key, + ref: setRef, + }); + } + + return ( +
+ {node} +
+ ); + }, + [getKey, itemRender, registerElement], + ); + + let rowIndex = 0; + const rawContent = group + ? Array.from(groupData).map(([groupKey, groupItems]) => { + const headerRow = { type: 'header', groupKey } as Row; + const key = getKey(headerRow); + const groupRowIndex = rowIndex; + const currentGroupItems = groupKeyToItems.get(groupKey) || []; + + rowIndex += 1; + + return ( +
registerElement(key, groupRowIndex, element)} + > + + {groupItems.map(({ item, index }) => { + const itemNode = renderItem(item, index, rowIndex); + rowIndex += 1; + return itemNode; + })} +
+ ); + }) + : data.map((item, index) => { + const itemNode = renderItem(item, index, rowIndex); + rowIndex += 1; + return itemNode; + }); + + return ( +
+
{rawContent}
+
+ ); +} + +const RawListWithRef = React.forwardRef(RawList) as < + T, + K extends React.Key = React.Key, +>( + props: RawListProps & { ref?: React.Ref }, +) => React.ReactElement; + +export default RawListWithRef; diff --git a/src/hooks/useStickyGroupHeader.tsx b/src/hooks/useStickyGroupHeader.tsx index d225983..254dcfd 100644 --- a/src/hooks/useStickyGroupHeader.tsx +++ b/src/hooks/useStickyGroupHeader.tsx @@ -7,10 +7,35 @@ type ExtraRenderInfo = Parameters< NonNullable['extraRender']> >[0]; +type HeaderRow = { groupKey: K; rowIndex: number }; + +// `headerRows` is sorted by rowIndex. Find the last header not after `start`. +function findActiveHeaderIndex( + headerRows: HeaderRow[], + start: number, +) { + let left = 0; + let right = headerRows.length - 1; + let activeIndex = 0; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + + if (headerRows[mid].rowIndex <= start) { + activeIndex = mid; + left = mid + 1; + } else { + right = mid - 1; + } + } + + return activeIndex; +} + export interface StickyHeaderParams { enabled: boolean; group: Group | undefined; - headerRows: { groupKey: K; rowIndex: number }[]; + headerRows: HeaderRow[]; groupKeyToItems: Map; prefixCls: string; } @@ -35,15 +60,8 @@ export default function useStickyGroupHeader< return null; } - let activeHeaderIdx = 0; - let currHeader = headerRows[0]; - for (let i = headerRows.length - 1; i >= 0; i -= 1) { - if (headerRows[i].rowIndex <= start) { - activeHeaderIdx = i; - currHeader = headerRows[i]; - break; - } - } + const activeHeaderIdx = findActiveHeaderIndex(headerRows, start); + const currHeader = headerRows[activeHeaderIdx]; const groupItems = groupKeyToItems.get(currHeader.groupKey) || []; const currentSize = getSize(currHeader.groupKey); diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index 9c9b8ad..eebfdb3 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -143,12 +143,57 @@ describe('Listy behaviors', () => { const stickyHeader = container.querySelector( '.rc-listy-group-header-sticky', ); + const groupSections = container.querySelectorAll('.rc-listy-group-section'); + + expect(container.querySelector('[data-testid="mock-virtual-list"]')).toBeNull(); expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveClass('rc-listy-group-header'); expect(stickyHeader).toHaveTextContent('Group Group A'); + expect(groupSections).toHaveLength(1); + expect(groupSections[0]).toContainElement(stickyHeader as HTMLElement); expect(title).toHaveBeenCalled(); }); + it('scrolls raw list group sections by group key', () => { + const ref = React.createRef(); + const { container } = renderList({ + ref, + virtual: false, + items: [ + { id: 1, group: 'Group A' }, + { id: 2, group: 'Group A' }, + { id: 3, group: 'Group B' }, + ], + group: { + key: (item) => item.group, + title: (groupKey) => Group {String(groupKey)}, + }, + }); + + const holder = container.querySelector('.rc-listy-holder') as HTMLDivElement; + const groupSections = container.querySelectorAll('.rc-listy-group-section'); + const groupBSection = groupSections[1] as HTMLElement; + + Object.defineProperty(holder, 'clientHeight', { + configurable: true, + value: 100, + }); + holder.getBoundingClientRect = () => + ({ top: 10, bottom: 110 } as DOMRect); + groupBSection.getBoundingClientRect = () => + ({ top: 210, bottom: 270 } as DOMRect); + + act(() => { + ref.current?.scrollTo({ + groupKey: 'Group B', + align: 'top', + offset: 5, + }); + }); + + expect(holder.scrollTop).toBe(195); + }); + it('scroll to group', () => { const scrollHandler = jest.fn(); MockedVirtualList.__setScrollHandler(scrollHandler); From 751d986a576c26ca75a8298b82c4f5e91c0885ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 18 May 2026 23:34:03 +0800 Subject: [PATCH 02/25] improve raw list coverage --- tests/listy.behavior.test.tsx | 68 +++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index eebfdb3..bb57469 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -92,6 +92,9 @@ describe('Listy behaviors', () => { { id: 1, group: 'Group A' }, { id: 2, group: 'Group A' }, ]; + const resolvedItemRender = + rest.itemRender || + ((item) =>
{item.id}
); return render( { rowKey="id" itemHeight={20} height={100} - itemRender={(item) => ( -
{item.id}
- )} + itemRender={resolvedItemRender} />, ); }; @@ -194,6 +195,67 @@ describe('Listy behaviors', () => { expect(holder.scrollTop).toBe(195); }); + it('supports raw list scroll APIs without grouping', () => { + const ref = React.createRef(); + const { container } = renderList({ + ref, + virtual: false, + items: [ + { id: 1, name: 'One' }, + { id: 2, name: 'Two' }, + ], + itemRender: (item) => item.name, + }); + + const holder = container.querySelector('.rc-listy-holder') as HTMLDivElement; + const itemNodes = container.querySelectorAll( + '.rc-listy-holder-inner > div', + ); + const secondItem = itemNodes[1] as HTMLElement; + + Object.defineProperty(holder, 'clientHeight', { + configurable: true, + value: 50, + }); + Object.defineProperty(secondItem, 'offsetHeight', { + configurable: true, + value: 30, + }); + holder.getBoundingClientRect = () => ({ top: 10 } as DOMRect); + secondItem.getBoundingClientRect = () => ({ top: 100 } as DOMRect); + + act(() => { + ref.current?.scrollTo(); + ref.current?.scrollTo(24); + }); + expect(holder.scrollTop).toBe(24); + + act(() => { + ref.current?.scrollTo({ left: 7, top: 12 }); + }); + expect(holder.scrollLeft).toBe(7); + expect(holder.scrollTop).toBe(12); + + act(() => { + ref.current?.scrollTo({ index: 1, align: 'bottom', offset: 3 }); + }); + expect(holder.scrollTop).toBe(85); + + holder.scrollTop = 50; + secondItem.getBoundingClientRect = () => ({ top: 0 } as DOMRect); + act(() => { + ref.current?.scrollTo({ index: 1, align: 'auto', offset: 4 }); + }); + expect(holder.scrollTop).toBe(36); + + holder.scrollTop = 10; + secondItem.getBoundingClientRect = () => ({ top: 100 } as DOMRect); + act(() => { + ref.current?.scrollTo({ index: 1, align: 'auto', offset: 4 }); + }); + expect(holder.scrollTop).toBe(84); + }); + it('scroll to group', () => { const scrollHandler = jest.fn(); MockedVirtualList.__setScrollHandler(scrollHandler); From c1ce64276ebd439874869db30fccd7fd1d79aea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 18 May 2026 23:37:24 +0800 Subject: [PATCH 03/25] cover raw list scroll info --- src/RawList.tsx | 5 +---- tests/listy.behavior.test.tsx | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/RawList.tsx b/src/RawList.tsx index 8410e6b..8b6ffdd 100644 --- a/src/RawList.tsx +++ b/src/RawList.tsx @@ -79,10 +79,7 @@ function RawList( align: 'top' | 'bottom' | 'auto' = 'top', offset = 0, ) => { - const holder = holderRef.current; - if (!holder) { - return; - } + const holder = holderRef.current as HTMLDivElement; const elementTop = getElementTop(holder, element); const elementBottom = elementTop + element.offsetHeight; diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index bb57469..fff21b1 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -1,7 +1,11 @@ import React from 'react'; import { act, render } from '@testing-library/react'; -import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; +import type { + ListProps as VirtualListProps, + ListRef as VirtualListRef, +} from '@rc-component/virtual-list'; import Listy, { type ListyRef, type ListyProps } from '@rc-component/listy'; +import RawList from '../src/RawList'; type ExtraRenderInfo = Parameters< NonNullable['extraRender']> @@ -256,6 +260,29 @@ describe('Listy behaviors', () => { expect(holder.scrollTop).toBe(84); }); + it('exposes raw list scroll info', () => { + const ref = React.createRef(); + const { container } = render( + (row.type === 'item' ? row.item.id : row.groupKey)} + itemRender={(item) =>
{item.id}
} + prefixCls="rc-listy" + />, + ); + + const holder = container.querySelector('.rc-listy-holder') as HTMLDivElement; + holder.scrollLeft = 11; + holder.scrollTop = 22; + + expect(ref.current?.nativeElement).toBe(holder); + expect(ref.current?.getScrollInfo()).toEqual({ x: 11, y: 22 }); + }); + it('scroll to group', () => { const scrollHandler = jest.fn(); MockedVirtualList.__setScrollHandler(scrollHandler); From c69d6ccfce45b1d7408e1c82f7d6153917152ffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 18 May 2026 23:44:09 +0800 Subject: [PATCH 04/25] refactor raw list scroll hook --- src/RawList/index.tsx | 125 ++++++++++++++++++ .../useRawListScroll.ts} | 123 +---------------- tests/listy.behavior.test.tsx | 65 ++++++++- 3 files changed, 194 insertions(+), 119 deletions(-) create mode 100644 src/RawList/index.tsx rename src/{RawList.tsx => RawList/useRawListScroll.ts} (51%) diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx new file mode 100644 index 0000000..da3a4fd --- /dev/null +++ b/src/RawList/index.tsx @@ -0,0 +1,125 @@ +import * as React from 'react'; +import type { ListRef } from '@rc-component/virtual-list'; +import GroupHeader from '../GroupHeader'; +import type { Row } from '../hooks/useFlattenRows'; +import type { GroupSegmentItem, Group } from '../hooks/useGroupSegments'; +import useRawListScroll from './useRawListScroll'; + +export interface RawListProps { + data: T[]; + group: Group | undefined; + groupData: Map[]>; + groupKeyToItems: Map; + getKey: (row: Row) => React.Key; + height?: number; + itemRender: (item: T, index: number) => React.ReactNode; + onScroll?: React.UIEventHandler; + prefixCls: string; + sticky?: boolean; +} + +function RawList( + props: RawListProps, + ref: React.Ref, +) { + const { + data, + group, + groupData, + groupKeyToItems, + getKey, + height, + itemRender, + onScroll, + prefixCls, + sticky, + } = props; + + const { holderRef, registerElement } = useRawListScroll(ref); + + const renderItem = React.useCallback( + (item: T, index: number, rowIndex: number) => { + const row = { type: 'item', item, index } as Row; + const key = getKey(row); + const node = itemRender(item, index); + const setRef = (element: HTMLElement | null) => { + registerElement(key, rowIndex, element); + }; + + if (React.isValidElement(node)) { + return React.cloneElement(node as React.ReactElement, { + key, + ref: setRef, + }); + } + + return ( +
+ {node} +
+ ); + }, + [getKey, itemRender, registerElement], + ); + + let rowIndex = 0; + const rawContent = group + ? Array.from(groupData).map(([groupKey, groupItems]) => { + const headerRow = { type: 'header', groupKey } as Row; + const key = getKey(headerRow); + const groupRowIndex = rowIndex; + const currentGroupItems = groupKeyToItems.get(groupKey) || []; + + rowIndex += 1; + + return ( +
registerElement(key, groupRowIndex, element)} + > + + {groupItems.map(({ item, index }) => { + const itemNode = renderItem(item, index, rowIndex); + rowIndex += 1; + return itemNode; + })} +
+ ); + }) + : data.map((item, index) => { + const itemNode = renderItem(item, index, rowIndex); + rowIndex += 1; + return itemNode; + }); + + return ( +
+
{rawContent}
+
+ ); +} + +const RawListWithRef = React.forwardRef(RawList) as < + T, + K extends React.Key = React.Key, +>( + props: RawListProps & { ref?: React.Ref }, +) => React.ReactElement; + +export default RawListWithRef; diff --git a/src/RawList.tsx b/src/RawList/useRawListScroll.ts similarity index 51% rename from src/RawList.tsx rename to src/RawList/useRawListScroll.ts index 8b6ffdd..816cfaa 100644 --- a/src/RawList.tsx +++ b/src/RawList/useRawListScroll.ts @@ -1,21 +1,5 @@ import * as React from 'react'; import type { ListRef, ScrollTo } from '@rc-component/virtual-list'; -import GroupHeader from './GroupHeader'; -import type { Row } from './hooks/useFlattenRows'; -import type { GroupSegmentItem, Group } from './hooks/useGroupSegments'; - -export interface RawListProps { - data: T[]; - group: Group | undefined; - groupData: Map[]>; - groupKeyToItems: Map; - getKey: (row: Row) => React.Key; - height?: number; - itemRender: (item: T, index: number) => React.ReactNode; - onScroll?: React.UIEventHandler; - prefixCls: string; - sticky?: boolean; -} type ScrollConfig = NonNullable[0]>; type ScrollPositionConfig = Extract; @@ -39,23 +23,7 @@ function getElementTop(container: HTMLElement, element: HTMLElement) { ); } -function RawList( - props: RawListProps, - ref: React.Ref, -) { - const { - data, - group, - groupData, - groupKeyToItems, - getKey, - height, - itemRender, - onScroll, - prefixCls, - sticky, - } = props; - +export default function useRawListScroll(ref: React.Ref) { const holderRef = React.useRef(null); const keyElementMapRef = React.useRef(new Map()); const indexElementMapRef = React.useRef(new Map()); @@ -148,89 +116,8 @@ function RawList( [scrollTo], ); - const renderItem = React.useCallback( - (item: T, index: number, rowIndex: number) => { - const row = { type: 'item', item, index } as Row; - const key = getKey(row); - const node = itemRender(item, index); - const setRef = (element: HTMLElement | null) => { - registerElement(key, rowIndex, element); - }; - - if (React.isValidElement(node)) { - return React.cloneElement(node as React.ReactElement, { - key, - ref: setRef, - }); - } - - return ( -
- {node} -
- ); - }, - [getKey, itemRender, registerElement], - ); - - let rowIndex = 0; - const rawContent = group - ? Array.from(groupData).map(([groupKey, groupItems]) => { - const headerRow = { type: 'header', groupKey } as Row; - const key = getKey(headerRow); - const groupRowIndex = rowIndex; - const currentGroupItems = groupKeyToItems.get(groupKey) || []; - - rowIndex += 1; - - return ( -
registerElement(key, groupRowIndex, element)} - > - - {groupItems.map(({ item, index }) => { - const itemNode = renderItem(item, index, rowIndex); - rowIndex += 1; - return itemNode; - })} -
- ); - }) - : data.map((item, index) => { - const itemNode = renderItem(item, index, rowIndex); - rowIndex += 1; - return itemNode; - }); - - return ( -
-
{rawContent}
-
- ); + return { + holderRef, + registerElement, + }; } - -const RawListWithRef = React.forwardRef(RawList) as < - T, - K extends React.Key = React.Key, ->( - props: RawListProps & { ref?: React.Ref }, -) => React.ReactElement; - -export default RawListWithRef; diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index fff21b1..dd4bf23 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -240,6 +240,30 @@ describe('Listy behaviors', () => { expect(holder.scrollLeft).toBe(7); expect(holder.scrollTop).toBe(12); + act(() => { + ref.current?.scrollTo({ left: 8 }); + }); + expect(holder.scrollLeft).toBe(8); + expect(holder.scrollTop).toBe(12); + + act(() => { + ref.current?.scrollTo({ top: 13 }); + }); + expect(holder.scrollLeft).toBe(8); + expect(holder.scrollTop).toBe(13); + + holder.scrollTop = 0; + act(() => { + ref.current?.scrollTo({ key: 2 }); + }); + expect(holder.scrollTop).toBe(90); + + act(() => { + ref.current?.scrollTo({ index: 99 }); + }); + expect(holder.scrollTop).toBe(90); + + holder.scrollTop = 12; act(() => { ref.current?.scrollTo({ index: 1, align: 'bottom', offset: 3 }); }); @@ -258,11 +282,18 @@ describe('Listy behaviors', () => { ref.current?.scrollTo({ index: 1, align: 'auto', offset: 4 }); }); expect(holder.scrollTop).toBe(84); + + holder.scrollTop = 20; + secondItem.getBoundingClientRect = () => ({ top: 10 } as DOMRect); + act(() => { + ref.current?.scrollTo({ index: 1, align: 'auto', offset: 4 }); + }); + expect(holder.scrollTop).toBe(20); }); it('exposes raw list scroll info', () => { const ref = React.createRef(); - const { container } = render( + const { container, unmount } = render( { expect(ref.current?.nativeElement).toBe(holder); expect(ref.current?.getScrollInfo()).toEqual({ x: 11, y: 22 }); + + const rawListRef = ref.current; + unmount(); + expect(rawListRef?.getScrollInfo()).toEqual({ x: 0, y: 0 }); + }); + + it('passes empty group items when raw group item map is missing', () => { + const title = jest.fn(() => null); + + render( + item.group, + title, + }} + groupData={ + new Map([ + [ + 'Group A', + [{ item: { id: 1, group: 'Group A' }, index: 0 }], + ], + ]) + } + groupKeyToItems={new Map()} + getKey={(row) => (row.type === 'item' ? row.item.id : row.groupKey)} + itemRender={(item) =>
{item.id}
} + prefixCls="rc-listy" + />, + ); + + expect(title).toHaveBeenCalledWith('Group A', []); }); it('scroll to group', () => { From 3026e77f3fd421e240fe085e3d787be2bed3317c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 18 May 2026 23:53:01 +0800 Subject: [PATCH 05/25] fix raw list ref typing --- src/RawList/useRawListScroll.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts index 816cfaa..a84e850 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -106,7 +106,7 @@ export default function useRawListScroll(ref: React.Ref) { React.useImperativeHandle( ref, () => ({ - nativeElement: holderRef.current, + nativeElement: holderRef.current as HTMLDivElement, scrollTo, getScrollInfo: () => ({ x: holderRef.current?.scrollLeft || 0, From 7b348723ba5a61e84a5710fec9e90e7f7da134f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 19 May 2026 16:42:35 +0800 Subject: [PATCH 06/25] simplify raw list scroll target lookup --- src/RawList/index.tsx | 22 +++++++++++++-------- src/RawList/useRawListScroll.ts | 35 +++++++++++++-------------------- tests/listy.behavior.test.tsx | 2 ++ 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index da3a4fd..b90d231 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -35,31 +35,37 @@ function RawList( sticky, } = props; - const { holderRef, registerElement } = useRawListScroll(ref); + const holderRef = useRawListScroll(ref); + + const getScrollTargetProps = React.useCallback( + (key: React.Key, rowIndex: number) => ({ + 'data-key': String(key), + 'data-index': rowIndex, + }), + [], + ); const renderItem = React.useCallback( (item: T, index: number, rowIndex: number) => { const row = { type: 'item', item, index } as Row; const key = getKey(row); const node = itemRender(item, index); - const setRef = (element: HTMLElement | null) => { - registerElement(key, rowIndex, element); - }; + const scrollTargetProps = getScrollTargetProps(key, rowIndex); if (React.isValidElement(node)) { return React.cloneElement(node as React.ReactElement, { key, - ref: setRef, + ...scrollTargetProps, }); } return ( -
+
{node}
); }, - [getKey, itemRender, registerElement], + [getKey, getScrollTargetProps, itemRender], ); let rowIndex = 0; @@ -76,7 +82,7 @@ function RawList(
registerElement(key, groupRowIndex, element)} + {...getScrollTargetProps(key, groupRowIndex)} > [0]>; type ScrollPositionConfig = Extract; type ScrollKeyConfig = Extract; +type ScrollIndexConfig = Extract; function isScrollPositionConfig( config: ScrollConfig, @@ -23,23 +24,18 @@ function getElementTop(container: HTMLElement, element: HTMLElement) { ); } +function findDataElement( + container: HTMLElement, + dataName: 'key' | 'index', + value: React.Key, +) { + return Array.from( + container.querySelectorAll(`[data-${dataName}]`), + ).find(element => element.dataset[dataName] === String(value)); +} + export default function useRawListScroll(ref: React.Ref) { const holderRef = React.useRef(null); - const keyElementMapRef = React.useRef(new Map()); - const indexElementMapRef = React.useRef(new Map()); - - const registerElement = React.useCallback( - (key: React.Key, rowIndex: number, element: HTMLElement | null) => { - if (element) { - keyElementMapRef.current.set(key, element); - indexElementMapRef.current.set(rowIndex, element); - } else { - keyElementMapRef.current.delete(key); - indexElementMapRef.current.delete(rowIndex); - } - }, - [], - ); const scrollToElement = React.useCallback( ( @@ -93,8 +89,8 @@ export default function useRawListScroll(ref: React.Ref) { } const targetElement = isScrollKeyConfig(config) - ? keyElementMapRef.current.get(config.key) - : indexElementMapRef.current.get(config.index); + ? findDataElement(holder, 'key', config.key) + : findDataElement(holder, 'index', (config as ScrollIndexConfig).index); if (targetElement) { scrollToElement(targetElement, config.align, config.offset); @@ -116,8 +112,5 @@ export default function useRawListScroll(ref: React.Ref) { [scrollTo], ); - return { - holderRef, - registerElement, - }; + return holderRef; } diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index dd4bf23..8dd9d95 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -156,6 +156,8 @@ describe('Listy behaviors', () => { expect(stickyHeader).toHaveTextContent('Group Group A'); expect(groupSections).toHaveLength(1); expect(groupSections[0]).toContainElement(stickyHeader as HTMLElement); + expect(groupSections[0]).toHaveAttribute('data-key', 'Group A'); + expect(groupSections[0]).toHaveAttribute('data-index', '0'); expect(title).toHaveBeenCalled(); }); From 9207b3b69f19a465437fd70c0818a675a7134020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 19 May 2026 17:05:48 +0800 Subject: [PATCH 07/25] simplify raw list scroll config --- src/RawList/index.tsx | 21 ++++++------------- src/RawList/useRawListScroll.ts | 37 +++++++++++++-------------------- tests/listy.behavior.test.tsx | 18 +++------------- 3 files changed, 23 insertions(+), 53 deletions(-) diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index b90d231..861fac1 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -38,19 +38,18 @@ function RawList( const holderRef = useRawListScroll(ref); const getScrollTargetProps = React.useCallback( - (key: React.Key, rowIndex: number) => ({ + (key: React.Key) => ({ 'data-key': String(key), - 'data-index': rowIndex, }), [], ); const renderItem = React.useCallback( - (item: T, index: number, rowIndex: number) => { + (item: T, index: number) => { const row = { type: 'item', item, index } as Row; const key = getKey(row); const node = itemRender(item, index); - const scrollTargetProps = getScrollTargetProps(key, rowIndex); + const scrollTargetProps = getScrollTargetProps(key); if (React.isValidElement(node)) { return React.cloneElement(node as React.ReactElement, { @@ -68,21 +67,17 @@ function RawList( [getKey, getScrollTargetProps, itemRender], ); - let rowIndex = 0; const rawContent = group ? Array.from(groupData).map(([groupKey, groupItems]) => { const headerRow = { type: 'header', groupKey } as Row; const key = getKey(headerRow); - const groupRowIndex = rowIndex; const currentGroupItems = groupKeyToItems.get(groupKey) || []; - rowIndex += 1; - return (
( sticky={sticky} /> {groupItems.map(({ item, index }) => { - const itemNode = renderItem(item, index, rowIndex); - rowIndex += 1; - return itemNode; + return renderItem(item, index); })}
); }) : data.map((item, index) => { - const itemNode = renderItem(item, index, rowIndex); - rowIndex += 1; - return itemNode; + return renderItem(item, index); }); return ( diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts index 2c65688..202c873 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -4,13 +4,6 @@ import type { ListRef, ScrollTo } from '@rc-component/virtual-list'; type ScrollConfig = NonNullable[0]>; type ScrollPositionConfig = Extract; type ScrollKeyConfig = Extract; -type ScrollIndexConfig = Extract; - -function isScrollPositionConfig( - config: ScrollConfig, -): config is ScrollPositionConfig { - return typeof config === 'object' && ('left' in config || 'top' in config); -} function isScrollKeyConfig(config: ScrollConfig): config is ScrollKeyConfig { return typeof config === 'object' && 'key' in config; @@ -26,12 +19,11 @@ function getElementTop(container: HTMLElement, element: HTMLElement) { function findDataElement( container: HTMLElement, - dataName: 'key' | 'index', value: React.Key, ) { - return Array.from( - container.querySelectorAll(`[data-${dataName}]`), - ).find(element => element.dataset[dataName] === String(value)); + return Array.from(container.querySelectorAll('[data-key]')).find( + element => element.dataset.key === String(value), + ); } export default function useRawListScroll(ref: React.Ref) { @@ -78,22 +70,21 @@ export default function useRawListScroll(ref: React.Ref) { return; } - if (isScrollPositionConfig(config)) { - if (config.left !== undefined) { - holder.scrollLeft = config.left; - } - if (config.top !== undefined) { - holder.scrollTop = config.top; + if (isScrollKeyConfig(config)) { + const targetElement = findDataElement(holder, config.key); + + if (targetElement) { + scrollToElement(targetElement, config.align, config.offset); } return; } - const targetElement = isScrollKeyConfig(config) - ? findDataElement(holder, 'key', config.key) - : findDataElement(holder, 'index', (config as ScrollIndexConfig).index); - - if (targetElement) { - scrollToElement(targetElement, config.align, config.offset); + const { left, top } = config as ScrollPositionConfig; + if (left !== undefined) { + holder.scrollLeft = left; + } + if (top !== undefined) { + holder.scrollTop = top; } }, [scrollToElement], diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index 8dd9d95..ef9c823 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -157,7 +157,6 @@ describe('Listy behaviors', () => { expect(groupSections).toHaveLength(1); expect(groupSections[0]).toContainElement(stickyHeader as HTMLElement); expect(groupSections[0]).toHaveAttribute('data-key', 'Group A'); - expect(groupSections[0]).toHaveAttribute('data-index', '0'); expect(title).toHaveBeenCalled(); }); @@ -260,35 +259,24 @@ describe('Listy behaviors', () => { }); expect(holder.scrollTop).toBe(90); - act(() => { - ref.current?.scrollTo({ index: 99 }); - }); - expect(holder.scrollTop).toBe(90); - - holder.scrollTop = 12; - act(() => { - ref.current?.scrollTo({ index: 1, align: 'bottom', offset: 3 }); - }); - expect(holder.scrollTop).toBe(85); - holder.scrollTop = 50; secondItem.getBoundingClientRect = () => ({ top: 0 } as DOMRect); act(() => { - ref.current?.scrollTo({ index: 1, align: 'auto', offset: 4 }); + ref.current?.scrollTo({ key: 2, align: 'auto', offset: 4 }); }); expect(holder.scrollTop).toBe(36); holder.scrollTop = 10; secondItem.getBoundingClientRect = () => ({ top: 100 } as DOMRect); act(() => { - ref.current?.scrollTo({ index: 1, align: 'auto', offset: 4 }); + ref.current?.scrollTo({ key: 2, align: 'auto', offset: 4 }); }); expect(holder.scrollTop).toBe(84); holder.scrollTop = 20; secondItem.getBoundingClientRect = () => ({ top: 10 } as DOMRect); act(() => { - ref.current?.scrollTo({ index: 1, align: 'auto', offset: 4 }); + ref.current?.scrollTo({ key: 2, align: 'auto', offset: 4 }); }); expect(holder.scrollTop).toBe(20); }); From 49ab398bafe470dd600187572a4031aed5b0a0e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 19 May 2026 17:19:35 +0800 Subject: [PATCH 08/25] use native scroll into view --- src/RawList/useRawListScroll.ts | 56 ++++++++----------------------- tests/listy.behavior.test.tsx | 58 ++++++++++++++------------------- 2 files changed, 37 insertions(+), 77 deletions(-) diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts index 202c873..34a6507 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -9,55 +9,25 @@ function isScrollKeyConfig(config: ScrollConfig): config is ScrollKeyConfig { return typeof config === 'object' && 'key' in config; } -function getElementTop(container: HTMLElement, element: HTMLElement) { - return ( - element.getBoundingClientRect().top - - container.getBoundingClientRect().top + - container.scrollTop - ); -} - -function findDataElement( - container: HTMLElement, - value: React.Key, -) { +function findDataElement(container: HTMLElement, value: React.Key) { return Array.from(container.querySelectorAll('[data-key]')).find( element => element.dataset.key === String(value), ); } +function getScrollIntoViewOptions( + align: ScrollKeyConfig['align'] = 'top', +): ScrollIntoViewOptions { + return { + block: + align === 'bottom' ? 'end' : align === 'auto' ? 'nearest' : 'start', + inline: 'nearest', + }; +} + export default function useRawListScroll(ref: React.Ref) { const holderRef = React.useRef(null); - const scrollToElement = React.useCallback( - ( - element: HTMLElement, - align: 'top' | 'bottom' | 'auto' = 'top', - offset = 0, - ) => { - const holder = holderRef.current as HTMLDivElement; - - const elementTop = getElementTop(holder, element); - const elementBottom = elementTop + element.offsetHeight; - const scrollBottom = holder.scrollTop + holder.clientHeight; - - if (align === 'auto') { - if (elementTop < holder.scrollTop) { - holder.scrollTop = elementTop - offset; - } else if (elementBottom > scrollBottom) { - holder.scrollTop = elementBottom - holder.clientHeight + offset; - } - return; - } - - holder.scrollTop = - align === 'bottom' - ? elementBottom - holder.clientHeight + offset - : elementTop - offset; - }, - [], - ); - const scrollTo: ScrollTo = React.useCallback( (config) => { const holder = holderRef.current; @@ -74,7 +44,7 @@ export default function useRawListScroll(ref: React.Ref) { const targetElement = findDataElement(holder, config.key); if (targetElement) { - scrollToElement(targetElement, config.align, config.offset); + targetElement.scrollIntoView(getScrollIntoViewOptions(config.align)); } return; } @@ -87,7 +57,7 @@ export default function useRawListScroll(ref: React.Ref) { holder.scrollTop = top; } }, - [scrollToElement], + [], ); React.useImperativeHandle( diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index ef9c823..a56a2f9 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -176,18 +176,10 @@ describe('Listy behaviors', () => { }, }); - const holder = container.querySelector('.rc-listy-holder') as HTMLDivElement; const groupSections = container.querySelectorAll('.rc-listy-group-section'); const groupBSection = groupSections[1] as HTMLElement; - - Object.defineProperty(holder, 'clientHeight', { - configurable: true, - value: 100, - }); - holder.getBoundingClientRect = () => - ({ top: 10, bottom: 110 } as DOMRect); - groupBSection.getBoundingClientRect = () => - ({ top: 210, bottom: 270 } as DOMRect); + const scrollIntoView = jest.fn(); + groupBSection.scrollIntoView = scrollIntoView; act(() => { ref.current?.scrollTo({ @@ -197,7 +189,10 @@ describe('Listy behaviors', () => { }); }); - expect(holder.scrollTop).toBe(195); + expect(scrollIntoView).toHaveBeenCalledWith({ + block: 'start', + inline: 'nearest', + }); }); it('supports raw list scroll APIs without grouping', () => { @@ -217,17 +212,8 @@ describe('Listy behaviors', () => { '.rc-listy-holder-inner > div', ); const secondItem = itemNodes[1] as HTMLElement; - - Object.defineProperty(holder, 'clientHeight', { - configurable: true, - value: 50, - }); - Object.defineProperty(secondItem, 'offsetHeight', { - configurable: true, - value: 30, - }); - holder.getBoundingClientRect = () => ({ top: 10 } as DOMRect); - secondItem.getBoundingClientRect = () => ({ top: 100 } as DOMRect); + const scrollIntoView = jest.fn(); + secondItem.scrollIntoView = scrollIntoView; act(() => { ref.current?.scrollTo(); @@ -257,28 +243,32 @@ describe('Listy behaviors', () => { act(() => { ref.current?.scrollTo({ key: 2 }); }); - expect(holder.scrollTop).toBe(90); + expect(scrollIntoView).toHaveBeenLastCalledWith({ + block: 'start', + inline: 'nearest', + }); - holder.scrollTop = 50; - secondItem.getBoundingClientRect = () => ({ top: 0 } as DOMRect); act(() => { - ref.current?.scrollTo({ key: 2, align: 'auto', offset: 4 }); + ref.current?.scrollTo({ key: 2, align: 'bottom', offset: 4 }); + }); + expect(scrollIntoView).toHaveBeenLastCalledWith({ + block: 'end', + inline: 'nearest', }); - expect(holder.scrollTop).toBe(36); - holder.scrollTop = 10; - secondItem.getBoundingClientRect = () => ({ top: 100 } as DOMRect); act(() => { ref.current?.scrollTo({ key: 2, align: 'auto', offset: 4 }); }); - expect(holder.scrollTop).toBe(84); + expect(scrollIntoView).toHaveBeenLastCalledWith({ + block: 'nearest', + inline: 'nearest', + }); - holder.scrollTop = 20; - secondItem.getBoundingClientRect = () => ({ top: 10 } as DOMRect); + const scrollCount = scrollIntoView.mock.calls.length; act(() => { - ref.current?.scrollTo({ key: 2, align: 'auto', offset: 4 }); + ref.current?.scrollTo({ key: 99 }); }); - expect(holder.scrollTop).toBe(20); + expect(scrollIntoView).toHaveBeenCalledTimes(scrollCount); }); it('exposes raw list scroll info', () => { From f1f8cb40aa82ddbb7b36e171149b016cc552796d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 19 May 2026 17:22:43 +0800 Subject: [PATCH 09/25] query raw list key directly --- src/RawList/useRawListScroll.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts index 34a6507..6ab9e9c 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -10,8 +10,8 @@ function isScrollKeyConfig(config: ScrollConfig): config is ScrollKeyConfig { } function findDataElement(container: HTMLElement, value: React.Key) { - return Array.from(container.querySelectorAll('[data-key]')).find( - element => element.dataset.key === String(value), + return container.querySelector( + `[data-key="${CSS.escape(String(value))}"]`, ); } From adb2649a3d4e382ec39ca5ac55f9dae92a508a83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 19 May 2026 17:25:33 +0800 Subject: [PATCH 10/25] inline raw list scroll lookup --- src/RawList/useRawListScroll.ts | 38 ++++++++++++--------------------- 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts index 6ab9e9c..2aa91cc 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -3,27 +3,6 @@ import type { ListRef, ScrollTo } from '@rc-component/virtual-list'; type ScrollConfig = NonNullable[0]>; type ScrollPositionConfig = Extract; -type ScrollKeyConfig = Extract; - -function isScrollKeyConfig(config: ScrollConfig): config is ScrollKeyConfig { - return typeof config === 'object' && 'key' in config; -} - -function findDataElement(container: HTMLElement, value: React.Key) { - return container.querySelector( - `[data-key="${CSS.escape(String(value))}"]`, - ); -} - -function getScrollIntoViewOptions( - align: ScrollKeyConfig['align'] = 'top', -): ScrollIntoViewOptions { - return { - block: - align === 'bottom' ? 'end' : align === 'auto' ? 'nearest' : 'start', - inline: 'nearest', - }; -} export default function useRawListScroll(ref: React.Ref) { const holderRef = React.useRef(null); @@ -40,11 +19,22 @@ export default function useRawListScroll(ref: React.Ref) { return; } - if (isScrollKeyConfig(config)) { - const targetElement = findDataElement(holder, config.key); + if ('key' in config) { + const targetElement = holder.querySelector( + `[data-key="${CSS.escape(String(config.key))}"]`, + ); if (targetElement) { - targetElement.scrollIntoView(getScrollIntoViewOptions(config.align)); + const { align = 'top' } = config; + targetElement.scrollIntoView({ + block: + align === 'bottom' + ? 'end' + : align === 'auto' + ? 'nearest' + : 'start', + inline: 'nearest', + }); } return; } From 7e5e65b962c7288bfc9915422b309b2787e4623a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 19 May 2026 17:36:58 +0800 Subject: [PATCH 11/25] hide raw scroll info from ref --- README.md | 2 +- src/List.tsx | 28 +++++++++++++++++++++------- src/RawList/index.tsx | 6 +++--- src/RawList/useRawListScroll.ts | 16 ++++------------ tests/listy.behavior.test.tsx | 18 ++++-------------- 5 files changed, 33 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 4d51b44..e44bd05 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ npm start ### ListyRef -- `scrollTo(config: number | { key?: React.Key; index?: number; align?: 'top' | 'bottom' | 'auto'; offset?: number; } | { groupKey: React.Key; align?: 'top' | 'bottom' | 'auto'; offset?: number; })` +- `scrollTo(config?: number | null | { left?: number; top?: number } | { key: React.Key; align?: 'top' | 'bottom' | 'auto'; offset?: number } | { groupKey: React.Key; align?: 'top' | 'bottom' | 'auto'; offset?: number })` - 传入 `groupKey` 时会直接滚动到对应组头(需启用 `group`) ## Test Case diff --git a/src/List.tsx b/src/List.tsx index 36c1b7e..9d2e5d8 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import VirtualList, { - type ListRef, - type ScrollTo, + type ListRef as VirtualListRef, } from '@rc-component/virtual-list'; import { useImperativeHandle, forwardRef } from 'react'; import useGroupSegments from './hooks/useGroupSegments'; @@ -18,13 +17,27 @@ type RowKey = keyof T | ((item: T) => React.Key); export type ScrollAlign = 'top' | 'bottom' | 'auto'; export interface GroupScrollToConfig { - groupKey: string; + groupKey: React.Key; align?: ScrollAlign; offset?: number; } +export interface KeyScrollToConfig { + key: React.Key; + align?: ScrollAlign; + offset?: number; +} + +export interface PositionScrollToConfig { + left?: number; + top?: number; +} + export type ListyScrollToConfig = - | Parameters[0] + | number + | null + | KeyScrollToConfig + | PositionScrollToConfig | GroupScrollToConfig; export interface ListyRef { @@ -66,7 +79,7 @@ function Listy( const data = React.useMemo(() => items || [], [items]); // =============================== Refs =============================== - const listRef = React.useRef(null); + const listRef = React.useRef(null); // ========================== Imperative API ========================== useImperativeHandle(ref, () => ({ @@ -80,7 +93,7 @@ function Listy( }); return; } - listRef.current?.scrollTo(config as Parameters[0]); + listRef.current?.scrollTo(config); }, })); @@ -139,7 +152,6 @@ function Listy( // ============================== Render =============================== const sharedListProps = { - ref: listRef, height, onScroll, prefixCls, @@ -148,6 +160,7 @@ function Listy( const listNode = virtual === false ? ( ( /> ) : ( } {...sharedListProps} virtual={virtual} data={rows} diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index 861fac1..4d44148 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; -import type { ListRef } from '@rc-component/virtual-list'; import GroupHeader from '../GroupHeader'; import type { Row } from '../hooks/useFlattenRows'; import type { GroupSegmentItem, Group } from '../hooks/useGroupSegments'; import useRawListScroll from './useRawListScroll'; +import type { ListyRef } from '../List'; export interface RawListProps { data: T[]; @@ -20,7 +20,7 @@ export interface RawListProps { function RawList( props: RawListProps, - ref: React.Ref, + ref: React.Ref, ) { const { data, @@ -116,7 +116,7 @@ const RawListWithRef = React.forwardRef(RawList) as < T, K extends React.Key = React.Key, >( - props: RawListProps & { ref?: React.Ref }, + props: RawListProps & { ref?: React.Ref }, ) => React.ReactElement; export default RawListWithRef; diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts index 2aa91cc..2e875ca 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -1,13 +1,10 @@ import * as React from 'react'; -import type { ListRef, ScrollTo } from '@rc-component/virtual-list'; +import type { ListyRef, PositionScrollToConfig } from '../List'; -type ScrollConfig = NonNullable[0]>; -type ScrollPositionConfig = Extract; - -export default function useRawListScroll(ref: React.Ref) { +export default function useRawListScroll(ref: React.Ref) { const holderRef = React.useRef(null); - const scrollTo: ScrollTo = React.useCallback( + const scrollTo: ListyRef['scrollTo'] = React.useCallback( (config) => { const holder = holderRef.current; if (!holder || config == null) { @@ -39,7 +36,7 @@ export default function useRawListScroll(ref: React.Ref) { return; } - const { left, top } = config as ScrollPositionConfig; + const { left, top } = config as PositionScrollToConfig; if (left !== undefined) { holder.scrollLeft = left; } @@ -53,12 +50,7 @@ export default function useRawListScroll(ref: React.Ref) { React.useImperativeHandle( ref, () => ({ - nativeElement: holderRef.current as HTMLDivElement, scrollTo, - getScrollInfo: () => ({ - x: holderRef.current?.scrollLeft || 0, - y: holderRef.current?.scrollTop || 0, - }), }), [scrollTo], ); diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index a56a2f9..abfcd7c 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { act, render } from '@testing-library/react'; import type { ListProps as VirtualListProps, - ListRef as VirtualListRef, } from '@rc-component/virtual-list'; import Listy, { type ListyRef, type ListyProps } from '@rc-component/listy'; import RawList from '../src/RawList'; @@ -271,9 +270,9 @@ describe('Listy behaviors', () => { expect(scrollIntoView).toHaveBeenCalledTimes(scrollCount); }); - it('exposes raw list scroll info', () => { - const ref = React.createRef(); - const { container, unmount } = render( + it('exposes raw list scrollTo only', () => { + const ref = React.createRef(); + render( { />, ); - const holder = container.querySelector('.rc-listy-holder') as HTMLDivElement; - holder.scrollLeft = 11; - holder.scrollTop = 22; - - expect(ref.current?.nativeElement).toBe(holder); - expect(ref.current?.getScrollInfo()).toEqual({ x: 11, y: 22 }); - - const rawListRef = ref.current; - unmount(); - expect(rawListRef?.getScrollInfo()).toEqual({ x: 0, y: 0 }); + expect(Object.keys(ref.current || {})).toEqual(['scrollTo']); }); it('passes empty group items when raw group item map is missing', () => { From e3e2f74252cdb09194fdf35ed0eb45645fe426ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 20 May 2026 15:21:20 +0800 Subject: [PATCH 12/25] use div for raw group section --- src/RawList/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index 4d44148..6609728 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -74,7 +74,7 @@ function RawList( const currentGroupItems = groupKeyToItems.get(groupKey) || []; return ( -
( {groupItems.map(({ item, index }) => { return renderItem(item, index); })} -
+
); }) : data.map((item, index) => { From 35b69f1dcd010452b243b019397663e3d98e95a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 20 May 2026 15:32:22 +0800 Subject: [PATCH 13/25] refactor virtual list implementation --- src/GroupHeader.tsx | 2 +- src/List.tsx | 173 +----------------- src/RawList/index.tsx | 45 ++--- src/RawList/useRawListScroll.ts | 7 +- src/VirtualList/index.tsx | 129 +++++++++++++ .../useStickyGroupHeader.tsx | 2 +- src/hooks/useFlattenRows.ts | 3 +- src/hooks/useGroupSegments.ts | 6 +- src/index.ts | 2 +- src/interface.ts | 63 +++++++ tests/hooks.test.tsx | 4 +- tests/listy.behavior.test.tsx | 41 +++-- 12 files changed, 258 insertions(+), 219 deletions(-) create mode 100644 src/VirtualList/index.tsx rename src/{hooks => VirtualList}/useStickyGroupHeader.tsx (98%) create mode 100644 src/interface.ts diff --git a/src/GroupHeader.tsx b/src/GroupHeader.tsx index e0a8368..e1d1827 100644 --- a/src/GroupHeader.tsx +++ b/src/GroupHeader.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import clsx from 'clsx'; -import type { Group } from './hooks/useGroupSegments'; +import type { Group } from './interface'; export interface GroupHeaderProps { group: Group; diff --git a/src/List.tsx b/src/List.tsx index 9d2e5d8..83c6503 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -1,192 +1,37 @@ import * as React from 'react'; -import VirtualList, { - type ListRef as VirtualListRef, -} from '@rc-component/virtual-list'; -import { useImperativeHandle, forwardRef } from 'react'; -import useGroupSegments from './hooks/useGroupSegments'; -import type { Group } from './hooks/useGroupSegments'; -import useFlattenRows from './hooks/useFlattenRows'; -import type { Row } from './hooks/useFlattenRows'; -import useStickyGroupHeader from './hooks/useStickyGroupHeader'; -import GroupHeader from './GroupHeader'; +import { forwardRef } from 'react'; import RawList from './RawList'; -import { useEvent } from '@rc-component/util'; - -type RowKey = keyof T | ((item: T) => React.Key); - -export type ScrollAlign = 'top' | 'bottom' | 'auto'; - -export interface GroupScrollToConfig { - groupKey: React.Key; - align?: ScrollAlign; - offset?: number; -} - -export interface KeyScrollToConfig { - key: React.Key; - align?: ScrollAlign; - offset?: number; -} - -export interface PositionScrollToConfig { - left?: number; - top?: number; -} - -export type ListyScrollToConfig = - | number - | null - | KeyScrollToConfig - | PositionScrollToConfig - | GroupScrollToConfig; - -export interface ListyRef { - scrollTo: (config?: ListyScrollToConfig) => void; -} - -export interface ListyProps { - items?: T[]; - sticky?: boolean; - itemHeight?: number; - height?: number; - group?: Group; - virtual?: boolean; - prefixCls?: string; - rowKey: RowKey; - onScroll?: React.UIEventHandler; - itemRender: (item: T, index: number) => React.ReactNode; -} +import VirtualList from './VirtualList'; +import type { ListyProps, ListyRef } from './interface'; function Listy( props: ListyProps, ref: React.Ref, ) { // ============================== Props ============================== - const { - items, - itemRender, - group, - onScroll, - rowKey, - height, - itemHeight, - sticky, - virtual = true, - prefixCls = 'rc-listy', - } = props; + const { items, virtual = true, prefixCls = 'rc-listy', ...restProps } = props; // =============================== Data =============================== const data = React.useMemo(() => items || [], [items]); - // =============================== Refs =============================== - const listRef = React.useRef(null); - - // ========================== Imperative API ========================== - useImperativeHandle(ref, () => ({ - scrollTo: (config) => { - if (config && typeof config === 'object' && 'groupKey' in config) { - const { groupKey, align, offset } = config; - listRef.current?.scrollTo({ - key: groupKey, - align, - offset, - }); - return; - } - listRef.current?.scrollTo(config); - }, - })); - - // ============================= Grouping ============================= - const groupData = useGroupSegments(data, group); - - // ============================= Row Keys ============================= - const getKey = useEvent((row: Row): React.Key => { - if (row.type === 'header') { - return row.groupKey; - } - - if (typeof rowKey === 'function') { - return rowKey(row.item); - } - return row.item[rowKey] as React.Key; - }); - - // ============================= Flat Rows ============================= - const { rows, headerRows, groupKeyToItems } = useFlattenRows( - data, - groupData, - group, - ); - - // =========================== Sticky Header =========================== - const extraRender = useStickyGroupHeader({ - enabled: !!(sticky && group && virtual), - group, - headerRows, - groupKeyToItems, - prefixCls, - }); - - // ============================= Row Render ============================ - const renderHeaderRow = React.useCallback( - (groupKey: K) => { - if (!group) { - return null; - } - - const groupItems = groupKeyToItems.get(groupKey) || []; - - return ( - - ); - }, - [group, groupKeyToItems, prefixCls, sticky, virtual], - ); - // ============================== Render =============================== const sharedListProps = { - height, - onScroll, + ...restProps, + data, prefixCls, }; const listNode = virtual === false ? ( ) : ( } + ref={ref} {...sharedListProps} - virtual={virtual} - data={rows} - fullHeight={false} - itemHeight={itemHeight} - itemKey={getKey} - extraRender={extraRender} - > - {(row: Row) => - row.type === 'header' - ? renderHeaderRow(row.groupKey) - : itemRender(row.item, row.index) - } - + /> ); return ( diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index 6609728..7e5233f 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -1,22 +1,12 @@ import * as React from 'react'; +import { useEvent } from '@rc-component/util'; import GroupHeader from '../GroupHeader'; -import type { Row } from '../hooks/useFlattenRows'; -import type { GroupSegmentItem, Group } from '../hooks/useGroupSegments'; +import useGroupSegments from '../hooks/useGroupSegments'; import useRawListScroll from './useRawListScroll'; -import type { ListyRef } from '../List'; +import type { ListComponentProps, ListyRef } from '../interface'; -export interface RawListProps { - data: T[]; - group: Group | undefined; - groupData: Map[]>; - groupKeyToItems: Map; - getKey: (row: Row) => React.Key; - height?: number; - itemRender: (item: T, index: number) => React.ReactNode; - onScroll?: React.UIEventHandler; - prefixCls: string; - sticky?: boolean; -} +export type RawListProps = + ListComponentProps; function RawList( props: RawListProps, @@ -25,17 +15,23 @@ function RawList( const { data, group, - groupData, - groupKeyToItems, - getKey, height, itemRender, onScroll, prefixCls, + rowKey, sticky, } = props; const holderRef = useRawListScroll(ref); + const groupData = useGroupSegments(data, group); + + const getItemKey = useEvent((item: T): React.Key => { + if (typeof rowKey === 'function') { + return rowKey(item); + } + return item[rowKey] as React.Key; + }); const getScrollTargetProps = React.useCallback( (key: React.Key) => ({ @@ -46,8 +42,7 @@ function RawList( const renderItem = React.useCallback( (item: T, index: number) => { - const row = { type: 'item', item, index } as Row; - const key = getKey(row); + const key = getItemKey(item); const node = itemRender(item, index); const scrollTargetProps = getScrollTargetProps(key); @@ -64,20 +59,18 @@ function RawList(
); }, - [getKey, getScrollTargetProps, itemRender], + [getItemKey, getScrollTargetProps, itemRender], ); const rawContent = group ? Array.from(groupData).map(([groupKey, groupItems]) => { - const headerRow = { type: 'header', groupKey } as Row; - const key = getKey(headerRow); - const currentGroupItems = groupKeyToItems.get(groupKey) || []; + const currentGroupItems = groupItems.map(({ item }) => item); return (
) { const holderRef = React.useRef(null); @@ -16,9 +16,10 @@ export default function useRawListScroll(ref: React.Ref) { return; } - if ('key' in config) { + if ('key' in config || 'groupKey' in config) { + const targetKey = 'groupKey' in config ? config.groupKey : config.key; const targetElement = holder.querySelector( - `[data-key="${CSS.escape(String(config.key))}"]`, + `[data-key="${CSS.escape(String(targetKey))}"]`, ); if (targetElement) { diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx new file mode 100644 index 0000000..7618667 --- /dev/null +++ b/src/VirtualList/index.tsx @@ -0,0 +1,129 @@ +import * as React from 'react'; +import RcVirtualList, { + type ListRef as RcVirtualListRef, + type ScrollConfig, +} from '@rc-component/virtual-list'; +import { useEvent } from '@rc-component/util'; +import GroupHeader from '../GroupHeader'; +import type { ListComponentProps, ListyRef } from '../interface'; +import useFlattenRows from '../hooks/useFlattenRows'; +import type { Row } from '../hooks/useFlattenRows'; +import useGroupSegments from '../hooks/useGroupSegments'; +import useStickyGroupHeader from './useStickyGroupHeader'; + +export type VirtualListProps< + T, + K extends React.Key = React.Key, +> = ListComponentProps; + +function VirtualList( + props: VirtualListProps, + ref: React.Ref, +) { + const { + data, + group, + height, + itemHeight, + itemRender, + onScroll, + prefixCls, + rowKey, + sticky, + } = props; + + const listRef = React.useRef(null); + + React.useImperativeHandle( + ref, + () => ({ + scrollTo: (config) => { + if (config && typeof config === 'object' && 'groupKey' in config) { + const { groupKey, align, offset } = config; + listRef.current?.scrollTo({ + key: groupKey, + align, + offset, + }); + return; + } + + listRef.current?.scrollTo(config as number | ScrollConfig | null); + }, + }), + [], + ); + + const groupData = useGroupSegments(data, group); + + const getKey = useEvent((row: Row): React.Key => { + if (row.type === 'header') { + return row.groupKey; + } + + if (typeof rowKey === 'function') { + return rowKey(row.item); + } + return row.item[rowKey] as React.Key; + }); + + const { rows, headerRows, groupKeyToItems } = useFlattenRows( + data, + groupData, + group, + ); + + const extraRender = useStickyGroupHeader({ + enabled: !!(sticky && group), + group, + headerRows, + groupKeyToItems, + prefixCls, + }); + + const renderHeaderRow = React.useCallback( + (groupKey: K) => { + const groupItems = groupKeyToItems.get(groupKey) || []; + + return ( + + ); + }, + [group, groupKeyToItems, prefixCls], + ); + + return ( + + {(row: Row) => + row.type === 'header' + ? renderHeaderRow(row.groupKey) + : itemRender(row.item, row.index) + } + + ); +} + +const VirtualListWithRef = React.forwardRef(VirtualList) as < + T, + K extends React.Key = React.Key, +>( + props: VirtualListProps & { ref?: React.Ref }, +) => React.ReactElement; + +export default VirtualListWithRef; diff --git a/src/hooks/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx similarity index 98% rename from src/hooks/useStickyGroupHeader.tsx rename to src/VirtualList/useStickyGroupHeader.tsx index 254dcfd..f8a17a2 100644 --- a/src/hooks/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; -import type { Group } from './useGroupSegments'; +import type { Group } from '../interface'; import GroupHeader from '../GroupHeader'; type ExtraRenderInfo = Parameters< diff --git a/src/hooks/useFlattenRows.ts b/src/hooks/useFlattenRows.ts index 02b9551..ab48749 100644 --- a/src/hooks/useFlattenRows.ts +++ b/src/hooks/useFlattenRows.ts @@ -1,5 +1,6 @@ import * as React from 'react'; -import type { Group, GroupSegmentItem } from './useGroupSegments'; +import type { Group } from '../interface'; +import type { GroupSegmentItem } from './useGroupSegments'; export type Row = | { type: 'header'; groupKey: K } diff --git a/src/hooks/useGroupSegments.ts b/src/hooks/useGroupSegments.ts index 8611c4e..0c6d8f2 100644 --- a/src/hooks/useGroupSegments.ts +++ b/src/hooks/useGroupSegments.ts @@ -1,9 +1,7 @@ import * as React from 'react'; +import type { Group } from '../interface'; -export interface Group { - key: (item: T) => K; - title: (groupKey: K, items: T[]) => React.ReactNode; -} +export type { Group } from '../interface'; export interface GroupSegmentItem { item: T; diff --git a/src/index.ts b/src/index.ts index 69fef22..d6d300d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import Listy from './List'; -export type { ListyRef, ListyProps } from './List'; +export type { ListyRef, ListyProps } from './interface'; export default Listy; diff --git a/src/interface.ts b/src/interface.ts new file mode 100644 index 0000000..1032c2f --- /dev/null +++ b/src/interface.ts @@ -0,0 +1,63 @@ +import type * as React from 'react'; + +export type RowKey = keyof T | ((item: T) => React.Key); + +export type ScrollAlign = 'top' | 'bottom' | 'auto'; + +export interface Group { + key: (item: T) => K; + title: (groupKey: K, items: T[]) => React.ReactNode; +} + +export interface GroupScrollToConfig { + groupKey: React.Key; + align?: ScrollAlign; + offset?: number; +} + +export interface KeyScrollToConfig { + key: React.Key; + align?: ScrollAlign; + offset?: number; +} + +export interface PositionScrollToConfig { + left?: number; + top?: number; +} + +export type ListyScrollToConfig = + | number + | null + | KeyScrollToConfig + | PositionScrollToConfig + | GroupScrollToConfig; + +export interface ListyRef { + scrollTo: (config?: ListyScrollToConfig) => void; +} + +export interface ListyProps { + items?: T[]; + sticky?: boolean; + itemHeight?: number; + height?: number; + group?: Group; + virtual?: boolean; + prefixCls?: string; + rowKey: RowKey; + onScroll?: React.UIEventHandler; + itemRender: (item: T, index: number) => React.ReactNode; +} + +export interface ListComponentProps { + data: T[]; + sticky?: boolean; + itemHeight?: number; + height?: number; + group?: Group; + prefixCls: string; + rowKey: RowKey; + onScroll?: React.UIEventHandler; + itemRender: (item: T, index: number) => React.ReactNode; +} diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 7e0a4d3..02ca1c3 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -4,8 +4,8 @@ import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; import useFlattenRows from '../src/hooks/useFlattenRows'; import useGroupSegments from '../src/hooks/useGroupSegments'; -import useStickyGroupHeader from '../src/hooks/useStickyGroupHeader'; -import type { StickyHeaderParams } from '../src/hooks/useStickyGroupHeader'; +import useStickyGroupHeader from '../src/VirtualList/useStickyGroupHeader'; +import type { StickyHeaderParams } from '../src/VirtualList/useStickyGroupHeader'; const PREFIX_CLS = 'rc-listy'; diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index abfcd7c..c1a7dcd 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -277,43 +277,52 @@ describe('Listy behaviors', () => { ref={ref} data={[{ id: 1 }]} group={undefined} - groupData={new Map()} - groupKeyToItems={new Map()} - getKey={(row) => (row.type === 'item' ? row.item.id : row.groupKey)} itemRender={(item) =>
{item.id}
} prefixCls="rc-listy" + rowKey="id" />, ); expect(Object.keys(ref.current || {})).toEqual(['scrollTo']); }); - it('passes empty group items when raw group item map is missing', () => { + it('passes raw group items to title', () => { const title = jest.fn(() => null); render( item.group, title, }} - groupData={ - new Map([ - [ - 'Group A', - [{ item: { id: 1, group: 'Group A' }, index: 0 }], - ], - ]) - } - groupKeyToItems={new Map()} - getKey={(row) => (row.type === 'item' ? row.item.id : row.groupKey)} itemRender={(item) =>
{item.id}
} prefixCls="rc-listy" + rowKey="id" + />, + ); + + expect(title).toHaveBeenCalledWith('Group A', [ + { id: 1, group: 'Group A' }, + { id: 2, group: 'Group A' }, + ]); + }); + + it('supports raw list rowKey function', () => { + const { container } = render( +
{item.id}
} + prefixCls="rc-listy" + rowKey={(item) => `item-${item.id}`} />, ); - expect(title).toHaveBeenCalledWith('Group A', []); + expect(container.querySelector('[data-key="item-1"]')).not.toBeNull(); }); it('scroll to group', () => { From c38dfc674c9af8cd08559ef4b3e13d4e59e3e31b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 20 May 2026 15:41:30 +0800 Subject: [PATCH 14/25] add basic demo group scroll button --- docs/examples/basic.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/examples/basic.tsx b/docs/examples/basic.tsx index fba1f81..16993a7 100644 --- a/docs/examples/basic.tsx +++ b/docs/examples/basic.tsx @@ -62,6 +62,17 @@ export default () => { > Scroll To 100 + +
); }; From 799589ee341cd3bc0d2e8c50c3bf4399d09ffb2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 20 May 2026 15:45:53 +0800 Subject: [PATCH 15/25] align basic demo scroll target --- docs/examples/basic.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/examples/basic.tsx b/docs/examples/basic.tsx index 16993a7..1fb1eee 100644 --- a/docs/examples/basic.tsx +++ b/docs/examples/basic.tsx @@ -8,7 +8,7 @@ export default () => { const groupItemsCount = 20; const groupIndex = Math.floor(index / groupItemsCount); return { - id: index + 1, + id: index, name: `${index} (group ${groupIndex})`, type: `Group ${groupIndex * groupItemsCount}`, }; From 8ce487ef940fa0db03432c2e46927b0175a2e1d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 20 May 2026 15:48:38 +0800 Subject: [PATCH 16/25] offset basic demo item scroll --- docs/examples/basic.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/examples/basic.tsx b/docs/examples/basic.tsx index 1fb1eee..6599303 100644 --- a/docs/examples/basic.tsx +++ b/docs/examples/basic.tsx @@ -20,6 +20,7 @@ export default () => { lineHeight: '32px', borderBottom: '1px solid rgb(79, 53, 53)', }; + const groupHeaderHeight = 32; return (
@@ -40,8 +41,8 @@ export default () => { style={{ fontWeight: 600, padding: '0 12px', - height: 32, - lineHeight: '32px', + height: groupHeaderHeight, + lineHeight: `${groupHeaderHeight}px`, borderBottom: '1px solid #f5f5f5', backgroundColor: 'gray', }} @@ -57,6 +58,7 @@ export default () => { listRef.current?.scrollTo({ key: 100, align: 'top', + offset: groupHeaderHeight, }) } > From f24320e2befb69f1682582f8f99df401050cd450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 20 May 2026 15:52:37 +0800 Subject: [PATCH 17/25] remove fixed basic demo scroll offset --- docs/examples/basic.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/examples/basic.tsx b/docs/examples/basic.tsx index 6599303..1fb1eee 100644 --- a/docs/examples/basic.tsx +++ b/docs/examples/basic.tsx @@ -20,7 +20,6 @@ export default () => { lineHeight: '32px', borderBottom: '1px solid rgb(79, 53, 53)', }; - const groupHeaderHeight = 32; return (
@@ -41,8 +40,8 @@ export default () => { style={{ fontWeight: 600, padding: '0 12px', - height: groupHeaderHeight, - lineHeight: `${groupHeaderHeight}px`, + height: 32, + lineHeight: '32px', borderBottom: '1px solid #f5f5f5', backgroundColor: 'gray', }} @@ -58,7 +57,6 @@ export default () => { listRef.current?.scrollTo({ key: 100, align: 'top', - offset: groupHeaderHeight, }) } > From 6e730391b6d62bcefa6092482c1cd2018b9f798e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 20 May 2026 16:59:06 +0800 Subject: [PATCH 18/25] Support sticky scroll offset --- package.json | 2 +- src/RawList/index.tsx | 4 +- src/VirtualList/index.tsx | 94 ++++++++++++++++++++++++++--------- tests/listy.behavior.test.tsx | 66 ++++++++++++++++++++++-- 4 files changed, 134 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index 3879d7d..3403576 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.3.1", "clsx": "^2.1.1", - "@rc-component/virtual-list": "^1.1.0" + "@rc-component/virtual-list": "^1.2.0" }, "devDependencies": { "@rc-component/father-plugin": "^2.1.3", diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index 7e5233f..490033d 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -46,7 +46,7 @@ function RawList( const node = itemRender(item, index); const scrollTargetProps = getScrollTargetProps(key); - if (React.isValidElement(node)) { + if (React.isValidElement(node) && node.type !== React.Fragment) { return React.cloneElement(node as React.ReactElement, { key, ...scrollTargetProps, @@ -63,7 +63,7 @@ function RawList( ); const rawContent = group - ? Array.from(groupData).map(([groupKey, groupItems]) => { + ? Array.from(groupData, ([groupKey, groupItems]) => { const currentGroupItems = groupItems.map(({ item }) => item); return ( diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index 7618667..81442e8 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import RcVirtualList, { type ListRef as RcVirtualListRef, type ScrollConfig, + type ScrollOffsetInfo, } from '@rc-component/virtual-list'; import { useEvent } from '@rc-component/util'; import GroupHeader from '../GroupHeader'; @@ -34,37 +35,21 @@ function VirtualList( const listRef = React.useRef(null); - React.useImperativeHandle( - ref, - () => ({ - scrollTo: (config) => { - if (config && typeof config === 'object' && 'groupKey' in config) { - const { groupKey, align, offset } = config; - listRef.current?.scrollTo({ - key: groupKey, - align, - offset, - }); - return; - } - - listRef.current?.scrollTo(config as number | ScrollConfig | null); - }, - }), - [], - ); - const groupData = useGroupSegments(data, group); + const getItemKey = useEvent((item: T): React.Key => { + if (typeof rowKey === 'function') { + return rowKey(item); + } + return item[rowKey] as React.Key; + }); + const getKey = useEvent((row: Row): React.Key => { if (row.type === 'header') { return row.groupKey; } - if (typeof rowKey === 'function') { - return rowKey(row.item); - } - return row.item[rowKey] as React.Key; + return getItemKey(row.item); }); const { rows, headerRows, groupKeyToItems } = useFlattenRows( @@ -73,6 +58,67 @@ function VirtualList( group, ); + const itemKeyToGroupKey = React.useMemo(() => { + const itemGroupMap = new Map(); + + groupData.forEach((groupItems, groupKey) => { + groupItems.forEach(({ item }) => { + itemGroupMap.set(getItemKey(item), groupKey); + }); + }); + + return itemGroupMap; + }, [getItemKey, groupData]); + + const scrollTo = useEvent((config) => { + if (config && typeof config === 'object' && 'groupKey' in config) { + const { groupKey, align, offset } = config; + listRef.current?.scrollTo({ + key: groupKey, + align, + offset, + }); + return; + } + + if ( + config && + typeof config === 'object' && + 'key' in config && + sticky && + group && + config.align === 'top' + ) { + const groupKey = itemKeyToGroupKey.get(config.key); + + if (groupKey !== undefined) { + const { offset = 0 } = config; + + listRef.current?.scrollTo({ + ...config, + // Use the measured header height so top-aligned items stay below it. + offset: ({ getSize }: ScrollOffsetInfo) => { + const headerSize = getSize(groupKey); + const headerHeight = headerSize.bottom - headerSize.top; + + return offset + (Number.isFinite(headerHeight) ? headerHeight : 0); + }, + }); + return; + } + } + + listRef.current?.scrollTo(config as number | ScrollConfig | null); + }); + + React.useImperativeHandle( + ref, + () => ({ + scrollTo, + }), + [scrollTo], + ); + const extraRender = useStickyGroupHeader({ enabled: !!(sticky && group), group, diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index c1a7dcd..eb4a6bc 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -325,10 +325,28 @@ describe('Listy behaviors', () => { expect(container.querySelector('[data-key="item-1"]')).not.toBeNull(); }); + it('wraps raw list fragment items as scroll targets', () => { + const { container } = render( + ( + <> + {item.id} + + )} + prefixCls="rc-listy" + rowKey="id" + />, + ); + + expect(container.querySelector('[data-key="1"]')).not.toBeNull(); + }); + it('scroll to group', () => { const scrollHandler = jest.fn(); MockedVirtualList.__setScrollHandler(scrollHandler); - + const ref = React.createRef(); renderList({ ref, @@ -337,15 +355,53 @@ describe('Listy behaviors', () => { title: () => null, }, }); - + act(() => { - ref.current?.scrollTo({ groupKey: 'Group A', align: 'bottom', offset: 12 }); + ref.current?.scrollTo({ + groupKey: 'Group A', + align: 'bottom', + offset: 12, + }); }); - + expect(scrollHandler).toHaveBeenCalledWith({ key: 'Group A', align: 'bottom', offset: 12, }); - }); + }); + + it('offsets sticky virtual scrollTo by group header height', () => { + const scrollHandler = jest.fn(); + MockedVirtualList.__setScrollHandler(scrollHandler); + + const ref = React.createRef(); + renderList({ + ref, + sticky: true, + group: { + key: (item) => item.group, + title: () => null, + }, + }); + + act(() => { + ref.current?.scrollTo({ key: 2, align: 'top', offset: 5 }); + }); + + expect(scrollHandler).toHaveBeenCalledWith({ + key: 2, + align: 'top', + offset: expect.any(Function), + }); + + const [{ offset }] = scrollHandler.mock.calls[0]; + + expect( + offset({ + getSize: (key: React.Key) => + key === 'Group A' ? { top: 10, bottom: 34 } : { top: 0, bottom: 0 }, + }), + ).toBe(29); + }); }); From 23de8e5ff4f5d76fb083728ea15fbf65efc23713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 20 May 2026 17:13:17 +0800 Subject: [PATCH 19/25] Move list types into List --- src/GroupHeader.tsx | 2 +- src/List.tsx | 63 +++++++++++++++++++++++- src/RawList/index.tsx | 2 +- src/RawList/useRawListScroll.ts | 2 +- src/VirtualList/index.tsx | 2 +- src/VirtualList/useStickyGroupHeader.tsx | 2 +- src/hooks/useFlattenRows.ts | 2 +- src/hooks/useGroupSegments.ts | 4 +- src/index.ts | 2 +- src/interface.ts | 63 ------------------------ 10 files changed, 70 insertions(+), 74 deletions(-) delete mode 100644 src/interface.ts diff --git a/src/GroupHeader.tsx b/src/GroupHeader.tsx index e1d1827..d1a2ba8 100644 --- a/src/GroupHeader.tsx +++ b/src/GroupHeader.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import clsx from 'clsx'; -import type { Group } from './interface'; +import type { Group } from './List'; export interface GroupHeaderProps { group: Group; diff --git a/src/List.tsx b/src/List.tsx index 83c6503..8f6dbb6 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -2,7 +2,68 @@ import * as React from 'react'; import { forwardRef } from 'react'; import RawList from './RawList'; import VirtualList from './VirtualList'; -import type { ListyProps, ListyRef } from './interface'; + +export type RowKey = keyof T | ((item: T) => React.Key); + +export type ScrollAlign = 'top' | 'bottom' | 'auto'; + +export interface Group { + key: (item: T) => K; + title: (groupKey: K, items: T[]) => React.ReactNode; +} + +export interface GroupScrollToConfig { + groupKey: React.Key; + align?: ScrollAlign; + offset?: number; +} + +export interface KeyScrollToConfig { + key: React.Key; + align?: ScrollAlign; + offset?: number; +} + +export interface PositionScrollToConfig { + left?: number; + top?: number; +} + +export type ListyScrollToConfig = + | number + | null + | KeyScrollToConfig + | PositionScrollToConfig + | GroupScrollToConfig; + +export interface ListyRef { + scrollTo: (config?: ListyScrollToConfig) => void; +} + +export interface ListyProps { + items?: T[]; + sticky?: boolean; + itemHeight?: number; + height?: number; + group?: Group; + virtual?: boolean; + prefixCls?: string; + rowKey: RowKey; + onScroll?: React.UIEventHandler; + itemRender: (item: T, index: number) => React.ReactNode; +} + +export interface ListComponentProps { + data: T[]; + sticky?: boolean; + itemHeight?: number; + height?: number; + group?: Group; + prefixCls: string; + rowKey: RowKey; + onScroll?: React.UIEventHandler; + itemRender: (item: T, index: number) => React.ReactNode; +} function Listy( props: ListyProps, diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index 490033d..b708c47 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -3,7 +3,7 @@ import { useEvent } from '@rc-component/util'; import GroupHeader from '../GroupHeader'; import useGroupSegments from '../hooks/useGroupSegments'; import useRawListScroll from './useRawListScroll'; -import type { ListComponentProps, ListyRef } from '../interface'; +import type { ListComponentProps, ListyRef } from '../List'; export type RawListProps = ListComponentProps; diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts index 3e87d66..04519e3 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { ListyRef, PositionScrollToConfig } from '../interface'; +import type { ListyRef, PositionScrollToConfig } from '../List'; export default function useRawListScroll(ref: React.Ref) { const holderRef = React.useRef(null); diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index 81442e8..0bb3aaa 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -6,7 +6,7 @@ import RcVirtualList, { } from '@rc-component/virtual-list'; import { useEvent } from '@rc-component/util'; import GroupHeader from '../GroupHeader'; -import type { ListComponentProps, ListyRef } from '../interface'; +import type { ListComponentProps, ListyRef } from '../List'; import useFlattenRows from '../hooks/useFlattenRows'; import type { Row } from '../hooks/useFlattenRows'; import useGroupSegments from '../hooks/useGroupSegments'; diff --git a/src/VirtualList/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx index f8a17a2..fa480e9 100644 --- a/src/VirtualList/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; -import type { Group } from '../interface'; +import type { Group } from '../List'; import GroupHeader from '../GroupHeader'; type ExtraRenderInfo = Parameters< diff --git a/src/hooks/useFlattenRows.ts b/src/hooks/useFlattenRows.ts index ab48749..1fdb7dd 100644 --- a/src/hooks/useFlattenRows.ts +++ b/src/hooks/useFlattenRows.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { Group } from '../interface'; +import type { Group } from '../List'; import type { GroupSegmentItem } from './useGroupSegments'; export type Row = diff --git a/src/hooks/useGroupSegments.ts b/src/hooks/useGroupSegments.ts index 0c6d8f2..ab6a7ea 100644 --- a/src/hooks/useGroupSegments.ts +++ b/src/hooks/useGroupSegments.ts @@ -1,7 +1,5 @@ import * as React from 'react'; -import type { Group } from '../interface'; - -export type { Group } from '../interface'; +import type { Group } from '../List'; export interface GroupSegmentItem { item: T; diff --git a/src/index.ts b/src/index.ts index d6d300d..69fef22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import Listy from './List'; -export type { ListyRef, ListyProps } from './interface'; +export type { ListyRef, ListyProps } from './List'; export default Listy; diff --git a/src/interface.ts b/src/interface.ts deleted file mode 100644 index 1032c2f..0000000 --- a/src/interface.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type * as React from 'react'; - -export type RowKey = keyof T | ((item: T) => React.Key); - -export type ScrollAlign = 'top' | 'bottom' | 'auto'; - -export interface Group { - key: (item: T) => K; - title: (groupKey: K, items: T[]) => React.ReactNode; -} - -export interface GroupScrollToConfig { - groupKey: React.Key; - align?: ScrollAlign; - offset?: number; -} - -export interface KeyScrollToConfig { - key: React.Key; - align?: ScrollAlign; - offset?: number; -} - -export interface PositionScrollToConfig { - left?: number; - top?: number; -} - -export type ListyScrollToConfig = - | number - | null - | KeyScrollToConfig - | PositionScrollToConfig - | GroupScrollToConfig; - -export interface ListyRef { - scrollTo: (config?: ListyScrollToConfig) => void; -} - -export interface ListyProps { - items?: T[]; - sticky?: boolean; - itemHeight?: number; - height?: number; - group?: Group; - virtual?: boolean; - prefixCls?: string; - rowKey: RowKey; - onScroll?: React.UIEventHandler; - itemRender: (item: T, index: number) => React.ReactNode; -} - -export interface ListComponentProps { - data: T[]; - sticky?: boolean; - itemHeight?: number; - height?: number; - group?: Group; - prefixCls: string; - rowKey: RowKey; - onScroll?: React.UIEventHandler; - itemRender: (item: T, index: number) => React.ReactNode; -} From b5d26c81933c07e9c0933fe977aaf6d5c85a858b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 20 May 2026 17:18:40 +0800 Subject: [PATCH 20/25] Keep group type with group segments --- src/GroupHeader.tsx | 2 +- src/List.tsx | 6 +----- src/VirtualList/useStickyGroupHeader.tsx | 2 +- src/hooks/useFlattenRows.ts | 3 +-- src/hooks/useGroupSegments.ts | 6 +++++- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/GroupHeader.tsx b/src/GroupHeader.tsx index d1a2ba8..e0a8368 100644 --- a/src/GroupHeader.tsx +++ b/src/GroupHeader.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import clsx from 'clsx'; -import type { Group } from './List'; +import type { Group } from './hooks/useGroupSegments'; export interface GroupHeaderProps { group: Group; diff --git a/src/List.tsx b/src/List.tsx index 8f6dbb6..3224400 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -2,16 +2,12 @@ import * as React from 'react'; import { forwardRef } from 'react'; import RawList from './RawList'; import VirtualList from './VirtualList'; +import type { Group } from './hooks/useGroupSegments'; export type RowKey = keyof T | ((item: T) => React.Key); export type ScrollAlign = 'top' | 'bottom' | 'auto'; -export interface Group { - key: (item: T) => K; - title: (groupKey: K, items: T[]) => React.ReactNode; -} - export interface GroupScrollToConfig { groupKey: React.Key; align?: ScrollAlign; diff --git a/src/VirtualList/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx index fa480e9..16a27ca 100644 --- a/src/VirtualList/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; -import type { Group } from '../List'; +import type { Group } from '../hooks/useGroupSegments'; import GroupHeader from '../GroupHeader'; type ExtraRenderInfo = Parameters< diff --git a/src/hooks/useFlattenRows.ts b/src/hooks/useFlattenRows.ts index 1fdb7dd..02b9551 100644 --- a/src/hooks/useFlattenRows.ts +++ b/src/hooks/useFlattenRows.ts @@ -1,6 +1,5 @@ import * as React from 'react'; -import type { Group } from '../List'; -import type { GroupSegmentItem } from './useGroupSegments'; +import type { Group, GroupSegmentItem } from './useGroupSegments'; export type Row = | { type: 'header'; groupKey: K } diff --git a/src/hooks/useGroupSegments.ts b/src/hooks/useGroupSegments.ts index ab6a7ea..8611c4e 100644 --- a/src/hooks/useGroupSegments.ts +++ b/src/hooks/useGroupSegments.ts @@ -1,5 +1,9 @@ import * as React from 'react'; -import type { Group } from '../List'; + +export interface Group { + key: (item: T) => K; + title: (groupKey: K, items: T[]) => React.ReactNode; +} export interface GroupSegmentItem { item: T; From ac10b64125e90727d8e7f0e8428564f56093e14b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 20 May 2026 17:27:30 +0800 Subject: [PATCH 21/25] Simplify internal list ref types --- src/RawList/index.tsx | 7 +------ src/VirtualList/index.tsx | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index b708c47..30321e8 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -105,11 +105,6 @@ function RawList( ); } -const RawListWithRef = React.forwardRef(RawList) as < - T, - K extends React.Key = React.Key, ->( - props: RawListProps & { ref?: React.Ref }, -) => React.ReactElement; +const RawListWithRef = React.forwardRef(RawList as any) as any; export default RawListWithRef; diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index 0bb3aaa..43213b9 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -165,11 +165,6 @@ function VirtualList( ); } -const VirtualListWithRef = React.forwardRef(VirtualList) as < - T, - K extends React.Key = React.Key, ->( - props: VirtualListProps & { ref?: React.Ref }, -) => React.ReactElement; +const VirtualListWithRef = React.forwardRef(VirtualList as any) as any; export default VirtualListWithRef; From b28267e51ab14c37ee6ecfc3c3f008a26f3b0516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 20 May 2026 17:36:03 +0800 Subject: [PATCH 22/25] Wrap list items with item class --- src/RawList/index.tsx | 18 +++++++---------- src/VirtualList/index.tsx | 6 +++++- tests/__snapshots__/listy.test.tsx.snap | 8 ++++++-- tests/listy.behavior.test.tsx | 26 +++++++++++++++++++------ 4 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index 30321e8..9c88469 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -43,23 +43,19 @@ function RawList( const renderItem = React.useCallback( (item: T, index: number) => { const key = getItemKey(item); - const node = itemRender(item, index); const scrollTargetProps = getScrollTargetProps(key); - if (React.isValidElement(node) && node.type !== React.Fragment) { - return React.cloneElement(node as React.ReactElement, { - key, - ...scrollTargetProps, - }); - } - return ( -
- {node} +
+ {itemRender(item, index)}
); }, - [getItemKey, getScrollTargetProps, itemRender], + [getItemKey, getScrollTargetProps, itemRender, prefixCls], ); const rawContent = group diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index 43213b9..7fa6d6a 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -159,7 +159,11 @@ function VirtualList( {(row: Row) => row.type === 'header' ? renderHeaderRow(row.groupKey) - : itemRender(row.item, row.index) + : ( +
+ {itemRender(row.item, row.index)} +
+ ) } ); diff --git a/tests/__snapshots__/listy.test.tsx.snap b/tests/__snapshots__/listy.test.tsx.snap index a3e1692..5a17816 100644 --- a/tests/__snapshots__/listy.test.tsx.snap +++ b/tests/__snapshots__/listy.test.tsx.snap @@ -17,8 +17,12 @@ exports[`Listy should match snapshot 1`] = ` class="rc-listy-holder-inner" style="display: flex; flex-direction: column;" > -
- 1 +
+
+ 1 +
diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index eb4a6bc..eb71dbd 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -133,6 +133,17 @@ describe('Listy behaviors', () => { expect(lastProps.data).toEqual([]); }); + it('wraps virtual items with item class', () => { + const { container } = renderList(); + + const itemNodes = container.querySelectorAll('.rc-listy-item'); + + expect(itemNodes).toHaveLength(2); + expect(itemNodes[0]).toContainElement( + container.querySelector('[data-testid="item-1"]') as HTMLElement, + ); + }); + it('applies sticky class when virtual list is disabled', () => { const title = jest.fn((key: React.Key) => Group {String(key)}); const { container } = renderList({ @@ -207,9 +218,7 @@ describe('Listy behaviors', () => { }); const holder = container.querySelector('.rc-listy-holder') as HTMLDivElement; - const itemNodes = container.querySelectorAll( - '.rc-listy-holder-inner > div', - ); + const itemNodes = container.querySelectorAll('.rc-listy-item'); const secondItem = itemNodes[1] as HTMLElement; const scrollIntoView = jest.fn(); secondItem.scrollIntoView = scrollIntoView; @@ -322,10 +331,12 @@ describe('Listy behaviors', () => { />, ); - expect(container.querySelector('[data-key="item-1"]')).not.toBeNull(); + const itemNode = container.querySelector('.rc-listy-item'); + + expect(itemNode).toHaveAttribute('data-key', 'item-1'); }); - it('wraps raw list fragment items as scroll targets', () => { + it('wraps raw list items with item class', () => { const { container } = render( { />, ); - expect(container.querySelector('[data-key="1"]')).not.toBeNull(); + const itemNode = container.querySelector('.rc-listy-item'); + + expect(itemNode).toHaveAttribute('data-key', '1'); + expect(itemNode).toContainElement(container.querySelector('span')); }); it('scroll to group', () => { From a14d6d894f65bc30303b0a8d9f4e9c1e46437af1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 20 May 2026 17:44:46 +0800 Subject: [PATCH 23/25] Add no virtual item scroll button --- docs/examples/no-virtual.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/examples/no-virtual.tsx b/docs/examples/no-virtual.tsx index cd1cd1c..708878e 100644 --- a/docs/examples/no-virtual.tsx +++ b/docs/examples/no-virtual.tsx @@ -92,6 +92,10 @@ export default () => { [], ); + const handleScrollToItem = useCallback((itemId: string) => { + listRef.current?.scrollTo({ key: itemId, align: 'top' }); + }, []); + return (
{ ); })} + Total Items: {items.length}
From 1217446f65881f45b31e29dbeb3ac88919e3da10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 20 May 2026 17:56:05 +0800 Subject: [PATCH 24/25] Offset raw item scroll for sticky headers --- src/GroupHeader.tsx | 14 +++++++-- src/RawList/index.tsx | 58 +++++++++++++++++++++++++++++------ tests/listy.behavior.test.tsx | 46 +++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 12 deletions(-) diff --git a/src/GroupHeader.tsx b/src/GroupHeader.tsx index e0a8368..f9c82c7 100644 --- a/src/GroupHeader.tsx +++ b/src/GroupHeader.tsx @@ -12,8 +12,9 @@ export interface GroupHeaderProps { style?: React.CSSProperties; } -export default function GroupHeader( +function GroupHeader( props: GroupHeaderProps, + ref: React.Ref, ) { const { group, @@ -31,8 +32,17 @@ export default function GroupHeader( }); return ( -
+
{group.title(groupKey, groupItems)}
); } + +const GroupHeaderWithRef = React.forwardRef(GroupHeader) as < + T, + K extends React.Key = React.Key, +>( + props: GroupHeaderProps & { ref?: React.Ref }, +) => React.ReactElement; + +export default GroupHeaderWithRef; diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index 9c88469..8e11d44 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import ResizeObserver from '@rc-component/resize-observer'; import { useEvent } from '@rc-component/util'; import GroupHeader from '../GroupHeader'; import useGroupSegments from '../hooks/useGroupSegments'; @@ -25,6 +26,9 @@ function RawList( const holderRef = useRawListScroll(ref); const groupData = useGroupSegments(data, group); + const [headerHeights, setHeaderHeights] = React.useState< + Map + >(() => new Map()); const getItemKey = useEvent((item: T): React.Key => { if (typeof rowKey === 'function') { @@ -40,22 +44,49 @@ function RawList( [], ); + const setGroupHeaderHeight = React.useCallback( + (groupKey: K, headerHeight: number) => { + setHeaderHeights((prev) => { + const next = new Map(prev); + next.set(groupKey, headerHeight); + return next; + }); + }, + [], + ); + const renderItem = React.useCallback( - (item: T, index: number) => { + (item: T, index: number, groupKey?: K) => { const key = getItemKey(item); const scrollTargetProps = getScrollTargetProps(key); + const headerHeight = + sticky && groupKey !== undefined ? headerHeights.get(groupKey) : 0; return (
{itemRender(item, index)}
); }, - [getItemKey, getScrollTargetProps, itemRender, prefixCls], + [ + getItemKey, + getScrollTargetProps, + headerHeights, + itemRender, + prefixCls, + sticky, + ], ); const rawContent = group @@ -68,15 +99,22 @@ function RawList( className={`${prefixCls}-group-section`} {...getScrollTargetProps(groupKey)} > - + { + setGroupHeaderHeight(groupKey, offsetHeight); + }} + > + + {groupItems.map(({ item, index }) => { - return renderItem(item, index); + return renderItem(item, index, groupKey); })}
); diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index eb71dbd..584e651 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { act, render } from '@testing-library/react'; +import { _rs as triggerResize } from '@rc-component/resize-observer'; import type { ListProps as VirtualListProps, } from '@rc-component/virtual-list'; @@ -357,6 +358,51 @@ describe('Listy behaviors', () => { expect(itemNode).toContainElement(container.querySelector('span')); }); + it('keeps raw sticky group header from covering top-aligned items', async () => { + const { container } = renderList({ + virtual: false, + sticky: true, + group: { + key: (item) => item.group, + title: (groupKey) => {String(groupKey)}, + }, + }); + + const groupHeader = container.querySelector( + '.rc-listy-group-header', + ) as HTMLElement; + Object.defineProperty(groupHeader, 'offsetHeight', { + configurable: true, + value: 36, + }); + groupHeader.getBoundingClientRect = jest.fn( + () => + ({ + bottom: 36, + height: 36, + left: 0, + right: 100, + top: 0, + width: 100, + x: 0, + y: 0, + toJSON: () => {}, + }) as DOMRect, + ); + + await act(async () => { + triggerResize?.([ + { target: groupHeader } as unknown as ResizeObserverEntry, + ]); + await Promise.resolve(); + }); + + const itemNode = container.querySelector('[data-key="1"]') as HTMLElement; + + expect(itemNode).toHaveClass('rc-listy-item'); + expect(itemNode).toHaveStyle({ scrollMarginTop: '36px' }); + }); + it('scroll to group', () => { const scrollHandler = jest.fn(); MockedVirtualList.__setScrollHandler(scrollHandler); From f8a742aaa29c40717bef50f37f1a1f5fb5b0ac1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 20 May 2026 18:06:40 +0800 Subject: [PATCH 25/25] Update no virtual item scroll target --- docs/examples/no-virtual.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/examples/no-virtual.tsx b/docs/examples/no-virtual.tsx index 708878e..32c6883 100644 --- a/docs/examples/no-virtual.tsx +++ b/docs/examples/no-virtual.tsx @@ -159,8 +159,8 @@ export default () => { ); })} - Total Items: {items.length}