import type { DCloneHTMLElement, DId } from '../../utils/types';
import { isBoolean, isNumber, isUndefined, nth } from 'lodash';
import React, { useImperativeHandle, useMemo, useRef, useState } from 'react';
import { useEventCallback, useForkRef, useIsomorphicLayoutEffect, useRefExtra, useResize } from '@react-devui/hooks';
import { checkScrollEnd, toPx } from '@react-devui/utils';
import { cloneHTMLElement, registerComponentMate } from '../../utils';
import { useComponentConfig } from '../root';
const EMPTY = Symbol();
export type DVirtualScrollPerformance<T> = Pick<
DVirtualScrollProps<T>,
'dList' | 'dExpands' | 'dItemSize' | 'dItemNested' | 'dItemKey' | 'dFocusable'
>;
export interface DVirtualScrollRef<T> {
scrollToItem: (item: T) => void;
scrollToStep: (step: 1 | -1) => T | undefined;
scrollToNested: () => T | undefined;
scrollToStart: () => T | undefined;
scrollToEnd: () => T | undefined;
}
export interface DItemRenderProps {
aria: {
'aria-level': number;
'aria-setsize': number;
'aria-posinset': number;
};
vsList?: React.ReactNode;
}
export interface DVirtualScrollProps<T> {
children: (props: { render: DCloneHTMLElement; vsList: React.ReactNode }) => JSX.Element | null;
dRef?: { list?: React.ForwardedRef<any> };
dList: T[];
dFillNode: React.ReactElement;
dItemRender: (item: T, index: number, props: DItemRenderProps, parent: T[]) => React.ReactNode;
dItemSize: number | ((item: T) => number);
dItemNested?: (item: T) => { list?: T[]; emptySize?: number; inAriaSetsize: boolean } | undefined;
dItemKey: (item: T) => DId;
dFocusable?: boolean | ((item: T) => boolean);
dFocusItem?: T;
dSize?: number;
dPadding?: number;
dHorizontal?: boolean;
dEmptyRender?: (item: T) => React.ReactNode;
dExpands?: Set<DId>;
onScrollEnd?: () => void;
}
const { COMPONENT_NAME } = registerComponentMate({ COMPONENT_NAME: 'DVirtualScroll' as const });
function VirtualScroll<T>(props: DVirtualScrollProps<T>, ref: React.ForwardedRef<DVirtualScrollRef<T>>): JSX.Element | null {
const {
children,
dRef,
dList,
dFillNode,
dItemRender,
dItemSize,
dItemNested,
dItemKey,
dFocusable = true,
dFocusItem,
dSize,
dPadding,
dHorizontal = false,
dEmptyRender,
dExpands,
onScrollEnd,
} = useComponentConfig(COMPONENT_NAME, props);
const listRef = useRefExtra(undefined, true);
const combineListlRef = useForkRef(listRef, dRef?.list);
const dataRef = useRef<{
listCache: Map<DId, React.ReactNode[]>;
}>({
listCache: new Map(),
});
const [scrollPosition, setScrollPosition] = useState(0);
const getItemSize = useMemo(() => (isNumber(dItemSize) ? () => dItemSize : dItemSize), [dItemSize]);
const checkFocusable = useMemo(() => (isBoolean(dFocusable) ? () => dFocusable : dFocusable), [dFocusable]);
const [itemsMap, totalSize, firstFocusableItem, lastFocusableItem] = useMemo(() => {
let accSize = 0;
let firstFocusableItem: T | undefined;
let lastFocusableItem: T | undefined;
const items = new Map<DId, { item: T; level: number; accSize: number; nestedSize: number }>();
const reduceArr = (arr: T[], level = 0) => {
let size = 0;
for (const item of arr) {
if (checkFocusable(item)) {
lastFocusableItem = item;
if (isUndefined(firstFocusableItem)) {
firstFocusableItem = item;
}
}
const key = dItemKey(item);
const itemSize = getItemSize(item);
size += itemSize;
accSize += itemSize;
const data = { item, level, accSize, nestedSize: 0 };
items.set(key, data);
const nestedData = dItemNested?.(item);
if (nestedData && nestedData.list && (isUndefined(dExpands) || dExpands.has(key))) {
if (nestedData.list.length === 0) {
data.nestedSize = nestedData.emptySize ?? 0;
size += data.nestedSize;
accSize += data.nestedSize;
} else {
data.nestedSize = reduceArr(nestedData.list, level + 1);
size += data.nestedSize;
}
}
}
return size;
};
return [items, reduceArr(dList), firstFocusableItem, lastFocusableItem];
}, [checkFocusable, dExpands, dItemKey, dItemNested, dList, getItemSize]);
const [elSize, setElSize] = useState<number>();
const [elPaddingSize, setElPaddingSize] = useState<number>();
const getSize = () => {
if (listRef.current) {
if (isUndefined(dSize)) {
setElSize(listRef.current[dHorizontal ? 'clientWidth' : 'clientHeight']);
}
if (isUndefined(dPadding)) {
setElPaddingSize(toPx(getComputedStyle(listRef.current).getPropertyValue(dHorizontal ? 'padding-left' : 'padding-top'), true));
}
}
};
useIsomorphicLayoutEffect(() => {
getSize();
});
useResize(listRef, getSize);
const ulSize = dSize ?? elSize;
const paddingSize = dPadding ?? elPaddingSize;
const list = (() => {
if (isUndefined(ulSize) || isUndefined(paddingSize)) {
return [];
}
const maxScrollSize = Math.max(totalSize + paddingSize * 2 - ulSize, 0);
const _scrollPosition = Math.min(scrollPosition, maxScrollSize);
let totalAccSize = 0;
const startSize = _scrollPosition - ulSize - paddingSize;
const endSize = _scrollPosition + ulSize + ulSize - paddingSize;
let hasStart = false;
let hasEnd = false;
const getList = (arr: (T | typeof EMPTY)[], parent: T[] = []): React.ReactNode[] => {
const fillSize = [0, 0];
const list: React.ReactNode[] = [];
const setsize = arr.filter((item) => {
if (item === EMPTY) {
return false;
} else {
const nestedData = dItemNested?.(item);
if (nestedData && nestedData.list) {
return nestedData.inAriaSetsize;
}
}
return true;
}).length;
for (const [index, item] of arr.entries()) {
let key: DId = '';
let size = 0;
let nestedList: T[] | undefined;
let childrenSize = 0;
let emptyNode: React.ReactNode;
if (item === EMPTY) {
size = dItemNested?.(parent[parent.length - 1])?.emptySize ?? 0;
emptyNode = <React.Fragment key="$$empty">{dEmptyRender?.(parent[parent.length - 1])}</React.Fragment>;
} else {
key = dItemKey(item);
size = getItemSize(item);
nestedList = dItemNested?.(item)?.list;
if (nestedList) {
childrenSize = itemsMap.get(key)!.nestedSize;
}
}
if (hasEnd) {
fillSize[1] += size + childrenSize;
continue;
}
totalAccSize += size;
if (nestedList) {
if (totalAccSize + childrenSize > startSize) {
let childrenList: React.ReactNode[] = [];
if (isUndefined(dExpands)) {
childrenList = getList(nestedList.length === 0 ? [EMPTY] : nestedList, parent.concat([item as T]));
} else {
childrenList = dataRef.current.listCache.get(key) ?? [];
if (dExpands.has(key)) {
childrenList = getList(nestedList.length === 0 ? [EMPTY] : nestedList, parent.concat([item as T]));
dataRef.current.listCache.set(key, childrenList);
}
}
list.push(
dItemRender(
item as T,
index,
{
aria: { 'aria-level': parent.length + 1, 'aria-setsize': setsize, 'aria-posinset': index + 1 },
vsList: childrenList,
},
parent
)
);
} else {
totalAccSize += childrenSize;
fillSize[0] += size + childrenSize;
}
} else if (!hasStart) {
if (totalAccSize > startSize) {
list.push(
item === EMPTY
? emptyNode
: dItemRender(
item,
index,
{
aria: { 'aria-level': parent.length + 1, 'aria-setsize': setsize, 'aria-posinset': index + 1 },
},
parent
)
);
hasStart = true;
} else {
fillSize[0] += size;
}
} else if (!hasEnd) {
if (totalAccSize > endSize) {
hasEnd = true;
fillSize[1] += size;
} else {
list.push(
item === EMPTY
? emptyNode
: dItemRender(
item,
index,
{
aria: { 'aria-level': parent.length + 1, 'aria-setsize': setsize, 'aria-posinset': index + 1 },
},
parent
)
);
}
}
}
if (fillSize[0] > 0) {
list.unshift(
React.cloneElement(dFillNode, {
key: '$$fill-size-0',
style: {
...dFillNode.props.style,
overflow: 'hidden',
[dHorizontal ? 'height' : 'width']: 0,
[dHorizontal ? 'width' : 'height']: fillSize[0],
flexShrink: 0,
},
'aria-hidden': true,
})
);
}
if (fillSize[1] > 0) {
list.push(
React.cloneElement(dFillNode, {
key: '$$fill-size-1',
style: {
...dFillNode.props.style,
overflow: 'hidden',
[dHorizontal ? 'height' : 'width']: 0,
[dHorizontal ? 'width' : 'height']: fillSize[1],
flexShrink: 0,
},
'aria-hidden': true,
})
);
}
return list;
};
return getList(dList);
})();
const scrollTo = (num: number) => {
if (listRef.current) {
listRef.current[dHorizontal ? 'scrollLeft' : 'scrollTop'] = num;
}
};
const scrollToItem = useEventCallback((item: T) => {
if (!isUndefined(paddingSize)) {
const findItem = itemsMap.get(dItemKey(item));
if (!isUndefined(findItem)) {
scrollTo(findItem.accSize - getItemSize(findItem.item) + paddingSize);
}
}
});
const scrollToStart = useEventCallback(() => {
scrollTo(0);
return firstFocusableItem;
});
const scrollToEnd = useEventCallback(() => {
if (listRef.current) {
scrollTo(listRef.current[dHorizontal ? 'scrollWidth' : 'scrollHeight']);
}
return lastFocusableItem;
});
const scrollToStep = useEventCallback((step: 1 | -1) => {
if (!isUndefined(paddingSize)) {
if (isUndefined(dFocusItem)) {
return;
}
let findItem: T | undefined;
let offsetSize: [number, number] | undefined;
if (listRef.current) {
let index = -1;
let findIndex = -1;
const accSizeList = [];
const focusKey = dItemKey(dFocusItem);
for (const iterator of itemsMap) {
index += 1;
if (iterator[0] === focusKey) {
findIndex = index;
}
accSizeList.push(iterator[1]);
}
if (findIndex !== -1) {
if (step === 1) {
for (let index = findIndex + 1, n = 0; n < accSizeList.length; index++, n++) {
const accSizeItem = nth(accSizeList, index % accSizeList.length);
if (accSizeItem && checkFocusable(accSizeItem.item)) {
findItem = accSizeItem.item;
offsetSize = [accSizeItem.accSize - getItemSize(findItem) + paddingSize, accSizeItem.accSize + paddingSize];
break;
}
}
} else {
for (let index = findIndex - 1, n = 0; n < accSizeList.length; index--, n++) {
const accSizeItem = nth(accSizeList, index);
if (accSizeItem && checkFocusable(accSizeItem.item)) {
findItem = accSizeItem.item;
offsetSize = [accSizeItem.accSize - getItemSize(findItem) + paddingSize, accSizeItem.accSize + paddingSize];
break;
}
}
}
}
if (!isUndefined(offsetSize)) {
const listElScrollPosition = listRef.current[dHorizontal ? 'scrollLeft' : 'scrollTop'];
const listElClientSize = listRef.current[dHorizontal ? 'clientWidth' : 'clientHeight'];
if (listElScrollPosition > offsetSize[1]) {
scrollTo(offsetSize[0] - paddingSize);
} else if (offsetSize[0] > listElScrollPosition + listElClientSize) {
scrollTo(offsetSize[1] - listElClientSize + paddingSize);
} else {
if (step === 1) {
if (offsetSize[1] > listElScrollPosition + listElClientSize) {
scrollTo(offsetSize[1] - listElClientSize + paddingSize);
}
} else {
if (listElScrollPosition > offsetSize[0]) {
scrollTo(offsetSize[0] - paddingSize);
}
}
}
}
}
return findItem;
}
});
const scrollToNested = useEventCallback(() => {
if (!isUndefined(paddingSize)) {
if (isUndefined(dFocusItem)) {
return;
}
let findItem: T | undefined;
let offsetSize: [number, number] | undefined;
if (listRef.current) {
let index = -1;
let findIndex = -1;
let level = 0;
const accSizeList = [];
const focusKey = dItemKey(dFocusItem);
for (const iterator of itemsMap) {
index += 1;
if (iterator[0] === focusKey) {
findIndex = index;
level = iterator[1].level;
}
accSizeList.push(iterator[1]);
}
if (findIndex !== -1) {
for (let index = findIndex + 1; index < accSizeList.length; index++) {
const accSizeItem = accSizeList[index];
if (accSizeItem.level <= level) {
return;
}
if (checkFocusable(accSizeItem.item)) {
findItem = accSizeItem.item;
offsetSize = [accSizeItem.accSize - getItemSize(findItem) + paddingSize, accSizeItem.accSize + paddingSize];
break;
}
}
}
if (!isUndefined(offsetSize)) {
const listElScrollPosition = listRef.current[dHorizontal ? 'scrollLeft' : 'scrollTop'];
const listElClientSize = listRef.current[dHorizontal ? 'clientWidth' : 'clientHeight'];
if (listElScrollPosition > offsetSize[1]) {
scrollTo(offsetSize[0] - paddingSize);
} else if (offsetSize[0] > listElScrollPosition + listElClientSize) {
scrollTo(offsetSize[1] - listElClientSize + paddingSize);
} else {
if (offsetSize[1] > listElScrollPosition + listElClientSize) {
scrollTo(offsetSize[1] - listElClientSize + paddingSize);
}
}
}
}
return findItem;
}
});
useImperativeHandle(
ref,
() => ({
scrollToItem,
scrollToStep,
scrollToNested,
scrollToStart,
scrollToEnd,
}),
[scrollToItem, scrollToStep, scrollToNested, scrollToStart, scrollToEnd]
);
return children({
render: (el) =>
cloneHTMLElement(el, {
ref: combineListlRef,
onScroll: (e) => {
el.props.onScroll?.(e);
setScrollPosition(e.currentTarget[dHorizontal ? 'scrollLeft' : 'scrollTop']);
if (checkScrollEnd(e.currentTarget)[dHorizontal ? 'x' : 'y']) {
onScrollEnd?.();
}
},
}),
vsList: list,
});
}
export const DVirtualScroll: <T>(
props: DVirtualScrollProps<T> & React.RefAttributes<DVirtualScrollRef<T>>
) => ReturnType<typeof VirtualScroll> = React.forwardRef(VirtualScroll) as any;