Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions assets/index.less
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@
left: 0;
right: 0;
transform: translateY(0);
pointer-events: auto;
}

&-holder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
}
}

Expand Down
1 change: 1 addition & 0 deletions src/VirtualList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ function VirtualList<T, K extends React.Key = React.Key>(
headerRows,
groupKeyToItems,
prefixCls,
listRef,
});

// ============================ Render Row ============================
Expand Down
50 changes: 30 additions & 20 deletions src/VirtualList/useStickyGroupHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as React from 'react';
import type { ListProps as VirtualListProps } from '@rc-component/virtual-list';
import Portal from '@rc-component/portal';
import type {
ListProps as VirtualListProps,
ListRef as RcVirtualListRef,
} from '@rc-component/virtual-list';
import type { Group } from '../hooks/useGroupSegments';
import GroupHeader from '../GroupHeader';

Expand Down Expand Up @@ -41,6 +45,7 @@ export interface StickyHeaderParams<T, K extends React.Key = React.Key> {
headerRows: HeaderRow<K>[];
groupKeyToItems: Map<K, T[]>;
prefixCls: string;
listRef: React.RefObject<RcVirtualListRef | null>;
}

export default function useStickyGroupHeader<
Expand All @@ -54,17 +59,23 @@ export default function useStickyGroupHeader<
headerRows,
groupKeyToItems,
prefixCls,
listRef,
} = params;

// ============================ Extra Render ==========================
const extraRender = React.useCallback(
(info: ExtraRenderInfo) => {
const { getSize, offsetY, scrollTop, start, virtual } = info;
const { getSize, scrollTop, start, virtual } = info;

if (!enabled || !group || !headerRows.length || !virtual) {
return null;
}

const container = listRef.current?.nativeElement;
if (!container) {
return null;
}

// The sticky header is the latest group header before the visible range.
const activeHeaderIdx = findActiveHeaderIndex(headerRows, start);
const currHeader = headerRows[activeHeaderIdx];
Expand All @@ -73,29 +84,28 @@ export default function useStickyGroupHeader<
const currentSize = getSize(currHeader.groupKey);
const headerHeight = currentSize.bottom - currentSize.top;

// Convert the virtual list scroll position into the overlay top offset.
const fixedTop = scrollTop - offsetY;

// Let the next group header push the current fixed header away.
const nextHeader = headerRows[activeHeaderIdx + 1];
const nextTop = nextHeader
? getSize(nextHeader.groupKey).top - headerHeight - offsetY
: fixedTop;
const top = Math.min(fixedTop, nextTop);
const top = nextHeader
? Math.min(0, getSize(nextHeader.groupKey).top - headerHeight - scrollTop)
: 0;
Comment thread
aojunhao123 marked this conversation as resolved.

// Render a cloned header above the virtual list items.
// Render a cloned header pinned over the virtual list.
return (
<GroupHeader
fixed
group={group}
groupKey={currHeader.groupKey}
groupItems={groupItems}
prefixCls={prefixCls}
style={{ top }}
/>
<Portal open getContainer={() => container}>
<div className={`${prefixCls}-group-header-holder`}>
<GroupHeader
fixed
group={group}
groupKey={currHeader.groupKey}
groupItems={groupItems}
prefixCls={prefixCls}
style={{ top }}
/>
</div>
</Portal>
);
Comment thread
aojunhao123 marked this conversation as resolved.
},
[enabled, group, headerRows, groupKeyToItems, prefixCls],
[enabled, group, headerRows, groupKeyToItems, prefixCls, listRef],
);

// ============================== Return ==============================
Expand Down
94 changes: 55 additions & 39 deletions tests/hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import React from 'react';
import { render, renderHook } from '@testing-library/react';
import type { ListProps as VirtualListProps } from '@rc-component/virtual-list';
import { cleanup, render, renderHook } from '@testing-library/react';
import type {
ListProps as VirtualListProps,
ListRef as RcVirtualListRef,
} from '@rc-component/virtual-list';

