Merge pull request #118 from Stremio/toasts-refactor

Toasts refactor
This commit is contained in:
Nikola Hristov 2020-02-01 11:15:58 +02:00 committed by GitHub
commit be144496e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 221 additions and 191 deletions

View file

@ -2,7 +2,7 @@ require('spatial-navigation-polyfill');
const React = require('react'); const React = require('react');
const { Router } = require('stremio-router'); const { Router } = require('stremio-router');
const { Core, KeyboardNavigation, ServicesProvider, Shell } = require('stremio/services'); const { Core, KeyboardNavigation, ServicesProvider, Shell } = require('stremio/services');
const { ToastsContainerProvider } = require('stremio/common/Toasts/ToastsContainerContext'); const { ToastProvider } = require('stremio/common');
const routerViewsConfig = require('./routerViewsConfig'); const routerViewsConfig = require('./routerViewsConfig');
const styles = require('./styles'); const styles = require('./styles');
@ -51,14 +51,14 @@ const App = () => {
<ServicesProvider services={services}> <ServicesProvider services={services}>
{ {
shellInitialized && coreInitialized ? shellInitialized && coreInitialized ?
<ToastsContainerProvider> <ToastProvider className={styles['toasts-container']}>
<Router <Router
className={styles['router']} className={styles['router']}
homePath={'/'} homePath={'/'}
viewsConfig={routerViewsConfig} viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch} onPathNotMatch={onPathNotMatch}
/> />
</ToastsContainerProvider> </ToastProvider>
: :
<div className={styles['app-loader']} /> <div className={styles['app-loader']} />
} }

View file

@ -72,6 +72,23 @@ html {
width: 100%; width: 100%;
height: 100%; height: 100%;
.toasts-container {
position: absolute;
top: calc(1.2 * var(--nav-bar-size));
right: 0;
bottom: calc(1.2 * var(--nav-bar-size));
left: auto;
z-index: 1;
padding: 0 calc(1.2 * var(--nav-bar-size));
overflow-y: auto;
scrollbar-width: none;
pointer-events: none;
&::-webkit-scrollbar {
width: var(--scroll-bar-width);
}
}
.router { .router {
width: 100%; width: 100%;
height: 100%; height: 100%;

View file

@ -77,7 +77,7 @@ const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
ColorInput.propTypes = { ColorInput.propTypes = {
className: PropTypes.string, className: PropTypes.string,
value: PropTypes.string, value: PropTypes.string,
dataset: PropTypes.objectOf(PropTypes.string), dataset: PropTypes.object,
onChange: PropTypes.func, onChange: PropTypes.func,
onClick: PropTypes.func onClick: PropTypes.func
}; };

View file

@ -121,7 +121,7 @@ MetaItem.propTypes = {
playIcon: PropTypes.bool, playIcon: PropTypes.bool,
progress: PropTypes.number, progress: PropTypes.number,
options: PropTypes.array, options: PropTypes.array,
dataset: PropTypes.objectOf(PropTypes.string), dataset: PropTypes.object,
optionOnSelect: PropTypes.func, optionOnSelect: PropTypes.func,
onClick: PropTypes.func onClick: PropTypes.func
}; };

View file

@ -112,7 +112,7 @@ ModalDialog.propTypes = {
PropTypes.arrayOf(PropTypes.node), PropTypes.arrayOf(PropTypes.node),
PropTypes.node PropTypes.node
]), ]),
dataset: PropTypes.objectOf(PropTypes.string), dataset: PropTypes.object,
onCloseRequest: PropTypes.func onCloseRequest: PropTypes.func
}; };

View file

@ -143,7 +143,7 @@ Multiselect.propTypes = {
})), })),
selected: PropTypes.arrayOf(PropTypes.string), selected: PropTypes.arrayOf(PropTypes.string),
disabled: PropTypes.bool, disabled: PropTypes.bool,
dataset: PropTypes.objectOf(PropTypes.string), dataset: PropTypes.object,
renderLabelContent: PropTypes.func, renderLabelContent: PropTypes.func,
renderLabelText: PropTypes.func, renderLabelText: PropTypes.func,
onOpen: PropTypes.func, onOpen: PropTypes.func,

View file

