mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
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 { Spinner } from '/@/shared/components/spinner/spinner';
|
|
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> {
|
|
disabled?: boolean;
|
|
displayCountType?: 'album' | 'song';
|
|
height: number;
|
|
isLoading?: boolean;
|
|
label?: React.ReactNode | string;
|
|
onChange: (value: null | string[]) => void;
|
|
options: VirtualMultiSelectOption<T>[];
|
|
RowComponent: (
|
|
props: RowComponentProps<{
|
|
disabled?: boolean;
|
|
displayCountType?: 'album' | 'song';
|
|
focusedIndex: null | number;
|
|
onToggle: (value: string) => void;
|
|
options: VirtualMultiSelectOption<T>[];
|
|
value: string[];
|
|
}>,
|
|
) => React.ReactElement;
|
|
singleSelect?: boolean;
|
|
value: string[];
|
|
}
|
|
|
|
export function VirtualMultiSelect<T>({
|
|
disabled = false,
|
|
displayCountType = 'album',
|
|
height,
|
|
isLoading = false,
|
|
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 (disabled) return;
|
|
if (value.includes(optionValue)) {
|
|
const newValue = value.filter((v) => v !== optionValue);
|
|
onChange(newValue.length > 0 ? newValue : null);
|
|
} else {
|
|
onChange(singleSelect ? [optionValue] : [...value, optionValue]);
|
|
}
|
|
},
|
|
[disabled, onChange, singleSelect, value],
|
|
);
|
|
|
|
const handleDeselect = useCallback(
|
|
(optionValue: string) => {
|
|
if (disabled) return;
|
|
const newValue = value.filter((v) => v !== optionValue);
|
|
onChange(newValue.length > 0 ? newValue : null);
|
|
},
|
|
[disabled, 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 (disabled || 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;
|
|
}
|
|
},
|
|
[disabled, focusedIndex, handleToggle, scrollToIndex, stableOptions],
|
|
);
|
|
|
|
return (
|
|
<div className={`${styles.container} ${disabled ? styles.disabled : ''}`}>
|
|
<TextInput
|
|
className={styles['search-input']}
|
|
disabled={disabled}
|
|
label={labelWithClear}
|
|
leftSection={
|
|
value.length > 0 && !disabled ? (
|
|
<ActionIcon
|
|
icon="x"
|
|
iconProps={{ size: 'md' }}
|
|
onClick={() => {
|
|
onChange(null);
|
|
setSearch('');
|
|
}}
|
|
size="xs"
|
|
variant="subtle"
|
|
/>
|
|
) : undefined
|
|
}
|
|
onChange={(e) => {
|
|
if (!disabled) {
|
|
setSearch(e.currentTarget.value);
|
|
}
|
|
}}
|
|
placeholder={placeholder}
|
|
rightSection={
|
|
<Group gap="xs" wrap="nowrap">
|
|
{search && !disabled ? (
|
|
<ActionIcon
|
|
icon="x"
|
|
iconProps={{ size: 'md' }}
|
|
onClick={() => setSearch('')}
|
|
size="xs"
|
|
variant="subtle"
|
|
/>
|
|
) : (
|
|
<Icon icon="search" />
|
|
)}
|
|
</Group>
|
|
}
|
|
styles={{
|
|
input: disabled ? { opacity: 0.6 } : undefined,
|
|
label: { width: '100%' },
|
|
section: disabled ? { opacity: 0.6 } : undefined,
|
|
wrapper: disabled ? { opacity: 0.6 } : undefined,
|
|
}}
|
|
value={search}
|
|
/>
|
|
<div
|
|
className={styles['list-container']}
|
|
onKeyDown={handleKeyDown}
|
|
onMouseDown={(e) => {
|
|
if (disabled) return;
|
|
const element = e.currentTarget as HTMLDivElement;
|
|
if (element.focus) {
|
|
element.focus({ preventScroll: true });
|
|
}
|
|
}}
|
|
ref={listContainerRef}
|
|
style={{ height: `${height}px` }}
|
|
tabIndex={disabled ? -1 : 0}
|
|
>
|
|
{isLoading ? (
|
|
<Center h="100%">
|
|
<Spinner />
|
|
</Center>
|
|
) : 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={{
|
|
disabled,
|
|
displayCountType,
|
|
focusedIndex,
|
|
onToggle: handleToggle,
|
|
options: stableOptions,
|
|
value,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
{selectedOptions.length > 0 && (
|
|
<Stack gap="xs" mt="sm">
|
|
{selectedOptions.map((option) => (
|
|
<Group
|
|
className={`${styles['selected-option']} ${disabled ? styles.disabled : ''}`}
|
|
gap="sm"
|
|
key={option.value}
|
|
onClick={() => handleDeselect(option.value)}
|
|
wrap="nowrap"
|
|
>
|
|
{!disabled && (
|
|
<ActionIcon
|
|
icon="minus"
|
|
iconProps={{ size: 'sm' }}
|
|
size="xs"
|
|
stopsPropagation
|
|
variant="transparent"
|
|
/>
|
|
)}
|
|
<Text isNoSelect overflow="hidden" size="sm">
|
|
{option.label}
|
|
</Text>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|