feat: initial import of arcade-machine logic

This commit is contained in:
Connor Peet 2018-07-11 14:24:11 -07:00
Родитель 07d9511ea4
Коммит b63c4adf58
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: CF8FD2EA0DBC61BD
32 изменённых файлов: 12449 добавлений и 0 удалений

9
.editorconfig Normal file
Просмотреть файл

@ -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

50
LICENSE Normal file
Просмотреть файл

@ -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.

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

@ -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>

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

@ -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'));

66
demo/style.css Normal file
Просмотреть файл

@ -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;
}

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

@ -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,
},
};

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

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

68
package.json Normal file
Просмотреть файл

@ -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"
}
}

70
src/arc-event.ts Normal file
Просмотреть файл

@ -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;
}
}

31
src/core/gamepad.ts Normal file
Просмотреть файл

@ -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;
}

67
src/core/xbox-gamepad.ts Normal file
Просмотреть файл

@ -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;
}
}

714
src/focus-service.ts Normal file
Просмотреть файл

@ -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;
}

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

@ -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';

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

@ -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();
}
}),
);
}
}

41
src/internal-types.ts Normal file
Просмотреть файл

@ -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;

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

@ -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;
}
}

347
test-setup.js Normal file
Просмотреть файл

@ -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() });

20
tsconfig.json Normal file
Просмотреть файл

@ -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"]
}

3
tslint.json Normal file
Просмотреть файл

@ -0,0 +1,3 @@
{
"extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"]
}