diff --git a/src/GroupHeader.tsx b/src/GroupHeader.tsx index f9c82c7..6e5f806 100644 --- a/src/GroupHeader.tsx +++ b/src/GroupHeader.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import clsx from 'clsx'; import type { Group } from './hooks/useGroupSegments'; +// ============================== Types =============================== export interface GroupHeaderProps { group: Group; groupKey: K; @@ -16,6 +17,7 @@ function GroupHeader( props: GroupHeaderProps, ref: React.Ref, ) { + // ============================== Props ============================== const { group, groupKey, @@ -26,11 +28,13 @@ function GroupHeader( style, } = props; + // ============================= Classes ============================= const className = clsx(`${prefixCls}-group-header`, { [`${prefixCls}-group-header-sticky`]: sticky, [`${prefixCls}-group-header-fixed`]: fixed, }); + // ============================== Render ============================== return (
{group.title(groupKey, groupItems)} diff --git a/src/List.tsx b/src/List.tsx index 3224400..c6393c9 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -4,6 +4,7 @@ import RawList from './RawList'; import VirtualList from './VirtualList'; import type { Group } from './hooks/useGroupSegments'; +// ============================== Types =============================== export type RowKey = keyof T | ((item: T) => React.Key); export type ScrollAlign = 'top' | 'bottom' | 'auto'; @@ -76,26 +77,17 @@ function Listy( ...restProps, data, prefixCls, + ref, }; const listNode = - virtual === false ? ( - + virtual ? ( + ) : ( - + ); - return ( -
- {listNode} -
- ); + return listNode; } // Const to support generic with forwardRef diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index 8e11d44..7cb4670 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; -import ResizeObserver from '@rc-component/resize-observer'; import { useEvent } from '@rc-component/util'; import GroupHeader from '../GroupHeader'; import useGroupSegments from '../hooks/useGroupSegments'; import useRawListScroll from './useRawListScroll'; import type { ListComponentProps, ListyRef } from '../List'; +// ============================== Types =============================== export type RawListProps = ListComponentProps; @@ -13,6 +13,7 @@ function RawList( props: RawListProps, ref: React.Ref, ) { + // ============================== Props ============================== const { data, group, @@ -24,12 +25,13 @@ function RawList( sticky, } = props; - const holderRef = useRawListScroll(ref); + // =============================== Refs =============================== + const holderRef = useRawListScroll(ref, prefixCls, !!(sticky && group)); + + // =============================== Data =============================== const groupData = useGroupSegments(data, group); - const [headerHeights, setHeaderHeights] = React.useState< - Map - >(() => new Map()); + // ============================== Utils =============================== const getItemKey = useEvent((item: T): React.Key => { if (typeof rowKey === 'function') { return rowKey(item); @@ -44,32 +46,20 @@ function RawList( [], ); - const setGroupHeaderHeight = React.useCallback( - (groupKey: K, headerHeight: number) => { - setHeaderHeights((prev) => { - const next = new Map(prev); - next.set(groupKey, headerHeight); - return next; - }); - }, - [], - ); - + // ============================ Render Item =========================== const renderItem = React.useCallback( (item: T, index: number, groupKey?: K) => { const key = getItemKey(item); const scrollTargetProps = getScrollTargetProps(key); - const headerHeight = - sticky && groupKey !== undefined ? headerHeights.get(groupKey) : 0; return (
( [ getItemKey, getScrollTargetProps, - headerHeights, itemRender, prefixCls, sticky, ], ); + // ============================= Content ============================== const rawContent = group ? Array.from(groupData, ([groupKey, groupItems]) => { const currentGroupItems = groupItems.map(({ item }) => item); @@ -99,20 +89,13 @@ function RawList( className={`${prefixCls}-group-section`} {...getScrollTargetProps(groupKey)} > - { - setGroupHeaderHeight(groupKey, offsetHeight); - }} - > - - + {groupItems.map(({ item, index }) => { return renderItem(item, index, groupKey); })} @@ -123,10 +106,11 @@ function RawList( return renderItem(item, index); }); + // ============================== Render ============================== return (
( }} onScroll={onScroll} > -
{rawContent}
+ {rawContent}
); } diff --git a/src/RawList/useRawListScroll.ts b/src/RawList/useRawListScroll.ts index 04519e3..b2463f4 100644 --- a/src/RawList/useRawListScroll.ts +++ b/src/RawList/useRawListScroll.ts @@ -1,9 +1,55 @@ import * as React from 'react'; -import type { ListyRef, PositionScrollToConfig } from '../List'; +import type { ListyRef, PositionScrollToConfig, ScrollAlign } from '../List'; -export default function useRawListScroll(ref: React.Ref) { +export default function useRawListScroll( + ref: React.Ref, + prefixCls: string, + stickyGroup: boolean, +) { + // =============================== Refs =============================== const holderRef = React.useRef(null); + // ============================== Utils =============================== + const getStickyHeaderHeight = React.useCallback( + (targetElement: HTMLElement) => { + if (!stickyGroup) { + return 0; + } + + const groupSection = targetElement.closest( + `.${CSS.escape(`${prefixCls}-group-section`)}`, + ); + const groupHeader = groupSection?.querySelector( + `.${CSS.escape(`${prefixCls}-group-header`)}`, + ); + + if (!groupHeader) { + return 0; + } + + const rect = groupHeader.getBoundingClientRect(); + const height = + rect.height || rect.bottom - rect.top || groupHeader.offsetHeight; + + return Number.isFinite(height) ? height : 0; + }, + [prefixCls, stickyGroup], + ); + + const setTargetScrollMargin = React.useCallback( + (targetElement: HTMLElement, align: ScrollAlign) => { + const marginTop = + align === 'top' ? getStickyHeaderHeight(targetElement) : 0; + + targetElement.style.setProperty( + `--${prefixCls}-item-scroll-margin-top`, + `${marginTop}px`, + ); + }, + [getStickyHeaderHeight, prefixCls], + ); + + // ============================== Scroll ============================== const scrollTo: ListyRef['scrollTo'] = React.useCallback( (config) => { const holder = holderRef.current; @@ -17,13 +63,17 @@ export default function useRawListScroll(ref: React.Ref) { } if ('key' in config || 'groupKey' in config) { + const { align = 'top' } = config; const targetKey = 'groupKey' in config ? config.groupKey : config.key; const targetElement = holder.querySelector( `[data-key="${CSS.escape(String(targetKey))}"]`, ); if (targetElement) { - const { align = 'top' } = config; + if ('key' in config) { + setTargetScrollMargin(targetElement, align); + } + targetElement.scrollIntoView({ block: align === 'bottom' @@ -45,9 +95,10 @@ export default function useRawListScroll(ref: React.Ref) { holder.scrollTop = top; } }, - [], + [setTargetScrollMargin], ); + // ============================ Imperative ============================ React.useImperativeHandle( ref, () => ({ @@ -56,5 +107,6 @@ export default function useRawListScroll(ref: React.Ref) { [scrollTo], ); + // ============================== Return ============================== return holderRef; } diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index 7fa6d6a..26fffa0 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -7,11 +7,12 @@ import RcVirtualList, { import { useEvent } from '@rc-component/util'; import GroupHeader from '../GroupHeader'; import type { ListComponentProps, ListyRef } from '../List'; -import useFlattenRows from '../hooks/useFlattenRows'; -import type { Row } from '../hooks/useFlattenRows'; import useGroupSegments from '../hooks/useGroupSegments'; +import useFlattenRows from './useFlattenRows'; +import type { Row } from './useFlattenRows'; import useStickyGroupHeader from './useStickyGroupHeader'; +// ============================== Types =============================== export type VirtualListProps< T, K extends React.Key = React.Key, @@ -21,6 +22,7 @@ function VirtualList( props: VirtualListProps, ref: React.Ref, ) { + // ============================== Props ============================== const { data, group, @@ -33,10 +35,13 @@ function VirtualList( sticky, } = props; + // =============================== Refs =============================== const listRef = React.useRef(null); + // =============================== Data =============================== const groupData = useGroupSegments(data, group); + // =============================== Keys =============================== const getItemKey = useEvent((item: T): React.Key => { if (typeof rowKey === 'function') { return rowKey(item); @@ -52,12 +57,14 @@ function VirtualList( return getItemKey(row.item); }); + // ============================== Rows ================================ const { rows, headerRows, groupKeyToItems } = useFlattenRows( data, groupData, group, ); + // ============================== Lookup ============================== const itemKeyToGroupKey = React.useMemo(() => { const itemGroupMap = new Map(); @@ -70,7 +77,9 @@ function VirtualList( return itemGroupMap; }, [getItemKey, groupData]); + // ============================== Scroll ============================== const scrollTo = useEvent((config) => { + // Group headers are rows in the virtual data, so group scroll maps to key scroll. if (config && typeof config === 'object' && 'groupKey' in config) { const { groupKey, align, offset } = config; listRef.current?.scrollTo({ @@ -81,6 +90,7 @@ function VirtualList( return; } + // For sticky grouped lists, top-aligned item scroll should land below its header. if ( config && typeof config === 'object' && @@ -108,9 +118,11 @@ function VirtualList( } } + // Other scroll shapes are already supported by the underlying virtual list. listRef.current?.scrollTo(config as number | ScrollConfig | null); }); + // ============================ Imperative ============================ React.useImperativeHandle( ref, () => ({ @@ -119,6 +131,7 @@ function VirtualList( [scrollTo], ); + // ============================== Sticky ============================== const extraRender = useStickyGroupHeader({ enabled: !!(sticky && group), group, @@ -127,6 +140,7 @@ function VirtualList( prefixCls, }); + // ============================ Render Row ============================ const renderHeaderRow = React.useCallback( (groupKey: K) => { const groupItems = groupKeyToItems.get(groupKey) || []; @@ -143,6 +157,7 @@ function VirtualList( [group, groupKeyToItems, prefixCls], ); + // ============================== Render ============================== return ( = | { type: 'header'; groupKey: K } | { type: 'item'; item: T; index: number }; @@ -22,10 +23,12 @@ export default function useFlattenRows( group?: Group, ): FlattenRowsResult { return React.useMemo(() => { + // ============================== Init ================================ const flatRows: Row[] = []; const headerRows: { groupKey: K; rowIndex: number }[] = []; const groupKeyToItems = new Map(); + // ============================ No Group ============================== if (!group) { data.forEach((item, index) => { flatRows.push({ type: 'item', item, index }); @@ -34,6 +37,7 @@ export default function useFlattenRows( return { rows: flatRows, headerRows, groupKeyToItems }; } + // ============================= Flatten ============================== groupData.forEach((groupItems, groupKey) => { groupKeyToItems.set( groupKey, @@ -48,6 +52,7 @@ export default function useFlattenRows( }); }); + // ============================== Return ============================== return { rows: flatRows, headerRows, groupKeyToItems }; }, [data, group, groupData]); } diff --git a/src/VirtualList/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx index 16a27ca..b71cef3 100644 --- a/src/VirtualList/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -3,12 +3,14 @@ import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; import type { Group } from '../hooks/useGroupSegments'; import GroupHeader from '../GroupHeader'; +// ============================== Types =============================== 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`. function findActiveHeaderIndex( headerRows: HeaderRow[], @@ -32,6 +34,7 @@ function findActiveHeaderIndex( return activeIndex; } +// ============================== Params ============================== export interface StickyHeaderParams { enabled: boolean; group: Group | undefined; @@ -44,6 +47,7 @@ export default function useStickyGroupHeader< T, K extends React.Key = React.Key, >(params: StickyHeaderParams) { + // ============================== Props ============================== const { enabled, group, @@ -52,6 +56,7 @@ export default function useStickyGroupHeader< prefixCls, } = params; + // ============================ Extra Render ========================== const extraRender = React.useCallback( (info: ExtraRenderInfo) => { const { getSize, offsetY, scrollTop, start, virtual } = info; @@ -60,20 +65,25 @@ 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]; const groupItems = groupKeyToItems.get(currHeader.groupKey) || []; 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); + // Render a cloned header above the virtual list items. return ( { key: (item: T) => K; title: (groupKey: K, items: T[]) => React.ReactNode; @@ -21,12 +22,15 @@ export default function useGroupSegments( group?: Group, ): Map[]> { return React.useMemo(() => { + // ============================== Init ================================ const map = new Map[]>(); + // ============================ No Group ============================== if (!group) { return map; } + // ============================= Collect ============================== data.forEach((item, index) => { const groupKey = group.key(item); const groupItems = map.get(groupKey); @@ -39,6 +43,7 @@ export default function useGroupSegments( } }); + // ============================== Return ============================== return map; }, [data, group]); } diff --git a/tests/__snapshots__/listy.test.tsx.snap b/tests/__snapshots__/listy.test.tsx.snap index 5a17816..639da46 100644 --- a/tests/__snapshots__/listy.test.tsx.snap +++ b/tests/__snapshots__/listy.test.tsx.snap @@ -4,25 +4,21 @@ exports[`Listy should match snapshot 1`] = `
-
-
+
+
-
-
- 1 -
+
+ 1
diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 02ca1c3..77ca99e 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { render, renderHook } from '@testing-library/react'; import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; -import useFlattenRows from '../src/hooks/useFlattenRows'; import useGroupSegments from '../src/hooks/useGroupSegments'; +import useFlattenRows from '../src/VirtualList/useFlattenRows'; import useStickyGroupHeader from '../src/VirtualList/useStickyGroupHeader'; import type { StickyHeaderParams } from '../src/VirtualList/useStickyGroupHeader'; diff --git a/tests/listy.behavior.test.tsx b/tests/listy.behavior.test.tsx index 584e651..cf831a2 100644 --- a/tests/listy.behavior.test.tsx +++ b/tests/listy.behavior.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { act, render } from '@testing-library/react'; -import { _rs as triggerResize } from '@rc-component/resize-observer'; import type { ListProps as VirtualListProps, } from '@rc-component/virtual-list'; @@ -218,7 +217,7 @@ describe('Listy behaviors', () => { itemRender: (item) => item.name, }); - const holder = container.querySelector('.rc-listy-holder') as HTMLDivElement; + const holder = container.querySelector('.rc-listy') as HTMLDivElement; const itemNodes = container.querySelectorAll('.rc-listy-item'); const secondItem = itemNodes[1] as HTMLElement; const scrollIntoView = jest.fn(); @@ -358,8 +357,10 @@ describe('Listy behaviors', () => { expect(itemNode).toContainElement(container.querySelector('span')); }); - it('keeps raw sticky group header from covering top-aligned items', async () => { + it('sets raw sticky scroll margin before scrolling top-aligned items', () => { + const ref = React.createRef(); const { container } = renderList({ + ref, virtual: false, sticky: true, group: { @@ -390,17 +391,52 @@ describe('Listy behaviors', () => { }) as DOMRect, ); - await act(async () => { - triggerResize?.([ - { target: groupHeader } as unknown as ResizeObserverEntry, - ]); - await Promise.resolve(); + const itemNode = container.querySelector('[data-key="1"]') as HTMLElement; + const scrollIntoView = jest.fn(() => { + expect( + itemNode.style.getPropertyValue('--rc-listy-item-scroll-margin-top'), + ).toBe('36px'); }); + itemNode.scrollIntoView = scrollIntoView; - const itemNode = container.querySelector('[data-key="1"]') as HTMLElement; + act(() => { + ref.current?.scrollTo({ key: 1, align: 'top' }); + }); expect(itemNode).toHaveClass('rc-listy-item'); - expect(itemNode).toHaveStyle({ scrollMarginTop: '36px' }); + expect(itemNode.style.scrollMarginTop).toBe( + 'var(--rc-listy-item-scroll-margin-top, 0px)', + ); + expect(scrollIntoView).toHaveBeenCalledWith({ + block: 'start', + inline: 'nearest', + }); + }); + + it('falls back to zero raw sticky scroll margin without a header', () => { + const ref = React.createRef(); + const { container } = renderList({ + ref, + virtual: false, + sticky: true, + group: { + key: (item) => item.group, + title: (groupKey) => {String(groupKey)}, + }, + }); + + container.querySelector('.rc-listy-group-header')?.remove(); + + const itemNode = container.querySelector('[data-key="1"]') as HTMLElement; + itemNode.scrollIntoView = jest.fn(); + + act(() => { + ref.current?.scrollTo({ key: 1, align: 'top' }); + }); + + expect( + itemNode.style.getPropertyValue('--rc-listy-item-scroll-margin-top'), + ).toBe('0px'); }); it('scroll to group', () => {