modals refactored

This commit is contained in:
NikolaBorislavovHristov 2019-01-25 17:00:43 +02:00
parent 486db927db
commit 09ecae54e2
21 changed files with 227 additions and 194 deletions

18
src/App/App.js Normal file
View file

@ -0,0 +1,18 @@
import React, { StrictMode } from 'react';
import { Router } from 'stremio-common';
import ModalsContainerProvider from './ModalsContainerProvider';
import RouterFocusableProvider from './RouterFocusableProvider';
import routerConfig from './routerConfig';
import styles from './styles';
const App = () => (
<StrictMode>
<ModalsContainerProvider modalsContainerClassName={styles['application-layer']}>
<RouterFocusableProvider routesContainerClassName={styles['application-layer']}>
<Router routeClassName={styles['route']} config={routerConfig} />
</RouterFocusableProvider>
</ModalsContainerProvider>
</StrictMode>
);
export default App;

View file

@ -0,0 +1,35 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { ModalsContainerContext } from 'stremio-common';
class ModalsContainerProvider extends Component {
constructor(props) {
super(props);
this.modalsContainerRef = React.createRef();
}
shouldComponentUpdate(nextProps, nextState) {
return nextProps.children !== this.props.children ||
nextProps.modalsContainerClassName !== this.props.modalsContainerClassName;
}
componentDidMount() {
this.forceUpdate();
}
render() {
return (
<ModalsContainerContext.Provider value={this.modalsContainerRef.current}>
{this.props.children}
<div ref={this.modalsContainerRef} className={this.props.modalsContainerClassName} />
</ModalsContainerContext.Provider>
);
}
}
ModalsContainerProvider.propTypes = {
modalsContainerClassName: PropTypes.string
};
export default ModalsContainerProvider;

View file

@ -0,0 +1,67 @@
import React, { Component } from 'react';
import { FocusableContext, withModalsContainer } from 'stremio-common';
class RouterFocusableProvider extends Component {
constructor(props) {
super(props);
this.routesContainerRef = React.createRef();
this.modalsContainerDomTreeObserver = new MutationObserver(this.onModalsContainerDomTreeChange);
this.state = {
focusable: false
};
}
componentDidMount() {
if (this.props.modalsContainer !== null) {
this.onModalsContainerDomTreeChange();
this.modalsContainerDomTreeObserver.observe(this.props.modalsContainer, {
childList: true
});
}
}
componentWillUnmount() {
this.modalsContainerDomTreeObserver.disconnect();
}
shouldComponentUpdate(nextProps, nextState) {
return nextState.focusable !== this.state.focusable ||
nextProps.routesContainerClassName !== this.props.routesContainerClassName ||
nextProps.modalsContainer !== this.props.modalsContainer ||
nextProps.children !== this.props.children;
}
componentDidUpdate(prevProps, prevState) {
if (prevState.focusable && !this.state.focusable) {
const focusedElement = this.routesContainerRef.current.querySelector(':focus');
if (focusedElement !== null) {
focusedElement.blur();
}
}
if (prevProps.modalsContainer !== this.props.modalsContainer) {
this.onModalsContainerDomTreeChange();
this.modalsContainerDomTreeObserver.disconnect();
this.modalsContainerDomTreeObserver.observe(this.props.modalsContainer, {
childList: true
});
}
}
onModalsContainerDomTreeChange = () => {
this.setState({ focusable: this.props.modalsContainer.childElementCount === 0 });
}
render() {
return (
<FocusableContext.Provider value={this.state.focusable}>
<div ref={this.routesContainerRef} className={this.props.routesContainerClassName}>
{this.props.children}
</div>
</FocusableContext.Provider>
);
}
}
export default withModalsContainer(RouterFocusableProvider);

3
src/App/index.js Normal file
View file

@ -0,0 +1,3 @@
import App from './App';
export default App;

View file

