Merge branch 'discover-filters' into inputs-modal

This commit is contained in:
svetlagasheva 2020-02-14 11:38:52 +02:00
commit fc94063d8f
8 changed files with 188 additions and 136 deletions

View file

@ -1,15 +1,15 @@
const React = require('react'); const React = require('react');
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const classnames = require('classnames'); const classnames = require('classnames');
const { useModalsContainer } = require('stremio/router');
const Button = require('stremio/common/Button'); const Button = require('stremio/common/Button');
const Icon = require('stremio-icons/dom'); const Icon = require('stremio-icons/dom');
const { Modal } = require('stremio-router'); const { Modal } = require('stremio-router');
const styles = require('./styles'); const styles = require('./styles');
const ModalDialog = ({ className, title, buttons, children, dataset, onCloseRequest, ...props }) => { const ModalDialog = ({ className, title, buttons, children, dataset, onCloseRequest, ...props }) => {
const onModalDialogContainerMouseDown = React.useCallback((event) => { const modalContainerRef = React.useRef(null);
event.nativeEvent.closeModalDialogPrevented = true; const modalsContainer = useModalsContainer();
}, []);
const closeButtonOnClick = React.useCallback((event) => { const closeButtonOnClick = React.useCallback((event) => {
if (typeof onCloseRequest === 'function') { if (typeof onCloseRequest === 'function') {
onCloseRequest({ onCloseRequest({
@ -20,42 +20,50 @@ const ModalDialog = ({ className, title, buttons, children, dataset, onCloseRequ
}); });
} }
}, [dataset, onCloseRequest]); }, [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(() => { React.useEffect(() => {
const onCloseEvent = (event) => { const onWindowResize = (event) => {
if (!event.closeModalDialogPrevented && typeof onCloseRequest === 'function') { if (typeof onCloseRequest === 'function') {
const closeEvent = { onCloseRequest({
type: 'close', type: 'close',
dataset: dataset, dataset: dataset,
nativeEvent: event nativeEvent: event
}; });
switch (event.type) { }
case 'resize': };
onCloseRequest(closeEvent); const onWindowKeyDown = (event) => {
break; // its `-2` because focus lock render locking divs around its content
case 'keydown': if (modalsContainer.childNodes[modalsContainer.childElementCount - 2] === modalContainerRef.current) {
if (event.key === 'Escape') { if (typeof onCloseRequest === 'function') {
onCloseRequest(closeEvent); onCloseRequest({
} type: 'close',
break; dataset: dataset,
case 'mousedown': nativeEvent: event
if (event.target !== document.documentElement) { });
onCloseRequest(closeEvent);
}
break;
} }
} }
}; };
window.addEventListener('resize', onCloseEvent); window.addEventListener('resize', onWindowResize);
window.addEventListener('keydown', onCloseEvent); window.addEventListener('keydown', onWindowKeyDown);
window.addEventListener('mousedown', onCloseEvent);
return () => { return () => {
window.removeEventListener('resize', onCloseEvent); window.removeEventListener('resize', onWindowResize);
window.removeEventListener('keydown', onCloseEvent); window.removeEventListener('keydown', onWindowKeyDown);
window.removeEventListener('mousedown', onCloseEvent);
}; };
}, [dataset, onCloseRequest]); }, [dataset, onCloseRequest]);
return ( 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['modal-dialog-container']} onMouseDown={onModalDialogContainerMouseDown}>
<div className={styles['header-container']}> <div className={styles['header-container']}>
{ {

View file

@ -8,7 +8,7 @@ const ModalDialog = require('stremio/common/ModalDialog');
const useBinaryState = require('stremio/common/useBinaryState'); const useBinaryState = require('stremio/common/useBinaryState');
const styles = require('./styles'); 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 [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
const options = React.useMemo(() => { const options = React.useMemo(() => {
return Array.isArray(props.options) ? return Array.isArray(props.options) ?
@ -26,19 +26,19 @@ const Multiselect = ({ className, direction, title, disabled, dataset, modalOpti
: :
[]; [];
}, [props.selected]); }, [props.selected]);
const popupLabelOnClick = React.useCallback((event) => { const labelOnClick = React.useCallback((event) => {
if (typeof props.onClick === 'function') { if (typeof props.onClick === 'function') {
props.onClick(event); props.onClick(event);
} }
if (!event.nativeEvent.togglePopupPrevented) { if (!event.nativeEvent.toggleMenuPrevented) {
toggleMenu(); toggleMenu();
} }
}, [props.onClick, toggleMenu]); }, [props.onClick, toggleMenu]);
const popupMenuOnClick = React.useCallback((event) => { const menuOnClick = React.useCallback((event) => {
event.nativeEvent.togglePopupPrevented = true; event.nativeEvent.toggleMenuPrevented = true;
}, []); }, []);
const popupMenuOnKeyDown = React.useCallback((event) => { const menuOnKeyDown = React.useCallback((event) => {
event.nativeEvent.buttonClickPrevented = true; event.nativeEvent.buttonClickPrevented = true;
}, []); }, []);
const optionOnClick = React.useCallback((event) => { const optionOnClick = React.useCallback((event) => {
@ -78,8 +78,8 @@ const Multiselect = ({ className, direction, title, disabled, dataset, modalOpti
mountedRef.current = true; mountedRef.current = true;
}, [menuOpen]); }, [menuOpen]);
const renderLabel = React.useCallback(({ children, className, ...props }) => ( const renderLabel = React.useMemo(() => ({ children, className, ...props }) => (
<Button {...props} className={classnames(className, styles['label-container'], { 'active': menuOpen })} title={title} disabled={disabled} onClick={popupLabelOnClick}> <Button {...props} className={classnames(className, styles['label-container'], { 'active': menuOpen })} title={title} disabled={disabled} onClick={labelOnClick}>
{ {
typeof renderLabelContent === 'function' ? typeof renderLabelContent === 'function' ?
renderLabelContent() renderLabelContent()
@ -107,67 +107,55 @@ const Multiselect = ({ className, direction, title, disabled, dataset, modalOpti
} }
{children} {children}
</Button> </Button>
)); ), [menuOpen, title, disabled, options, selected, labelOnClick, renderLabelContent, renderLabelText]);
const renderOptions = React.useCallback((options) => { const renderMenu = React.useMemo(() => () => (
return ( <div className={styles['menu-container']} onKeyDown={menuOnKeyDown} onClick={menuOnClick}>
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>
{ {
!modalOptions ? options.length > 0 ?
<Popup options.map(({ label, value }) => (
open={menuOpen} <Button key={value} className={classnames(styles['option-container'], { 'selected': selected.includes(value) })} title={typeof label === 'string' ? label : value} data-value={value} onClick={optionOnClick}>
direction={direction} <div className={styles['label']}>{typeof label === 'string' ? label : value}</div>
onCloseRequest={closeMenu} <Icon className={styles['icon']} icon={'ic_check'} />
renderLabel={(labelProps) => { </Button>
return renderLabel({ ))
...labelProps,
...props,
className: classnames(className, labelProps.className)
});
}}
renderMenu={() => (
<div className={styles['menu-container']} onKeyDown={popupMenuOnKeyDown} onClick={popupMenuOnClick}>
{renderOptions(options)}
</div>
)}
/>
: :
<React.Fragment> <div className={styles['no-options-container']}>
{renderLabel({ <div className={styles['label']}>No options available</div>
...props, </div>
className
})}
{
menuOpen ?
<ModalDialog title={title} onCloseRequest={closeMenu}>
<div className={classnames(styles['menu-container'], { 'modal': modalOptions })}>
{renderOptions(options)}
</div>
</ModalDialog>
:
null
}
</React.Fragment>
} }
</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 = { Multiselect.propTypes = {
className: PropTypes.string, className: PropTypes.string,
mode: PropTypes.oneOf(['popup', 'modal']),
direction: PropTypes.any, direction: PropTypes.any,
title: PropTypes.string, title: PropTypes.string,
options: PropTypes.arrayOf(PropTypes.shape({ options: PropTypes.arrayOf(PropTypes.shape({

View file

@ -40,33 +40,58 @@
} }
} }
.menu-container { .modal-container, .popup-menu-container {
&:global(.modal) { .menu-container {
.option-container { .option-container {
width: 15rem; display: flex;
flex-direction: row;
align-items: center;
padding: 1rem;
background-color: var(--color-backgroundlighter);
&:not(:last-child) { &:global(.selected) {
margin-bottom: 1rem; 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 { .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 { .label {
display: flex; flex-grow: 0;
flex-direction: row; flex-shrink: 1;
align-items: center; flex-basis: auto;
padding: 1rem; font-size: 1.2rem;
background-color: var(--color-backgroundlighter); text-align: center;
color: var(--color-surfacelighter);
&:global(.selected) {
background-color: var(--color-surfacedarker);
.icon {
display: block;
} }
} }

View file

@ -5,15 +5,17 @@ const classnames = require('classnames');
const FocusLock = require('react-focus-lock').default; const FocusLock = require('react-focus-lock').default;
const { useModalsContainer } = require('../ModalsContainerContext'); const { useModalsContainer } = require('../ModalsContainerContext');
const Modal = ({ className, autoFocus, disabled, children, ...props }) => { const Modal = React.forwardRef(({ className, autoFocus, disabled, children, ...props }, ref) => {
const modalsContainer = useModalsContainer(); const modalsContainer = useModalsContainer();
return ReactDOM.createPortal( 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} {children}
</FocusLock>, </FocusLock>,
modalsContainer modalsContainer
); );
}; });
Modal.displayName = 'Modal';
Modal.propTypes = { Modal.propTypes = {
className: PropTypes.string, className: PropTypes.string,

View file

@ -1,9 +1,11 @@
const { useRouteFocused } = require('./RouteFocusedContext'); const { useRouteFocused } = require('./RouteFocusedContext');
const { useModalsContainer } = require('./ModalsContainerContext');
const Modal = require('./Modal'); const Modal = require('./Modal');
const Router = require('./Router'); const Router = require('./Router');
module.exports = { module.exports = {
useRouteFocused, useRouteFocused,
useModalsContainer,
Modal, Modal,
Router Router
}; };

View file

@ -52,12 +52,16 @@ const Discover = ({ urlParams, queryParams }) => {
<MainNavBar className={styles['nav-bar']} route={'discover'} /> <MainNavBar className={styles['nav-bar']} route={'discover'} />
<div className={styles['discover-content']}> <div className={styles['discover-content']}>
<div className={styles['selectable-inputs-container']}> <div className={styles['selectable-inputs-container']}>
{selectInputs.map((selectInput, index) => ( {selectInputs.map(({ title, options, selected, renderLabelText, onSelect }, index) => (
<Multiselect <Multiselect
{...selectInput}
key={index} key={index}
modalSelects={false} modalSelects={false}
className={styles['select-input-container']} 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}> <Button className={styles['filter-container']} title={'More filters'} onClick={openInputsModal}>
@ -144,13 +148,23 @@ const Discover = ({ urlParams, queryParams }) => {
</div> </div>
{ {
inputsModalOpen ? inputsModalOpen ?
<ModalDialog title={'Select inputs'} className={styles['selectable-inputs-modal-container']} onCloseRequest={closeInputsModal}> <ModalDialog title={'Catalog filters'} className={styles['selectable-inputs-modal-container']} onCloseRequest={closeInputsModal}>
{selectInputs.map((selectInput, index) => ( {selectInputs.map(({ title, isRequired, options, selected, renderLabelText, onSelect }, index) => (
<Multiselect <div key={index} className={styles['selectable-inputs-container']}>
{...selectInput} <div className={styles['select-input-label-container']} title={title}>
key={index} {title}
modalSelects={true} {isRequired ? '*' : null}
/> </div>
<Multiselect
className={styles['select-input-container']}
mode={'modal'}
title={title}
options={options}
selected={selected}
renderLabelText={renderLabelText}
onSelect={onSelect}
/>
</div>
))} ))}
</ModalDialog> </ModalDialog>
: :

View file

@ -5,6 +5,7 @@
label-container-label: label; label-container-label: label;
label-container-icon: icon; label-container-icon: icon;
multiselect-menu-container: menu-container; multiselect-menu-container: menu-container;
multiselect-modal-container: modal-container;
} }
:import('~stremio/common/PaginationInput/styles.less') { :import('~stremio/common/PaginationInput/styles.less') {
@ -176,25 +177,36 @@
} }
.selectable-inputs-modal-container { .selectable-inputs-modal-container {
.multiselect-label-container { .selectable-inputs-container {
width: 15rem; display: flex;
height: 3rem; flex-direction: row;
align-items: center;
&:not(:last-child) { &:not(:last-child) {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
&:hover { .select-input-label-container {
background-color: var(--color-surfacelight); flex: none;
width: 10rem;
.label-container-label { max-height: 2.4em;
color: var(--color-backgrounddarker); padding-right: 0.5rem;
} color: var(--color-backgrounddarker);
.label-container-icon {
fill: 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;
} }
} }

View file

@ -110,6 +110,7 @@ const mapSelectableInputs = (discover) => {
}; };
const extraSelects = discover.selectable.extra.map((extra) => { const extraSelects = discover.selectable.extra.map((extra) => {
const title = `Select ${extra.name}`; const title = `Select ${extra.name}`;
const isRequired = extra.isRequired;
const options = (extra.isRequired ? [] : [NONE_EXTRA_VALUE]) const options = (extra.isRequired ? [] : [NONE_EXTRA_VALUE])
.concat(extra.options) .concat(extra.options)
.map((option) => ({ .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 ? const paginationInput = discover.selectable.has_prev_page || discover.selectable.has_next_page ?
{ {