From bdf86cde6e2301c6c3dab0520762227166a3c82b Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Mon, 13 Jul 2026 12:48:59 +0800 Subject: [PATCH 1/7] fix: honor offset in non-virtual scrollTo, export scroll config types The virtual list already applied the `offset` from `scrollTo({ key, align, offset })`, but the non-virtual RawList path ignored it: it set a `--rc-listy-item-scroll-margin-top` CSS variable that only accounted for the sticky header height and never the user's offset, and it never set a bottom margin, so `align: 'bottom'` offsets did nothing. - RawList now applies `scrollMarginTop`/`scrollMarginBottom` directly on the scroll target at scroll time (header height + offset for items on `top` align, offset otherwise), matching the virtual path. - Drop the CSS-variable indirection and the per-render inline scrollMarginTop on every item; the margin is applied only on the target, only when scrolling. - Export `ScrollAlign` and `ListyScrollToConfig` so consumers can type the config they pass to `scrollTo`. - Add a `scroll-to` demo exercising both virtual and raw modes. --- docs/demos/scroll-to.md | 8 ++ docs/examples/scroll-to.tsx | 185 ++++++++++++++++++++++++++++++++ src/RawList/index.tsx | 14 +-- src/RawList/useRawListScroll.ts | 34 +++--- src/index.ts | 2 + tests/listy.behavior.test.tsx | 17 ++- tests/semantic.test.tsx | 43 +++++--- 7 files changed, 250 insertions(+), 53 deletions(-) create mode 100644 docs/demos/scroll-to.md create mode 100644 docs/examples/scroll-to.tsx diff --git a/docs/demos/scroll-to.md b/docs/demos/scroll-to.md new file mode 100644 index 0000000..a287e28 --- /dev/null +++ b/docs/demos/scroll-to.md @@ -0,0 +1,8 @@ +--- +title: Scroll To +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/scroll-to.tsx b/docs/examples/scroll-to.tsx new file mode 100644 index 0000000..576d1e3 --- /dev/null +++ b/docs/examples/scroll-to.tsx @@ -0,0 +1,185 @@ +import React, { useRef, useState } from 'react'; +import Listy, { + type ListyRef, + type ListyScrollToConfig, + type ScrollAlign, +} from '@rc-component/listy'; +import '../../assets/index.less'; + +const GROUP_SIZE = 15; +const GROUP_COUNT = 6; + +interface Row { + id: number; + name: string; + group: string; +} + +const items: Row[] = Array.from( + { length: GROUP_SIZE * GROUP_COUNT }, + (_, index) => ({ + id: index, + name: `Item ${index}`, + group: `Group ${Math.floor(index / GROUP_SIZE)}`, + }), +); + +const GROUP_KEYS = Array.from({ length: GROUP_COUNT }, (_, i) => `Group ${i}`); + +const controlRow: React.CSSProperties = { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, +}; + +export default () => { + const listRef = useRef(null); + const [virtual, setVirtual] = useState(true); + const [align, setAlign] = useState('top'); + const [offset, setOffset] = useState(0); + const [itemKey, setItemKey] = useState(50); + const [groupKey, setGroupKey] = useState('Group 3'); + const [lastConfig, setLastConfig] = useState(); + + // Single entry point so the demo can echo the exact config it passes in. + const run = (config: ListyScrollToConfig) => { + setLastConfig(config); + listRef.current?.scrollTo(config); + }; + + return ( +
+
+ + + + + +
+ + {/* number | { top } — absolute pixel scroll */} +
+ + + +
+ + {/* { key, align, offset } — scroll to an item */} +
+ + + + {/* { groupKey, align, offset } — scroll to a group header */} + + +
+ +
+ last config:{' '} + {lastConfig === undefined ? '—' : JSON.stringify(lastConfig)} +
+ + { + const height = 40 + (index % 3) * 16; + return ( +
+ {item.name} +
+ ); + }} + group={{ + key: (item) => item.group, + title: (key, groupItems) => ( +
+ {key} · {groupItems.length} items +
+ ), + }} + /> +
+ ); +}; diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index 5437218..9d9c746 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -52,7 +52,7 @@ function RawList( // ============================ Render Item =========================== const renderItem = React.useCallback( - (item: T, index: number, groupKey?: K) => { + (item: T, index: number) => { const key = getItemKey(item); const scrollTargetProps = getScrollTargetProps(key); @@ -60,14 +60,7 @@ function RawList(
{itemRender(item, index)} @@ -80,7 +73,6 @@ function RawList( getScrollTargetProps, itemRender, prefixCls, - sticky, styles?.item, ], ); @@ -106,7 +98,7 @@ function RawList( style={styles?.groupHeader} /> {groupItems.map(({ item, index }) => { - return renderItem(item, index, groupKey); + return renderItem(item, index); })}
); diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts index af6ba93..1e42ee6 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -36,17 +36,20 @@ export default function useRawListScroll( [prefixCls, stickyGroup], ); - const setTargetScrollMargin = React.useCallback( - (targetElement: HTMLElement, align: ScrollAlign) => { - const marginTop = - align === 'top' ? getStickyHeaderHeight(targetElement) : 0; - - targetElement.style.setProperty( - `--${prefixCls}-item-scroll-margin-top`, - `${marginTop}px`, - ); + const applyScrollMargin = React.useCallback( + ( + targetElement: HTMLElement, + align: ScrollAlign, + offset: number, + isItem: boolean, + ) => { + const headerOffset = + isItem && align === 'top' ? getStickyHeaderHeight(targetElement) : 0; + + targetElement.style.scrollMarginTop = `${headerOffset + offset}px`; + targetElement.style.scrollMarginBottom = `${offset}px`; }, - [getStickyHeaderHeight, prefixCls], + [getStickyHeaderHeight], ); // ============================== Scroll ============================== @@ -63,16 +66,15 @@ export default function useRawListScroll( } if ('key' in config || 'groupKey' in config) { - const { align = 'top' } = config; - const targetKey = 'groupKey' in config ? config.groupKey : config.key; + const { align = 'top', offset = 0 } = config; + const isItem = 'key' in config; + const targetKey = isItem ? config.key : config.groupKey; const targetElement = holder.querySelector( `[data-key="${CSS.escape(String(targetKey))}"]`, ); if (targetElement) { - if ('key' in config) { - setTargetScrollMargin(targetElement, align); - } + applyScrollMargin(targetElement, align, offset, isItem); targetElement.scrollIntoView({ block: @@ -95,7 +97,7 @@ export default function useRawListScroll( holder.scrollTop = top; } }, - [setTargetScrollMargin], + [applyScrollMargin], ); // ============================ Imperative ============================ diff --git a/src/index.ts b/src/index.ts index 5ae2135..ae47b13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ export type { ListySemanticName, ListyClassNames, ListyStyles, + ScrollAlign, + ListyScrollToConfig, } from './List'; export default Listy; diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index 126c339..06c92f0 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -199,6 +199,7 @@ describe('Listy behaviors', () => { }); }); + expect(groupBSection.style.scrollMarginTop).toBe('5px'); expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start', inline: 'nearest', @@ -259,6 +260,7 @@ describe('Listy behaviors', () => { act(() => { ref.current?.scrollTo({ key: 2, align: 'bottom', offset: 4 }); }); + expect(secondItem.style.scrollMarginBottom).toBe('4px'); expect(scrollIntoView).toHaveBeenLastCalledWith({ block: 'end', inline: 'nearest', @@ -393,20 +395,17 @@ describe('Listy behaviors', () => { 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'); + // Header height plus the user offset must be applied before scrolling. + expect(itemNode.style.scrollMarginTop).toBe('46px'); }); itemNode.scrollIntoView = scrollIntoView; act(() => { - ref.current?.scrollTo({ key: 1, align: 'top' }); + ref.current?.scrollTo({ key: 1, align: 'top', offset: 10 }); }); expect(itemNode).toHaveClass('rc-listy-item'); - expect(itemNode.style.scrollMarginTop).toBe( - 'var(--rc-listy-item-scroll-margin-top, 0px)', - ); + expect(itemNode.style.scrollMarginTop).toBe('46px'); expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start', inline: 'nearest', @@ -434,9 +433,7 @@ describe('Listy behaviors', () => { ref.current?.scrollTo({ key: 1, align: 'top' }); }); - expect( - itemNode.style.getPropertyValue('--rc-listy-item-scroll-margin-top'), - ).toBe('0px'); + expect(itemNode.style.scrollMarginTop).toBe('0px'); }); it('scroll to group', () => { diff --git a/tests/semantic.test.tsx b/tests/semantic.test.tsx index 7351302..7c77dd1 100644 --- a/tests/semantic.test.tsx +++ b/tests/semantic.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { render } from '@testing-library/react'; -import Listy from '@rc-component/listy'; +import { render, act } from '@testing-library/react'; +import Listy, { type ListyRef } from '@rc-component/listy'; import GroupHeader from '../src/GroupHeader'; import useStickyGroupHeader from '../src/VirtualList/useStickyGroupHeader'; @@ -121,28 +121,39 @@ describe('semantic DOM (classNames / styles)', () => { }); }); - it('merges item style with the sticky scroll-margin (no overwrite)', () => { - const { container } = renderRaw({ sticky: true }); - const item = container.querySelector('.rc-listy-item') as HTMLElement; + it('keeps custom item style while applying the scroll margin on scroll', () => { + const ref = React.createRef(); + const { container } = renderRaw({ sticky: true, ref }); + const item = container.querySelector('[data-key="1"]') as HTMLElement; + item.scrollIntoView = jest.fn(); // custom style survives... expect(item).toHaveStyle({ color: 'rgb(4, 5, 6)' }); - // ...alongside the internal sticky scroll margin. - expect(item.style.scrollMarginTop).toBe( - 'var(--rc-listy-item-scroll-margin-top, 0px)', - ); + + act(() => { + ref.current?.scrollTo({ key: 1, align: 'top', offset: 3 }); + }); + // ...alongside the internal scroll margin applied to the scroll target. + expect(item.style.scrollMarginTop).toBe('3px'); + expect(item).toHaveStyle({ color: 'rgb(4, 5, 6)' }); }); - it('does not let styles.item.scrollMarginTop override the internal offset', () => { + it('lets the internal scroll offset override styles.item.scrollMarginTop', () => { + const ref = React.createRef(); const { container } = renderRaw({ sticky: true, + ref, styles: { item: { color: 'rgb(4, 5, 6)', scrollMarginTop: 999 } }, }); - const item = container.querySelector('.rc-listy-item') as HTMLElement; - // the internal scrollTo offset must win, not the user's 999px... - expect(item.style.scrollMarginTop).toBe( - 'var(--rc-listy-item-scroll-margin-top, 0px)', - ); - // ...while the user's other item styles still apply. + const item = container.querySelector('[data-key="1"]') as HTMLElement; + item.scrollIntoView = jest.fn(); + // the user's value is present at rest... + expect(item.style.scrollMarginTop).toBe('999px'); + + act(() => { + ref.current?.scrollTo({ key: 1, align: 'top', offset: 7 }); + }); + // ...but the internal offset wins on the scroll target. + expect(item.style.scrollMarginTop).toBe('7px'); expect(item).toHaveStyle({ color: 'rgb(4, 5, 6)' }); }); }); From 9e9b461a392f0f169d4d6861a055250b4b484039 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Mon, 13 Jul 2026 15:01:08 +0800 Subject: [PATCH 2/7] fix: apply sticky header offset for auto-aligned scrollTo `align: 'auto'` maps to `scrollIntoView({ block: 'nearest' })`, which can land the item at the container top where the sticky header would occlude it. The header offset was only added for `align: 'top'`, so `auto` (and the default) were left uncorrected in both the raw and virtual paths. Apply the header offset whenever alignment is not explicitly `bottom`, in both RawList and VirtualList, keeping the two modes consistent. --- src/RawList/useRawListScroll.ts | 4 +- src/VirtualList/index.tsx | 6 ++- tests/listy.behavior.test.tsx | 68 +++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts index 1e42ee6..3624e3f 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -43,8 +43,10 @@ export default function useRawListScroll( offset: number, isItem: boolean, ) => { + // `top` and `auto` can both land the item at the container top, where a + // sticky header would occlude it; only `bottom` is safe to skip. const headerOffset = - isItem && align === 'top' ? getStickyHeaderHeight(targetElement) : 0; + isItem && align !== 'bottom' ? getStickyHeaderHeight(targetElement) : 0; targetElement.style.scrollMarginTop = `${headerOffset + offset}px`; targetElement.style.scrollMarginBottom = `${offset}px`; diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index 4f086ae..def8951 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -94,14 +94,16 @@ function VirtualList( return; } - // For sticky grouped lists, top-aligned item scroll should land below its header. + // For sticky grouped lists, any scroll that can land the item at the top + // (`top`, `auto`, or the default) must sit below its header; only explicit + // `bottom` alignment is safe to skip. if ( config && typeof config === 'object' && 'key' in config && sticky && group && - config.align === 'top' + config.align !== 'bottom' ) { const groupKey = itemKeyToGroupKey.get(config.key); diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index 06c92f0..a60df34 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -412,6 +412,40 @@ describe('Listy behaviors', () => { }); }); + it('adds the sticky header offset for auto-aligned raw items', () => { + const ref = React.createRef(); + const { container } = renderList({ + ref, + 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, top: 0 }) as DOMRect, + ); + + const itemNode = container.querySelector('[data-key="1"]') as HTMLElement; + itemNode.scrollIntoView = jest.fn(); + + act(() => { + ref.current?.scrollTo({ key: 1, align: 'auto', offset: 10 }); + }); + + // `auto` can land the item at the top, so the header offset still applies. + expect(itemNode.style.scrollMarginTop).toBe('46px'); + }); + it('falls back to zero raw sticky scroll margin without a header', () => { const ref = React.createRef(); const { container } = renderList({ @@ -512,4 +546,38 @@ describe('Listy behaviors', () => { expect(holder).toHaveClass('rc-listy-rtl'); expect(holder).toHaveAttribute('dir', 'rtl'); }); + + it('offsets sticky virtual scrollTo for auto-aligned items too', () => { + 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: 'auto', offset: 5 }); + }); + + expect(scrollHandler).toHaveBeenCalledWith({ + key: 2, + align: 'auto', + 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 7b6970cff5ca57332a349448aaed53761f288a6d Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Mon, 13 Jul 2026 15:26:21 +0800 Subject: [PATCH 3/7] update --- src/RawList/useRawListScroll.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts index 3624e3f..706df8b 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -43,8 +43,6 @@ export default function useRawListScroll( offset: number, isItem: boolean, ) => { - // `top` and `auto` can both land the item at the container top, where a - // sticky header would occlude it; only `bottom` is safe to skip. const headerOffset = isItem && align !== 'bottom' ? getStickyHeaderHeight(targetElement) : 0; From 0303776c3d591331ac98690cbd6fd2cdfd1c4095 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Tue, 14 Jul 2026 20:06:49 +0800 Subject: [PATCH 4/7] fix: tag row keys by type so item and group scroll targets stay distinct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item keys and group keys shared one namespace in virtual row keys and raw-mode data-key attributes, so a colliding key (an item id equal to a group key) made scrollTo target the wrong node. Row keys are now tagged as `item:x` / `group:x` via a single toTaggedKey helper, stamped once per row at flatten time — which also turns the per-frame itemKey calls in the virtual hot path into plain property reads. Also fixes two edges in the sticky-header offset path: the group lookup map is keyed by tagged keys so a string/number key mismatch can no longer scroll while silently dropping the header compensation, and a falsy group key (0, '') no longer disables the header push-up. Note: raw-mode data-key attribute values change from the raw key to the tagged form (`data-key="item:1"`). --- src/RawList/index.tsx | 18 ++-- src/RawList/useRawListScroll.ts | 9 +- src/VirtualList/index.tsx | 93 +++++++++---------- src/VirtualList/useFlattenRows.ts | 30 +++++-- src/VirtualList/useStickyGroupHeader.tsx | 19 ++-- src/hooks/useItemKey.ts | 9 ++ src/util.ts | 13 +++ tests/hooks.test.tsx | 83 ++++++++++++++--- tests/listy.behavior.test.tsx | 110 ++++++++++++++++++++--- tests/semantic.test.tsx | 9 +- 10 files changed, 288 insertions(+), 105 deletions(-) create mode 100644 src/hooks/useItemKey.ts create mode 100644 src/util.ts diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index 9d9c746..4ca03f4 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; import clsx from 'clsx'; -import { useEvent } from '@rc-component/util'; import GroupHeader from '../GroupHeader'; import useGroupSegments from '../hooks/useGroupSegments'; +import useItemKey from '../hooks/useItemKey'; import useRawListScroll from './useRawListScroll'; +import { toTaggedKey, type KeyType } from '../util'; import type { ListComponentProps, ListyRef } from '../List'; // ============================== Types =============================== @@ -36,16 +37,11 @@ function RawList( const groupData = useGroupSegments(data, group); // ============================== Utils =============================== - const getItemKey = useEvent((item: T): React.Key => { - if (typeof rowKey === 'function') { - return rowKey(item); - } - return item[rowKey] as React.Key; - }); + const getItemKey = useItemKey(rowKey); const getScrollTargetProps = React.useCallback( - (key: React.Key) => ({ - 'data-key': String(key), + (key: React.Key, type: KeyType) => ({ + 'data-key': toTaggedKey(key, type), }), [], ); @@ -54,7 +50,7 @@ function RawList( const renderItem = React.useCallback( (item: T, index: number) => { const key = getItemKey(item); - const scrollTargetProps = getScrollTargetProps(key); + const scrollTargetProps = getScrollTargetProps(key, 'item'); return (
(
( - `[data-key="${CSS.escape(String(targetKey))}"]`, + `[data-key="${CSS.escape( + targetKey + )}"]`, ); if (targetElement) { diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index def8951..437deeb 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -8,7 +8,9 @@ import RcVirtualList, { import { useEvent } from '@rc-component/util'; import GroupHeader from '../GroupHeader'; import type { ListComponentProps, ListyRef } from '../List'; +import { toTaggedKey } from '../util'; import useGroupSegments from '../hooks/useGroupSegments'; +import useItemKey from '../hooks/useItemKey'; import useFlattenRows from './useFlattenRows'; import type { Row } from './useFlattenRows'; import useStickyGroupHeader from './useStickyGroupHeader'; @@ -46,86 +48,75 @@ function VirtualList( const groupData = useGroupSegments(data, group); // =============================== Keys =============================== - 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; - } - - return getItemKey(row.item); - }); + const getItemKey = useItemKey(rowKey); // ============================== Rows ================================ const { rows, groupKeys, groupKeyToItems } = useFlattenRows( data, groupData, + getItemKey, group, ); // ============================== Lookup ============================== const itemKeyToGroupKey = React.useMemo(() => { - const itemGroupMap = new Map(); - - groupData.forEach((groupItems, groupKey) => { - groupItems.forEach(({ item }) => { - itemGroupMap.set(getItemKey(item), groupKey); - }); + const itemGroupMap = new Map(); + let currentGroupKey: K | undefined; + + rows.forEach((row) => { + if (row.type === 'group') { + currentGroupKey = row.groupKey; + } else if (currentGroupKey !== undefined) { + itemGroupMap.set(row.taggedKey, currentGroupKey); + } }); return itemGroupMap; - }, [getItemKey, groupData]); + }, [rows]); // ============================== 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) { + if (!config || typeof config !== 'object') { + listRef.current?.scrollTo(config as number | ScrollConfig | null); + return; + } + + if ('groupKey' in config) { const { groupKey, align, offset } = config; listRef.current?.scrollTo({ - key: groupKey, + key: toTaggedKey(groupKey, 'group'), align, offset, }); return; } - // For sticky grouped lists, any scroll that can land the item at the top - // (`top`, `auto`, or the default) must sit below its header; only explicit - // `bottom` alignment is safe to skip. - if ( - config && - typeof config === 'object' && - 'key' in config && - sticky && - group && - config.align !== 'bottom' - ) { - const groupKey = itemKeyToGroupKey.get(config.key); - - if (groupKey !== undefined) { - const { offset = 0 } = config; - - listRef.current?.scrollTo({ - ...config, + if ('key' in config) { + const stickyGroupKey = + sticky && group && config.align !== 'bottom' + ? itemKeyToGroupKey.get(toTaggedKey(config.key, 'item')) + : undefined; + + listRef.current?.scrollTo({ + ...config, + key: toTaggedKey(config.key, 'item'), + ...(stickyGroupKey !== undefined && { // Use the measured header height so top-aligned items stay below it. offset: ({ getSize }: ScrollOffsetInfo) => { - const headerSize = getSize(groupKey); + const headerSize = getSize(toTaggedKey(stickyGroupKey, 'group')); const headerHeight = headerSize.bottom - headerSize.top; - return offset + (Number.isFinite(headerHeight) ? headerHeight : 0); + return ( + (config.offset ?? 0) + + (Number.isFinite(headerHeight) ? headerHeight : 0) + ); }, - }); - return; - } + }), + }); + return; } - // Other scroll shapes are already supported by the underlying virtual list. - listRef.current?.scrollTo(config as number | ScrollConfig | null); + listRef.current?.scrollTo(config); }); // ============================ Imperative ============================ @@ -177,7 +168,7 @@ function VirtualList( fullHeight={false} height={height} itemHeight={itemHeight} - itemKey={getKey} + itemKey="taggedKey" onScroll={onScroll} prefixCls={prefixCls} virtual @@ -186,7 +177,7 @@ function VirtualList( style={styles?.root} > {(row: Row) => - row.type === 'header' + row.type === 'group' ? renderHeaderRow(row.groupKey) : (
= - | { type: 'header'; groupKey: K } - | { type: 'item'; item: T; index: number }; +export type Row = ( + | { type: 'group'; groupKey: K } + | { type: 'item'; item: T; index: number } +) & { + taggedKey: string; +}; export interface FlattenRowsResult { rows: Row[]; @@ -20,6 +24,7 @@ export interface FlattenRowsResult { export default function useFlattenRows( data: T[], groupData: Map[]>, + getItemKey: (item: T) => React.Key, group?: Group, ): FlattenRowsResult { return React.useMemo(() => { @@ -28,10 +33,17 @@ export default function useFlattenRows( const groupKeys: K[] = []; const groupKeyToItems = new Map(); + const itemRow = (item: T, index: number): Row => ({ + type: 'item', + item, + index, + taggedKey: toTaggedKey(getItemKey(item), 'item'), + }); + // ============================ No Group ============================== if (!group) { data.forEach((item, index) => { - flatRows.push({ type: 'item', item, index }); + flatRows.push(itemRow(item, index)); }); return { rows: flatRows, groupKeys, groupKeyToItems }; @@ -45,14 +57,18 @@ export default function useFlattenRows( ); groupKeys.push(groupKey); - flatRows.push({ type: 'header', groupKey }); + flatRows.push({ + type: 'group', + groupKey, + taggedKey: toTaggedKey(groupKey, 'group'), + }); groupItems.forEach(({ item, index }) => { - flatRows.push({ type: 'item', item, index }); + flatRows.push(itemRow(item, index)); }); }); // ============================== Return ============================== return { rows: flatRows, groupKeys, groupKeyToItems }; - }, [data, group, groupData]); + }, [data, group, groupData, getItemKey]); } diff --git a/src/VirtualList/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx index cd94536..d742a23 100644 --- a/src/VirtualList/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -6,6 +6,7 @@ import type { } from '@rc-component/virtual-list'; import type { Group } from '../hooks/useGroupSegments'; import GroupHeader from '../GroupHeader'; +import { toTaggedKey } from '../util'; // ============================== Types =============================== type ExtraRenderInfo = Parameters< @@ -80,22 +81,30 @@ export default function useStickyGroupHeader< return null; } + const getGroupSize = (groupKey: K) => + getSize(toTaggedKey(groupKey, 'group')); + // The sticky header is the group whose section the viewport top sits in. const activeHeaderIdx = findActiveHeaderIndex( groupKeys, - (groupKey) => getSize(groupKey).top, + (groupKey) => getGroupSize(groupKey).top, scrollTop, ); const currGroupKey = groupKeys[activeHeaderIdx]; const groupItems = groupKeyToItems.get(currGroupKey) || []; - const currentSize = getSize(currGroupKey); + const currentSize = getGroupSize(currGroupKey); const headerHeight = currentSize.bottom - currentSize.top; const nextGroupKey = groupKeys[activeHeaderIdx + 1]; - const top = nextGroupKey - ? Math.min(0, getSize(nextGroupKey).top - headerHeight - scrollTop) - : 0; + // Explicit undefined check: a falsy group key (0, '') is still a group. + const top = + nextGroupKey !== undefined + ? Math.min( + 0, + getGroupSize(nextGroupKey).top - headerHeight - scrollTop, + ) + : 0; // Render a cloned header pinned over the virtual list. return ( diff --git a/src/hooks/useItemKey.ts b/src/hooks/useItemKey.ts new file mode 100644 index 0000000..07737e5 --- /dev/null +++ b/src/hooks/useItemKey.ts @@ -0,0 +1,9 @@ +import type * as React from 'react'; +import { useEvent } from '@rc-component/util'; +import type { RowKey } from '../List'; + +export default function useItemKey(rowKey: RowKey) { + return useEvent((item: T): React.Key => + typeof rowKey === 'function' ? rowKey(item) : (item[rowKey] as React.Key), + ); +} diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..8b28013 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,13 @@ +import type * as React from 'react'; + +/** Which kind of entity a tagged key addresses. */ +export type KeyType = 'item' | 'group'; + +/** + * Build the type-tagged key (`item:x` / `group:x`) used for virtual row keys, + * scroll targets, getSize lookups and raw-mode data-key attributes, so item + * and group keys can never collide. Constructed and compared as a whole — + * never parsed back. + */ +export const toTaggedKey = (oriKey: React.Key, type: KeyType): string => + `${type}:${oriKey}`; diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 906458a..a907803 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -9,6 +9,7 @@ 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'; +import { toTaggedKey } from '../src/util'; const PREFIX_CLS = 'rc-listy'; @@ -130,15 +131,15 @@ describe('useFlattenRows', () => { const { result } = renderHook(() => { const groupData = useGroupSegments(items, group); - return useFlattenRows(items, groupData, group); + return useFlattenRows(items, groupData, (item) => item.id, group); }); expect(result.current.rows).toEqual([ - { type: 'header', groupKey: 'A' }, - { type: 'item', item: items[0], index: 0 }, - { type: 'item', item: items[2], index: 2 }, - { type: 'header', groupKey: 'B' }, - { type: 'item', item: items[1], index: 1 }, + { type: 'group', groupKey: 'A', taggedKey: toTaggedKey('A', 'group') }, + { type: 'item', item: items[0], index: 0, taggedKey: toTaggedKey(0, 'item') }, + { type: 'item', item: items[2], index: 2, taggedKey: toTaggedKey(2, 'item') }, + { type: 'group', groupKey: 'B', taggedKey: toTaggedKey('B', 'group') }, + { type: 'item', item: items[1], index: 1, taggedKey: toTaggedKey(1, 'item') }, ]); expect(result.current.groupKeys).toEqual(['A', 'B']); expect(result.current.groupKeyToItems).toEqual( @@ -157,13 +158,13 @@ describe('useFlattenRows', () => { const { result } = renderHook(() => { const groupData = useGroupSegments(items); - return useFlattenRows(items, groupData); + return useFlattenRows(items, groupData, (item) => item.id); }); expect(result.current).toEqual({ rows: [ - { type: 'item', item: items[0], index: 0 }, - { type: 'item', item: items[1], index: 1 }, + { type: 'item', item: items[0], index: 0, taggedKey: toTaggedKey(0, 'item') }, + { type: 'item', item: items[1], index: 1, taggedKey: toTaggedKey(1, 'item') }, ], groupKeys: [], groupKeyToItems: new Map(), @@ -260,7 +261,9 @@ describe('useStickyGroupHeader', () => { scrollTop: 80, start: 1, getSize: (key: React.Key) => - key === 'Group 2' ? { top: 500, bottom: 524 } : { top: 0, bottom: 24 }, + key === toTaggedKey('Group 2', 'group') + ? { top: 500, bottom: 524 } + : { top: 0, bottom: 24 }, }); const container = document.createElement('div'); @@ -295,10 +298,10 @@ describe('useStickyGroupHeader', () => { scrollTop: 70, start: 3, getSize: (key: React.Key) => { - if (key === 'Group 1') { + if (key === toTaggedKey('Group 1', 'group')) { return { top: 0, bottom: 20 }; } - if (key === 'Group 2') { + if (key === toTaggedKey('Group 2', 'group')) { return { top: 80, bottom: 100 }; } return { top: 0, bottom: 0 }; @@ -328,6 +331,54 @@ describe('useStickyGroupHeader', () => { expect(stickyHeader).toHaveTextContent('Group 1'); expect(stickyHeader).toHaveStyle({ top: '-10px' }); }); + it('pushes the fixed header away even when the next group key is falsy', () => { + const title = jest.fn().mockImplementation((key: React.Key) => ( + {String(key)} + )); + + // Numeric group keys where the incoming group is keyed 0 (falsy). + const info = createRenderInfo({ + scrollTop: 70, + start: 0, + getSize: (key: React.Key) => { + if (key === toTaggedKey(1, 'group')) { + return { top: 0, bottom: 20 }; + } + if (key === toTaggedKey(0, 'group')) { + return { top: 80, bottom: 100 }; + } + return { top: 0, bottom: 0 }; + }, + }); + + const container = document.createElement('div'); + document.body.appendChild(container); + const params: StickyHeaderParams = { + enabled: true, + group: { + key: (item) => item.group, + title, + }, + groupKeys: [1, 0], + groupKeyToItems: new Map([ + [1, baseItems.slice(0, 3)], + [0, baseItems.slice(3, 6)], + ]), + prefixCls: PREFIX_CLS, + listRef: createListRef(container), + }; + + render(); + + const stickyHeader = container.querySelector( + `.${PREFIX_CLS}-group-header-fixed`, + ); + expect(stickyHeader).not.toBeNull(); + expect(stickyHeader).toHaveTextContent('1'); + // Group 0's header is approaching: min(0, 80 - 20 - 70) = -10. + expect(stickyHeader).toHaveStyle({ top: '-10px' }); + }); + it('activates the current group, not the previous one, when its header sits flush at the top', () => { const title = jest.fn().mockImplementation((key: React.Key) => ( {String(key)} @@ -340,7 +391,9 @@ describe('useStickyGroupHeader', () => { scrollTop: 200, start: 3, getSize: (key: React.Key) => - key === 'Group 2' ? { top: 200, bottom: 220 } : { top: 0, bottom: 20 }, + key === toTaggedKey('Group 2', 'group') + ? { top: 200, bottom: 220 } + : { top: 0, bottom: 20 }, }); const container = document.createElement('div'); @@ -381,7 +434,9 @@ describe('useStickyGroupHeader', () => { scrollTop: 199.5, start: 3, getSize: (key: React.Key) => - key === 'Group 2' ? { top: 200, bottom: 220 } : { top: 0, bottom: 20 }, + key === toTaggedKey('Group 2', 'group') + ? { top: 200, bottom: 220 } + : { top: 0, bottom: 20 }, }); const container = document.createElement('div'); diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index a60df34..5ce76d3 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -123,7 +123,7 @@ describe('Listy behaviors', () => { ref.current?.scrollTo({ key: 2 }); }); - expect(scrollHandler).toHaveBeenCalledWith({ key: 2 }); + expect(scrollHandler).toHaveBeenCalledWith({ key: 'item:2' }); }); it('treats missing items prop as empty array', () => { @@ -166,7 +166,7 @@ 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-key', 'group:Group A'); expect(title).toHaveBeenCalled(); }); @@ -335,7 +335,7 @@ describe('Listy behaviors', () => { const itemNode = container.querySelector('.rc-listy-item'); - expect(itemNode).toHaveAttribute('data-key', 'item-1'); + expect(itemNode).toHaveAttribute('data-key', 'item:item-1'); }); it('wraps raw list items with item class', () => { @@ -355,7 +355,7 @@ describe('Listy behaviors', () => { const itemNode = container.querySelector('.rc-listy-item'); - expect(itemNode).toHaveAttribute('data-key', '1'); + expect(itemNode).toHaveAttribute('data-key', 'item:1'); expect(itemNode).toContainElement(container.querySelector('span')); }); @@ -393,7 +393,7 @@ describe('Listy behaviors', () => { }) as DOMRect, ); - const itemNode = container.querySelector('[data-key="1"]') as HTMLElement; + const itemNode = container.querySelector('[data-key="item:1"]') as HTMLElement; const scrollIntoView = jest.fn(() => { // Header height plus the user offset must be applied before scrolling. expect(itemNode.style.scrollMarginTop).toBe('46px'); @@ -435,7 +435,7 @@ describe('Listy behaviors', () => { () => ({ bottom: 36, height: 36, top: 0 }) as DOMRect, ); - const itemNode = container.querySelector('[data-key="1"]') as HTMLElement; + const itemNode = container.querySelector('[data-key="item:1"]') as HTMLElement; itemNode.scrollIntoView = jest.fn(); act(() => { @@ -460,7 +460,7 @@ describe('Listy behaviors', () => { container.querySelector('.rc-listy-group-header')?.remove(); - const itemNode = container.querySelector('[data-key="1"]') as HTMLElement; + const itemNode = container.querySelector('[data-key="item:1"]') as HTMLElement; itemNode.scrollIntoView = jest.fn(); act(() => { @@ -492,7 +492,7 @@ describe('Listy behaviors', () => { }); expect(scrollHandler).toHaveBeenCalledWith({ - key: 'Group A', + key: 'group:Group A', align: 'bottom', offset: 12, }); @@ -517,7 +517,7 @@ describe('Listy behaviors', () => { }); expect(scrollHandler).toHaveBeenCalledWith({ - key: 2, + key: 'item:2', align: 'top', offset: expect.any(Function), }); @@ -527,7 +527,7 @@ describe('Listy behaviors', () => { expect( offset({ getSize: (key: React.Key) => - key === 'Group A' ? { top: 10, bottom: 34 } : { top: 0, bottom: 0 }, + key === 'group:Group A' ? { top: 10, bottom: 34 } : { top: 0, bottom: 0 }, }), ).toBe(29); }); @@ -566,7 +566,7 @@ describe('Listy behaviors', () => { }); expect(scrollHandler).toHaveBeenCalledWith({ - key: 2, + key: 'item:2', align: 'auto', offset: expect.any(Function), }); @@ -576,8 +576,94 @@ describe('Listy behaviors', () => { expect( offset({ getSize: (key: React.Key) => - key === 'Group A' ? { top: 10, bottom: 34 } : { top: 0, bottom: 0 }, + key === 'group:Group A' ? { top: 10, bottom: 34 } : { top: 0, bottom: 0 }, }), ).toBe(29); }); + + it('applies the sticky offset when the scroll key type differs from the rowKey type', () => { + 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: 'item:2', + align: 'top', + offset: expect.any(Function), + }); + }); + + it('keeps virtual item and group scroll distinct when their keys collide', () => { + const scrollHandler = jest.fn(); + MockedVirtualList.__setScrollHandler(scrollHandler); + + const ref = React.createRef(); + renderList({ + ref, + // Item id and group key both stringify to 'x'. + items: [{ id: 'x', group: 'x' }], + group: { + key: (item) => item.group, + title: () => null, + }, + }); + + act(() => { + ref.current?.scrollTo({ key: 'x' }); + ref.current?.scrollTo({ groupKey: 'x' }); + }); + + // Type-tagged row keys stop the shared raw key from mapping to one row. + expect(scrollHandler).toHaveBeenNthCalledWith(1, { key: 'item:x' }); + expect(scrollHandler).toHaveBeenNthCalledWith(2, { key: 'group:x' }); + }); + + it('scrolls the item, not the colliding group section, in a raw list', () => { + const ref = React.createRef(); + const { container } = renderList({ + ref, + virtual: false, + // Item id and group key both stringify to 'x'. + items: [{ id: 'x', group: 'x' }], + group: { + key: (item) => item.group, + title: () => null, + }, + }); + + const groupSection = container.querySelector( + '.rc-listy-group-section', + ) as HTMLElement; + const itemNode = container.querySelector('.rc-listy-item') as HTMLElement; + const groupScroll = jest.fn(); + const itemScroll = jest.fn(); + groupSection.scrollIntoView = groupScroll; + itemNode.scrollIntoView = itemScroll; + + act(() => { + ref.current?.scrollTo({ key: 'x' }); + }); + // The item — not the group section sharing the key — is the target. + expect(itemScroll).toHaveBeenCalledTimes(1); + expect(groupScroll).not.toHaveBeenCalled(); + + act(() => { + ref.current?.scrollTo({ groupKey: 'x' }); + }); + expect(groupScroll).toHaveBeenCalledTimes(1); + expect(itemScroll).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/semantic.test.tsx b/tests/semantic.test.tsx index 7c77dd1..7c94ff6 100644 --- a/tests/semantic.test.tsx +++ b/tests/semantic.test.tsx @@ -3,6 +3,7 @@ import { render, act } from '@testing-library/react'; import Listy, { type ListyRef } from '@rc-component/listy'; import GroupHeader from '../src/GroupHeader'; import useStickyGroupHeader from '../src/VirtualList/useStickyGroupHeader'; +import { toTaggedKey } from '../src/util'; jest.mock('@rc-component/virtual-list', () => { const ReactMock = require('react'); @@ -124,7 +125,7 @@ describe('semantic DOM (classNames / styles)', () => { it('keeps custom item style while applying the scroll margin on scroll', () => { const ref = React.createRef(); const { container } = renderRaw({ sticky: true, ref }); - const item = container.querySelector('[data-key="1"]') as HTMLElement; + const item = container.querySelector('[data-key="item:1"]') as HTMLElement; item.scrollIntoView = jest.fn(); // custom style survives... expect(item).toHaveStyle({ color: 'rgb(4, 5, 6)' }); @@ -144,7 +145,7 @@ describe('semantic DOM (classNames / styles)', () => { ref, styles: { item: { color: 'rgb(4, 5, 6)', scrollMarginTop: 999 } }, }); - const item = container.querySelector('[data-key="1"]') as HTMLElement; + const item = container.querySelector('[data-key="item:1"]') as HTMLElement; item.scrollIntoView = jest.fn(); // the user's value is present at rest... expect(item.style.scrollMarginTop).toBe('999px'); @@ -279,7 +280,9 @@ describe('semantic DOM (classNames / styles)', () => { // A is the active header; B is approaching, so the computed top is a // negative push offset: min(0, 100 - 24 - 90) = -14. getSize: (key: React.Key) => - key === 'B' ? { top: 100, bottom: 124 } : { top: 0, bottom: 24 }, + key === toTaggedKey('B', 'group') + ? { top: 100, bottom: 124 } + : { top: 0, bottom: 24 }, scrollTop: 90, virtual: true, }), From 779a9027dc8d3005cee3e168474a6955deec7eb2 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Tue, 14 Jul 2026 20:10:21 +0800 Subject: [PATCH 5/7] test: cover virtual scrollTo passthrough branches The scrollTo refactor split the single fallthrough into explicit branches; the numeric-offset and position-config paths had no virtual-mode coverage (only raw-mode). --- tests/listy.behavior.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index 5ce76d3..3c87755 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -581,6 +581,23 @@ describe('Listy behaviors', () => { ).toBe(29); }); + it('forwards non-key scroll configs to the virtual list untouched', () => { + const scrollHandler = jest.fn(); + MockedVirtualList.__setScrollHandler(scrollHandler); + + const ref = React.createRef(); + renderList({ ref }); + + act(() => { + ref.current?.scrollTo(120); + ref.current?.scrollTo({ top: 40, left: 4 }); + }); + + // Plain offsets and position configs carry no keys to tag: pass through. + expect(scrollHandler).toHaveBeenNthCalledWith(1, 120); + expect(scrollHandler).toHaveBeenNthCalledWith(2, { top: 40, left: 4 }); + }); + it('applies the sticky offset when the scroll key type differs from the rowKey type', () => { const scrollHandler = jest.fn(); MockedVirtualList.__setScrollHandler(scrollHandler); From 1a4965c69012e6f9090e4135141b28f1ad39e2ac Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Wed, 15 Jul 2026 00:08:11 +0800 Subject: [PATCH 6/7] fix: preserve user scroll-margin on non-virtual scrollTo; use resolved align Non-virtual (raw list) scrollTo wrote scroll-margin directly on the target and left it there, permanently overwriting styles.item.scrollMargin*. React diffs the style prop against its own last-rendered value, not the live DOM, so a same-value rerender never restores it. Apply the margin only for the synchronous scrollIntoView call and restore the element's inline value right after. Virtual scrollTo now reads the resolved `align` exposed by the virtual-list offset callback instead of reverse-engineering the direction via getScrollInfo(), adding the sticky-header offset only when the item lands at the top. --- .dumirc.ts | 4 +- jest.config.js | 3 ++ src/RawList/useRawListScroll.ts | 28 ++++++------ src/VirtualList/index.tsx | 35 ++++++++------ tests/listy.behavior.test.tsx | 81 ++++++++++++++++++++++++++++----- tests/semantic.test.tsx | 26 +++++++---- 6 files changed, 128 insertions(+), 49 deletions(-) diff --git a/.dumirc.ts b/.dumirc.ts index 6d575e3..06a978f 100644 --- a/.dumirc.ts +++ b/.dumirc.ts @@ -3,8 +3,8 @@ import path from 'path'; export default defineConfig({ alias: { - 'rc-listy$': path.resolve('src'), - 'rc-listy/es': path.resolve('src'), + '@rc-component/listy$': path.resolve('src'), + '@rc-component/listy/es': path.resolve('src'), }, mfsu: false, favicons: ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'], diff --git a/jest.config.js b/jest.config.js index 1b72e2a..a1b9f5f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,8 @@ module.exports = { setupFiles: ['./tests/setup.js'], + // ponytail: git worktrees under .claude/ are duplicate copies of this package; + // excluding them from the haste map avoids duplicate-module-name crashes. + modulePathIgnorePatterns: ['/.claude/'], moduleNameMapper: { '^@rc-component/listy$': '/src/index.ts', }, diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts index 3bffd1d..8343d05 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -37,7 +37,7 @@ export default function useRawListScroll( [prefixCls, stickyGroup], ); - const applyScrollMargin = React.useCallback( + const scrollTargetIntoView = React.useCallback( ( targetElement: HTMLElement, align: ScrollAlign, @@ -47,8 +47,20 @@ export default function useRawListScroll( const headerOffset = isItem && align !== 'bottom' ? getStickyHeaderHeight(targetElement) : 0; + const prevTop = targetElement.style.scrollMarginTop; + const prevBottom = targetElement.style.scrollMarginBottom; + targetElement.style.scrollMarginTop = `${headerOffset + offset}px`; targetElement.style.scrollMarginBottom = `${offset}px`; + + targetElement.scrollIntoView({ + block: + align === 'bottom' ? 'end' : align === 'auto' ? 'nearest' : 'start', + inline: 'nearest', + }); + + targetElement.style.scrollMarginTop = prevTop; + targetElement.style.scrollMarginBottom = prevBottom; }, [getStickyHeaderHeight], ); @@ -79,17 +91,7 @@ export default function useRawListScroll( ); if (targetElement) { - applyScrollMargin(targetElement, align, offset, isItem); - - targetElement.scrollIntoView({ - block: - align === 'bottom' - ? 'end' - : align === 'auto' - ? 'nearest' - : 'start', - inline: 'nearest', - }); + scrollTargetIntoView(targetElement, align, offset, isItem); } return; } @@ -102,7 +104,7 @@ export default function useRawListScroll( holder.scrollTop = top; } }, - [applyScrollMargin], + [scrollTargetIntoView], ); // ============================ Imperative ============================ diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index 437deeb..2b5496a 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -92,26 +92,33 @@ function VirtualList( } if ('key' in config) { + const taggedItemKey = toTaggedKey(config.key, 'item'); const stickyGroupKey = sticky && group && config.align !== 'bottom' - ? itemKeyToGroupKey.get(toTaggedKey(config.key, 'item')) + ? itemKeyToGroupKey.get(taggedItemKey) : undefined; + if (stickyGroupKey === undefined) { + listRef.current?.scrollTo({ ...config, key: taggedItemKey }); + return; + } + listRef.current?.scrollTo({ ...config, - key: toTaggedKey(config.key, 'item'), - ...(stickyGroupKey !== undefined && { - // Use the measured header height so top-aligned items stay below it. - offset: ({ getSize }: ScrollOffsetInfo) => { - const headerSize = getSize(toTaggedKey(stickyGroupKey, 'group')); - const headerHeight = headerSize.bottom - headerSize.top; - - return ( - (config.offset ?? 0) + - (Number.isFinite(headerHeight) ? headerHeight : 0) - ); - }, - }), + key: taggedItemKey, + offset: ({ getSize, align }: ScrollOffsetInfo) => { + const baseOffset = config.offset ?? 0; + + if (align !== 'top') { + return baseOffset; + } + + // Use the measured header height so the item stays below it. + const headerSize = getSize(toTaggedKey(stickyGroupKey, 'group')); + const headerHeight = headerSize.bottom - headerSize.top; + + return baseOffset + (Number.isFinite(headerHeight) ? headerHeight : 0); + }, }); return; } diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index 3c87755..cb2ce8d 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -24,10 +24,13 @@ jest.mock('@rc-component/virtual-list', () => { }; let scrollHandler = (config: any) => {}; let lastProps: any = null; + // Stand-in for rc-virtual-list's scroll offset, mutable per test. + const scrollInfo = { x: 0, y: 0 }; const MockVirtualList = React.forwardRef((props: any, ref: any) => { lastProps = props; React.useImperativeHandle(ref, () => ({ + getScrollInfo: () => ({ ...scrollInfo }), scrollTo: (config: any) => { scrollHandler(config); }, @@ -57,6 +60,9 @@ jest.mock('@rc-component/virtual-list', () => { scrollHandler = handler; }; (MockVirtualList as any).__getLastProps = () => lastProps; + (MockVirtualList as any).__setScrollTop = (scrollTop: number) => { + scrollInfo.y = scrollTop; + }; return { __esModule: true, @@ -68,6 +74,7 @@ type MockedVirtualListComponent = React.ForwardRefExoticComponent & { __setExtraInfo(info: Partial): void; __setScrollHandler(handler: (config: any) => void): void; __getLastProps(): any; + __setScrollTop(scrollTop: number): void; }; const MockedVirtualList = require('@rc-component/virtual-list') @@ -81,6 +88,7 @@ describe('Listy behaviors', () => { virtual: true, }); MockedVirtualList.__setScrollHandler(() => {}); + MockedVirtualList.__setScrollTop(0); }); const renderList = ( @@ -188,7 +196,10 @@ describe('Listy behaviors', () => { const groupSections = container.querySelectorAll('.rc-listy-group-section'); const groupBSection = groupSections[1] as HTMLElement; - const scrollIntoView = jest.fn(); + let marginDuringScroll: string | undefined; + const scrollIntoView = jest.fn(() => { + marginDuringScroll = groupBSection.style.scrollMarginTop; + }); groupBSection.scrollIntoView = scrollIntoView; act(() => { @@ -199,7 +210,9 @@ describe('Listy behaviors', () => { }); }); - expect(groupBSection.style.scrollMarginTop).toBe('5px'); + // Margin is applied for the scroll, then restored (no user value here). + expect(marginDuringScroll).toBe('5px'); + expect(groupBSection.style.scrollMarginTop).toBe(''); expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start', inline: 'nearest', @@ -221,7 +234,10 @@ describe('Listy behaviors', () => { 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(); + let bottomMarginDuringScroll: string | undefined; + const scrollIntoView = jest.fn(() => { + bottomMarginDuringScroll = secondItem.style.scrollMarginBottom; + }); secondItem.scrollIntoView = scrollIntoView; act(() => { @@ -260,7 +276,9 @@ describe('Listy behaviors', () => { act(() => { ref.current?.scrollTo({ key: 2, align: 'bottom', offset: 4 }); }); - expect(secondItem.style.scrollMarginBottom).toBe('4px'); + // Margin is applied for the scroll, then restored (no user value here). + expect(bottomMarginDuringScroll).toBe('4px'); + expect(secondItem.style.scrollMarginBottom).toBe(''); expect(scrollIntoView).toHaveBeenLastCalledWith({ block: 'end', inline: 'nearest', @@ -405,7 +423,8 @@ describe('Listy behaviors', () => { }); expect(itemNode).toHaveClass('rc-listy-item'); - expect(itemNode.style.scrollMarginTop).toBe('46px'); + // Restored after the scroll (the user set no scroll margin). + expect(itemNode.style.scrollMarginTop).toBe(''); expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start', inline: 'nearest', @@ -436,14 +455,19 @@ describe('Listy behaviors', () => { ); const itemNode = container.querySelector('[data-key="item:1"]') as HTMLElement; - itemNode.scrollIntoView = jest.fn(); + let marginDuringScroll: string | undefined; + itemNode.scrollIntoView = jest.fn(() => { + marginDuringScroll = itemNode.style.scrollMarginTop; + }); act(() => { ref.current?.scrollTo({ key: 1, align: 'auto', offset: 10 }); }); - // `auto` can land the item at the top, so the header offset still applies. - expect(itemNode.style.scrollMarginTop).toBe('46px'); + // `auto` can land the item at the top, so the header offset still applies + // during the scroll (and is restored afterward). + expect(marginDuringScroll).toBe('46px'); + expect(itemNode.style.scrollMarginTop).toBe(''); }); it('falls back to zero raw sticky scroll margin without a header', () => { @@ -461,13 +485,18 @@ describe('Listy behaviors', () => { container.querySelector('.rc-listy-group-header')?.remove(); const itemNode = container.querySelector('[data-key="item:1"]') as HTMLElement; - itemNode.scrollIntoView = jest.fn(); + let marginDuringScroll: string | undefined; + itemNode.scrollIntoView = jest.fn(() => { + marginDuringScroll = itemNode.style.scrollMarginTop; + }); act(() => { ref.current?.scrollTo({ key: 1, align: 'top' }); }); - expect(itemNode.style.scrollMarginTop).toBe('0px'); + // No header, no offset -> zero margin during the scroll, restored after. + expect(marginDuringScroll).toBe('0px'); + expect(itemNode.style.scrollMarginTop).toBe(''); }); it('scroll to group', () => { @@ -528,6 +557,7 @@ describe('Listy behaviors', () => { offset({ getSize: (key: React.Key) => key === 'group:Group A' ? { top: 10, bottom: 34 } : { top: 0, bottom: 0 }, + align: 'top', }), ).toBe(29); }); @@ -547,7 +577,7 @@ describe('Listy behaviors', () => { expect(holder).toHaveAttribute('dir', 'rtl'); }); - it('offsets sticky virtual scrollTo for auto-aligned items too', () => { + it('offsets auto-aligned scrollTo when it resolves to a top landing', () => { const scrollHandler = jest.fn(); MockedVirtualList.__setScrollHandler(scrollHandler); @@ -571,14 +601,41 @@ describe('Listy behaviors', () => { offset: expect.any(Function), }); + const [{ offset }] = scrollHandler.mock.calls[0]; + const getSize = (key: React.Key) => + key === 'group:Group A' ? { top: 10, bottom: 34 } : { top: 0, bottom: 0 }; + + expect(offset({ getSize, align: 'auto' })).toBe(5); + expect(offset({ getSize, align: 'top' })).toBe(29); + }); + + it('skips the sticky offset when auto-aligned scrollTo lands at the bottom', () => { + 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: 'auto', offset: 5 }); + }); + const [{ offset }] = scrollHandler.mock.calls[0]; expect( offset({ getSize: (key: React.Key) => key === 'group:Group A' ? { top: 10, bottom: 34 } : { top: 0, bottom: 0 }, + align: 'bottom', }), - ).toBe(29); + ).toBe(5); }); it('forwards non-key scroll configs to the virtual list untouched', () => { diff --git a/tests/semantic.test.tsx b/tests/semantic.test.tsx index 7c94ff6..3db2b4f 100644 --- a/tests/semantic.test.tsx +++ b/tests/semantic.test.tsx @@ -122,23 +122,28 @@ describe('semantic DOM (classNames / styles)', () => { }); }); - it('keeps custom item style while applying the scroll margin on scroll', () => { + it('applies the scroll margin only during scroll and restores the item afterward', () => { const ref = React.createRef(); const { container } = renderRaw({ sticky: true, ref }); const item = container.querySelector('[data-key="item:1"]') as HTMLElement; - item.scrollIntoView = jest.fn(); + let marginDuringScroll: string | undefined; + item.scrollIntoView = jest.fn(() => { + marginDuringScroll = item.style.scrollMarginTop; + }); // custom style survives... expect(item).toHaveStyle({ color: 'rgb(4, 5, 6)' }); act(() => { ref.current?.scrollTo({ key: 1, align: 'top', offset: 3 }); }); - // ...alongside the internal scroll margin applied to the scroll target. - expect(item.style.scrollMarginTop).toBe('3px'); + // ...the internal margin is applied for the scrollIntoView call... + expect(marginDuringScroll).toBe('3px'); + // ...then restored (the user set no scroll margin, so back to empty). + expect(item.style.scrollMarginTop).toBe(''); expect(item).toHaveStyle({ color: 'rgb(4, 5, 6)' }); }); - it('lets the internal scroll offset override styles.item.scrollMarginTop', () => { + it('restores styles.item.scrollMarginTop after applying the internal offset for the scroll', () => { const ref = React.createRef(); const { container } = renderRaw({ sticky: true, @@ -146,15 +151,20 @@ describe('semantic DOM (classNames / styles)', () => { styles: { item: { color: 'rgb(4, 5, 6)', scrollMarginTop: 999 } }, }); const item = container.querySelector('[data-key="item:1"]') as HTMLElement; - item.scrollIntoView = jest.fn(); + let marginDuringScroll: string | undefined; + item.scrollIntoView = jest.fn(() => { + marginDuringScroll = item.style.scrollMarginTop; + }); // the user's value is present at rest... expect(item.style.scrollMarginTop).toBe('999px'); act(() => { ref.current?.scrollTo({ key: 1, align: 'top', offset: 7 }); }); - // ...but the internal offset wins on the scroll target. - expect(item.style.scrollMarginTop).toBe('7px'); + // ...the internal offset is applied only for the scroll... + expect(marginDuringScroll).toBe('7px'); + // ...and the user's value is restored afterward, not clobbered. + expect(item.style.scrollMarginTop).toBe('999px'); expect(item).toHaveStyle({ color: 'rgb(4, 5, 6)' }); }); }); From 0c69e3ec8b558f79ed9c7c8565906381c5a97054 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Wed, 15 Jul 2026 00:09:25 +0800 Subject: [PATCH 7/7] update --- jest.config.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/jest.config.js b/jest.config.js index a1b9f5f..1b72e2a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,8 +1,5 @@ module.exports = { setupFiles: ['./tests/setup.js'], - // ponytail: git worktrees under .claude/ are duplicate copies of this package; - // excluding them from the haste map avoids duplicate-module-name crashes. - modulePathIgnorePatterns: ['/.claude/'], moduleNameMapper: { '^@rc-component/listy$': '/src/index.ts', },