@ -35,7 +35,7 @@ const PaginationInput = ({ className, label, dataset, onSelect, ...props }) => {
PaginationInput.propTypes = { PaginationInput.propTypes = {
className: PropTypes.string, className: PropTypes.string,
label: PropTypes.string, label: PropTypes.string,
dataset: PropTypes.objectOf(PropTypes.string), dataset: PropTypes.object,
onSelect: PropTypes.func onSelect: PropTypes.func
}; };

View file

@ -103,7 +103,7 @@ Popup.propTypes = {
direction: PropTypes.oneOf(['top-left', 'bottom-left', 'top-right', 'bottom-right']), direction: PropTypes.oneOf(['top-left', 'bottom-left', 'top-right', 'bottom-right']),
renderLabel: PropTypes.func.isRequired, renderLabel: PropTypes.func.isRequired,
renderMenu: PropTypes.func.isRequired, renderMenu: PropTypes.func.isRequired,
dataset: PropTypes.objectOf(PropTypes.string), dataset: PropTypes.object,
onCloseRequest: PropTypes.func onCloseRequest: PropTypes.func
}; };

View file

@ -0,0 +1,10 @@
const React = require('react');
const ToastContext = React.createContext({
show: () => { },
clear: () => { }
});
ToastContext.displayName = 'ToastContext';
module.exports = ToastContext;

View file

@ -0,0 +1,71 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('stremio-icons/dom');
const Button = require('stremio/common/Button');
const styles = require('./styles');
const ToastItem = ({ type, title, message, icon, dataset, onSelect, onClose }) => {
const toastOnClick = React.useCallback((event) => {
if (!event.nativeEvent.selectPrevented && typeof onSelect === 'function') {
onSelect({
type: 'select',
dataset: dataset,
reactEvent: event,
nativeEvent: event.nativeEvent
});
}
}, [dataset, onSelect]);
const closeButtonOnClick = React.useCallback((event) => {
event.nativeEvent.selectPrevented = true;
if (typeof onClose === 'function') {
onClose({
type: 'close',
dataset: dataset,
reactEvent: event,
nativeEvent: event.nativeEvent
});
}
}, [dataset, onClose]);
return (
<Button className={classnames(styles['toast-item-container'], styles['success'], styles[type])} tabIndex={-1} onClick={toastOnClick}>
{
typeof icon === 'string' && icon.length > 0 ?
<div className={styles['icon-container']}>
<Icon className={styles['icon']} icon={icon} />
</div>
:
null
}
<div className={styles['info-container']}>
{
typeof title === 'string' && title.length > 0 ?
<div className={styles['title-container']}>{title}</div>
:
null
}
{
typeof message === 'string' && message.length > 0 ?
<div className={styles['message-container']}>{message}</div>
:
null
}
</div>
<Button className={styles['close-button-container']} title={'Close'} tabIndex={-1} onClick={closeButtonOnClick}>
<Icon className={styles['icon']} icon={'ic_x'} />
</Button>
</Button>
);
};
ToastItem.propTypes = {
type: PropTypes.oneOf(['success', 'alert', 'error']),
title: PropTypes.string,
message: PropTypes.string,
icon: PropTypes.string,
dataset: PropTypes.object,
onSelect: PropTypes.func,
onClose: PropTypes.func
};
module.exports = ToastItem;

View file

@ -0,0 +1,3 @@
const ToastItem = require('./ToastItem');
module.exports = ToastItem;

View file

