mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-03-11 21:27:05 +00:00
Merge branch 'discover-filters' into inputs-modal
This commit is contained in:
commit
fc94063d8f
8 changed files with 188 additions and 136 deletions
|
|
@ -1,15 +1,15 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { useModalsContainer } = require('stremio/router');
|
||||
const Button = require('stremio/common/Button');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Modal } = require('stremio-router');
|
||||
const styles = require('./styles');
|
||||
|
||||
const ModalDialog = ({ className, title, buttons, children, dataset, onCloseRequest, ...props }) => {
|
||||
const onModalDialogContainerMouseDown = React.useCallback((event) => {
|
||||
event.nativeEvent.closeModalDialogPrevented = true;
|
||||
}, []);
|
||||
const modalContainerRef = React.useRef(null);
|
||||
const modalsContainer = useModalsContainer();
|
||||
const closeButtonOnClick = React.useCallback((event) => {
|
||||
if (typeof onCloseRequest === 'function') {
|
||||
onCloseRequest({
|
||||
|
|
@ -20,42 +20,50 @@ const ModalDialog = ({ className, title, buttons, children, dataset, onCloseRequ
|
|||
});
|
||||
}
|
||||
}, [dataset, onCloseRequest]);
|
||||
const onModalContainerMouseDown = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.closeModalDialogPrevented && typeof onCloseRequest === 'function') {
|
||||
onCloseRequest({
|
||||
type: 'close',
|
||||
dataset: dataset,
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
}
|
||||
}, [dataset, onCloseRequest]);
|
||||
const onModalDialogContainerMouseDown = React.useCallback((event) => {
|
||||
event.nativeEvent.closeModalDialogPrevented = true;
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
const onCloseEvent = (event) => {
|
||||
if (!event.closeModalDialogPrevented && typeof onCloseRequest === 'function') {
|
||||
const closeEvent = {
|
||||
const onWindowResize = (event) => {
|
||||
if (typeof onCloseRequest === 'function') {
|
||||
onCloseRequest({
|
||||
type: 'close',
|
||||
dataset: dataset,
|
||||
nativeEvent: event
|
||||
};
|
||||
switch (event.type) {
|
||||
case 'resize':
|
||||
onCloseRequest(closeEvent);
|
||||
break;
|
||||
case 'keydown':
|
||||
if (event.key === 'Escape') {
|
||||
onCloseRequest(closeEvent);
|
||||
}
|
||||
break;
|
||||
case 'mousedown':
|
||||
if (event.target !== document.documentElement) {
|
||||
onCloseRequest(closeEvent);
|
||||
}
|
||||
break;
|
||||
});
|
||||
}
|
||||
};
|
||||
const onWindowKeyDown = (event) => {
|
||||
// its `-2` because focus lock render locking divs around its content
|
||||
if (modalsContainer.childNodes[modalsContainer.childElementCount - 2] === modalContainerRef.current) {
|
||||
if (typeof onCloseRequest === 'function') {
|
||||
onCloseRequest({
|
||||
type: 'close',
|
||||
dataset: dataset,
|
||||
nativeEvent: event
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', onCloseEvent);
|
||||
window.addEventListener('keydown', onCloseEvent);
|
||||
window.addEventListener('mousedown', onCloseEvent);
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
window.addEventListener('keydown', onWindowKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onCloseEvent);
|
||||
window.removeEventListener('keydown', onCloseEvent);
|
||||
window.removeEventListener('mousedown', onCloseEvent);
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
window.removeEventListener('keydown', onWindowKeyDown);
|
||||
};
|
||||
}, [dataset, onCloseRequest]);
|
||||
return (
|
||||
<Modal {...props} className={classnames(className, styles['modal-container'])}>
|
||||
<Modal ref={modalContainerRef} {...props} className={classnames(className, styles['modal-container'])} onMouseDown={onModalContainerMouseDown}>
|
||||
<div className={styles['modal-dialog-container']} onMouseDown={onModalDialogContainerMouseDown}>
|
||||
<div className={styles['header-container']}>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const ModalDialog = require('stremio/common/ModalDialog');
|
|||
const useBinaryState = require('stremio/common/useBinaryState');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Multiselect = ({ className, direction, title, disabled, dataset, modalOptions, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => {
|
||||
const Multiselect = ({ className, mode, direction, title, disabled, dataset, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => {
|
||||
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
||||
const options = React.useMemo(() => {
|
||||
return Array.isArray(props.options) ?
|
||||
|
|
@ -26,19 +26,19 @@ const Multiselect = ({ className, direction, title, disabled, dataset, modalOpti
|
|||
:
|
||||
[];
|
||||
}, [props.selected]);
|
||||
const popupLabelOnClick = React.useCallback((event) => {
|
||||
const labelOnClick = React.useCallback((event) => {
|
||||
if (typeof props.onClick === 'function') {
|
||||
props.onClick(event);
|
||||
}
|
||||
|
||||
if (!event.nativeEvent.togglePopupPrevented) {
|
||||
if (!event.nativeEvent.toggleMenuPrevented) {
|
||||
toggleMenu();
|
||||
}
|
||||
}, [props.onClick, toggleMenu]);
|
||||
const popupMenuOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.togglePopupPrevented = true;
|
||||
const menuOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.toggleMenuPrevented = true;
|
||||
}, []);
|
||||
const popupMenuOnKeyDown = React.useCallback((event) => {
|
||||
const menuOnKeyDown = React.useCallback((event) => {
|
||||
event.nativeEvent.buttonClickPrevented = true;
|
||||
}, []);
|
||||
const optionOnClick = React.useCallback((event) => {
|
||||
|
|
@ -78,8 +78,8 @@ const Multiselect = ({ className, direction, title, disabled, dataset, modalOpti
|
|||
|
||||
mountedRef.current = true;
|
||||
}, [menuOpen]);
|
||||
const renderLabel = React.useCallback(({ children, className, ...props }) => (
|
||||
<Button {...props} className={classnames(className, styles['label-container'], { 'active': menuOpen })} title={title} disabled={disabled} onClick={popupLabelOnClick}>
|
||||
const renderLabel = React.useMemo(() => ({ children, className, ...props }) => (
|
||||
<Button {...props} className={classnames(className, styles['label-container'], { 'active': menuOpen })} title={title} disabled={disabled} onClick={labelOnClick}>
|
||||
{
|
||||
typeof renderLabelContent === 'function' ?
|
||||
renderLabelContent()
|
||||
|
|
@ -107,67 +107,55 @@ const Multiselect = ({ className, direction, title, disabled, dataset, modalOpti
|
|||
}
|
||||
{children}
|
||||
</Button>
|
||||
));
|
||||
const renderOptions = React.useCallback((options) => {
|
||||
return (
|
||||
options.length > 0 ?
|
||||
options.map(({ label, value }) => (
|
||||
<Button key={value} className={classnames(styles['option-container'], { 'selected': selected.includes(value) })} title={typeof label === 'string' ? label : value} data-value={value} onClick={optionOnClick}>
|
||||
<div className={styles['label']}>{typeof label === 'string' ? label : value}</div>
|
||||
<Icon className={styles['icon']} icon={'ic_check'} />
|
||||
</Button>
|
||||
))
|
||||
:
|
||||
<div className={styles['no-options-container']}>
|
||||
<div className={styles['label']}>No options available</div>
|
||||
</div>
|
||||
);
|
||||
}, [options]);
|
||||
return (
|
||||
<React.Fragment>
|
||||
), [menuOpen, title, disabled, options, selected, labelOnClick, renderLabelContent, renderLabelText]);
|
||||
const renderMenu = React.useMemo(() => () => (
|
||||
<div className={styles['menu-container']} onKeyDown={menuOnKeyDown} onClick={menuOnClick}>
|
||||
{
|
||||
!modalOptions ?
|
||||
<Popup
|
||||
open={menuOpen}
|
||||
direction={direction}
|
||||
onCloseRequest={closeMenu}
|
||||
renderLabel={(labelProps) => {
|
||||
return renderLabel({
|
||||
...labelProps,
|
||||
...props,
|
||||
className: classnames(className, labelProps.className)
|
||||
});
|
||||
}}
|
||||
renderMenu={() => (
|
||||
<div className={styles['menu-container']} onKeyDown={popupMenuOnKeyDown} onClick={popupMenuOnClick}>
|
||||
{renderOptions(options)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
options.length > 0 ?
|
||||
options.map(({ label, value }) => (
|
||||
<Button key={value} className={classnames(styles['option-container'], { 'selected': selected.includes(value) })} title={typeof label === 'string' ? label : value} data-value={value} onClick={optionOnClick}>
|
||||
<div className={styles['label']}>{typeof label === 'string' ? label : value}</div>
|
||||
<Icon className={styles['icon']} icon={'ic_check'} />
|
||||
</Button>
|
||||
))
|
||||
:
|
||||
<React.Fragment>
|
||||
{renderLabel({
|
||||
...props,
|
||||
className
|
||||
})}
|
||||
{
|
||||
menuOpen ?
|
||||
<ModalDialog title={title} onCloseRequest={closeMenu}>
|
||||
<div className={classnames(styles['menu-container'], { 'modal': modalOptions })}>
|
||||
{renderOptions(options)}
|
||||
</div>
|
||||
</ModalDialog>
|
||||
:
|
||||
null
|
||||
}
|
||||
</React.Fragment>
|
||||
<div className={styles['no-options-container']}>
|
||||
<div className={styles['label']}>No options available</div>
|
||||
</div>
|
||||
}
|
||||
</React.Fragment>
|
||||
);
|
||||
</div>
|
||||
), [options, selected, menuOnKeyDown, menuOnClick, optionOnClick]);
|
||||
const renderPopupLabel = React.useMemo(() => (labelProps) => {
|
||||
return renderLabel({
|
||||
...labelProps,
|
||||
...props,
|
||||
className: classnames(className, labelProps.className)
|
||||
});
|
||||
}, [props, className, renderLabel]);
|
||||
return mode === 'modal' ?
|
||||
renderLabel({
|
||||
...props,
|
||||
className,
|
||||
children: menuOpen ?
|
||||
<ModalDialog className={styles['modal-container']} title={title} onCloseRequest={closeMenu} onKeyDown={menuOnKeyDown} onClick={menuOnClick}>
|
||||
{renderMenu()}
|
||||
</ModalDialog>
|
||||
:
|
||||
null
|
||||
})
|
||||
:
|
||||
<Popup
|
||||
open={menuOpen}
|
||||
direction={direction}
|
||||
onCloseRequest={closeMenu}
|
||||
renderLabel={renderPopupLabel}
|
||||
renderMenu={renderMenu}
|
||||
/>;
|
||||
};
|
||||
|
||||
Multiselect.propTypes = {
|
||||
className: PropTypes.string,
|
||||
mode: PropTypes.oneOf(['popup', 'modal']),
|
||||
direction: PropTypes.any,
|
||||
title: PropTypes.string,
|
||||
options: PropTypes.arrayOf(PropTypes.shape({
|
||||
|
|
|
|||
|
|
@ -40,33 +40,58 @@
|
|||
}
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
&:global(.modal) {
|
||||
.modal-container, .popup-menu-container {
|
||||
.menu-container {
|
||||
.option-container {
|
||||
width: 15rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 1rem;
|
||||
&:global(.selected) {
|
||||
background-color: var(--color-surfacedarker);
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--color-surfacedark);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
max-height: 4.8em;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
display: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-left: 1rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
.no-options-container {
|
||||
width: 15rem;
|
||||
}
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
.option-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
&:global(.selected) {
|
||||
background-color: var(--color-surfacedarker);
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
.label {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,15 +5,17 @@ const classnames = require('classnames');
|
|||
const FocusLock = require('react-focus-lock').default;
|
||||
const { useModalsContainer } = require('../ModalsContainerContext');
|
||||
|
||||
const Modal = ({ className, autoFocus, disabled, children, ...props }) => {
|
||||
const Modal = React.forwardRef(({ className, autoFocus, disabled, children, ...props }, ref) => {
|
||||
const modalsContainer = useModalsContainer();
|
||||
return ReactDOM.createPortal(
|
||||
<FocusLock className={classnames(className, 'modal-container')} autoFocus={!!autoFocus} disabled={!!disabled} lockProps={props}>
|
||||
<FocusLock ref={ref} className={classnames(className, 'modal-container')} autoFocus={!!autoFocus} disabled={!!disabled} lockProps={props}>
|
||||
{children}
|
||||
</FocusLock>,
|
||||
modalsContainer
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
Modal.displayName = 'Modal';
|
||||
|
||||
Modal.propTypes = {
|
||||
className: PropTypes.string,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
const { useRouteFocused } = require('./RouteFocusedContext');
|
||||
const { useModalsContainer } = require('./ModalsContainerContext');
|
||||
const Modal = require('./Modal');
|
||||
const Router = require('./Router');
|
||||
|
||||
module.exports = {
|
||||
useRouteFocused,
|
||||
useModalsContainer,
|
||||
Modal,
|
||||
Router
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,12 +52,16 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
<MainNavBar className={styles['nav-bar']} route={'discover'} />
|
||||
<div className={styles['discover-content']}>
|
||||
<div className={styles['selectable-inputs-container']}>
|
||||
{selectInputs.map((selectInput, index) => (
|
||||
{selectInputs.map(({ title, options, selected, renderLabelText, onSelect }, index) => (
|
||||
<Multiselect
|
||||
{...selectInput}
|
||||
key={index}
|
||||
modalSelects={false}
|
||||
className={styles['select-input-container']}
|
||||
title={title}
|
||||
options={options}
|
||||
selected={selected}
|
||||
renderLabelText={renderLabelText}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
<Button className={styles['filter-container']} title={'More filters'} onClick={openInputsModal}>
|
||||
|
|
@ -144,13 +148,23 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
</div>
|
||||
{
|
||||
inputsModalOpen ?
|
||||
<ModalDialog title={'Select inputs'} className={styles['selectable-inputs-modal-container']} onCloseRequest={closeInputsModal}>
|
||||
{selectInputs.map((selectInput, index) => (
|
||||
<Multiselect
|
||||
{...selectInput}
|
||||
key={index}
|
||||
modalSelects={true}
|
||||
/>
|
||||
<ModalDialog title={'Catalog filters'} className={styles['selectable-inputs-modal-container']} onCloseRequest={closeInputsModal}>
|
||||
{selectInputs.map(({ title, isRequired, options, selected, renderLabelText, onSelect }, index) => (
|
||||
<div key={index} className={styles['selectable-inputs-container']}>
|
||||
<div className={styles['select-input-label-container']} title={title}>
|
||||
{title}
|
||||
{isRequired ? '*' : null}
|
||||
</div>
|
||||
<Multiselect
|
||||
className={styles['select-input-container']}
|
||||
mode={'modal'}
|
||||
title={title}
|
||||
options={options}
|
||||
selected={selected}
|
||||
renderLabelText={renderLabelText}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ModalDialog>
|
||||
:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
label-container-label: label;
|
||||
label-container-icon: icon;
|
||||
multiselect-menu-container: menu-container;
|
||||
multiselect-modal-container: modal-container;
|
||||
}
|
||||
|
||||
:import('~stremio/common/PaginationInput/styles.less') {
|
||||
|
|
@ -176,25 +177,36 @@
|
|||
}
|
||||
|
||||
.selectable-inputs-modal-container {
|
||||
.multiselect-label-container {
|
||||
width: 15rem;
|
||||
height: 3rem;
|
||||
.selectable-inputs-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surfacelight);
|
||||
|
||||
.label-container-label {
|
||||
color: var(--color-backgrounddarker);
|
||||
}
|
||||
|
||||
.label-container-icon {
|
||||
fill: var(--color-backgrounddarker);
|
||||
}
|
||||
.select-input-label-container {
|
||||
flex: none;
|
||||
width: 10rem;
|
||||
max-height: 2.4em;
|
||||
padding-right: 0.5rem;
|
||||
color: var(--color-backgrounddarker);
|
||||
}
|
||||
|
||||
.select-input-container {
|
||||
flex: none;
|
||||
width: 15rem;
|
||||
height: 3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect-modal-container {
|
||||
.multiselect-menu-container {
|
||||
width: 15rem;
|
||||
max-height: calc(3.2rem * 7);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ const mapSelectableInputs = (discover) => {
|
|||
};
|
||||
const extraSelects = discover.selectable.extra.map((extra) => {
|
||||
const title = `Select ${extra.name}`;
|
||||
const isRequired = extra.isRequired;
|
||||
const options = (extra.isRequired ? [] : [NONE_EXTRA_VALUE])
|
||||
.concat(extra.options)
|
||||
.map((option) => ({
|
||||
|
|
@ -141,7 +142,7 @@ const mapSelectableInputs = (discover) => {
|
|||
}
|
||||
});
|
||||
};
|
||||
return { title, options, selected, renderLabelText, onSelect };
|
||||
return { title, isRequired, options, selected, renderLabelText, onSelect };
|
||||
});
|
||||
const paginationInput = discover.selectable.has_prev_page || discover.selectable.has_next_page ?
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue