Merge pull request #664 from Stremio/feature-multiselect-level-menu

Feature: multiselect level menu
This commit is contained in:
Alexandru Branza 2024-09-18 18:08:55 +03:00 committed by GitHub
commit e7dbff46c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 317 additions and 9 deletions

View file

@ -0,0 +1,30 @@
// Copyright (C) 2017-2024 Smart code 203358507
.dropdown {
background: var(--modal-background-color);
display: none;
position: absolute;
width: 100%;
top: 100%;
left: 0;
z-index: 10;
box-shadow: var(--outer-glow);
border-radius: var(--border-radius);
overflow: hidden;
&.open {
display: block;
}
.back-button {
display: flex;
align-items: center;
gap: 0 0.5rem;
padding: 0.75rem;
color: var(--primary-foreground-color);
.back-button-icon {
width: 1.5rem;
}
}
}

View file

@ -0,0 +1,55 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React from 'react';
import Button from 'stremio/common/Button';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import Option from './Option';
import Icon from '@stremio/stremio-icons/react';
import styles from './Dropdown.less';
type Props = {
options: MultiselectMenuOption[];
selectedOption?: MultiselectMenuOption | null;
menuOpen: boolean | (() => void);
level: number;
setLevel: (level: number) => void;
onSelect: (value: number) => void;
};
const Dropdown = ({ level, setLevel, options, onSelect, selectedOption, menuOpen }: Props) => {
const { t } = useTranslation();
const onBackButtonClick = () => {
setLevel(level - 1);
};
return (
<div className={classNames(styles['dropdown'], { [styles['open']]: menuOpen })} role={'listbox'}>
{
level > 0 ?
<Button className={styles['back-button']} onClick={onBackButtonClick}>
<Icon name={'caret-left'} className={styles['back-button-icon']} />
{t('BACK')}
</Button>
: null
}
{
options
.filter((option: MultiselectMenuOption) => !option.hidden)
.map((option: MultiselectMenuOption, index) => (
<Option
key={index}
option={option}
onSelect={onSelect}
selectedOption={selectedOption}
/>
))
}
</div>
);
};
export default Dropdown;

View file

@ -0,0 +1,29 @@
// Copyright (C) 2017-2024 Smart code 203358507
.option {
font-size: var(--font-size-normal);
color: var(--primary-foreground-color);
align-items: center;
display: flex;
flex-direction: row;
padding: 1rem;
.label {
flex: 1;
color: var(--primary-foreground-color);
}
.icon {
flex: none;
width: 0.5rem;
height: 0.5rem;
border-radius: 100%;
margin-left: 1rem;
background-color: var(--secondary-accent-color);
opacity: 1;
}
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
}

View file

@ -0,0 +1,46 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { useCallback, useMemo } from 'react';
import classNames from 'classnames';
import Button from 'stremio/common/Button';
import styles from './Option.less';
import Icon from '@stremio/stremio-icons/react';
type Props = {
option: MultiselectMenuOption;
selectedOption?: MultiselectMenuOption | null;
onSelect: (value: number) => void;
};
const Option = ({ option, selectedOption, onSelect }: Props) => {
// consider using option.id === selectedOption?.id instead
const selected = useMemo(() => option?.value === selectedOption?.value, [option, selectedOption]);
const handleClick = useCallback(() => {
onSelect(option.value);
}, [onSelect, option.value]);
return (
<Button
className={classNames(styles['option'], { [styles['selected']]: selected })}
key={option.id}
onClick={handleClick}
aria-selected={selected}
>
<div className={styles['label']}>{ option.label }</div>
{
selected && !option.level ?
<div className={styles['icon']} />
: null
}
{
option.level ?
<Icon name={'caret-right'} className={styles['option-caret']} />
: null
}
</Button>
);
};
export default Option;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2024 Smart code 203358507
import Option from './Option';
export default Option;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2024 Smart code 203358507
import Dropdown from './Dropdown';
export default Dropdown;

View file

@ -0,0 +1,39 @@
// Copyright (C) 2017-2024 Smart code 203358507
@border-radius: 2.75rem;
.multiselect-menu {
position: relative;
min-width: 8.5rem;
overflow: visible;
border-radius: @border-radius;
&.disabled {
pointer-events: none;
opacity: 0.3;
}
.multiselect-button {
color: var(--primary-foreground-color);
padding: 0.75rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0 0.5rem;
border-radius: @border-radius;
.icon {
width: 1rem;
color: var(--primary-foreground-color);
opacity: 0.6;
&.open {
transform: rotate(180deg);
}
}
}
&:hover {
background-color: var(--overlay-color);
}
}

View file

