diff --git a/assets/index.less b/assets/index.less index 9dbf395..c2ddd93 100644 --- a/assets/index.less +++ b/assets/index.less @@ -12,15 +12,14 @@ left: 0; right: 0; } - } - &-sticky-header { - position: absolute; - top: 0; - left: 0; - right: 0; - transform: translateY(0); - background-color: #fff; + &-fixed { + position: absolute; + top: 0; + left: 0; + right: 0; + transform: translateY(0); + } } &-scrollbar { diff --git a/package.json b/package.json index e31a9fb..3879d7d 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-virtual-list": "^3.19.2" + "@rc-component/virtual-list": "^1.1.0" }, "devDependencies": { "@rc-component/father-plugin": "^2.1.3", diff --git a/src/GroupHeader.tsx b/src/GroupHeader.tsx new file mode 100644 index 0000000..e0a8368 --- /dev/null +++ b/src/GroupHeader.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import type { Group } from './hooks/useGroupSegments'; + +export interface GroupHeaderProps { + group: Group; + groupKey: K; + groupItems: T[]; + prefixCls: string; + fixed?: boolean; + sticky?: boolean; + style?: React.CSSProperties; +} + +export default function GroupHeader( + props: GroupHeaderProps, +) { + const { + group, + groupKey, + groupItems, + prefixCls, + fixed, + sticky, + style, + } = props; + + const className = clsx(`${prefixCls}-group-header`, { + [`${prefixCls}-group-header-sticky`]: sticky, + [`${prefixCls}-group-header-fixed`]: fixed, + }); + + return ( +
+ {group.title(groupKey, groupItems)} +
+ ); +} diff --git a/src/List.tsx b/src/List.tsx index 213ef08..0f70d18 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -1,13 +1,15 @@ import * as React from 'react'; -import VirtualList, { type ListRef } from 'rc-virtual-list'; -import type { ScrollTo } from 'rc-virtual-list/lib/List'; +import VirtualList, { + type ListRef, + type ScrollTo, +} from '@rc-component/virtual-list'; import { useImperativeHandle, forwardRef } from 'react'; import useGroupSegments from './hooks/useGroupSegments'; import type { Group } from './hooks/useGroupSegments'; import useFlattenRows from './hooks/useFlattenRows'; import type { Row } from './hooks/useFlattenRows'; import useStickyGroupHeader from './hooks/useStickyGroupHeader'; -import clsx from 'clsx'; +import GroupHeader from './GroupHeader'; import { useEvent } from '@rc-component/util'; type RowKey = keyof T | ((item: T) => React.Key); @@ -64,7 +66,6 @@ function Listy( // =============================== Refs =============================== const listRef = React.useRef(null); - const containerRef = React.useRef(null); // ========================== Imperative API ========================== useImperativeHandle(ref, () => ({ @@ -106,12 +107,10 @@ function Listy( // =========================== Sticky Header =========================== const extraRender = useStickyGroupHeader({ - enabled: !!(sticky && group), + enabled: !!(sticky && group && virtual), group, headerRows, groupKeyToItems, - containerRef, - listRef, prefixCls, }); @@ -123,14 +122,15 @@ function Listy( } const groupItems = groupKeyToItems.get(groupKey) || []; - const headerClassName = clsx(`${prefixCls}-group-header`, { - [`${prefixCls}-group-header-sticky`]: sticky && !virtual, - }); return ( -
- {group.title(groupKey, groupItems)} -
+ ); }, [group, groupKeyToItems, prefixCls, sticky, virtual], @@ -138,7 +138,7 @@ function Listy( // ============================== Render =============================== return ( -
+
['extraRender']> +>[0]; export interface StickyHeaderParams { enabled: boolean; group: Group | undefined; headerRows: { groupKey: K; rowIndex: number }[]; groupKeyToItems: Map; - containerRef: React.RefObject; - listRef: React.RefObject; prefixCls: string; } @@ -23,106 +24,50 @@ export default function useStickyGroupHeader< group, headerRows, groupKeyToItems, - containerRef, - listRef, prefixCls, } = params; - const lastHeaderIdxRef = React.useRef(0); - const extraRender = React.useCallback( (info: ExtraRenderInfo) => { - const { virtual } = info; + const { getSize, offsetY, scrollTop, start, virtual } = info; if (!enabled || !group || !headerRows.length || !virtual) { - lastHeaderIdxRef.current = 0; - return null; - } - - const container = containerRef.current; - if (!container) { return null; } - // maybe rc-virtual-list will expose scrollTop in the future - const getHolderScrollTop = () => { - const holder = - container.querySelector(`.${prefixCls}-holder`) || - listRef.current?.nativeElement?.querySelector?.( - `.${prefixCls}-holder`, - ); - if (holder) { - return holder.scrollTop; - } - const infoScrollTop = listRef.current?.getScrollInfo?.().y ?? 0; - return infoScrollTop; - }; - - const resolveByScrollTop = (scrollTop: number) => { - const cachedIdx = lastHeaderIdxRef.current; - const cachedRow = headerRows[cachedIdx]; - const cachedTop = cachedRow - ? info.getSize(cachedRow.groupKey).top - : null; - const nextRow = headerRows[cachedIdx + 1]; - const nextTop = nextRow ? info.getSize(nextRow.groupKey).top : null; - - if ( - cachedRow && - cachedTop !== null && - scrollTop >= cachedTop && - (nextTop === null || scrollTop < nextTop) - ) { - return cachedIdx; + 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; } + } - let lo = 0; - let hi = headerRows.length - 1; - let candidate = 0; - - while (lo <= hi) { - const mid = Math.floor((lo + hi) / 2); - const { top } = info.getSize(headerRows[mid].groupKey); - if (top <= scrollTop) { - candidate = mid; - lo = mid + 1; - } else { - hi = mid - 1; - } - } - - return candidate; - }; - - const scrollTop = getHolderScrollTop(); - const activeHeaderIdx = resolveByScrollTop(scrollTop); - - lastHeaderIdxRef.current = activeHeaderIdx; - - const currHeader = headerRows[activeHeaderIdx]; const groupItems = groupKeyToItems.get(currHeader.groupKey) || []; + const currentSize = getSize(currHeader.groupKey); + const headerHeight = currentSize.bottom - currentSize.top; + const fixedTop = scrollTop - offsetY; - const headerNode = ( -
- {group.title(currHeader.groupKey, groupItems)} -
- ); + const nextHeader = headerRows[activeHeaderIdx + 1]; + const nextTop = nextHeader + ? getSize(nextHeader.groupKey).top - headerHeight - offsetY + : fixedTop; + const top = Math.min(fixedTop, nextTop); return ( - container}> - {headerNode} - + ); }, - [ - enabled, - group, - headerRows, - groupKeyToItems, - containerRef, - listRef, - prefixCls, - ], + [enabled, group, headerRows, groupKeyToItems, prefixCls], ); return extraRender; diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 30a7886..7e0a4d3 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { render, renderHook } from '@testing-library/react'; -import type { ListRef } from 'rc-virtual-list'; -import type { ExtraRenderInfo } from 'rc-virtual-list/lib/interface'; +import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; import useFlattenRows from '../src/hooks/useFlattenRows'; import useGroupSegments from '../src/hooks/useGroupSegments'; @@ -15,6 +14,10 @@ interface GroupedItem { group: string; } +type ExtraRenderInfo = Parameters< + NonNullable['extraRender']> +>[0]; + const createRenderInfo = ( overrides: Partial = {}, ): ExtraRenderInfo => ({ @@ -22,6 +25,7 @@ const createRenderInfo = ( end: 0, virtual: true, offsetX: 0, + scrollTop: 0, offsetY: 0, rtl: false, getSize: () => ({ top: 0, bottom: 0 }), @@ -39,32 +43,6 @@ const StickyHeaderTester = ({ return <>{extraRender(info)}; }; -const createRefObject = ( - element: T, -): React.RefObject => - ({ - current: element, - } as React.RefObject); - -const createListRef = ( - overrides: Partial = {}, -): React.RefObject => { - const defaultHolder = document.createElement('div'); - const base: ListRef = { - nativeElement: defaultHolder, - getScrollInfo: () => ({ x: 0, y: 0 }), - scrollTo: () => {}, - }; - - return { - current: { - ...base, - ...overrides, - nativeElement: overrides.nativeElement ?? base.nativeElement, - }, - } as React.RefObject; -}; - describe('useGroupSegments', () => { it('groups items by key across the full data set', () => { const items: GroupedItem[] = [ @@ -204,11 +182,7 @@ describe('useStickyGroupHeader', () => { ['Group 2', baseItems.slice(3, 6)], ]); - it('renders sticky portal for the active header row', () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const containerRef = createRefObject(container); - + it('renders sticky header for the active header row', () => { const title = jest .fn() .mockImplementation((key: React.Key, groupItems: GroupedItem[]) => ( @@ -216,7 +190,7 @@ describe('useStickyGroupHeader', () => { {String(key)}-{groupItems.length} )); - const info = createRenderInfo({ start: 5 }); + const info = createRenderInfo({ scrollTop: 5, start: 5 }); const params: StickyHeaderParams = { enabled: true, group: { @@ -225,32 +199,25 @@ describe('useStickyGroupHeader', () => { }, headerRows, groupKeyToItems: baseItemsMap, - containerRef, - listRef: createListRef({ - nativeElement: container, - getScrollInfo: () => ({ x: 0, y: info.start }), - }), prefixCls: PREFIX_CLS, }; - const { unmount } = render( + const { container: renderContainer } = render( , ); - const stickyHeader = container.querySelector(`.${PREFIX_CLS}-sticky-header`); + const stickyHeader = renderContainer.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)); - - unmount(); - document.body.removeChild(container); }); - it('skips portal rendering when virtual list is disabled', () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const containerRef = createRefObject(container); - + it('skips sticky header rendering when virtual list is disabled', () => { const info = createRenderInfo({ virtual: false }); const params: StickyHeaderParams = { enabled: true, @@ -260,41 +227,28 @@ describe('useStickyGroupHeader', () => { }, headerRows, groupKeyToItems: baseItemsMap, - containerRef, - listRef: createListRef({ nativeElement: container }), prefixCls: PREFIX_CLS, }; - const { unmount } = render(); + const { container: renderContainer } = render( + , + ); - const stickyHeader = container.querySelector(`.${PREFIX_CLS}-sticky-header`); + const stickyHeader = renderContainer.querySelector( + `.${PREFIX_CLS}-group-header-fixed`, + ); expect(stickyHeader).toBeNull(); - - unmount(); - document.body.removeChild(container); }); - it('syncs sticky header with scrollTop even if start index is stale', () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const containerRef = createRefObject(container); - const listRef = createListRef({ getScrollInfo: () => ({ x: 0, y: 80 }) }); - + it('uses the visible start row to resolve the active header', () => { const title = jest.fn().mockImplementation((key: React.Key) => ( {String(key)} )); const info = createRenderInfo({ - start: 3, - getSize: (key: React.Key) => { - if (key === 'Group 1') { - return { top: 0, bottom: 60 }; - } - if (key === 'Group 2') { - return { top: 80, bottom: 120 }; - } - return { top: 0, bottom: 0 }; - }, + offsetY: 80, + scrollTop: 80, + start: 4, }); const params: StickyHeaderParams = { @@ -305,51 +259,71 @@ describe('useStickyGroupHeader', () => { }, headerRows, groupKeyToItems: baseItemsMap, - containerRef, - listRef, prefixCls: PREFIX_CLS, }; - const { unmount } = render( + const { container: renderContainer } = render( , ); - const stickyHeader = container.querySelector(`.${PREFIX_CLS}-sticky-header`); + const stickyHeader = renderContainer.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)); - - unmount(); - document.body.removeChild(container); }); - it('prefers holder scrollTop over virtual start', () => { - const holder = document.createElement('div'); - holder.className = `${PREFIX_CLS}-holder`; - holder.scrollTop = 80; - - const container = document.createElement('div'); - container.appendChild(holder); - document.body.appendChild(container); + it('offsets the fixed header by the extra render scrollTop', () => { + const title = jest.fn().mockImplementation((key: React.Key) => ( + {String(key)} + )); - const containerRef = createRefObject(container); - const listRef = createListRef({ - nativeElement: container, - getScrollInfo: () => ({ x: 0, y: 0 }), + const info = createRenderInfo({ + offsetY: 64, + scrollTop: 80, + start: 4, }); + const params: StickyHeaderParams = { + enabled: true, + group: { + key: (item) => item.group, + title, + }, + headerRows, + groupKeyToItems: baseItemsMap, + prefixCls: PREFIX_CLS, + }; + + const { container: renderContainer } = render( + , + ); + + const stickyHeader = renderContainer.querySelector( + `.${PREFIX_CLS}-group-header-fixed`, + ); + expect(stickyHeader).not.toBeNull(); + expect(stickyHeader).toHaveTextContent('Group 2'); + expect(stickyHeader).toHaveStyle({ top: '16px' }); + }); + + it('pushes the fixed header away when the next group reaches the top', () => { const title = jest.fn().mockImplementation((key: React.Key) => ( {String(key)} )); const info = createRenderInfo({ + offsetY: 64, + scrollTop: 70, start: 3, getSize: (key: React.Key) => { if (key === 'Group 1') { - return { top: 0, bottom: 60 }; + return { top: 0, bottom: 20 }; } if (key === 'Group 2') { - return { top: 80, bottom: 120 }; + return { top: 80, bottom: 100 }; } return { top: 0, bottom: 0 }; }, @@ -363,20 +337,18 @@ describe('useStickyGroupHeader', () => { }, headerRows, groupKeyToItems: baseItemsMap, - containerRef, - listRef, prefixCls: PREFIX_CLS, }; - const { unmount } = render( + const { container: renderContainer } = render( , ); - const stickyHeader = container.querySelector(`.${PREFIX_CLS}-sticky-header`); + const stickyHeader = renderContainer.querySelector( + `.${PREFIX_CLS}-group-header-fixed`, + ); expect(stickyHeader).not.toBeNull(); - expect(stickyHeader).toHaveTextContent('Group 2'); - - unmount(); - document.body.removeChild(container); + expect(stickyHeader).toHaveTextContent('Group 1'); + expect(stickyHeader).toHaveStyle({ top: '-4px' }); }); }); diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index 9e6e6db..9c9b8ad 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -1,15 +1,20 @@ import React from 'react'; import { act, render } from '@testing-library/react'; -import type { ExtraRenderInfo } from 'rc-virtual-list/lib/interface'; +import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; import Listy, { type ListyRef, type ListyProps } from '@rc-component/listy'; -jest.mock('rc-virtual-list', () => { +type ExtraRenderInfo = Parameters< + NonNullable['extraRender']> +>[0]; + +jest.mock('@rc-component/virtual-list', () => { const React = require('react'); let extraInfo = { start: 0, end: 0, virtual: true, offsetX: 0, + scrollTop: 0, offsetY: 0, rtl: false, getSize: () => ({ top: 0, bottom: 0 }), @@ -62,7 +67,7 @@ type MockedVirtualListComponent = React.ForwardRefExoticComponent & { __getLastProps(): any; }; -const MockedVirtualList = require('rc-virtual-list') +const MockedVirtualList = require('@rc-component/virtual-list') .default as MockedVirtualListComponent; describe('Listy behaviors', () => { @@ -139,6 +144,7 @@ describe('Listy behaviors', () => { '.rc-listy-group-header-sticky', ); expect(stickyHeader).not.toBeNull(); + expect(stickyHeader).toHaveClass('rc-listy-group-header'); expect(stickyHeader).toHaveTextContent('Group Group A'); expect(title).toHaveBeenCalled(); });