feat: initial import of arcade-machine logic
This commit is contained in:
Родитель
07d9511ea4
Коммит
b63c4adf58
|
@ -0,0 +1,9 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
|
@ -0,0 +1,50 @@
|
|||
Much of the code and algorithms in this project are from the project at
|
||||
https://git.io/vPxQQ, which is provided under the following license:
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 Microsoft. All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
|
||||
The original code in this project is available under the following license:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>arcade-machine-react</title>
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="./demo/index.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,184 @@
|
|||
import * as React from 'react';
|
||||
import { render } from 'react-dom';
|
||||
import { ArcRoot, ArcAutoFocus, ArcUp, ArcDown } from '../src';
|
||||
|
||||
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));
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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"
|
||||
tabIndex={0}
|
||||
style={{ display: 'flex', justifyContent: 'space-evenly' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
With 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" tabIndex={0}>
|
||||
<div className="square" tabIndex={0} />
|
||||
<div className="square" tabIndex={0} style={{ 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
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
With 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"
|
||||
style={{ minHeight: '85px', width: '100%' }}
|
||||
tabIndex={0}
|
||||
>
|
||||
Empty element with arc-focus-inside
|
||||
<div
|
||||
className="area"
|
||||
style={{ minHeight: '45px', width: '80%', margin: '15px' }}
|
||||
tabIndex={0}
|
||||
>
|
||||
Empty element with arc-focus-inside
|
||||
</div>
|
||||
</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
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
render(<MyApp />, document.getElementById('app'));
|
|
@ -0,0 +1,66 @@
|
|||
:host {
|
||||
font-family: monospace;
|
||||
max-width: 960px;
|
||||
margin: 15px auto;
|
||||
display: block;
|
||||
}
|
||||
.area {
|
||||
border: 1px solid #000;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.area:after {
|
||||
content: '';
|
||||
display: block;
|
||||
}
|
||||
.area.arc--selected {
|
||||
border-color: #f00;
|
||||
}
|
||||
.box-wrapper {
|
||||
width: 100px;
|
||||
display: inline-block;
|
||||
}
|
||||
.box {
|
||||
margin: 15px;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
.box:focus {
|
||||
background: #f00;
|
||||
}
|
||||
.square {
|
||||
display: inline-block;
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
margin: 15px;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
.square:focus {
|
||||
background: #f00;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
margin: 15px;
|
||||
align-content: center;
|
||||
}
|
||||
form div {
|
||||
margin-right: 5px;
|
||||
}
|
||||
input,
|
||||
button,
|
||||
textarea {
|
||||
border: 1px solid #000;
|
||||
padding: 5px 8px;
|
||||
border-radius: 0;
|
||||
box-shadow: 0;
|
||||
outline: 0 !important;
|
||||
}
|
||||
input:focus,
|
||||
button:focus,
|
||||
textarea:focus {
|
||||
border-color: #f00;
|
||||
}
|
||||
.scroll-restriction {
|
||||
overflow: auto;
|
||||
height: 100px;
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
module.exports = {
|
||||
entry: './demo/index.tsx',
|
||||
devtool: 'source-map',
|
||||
mode: 'development',
|
||||
output: {
|
||||
filename: './demo/index.js',
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js'],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
exclude: /node_modules/,
|
||||
loader: 'ts-loader',
|
||||
},
|
||||
],
|
||||
},
|
||||
devServer: {
|
||||
contentBase: __dirname,
|
||||
},
|
||||
};
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -0,0 +1,68 @@
|
|||
{
|
||||
"name": "arcade-machine-react",
|
||||
"version": "1.0.0",
|
||||
"description": "Input abstraction layer for gamepads, keyboards, and UWP apps",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "jest && npm run lint:ts",
|
||||
"test:watch": "jest --watch",
|
||||
"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"
|
||||
},
|
||||
"author": "Connor Peet <connor@peet.io>",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/enzyme": "^3.1.11",
|
||||
"@types/jest": "^23.1.5",
|
||||
"@types/react": "^16.4.6",
|
||||
"@types/react-dom": "^16.0.6",
|
||||
"@types/winrt-uwp": "0.0.19",
|
||||
"enzyme": "^3.3.0",
|
||||
"enzyme-adapter-react-16": "^1.1.1",
|
||||
"jest": "^23.4.0",
|
||||
"prettier": "^1.13.7",
|
||||
"react-dom": "^16.4.1",
|
||||
"rxjs": "^6.2.1",
|
||||
"ts-jest": "^23.0.0",
|
||||
"ts-loader": "^4.4.2",
|
||||
"tslint": "^5.10.0",
|
||||
"tslint-config-prettier": "^1.13.0",
|
||||
"tslint-react": "^3.6.0",
|
||||
"typescript": "^2.9.2",
|
||||
"webpack": "^4.15.1",
|
||||
"webpack-cli": "^3.0.8",
|
||||
"webpack-dev-server": "^3.1.4"
|
||||
},
|
||||
"jest": {
|
||||
"transform": {
|
||||
"^.+\\.tsx?$": "ts-jest"
|
||||
},
|
||||
"setupTestFrameworkScriptFile": "./test-setup.js",
|
||||
"testRegex": "src/.+\\.test\\.(jsx?|tsx?)$",
|
||||
"moduleFileExtensions": [
|
||||
"ts",
|
||||
"tsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"json",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0",
|
||||
"rxjs": "^6.0.0"
|
||||
},
|
||||
"prettier": {
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^16.4.1",
|
||||
"uwp-keycodes": "^1.1.1"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
import { Direction, IArcHandler } from './model';
|
||||
|
||||
/**
|
||||
* ArcEvents are fired on an element when an input occurs. They include
|
||||
* information about the input and provide utilities similar to standard
|
||||
* HTML events.
|
||||
*/
|
||||
export class ArcEvent {
|
||||
/**
|
||||
* The 'arc' directive reference, may not be filled for elements which
|
||||
* are focusable without the directive, like form controls.
|
||||
*/
|
||||
public readonly directive?: IArcHandler;
|
||||
|
||||
/**
|
||||
* The direction we're navigating.
|
||||
*/
|
||||
public readonly event: Direction;
|
||||
|
||||
/**
|
||||
* The currently focused element we're navigating from.
|
||||
*/
|
||||
public readonly target: HTMLElement | null;
|
||||
|
||||
/**
|
||||
* `next` is the element that we'll select next, on directional navigation,
|
||||
* unless the element is cancelled. This *is* settable and you can use it
|
||||
* to modify the focus target. This will be set to `null` on non-directional
|
||||
* navigation or if we can't find a subsequent element to select.
|
||||
*/
|
||||
public next: HTMLElement | null;
|
||||
|
||||
/**
|
||||
* Whether the default action (focus change) of this event
|
||||
* has been cancelled.
|
||||
*/
|
||||
public readonly defaultPrevented = false;
|
||||
|
||||
/**
|
||||
* Callback for when propogation is stopped.
|
||||
*/
|
||||
protected propogationStopped = false;
|
||||
|
||||
constructor(options: {
|
||||
directive?: IArcHandler;
|
||||
next: HTMLElement | null;
|
||||
event: Direction;
|
||||
target: HTMLElement | null;
|
||||
}) {
|
||||
this.directive = options.directive;
|
||||
this.event = options.event;
|
||||
this.target = options.target;
|
||||
this.next = options.next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be called to prevent the event from bubbling to higher up listeners.
|
||||
*/
|
||||
public stopPropagation() {
|
||||
this.propogationStopped = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be called to prevent the focus from changing, or the action from
|
||||
* otherwise submitting, as a result of this event.
|
||||
*/
|
||||
public preventDefault() {
|
||||
(this as any).defaultPrevented = true;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import { mount } from 'enzyme';
|
||||
import * as React from 'react';
|
||||
|
||||
import { ArcAutoFocus } from './arc-autofocus';
|
||||
|
||||
const NormalInput = (props: { className: string }) => <input className={props.className}/>;
|
||||
const FocusedInput = ArcAutoFocus(NormalInput);
|
||||
|
||||
describe('ArcAutoFocus', () => {
|
||||
it('focuses the first html element', () => {
|
||||
const cmp = mount(<div>
|
||||
<NormalInput className="not-focused" />
|
||||
<FocusedInput className="focused" />
|
||||
</div>);
|
||||
|
||||
expect(document.activeElement.className).toEqual('focused');
|
||||
cmp.unmount();
|
||||
});
|
||||
|
||||
it('focuses via selector', () => {
|
||||
const Fixture = ArcAutoFocus(
|
||||
<div>
|
||||
<NormalInput className="not-focused" />
|
||||
<NormalInput className="focused" />,
|
||||
</div>,
|
||||
'.focused'
|
||||
);
|
||||
const cmp = mount(<div><Fixture /></div>);
|
||||
expect(document.activeElement.className).toEqual('focused');
|
||||
cmp.unmount();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,45 @@
|
|||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import { Composable, renderComposed } from '../internal-types';
|
||||
|
||||
/**
|
||||
* Component that autofocuses whatever is contained inside it. By default,
|
||||
* it will focus its first direct child, but can also take a selector
|
||||
* or try to focus itself as a fallback. It will only run focusing
|
||||
* when it's first mounted.
|
||||
*
|
||||
* Note: for elements that have it, you should use the React built-in
|
||||
* autoFocus instead -- this is not present for elements which aren't
|
||||
* usually focusable, however.
|
||||
*
|
||||
* @example
|
||||
* const box = ArcAutoFocus(<div class="myBox" tabIndex={0}>)
|
||||
*/
|
||||
export const ArcAutoFocus = <P extends {} = {}>(
|
||||
Composed: Composable<P>,
|
||||
selector?: string,
|
||||
) =>
|
||||
class ArcAutoFocusComponent extends React.PureComponent<P> {
|
||||
public componentDidMount() {
|
||||
const node = ReactDOM.findDOMNode(this);
|
||||
if (!(node instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
let focusTarget: Element | null = null;
|
||||
if (selector) {
|
||||
focusTarget = node.querySelector(selector);
|
||||
} else if (node.children.length) {
|
||||
focusTarget = node.children[0];
|
||||
} else {
|
||||
focusTarget = node;
|
||||
}
|
||||
|
||||
if (focusTarget) {
|
||||
(focusTarget as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return renderComposed(Composed, this.props);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,39 @@
|
|||
import * as React from 'react';
|
||||
import { FocusService } from '../focus-service';
|
||||
import { InputService } from '../input';
|
||||
import { ArcContext, Composable, renderComposed } from '../internal-types';
|
||||
import { StateContainer } from '../state/state-container';
|
||||
|
||||
/**
|
||||
* HOC for defining the root of the arcade-machine. This should be wrapped
|
||||
* around the root of your application, or its content. Only components
|
||||
* contained within the ArcRoot will be focusable.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* class MyAppContext extends React.Component {
|
||||
* // ...
|
||||
* }
|
||||
*
|
||||
* export default ArcRoot(MyAppContent);
|
||||
*/
|
||||
export const ArcRoot = <P extends {}>(Composed: Composable<P>) =>
|
||||
class ArcRootComponent extends React.PureComponent<P> {
|
||||
private stateContainer = new StateContainer();
|
||||
private rootRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
public componentDidMount() {
|
||||
const focus = new FocusService(this.stateContainer);
|
||||
new InputService(focus).bootstrap(this.rootRef.current!);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<ArcContext.Provider value={{ state: this.stateContainer }}>
|
||||
<div ref={this.rootRef}>
|
||||
{renderComposed(Composed, this.props)}
|
||||
</div>
|
||||
</ArcContext.Provider>
|
||||
);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,55 @@
|
|||
import { mount } from 'enzyme';
|
||||
import * as React from 'react';
|
||||
|
||||
import { ArcContext } from '../internal-types';
|
||||
import { StateContainer } from '../state/state-container';
|
||||
import { ArcDown, ArcUp } from './arc-scope';
|
||||
|
||||
describe('ArcScope', () => {
|
||||
const render = (Component: React.ComponentType<{}>) => {
|
||||
const state = new StateContainer();
|
||||
const contents = mount(
|
||||
<div>
|
||||
<ArcContext.Provider value={{ state }}>
|
||||
<Component />
|
||||
</ArcContext.Provider>,
|
||||
</div>,
|
||||
);
|
||||
|
||||
return {
|
||||
contents,
|
||||
state,
|
||||
};
|
||||
};
|
||||
|
||||
it('renders and stores data in the state', () => {
|
||||
const { state, contents } = render(ArcDown('#foo', () => <div className="testclass" />));
|
||||
const targetEl = contents.getDOMNode().querySelector('.testclass') as HTMLElement;
|
||||
expect(targetEl).toBeTruthy();
|
||||
const record = state.find(targetEl);
|
||||
expect(record).toBeTruthy();
|
||||
expect(record).toEqual({
|
||||
arcFocusDown: '#foo',
|
||||
element: targetEl,
|
||||
});
|
||||
});
|
||||
|
||||
it('removes state when unmounting the component', () => {
|
||||
const { state, contents } = render(ArcDown('#foo', () => <div className="testclass" />));
|
||||
const targetEl = contents.getDOMNode().querySelector('.testclass') as HTMLElement;
|
||||
contents.unmount();
|
||||
expect(state.find(targetEl)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('composes multiple arc scopes into a single context', () => {
|
||||
const { state, contents } = render(ArcDown('#foo', ArcUp('#bar', () => <div className="testclass" />)));
|
||||
const targetEl = contents.getDOMNode().querySelector('.testclass') as HTMLElement;
|
||||
expect(state.find(targetEl)).toEqual({
|
||||
arcFocusDown: '#foo',
|
||||
arcFocusUp: '#bar',
|
||||
element: targetEl,
|
||||
});
|
||||
|
||||
expect((state as any).arcs.get(targetEl).records).toHaveLength(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,148 @@
|
|||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
|
||||
import { ArcEvent } from '../arc-event';
|
||||
import { ArcContext, Composable, requireContext } from '../internal-types';
|
||||
import { IArcHandler } from '../model';
|
||||
import { StateContainer } from '../state/state-container';
|
||||
|
||||
const isArcScopeSymbol = Symbol();
|
||||
|
||||
interface IArcScopeClass<P> extends React.ComponentClass<P> {
|
||||
options: Partial<IArcHandler>;
|
||||
[isArcScopeSymbol]: true;
|
||||
}
|
||||
|
||||
function isArcScope(type: any): type is IArcScopeClass<any> {
|
||||
return type[isArcScopeSymbol] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* ArcScope configures the arcade-machine options used for components
|
||||
* nested/composed inside of it.
|
||||
*/
|
||||
export const ArcScope = <P extends {} = {}>(
|
||||
Composed: Composable<P>,
|
||||
options: Partial<IArcHandler>,
|
||||
): React.ComponentClass<P> => {
|
||||
// We collapse multiple combined scopes together, in an optimization
|
||||
// for nested HOCs.
|
||||
if (isArcScope(Composed)) {
|
||||
Composed.options = { ...Composed.options, ...options };
|
||||
return Composed as React.ComponentClass<P>;
|
||||
}
|
||||
|
||||
return class ArcScopeComponent extends React.PureComponent<P> {
|
||||
/**
|
||||
* Symbole to denote collapsable classes of this type.
|
||||
*/
|
||||
public static [isArcScopeSymbol] = true;
|
||||
|
||||
/**
|
||||
* Options, modified during composition.
|
||||
*/
|
||||
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 typeof Composed === 'function' ? <Composed {...this.props} /> : Composed;
|
||||
});
|
||||
|
||||
public componentDidMount() {
|
||||
const node = ReactDOM.findDOMNode(this);
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
throw new Error(
|
||||
`Attempted to mount an <ArcScope /> not attached to an element, got ${node}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.stateContainer.add(this, { element: node, ...ArcScopeComponent.options });
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.stateContainer.remove(this, this.node);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return <ArcContext.Consumer>{this.withContext}</ArcContext.Consumer>;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Overrides the element focused when going up from this element.
|
||||
*/
|
||||
export const ArcUp = <P extends {} = {}>(target: HTMLElement | string, composed: Composable<P>) =>
|
||||
ArcScope(composed, { arcFocusUp: target });
|
||||
|
||||
/**
|
||||
* Overrides the element focused when going left from this element.
|
||||
*/
|
||||
export const ArcLeft = <P extends {} = {}>(target: HTMLElement | string, composed: Composable<P>) =>
|
||||
ArcScope(composed, { arcFocusLeft: target });
|
||||
|
||||
/**
|
||||
* Overrides the element focused when going down from this element.
|
||||
*/
|
||||
export const ArcDown = <P extends {} = {}>(target: HTMLElement | string, composed: Composable<P>) =>
|
||||
ArcScope(composed, { arcFocusDown: target });
|
||||
|
||||
/**
|
||||
* Overrides the element focused when going right from this element.
|
||||
*/
|
||||
export const ArcRight = <P extends {} = {}>(
|
||||
target: HTMLElement | string,
|
||||
composed: Composable<P>,
|
||||
) => ArcScope(composed, { arcFocusRight: target });
|
||||
|
||||
/**
|
||||
* Excludes the composed element from being focusable
|
||||
*/
|
||||
export const ArcExcludeThis = <P extends {} = {}>(composed: Composable<P>) =>
|
||||
ArcScope(composed, { excludeThis: true });
|
||||
|
||||
/**
|
||||
* Excludes the composed element and all children from being focusable
|
||||
*/
|
||||
export const ArcExcludeDeep = <P extends {} = {}>(composed: Composable<P>) =>
|
||||
ArcScope(composed, { exclude: true });
|
||||
|
||||
/**
|
||||
* Called with an IArcEvent focus is about
|
||||
* to leave this element or one of its children.
|
||||
*/
|
||||
export const ArcOnOutgoing = <P extends {} = {}>(
|
||||
handler: (ev: ArcEvent) => void,
|
||||
composed: Composable<P>,
|
||||
) => ArcScope(composed, { onOutgoing: handler });
|
||||
|
||||
/**
|
||||
* Called with an IArcEvent focus is about
|
||||
* to enter this element or one of its children.
|
||||
*/
|
||||
export const ArcOnIncoming = <P extends {} = {}>(
|
||||
handler: (ev: ArcEvent) => void,
|
||||
composed: Composable<P>,
|
||||
) => ArcScope(composed, { onIncoming: handler });
|
||||
|
||||
/**
|
||||
* Triggers a focus change event.
|
||||
*/
|
||||
export const ArcOnFocus = <P extends {} = {}>(
|
||||
handler: (el: HTMLElement | null) => void,
|
||||
composed: Composable<P>,
|
||||
) => ArcScope(composed, { onFocus: handler });
|
|
@ -0,0 +1,72 @@
|
|||
import { IDebouncer } from './gamepad';
|
||||
|
||||
const enum DebouncerStage {
|
||||
IDLE,
|
||||
HELD,
|
||||
FAST,
|
||||
}
|
||||
|
||||
/**
|
||||
* DirectionalDebouncer debounces directional navigation like arrow keys,
|
||||
* handling "holding" states.
|
||||
*/
|
||||
export class DirectionalDebouncer implements IDebouncer {
|
||||
/**
|
||||
* Initial debounce after a joystick is pressed before beginning shorter
|
||||
* press debouncded.
|
||||
*/
|
||||
public static initialDebounce = 500;
|
||||
|
||||
/**
|
||||
* Fast debounce time for joysticks when they're being held in a direction.
|
||||
*/
|
||||
public static fastDebounce = 150;
|
||||
|
||||
/**
|
||||
* The time that the debounce was initially started.
|
||||
*/
|
||||
private heldAt = 0;
|
||||
|
||||
/**
|
||||
* Current state of the debouncer.
|
||||
*/
|
||||
private stage = DebouncerStage.IDLE;
|
||||
|
||||
constructor(private predicate: () => boolean) {}
|
||||
|
||||
/**
|
||||
* Returns whether the key should be registered as pressed.
|
||||
*/
|
||||
public attempt(now: number): boolean {
|
||||
const result = this.predicate();
|
||||
if (!result) {
|
||||
this.stage = DebouncerStage.IDLE;
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (this.stage) {
|
||||
case DebouncerStage.IDLE:
|
||||
this.stage = DebouncerStage.HELD;
|
||||
this.heldAt = now;
|
||||
return true;
|
||||
|
||||
case DebouncerStage.HELD:
|
||||
if (now - this.heldAt < DirectionalDebouncer.initialDebounce) {
|
||||
return false;
|
||||
}
|
||||
this.heldAt = now;
|
||||
this.stage = DebouncerStage.FAST;
|
||||
return true;
|
||||
|
||||
case DebouncerStage.FAST:
|
||||
if (now - this.heldAt < DirectionalDebouncer.fastDebounce) {
|
||||
return false;
|
||||
}
|
||||
this.heldAt = now;
|
||||
return true;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown debouncer stage ${this.stage}!`);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* FiredDebouncer handles single "fired" states that happen from button presses.
|
||||
*/
|
||||
export class FiredDebouncer {
|
||||
private fired = false;
|
||||
|
||||
constructor(private predicate: () => boolean) {}
|
||||
|
||||
/**
|
||||
* Returns whether the key should be registered as pressed.
|
||||
*/
|
||||
public attempt(): boolean {
|
||||
const result = this.predicate();
|
||||
const hadFired = this.fired;
|
||||
this.fired = result;
|
||||
|
||||
return !hadFired && result;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import { Direction } from '../model';
|
||||
|
||||
export interface IGamepadWrapper {
|
||||
/**
|
||||
* Map from a direction to a function that takes in a time (now)
|
||||
* and returns whether that direction fired
|
||||
*/
|
||||
readonly events: Map<Direction, (now: number) => boolean>;
|
||||
|
||||
/**
|
||||
* The actual Gamepad object that can be updated/accessed;
|
||||
*/
|
||||
pad: Gamepad;
|
||||
|
||||
/**
|
||||
* Returns whether the gamepad is still connected;
|
||||
*/
|
||||
isConnected(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* IDebouncer should be called whenever an input is held down. It returns
|
||||
* whether that input should fire a nevigational event.
|
||||
*/
|
||||
export interface IDebouncer {
|
||||
/**
|
||||
* Called with the current time, determines whether to fire a navigational
|
||||
* event.
|
||||
*/
|
||||
attempt(node: number): boolean;
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
import { Direction, nonDirectionalButtons } from '../model';
|
||||
import { DirectionalDebouncer } from './directional-debouncer';
|
||||
import { FiredDebouncer } from './fired-debouncer';
|
||||
import { IGamepadWrapper } from './gamepad';
|
||||
|
||||
/**
|
||||
* XboxGamepadWrapper wraps an Xbox controller input for arcade-machine.
|
||||
*/
|
||||
export class XboxGamepadWrapper implements IGamepadWrapper {
|
||||
/**
|
||||
* Magnitude that joysticks have to go in one direction to be translated
|
||||
* into a direction key press.
|
||||
*/
|
||||
public static joystickThreshold = 0.5;
|
||||
|
||||
/**
|
||||
* Map from Direction to a function that takes a time (now) and returns
|
||||
* whether that direction fired
|
||||
*/
|
||||
public events = new Map<Direction, (now: number) => boolean>();
|
||||
|
||||
constructor(public pad: Gamepad) {
|
||||
const left = new DirectionalDebouncer(() => {
|
||||
/* left joystick */
|
||||
return (
|
||||
this.pad.axes[0] < -XboxGamepadWrapper.joystickThreshold ||
|
||||
this.pad.buttons[Direction.Left].pressed
|
||||
);
|
||||
});
|
||||
const right = new DirectionalDebouncer(() => {
|
||||
/* right joystick */
|
||||
return (
|
||||
this.pad.axes[0] > XboxGamepadWrapper.joystickThreshold ||
|
||||
this.pad.buttons[Direction.Right].pressed
|
||||
);
|
||||
});
|
||||
const up = new DirectionalDebouncer(() => {
|
||||
/* up joystick */
|
||||
return (
|
||||
this.pad.axes[1] < -XboxGamepadWrapper.joystickThreshold ||
|
||||
this.pad.buttons[Direction.Up].pressed
|
||||
);
|
||||
});
|
||||
const down = new DirectionalDebouncer(() => {
|
||||
/* down joystick */
|
||||
return (
|
||||
this.pad.axes[1] > XboxGamepadWrapper.joystickThreshold ||
|
||||
this.pad.buttons[Direction.Down].pressed
|
||||
);
|
||||
});
|
||||
|
||||
this.events.set(Direction.Left, now => left.attempt(now));
|
||||
this.events.set(Direction.Right, now => right.attempt(now));
|
||||
this.events.set(Direction.Up, now => up.attempt(now));
|
||||
this.events.set(Direction.Down, now => down.attempt(now));
|
||||
|
||||
for (const button of nonDirectionalButtons) {
|
||||
this.events.set(button, () =>
|
||||
new FiredDebouncer(() => this.pad.buttons[button].pressed).attempt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public isConnected() {
|
||||
return this.pad.connected;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,714 @@
|
|||
import { Subscription } from 'rxjs';
|
||||
|
||||
import { ArcEvent } from './arc-event';
|
||||
import { ElementFinder } from './focus-strategies/focus-by-distance';
|
||||
import { FocusByRegistry } from './focus-strategies/focus-by-registry';
|
||||
import { Direction, isDirectional, isHorizontal } from './model';
|
||||
import { StateContainer } from './state/state-container';
|
||||
|
||||
const defaultFocusRoot = document.body;
|
||||
// These factors can be tweaked to adjust which elements are favored by the focus algorithm
|
||||
const scoringConstants = Object.freeze({
|
||||
fastSearchMaxPointChecks: 30,
|
||||
fastSearchMinimumDistance: 40,
|
||||
fastSearchPointDistance: 10,
|
||||
maxFastSearchSize: 0.5,
|
||||
});
|
||||
|
||||
interface IFocusState {
|
||||
root: HTMLElement;
|
||||
focusedElem: HTMLElement | null;
|
||||
}
|
||||
|
||||
interface IReducedClientRect {
|
||||
top: number;
|
||||
left: number;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
// Default client rect to use. We set the top, left, bottom and right
|
||||
// properties of the referenceBoundingRectangle to '-1' (as opposed to '0')
|
||||
// because we want to make sure that even elements that are up to the edge
|
||||
// of the screen can receive focus.
|
||||
const defaultRect: ClientRect = Object.freeze({
|
||||
bottom: -1,
|
||||
height: 0,
|
||||
left: -1,
|
||||
right: -1,
|
||||
top: -1,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
function roundRect(rect: HTMLElement | ClientRect): ClientRect {
|
||||
if (rect instanceof HTMLElement) {
|
||||
rect = rect.getBoundingClientRect();
|
||||
}
|
||||
|
||||
// There's rounding here because floating points make certain math not work.
|
||||
return {
|
||||
bottom: Math.floor(rect.top + rect.height),
|
||||
height: Math.floor(rect.height),
|
||||
left: Math.floor(rect.left),
|
||||
right: Math.floor(rect.left + rect.width),
|
||||
top: Math.floor(rect.top),
|
||||
width: Math.floor(rect.width),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the common ancestor in the DOM of two nodes. From:
|
||||
* http://stackoverflow.com/a/7648545
|
||||
*/
|
||||
function getCommonAncestor(
|
||||
nodeA: HTMLElement | null,
|
||||
nodeB: HTMLElement | null,
|
||||
): HTMLElement | null {
|
||||
if (nodeA === null || nodeB === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mask = 0x10;
|
||||
while (nodeA != null && nodeA.parentElement) {
|
||||
nodeA = nodeA.parentElement;
|
||||
// tslint:disable-next-line
|
||||
if ((nodeA.compareDocumentPosition(nodeB) & mask) === mask) {
|
||||
return nodeA;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolation with quadratic speed up and slow down.
|
||||
*/
|
||||
function quad(start: number, end: number, progress: number): number {
|
||||
const diff = end - start;
|
||||
if (progress < 0.5) {
|
||||
return diff * (2 * progress ** 2) + start;
|
||||
} else {
|
||||
const displaced = progress - 1;
|
||||
return diff * (-2 * displaced ** 2 + 1) + start;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the target DOM node is a child of the root.
|
||||
*/
|
||||
function isNodeAttached(node: HTMLElement | null, root: HTMLElement | null) {
|
||||
if (!node || !root) {
|
||||
return false;
|
||||
}
|
||||
return root.contains(node);
|
||||
}
|
||||
|
||||
export class FocusService {
|
||||
public enableRaycast = false;
|
||||
public focusedClass = 'arc--selected-direct';
|
||||
|
||||
/**
|
||||
* Animation speed in pixels per second for scrolling elements into view.
|
||||
* This can be Infinity to disable the animation, or null to disable scrolling.
|
||||
*/
|
||||
public scrollSpeed: number | null = 1000;
|
||||
public focusRoot: HTMLElement = defaultFocusRoot;
|
||||
// The currently selected element.
|
||||
public selected: HTMLElement | null = null;
|
||||
// Focus root, the service operates below here.
|
||||
private root: HTMLElement | null = null;
|
||||
// Subscription to focus update events.
|
||||
private registrySubscription?: Subscription;
|
||||
|
||||
private prevElement: HTMLElement | undefined;
|
||||
// The client bounding rect when we first selected the element, cached
|
||||
// so that we can reuse it if the element gets detached.
|
||||
private referenceRect: ClientRect = defaultRect;
|
||||
private focusStack: IFocusState[] = [];
|
||||
private focusByRegistry = new FocusByRegistry();
|
||||
|
||||
constructor(private registry: StateContainer) {}
|
||||
|
||||
public trapFocus(newRootElem: HTMLElement) {
|
||||
this.focusStack.push({
|
||||
focusedElem: this.selected,
|
||||
root: this.focusRoot,
|
||||
});
|
||||
this.focusRoot = newRootElem;
|
||||
}
|
||||
|
||||
public releaseFocus(releaseElem?: HTMLElement, scrollSpeed: number | null = this.scrollSpeed) {
|
||||
if (releaseElem) {
|
||||
if (releaseElem === this.focusRoot) {
|
||||
this.releaseFocus(undefined, scrollSpeed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const lastFocusState = this.focusStack.pop();
|
||||
if (lastFocusState && lastFocusState.focusedElem) {
|
||||
this.focusRoot = lastFocusState.root;
|
||||
this.selectNode(lastFocusState.focusedElem, scrollSpeed);
|
||||
} else {
|
||||
throw new Error(
|
||||
'No more focus traps to release. Make sure you call trapFocus before using releaseFocus',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Useful for resetting all focus traps e.g. on page navigation
|
||||
*/
|
||||
public clearAllTraps() {
|
||||
this.focusStack.length = 0;
|
||||
this.focusRoot = defaultFocusRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the root element to use for focusing.
|
||||
*/
|
||||
public setRoot(root: HTMLElement, scrollSpeed: number | null = this.scrollSpeed) {
|
||||
if (this.registrySubscription) {
|
||||
this.registrySubscription.unsubscribe();
|
||||
}
|
||||
|
||||
this.root = root;
|
||||
|
||||
if (!this.selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!root.contains(this.selected)) {
|
||||
this.setDefaultFocus(scrollSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public selectNode(next: HTMLElement, scrollSpeed: number | null = this.scrollSpeed) {
|
||||
if (!this.focusRoot.contains(next)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canceled = !next.dispatchEvent(
|
||||
new Event('arcselectingnode', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectNodeWithoutEvent(next, scrollSpeed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the selected DOM node.
|
||||
* This is useful when you do not want to dispatch another event
|
||||
* e.g. when intercepting and transfering focus
|
||||
*/
|
||||
public selectNodeWithoutEvent(next: HTMLElement, scrollSpeed: number | null = this.scrollSpeed) {
|
||||
if (!this.root) {
|
||||
throw new Error('root not set');
|
||||
}
|
||||
if (this.selected === next) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.prevElement = this.selected || undefined;
|
||||
|
||||
this.triggerOnFocusHandlers(next);
|
||||
this.switchFocusClass(this.selected, next, this.focusedClass);
|
||||
this.selected = next;
|
||||
this.referenceRect = next.getBoundingClientRect();
|
||||
this.rescroll(next, scrollSpeed, this.root);
|
||||
|
||||
const canceled = !next.dispatchEvent(
|
||||
new Event('arcfocuschanging', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
if (!canceled) {
|
||||
next.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees resources associated with the service.
|
||||
*/
|
||||
public teardown() {
|
||||
if (!this.registrySubscription) {
|
||||
return;
|
||||
}
|
||||
this.registrySubscription.unsubscribe();
|
||||
}
|
||||
|
||||
public createArcEvent(direction: Direction): ArcEvent {
|
||||
const directive = this.selected ? this.registry.find(this.selected) : undefined;
|
||||
|
||||
let nextElem: HTMLElement | null = null;
|
||||
if (isDirectional(direction)) {
|
||||
let refRect = this.referenceRect;
|
||||
|
||||
if (this.selected && this.focusRoot.contains(this.selected)) {
|
||||
refRect = this.selected.getBoundingClientRect();
|
||||
}
|
||||
|
||||
nextElem = this.getFocusableElement(
|
||||
direction,
|
||||
this.focusRoot,
|
||||
refRect,
|
||||
new Set<HTMLElement>(),
|
||||
);
|
||||
}
|
||||
|
||||
return new ArcEvent({
|
||||
directive,
|
||||
event: direction,
|
||||
next: nextElem,
|
||||
target: this.selected,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Attempts to effect the focus command, returning a
|
||||
* boolean if it was handled.
|
||||
*/
|
||||
public bubble(ev: ArcEvent): boolean {
|
||||
if (isNodeAttached(this.selected, this.root)) {
|
||||
this.bubbleEvent(ev, false);
|
||||
}
|
||||
|
||||
// Abort if the user handled
|
||||
if (ev.defaultPrevented) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Bubble once more on the target.
|
||||
if (ev.next) {
|
||||
this.bubbleEvent(ev, true, ev.next);
|
||||
if (ev.defaultPrevented) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public defaultFires(ev: ArcEvent, scrollSpeed: number | null = this.scrollSpeed): boolean {
|
||||
if (ev.defaultPrevented) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const directional = isDirectional(ev.event);
|
||||
if (directional && ev.next !== null) {
|
||||
this.selectNode(ev.next, scrollSpeed);
|
||||
return true;
|
||||
} else if (ev.event === Direction.Submit) {
|
||||
if (this.selected) {
|
||||
this.selected.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private getFocusableElement(
|
||||
direction: Direction,
|
||||
root: HTMLElement,
|
||||
refRect: ClientRect,
|
||||
ignore: Set<HTMLElement>,
|
||||
): HTMLElement | null {
|
||||
let nextFocusableEl = this.findNextFocusable(direction, root, refRect, ignore);
|
||||
|
||||
if (!nextFocusableEl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directive = this.registry.find(nextFocusableEl);
|
||||
if (directive && directive.arcFocusInside) {
|
||||
const elementInside = this.getFocusableElement(direction, nextFocusableEl, refRect, ignore);
|
||||
|
||||
// get focusable again if no focusable elements inside the current element
|
||||
nextFocusableEl =
|
||||
elementInside ||
|
||||
this.getFocusableElement(direction, root, refRect, ignore.add(nextFocusableEl));
|
||||
}
|
||||
|
||||
return nextFocusableEl;
|
||||
}
|
||||
|
||||
private findNextFocusable(
|
||||
direction: Direction,
|
||||
root: HTMLElement,
|
||||
refRect: ClientRect,
|
||||
ignore: Set<HTMLElement>,
|
||||
) {
|
||||
const directive = this.selected ? this.registry.find(this.selected) : undefined;
|
||||
let nextElem: HTMLElement | null = null;
|
||||
|
||||
if (directive) {
|
||||
nextElem = this.focusByRegistry.findNextFocus(direction, directive);
|
||||
}
|
||||
|
||||
if (!nextElem && this.enableRaycast) {
|
||||
nextElem = this.findNextFocusByRaycast(direction, this.focusRoot, this.referenceRect);
|
||||
}
|
||||
|
||||
if (!nextElem) {
|
||||
const focusableElems = Array.from(root.querySelectorAll<HTMLElement>('[tabIndex]')).filter(
|
||||
el => !ignore.has(el) && this.isFocusable(el),
|
||||
);
|
||||
|
||||
const finder = new ElementFinder(direction, refRect, focusableElems, this.prevElement);
|
||||
nextElem = finder.find();
|
||||
}
|
||||
return nextElem;
|
||||
}
|
||||
|
||||
private triggerOnFocusHandlers(next: HTMLElement) {
|
||||
if (!this.root) {
|
||||
throw new Error('root not set');
|
||||
}
|
||||
const isAttached = this.selected !== null && this.root.contains(this.selected);
|
||||
if (!isAttached) {
|
||||
let elem: HTMLElement | null = next;
|
||||
while (elem !== null && elem !== this.root) {
|
||||
this.triggerFocusChange(elem, null);
|
||||
elem = elem.parentElement;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the common ancestor of the next and currently selected element.
|
||||
// Trigger focus changes on every element that we touch.
|
||||
const common = getCommonAncestor(next, this.selected);
|
||||
let el = this.selected;
|
||||
while (el !== common && el !== null) {
|
||||
this.triggerFocusChange(el, null);
|
||||
el = el.parentElement;
|
||||
}
|
||||
|
||||
el = next;
|
||||
while (el !== common && el !== null) {
|
||||
this.triggerFocusChange(el, null);
|
||||
el = el.parentElement;
|
||||
}
|
||||
|
||||
el = common;
|
||||
while (el !== this.root && el !== null) {
|
||||
this.triggerFocusChange(el, null);
|
||||
el = el.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
private switchFocusClass(prevElem: HTMLElement | null, nextElem: HTMLElement, className: string) {
|
||||
if (className) {
|
||||
if (prevElem) {
|
||||
prevElem.classList.remove(className);
|
||||
}
|
||||
nextElem.classList.add(className);
|
||||
}
|
||||
}
|
||||
|
||||
private triggerFocusChange(el: HTMLElement, next: HTMLElement | null) {
|
||||
const directive = this.registry.find(el);
|
||||
if (directive && directive.onFocus) {
|
||||
directive.onFocus(next);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the page so that the selected element is visible.
|
||||
*/
|
||||
private rescroll(el: HTMLElement, scrollSpeed: number | null, container: HTMLElement) {
|
||||
// Abort if scrolling is disabled.
|
||||
if (scrollSpeed === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Animation function to transition a scroll on the `parent` from the
|
||||
// `original` value to the `target` value by calling `set.
|
||||
const animate = (
|
||||
parentElement: HTMLElement,
|
||||
target: number,
|
||||
original: number,
|
||||
setter: (x: number) => void,
|
||||
) => {
|
||||
if (scrollSpeed === Infinity) {
|
||||
parentElement.scrollTop = target;
|
||||
return;
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
const duration = (Math.abs(target - original) / scrollSpeed) * 1000;
|
||||
const run = (now: number) => {
|
||||
const progress = Math.min((now - start) / duration, 1);
|
||||
setter(quad(original, target, progress));
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(run);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(run);
|
||||
};
|
||||
|
||||
// The scroll calculation loop. Starts at the element and goes up, ensuring
|
||||
// that the element (or the box where the element will be after scrolling
|
||||
// is applied) is visible in all containers.
|
||||
const { width, height, top, left } = this.referenceRect;
|
||||
|
||||
let parent = el.parentElement;
|
||||
while (parent != null && parent !== container.parentElement) {
|
||||
// Special case: treat the body as the viewport as far as scrolling goes.
|
||||
let prect: IReducedClientRect;
|
||||
if (parent === container) {
|
||||
const containerStyle = window.getComputedStyle(container, undefined);
|
||||
const paddingTop = containerStyle.paddingTop
|
||||
? Number(containerStyle.paddingTop.slice(0, -2))
|
||||
: 0;
|
||||
const paddingBottom = containerStyle.paddingBottom
|
||||
? Number(containerStyle.paddingBottom.slice(0, -2))
|
||||
: 0;
|
||||
const paddingLeft = containerStyle.paddingLeft
|
||||
? Number(containerStyle.paddingLeft.slice(0, -2))
|
||||
: 0;
|
||||
const paddingRight = containerStyle.paddingRight
|
||||
? Number(containerStyle.paddingRight.slice(0, -2))
|
||||
: 0;
|
||||
prect = {
|
||||
height: container.clientHeight - paddingTop - paddingBottom,
|
||||
left: paddingLeft,
|
||||
top: paddingTop,
|
||||
width: container.clientWidth - paddingLeft - paddingRight,
|
||||
};
|
||||
} else {
|
||||
prect = parent.getBoundingClientRect();
|
||||
}
|
||||
|
||||
// Trigger if this element has a vertical scrollbar
|
||||
if (parent.scrollHeight > parent.clientHeight) {
|
||||
const scrollTop = parent.scrollTop;
|
||||
const showsTop = scrollTop + (top - prect.top);
|
||||
const showsBottom = showsTop + (height - prect.height);
|
||||
|
||||
if (showsTop < scrollTop) {
|
||||
animate(parent, showsTop, scrollTop, x => ((parent as HTMLElement).scrollTop = x));
|
||||
} else if (showsBottom > scrollTop) {
|
||||
animate(parent, showsBottom, scrollTop, x => ((parent as HTMLElement).scrollTop = x));
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger if this element has a horizontal scrollbar
|
||||
if (parent.scrollWidth > parent.clientWidth) {
|
||||
const scrollLeft = parent.scrollLeft;
|
||||
const showsLeft = scrollLeft + (left - prect.left);
|
||||
const showsRight = showsLeft + (width - prect.width);
|
||||
|
||||
if (showsLeft < scrollLeft) {
|
||||
animate(parent, showsLeft, scrollLeft, x => ((parent as HTMLElement).scrollLeft = x));
|
||||
} else if (showsRight > scrollLeft) {
|
||||
animate(parent, showsRight, scrollLeft, x => ((parent as HTMLElement).scrollLeft = x));
|
||||
}
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bubbles the ArcEvent from the currently selected element
|
||||
* to all parent arc directives.
|
||||
*/
|
||||
private bubbleEvent(
|
||||
ev: ArcEvent,
|
||||
incoming: boolean,
|
||||
source: HTMLElement | null = this.selected,
|
||||
): ArcEvent {
|
||||
for (
|
||||
let el = source;
|
||||
!(ev as any).propagationStopped && el !== this.root && el;
|
||||
el = el.parentElement
|
||||
) {
|
||||
if (el === undefined) {
|
||||
// tslint:disable-next-line
|
||||
console.warn(
|
||||
`arcade-machine focusable element was moved outside of` +
|
||||
'the focus root. We may not be able to handle focus correctly.',
|
||||
el,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const directive = this.registry.find(el);
|
||||
if (directive) {
|
||||
if (incoming && directive.onIncoming) {
|
||||
directive.onIncoming(ev);
|
||||
} else if (!incoming && directive.onOutgoing) {
|
||||
directive.onOutgoing(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the element can receive focus.
|
||||
*/
|
||||
private isFocusable(el: HTMLElement): boolean {
|
||||
if (el === this.selected) {
|
||||
return false;
|
||||
}
|
||||
// to prevent navigating to parent container elements with arc-focus-inside
|
||||
if (this.selected && el.contains(this.selected)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Dev note: el.tabindex is not consistent across browsers
|
||||
const tabIndex = el.getAttribute('tabIndex');
|
||||
if (!tabIndex || +tabIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = this.registry.find(el);
|
||||
if (record && record.excludeThis && record.excludeThis) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.registry.hasExcludedDeepElements()) {
|
||||
let parent: HTMLElement | null = el;
|
||||
while (parent) {
|
||||
const parentRecord = this.registry.find(parent);
|
||||
if (parentRecord && parentRecord.exclude && parentRecord.exclude) {
|
||||
return false;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
return this.isVisible(el);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a final check, which can be more expensive, run only if we want to
|
||||
* set the element as our next preferred candidate for focus.
|
||||
*/
|
||||
private checkFinalFocusable(el: HTMLElement): boolean {
|
||||
return this.isVisible(el);
|
||||
}
|
||||
|
||||
private isVisible(el: HTMLElement | null) {
|
||||
if (!el) {
|
||||
return false;
|
||||
}
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the focus if arcade-machine wanders out of root
|
||||
*/
|
||||
private setDefaultFocus(scrollSpeed: number | null = this.scrollSpeed) {
|
||||
const focusableElems = this.focusRoot.querySelectorAll('[tabIndex]');
|
||||
|
||||
// tslint:disable-next-line
|
||||
for (let i = 0; i < focusableElems.length; i += 1) {
|
||||
const potentialElement = focusableElems[i] as HTMLElement;
|
||||
if (this.selected === potentialElement || !this.isFocusable(potentialElement)) {
|
||||
continue;
|
||||
}
|
||||
const potentialRect = roundRect(potentialElement.getBoundingClientRect());
|
||||
// Skip elements that have either a width of zero or a height of zero
|
||||
if (potentialRect.width === 0 || potentialRect.height === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.selectNode(potentialElement, scrollSpeed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* findNextFocusByRaycast is a speedy implementation of focus searching
|
||||
* that uses a raycast to determine the next best element.
|
||||
*/
|
||||
private findNextFocusByRaycast(
|
||||
direction: Direction,
|
||||
root: HTMLElement,
|
||||
referenceRect: ClientRect,
|
||||
) {
|
||||
if (!this.selected) {
|
||||
this.setDefaultFocus();
|
||||
}
|
||||
if (!this.selected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let maxDistance =
|
||||
scoringConstants.maxFastSearchSize *
|
||||
(isHorizontal(direction) ? referenceRect.width : referenceRect.height);
|
||||
if (maxDistance < scoringConstants.fastSearchMinimumDistance) {
|
||||
maxDistance = scoringConstants.fastSearchMinimumDistance;
|
||||
}
|
||||
|
||||
// Sanity check so that we don't freeze if we get some insanely big element
|
||||
let searchPointDistance = scoringConstants.fastSearchPointDistance;
|
||||
if (maxDistance / searchPointDistance > scoringConstants.fastSearchMaxPointChecks) {
|
||||
searchPointDistance = maxDistance / scoringConstants.fastSearchMaxPointChecks;
|
||||
}
|
||||
|
||||
let baseX: number;
|
||||
let baseY: number;
|
||||
let seekX = 0;
|
||||
let seekY = 0;
|
||||
switch (direction) {
|
||||
case Direction.Left:
|
||||
baseX = referenceRect.left - 1;
|
||||
baseY = referenceRect.top + referenceRect.height / 2;
|
||||
seekX = -1;
|
||||
break;
|
||||
case Direction.Right:
|
||||
baseX = referenceRect.left + referenceRect.width + 1;
|
||||
baseY = referenceRect.top + referenceRect.height / 2;
|
||||
seekX = 1;
|
||||
break;
|
||||
case Direction.Up:
|
||||
baseX = referenceRect.left + referenceRect.width / 2;
|
||||
baseY = referenceRect.top - 1;
|
||||
seekY = -1;
|
||||
break;
|
||||
case Direction.Down:
|
||||
baseX = referenceRect.left + referenceRect.width / 2;
|
||||
baseY = referenceRect.top + referenceRect.height + 1;
|
||||
seekY = 1;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid direction');
|
||||
}
|
||||
|
||||
for (let i = 0; i < maxDistance; i += searchPointDistance) {
|
||||
const el = document.elementFromPoint(baseX + seekX * i, baseY + seekY * i) as HTMLElement;
|
||||
|
||||
if (!el || el === this.selected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isNodeAttached(el, root) || !this.isFocusable(el) || !this.checkFinalFocusable(el)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,182 @@
|
|||
import { Direction, isHorizontal } from '../model';
|
||||
|
||||
/**
|
||||
* PotentialElement is a FocusStrategy which uses element positions in the DOM
|
||||
* to determine the best next element to focus.
|
||||
*/
|
||||
class PotentialElement {
|
||||
public rect: ClientRect;
|
||||
public percentInShadow = 0;
|
||||
public primaryDistance = Infinity;
|
||||
public secondaryDistance = Infinity;
|
||||
|
||||
constructor(public el: HTMLElement) {
|
||||
this.rect = this.el.getBoundingClientRect();
|
||||
}
|
||||
|
||||
public calcPercentInShadow(refRect: ClientRect, dir: Direction) {
|
||||
if (isHorizontal(dir)) {
|
||||
this.percentInShadow =
|
||||
Math.min(this.rect.bottom, refRect.bottom) - Math.max(this.rect.top, refRect.top);
|
||||
} else {
|
||||
this.percentInShadow =
|
||||
Math.min(this.rect.right, refRect.right) - Math.max(this.rect.left, refRect.left);
|
||||
}
|
||||
}
|
||||
|
||||
public calcPrimaryDistance(refRect: ClientRect, dir: Direction) {
|
||||
switch (dir) {
|
||||
case Direction.Left:
|
||||
this.primaryDistance = refRect.left - this.rect.right;
|
||||
break;
|
||||
case Direction.Right:
|
||||
this.primaryDistance = this.rect.left - refRect.right;
|
||||
break;
|
||||
case Direction.Up:
|
||||
this.primaryDistance = refRect.top - this.rect.bottom;
|
||||
break;
|
||||
case Direction.Down:
|
||||
this.primaryDistance = this.rect.top - refRect.bottom;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid direction ${dir}`);
|
||||
}
|
||||
}
|
||||
|
||||
public calcSecondaryDistance(refRect: ClientRect, dir: Direction) {
|
||||
if (isHorizontal(dir)) {
|
||||
const refCenter = refRect.top + refRect.height / 2;
|
||||
const isAbove = this.rect.bottom < refCenter;
|
||||
|
||||
this.secondaryDistance = isAbove ? refCenter - this.rect.bottom : this.rect.top - refCenter;
|
||||
} else {
|
||||
const refCenter = refRect.left + refRect.width / 2;
|
||||
const isLeft = this.rect.right < refCenter;
|
||||
|
||||
this.secondaryDistance = isLeft ? refCenter - this.rect.right : this.rect.left - refCenter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tslint:disable-next-line
|
||||
export class ElementFinder {
|
||||
private shortlisted: PotentialElement[];
|
||||
|
||||
constructor(
|
||||
private readonly dir: Direction,
|
||||
private readonly refRect: ClientRect,
|
||||
candidates: HTMLElement[],
|
||||
private readonly prevEl?: HTMLElement,
|
||||
) {
|
||||
this.shortlisted = candidates.map(candidate => new PotentialElement(candidate));
|
||||
}
|
||||
|
||||
public find() {
|
||||
this.shortlisted = this.getElementsInDirection();
|
||||
this.shortlisted = this.shortlisted.filter(el => el.rect.width && el.rect.height);
|
||||
if (!this.shortlisted.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.shortlisted.forEach(el => el.calcPercentInShadow(this.refRect, this.dir));
|
||||
|
||||
const hasElementsInShadow = this.shortlisted.some(el => el.percentInShadow > 0);
|
||||
// Case: No elements in shadow
|
||||
// +------+
|
||||
// | |
|
||||
// +------+
|
||||
// +---------+ --------------
|
||||
// | X -> |
|
||||
// +---------+---------------
|
||||
// +------+ +------+
|
||||
// | X | | |
|
||||
// | | | |
|
||||
// +------+ +------+
|
||||
if (!hasElementsInShadow) {
|
||||
if (isHorizontal(this.dir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.shortlisted.forEach(el => el.calcPrimaryDistance(this.refRect, this.dir));
|
||||
const shortestPrimaryDist = this.getShortestPrimaryDist(this.shortlisted);
|
||||
|
||||
this.shortlisted = this.shortlisted.filter(el => el.primaryDistance === shortestPrimaryDist);
|
||||
this.shortlisted.forEach(el => el.calcSecondaryDistance(this.refRect, this.dir));
|
||||
|
||||
// return the closest element on secondary axis
|
||||
return this.shortlisted.reduce(
|
||||
(prev, curr) => (curr.secondaryDistance <= prev.secondaryDistance ? curr : prev),
|
||||
).el;
|
||||
}
|
||||
|
||||
this.shortlisted = this.shortlisted.filter(el => el.percentInShadow > 0);
|
||||
this.shortlisted.forEach(el => el.calcPrimaryDistance(this.refRect, this.dir));
|
||||
const shortestDist = this.getShortestPrimaryDist(this.shortlisted);
|
||||
|
||||
this.shortlisted = this.shortlisted.filter(el => el.primaryDistance === shortestDist);
|
||||
|
||||
// Case: Multiple elements in shadow
|
||||
// +---------+ -------------------------
|
||||
// | | +------+
|
||||
// | | | |
|
||||
// | X -> | | |
|
||||
// | | +------+
|
||||
// | | +------+
|
||||
// +---------+--------------------------
|
||||
// | |
|
||||
// +------+
|
||||
if (this.shortlisted.length === 1) {
|
||||
return this.shortlisted[0].el;
|
||||
}
|
||||
|
||||
// Case: Mutiple elements in shadow with equal distance
|
||||
// +---------++------+
|
||||
// | || |
|
||||
// | || |
|
||||
// | X -> |+------+
|
||||
// | || |
|
||||
// | || |
|
||||
// +---------++------+
|
||||
if (this.prevEl && this.shortlisted.some(el => el.el === this.prevEl)) {
|
||||
return this.prevEl;
|
||||
}
|
||||
|
||||
if (isHorizontal(this.dir)) {
|
||||
// return top most element
|
||||
return this.shortlisted.reduce((prev, curr) => (curr.rect.top < prev.rect.top ? curr : prev))
|
||||
.el;
|
||||
} else {
|
||||
// return top left element
|
||||
return this.shortlisted.reduce(
|
||||
(prev, curr) => (curr.rect.left < prev.rect.left ? curr : prev),
|
||||
).el;
|
||||
}
|
||||
}
|
||||
|
||||
private getElementsInDirection() {
|
||||
return this.shortlisted.filter(el => {
|
||||
switch (this.dir) {
|
||||
case Direction.Left:
|
||||
return el.rect.right <= this.refRect.left;
|
||||
case Direction.Right:
|
||||
return el.rect.left >= this.refRect.right;
|
||||
case Direction.Up:
|
||||
return el.rect.bottom <= this.refRect.top;
|
||||
case Direction.Down:
|
||||
return el.rect.top >= this.refRect.bottom;
|
||||
default:
|
||||
throw new Error(`Invalid direction ${this.dir}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getShortestPrimaryDist(elements: PotentialElement[]) {
|
||||
let shortestDist = elements[0].primaryDistance;
|
||||
for (const element of elements) {
|
||||
if (element.primaryDistance < shortestDist) {
|
||||
shortestDist = element.primaryDistance;
|
||||
}
|
||||
}
|
||||
return shortestDist;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import { Direction, IArcHandler } from '../model';
|
||||
import { IFocusStrategy } from './index';
|
||||
|
||||
export class FocusByRegistry implements IFocusStrategy {
|
||||
public findNextFocus(direction: Direction, arcHandler: IArcHandler) {
|
||||
const selectedEl = arcHandler;
|
||||
if (selectedEl) {
|
||||
switch (direction) {
|
||||
case Direction.Left:
|
||||
if (selectedEl.arcFocusLeft) {
|
||||
return this.getElement(selectedEl.arcFocusLeft);
|
||||
}
|
||||
break;
|
||||
case Direction.Right:
|
||||
if (selectedEl.arcFocusRight) {
|
||||
return this.getElement(selectedEl.arcFocusRight);
|
||||
}
|
||||
break;
|
||||
case Direction.Up:
|
||||
if (selectedEl.arcFocusUp) {
|
||||
return this.getElement(selectedEl.arcFocusUp);
|
||||
}
|
||||
break;
|
||||
case Direction.Down:
|
||||
if (selectedEl.arcFocusDown) {
|
||||
return this.getElement(selectedEl.arcFocusDown);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getElement(el: HTMLElement | string) {
|
||||
if (typeof el === 'string') {
|
||||
return document.querySelector(el) as HTMLElement;
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { Direction, IArcHandler } from '../model';
|
||||
|
||||
export interface IFocusOptions {
|
||||
/**
|
||||
* The direction the focus is going.
|
||||
*/
|
||||
direction: Direction;
|
||||
|
||||
/**
|
||||
* The last element that was focuse.
|
||||
*/
|
||||
element?: HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* IFocusStrategy is a method of finding the next focusable element. Used
|
||||
* within the focus service.
|
||||
*/
|
||||
export interface IFocusStrategy {
|
||||
findNextFocus(direction: Direction, arcHandler: IArcHandler): HTMLElement | null;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export * from './components/arc-autofocus';
|
||||
export * from './components/arc-root';
|
||||
export * from './components/arc-scope';
|
||||
export * from './model';
|
||||
export * from './arc-event';
|
|
@ -0,0 +1,388 @@
|
|||
import { fromEvent, merge, Subject, Subscription } from 'rxjs';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { keys } from 'uwp-keycodes';
|
||||
|
||||
import { ArcEvent } from './arc-event';
|
||||
import { IGamepadWrapper } from './core/gamepad';
|
||||
import { XboxGamepadWrapper } from './core/xbox-gamepad';
|
||||
import { FocusService } from './focus-service';
|
||||
import { Direction, nonDirectionalButtons } from './model';
|
||||
|
||||
/**
|
||||
* Based on the currently focused DOM element, returns whether the directional
|
||||
* input is part of a form control and should be allowed to bubble through.
|
||||
*/
|
||||
function isForForm(direction: Direction, selected: HTMLElement | null): boolean {
|
||||
if (!selected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Always allow the browser to handle enter key presses in a form or text area.
|
||||
if (direction === Direction.Submit) {
|
||||
let parent: HTMLElement | null = selected;
|
||||
while (parent) {
|
||||
if (
|
||||
parent.tagName === 'FORM' ||
|
||||
parent.tagName === 'TEXTAREA' ||
|
||||
(parent.tagName === 'INPUT' && (parent as HTMLInputElement).type !== 'button')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Okay, not a submission? Well, if we aren't inside a text input, go ahead
|
||||
// and let arcade-machine try to deal with the output.
|
||||
const tag = selected.tagName;
|
||||
if (tag !== 'INPUT' && tag !== 'TEXTAREA') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We'll say that up/down has no effect.
|
||||
if (direction === Direction.Down || direction === Direction.Up) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deal with the output ourselves, allowing arcade-machine to handle it only
|
||||
// if the key press would not have any effect in the context of the input.
|
||||
const input = selected as HTMLInputElement | HTMLTextAreaElement;
|
||||
const { type } = input;
|
||||
if (
|
||||
type !== 'text' &&
|
||||
type !== 'search' &&
|
||||
type !== 'url' &&
|
||||
type !== 'tel' &&
|
||||
type !== 'password'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cursor = input.selectionStart;
|
||||
if (cursor !== input.selectionEnd) {
|
||||
// key input on any range selection will be effectual.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cursor === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(cursor > 0 && direction === Direction.Left) ||
|
||||
(cursor > 0 && direction === Direction.Back) ||
|
||||
(cursor < input.value.length && direction === Direction.Right)
|
||||
);
|
||||
}
|
||||
|
||||
export interface IWindowsInputPane {
|
||||
tryShow(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* InputService handles passing input from the external device (gamepad API
|
||||
* or keyboard) to the arc internals.
|
||||
*/
|
||||
export class InputService {
|
||||
public get keyboardVisible(): boolean {
|
||||
if (!this.inputPane) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.inputPane.occludedRect.y !== 0 || this.inputPane.visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* codeToDirection returns a direction from keyCode
|
||||
*/
|
||||
public codeDirectionMap = new Map<number, Direction>([
|
||||
[keys.LeftArrow, Direction.Left],
|
||||
[keys.GamepadLeftThumbstickLeft, Direction.Left],
|
||||
[keys.GamepadDPadLeft, Direction.Left],
|
||||
[keys.NavigationLeft, Direction.Left],
|
||||
|
||||
[keys.RightArrow, Direction.Right],
|
||||
[keys.GamepadLeftThumbstickRight, Direction.Right],
|
||||
[keys.GamepadDPadRight, Direction.Right],
|
||||
[keys.NavigationRight, Direction.Right],
|
||||
|
||||
[keys.UpArrow, Direction.Up],
|
||||
[keys.GamepadLeftThumbstickUp, Direction.Up],
|
||||
[keys.GamepadDPadUp, Direction.Up],
|
||||
[keys.NavigationUp, Direction.Up],
|
||||
|
||||
[keys.DownArrow, Direction.Down],
|
||||
[keys.GamepadLeftThumbstickDown, Direction.Down],
|
||||
[keys.GamepadDPadDown, Direction.Down],
|
||||
[keys.NavigationDown, Direction.Down],
|
||||
|
||||
[keys.Enter, Direction.Submit],
|
||||
[keys.NavigationAccept, Direction.Submit],
|
||||
[keys.GamepadA, Direction.Submit],
|
||||
|
||||
[keys.Escape, Direction.Back],
|
||||
[keys.GamepadB, Direction.Back],
|
||||
|
||||
[keys.Numpad7, Direction.X],
|
||||
[keys.GamepadX, Direction.X],
|
||||
[keys.Numpad9, Direction.Y],
|
||||
[keys.GamepadY, Direction.Y],
|
||||
|
||||
[keys.Numpad4, Direction.TabLeft],
|
||||
[keys.GamepadLeftShoulder, Direction.TabLeft],
|
||||
[keys.Numpad6, Direction.TabRight],
|
||||
[keys.GamepadRightShoulder, Direction.TabRight],
|
||||
[keys.Numpad8, Direction.TabUp],
|
||||
[keys.GamepadLeftTrigger, Direction.TabUp],
|
||||
[keys.Numpad2, Direction.TabDown],
|
||||
[keys.GamepadRightTrigger, Direction.TabDown],
|
||||
|
||||
[keys.Divide, Direction.View],
|
||||
[keys.GamepadView, Direction.View],
|
||||
[keys.Multiply, Direction.Menu],
|
||||
[keys.GamepadMenu, Direction.Menu],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Mock source for gamepad connections. You can provide gamepads manually
|
||||
* here, but this is mostly for testing purposes.
|
||||
*/
|
||||
public gamepadSrc = new Subject<{ gamepad: Gamepad }>();
|
||||
|
||||
/**
|
||||
* Mock source for keyboard events. You can provide events manually
|
||||
* here, but this is mostly for testing purposes.
|
||||
*/
|
||||
public keyboardSrc = new Subject<{
|
||||
defaultPrevented: boolean;
|
||||
keyCode: number;
|
||||
preventDefault: () => void;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Inputpane and boolean to indicate whether it's visible
|
||||
*/
|
||||
private inputPane = (() => {
|
||||
try {
|
||||
return Windows.UI.ViewManagement.InputPane.getForCurrentView();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
private gamepads: { [key: string]: IGamepadWrapper } = {};
|
||||
private subscriptions: Subscription[] = [];
|
||||
private pollRaf: number | null = null;
|
||||
private emitters = new Map<Direction, Subject<ArcEvent>>();
|
||||
|
||||
constructor(private focus: FocusService) {}
|
||||
|
||||
/**
|
||||
* Gets the (global) ArcEvent emitter for a direction
|
||||
*/
|
||||
public getDirectionEmitter(direction: Direction): Subject<ArcEvent> | undefined {
|
||||
return this.emitters.get(direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap attaches event listeners from the service to the DOM and sets
|
||||
* up the focuser rooted in the target element.
|
||||
*/
|
||||
public bootstrap(root: HTMLElement = document.body) {
|
||||
nonDirectionalButtons.forEach(num => this.emitters.set(num, new Subject<ArcEvent>()));
|
||||
|
||||
// The gamepadInputEmulation is a string property that exists in
|
||||
// JavaScript UWAs and in WebViews in UWAs. It won't exist in
|
||||
// Win8.1 style apps or browsers.
|
||||
if ('gamepadInputEmulation' in navigator) {
|
||||
// We want the gamepad to provide gamepad VK keyboard events rather than moving a
|
||||
// mouse like cursor. The gamepad will provide such keyboard events and provide
|
||||
// input to the DOM
|
||||
navigator.gamepadInputEmulation = 'keyboard';
|
||||
} else if ('getGamepads' in navigator) {
|
||||
// Poll connected gamepads and use that for input if keyboard emulation isn't available
|
||||
this.watchForGamepad();
|
||||
}
|
||||
|
||||
this.addKeyboardListeners();
|
||||
this.focus.setRoot(root, this.focus.scrollSpeed);
|
||||
|
||||
this.subscriptions.push(
|
||||
fromEvent<FocusEvent>(document, 'focusin')
|
||||
.pipe(filter(ev => ev.target !== this.focus.selected))
|
||||
.subscribe(ev => {
|
||||
this.focus.onFocusChange(ev.target as HTMLElement, this.focus.scrollSpeed);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters all listeners and frees resources associated with the service.
|
||||
*/
|
||||
public teardown() {
|
||||
this.focus.teardown();
|
||||
this.gamepads = {};
|
||||
if (this.pollRaf) {
|
||||
cancelAnimationFrame(this.pollRaf);
|
||||
}
|
||||
this.subscriptions.forEach(sub => sub.unsubscribe());
|
||||
|
||||
if ('gamepadInputEmulation' in navigator) {
|
||||
(navigator as any).gamepadInputEmulation = 'mouse';
|
||||
}
|
||||
}
|
||||
|
||||
public setRoot(root: HTMLElement) {
|
||||
this.focus.setRoot(root, this.focus.scrollSpeed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a direction event, returns whether the event has been handled
|
||||
*/
|
||||
public handleDirection(direction: Direction): boolean {
|
||||
let dirHandled: boolean;
|
||||
const ev = this.focus.createArcEvent(direction);
|
||||
const forForm = isForForm(direction, this.focus.selected);
|
||||
dirHandled = !forForm && this.bubbleDirection(ev);
|
||||
|
||||
const dirEmitter = this.emitters.get(direction);
|
||||
if (dirEmitter) {
|
||||
dirEmitter.next(ev);
|
||||
}
|
||||
|
||||
if (!forForm && !dirHandled) {
|
||||
return this.focus.defaultFires(ev);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects any connected gamepads and watches for new ones to start
|
||||
* polling them. This is the entry point for gamepad input handling.
|
||||
*/
|
||||
private watchForGamepad() {
|
||||
const addGamepad = (pad: Gamepad | null) => {
|
||||
let gamepad: IGamepadWrapper | null = null;
|
||||
if (pad === null) {
|
||||
return;
|
||||
}
|
||||
if (/xbox/i.test(pad.id)) {
|
||||
gamepad = new XboxGamepadWrapper(pad);
|
||||
}
|
||||
if (!gamepad) {
|
||||
// We can try, at least ¯\_(ツ)_/¯ and this should
|
||||
// usually be OK due to remapping.
|
||||
gamepad = new XboxGamepadWrapper(pad);
|
||||
}
|
||||
|
||||
this.gamepads[pad.id] = gamepad;
|
||||
};
|
||||
|
||||
Array.from(navigator.getGamepads())
|
||||
.filter(pad => !!pad)
|
||||
.forEach(addGamepad);
|
||||
|
||||
if (Object.keys(this.gamepads).length > 0) {
|
||||
this.scheduleGamepadPoll();
|
||||
}
|
||||
|
||||
this.subscriptions.push(
|
||||
merge(this.gamepadSrc, fromEvent(window, 'gamepadconnected')).subscribe(ev => {
|
||||
addGamepad((ev as any).gamepad);
|
||||
if (this.pollRaf) {
|
||||
cancelAnimationFrame(this.pollRaf);
|
||||
}
|
||||
this.scheduleGamepadPoll();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a new gamepad poll at the next animation frame.
|
||||
*/
|
||||
private scheduleGamepadPoll() {
|
||||
this.pollRaf = requestAnimationFrame(now => {
|
||||
this.pollGamepad(now);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for input provided by the gamepad and fires off events as
|
||||
* necessary. It schedules itself again provided that there's still
|
||||
* a connected gamepad somewhere.
|
||||
*/
|
||||
private pollGamepad(now: number) {
|
||||
for (const pad of navigator.getGamepads()) {
|
||||
if (pad === null) {
|
||||
continue;
|
||||
}
|
||||
const gamepad = this.gamepads[pad.id];
|
||||
if (!gamepad) {
|
||||
continue;
|
||||
}
|
||||
gamepad.pad = pad;
|
||||
|
||||
if (!gamepad.isConnected()) {
|
||||
delete this.gamepads[pad.id];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.keyboardVisible) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nonDirectionalButtons.forEach(dir => {
|
||||
const gamepadEvt = gamepad.events.get(dir);
|
||||
if (gamepadEvt && gamepadEvt(now)) {
|
||||
this.handleDirection(dir);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(this.gamepads).length > 0) {
|
||||
this.scheduleGamepadPoll();
|
||||
} else {
|
||||
this.pollRaf = null;
|
||||
}
|
||||
}
|
||||
|
||||
private bubbleDirection(ev: ArcEvent): boolean {
|
||||
const event = ev.event;
|
||||
if (
|
||||
event === Direction.Up ||
|
||||
event === Direction.Right ||
|
||||
event === Direction.Down ||
|
||||
event === Direction.Left ||
|
||||
event === Direction.Submit ||
|
||||
event === Direction.Back
|
||||
) {
|
||||
return this.focus.bubble(ev);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a key down event, returns whether the event has resulted
|
||||
* in a navigation and should be cancelled.
|
||||
*/
|
||||
private handleKeyDown(keyCode: number): boolean {
|
||||
const direction = this.codeDirectionMap.get(keyCode);
|
||||
return direction === undefined ? false : this.handleDirection(direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds listeners for keyboard events.
|
||||
*/
|
||||
private addKeyboardListeners() {
|
||||
this.subscriptions.push(
|
||||
merge(this.keyboardSrc, fromEvent<KeyboardEvent>(window, 'keydown')).subscribe(ev => {
|
||||
if (!ev.defaultPrevented && this.handleKeyDown(ev.keyCode)) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import { createContext, createElement } from 'react';
|
||||
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.
|
||||
*/
|
||||
export type Composable<P> = React.ReactElement<any> | React.ComponentType<P>;
|
||||
|
||||
/**
|
||||
* renderComposed can be used in render() functions to output a composed element.
|
||||
*/
|
||||
export const renderComposed = <P>(composed: Composable<P>, props: P) =>
|
||||
typeof composed === 'function' ? createElement(composed, props) : composed;
|
|
@ -0,0 +1,138 @@
|
|||
import { ArcEvent } from './arc-event';
|
||||
|
||||
/**
|
||||
* Direction is an enum of possible gamepad events which can fire.
|
||||
*/
|
||||
export const enum Direction {
|
||||
Submit = 0,
|
||||
Back = 1,
|
||||
X = 2,
|
||||
Y = 3,
|
||||
TabLeft = 4, // Left Bumper
|
||||
TabRight = 5, // Right Bumper
|
||||
TabUp = 6, // Left Trigger
|
||||
TabDown = 7, // Right Trigger
|
||||
View = 8, // Left small button, aka start
|
||||
Menu = 9, // Right small button
|
||||
Up = 12,
|
||||
Down = 13,
|
||||
Left = 14,
|
||||
Right = 15,
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of left/right/up/down directional buttons.
|
||||
*/
|
||||
export const directionalButtons: ReadonlySet<Direction> = new Set([
|
||||
Direction.Left,
|
||||
Direction.Right,
|
||||
Direction.Up,
|
||||
Direction.Down,
|
||||
]);
|
||||
|
||||
/**
|
||||
* The set of gamepad buttons that aren't left/right/up/down focuses.
|
||||
*/
|
||||
export const nonDirectionalButtons: ReadonlySet<Direction> = new Set([
|
||||
Direction.Submit,
|
||||
Direction.Back,
|
||||
Direction.X,
|
||||
Direction.Y,
|
||||
Direction.TabLeft,
|
||||
Direction.TabRight,
|
||||
Direction.TabUp,
|
||||
Direction.TabDown,
|
||||
Direction.View,
|
||||
Direction.Menu,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns if the direction is left or right.
|
||||
*/
|
||||
export function isHorizontal(direction: Direction) {
|
||||
return direction === Direction.Left || direction === Direction.Right;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the direction is up or down.
|
||||
*/
|
||||
export function isVertical(direction: Direction) {
|
||||
return direction === Direction.Up || direction === Direction.Down;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the button press is directional.
|
||||
*/
|
||||
export function isDirectional(direction: Direction) {
|
||||
return (
|
||||
direction === Direction.Up ||
|
||||
direction === Direction.Down ||
|
||||
direction === Direction.Left ||
|
||||
direction === Direction.Right
|
||||
);
|
||||
}
|
||||
|
||||
export interface IArcHandler {
|
||||
/**
|
||||
* The associated DOM element.
|
||||
*/
|
||||
readonly element: HTMLElement;
|
||||
|
||||
/**
|
||||
* A method which can return "false" if this handler should not be
|
||||
* included as focusable.
|
||||
*/
|
||||
readonly excludeThis?: boolean;
|
||||
|
||||
/**
|
||||
* A method which can return "false" if this handler and all its children
|
||||
* should not be included as focusable.
|
||||
*/
|
||||
readonly exclude?: boolean;
|
||||
|
||||
/**
|
||||
* Element or selector which should be focused when navigating to
|
||||
* the left of this component.
|
||||
*/
|
||||
readonly arcFocusLeft?: HTMLElement | string;
|
||||
|
||||
/**
|
||||
* Element or selector which should be focused when navigating to
|
||||
* the right of this component.
|
||||
*/
|
||||
readonly arcFocusRight?: HTMLElement | string;
|
||||
|
||||
/**
|
||||
* Element or selector which should be focused when navigating to
|
||||
* above this component.
|
||||
*/
|
||||
readonly arcFocusUp?: HTMLElement | string;
|
||||
|
||||
/**
|
||||
* Element or selector which should be focused when navigating to
|
||||
* below this component.
|
||||
*/
|
||||
readonly arcFocusDown?: HTMLElement | string;
|
||||
|
||||
/**
|
||||
* If focused, the element transfers focus to its children if true
|
||||
*/
|
||||
readonly arcFocusInside?: boolean;
|
||||
|
||||
/**
|
||||
* Called with an IArcEvent focus is about
|
||||
* to leave this element or one of its children.
|
||||
*/
|
||||
onOutgoing?(ev: ArcEvent): void;
|
||||
|
||||
/**
|
||||
* Called with an IArcEvent focus is about
|
||||
* to enter this element or one of its children.
|
||||
*/
|
||||
onIncoming?(ev: ArcEvent): void;
|
||||
|
||||
/**
|
||||
* Triggers a focus change event.
|
||||
*/
|
||||
onFocus?(el: HTMLElement | null): void;
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
import { ArcEvent } from '../arc-event';
|
||||
import { IArcHandler } from '../model';
|
||||
|
||||
const functionCalls: Array<'onOutgoing' | 'onIncoming' | 'onFocus'> = [
|
||||
'onOutgoing',
|
||||
'onIncoming',
|
||||
'onFocus',
|
||||
];
|
||||
|
||||
/**
|
||||
* ElementStateRecord is stored in the StateContainer. A single element might
|
||||
* have several records attached to it; the ArcRecord collates and merges them
|
||||
* into a single handler.
|
||||
*/
|
||||
export class ElementStateRecord {
|
||||
/**
|
||||
* The merged handler, suitable for public use.
|
||||
*/
|
||||
public resolved: Readonly<IArcHandler> | undefined;
|
||||
|
||||
/**
|
||||
* A list of all registered handlers.
|
||||
*/
|
||||
private readonly records: Array<{ key: any; handler: IArcHandler }>;
|
||||
|
||||
constructor(key: any, initialHandler: IArcHandler) {
|
||||
this.resolved = initialHandler;
|
||||
this.records = [{ key, handler: initialHandler }];
|
||||
}
|
||||
|
||||
public add(key: any, arc: IArcHandler) {
|
||||
const existing = this.records.findIndex(r => r.key === key);
|
||||
if (existing > -1) {
|
||||
this.records[existing] = { key, handler: arc };
|
||||
} else {
|
||||
this.records.push({ key, handler: arc });
|
||||
}
|
||||
this.recreateResolved();
|
||||
}
|
||||
|
||||
public remove(key: any) {
|
||||
const index = this.records.findIndex(r => r.key === key);
|
||||
if (index > -1) {
|
||||
this.records.splice(index, 1);
|
||||
}
|
||||
this.recreateResolved();
|
||||
}
|
||||
|
||||
private callForEach(method: 'onOutgoing' | 'onIncoming' | 'onFocus') {
|
||||
return (arg: HTMLElement | ArcEvent | null) => {
|
||||
for (const record of this.records) {
|
||||
const fn = record.handler[method];
|
||||
if (!fn) {
|
||||
continue;
|
||||
}
|
||||
|
||||
(fn as any)(arg);
|
||||
|
||||
if (arg instanceof ArcEvent && (arg as any).propogationStopped) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private recreateResolved() {
|
||||
if (this.records.length === 0) {
|
||||
this.resolved = undefined;
|
||||
return;
|
||||
}
|
||||
if (this.records.length === 1) {
|
||||
this.resolved = this.records[0].handler;
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = { ...this.records[0].handler };
|
||||
for (let i = 1; i < this.records.length; i++) {
|
||||
Object.assign(resolved, this.records[i].handler);
|
||||
}
|
||||
|
||||
for (const call of functionCalls) {
|
||||
if (resolved[call]) {
|
||||
resolved[call] = this.callForEach(call);
|
||||
}
|
||||
}
|
||||
|
||||
this.resolved = resolved;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
import { ArcEvent } from '../arc-event';
|
||||
import { Direction } from '../model';
|
||||
import { StateContainer } from './state-container';
|
||||
|
||||
describe('StateContainer', () => {
|
||||
it('adds, removes, and finds elements', () => {
|
||||
const store = new StateContainer();
|
||||
const element = document.createElement('div');
|
||||
expect(store.find(element)).toBeUndefined();
|
||||
|
||||
store.add(0, { element, arcFocusInside: true });
|
||||
const result = store.find(element);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.arcFocusInside).toBe(true);
|
||||
|
||||
store.remove(0, element);
|
||||
expect(store.find(element)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refcounts deep checks', () => {
|
||||
const store = new StateContainer();
|
||||
const element = document.createElement('div');
|
||||
expect(store.hasExcludedDeepElements()).toBe(false);
|
||||
|
||||
store.add(0, { element, exclude: true });
|
||||
expect(store.hasExcludedDeepElements()).toBe(true);
|
||||
|
||||
store.remove(0, element);
|
||||
expect(store.hasExcludedDeepElements()).toBe(false);
|
||||
});
|
||||
|
||||
it('merges in state', () => {
|
||||
const store = new StateContainer();
|
||||
const element = document.createElement('div');
|
||||
expect(store.find(element)).toBeUndefined();
|
||||
|
||||
store.add(1, { element, arcFocusUp: '.up' });
|
||||
expect(store.find(element)).toEqual({ element, arcFocusUp: '.up' });
|
||||
|
||||
store.add(2, { element, arcFocusDown: '.down' });
|
||||
expect(store.find(element)).toEqual({ element, arcFocusUp: '.up', arcFocusDown: '.down' });
|
||||
|
||||
store.remove(1, element);
|
||||
expect(store.find(element)).toEqual({ element, arcFocusDown: '.down' });
|
||||
|
||||
store.remove(2, element);
|
||||
expect(store.find(element)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refcounts deep checks when merging in state', () => {
|
||||
const store = new StateContainer();
|
||||
const element = document.createElement('div');
|
||||
|
||||
store.add(1, { element, exclude: false });
|
||||
expect(store.hasExcludedDeepElements()).toBe(false);
|
||||
|
||||
store.add(2, { element, exclude: true });
|
||||
expect(store.hasExcludedDeepElements()).toBe(true);
|
||||
|
||||
store.add(3, { element, exclude: true });
|
||||
expect(store.hasExcludedDeepElements()).toBe(true);
|
||||
|
||||
store.remove(2, element);
|
||||
expect(store.hasExcludedDeepElements()).toBe(true);
|
||||
store.remove(3, element);
|
||||
expect(store.hasExcludedDeepElements()).toBe(false);
|
||||
store.remove(1, element);
|
||||
expect(store.hasExcludedDeepElements()).toBe(false);
|
||||
});
|
||||
|
||||
it('combines function calls', () => {
|
||||
const calls: number[] = [];
|
||||
const store = new StateContainer();
|
||||
const element = document.createElement('div');
|
||||
|
||||
store.add(0, { element, onOutgoing: () => calls.push(0) });
|
||||
store.add(1, { element, onOutgoing: () => calls.push(1) });
|
||||
store.add(2, { element, onOutgoing: ev => ev.stopPropagation() });
|
||||
store.add(3, { element, onOutgoing: () => calls.push(3) });
|
||||
store.find(element)!.onOutgoing!(
|
||||
new ArcEvent({ next: null, event: Direction.Submit, target: null }),
|
||||
);
|
||||
|
||||
expect(calls).toEqual([0, 1]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,82 @@
|
|||
import { IArcHandler } from '../model';
|
||||
import { ElementStateRecord } from './element-state-record';
|
||||
|
||||
/**
|
||||
* One StateContainer is held per arcade-machine instance, and provided
|
||||
* in the context to lower components. It allows components to register
|
||||
* and unregister themselves to hook into events and customize how focus
|
||||
* is dealt with.
|
||||
*/
|
||||
export class StateContainer {
|
||||
/**
|
||||
* Mapping of HTML elements to options set on those elements.
|
||||
*/
|
||||
private readonly arcs = new Map<HTMLElement, ElementStateRecord>();
|
||||
|
||||
/**
|
||||
* Ref counter for the number of components that use the exclude()
|
||||
* option. This results in more expensive lookup operations, so we avoid
|
||||
* doing it so if there's no one requesting exclusion.
|
||||
*/
|
||||
private excludedDeepCount = 0;
|
||||
|
||||
/**
|
||||
* Stores a directive into the registry.
|
||||
*/
|
||||
public add(key: any, arc: IArcHandler) {
|
||||
const record = this.arcs.get(arc.element);
|
||||
if (!record) {
|
||||
this.arcs.set(arc.element, new ElementStateRecord(key, arc));
|
||||
if (arc.exclude) {
|
||||
this.excludedDeepCount++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const hadExclusion = record.resolved!.exclude;
|
||||
record.add(key, arc);
|
||||
if (record.resolved!.exclude && !hadExclusion) {
|
||||
this.excludedDeepCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a directive from the registry.
|
||||
*/
|
||||
public remove(key: any, el: HTMLElement) {
|
||||
const record = this.arcs.get(el);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hadExclusion = record.resolved!.exclude;
|
||||
record.remove(key);
|
||||
const hasExclusion = record.resolved && record.resolved.exclude;
|
||||
if (!record.resolved) {
|
||||
this.arcs.delete(el);
|
||||
}
|
||||
|
||||
if (hadExclusion && !hasExclusion) {
|
||||
this.excludedDeepCount--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ArcDirective associated with the element. Returns
|
||||
* undefined if the element has no associated arc.
|
||||
*/
|
||||
public find(el: HTMLElement): Readonly<IArcHandler> | undefined {
|
||||
const record = this.arcs.get(el);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
return record.resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether there are any elements with deep exclusions in the registry.
|
||||
*/
|
||||
public hasExcludedDeepElements(): boolean {
|
||||
return this.excludedDeepCount > 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,347 @@
|
|||
const { configure } = require('enzyme');
|
||||
|
||||
const React = require('react');
|
||||
const ReactDOM = require('react-dom');
|
||||
const ReactDOMServer = require('react-dom/server');
|
||||
const ShallowRenderer = require('react-test-renderer/shallow');
|
||||
const TestUtils = require('react-dom/test-utils');
|
||||
const { isElement } = require('react-is');
|
||||
const { EnzymeAdapter } = require('enzyme');
|
||||
const {
|
||||
elementToTree,
|
||||
nodeTypeFromType,
|
||||
mapNativeEventNames,
|
||||
propFromEvent,
|
||||
assertDomAvailable,
|
||||
withSetStateAllowed,
|
||||
createRenderWrapper,
|
||||
createMountWrapper,
|
||||
propsWithKeysAndRef,
|
||||
ensureKeyOrUndefined,
|
||||
} = require('enzyme-adapter-utils');
|
||||
const { findCurrentFiberUsingSlowPath } = require('react-reconciler/reflection');
|
||||
|
||||
const HostRoot = 3;
|
||||
const ClassComponent = 2;
|
||||
const Fragment = 10;
|
||||
const FunctionalComponent = 1;
|
||||
const HostPortal = 4;
|
||||
const HostComponent = 5;
|
||||
const HostText = 6;
|
||||
const Mode = 11;
|
||||
const ContextConsumer = 12;
|
||||
const ContextProvider = 13;
|
||||
|
||||
function nodeAndSiblingsArray(nodeWithSibling) {
|
||||
const array = [];
|
||||
let node = nodeWithSibling;
|
||||
while (node != null) {
|
||||
array.push(node);
|
||||
node = node.sibling;
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
function flatten(arr) {
|
||||
const result = [];
|
||||
const stack = [{ i: 0, array: arr }];
|
||||
while (stack.length) {
|
||||
const n = stack.pop();
|
||||
while (n.i < n.array.length) {
|
||||
const el = n.array[n.i];
|
||||
n.i += 1;
|
||||
if (Array.isArray(el)) {
|
||||
stack.push(n);
|
||||
stack.push({ i: 0, array: el });
|
||||
break;
|
||||
}
|
||||
result.push(el);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toTree(vnode) {
|
||||
if (vnode == null) {
|
||||
return null;
|
||||
}
|
||||
// TODO(lmr): I'm not really sure I understand whether or not this is what
|
||||
// i should be doing, or if this is a hack for something i'm doing wrong
|
||||
// somewhere else. Should talk to sebastian about this perhaps
|
||||
const node = findCurrentFiberUsingSlowPath(vnode);
|
||||
switch (node.tag) {
|
||||
case HostRoot: // 3
|
||||
return toTree(node.child);
|
||||
case HostPortal: // 4
|
||||
return toTree(node.child);
|
||||
case ClassComponent:
|
||||
return {
|
||||
nodeType: 'class',
|
||||
type: node.type,
|
||||
props: { ...node.memoizedProps },
|
||||
key: ensureKeyOrUndefined(node.key),
|
||||
ref: node.ref,
|
||||
instance: node.stateNode,
|
||||
rendered: childrenToTree(node.child),
|
||||
};
|
||||
case FunctionalComponent: // 1
|
||||
return {
|
||||
nodeType: 'function',
|
||||
type: node.type,
|
||||
props: { ...node.memoizedProps },
|
||||
key: ensureKeyOrUndefined(node.key),
|
||||
ref: node.ref,
|
||||
instance: null,
|
||||
rendered: childrenToTree(node.child),
|
||||
};
|
||||
case HostComponent: { // 5
|
||||
let renderedNodes = flatten(nodeAndSiblingsArray(node.child).map(toTree));
|
||||
if (renderedNodes.length === 0) {
|
||||
renderedNodes = [node.memoizedProps.children];
|
||||
}
|
||||
return {
|
||||
nodeType: 'host',
|
||||
type: node.type,
|
||||
props: { ...node.memoizedProps },
|
||||
key: ensureKeyOrUndefined(node.key),
|
||||
ref: node.ref,
|
||||
instance: node.stateNode,
|
||||
rendered: renderedNodes,
|
||||
};
|
||||
}
|
||||
case HostText: // 6
|
||||
return node.memoizedProps;
|
||||
case Fragment: // 10
|
||||
case Mode: // 11
|
||||
case ContextProvider: // 13
|
||||
case ContextConsumer: // 12
|
||||
return childrenToTree(node.child);
|
||||
default:
|
||||
throw new Error(`Enzyme Internal Error: unknown node with tag ${node.tag}`);
|
||||
}
|
||||
}
|
||||
|
||||
function childrenToTree(node) {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
const children = nodeAndSiblingsArray(node);
|
||||
if (children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (children.length === 1) {
|
||||
return toTree(children[0]);
|
||||
}
|
||||
return flatten(children.map(toTree));
|
||||
}
|
||||
|
||||
function nodeToHostNode(_node) {
|
||||
// NOTE(lmr): node could be a function component
|
||||
// which wont have an instance prop, but we can get the
|
||||
// host node associated with its return value at that point.
|
||||
// Although this breaks down if the return value is an array,
|
||||
// as is possible with React 16.
|
||||
let node = _node;
|
||||
while (node && !Array.isArray(node) && node.instance === null) {
|
||||
node = node.rendered;
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
// TODO(lmr): throw warning regarding not being able to get a host node here
|
||||
throw new Error('Trying to get host node of an array');
|
||||
}
|
||||
// if the SFC returned null effectively, there is no host node.
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
return ReactDOM.findDOMNode(node.instance);
|
||||
}
|
||||
|
||||
class ReactSixteenAdapter extends EnzymeAdapter {
|
||||
constructor() {
|
||||
super();
|
||||
const { lifecycles } = this.options;
|
||||
this.options = {
|
||||
...this.options,
|
||||
enableComponentDidUpdateOnSetState: true, // TODO: remove, semver-major
|
||||
lifecycles: {
|
||||
...lifecycles,
|
||||
componentDidUpdate: {
|
||||
onSetState: true,
|
||||
},
|
||||
getSnapshotBeforeUpdate: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createMountRenderer(options) {
|
||||
assertDomAvailable('mount');
|
||||
const { attachTo, hydrateIn } = options;
|
||||
const domNode = hydrateIn || attachTo || global.document.createElement('div');
|
||||
let instance = null;
|
||||
return {
|
||||
render(el, context, callback) {
|
||||
if (instance === null) {
|
||||
const { type, props, ref } = el;
|
||||
const wrapperProps = {
|
||||
Component: type,
|
||||
props,
|
||||
context,
|
||||
...(ref && { ref }),
|
||||
};
|
||||
const ReactWrapperComponent = createMountWrapper(el, options);
|
||||
const wrappedEl = React.createElement(ReactWrapperComponent, wrapperProps);
|
||||
instance = hydrateIn
|
||||
? ReactDOM.hydrate(wrappedEl, domNode)
|
||||
: ReactDOM.render(wrappedEl, domNode);
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
instance.setChildProps(el.props, context, callback);
|
||||
}
|
||||
},
|
||||
unmount() {
|
||||
ReactDOM.unmountComponentAtNode(domNode);
|
||||
instance = null;
|
||||
},
|
||||
getNode() {
|
||||
return instance ? toTree(instance._reactInternalFiber).rendered : null;
|
||||
},
|
||||
simulateEvent(node, event, mock) {
|
||||
const mappedEvent = mapNativeEventNames(event, { animation: true });
|
||||
const eventFn = TestUtils.Simulate[mappedEvent];
|
||||
if (!eventFn) {
|
||||
throw new TypeError(`ReactWrapper::simulate() event '${event}' does not exist`);
|
||||
}
|
||||
// eslint-disable-next-line react/no-find-dom-node
|
||||
eventFn(nodeToHostNode(node), mock);
|
||||
},
|
||||
batchedUpdates(fn) {
|
||||
return fn();
|
||||
// return ReactDOM.unstable_batchedUpdates(fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createShallowRenderer(/* options */) {
|
||||
const renderer = new ShallowRenderer();
|
||||
let isDOM = false;
|
||||
let cachedNode = null;
|
||||
return {
|
||||
render(el, context) {
|
||||
cachedNode = el;
|
||||
/* eslint consistent-return: 0 */
|
||||
if (typeof el.type === 'string') {
|
||||
isDOM = true;
|
||||
} else {
|
||||
isDOM = false;
|
||||
const { type: Component } = el;
|
||||
const isStateful = Component.prototype && (
|
||||
Component.prototype.isReactComponent
|
||||
|| Array.isArray(Component.__reactAutoBindPairs) // fallback for createClass components
|
||||
);
|
||||
if (!isStateful) {
|
||||
const wrappedEl = Object.assign(
|
||||
(...args) => Component(...args), // eslint-disable-line new-cap
|
||||
Component,
|
||||
);
|
||||
return withSetStateAllowed(() => renderer.render({ ...el, type: wrappedEl }, context));
|
||||
}
|
||||
return withSetStateAllowed(() => renderer.render(el, context));
|
||||
}
|
||||
},
|
||||
unmount() {
|
||||
renderer.unmount();
|
||||
},
|
||||
getNode() {
|
||||
if (isDOM) {
|
||||
return elementToTree(cachedNode);
|
||||
}
|
||||
const output = renderer.getRenderOutput();
|
||||
return {
|
||||
nodeType: nodeTypeFromType(cachedNode.type),
|
||||
type: cachedNode.type,
|
||||
props: cachedNode.props,
|
||||
key: ensureKeyOrUndefined(cachedNode.key),
|
||||
ref: cachedNode.ref,
|
||||
instance: renderer._instance,
|
||||
rendered: Array.isArray(output)
|
||||
? flatten(output).map(elementToTree)
|
||||
: elementToTree(output),
|
||||
};
|
||||
},
|
||||
simulateEvent(node, event, ...args) {
|
||||
const handler = node.props[propFromEvent(event)];
|
||||
if (handler) {
|
||||
withSetStateAllowed(() => {
|
||||
// TODO(lmr): create/use synthetic events
|
||||
// TODO(lmr): emulate React's event propagation
|
||||
// ReactDOM.unstable_batchedUpdates(() => {
|
||||
handler(...args);
|
||||
// });
|
||||
});
|
||||
}
|
||||
},
|
||||
batchedUpdates(fn) {
|
||||
return fn();
|
||||
// return ReactDOM.unstable_batchedUpdates(fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createStringRenderer(options) {
|
||||
return {
|
||||
render(el, context) {
|
||||
if (options.context && (el.type.contextTypes || options.childContextTypes)) {
|
||||
const childContextTypes = {
|
||||
...(el.type.contextTypes || {}),
|
||||
...options.childContextTypes,
|
||||
};
|
||||
const ContextWrapper = createRenderWrapper(el, context, childContextTypes);
|
||||
return ReactDOMServer.renderToStaticMarkup(React.createElement(ContextWrapper));
|
||||
}
|
||||
return ReactDOMServer.renderToStaticMarkup(el);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Provided a bag of options, return an `EnzymeRenderer`. Some options can be implementation
|
||||
// specific, like `attach` etc. for React, but not part of this interface explicitly.
|
||||
// eslint-disable-next-line class-methods-use-this, no-unused-vars
|
||||
createRenderer(options) {
|
||||
switch (options.mode) {
|
||||
case EnzymeAdapter.MODES.MOUNT: return this.createMountRenderer(options);
|
||||
case EnzymeAdapter.MODES.SHALLOW: return this.createShallowRenderer(options);
|
||||
case EnzymeAdapter.MODES.STRING: return this.createStringRenderer(options);
|
||||
default:
|
||||
throw new Error(`Enzyme Internal Error: Unrecognized mode: ${options.mode}`);
|
||||
}
|
||||
}
|
||||
|
||||
// converts an RSTNode to the corresponding JSX Pragma Element. This will be needed
|
||||
// in order to implement the `Wrapper.mount()` and `Wrapper.shallow()` methods, but should
|
||||
// be pretty straightforward for people to implement.
|
||||
// eslint-disable-next-line class-methods-use-this, no-unused-vars
|
||||
nodeToElement(node) {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
return React.createElement(node.type, propsWithKeysAndRef(node));
|
||||
}
|
||||
|
||||
elementToNode(element) {
|
||||
return elementToTree(element);
|
||||
}
|
||||
|
||||
nodeToHostNode(node) {
|
||||
return nodeToHostNode(node);
|
||||
}
|
||||
|
||||
isValidElement(element) {
|
||||
return isElement(element);
|
||||
}
|
||||
|
||||
createElement(...args) {
|
||||
return React.createElement(...args);
|
||||
}
|
||||
}
|
||||
|
||||
configure({ adapter: new ReactSixteenAdapter() });
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"strict": true,
|
||||
"jsx": "react",
|
||||
"module": "esnext",
|
||||
"target": "es6",
|
||||
"outDir": "dist",
|
||||
"lib": ["es6", "dom"],
|
||||
"types": ["jest", "winrt-uwp"],
|
||||
"moduleResolution": "node",
|
||||
"typeRoots": [
|
||||
"node_modules/@types"
|
||||
]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"]
|
||||
}
|
Загрузка…
Ссылка в новой задаче