feat: implement virtual focus store, benchmarks
This commit is contained in:
Родитель
162e9c0571
Коммит
b645e809d0
|
@ -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;
|
||||
},
|
||||
};
|
379
demo/index.tsx
379
demo/index.tsx
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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();
|
Загрузка…
Ссылка в новой задаче