Skip to content

Commit

Permalink
feat: virtual list SelectFilter
Browse files Browse the repository at this point in the history
  • Loading branch information
schummar committed Apr 28, 2023
1 parent 15714b9 commit 51ddeb6
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 17 deletions.
8 changes: 7 additions & 1 deletion docs/.storybook/preview-head.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
<script>
window.global = window;
</script>
</script>

<style>
body{
font-family: Arial, Helvetica, sans-serif;
}
</style>
25 changes: 20 additions & 5 deletions src/components/selectFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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';
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<T>(set: Set<T>, value: T, singleSelect?: boolean) {
const newSet = new Set(singleSelect ? [] : set);
Expand Down Expand Up @@ -40,6 +42,9 @@ export function SelectFilter<T, V, F extends SerializableValue>({
hideSearchField?: boolean;
/** If enabled, the reset button is hidden. */
hideResetButton?: boolean;
/** Virtual list props.
* @default true */
virtual?: VirtualListProps<unknown>['virtual'];
} & CommonFilterProps<T, V, F, Set<F>>): JSX.Element {
const IconButton = useTheme((t) => t.components.IconButton);
const Checkbox = useTheme((t) => t.components.Checkbox);
Expand Down Expand Up @@ -95,6 +100,10 @@ export function SelectFilter<T, V, F extends SerializableValue>({

const Button = useTheme((t) => t.components.Button);

useEffect(() => {
setQuery('');
}, [isActive]);

return (
<div
css={{
Expand Down Expand Up @@ -125,8 +134,14 @@ export function SelectFilter<T, V, F extends SerializableValue>({
</Button>
)}

<div css={{ maxHeight: '20em', overflowY: 'auto' }}>
{ordered.map((option, index) => (
<VirtualList
items={ordered}
css={{
width: '20em',
maxHeight: '20em',
}}
>
{(option, index) => (
<FormControlLabel
key={index}
control={
Expand All @@ -137,8 +152,8 @@ export function SelectFilter<T, V, F extends SerializableValue>({
}
label={render(option)}
></FormControlLabel>
))}
</div>
)}
</VirtualList>

{ordered.length === 0 && <span css={{ textAlign: 'center' }}>{noResults}</span>}
</div>
Expand Down
121 changes: 121 additions & 0 deletions src/components/virtualList.tsx
Original file line number Diff line number Diff line change
@@ -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<T> extends Omit<HTMLProps<HTMLDivElement>, '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<T>({
virtual = true,
items,
children,
...props
}: VirtualListProps<T>): JSX.Element {
const container = useRef<HTMLDivElement>(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 (
<div
{...props}
css={{
overflowY: 'auto',
display: 'grid',
}}
>
{items.map((item, index) => children(item, index))}
</div>
);
}

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 (
<div
{...props}
onScroll={(event) => {
update();
props.onScroll?.(event);
}}
ref={container}
css={{
overflowY: 'auto',
display: 'grid',
}}
>
<div data-virtual-before style={{ height: before }} />
{items.slice(from, to).map((item, index) => children(item, index + from))}
<div data-virtual-after style={{ height: after }} />
</div>
);
}

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;
}
19 changes: 8 additions & 11 deletions src/components/virtualized.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function Virtualized<T>({
const table = useTableContext<T>();
const virtual = table.useState((state) => state.props.virtual);
const probeRef = useRef<HTMLDivElement>(null);
const [, setCounter] = useState(0);
const [, setId] = useState({});

const {
itemIds = [],
Expand Down Expand Up @@ -92,23 +92,20 @@ export function Virtualized<T>({
);

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}`),
Expand Down

0 comments on commit 51ddeb6

Please sign in to comment.