docs: initial works on formalized docs page

This commit is contained in:
Connor Peet 2019-06-09 18:46:55 -07:00
Родитель 5efb2f6889
Коммит e964f7ff0d
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: CF8FD2EA0DBC61BD
57 изменённых файлов: 2759 добавлений и 991 удалений

Просмотреть файл

@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>arcade-machine-react</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div id="app"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.10/lodash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/benchmark/2.1.4/benchmark.min.js"></script>
<script src="./demo/index.js"></script>
</body>
</html>

Просмотреть файл

@ -1,132 +0,0 @@
import * as React from 'react';
import { Suite, Event } from 'benchmark';
import { IBenchmark, benchmarks } from './benchmarks';
declare const Benchmark: any;
/**
* BenchmarkRow is rendered for each element in the list of benchmarks.
*/
export class BenchmarkRow extends React.Component<
{ case: IBenchmark<any> },
{ result: string; running: boolean }
> {
private readonly fixtureRef = React.createRef<HTMLTableDataCellElement>();
public state = { result: '', running: false };
private runSelf = () => {
const suite = new Benchmark.Suite();
this.addTestCase(suite);
suite.run({ async: true });
};
public addTestCase(suite: Suite) {
const { name, setup, iterate } = this.props.case;
const state = { container: this.fixtureRef.current! };
this.setState({ result: 'queued...' });
let setupResult: any;
let didRun = false;
let previous: any;
suite.add(
name,
() => {
if (!didRun) {
this.setState({ result: 'running', running: true });
setupResult = setup ? setup(state) : null;
didRun = true;
}
previous = iterate(state, setupResult, previous);
},
{
onComplete: (ev: Event) => {
const target: any = ev.target;
if (target.error) {
this.setState({ result: target.error.toString(), running: false });
return;
}
this.setState({
running: false,
result: `${Math.round(target.hz)} ops/sec ± ${target.stats.rme.toFixed(2)} (${(
target.stats.mean * 1000
).toFixed(3)}ms each)`,
});
},
},
);
}
public render() {
return (
<tr>
<td>{this.props.case.name}</td>
<td ref={this.fixtureRef}>{this.state.running ? <this.props.case.fixture /> : null}</td>
<td>
{this.state.result}
<br />
<button onClick={this.runSelf} disabled={this.state.running}>
Run This
</button>
</td>
</tr>
);
}
}
/**
* Benchmarks is the table of runnable benchmarks.
*/
export class Benchmarks extends React.Component<{}, { running: boolean }> {
public state = { running: false };
private readonly benchmarkRows: React.RefObject<BenchmarkRow>[] = benchmarks.map(() =>
React.createRef(),
);
private runAll = () => {
const suite = new Benchmark.Suite();
this.benchmarkRows.forEach(row => {
row.current!.addTestCase(suite);
});
suite.on('complete', () => {
this.setState({ running: false });
});
suite.run({ async: true });
};
public render() {
return (
<React.Fragment>
<h1>Benchmarks</h1>
<div className="area">
<table className="benchmarks">
<thead>
<tr>
<th>Benchmark</th>
<th>DOM Fixture</th>
<th>
Result{' '}
<button onClick={this.runAll} disabled={this.state.running}>
Run All
</button>
</th>
</tr>
</thead>
<tbody>
{benchmarks.map((benchmark, i) => (
<BenchmarkRow case={benchmark} ref={this.benchmarkRows[i]} key={i} />
))}
</tbody>
</table>
</div>
</React.Fragment>
);
}
}

Просмотреть файл

@ -1,23 +0,0 @@
import * as React from 'react';
export const FocusFormDemo = () => (
<React.Fragment>
<h1>A Form</h1>
<div className="area">
<form>
<div>
<input tabIndex={0} placeholder="Username" />
</div>
<div>
<input tabIndex={0} placeholder="Password" type="password" />
</div>
<div>
<textarea tabIndex={0} />
</div>
<div>
<button tabIndex={0}>Submit</button>
</div>
</form>
</div>
</React.Fragment>
);

Просмотреть файл

@ -1,39 +0,0 @@
import * as React from 'react';
export const FocusGridDemo = () => (
<React.Fragment>
<h1>A Grid</h1>
<div className="area">
<div>
<div className="square" tabIndex={0} />
</div>
<div>
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
</div>
<div>
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
</div>
<div>
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
</div>
<div>
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
</div>
<div>
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
</div>
<div>
<div className="square" tabIndex={0} />
</div>
</div>
</React.Fragment>
);

Просмотреть файл

@ -1,41 +0,0 @@
import * as React from 'react';
import { ArcOnIncoming } from '../../src';
const ShouldNotFocus = ArcOnIncoming(
ev => alert(`Unexpected incoming focus in: ${ev.next!.parentElement!.innerHTML}`),
(props: { style?: any }) => <div className="square" tabIndex={0} {...props} />,
);
export const FocusHiddenDemo = () => (
<React.Fragment>
<h1>Hidden Boxes</h1>
To the right of each description is an invisible box. These should not be focusable, and will
alert if you try to focus them.
<div className="area">
<div className="visibility-test">
<div className="case-name" tabIndex={0}>
base case (should focus)
</div>
<div>
<div className="square" tabIndex={0} />
</div>
</div>
<div className="visibility-test">
<div className="case-name" tabIndex={0}>
display: none
</div>
<div>
<ShouldNotFocus style={{ display: 'none' }} />
</div>
</div>
<div className="visibility-test">
<div className="case-name" tabIndex={0}>
parent display: none
</div>
<div style={{ display: 'none' }}>
<ShouldNotFocus />
</div>
</div>
</div>
</React.Fragment>
);

Просмотреть файл

@ -1,53 +0,0 @@
import * as React from 'react';
export class FocusHistoryDemo extends React.Component<{}, { ticker: number }> {
private readonly boxes: string[] = [];
constructor(props: {}) {
super(props);
this.state = {
ticker: 0,
};
for (let i = 0; i < 50; i++) {
this.boxes.push(String(`Box ${i}`));
}
let k = 0;
setInterval(() => this.setState({ ...this.state, ticker: ++k }), 2500);
}
public render() {
return (
<React.Fragment>
<h1>History</h1>
<h2>Prefer last focused element</h2>
<div className="area" style={{ display: 'flex', alignItems: 'center' }}>
<div
className="box"
tabIndex={0}
style={{ display: 'inline-block', marginLeft: 50, width: 150, height: 150 }}
/>
<div id="focus-inside1" style={{ display: 'inline-block', margin: 50 }}>
<div className="box" tabIndex={0} style={{ width: 50, height: 50 }} />
<div className="box" tabIndex={0} style={{ width: 50, height: 50 }} />
<div className="box" tabIndex={0} style={{ width: 50, height: 50 }} />
</div>
</div>
<h1>Adding/Removing Elements</h1>
<div className="area">
{this.boxes.map((box, i) => (
<div className="box-wrapper" key={i}>
{(i + this.state.ticker) % 2 === 0 ? (
<div className="box" tabIndex={0}>
{box}
</div>
) : null}
</div>
))}
</div>
</React.Fragment>
);
}
}

Просмотреть файл

@ -1,58 +0,0 @@
import { ArcAutoFocus, ArcUp, ArcDown } from '../../src';
import * as React from 'react';
const AutofocusBox = ArcAutoFocus(
class extends React.PureComponent<{ onClick: () => void }> {
public render() {
return (
<div className="box" tabIndex={0} onClick={this.props.onClick}>
I capture default focus! Click me to toggle!
</div>
);
}
},
);
const UpDownOverrideBox = ({ index }: { index: number }) => (
<div id={`override${index}`} className="box" tabIndex={0}>
up/down override
</div>
);
const UpDownOverride1 = ArcUp('#override3', ArcDown('#override2', UpDownOverrideBox));
const UpDownOverride2 = ArcUp('#override1', ArcDown('#override3', UpDownOverrideBox));
const UpDownOverride3 = ArcUp('#override2', ArcDown('#override1', UpDownOverrideBox));
export class FocusHooksDemo extends React.Component<{}, { showAFBox: boolean }> {
constructor(props: {}) {
super(props);
this.state = { showAFBox: true };
}
private readonly toggleAFBox = () => {
this.setState({ showAFBox: false });
setTimeout(() => this.setState({ ...this.state, showAFBox: true }), 1000);
};
public render() {
return (
<React.Fragment>
<h1>Special Handlers</h1>
<div className="area">
<div className="box-wrapper" style={{ width: '200px' }}>
{this.state.showAFBox ? <AutofocusBox onClick={this.toggleAFBox} /> : null}
</div>
<div className="box-wrapper">
<UpDownOverride1 index={1} />
</div>
<div className="box-wrapper">
<UpDownOverride2 index={2} />
</div>
<div className="box-wrapper">
<UpDownOverride3 index={3} />
</div>
</div>
</React.Fragment>
);
}
}

