feat: implement virtual focus store, benchmarks

This commit is contained in:
Connor Peet 2018-07-18 17:57:00 -07:00
Родитель 162e9c0571
Коммит b645e809d0
38 изменённых файлов: 1093 добавлений и 581 удалений

16
demo/benchmarks.html Normal file
Просмотреть файл

@ -0,0 +1,16 @@
<!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>

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

@ -0,0 +1,132 @@
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>
);
}
}

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

@ -0,0 +1,23 @@
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>
);

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

@ -0,0 +1,39 @@
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>
);

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

@ -0,0 +1,41 @@
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>
);

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

@ -0,0 +1,53 @@
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>
);
}
}

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

@ -0,0 +1,58 @@
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>
);
}
}

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

@ -0,0 +1,104 @@
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>
);

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

@ -0,0 +1,70 @@
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>
);
}
}

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

@ -0,0 +1,28 @@
import { selectSimpleElementBenchmark } from './select-simple-element';
import { selectArcadeMachineBenchmark } from './select-arcade-machine';
import { virtualArcadeSelectBenchmark } from './virtual-arcade-select';
import { selectDeeplyNested } 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,
selectDeeplyNested,
];

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

@ -0,0 +1,32 @@
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;
},
};

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

@ -0,0 +1,48 @@
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 selectDeeplyNested: 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;
},
};

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

@ -0,0 +1,27 @@
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;
},
};

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

@ -0,0 +1,35 @@
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,358 +1,33 @@
import { FocusExclude } from '../src/components/arc-exclude';
import * as React from 'react';
import { render } from 'react-dom';
import {
ArcAutoFocus,
ArcDown,
ArcFocusTrap,
ArcOnIncoming,
ArcRoot,
ArcUp,
FocusArea,
FocusTrap,
} from '../src';
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 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 DemoApp = ArcRoot(
() => (
<div>
<FocusHooksDemo />
<FocusInsideDemo />
<FocusTrapsDemo />
<FocusFormDemo />
<FocusHistoryDemo />
<FocusGridDemo />
<FocusHiddenDemo />
</div>
),
{ ...defaultOptions(), elementStore: new VirtualElementStore() },
);
const UpDownOverrideBox = ({ index }: { index: number }) => (
<div id={`override${index}`} className="box" tabIndex={0}>
up/down override
</div>
const BenchmarkApp = () => <Benchmarks />;
render(
window.location.toString().includes('benchmarks') ? <BenchmarkApp /> : <DemoApp />,
document.getElementById('app'),
);
const UpDownOverride1 = ArcUp('#override3', ArcDown('#override2', UpDownOverrideBox));
const UpDownOverride2 = ArcUp('#override1', ArcDown('#override3', UpDownOverrideBox));
const UpDownOverride3 = ArcUp('#override2', ArcDown('#override1', UpDownOverrideBox));
const ArcShouldNotFocus = ArcOnIncoming(
ev => alert(`Unexpected incoming focus in: ${ev.next.parentElement.innerHTML}`),
(props: { style?: any }) => <div className="square" tabIndex={0} {...props} />,
);
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>
);
}
},
);
const MyApp = ArcRoot(
class extends React.Component<
{},
{
showAFBox: boolean;
isDialogVisible: boolean;
ticker: number;
}
> {
private readonly boxes: string[] = [];
private readonly toggleAFBox = () => {
this.setState({ ticker: 0, showAFBox: false });
setTimeout(() => this.setState({ ...this.state, showAFBox: true }), 1000);
};
private readonly onDialogOpen = () => {
this.setState({ isDialogVisible: true });
};
private readonly onDialogClose = () => {
this.setState({ isDialogVisible: false });
};
constructor(props: {}) {
super(props);
this.state = {
showAFBox: true,
isDialogVisible: false,
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 (
<div>
<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>
<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>
<h1>Focus Child Elements Only</h1>
<button tabIndex={0} onClick={this.onDialogOpen}>
Open Dialog
</button>
<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>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>
<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>
<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>
<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>
<ArcShouldNotFocus style={{ display: 'none' }} />
</div>
</div>
<div className="visibility-test">
<div className="case-name" tabIndex={0}>
parent display: none
</div>
<div style={{ display: 'none' }}>
<ArcShouldNotFocus />
</div>
</div>
</div>
{this.state.isDialogVisible ? <Dialog onClose={this.onDialogClose} /> : null}
</div>
);
}
},
);
render(<MyApp />, document.getElementById('app'));

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

@ -24,6 +24,7 @@
background: #000;
color: #fff;
}
.box.arc-selected,
.box:focus {
background: #f00;
}
@ -35,6 +36,7 @@
background: #000;
color: #fff;
}
.square.arc-selected,
.square:focus {
background: #f00;
}
@ -55,8 +57,11 @@ textarea {
box-shadow: 0;
outline: 0 !important;
}
input.arc-selected,
input:focus,
button.arc-selected,
button:focus,
textarea.arc-selected,
textarea:focus {
border-color: #f00;
}
@ -94,6 +99,19 @@ textarea:focus {
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;
}

