From bdf86cde6e2301c6c3dab0520762227166a3c82b Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Mon, 13 Jul 2026 12:48:59 +0800 Subject: [PATCH 01/13] fix: honor offset in non-virtual scrollTo, export scroll config types The virtual list already applied the `offset` from `scrollTo({ key, align, offset })`, but the non-virtual RawList path ignored it: it set a `--rc-listy-item-scroll-margin-top` CSS variable that only accounted for the sticky header height and never the user's offset, and it never set a bottom margin, so `align: 'bottom'` offsets did nothing. - RawList now applies `scrollMarginTop`/`scrollMarginBottom` directly on the scroll target at scroll time (header height + offset for items on `top` align, offset otherwise), matching the virtual path. - Drop the CSS-variable indirection and the per-render inline scrollMarginTop on every item; the margin is applied only on the target, only when scrolling. - Export `ScrollAlign` and `ListyScrollToConfig` so consumers can type the config they pass to `scrollTo`. - Add a `scroll-to` demo exercising both virtual and raw modes. --- docs/demos/scroll-to.md | 8 ++ docs/examples/scroll-to.tsx | 185 ++++++++++++++++++++++++++++++++ src/RawList/index.tsx | 14 +-- src/RawList/useRawListScroll.ts | 34 +++--- src/index.ts | 2 + tests/listy.behavior.test.tsx | 17 ++- tests/semantic.test.tsx | 43 +++++--- 7 files changed, 250 insertions(+), 53 deletions(-) create mode 100644 docs/demos/scroll-to.md create mode 100644 docs/examples/scroll-to.tsx diff --git a/docs/demos/scroll-to.md b/docs/demos/scroll-to.md new file mode 100644 index 0000000..a287e28 --- /dev/null +++ b/docs/demos/scroll-to.md @@ -0,0 +1,8 @@ +--- +title: Scroll To +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/scroll-to.tsx b/docs/examples/scroll-to.tsx new file mode 100644 index 0000000..576d1e3 --- /dev/null +++ b/docs/examples/scroll-to.tsx @@ -0,0 +1,185 @@ +import React, { useRef, useState } from 'react'; +import Listy, { + type ListyRef, + type ListyScrollToConfig, + type ScrollAlign, +} from '@rc-component/listy'; +import '../../assets/index.less'; + +const GROUP_SIZE = 15; +const GROUP_COUNT = 6; + +interface Row { + id: number; + name: string; + group: string; +} + +const items: Row[] = Array.from( + { length: GROUP_SIZE * GROUP_COUNT }, + (_, index) => ({ + id: index, + name: `Item ${index}`, + group: `Group ${Math.floor(index / GROUP_SIZE)}`, + }), +); + +const GROUP_KEYS = Array.from({ length: GROUP_COUNT }, (_, i) => `Group ${i}`); + +const controlRow: React.CSSProperties = { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, +}; + +export default () => { + const listRef = useRef(null); + const [virtual, setVirtual] = useState(true); + const [align, setAlign] = useState('top'); + const [offset, setOffset] = useState(0); + const [itemKey, setItemKey] = useState(50); + const [groupKey, setGroupKey] = useState('Group 3'); + const [lastConfig, setLastConfig] = useState(); + + // Single entry point so the demo can echo the exact config it passes in. + const run = (config: ListyScrollToConfig) => { + setLastConfig(config); + listRef.current?.scrollTo(config); + }; + + return ( +
+
+ + + + + +
+ + {/* number | { top } — absolute pixel scroll */} +
+ + + +
+ + {/* { key, align, offset } — scroll to an item */} +
+ + + + {/* { groupKey, align, offset } — scroll to a group header */} + + +
+ +
+ last config:{' '} + {lastConfig === undefined ? '—' : JSON.stringify(lastConfig)} +
+ + { + const height = 40 + (index % 3) * 16; + return ( +
+ {item.name} +
+ ); + }} + group={{ + key: (item) => item.group, + title: (key, groupItems) => ( +
+ {key} · {groupItems.length} items +
+ ), + }} + /> +
+ ); +}; diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index 5437218..9d9c746 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -52,7 +52,7 @@ function RawList( // ============================ Render Item =========================== const renderItem = React.useCallback( - (item: T, index: number, groupKey?: K) => { + (item: T, index: number) => { const key = getItemKey(item); const scrollTargetProps = getScrollTargetProps(key); @@ -60,14 +60,7 @@ function RawList(
{itemRender(item, index)} @@ -80,7 +73,6 @@ function RawList( getScrollTargetProps, itemRender, prefixCls, - sticky, styles?.item, ], ); @@ -106,7 +98,7 @@ function RawList( style={styles?.groupHeader} /> {groupItems.map(({ item, index }) => { - return renderItem(item, index, groupKey); + return renderItem(item, index); })}
); diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts index af6ba93..1e42ee6 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -36,17 +36,20 @@ export default function useRawListScroll( [prefixCls, stickyGroup], ); - const setTargetScrollMargin = React.useCallback( - (targetElement: HTMLElement, align: ScrollAlign) => { - const marginTop = - align === 'top' ? getStickyHeaderHeight(targetElement) : 0; - - targetElement.style.setProperty( - `--${prefixCls}-item-scroll-margin-top`, - `${marginTop}px`, - ); + const applyScrollMargin = React.useCallback( + ( + targetElement: HTMLElement, + align: ScrollAlign, + offset: number, + isItem: boolean, + ) => { + const headerOffset = + isItem && align === 'top' ? getStickyHeaderHeight(targetElement) : 0; + + targetElement.style.scrollMarginTop = `${headerOffset + offset}px`; + targetElement.style.scrollMarginBottom = `${offset}px`; }, - [getStickyHeaderHeight, prefixCls], + [getStickyHeaderHeight], ); // ============================== Scroll ============================== @@ -63,16 +66,15 @@ export default function useRawListScroll( } if ('key' in config || 'groupKey' in config) { - const { align = 'top' } = config; - const targetKey = 'groupKey' in config ? config.groupKey : config.key; + const { align = 'top', offset = 0 } = config; + const isItem = 'key' in config; + const targetKey = isItem ? config.key : config.groupKey; const targetElement = holder.querySelector( `[data-key="${CSS.escape(String(targetKey))}"]`, ); if (targetElement) { - if ('key' in config) { - setTargetScrollMargin(targetElement, align); - } + applyScrollMargin(targetElement, align, offset, isItem); targetElement.scrollIntoView({ block: @@ -95,7 +97,7 @@ export default function useRawListScroll( holder.scrollTop = top; } }, - [setTargetScrollMargin], + [applyScrollMargin], ); // ============================ Imperative ============================ diff --git a/src/index.ts b/src/index.ts index 5ae2135..ae47b13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ export type { ListySemanticName, ListyClassNames, ListyStyles, + ScrollAlign, + ListyScrollToConfig, } from './List'; export default Listy; diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index 126c339..06c92f0 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -199,6 +199,7 @@ describe('Listy behaviors', () => { }); }); + expect(groupBSection.style.scrollMarginTop).toBe('5px'); expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start', inline: 'nearest', @@ -259,6 +260,7 @@ describe('Listy behaviors', () => { act(() => { ref.current?.scrollTo({ key: 2, align: 'bottom', offset: 4 }); }); + expect(secondItem.style.scrollMarginBottom).toBe('4px'); expect(scrollIntoView).toHaveBeenLastCalledWith({ block: 'end', inline: 'nearest', @@ -393,20 +395,17 @@ describe('Listy behaviors', () => { const itemNode = container.querySelector('[data-key="1"]') as HTMLElement; const scrollIntoView = jest.fn(() => { - expect( - itemNode.style.getPropertyValue('--rc-listy-item-scroll-margin-top'), - ).toBe('36px'); + // Header height plus the user offset must be applied before scrolling. + expect(itemNode.style.scrollMarginTop).toBe('46px'); }); itemNode.scrollIntoView = scrollIntoView; act(() => { - ref.current?.scrollTo({ key: 1, align: 'top' }); + ref.current?.scrollTo({ key: 1, align: 'top', offset: 10 }); }); expect(itemNode).toHaveClass('rc-listy-item'); - expect(itemNode.style.scrollMarginTop).toBe( - 'var(--rc-listy-item-scroll-margin-top, 0px)', - ); + expect(itemNode.style.scrollMarginTop).toBe('46px'); expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start', inline: 'nearest', @@ -434,9 +433,7 @@ describe('Listy behaviors', () => { ref.current?.scrollTo({ key: 1, align: 'top' }); }); - expect( - itemNode.style.getPropertyValue('--rc-listy-item-scroll-margin-top'), - ).toBe('0px'); + expect(itemNode.style.scrollMarginTop).toBe('0px'); }); it('scroll to group', () => { diff --git a/tests/semantic.test.tsx b/tests/semantic.test.tsx index 7351302..7c77dd1 100644 --- a/tests/semantic.test.tsx +++ b/tests/semantic.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { render } from '@testing-library/react'; -import Listy from '@rc-component/listy'; +import { render, act } from '@testing-library/react'; +import Listy, { type ListyRef } from '@rc-component/listy'; import GroupHeader from '../src/GroupHeader'; import useStickyGroupHeader from '../src/VirtualList/useStickyGroupHeader'; @@ -121,28 +121,39 @@ describe('semantic DOM (classNames / styles)', () => { }); }); - it('merges item style with the sticky scroll-margin (no overwrite)', () => { - const { container } = renderRaw({ sticky: true }); - const item = container.querySelector('.rc-listy-item') as HTMLElement; + it('keeps custom item style while applying the scroll margin on scroll', () => { + const ref = React.createRef(); + const { container } = renderRaw({ sticky: true, ref }); + const item = container.querySelector('[data-key="1"]') as HTMLElement; + item.scrollIntoView = jest.fn(); // custom style survives... expect(item).toHaveStyle({ color: 'rgb(4, 5, 6)' }); - // ...alongside the internal sticky scroll margin. - expect(item.style.scrollMarginTop).toBe( - 'var(--rc-listy-item-scroll-margin-top, 0px)', - ); + + act(() => { + ref.current?.scrollTo({ key: 1, align: 'top', offset: 3 }); + }); + // ...alongside the internal scroll margin applied to the scroll target. + expect(item.style.scrollMarginTop).toBe('3px'); + expect(item).toHaveStyle({ color: 'rgb(4, 5, 6)' }); }); - it('does not let styles.item.scrollMarginTop override the internal offset', () => { + it('lets the internal scroll offset override styles.item.scrollMarginTop', () => { + const ref = React.createRef(); const { container } = renderRaw({ sticky: true, + ref, styles: { item: { color: 'rgb(4, 5, 6)', scrollMarginTop: 999 } }, }); - const item = container.querySelector('.rc-listy-item') as HTMLElement; - // the internal scrollTo offset must win, not the user's 999px... - expect(item.style.scrollMarginTop).toBe( - 'var(--rc-listy-item-scroll-margin-top, 0px)', - ); - // ...while the user's other item styles still apply. + const item = container.querySelector('[data-key="1"]') as HTMLElement; + item.scrollIntoView = jest.fn(); + // the user's value is present at rest... + expect(item.style.scrollMarginTop).toBe('999px'); + + act(() => { + ref.current?.scrollTo({ key: 1, align: 'top', offset: 7 }); + }); + // ...but the internal offset wins on the scroll target. + expect(item.style.scrollMarginTop).toBe('7px'); expect(item).toHaveStyle({ color: 'rgb(4, 5, 6)' }); }); }); From 9e9b461a392f0f169d4d6861a055250b4b484039 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Mon, 13 Jul 2026 15:01:08 +0800 Subject: [PATCH 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",