Просмотреть файл

@ -1,104 +0,0 @@
import { FocusArea, FocusExclude } from '../../src';
import * as React from 'react';
export const FocusInsideDemo = () => (
<React.Fragment>
<h1>Focus Inside</h1>
Transfer focus to elements inside me
<div className="area" style={{ display: 'flex', justifyContent: 'space-evenly' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
}}
>
With arc-focus-inside
<FocusArea>
<div id="focus-inside1" className="area">
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
</div>
</FocusArea>
<FocusArea>
<div id="focus-inside1" className="area">
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} style={{ marginLeft: '100px' }} />
</div>
</FocusArea>
<FocusArea>
<div id="focus-inside1" className="area">
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
</div>
</FocusArea>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
}}
>
With focus excluded
<FocusExclude>
<div id="focus-inside1" className="area">
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
</div>
</FocusExclude>
<FocusExclude>
<div id="focus-inside1" className="area">
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} style={{ marginLeft: '100px' }} />
</div>
</FocusExclude>
<FocusExclude>
<div id="focus-inside1" className="area">
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
</div>
</FocusExclude>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
}}
>
Without arc-focus-inside
<div id="focus-inside1" className="area">
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
</div>
<div id="focus-inside1" className="area">
<div className="square" tabIndex={0} />
<div
className="square"
tabIndex={0}
style={{
display: 'inline-block',
width: '50px',
height: '50px',
marginLeft: '100px',
}}
/>
</div>
<div id="focus-inside1" className="area">
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
<div className="square" tabIndex={0} />
</div>
</div>
</div>
</React.Fragment>
);

Просмотреть файл

@ -1,70 +0,0 @@
import { ArcFocusTrap, FocusTrap } from '../../src';
import * as React from 'react';
const Dialog = ArcFocusTrap(
class extends React.Component<{ onClose: () => void }, { showTrap: boolean }> {
private showTrap = () => this.setState({ showTrap: true });
private hideTrap = () => this.setState({ showTrap: false });
constructor(props: { onClose: () => void }) {
super(props);
this.state = { showTrap: false };
}
public render() {
return (
<div className="area dialog">
<div>
<button tabIndex={0}>Button 1</button>
<button tabIndex={0}>Button 2</button>
</div>
<div>
<button tabIndex={0} onClick={this.showTrap}>
Show nested focus trap
</button>
</div>
{this.state.showTrap ? (
<FocusTrap>
<div style={{ border: '1px solid red' }}>
<button tabIndex={0}>Button 1</button>
<button tabIndex={0}>Button 2</button>
<button onClick={this.hideTrap} tabIndex={0}>
Hide nested trap
</button>
</div>
</FocusTrap>
) : null}
<div>
<button onClick={this.props.onClose} tabIndex={0}>
Close
</button>
</div>
</div>
);
}
},
);
export class FocusTrapsDemo extends React.Component<{}, { dialog: boolean }> {
private readonly onDialogOpen = () => {
this.setState({ dialog: true });
};
private readonly onDialogClose = () => {
this.setState({ dialog: false });
};
public state = { dialog: false };
render() {
return (
<React.Fragment>
<h1>Focus Child Elements Only</h1>
<button tabIndex={0} onClick={this.onDialogOpen}>
Open Dialog
</button>
{this.state.dialog ? <Dialog onClose={this.onDialogClose} /> : null}
</React.Fragment>
);
}
}

Просмотреть файл

@ -1,28 +0,0 @@
import { selectSimpleElementBenchmark } from './select-simple-element';
import { selectArcadeMachineBenchmark } from './select-arcade-machine';
import { virtualArcadeSelectBenchmark } from './virtual-arcade-select';
import { selectDeeplyNestedBenchmark } from './select-deeply-nested';
/**
* IBenchmarkState is passed to the setup of each benchmark script.
*/
export interface IBenchmarkState {
container: HTMLElement;
}
/**
* IBenchmark describes a benchmark to run on the arcade machine.
*/
export interface IBenchmark<T, R = void> {
name: string;
fixture: React.ComponentType<any>;
setup?: (container: IBenchmarkState) => T;
iterate: (container: IBenchmarkState, setup: T, prev: R | undefined) => R;
}
export const benchmarks: IBenchmark<any, any>[] = [
selectSimpleElementBenchmark,
selectArcadeMachineBenchmark,
virtualArcadeSelectBenchmark,
selectDeeplyNestedBenchmark,
];

Просмотреть файл

@ -1,32 +0,0 @@
import * as React from 'react';
import { ArcRoot, Button, defaultOptions } from '../../../src';
import { IBenchmark } from './index';
import { Subject } from 'rxjs';
const mockInputMethod = {
observe: new Subject<{ button: Button }>(),
isSupported: true,
};
export const selectArcadeMachineBenchmark: IBenchmark<void, boolean> = {
name: 'arcade-machine focus',
fixture: ArcRoot(
() => (
<React.Fragment>
<div className="square box box1" tabIndex={0} />
<div className="square box box2" tabIndex={0} />
</React.Fragment>
),
{
...defaultOptions(),
inputs: [mockInputMethod],
},
),
setup: state => {
(state.container.querySelector('.box1') as HTMLElement).focus();
},
iterate: (_state, _setup, toggle) => {
mockInputMethod.observe.next({ button: toggle ? Button.Right : Button.Left });
return !toggle;
},
};

Просмотреть файл

@ -1,48 +0,0 @@
import * as React from 'react';
import { ArcRoot, Button, defaultOptions, VirtualElementStore } from '../../../src';
import { IBenchmark } from './index';
import { Subject } from 'rxjs';
const mockInputMethod = {
observe: new Subject<{ button: Button }>(),
isSupported: true,
};
const elementStore = new VirtualElementStore();
interface INestedProps {
levels: number;
children: React.ReactElement<any>;
}
const NestedElement: React.ComponentType<INestedProps> = ({ levels, children }: INestedProps) => (
<div>{levels ? <NestedElement levels={levels - 1} children={children} /> : children}</div>
);
export const selectDeeplyNestedBenchmark: IBenchmark<void, boolean> = {
name: 'virtual store w/ highly nested elements (100 levels from the root)',
fixture: ArcRoot(
() => (
<React.Fragment>
<NestedElement levels={100}>
<div className="square box box1" tabIndex={0} />
</NestedElement>
<NestedElement levels={100}>
<div className="square box box2" tabIndex={0} />
</NestedElement>
</React.Fragment>
),
{
...defaultOptions(),
inputs: [mockInputMethod],
elementStore,
},
),
setup: state => {
elementStore.element = state.container.querySelector('.box1') as HTMLElement;
},
iterate: (_state, _setup, toggle) => {
mockInputMethod.observe.next({ button: toggle ? Button.Up : Button.Down });
return !toggle;
},
};

Просмотреть файл

@ -1,27 +0,0 @@
import * as React from 'react';
import { IBenchmark } from './index';
export const selectSimpleElementBenchmark: IBenchmark<[HTMLElement, HTMLElement], boolean> = {
name: 'Select simple element (base case, no arcade-machine)',
fixture: () => (
<React.Fragment>
<div className="square box box1" tabIndex={0} />
<div className="square box box2" tabIndex={0} />
</React.Fragment>
),
setup: state => {
return [
state.container.querySelector('.box1') as HTMLElement,
state.container.querySelector('.box2') as HTMLElement,
];
},
iterate: (_state, [a, b], toggle) => {
if (toggle) {
a.focus();
} else {
b.focus();
}
return !toggle;
},
};

Просмотреть файл