@ -0,0 +1,57 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React from 'react';
import Button from 'stremio/common/Button';
import useBinaryState from 'stremio/common/useBinaryState';
import Dropdown from './Dropdown';
import classNames from 'classnames';
import Icon from '@stremio/stremio-icons/react';
import styles from './MultiselectMenu.less';
import useOutsideClick from 'stremio/common/useOutsideClick';
type Props = {
className?: string,
title?: string;
options: MultiselectMenuOption[];
selectedOption?: MultiselectMenuOption;
onSelect: (value: number) => void;
};
const MultiselectMenu = ({ className, title, options, selectedOption, 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();
};
return (
<div className={classNames(styles['multiselect-menu'], className)} ref={multiselectMenuRef}>
<Button
className={classNames(styles['multiselect-button'], { [styles['open']]: menuOpen })}
onClick={toggleMenu}
tabIndex={0}
aria-haspopup='listbox'
aria-expanded={menuOpen}
>
{title}
<Icon name={'caret-down'} className={classNames(styles['icon'], { [styles['open']]: menuOpen })} />
</Button>
{
menuOpen ?
<Dropdown
level={level}
setLevel={setLevel}
options={options}
onSelect={onOptionSelect}
menuOpen={menuOpen}
selectedOption={selectedOption}
/>
: null
}
</div>
);
};
export default MultiselectMenu;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2024 Smart code 203358507
import MultiselectMenu from './MultiselectMenu';
export default MultiselectMenu;

9
src/common/MultiselectMenu/types.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
type MultiselectMenuOption = {
id?: number;
label: string;
value: number;
destination?: string;
default?: boolean;
hidden?: boolean;
level?: MultiselectMenuOption[];
};

View file

@ -15,6 +15,7 @@ const MetaPreview = require('./MetaPreview');
const MetaRow = require('./MetaRow');
const ModalDialog = require('./ModalDialog');
const Multiselect = require('./Multiselect');
const { default: MultiselectMenu } = require('./MultiselectMenu');
const { HorizontalNavBar, VerticalNavBar } = require('./NavBar');
const PaginationInput = require('./PaginationInput');
const PlayIconCircleCentered = require('./PlayIconCircleCentered');
@ -63,6 +64,7 @@ module.exports = {
MetaRow,
ModalDialog,
Multiselect,
MultiselectMenu,
HorizontalNavBar,
VerticalNavBar,
PaginationInput,

View file

@ -0,0 +1,27 @@
// Copyright (C) 2017-2024 Smart code 203358507
import { useEffect, useRef } from 'react';
const useOutsideClick = (callback: () => void) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};
document.addEventListener('mouseup', handleClickOutside);
document.addEventListener('touchend', handleClickOutside);
return () => {
document.removeEventListener('mouseup', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
};
}, [callback]);
return ref;
};
export default useOutsideClick;

View file

@ -5,9 +5,10 @@ const PropTypes = require('prop-types');
const classnames = require('classnames');
const { t } = require('i18next');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { Button, Multiselect } = require('stremio/common');
const { Button } = require('stremio/common');
const SeasonsBarPlaceholder = require('./SeasonsBarPlaceholder');
const styles = require('./styles');
const { MultiselectMenu } = require('stremio/common');
const SeasonsBar = ({ className, seasons, season, onSelect }) => {
const options = React.useMemo(() => {
@ -16,8 +17,8 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
label: season > 0 ? `${t('SEASON')} ${season}` : t('SPECIAL')
}));
}, [seasons]);
const selected = React.useMemo(() => {
return [String(season)];
const selectedSeason = React.useMemo(() => {
return { label: String(season), value: String(season) };
}, [season]);
const prevNextButtonOnClick = React.useCallback((event) => {
if (typeof onSelect === 'function') {
@ -35,8 +36,7 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
});
}
}, [season, seasons, onSelect]);
const seasonOnSelect = React.useCallback((event) => {
const value = parseFloat(event.value);
const seasonOnSelect = React.useCallback((value) => {
if (typeof onSelect === 'function') {
onSelect({
type: 'select',
@ -61,12 +61,11 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
<Icon className={styles['icon']} name={'chevron-back'} />
<div className={styles['label']}>Prev</div>
</Button>
<Multiselect
<MultiselectMenu
className={styles['seasons-popup-label-container']}
title={season > 0 ? `${t('SEASON')} ${season}` : t('SPECIAL')}
direction={'bottom-left'}
options={options}
selected={selected}
title={season > 0 ? `${t('SEASON')} ${season}` : t('SPECIAL')}
selectedOption={selectedSeason}
onSelect={seasonOnSelect}
/>
<Button className={classnames(styles['next-season-button'], { 'disabled': nextDisabled })} title={'Next season'} data-action={'next'} onClick={prevNextButtonOnClick}>