refactor(MultiselectMenu): use value only

This commit is contained in:
Timothy Z. 2025-06-03 12:34:14 +03:00
parent 718a64877c
commit fd4c9e73c8
13 changed files with 63 additions and 117 deletions

View file

@ -10,23 +10,25 @@ import styles from './Dropdown.less';
type Props = {
options: MultiselectMenuOption[];
selectedOption?: MultiselectMenuOption | null;
value?: string | number | null;
menuOpen: boolean | (() => void);
level: number;
setLevel: (level: number) => void;
onSelect: (value: number) => void;
onSelect: (value: string | number | null) => void;
};
const Dropdown = ({ level, setLevel, options, onSelect, selectedOption, menuOpen }: Props) => {
const Dropdown = ({ level, setLevel, options, onSelect, value, menuOpen }: Props) => {
const { t } = useTranslation();
const optionsRef = useRef(new Map());
const containerRef = useRef(null);
const handleSetOptionRef = useCallback((value: number) => (node: HTMLButtonElement | null) => {
const selectedOption = options.find(opt => opt.value === value) || null;
const handleSetOptionRef = useCallback((optionValue: string | number) => (node: HTMLButtonElement | null) => {
if (node) {
optionsRef.current.set(value, node);
optionsRef.current.set(optionValue, node);
} else {
optionsRef.current.delete(value);
optionsRef.current.delete(optionValue);
}
}, []);
@ -67,7 +69,7 @@ const Dropdown = ({ level, setLevel, options, onSelect, selectedOption, menuOpen
ref={handleSetOptionRef(option.value)}
option={option}
onSelect={onSelect}
selectedOption={selectedOption}
selectedValue={value}
/>
))
}

View file

@ -8,13 +8,12 @@ import Icon from '@stremio/stremio-icons/react';
type Props = {
option: MultiselectMenuOption;
selectedOption?: MultiselectMenuOption | null;
onSelect: (value: number) => void;
selectedValue?: string | number | null;
onSelect: (value: string | number | null) => void;
};
const Option = forwardRef<HTMLButtonElement, Props>(({ option, selectedOption, onSelect }, ref) => {
// consider using option.id === selectedOption?.id instead
const selected = useMemo(() => option?.value === selectedOption?.value, [option, selectedOption]);
const Option = forwardRef<HTMLButtonElement, Props>(({ option, selectedValue, onSelect }, ref) => {
const selected = useMemo(() => option?.value === selectedValue, [option, selectedValue]);
const handleClick = useCallback(() => {
onSelect(option.value);

View file

@ -14,7 +14,6 @@
}
.multiselect-button {
color: var(--primary-foreground-color);
padding: 0.75rem 1.5rem;
display: flex;
flex: 1;
@ -23,6 +22,13 @@
gap: 0 0.5rem;
border-radius: @border-radius;
.label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--primary-foreground-color);
}
.icon {
width: 1rem;
color: var(--primary-foreground-color);

View file

@ -13,17 +13,19 @@ type Props = {
className?: string,
title?: string | (() => string);
options: MultiselectMenuOption[];
selectedOption?: MultiselectMenuOption;
onSelect: (value: number) => void;
value?: string | number | null;
onSelect: (value: string | number | null) => void;
};
const MultiselectMenu = ({ className, title, options, selectedOption, onSelect }: Props) => {
const MultiselectMenu = ({ className, title, options, value, onSelect }: Props) => {
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
const multiselectMenuRef = useOutsideClick(() => closeMenu());
const [level, setLevel] = React.useState<number>(0);
const onOptionSelect = (value: number) => {
level ? setLevel(level + 1) : onSelect(value), closeMenu();
const selectedOption = options.find(opt => opt.value === value);
const onOptionSelect = (selectedValue: string | number | null) => {
level ? setLevel(level + 1) : onSelect(selectedValue), closeMenu();
};
return (
@ -35,11 +37,13 @@ const MultiselectMenu = ({ className, title, options, selectedOption, onSelect }
aria-haspopup='listbox'
aria-expanded={menuOpen}
>
<div className={styles['label']}>
{
typeof title === 'function'
? title()
: title ?? selectedOption?.label
}
</div>
<Icon name={'caret-down'} className={classNames(styles['icon'], { [styles['open']]: menuOpen })} />
</Button>
{
@ -50,7 +54,7 @@ const MultiselectMenu = ({ className, title, options, selectedOption, onSelect }
options={options}
onSelect={onOptionSelect}
menuOpen={menuOpen}
selectedOption={selectedOption}
value={value}
/>
: null
}

View file

@ -13,12 +13,7 @@ const mapSelectableInputs = (installedAddons, remoteAddons, t) => {
label: t.stringWithPrefix(name, 'ADDON_'),
title: t.stringWithPrefix(name, 'ADDON_'),
})),
selectedOption: selectedCatalog
? {
label: t.stringWithPrefix(selectedCatalog.name, 'ADDON_'),
value: selectedCatalog.deepLinks.addons,
}
: undefined,
value: selectedCatalog ? selectedCatalog.deepLinks.addons : undefined,
title: remoteAddons.selected !== null ?
() => {
const selectableCatalog = remoteAddons.selectable.catalogs
@ -44,12 +39,7 @@ const mapSelectableInputs = (installedAddons, remoteAddons, t) => {
value: deepLinks.addons,
label: t.stringWithPrefix(type, 'TYPE_')
})),
selectedOption: selectedType
? {
label: selectedType.type !== null ? t.stringWithPrefix(selectedType.type, 'TYPE_') : t.string('TYPE_ALL'),
value: selectedType.deepLinks.addons
}
: undefined,
value: selectedType ? selectedType.deepLinks.addons : undefined,
title: () => {
return installedAddons.selected !== null ?
installedAddons.selected.request.type === null ?

View file

@ -100,13 +100,13 @@ const Discover = ({ urlParams, queryParams }) => {
<div className={styles['discover-content']}>
<div className={styles['catalog-container']}>
<div className={styles['selectable-inputs-container']}>
{selectInputs.map(({ title, options, selectedOption, onSelect }, index) => (
{selectInputs.map(({ title, options, value, onSelect }, index) => (
<MultiselectMenu
key={index}
className={styles['select-input']}
title={title}
options={options}
selectedOption={selectedOption}
value={value}
onSelect={onSelect}
/>
))}
@ -202,13 +202,13 @@ const Discover = ({ urlParams, queryParams }) => {
{
inputsModalOpen ?
<ModalDialog title={'Catalog filters'} className={styles['selectable-inputs-modal']} onCloseRequest={closeInputsModal}>
{selectInputs.map(({ title, options, selectedOption, onSelect }, index) => (
{selectInputs.map(({ title, options, value, onSelect }, index) => (
<MultiselectMenu
key={index}
className={styles['select-input']}
title={title}
options={options}
selectedOption={selectedOption}
value={value}
onSelect={onSelect}
/>
))}

View file

@ -11,11 +11,8 @@ const mapSelectableInputs = (discover, t) => {
value: deepLinks.discover,
label: t.stringWithPrefix(type, 'TYPE_')
})),
selectedOption: selectedType
? {
label: t.stringWithPrefix(selectedType.type, 'TYPE_'),
value: selectedType.deepLinks.discover,
}
value: selectedType
? selectedType.deepLinks.discover
: undefined,
title: discover.selected !== null
? () => t.stringWithPrefix(discover.selected.request.path.type, 'TYPE_')
@ -32,11 +29,8 @@ const mapSelectableInputs = (discover, t) => {
label: t.catalogTitle({ addon, id, name }),
title: `${name} (${addon.manifest.name})`
})),
selectedOption: discover.selected?.request.path.id
? {
label: t.catalogTitle({ addon: selectedCatalog.addon, id: selectedCatalog.id, name: selectedCatalog.name }),
value: selectedCatalog.deepLinks.discover
}
value: discover.selected?.request.path.id
? selectedCatalog.deepLinks.discover
: undefined,
title: discover.selected !== null
? () => {
@ -61,13 +55,10 @@ const mapSelectableInputs = (discover, t) => {
value
})
})),
selectedOption: {
label: typeof selectedExtra.value === 'string' ? t.stringWithPrefix(selectedExtra.value) : t.string('NONE'),
value: JSON.stringify({
href: selectedExtra.deepLinks.discover,
value: selectedExtra.value,
})
},
value: JSON.stringify({
href: selectedExtra.deepLinks.discover,
value: selectedExtra.value,
}),
title: options.some(({ selected, value }) => selected && value === null) ?
() => t.stringWithPrefix(name, 'SELECT_')
: t.stringWithPrefix(selectedExtra.value),

View file

@ -64,10 +64,10 @@ const Library = ({ model, urlParams, queryParams }) => {
}
}, [profile.auth, library.selected]);
React.useEffect(() => {
if (!library.selected?.type && typeSelect.selectedOption) {
window.location = typeSelect.selectedOption.value;
if (!library.selected?.type && typeSelect.value) {
window.location = typeSelect.value;
}
}, [typeSelect.selectedOption, library.selected]);
}, [typeSelect.value, library.selected]);
return (
<MainNavBars className={styles['library-container']} route={model}>
{

View file

@ -10,10 +10,7 @@ const mapSelectableInputs = (library, t) => {
value: deepLinks.library,
label: type === null ? t.string('TYPE_ALL') : t.stringWithPrefix(type, 'TYPE_')
})),
selectedOption: {
label: selectedType?.type === null ? t.string('TYPE_ALL') : t.stringWithPrefix(selectedType?.type, 'TYPE_'),
value: selectedType?.deepLinks.library
},
value: selectedType?.deepLinks.library,
onSelect: (value) => {
window.location = value;
}

View file

@ -89,10 +89,7 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
title: streamsByAddon[transportUrl].addon.manifest.name,
}))
],
selectedOption: {
label: selectedAddon === ALL_ADDONS_KEY ? t('ALL_ADDONS') : streamsByAddon[selectedAddon]?.addon.manifest.name,
value: selectedAddon
},
value: selectedAddon,
onSelect: onAddonSelected
};
}, [streamsByAddon, selectedAddon]);

View file

@ -17,7 +17,7 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
}));
}, [seasons]);
const selectedSeason = React.useMemo(() => {
return { label: String(season), value: String(season) };
return String(season);
}, [season]);
const prevNextButtonOnClick = React.useCallback((event) => {
if (typeof onSelect === 'function') {
@ -64,7 +64,7 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
className={styles['seasons-popup-label-container']}
options={options}
title={season > 0 ? `${t('SEASON')} ${season}` : t('SPECIAL')}
selectedOption={selectedSeason}
value={selectedSeason}
onSelect={seasonOnSelect}
/>
<Button className={classnames(styles['next-season-button'], { 'disabled': nextDisabled })} title={'Next season'} data-action={'next'} onClick={prevNextButtonOnClick}>

View file

@ -15,10 +15,7 @@ const useProfileSettingsInputs = (profile) => {
value: codes[0],
label: name,
})),
selectedOption: {
label: interfaceLanguages.find(({ codes }) => codes[0] === profile.settings.interfaceLanguage || codes[1] === profile.settings.interfaceLanguage)?.name,
value: interfaceLanguages.find(({ codes }) => codes[1] === profile.settings.interfaceLanguage)?.codes?.[0] || profile.settings.interfaceLanguage
},
value: interfaceLanguages.find(({ codes }) => codes[1] === profile.settings.interfaceLanguage)?.codes?.[0] || profile.settings.interfaceLanguage,
onSelect: (value) => {
core.transport.dispatch({
action: 'Ctx',
@ -73,10 +70,7 @@ const useProfileSettingsInputs = (profile) => {
label: languageNames[code]
}))
],
selectedOption: {
label: languageNames[profile.settings.subtitlesLanguage],
value: profile.settings.subtitlesLanguage
},
value: profile.settings.subtitlesLanguage,
onSelect: (value) => {
core.transport.dispatch({
action: 'Ctx',
@ -95,10 +89,7 @@ const useProfileSettingsInputs = (profile) => {
value: `${size}`,
label: `${size}%`
})),
selectedOption: {
label: `${profile.settings.subtitlesSize}%`,
value: `${profile.settings.subtitlesSize}`
},
value: `${profile.settings.subtitlesSize}`,
title: () => {
return `${profile.settings.subtitlesSize}%`;
},
@ -165,10 +156,7 @@ const useProfileSettingsInputs = (profile) => {
value: code,
label: languageNames[code]
})),
selectedOption: {
label: languageNames[profile.settings.audioLanguage],
value: profile.settings.audioLanguage
},
value: profile.settings.audioLanguage,
onSelect: (value) => {
core.transport.dispatch({
action: 'Ctx',
@ -218,10 +206,7 @@ const useProfileSettingsInputs = (profile) => {
value: `${size}`,
label: `${size / 1000} ${t('SECONDS')}`
})),
selectedOption: {
label: `${profile.settings.seekTimeDuration / 1000} ${t('SECONDS')}`,
value: `${profile.settings.seekTimeDuration}`
},
value: `${profile.settings.seekTimeDuration}`,
title: () => {
return `${profile.settings.seekTimeDuration / 1000} ${t('SECONDS')}`;
},
@ -243,10 +228,7 @@ const useProfileSettingsInputs = (profile) => {
value: `${size}`,
label: `${size / 1000} ${t('SECONDS')}`
})),
selectedOption: {
label: `${profile.settings.seekShortTimeDuration / 1000} ${t('SECONDS')}`,
value: `${profile.settings.seekShortTimeDuration}`,
},
value: `${profile.settings.seekShortTimeDuration}`,
title: () => {
return `${profile.settings.seekShortTimeDuration / 1000} ${t('SECONDS')}`;
},
@ -270,10 +252,7 @@ const useProfileSettingsInputs = (profile) => {
value,
label: t(label),
})),
selectedOption: {
label: CONSTANTS.EXTERNAL_PLAYERS.find(({ value }) => value === profile.settings.playerType)?.label,
value: profile.settings.playerType
},
value: profile.settings.playerType,
title: () => {
const selectedOption = CONSTANTS.EXTERNAL_PLAYERS.find(({ value }) => value === profile.settings.playerType);
return selectedOption ? t(selectedOption.label, { defaultValue: selectedOption.label }) : profile.settings.playerType;
@ -296,12 +275,7 @@ const useProfileSettingsInputs = (profile) => {
value: `${duration}`,
label: duration === 0 ? 'Disabled' : `${duration / 1000} ${t('SECONDS')}`
})),
selectedOption: {
label: profile.settings.nextVideoNotificationDuration === 0
? 'Disabled'
: `${profile.settings.nextVideoNotificationDuration / 1000} ${t('SECONDS')}`,
value: `${profile.settings.nextVideoNotificationDuration}`
},
value: `${profile.settings.nextVideoNotificationDuration}`,
title: () => {
return profile.settings.nextVideoNotificationDuration === 0 ?
'Disabled'

View file

@ -77,10 +77,7 @@ const useStreamingServerSettingsInputs = (streamingServer) => {
value: address,
}))
],
selectedOption: {
label: streamingServer.settings.content.remoteHttps || t('SETTINGS_DISABLED'),
value: streamingServer.settings.content.remoteHttps
},
value: streamingServer.settings.content.remoteHttps,
onSelect: (value) => {
core.transport.dispatch({
action: 'StreamingServer',
@ -106,10 +103,7 @@ const useStreamingServerSettingsInputs = (streamingServer) => {
label: cacheSizeToString(size),
value: JSON.stringify(size)
})),
selectedOption: {
label: cacheSizeToString(streamingServer.settings.content.cacheSize),
value: JSON.stringify(streamingServer.settings.content.cacheSize)
},
value: JSON.stringify(streamingServer.settings.content.cacheSize),
title: () => {
return cacheSizeToString(streamingServer.settings.content.cacheSize);
},
@ -158,12 +152,7 @@ const useStreamingServerSettingsInputs = (streamingServer) => {
:
[]
),
selectedOption: {
label: isCustomTorrentProfileSelected
? 'custom'
: Object.keys(TORRENT_PROFILES).find((profileName) => JSON.stringify(TORRENT_PROFILES[profileName]) === JSON.stringify(selectedTorrentProfile)),
value: JSON.stringify(selectedTorrentProfile)
},
value: JSON.stringify(selectedTorrentProfile),
onSelect: (value) => {
core.transport.dispatch({
action: 'StreamingServer',
@ -194,10 +183,7 @@ const useStreamingServerSettingsInputs = (streamingServer) => {
value: name,
}))
],
selectedOption: {
label: streamingServer.settings.content.transcodeProfile || t('SETTINGS_DISABLED'),
value: streamingServer.settings.content.transcodeProfile
},
value: streamingServer.settings.content.transcodeProfile ?? null,
onSelect: (value) => {
core.transport.dispatch({
action: 'StreamingServer',