@ -1,35 +0,0 @@
import * as React from 'react';
import { ArcRoot, Button, defaultOptions, VirtualElementStore } from '../../../src';
import { IBenchmark } from './index';
import { Subject } from 'rxjs';
const mockInputMethod = {
observe: new Subject<{ button: Button }>(),
isSupported: true,
};
const elementStore = new VirtualElementStore();
export const virtualArcadeSelectBenchmark: IBenchmark<void, boolean> = {
name: 'arcade-machine focus with a virtual element store',
fixture: ArcRoot(
() => (
<React.Fragment>
<div className="square box box1" tabIndex={0} />
<div className="square box box2" tabIndex={0} />
</React.Fragment>
),
{
...defaultOptions(),
inputs: [mockInputMethod],
elementStore,
},
),
setup: state => {
elementStore.element = state.container.querySelector('.box1') as HTMLElement;
},
iterate: (_state, _setup, toggle) => {
mockInputMethod.observe.next({ button: toggle ? Button.Right : Button.Left });
return !toggle;
},
};

Просмотреть файл

@ -1,14 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>arcade-machine-react</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div id="app"></div>
<script src="./demo/index.js"></script>
</body>
</html>

Просмотреть файл

@ -1,33 +0,0 @@
import * as React from 'react';
import { render } from 'react-dom';
import { ArcRoot, defaultOptions, VirtualElementStore } from '../src';
import { FocusHooksDemo } from './components/FocusHooksDemo';
import { FocusInsideDemo } from './components/FocusInsideDemo';
import { FocusTrapsDemo } from './components/FocusTrapsDemo';
import { FocusHistoryDemo } from './components/FocusHistoryDemo';
import { FocusGridDemo } from './components/FocusGridDemo';
import { FocusHiddenDemo } from './components/FocusHiddenDemo';
import { Benchmarks } from './components/FocusBenchmarks';
import { FocusFormDemo } from './components/FocusFormDemo';
const DemoApp = ArcRoot(
() => (
<div>
<FocusHooksDemo />
<FocusInsideDemo />
<FocusTrapsDemo />
<FocusFormDemo />
<FocusHistoryDemo />
<FocusGridDemo />
<FocusHiddenDemo />
</div>
),
{ ...defaultOptions(), elementStore: new VirtualElementStore() },
);
const BenchmarkApp = () => <Benchmarks />;
render(
window.location.toString().includes('benchmarks') ? <BenchmarkApp /> : <DemoApp />,
document.getElementById('app'),
);

Просмотреть файл

@ -1,117 +0,0 @@
:host {
font-family: monospace;
max-width: 960px;
margin: 15px auto;
display: block;
}
.area {
border: 1px solid #000;
margin: 15px 0;
}
.area:after {
content: '';
display: block;
}
.area.arc--selected {
border-color: #f00;
}
.box-wrapper {
width: 100px;
display: inline-block;
}
.box {
margin: 15px;
background: #000;
color: #fff;
}
.box.arc-selected,
.box:focus {
background: #f00;
}
.square {
display: inline-block;
height: 50px;
width: 50px;
margin: 15px;
background: #000;
color: #fff;
}
.square.arc-selected,
.square:focus {
background: #f00;
}
form {
display: flex;
margin: 15px;
align-content: center;
}
form div {
margin-right: 5px;
}
input,
button,
textarea {
border: 1px solid #000;
padding: 5px 8px;
border-radius: 0;
box-shadow: 0;
outline: 0 !important;
}
input.arc-selected,
input:focus,
button.arc-selected,
button:focus,
textarea.arc-selected,
textarea:focus {
border-color: #f00;
}
.scroll-restriction {
overflow: auto;
height: 100px;
}
.dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
padding: 15px;
background: #fff;
}
.dialog button {
display: inline-block;
margin: 5px;
}
.visibility-test {
display: flex;
align-items: center;
height: 50px;
margin: 16px;
}
.visibility-test .case-name {
display: block;
width: 200px;
height: 50px;
border: 1px solid black;
margin: 16px;
}
.visibility-test .case-name.arc-selected,
.visibility-test .case-name:focus {
border-color: red;
}
table.benchmarks th {
text-align: left;
}
table.benchmarks td:nth-child(1) {
width: 200px;
}
table.benchmarks td:nth-child(2) {
width: 500px;
}

Просмотреть файл

@ -1,23 +0,0 @@
module.exports = {
entry: './demo/index.tsx',
devtool: 'source-map',
mode: 'development',
output: {
filename: './demo/index.js',
},
resolve: {
extensions: ['.ts', '.tsx', '.js'],
},
module: {
rules: [
{
test: /\.tsx?$/,
exclude: /node_modules/,
loader: 'ts-loader',
},
],
},
devServer: {
contentBase: __dirname,
},
};

79
docs/app.component.scss Normal file
Просмотреть файл

