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 & { label: string; value: string }; interface VirtualMultiSelectProps { disabled?: boolean; displayCountType?: 'album' | 'song'; height: number; isLoading?: boolean; label?: React.ReactNode | string; onChange: (value: null | string[]) => void; options: VirtualMultiSelectOption[]; RowComponent: ( props: RowComponentProps<{ disabled?: boolean; displayCountType?: 'album' | 'song'; focusedIndex: null | number; onToggle: (value: string) => void; options: VirtualMultiSelectOption[]; value: string[]; }>, ) => React.ReactElement; singleSelect?: boolean; value: string[]; } export function VirtualMultiSelect({ disabled = false, displayCountType = 'album', height, isLoading = false, label, onChange, options, RowComponent, singleSelect = false, value, }: VirtualMultiSelectProps) { const { t } = useTranslation(); const [search, setSearch] = useState(''); const [focusedIndex, setFocusedIndex] = useState(null); const listContainerRef = useRef(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) => { 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 (
0 && !disabled ? ( { onChange(null); setSearch(''); }} size="xs" variant="subtle" /> ) : undefined } onChange={(e) => { if (!disabled) { setSearch(e.currentTarget.value); } }} placeholder={placeholder} rightSection={ {search && !disabled ? ( setSearch('')} size="xs" variant="subtle" /> ) : ( )} } styles={{ input: disabled ? { opacity: 0.6 } : undefined, label: { width: '100%' }, section: disabled ? { opacity: 0.6 } : undefined, wrapper: disabled ? { opacity: 0.6 } : undefined, }} value={search} />
{ 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 ? (
) : stableOptions.length === 0 ? (
{t('common.noResultsFromQuery', { postProcess: 'sentenceCase' })}
) : ( )}
{selectedOptions.length > 0 && ( {selectedOptions.map((option) => ( handleDeselect(option.value)} wrap="nowrap" > {!disabled && ( )} {option.label} ))} )}
); }