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/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..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,38 +37,26 @@ 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), }), [], ); // ============================ Render Item =========================== const renderItem = React.useCallback( - (item: T, index: number, groupKey?: K) => { + (item: T, index: number) => { const key = getItemKey(item); - const scrollTargetProps = getScrollTargetProps(key); + const scrollTargetProps = getScrollTargetProps(key, 'item'); return (
{itemRender(item, index)} @@ -80,7 +69,6 @@ function RawList( getScrollTargetProps, itemRender, prefixCls, - sticky, styles?.item, ], ); @@ -94,7 +82,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..8343d05 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import { toTaggedKey } from '../util'; import type { ListyRef, ScrollAlign } from '../List'; export default function useRawListScroll( @@ -36,17 +37,32 @@ 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 scrollTargetIntoView = React.useCallback( + ( + targetElement: HTMLElement, + align: ScrollAlign, + offset: number, + isItem: boolean, + ) => { + 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, prefixCls], + [getStickyHeaderHeight], ); // ============================== Scroll ============================== @@ -63,26 +79,19 @@ 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 + ? toTaggedKey(config.key, 'item') + : toTaggedKey(config.groupKey, 'group'); const targetElement = holder.querySelector( - `[data-key="${CSS.escape(String(targetKey))}"]`, + `[data-key="${CSS.escape( + targetKey + )}"]`, ); if (targetElement) { - if ('key' in config) { - setTargetScrollMargin(targetElement, align); - } - - targetElement.scrollIntoView({ - block: - align === 'bottom' - ? 'end' - : align === 'auto' - ? 'nearest' - : 'start', - inline: 'nearest', - }); + scrollTargetIntoView(targetElement, align, offset, isItem); } return; } @@ -95,7 +104,7 @@ export default function useRawListScroll( holder.scrollTop = top; } }, - [setTargetScrollMargin], + [scrollTargetIntoView], ); // ============================ Imperative ============================ diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index 4f086ae..2b5496a 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,84 +48,82 @@ 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, top-aligned item scroll should land below its header. - if ( - config && - typeof config === 'object' && - 'key' in config && - sticky && - group && - config.align === 'top' - ) { - const groupKey = itemKeyToGroupKey.get(config.key); - - if (groupKey !== undefined) { - const { offset = 0 } = config; - - listRef.current?.scrollTo({ - ...config, - // Use the measured header height so top-aligned items stay below it. - offset: ({ getSize }: ScrollOffsetInfo) => { - const headerSize = getSize(groupKey); - const headerHeight = headerSize.bottom - headerSize.top; - - return offset + (Number.isFinite(headerHeight) ? headerHeight : 0); - }, - }); + if ('key' in config) { + const taggedItemKey = toTaggedKey(config.key, 'item'); + const stickyGroupKey = + sticky && group && config.align !== 'bottom' + ? itemKeyToGroupKey.get(taggedItemKey) + : undefined; + + if (stickyGroupKey === undefined) { + listRef.current?.scrollTo({ ...config, key: taggedItemKey }); return; } + + listRef.current?.scrollTo({ + ...config, + 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; } - // Other scroll shapes are already supported by the underlying virtual list. - listRef.current?.scrollTo(config as number | ScrollConfig | null); + listRef.current?.scrollTo(config); }); // ============================ Imperative ============================ @@ -175,7 +175,7 @@ function VirtualList( fullHeight={false} height={height} itemHeight={itemHeight} - itemKey={getKey} + itemKey="taggedKey" onScroll={onScroll} prefixCls={prefixCls} virtual @@ -184,7 +184,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/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/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 126c339..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 = ( @@ -123,7 +131,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 +174,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(); }); @@ -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,6 +210,9 @@ describe('Listy behaviors', () => { }); }); + // 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', @@ -220,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(() => { @@ -259,6 +276,9 @@ describe('Listy behaviors', () => { act(() => { ref.current?.scrollTo({ key: 2, align: 'bottom', offset: 4 }); }); + // 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', @@ -333,7 +353,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', () => { @@ -353,7 +373,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')); }); @@ -391,28 +411,65 @@ 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(() => { - 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)', - ); + // Restored after the scroll (the user set no scroll margin). + expect(itemNode.style.scrollMarginTop).toBe(''); expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start', inline: 'nearest', }); }); + 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="item:1"]') as HTMLElement; + 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 + // 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', () => { const ref = React.createRef(); const { container } = renderList({ @@ -427,16 +484,19 @@ describe('Listy behaviors', () => { container.querySelector('.rc-listy-group-header')?.remove(); - const itemNode = container.querySelector('[data-key="1"]') as HTMLElement; - itemNode.scrollIntoView = jest.fn(); + const itemNode = container.querySelector('[data-key="item:1"]') as HTMLElement; + let marginDuringScroll: string | undefined; + itemNode.scrollIntoView = jest.fn(() => { + marginDuringScroll = itemNode.style.scrollMarginTop; + }); act(() => { ref.current?.scrollTo({ key: 1, align: 'top' }); }); - expect( - itemNode.style.getPropertyValue('--rc-listy-item-scroll-margin-top'), - ).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', () => { @@ -461,7 +521,7 @@ describe('Listy behaviors', () => { }); expect(scrollHandler).toHaveBeenCalledWith({ - key: 'Group A', + key: 'group:Group A', align: 'bottom', offset: 12, }); @@ -486,7 +546,7 @@ describe('Listy behaviors', () => { }); expect(scrollHandler).toHaveBeenCalledWith({ - key: 2, + key: 'item:2', align: 'top', offset: expect.any(Function), }); @@ -496,7 +556,8 @@ 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 }, + align: 'top', }), ).toBe(29); }); @@ -515,4 +576,168 @@ describe('Listy behaviors', () => { expect(holder).toHaveClass('rc-listy-rtl'); expect(holder).toHaveAttribute('dir', 'rtl'); }); + + it('offsets auto-aligned scrollTo when it resolves to a top landing', () => { + 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: 'item:2', + align: 'auto', + 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(5); + }); + + 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); + + 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 7351302..3db2b4f 100644 --- a/tests/semantic.test.tsx +++ b/tests/semantic.test.tsx @@ -1,8 +1,9 @@ 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'; +import { toTaggedKey } from '../src/util'; jest.mock('@rc-component/virtual-list', () => { const ReactMock = require('react'); @@ -121,28 +122,49 @@ 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('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; + let marginDuringScroll: string | undefined; + item.scrollIntoView = jest.fn(() => { + marginDuringScroll = item.style.scrollMarginTop; + }); // 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 }); + }); + // ...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('does not let styles.item.scrollMarginTop override the internal offset', () => { + it('restores styles.item.scrollMarginTop after applying the internal offset for the scroll', () => { + 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="item:1"]') as HTMLElement; + 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 }); + }); + // ...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)' }); }); }); @@ -268,7 +290,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, }),