@ -0,0 +1,79 @@
.demo {
max-width: 1280px;
margin: 50px;
margin-left: 250px;
padding: 0 8px;
p {
line-height: 1.5rem;
}
p,
blockquote,
ul,
ol {
+ blockquote,
+ ul,
+ ol,
+ p {
margin-top: 1rem;
}
}
li {
margin: 0.5em 0;
ul,
ol {
margin: 0 2rem;
}
}
hr {
height: 1px;
border: none;
color: #ccc;
background-color: #ccc;
margin: 2em 0;
}
blockquote {
padding: 1rem;
margin-bottom: 1rem;
background: rgba(0, 0, 0, 0.05);
border-left: 2px solid #ccc;
}
dt {
font-weight: bold;
margin-bottom: 1rem;
}
dd {
margin-bottom: 2rem;
}
:global .prism-code {
margin: 1rem 0;
background: rgb(42, 39, 52);
padding: 8px 0;
:global .token-line {
padding: 2px 8px;
&:nth-child(odd) {
background: rgba(#fff, 0.02);
}
}
}
}
.table {
margin: 1rem 0;
border-collapse: collapse;
td {
border: 1px solid #ccc;
padding: 4px;
}
}

352
docs/app.tsx Normal file
Просмотреть файл

@ -0,0 +1,352 @@
import * as React from 'react';
import * as styles from './app.component.scss';
import { Demo } from './demo';
import { nav, Reference } from './nav/tree';
import { Title } from './nav/title';
import { Sidebar } from './nav/sidebar';
const navigation = nav('arcade-machine-react', {
basics: nav('The Basics', {
demo: nav('Demo: Hello, World!'),
}),
handlers: nav('Custom Handlers', {
demo: nav('Demo: Set Next Elements'),
}),
focusTraps: nav('Focus Traps', {
demo: nav('Demo: Modal with Focus Trap'),
}),
focusArea: nav('Focus Areas', {
demo: nav('Demo: Focus Areas'),
}),
focusExclude: nav('Focus Exclusion', {
demo: nav('Demo: Focus Exclusion'),
}),
scrollable: nav('Scrolling', {
demo: nav('Demo: Scrolling'),
}),
faq: nav('FAQ'),
});
export const App: React.FC = () => (
<>
<Sidebar node={navigation} />
<div className={styles.demo}>
<Title node={navigation} />
<p>
Arcade machine is an abstraction layer over gamepads for web-based platforms. It handles
directional navigation for the application, and includes rich React bindings for integration
with your application.
</p>
<Title node={navigation.basics} />
<p>
Arcade machine works both with keyboards and gamepads. If you have a controller, you can
plug it into your PC now, otherwise you can play with these examples using your keyboard.
</p>
<p>
To get started with arcade machine, you need to make two modifications to your application:
wrap the root of your application in the <code>ArcRoot</code> HOC, and set a{' '}
<code>tabIndex</code> property on all elements that should be focusable.
<sup>
<Reference node={navigation.faq}>Why?</Reference>
</sup>{' '}
The default focusable value is <code>tabIndex=0</code>.
</p>
<Title node={navigation.basics.demo} />
<p>
Here's a quick demonstration. You can navigate around the below example with your arrow
keys, or with a connected controlled.
</p>
<Demo name="hello-world" />
<Title node={navigation.handlers} />
<p>
You can handle focus events using <code>{'<ArcScope />'}</code>. You can specify elements
that should be focused after this one (by selector or the actual element), and additionally
add handlers that hook into incoming, outgoing, and button press events. This is the base
component on which many of the following components are built. The component takes these
properties:
</p>
<table className={styles.table}>
<tbody>
<tr>
<td>
<code>arcFocusLeft</code>
</td>
<td>
<code>undefined | string | HTMLElement</code>
</td>
<td>
Element or selector which should be focused when navigating to the left of this
component.
</td>
</tr>
<tr>
<td>
<code>arcFocusRight</code>
</td>
<td>
<code>undefined | string | HTMLElement</code>
</td>
<td>
Element or selector which should be focused when navigating to the right of this
component.
</td>
</tr>
<tr>
<td>
<code>arcFocusUp</code>
</td>
<td>
<code>undefined | string | HTMLElement</code>
</td>
<td>
Element or selector which should be focused when navigating to above this component.
</td>
</tr>
<tr>
<td>
<code>arcFocusDown</code>
</td>
<td>
<code>undefined | string | HTMLElement</code>
</td>
<td>
Element or selector which should be focused when navigating to below this component.
</td>
</tr>
<tr>
<td>
<code>onOutgoing</code>
</td>
<td>
<code>onOutgoing?(ev: ArcFocusEvent): void</code>
</td>
<td>
Called with an IArcEvent focus is about to leave this element or one of its children.
</td>
</tr>
<tr>
<td>
<code>onIncoming</code>
</td>
<td>
<code>onIncoming?(ev: ArcFocusEvent): void</code>
</td>
<td>
Called with an IArcEvent focus is about to enter this element or one of its children.
</td>
</tr>
<tr>
<td>
<code>onIncoming</code>
</td>
<td>
<code>onIncoming?(ev: ArcEvent): void</code>
</td>
<td>
Triggers when a button is pressed in the element or one of its children. This will
fire before the <code>onOutgoing</code> handler, for directional events.
</td>
</tr>
</tbody>
</table>
<Title node={navigation.handlers.demo} />
<p>
Here's an example of using overriding the "next" element. On these boxes, pressing up/down
will instead focus on adjact boxes. Normally, if no focusable element can be found next, it
would do nothing.
</p>
<Demo name="scope-override-focus" />
<Title node={navigation.focusTraps} />
<p>
Focus traps are used to force focus to stay within a subset of your application. This is
great for use in modals and overlays. You can optionally set the <code>focusIn</code> and{' '}
<code>focusOut</code> properties to HTML elements or selectors to specify where focus should
be given when entering and leaving the focus trap, respectively.
</p>
<table className={styles.table}>
<tbody>
<tr>
<td>
<code>focusIn</code>
</td>
<td>
<code>undefined | string | HTMLElement</code>
</td>
<td>Element or selector to give focus to when the focus trap is created.</td>
</tr>
<tr>
<td>
<code>focusOut</code>
</td>
<td>
<code>undefined | string | HTMLElement</code>
</td>
<td>
Element or selector to give focus to when the focus trap is released. If not provided,
the last focused element before the trap was created will be shown.
</td>
</tr>
</tbody>
</table>
<Title node={navigation.focusTraps.demo} />
<p>
Here, we trap focus inside the modal when it's open. We also use{' '}
<code>{`<ArcScope />`}</code> to close the modal when "back" (B on Xbox controllers, or
Escape on the keyboard) is pressed.
</p>
<Demo name="modal" />
<Title node={navigation.focusArea} />
<p>
Focus areas are sections of the page which act as opaque focusable 'blocks', and then
transfer their focus to a child. This is a common pattern seen if you have multiple rows of
content, and want focus to transfer to the first element in each row when navigating down
between rows. It takes, as its property, a <code>focusIn</code> property, which is a
selector or HTML element to give focus to.
</p>
<table className={styles.table}>
<tbody>
<tr>
<td>
<code>focusIn</code>
</td>
<td>
<code>undefined | string | HTMLElement</code>
</td>
<td>Element or selector to give focus to when the focus trap is created.</td>
</tr>
</tbody>
</table>
<Title node={navigation.focusArea.demo} />
<p>
In this demo, regardless of where you are in the previous row, focus is always transferred
to the left-most element of each node when you navigate into it.
</p>
<Demo name="focus-areas" />
<Title node={navigation.focusExclude} />
<p>
Focus exclusion areas can prevent their contents from being focused on. Simple enough. Good
if you have content that's loading in or simply disabled. By default, it'll only exclude its
direct child node, and it will prevent the entire subtree from being focused on if{' '}
<code>deep</code> is set.
</p>
<table className={styles.table}>
<tbody>
<tr>
<td>
<code>active</code>
</td>
<td>
<code>undefined | boolean</code>
</td>
<td>Whether the exclusion is active. Defaults to true if not provided.</td>
</tr>
</tbody>
<tbody>
<tr>
<td>
<code>deep</code>
</td>
<td>
<code>undefined | boolean</code>
</td>
<td>Whether to exclude the entire subtree contained in this node.</td>
</tr>
</tbody>
</table>
<Title node={navigation.focusExclude.demo} />
<p>
In this demo, regardless of where you are in the previous row, focus is always transferred
to the left-most element of each node when you navigate into it.
</p>
<Demo name="focus-exclude" />
<Title node={navigation.scrollable} />
<p>
By default, browsers will automatically scroll whenever to the focused element when you use
native focus. We have scrolling functionality built-in in the event you use the virtual
element store (todo: document this), rather than native focus. Our implementation also
supports smooth scrolling out of the box.
</p>
<p>
To use arcade machine's scrolling, you wrap your scroll container with the{' '}
<code>{`<Scrollable />`}</code> element. By default, it'll scroll vertically, but you can
override this via properties.
</p>
<table className={styles.table}>
<tbody>
<tr>
<td>
<code>vertical</code>
</td>
<td>
<code>undefined | boolean</code>
</td>
<td>Whether to scroll vertically, defaults to true.</td>
</tr>
</tbody>
<tbody>
<tr>
<td>
<code>horizontal</code>
</td>
<td>
<code>undefined | boolean</code>
</td>
<td>Whether to scroll horizontally, defaults to true false.</td>
</tr>
</tbody>
</table>
<Title node={navigation.scrollable.demo} />
<Demo name="focus-scrollable" />
<Title node={navigation.faq} />
<dl>
<dt>
<a id="why-tabindex" /> Why do I need a <code>tabIndex</code> on focusable items?
</dt>
<dd>
The DOM API lacks an efficient way to query focusable elements. Similar directional
navigation implementations get around this by querying all elements on focus change, and
manually looking for ones that should be focusable. We use the tabIndex property as a flag
which allows us to efficiently filter out the noise.
</dd>
</dl>
</div>
</>
);

48
docs/demo.component.scss Normal file
Просмотреть файл

@ -0,0 +1,48 @@
.wrapper {
margin-top: 1rem;
background: #eee;
}
.tabs {
display: flex;
li {
list-style-type: none;
margin: 0;
padding: 0.75rem 2rem;
color: #666;
font-size: 0.9rem;
cursor: pointer;
&.active {
background: #ccc;
color: #000;
}
}
}
.code,
.demo,
.demoHidden {
height: 500px;
overflow-y: auto;
}
.demo {
display: flex;
align-items: center;
justify-content: center;
}
.demoHidden {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2em;
cursor: pointer;
color: #aaa;
}
.code :global .prism-code {
margin: 0;
}

92
docs/demo.tsx Normal file
Просмотреть файл

@ -0,0 +1,92 @@
import * as React from 'react';
import { Highlighted } from './highlighted';
import * as styles from './demo.component.scss';
interface IProps {
name: string;
}
const massageSource = (source: string) => {
return source.replace(/(\.\.\/)+src/g, '@mixer/arcade-machine-react');
};
const enum Tab {
DemoHidden,
DemoVisible,
Code,
}
interface IState {
tab: Tab;
}
let hidePrevious: (() => void) | undefined;
export class Demo extends React.PureComponent<IProps, IState> {
public state: IState = { tab: Tab.DemoHidden };
public render() {
const { default: Component } = require(`./demos/${this.props.name}`);
const { default: source } = require(`!!raw-loader!./demos/${this.props.name}`);
return (
<div className={styles.wrapper}>
<ol className={styles.tabs}>
<li
className={this.state.tab !== Tab.Code ? styles.active : undefined}
onClick={this.openDemo}
>
Demo
</li>
<li
className={this.state.tab === Tab.Code ? styles.active : undefined}
onClick={this.openCode}
>
Source
</li>
</ol>
{this.state.tab === Tab.Code && (
<div className={styles.code}>
<Highlighted code={massageSource(source)} />
</div>
)}
{this.state.tab === Tab.DemoVisible && (
<div className={styles.demo}>
<Component />
</div>
)}
{this.state.tab === Tab.DemoHidden && (
<div className={styles.demoHidden} onClick={this.openDemo}>
Click to Open Demo
</div>
)}
</div>
);
}
private readonly hideDemo = () => {
this.setState({ tab: Tab.DemoHidden });
if (hidePrevious === this.hideDemo) {
hidePrevious = undefined;
}
};
private readonly openDemo = () => {
if (!hidePrevious || hidePrevious === this.hideDemo) {
this.setState({ tab: Tab.DemoVisible });
hidePrevious = this.hideDemo;
} else {
hidePrevious();
setTimeout(this.openDemo, 100);
}
};
private readonly openCode = () => {
this.setState({ tab: Tab.Code });
if (hidePrevious === this.hideDemo) {
hidePrevious = undefined;
}
};
}

23
docs/demos/demo-utils.tsx Normal file
Просмотреть файл

@ -0,0 +1,23 @@
import * as React from 'react';
import * as styles from './hello-world.component.scss';
export const repeat = <T extends {}>(n: number, fn: (i: number) => T): T[] => {
const data: T[] = [];
for (let i = 0; i < n; i++) {
data.push(fn(i));
}
return data;
};
export const basicGrid = (width: number, height: number) => (
<>
{repeat(height, i => (
<div className={styles.row} key={i}>
{repeat(width, k => (
<div className={styles.box} key={k} tabIndex={0} />
))}
</div>
))}
</>
);

Просмотреть файл

@ -0,0 +1,22 @@
import * as React from 'react';
import { ArcRoot, defaultOptions, FocusArea } from '../../src';
import * as styles from './hello-world.component.scss';
import { repeat } from './demo-utils';
export default ArcRoot(
() => (
<>
{repeat(3, i => (
<React.Fragment key={i}>
<b>Content Row {i + 1}</b>
<FocusArea className={styles.row} focusIn="[data-nth='0']">
{repeat(5, k => (
<div className={styles.box} data-nth={k} key={k} tabIndex={0} />
))}
</FocusArea>
</React.Fragment>
))}
</>
),
defaultOptions(),
);

Просмотреть файл

@ -0,0 +1,39 @@
import * as React from 'react';
import { ArcRoot, defaultOptions, FocusExclude } from '../../src';
import * as styles from './hello-world.component.scss';
import { repeat } from './demo-utils';
export default ArcRoot(() => {
const [excludeActive, setActive] = React.useState(true);
return (
<>
<b>Normal Row</b>
<div className={styles.row}>
{repeat(5, k => (
<div className={styles.box} key={k} tabIndex={0} />
))}
</div>
<b>Excluded Row (active={String(excludeActive)})</b>
<FocusExclude deep active={excludeActive}>
<div className={styles.row}>
{repeat(5, k => (
<div className={styles.box} key={k} tabIndex={0} />
))}
</div>
</FocusExclude>
<b>Normal Row</b>
<div className={styles.row}>
{repeat(5, k => (
<div className={styles.box} key={k} tabIndex={0} />
))}
</div>
<input
type="checkbox"
checked={excludeActive}
onChange={ev => setActive(ev.target.checked)}
/>
Exclusion is active?
</>
);
}, defaultOptions());

Просмотреть файл

@ -0,0 +1,21 @@
import * as React from 'react';
import { ArcRoot, defaultOptions, Scrollable, VirtualElementStore } from '../../src';
import { basicGrid } from './demo-utils';
export default ArcRoot(
() => (
<>
<b>Vertical Scrolling</b>
<Scrollable>
<div style={{ overflowY: 'scroll', height: 200, width: 500 }}>{basicGrid(5, 5)}</div>
</Scrollable>
<b>Horizontal Scrolling</b>
<Scrollable horizontal vertical={false}>
<div style={{ overflowX: 'scroll', height: 200, width: 500 }}>
<div style={{ width: 2000 }}>{basicGrid(15, 2)}</div>
</div>
</Scrollable>
</>
),
{ ...defaultOptions(), elementStore: new VirtualElementStore() },
);

Просмотреть файл

@ -0,0 +1,19 @@
.row {
display: flex;
}
.box {
margin: 1rem;
width: 50px;
height: 50px;
background: #ccc;
&:focus,
&.arc-selected {
background: red;
}
}
:global .arc-selected {
background: red;
}

Просмотреть файл

@ -0,0 +1,19 @@
import * as React from 'react';
import { ArcRoot, defaultOptions } from '../../src';
import * as styles from './hello-world.component.scss';
import { repeat } from './demo-utils';
export default ArcRoot(
() => (
<>
{repeat(3, i => (
<div className={styles.row} key={i}>
{repeat(5, k => (
<div className={styles.box} key={k} tabIndex={0} />
))}
</div>
))}
</>
),
defaultOptions(),
);

Просмотреть файл

@ -0,0 +1,34 @@
.demo {
position: relative;
width: 500px;
height: 300px;
button {
background: #aaa;
padding: 8px;
&:focus {
background: red;
color: #fff;
outline: 0;
}
}
}
.modal {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(#000, 0.5);
}
.inner {
padding: 8px;
background: #fff;
border: 1px solid #000;
}

32
docs/demos/modal.tsx Normal file
Просмотреть файл

@ -0,0 +1,32 @@
import * as React from 'react';
import { ArcRoot, defaultOptions, FocusTrap, ArcScope, Button } from '../../src';
import * as styles from './modal.component.scss';
import { basicGrid } from './demo-utils';
export default ArcRoot(() => {
const [open, setOpen] = React.useState(false);
return (
<div className={styles.demo}>
<button onClick={() => setOpen(true)} tabIndex={0}>
Open Modal
</button>
{basicGrid(5, 2)}
{open && (
<FocusTrap>
<ArcScope onButton={ev => ev.event === Button.Back && setOpen(false)}>
<div className={styles.modal}>
<div className={styles.inner}>
This is a modal!
{basicGrid(5, 2)}
<button onClick={() => setOpen(false)} tabIndex={0}>
Close
</button>
</div>
</div>
</ArcScope>
</FocusTrap>
)}
</div>
);
}, defaultOptions());

Просмотреть файл

@ -0,0 +1,22 @@
import * as React from 'react';
import { ArcRoot, defaultOptions, ArcScope } from '../../src';
import * as styles from './hello-world.component.scss';
export default ArcRoot(
() => (
<>
<div className={styles.row}>
<ArcScope arcFocusDown={'#center'} arcFocusUp={'#right'}>
<div tabIndex={0} id="left" className={styles.box} />
</ArcScope>
<ArcScope arcFocusDown={'#right'} arcFocusUp={'#left'}>
<div tabIndex={0} id="center" className={styles.box} />
</ArcScope>
<ArcScope arcFocusDown={'#left'} arcFocusUp={'#center'}>
<div tabIndex={0} id="right" className={styles.box} />
</ArcScope>
</div>
</>
),
defaultOptions(),
);

18
docs/highlighted.tsx Normal file
Просмотреть файл

@ -0,0 +1,18 @@
import * as React from 'react';
import Highlight, { defaultProps } from 'prism-react-renderer';
export const Highlighted: React.FC<{ code: string }> = props => (
<Highlight {...defaultProps} code={props.code} language="jsx">
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre className={className} style={style}>
{tokens.map((line, i) => (
<div {...getLineProps({ line, key: i })}>
{line.map((token, key) => (
<span {...getTokenProps({ token, key })} />
))}
</div>
))}
</pre>
)}
</Highlight>
);

