From 3f012c1163c843a47de64475a0b1739b893bb2b8 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, 25 May 2026 16:53:29 +0800 Subject: [PATCH 01/12] chore: add source section comments --- src/GroupHeader.tsx | 4 ++++ src/List.tsx | 1 + src/RawList/index.tsx | 9 +++++++++ src/RawList/useRawListScroll.ts | 4 ++++ src/VirtualList/index.tsx | 12 ++++++++++++ src/VirtualList/useStickyGroupHeader.tsx | 6 ++++++ src/hooks/useFlattenRows.ts | 5 +++++ src/hooks/useGroupSegments.ts | 5 +++++ src/index.ts | 2 ++ 9 files changed, 48 insertions(+) diff --git a/src/GroupHeader.tsx b/src/GroupHeader.tsx index f9c82c7..6e5f806 100644 --- a/src/GroupHeader.tsx +++ b/src/GroupHeader.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import clsx from 'clsx'; import type { Group } from './hooks/useGroupSegments'; +// ============================== Types =============================== export interface GroupHeaderProps { group: Group; groupKey: K; @@ -16,6 +17,7 @@ function GroupHeader( props: GroupHeaderProps, ref: React.Ref, ) { + // ============================== Props ============================== const { group, groupKey, @@ -26,11 +28,13 @@ function GroupHeader( style, } = props; + // ============================= Classes ============================= const className = clsx(`${prefixCls}-group-header`, { [`${prefixCls}-group-header-sticky`]: sticky, [`${prefixCls}-group-header-fixed`]: fixed, }); + // ============================== Render ============================== return (
{group.title(groupKey, groupItems)} diff --git a/src/List.tsx b/src/List.tsx index 3224400..89cf13f 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -4,6 +4,7 @@ import RawList from './RawList'; import VirtualList from './VirtualList'; import type { Group } from './hooks/useGroupSegments'; +// ============================== Types =============================== export type RowKey = keyof T | ((item: T) => React.Key); export type ScrollAlign = 'top' | 'bottom' | 'auto'; diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index 8e11d44..d338358 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -6,6 +6,7 @@ import useGroupSegments from '../hooks/useGroupSegments'; import useRawListScroll from './useRawListScroll'; import type { ListComponentProps, ListyRef } from '../List'; +// ============================== Types =============================== export type RawListProps = ListComponentProps; @@ -13,6 +14,7 @@ function RawList( props: RawListProps, ref: React.Ref, ) { + // ============================== Props ============================== const { data, group, @@ -24,12 +26,16 @@ function RawList( sticky, } = props; + // =============================== Refs =============================== const holderRef = useRawListScroll(ref); + + // =============================== Data =============================== const groupData = useGroupSegments(data, group); const [headerHeights, setHeaderHeights] = React.useState< Map >(() => new Map()); + // ============================== Utils =============================== const getItemKey = useEvent((item: T): React.Key => { if (typeof rowKey === 'function') { return rowKey(item); @@ -55,6 +61,7 @@ function RawList( [], ); + // ============================ Render Item =========================== const renderItem = React.useCallback( (item: T, index: number, groupKey?: K) => { const key = getItemKey(item); @@ -89,6 +96,7 @@ function RawList( ], ); + // ============================= Content ============================== const rawContent = group ? Array.from(groupData, ([groupKey, groupItems]) => { const currentGroupItems = groupItems.map(({ item }) => item); @@ -123,6 +131,7 @@ function RawList( return renderItem(item, index); }); + // ============================== Render ============================== return (
) { + // =============================== Refs =============================== const holderRef = React.useRef(null); + // ============================== Scroll ============================== const scrollTo: ListyRef['scrollTo'] = React.useCallback( (config) => { const holder = holderRef.current; @@ -48,6 +50,7 @@ export default function useRawListScroll(ref: React.Ref) { [], ); + // ============================ Imperative ============================ React.useImperativeHandle( ref, () => ({ @@ -56,5 +59,6 @@ export default function useRawListScroll(ref: React.Ref) { [scrollTo], ); + // ============================== Return ============================== return holderRef; } diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index 7fa6d6a..01f4675 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -12,6 +12,7 @@ import type { Row } from '../hooks/useFlattenRows'; import useGroupSegments from '../hooks/useGroupSegments'; import useStickyGroupHeader from './useStickyGroupHeader'; +// ============================== Types =============================== export type VirtualListProps< T, K extends React.Key = React.Key, @@ -21,6 +22,7 @@ function VirtualList( props: VirtualListProps, ref: React.Ref, ) { + // ============================== Props ============================== const { data, group, @@ -33,10 +35,13 @@ function VirtualList( sticky, } = props; + // =============================== Refs =============================== const listRef = React.useRef(null); + // =============================== Data =============================== const groupData = useGroupSegments(data, group); + // =============================== Keys =============================== const getItemKey = useEvent((item: T): React.Key => { if (typeof rowKey === 'function') { return rowKey(item); @@ -52,12 +57,14 @@ function VirtualList( return getItemKey(row.item); }); + // ============================== Rows ================================ const { rows, headerRows, groupKeyToItems } = useFlattenRows( data, groupData, group, ); + // ============================== Lookup ============================== const itemKeyToGroupKey = React.useMemo(() => { const itemGroupMap = new Map(); @@ -70,6 +77,7 @@ function VirtualList( return itemGroupMap; }, [getItemKey, groupData]); + // ============================== Scroll ============================== const scrollTo = useEvent((config) => { if (config && typeof config === 'object' && 'groupKey' in config) { const { groupKey, align, offset } = config; @@ -111,6 +119,7 @@ function VirtualList( listRef.current?.scrollTo(config as number | ScrollConfig | null); }); + // ============================ Imperative ============================ React.useImperativeHandle( ref, () => ({ @@ -119,6 +128,7 @@ function VirtualList( [scrollTo], ); + // ============================== Sticky ============================== const extraRender = useStickyGroupHeader({ enabled: !!(sticky && group), group, @@ -127,6 +137,7 @@ function VirtualList( prefixCls, }); + // ============================ Render Row ============================ const renderHeaderRow = React.useCallback( (groupKey: K) => { const groupItems = groupKeyToItems.get(groupKey) || []; @@ -143,6 +154,7 @@ function VirtualList( [group, groupKeyToItems, prefixCls], ); + // ============================== Render ============================== return ( ['extraRender']> >[0]; type HeaderRow = { groupKey: K; rowIndex: number }; +// ============================== Utils =============================== // `headerRows` is sorted by rowIndex. Find the last header not after `start`. function findActiveHeaderIndex( headerRows: HeaderRow[], @@ -32,6 +34,7 @@ function findActiveHeaderIndex( return activeIndex; } +// ============================== Params ============================== export interface StickyHeaderParams { enabled: boolean; group: Group | undefined; @@ -44,6 +47,7 @@ export default function useStickyGroupHeader< T, K extends React.Key = React.Key, >(params: StickyHeaderParams) { + // ============================== Props ============================== const { enabled, group, @@ -52,6 +56,7 @@ export default function useStickyGroupHeader< prefixCls, } = params; + // ============================ Extra Render ========================== const extraRender = React.useCallback( (info: ExtraRenderInfo) => { const { getSize, offsetY, scrollTop, start, virtual } = info; @@ -88,5 +93,6 @@ export default function useStickyGroupHeader< [enabled, group, headerRows, groupKeyToItems, prefixCls], ); + // ============================== Return ============================== return extraRender; } diff --git a/src/hooks/useFlattenRows.ts b/src/hooks/useFlattenRows.ts index 02b9551..0cb21f7 100644 --- a/src/hooks/useFlattenRows.ts +++ b/src/hooks/useFlattenRows.ts @@ -1,6 +1,7 @@ import * as React from 'react'; import type { Group, GroupSegmentItem } from './useGroupSegments'; +// ============================== Types =============================== export type Row = | { type: 'header'; groupKey: K } | { type: 'item'; item: T; index: number }; @@ -22,10 +23,12 @@ export default function useFlattenRows( group?: Group, ): FlattenRowsResult { return React.useMemo(() => { + // ============================== Init ================================ const flatRows: Row[] = []; const headerRows: { groupKey: K; rowIndex: number }[] = []; const groupKeyToItems = new Map(); + // ============================ No Group ============================== if (!group) { data.forEach((item, index) => { flatRows.push({ type: 'item', item, index }); @@ -34,6 +37,7 @@ export default function useFlattenRows( return { rows: flatRows, headerRows, groupKeyToItems }; } + // ============================= Flatten ============================== groupData.forEach((groupItems, groupKey) => { groupKeyToItems.set( groupKey, @@ -48,6 +52,7 @@ export default function useFlattenRows( }); }); + // ============================== Return ============================== return { rows: flatRows, headerRows, groupKeyToItems }; }, [data, group, groupData]); } diff --git a/src/hooks/useGroupSegments.ts b/src/hooks/useGroupSegments.ts index 8611c4e..9dc01ab 100644 --- a/src/hooks/useGroupSegments.ts +++ b/src/hooks/useGroupSegments.ts @@ -1,5 +1,6 @@ import * as React from 'react'; +// ============================== Types =============================== export interface Group { key: (item: T) => K; title: (groupKey: K, items: T[]) => React.ReactNode; @@ -21,12 +22,15 @@ export default function useGroupSegments( group?: Group, ): Map[]> { return React.useMemo(() => { + // ============================== Init ================================ const map = new Map[]>(); + // ============================ No Group ============================== if (!group) { return map; } + // ============================= Collect ============================== data.forEach((item, index) => { const groupKey = group.key(item); const groupItems = map.get(groupKey); @@ -39,6 +43,7 @@ export default function useGroupSegments( } }); + // ============================== Return ============================== return map; }, [data, group]); } diff --git a/src/index.ts b/src/index.ts index 69fef22..63f849c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ import Listy from './List'; +// ============================== Types =============================== export type { ListyRef, ListyProps } from './List'; +// ============================== Export ============================== export default Listy; From ea9f4b4bd97256de3ec790629015624e75ec9566 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, 25 May 2026 17:17:06 +0800 Subject: [PATCH 02/12] chore: share list ref prop --- src/List.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/List.tsx b/src/List.tsx index 89cf13f..03c3767 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -77,19 +77,14 @@ function Listy( ...restProps, data, prefixCls, + ref, }; const listNode = virtual === false ? ( - + ) : ( - + ); return ( From 3ce617887fe2aec469603ed9c93c116a2d613328 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, 25 May 2026 17:18:25 +0800 Subject: [PATCH 03/12] refactor: remove list wrapper --- src/List.tsx | 6 +----- tests/__snapshots__/listy.test.tsx.snap | 24 ++++++++++-------------- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/src/List.tsx b/src/List.tsx index 03c3767..ef21f1e 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -87,11 +87,7 @@ function Listy( ); - return ( -
- {listNode} -
- ); + return listNode; } // Const to support generic with forwardRef diff --git a/tests/__snapshots__/listy.test.tsx.snap b/tests/__snapshots__/listy.test.tsx.snap index 5a17816..639da46 100644 --- a/tests/__snapshots__/listy.test.tsx.snap +++ b/tests/__snapshots__/listy.test.tsx.snap @@ -4,25 +4,21 @@ exports[`Listy should match snapshot 1`] = `
-
-
+
+
-
-
- 1 -
+
+ 1
From 8ccbacf281ad163b04cc4ada5897789e4449a7b3 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, 25 May 2026 17:19:38 +0800 Subject: [PATCH 04/12] refactor: simplify virtual list branch --- src/List.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/List.tsx b/src/List.tsx index ef21f1e..c6393c9 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -81,10 +81,10 @@ function Listy( }; const listNode = - virtual === false ? ( - - ) : ( + virtual ? ( + ) : ( + ); return listNode; From 8ef0daab95eddf67fdb46c5ad1a10e3f3f0ee5c1 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, 25 May 2026 17:26:06 +0800 Subject: [PATCH 05/12] chore: explain sticky group header render --- src/VirtualList/useStickyGroupHeader.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/VirtualList/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx index 76603b5..b71cef3 100644 --- a/src/VirtualList/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -65,20 +65,25 @@ export default function useStickyGroupHeader< return null; } + // The sticky header is the latest group header before the visible range. const activeHeaderIdx = findActiveHeaderIndex(headerRows, start); const currHeader = headerRows[activeHeaderIdx]; const groupItems = groupKeyToItems.get(currHeader.groupKey) || []; const currentSize = getSize(currHeader.groupKey); const headerHeight = currentSize.bottom - currentSize.top; + + // Convert the virtual list scroll position into the overlay top offset. const fixedTop = scrollTop - offsetY; + // Let the next group header push the current fixed header away. const nextHeader = headerRows[activeHeaderIdx + 1]; const nextTop = nextHeader ? getSize(nextHeader.groupKey).top - headerHeight - offsetY : fixedTop; const top = Math.min(fixedTop, nextTop); + // Render a cloned header above the virtual list items. return ( Date: Mon, 25 May 2026 17:39:48 +0800 Subject: [PATCH 06/12] chore: explain virtual scroll handling --- src/VirtualList/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index 01f4675..ed796da 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -79,6 +79,7 @@ function VirtualList( // ============================== Scroll ============================== const scrollTo = useEvent((config) => { + // Group headers are rows in the virtual data, so group scroll maps to key scroll. if (config && typeof config === 'object' && 'groupKey' in config) { const { groupKey, align, offset } = config; listRef.current?.scrollTo({ @@ -89,6 +90,7 @@ function VirtualList( return; } + // For sticky grouped lists, top-aligned item scroll should land below its header. if ( config && typeof config === 'object' && @@ -116,6 +118,7 @@ function VirtualList( } } + // Other scroll shapes are already supported by the underlying virtual list. listRef.current?.scrollTo(config as number | ScrollConfig | null); }); From 5bf1520d7c4a4683437bca5d8d7fec5c09127c57 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, 25 May 2026 17:41:21 +0800 Subject: [PATCH 07/12] refactor: colocate virtual flatten rows hook --- src/VirtualList/index.tsx | 4 ++-- src/{hooks => VirtualList}/useFlattenRows.ts | 2 +- tests/hooks.test.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/{hooks => VirtualList}/useFlattenRows.ts (96%) diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index ed796da..26fffa0 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -7,9 +7,9 @@ import RcVirtualList, { import { useEvent } from '@rc-component/util'; import GroupHeader from '../GroupHeader'; import type { ListComponentProps, ListyRef } from '../List'; -import useFlattenRows from '../hooks/useFlattenRows'; -import type { Row } from '../hooks/useFlattenRows'; import useGroupSegments from '../hooks/useGroupSegments'; +import useFlattenRows from './useFlattenRows'; +import type { Row } from './useFlattenRows'; import useStickyGroupHeader from './useStickyGroupHeader'; // ============================== Types =============================== diff --git a/src/hooks/useFlattenRows.ts b/src/VirtualList/useFlattenRows.ts similarity index 96% rename from src/hooks/useFlattenRows.ts rename to src/VirtualList/useFlattenRows.ts index 0cb21f7..689b6a7 100644 --- a/src/hooks/useFlattenRows.ts +++ b/src/VirtualList/useFlattenRows.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { Group, GroupSegmentItem } from './useGroupSegments'; +import type { Group, GroupSegmentItem } from '../hooks/useGroupSegments'; // ============================== Types =============================== export type Row = diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 02ca1c3..77ca99e 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { render, renderHook } from '@testing-library/react'; import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; -import useFlattenRows from '../src/hooks/useFlattenRows'; import useGroupSegments from '../src/hooks/useGroupSegments'; +import useFlattenRows from '../src/VirtualList/useFlattenRows'; import useStickyGroupHeader from '../src/VirtualList/useStickyGroupHeader'; import type { StickyHeaderParams } from '../src/VirtualList/useStickyGroupHeader'; From 67fa19c3c78c5da5a5b0ca37e6f7a97316148d4c 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, 25 May 2026 17:59:41 +0800 Subject: [PATCH 08/12] refactor: measure raw sticky scroll margin on demand --- src/RawList/index.tsx | 45 +++++++----------------------- src/RawList/useRawListScroll.ts | 49 +++++++++++++++++++++++++++++++-- tests/listy.behavior.test.tsx | 28 +++++++++++++------ 3 files changed, 75 insertions(+), 47 deletions(-) diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index d338358..9b767dc 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -1,5 +1,4 @@ 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'; @@ -27,13 +26,10 @@ function RawList( } = props; // =============================== Refs =============================== - const holderRef = useRawListScroll(ref); + const holderRef = useRawListScroll(ref, prefixCls); // =============================== Data =============================== const groupData = useGroupSegments(data, group); - const [headerHeights, setHeaderHeights] = React.useState< - Map - >(() => new Map()); // ============================== Utils =============================== const getItemKey = useEvent((item: T): React.Key => { @@ -50,33 +46,20 @@ function RawList( [], ); - const setGroupHeaderHeight = React.useCallback( - (groupKey: K, headerHeight: number) => { - setHeaderHeights((prev) => { - const next = new Map(prev); - next.set(groupKey, headerHeight); - return next; - }); - }, - [], - ); - // ============================ Render Item =========================== const renderItem = React.useCallback( (item: T, index: number, groupKey?: K) => { const key = getItemKey(item); const scrollTargetProps = getScrollTargetProps(key); - const headerHeight = - sticky && groupKey !== undefined ? headerHeights.get(groupKey) : 0; return (
( [ getItemKey, getScrollTargetProps, - headerHeights, itemRender, prefixCls, sticky, @@ -107,20 +89,13 @@ function RawList( className={`${prefixCls}-group-section`} {...getScrollTargetProps(groupKey)} > - { - setGroupHeaderHeight(groupKey, offsetHeight); - }} - > - - + {groupItems.map(({ item, index }) => { return renderItem(item, index, groupKey); })} diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts index 2b2286a..af24942 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -1,10 +1,49 @@ import * as React from 'react'; import type { ListyRef, PositionScrollToConfig } from '../List'; -export default function useRawListScroll(ref: React.Ref) { +export default function useRawListScroll( + ref: React.Ref, + prefixCls: string, +) { // =============================== Refs =============================== const holderRef = React.useRef(null); + // ============================== Utils =============================== + const getStickyHeaderHeight = React.useCallback( + (targetElement: HTMLElement) => { + const groupSection = targetElement.closest( + `.${CSS.escape(`${prefixCls}-group-section`)}`, + ); + const groupHeader = groupSection?.querySelector( + `.${CSS.escape(`${prefixCls}-group-header-sticky`)}`, + ); + + if (!groupHeader) { + return 0; + } + + const rect = groupHeader.getBoundingClientRect(); + const height = + rect.height || rect.bottom - rect.top || groupHeader.offsetHeight; + + return Number.isFinite(height) ? height : 0; + }, + [prefixCls], + ); + + const setTargetScrollMargin = React.useCallback( + (targetElement: HTMLElement, align: string) => { + const marginTop = + align === 'top' ? getStickyHeaderHeight(targetElement) : 0; + + targetElement.style.setProperty( + `--${prefixCls}-item-scroll-margin-top`, + `${marginTop}px`, + ); + }, + [getStickyHeaderHeight, prefixCls], + ); + // ============================== Scroll ============================== const scrollTo: ListyRef['scrollTo'] = React.useCallback( (config) => { @@ -19,13 +58,17 @@ export default function useRawListScroll(ref: React.Ref) { } if ('key' in config || 'groupKey' in config) { + const { align = 'top' } = config; const targetKey = 'groupKey' in config ? config.groupKey : config.key; const targetElement = holder.querySelector( `[data-key="${CSS.escape(String(targetKey))}"]`, ); if (targetElement) { - const { align = 'top' } = config; + if ('key' in config) { + setTargetScrollMargin(targetElement, align); + } + targetElement.scrollIntoView({ block: align === 'bottom' @@ -47,7 +90,7 @@ export default function useRawListScroll(ref: React.Ref) { holder.scrollTop = top; } }, - [], + [setTargetScrollMargin], ); // ============================ Imperative ============================ diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index 584e651..477fda0 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -1,6 +1,5 @@ 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'; @@ -358,8 +357,10 @@ describe('Listy behaviors', () => { expect(itemNode).toContainElement(container.querySelector('span')); }); - it('keeps raw sticky group header from covering top-aligned items', async () => { + it('sets raw sticky scroll margin before scrolling top-aligned items', () => { + const ref = React.createRef(); const { container } = renderList({ + ref, virtual: false, sticky: true, group: { @@ -390,17 +391,26 @@ describe('Listy behaviors', () => { }) as DOMRect, ); - await act(async () => { - triggerResize?.([ - { target: groupHeader } as unknown as ResizeObserverEntry, - ]); - await Promise.resolve(); + const itemNode = container.querySelector('[data-key="1"]') as HTMLElement; + const scrollIntoView = jest.fn(() => { + expect( + itemNode.style.getPropertyValue('--rc-listy-item-scroll-margin-top'), + ).toBe('36px'); }); + itemNode.scrollIntoView = scrollIntoView; - const itemNode = container.querySelector('[data-key="1"]') as HTMLElement; + act(() => { + ref.current?.scrollTo({ key: 1, align: 'top' }); + }); expect(itemNode).toHaveClass('rc-listy-item'); - expect(itemNode).toHaveStyle({ scrollMarginTop: '36px' }); + expect(itemNode.style.scrollMarginTop).toBe( + 'var(--rc-listy-item-scroll-margin-top, 0px)', + ); + expect(scrollIntoView).toHaveBeenCalledWith({ + block: 'start', + inline: 'nearest', + }); }); it('scroll to group', () => { From c92a5be6832bfb9c9cfcdfa25bc6d7b45557f0f9 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, 25 May 2026 18:04:48 +0800 Subject: [PATCH 09/12] refactor: gate raw sticky scroll margin --- src/RawList/index.tsx | 2 +- src/RawList/useRawListScroll.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index 9b767dc..b7555b3 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -26,7 +26,7 @@ function RawList( } = props; // =============================== Refs =============================== - const holderRef = useRawListScroll(ref, prefixCls); + const holderRef = useRawListScroll(ref, prefixCls, !!(sticky && group)); // =============================== Data =============================== const groupData = useGroupSegments(data, group); diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts index af24942..b2463f4 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -1,9 +1,10 @@ import * as React from 'react'; -import type { ListyRef, PositionScrollToConfig } from '../List'; +import type { ListyRef, PositionScrollToConfig, ScrollAlign } from '../List'; export default function useRawListScroll( ref: React.Ref, prefixCls: string, + stickyGroup: boolean, ) { // =============================== Refs =============================== const holderRef = React.useRef(null); @@ -11,11 +12,15 @@ export default function useRawListScroll( // ============================== Utils =============================== const getStickyHeaderHeight = React.useCallback( (targetElement: HTMLElement) => { + if (!stickyGroup) { + return 0; + } + const groupSection = targetElement.closest( `.${CSS.escape(`${prefixCls}-group-section`)}`, ); const groupHeader = groupSection?.querySelector( - `.${CSS.escape(`${prefixCls}-group-header-sticky`)}`, + `.${CSS.escape(`${prefixCls}-group-header`)}`, ); if (!groupHeader) { @@ -28,11 +33,11 @@ export default function useRawListScroll( return Number.isFinite(height) ? height : 0; }, - [prefixCls], + [prefixCls, stickyGroup], ); const setTargetScrollMargin = React.useCallback( - (targetElement: HTMLElement, align: string) => { + (targetElement: HTMLElement, align: ScrollAlign) => { const marginTop = align === 'top' ? getStickyHeaderHeight(targetElement) : 0; From 39ab66c287128ff22de708944d94cbd864cc7da3 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, 26 May 2026 14:20:18 +0800 Subject: [PATCH 10/12] refactor: simplify raw list dom --- src/RawList/index.tsx | 4 ++-- tests/listy.behavior.test.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index b7555b3..7cb4670 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -110,7 +110,7 @@ function RawList( return (
( }} onScroll={onScroll} > -
{rawContent}
+ {rawContent}
); } diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index 477fda0..de50e48 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -217,7 +217,7 @@ describe('Listy behaviors', () => { itemRender: (item) => item.name, }); - const holder = container.querySelector('.rc-listy-holder') as HTMLDivElement; + const holder = container.querySelector('.rc-listy') as HTMLDivElement; const itemNodes = container.querySelectorAll('.rc-listy-item'); const secondItem = itemNodes[1] as HTMLElement; const scrollIntoView = jest.fn(); From 0fa4f7d048eb4bcfd6751ede294bb6fece6fd888 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, 26 May 2026 14:24:39 +0800 Subject: [PATCH 11/12] test: cover raw sticky scroll fallback --- tests/listy.behavior.test.tsx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index de50e48..cf831a2 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -413,6 +413,32 @@ describe('Listy behaviors', () => { }); }); + it('falls back to zero raw sticky scroll margin without a header', () => { + const ref = React.createRef(); + const { container } = renderList({ + ref, + virtual: false, + sticky: true, + group: { + key: (item) => item.group, + title: (groupKey) => {String(groupKey)}, + }, + }); + + container.querySelector('.rc-listy-group-header')?.remove(); + + const itemNode = container.querySelector('[data-key="1"]') as HTMLElement; + itemNode.scrollIntoView = jest.fn(); + + act(() => { + ref.current?.scrollTo({ key: 1, align: 'top' }); + }); + + expect( + itemNode.style.getPropertyValue('--rc-listy-item-scroll-margin-top'), + ).toBe('0px'); + }); + it('scroll to group', () => { const scrollHandler = jest.fn(); MockedVirtualList.__setScrollHandler(scrollHandler); From 795e0eb1396160392e4aaeb191dccc1db1ee9fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E7=88=B1=E5=90=83=E7=99=BD=E8=90=9D?= =?UTF-8?q?=E5=8D=9C?= Date: Tue, 26 May 2026 14:40:36 +0800 Subject: [PATCH 12/12] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 63f849c..69fef22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,5 @@ import Listy from './List'; -// ============================== Types =============================== export type { ListyRef, ListyProps } from './List'; -// ============================== Export ============================== export default Listy;