-
-
Notifications
You must be signed in to change notification settings - Fork 3
fix: show the correct group header when scrolled flush to its top #50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| 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<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>; | ||||||||||||||||||||||||||||||||
|
|
@@ -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]; | ||||||||||||||||||||||||||||||||
|
Comment on lines
+80
to
+85
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle the case where
Suggested change
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| 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< | |||||||||||||||||||||||||||||||
| <GroupHeader | ||||||||||||||||||||||||||||||||
| fixed | ||||||||||||||||||||||||||||||||
| group={group} | ||||||||||||||||||||||||||||||||
| groupKey={currHeader.groupKey} | ||||||||||||||||||||||||||||||||
| groupKey={currGroupKey} | ||||||||||||||||||||||||||||||||
| groupItems={groupItems} | ||||||||||||||||||||||||||||||||
| prefixCls={prefixCls} | ||||||||||||||||||||||||||||||||
| style={{ top }} | ||||||||||||||||||||||||||||||||
|
|
@@ -105,7 +109,7 @@ export default function useStickyGroupHeader< | |||||||||||||||||||||||||||||||
| </Portal> | ||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||
| [enabled, group, headerRows, groupKeyToItems, prefixCls, listRef], | ||||||||||||||||||||||||||||||||
| [enabled, group, groupKeys, groupKeyToItems, prefixCls, listRef], | ||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // ============================== Return ============================== | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<React.Key, GroupedItem[]>([ | ||
| ['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: () => <span>noop</span>, | ||
| }, | ||
| 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) => ( | ||
| <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'); | ||
|
|
@@ -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) => ( | ||
| <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'); | ||
|
|
@@ -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) => ( | ||
| <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'); | ||
|
|
@@ -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'); | ||
| }); | ||
| }); | ||
|
Comment on lines
+369
to
409
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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). |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
scrollTopis 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),findActiveHeaderIndexcurrently defaults to0. This causes the first group's sticky header to remain visible attop: 0even though the real header has not yet reached the top of the viewport, leading to a visual glitch (duplicate/detached header). We should initializeactiveIndexto-1to indicate that no header is active yet.