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 (
+
+ );
+}
+
+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;"
>
-
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);
+ });
});