40
docs/index.scss Normal file
Просмотреть файл

@ -0,0 +1,40 @@
$font-prefix: '../node_modules/@ibm/plex/';
@import '../node_modules/@ibm/plex/scss/mono/regular/latin1';
@import '../node_modules/@ibm/plex/scss/mono/bold/latin1';
@import '../node_modules/@ibm/plex/scss/serif/semibold/latin1';
@import '../node_modules/@ibm/plex/scss/sans/regular/latin1';
:root {
--font-family-sans: 'IBM Plex Sans', sans-serif;
--font-family-serif: 'IBM Plex Serif', serif;
--font-family-mono: 'IBM Plex Mono', monospace;
}
* {
margin: 0;
padding: 0;
}
body {
font-family: var(--font-family-sans);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-family-serif);
font-weight: normal;
margin: 3rem 0 1rem;
}
h1 {
font-size: 3rem;
}
h2 {
font-size: 2rem;
}
pre, code {
font-family: var(--font-family-mono);
}

14
docs/index.tsx Normal file
Просмотреть файл

@ -0,0 +1,14 @@
/// <reference path="types/styles.d.ts" />
import * as React from 'react';
import { render } from 'react-dom';
import { App } from './app';
import '../node_modules/normalize.css/normalize.css';
import '../node_modules/highlight.js/styles/github.css';
import './index.scss';
const target = document.createElement('div');
document.body.appendChild(target);
render(<App />, target);