@ -1,14 +1,15 @@
.toast-container { .toast-item-container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 25rem;
min-height: 6rem; min-height: 6rem;
margin-bottom: 1rem; margin-bottom: 1rem;
border: thin solid; border: thin solid;
color: var(--color-backgrounddarker);
fill: var(--color-backgrounddarker);
background-color: var(--color-surfacelighter); background-color: var(--color-surfacelighter);
overflow: visible; overflow: visible;
pointer-events: all; box-shadow: 0 0.3rem 0.5rem var(--color-backgrounddarker40),
0 0.6rem 1rem var(--color-backgrounddarker20);
pointer-events: auto;
&.success { &.success {
color: var(--color-signal5); color: var(--color-signal5);
@ -26,10 +27,10 @@
} }
.icon-container { .icon-container {
width: 5rem; flex: none;
padding: 1rem; align-self: stretch;
padding-right: 0; width: 4.5rem;
overflow: visible; padding: 1.2rem 0 1.2rem 1.2rem;
.icon { .icon {
display: block; display: block;
@ -38,33 +39,35 @@
} }
} }
.message-container { .info-container {
flex: 1; flex: 1;
align-self: stretch;
padding: 1rem; padding: 1rem;
&.clickable { .title-container {
cursor: pointer; font-size: 1.2rem;
} }
.message-caption { .message-container {
font-weight: bold; font-size: 1.1rem;
} }
} }
.close-button-container { .close-button-container {
flex: none; flex: none;
align-self: flex-start;
width: 3rem; width: 3rem;
height: 3rem; height: 3rem;
padding: 1rem; padding: 1rem;
&:hover {
background-color: var(--color-surfacelight);
}
.icon { .icon {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
&:hover {
background-color: var(--color-surfacelight);
}
} }
} }

View file

@ -0,0 +1,72 @@
const React = require('react');
const PropTypes = require('prop-types');
const ToastItem = require('./ToastItem');
const ToastContext = require('./ToastContext');
const DEFAULT_TIMEOUT = 3000;
const ToastProvider = ({ className, children }) => {
const [container, setContainer] = React.useState(null);
const [items, dispatch] = React.useReducer(
(items, action) => {
switch (action.type) {
case 'add':
return items.concat(action.item);
case 'remove':
return items.filter((item) => item.id !== action.id);
case 'clear':
return [];
default:
return items;
}
},
[]
);
const itemOnClose = React.useCallback((event) => {
clearTimeout(event.dataset.id);
dispatch({ type: 'remove', id: event.dataset.id });
}, []);
const toast = React.useMemo(() => ({
show: (item) => {
const timeout = typeof item.timeout === 'number' && !isNaN(item.timeout) ?
item.timeout
:
DEFAULT_TIMEOUT;
const id = setTimeout(() => {
dispatch({ type: 'remove', id });
}, timeout);
dispatch({
type: 'add',
item: {
...item,
id,
dataset: {
...item.dataset,
id
},
onClose: itemOnClose
}
});
},
clear: () => {
dispatch({ type: 'clear' });
}
}), []);
return (
<ToastContext.Provider value={toast}>
{container instanceof HTMLElement ? children : null}
<div ref={setContainer} className={className}>
{items.map((item, index) => (
<ToastItem key={index} {...item} />
))}
</div>
</ToastContext.Provider>
);
};
ToastProvider.propTypes = {
className: PropTypes.string,
children: PropTypes.node
};
module.exports = ToastProvider;

View file

@ -0,0 +1,7 @@
const ToastProvider = require('./ToastProvider');
const useToast = require('./useToast');
module.exports = {
ToastProvider,
useToast
};

View file

@ -0,0 +1,8 @@
const React = require('react');
const ToastContext = require('./ToastContext');
const useToast = () => {
return React.useContext(ToastContext);
};
module.exports = useToast;

View file

@ -1,50 +0,0 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('stremio-icons/dom');
const Button = require('stremio/common/Button');
const styles = require('./styles');
const Toast = ({ type, title, text, icon, closeButton, onClick, onClose }) => {
return (
<div className={classnames(styles['toast-container'], styles[type])}>
{
typeof icon === 'string' && icon.length > 0 ?
<div className={styles['icon-container']}>
<Icon className={styles['icon']} icon={icon} />
</div>
:
null
}
<div className={classnames(styles['message-container'], { [styles.clickable]: typeof onClick === 'function' })} onClick={onClick}>
{
typeof title === 'string' && title.length > 0 ?
<h1>{title}</h1>
:
null
}
{text}
</div>
{
closeButton ?
<Button className={styles['close-button-container']} title={'Close'} onClick={onClose}>
<Icon className={styles['icon']} icon={'ic_x'} />
</Button>
:
null
}
</div>
);
};
Toast.propTypes = {
type: PropTypes.string,
title: PropTypes.string,
text: PropTypes.string,
icon: PropTypes.string,
closeButton: PropTypes.bool,
onClick: PropTypes.func,
onClose: PropTypes.func
};
module.exports = Toast;

View file

@ -1,3 +0,0 @@
const Toast = require('./Toast');
module.exports = Toast;

View file

@ -1,55 +0,0 @@
const React = require('react');
const { Modal } = require('stremio-router');
const ModalsContainerContext = require('stremio-router/ModalsContainerContext/ModalsContainerContext');
const { useToastsContainer } = require('./ToastsContainerContext');
const Toast = require('./Toast');
const DEFAULT_TIMEOUT = 2000;
const Toasts = React.forwardRef(({ className }, ref) => {
const toastsContainer = useToastsContainer();
const [toasts, dispatch] = React.useReducer(
(state, action) => {
switch (action.type) {
case 'add':
return state.concat([action.item]);
case 'remove':
return state.filter(item => item !== action.item);
case 'removeAll':
state.forEach(item => clearTimeout(item.timerId));
return [];
default:
return state;
}
}, []
);
const hideAll = React.useCallback(() => {
dispatch({ type: 'removeAll' });
}, []);
const show = React.useCallback(({ type, icon, title, text, closeButton, timeout, onClick }) => {
timeout = timeout !== null && !isNaN(timeout) ? timeout : DEFAULT_TIMEOUT;
const close = () => {
clearTimeout(newItem.timerId);
dispatch({ type: 'remove', item: newItem });
};
const newItem = { type, icon, title, text, closeButton, timeout, onClick, onClose: close };
if (timeout !== 0) {
newItem.timerId = setTimeout(close, timeout);
}
dispatch({ type: 'add', item: newItem });
return close;
}, []);
React.useImperativeHandle(ref, () => ({ show, hideAll }));
return toasts.length === 0 ? null : (
<ModalsContainerContext.Provider value={toastsContainer}>
<Modal className={className} disabled={true}>
{toasts.map((toast, index) => (<Toast {...toast} key={index} />))}
</Modal>
</ModalsContainerContext.Provider>
);
});
module.exports = Toasts;

View file

@ -1,7 +0,0 @@
const React = require('react');
const ToastsContainerContext = React.createContext(null);
ToastsContainerContext.displayName = 'ToastsContainerContext';
module.exports = ToastsContainerContext;

View file

@ -1,20 +0,0 @@
const React = require('react');
const PropTypes = require('prop-types');
const ToastsContainerContext = require('./ToastsContainerContext');
const styles = require('./styles');
const ToastsContainerProvider = ({ children }) => {
const [container, setContainer] = React.useState(null);
return (
<ToastsContainerContext.Provider value={container}>
{container instanceof HTMLElement ? children : null}
<div ref={setContainer} className={styles['toasts-container']} />
</ToastsContainerContext.Provider>
);
};
ToastsContainerProvider.propTypes = {
children: PropTypes.node
};
module.exports = ToastsContainerProvider;

View file

@ -1,7 +0,0 @@
const ToastsContainerProvider = require('./ToastsContainerProvider');
const useToastsContainer = require('./useToastsContainer');
module.exports = {
ToastsContainerProvider,
useToastsContainer
};

View file

@ -1,9 +0,0 @@
.toasts-container {
position: absolute;
top: var(--nav-bar-size);
right: 0;
bottom: 0;
left: auto !important;
z-index: 0;
pointer-events: none;
}

View file

@ -1,8 +0,0 @@
const React = require('react');
const ToastsContainerContext = require('./ToastsContainerContext');
const useToastsContainer = () => {
return React.useContext(ToastsContainerContext);
};
module.exports = useToastsContainer;

View file

@ -1,3 +0,0 @@
const Toasts = require('./Toasts');
module.exports = Toasts;

View file

@ -16,7 +16,7 @@ const Popup = require('./Popup');
const SharePrompt = require('./SharePrompt'); const SharePrompt = require('./SharePrompt');
const Slider = require('./Slider'); const Slider = require('./Slider');
const TextInput = require('./TextInput'); const TextInput = require('./TextInput');
const Toasts = require('./Toasts'); const { ToastProvider, useToast } = require('./Toast');
const routesRegexp = require('./routesRegexp'); const routesRegexp = require('./routesRegexp');
const useAnimationFrame = require('./useAnimationFrame'); const useAnimationFrame = require('./useAnimationFrame');
const useBinaryState = require('./useBinaryState'); const useBinaryState = require('./useBinaryState');
@ -47,7 +47,8 @@ module.exports = {
SharePrompt, SharePrompt,
Slider, Slider,
TextInput, TextInput,
Toasts, ToastProvider,
useToast,
routesRegexp, routesRegexp,
useAnimationFrame, useAnimationFrame,
useBinaryState, useBinaryState,

View file

@ -105,7 +105,7 @@ Addon.propTypes = {
installed: PropTypes.bool, installed: PropTypes.bool,
onToggle: PropTypes.func, onToggle: PropTypes.func,
onShare: PropTypes.func, onShare: PropTypes.func,
dataset: PropTypes.objectOf(PropTypes.string) dataset: PropTypes.object
}; };
module.exports = Addon; module.exports = Addon;