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
4 changes: 2 additions & 2 deletions src/VirtualList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function VirtualList<T, K extends React.Key = React.Key>(
});

// ============================== Rows ================================
const { rows, headerRows, groupKeyToItems } = useFlattenRows<T, K>(
const { rows, groupKeys, groupKeyToItems } = useFlattenRows<T, K>(
data,
groupData,
group,
Expand Down Expand Up @@ -135,7 +135,7 @@ function VirtualList<T, K extends React.Key = React.Key>(
const extraRender = useStickyGroupHeader<T, K>({
enabled: !!(sticky && group),
group,
headerRows,
groupKeys,
groupKeyToItems,
prefixCls,
listRef,
Expand Down
10 changes: 5 additions & 5 deletions src/VirtualList/useFlattenRows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type Row<T, K extends React.Key = React.Key> =

export interface FlattenRowsResult<T, K extends React.Key = React.Key> {
rows: Row<T, K>[];
headerRows: { groupKey: K; rowIndex: number }[];
groupKeys: K[];
groupKeyToItems: Map<K, T[]>;
}

Expand All @@ -25,7 +25,7 @@ export default function useFlattenRows<T, K extends React.Key = React.Key>(
return React.useMemo(() => {
// ============================== Init ================================
const flatRows: Row<T, K>[] = [];
const headerRows: { groupKey: K; rowIndex: number }[] = [];
const groupKeys: K[] = [];
const groupKeyToItems = new Map<K, T[]>();

// ============================ No Group ==============================
Expand All @@ -34,7 +34,7 @@ export default function useFlattenRows<T, K extends React.Key = React.Key>(
flatRows.push({ type: 'item', item, index });
});

return { rows: flatRows, headerRows, groupKeyToItems };
return { rows: flatRows, groupKeys, groupKeyToItems };
}

// ============================= Flatten ==============================
Expand All @@ -44,7 +44,7 @@ export default function useFlattenRows<T, K extends React.Key = React.Key>(
groupItems.map(({ item }) => item),
);

headerRows.push({ groupKey, rowIndex: flatRows.length });
groupKeys.push(groupKey);
flatRows.push({ type: 'header', groupKey });

groupItems.forEach(({ item, index }) => {
Expand All @@ -53,6 +53,6 @@ export default function useFlattenRows<T, K extends React.Key = React.Key>(
});

// ============================== Return ==============================
return { rows: flatRows, headerRows, groupKeyToItems };
return { rows: flatRows, groupKeys, groupKeyToItems };
}, [data, group, groupData]);
}
46 changes: 25 additions & 21 deletions src/VirtualList/useStickyGroupHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,22 @@ type ExtraRenderInfo = Parameters<
NonNullable<VirtualListProps<unknown>['extraRender']>
>[0];

type HeaderRow<K extends React.Key> = { groupKey: K; rowIndex: number };

// ============================== Utils ===============================
// `headerRows` is sorted by rowIndex. Find the last header not after `start`.
const HEADER_TOP_TOLERANCE = 1;

function findActiveHeaderIndex<K extends React.Key>(
headerRows: HeaderRow<K>[],
start: number,
groupKeys: K[],
getHeaderTop: (groupKey: K) => number,
scrollTop: number,
) {
let left = 0;
let right = headerRows.length - 1;
let right = groupKeys.length - 1;
let activeIndex = 0;
Comment on lines 23 to 25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When scrollTop is less than the first group header's top (e.g., during rubber-banding/elastic scrolling on iOS/macOS, or if there is header/padding space above the first group), findActiveHeaderIndex currently defaults to 0. This causes the first group's sticky header to remain visible at top: 0 even though the real header has not yet reached the top of the viewport, leading to a visual glitch (duplicate/detached header). We should initialize activeIndex to -1 to indicate that no header is active yet.

Suggested change
let left = 0;
let right = headerRows.length - 1;
let right = groupKeys.length - 1;
let activeIndex = 0;
let left = 0;
let right = groupKeys.length - 1;
let activeIndex = -1;


while (left <= right) {
const mid = Math.floor((left + right) / 2);

if (headerRows[mid].rowIndex <= start) {
if (getHeaderTop(groupKeys[mid]) <= scrollTop + HEADER_TOP_TOLERANCE) {
activeIndex = mid;
left = mid + 1;
} else {
Expand All @@ -42,7 +42,7 @@ function findActiveHeaderIndex<K extends React.Key>(
export interface StickyHeaderParams<T, K extends React.Key = React.Key> {
enabled: boolean;
group: Group<T, K> | undefined;
headerRows: HeaderRow<K>[];
groupKeys: K[];
groupKeyToItems: Map<K, T[]>;
prefixCls: string;
listRef: React.RefObject<RcVirtualListRef | null>;
Expand All @@ -56,7 +56,7 @@ export default function useStickyGroupHeader<
const {
enabled,
group,
headerRows,
groupKeys,
groupKeyToItems,
prefixCls,
listRef,
Expand All @@ -65,9 +65,9 @@ export default function useStickyGroupHeader<
// ============================ Extra Render ==========================
const extraRender = React.useCallback(
(info: ExtraRenderInfo) => {
const { getSize, scrollTop, start, virtual } = info;
const { getSize, scrollTop, virtual } = info;

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

Expand All @@ -76,17 +76,21 @@ export default function useStickyGroupHeader<
return null;
}

// The sticky header is the latest group header before the visible range.
const activeHeaderIdx = findActiveHeaderIndex(headerRows, start);
const currHeader = headerRows[activeHeaderIdx];
// The sticky header is the group whose section the viewport top sits in.
const activeHeaderIdx = findActiveHeaderIndex(
groupKeys,
(groupKey) => getSize(groupKey).top,
scrollTop,
);
const currGroupKey = groupKeys[activeHeaderIdx];
Comment on lines +80 to +85

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Handle the case where activeHeaderIdx is -1 (no active header) by returning null early. This prevents rendering a redundant sticky header when the list is scrolled above the first group header, and avoids potential runtime errors when trying to access groupKeys[-1].

Suggested change
const activeHeaderIdx = findActiveHeaderIndex(
groupKeys,
(groupKey) => getSize(groupKey).top,
scrollTop,
);
const currGroupKey = groupKeys[activeHeaderIdx];
const activeHeaderIdx = findActiveHeaderIndex(
groupKeys,
(groupKey) => getSize(groupKey).top,
scrollTop,
);
if (activeHeaderIdx === -1) {
return null;
}
const currGroupKey = groupKeys[activeHeaderIdx];


const groupItems = groupKeyToItems.get(currHeader.groupKey) || [];
const currentSize = getSize(currHeader.groupKey);
const groupItems = groupKeyToItems.get(currGroupKey) || [];
const currentSize = getSize(currGroupKey);
const headerHeight = currentSize.bottom - currentSize.top;

const nextHeader = headerRows[activeHeaderIdx + 1];
const top = nextHeader
? Math.min(0, getSize(nextHeader.groupKey).top - headerHeight - scrollTop)
const nextGroupKey = groupKeys[activeHeaderIdx + 1];
const top = nextGroupKey
? Math.min(0, getSize(nextGroupKey).top - headerHeight - scrollTop)
: 0;

// Render a cloned header pinned over the virtual list.
Expand All @@ -96,7 +100,7 @@ export default function useStickyGroupHeader<
<GroupHeader
fixed
group={group}
groupKey={currHeader.groupKey}
groupKey={currGroupKey}
groupItems={groupItems}
prefixCls={prefixCls}
style={{ top }}
Expand All @@ -105,7 +109,7 @@ export default function useStickyGroupHeader<
</Portal>
);
},
[enabled, group, headerRows, groupKeyToItems, prefixCls, listRef],
[enabled, group, groupKeys, groupKeyToItems, prefixCls, listRef],
);

// ============================== Return ==============================
Expand Down
115 changes: 77 additions & 38 deletions tests/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,7 @@ describe('useFlattenRows', () => {
{ type: 'header', groupKey: 'B' },
{ type: 'item', item: items[1], index: 1 },
]);
expect(result.current.headerRows).toEqual([
{ groupKey: 'A', rowIndex: 0 },
{ groupKey: 'B', rowIndex: 3 },
]);
expect(result.current.groupKeys).toEqual(['A', 'B']);
expect(result.current.groupKeyToItems).toEqual(
new Map([
['A', [items[0], items[2]]],
Expand All @@ -168,7 +165,7 @@ describe('useFlattenRows', () => {
{ type: 'item', item: items[0], index: 0 },
{ type: 'item', item: items[1], index: 1 },
],
headerRows: [],
groupKeys: [],
groupKeyToItems: new Map(),
});
});
Expand All @@ -183,10 +180,7 @@ describe('useStickyGroupHeader', () => {
{ id: 4, group: 'Group 2' },
{ id: 5, group: 'Group 2' },
];
const headerRows = [
{ groupKey: 'Group 1', rowIndex: 0 },
{ groupKey: 'Group 2', rowIndex: 4 },
];
const groupKeys = ['Group 1', 'Group 2'];
const baseItemsMap = new Map<React.Key, GroupedItem[]>([
['Group 1', baseItems.slice(0, 3)],
['Group 2', baseItems.slice(3, 6)],
Expand Down Expand Up @@ -214,7 +208,7 @@ describe('useStickyGroupHeader', () => {
key: (item) => item.group,
title,
},
headerRows,
groupKeys,
groupKeyToItems: baseItemsMap,
prefixCls: PREFIX_CLS,
listRef: createListRef(container),
Expand Down Expand Up @@ -243,7 +237,7 @@ describe('useStickyGroupHeader', () => {
key: (item) => item.group,
title: () => <span>noop</span>,
},
headerRows,
groupKeys,
groupKeyToItems: baseItemsMap,
prefixCls: PREFIX_CLS,
listRef: createListRef(container),
Expand All @@ -256,14 +250,17 @@ describe('useStickyGroupHeader', () => {
);
expect(stickyHeader).toBeNull(); });

it('uses the visible start row to resolve the active header', () => {
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({
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');
Expand All @@ -274,7 +271,7 @@ describe('useStickyGroupHeader', () => {
key: (item) => item.group,
title,
},
headerRows,
groupKeys,
groupKeyToItems: baseItemsMap,
prefixCls: PREFIX_CLS,
listRef: createListRef(container),
Expand All @@ -286,21 +283,26 @@ describe('useStickyGroupHeader', () => {
`.${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(stickyHeader).toHaveTextContent('Group 1');
expect(stickyHeader).toHaveStyle({ top: '0px' }); });

it('keeps the fixed header pinned at 0 within a group regardless of scroll', () => {
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>
));

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

const container = document.createElement('div');
Expand All @@ -311,7 +313,7 @@ describe('useStickyGroupHeader', () => {
key: (item) => item.group,
title,
},
headerRows,
groupKeys,
groupKeyToItems: baseItemsMap,
prefixCls: PREFIX_CLS,
listRef: createListRef(container),
Expand All @@ -324,25 +326,62 @@ describe('useStickyGroupHeader', () => {
);
expect(stickyHeader).not.toBeNull();
expect(stickyHeader).toHaveTextContent('Group 1');
expect(stickyHeader).toHaveStyle({ top: '0px' }); });
expect(stickyHeader).toHaveStyle({ top: '-10px' }); });

it('pushes the fixed header away when the next group reaches the top', () => {
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) => (
<span data-testid="sticky-title">{String(key)}</span>
));

// Group 2's header sits exactly at the viewport top (scrollTop === its top),
// while `start` is Group 1's last item row (3) — the row before Group 2's
// header row (4). This is the off-by-one boundary.
const info = createRenderInfo({
scrollTop: 70,
scrollTop: 200,
start: 3,
getSize: (key: React.Key) => {
if (key === 'Group 1') {
return { top: 0, bottom: 20 };
}
if (key === 'Group 2') {
return { top: 80, bottom: 100 };
}
return { top: 0, bottom: 0 };
getSize: (key: React.Key) =>
key === 'Group 2' ? { top: 200, bottom: 220 } : { top: 0, bottom: 20 },
});

const container = document.createElement('div');
document.body.appendChild(container);
const params: StickyHeaderParams<GroupedItem> = {
enabled: true,
group: {
key: (item) => item.group,
title,
},
groupKeys,
groupKeyToItems: baseItemsMap,
prefixCls: PREFIX_CLS,
listRef: createListRef(container),
};

render(<StickyHeaderTester params={params} info={info} />);

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

it('tolerates a sub-pixel scroll offset just short of the header top', () => {
const title = jest.fn().mockImplementation((key: React.Key) => (
<span data-testid="sticky-title">{String(key)}</span>
));

// On a HiDPI screen the scroll offset can rest a fraction of a pixel below
// a header's true top (here 199.5 vs 200). Without tolerance the strict
// compare resolves to the previous group and blanks the title.
const info = createRenderInfo({
scrollTop: 199.5,
start: 3,
getSize: (key: React.Key) =>
key === 'Group 2' ? { top: 200, bottom: 220 } : { top: 0, bottom: 20 },
});

const container = document.createElement('div');
Expand All @@ -353,7 +392,7 @@ describe('useStickyGroupHeader', () => {
key: (item) => item.group,
title,
},
headerRows,
groupKeys,
groupKeyToItems: baseItemsMap,
prefixCls: PREFIX_CLS,
listRef: createListRef(container),
Expand All @@ -365,6 +404,6 @@ describe('useStickyGroupHeader', () => {
`.${PREFIX_CLS}-group-header-fixed`,
);
expect(stickyHeader).not.toBeNull();
expect(stickyHeader).toHaveTextContent('Group 1');
expect(stickyHeader).toHaveStyle({ top: '-10px' }); });
expect(stickyHeader).toHaveTextContent('Group 2');
});
});
Comment on lines +369 to 409

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a test case to verify that the sticky header is not rendered when the list is scrolled above the first group header (e.g., during rubber-banding).

    expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6));
  });

  it('returns null and does not render sticky header when scrolled above the first group header', () => {
    const title = jest.fn().mockImplementation((key: React.Key) => (
      <span data-testid="sticky-title">{String(key)}</span>
    ));

    const info = createRenderInfo({
      scrollTop: -10,
      start: 0,
      getSize: (key: React.Key) => ({ top: 0, bottom: 20 }),
    });

    const container = document.createElement('div');
    document.body.appendChild(container);
    const params: StickyHeaderParams<GroupedItem> = {
      enabled: true,
      group: { 
        key: (item) => item.group,
        title,
      },
      groupKeys,
      groupKeyToItems: baseItemsMap,
      prefixCls: PREFIX_CLS,
      listRef: createListRef(container),
    };

    render(<StickyHeaderTester params={params} info={info} />);

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

Loading