22
package-lock.json сгенерированный
Просмотреть файл

@ -4,6 +4,12 @@
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@types/benchmark": {
"version": "1.0.31",
"resolved": "https://registry.npmjs.org/@types/benchmark/-/benchmark-1.0.31.tgz",
"integrity": "sha512-F6fVNOkGEkSdo/19yWYOwVKGvzbTeWkR/XQYBKtGBQ9oGRjBN9f/L4aJI4sDcVPJO58Y1CJZN8va9V2BhrZapA==",
"dev": true
},
"@types/chai": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.1.4.tgz",
@ -958,6 +964,16 @@
"tweetnacl": "0.14.5"
}
},
"benchmark": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/benchmark/-/benchmark-2.1.4.tgz",
"integrity": "sha1-CfPeMckWQl1JjMLuVloOvzwqVik=",
"dev": true,
"requires": {
"lodash": "4.17.10",
"platform": "1.3.5"
}
},
"better-assert": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz",
@ -7125,6 +7141,12 @@
"find-up": "2.1.0"
}
},
"platform": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.5.tgz",
"integrity": "sha512-TuvHS8AOIZNAlE77WUDiR4rySV/VMptyMfcfeoMgs4P8apaZM3JrnbzBiixKUv+XR6i+BXrQh8WAnjaSPFO65Q==",
"dev": true
},
"plugin-error": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-0.1.2.tgz",

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

