From 51ddeb6975a6aa623c43bf3d58a6e3375528c007 Mon Sep 17 00:00:00 2001 From: Marco Schumacher Date: Fri, 28 Apr 2023 10:53:50 +0200 Subject: [PATCH] feat: virtual list SelectFilter --- docs/.storybook/preview-head.html | 8 +- src/components/selectFilter.tsx | 25 ++++-- src/components/virtualList.tsx | 121 ++++++++++++++++++++++++++++++ src/components/virtualized.tsx | 19 ++--- 4 files changed, 156 insertions(+), 17 deletions(-) create mode 100644 src/components/virtualList.tsx diff --git a/docs/.storybook/preview-head.html b/docs/.storybook/preview-head.html index 05da1e9..61759b6 100644 --- a/docs/.storybook/preview-head.html +++ b/docs/.storybook/preview-head.html @@ -1,3 +1,9 @@ \ No newline at end of file + + + \ No newline at end of file diff --git a/src/components/selectFilter.tsx b/src/components/selectFilter.tsx index badd6ee..4ea683a 100644 --- a/src/components/selectFilter.tsx +++ b/src/components/selectFilter.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useFilter } from '../hooks/useFilter'; import { useTheme } from '../hooks/useTheme'; import { asString, castArray, flatMap, uniq } from '../misc/helpers'; @@ -7,6 +7,8 @@ import { useColumnContext, useTableContext } from '../misc/tableContext'; import type { CommonFilterProps, InternalColumn, SerializableValue } from '../types'; import { AutoFocusTextField } from './autoFocusTextField'; import { FormControlLabel } from './formControlLabel'; +import type { VirtualListProps } from './virtualList'; +import { VirtualList } from './virtualList'; function toggle(set: Set, value: T, singleSelect?: boolean) { const newSet = new Set(singleSelect ? [] : set); @@ -40,6 +42,9 @@ export function SelectFilter({ hideSearchField?: boolean; /** If enabled, the reset button is hidden. */ hideResetButton?: boolean; + /** Virtual list props. + * @default true */ + virtual?: VirtualListProps['virtual']; } & CommonFilterProps>): JSX.Element { const IconButton = useTheme((t) => t.components.IconButton); const Checkbox = useTheme((t) => t.components.Checkbox); @@ -95,6 +100,10 @@ export function SelectFilter({ const Button = useTheme((t) => t.components.Button); + useEffect(() => { + setQuery(''); + }, [isActive]); + return (
({ )} -
- {ordered.map((option, index) => ( + + {(option, index) => ( ({ } label={render(option)} > - ))} -
+ )} + {ordered.length === 0 && {noResults}}
diff --git a/src/components/virtualList.tsx b/src/components/virtualList.tsx new file mode 100644 index 0000000..0bd03a4 --- /dev/null +++ b/src/components/virtualList.tsx @@ -0,0 +1,121 @@ +import type { HTMLProps, ReactNode } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { throttle } from '../misc/throttle'; + +export interface VirtualListProps extends Omit, 'children'> { + virtual?: + | boolean + | { + rowHeight?: number; + initalRowHeight?: number; + throttleScroll?: number; + overscan?: number; + overscanBottom?: number; + overscanTop?: number; + }; + items: T[]; + children: (item: T, index: number) => ReactNode; +} + +export function VirtualList({ + virtual = true, + items, + children, + ...props +}: VirtualListProps): JSX.Element { + const container = useRef(null); + const [, setId] = useState({}); + + const throttleScroll = (typeof virtual === 'boolean' ? undefined : virtual)?.throttleScroll ?? 16; + const update = useMemo(() => throttle(() => setId({}), throttleScroll), [throttleScroll]); + + useEffect(() => { + if (!virtual) return; + + const ro = new ResizeObserver(update); + + if (container.current) { + ro.observe(container.current); + } + + window.addEventListener('resize', update, true); + return () => { + ro.disconnect(); + window.removeEventListener('resize', update, true); + update.cancel(); + }; + }, [update, virtual, throttleScroll]); + + if (!virtual) { + return ( +
+ {items.map((item, index) => children(item, index))} +
+ ); + } + + const { + rowHeight, + initalRowHeight = 38, + overscan = 100, + overscanTop, + overscanBottom, + } = (virtual instanceof Object ? virtual : undefined) ?? {}; + + const itemHeight = rowHeight ?? (averageItemHeight(container.current) || initalRowHeight); + const from = container.current?.clientHeight + ? Math.max( + 0, + Math.floor((container.current.scrollTop - (overscanTop ?? overscan)) / itemHeight), + ) + : 0; + const to = container.current?.clientHeight + ? Math.min( + items.length, + Math.ceil( + (container.current.scrollTop + + container.current.clientHeight + + (overscanBottom ?? overscan)) / + itemHeight, + ), + ) + : 1; + const before = from * itemHeight; + const after = (items.length - to) * itemHeight; + + return ( +
{ + update(); + props.onScroll?.(event); + }} + ref={container} + css={{ + overflowY: 'auto', + display: 'grid', + }} + > +
+ {items.slice(from, to).map((item, index) => children(item, index + from))} +
+
+ ); +} + +function averageItemHeight(container: HTMLDivElement | null) { + if (!container?.children.length) { + return undefined; + } + + const heights = Array.from(container.children) + .slice(1, -1) + .map((child) => child.clientHeight); + return heights.reduce((a, b) => a + b, 0) / heights.length; +} diff --git a/src/components/virtualized.tsx b/src/components/virtualized.tsx index cfae481..fc7acab 100644 --- a/src/components/virtualized.tsx +++ b/src/components/virtualized.tsx @@ -31,7 +31,7 @@ export function Virtualized({ const table = useTableContext(); const virtual = table.useState((state) => state.props.virtual); const probeRef = useRef(null); - const [, setCounter] = useState(0); + const [, setId] = useState({}); const { itemIds = [], @@ -92,23 +92,20 @@ export function Virtualized({ ); const throttleScroll = (typeof virtual === 'boolean' ? undefined : virtual)?.throttleScroll ?? 16; - const incCounter = useMemo( - () => throttle(() => setCounter((c) => c + 1), throttleScroll), - [throttleScroll], - ); + const update = useMemo(() => throttle(() => setId({}), throttleScroll), [throttleScroll]); useEffect(() => { if (!virtual || !probeRef.current) return; - window.addEventListener('scroll', incCounter, true); - window.addEventListener('resize', incCounter, true); + window.addEventListener('scroll', update, true); + window.addEventListener('resize', update, true); return () => { - window.removeEventListener('scroll', incCounter, true); - window.removeEventListener('resize', incCounter, true); + window.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update, true); }; - }, [incCounter, virtual]); + }, [update, virtual]); - useEffect(() => incCounter.cancel, [incCounter]); + useEffect(() => update.cancel, [update]); useLayoutEffect(() => table.getState().props.debugRender?.(`Virtualalized render ${from} to ${to}`),