mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-04-19 18:02:13 +00:00
Merge pull request #122 from Stremio/inputs-modal
Discover selects modal
This commit is contained in:
commit
8a51046b37
8 changed files with 221 additions and 139 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,39 @@ 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 = {
|
||||
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 (event.key === 'Escape' && 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('keydown', onWindowKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onCloseEvent);
|
||||
window.removeEventListener('keydown', onCloseEvent);
|
||||
window.removeEventListener('mousedown', onCloseEvent);
|
||||
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']}>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ const classnames = require('classnames');
|
|||
const Icon = require('stremio-icons/dom');
|
||||
const Button = require('stremio/common/Button');
|
||||
const Popup = require('stremio/common/Popup');
|
||||
const ModalDialog = require('stremio/common/ModalDialog');
|
||||
const useBinaryState = require('stremio/common/useBinaryState');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Multiselect = ({ className, direction, title, disabled, dataset, 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) ?
|
||||
|
|
@ -25,19 +26,19 @@ const Multiselect = ({ className, direction, title, disabled, dataset, renderLab
|
|||
:
|
||||
[];
|
||||
}, [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) => {
|
||||
|
|
@ -77,64 +78,84 @@ const Multiselect = ({ className, direction, title, disabled, dataset, renderLab
|
|||
|
||||
mountedRef.current = true;
|
||||
}, [menuOpen]);
|
||||
return (
|
||||
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()
|
||||
:
|
||||
<React.Fragment>
|
||||
<div className={styles['label']}>
|
||||
{
|
||||
typeof renderLabelText === 'function' ?
|
||||
renderLabelText()
|
||||
:
|
||||
selected.length > 0 ?
|
||||
options.reduce((labels, { label, value }) => {
|
||||
if (selected.includes(value)) {
|
||||
labels.push(typeof label === 'string' ? label : value);
|
||||
}
|
||||
|
||||
return labels;
|
||||
}, []).join(', ')
|
||||
:
|
||||
title
|
||||
}
|
||||
</div>
|
||||
<Icon className={styles['icon']} icon={'ic_arrow_down'} />
|
||||
</React.Fragment>
|
||||
}
|
||||
{children}
|
||||
</Button>
|
||||
), [menuOpen, title, disabled, options, selected, labelOnClick, renderLabelContent, renderLabelText]);
|
||||
const renderMenu = React.useMemo(() => () => (
|
||||
<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>
|
||||
}
|
||||
</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={({ children, ...labelProps }) => (
|
||||
<Button {...labelProps} {...props} className={classnames(className, labelProps.className, styles['label-container'], { 'active': menuOpen })} title={title} disabled={disabled} onClick={popupLabelOnClick}>
|
||||
{
|
||||
typeof renderLabelContent === 'function' ?
|
||||
renderLabelContent()
|
||||
:
|
||||
<React.Fragment>
|
||||
<div className={styles['label']}>
|
||||
{
|
||||
typeof renderLabelText === 'function' ?
|
||||
renderLabelText()
|
||||
:
|
||||
selected.length > 0 ?
|
||||
options.reduce((labels, { label, value }) => {
|
||||
if (selected.includes(value)) {
|
||||
labels.push(typeof label === 'string' ? label : value);
|
||||
}
|
||||
|
||||
return labels;
|
||||
}, []).join(', ')
|
||||
:
|
||||
title
|
||||
}
|
||||
</div>
|
||||
<Icon className={styles['icon']} icon={'ic_arrow_down'} />
|
||||
</React.Fragment>
|
||||
}
|
||||
{children}
|
||||
</Button>
|
||||
)}
|
||||
renderMenu={() => (
|
||||
<div className={styles['menu-container']} onKeyDown={popupMenuOnKeyDown} onClick={popupMenuOnClick}>
|
||||
{
|
||||
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>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -37,59 +37,61 @@
|
|||
|
||||
.popup-menu-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
.option-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
.modal-container, .popup-menu-container {
|
||||
.menu-container {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--color-surfacedark);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
max-height: 4.8em;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
&:global(.selected) {
|
||||
background-color: var(--color-surfacedarker);
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
display: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-left: 1rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.no-options-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
&:hover, &:focus {
|
||||
background-color: var(--color-surfacedark);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
.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,11 +52,15 @@ 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}
|
||||
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}>
|
||||
|
|
@ -143,7 +147,25 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
</div>
|
||||
{
|
||||
inputsModalOpen ?
|
||||
<ModalDialog onCloseRequest={closeInputsModal} />
|
||||
<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>
|
||||
:
|
||||
null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
:import('~stremio/common/Multiselect/styles.less') {
|
||||
multiselect-menu-container: menu-container;
|
||||
multiselect-modal-container: modal-container;
|
||||
}
|
||||
|
||||
:import('~stremio/common/PaginationInput/styles.less') {
|
||||
|
|
@ -172,6 +173,40 @@
|
|||
}
|
||||
}
|
||||
|
||||
.selectable-inputs-modal-container {
|
||||
.selectable-inputs-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: @xxlarge) {
|
||||
.discover-container {
|
||||
.discover-content {
|
||||
|
|
|
|||
|
|
@ -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