Просмотреть файл

@ -0,0 +1,30 @@
.sidebar {
position: fixed;
top: 8px;
left: 8px;
width: 250px;
font-size: 0.8em;
ol {
margin-left: 0.5rem;
}
li {
list-style-type: none;
}
a {
color: #000;
text-decoration: none;
display: inline-block;
margin: 0.5rem 0;
&:hover {
color: #666;
}
&.active {
font-weight: bold;
}
}
}

87
docs/nav/sidebar.tsx Normal file
Просмотреть файл

@ -0,0 +1,87 @@
import * as React from 'react';
import { NavNode, Reference, flatten } from './tree';
import * as styles from './sidebar.component.scss';
import { Subscription, fromEvent } from 'rxjs';
import { map, filter, throttleTime } from 'rxjs/operators';
const SidebarNode: React.FC<{ node: NavNode<any>; active: string | null }> = ({ node, active }) => {
const { title, link, depth, ...children } = node;
const childKeys = Object.keys(children);
return (
<>
<Reference className={active === link ? styles.active : undefined} node={node}>
{title}
</Reference>
{childKeys.length > 0 && (
<ol>
{childKeys.map(key => (
<li key={key}>
<SidebarNode node={children[key]} active={active} />
</li>
))}
</ol>
)}
</>
);
};
export class Sidebar extends React.PureComponent<
{ node: NavNode<any> },
{ active: string | null }
> {
public state: { active: string | null } = { active: null };
private subscriptions: Subscription[] = [];
private cachedPositions?: [NavNode<any>, number][];
public componentDidMount() {
this.subscriptions.push(
fromEvent(window, 'scroll', { passive: true })
.pipe(
throttleTime(10),
map(() => {
const items = this.getPositions();
const best = items.find(n => n[1] < window.scrollY + 50);
return best ? best[0] : items[items.length - 1][0];
}),
filter(node => node !== this.state.active),
)
.subscribe(node => {
this.setState({ active: node.link });
}),
);
this.subscriptions.push(
fromEvent(window, 'resize').subscribe(() => (this.cachedPositions = undefined)),
);
}
public componentWillUnmount() {
this.subscriptions.forEach(s => s.unsubscribe());
}
public render() {
return (
<div className={styles.sidebar}>
<SidebarNode node={this.props.node} active={this.state.active} />
</div>
);
}
private getPositions = () => {
if (this.cachedPositions) {
return this.cachedPositions;
}
this.cachedPositions = [];
for (const node of flatten(this.props.node)) {
const el = document.getElementById(node.link);
if (el) {
this.cachedPositions.push([node, el.offsetTop]);
}
}
this.cachedPositions.sort((a, b) => b[1] - a[1]);
return this.cachedPositions;
};
}

5
docs/nav/title.tsx Normal file
Просмотреть файл

@ -0,0 +1,5 @@
import * as React from 'react';
import { NavNode } from './tree';
export const Title: React.FC<{ node: NavNode<any> }> = ({ node }) =>
React.createElement(`h${node.depth}`, { id: node.link }, [node.title]);

63
docs/nav/tree.tsx Normal file
Просмотреть файл

@ -0,0 +1,63 @@
import * as React from 'react';
export interface NavTree {}
export type NavNode<T extends { [key: string]: NavNode<any> }> = {
title: string;
link: string;
depth: number;
} & T;
function increaseDepth<T extends { [key: string]: NavNode<any> }>(node: NavNode<T>): NavNode<T> {
const { title, link, depth, ...children } = node;
const out: any = { title, link, depth: depth + 1 };
for (const key of Object.keys(children)) {
out[key] = increaseDepth(children[key]);
}
return out;
}
/**
* Flattens the nav tree.
*/
export const flatten = (node: NavNode<any>): NavNode<{}>[] => {
const out: NavNode<{}>[] = [];
const queue = [node];
let next: NavNode<any> | null;
while ((next = queue.pop())) {
const { title, link, depth, ...children } = next;
out.push({ title, link, depth });
for (const key of Object.keys(children)) {
queue.push(children[key]);
}
}
return out;
};
/**
* Creates a new navigation node.
*/
export const nav = <T extends { [key: string]: NavNode<any> }>(
title: string,
children?: T,
): NavNode<T> =>
increaseDepth<T>({
title,
link: title.toLowerCase().replace(/[^a-z0-9]/g, '-'),
depth: 0,
...children,
} as NavNode<T>);
export const Reference: React.FC<{ node: NavNode<any>; className?: string }> = ({
node,
className,
children,
}) => (
<a className={className} href={`#${node.link}`}>
{children}
</a>
);

1
docs/types/styles.d.ts поставляемый Normal file
Просмотреть файл

@ -0,0 +1 @@
declare module '*.scss';

68
docs/webpack.config.js Normal file
Просмотреть файл

@ -0,0 +1,68 @@
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { resolve } = require('path');
module.exports = {
entry: './docs/index.tsx',
devtool: 'source-map',
mode: 'development',
output: {
filename: 'bundle.js',
path: resolve(__dirname, '..', 'dist', 'docs'),
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.woff', '.woff2'],
},
module: {
rules: [
{
test: /\.tsx?$/,
exclude: /node_modules/,
loader: 'ts-loader',
},
{
sideEffects: true,
include: resolve(__dirname, 'index.scss'),
use: ['style-loader', 'css-loader', 'sass-loader'],
},
{
sideEffects: true,
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.component.scss$/,
use: [
{
loader: 'style-loader',
},
{
loader: 'css-loader',
options: {
modules: true,
localIdentName: '[local]__[hash:base64:5]',
},
},
{
loader: 'sass-loader',
},
],
},
{
test: /\.(png|jpg|gif|woff|woff2)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 8192,
},
},
],
},
],
},
plugins: [
new HtmlWebpackPlugin({
title: 'Arcade Machine Documentation',
}),
],
};

Просмотреть файл

@ -0,0 +1,5 @@
module.exports = {
...require('./webpack.config'),
mode: 'production',
devtool: false,
};

