mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-04-17 20:22:55 +00:00
focusability integrated with modals and routes
This commit is contained in:
parent
c5af1c5d8c
commit
486db927db
9 changed files with 159 additions and 90 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import React, { PureComponent, StrictMode } from 'react';
|
||||
import { Router } from 'stremio-common';
|
||||
import { Router, Modal } from 'stremio-common';
|
||||
import routerConfig from './routerConfig';
|
||||
import styles from './styles';
|
||||
|
||||
|
|
@ -7,13 +7,19 @@ class App extends PureComponent {
|
|||
render() {
|
||||
return (
|
||||
<StrictMode>
|
||||
<Router
|
||||
routeContainerClassName={styles['route-container']}
|
||||
config={routerConfig}
|
||||
/>
|
||||
<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;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@
|
|||
--scroll-bar-width: 8px;
|
||||
--landscape-shape-ratio: 0.5625;
|
||||
--poster-shape-ratio: 1.464;
|
||||
--window-min-width: 1000px;
|
||||
--window-min-height: 650px;
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
@ -64,8 +66,8 @@ html, body, :global(#app) {
|
|||
z-index: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
min-width: 1000px;
|
||||
min-height: 650px;
|
||||
min-width: var(--window-min-width);
|
||||
min-height: var(--window-min-height);
|
||||
font-family: 'Roboto', 'sans-serif';
|
||||
line-height: 1;
|
||||
|
||||
|
|
@ -84,18 +86,34 @@ html, body, :global(#app) {
|
|||
|
||||
: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(.modal-container), .route-container {
|
||||
:global(#modals) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
z-index: 1;
|
||||
|
||||
.route-container {
|
||||
background-color: var(--color-background);
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import FocusableContext from './FocusableContext';
|
||||
|
||||
class FocusableProvider extends Component {
|
||||
|
|
@ -6,21 +7,24 @@ class FocusableProvider extends Component {
|
|||
super(props);
|
||||
|
||||
this.childElementRef = React.createRef();
|
||||
this.containerClildListObserver = new MutationObserver(this.containerClildListOnChange);
|
||||
this.observers = props.elements.map((element) => ({
|
||||
element,
|
||||
observer: new MutationObserver(this.onDomTreeChange)
|
||||
}));
|
||||
this.state = {
|
||||
focusable: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.containerClildListOnChange();
|
||||
this.containerClildListObserver.observe(this.childElementRef.current.parentElement, {
|
||||
this.onDomTreeChange();
|
||||
this.observers.forEach(({ element, observer }) => observer.observe(element, {
|
||||
childList: true
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.containerClildListObserver.disconnect();
|
||||
this.observers.forEach(({ observer }) => observer.disconnect());
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
|
|
@ -28,7 +32,7 @@ class FocusableProvider extends Component {
|
|||
nextState.focusable !== this.state.focusable;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
componentDidUpdate() {
|
||||
if (!this.state.focusable) {
|
||||
const focusedElement = this.childElementRef.current.querySelector(':focus');
|
||||
if (focusedElement) {
|
||||
|
|
@ -37,8 +41,15 @@ class FocusableProvider extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
containerClildListOnChange = () => {
|
||||
this.setState({ focusable: this.childElementRef.current.nextElementSibling === null });
|
||||
onDomTreeChange = () => {
|
||||
this.props.onDomTreeChange({
|
||||
child: this.childElementRef.current,
|
||||
onFocusableChange: this.onFocusableChange
|
||||
});
|
||||
}
|
||||
|
||||
onFocusableChange = (focusable) => {
|
||||
this.setState({ focusable });
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
@ -50,4 +61,9 @@ class FocusableProvider extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
FocusableProvider.propTypes = {
|
||||
onDomTreeChange: PropTypes.func.isRequired,
|
||||
elements: PropTypes.arrayOf(PropTypes.instanceOf(HTMLElement).isRequired).isRequired
|
||||
};
|
||||
|
||||
export default FocusableProvider;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,26 @@
|
|||
import React from 'react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import classnames from 'classnames';
|
||||
import { FocusableProvider } from 'stremio-common';
|
||||
|
||||
const Modal = (props) => ReactDOM.createPortal(
|
||||
<FocusableProvider>
|
||||
<div {...props} className={classnames(Modal.className, props.className)} />
|
||||
</FocusableProvider>,
|
||||
Modal.container
|
||||
);
|
||||
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);
|
||||
};
|
||||
});
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<FocusableProvider elements={[Modal.modalsContainer]} onDomTreeChange={onDomTreeChange}>
|
||||
{React.Children.only(children)}
|
||||
</FocusableProvider>,
|
||||
modalContainer
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
|
|
|
|||
|
|
@ -212,12 +212,22 @@ class Popup extends Component {
|
|||
this.setState({ open: false });
|
||||
}
|
||||
|
||||
labelOnClick = (event) => {
|
||||
event.stopPropagation();
|
||||
this.open();
|
||||
}
|
||||
|
||||
menuContainerOnClick = (event) => {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
modalBackgroundOnClick = (event) => {
|
||||
event.stopPropagation();
|
||||
this.close();
|
||||
}
|
||||
|
||||
renderLabel(children) {
|
||||
return React.cloneElement(children, { ref: this.labelRef, onClick: this.open });
|
||||
return React.cloneElement(children, { ref: this.labelRef, onClick: this.labelOnClick });
|
||||
}
|
||||
|
||||
renderMenu(children) {
|
||||
|
|
@ -226,21 +236,23 @@ class Popup extends Component {
|
|||
}
|
||||
|
||||
return (
|
||||
<Modal className={this.props.className} onClick={this.close}>
|
||||
<div ref={this.menuContainerRef} className={styles['menu-container']} onClick={this.menuContainerOnClick}>
|
||||
<div ref={this.menuScrollRef} className={styles['menu-scroll-container']}>
|
||||
{React.cloneElement(children, { ref: this.menuChildrenRef })}
|
||||
<Modal>
|
||||
<div className={classnames(styles['modal-container'], this.props.className)} onClick={this.modalBackgroundOnClick}>
|
||||
<div ref={this.menuContainerRef} className={styles['menu-container']} onClick={this.menuContainerOnClick}>
|
||||
<div ref={this.menuScrollRef} className={styles['menu-scroll-container']}>
|
||||
{React.cloneElement(children, { ref: this.menuChildrenRef })}
|
||||
</div>
|
||||
<div ref={this.menuBorderTopRef} className={classnames(styles['border'], styles['border-top'])} />
|
||||
<div ref={this.menuBorderRightRef} className={classnames(styles['border'], styles['border-right'])} />
|
||||
<div ref={this.menuBorderBottomRef} className={classnames(styles['border'], styles['border-bottom'])} />
|
||||
<div ref={this.menuBorderLeftRef} className={classnames(styles['border'], styles['border-left'])} />
|
||||
</div>
|
||||
<div ref={this.menuBorderTopRef} className={classnames(styles['border'], styles['border-top'])} />
|
||||
<div ref={this.menuBorderRightRef} className={classnames(styles['border'], styles['border-right'])} />
|
||||
<div ref={this.menuBorderBottomRef} className={classnames(styles['border'], styles['border-bottom'])} />
|
||||
<div ref={this.menuBorderLeftRef} className={classnames(styles['border'], styles['border-left'])} />
|
||||
<div ref={this.labelBorderTopRef} className={classnames(styles['border'], styles['border-top'])} />
|
||||
<div ref={this.labelBorderRightRef} className={classnames(styles['border'], styles['border-right'])} />
|
||||
<div ref={this.labelBorderBottomRef} className={classnames(styles['border'], styles['border-bottom'])} />
|
||||
<div ref={this.labelBorderLeftRef} className={classnames(styles['border'], styles['border-left'])} />
|
||||
<div ref={this.hiddenBorderRef} className={classnames(styles['border'], styles['border-hidden'])} />
|
||||
</div>
|
||||
<div ref={this.labelBorderTopRef} className={classnames(styles['border'], styles['border-top'])} />
|
||||
<div ref={this.labelBorderRightRef} className={classnames(styles['border'], styles['border-right'])} />
|
||||
<div ref={this.labelBorderBottomRef} className={classnames(styles['border'], styles['border-bottom'])} />
|
||||
<div ref={this.labelBorderLeftRef} className={classnames(styles['border'], styles['border-left'])} />
|
||||
<div ref={this.hiddenBorderRef} className={classnames(styles['border'], styles['border-hidden'])} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,49 @@
|
|||
.menu-container {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
.modal-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.menu-scroll-container {
|
||||
box-shadow: var(--box-shadow);
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
.menu-container {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
|
||||
.border {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background-color: var(--border-color);
|
||||
|
||||
&-top {
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
.menu-scroll-container {
|
||||
box-shadow: var(--box-shadow);
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&-bottom {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
.border {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background-color: var(--border-color);
|
||||
|
||||
&-left {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
&-top {
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&-right {
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
&-bottom {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&-hidden {
|
||||
display: none;
|
||||
border: 1px solid;
|
||||
&-left {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&-right {
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&-hidden {
|
||||
display: none;
|
||||
border: 1px solid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import React, { Component, Fragment } from 'react';
|
|||
import pathToRegexp from 'path-to-regexp';
|
||||
import PathUtils from 'path';
|
||||
import UrlUtils from 'url';
|
||||
import { FocusableProvider } from 'stremio-common';
|
||||
import { Modal, FocusableProvider } from 'stremio-common';
|
||||
|
||||
class Router extends Component {
|
||||
constructor(props) {
|
||||
|
|
@ -48,6 +48,10 @@ 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);
|
||||
|
|
@ -101,8 +105,8 @@ class Router extends Component {
|
|||
this.state.views
|
||||
.filter(({ element }) => React.isValidElement(element))
|
||||
.map(({ path, element }) => (
|
||||
<FocusableProvider key={path}>
|
||||
<div className={this.props.routeContainerClassName}>{element}</div>
|
||||
<FocusableProvider key={path} elements={[Router.routesContainer, Modal.modalsContainer]} onDomTreeChange={this.onDomTreeChange}>
|
||||
<div className={Router.routeClassName}>{element}</div>
|
||||
</FocusableProvider>
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="modals"></div>
|
||||
<script src="https://www.youtube.com/iframe_api"></script>
|
||||
</body>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Modal } from 'stremio-common';
|
||||
import App from './app';
|
||||
|
||||
const container = document.getElementById('app');
|
||||
|
||||
Modal.container = container;
|
||||
Modal.className = 'modal-container';
|
||||
|
||||
ReactDOM.render(<App />, container);
|
||||
ReactDOM.render(<App />, App.containerElement);
|
||||
|
|
|
|||
Loading…
Reference in a new issue