add VirtualMultiSelect component for filters

This commit is contained in:
jeffvli
2026-01-17 16:25:12 -08:00
parent d793e67b56
commit 8c5188dfd0
4 changed files with 453 additions and 0 deletions
@@ -0,0 +1,25 @@
.container {
display: flex;
flex-direction: column;
min-height: 0;
}
.list-container {
position: relative;
flex-shrink: 0;
overflow: hidden;
background-color: var(--theme-colors-surface);
}
.search-input {
border-bottom: 1px solid var(--theme-colors-border);
}
.selected-option {
cursor: pointer;
transition: background-color 0.15s ease;
}
.selected-option:hover {
background-color: alpha(var(--theme-colors-surface), 0.6);
}
@@ -0,0 +1,285 @@
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { List, RowComponentProps, useDynamicRowHeight, useListRef } from 'react-window-v2';
import styles from './virtual-multi-select.module.css';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Center } from '/@/shared/components/center/center';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Stack } from '/@/shared/components/stack/stack';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { Text } from '/@/shared/components/text/text';
export type VirtualMultiSelectOption<T> = T & { label: string; value: string };
interface VirtualMultiSelectProps<T> {
height: number;
label?: React.ReactNode | string;
onChange: (value: null | string[]) => void;
options: VirtualMultiSelectOption<T>[];
RowComponent: (
props: RowComponentProps<{
focusedIndex: null | number;
onToggle: (value: string) => void;
options: VirtualMultiSelectOption<T>[];
value: string[];
}>,
) => React.ReactElement;
singleSelect?: boolean;
value: string[];
}
export function VirtualMultiSelect<T>({
height,
label,
onChange,
options,
RowComponent,
singleSelect = false,
value,
}: VirtualMultiSelectProps<T>) {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [focusedIndex, setFocusedIndex] = useState<null | number>(null);
const listContainerRef = useRef<HTMLDivElement>(null);
const listRef = useListRef(null);
const rowHeight = useDynamicRowHeight({
defaultRowHeight: 50,
});
const [initialize, osInstance] = useOverlayScrollbars({
defer: false,
options: {
overflow: { x: 'hidden', y: 'scroll' },
paddingAbsolute: true,
scrollbars: {
autoHide: 'leave',
autoHideDelay: 500,
pointers: ['mouse', 'pen', 'touch'],
theme: 'feishin-os-scrollbar',
},
},
});
const selectedOptions = useMemo(
() => options.filter((option) => value.includes(option.value)),
[options, value],
);
const stableOptions = useMemo(
() =>
options.filter(
(option) =>
!value.includes(option.value) &&
option.label.toLowerCase().includes(search.toLowerCase()),
),
[options, search, value],
);
useEffect(() => {
const { current: container } = listContainerRef;
if (!container) return;
const viewport = container.firstElementChild as HTMLElement;
if (!viewport) return;
initialize({
elements: {
viewport,
},
target: container,
});
return () => osInstance()?.destroy();
}, [initialize, osInstance, stableOptions.length]);
const handleToggle = useCallback(
(optionValue: string) => {
if (value.includes(optionValue)) {
const newValue = value.filter((v) => v !== optionValue);
onChange(newValue.length > 0 ? newValue : null);
} else {
onChange(singleSelect ? [optionValue] : [...value, optionValue]);
}
},
[onChange, singleSelect, value],
);
const handleDeselect = useCallback(
(optionValue: string) => {
const newValue = value.filter((v) => v !== optionValue);
onChange(newValue.length > 0 ? newValue : null);
},
[onChange, value],
);
const placeholder = useMemo(
() => (value.length > 0 ? t('common.countSelected', { count: value.length }) : undefined),
[t, value.length],
);
const labelWithClear = useMemo(() => {
if (!label) return undefined;
return label;
}, [label]);
const scrollToIndex = useCallback(
(index: number) => {
const list = listRef.current;
list?.scrollToRow({
align: 'auto',
behavior: 'auto',
index,
});
},
[listRef],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (stableOptions.length === 0) return;
switch (e.key) {
case ' ':
case 'Enter': {
e.preventDefault();
e.stopPropagation();
if (focusedIndex !== null && stableOptions[focusedIndex]) {
handleToggle(stableOptions[focusedIndex].value);
}
break;
}
case 'ArrowDown': {
e.preventDefault();
e.stopPropagation();
const newIndex =
focusedIndex === null
? 0
: Math.min(focusedIndex + 1, stableOptions.length - 1);
setFocusedIndex(newIndex);
scrollToIndex(newIndex);
break;
}
case 'ArrowUp': {
e.preventDefault();
e.stopPropagation();
const newIndex = focusedIndex === null ? 0 : Math.max(focusedIndex - 1, 0);
setFocusedIndex(newIndex);
scrollToIndex(newIndex);
break;
}
case 'Tab': {
setFocusedIndex(null);
break;
}
default:
break;
}
},
[focusedIndex, handleToggle, scrollToIndex, stableOptions],
);
return (
<div className={styles.container}>
<TextInput
className={styles['search-input']}
label={labelWithClear}
leftSection={
value.length > 0 ? (
<ActionIcon
icon="x"
iconProps={{ size: 'md' }}
onClick={() => {
onChange(null);
setSearch('');
}}
size="xs"
variant="subtle"
/>
) : undefined
}
onChange={(e) => setSearch(e.currentTarget.value)}
placeholder={placeholder}
rightSection={
<Group gap="xs" wrap="nowrap">
{search ? (
<ActionIcon
icon="x"
iconProps={{ size: 'md' }}
onClick={() => setSearch('')}
size="xs"
variant="subtle"
/>
) : (
<Icon icon="search" />
)}
</Group>
}
styles={{ label: { width: '100%' } }}
value={search}
/>
<div
className={styles['list-container']}
onKeyDown={handleKeyDown}
onMouseDown={(e) => {
const element = e.currentTarget as HTMLDivElement;
if (element.focus) {
element.focus({ preventScroll: true });
}
}}
ref={listContainerRef}
style={{ height: `${height}px` }}
tabIndex={0}
>
{stableOptions.length === 0 ? (
<Center h="100%">
<Text isMuted isNoSelect size="sm">
{t('common.noResultsFromQuery', { postProcess: 'sentenceCase' })}
</Text>
</Center>
) : (
<List
listRef={listRef}
rowComponent={RowComponent}
rowCount={stableOptions.length}
rowHeight={rowHeight}
rowProps={{
focusedIndex,
onToggle: handleToggle,
options: stableOptions,
value,
}}
/>
)}
</div>
{selectedOptions.length > 0 && (
<Stack gap="xs" mt="sm">
{selectedOptions.map((option) => (
<Group
className={styles['selected-option']}
gap="sm"
key={option.value}
onClick={() => handleDeselect(option.value)}
wrap="nowrap"
>
<ActionIcon
icon="minus"
iconProps={{ size: 'sm' }}
size="xs"
stopsPropagation
variant="transparent"
/>
<Text isNoSelect overflow="hidden" size="sm">
{option.label}
</Text>
</Group>
))}
</Stack>
)}
</div>
);
}