From 8c5188dfd0cc359759f8bfdea3a6b24f2632fd71 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sat, 17 Jan 2026 16:25:12 -0800 Subject: [PATCH] add VirtualMultiSelect component for filters --- .../components/multi-select-rows.module.css | 29 ++ .../shared/components/multi-select-rows.tsx | 114 +++++++ .../virtual-multi-select.module.css | 25 ++ .../multi-select/virtual-multi-select.tsx | 285 ++++++++++++++++++ 4 files changed, 453 insertions(+) create mode 100644 src/renderer/features/shared/components/multi-select-rows.module.css create mode 100644 src/renderer/features/shared/components/multi-select-rows.tsx create mode 100644 src/shared/components/multi-select/virtual-multi-select.module.css create mode 100644 src/shared/components/multi-select/virtual-multi-select.tsx diff --git a/src/renderer/features/shared/components/multi-select-rows.module.css b/src/renderer/features/shared/components/multi-select-rows.module.css new file mode 100644 index 000000000..05e8642f3 --- /dev/null +++ b/src/renderer/features/shared/components/multi-select-rows.module.css @@ -0,0 +1,29 @@ +.row { + padding: var(--theme-spacing-xs) var(--theme-spacing-sm); + border: 1px solid transparent; + border-radius: var(--theme-radius-md); +} + +.row:hover { + cursor: pointer; + background-color: alpha(var(--theme-colors-background), 0.5); +} + +.row-image { + flex-shrink: 0; + width: 40px; + height: 40px; +} + +.row-content { + flex: 1; + min-width: 0; +} + +.row.selected { + background-color: var(--theme-colors-surface); +} + +.row[data-focused='true'] { + border: 1px solid var(--theme-colors-primary); +} diff --git a/src/renderer/features/shared/components/multi-select-rows.tsx b/src/renderer/features/shared/components/multi-select-rows.tsx new file mode 100644 index 000000000..35b6a4dbd --- /dev/null +++ b/src/renderer/features/shared/components/multi-select-rows.tsx @@ -0,0 +1,114 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RowComponentProps } from 'react-window-v2'; + +import styles from './multi-select-rows.module.css'; + +import { ItemImage } from '/@/renderer/components/item-image/item-image'; +import { Group } from '/@/shared/components/group/group'; +import { VirtualMultiSelectOption } from '/@/shared/components/multi-select/virtual-multi-select'; +import { Text } from '/@/shared/components/text/text'; +import { LibraryItem } from '/@/shared/types/domain-types'; + +export function ArtistMultiSelectRow({ + focusedIndex, + index, + onToggle, + options, + style, +}: RowComponentProps<{ + focusedIndex: null | number; + onToggle: (value: string) => void; + options: VirtualMultiSelectOption<{ + albumCount: null | number; + imageUrl: string | undefined; + }>[]; + value: string[]; +}>) { + const { t } = useTranslation(); + + const handleClick = useCallback(() => { + onToggle(options[index].value); + }, [onToggle, options, index]); + + const isFocused = focusedIndex === index; + + return ( + + +
+ + {options[index].label} + + + {options[index].albumCount ? ( + <> + {options[index].albumCount}{' '} + {t('entity.album', { count: options[index].albumCount })} + + ) : ( + <>  + )} + +
+
+ ); +} + +export function GenreMultiSelectRow({ + focusedIndex, + index, + onToggle, + options, + style, +}: RowComponentProps<{ + focusedIndex: null | number; + onToggle: (value: string) => void; + options: VirtualMultiSelectOption<{ albumCount: null | number }>[]; + value: string[]; +}>) { + const { t } = useTranslation(); + + const handleClick = useCallback(() => { + onToggle(options[index].value); + }, [onToggle, options, index]); + + const isFocused = focusedIndex === index; + + return ( + +
+ + {options[index].label} + + + {options[index].albumCount ? ( + <> + {options[index].albumCount}{' '} + {t('entity.album', { count: options[index].albumCount })} + + ) : ( + <>  + )} + +
+
+ ); +} diff --git a/src/shared/components/multi-select/virtual-multi-select.module.css b/src/shared/components/multi-select/virtual-multi-select.module.css new file mode 100644 index 000000000..6b70f70aa --- /dev/null +++ b/src/shared/components/multi-select/virtual-multi-select.module.css @@ -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); +} diff --git a/src/shared/components/multi-select/virtual-multi-select.tsx b/src/shared/components/multi-select/virtual-multi-select.tsx new file mode 100644 index 000000000..1aaab3e3b --- /dev/null +++ b/src/shared/components/multi-select/virtual-multi-select.tsx @@ -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 & { label: string; value: string }; + +interface VirtualMultiSelectProps { + height: number; + label?: React.ReactNode | string; + onChange: (value: null | string[]) => void; + options: VirtualMultiSelectOption[]; + RowComponent: ( + props: RowComponentProps<{ + focusedIndex: null | number; + onToggle: (value: string) => void; + options: VirtualMultiSelectOption[]; + value: string[]; + }>, + ) => React.ReactElement; + singleSelect?: boolean; + value: string[]; +} + +export function VirtualMultiSelect({ + height, + 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 (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) => { + 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 ( +
+ 0 ? ( + { + onChange(null); + setSearch(''); + }} + size="xs" + variant="subtle" + /> + ) : undefined + } + onChange={(e) => setSearch(e.currentTarget.value)} + placeholder={placeholder} + rightSection={ + + {search ? ( + setSearch('')} + size="xs" + variant="subtle" + /> + ) : ( + + )} + + } + styles={{ label: { width: '100%' } }} + value={search} + /> +
{ + const element = e.currentTarget as HTMLDivElement; + if (element.focus) { + element.focus({ preventScroll: true }); + } + }} + ref={listContainerRef} + style={{ height: `${height}px` }} + tabIndex={0} + > + {stableOptions.length === 0 ? ( +
+ + {t('common.noResultsFromQuery', { postProcess: 'sentenceCase' })} + +
+ ) : ( + + )} +
+ {selectedOptions.length > 0 && ( + + {selectedOptions.map((option) => ( + handleDeselect(option.value)} + wrap="nowrap" + > + + + {option.label} + + + ))} + + )} +
+ ); +}