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
15 changes: 7 additions & 8 deletions assets/index.less
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions src/GroupHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as React from 'react';
import clsx from 'clsx';
import type { Group } from './hooks/useGroupSegments';

export interface GroupHeaderProps<T, K extends React.Key = React.Key> {
group: Group<T, K>;
groupKey: K;
groupItems: T[];
prefixCls: string;
fixed?: boolean;
sticky?: boolean;
style?: React.CSSProperties;
}

export default function GroupHeader<T, K extends React.Key = React.Key>(
props: GroupHeaderProps<T, K>,
) {
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 (
<div className={className} style={style}>
{group.title(groupKey, groupItems)}
</div>
);
}
28 changes: 14 additions & 14 deletions src/List.tsx
Original file line number Diff line number Diff line change
@@ -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<T> = keyof T | ((item: T) => React.Key);
Expand Down Expand Up @@ -64,7 +66,6 @@ function Listy<T, K extends React.Key = React.Key>(

// =============================== Refs ===============================
const listRef = React.useRef<ListRef>(null);
const containerRef = React.useRef<HTMLDivElement>(null);

// ========================== Imperative API ==========================
useImperativeHandle(ref, () => ({
Expand Down Expand Up @@ -106,12 +107,10 @@ function Listy<T, K extends React.Key = React.Key>(

// =========================== Sticky Header ===========================
const extraRender = useStickyGroupHeader<T, K>({
enabled: !!(sticky && group),
enabled: !!(sticky && group && virtual),
group,
headerRows,
groupKeyToItems,
containerRef,
listRef,
prefixCls,
});

Expand All @@ -123,22 +122,23 @@ function Listy<T, K extends React.Key = React.Key>(
}

const groupItems = groupKeyToItems.get(groupKey) || [];
const headerClassName = clsx(`${prefixCls}-group-header`, {
[`${prefixCls}-group-header-sticky`]: sticky && !virtual,
});

return (
<div className={headerClassName}>
{group.title(groupKey, groupItems)}
</div>
<GroupHeader
group={group}
groupKey={groupKey}
groupItems={groupItems}
prefixCls={prefixCls}
sticky={sticky && !virtual}
/>
);
},
[group, groupKeyToItems, prefixCls, sticky, virtual],
);

// ============================== Render ===============================
return (
<div ref={containerRef} className={prefixCls}>
<div className={prefixCls}>
<VirtualList
virtual={virtual}
ref={listRef}
Expand Down
119 changes: 32 additions & 87 deletions src/hooks/useStickyGroupHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import * as React from 'react';
import Portal from '@rc-component/portal';
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 type { Group } from './useGroupSegments';
import GroupHeader from '../GroupHeader';

type ExtraRenderInfo = Parameters<
NonNullable<VirtualListProps<unknown>['extraRender']>
>[0];

export interface StickyHeaderParams<T, K extends React.Key = React.Key> {
enabled: boolean;
group: Group<T, K> | undefined;
headerRows: { groupKey: K; rowIndex: number }[];
groupKeyToItems: Map<K, T[]>;
containerRef: React.RefObject<HTMLDivElement | null>;
listRef: React.RefObject<ListRef | null>;
prefixCls: string;
}

Expand All @@ -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<HTMLDivElement>(`.${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 = (
<div className={`${prefixCls}-sticky-header`}>
{group.title(currHeader.groupKey, groupItems)}
</div>
);
const nextHeader = headerRows[activeHeaderIdx + 1];
const nextTop = nextHeader
? getSize(nextHeader.groupKey).top - headerHeight - offsetY
: fixedTop;
const top = Math.min(fixedTop, nextTop);

return (
<Portal open getContainer={() => container}>
{headerNode}
</Portal>
<GroupHeader
fixed
group={group}
groupKey={currHeader.groupKey}
groupItems={groupItems}
prefixCls={prefixCls}
style={{ top }}
/>
);
},
[
enabled,
group,
headerRows,
groupKeyToItems,
containerRef,
listRef,
prefixCls,
],
[enabled, group, headerRows, groupKeyToItems, prefixCls],
);

return extraRender;
Expand Down
Loading
Loading