conditionally disable Subsonic list filters based on availability (#1567)

This commit is contained in:
jeffvli
2026-01-17 18:20:40 -08:00
parent 9f9d685353
commit 27f82aef94
7 changed files with 275 additions and 38 deletions
@@ -1599,6 +1599,76 @@ export const SubsonicController: InternalControllerEndpoint = {
return (res.body.starred?.song || []).length || 0; return (res.body.starred?.song || []).length || 0;
} }
const artistIds = query.albumArtistIds || query.artistIds;
if (query.albumIds || artistIds) {
const fromAlbumPromises: Promise<ServerInferResponses<typeof contract.getAlbum>>[] = [];
const artistDetailPromises: Promise<ServerInferResponses<typeof contract.getArtist>>[] =
[];
if (query.albumIds) {
for (const albumId of query.albumIds) {
fromAlbumPromises.push(
ssApiClient(apiClientProps).getAlbum({
query: {
id: albumId,
},
}),
);
}
}
if (artistIds) {
for (const artistId of artistIds) {
artistDetailPromises.push(
ssApiClient(apiClientProps).getArtist({
query: {
id: artistId,
},
}),
);
}
const artistResult = await Promise.all(artistDetailPromises);
const albums = artistResult.flatMap((artist) => {
if (artist.status !== 200) {
return [];
}
return artist.body.artist.album ?? [];
});
const albumIds = albums.map((album) => album.id);
for (const albumId of albumIds) {
fromAlbumPromises.push(
ssApiClient(apiClientProps).getAlbum({
query: {
id: albumId.toString(),
},
}),
);
}
}
let results: z.infer<typeof ssType._response.song>[] = [];
if (fromAlbumPromises.length > 0) {
const albumsResult = await Promise.all(fromAlbumPromises);
results = albumsResult.flatMap((album) => {
if (album.status !== 200) {
return [];
}
return album.body.album.song;
});
}
return results.length;
}
let totalRecordCount = 0; let totalRecordCount = 0;
// Rather than just do `search3` by groups of 500, instead // Rather than just do `search3` by groups of 500, instead
@@ -74,11 +74,22 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
})); }));
}, [items]); }, [items]);
const hasFavorite = query.favorite === true;
const hasArtist = query.artistIds && query.artistIds.length > 0;
const hasGenre = query.genreIds && query.genreIds.length > 0;
const hasYear = query.minYear !== undefined || query.maxYear !== undefined;
const isFavoriteDisabled = hasArtist || hasGenre || hasYear;
const isArtistDisabled = hasFavorite || hasGenre || hasYear;
const isGenreDisabled = hasFavorite || hasArtist || hasYear;
const isYearDisabled = hasFavorite || hasArtist || hasGenre;
const handleAlbumArtistFilter = useCallback( const handleAlbumArtistFilter = useCallback(
(e: null | string[]) => { (e: null | string[]) => {
if (isArtistDisabled && e !== null) return;
setAlbumArtist(e ?? null); setAlbumArtist(e ?? null);
}, },
[setAlbumArtist], [isArtistDisabled, setAlbumArtist],
); );
const genreListQuery = useGenreList(); const genreListQuery = useGenreList();
@@ -97,13 +108,14 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
const handleGenresFilter = useCallback( const handleGenresFilter = useCallback(
(e: null | string[]) => { (e: null | string[]) => {
if (isGenreDisabled && e !== null && e.length > 0) return; // Prevent setting if disabled
if (e && e.length > 0) { if (e && e.length > 0) {
setGenreId([e[0]]); setGenreId([e[0]]);
} else { } else {
setGenreId(null); setGenreId(null);
} }
}, },
[setGenreId], [isGenreDisabled, setGenreId],
); );
const genreFilterLabel = useMemo(() => { const genreFilterLabel = useMemo(() => {
@@ -119,17 +131,23 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
{ {
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => { onChange: (e: ChangeEvent<HTMLInputElement>) => {
if (isFavoriteDisabled && e.target.checked) return; // Prevent setting if disabled
const favoriteValue = e.target.checked ? true : undefined; const favoriteValue = e.target.checked ? true : undefined;
setFavorite(favoriteValue ?? null); setFavorite(favoriteValue ?? null);
}, },
value: query.favorite, value: query.favorite,
}, },
], ],
[t, query.favorite, setFavorite], [isFavoriteDisabled, query.favorite, setFavorite, t],
); );
const handleMinYearFilter = useMemo( const handleMinYearFilter = useMemo(
() => (e: number | string) => { () => (e: number | string) => {
if (isYearDisabled) {
const isEmpty = e === '' || e === null || e === undefined || isNaN(Number(e));
if (!isEmpty) return;
}
// Handle empty string, null, undefined, or invalid numbers as clearing // Handle empty string, null, undefined, or invalid numbers as clearing
if (e === '' || e === null || e === undefined || isNaN(Number(e))) { if (e === '' || e === null || e === undefined || isNaN(Number(e))) {
setMinYear(null); setMinYear(null);
@@ -144,11 +162,16 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
setMinYear(null); setMinYear(null);
} }
}, },
[setMinYear], [isYearDisabled, setMinYear],
); );
const handleMaxYearFilter = useMemo( const handleMaxYearFilter = useMemo(
() => (e: number | string) => { () => (e: number | string) => {
if (isYearDisabled) {
const isEmpty = e === '' || e === null || e === undefined || isNaN(Number(e));
if (!isEmpty) return;
}
// Handle empty string, null, undefined, or invalid numbers as clearing // Handle empty string, null, undefined, or invalid numbers as clearing
if (e === '' || e === null || e === undefined || isNaN(Number(e))) { if (e === '' || e === null || e === undefined || isNaN(Number(e))) {
setMaxYear(null); setMaxYear(null);
@@ -163,7 +186,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
setMaxYear(null); setMaxYear(null);
} }
}, },
[setMaxYear], [isYearDisabled, setMaxYear],
); );
const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300); const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);
@@ -203,26 +226,32 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
value: 'multi', value: 'multi',
}, },
]} ]}
disabled={isArtistDisabled}
onChange={handleArtistSelectModeChange} onChange={handleArtistSelectModeChange}
size="xs" size="xs"
value={artistSelectMode} value={artistSelectMode}
/> />
</Group> </Group>
); );
}, [artistSelectMode, handleArtistSelectModeChange, t]); }, [artistSelectMode, handleArtistSelectModeChange, isArtistDisabled, t]);
return ( return (
<Stack px="md" py="md"> <Stack px="md" py="md">
{toggleFilters.map((filter) => ( {toggleFilters.map((filter) => (
<Group justify="space-between" key={`ss-filter-${filter.label}`}> <Group justify="space-between" key={`ss-filter-${filter.label}`}>
<Text>{filter.label}</Text> <Text>{filter.label}</Text>
<Switch checked={filter.value ?? false} onChange={filter.onChange} /> <Switch
checked={filter.value ?? false}
disabled={isFavoriteDisabled}
onChange={filter.onChange}
/>
</Group> </Group>
))} ))}
{!disableArtistFilter && ( {!disableArtistFilter && (
<> <>
<Divider my="md" /> <Divider my="md" />
<VirtualMultiSelect <VirtualMultiSelect
disabled={isArtistDisabled}
displayCountType="album" displayCountType="album"
height={300} height={300}
label={artistFilterLabel} label={artistFilterLabel}
@@ -238,6 +267,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
<> <>
<Divider my="md" /> <Divider my="md" />
<VirtualMultiSelect <VirtualMultiSelect
disabled={isGenreDisabled}
displayCountType="album" displayCountType="album"
height={220} height={220}
label={genreFilterLabel} label={genreFilterLabel}
@@ -252,7 +282,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
<Divider my="md" /> <Divider my="md" />
<Group grow> <Group grow>
<NumberInput <NumberInput
disabled={Boolean(query.genreIds && query.genreIds.length > 0)} disabled={isYearDisabled}
hideControls={false} hideControls={false}
label={t('filter.fromYear', { postProcess: 'sentenceCase' })} label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={5000} max={5000}
@@ -261,7 +291,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
value={query.minYear ?? undefined} value={query.minYear ?? undefined}
/> />
<NumberInput <NumberInput
disabled={Boolean(query.genreIds && query.genreIds.length > 0)} disabled={isYearDisabled}
hideControls={false} hideControls={false}
label={t('filter.toYear', { postProcess: 'sentenceCase' })} label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={5000} max={5000}
@@ -27,3 +27,13 @@
.row[data-focused='true'] { .row[data-focused='true'] {
border: 1px solid var(--theme-colors-primary); border: 1px solid var(--theme-colors-primary);
} }
.row.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.row.disabled:hover {
cursor: not-allowed;
background-color: transparent;
}
@@ -11,6 +11,7 @@ import { Text } from '/@/shared/components/text/text';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
export function ArtistMultiSelectRow({ export function ArtistMultiSelectRow({
disabled = false,
displayCountType = 'album', displayCountType = 'album',
focusedIndex, focusedIndex,
index, index,
@@ -18,6 +19,7 @@ export function ArtistMultiSelectRow({
options, options,
style, style,
}: RowComponentProps<{ }: RowComponentProps<{
disabled?: boolean;
displayCountType?: 'album' | 'song'; displayCountType?: 'album' | 'song';
focusedIndex: null | number; focusedIndex: null | number;
onToggle: (value: string) => void; onToggle: (value: string) => void;
@@ -41,11 +43,11 @@ export function ArtistMultiSelectRow({
return ( return (
<Group <Group
className={styles.row} className={`${styles.row} ${disabled ? styles.disabled : ''}`}
gap="sm" gap="sm"
onClick={handleClick} onClick={disabled ? undefined : handleClick}
style={{ ...style }} style={{ ...style }}
{...(isFocused && { 'data-focused': true })} {...(isFocused && !disabled && { 'data-focused': true })}
> >
<ItemImage <ItemImage
containerClassName={styles.rowImage} containerClassName={styles.rowImage}
@@ -70,6 +72,7 @@ export function ArtistMultiSelectRow({
} }
export function GenreMultiSelectRow({ export function GenreMultiSelectRow({
disabled = false,
displayCountType = 'album', displayCountType = 'album',
focusedIndex, focusedIndex,
index, index,
@@ -77,6 +80,7 @@ export function GenreMultiSelectRow({
options, options,
style, style,
}: RowComponentProps<{ }: RowComponentProps<{
disabled?: boolean;
displayCountType?: 'album' | 'song'; displayCountType?: 'album' | 'song';
focusedIndex: null | number; focusedIndex: null | number;
onToggle: (value: string) => void; onToggle: (value: string) => void;
@@ -99,11 +103,11 @@ export function GenreMultiSelectRow({
return ( return (
<Group <Group
className={styles.row} className={`${styles.row} ${disabled ? styles.disabled : ''}`}
gap="sm" gap="sm"
onClick={handleClick} onClick={disabled ? undefined : handleClick}
style={{ ...style }} style={{ ...style }}
{...(isFocused && { 'data-focused': true })} {...(isFocused && !disabled && { 'data-focused': true })}
> >
<div className={styles.rowContent}> <div className={styles.rowContent}>
<Text isNoSelect overflow="hidden" size="sm"> <Text isNoSelect overflow="hidden" size="sm">
@@ -1,10 +1,17 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { ChangeEvent, useCallback, useMemo } from 'react'; import { ChangeEvent, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { useListContext } from '/@/renderer/context/list-context'; import { useListContext } from '/@/renderer/context/list-context';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
import { GenreMultiSelectRow } from '/@/renderer/features/shared/components/multi-select-rows'; import {
ArtistMultiSelectRow,
GenreMultiSelectRow,
} from '/@/renderer/features/shared/components/multi-select-rows';
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters'; import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
import { useCurrentServerId } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Divider } from '/@/shared/components/divider/divider'; import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
@@ -12,10 +19,12 @@ import { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-mu
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';
export const SubsonicSongFilters = () => { export const SubsonicSongFilters = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { clear, query, setFavorite, setGenreId } = useSongListFilters(); const serverId = useCurrentServerId();
const { clear, query, setArtistIds, setFavorite, setGenreId } = useSongListFilters();
const { customFilters } = useListContext(); const { customFilters } = useListContext();
@@ -35,15 +44,75 @@ export const SubsonicSongFilters = () => {
const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]); const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]);
const albumArtistListQuery = useSuspenseQuery(
artistsQueries.albumArtistList({
options: {
gcTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1,
},
query: {
sortBy: AlbumArtistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
}),
);
const items = albumArtistListQuery?.data?.items;
const selectableAlbumArtists = useMemo(() => {
if (!items) return [];
return items.map((artist) => ({
albumCount: artist.albumCount,
imageUrl: getItemImageUrl({
id: artist.id,
itemType: LibraryItem.ARTIST,
type: 'table',
}),
label: artist.name,
songCount: artist.songCount,
value: artist.id,
}));
}, [items]);
const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]);
const hasFavorite = query.favorite === true;
const hasArtist = query.artistIds && query.artistIds.length > 0;
const hasGenre = query.genreIds && query.genreIds.length > 0;
const isFavoriteDisabled = hasArtist || hasGenre;
const isArtistDisabled = hasFavorite || hasGenre;
const isGenreDisabled = hasFavorite || hasArtist;
const handleArtistFilter = useCallback(
(e: null | string[]) => {
if (isArtistDisabled && e !== null) return;
setArtistIds(e ?? null);
},
[isArtistDisabled, setArtistIds],
);
const artistFilterLabel = useMemo(() => {
return (
<Text fw={500} size="sm">
{t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
</Text>
);
}, [t]);
const handleGenresFilter = useCallback( const handleGenresFilter = useCallback(
(e: null | string[]) => { (e: null | string[]) => {
if (isGenreDisabled && e !== null && e.length > 0) return;
if (e && e.length > 0) { if (e && e.length > 0) {
setGenreId([e[0]]); setGenreId([e[0]]);
} else { } else {
setGenreId(null); setGenreId(null);
} }
}, },
[setGenreId], [isGenreDisabled, setGenreId],
); );
const genreFilterLabel = useMemo(() => { const genreFilterLabel = useMemo(() => {
@@ -59,13 +128,14 @@ export const SubsonicSongFilters = () => {
{ {
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => { onChange: (e: ChangeEvent<HTMLInputElement>) => {
if (isFavoriteDisabled && e.target.checked) return;
const favoriteValue = e.target.checked ? true : undefined; const favoriteValue = e.target.checked ? true : undefined;
setFavorite(favoriteValue ?? null); setFavorite(favoriteValue ?? null);
}, },
value: query.favorite, value: query.favorite,
}, },
], ],
[t, query.favorite, setFavorite], [isFavoriteDisabled, query.favorite, setFavorite, t],
); );
return ( return (
@@ -73,13 +143,30 @@ export const SubsonicSongFilters = () => {
{toggleFilters.map((filter) => ( {toggleFilters.map((filter) => (
<Group justify="space-between" key={`ss-filter-${filter.label}`}> <Group justify="space-between" key={`ss-filter-${filter.label}`}>
<Text>{filter.label}</Text> <Text>{filter.label}</Text>
<Switch checked={filter.value ?? false} onChange={filter.onChange} /> <Switch
checked={filter.value ?? false}
disabled={isFavoriteDisabled}
onChange={filter.onChange}
/>
</Group> </Group>
))} ))}
<Divider my="md" />
<VirtualMultiSelect
disabled={isArtistDisabled}
displayCountType="song"
height={300}
label={artistFilterLabel}
onChange={handleArtistFilter}
options={selectableAlbumArtists}
RowComponent={ArtistMultiSelectRow}
singleSelect={true}
value={selectedArtistIds}
/>
{!isGenrePage && ( {!isGenrePage && (
<> <>
<Divider my="md" /> <Divider my="md" />
<VirtualMultiSelect <VirtualMultiSelect
disabled={isGenreDisabled}
displayCountType="song" displayCountType="song"
height={220} height={220}
label={genreFilterLabel} label={genreFilterLabel}
@@ -4,6 +4,11 @@
min-height: 0; min-height: 0;
} }
.container.disabled {
cursor: not-allowed;
user-select: none;
}
.list-container { .list-container {
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
@@ -11,6 +16,10 @@
background-color: var(--theme-colors-surface); background-color: var(--theme-colors-surface);
} }
.container.disabled .list-container {
opacity: 0.6;
}
.search-input { .search-input {
border-bottom: 1px solid var(--theme-colors-border); border-bottom: 1px solid var(--theme-colors-border);
} }
@@ -23,3 +32,11 @@
.selected-option:hover { .selected-option:hover {
background-color: alpha(var(--theme-colors-surface), 0.6); background-color: alpha(var(--theme-colors-surface), 0.6);
} }
.selected-option.disabled {
cursor: not-allowed;
}
.selected-option.disabled:hover {
background-color: transparent;
}
@@ -17,6 +17,7 @@ import { Text } from '/@/shared/components/text/text';
export type VirtualMultiSelectOption<T> = T & { label: string; value: string }; export type VirtualMultiSelectOption<T> = T & { label: string; value: string };
interface VirtualMultiSelectProps<T> { interface VirtualMultiSelectProps<T> {
disabled?: boolean;
displayCountType?: 'album' | 'song'; displayCountType?: 'album' | 'song';
height: number; height: number;
isLoading?: boolean; isLoading?: boolean;
@@ -25,6 +26,7 @@ interface VirtualMultiSelectProps<T> {
options: VirtualMultiSelectOption<T>[]; options: VirtualMultiSelectOption<T>[];
RowComponent: ( RowComponent: (
props: RowComponentProps<{ props: RowComponentProps<{
disabled?: boolean;
displayCountType?: 'album' | 'song'; displayCountType?: 'album' | 'song';
focusedIndex: null | number; focusedIndex: null | number;
onToggle: (value: string) => void; onToggle: (value: string) => void;
@@ -37,6 +39,7 @@ interface VirtualMultiSelectProps<T> {
} }
export function VirtualMultiSelect<T>({ export function VirtualMultiSelect<T>({
disabled = false,
displayCountType = 'album', displayCountType = 'album',
height, height,
isLoading = false, isLoading = false,
@@ -105,6 +108,7 @@ export function VirtualMultiSelect<T>({
const handleToggle = useCallback( const handleToggle = useCallback(
(optionValue: string) => { (optionValue: string) => {
if (disabled) return;
if (value.includes(optionValue)) { if (value.includes(optionValue)) {
const newValue = value.filter((v) => v !== optionValue); const newValue = value.filter((v) => v !== optionValue);
onChange(newValue.length > 0 ? newValue : null); onChange(newValue.length > 0 ? newValue : null);
@@ -112,15 +116,16 @@ export function VirtualMultiSelect<T>({
onChange(singleSelect ? [optionValue] : [...value, optionValue]); onChange(singleSelect ? [optionValue] : [...value, optionValue]);
} }
}, },
[onChange, singleSelect, value], [disabled, onChange, singleSelect, value],
); );
const handleDeselect = useCallback( const handleDeselect = useCallback(
(optionValue: string) => { (optionValue: string) => {
if (disabled) return;
const newValue = value.filter((v) => v !== optionValue); const newValue = value.filter((v) => v !== optionValue);
onChange(newValue.length > 0 ? newValue : null); onChange(newValue.length > 0 ? newValue : null);
}, },
[onChange, value], [disabled, onChange, value],
); );
const placeholder = useMemo( const placeholder = useMemo(
@@ -147,7 +152,7 @@ export function VirtualMultiSelect<T>({
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => { (e: React.KeyboardEvent<HTMLDivElement>) => {
if (stableOptions.length === 0) return; if (disabled || stableOptions.length === 0) return;
switch (e.key) { switch (e.key) {
case ' ': case ' ':
@@ -186,16 +191,17 @@ export function VirtualMultiSelect<T>({
break; break;
} }
}, },
[focusedIndex, handleToggle, scrollToIndex, stableOptions], [disabled, focusedIndex, handleToggle, scrollToIndex, stableOptions],
); );
return ( return (
<div className={styles.container}> <div className={`${styles.container} ${disabled ? styles.disabled : ''}`}>
<TextInput <TextInput
className={styles['search-input']} className={styles['search-input']}
disabled={disabled}
label={labelWithClear} label={labelWithClear}
leftSection={ leftSection={
value.length > 0 ? ( value.length > 0 && !disabled ? (
<ActionIcon <ActionIcon
icon="x" icon="x"
iconProps={{ size: 'md' }} iconProps={{ size: 'md' }}
@@ -208,11 +214,15 @@ export function VirtualMultiSelect<T>({
/> />
) : undefined ) : undefined
} }
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => {
if (!disabled) {
setSearch(e.currentTarget.value);
}
}}
placeholder={placeholder} placeholder={placeholder}
rightSection={ rightSection={
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
{search ? ( {search && !disabled ? (
<ActionIcon <ActionIcon
icon="x" icon="x"
iconProps={{ size: 'md' }} iconProps={{ size: 'md' }}
@@ -225,13 +235,19 @@ export function VirtualMultiSelect<T>({
)} )}
</Group> </Group>
} }
styles={{ label: { width: '100%' } }} styles={{
input: disabled ? { opacity: 0.6 } : undefined,
label: { width: '100%' },
section: disabled ? { opacity: 0.6 } : undefined,
wrapper: disabled ? { opacity: 0.6 } : undefined,
}}
value={search} value={search}
/> />
<div <div
className={styles['list-container']} className={styles['list-container']}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onMouseDown={(e) => { onMouseDown={(e) => {
if (disabled) return;
const element = e.currentTarget as HTMLDivElement; const element = e.currentTarget as HTMLDivElement;
if (element.focus) { if (element.focus) {
element.focus({ preventScroll: true }); element.focus({ preventScroll: true });
@@ -239,7 +255,7 @@ export function VirtualMultiSelect<T>({
}} }}
ref={listContainerRef} ref={listContainerRef}
style={{ height: `${height}px` }} style={{ height: `${height}px` }}
tabIndex={0} tabIndex={disabled ? -1 : 0}
> >
{isLoading ? ( {isLoading ? (
<Center h="100%"> <Center h="100%">
@@ -258,6 +274,7 @@ export function VirtualMultiSelect<T>({
rowCount={stableOptions.length} rowCount={stableOptions.length}
rowHeight={rowHeight} rowHeight={rowHeight}
rowProps={{ rowProps={{
disabled,
displayCountType, displayCountType,
focusedIndex, focusedIndex,
onToggle: handleToggle, onToggle: handleToggle,
@@ -271,19 +288,21 @@ export function VirtualMultiSelect<T>({
<Stack gap="xs" mt="sm"> <Stack gap="xs" mt="sm">
{selectedOptions.map((option) => ( {selectedOptions.map((option) => (
<Group <Group
className={styles['selected-option']} className={`${styles['selected-option']} ${disabled ? styles.disabled : ''}`}
gap="sm" gap="sm"
key={option.value} key={option.value}
onClick={() => handleDeselect(option.value)} onClick={() => handleDeselect(option.value)}
wrap="nowrap" wrap="nowrap"
> >
<ActionIcon {!disabled && (
icon="minus" <ActionIcon
iconProps={{ size: 'sm' }} icon="minus"
size="xs" iconProps={{ size: 'sm' }}
stopsPropagation size="xs"
variant="transparent" stopsPropagation
/> variant="transparent"
/>
)}
<Text isNoSelect overflow="hidden" size="sm"> <Text isNoSelect overflow="hidden" size="sm">
{option.label} {option.label}
</Text> </Text>