@ -71,6 +71,33 @@ html, body, :global(#app) {
font-family: 'Roboto', 'sans-serif';
line-height: 1;
.application-layer {
position: absolute;
top: 0;
left: 0;
z-index: 0;
:global(.modal-container), .route {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100vw;
height: 100vh;
min-width: var(--window-min-width);
min-height: var(--window-min-height);
overflow: hidden;
&.route {
background-color: var(--color-background);
}
&:not(:last-child) {
display: none;
}
}
}
::-webkit-scrollbar {
width: var(--scroll-bar-width);
}
@ -82,38 +109,4 @@ html, body, :global(#app) {
::-webkit-scrollbar-track {
background-color: var(--color-backgroundlight);
}
}
:global(#app) {
overflow: hidden;
.route-container {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
overflow: hidden;
background-color: var(--color-background);
}
}
:global(#modals) {
position: absolute;
top: 0;
left: 0;
z-index: 1;
.modal-container {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100vw;
height: 100vh;
min-width: var(--window-min-width);
min-height: var(--window-min-height);
overflow: hidden;
}
}

View file

@ -1,25 +0,0 @@
import React, { PureComponent, StrictMode } from 'react';
import { Router, Modal } from 'stremio-common';
import routerConfig from './routerConfig';
import styles from './styles';
class App extends PureComponent {
render() {
return (
<StrictMode>
<Router config={routerConfig} />
</StrictMode>
);
}
}
const appContainerElement = document.getElementById('app');
const modalsContainerElement = document.getElementById('modals');
App.containerElement = appContainerElement;
Router.routesContainer = appContainerElement;
Router.routeClassName = styles['route-container'];
Modal.modalsContainer = modalsContainerElement;
Modal.modalClassName = styles['modal-container'];
export default App;

View file

@ -1,3 +0,0 @@
import App from './app';
export default App;

View file

@ -1,6 +1,6 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { Focusable } from 'stremio-common';
import { withFocusable } from 'stremio-common';
class Button extends PureComponent {
onClick = (event) => {
@ -20,23 +20,25 @@ class Button extends PureComponent {
}
render() {
const { stopPropagation, forwardedRef, ...props } = this.props;
const { forwardedRef, focusable, stopPropagation, ...props } = this.props;
return (
<Focusable ref={forwardedRef}>
<div
{...props}
onClick={this.onClick}
onKeyUp={this.onKeyUp}
/>
</Focusable>
<div
{...props}
ref={forwardedRef}
tabIndex={focusable ? 0 : -1}
onClick={this.onClick}
onKeyUp={this.onKeyUp}
/>
);
}
}
Button.propTypes = {
focusable: PropTypes.bool.isRequired,
stopPropagation: PropTypes.bool.isRequired
};
Button.defaultProps = {
focusable: false,
stopPropagation: true
};
@ -46,4 +48,4 @@ const ButtonWithForwardedRef = React.forwardRef((props, ref) => (
ButtonWithForwardedRef.displayName = 'ButtonWithForwardedRef';
export default ButtonWithForwardedRef;
export default withFocusable(ButtonWithForwardedRef);

View file

@ -1,14 +0,0 @@
import React from 'react';
import FocusableContext from './FocusableContext';
const Focusable = React.forwardRef(({ children, ...props }, ref) => {
return (
<FocusableContext.Consumer>
{focusable => React.cloneElement(React.Children.only(children), { ...props, ref, tabIndex: focusable ? 0 : -1 })}
</FocusableContext.Consumer>
);
});
Focusable.displayName = 'Focusable';
export default Focusable;

View file

@ -1,69 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import FocusableContext from './FocusableContext';
class FocusableProvider extends Component {
constructor(props) {
super(props);
this.childElementRef = React.createRef();
this.observers = props.elements.map((element) => ({
element,
observer: new MutationObserver(this.onDomTreeChange)
}));
this.state = {
focusable: false
};
}
componentDidMount() {
this.onDomTreeChange();
this.observers.forEach(({ element, observer }) => observer.observe(element, {
childList: true
}));
}
componentWillUnmount() {
this.observers.forEach(({ observer }) => observer.disconnect());
}
shouldComponentUpdate(nextProps, nextState) {
return nextProps.children !== this.props.children ||
nextState.focusable !== this.state.focusable;
}
componentDidUpdate() {
if (!this.state.focusable) {
const focusedElement = this.childElementRef.current.querySelector(':focus');
if (focusedElement) {
focusedElement.blur();
}
}
}
onDomTreeChange = () => {
this.props.onDomTreeChange({
child: this.childElementRef.current,
onFocusableChange: this.onFocusableChange
});
}
onFocusableChange = (focusable) => {
this.setState({ focusable });
}
render() {
return (
<FocusableContext.Provider value={this.state.focusable}>
{React.cloneElement(React.Children.only(this.props.children), { ref: this.childElementRef })}
</FocusableContext.Provider>
);
}
}
FocusableProvider.propTypes = {
onDomTreeChange: PropTypes.func.isRequired,
elements: PropTypes.arrayOf(PropTypes.instanceOf(HTMLElement).isRequired).isRequired
};
export default FocusableProvider;

View file

@ -1,7 +1,7 @@
import Focusable from './Focusable';
import FocusableProvider from './FocusableProvider';
import FocusableContext from './FocusableContext';
import withFocusable from './withFocusable';
export {
Focusable,
FocusableProvider
FocusableContext,
withFocusable
};

View file

@ -0,0 +1,14 @@
import React from 'react';
import FocusableContext from './FocusableContext';
const withFocusable = (Component) => {
return function withFocusable(props) {
return (
<FocusableContext.Consumer>
{focusable => <Component {...props} focusable={focusable} />}
</FocusableContext.Consumer>
);
};
};
export default withFocusable;

View file

@ -1,26 +1,21 @@
import React, { useState, useCallback, useEffect } from 'react';
import React from 'react';
import ReactDOM from 'react-dom';
import { FocusableProvider } from 'stremio-common';
import { FocusableContext } from 'stremio-common';
import withModalsContainer from './withModalsContainer';
const Modal = ({ children }) => {
const [modalContainer] = useState(document.createElement('div'));
const onDomTreeChange = useCallback(({ onFocusableChange }) => {
onFocusableChange(modalContainer.nextElementSibling === null);
});
useEffect(() => {
modalContainer.className = Modal.modalClassName;
Modal.modalsContainer.appendChild(modalContainer);
return () => {
Modal.modalsContainer.removeChild(modalContainer);
};
});
const Modal = ({ modalsContainer, children }) => {
if (modalsContainer === null) {
return null;
}
return ReactDOM.createPortal(
<FocusableProvider elements={[Modal.modalsContainer]} onDomTreeChange={onDomTreeChange}>
{React.Children.only(children)}
</FocusableProvider>,
modalContainer
<FocusableContext.Provider value={true}>
<div className={'modal-container'}>
{children}
</div>
</FocusableContext.Provider>,
modalsContainer
);
};
export default Modal;
export default withModalsContainer(Modal);

View file

@ -0,0 +1,3 @@
import React from 'react';
export default React.createContext(null);

View file

@ -1,3 +1,9 @@
import ModalsContainerContext from './ModalsContainerContext';
import Modal from './Modal';
import withModalsContainer from './withModalsContainer';
export default Modal;
export {
ModalsContainerContext,
Modal,
withModalsContainer
};

View file

@ -0,0 +1,14 @@
import React from 'react';
import ModalsContainerContext from './ModalsContainerContext';
const withModalsContainer = (Component) => {
return function withModalsContainer(props) {
return (
<ModalsContainerContext.Consumer>
{modalsContainer => <Component {...props} modalsContainer={modalsContainer} />}
</ModalsContainerContext.Consumer>
);
};
};
export default withModalsContainer;

View file

@ -2,7 +2,6 @@ import React, { Component, Fragment } from 'react';
import pathToRegexp from 'path-to-regexp';
import PathUtils from 'path';
import UrlUtils from 'url';
import { Modal, FocusableProvider } from 'stremio-common';
class Router extends Component {
constructor(props) {
@ -48,10 +47,6 @@ class Router extends Component {
return nextState.views !== this.state.views;
}
onDomTreeChange = ({ child, onFocusableChange }) => {
onFocusableChange(Modal.modalsContainer.childElementCount === 0 && child.nextElementSibling === null);
}
onLocationChanged = () => {
const hashIndex = window.location.href.indexOf('#');
const hashPath = hashIndex === -1 ? '' : window.location.href.substring(hashIndex + 1);
@ -105,9 +100,7 @@ class Router extends Component {
this.state.views
.filter(({ element }) => React.isValidElement(element))
.map(({ path, element }) => (
<FocusableProvider key={path} elements={[Router.routesContainer, Modal.modalsContainer]} onDomTreeChange={this.onDomTreeChange}>
<div className={Router.routeClassName}>{element}</div>
</FocusableProvider>
<div key={path} className={this.props.routeClassName}>{element}</div>
))
}
</Fragment>

View file

@ -1,7 +1,7 @@
import Checkbox from './Checkbox';
import Popup from './Popup';
import NavBar from './NavBar';
import Modal from './Modal';
import { ModalsContainerContext, Modal, withModalsContainer } from './Modal';
import MetadataItem from './MetadataItem';
import Router from './Router';
import LibraryItemList from './LibraryItemList';
@ -9,14 +9,16 @@ import MetaItem from './MetaItem';
import ShareAddon from './ShareAddon';
import UserPanel from './UserPanel';
import Slider from './Slider';
import { Focusable, FocusableProvider } from './Focusable';
import { FocusableContext, withFocusable } from './Focusable';
import Button from './Button';
export {
Checkbox,
Popup,
NavBar,
ModalsContainerContext,
Modal,
withModalsContainer,
MetadataItem,
Router,
LibraryItemList,
@ -24,7 +26,7 @@ export {
ShareAddon,
UserPanel,
Slider,
Focusable,
FocusableProvider,
FocusableContext,
withFocusable,
Button
};

View file

@ -9,7 +9,6 @@
<body>
<div id="app"></div>
<div id="modals"></div>
<script src="https://www.youtube.com/iframe_api"></script>
</body>

View file

@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './app';
import App from './App';
ReactDOM.render(<App />, App.containerElement);
ReactDOM.render(<App />, document.getElementById('app'));