diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index 96f32e9..a435e9d 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -58,7 +58,7 @@ function VirtualList( }); // ============================== Rows ================================ - const { rows, headerRows, groupKeyToItems } = useFlattenRows( + const { rows, groupKeys, groupKeyToItems } = useFlattenRows( data, groupData, group, @@ -135,7 +135,7 @@ function VirtualList( const extraRender = useStickyGroupHeader({ enabled: !!(sticky && group), group, - headerRows, + groupKeys, groupKeyToItems, prefixCls, listRef, diff --git a/src/VirtualList/useFlattenRows.ts b/src/VirtualList/useFlattenRows.ts index 689b6a7..17dd954 100644 --- a/src/VirtualList/useFlattenRows.ts +++ b/src/VirtualList/useFlattenRows.ts @@ -8,7 +8,7 @@ export type Row = export interface FlattenRowsResult { rows: Row[]; - headerRows: { groupKey: K; rowIndex: number }[]; + groupKeys: K[]; groupKeyToItems: Map; } @@ -25,7 +25,7 @@ export default function useFlattenRows( return React.useMemo(() => { // ============================== Init ================================ const flatRows: Row[] = []; - const headerRows: { groupKey: K; rowIndex: number }[] = []; + const groupKeys: K[] = []; const groupKeyToItems = new Map(); // ============================ No Group ============================== @@ -34,7 +34,7 @@ export default function useFlattenRows( flatRows.push({ type: 'item', item, index }); }); - return { rows: flatRows, headerRows, groupKeyToItems }; + return { rows: flatRows, groupKeys, groupKeyToItems }; } // ============================= Flatten ============================== @@ -44,7 +44,7 @@ export default function useFlattenRows( groupItems.map(({ item }) => item), ); - headerRows.push({ groupKey, rowIndex: flatRows.length }); + groupKeys.push(groupKey); flatRows.push({ type: 'header', groupKey }); groupItems.forEach(({ item, index }) => { @@ -53,6 +53,6 @@ export default function useFlattenRows( }); // ============================== Return ============================== - return { rows: flatRows, headerRows, groupKeyToItems }; + return { rows: flatRows, groupKeys, groupKeyToItems }; }, [data, group, groupData]); } diff --git a/src/VirtualList/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx index efba050..2762277 100644 --- a/src/VirtualList/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -12,22 +12,22 @@ type ExtraRenderInfo = Parameters< NonNullable['extraRender']> >[0]; -type HeaderRow = { groupKey: K; rowIndex: number }; - // ============================== Utils =============================== -// `headerRows` is sorted by rowIndex. Find the last header not after `start`. +const HEADER_TOP_TOLERANCE = 1; + function findActiveHeaderIndex( - headerRows: HeaderRow[], - 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; 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 { @@ -42,7 +42,7 @@ function findActiveHeaderIndex( export interface StickyHeaderParams { enabled: boolean; group: Group | undefined; - headerRows: HeaderRow[]; + groupKeys: K[]; groupKeyToItems: Map; prefixCls: string; listRef: React.RefObject; @@ -56,7 +56,7 @@ export default function useStickyGroupHeader< const { enabled, group, - headerRows, + groupKeys, groupKeyToItems, prefixCls, listRef, @@ -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; } @@ -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]; - 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. @@ -96,7 +100,7 @@ export default function useStickyGroupHeader< ); }, - [enabled, group, headerRows, groupKeyToItems, prefixCls, listRef], + [enabled, group, groupKeys, groupKeyToItems, prefixCls, listRef], ); // ============================== Return ============================== diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 645e89b..906458a 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -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]]], @@ -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(), }); }); @@ -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([ ['Group 1', baseItems.slice(0, 3)], ['Group 2', baseItems.slice(3, 6)], @@ -214,7 +208,7 @@ describe('useStickyGroupHeader', () => { key: (item) => item.group, title, }, - headerRows, + groupKeys, groupKeyToItems: baseItemsMap, prefixCls: PREFIX_CLS, listRef: createListRef(container), @@ -243,7 +237,7 @@ describe('useStickyGroupHeader', () => { key: (item) => item.group, title: () => noop, }, - headerRows, + groupKeys, groupKeyToItems: baseItemsMap, prefixCls: PREFIX_CLS, listRef: createListRef(container), @@ -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) => ( {String(key)} )); + // 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'); @@ -274,7 +271,7 @@ describe('useStickyGroupHeader', () => { key: (item) => item.group, title, }, - headerRows, + groupKeys, groupKeyToItems: baseItemsMap, prefixCls: PREFIX_CLS, listRef: createListRef(container), @@ -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) => ( {String(key)} )); - // 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'); @@ -311,7 +313,7 @@ describe('useStickyGroupHeader', () => { key: (item) => item.group, title, }, - headerRows, + groupKeys, groupKeyToItems: baseItemsMap, prefixCls: PREFIX_CLS, listRef: createListRef(container), @@ -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) => ( {String(key)} )); + // 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 = { + enabled: true, + group: { + key: (item) => item.group, + title, }, + groupKeys, + groupKeyToItems: baseItemsMap, + prefixCls: PREFIX_CLS, + listRef: createListRef(container), + }; + + render(); + + 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) => ( + {String(key)} + )); + + // 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'); @@ -353,7 +392,7 @@ describe('useStickyGroupHeader', () => { key: (item) => item.group, title, }, - headerRows, + groupKeys, groupKeyToItems: baseItemsMap, prefixCls: PREFIX_CLS, listRef: createListRef(container), @@ -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'); + }); });