diff --git a/README.md b/README.md index 4d51b44..e44bd05 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ npm start ### ListyRef -- `scrollTo(config: number | { key?: React.Key; index?: number; align?: 'top' | 'bottom' | 'auto'; offset?: number; } | { groupKey: React.Key; align?: 'top' | 'bottom' | 'auto'; offset?: number; })` +- `scrollTo(config?: number | null | { left?: number; top?: number } | { key: React.Key; align?: 'top' | 'bottom' | 'auto'; offset?: number } | { groupKey: React.Key; align?: 'top' | 'bottom' | 'auto'; offset?: number })` - 传入 `groupKey` 时会直接滚动到对应组头(需启用 `group`) ## Test Case diff --git a/assets/index.less b/assets/index.less index c2ddd93..dcd058f 100644 --- a/assets/index.less +++ b/assets/index.less @@ -11,6 +11,7 @@ top: 0; left: 0; right: 0; + z-index: 1; } &-fixed { @@ -22,6 +23,10 @@ } } + &-group-section { + position: relative; + } + &-scrollbar { z-index: 1; } diff --git a/docs/examples/basic.tsx b/docs/examples/basic.tsx index fba1f81..1fb1eee 100644 --- a/docs/examples/basic.tsx +++ b/docs/examples/basic.tsx @@ -8,7 +8,7 @@ export default () => { const groupItemsCount = 20; const groupIndex = Math.floor(index / groupItemsCount); return { - id: index + 1, + id: index, name: `${index} (group ${groupIndex})`, type: `Group ${groupIndex * groupItemsCount}`, }; @@ -62,6 +62,17 @@ export default () => { > Scroll To 100 + + ); }; diff --git a/docs/examples/no-virtual.tsx b/docs/examples/no-virtual.tsx index cd1cd1c..32c6883 100644 --- a/docs/examples/no-virtual.tsx +++ b/docs/examples/no-virtual.tsx @@ -92,6 +92,10 @@ export default () => { [], ); + const handleScrollToItem = useCallback((itemId: string) => { + listRef.current?.scrollTo({ key: itemId, align: 'top' }); + }, []); + return (
{ ); })} + Total Items: {items.length}
diff --git a/package.json b/package.json index 3879d7d..3403576 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.3.1", "clsx": "^2.1.1", - "@rc-component/virtual-list": "^1.1.0" + "@rc-component/virtual-list": "^1.2.0" }, "devDependencies": { "@rc-component/father-plugin": "^2.1.3", diff --git a/src/GroupHeader.tsx b/src/GroupHeader.tsx index e0a8368..f9c82c7 100644 --- a/src/GroupHeader.tsx +++ b/src/GroupHeader.tsx @@ -12,8 +12,9 @@ export interface GroupHeaderProps { style?: React.CSSProperties; } -export default function GroupHeader( +function GroupHeader( props: GroupHeaderProps, + ref: React.Ref, ) { const { group, @@ -31,8 +32,17 @@ export default function GroupHeader( }); return ( -
+
{group.title(groupKey, groupItems)}
); } + +const GroupHeaderWithRef = React.forwardRef(GroupHeader) as < + T, + K extends React.Key = React.Key, +>( + props: GroupHeaderProps & { ref?: React.Ref }, +) => React.ReactElement; + +export default GroupHeaderWithRef; diff --git a/src/List.tsx b/src/List.tsx index 0f70d18..3224400 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -1,29 +1,35 @@ import * as React from 'react'; -import VirtualList, { - type ListRef, - type ScrollTo, -} from '@rc-component/virtual-list'; -import { useImperativeHandle, forwardRef } from 'react'; -import useGroupSegments from './hooks/useGroupSegments'; +import { forwardRef } from 'react'; +import RawList from './RawList'; +import VirtualList from './VirtualList'; import type { Group } from './hooks/useGroupSegments'; -import useFlattenRows from './hooks/useFlattenRows'; -import type { Row } from './hooks/useFlattenRows'; -import useStickyGroupHeader from './hooks/useStickyGroupHeader'; -import GroupHeader from './GroupHeader'; -import { useEvent } from '@rc-component/util'; -type RowKey = keyof T | ((item: T) => React.Key); +export type RowKey = keyof T | ((item: T) => React.Key); export type ScrollAlign = 'top' | 'bottom' | 'auto'; export interface GroupScrollToConfig { - groupKey: string; + groupKey: React.Key; align?: ScrollAlign; offset?: number; } +export interface KeyScrollToConfig { + key: React.Key; + align?: ScrollAlign; + offset?: number; +} + +export interface PositionScrollToConfig { + left?: number; + top?: number; +} + export type ListyScrollToConfig = - | Parameters[0] + | number + | null + | KeyScrollToConfig + | PositionScrollToConfig | GroupScrollToConfig; export interface ListyRef { @@ -43,120 +49,51 @@ export interface ListyProps { itemRender: (item: T, index: number) => React.ReactNode; } +export interface ListComponentProps { + data: T[]; + sticky?: boolean; + itemHeight?: number; + height?: number; + group?: Group; + prefixCls: string; + rowKey: RowKey; + onScroll?: React.UIEventHandler; + itemRender: (item: T, index: number) => React.ReactNode; +} + function Listy( props: ListyProps, ref: React.Ref, ) { // ============================== Props ============================== - const { - items, - itemRender, - group, - onScroll, - rowKey, - height, - itemHeight, - sticky, - virtual = true, - prefixCls = 'rc-listy', - } = props; + const { items, virtual = true, prefixCls = 'rc-listy', ...restProps } = props; // =============================== Data =============================== const data = React.useMemo(() => items || [], [items]); - // =============================== Refs =============================== - const listRef = React.useRef(null); - - // ========================== Imperative API ========================== - useImperativeHandle(ref, () => ({ - scrollTo: (config) => { - if (config && typeof config === 'object' && 'groupKey' in config) { - const { groupKey, align, offset } = config; - listRef.current?.scrollTo({ - key: groupKey, - align, - offset, - }); - return; - } - listRef.current?.scrollTo(config as Parameters[0]); - }, - })); - - // ============================= Grouping ============================= - const groupData = useGroupSegments(data, group); - - // ============================= Row Keys ============================= - const getKey = useEvent((row: Row): React.Key => { - if (row.type === 'header') { - return row.groupKey; - } - - if (typeof rowKey === 'function') { - return rowKey(row.item); - } - return row.item[rowKey] as React.Key; - }); - - // ============================= Flat Rows ============================= - const { rows, headerRows, groupKeyToItems } = useFlattenRows( + // ============================== Render =============================== + const sharedListProps = { + ...restProps, data, - groupData, - group, - ); - - // =========================== Sticky Header =========================== - const extraRender = useStickyGroupHeader({ - enabled: !!(sticky && group && virtual), - group, - headerRows, - groupKeyToItems, prefixCls, - }); - - // ============================= Row Render ============================ - const renderHeaderRow = React.useCallback( - (groupKey: K) => { - if (!group) { - return null; - } - - const groupItems = groupKeyToItems.get(groupKey) || []; - - return ( - - ); - }, - [group, groupKeyToItems, prefixCls, sticky, virtual], - ); + }; + + const listNode = + virtual === false ? ( + + ) : ( + + ); - // ============================== Render =============================== return (
- - {(row: Row) => - row.type === 'header' - ? renderHeaderRow(row.groupKey) - : itemRender(row.item, row.index) - } - + {listNode}
); } diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx new file mode 100644 index 0000000..8e11d44 --- /dev/null +++ b/src/RawList/index.tsx @@ -0,0 +1,144 @@ +import * as React from 'react'; +import ResizeObserver from '@rc-component/resize-observer'; +import { useEvent } from '@rc-component/util'; +import GroupHeader from '../GroupHeader'; +import useGroupSegments from '../hooks/useGroupSegments'; +import useRawListScroll from './useRawListScroll'; +import type { ListComponentProps, ListyRef } from '../List'; + +export type RawListProps = + ListComponentProps; + +function RawList( + props: RawListProps, + ref: React.Ref, +) { + const { + data, + group, + height, + itemRender, + onScroll, + prefixCls, + rowKey, + sticky, + } = props; + + const holderRef = useRawListScroll(ref); + const groupData = useGroupSegments(data, group); + const [headerHeights, setHeaderHeights] = React.useState< + Map + >(() => new Map()); + + const getItemKey = useEvent((item: T): React.Key => { + if (typeof rowKey === 'function') { + return rowKey(item); + } + return item[rowKey] as React.Key; + }); + + const getScrollTargetProps = React.useCallback( + (key: React.Key) => ({ + 'data-key': String(key), + }), + [], + ); + + const setGroupHeaderHeight = React.useCallback( + (groupKey: K, headerHeight: number) => { + setHeaderHeights((prev) => { + const next = new Map(prev); + next.set(groupKey, headerHeight); + return next; + }); + }, + [], + ); + + const renderItem = React.useCallback( + (item: T, index: number, groupKey?: K) => { + const key = getItemKey(item); + const scrollTargetProps = getScrollTargetProps(key); + const headerHeight = + sticky && groupKey !== undefined ? headerHeights.get(groupKey) : 0; + + return ( +
+ {itemRender(item, index)} +
+ ); + }, + [ + getItemKey, + getScrollTargetProps, + headerHeights, + itemRender, + prefixCls, + sticky, + ], + ); + + const rawContent = group + ? Array.from(groupData, ([groupKey, groupItems]) => { + const currentGroupItems = groupItems.map(({ item }) => item); + + return ( +
+ { + setGroupHeaderHeight(groupKey, offsetHeight); + }} + > + + + {groupItems.map(({ item, index }) => { + return renderItem(item, index, groupKey); + })} +
+ ); + }) + : data.map((item, index) => { + return renderItem(item, index); + }); + + return ( +
+
{rawContent}
+
+ ); +} + +const RawListWithRef = React.forwardRef(RawList as any) as any; + +export default RawListWithRef; diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts new file mode 100644 index 0000000..04519e3 --- /dev/null +++ b/src/RawList/useRawListScroll.ts @@ -0,0 +1,60 @@ +import * as React from 'react'; +import type { ListyRef, PositionScrollToConfig } from '../List'; + +export default function useRawListScroll(ref: React.Ref) { + const holderRef = React.useRef(null); + + const scrollTo: ListyRef['scrollTo'] = React.useCallback( + (config) => { + const holder = holderRef.current; + if (!holder || config == null) { + return; + } + + if (typeof config === 'number') { + holder.scrollTop = config; + return; + } + + if ('key' in config || 'groupKey' in config) { + const targetKey = 'groupKey' in config ? config.groupKey : config.key; + const targetElement = holder.querySelector( + `[data-key="${CSS.escape(String(targetKey))}"]`, + ); + + if (targetElement) { + const { align = 'top' } = config; + targetElement.scrollIntoView({ + block: + align === 'bottom' + ? 'end' + : align === 'auto' + ? 'nearest' + : 'start', + inline: 'nearest', + }); + } + return; + } + + const { left, top } = config as PositionScrollToConfig; + if (left !== undefined) { + holder.scrollLeft = left; + } + if (top !== undefined) { + holder.scrollTop = top; + } + }, + [], + ); + + React.useImperativeHandle( + ref, + () => ({ + scrollTo, + }), + [scrollTo], + ); + + return holderRef; +} diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx new file mode 100644 index 0000000..7fa6d6a --- /dev/null +++ b/src/VirtualList/index.tsx @@ -0,0 +1,174 @@ +import * as React from 'react'; +import RcVirtualList, { + type ListRef as RcVirtualListRef, + type ScrollConfig, + type ScrollOffsetInfo, +} from '@rc-component/virtual-list'; +import { useEvent } from '@rc-component/util'; +import GroupHeader from '../GroupHeader'; +import type { ListComponentProps, ListyRef } from '../List'; +import useFlattenRows from '../hooks/useFlattenRows'; +import type { Row } from '../hooks/useFlattenRows'; +import useGroupSegments from '../hooks/useGroupSegments'; +import useStickyGroupHeader from './useStickyGroupHeader'; + +export type VirtualListProps< + T, + K extends React.Key = React.Key, +> = ListComponentProps; + +function VirtualList( + props: VirtualListProps, + ref: React.Ref, +) { + const { + data, + group, + height, + itemHeight, + itemRender, + onScroll, + prefixCls, + rowKey, + sticky, + } = props; + + const listRef = React.useRef(null); + + const groupData = useGroupSegments(data, group); + + 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 { rows, headerRows, groupKeyToItems } = useFlattenRows( + data, + groupData, + group, + ); + + const itemKeyToGroupKey = React.useMemo(() => { + const itemGroupMap = new Map(); + + groupData.forEach((groupItems, groupKey) => { + groupItems.forEach(({ item }) => { + itemGroupMap.set(getItemKey(item), groupKey); + }); + }); + + return itemGroupMap; + }, [getItemKey, groupData]); + + const scrollTo = useEvent((config) => { + if (config && typeof config === 'object' && 'groupKey' in config) { + const { groupKey, align, offset } = config; + listRef.current?.scrollTo({ + key: groupKey, + align, + offset, + }); + return; + } + + 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); + }, + }); + return; + } + } + + listRef.current?.scrollTo(config as number | ScrollConfig | null); + }); + + React.useImperativeHandle( + ref, + () => ({ + scrollTo, + }), + [scrollTo], + ); + + const extraRender = useStickyGroupHeader({ + enabled: !!(sticky && group), + group, + headerRows, + groupKeyToItems, + prefixCls, + }); + + const renderHeaderRow = React.useCallback( + (groupKey: K) => { + const groupItems = groupKeyToItems.get(groupKey) || []; + + return ( + + ); + }, + [group, groupKeyToItems, prefixCls], + ); + + return ( + + {(row: Row) => + row.type === 'header' + ? renderHeaderRow(row.groupKey) + : ( +
+ {itemRender(row.item, row.index)} +
+ ) + } +
+ ); +} + +const VirtualListWithRef = React.forwardRef(VirtualList as any) as any; + +export default VirtualListWithRef; diff --git a/src/hooks/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx similarity index 68% rename from src/hooks/useStickyGroupHeader.tsx rename to src/VirtualList/useStickyGroupHeader.tsx index d225983..16a27ca 100644 --- a/src/hooks/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -1,16 +1,41 @@ import * as React from 'react'; import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; -import type { Group } from './useGroupSegments'; +import type { Group } from '../hooks/useGroupSegments'; import GroupHeader from '../GroupHeader'; type ExtraRenderInfo = Parameters< NonNullable['extraRender']> >[0]; +type HeaderRow = { groupKey: K; rowIndex: number }; + +// `headerRows` is sorted by rowIndex. Find the last header not after `start`. +function findActiveHeaderIndex( + headerRows: HeaderRow[], + start: number, +) { + let left = 0; + let right = headerRows.length - 1; + let activeIndex = 0; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + + if (headerRows[mid].rowIndex <= start) { + activeIndex = mid; + left = mid + 1; + } else { + right = mid - 1; + } + } + + return activeIndex; +} + export interface StickyHeaderParams { enabled: boolean; group: Group | undefined; - headerRows: { groupKey: K; rowIndex: number }[]; + headerRows: HeaderRow[]; groupKeyToItems: Map; prefixCls: string; } @@ -35,15 +60,8 @@ export default function useStickyGroupHeader< return null; } - let activeHeaderIdx = 0; - let currHeader = headerRows[0]; - for (let i = headerRows.length - 1; i >= 0; i -= 1) { - if (headerRows[i].rowIndex <= start) { - activeHeaderIdx = i; - currHeader = headerRows[i]; - break; - } - } + const activeHeaderIdx = findActiveHeaderIndex(headerRows, start); + const currHeader = headerRows[activeHeaderIdx]; const groupItems = groupKeyToItems.get(currHeader.groupKey) || []; const currentSize = getSize(currHeader.groupKey); diff --git a/tests/__snapshots__/listy.test.tsx.snap b/tests/__snapshots__/listy.test.tsx.snap index a3e1692..5a17816 100644 --- a/tests/__snapshots__/listy.test.tsx.snap +++ b/tests/__snapshots__/listy.test.tsx.snap @@ -17,8 +17,12 @@ exports[`Listy should match snapshot 1`] = ` class="rc-listy-holder-inner" style="display: flex; flex-direction: column;" > -
- 1 +
+
+ 1 +
diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 7e0a4d3..02ca1c3 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -4,8 +4,8 @@ import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; import useFlattenRows from '../src/hooks/useFlattenRows'; import useGroupSegments from '../src/hooks/useGroupSegments'; -import useStickyGroupHeader from '../src/hooks/useStickyGroupHeader'; -import type { StickyHeaderParams } from '../src/hooks/useStickyGroupHeader'; +import useStickyGroupHeader from '../src/VirtualList/useStickyGroupHeader'; +import type { StickyHeaderParams } from '../src/VirtualList/useStickyGroupHeader'; const PREFIX_CLS = 'rc-listy'; diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index 9c9b8ad..584e651 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -1,7 +1,11 @@ import React from 'react'; import { act, render } from '@testing-library/react'; -import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; +import { _rs as triggerResize } from '@rc-component/resize-observer'; +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'; type ExtraRenderInfo = Parameters< NonNullable['extraRender']> @@ -92,6 +96,9 @@ describe('Listy behaviors', () => { { id: 1, group: 'Group A' }, { id: 2, group: 'Group A' }, ]; + const resolvedItemRender = + rest.itemRender || + ((item) =>
{item.id}
); return render( { rowKey="id" itemHeight={20} height={100} - itemRender={(item) => ( -
{item.id}
- )} + itemRender={resolvedItemRender} />, ); }; @@ -129,6 +134,17 @@ describe('Listy behaviors', () => { expect(lastProps.data).toEqual([]); }); + it('wraps virtual items with item class', () => { + const { container } = renderList(); + + const itemNodes = container.querySelectorAll('.rc-listy-item'); + + expect(itemNodes).toHaveLength(2); + expect(itemNodes[0]).toContainElement( + container.querySelector('[data-testid="item-1"]') as HTMLElement, + ); + }); + it('applies sticky class when virtual list is disabled', () => { const title = jest.fn((key: React.Key) => Group {String(key)}); const { container } = renderList({ @@ -143,16 +159,254 @@ describe('Listy behaviors', () => { const stickyHeader = container.querySelector( '.rc-listy-group-header-sticky', ); + const groupSections = container.querySelectorAll('.rc-listy-group-section'); + + 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'); + expect(groupSections).toHaveLength(1); + expect(groupSections[0]).toContainElement(stickyHeader as HTMLElement); + expect(groupSections[0]).toHaveAttribute('data-key', 'Group A'); expect(title).toHaveBeenCalled(); }); + it('scrolls raw list group sections by group key', () => { + const ref = React.createRef(); + const { container } = renderList({ + ref, + virtual: false, + items: [ + { id: 1, group: 'Group A' }, + { id: 2, group: 'Group A' }, + { id: 3, group: 'Group B' }, + ], + group: { + key: (item) => item.group, + title: (groupKey) => Group {String(groupKey)}, + }, + }); + + const groupSections = container.querySelectorAll('.rc-listy-group-section'); + const groupBSection = groupSections[1] as HTMLElement; + const scrollIntoView = jest.fn(); + groupBSection.scrollIntoView = scrollIntoView; + + act(() => { + ref.current?.scrollTo({ + groupKey: 'Group B', + align: 'top', + offset: 5, + }); + }); + + expect(scrollIntoView).toHaveBeenCalledWith({ + block: 'start', + inline: 'nearest', + }); + }); + + it('supports raw list scroll APIs without grouping', () => { + const ref = React.createRef(); + const { container } = renderList({ + ref, + virtual: false, + items: [ + { id: 1, name: 'One' }, + { id: 2, name: 'Two' }, + ], + itemRender: (item) => item.name, + }); + + const holder = container.querySelector('.rc-listy-holder') as HTMLDivElement; + const itemNodes = container.querySelectorAll('.rc-listy-item'); + const secondItem = itemNodes[1] as HTMLElement; + const scrollIntoView = jest.fn(); + secondItem.scrollIntoView = scrollIntoView; + + act(() => { + ref.current?.scrollTo(); + ref.current?.scrollTo(24); + }); + expect(holder.scrollTop).toBe(24); + + act(() => { + ref.current?.scrollTo({ left: 7, top: 12 }); + }); + expect(holder.scrollLeft).toBe(7); + expect(holder.scrollTop).toBe(12); + + act(() => { + ref.current?.scrollTo({ left: 8 }); + }); + expect(holder.scrollLeft).toBe(8); + expect(holder.scrollTop).toBe(12); + + act(() => { + ref.current?.scrollTo({ top: 13 }); + }); + expect(holder.scrollLeft).toBe(8); + expect(holder.scrollTop).toBe(13); + + holder.scrollTop = 0; + act(() => { + ref.current?.scrollTo({ key: 2 }); + }); + expect(scrollIntoView).toHaveBeenLastCalledWith({ + block: 'start', + inline: 'nearest', + }); + + act(() => { + ref.current?.scrollTo({ key: 2, align: 'bottom', offset: 4 }); + }); + expect(scrollIntoView).toHaveBeenLastCalledWith({ + block: 'end', + inline: 'nearest', + }); + + act(() => { + ref.current?.scrollTo({ key: 2, align: 'auto', offset: 4 }); + }); + expect(scrollIntoView).toHaveBeenLastCalledWith({ + block: 'nearest', + inline: 'nearest', + }); + + const scrollCount = scrollIntoView.mock.calls.length; + act(() => { + ref.current?.scrollTo({ key: 99 }); + }); + expect(scrollIntoView).toHaveBeenCalledTimes(scrollCount); + }); + + it('exposes raw list scrollTo only', () => { + const ref = React.createRef(); + render( +
{item.id}
} + prefixCls="rc-listy" + rowKey="id" + />, + ); + + expect(Object.keys(ref.current || {})).toEqual(['scrollTo']); + }); + + it('passes raw group items to title', () => { + const title = jest.fn(() => null); + + render( + item.group, + title, + }} + itemRender={(item) =>
{item.id}
} + prefixCls="rc-listy" + rowKey="id" + />, + ); + + expect(title).toHaveBeenCalledWith('Group A', [ + { id: 1, group: 'Group A' }, + { id: 2, group: 'Group A' }, + ]); + }); + + it('supports raw list rowKey function', () => { + const { container } = render( +
{item.id}
} + prefixCls="rc-listy" + rowKey={(item) => `item-${item.id}`} + />, + ); + + const itemNode = container.querySelector('.rc-listy-item'); + + expect(itemNode).toHaveAttribute('data-key', 'item-1'); + }); + + it('wraps raw list items with item class', () => { + const { container } = render( + ( + <> + {item.id} + + )} + prefixCls="rc-listy" + rowKey="id" + />, + ); + + const itemNode = container.querySelector('.rc-listy-item'); + + expect(itemNode).toHaveAttribute('data-key', '1'); + expect(itemNode).toContainElement(container.querySelector('span')); + }); + + it('keeps raw sticky group header from covering top-aligned items', async () => { + const { container } = renderList({ + 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, + left: 0, + right: 100, + top: 0, + width: 100, + x: 0, + y: 0, + toJSON: () => {}, + }) as DOMRect, + ); + + await act(async () => { + triggerResize?.([ + { target: groupHeader } as unknown as ResizeObserverEntry, + ]); + await Promise.resolve(); + }); + + const itemNode = container.querySelector('[data-key="1"]') as HTMLElement; + + expect(itemNode).toHaveClass('rc-listy-item'); + expect(itemNode).toHaveStyle({ scrollMarginTop: '36px' }); + }); + it('scroll to group', () => { const scrollHandler = jest.fn(); MockedVirtualList.__setScrollHandler(scrollHandler); - + const ref = React.createRef(); renderList({ ref, @@ -161,15 +415,53 @@ describe('Listy behaviors', () => { title: () => null, }, }); - + act(() => { - ref.current?.scrollTo({ groupKey: 'Group A', align: 'bottom', offset: 12 }); + ref.current?.scrollTo({ + groupKey: 'Group A', + align: 'bottom', + offset: 12, + }); }); - + expect(scrollHandler).toHaveBeenCalledWith({ key: 'Group A', align: 'bottom', offset: 12, }); - }); + }); + + it('offsets sticky virtual scrollTo by group header height', () => { + 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: 2, + align: 'top', + 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); + }); });