From ad99c7f70d42e485cc9964d11128d99e730d4383 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 12:03:11 +0800 Subject: [PATCH 1/8] extract group header component --- src/GroupHeader.tsx | 36 ++++++++++++++++++++++++++++++ src/List.tsx | 15 +++++++------ src/hooks/useStickyGroupHeader.tsx | 11 ++++++--- 3 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 src/GroupHeader.tsx diff --git a/src/GroupHeader.tsx b/src/GroupHeader.tsx new file mode 100644 index 0000000..25ca6d9 --- /dev/null +++ b/src/GroupHeader.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import type { Group } from './hooks/useGroupSegments'; + +export interface GroupHeaderProps { + group: Group; + groupKey: K; + groupItems: T[]; + prefixCls: string; + sticky?: boolean; + variant?: 'list' | 'sticky'; +} + +export default function GroupHeader( + props: GroupHeaderProps, +) { + const { + group, + groupKey, + groupItems, + prefixCls, + sticky, + variant = 'list', + } = props; + + const className = clsx( + variant === 'sticky' + ? `${prefixCls}-sticky-header` + : `${prefixCls}-group-header`, + { + [`${prefixCls}-group-header-sticky`]: variant === 'list' && sticky, + }, + ); + + return
{group.title(groupKey, groupItems)}
; +} diff --git a/src/List.tsx b/src/List.tsx index 213ef08..8a985aa 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -7,7 +7,7 @@ import type { Group } from './hooks/useGroupSegments'; import useFlattenRows from './hooks/useFlattenRows'; import type { Row } from './hooks/useFlattenRows'; import useStickyGroupHeader from './hooks/useStickyGroupHeader'; -import clsx from 'clsx'; +import GroupHeader from './GroupHeader'; import { useEvent } from '@rc-component/util'; type RowKey = keyof T | ((item: T) => React.Key); @@ -123,14 +123,15 @@ function Listy( } const groupItems = groupKeyToItems.get(groupKey) || []; - const headerClassName = clsx(`${prefixCls}-group-header`, { - [`${prefixCls}-group-header-sticky`]: sticky && !virtual, - }); return ( -
- {group.title(groupKey, groupItems)} -
+ ); }, [group, groupKeyToItems, prefixCls, sticky, virtual], diff --git a/src/hooks/useStickyGroupHeader.tsx b/src/hooks/useStickyGroupHeader.tsx index 42a8564..ec59cb9 100644 --- a/src/hooks/useStickyGroupHeader.tsx +++ b/src/hooks/useStickyGroupHeader.tsx @@ -3,6 +3,7 @@ import Portal from '@rc-component/portal'; import type { ListRef } from 'rc-virtual-list'; import type { ExtraRenderInfo } from 'rc-virtual-list/lib/interface'; import type { Group } from './useGroupSegments'; +import GroupHeader from '../GroupHeader'; export interface StickyHeaderParams { enabled: boolean; @@ -103,9 +104,13 @@ export default function useStickyGroupHeader< const groupItems = groupKeyToItems.get(currHeader.groupKey) || []; const headerNode = ( -
- {group.title(currHeader.groupKey, groupItems)} -
+ ); return ( From 516d39cf970ed02cd7a669790a8ab6792777a68a 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 14:55:09 +0800 Subject: [PATCH 2/8] fix sticky group header offset --- src/GroupHeader.tsx | 8 +++++++- src/hooks/useStickyGroupHeader.tsx | 11 +++------- tests/hooks.test.tsx | 33 +++++++++++++++++++++--------- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/GroupHeader.tsx b/src/GroupHeader.tsx index 25ca6d9..ceadf3d 100644 --- a/src/GroupHeader.tsx +++ b/src/GroupHeader.tsx @@ -8,6 +8,7 @@ export interface GroupHeaderProps { groupItems: T[]; prefixCls: string; sticky?: boolean; + style?: React.CSSProperties; variant?: 'list' | 'sticky'; } @@ -20,6 +21,7 @@ export default function GroupHeader( groupItems, prefixCls, sticky, + style, variant = 'list', } = props; @@ -32,5 +34,9 @@ export default function GroupHeader( }, ); - return
{group.title(groupKey, groupItems)}
; + return ( +
+ {group.title(groupKey, groupItems)} +
+ ); } diff --git a/src/hooks/useStickyGroupHeader.tsx b/src/hooks/useStickyGroupHeader.tsx index ec59cb9..fbf596a 100644 --- a/src/hooks/useStickyGroupHeader.tsx +++ b/src/hooks/useStickyGroupHeader.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import Portal from '@rc-component/portal'; import type { ListRef } from 'rc-virtual-list'; import type { ExtraRenderInfo } from 'rc-virtual-list/lib/interface'; import type { Group } from './useGroupSegments'; @@ -102,22 +101,18 @@ export default function useStickyGroupHeader< const currHeader = headerRows[activeHeaderIdx]; const groupItems = groupKeyToItems.get(currHeader.groupKey) || []; + const top = scrollTop - info.offsetY; - const headerNode = ( + return ( ); - - return ( - container}> - {headerNode} - - ); }, [ enabled, diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 30a7886..0130e9e 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -204,7 +204,7 @@ describe('useStickyGroupHeader', () => { ['Group 2', baseItems.slice(3, 6)], ]); - it('renders sticky portal for the active header row', () => { + it('renders sticky header for the active header row', () => { const container = document.createElement('div'); document.body.appendChild(container); const containerRef = createRefObject(container); @@ -233,20 +233,23 @@ describe('useStickyGroupHeader', () => { prefixCls: PREFIX_CLS, }; - const { unmount } = render( + const { container: renderContainer, unmount } = render( , ); - const stickyHeader = container.querySelector(`.${PREFIX_CLS}-sticky-header`); + const stickyHeader = renderContainer.querySelector( + `.${PREFIX_CLS}-sticky-header`, + ); expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveTextContent('Group 2-3'); + expect(stickyHeader).toHaveStyle({ top: '5px' }); expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); unmount(); document.body.removeChild(container); }); - it('skips portal rendering when virtual list is disabled', () => { + it('skips sticky header rendering when virtual list is disabled', () => { const container = document.createElement('div'); document.body.appendChild(container); const containerRef = createRefObject(container); @@ -265,9 +268,13 @@ describe('useStickyGroupHeader', () => { prefixCls: PREFIX_CLS, }; - const { unmount } = render(); + const { container: renderContainer, unmount } = render( + , + ); - const stickyHeader = container.querySelector(`.${PREFIX_CLS}-sticky-header`); + const stickyHeader = renderContainer.querySelector( + `.${PREFIX_CLS}-sticky-header`, + ); expect(stickyHeader).toBeNull(); unmount(); @@ -310,11 +317,13 @@ describe('useStickyGroupHeader', () => { prefixCls: PREFIX_CLS, }; - const { unmount } = render( + const { container: renderContainer, unmount } = render( , ); - const stickyHeader = container.querySelector(`.${PREFIX_CLS}-sticky-header`); + const stickyHeader = renderContainer.querySelector( + `.${PREFIX_CLS}-sticky-header`, + ); expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveTextContent('Group 2'); expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); @@ -343,6 +352,7 @@ describe('useStickyGroupHeader', () => { )); const info = createRenderInfo({ + offsetY: 64, start: 3, getSize: (key: React.Key) => { if (key === 'Group 1') { @@ -368,13 +378,16 @@ describe('useStickyGroupHeader', () => { prefixCls: PREFIX_CLS, }; - const { unmount } = render( + const { container: renderContainer, unmount } = render( , ); - const stickyHeader = container.querySelector(`.${PREFIX_CLS}-sticky-header`); + const stickyHeader = renderContainer.querySelector( + `.${PREFIX_CLS}-sticky-header`, + ); expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveTextContent('Group 2'); + expect(stickyHeader).toHaveStyle({ top: '16px' }); unmount(); document.body.removeChild(container); From 5e8694d964376347f3fccd4a6996af3dfe8c17b6 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 15:06:18 +0800 Subject: [PATCH 3/8] align group header class names --- assets/index.less | 15 +++++++-------- src/GroupHeader.tsx | 16 ++++++---------- src/hooks/useStickyGroupHeader.tsx | 2 +- tests/hooks.test.tsx | 10 ++++++---- tests/listy.behavior.test.tsx | 1 + 5 files changed, 21 insertions(+), 23 deletions(-) diff --git a/assets/index.less b/assets/index.less index 9dbf395..c2ddd93 100644 --- a/assets/index.less +++ b/assets/index.less @@ -12,15 +12,14 @@ left: 0; right: 0; } - } - &-sticky-header { - position: absolute; - top: 0; - left: 0; - right: 0; - transform: translateY(0); - background-color: #fff; + &-fixed { + position: absolute; + top: 0; + left: 0; + right: 0; + transform: translateY(0); + } } &-scrollbar { diff --git a/src/GroupHeader.tsx b/src/GroupHeader.tsx index ceadf3d..e0a8368 100644 --- a/src/GroupHeader.tsx +++ b/src/GroupHeader.tsx @@ -7,9 +7,9 @@ export interface GroupHeaderProps { groupKey: K; groupItems: T[]; prefixCls: string; + fixed?: boolean; sticky?: boolean; style?: React.CSSProperties; - variant?: 'list' | 'sticky'; } export default function GroupHeader( @@ -20,19 +20,15 @@ export default function GroupHeader( groupKey, groupItems, prefixCls, + fixed, sticky, style, - variant = 'list', } = props; - const className = clsx( - variant === 'sticky' - ? `${prefixCls}-sticky-header` - : `${prefixCls}-group-header`, - { - [`${prefixCls}-group-header-sticky`]: variant === 'list' && sticky, - }, - ); + const className = clsx(`${prefixCls}-group-header`, { + [`${prefixCls}-group-header-sticky`]: sticky, + [`${prefixCls}-group-header-fixed`]: fixed, + }); return (
diff --git a/src/hooks/useStickyGroupHeader.tsx b/src/hooks/useStickyGroupHeader.tsx index fbf596a..2e708e6 100644 --- a/src/hooks/useStickyGroupHeader.tsx +++ b/src/hooks/useStickyGroupHeader.tsx @@ -105,12 +105,12 @@ export default function useStickyGroupHeader< return ( ); }, diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 0130e9e..df20202 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -238,9 +238,11 @@ describe('useStickyGroupHeader', () => { ); const stickyHeader = renderContainer.querySelector( - `.${PREFIX_CLS}-sticky-header`, + `.${PREFIX_CLS}-group-header-fixed`, ); expect(stickyHeader).not.toBeNull(); + expect(stickyHeader).toHaveClass(`${PREFIX_CLS}-group-header`); + expect(stickyHeader).toHaveClass(`${PREFIX_CLS}-group-header-fixed`); expect(stickyHeader).toHaveTextContent('Group 2-3'); expect(stickyHeader).toHaveStyle({ top: '5px' }); expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); @@ -273,7 +275,7 @@ describe('useStickyGroupHeader', () => { ); const stickyHeader = renderContainer.querySelector( - `.${PREFIX_CLS}-sticky-header`, + `.${PREFIX_CLS}-group-header-fixed`, ); expect(stickyHeader).toBeNull(); @@ -322,7 +324,7 @@ describe('useStickyGroupHeader', () => { ); const stickyHeader = renderContainer.querySelector( - `.${PREFIX_CLS}-sticky-header`, + `.${PREFIX_CLS}-group-header-fixed`, ); expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveTextContent('Group 2'); @@ -383,7 +385,7 @@ describe('useStickyGroupHeader', () => { ); const stickyHeader = renderContainer.querySelector( - `.${PREFIX_CLS}-sticky-header`, + `.${PREFIX_CLS}-group-header-fixed`, ); expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveTextContent('Group 2'); diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index 9e6e6db..8cb1234 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -139,6 +139,7 @@ describe('Listy behaviors', () => { '.rc-listy-group-header-sticky', ); expect(stickyHeader).not.toBeNull(); + expect(stickyHeader).toHaveClass('rc-listy-group-header'); expect(stickyHeader).toHaveTextContent('Group Group A'); expect(title).toHaveBeenCalled(); }); From 0eb4adc2abea2f33b7cf2473a81f116a42f6035c 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 15:16:54 +0800 Subject: [PATCH 4/8] simplify sticky group header render --- src/List.tsx | 6 +-- src/hooks/useStickyGroupHeader.tsx | 85 ++++-------------------------- tests/hooks.test.tsx | 85 +++++------------------------- 3 files changed, 24 insertions(+), 152 deletions(-) diff --git a/src/List.tsx b/src/List.tsx index 8a985aa..fdb288d 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -64,7 +64,6 @@ function Listy( // =============================== Refs =============================== const listRef = React.useRef(null); - const containerRef = React.useRef(null); // ========================== Imperative API ========================== useImperativeHandle(ref, () => ({ @@ -106,11 +105,10 @@ function Listy( // =========================== Sticky Header =========================== const extraRender = useStickyGroupHeader({ - enabled: !!(sticky && group), + enabled: !!(sticky && group && virtual), group, headerRows, groupKeyToItems, - containerRef, listRef, prefixCls, }); @@ -139,7 +137,7 @@ function Listy( // ============================== Render =============================== return ( -
+
{ group: Group | undefined; headerRows: { groupKey: K; rowIndex: number }[]; groupKeyToItems: Map; - containerRef: React.RefObject; listRef: React.RefObject; prefixCls: string; } @@ -23,85 +22,29 @@ export default function useStickyGroupHeader< group, headerRows, groupKeyToItems, - containerRef, listRef, prefixCls, } = params; - const lastHeaderIdxRef = React.useRef(0); - const extraRender = React.useCallback( (info: ExtraRenderInfo) => { - const { virtual } = info; + const { offsetY, start, virtual } = info; if (!enabled || !group || !headerRows.length || !virtual) { - lastHeaderIdxRef.current = 0; - return null; - } - - const container = containerRef.current; - if (!container) { return null; } - // maybe rc-virtual-list will expose scrollTop in the future - const getHolderScrollTop = () => { - const holder = - container.querySelector(`.${prefixCls}-holder`) || - listRef.current?.nativeElement?.querySelector?.( - `.${prefixCls}-holder`, - ); - if (holder) { - return holder.scrollTop; - } - const infoScrollTop = listRef.current?.getScrollInfo?.().y ?? 0; - return infoScrollTop; - }; - - const resolveByScrollTop = (scrollTop: number) => { - const cachedIdx = lastHeaderIdxRef.current; - const cachedRow = headerRows[cachedIdx]; - const cachedTop = cachedRow - ? info.getSize(cachedRow.groupKey).top - : null; - const nextRow = headerRows[cachedIdx + 1]; - const nextTop = nextRow ? info.getSize(nextRow.groupKey).top : null; - - if ( - cachedRow && - cachedTop !== null && - scrollTop >= cachedTop && - (nextTop === null || scrollTop < nextTop) - ) { - return cachedIdx; + let currHeader = headerRows[0]; + for (let i = headerRows.length - 1; i >= 0; i -= 1) { + if (headerRows[i].rowIndex <= start) { + currHeader = headerRows[i]; + break; } + } - let lo = 0; - let hi = headerRows.length - 1; - let candidate = 0; - - while (lo <= hi) { - const mid = Math.floor((lo + hi) / 2); - const { top } = info.getSize(headerRows[mid].groupKey); - if (top <= scrollTop) { - candidate = mid; - lo = mid + 1; - } else { - hi = mid - 1; - } - } - - return candidate; - }; - - const scrollTop = getHolderScrollTop(); - const activeHeaderIdx = resolveByScrollTop(scrollTop); - - lastHeaderIdxRef.current = activeHeaderIdx; - - const currHeader = headerRows[activeHeaderIdx]; const groupItems = groupKeyToItems.get(currHeader.groupKey) || []; - const top = scrollTop - info.offsetY; + const scrollTop = listRef.current?.getScrollInfo?.().y ?? offsetY; + const top = scrollTop - offsetY; return ( ); }, - [ - enabled, - group, - headerRows, - groupKeyToItems, - containerRef, - listRef, - prefixCls, - ], + [enabled, group, headerRows, groupKeyToItems, listRef, prefixCls], ); return extraRender; diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index df20202..c82e64b 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -39,13 +39,6 @@ const StickyHeaderTester = ({ return <>{extraRender(info)}; }; -const createRefObject = ( - element: T, -): React.RefObject => - ({ - current: element, - } as React.RefObject); - const createListRef = ( overrides: Partial = {}, ): React.RefObject => { @@ -205,10 +198,6 @@ describe('useStickyGroupHeader', () => { ]); it('renders sticky header for the active header row', () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const containerRef = createRefObject(container); - const title = jest .fn() .mockImplementation((key: React.Key, groupItems: GroupedItem[]) => ( @@ -225,15 +214,13 @@ describe('useStickyGroupHeader', () => { }, headerRows, groupKeyToItems: baseItemsMap, - containerRef, listRef: createListRef({ - nativeElement: container, getScrollInfo: () => ({ x: 0, y: info.start }), }), prefixCls: PREFIX_CLS, }; - const { container: renderContainer, unmount } = render( + const { container: renderContainer } = render( , ); @@ -246,16 +233,9 @@ describe('useStickyGroupHeader', () => { expect(stickyHeader).toHaveTextContent('Group 2-3'); expect(stickyHeader).toHaveStyle({ top: '5px' }); expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); - - unmount(); - document.body.removeChild(container); }); it('skips sticky header rendering when virtual list is disabled', () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const containerRef = createRefObject(container); - const info = createRenderInfo({ virtual: false }); const params: StickyHeaderParams = { enabled: true, @@ -265,12 +245,11 @@ describe('useStickyGroupHeader', () => { }, headerRows, groupKeyToItems: baseItemsMap, - containerRef, - listRef: createListRef({ nativeElement: container }), + listRef: createListRef(), prefixCls: PREFIX_CLS, }; - const { container: renderContainer, unmount } = render( + const { container: renderContainer } = render( , ); @@ -278,15 +257,9 @@ describe('useStickyGroupHeader', () => { `.${PREFIX_CLS}-group-header-fixed`, ); expect(stickyHeader).toBeNull(); - - unmount(); - document.body.removeChild(container); }); - it('syncs sticky header with scrollTop even if start index is stale', () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const containerRef = createRefObject(container); + it('uses the visible start row to resolve the active header', () => { const listRef = createListRef({ getScrollInfo: () => ({ x: 0, y: 80 }) }); const title = jest.fn().mockImplementation((key: React.Key) => ( @@ -294,16 +267,8 @@ describe('useStickyGroupHeader', () => { )); const info = createRenderInfo({ - start: 3, - getSize: (key: React.Key) => { - if (key === 'Group 1') { - return { top: 0, bottom: 60 }; - } - if (key === 'Group 2') { - return { top: 80, bottom: 120 }; - } - return { top: 0, bottom: 0 }; - }, + offsetY: 80, + start: 4, }); const params: StickyHeaderParams = { @@ -314,12 +279,11 @@ describe('useStickyGroupHeader', () => { }, headerRows, groupKeyToItems: baseItemsMap, - containerRef, listRef, prefixCls: PREFIX_CLS, }; - const { container: renderContainer, unmount } = render( + const { container: renderContainer } = render( , ); @@ -328,25 +292,13 @@ describe('useStickyGroupHeader', () => { ); expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveTextContent('Group 2'); + expect(stickyHeader).toHaveStyle({ top: '0px' }); expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); - - unmount(); - document.body.removeChild(container); }); - it('prefers holder scrollTop over virtual start', () => { - const holder = document.createElement('div'); - holder.className = `${PREFIX_CLS}-holder`; - holder.scrollTop = 80; - - const container = document.createElement('div'); - container.appendChild(holder); - document.body.appendChild(container); - - const containerRef = createRefObject(container); + it('offsets the fixed header by the virtual filler position', () => { const listRef = createListRef({ - nativeElement: container, - getScrollInfo: () => ({ x: 0, y: 0 }), + getScrollInfo: () => ({ x: 0, y: 80 }), }); const title = jest.fn().mockImplementation((key: React.Key) => ( @@ -355,16 +307,7 @@ describe('useStickyGroupHeader', () => { const info = createRenderInfo({ offsetY: 64, - start: 3, - getSize: (key: React.Key) => { - if (key === 'Group 1') { - return { top: 0, bottom: 60 }; - } - if (key === 'Group 2') { - return { top: 80, bottom: 120 }; - } - return { top: 0, bottom: 0 }; - }, + start: 4, }); const params: StickyHeaderParams = { @@ -375,12 +318,11 @@ describe('useStickyGroupHeader', () => { }, headerRows, groupKeyToItems: baseItemsMap, - containerRef, listRef, prefixCls: PREFIX_CLS, }; - const { container: renderContainer, unmount } = render( + const { container: renderContainer } = render( , ); @@ -390,8 +332,5 @@ describe('useStickyGroupHeader', () => { expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveTextContent('Group 2'); expect(stickyHeader).toHaveStyle({ top: '16px' }); - - unmount(); - document.body.removeChild(container); }); }); From 48f3e302c7cd3eed5b9ae720a79e05837a52e713 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 15:22:59 +0800 Subject: [PATCH 5/8] fix fixed group header scroll lag --- src/hooks/useStickyGroupHeader.tsx | 8 +++++++- tests/hooks.test.tsx | 22 +++++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/hooks/useStickyGroupHeader.tsx b/src/hooks/useStickyGroupHeader.tsx index 9bb393b..babc31b 100644 --- a/src/hooks/useStickyGroupHeader.tsx +++ b/src/hooks/useStickyGroupHeader.tsx @@ -43,7 +43,13 @@ export default function useStickyGroupHeader< } const groupItems = groupKeyToItems.get(currHeader.groupKey) || []; - const scrollTop = listRef.current?.getScrollInfo?.().y ?? offsetY; + const holder = listRef.current?.nativeElement?.querySelector( + `.${prefixCls}-holder`, + ); + // `extraRender` runs before the imperative ListRef is refreshed for this + // render, but rc-virtual-list syncs the holder scrollTop first. + const scrollTop = + holder?.scrollTop ?? listRef.current?.getScrollInfo?.().y ?? offsetY; const top = scrollTop - offsetY; return ( diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index c82e64b..7b5cb93 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -58,6 +58,22 @@ const createListRef = ( } as React.RefObject; }; +const createListRefWithHolder = ( + holderScrollTop: number, + overrides: Partial = {}, +): React.RefObject => { + const nativeElement = document.createElement('div'); + const holder = document.createElement('div'); + holder.className = `${PREFIX_CLS}-holder`; + holder.scrollTop = holderScrollTop; + nativeElement.appendChild(holder); + + return createListRef({ + nativeElement, + ...overrides, + }); +}; + describe('useGroupSegments', () => { it('groups items by key across the full data set', () => { const items: GroupedItem[] = [ @@ -296,9 +312,9 @@ describe('useStickyGroupHeader', () => { expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); }); - it('offsets the fixed header by the virtual filler position', () => { - const listRef = createListRef({ - getScrollInfo: () => ({ x: 0, y: 80 }), + it('uses holder scrollTop to avoid stale ref offset while scrolling', () => { + const listRef = createListRefWithHolder(80, { + getScrollInfo: () => ({ x: 0, y: 64 }), }); const title = jest.fn().mockImplementation((key: React.Key) => ( From 1921df25473e9b0ed8667345ff52413302a6f80f 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 15:37:35 +0800 Subject: [PATCH 6/8] use extra render scrollTop for sticky headers --- src/List.tsx | 1 - src/hooks/useStickyGroupHeader.tsx | 31 +++++---- tests/hooks.test.tsx | 108 +++++++++++++++-------------- 3 files changed, 73 insertions(+), 67 deletions(-) diff --git a/src/List.tsx b/src/List.tsx index fdb288d..72f26f7 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -109,7 +109,6 @@ function Listy( group, headerRows, groupKeyToItems, - listRef, prefixCls, }); diff --git a/src/hooks/useStickyGroupHeader.tsx b/src/hooks/useStickyGroupHeader.tsx index babc31b..7b274bd 100644 --- a/src/hooks/useStickyGroupHeader.tsx +++ b/src/hooks/useStickyGroupHeader.tsx @@ -1,15 +1,17 @@ import * as React from 'react'; -import type { ListRef } from 'rc-virtual-list'; import type { ExtraRenderInfo } from 'rc-virtual-list/lib/interface'; import type { Group } from './useGroupSegments'; import GroupHeader from '../GroupHeader'; +type StickyExtraRenderInfo = ExtraRenderInfo & { + scrollTop?: number; +}; + export interface StickyHeaderParams { enabled: boolean; group: Group | undefined; headerRows: { groupKey: K; rowIndex: number }[]; groupKeyToItems: Map; - listRef: React.RefObject; prefixCls: string; } @@ -22,35 +24,38 @@ export default function useStickyGroupHeader< group, headerRows, groupKeyToItems, - listRef, prefixCls, } = params; const extraRender = React.useCallback( (info: ExtraRenderInfo) => { - const { offsetY, start, virtual } = info; + const { getSize, offsetY, scrollTop = offsetY, start, virtual } = + info as StickyExtraRenderInfo; if (!enabled || !group || !headerRows.length || !virtual) { 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 groupItems = groupKeyToItems.get(currHeader.groupKey) || []; - const holder = listRef.current?.nativeElement?.querySelector( - `.${prefixCls}-holder`, - ); - // `extraRender` runs before the imperative ListRef is refreshed for this - // render, but rc-virtual-list syncs the holder scrollTop first. - const scrollTop = - holder?.scrollTop ?? listRef.current?.getScrollInfo?.().y ?? offsetY; - const top = scrollTop - offsetY; + const currentSize = getSize(currHeader.groupKey); + const headerHeight = currentSize.bottom - currentSize.top; + const fixedTop = scrollTop - offsetY; + + const nextHeader = headerRows[activeHeaderIdx + 1]; + const nextTop = nextHeader + ? getSize(nextHeader.groupKey).top - headerHeight - offsetY + : fixedTop; + const top = Math.min(fixedTop, nextTop); return ( ); }, - [enabled, group, headerRows, groupKeyToItems, listRef, prefixCls], + [enabled, group, headerRows, groupKeyToItems, prefixCls], ); return extraRender; diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 7b5cb93..3608e32 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { render, renderHook } from '@testing-library/react'; -import type { ListRef } from 'rc-virtual-list'; import type { ExtraRenderInfo } from 'rc-virtual-list/lib/interface'; import useFlattenRows from '../src/hooks/useFlattenRows'; @@ -15,13 +14,18 @@ interface GroupedItem { group: string; } +type StickyExtraRenderInfo = ExtraRenderInfo & { + scrollTop?: number; +}; + const createRenderInfo = ( - overrides: Partial = {}, -): ExtraRenderInfo => ({ + overrides: Partial = {}, +): StickyExtraRenderInfo => ({ start: 0, end: 0, virtual: true, offsetX: 0, + scrollTop: 0, offsetY: 0, rtl: false, getSize: () => ({ top: 0, bottom: 0 }), @@ -33,47 +37,12 @@ const StickyHeaderTester = ({ info, }: { params: StickyHeaderParams; - info: ExtraRenderInfo; + info: StickyExtraRenderInfo; }) => { const extraRender = useStickyGroupHeader(params); return <>{extraRender(info)}; }; -const createListRef = ( - overrides: Partial = {}, -): React.RefObject => { - const defaultHolder = document.createElement('div'); - const base: ListRef = { - nativeElement: defaultHolder, - getScrollInfo: () => ({ x: 0, y: 0 }), - scrollTo: () => {}, - }; - - return { - current: { - ...base, - ...overrides, - nativeElement: overrides.nativeElement ?? base.nativeElement, - }, - } as React.RefObject; -}; - -const createListRefWithHolder = ( - holderScrollTop: number, - overrides: Partial = {}, -): React.RefObject => { - const nativeElement = document.createElement('div'); - const holder = document.createElement('div'); - holder.className = `${PREFIX_CLS}-holder`; - holder.scrollTop = holderScrollTop; - nativeElement.appendChild(holder); - - return createListRef({ - nativeElement, - ...overrides, - }); -}; - describe('useGroupSegments', () => { it('groups items by key across the full data set', () => { const items: GroupedItem[] = [ @@ -221,7 +190,7 @@ describe('useStickyGroupHeader', () => { {String(key)}-{groupItems.length} )); - const info = createRenderInfo({ start: 5 }); + const info = createRenderInfo({ scrollTop: 5, start: 5 }); const params: StickyHeaderParams = { enabled: true, group: { @@ -230,9 +199,6 @@ describe('useStickyGroupHeader', () => { }, headerRows, groupKeyToItems: baseItemsMap, - listRef: createListRef({ - getScrollInfo: () => ({ x: 0, y: info.start }), - }), prefixCls: PREFIX_CLS, }; @@ -261,7 +227,6 @@ describe('useStickyGroupHeader', () => { }, headerRows, groupKeyToItems: baseItemsMap, - listRef: createListRef(), prefixCls: PREFIX_CLS, }; @@ -276,14 +241,13 @@ describe('useStickyGroupHeader', () => { }); it('uses the visible start row to resolve the active header', () => { - const listRef = createListRef({ getScrollInfo: () => ({ x: 0, y: 80 }) }); - const title = jest.fn().mockImplementation((key: React.Key) => ( {String(key)} )); const info = createRenderInfo({ offsetY: 80, + scrollTop: 80, start: 4, }); @@ -295,7 +259,6 @@ describe('useStickyGroupHeader', () => { }, headerRows, groupKeyToItems: baseItemsMap, - listRef, prefixCls: PREFIX_CLS, }; @@ -312,17 +275,14 @@ describe('useStickyGroupHeader', () => { expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); }); - it('uses holder scrollTop to avoid stale ref offset while scrolling', () => { - const listRef = createListRefWithHolder(80, { - getScrollInfo: () => ({ x: 0, y: 64 }), - }); - + it('offsets the fixed header by the extra render scrollTop', () => { const title = jest.fn().mockImplementation((key: React.Key) => ( {String(key)} )); const info = createRenderInfo({ offsetY: 64, + scrollTop: 80, start: 4, }); @@ -334,7 +294,6 @@ describe('useStickyGroupHeader', () => { }, headerRows, groupKeyToItems: baseItemsMap, - listRef, prefixCls: PREFIX_CLS, }; @@ -349,4 +308,47 @@ describe('useStickyGroupHeader', () => { expect(stickyHeader).toHaveTextContent('Group 2'); expect(stickyHeader).toHaveStyle({ top: '16px' }); }); + + it('pushes the fixed header away when the next group reaches the top', () => { + const title = jest.fn().mockImplementation((key: React.Key) => ( + {String(key)} + )); + + const info = createRenderInfo({ + offsetY: 64, + scrollTop: 70, + start: 3, + getSize: (key: React.Key) => { + if (key === 'Group 1') { + return { top: 0, bottom: 20 }; + } + if (key === 'Group 2') { + return { top: 80, bottom: 100 }; + } + return { top: 0, bottom: 0 }; + }, + }); + + const params: StickyHeaderParams = { + enabled: true, + group: { + key: (item) => item.group, + title, + }, + headerRows, + groupKeyToItems: baseItemsMap, + prefixCls: PREFIX_CLS, + }; + + const { container: renderContainer } = render( + , + ); + + const stickyHeader = renderContainer.querySelector( + `.${PREFIX_CLS}-group-header-fixed`, + ); + expect(stickyHeader).not.toBeNull(); + expect(stickyHeader).toHaveTextContent('Group 1'); + expect(stickyHeader).toHaveStyle({ top: '-4px' }); + }); }); From 1644394edb616dc99d0f9bb29a36150493ce2149 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 17:09:19 +0800 Subject: [PATCH 7/8] switch to scoped virtual list package --- package.json | 2 +- src/List.tsx | 6 ++++-- src/hooks/useStickyGroupHeader.tsx | 6 +++++- tests/hooks.test.tsx | 6 +++++- tests/listy.behavior.test.tsx | 10 +++++++--- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index e31a9fb..3879d7d 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-virtual-list": "^3.19.2" + "@rc-component/virtual-list": "^1.1.0" }, "devDependencies": { "@rc-component/father-plugin": "^2.1.3", diff --git a/src/List.tsx b/src/List.tsx index 72f26f7..0f70d18 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; -import VirtualList, { type ListRef } from 'rc-virtual-list'; -import type { ScrollTo } from 'rc-virtual-list/lib/List'; +import VirtualList, { + type ListRef, + type ScrollTo, +} from '@rc-component/virtual-list'; import { useImperativeHandle, forwardRef } from 'react'; import useGroupSegments from './hooks/useGroupSegments'; import type { Group } from './hooks/useGroupSegments'; diff --git a/src/hooks/useStickyGroupHeader.tsx b/src/hooks/useStickyGroupHeader.tsx index 7b274bd..e4ab2a2 100644 --- a/src/hooks/useStickyGroupHeader.tsx +++ b/src/hooks/useStickyGroupHeader.tsx @@ -1,8 +1,12 @@ import * as React from 'react'; -import type { ExtraRenderInfo } from 'rc-virtual-list/lib/interface'; +import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; import type { Group } from './useGroupSegments'; import GroupHeader from '../GroupHeader'; +type ExtraRenderInfo = Parameters< + NonNullable['extraRender']> +>[0]; + type StickyExtraRenderInfo = ExtraRenderInfo & { scrollTop?: number; }; diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 3608e32..967b789 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render, renderHook } from '@testing-library/react'; -import type { ExtraRenderInfo } from 'rc-virtual-list/lib/interface'; +import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; import useFlattenRows from '../src/hooks/useFlattenRows'; import useGroupSegments from '../src/hooks/useGroupSegments'; @@ -14,6 +14,10 @@ interface GroupedItem { group: string; } +type ExtraRenderInfo = Parameters< + NonNullable['extraRender']> +>[0]; + type StickyExtraRenderInfo = ExtraRenderInfo & { scrollTop?: number; }; diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index 8cb1234..400d5bb 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -1,9 +1,13 @@ import React from 'react'; import { act, render } from '@testing-library/react'; -import type { ExtraRenderInfo } from 'rc-virtual-list/lib/interface'; +import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; import Listy, { type ListyRef, type ListyProps } from '@rc-component/listy'; -jest.mock('rc-virtual-list', () => { +type ExtraRenderInfo = Parameters< + NonNullable['extraRender']> +>[0]; + +jest.mock('@rc-component/virtual-list', () => { const React = require('react'); let extraInfo = { start: 0, @@ -62,7 +66,7 @@ type MockedVirtualListComponent = React.ForwardRefExoticComponent & { __getLastProps(): any; }; -const MockedVirtualList = require('rc-virtual-list') +const MockedVirtualList = require('@rc-component/virtual-list') .default as MockedVirtualListComponent; describe('Listy behaviors', () => { From e6b5395a026c90d71e74f950c817b8224ecaf665 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 17:20:18 +0800 Subject: [PATCH 8/8] remove sticky extra render fallback type --- src/hooks/useStickyGroupHeader.tsx | 7 +------ tests/hooks.test.tsx | 10 +++------- tests/listy.behavior.test.tsx | 1 + 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/hooks/useStickyGroupHeader.tsx b/src/hooks/useStickyGroupHeader.tsx index e4ab2a2..d225983 100644 --- a/src/hooks/useStickyGroupHeader.tsx +++ b/src/hooks/useStickyGroupHeader.tsx @@ -7,10 +7,6 @@ type ExtraRenderInfo = Parameters< NonNullable['extraRender']> >[0]; -type StickyExtraRenderInfo = ExtraRenderInfo & { - scrollTop?: number; -}; - export interface StickyHeaderParams { enabled: boolean; group: Group | undefined; @@ -33,8 +29,7 @@ export default function useStickyGroupHeader< const extraRender = React.useCallback( (info: ExtraRenderInfo) => { - const { getSize, offsetY, scrollTop = offsetY, start, virtual } = - info as StickyExtraRenderInfo; + const { getSize, offsetY, scrollTop, start, virtual } = info; if (!enabled || !group || !headerRows.length || !virtual) { return null; diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 967b789..7e0a4d3 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -18,13 +18,9 @@ type ExtraRenderInfo = Parameters< NonNullable['extraRender']> >[0]; -type StickyExtraRenderInfo = ExtraRenderInfo & { - scrollTop?: number; -}; - const createRenderInfo = ( - overrides: Partial = {}, -): StickyExtraRenderInfo => ({ + overrides: Partial = {}, +): ExtraRenderInfo => ({ start: 0, end: 0, virtual: true, @@ -41,7 +37,7 @@ const StickyHeaderTester = ({ info, }: { params: StickyHeaderParams; - info: StickyExtraRenderInfo; + info: ExtraRenderInfo; }) => { const extraRender = useStickyGroupHeader(params); return <>{extraRender(info)}; diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index 400d5bb..9c9b8ad 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -14,6 +14,7 @@ jest.mock('@rc-component/virtual-list', () => { end: 0, virtual: true, offsetX: 0, + scrollTop: 0, offsetY: 0, rtl: false, getSize: () => ({ top: 0, bottom: 0 }),