+
+
+
+
+
+
+
+
+ {/* 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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',
},
From d5669310391b1f1d041ef24186170c7ec540b8b5 Mon Sep 17 00:00:00 2001
From: aojunhao123 <1844749591@qq.com>
Date: Wed, 15 Jul 2026 17:44:13 +0800
Subject: [PATCH 08/13] update
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 2a6da82..f9eddf3 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,7 @@
"@rc-component/resize-observer": "^1.0.0",
"@rc-component/util": "^1.3.1",
"clsx": "^2.1.1",
- "@rc-component/virtual-list": "^1.2.0"
+ "@rc-component/virtual-list": "^1.4.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
From 4a3e5e3cf5cb390800991c30f64d96e11d9304e7 Mon Sep 17 00:00:00 2001
From: aojunhao123 <1844749591@qq.com>
Date: Wed, 15 Jul 2026 18:45:14 +0800
Subject: [PATCH 09/13] update
---
.githooks/pre-commit | 15 +++++
docs/examples/scroll-to.tsx | 14 +++--
package.json | 1 +
src/RawList/index.tsx | 6 +-
src/RawList/useRawListScroll.ts | 6 +-
src/VirtualList/index.tsx | 32 +++++++----
src/VirtualList/useFlattenRows.ts | 3 +-
tests/hooks.test.tsx | 93 ++++++++++++++++++++++---------
tests/listy.behavior.test.tsx | 44 +++++++++------
tests/semantic.test.tsx | 18 ++++--
10 files changed, 157 insertions(+), 75 deletions(-)
create mode 100755 .githooks/pre-commit
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 0000000..d106d55
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,15 @@
+#!/bin/sh
+# Mirrors CI (lint + tsc + test) and adds the fmt check CI lacks.
+set -e
+
+# ponytail: prettier checks working-tree content of staged files, not the
+# staged blobs — partial staging can slip through; lint-staged if that bites.
+# Staged-files-only because the repo has pre-existing unformatted files.
+files=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.(ts|tsx|js|jsx|json|less|md)$' || true)
+if [ -n "$files" ]; then
+ echo "$files" | xargs npx prettier --check --ignore-unknown
+fi
+
+npm run lint
+npx tsc --noEmit
+npm test
diff --git a/docs/examples/scroll-to.tsx b/docs/examples/scroll-to.tsx
index 576d1e3..819835f 100644
--- a/docs/examples/scroll-to.tsx
+++ b/docs/examples/scroll-to.tsx
@@ -117,7 +117,10 @@ export default () => {
{/* { groupKey, align, offset } — scroll to a group header */}
-
last config:{' '}
- {lastConfig === undefined ? '—' : JSON.stringify(lastConfig)}
+
+ {lastConfig === undefined ? '—' : JSON.stringify(lastConfig)}
+
=
- ListComponentProps;
+export type RawListProps<
+ T,
+ K extends React.Key = React.Key,
+> = ListComponentProps;
function RawList(
props: RawListProps,
diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts
index 8343d05..69500a3 100644
--- a/src/RawList/useRawListScroll.ts
+++ b/src/RawList/useRawListScroll.ts
@@ -79,15 +79,13 @@ export default function useRawListScroll(
}
if ('key' in config || 'groupKey' in config) {
- const { align = 'top', offset = 0 } = config;
+ const { align = 'auto', offset = 0 } = config;
const isItem = 'key' in config;
const targetKey = isItem
? toTaggedKey(config.key, 'item')
: toTaggedKey(config.groupKey, 'group');
const targetElement = holder.querySelector(
- `[data-key="${CSS.escape(
- targetKey
- )}"]`,
+ `[data-key="${CSS.escape(targetKey)}"]`,
);
if (targetElement) {
diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx
index 2b5496a..03b8c5b 100644
--- a/src/VirtualList/index.tsx
+++ b/src/VirtualList/index.tsx
@@ -117,7 +117,9 @@ function VirtualList(
const headerSize = getSize(toTaggedKey(stickyGroupKey, 'group'));
const headerHeight = headerSize.bottom - headerSize.top;
- return baseOffset + (Number.isFinite(headerHeight) ? headerHeight : 0);
+ return (
+ baseOffset + (Number.isFinite(headerHeight) ? headerHeight : 0)
+ );
},
});
return;
@@ -163,7 +165,13 @@ function VirtualList(
/>
);
},
- [classNames?.groupHeader, group, groupKeyToItems, prefixCls, styles?.groupHeader],
+ [
+ classNames?.groupHeader,
+ group,
+ groupKeyToItems,
+ prefixCls,
+ styles?.groupHeader,
+ ],
);
// ============================== Render ==============================
@@ -184,16 +192,16 @@ function VirtualList(
style={styles?.root}
>
{(row: Row) =>
- row.type === 'group'
- ? renderHeaderRow(row.groupKey)
- : (
-
- {itemRender(row.item, row.index)}
-
- )
+ row.type === 'group' ? (
+ renderHeaderRow(row.groupKey)
+ ) : (
+
+ {itemRender(row.item, row.index)}
+
+ )
}
);
diff --git a/src/VirtualList/useFlattenRows.ts b/src/VirtualList/useFlattenRows.ts
index f882b95..0e4dc7b 100644
--- a/src/VirtualList/useFlattenRows.ts
+++ b/src/VirtualList/useFlattenRows.ts
@@ -4,8 +4,7 @@ import type { Group, GroupSegmentItem } from '../hooks/useGroupSegments';
// ============================== Types ===============================
export type Row = (
- | { type: 'group'; groupKey: K }
- | { type: 'item'; item: T; index: number }
+ { type: 'group'; groupKey: K } | { type: 'item'; item: T; index: number }
) & {
taggedKey: string;
};
diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx
index a907803..52a184d 100644
--- a/tests/hooks.test.tsx
+++ b/tests/hooks.test.tsx
@@ -50,9 +50,9 @@ const StickyHeaderTester = ({
const createListRef = (
nativeElement: HTMLElement,
): React.RefObject =>
- ({ current: { nativeElement } } as unknown as React.RefObject<
- RcVirtualListRef | null
- >);
+ ({
+ current: { nativeElement },
+ }) as unknown as React.RefObject;
describe('useGroupSegments', () => {
it('groups items by key across the full data set', () => {
@@ -136,10 +136,25 @@ describe('useFlattenRows', () => {
expect(result.current.rows).toEqual([
{ 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: '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') },
+ {
+ type: 'item',
+ item: items[1],
+ index: 1,
+ taggedKey: toTaggedKey(1, 'item'),
+ },
]);
expect(result.current.groupKeys).toEqual(['A', 'B']);
expect(result.current.groupKeyToItems).toEqual(
@@ -163,8 +178,18 @@ describe('useFlattenRows', () => {
expect(result.current).toEqual({
rows: [
- { type: 'item', item: items[0], index: 0, taggedKey: toTaggedKey(0, 'item') },
- { type: 'item', item: items[1], index: 1, taggedKey: toTaggedKey(1, 'item') },
+ {
+ 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(),
@@ -226,7 +251,8 @@ describe('useStickyGroupHeader', () => {
expect(stickyHeader).toHaveTextContent('Group 2-3');
// Last group, nothing to push it: pinned at the container top.
expect(stickyHeader).toHaveStyle({ top: '0px' });
- expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); });
+ expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6));
+ });
it('skips sticky header rendering when virtual list is disabled', () => {
const info = createRenderInfo({ virtual: false });
@@ -249,12 +275,15 @@ describe('useStickyGroupHeader', () => {
const stickyHeader = container.querySelector(
`.${PREFIX_CLS}-group-header-fixed`,
);
- expect(stickyHeader).toBeNull(); });
+ expect(stickyHeader).toBeNull();
+ });
it('keeps the fixed header pinned at 0 within a group regardless of scroll', () => {
- const title = jest.fn().mockImplementation((key: React.Key) => (
- {String(key)}
- ));
+ const title = jest
+ .fn()
+ .mockImplementation((key: React.Key) => (
+ {String(key)}
+ ));
// Active group is Group 1, with Group 2's header far below the viewport.
const info = createRenderInfo({
@@ -287,12 +316,15 @@ describe('useStickyGroupHeader', () => {
);
expect(stickyHeader).not.toBeNull();
expect(stickyHeader).toHaveTextContent('Group 1');
- expect(stickyHeader).toHaveStyle({ top: '0px' }); });
+ expect(stickyHeader).toHaveStyle({ top: '0px' });
+ });
it('pushes the fixed header away when the next group reaches the top', () => {
- const title = jest.fn().mockImplementation((key: React.Key) => (
- {String(key)}
- ));
+ const title = jest
+ .fn()
+ .mockImplementation((key: React.Key) => (
+ {String(key)}
+ ));
const info = createRenderInfo({
scrollTop: 70,
@@ -329,12 +361,15 @@ describe('useStickyGroupHeader', () => {
);
expect(stickyHeader).not.toBeNull();
expect(stickyHeader).toHaveTextContent('Group 1');
- expect(stickyHeader).toHaveStyle({ top: '-10px' }); });
+ 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)}
- ));
+ const title = jest
+ .fn()
+ .mockImplementation((key: React.Key) => (
+ {String(key)}
+ ));
// Numeric group keys where the incoming group is keyed 0 (falsy).
const info = createRenderInfo({
@@ -380,9 +415,11 @@ describe('useStickyGroupHeader', () => {
});
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)}
- ));
+ const title = jest
+ .fn()
+ .mockImplementation((key: React.Key) => (
+ {String(key)}
+ ));
// Group 2's header sits exactly at the viewport top (scrollTop === its top),
// while `start` is Group 1's last item row (3) — the row before Group 2's
@@ -423,9 +460,11 @@ describe('useStickyGroupHeader', () => {
});
it('tolerates a sub-pixel scroll offset just short of the header top', () => {
- const title = jest.fn().mockImplementation((key: React.Key) => (
- {String(key)}
- ));
+ const title = jest
+ .fn()
+ .mockImplementation((key: React.Key) => (
+ {String(key)}
+ ));
// On a HiDPI screen the scroll offset can rest a fraction of a pixel below
// a header's true top (here 199.5 vs 200). Without tolerance the strict
diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx
index cb2ce8d..548f3b4 100644
--- a/tests/listy.behavior.test.tsx
+++ b/tests/listy.behavior.test.tsx
@@ -1,8 +1,6 @@
import React from 'react';
import { act, render } from '@testing-library/react';
-import type {
- ListProps as VirtualListProps,
-} from '@rc-component/virtual-list';
+import type { ListProps as VirtualListProps } from '@rc-component/virtual-list';
import Listy, { type ListyRef, type ListyProps } from '@rc-component/listy';
import RawList from '../src/RawList';
@@ -24,13 +22,10 @@ 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);
},
@@ -60,9 +55,6 @@ 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,
@@ -74,7 +66,6 @@ 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')
@@ -88,7 +79,6 @@ describe('Listy behaviors', () => {
virtual: true,
});
MockedVirtualList.__setScrollHandler(() => {});
- MockedVirtualList.__setScrollTop(0);
});
const renderList = (
@@ -168,7 +158,9 @@ describe('Listy behaviors', () => {
);
const groupSections = container.querySelectorAll('.rc-listy-group-section');
- expect(container.querySelector('[data-testid="mock-virtual-list"]')).toBeNull();
+ expect(
+ container.querySelector('[data-testid="mock-virtual-list"]'),
+ ).toBeNull();
expect(stickyHeader).not.toBeNull();
expect(stickyHeader).toHaveClass('rc-listy-group-header');
expect(stickyHeader).toHaveTextContent('Group Group A');
@@ -268,8 +260,9 @@ describe('Listy behaviors', () => {
act(() => {
ref.current?.scrollTo({ key: 2 });
});
+ // Omitted align defaults to nearest, matching virtual mode's auto.
expect(scrollIntoView).toHaveBeenLastCalledWith({
- block: 'start',
+ block: 'nearest',
inline: 'nearest',
});
@@ -411,7 +404,9 @@ describe('Listy behaviors', () => {
}) as DOMRect,
);
- const itemNode = container.querySelector('[data-key="item: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');
@@ -454,7 +449,9 @@ describe('Listy behaviors', () => {
() => ({ bottom: 36, height: 36, top: 0 }) as DOMRect,
);
- const itemNode = container.querySelector('[data-key="item:1"]') as HTMLElement;
+ const itemNode = container.querySelector(
+ '[data-key="item:1"]',
+ ) as HTMLElement;
let marginDuringScroll: string | undefined;
itemNode.scrollIntoView = jest.fn(() => {
marginDuringScroll = itemNode.style.scrollMarginTop;
@@ -484,7 +481,9 @@ describe('Listy behaviors', () => {
container.querySelector('.rc-listy-group-header')?.remove();
- const itemNode = container.querySelector('[data-key="item:1"]') as HTMLElement;
+ const itemNode = container.querySelector(
+ '[data-key="item:1"]',
+ ) as HTMLElement;
let marginDuringScroll: string | undefined;
itemNode.scrollIntoView = jest.fn(() => {
marginDuringScroll = itemNode.style.scrollMarginTop;
@@ -556,7 +555,9 @@ describe('Listy behaviors', () => {
expect(
offset({
getSize: (key: React.Key) =>
- key === 'group:Group A' ? { top: 10, bottom: 34 } : { top: 0, bottom: 0 },
+ key === 'group:Group A'
+ ? { top: 10, bottom: 34 }
+ : { top: 0, bottom: 0 },
align: 'top',
}),
).toBe(29);
@@ -632,7 +633,9 @@ describe('Listy behaviors', () => {
expect(
offset({
getSize: (key: React.Key) =>
- key === 'group:Group A' ? { top: 10, bottom: 34 } : { top: 0, bottom: 0 },
+ key === 'group:Group A'
+ ? { top: 10, bottom: 34 }
+ : { top: 0, bottom: 0 },
align: 'bottom',
}),
).toBe(5);
@@ -733,6 +736,11 @@ describe('Listy behaviors', () => {
// The item — not the group section sharing the key — is the target.
expect(itemScroll).toHaveBeenCalledTimes(1);
expect(groupScroll).not.toHaveBeenCalled();
+ // Omitted align defaults to nearest, matching virtual mode's auto.
+ expect(itemScroll).toHaveBeenCalledWith({
+ block: 'nearest',
+ inline: 'nearest',
+ });
act(() => {
ref.current?.scrollTo({ groupKey: 'x' });
diff --git a/tests/semantic.test.tsx b/tests/semantic.test.tsx
index 3db2b4f..47c1475 100644
--- a/tests/semantic.test.tsx
+++ b/tests/semantic.test.tsx
@@ -48,7 +48,11 @@ const group = {
title: (key: React.Key) => Group {String(key)},
};
-const CLASSNAMES = { root: 'my-root', item: 'my-item', groupHeader: 'my-header' };
+const CLASSNAMES = {
+ root: 'my-root',
+ item: 'my-item',
+ groupHeader: 'my-header',
+};
const STYLES = {
root: { background: 'rgb(1, 2, 3)' },
item: { color: 'rgb(4, 5, 6)' },
@@ -71,7 +75,9 @@ describe('semantic DOM (classNames / styles)', () => {
/>,
);
- const node = container.querySelector('.rc-listy-group-header') as HTMLElement;
+ const node = container.querySelector(
+ '.rc-listy-group-header',
+ ) as HTMLElement;
expect(node).toHaveClass('rc-listy-group-header-fixed');
expect(node).toHaveClass('my-header');
expect(node).toHaveStyle({ color: 'rgb(7, 8, 9)' });
@@ -125,7 +131,9 @@ describe('semantic DOM (classNames / styles)', () => {
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;
+ const item = container.querySelector(
+ '[data-key="item:1"]',
+ ) as HTMLElement;
let marginDuringScroll: string | undefined;
item.scrollIntoView = jest.fn(() => {
marginDuringScroll = item.style.scrollMarginTop;
@@ -150,7 +158,9 @@ describe('semantic DOM (classNames / styles)', () => {
ref,
styles: { item: { color: 'rgb(4, 5, 6)', scrollMarginTop: 999 } },
});
- const item = container.querySelector('[data-key="item:1"]') as HTMLElement;
+ const item = container.querySelector(
+ '[data-key="item:1"]',
+ ) as HTMLElement;
let marginDuringScroll: string | undefined;
item.scrollIntoView = jest.fn(() => {
marginDuringScroll = item.style.scrollMarginTop;
From 57f8010226a6a20e7e66ebb0dc8b5aab939c346a Mon Sep 17 00:00:00 2001
From: aojunhao123 <1844749591@qq.com>
Date: Wed, 15 Jul 2026 19:27:36 +0800
Subject: [PATCH 10/13] update
---
eslint.config.mjs | 1 +
package.json | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/eslint.config.mjs b/eslint.config.mjs
index a298e8e..d9e494a 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -101,6 +101,7 @@ export default defineConfig([
files: ['tests/**/*.{js,jsx,ts,tsx}', '**/*.{test,spec}.{js,jsx,ts,tsx}'],
extends: [jest.configs['flat/recommended']],
rules: {
+ '@typescript-eslint/no-require-imports': 'off',
'jest/no-disabled-tests': 'off',
'jest/no-done-callback': 'off',
'jest/no-identical-title': 'off',
diff --git a/package.json b/package.json
index f1fdddc..ff68c5b 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,7 @@
"build": "dumi build",
"compile": "father build && lessc assets/index.less assets/index.css",
"prepublishOnly": "npm run compile && rc-np",
- "lint": "eslint src/ docs/examples/ --ext .tsx,.ts,.jsx,.js",
+ "lint": "eslint src/ tests/ docs/examples/ --ext .tsx,.ts,.jsx,.js",
"test": "rc-test",
"prepare": "git config core.hooksPath .githooks",
"prettier": "prettier --write .",
From 39abd496e6ce77bc4f88d4220bb0f95f0805b155 Mon Sep 17 00:00:00 2001
From: Koyomi <1844749591@qq.com>
Date: Wed, 15 Jul 2026 19:34:06 +0800
Subject: [PATCH 11/13] Add prettier check to pre-commit hook
Updated pre-commit hook to include prettier check for staged files.
---
.githooks/pre-commit | 3 ---
1 file changed, 3 deletions(-)
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index d106d55..7222d2d 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -2,9 +2,6 @@
# Mirrors CI (lint + tsc + test) and adds the fmt check CI lacks.
set -e
-# ponytail: prettier checks working-tree content of staged files, not the
-# staged blobs — partial staging can slip through; lint-staged if that bites.
-# Staged-files-only because the repo has pre-existing unformatted files.
files=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.(ts|tsx|js|jsx|json|less|md)$' || true)
if [ -n "$files" ]; then
echo "$files" | xargs npx prettier --check --ignore-unknown
From 615889900c147ee0bc5a27df77cc3bf0ac17ceb0 Mon Sep 17 00:00:00 2001
From: aojunhao123 <1844749591@qq.com>
Date: Thu, 16 Jul 2026 15:48:53 +0800
Subject: [PATCH 12/13] update
---
.githooks/pre-commit | 13 +------------
package.json | 10 +++++++---
src/List.tsx | 15 ++++++++-------
3 files changed, 16 insertions(+), 22 deletions(-)
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 7222d2d..d0a7784 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -1,12 +1 @@
-#!/bin/sh
-# Mirrors CI (lint + tsc + test) and adds the fmt check CI lacks.
-set -e
-
-files=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.(ts|tsx|js|jsx|json|less|md)$' || true)
-if [ -n "$files" ]; then
- echo "$files" | xargs npx prettier --check --ignore-unknown
-fi
-
-npm run lint
-npx tsc --noEmit
-npm test
+npx lint-staged
\ No newline at end of file
diff --git a/package.json b/package.json
index ff68c5b..9ad195c 100644
--- a/package.json
+++ b/package.json
@@ -38,17 +38,20 @@
"prettier": "prettier --write .",
"coverage": "rc-test --coverage"
},
+ "lint-staged": {
+ "*.{ts,tsx,js,jsx,json,less,md}": "prettier --write"
+ },
"dependencies": {
"@rc-component/motion": "^1.1.4",
"@rc-component/portal": "^2.0.0",
"@rc-component/resize-observer": "^1.0.0",
"@rc-component/util": "^1.3.1",
- "clsx": "^2.1.1",
- "@rc-component/virtual-list": "^1.4.0"
+ "@rc-component/virtual-list": "^1.4.0",
+ "clsx": "^2.1.1"
},
"devDependencies": {
- "@eslint/js": "^10.0.1",
"@eslint/compat": "^2.1.0",
+ "@eslint/js": "^10.0.1",
"@rc-component/father-plugin": "^2.2.0",
"@rc-component/np": "^1.0.4",
"@testing-library/jest-dom": "^6.9.1",
@@ -68,6 +71,7 @@
"father": "^4.6.24",
"globals": "^17.7.0",
"less": "^4.6.7",
+ "lint-staged": "^17.0.8",
"prettier": "^3.9.4",
"rc-test": "^7.1.3",
"react": "^19.2.7",
diff --git a/src/List.tsx b/src/List.tsx
index 0728fd8..d9ba9da 100644
--- a/src/List.tsx
+++ b/src/List.tsx
@@ -13,7 +13,9 @@ export type ListySemanticName = 'root' | 'item' | 'groupHeader';
export type ListyClassNames = Partial>;
-export type ListyStyles = Partial>;
+export type ListyStyles = Partial<
+ Record
+>;
export interface GroupScrollToConfig {
groupKey: React.Key;
@@ -92,12 +94,11 @@ function Listy(
ref,
};
- const listNode =
- virtual ? (
-
- ) : (
-
- );
+ const listNode = virtual ? (
+
+ ) : (
+
+ );
return listNode;
}
From afba0d939585ed9994b8eeefcadbe2b3b1001c1e Mon Sep 17 00:00:00 2001
From: aojunhao123 <1844749591@qq.com>
Date: Thu, 16 Jul 2026 17:08:25 +0800
Subject: [PATCH 13/13] update
---
.githooks/pre-commit | 1 -
.husky/pre-commit | 1 +
package.json | 3 ++-
3 files changed, 3 insertions(+), 2 deletions(-)
delete mode 100755 .githooks/pre-commit
create mode 100644 .husky/pre-commit
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
deleted file mode 100755
index d0a7784..0000000
--- a/.githooks/pre-commit
+++ /dev/null
@@ -1 +0,0 @@
-npx lint-staged
\ No newline at end of file
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 0000000..2312dc5
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1 @@
+npx lint-staged
diff --git a/package.json b/package.json
index 9ad195c..08b79d5 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"prepublishOnly": "npm run compile && rc-np",
"lint": "eslint src/ tests/ docs/examples/ --ext .tsx,.ts,.jsx,.js",
"test": "rc-test",
- "prepare": "git config core.hooksPath .githooks",
+ "prepare": "husky",
"prettier": "prettier --write .",
"coverage": "rc-test --coverage"
},
@@ -70,6 +70,7 @@
"eslint-plugin-react-hooks": "^7.1.1",
"father": "^4.6.24",
"globals": "^17.7.0",
+ "husky": "^9.1.7",
"less": "^4.6.7",
"lint-staged": "^17.0.8",
"prettier": "^3.9.4",