mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
add VirtualMultiSelect component for filters
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Group
|
||||||
|
className={styles.row}
|
||||||
|
gap="sm"
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{ ...style }}
|
||||||
|
{...(isFocused && { 'data-focused': true })}
|
||||||
|
>
|
||||||
|
<ItemImage
|
||||||
|
containerClassName={styles.rowImage}
|
||||||
|
itemType={LibraryItem.ARTIST}
|
||||||
|
src={options[index].imageUrl}
|
||||||
|
type="table"
|
||||||
|
/>
|
||||||
|
<div className={styles.rowContent}>
|
||||||
|
<Text isNoSelect overflow="hidden" size="sm">
|
||||||
|
{options[index].label}
|
||||||
|
</Text>
|
||||||
|
<Text isMuted overflow="hidden" size="xs">
|
||||||
|
{options[index].albumCount ? (
|
||||||
|
<>
|
||||||
|
{options[index].albumCount}{' '}
|
||||||
|
{t('entity.album', { count: options[index].albumCount })}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<> </>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Group
|
||||||
|
className={styles.row}
|
||||||
|
gap="sm"
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{ ...style }}
|
||||||
|
{...(isFocused && { 'data-focused': true })}
|
||||||
|
>
|
||||||
|
<div className={styles.rowContent}>
|
||||||
|
<Text isNoSelect overflow="hidden" size="sm">
|
||||||
|
{options[index].label}
|
||||||
|
</Text>
|
||||||
|
<Text isMuted overflow="hidden" size="xs">
|
||||||
|
{options[index].albumCount ? (
|
||||||
|
<>
|
||||||
|
{options[index].albumCount}{' '}
|
||||||
|
{t('entity.album', { count: options[index].albumCount })}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<> </>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user