+
+
+
+
+
+
+
+
+ {/* 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',
},