@ -7,22 +7,24 @@
"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/**/*.{json,ts}\"",
"test:fmt": "prettier --list-different \"{src,demo}/**/*.{json,ts,tsx}\"",
"start": "webpack-dev-server --config demo/webpack.config.js",
"build": "tsc",
"prepare": "npm run build",
"lint:ts": "tslint --project tsconfig.json --fix \"src/**/*.{ts,tsx}\"",
"fmt": "prettier --write \"src/**/*.{json,ts}\" && npm run lint:ts -- --fix"
"fmt": "prettier --write \"{src,demo}/**/*.{json,ts,tsx}\" && npm run lint:ts -- --fix"
},
"author": "Connor Peet <connor@peet.io>",
"license": "MIT",
"devDependencies": {
"@types/benchmark": "^1.0.31",
"@types/chai": "^4.1.4",
"@types/enzyme": "^3.1.11",
"@types/mocha": "^5.2.4",
"@types/react": "^16.4.6",
"@types/react-dom": "^16.0.6",
"@types/winrt-uwp": "0.0.19",
"benchmark": "^2.1.4",
"chai": "^4.1.2",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",

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

@ -1,18 +1,30 @@
import { expect } from 'chai';
import * as React from 'react';
import { NativeElementStore } from '../focus/native-element-store';
import { instance } from '../singleton';
import { StateContainer } from '../state/state-container';
import { ArcAutoFocus } from './arc-autofocus';
import { mountToDOM } from './util.test';
const NormalInput = (props: { className: string }) => <input className={props.className}/>;
const NormalInput = (props: { className: string }) => <input className={props.className} />;
const FocusedInput = ArcAutoFocus(NormalInput);
describe('ArcAutoFocus', () => {
beforeEach(() => {
instance.setServices({
elementStore: new NativeElementStore(),
stateContainer: new StateContainer(),
});
});
it('focuses the first html element', async () => {
const cmp = mountToDOM(<div>
<NormalInput className="not-focused" />
<FocusedInput className="focused" />
</div>);
const cmp = mountToDOM(
<div>
<NormalInput className="not-focused" />
<FocusedInput className="focused" />
</div>,
);
expect(document.activeElement.className).to.deep.equal('focused');
cmp.unmount();
@ -24,9 +36,13 @@ describe('ArcAutoFocus', () => {
<NormalInput className="not-focused" />
<NormalInput className="focused" />,
</div>,
'.focused'
'.focused',
);
const cmp = mountToDOM(
<div>
<Fixture />
</div>,
);
const cmp = mountToDOM(<div><Fixture /></div>);
expect(document.activeElement.className).to.deep.equal('focused');
cmp.unmount();
});

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

@ -1,6 +1,7 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Composable, findElement, renderComposed } from '../internal-types';
import { instance } from '../singleton';
/**
* Component that autofocuses whatever is contained inside it. By default,
@ -31,7 +32,7 @@ class AutoFocus extends React.PureComponent<{ selector?: string | HTMLElement }>
}
if (focusTarget) {
(focusTarget as HTMLElement).focus();
instance.getServices().elementStore.element = focusTarget as HTMLElement;
}
}

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

@ -1,8 +1,8 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { ArcContext, Composable, renderComposed, requireContext } from '../internal-types';
import { StateContainer } from '../state/state-container';
import { Composable, renderComposed } from '../internal-types';
import { instance } from '../singleton';
/**
* FocusExclude will exclude the attached element, and optionally its
@ -12,24 +12,11 @@ export class FocusExclude extends React.PureComponent<{
children: React.ReactNode;
deep?: boolean;
}> {
/**
* Gets the HTMLElement this scope is attached to.
*/
private stateContainer!: StateContainer;
/**
* The node this element is attached to.
*/
private node!: HTMLElement;
/**
* Handler function called with the ArcContext state.
*/
private readonly withContext = requireContext(({ state }) => {
this.stateContainer = state;
return this.props.children;
});
public componentDidMount() {
const element = ReactDOM.findDOMNode(this);
if (!(element instanceof HTMLElement)) {
@ -38,7 +25,7 @@ export class FocusExclude extends React.PureComponent<{
);
}
this.stateContainer.add(this, {
instance.getServices().stateContainer.add(this, {
element,
onIncoming: ev => {
if (!ev.next) {
@ -57,11 +44,11 @@ export class FocusExclude extends React.PureComponent<{
}
public componentWillUnmount() {
this.stateContainer.remove(this, this.node);
instance.getServices().stateContainer.remove(this, this.node);
}
public render() {
return <ArcContext.Consumer>{this.withContext}</ArcContext.Consumer>;
return this.props.children;
}
private isElementExcluded(element: HTMLElement | null): boolean {
@ -80,7 +67,6 @@ export class FocusExclude extends React.PureComponent<{
/**
* HOC to create a FocusExclude.
*/
export const ArcFocusExclude = <P extends {} = {}>(
Composed: Composable<P>,
deep?: boolean,
) => (props: P) => <FocusExclude deep={deep}>{renderComposed(Composed, props)}</FocusExclude>;
export const ArcFocusExclude = <P extends {} = {}>(Composed: Composable<P>, deep?: boolean) => (
props: P,
) => <FocusExclude deep={deep}>{renderComposed(Composed, props)}</FocusExclude>;

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

@ -2,8 +2,9 @@ import { expect } from 'chai';
import * as React from 'react';
import { ArcFocusEvent } from '../arc-focus-event';
import { ArcContext } from '../internal-types';
import { NativeElementStore } from '../focus/native-element-store';
import { Button } from '../model';
import { instance } from '../singleton';
import { StateContainer } from '../state/state-container';
import { FocusArea } from './arc-focus-area';
import { mountToDOM, NoopFocusContext } from './util.test';
@ -11,15 +12,18 @@ import { mountToDOM, NoopFocusContext } from './util.test';
describe('ArcFocusArea', () => {
const render = (focusIn?: string) => {
const state = new StateContainer();
instance.setServices({
elementStore: new NativeElementStore(),
stateContainer: state,
});
const contents = mountToDOM(
<div>
<ArcContext.Provider value={{ state }}>
<FocusArea focusIn={focusIn}>
<div className="a" />
<div className="b" tabIndex={0} />
<div className="c" tabIndex={0}/>
</FocusArea>
</ArcContext.Provider>,
<FocusArea focusIn={focusIn}>
<div className="a" />
<div className="b" tabIndex={0} />
<div className="c" tabIndex={0} />
</FocusArea>
</div>,
);

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

@ -1,6 +1,6 @@
import * as React from 'react';
import { ArcContext, Composable, findElement, findFocusable, renderComposed, requireContext } from '../internal-types';
import { StateContainer } from '../state/state-container';
import { Composable, findElement, findFocusable, renderComposed } from '../internal-types';
import { instance } from '../singleton';
/**
* The ArcFocusArea acts as a virtual focus element which transfers focus
@ -42,20 +42,10 @@ export class FocusArea extends React.PureComponent<{
focusIn?: HTMLElement | string;
}> {
private containerRef = React.createRef<HTMLDivElement>();
private stateContainer!: StateContainer;
private readonly withContext = requireContext(({ state }) => {
this.stateContainer = state;
return (
<div tabIndex={0} ref={this.containerRef}>
{this.props.children}
</div>
);
});
public componentDidMount() {
const element = this.containerRef.current!;
this.stateContainer.add(this, {
instance.getServices().stateContainer.add(this, {
element,
onIncoming: ev => {
if (ev.next !== element) {
@ -76,11 +66,15 @@ export class FocusArea extends React.PureComponent<{
}
public componentWillUnmount() {
this.stateContainer.remove(this, this.containerRef.current!);
instance.getServices().stateContainer.remove(this, this.containerRef.current!);
}
public render() {
return <ArcContext.Consumer>{this.withContext}</ArcContext.Consumer>;
return (
<div tabIndex={0} ref={this.containerRef}>
{this.props.children}
</div>
);
}
}

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

@ -2,8 +2,9 @@ import { expect } from 'chai';
import * as React from 'react';
import { ArcFocusEvent } from '../arc-focus-event';
import { ArcContext } from '../internal-types';
import { NativeElementStore } from '../focus/native-element-store';
import { Button } from '../model';
import { instance } from '../singleton';
import { StateContainer } from '../state/state-container';
import { FocusTrap, IFocusTrapProps } from './arc-focus-trap';
import { mountToDOM, NoopFocusContext } from './util.test';
@ -11,24 +12,23 @@ import { mountToDOM, NoopFocusContext } from './util.test';
const delay = () => new Promise(resolve => setTimeout(resolve));
describe('ArcFocusTrap', () => {
class TestComponent extends React.Component<{
state: StateContainer;
showTrapDefault?: boolean;
} & Partial<IFocusTrapProps>> {
class TestComponent extends React.Component<
{
showTrapDefault?: boolean;
} & Partial<IFocusTrapProps>
> {
public state = { showTrap: this.props.showTrapDefault };
public render() {
return (
<div>
<ArcContext.Provider value={{ state: this.props.state }}>
<div className="a" tabIndex={0} onClick={this.showTrap} />
{this.state.showTrap ? (
<FocusTrap focusIn={this.props.focusIn} focusOut={this.props.focusOut}>
<div className="b" tabIndex={0} onClick={this.hideTrap} />
<div className="c" tabIndex={0} />
</FocusTrap>
) : null}
</ArcContext.Provider>
<div className="a" tabIndex={0} onClick={this.showTrap} />
{this.state.showTrap ? (
<FocusTrap focusIn={this.props.focusIn} focusOut={this.props.focusOut}>
<div className="b" tabIndex={0} onClick={this.hideTrap} />
<div className="c" tabIndex={0} />
</FocusTrap>
) : null}
</div>
);
}
@ -39,13 +39,12 @@ describe('ArcFocusTrap', () => {
const render = (props?: Partial<IFocusTrapProps>, showTrapDefault: boolean = true) => {
const state = new StateContainer();
const contents = mountToDOM(
<TestComponent
state={state}
{...props}
showTrapDefault={showTrapDefault}
/>
);
instance.setServices({
elementStore: new NativeElementStore(),
stateContainer: state,
});
const contents = mountToDOM(<TestComponent {...props} showTrapDefault={showTrapDefault} />);
const element = contents.getDOMNode().querySelector('.arc-focus-trap') as HTMLElement;
const record = state.find(element)!;

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

@ -1,13 +1,6 @@
import * as React from 'react';
import {
ArcContext,
Composable,
findElement,
findFocusable,
renderComposed,
requireContext,
} from '../internal-types';
import { StateContainer } from '../state/state-container';
import { Composable, findElement, findFocusable, renderComposed } from '../internal-types';
import { instance } from '../singleton';
/**
* Properties passed to the FocusTrap.
@ -38,19 +31,9 @@ export interface IFocusTrapProps {
export class FocusTrap extends React.PureComponent<IFocusTrapProps> {
private containerRef = React.createRef<HTMLDivElement>();
private previouslyFocusedElement!: HTMLElement;
private stateContainer!: StateContainer;
private readonly withContext = requireContext(({ state }) => {
this.stateContainer = state;
return (
<div className="arc-focus-trap" tabIndex={0} ref={this.containerRef}>
{this.props.children}
</div>
);
});
public componentWillMount() {
this.previouslyFocusedElement = document.activeElement as HTMLElement;
this.previouslyFocusedElement = instance.getServices().elementStore.element;
}
public componentDidMount() {
@ -59,15 +42,16 @@ export class FocusTrap extends React.PureComponent<IFocusTrapProps> {
// setTimeout to give time for any autofocusing to fire before we go
// ahead and force the focus over.
setTimeout(() => {
if (!element.contains(document.activeElement)) {
const store = instance.getServices().elementStore;
if (!element.contains(store.element)) {
const next = findFocusable(element, this.props.focusIn);
if (next) {
next.focus();
store.element = next;
}
}
});
this.stateContainer.add(this, {
instance.getServices().stateContainer.add(this, {
element,
onOutgoing: ev => {
if (ev.next && !element.contains(ev.next)) {
@ -78,21 +62,26 @@ export class FocusTrap extends React.PureComponent<IFocusTrapProps> {
}
public componentWillUnmount() {
this.stateContainer.remove(this, this.containerRef.current!);
const { stateContainer, elementStore } = instance.getServices();
stateContainer.remove(this, this.containerRef.current!);
if (this.props.focusOut) {
const target = findElement(document.body, this.props.focusOut);
if (target) {
target.focus();
return
elementStore.element = target;
return;
}
}
this.previouslyFocusedElement.focus();
elementStore.element = this.previouslyFocusedElement;
}
public render() {
return <ArcContext.Consumer>{this.withContext}</ArcContext.Consumer>;
return (
<div className="arc-focus-trap" tabIndex={0} ref={this.containerRef}>
{this.props.children}
</div>
);
}
}

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

@ -2,17 +2,43 @@ import * as React from 'react';
import { ReplaySubject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { IFocusStrategy } from '../focus';
import { IElementStore, IFocusStrategy } from '../focus';
import { FocusService } from '../focus-service';
import { FocusByDistance } from '../focus/focus-by-distance';
import { FocusByRegistry } from '../focus/focus-by-registry';
import { NativeElementStore } from '../focus/native-element-store';
import { InputService } from '../input';
import { GamepadInput } from '../input/gamepad-input';
import { IInputMethod } from '../input/input-method';
import { KeyboardInput } from '../input/keyboard-input';
import { ArcContext, Composable, renderComposed } from '../internal-types';
import { Composable, renderComposed } from '../internal-types';
import { instance } from '../singleton';
import { StateContainer } from '../state/state-container';
/**
* IRootOptions injects structures to use for focusing, taking input, and
* so on. You can insert `defaultOptions()` if you want to stick with
* the default set of providers.
*/
export interface IRootOptions {
inputs: IInputMethod[];
focus: IFocusStrategy[];
elementStore: IElementStore;
}
/**
* defaultOptions returns a default set of IRootOptions. This is not provided
* implicitly in order to allow your bundler to tree-shake out any providers
* you don't use in your app.
*/
export function defaultOptions(): IRootOptions {
return {
elementStore: new NativeElementStore(),
focus: [new FocusByRegistry(), new FocusByDistance()],
inputs: [new GamepadInput(), new KeyboardInput()],
};
}
/**
* Component for defining the root of the arcade-machine. This should be wrapped
* around the root of your application, or its content. Only components
@ -26,18 +52,28 @@ import { StateContainer } from '../state/state-container';
*
* export default ArcRoot(MyAppContent);
*/
class Root extends React.PureComponent<{ inputs: IInputMethod[]; focus: IFocusStrategy[] }> {
class Root extends React.PureComponent<IRootOptions> {
private stateContainer = new StateContainer();
private focus!: FocusService;
private input!: InputService;
private rootRef = React.createRef<HTMLDivElement>();
private unmounted = new ReplaySubject<void>(1);
constructor(props: IRootOptions) {
super(props);
instance.setServices({
elementStore: this.props.elementStore,
stateContainer: this.stateContainer,
});
}
public componentDidMount() {
const focus = (this.focus = new FocusService(
this.stateContainer,
this.rootRef.current!,
this.props.focus,
this.props.elementStore,
));
const input = (this.input = new InputService(this.props.inputs));
@ -50,26 +86,17 @@ class Root extends React.PureComponent<{ inputs: IInputMethod[]; focus: IFocusSt
public componentWillUnmount() {
this.unmounted.next();
instance.setServices(undefined);
}
public render() {
return (
<ArcContext.Provider value={{ state: this.stateContainer }}>
<div ref={this.rootRef}>{this.props.children}</div>
</ArcContext.Provider>
);
return <div ref={this.rootRef}>{this.props.children}</div>;
}
}
/**
* HOC to create an arcade-machine Root element.
*/
export const ArcRoot = <P extends {}>(
Composed: Composable<P>,
inputs: IInputMethod[] = [new GamepadInput(), new KeyboardInput()],
focusStrategies: IFocusStrategy[] = [new FocusByRegistry(), new FocusByDistance()],
) => (props: P) => (
<Root inputs={inputs} focus={focusStrategies}>
{renderComposed(Composed, props)}
</Root>
);
export const ArcRoot = <P extends {}>(Composed: Composable<P>, options: IRootOptions) => (
props: P,
) => <Root {...options}>{renderComposed(Composed, props)}</Root>;

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

@ -1,7 +1,8 @@
import { expect } from 'chai';
import * as React from 'react';
import { ArcContext } from '../internal-types';
import { NativeElementStore } from '../focus/native-element-store';
import { instance } from '../singleton';
import { StateContainer } from '../state/state-container';
import { ArcDown, ArcUp } from './arc-scope';
import { mountToDOM } from './util.test';
@ -9,11 +10,14 @@ import { mountToDOM } from './util.test';
describe('ArcScope', () => {
const render = (Component: React.ComponentType<{}>) => {
const state = new StateContainer();
instance.setServices({
elementStore: new NativeElementStore(),
stateContainer: state,
});
const contents = mountToDOM(
<div>
<ArcContext.Provider value={{ state }}>
<Component />
</ArcContext.Provider>,
<Component />
</div>,
);
@ -43,7 +47,9 @@ describe('ArcScope', () => {
});
it('composes multiple arc scopes into a single context', () => {
const { state, contents } = render(ArcDown('#foo', ArcUp('#bar', () => <div className="testclass" />)));
const { state, contents } = render(
ArcDown('#foo', ArcUp('#bar', () => <div className="testclass" />)),
);
const targetEl = contents.getDOMNode().querySelector('.testclass') as HTMLElement;
expect(state.find(targetEl)).to.deep.equal({
arcFocusDown: '#foo',

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

@ -3,9 +3,9 @@ import * as ReactDOM from 'react-dom';
import { ArcEvent } from '../arc-event';
import { ArcFocusEvent } from '../arc-focus-event';
import { ArcContext, Composable, renderComposed, requireContext } from '../internal-types';
import { Composable, renderComposed } from '../internal-types';
import { IArcHandler } from '../model';
import { StateContainer } from '../state/state-container';
import { instance } from '../singleton';
const isArcScopeSymbol = Symbol();
@ -44,24 +44,11 @@ export const ArcScope = <P extends {} = {}>(
*/
public static options = options;
/**
* Gets the HTMLElement this scope is attached to.
*/
private stateContainer!: StateContainer;
/**
* The node this element is attached to.
*/
private node!: HTMLElement;
/**
* Handler function called with the ArcContext state.
*/
private readonly withContext = requireContext(({ state }) => {
this.stateContainer = state;
return renderComposed(Composed, this.props);
});
public componentDidMount() {
const node = ReactDOM.findDOMNode(this);
if (!(node instanceof HTMLElement)) {
@ -70,16 +57,18 @@ export const ArcScope = <P extends {} = {}>(
);
}
this.stateContainer.add(this, { element: node, ...ArcScopeComponent.options });
instance
.getServices()
.stateContainer.add(this, { element: node, ...ArcScopeComponent.options });
this.node = node;
}
public componentWillUnmount() {
this.stateContainer.remove(this, this.node);
instance.getServices().stateContainer.remove(this, this.node);
}
public render() {
return <ArcContext.Consumer>{this.withContext}</ArcContext.Consumer>;
return renderComposed(Composed, this.props);
}
};
};

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

@ -2,6 +2,7 @@ import { mount, ReactWrapper } from 'enzyme';
import * as React from 'react';
import { FocusContext } from '../focus';
import { Button } from '../model';
import { instance } from '../singleton';
const container = document.createElement('div');
document.body.appendChild(container);
@ -23,6 +24,7 @@ export function mountToDOM(element: React.ReactElement<any>) {
afterEach(() => {
mountings.forEach(m => m.unmount());
mountings = [];
instance.setServices(undefined);
});
/**

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

@ -1,6 +1,6 @@
import { ArcEvent } from './arc-event';
import { ArcFocusEvent } from './arc-focus-event';
import { FocusContext, IFocusStrategy } from './focus';
import { FocusContext, IElementStore, IFocusStrategy } from './focus';
import { isFocusable, isNodeAttached, roundRect } from './focus/dom-utils';
import { isForForm } from './focus/is-for-form';
import { rescroll } from './focus/rescroll';
@ -32,27 +32,20 @@ export class FocusService {
width: 0,
};
private get selected() {
return document.activeElement as HTMLElement;
}
/**
* The previously selected element.
*/
private previousSelectedElement = this.elementStore.element;
constructor(
private readonly registry: StateContainer,
private readonly root: HTMLElement,
private readonly strategies: IFocusStrategy[],
private readonly elementStore: IElementStore,
) {
this.setDefaultFocus();
}
/**
* onFocusChange is called when any element in the DOM gains focus. We use
* this is handle adjustments if the user interacts with other input
* devices, or if other application logic requests focus.
*/
public onFocusChange(focus: HTMLElement, scrollSpeed: number | null = this.scrollSpeed) {
this.selectNode(focus, scrollSpeed);
}
/**
* Wrapper around moveFocus to dispatch arcselectingnode event
*/
@ -61,13 +54,6 @@ export class FocusService {
return;
}
const canceled = !next.dispatchEvent(
new Event('arcselectingnode', { bubbles: true, cancelable: true }),
);
if (canceled) {
return;
}
this.selectNodeWithoutEvent(next, scrollSpeed);
}
@ -77,22 +63,18 @@ export class FocusService {
* e.g. when intercepting and transfering focus
*/
public selectNodeWithoutEvent(next: HTMLElement, scrollSpeed: number | null = this.scrollSpeed) {
const previous = this.elementStore.element;
if (!this.root) {
throw new Error('root not set');
}
if (this.selected === next) {
if (previous === next) {
return;
}
this.referenceRect = next.getBoundingClientRect();
rescroll(next, this.referenceRect, scrollSpeed, this.root);
const canceled = !next.dispatchEvent(
new Event('arcfocuschanging', { bubbles: true, cancelable: true }),
);
if (!canceled) {
next.focus();
}
this.previousSelectedElement = previous;
this.elementStore.element = next;
}
/**
@ -101,12 +83,13 @@ export class FocusService {
* by the focus service.
*/
public sendButton(direction: Button): boolean {
if (isForForm(direction, this.selected)) {
const selected = this.elementStore.element;
if (isForForm(direction, selected)) {
return false;
}
const ev = this.createArcEvent(direction);
this.bubbleEvent(ev, 'onButton');
const ev = this.createArcEvent(direction, selected);
this.bubbleEvent(ev, 'onButton', selected);
if (ev.defaultPrevented) {
return true;
}
@ -116,7 +99,7 @@ export class FocusService {
let originalNext = ev.next;
for (let i = 0; i < FocusService.maxFocusInterations; i++) {
if (this.bubbleInOut(ev)) {
if (this.bubbleInOut(ev, selected)) {
return true;
}
if (originalNext === ev.next) {
@ -134,29 +117,30 @@ export class FocusService {
* Creates an arcade-machine event to fire the given press. If a direction
* is given, we'll find an appropriate focusable element.
*/
private createArcEvent(direction: Button): ArcEvent {
private createArcEvent(direction: Button, selected: HTMLElement): ArcEvent {
if (!isDirectional(direction)) {
return new ArcEvent({
directive: this.registry.find(this.selected),
directive: this.registry.find(selected),
event: direction,
target: this.selected,
target: selected,
});
}
const context = new FocusContext(this.root, direction, this.strategies, {
activeElement: this.selected,
directive: this.registry.find(this.selected),
referenceRect: this.root.contains(this.selected)
? this.selected.getBoundingClientRect()
activeElement: selected,
directive: this.registry.find(selected),
previousElement: this.previousSelectedElement,
referenceRect: this.root.contains(selected)
? selected.getBoundingClientRect()
: this.referenceRect,
});
return new ArcFocusEvent({
context,
directive: this.registry.find(this.selected),
directive: this.registry.find(selected),
event: direction,
next: context ? context.find(this.root) : null,
target: this.selected,
target: selected,
});
}
@ -164,10 +148,10 @@ export class FocusService {
* Attempts to effect the focus command, returning a
* boolean if it was handled and no further action should be taken.
*/
private bubbleInOut(ev: ArcFocusEvent): boolean {
private bubbleInOut(ev: ArcFocusEvent, selected: HTMLElement): boolean {
const originalNext = ev.next;
if (isNodeAttached(this.selected, this.root)) {
this.bubbleEvent(ev, 'onOutgoing');
if (isNodeAttached(selected, this.root)) {
this.bubbleEvent(ev, 'onOutgoing', selected);
}
// Abort if the user handled
@ -192,10 +176,8 @@ export class FocusService {
this.selectNode(ev.next, scrollSpeed);
return true;
} else if (ev.event === Button.Submit) {
if (this.selected) {
this.selected.click();
return true;
}
this.elementStore.element.click();
return true;
}
return false;
@ -208,7 +190,7 @@ export class FocusService {
private bubbleEvent(
ev: ArcEvent,
trigger: keyof IArcHandler,
source: HTMLElement | null = this.selected,
source: HTMLElement | null,
): ArcEvent {
for (let el = source; !propogationStoped(ev) && el !== this.root && el; el = el.parentElement) {
if (el === undefined) {
@ -244,7 +226,7 @@ export class FocusService {
// tslint:disable-next-line
for (let i = 0; i < focusableElems.length; i += 1) {
const potentialElement = focusableElems[i] as HTMLElement;
if (this.selected === potentialElement || !isFocusable(potentialElement)) {
if (this.elementStore.element === potentialElement || !isFocusable(potentialElement)) {
continue;
}
const potentialRect = roundRect(potentialElement.getBoundingClientRect());

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

@ -1,3 +1,5 @@
import { instance } from '../singleton';
export function roundRect(rect: HTMLElement | ClientRect): ClientRect {
if (rect instanceof HTMLElement) {
rect = rect.getBoundingClientRect();
@ -35,11 +37,12 @@ export function isVisible(element: HTMLElement | null): boolean {
* Returns if the element can receive focus.
*/
export function isFocusable(el: HTMLElement): boolean {
if (el === document.activeElement) {
const activeElement = instance.getServices().elementStore.element;
if (el === activeElement) {
return false;
}
// to prevent navigating to parent container elements with arc-focus-inside
if (document.activeElement !== document.body && document.activeElement.contains(el)) {
if (activeElement !== document.body && activeElement.contains(el)) {
return false;
}

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

@ -62,12 +62,12 @@ class PotentialElement {
// tslint:disable-next-line
export class FocusByDistance implements IFocusStrategy {
public findNextFocus({ referenceRect, direction, ignore, root, activeElement }: IFocusOptions) {
public findNextFocus({ referenceRect, direction, ignore, root, previousElement }: IFocusOptions) {
const focusableElems = Array.from(root.querySelectorAll<HTMLElement>('[tabIndex]')).filter(
el => !ignore.has(el) && isFocusable(el),
);
return new ElementFinder(direction, referenceRect, focusableElems, activeElement).find();
return new ElementFinder(direction, referenceRect, focusableElems, previousElement).find();
}
}

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

@ -12,10 +12,15 @@ export interface IFocusOptions {
root: HTMLElement;
/**
* The last element that was focused.
* The current element that is focused.
*/
activeElement: HTMLElement;
/**
* The last element that was focused.
*/
previousElement: HTMLElement;
/**
* The position of the last selected element.
*/
@ -32,6 +37,14 @@ export interface IFocusOptions {
directive?: Readonly<IArcHandler>;
}
/**
* The IElementStore holds which item is currently in focus on the page.
* The focus servers will get *and set* the element to trigger focus changes.
*/
export interface IElementStore {
element: HTMLElement;
}
/**
* The FocusContext is created for each focus change operation. It contains
* the direction the focus is shifting, and the currently selected element
@ -47,6 +60,7 @@ export class FocusContext {
activeElement: HTMLElement;
directive?: Readonly<IArcHandler>;
referenceRect: ClientRect;
previousElement?: HTMLElement;
},
) {}
@ -59,6 +73,7 @@ export class FocusContext {
ignore: ReadonlySet<HTMLElement> = new Set(),
): HTMLElement | null {
const options: IFocusOptions = {
previousElement: this.source.activeElement,
...this.source,
direction: this.direction,
ignore,

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

@ -0,0 +1,15 @@
import { IElementStore } from '.';
/**
* NativeElementStore is an IElementStore that uses the browser's native focus
* for dealing with the focused element.
*/
export class NativeElementStore implements IElementStore {
public get element() {
return document.activeElement as HTMLElement;
}
public set element(element: HTMLElement) {
element.focus();
}
}

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

@ -0,0 +1,37 @@
import { IElementStore } from '.';
/**
* VirtualElementStore is an IElementStore that just keeps the focused element
* in memory, and does not use the native dom focus.
*/
export class VirtualElementStore implements IElementStore {
public get element() {
return document.body.contains(this.previousElement)
? this.previousElement
: (document.activeElement as HTMLElement);
}
public set element(element: HTMLElement) {
this.previousElement.classList.remove('arc-selected');
element.classList.add('arc-selected');
const needsFocus = this.needsNativeFocus(element);
if (needsFocus) {
element.focus();
} else if (this.gaveNativeFocus) {
this.previousElement.blur();
}
this.gaveNativeFocus = needsFocus;
this.previousElement = element;
}
private previousElement: HTMLElement = document.activeElement as HTMLElement;
private gaveNativeFocus = false;
private needsNativeFocus(element: HTMLElement) {
return (
element.tagName === 'TEXTAREA' || element.tagName === 'INPUT' || element.isContentEditable
);
}
}

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

@ -3,5 +3,8 @@ 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';

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

@ -1,34 +1,5 @@
import { createContext, createElement } from 'react';
import { createElement } from 'react';
import { ArcEvent } from './arc-event';
import { StateContainer } from './state/state-container';
/**
* IArcContextValue is given in th ArcContext for nested arcade-machine
* components to consume.
*/
export interface IArcContextValue {
state: StateContainer;
}
/**
* requireContext wraps the given function and throws if the context is null.
*/
export function requireContext<T>(fn: (value: IArcContextValue) => T) {
return (value: IArcContextValue | null) => {
if (value === null) {
throw new Error(
`A component attempted to use arcade-machine, but was not inside the ArcRoot`,
);
}
return fn(value);
};
}
/**
* References the arcade-machine context within React.
*/
export const ArcContext = createContext<IArcContextValue | null>(null);
/**
* Type for elements passed into a HOC.

50
src/singleton.ts Normal file
Просмотреть файл

@ -0,0 +1,50 @@
import { IElementStore } from './focus';
import { StateContainer } from './state/state-container';
/**
* IArcServices is held in the ArcSingleton.
*/
export interface IArcServices {
elementStore: IElementStore;
stateContainer: StateContainer;
}
/**
* The ArcSingleton stores the currently active arcade-machine root and
* services on the page.
*
* Singletons are bad and all that, but generally it never (currently) makes
* sense to have multiple arcade-machine roots, as they all would clobber over
* each others' input. Using a singleton rather than, say, react contexts,
* gives us simpler (and less) code, with better performance.
*/
export class ArcSingleton {
private services: IArcServices | undefined;
/**
* setServices is called by the ArcRoot when it gets set up.
*/
public setServices(services: IArcServices | undefined) {
if (this.services && services) {
throw new Error(
'Attempted to register a second <ArcRoot /> without destroying the first one. ' +
'Only one arcade-machine root component may be used at once.',
);
}
this.services = services;
}
/**
* Returns the services. Throws if none are registered.
*/
public getServices(): IArcServices {
if (!this.services) {
throw new Error('You cannot use arcade-machine functionality without an <ArcRoot />.');
}
return this.services;
}
}
export const instance = new ArcSingleton();