import useGroupSegments from '../src/hooks/useGroupSegments';
import useFlattenRows from '../src/VirtualList/useFlattenRows';
Expand Down Expand Up @@ -43,6 +46,13 @@ const StickyHeaderTester = ({
return <>{extraRender(info)}</>;
};

const createListRef = (
nativeElement: HTMLElement,
): React.RefObject<RcVirtualListRef | null> =>
({ current: { nativeElement } } as unknown as React.RefObject<
RcVirtualListRef | null
>);

describe('useGroupSegments', () => {
it('groups items by key across the full data set', () => {
const items: GroupedItem[] = [
Expand Down Expand Up @@ -182,6 +192,11 @@ describe('useStickyGroupHeader', () => {
['Group 2', baseItems.slice(3, 6)],
]);

afterEach(() => {
cleanup();
document.body.innerHTML = '';
});

it('renders sticky header for the active header row', () => {
const title = jest
.fn()
Expand All @@ -191,6 +206,8 @@ describe('useStickyGroupHeader', () => {
</span>
));
const info = createRenderInfo({ scrollTop: 5, start: 5 });
const container = document.createElement('div');
document.body.appendChild(container);
const params: StickyHeaderParams<GroupedItem> = {
enabled: true,
group: {
Expand All @@ -200,25 +217,26 @@ describe('useStickyGroupHeader', () => {
headerRows,
groupKeyToItems: baseItemsMap,
prefixCls: PREFIX_CLS,
listRef: createListRef(container),
};

const { container: renderContainer } = render(
<StickyHeaderTester params={params} info={info} />,
);
render(<StickyHeaderTester params={params} info={info} />);

const stickyHeader = renderContainer.querySelector(
const stickyHeader = container.querySelector(
`.${PREFIX_CLS}-group-header-fixed`,
);
expect(stickyHeader).not.toBeNull();
expect(stickyHeader).toHaveClass(`${PREFIX_CLS}-group-header`);
expect(stickyHeader).toHaveClass(`${PREFIX_CLS}-group-header-fixed`);
expect(stickyHeader).toHaveTextContent('Group 2-3');
expect(stickyHeader).toHaveStyle({ top: '5px' });
expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6));
});
// 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)); });

it('skips sticky header rendering when virtual list is disabled', () => {
const info = createRenderInfo({ virtual: false });
const container = document.createElement('div');
document.body.appendChild(container);
const params: StickyHeaderParams<GroupedItem> = {
enabled: true,
group: {
Expand All @@ -228,29 +246,28 @@ describe('useStickyGroupHeader', () => {
headerRows,
groupKeyToItems: baseItemsMap,
prefixCls: PREFIX_CLS,
listRef: createListRef(container),
};

const { container: renderContainer } = render(
<StickyHeaderTester params={params} info={info} />,
);
render(<StickyHeaderTester params={params} info={info} />);

const stickyHeader = renderContainer.querySelector(
const stickyHeader = container.querySelector(
`.${PREFIX_CLS}-group-header-fixed`,
);
expect(stickyHeader).toBeNull();
});
expect(stickyHeader).toBeNull(); });

it('uses the visible start row to resolve the active header', () => {
const title = jest.fn().mockImplementation((key: React.Key) => (
<span data-testid="sticky-title">{String(key)}</span>
));

const info = createRenderInfo({
offsetY: 80,
scrollTop: 80,
start: 4,
});

const container = document.createElement('div');
document.body.appendChild(container);
const params: StickyHeaderParams<GroupedItem> = {
enabled: true,
group: {
Expand All @@ -260,32 +277,34 @@ describe('useStickyGroupHeader', () => {
headerRows,
groupKeyToItems: baseItemsMap,
prefixCls: PREFIX_CLS,
listRef: createListRef(container),
};

const { container: renderContainer } = render(
<StickyHeaderTester params={params} info={info} />,
);
render(<StickyHeaderTester params={params} info={info} />);

const stickyHeader = renderContainer.querySelector(
const stickyHeader = container.querySelector(
`.${PREFIX_CLS}-group-header-fixed`,
);
expect(stickyHeader).not.toBeNull();
expect(stickyHeader).toHaveTextContent('Group 2');
expect(stickyHeader).toHaveStyle({ top: '0px' });
expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6));
});
expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); });

it('offsets the fixed header by the extra render scrollTop', () => {
it('keeps the fixed header pinned at 0 within a group regardless of scroll', () => {
const title = jest.fn().mockImplementation((key: React.Key) => (
<span data-testid="sticky-title">{String(key)}</span>
));

// Active group is Group 1, with Group 2's header far below the viewport.
const info = createRenderInfo({
offsetY: 64,
scrollTop: 80,
start: 4,
start: 1,
getSize: (key: React.Key) =>
key === 'Group 2' ? { top: 500, bottom: 524 } : { top: 0, bottom: 24 },
});

const container = document.createElement('div');
document.body.appendChild(container);
const params: StickyHeaderParams<GroupedItem> = {
enabled: true,
group: {
Expand All @@ -295,27 +314,24 @@ describe('useStickyGroupHeader', () => {
headerRows,
groupKeyToItems: baseItemsMap,
prefixCls: PREFIX_CLS,
listRef: createListRef(container),
};

const { container: renderContainer } = render(
<StickyHeaderTester params={params} info={info} />,
);
render(<StickyHeaderTester params={params} info={info} />);

const stickyHeader = renderContainer.querySelector(
const stickyHeader = container.querySelector(
`.${PREFIX_CLS}-group-header-fixed`,
);
expect(stickyHeader).not.toBeNull();
expect(stickyHeader).toHaveTextContent('Group 2');
expect(stickyHeader).toHaveStyle({ top: '16px' });
});
expect(stickyHeader).toHaveTextContent('Group 1');
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) => (
<span data-testid="sticky-title">{String(key)}</span>
));

const info = createRenderInfo({
offsetY: 64,
scrollTop: 70,
start: 3,
getSize: (key: React.Key) => {
Expand All @@ -329,6 +345,8 @@ describe('useStickyGroupHeader', () => {
},
});

const container = document.createElement('div');
document.body.appendChild(container);
const params: StickyHeaderParams<GroupedItem> = {
enabled: true,
group: {
Expand All @@ -338,17 +356,15 @@ describe('useStickyGroupHeader', () => {
headerRows,
groupKeyToItems: baseItemsMap,
prefixCls: PREFIX_CLS,
listRef: createListRef(container),
};

const { container: renderContainer } = render(
<StickyHeaderTester params={params} info={info} />,
);
render(<StickyHeaderTester params={params} info={info} />);

const stickyHeader = renderContainer.querySelector(
const stickyHeader = container.querySelector(
`.${PREFIX_CLS}-group-header-fixed`,
);
expect(stickyHeader).not.toBeNull();
expect(stickyHeader).toHaveTextContent('Group 1');
expect(stickyHeader).toHaveStyle({ top: '-4px' });
});
expect(stickyHeader).toHaveStyle({ top: '-10px' }); });
});
Loading