1436
package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -9,27 +9,33 @@
"test": "npm-run-all -p lint:ts test:unit test:fmt",
"test:unit": "karma start test/karma.config.js --single-run",
"test:watch": "karma start test/karma.config.js --watch",
"test:fmt": "prettier --list-different \"{src,demo}/**/*.{json,ts,tsx}\"",
"start": "webpack-dev-server --config demo/webpack.config.js",
"test:fmt": "prettier --list-different \"{src,docs}/**/*.{json,ts,tsx}\"",
"start": "webpack-dev-server --config docs/webpack.config.js",
"build": "tsc && tsc -p tsconfig.cjs.json",
"build:docs": "webpack --config docs/webpack.production.js",
"prepare": "npm run build",
"lint:ts": "tslint --project tsconfig.json --fix \"src/**/*.{ts,tsx}\"",
"fmt": "prettier --write \"{src,demo}/**/*.{json,ts,tsx}\" && npm run lint:ts -- --fix"
"fmt": "prettier --write \"{src,docs}/**/*.{json,ts,tsx}\" && npm run lint:ts -- --fix"
},
"author": "Connor Peet <connor@peet.io>",
"license": "MIT",
"devDependencies": {
"@ibm/plex": "^2.0.0",
"@types/benchmark": "^1.0.31",
"@types/chai": "^4.1.7",
"@types/enzyme": "^3.9.1",
"@types/mocha": "^5.2.6",
"@types/react": "^16.8.14",
"@types/react-dom": "^16.8.4",
"@types/react-highlight": "^0.12.1",
"@types/winrt-uwp": "0.0.19",
"benchmark": "^2.1.4",
"chai": "^4.2.0",
"css-loader": "^2.1.1",
"enzyme": "^3.9.0",
"enzyme-adapter-react-16": "^1.12.1",
"file-loader": "^4.0.0",
"html-webpack-plugin": "^3.2.0",
"karma": "^4.1.0",
"karma-chrome-launcher": "^2.2.0",
"karma-mocha": "^1.3.0",
@ -38,15 +44,23 @@
"karma-typescript": "^4.0.0",
"karma-webpack": "^3.0.5",
"mocha": "^6.1.4",
"node-sass": "^4.12.0",
"normalize.css": "^8.0.1",
"npm-run-all": "^4.1.5",
"prettier": "^1.17.0",
"prism-react-renderer": "^0.1.6",
"raw-loader": "^3.0.0",
"react-dom": "^16.8.6",
"react-highlight": "^0.12.0",
"rxjs": "^6.2.1",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"ts-loader": "^5.3.3",
"tslint": "^5.16.0",
"tslint-config-prettier": "^1.18.0",
"tslint-react": "^4.0.0",
"typescript": "^3.4.4",
"url-loader": "^2.0.0",
"webpack": "^4.30.0",
"webpack-cli": "^3.3.0",
"webpack-dev-server": "^3.3.1"

Просмотреть файл

@ -9,6 +9,7 @@ import { instance } from '../singleton';
*/
export class FocusExclude extends React.PureComponent<{
children: React.ReactNode;
active?: boolean;
deep?: boolean;
}> {
/**
@ -27,7 +28,7 @@ export class FocusExclude extends React.PureComponent<{
instance.getServices().stateContainer.add(this, {
element,
onIncoming: ev => {
if (!ev.next) {
if (!ev.next || this.props.active === false) {
return;
}
@ -43,7 +44,10 @@ export class FocusExclude extends React.PureComponent<{
}
public componentWillUnmount() {
instance.getServices().stateContainer.remove(this, this.node);
const services = instance.maybeGetServices();
if (services) {
services.stateContainer.remove(this, this.node);
}
}
public render() {

Просмотреть файл

@ -2,6 +2,11 @@ import * as React from 'react';
import { findElement, findFocusable } from '../internal-types';
import { instance } from '../singleton';
export type FocusAreaProps = {
children: React.ReactNode;
focusIn?: HTMLElement | string;
} & React.HTMLAttributes<HTMLDivElement>;
/**
* The ArcFocusArea acts as a virtual focus element which transfers focus
* to child. Take, for example, a list of lists, like this:
@ -37,10 +42,7 @@ import { instance } from '../singleton';
* {myContent.map(content => <ContentElement data={content} />)}
* </FocusArea>
*/
export class FocusArea extends React.PureComponent<{
children: React.ReactNode;
focusIn?: HTMLElement | string;
}> {
export class FocusArea extends React.PureComponent<FocusAreaProps> {
private containerRef = React.createRef<HTMLDivElement>();
public componentDidMount() {
@ -66,12 +68,17 @@ export class FocusArea extends React.PureComponent<{
}
public componentWillUnmount() {
instance.getServices().stateContainer.remove(this, this.containerRef.current!);
const services = instance.maybeGetServices();
if (services && this.containerRef.current) {
services.stateContainer.remove(this, this.containerRef.current);
}
}
public render() {
const { children, focusIn, ...htmlProps } = this.props;
return (
<div tabIndex={0} ref={this.containerRef}>
<div tabIndex={0} {...htmlProps} ref={this.containerRef}>
{this.props.children}
</div>
);

Просмотреть файл

@ -51,29 +51,26 @@ export class FocusTrap extends React.PureComponent<IFocusTrapProps> {
}
});
instance.getServices().stateContainer.add(this, {
element,
onOutgoing: ev => {
if (ev.next && !element.contains(ev.next)) {
ev.preventDefault();
}
},
});
instance.getServices().root.narrow(element);
}
public componentWillUnmount() {
const { stateContainer, elementStore } = instance.getServices();
stateContainer.remove(this, this.containerRef.current!);
const services = instance.maybeGetServices();
if (!services) {
return;
}
services.root.restore(this.containerRef.current!);
if (this.props.focusOut) {
const target = findElement(document.body, this.props.focusOut);
if (target) {
elementStore.element = target;
services.elementStore.element = target;
return;
}
}
elementStore.element = this.previouslyFocusedElement;
services.elementStore.element = this.previouslyFocusedElement;
}
public render() {

Просмотреть файл

@ -11,6 +11,7 @@ import { InputService } from '../input';
import { GamepadInput } from '../input/gamepad-input';
import { IInputMethod } from '../input/input-method';
import { KeyboardInput } from '../input/keyboard-input';
import { RootStore } from '../root-store';
import { IScrollingAlgorithm, ScrollExecutor } from '../scroll';
import { NativeSmoothScrollingAlgorithm } from '../scroll/native-smooth-scrolling';
import { ScrollRegistry } from '../scroll/scroll-registry';
@ -44,6 +45,10 @@ export function defaultOptions(): IRootOptions {
};
}
interface IState {
showChildren: boolean;
}
/**
* Component for defining the root of the arcade-machine. This should be wrapped
* around the root of your application, or its content. Only components
@ -57,37 +62,21 @@ export function defaultOptions(): IRootOptions {
*
* export default ArcRoot(MyAppContent);
*/
class Root extends React.PureComponent<IRootOptions> {
export class Root extends React.PureComponent<IRootOptions, IState> {
public state = { showChildren: false };
private focus?: FocusService;
private stateContainer = new StateContainer();
private scrollRegistry = new ScrollRegistry();
private rootRef = React.createRef<HTMLDivElement>();
private unmounted = new ReplaySubject<void>(1);
constructor(props: IRootOptions) {
super(props);
instance.setServices({
elementStore: this.props.elementStore,
scrollRegistry: this.scrollRegistry,
stateContainer: this.stateContainer,
});
}
public componentDidMount() {
const focus = new FocusService(
this.stateContainer,
this.rootRef.current!,
this.props.focus,
this.props.elementStore,
new ScrollExecutor(this.scrollRegistry, this.props.scrolling),
);
const input = new InputService(this.props.inputs);
input.events.pipe(takeUntil(this.unmounted)).subscribe(({ button, event }) => {
if (focus.sendButton(button) && event) {
event.preventDefault();
}
});
public componentDidUpdate(_: IRootOptions, prevState: IState) {
if (this.state.showChildren && !prevState.showChildren && this.focus) {
this.focus.setDefaultFocus();
}
}
public componentWillUnmount() {
@ -96,8 +85,40 @@ class Root extends React.PureComponent<IRootOptions> {
}
public render() {
return <div ref={this.rootRef}>{this.props.children}</div>;
return <div ref={this.setRoot}>{this.state.showChildren && this.props.children}</div>;
}
private readonly setRoot = (rootElement: HTMLDivElement | null) => {
if (!rootElement) {
return;
}
const root = new RootStore(rootElement);
instance.setServices({
elementStore: this.props.elementStore,
root,
scrollRegistry: this.scrollRegistry,
stateContainer: this.stateContainer,
});
const focus = (this.focus = new FocusService(
this.stateContainer,
root,
this.props.focus,
this.props.elementStore,
new ScrollExecutor(this.scrollRegistry, this.props.scrolling),
));
const input = new InputService(this.props.inputs);
input.events.pipe(takeUntil(this.unmounted)).subscribe(({ button, event }) => {
if (focus.sendButton(button) && event) {
event.preventDefault();
}
});
this.setState({ showChildren: true });
};
}
/**

Просмотреть файл

@ -39,7 +39,10 @@ export class ArcScope extends React.PureComponent<Partial<IArcHandler>> {
}
public componentWillUnmount() {
instance.getServices().stateContainer.remove(this, this.node);
const services = instance.maybeGetServices();
if (services) {
services.stateContainer.remove(this, this.node);
}
}
public render() {

Просмотреть файл

@ -10,8 +10,8 @@ import { instance } from '../singleton';
*/
export class Scrollable extends React.PureComponent<{
children: React.ReactNode;
horizontal: boolean;
vertical: boolean;
horizontal?: boolean;
vertical?: boolean;
}> {
/**
* The node this element is attached to.
@ -29,13 +29,16 @@ export class Scrollable extends React.PureComponent<{
this.node = element;
instance.getServices().scrollRegistry.add({
element,
horizontal: this.props.horizontal,
vertical: this.props.vertical,
horizontal: this.props.horizontal === true,
vertical: this.props.vertical !== false,
});
}
public componentWillUnmount() {
instance.getServices().scrollRegistry.remove(this.node);
const services = instance.maybeGetServices();
if (services) {
services.scrollRegistry.remove(this.node);
}
}
public render() {

Просмотреть файл

@ -1,10 +1,11 @@
import { ArcEvent } from './arc-event';
import { ArcFocusEvent } from './arc-focus-event';
import { FocusContext, IElementStore, IFocusStrategy } from './focus';
import { isFocusable, isNodeAttached, roundRect } from './focus/dom-utils';
import { isFocusable, roundRect } from './focus/dom-utils';
import { isForForm } from './focus/is-for-form';
import { propogationStoped, resetEvent } from './internal-types';
import { Button, IArcHandler, isDirectional } from './model';
import { RootStore } from './root-store';
import { ScrollExecutor } from './scroll';
import { StateContainer } from './state/state-container';
@ -33,7 +34,7 @@ export class FocusService {
constructor(
private readonly registry: StateContainer,
private readonly root: HTMLElement,
private readonly root: RootStore,
private readonly strategies: IFocusStrategy[],
private readonly elementStore: IElementStore,
private readonly scroller: ScrollExecutor,
@ -45,7 +46,7 @@ export class FocusService {
* Wrapper around moveFocus to dispatch arcselectingnode event
*/
public selectNode(next: HTMLElement) {
if (!this.root.contains(next)) {
if (!this.root.element.contains(next)) {
return;
}
@ -118,11 +119,11 @@ export class FocusService {
});
}
const context = new FocusContext(this.root, direction, this.strategies, {
const context = new FocusContext(this.root.element, direction, this.strategies, {
activeElement: selected,
directive: this.registry.find(selected),
previousElement: this.previousSelectedElement,
referenceRect: this.root.contains(selected)
referenceRect: this.root.element.contains(selected)
? selected.getBoundingClientRect()
: this.referenceRect,
});
@ -131,7 +132,7 @@ export class FocusService {
context,
directive: this.registry.find(selected),
event: direction,
next: context ? context.find(this.root) : null,
next: context ? context.find(this.root.element) : null,
target: selected,
});
}
@ -142,9 +143,7 @@ export class FocusService {
*/
private bubbleInOut(ev: ArcFocusEvent, selected: HTMLElement): boolean {
const originalNext = ev.next;
if (isNodeAttached(selected, this.root)) {
this.bubbleEvent(ev, 'onOutgoing', selected);
}
this.bubbleEvent(ev, 'onOutgoing', selected);
// Abort if the user handled
if (ev.defaultPrevented || originalNext !== ev.next) {
@ -184,7 +183,11 @@ export class FocusService {
trigger: keyof IArcHandler,
source: HTMLElement | null,
): ArcEvent {
for (let el = source; !propogationStoped(ev) && el !== this.root && el; el = el.parentElement) {
for (
let el = source;
!propogationStoped(ev) && el !== this.root.element && el;
el = el.parentElement
) {
if (el === undefined) {
// tslint:disable-next-line
console.warn(
@ -212,8 +215,8 @@ export class FocusService {
/**
* Reset the focus if arcade-machine wanders out of root
*/
private setDefaultFocus() {
const focusableElems = this.root.querySelectorAll('[tabIndex]');
public setDefaultFocus() {
const focusableElems = this.root.element.querySelectorAll('[tabIndex]');
// tslint:disable-next-line
for (let i = 0; i < focusableElems.length; i += 1) {

Просмотреть файл

@ -1,13 +1,14 @@
export * from './components/arc-autofocus';
export * from './components/arc-root';
export * from './components/arc-focus-area';
export * from './components/arc-scope';
export * from './components/arc-focus-trap';
export * from './components/arc-exclude';
export * from './model';
export * from './arc-event';
export * from './focus/virtual-element-store';
export * from './focus/native-element-store';
export * from './components/arc-autofocus';
export * from './components/arc-exclude';
export * from './components/arc-focus-area';
export * from './components/arc-focus-trap';
export * from './components/arc-root';
export * from './components/arc-scope';
export * from './components/arc-scrollable';
export * from './focus/focus-by-distance';
export * from './focus/focus-by-raycast';
export * from './focus/focus-by-registry';
export * from './focus/native-element-store';
export * from './focus/virtual-element-store';
export * from './model';

34
src/root-store.ts Normal file
Просмотреть файл

@ -0,0 +1,34 @@
/**
* Holds the current root element.
*/
export class RootStore {
private readonly roots: HTMLElement[];
constructor(root: HTMLElement) {
this.roots = [root];
}
public get element() {
return this.roots[this.roots.length - 1];
}
/**
* Scopes to the given new root.
*/
public narrow(root: HTMLElement) {
this.roots.push(root);
}
/**
* Restores a scoped element.
*/
public restore(fromRoot: HTMLElement) {
const index = this.roots.lastIndexOf(fromRoot);
if (index < 1) {
// tslint:disable-next-line
console.warn('arcade-machine: attempted to release a root we did not own');
}
this.roots.splice(index, 1);
}
}

Просмотреть файл

@ -27,14 +27,15 @@ export class ScrollExecutor {
* are computed and passed into it.
*/
public scrollTo(targetElement: HTMLElement, referenceRect: ClientRect) {
const horizontal = referenceRect.left < 0 || referenceRect.right > window.innerWidth;
if (!horizontal && referenceRect.top >= 0 && referenceRect.bottom < window.innerHeight) {
return;
let parent: Readonly<IScrollableContainer> | undefined;
for (const candidate of this.registry.getScrollContainers()) {
if (candidate.element.contains(targetElement)) {
if (!parent || parent.element.contains(candidate.element)) {
parent = candidate;
}
}
}
const parent = this.registry
.getScrollContainers()
.find(({ element }) => element.contains(targetElement));
if (!parent) {
return;
}

Просмотреть файл

@ -15,8 +15,9 @@ export class NativeSmoothScrollingAlgorithm implements IScrollingAlgorithm {
targetElement: HTMLElement,
rect: ClientRect,
): void {
const horizontal = horizontalDelta(rect);
const vertical = verticalDelta(rect);
const reference = parent.element.getBoundingClientRect();
const horizontal = horizontalDelta(rect, reference);
const vertical = verticalDelta(rect, reference);
try {
if (parent.vertical && vertical) {

Просмотреть файл

@ -59,9 +59,10 @@ export class SmoothScrollingAlgorithm implements IScrollingAlgorithm {
requestAnimationFrame(run);
};
const reference = parent.element.getBoundingClientRect();
if (parent.horizontal) {
animate(
horizontalDelta(referenceRect),
horizontalDelta(referenceRect, reference),
parent.element.scrollLeft,
x => (parent.element.scrollLeft = x),
);
@ -69,7 +70,7 @@ export class SmoothScrollingAlgorithm implements IScrollingAlgorithm {
if (parent.vertical) {
animate(
verticalDelta(referenceRect),
verticalDelta(referenceRect, reference),
parent.element.scrollTop,
x => (parent.element.scrollTop = x),
);

Просмотреть файл

@ -2,11 +2,11 @@
* Returns the difference the page has to be moved horizontall to bring
* the target rect into view.
*/
export function horizontalDelta(rect: ClientRect) {
return rect.left < 0
? rect.left
: rect.right > window.innerWidth
? rect.right - window.innerWidth
export function horizontalDelta(rect: ClientRect, reference: ClientRect) {
return rect.left < reference.left
? rect.left - reference.left
: rect.right > reference.right
? rect.right - reference.right
: 0;
}
@ -14,10 +14,10 @@ export function horizontalDelta(rect: ClientRect) {
* Returns the difference the page has to be moved vertically to bring
* the target rect into view.
*/
export function verticalDelta(rect: ClientRect) {
return rect.top < 0
? rect.top
: rect.bottom > window.innerHeight
? rect.bottom - window.innerHeight
export function verticalDelta(rect: ClientRect, reference: ClientRect) {
return rect.top < reference.top
? rect.top - reference.top
: rect.bottom > reference.bottom
? rect.bottom - reference.bottom
: 0;
}

Просмотреть файл

@ -1,4 +1,5 @@
import { IElementStore } from './focus';
import { RootStore } from './root-store';
import { ScrollRegistry } from './scroll/scroll-registry';
import { StateContainer } from './state/state-container';
@ -6,6 +7,7 @@ import { StateContainer } from './state/state-container';
* IArcServices is held in the ArcSingleton.
*/
export interface IArcServices {
root: RootStore;
elementStore: IElementStore;
stateContainer: StateContainer;
scrollRegistry: ScrollRegistry;
@ -41,6 +43,7 @@ export class ArcSingleton {
this.services = {
elementStore: services.elementStore!,
root: services.root!,
scrollRegistry: new ScrollRegistry(),
stateContainer: new StateContainer(),
...services,
@ -57,6 +60,13 @@ export class ArcSingleton {
return this.services;
}
/**
* Returns the services, or void if none found.
*/
public maybeGetServices(): IArcServices | void {
return this.services;
}
}
export const instance = new ArcSingleton();