; className?: string }> = ({
59 | node,
60 | className,
61 | children,
62 | }) => (
63 |
64 | {children || node.title}
65 |
66 | );
67 |
--------------------------------------------------------------------------------
/src/components/arc-scrollable.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDOM from 'react-dom';
3 |
4 | import { instance } from '../singleton';
5 |
6 | /**
7 | * Scrollable marks the associated DOM node as being a scroll container for
8 | * arcade-machine. This will cause arcade-machine to ensure that, within these
9 | * scroll containers, any focused element is visible.
10 | */
11 | export class Scrollable extends React.PureComponent<{
12 | children: React.ReactNode;
13 | horizontal?: boolean;
14 | vertical?: boolean;
15 | }> {
16 | /**
17 | * The node this element is attached to.
18 | */
19 | private node!: HTMLElement;
20 |
21 | public componentDidMount() {
22 | const element = ReactDOM.findDOMNode(this);
23 | if (!(element instanceof HTMLElement)) {
24 | throw new Error(
25 | `Attempted to mount an not attached to an element, got ${element}`,
26 | );
27 | }
28 |
29 | this.node = element;
30 | instance.getServices().scrollRegistry.add({
31 | element,
32 | horizontal: this.props.horizontal === true,
33 | vertical: this.props.vertical !== false,
34 | });
35 | }
36 |
37 | public componentWillUnmount() {
38 | const services = instance.maybeGetServices();
39 | if (services) {
40 | services.scrollRegistry.remove(this.node);
41 | }
42 | }
43 |
44 | public render() {
45 | return this.props.children;
46 | }
47 | }
48 |
49 | /**
50 | * HOC to create a Scrollable.
51 | */
52 | export const ArcScrollable = (
53 | Composed: React.ComponentType
,
54 | vertical: boolean = true,
55 | horizontal: boolean = false,
56 | ) => (props: P) => (
57 |
58 |
59 |
60 | );
61 |
--------------------------------------------------------------------------------
/src/focus/dom-utils.ts:
--------------------------------------------------------------------------------
1 | import { instance } from '../singleton';
2 |
3 | export function roundRect(rect: HTMLElement | ClientRect): ClientRect {
4 | if (rect instanceof HTMLElement) {
5 | rect = rect.getBoundingClientRect();
6 | }
7 |
8 | // There's rounding here because floating points make certain math not work.
9 | return {
10 | bottom: Math.floor(rect.top + rect.height),
11 | height: Math.floor(rect.height),
12 | left: Math.floor(rect.left),
13 | right: Math.floor(rect.left + rect.width),
14 | top: Math.floor(rect.top),
15 | width: Math.floor(rect.width),
16 | };
17 | }
18 |
19 | /**
20 | * Returns whether the target DOM node is a child of the root.
21 | */
22 | export function isNodeAttached(node: HTMLElement | null, root: HTMLElement | null): boolean {
23 | if (!node || !root) {
24 | return false;
25 | }
26 | return root.contains(node);
27 | }
28 |
29 | /**
30 | * Returns whether the provided element is visible.
31 | */
32 | export function isVisible(element: HTMLElement | null): boolean {
33 | return !!element && (element.offsetHeight !== 0 || element.offsetParent !== null);
34 | }
35 |
36 | /**
37 | * Returns if the element can receive focus.
38 | */
39 | export function isFocusable(
40 | el: HTMLElement,
41 | activeElement = instance.getServices().elementStore.element,
42 | ): boolean {
43 | if (el === activeElement) {
44 | return false;
45 | }
46 | // to prevent navigating to parent container elements with arc-focus-inside
47 | if (activeElement !== document.body && activeElement.contains(el)) {
48 | return false;
49 | }
50 |
51 | // Dev note: el.tabindex is not consistent across browsers
52 | const tabIndex = el.getAttribute('tabIndex');
53 | if (!tabIndex || +tabIndex < 0) {
54 | return false;
55 | }
56 |
57 | return isVisible(el);
58 | }
59 |
--------------------------------------------------------------------------------
/docs/webpack.config.js:
--------------------------------------------------------------------------------
1 | const HtmlWebpackPlugin = require('html-webpack-plugin');
2 | const { resolve } = require('path');
3 |
4 | module.exports = {
5 | entry: './docs/index.tsx',
6 | devtool: 'source-map',
7 | mode: 'development',
8 | output: {
9 | filename: 'bundle.js',
10 | path: resolve(__dirname, '..', 'dist', 'docs'),
11 | },
12 | resolve: {
13 | extensions: ['.ts', '.tsx', '.js', '.woff', '.woff2'],
14 | },
15 | module: {
16 | rules: [
17 | {
18 | test: /\.tsx?$/,
19 | exclude: /node_modules/,
20 | loader: 'ts-loader',
21 | },
22 | {
23 | sideEffects: true,
24 | include: resolve(__dirname, 'index.scss'),
25 | use: ['style-loader', 'css-loader', 'sass-loader'],
26 | },
27 | {
28 | sideEffects: true,
29 | test: /\.css$/,
30 | use: ['style-loader', 'css-loader'],
31 | },
32 | {
33 | test: /\.component.scss$/,
34 | use: [
35 | {
36 | loader: 'style-loader',
37 | },
38 | {
39 | loader: 'css-loader',
40 | options: {
41 | modules: true,
42 | localIdentName: '[local]__[hash:base64:5]',
43 | },
44 | },
45 | {
46 | loader: 'sass-loader',
47 | },
48 | ],
49 | },
50 | {
51 | test: /\.(png|jpg|gif|woff|woff2)$/i,
52 | use: [
53 | {
54 | loader: 'url-loader',
55 | options: {
56 | limit: 8192,
57 | },
58 | },
59 | ],
60 | },
61 | ],
62 | },
63 | devServer: {
64 | disableHostCheck: true,
65 | },
66 | plugins: [
67 | new HtmlWebpackPlugin({
68 | title: 'Arcade Machine Documentation',
69 | }),
70 | ],
71 | };
72 |
--------------------------------------------------------------------------------
/src/state/state-container.ts:
--------------------------------------------------------------------------------
1 | import { IArcHandler } from '../model';
2 | import { ElementStateRecord } from './element-state-record';
3 |
4 | /**
5 | * One StateContainer is held per arcade-machine instance, and provided
6 | * in the context to lower components. It allows components to register
7 | * and unregister themselves to hook into events and customize how focus
8 | * is dealt with.
9 | */
10 | export class StateContainer {
11 | /**
12 | * Mapping of HTML elements to options set on those elements.
13 | */
14 | private readonly arcs = new Map();
15 |
16 | /**
17 | * Stores a directive into the registry.
18 | */
19 | public add(key: any, arc: IArcHandler) {
20 | const record = this.arcs.get(arc.element);
21 | if (record) {
22 | record.add(key, arc);
23 | } else {
24 | this.arcs.set(arc.element, new ElementStateRecord(key, arc));
25 | }
26 | }
27 |
28 | /**
29 | * Removes a directive from the registry.
30 | */
31 | public remove(key: any, el: HTMLElement) {
32 | const record = this.arcs.get(el);
33 | if (!record) {
34 | return;
35 | }
36 |
37 | record.remove(key);
38 |
39 | if (!record.resolved) {
40 | this.arcs.delete(el);
41 | }
42 | }
43 |
44 | /**
45 | * Returns the ArcDirective associated with the element. Returns
46 | * undefined if the element has no associated arc.
47 | */
48 | public find(el: HTMLElement): Readonly | undefined {
49 | const record = this.arcs.get(el);
50 | if (!record) {
51 | return undefined;
52 | }
53 | return record.resolved;
54 | }
55 |
56 | /**
57 | * Returns whether the given element exists in the arcade-machine state.
58 | */
59 | public has(el: HTMLElement): boolean {
60 | return this.arcs.has(el);
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/components/arc-autofocus.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { findFocusable } from '../internal-types';
3 | import { instance } from '../singleton';
4 |
5 | /**
6 | * Component that autofocuses whatever is contained inside it. By default,
7 | * it will focus its first direct child, but can also take a selector
8 | * or try to focus itself as a fallback. It will only run focusing
9 | * when it's first mounted.
10 | *
11 | * Note: for elements that have it, you should use the React built-in
12 | * autoFocus instead -- this is not present for elements which aren't
13 | * usually focusable, however.
14 | *
15 | * @example
16 | *
17 | * {contents}
18 | * {contents}
19 | */
20 | export class AutoFocus extends React.PureComponent<
21 | { target?: string | HTMLElement; selector?: string | HTMLElement } & React.HTMLAttributes<
22 | HTMLDivElement
23 | >
24 | > {
25 | private readonly container = React.createRef();
26 |
27 | public componentDidMount() {
28 | const node = this.container.current;
29 | if (!(node instanceof HTMLElement)) {
30 | return;
31 | }
32 |
33 | const focusTarget = findFocusable(node, this.props.target);
34 | if (focusTarget) {
35 | instance.getServices().elementStore.element = focusTarget;
36 | }
37 | }
38 |
39 | public render() {
40 | return {this.props.children}
;
41 | }
42 | }
43 |
44 | /**
45 | * HOC for the AutoFocus component.
46 | */
47 | export const ArcAutoFocus = (
48 | Composed: React.ComponentType
,
49 | target?: string | HTMLElement,
50 | ) => (props: P) => (
51 |
52 |
53 |
54 | );
55 |
--------------------------------------------------------------------------------
/src/input/directional-debouncer.ts:
--------------------------------------------------------------------------------
1 | import { IDebouncer } from './gamepad';
2 |
3 | const enum DebouncerStage {
4 | IDLE,
5 | HELD,
6 | FAST,
7 | }
8 |
9 | /**
10 | * DirectionalDebouncer debounces directional navigation like arrow keys,
11 | * handling "holding" states.
12 | */
13 | export class DirectionalDebouncer implements IDebouncer {
14 | /**
15 | * Initial debounce after a joystick is pressed before beginning shorter
16 | * press debouncded.
17 | */
18 | public static initialDebounce = 500;
19 |
20 | /**
21 | * Fast debounce time for joysticks when they're being held in a direction.
22 | */
23 | public static fastDebounce = 150;
24 |
25 | /**
26 | * The time that the debounce was initially started.
27 | */
28 | private heldAt = 0;
29 |
30 | /**
31 | * Current state of the debouncer.
32 | */
33 | private stage = DebouncerStage.IDLE;
34 |
35 | constructor(private predicate: () => boolean) {}
36 |
37 | /**
38 | * Returns whether the key should be registered as pressed.
39 | */
40 | public attempt(now: number): boolean {
41 | const result = this.predicate();
42 | if (!result) {
43 | this.stage = DebouncerStage.IDLE;
44 | return false;
45 | }
46 |
47 | switch (this.stage) {
48 | case DebouncerStage.IDLE:
49 | this.stage = DebouncerStage.HELD;
50 | this.heldAt = now;
51 | return true;
52 |
53 | case DebouncerStage.HELD:
54 | if (now - this.heldAt < DirectionalDebouncer.initialDebounce) {
55 | return false;
56 | }
57 | this.heldAt = now;
58 | this.stage = DebouncerStage.FAST;
59 | return true;
60 |
61 | case DebouncerStage.FAST:
62 | if (now - this.heldAt < DirectionalDebouncer.fastDebounce) {
63 | return false;
64 | }
65 | this.heldAt = now;
66 | return true;
67 |
68 | default:
69 | throw new Error(`Unknown debouncer stage ${this.stage}!`);
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/state/state-container.test.ts:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 |
3 | import { ArcEvent } from '../arc-event';
4 | import { Button } from '../model';
5 | import { StateContainer } from './state-container';
6 |
7 | describe('StateContainer', () => {
8 | it('adds, removes, and finds elements', () => {
9 | const store = new StateContainer();
10 | const element = document.createElement('div');
11 | expect(store.find(element)).to.be.undefined;
12 |
13 | store.add(0, { element, arcFocusDown: '.foo' });
14 | const result = store.find(element);
15 | expect(result).not.to.be.undefined;
16 | expect(result!.arcFocusDown).to.equal('.foo');
17 |
18 | store.remove(0, element);
19 | expect(store.find(element)).to.be.undefined;
20 | });
21 |
22 | it('merges in state', () => {
23 | const store = new StateContainer();
24 | const element = document.createElement('div');
25 | expect(store.find(element)).to.be.undefined;
26 |
27 | store.add(1, { element, arcFocusUp: '.up' });
28 | expect(store.find(element)).to.deep.equal({ element, arcFocusUp: '.up' });
29 |
30 | store.add(2, { element, arcFocusDown: '.down' });
31 | expect(store.find(element)).to.deep.equal({
32 | arcFocusDown: '.down',
33 | arcFocusUp: '.up',
34 | element,
35 | });
36 |
37 | store.remove(1, element);
38 | expect(store.find(element)).to.deep.equal({ element, arcFocusDown: '.down' });
39 |
40 | store.remove(2, element);
41 | expect(store.find(element)).to.be.undefined;
42 | });
43 |
44 | it('combines function calls', () => {
45 | const calls: number[] = [];
46 | const store = new StateContainer();
47 | const element = document.createElement('div');
48 |
49 | store.add(0, { element, onButton: () => calls.push(0) });
50 | store.add(1, { element, onButton: () => calls.push(1) });
51 | store.add(2, { element, onButton: ev => ev.stopPropagation() });
52 | store.add(3, { element, onButton: () => calls.push(3) });
53 | store.find(element)!.onButton!(new ArcEvent({ event: Button.Submit, target: document.body }));
54 |
55 | expect(calls).to.deep.equal([0, 1]);
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/src/components/arc-exclude.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDOM from 'react-dom';
3 |
4 | import { instance } from '../singleton';
5 |
6 | /**
7 | * FocusExclude will exclude the attached element, and optionally its
8 | * subtree, from taking any arcade-machine focus.
9 | */
10 | export class FocusExclude extends React.PureComponent<{
11 | children: React.ReactNode;
12 | active?: boolean;
13 | deep?: boolean;
14 | }> {
15 | /**
16 | * The node this element is attached to.
17 | */
18 | private node!: HTMLElement;
19 |
20 | public componentDidMount() {
21 | const element = ReactDOM.findDOMNode(this);
22 | if (!(element instanceof HTMLElement)) {
23 | throw new Error(
24 | `Attempted to mount an not attached to an element, got ${element}`,
25 | );
26 | }
27 |
28 | instance.getServices().stateContainer.add(this, {
29 | element,
30 | onIncoming: ev => {
31 | if (!ev.next || this.props.active === false) {
32 | return;
33 | }
34 |
35 | const exclusions = new Set();
36 | while (this.isElementExcluded(ev.next)) {
37 | exclusions.add(ev.next!);
38 | ev.next = ev.focusContext.find(undefined, exclusions);
39 | }
40 | },
41 | });
42 |
43 | this.node = element;
44 | }
45 |
46 | public componentWillUnmount() {
47 | const services = instance.maybeGetServices();
48 | if (services) {
49 | services.stateContainer.remove(this, this.node);
50 | }
51 | }
52 |
53 | public render() {
54 | return this.props.children;
55 | }
56 |
57 | private isElementExcluded(element: HTMLElement | null): boolean {
58 | if (!element) {
59 | return false;
60 | }
61 |
62 | if (this.props.deep !== false) {
63 | return this.node.contains(element);
64 | }
65 |
66 | return this.node === element;
67 | }
68 | }
69 |
70 | /**
71 | * HOC to create a FocusExclude.
72 | */
73 | export const ArcFocusExclude = (
74 | Composed: React.ComponentType
,
75 | deep?: boolean,
76 | ) => (props: P) => (
77 |
78 |
79 |
80 | );
81 |
--------------------------------------------------------------------------------
/src/singleton.ts:
--------------------------------------------------------------------------------
1 | import { IElementStore } from './focus';
2 | import { RootStore } from './root-store';
3 | import { ScrollRegistry } from './scroll/scroll-registry';
4 | import { StateContainer } from './state/state-container';
5 |
6 | /**
7 | * IArcServices is held in the ArcSingleton.
8 | */
9 | export interface IArcServices {
10 | root: RootStore;
11 | elementStore: IElementStore;
12 | stateContainer: StateContainer;
13 | scrollRegistry: ScrollRegistry;
14 | }
15 |
16 | /**
17 | * The ArcSingleton stores the currently active arcade-machine root and
18 | * services on the page.
19 | *
20 | * Singletons are bad and all that, but generally it never (currently) makes
21 | * sense to have multiple arcade-machine roots, as they all would clobber over
22 | * each others' input. Using a singleton rather than, say, react contexts,
23 | * gives us simpler (and less) code, with better performance.
24 | */
25 | export class ArcSingleton {
26 | private services: IArcServices | undefined;
27 |
28 | /**
29 | * setServices is called by the ArcRoot when it gets set up.
30 | */
31 | public setServices(services: Partial | undefined) {
32 | if (this.services && services) {
33 | throw new Error(
34 | 'Attempted to register a second without destroying the first one. ' +
35 | 'Only one arcade-machine root component may be used at once.',
36 | );
37 | }
38 |
39 | if (!services) {
40 | this.services = undefined;
41 | return;
42 | }
43 |
44 | this.services = {
45 | elementStore: services.elementStore!,
46 | root: services.root!,
47 | scrollRegistry: new ScrollRegistry(),
48 | stateContainer: new StateContainer(),
49 | ...services,
50 | };
51 | }
52 |
53 | /**
54 | * Returns the services. Throws if none are registered.
55 | */
56 | public getServices(): IArcServices {
57 | if (!this.services) {
58 | throw new Error('You cannot use arcade-machine functionality without an .');
59 | }
60 |
61 | return this.services;
62 | }
63 |
64 | /**
65 | * Returns the services, or void if none found.
66 | */
67 | public maybeGetServices(): IArcServices | void {
68 | return this.services;
69 | }
70 | }
71 |
72 | export const instance = new ArcSingleton();
73 |
--------------------------------------------------------------------------------
/src/components/arc-scope.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import * as React from 'react';
3 |
4 | import { NativeElementStore } from '../focus/native-element-store';
5 | import { instance } from '../singleton';
6 | import { StateContainer } from '../state/state-container';
7 | import { ArcDown, ArcUp } from './arc-scope';
8 | import { mountToDOM, unmountAll } from './util.test';
9 |
10 | describe('ArcScope', () => {
11 | const render = (Component: React.ComponentType<{}>) => {
12 | const state = new StateContainer();
13 | instance.setServices({
14 | elementStore: new NativeElementStore(),
15 | stateContainer: state,
16 | });
17 |
18 | const contents = mountToDOM(
19 |
20 |
21 |
,
22 | );
23 |
24 | return {
25 | contents,
26 | state,
27 | };
28 | };
29 |
30 | it('renders and stores data in the state', () => {
31 | const { state, contents } = render(ArcDown('#foo', () => ));
32 | const targetEl = contents.getDOMNode().querySelector('.testclass') as HTMLElement;
33 | expect(targetEl).to.not.be.undefined;
34 | const record = state.find(targetEl);
35 | expect(record).to.not.be.undefined;
36 | expect(record!.arcFocusDown).to.equal('#foo');
37 | expect(record!.element).to.equal(targetEl);
38 | });
39 |
40 | it('removes state when unmounting the component', () => {
41 | const { state, contents } = render(ArcDown('#foo', () => ));
42 | const targetEl = contents.getDOMNode().querySelector('.testclass') as HTMLElement;
43 | unmountAll();
44 | expect(state.find(targetEl)).to.be.undefined;
45 | });
46 |
47 | it('composes multiple arc scopes into a single context', () => {
48 | const { state, contents } = render(
49 | ArcDown('#foo', ArcUp('#bar', () => )),
50 | );
51 | const targetEl = contents.getDOMNode().querySelector('.testclass') as HTMLElement;
52 | const record = state.find(targetEl);
53 | expect(record).to.not.be.undefined;
54 | expect(record!.arcFocusDown).to.equal('#foo');
55 | expect(record!.arcFocusUp).to.equal('#bar');
56 | expect(record!.element).to.equal(targetEl);
57 |
58 | expect((state as any).arcs.get(targetEl).records).to.have.lengthOf(1);
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/src/focus/is-for-form.ts:
--------------------------------------------------------------------------------
1 | import { Button } from '../model';
2 |
3 | /**
4 | * Based on the currently focused DOM element, returns whether the directional
5 | * input is part of a form control and should be allowed to bubble through.
6 | */
7 | export function isForForm(direction: Button, selected: HTMLElement | null): boolean {
8 | if (!selected) {
9 | return false;
10 | }
11 |
12 | // Always allow the browser to handle enter key presses in a form or text area.
13 | if (direction === Button.Submit) {
14 | let parent: HTMLElement | null = selected;
15 | while (parent) {
16 | if (
17 | parent.tagName === 'FORM' ||
18 | parent.tagName === 'TEXTAREA' ||
19 | (parent.tagName === 'INPUT' &&
20 | (parent as HTMLInputElement).type !== 'button' &&
21 | (parent as HTMLInputElement).type !== 'checkbox' &&
22 | (parent as HTMLInputElement).type !== 'radio')
23 | ) {
24 | return true;
25 | }
26 | parent = parent.parentElement;
27 | }
28 |
29 | return false;
30 | }
31 |
32 | // Okay, not a submission? Well, if we aren't inside a text input, go ahead
33 | // and let arcade-machine try to deal with the output.
34 | const tag = selected.tagName;
35 | if (tag !== 'INPUT' && tag !== 'TEXTAREA') {
36 | return false;
37 | }
38 |
39 | // We'll say that up/down has no effect.
40 | if (direction === Button.Down || direction === Button.Up) {
41 | return false;
42 | }
43 |
44 | // Deal with the output ourselves, allowing arcade-machine to handle it only
45 | // if the key press would not have any effect in the context of the input.
46 | const input = selected as HTMLInputElement | HTMLTextAreaElement;
47 | const { type } = input;
48 | if (
49 | type !== 'text' &&
50 | type !== 'search' &&
51 | type !== 'url' &&
52 | type !== 'tel' &&
53 | type !== 'password' &&
54 | type !== 'textarea'
55 | ) {
56 | return false;
57 | }
58 |
59 | const cursor = input.selectionStart;
60 | if (cursor !== input.selectionEnd) {
61 | // key input on any range selection will be effectual.
62 | return true;
63 | }
64 |
65 | if (cursor === null) {
66 | return false;
67 | }
68 |
69 | return (
70 | (cursor > 0 && direction === Button.Left) ||
71 | (cursor > 0 && direction === Button.Back) ||
72 | (cursor < input.value.length && direction === Button.Right)
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/src/input/xbox-gamepad.ts:
--------------------------------------------------------------------------------
1 | import { Button, nonDirectionalButtons } from '../model';
2 | import { DirectionalDebouncer } from './directional-debouncer';
3 | import { FiredDebouncer } from './fired-debouncer';
4 | import { IGamepadWrapper } from './gamepad';
5 |
6 | /**
7 | * XboxGamepadWrapper wraps an Xbox controller input for arcade-machine.
8 | */
9 | export class XboxGamepadWrapper implements IGamepadWrapper {
10 | /**
11 | * Magnitude that joysticks have to go in one direction to be translated
12 | * into a direction key press.
13 | */
14 | public static joystickThreshold = 0.5;
15 |
16 | /**
17 | * Map from Direction to a function that takes a time (now) and returns
18 | * whether that direction fired
19 | */
20 | public events = new Map