mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-03-11 21:27:05 +00:00
Merge pull request #664 from Stremio/feature-multiselect-level-menu
Feature: multiselect level menu
This commit is contained in:
commit
e7dbff46c9
13 changed files with 317 additions and 9 deletions
30
src/common/MultiselectMenu/Dropdown/Dropdown.less
Normal file
30
src/common/MultiselectMenu/Dropdown/Dropdown.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/common/MultiselectMenu/Dropdown/Dropdown.tsx
Normal file
55
src/common/MultiselectMenu/Dropdown/Dropdown.tsx
Normal 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;
|
||||
29
src/common/MultiselectMenu/Dropdown/Option/Option.less
Normal file
29
src/common/MultiselectMenu/Dropdown/Option/Option.less
Normal 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);
|
||||
}
|
||||
}
|
||||
46
src/common/MultiselectMenu/Dropdown/Option/Option.tsx
Normal file
46
src/common/MultiselectMenu/Dropdown/Option/Option.tsx
Normal 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;
|
||||
5
src/common/MultiselectMenu/Dropdown/Option/index.ts
Normal file
5
src/common/MultiselectMenu/Dropdown/Option/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import Option from './Option';
|
||||
|
||||
export default Option;
|
||||
5
src/common/MultiselectMenu/Dropdown/index.ts
Normal file
5
src/common/MultiselectMenu/Dropdown/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import Dropdown from './Dropdown';
|
||||
|
||||
export default Dropdown;
|
||||
39
src/common/MultiselectMenu/MultiselectMenu.less
Normal file
39
src/common/MultiselectMenu/MultiselectMenu.less
Normal 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);
|
||||
}
|
||||
}
|
||||
57
src/common/MultiselectMenu/MultiselectMenu.tsx
Normal file
57
src/common/MultiselectMenu/MultiselectMenu.tsx
Normal 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;
|
||||
5
src/common/MultiselectMenu/index.ts
Normal file
5
src/common/MultiselectMenu/index.ts
Normal 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
9
src/common/MultiselectMenu/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
type MultiselectMenuOption = {
|
||||
id?: number;
|
||||
label: string;
|
||||
value: number;
|
||||
destination?: string;
|
||||
default?: boolean;
|
||||
hidden?: boolean;
|
||||
level?: MultiselectMenuOption[];
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
27
src/common/useOutsideClick.ts
Normal file
27
src/common/useOutsideClick.ts
Normal 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;
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue