├── .npmignore ├── docs ├── types │ └── styles.d.ts ├── webpack.production.js ├── demos │ ├── forms.component.scss │ ├── hello-world.component.scss │ ├── modal.component.scss │ ├── autofocus.tsx │ ├── hello-world.tsx │ ├── demo-utils.tsx │ ├── focus-areas.tsx │ ├── benchmarks │ │ ├── select-simple-element.tsx │ │ ├── select-arcade-machine.tsx │ │ ├── select-raycast.tsx │ │ ├── select-arcade-machine-virtual.tsx │ │ └── select-deeply-nested.tsx │ ├── focus-scrollable.tsx │ ├── scope-override-focus.tsx │ ├── modal.tsx │ ├── forms.tsx │ ├── focus-exclude.tsx │ └── benchmarks.tsx ├── nav │ ├── title.component.scss │ ├── title.tsx │ ├── sidebar.component.scss │ ├── tree.tsx │ └── sidebar.tsx ├── index.tsx ├── highlighted.tsx ├── index.scss ├── demo.component.scss ├── app.component.scss ├── webpack.config.js ├── demo.tsx └── app.tsx ├── tsconfig.cjs.json ├── test ├── karma.shim.js ├── test-setup.js └── karma.config.js ├── tslint.json ├── .editorconfig ├── README.md ├── .travis.yml ├── src ├── focus │ ├── native-element-store.ts │ ├── virtual-element-store.ts │ ├── focus-by-registry.ts │ ├── dom-utils.ts │ ├── is-for-form.ts │ ├── focus-by-raycast.ts │ ├── index.ts │ └── focus-by-distance.ts ├── input │ ├── fired-debouncer.ts │ ├── input-method.ts │ ├── gamepad.ts │ ├── directional-debouncer.ts │ ├── xbox-gamepad.ts │ ├── keyboard-input.ts │ └── gamepad-input.ts ├── index.ts ├── input.ts ├── scroll │ ├── util.ts │ ├── scroll-registry.ts │ ├── index.ts │ ├── native-smooth-scrolling.ts │ └── smooth-scrolling-algorithm.ts ├── root-store.ts ├── internal-types.ts ├── arc-focus-event.ts ├── components │ ├── util.test.tsx │ ├── arc-autofocus.test.tsx │ ├── arc-scrollable.tsx │ ├── arc-autofocus.tsx │ ├── arc-exclude.tsx │ ├── arc-scope.test.tsx │ ├── arc-focus-area.test.tsx │ ├── arc-focus-trap.tsx │ ├── arc-focus-area.tsx │ ├── arc-focus-trap.test.tsx │ ├── arc-root.tsx │ └── arc-scope.tsx ├── arc-event.ts ├── state │ ├── state-container.ts │ ├── state-container.test.ts │ └── element-state-record.ts ├── singleton.ts ├── model.ts └── focus-service.ts ├── tsconfig.json ├── .gitignore ├── LICENSE └── package.json /.npmignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/dist 3 | -------------------------------------------------------------------------------- /docs/types/styles.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss'; 2 | -------------------------------------------------------------------------------- /docs/webpack.production.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('./webpack.config'), 3 | mode: 'production', 4 | devtool: false, 5 | }; 6 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "dist/cjs" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/karma.shim.js: -------------------------------------------------------------------------------- 1 | require('./test-setup'); 2 | 3 | const testsContext = require.context('../src', true, /\.test\.tsx?$/) 4 | testsContext.keys().forEach(testsContext) 5 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"], 3 | "rules": { 4 | "no-unused-expression": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /docs/demos/forms.component.scss: -------------------------------------------------------------------------------- 1 | .forms { 2 | display: flex; 3 | margin-top: 0.5rem; 4 | 5 | label { 6 | display: block; 7 | font-weight: bold; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/test-setup.js: -------------------------------------------------------------------------------- 1 | const { configure } = require('enzyme'); 2 | const ReactSixteenAdapter = require('enzyme-adapter-react-16'); 3 | configure({ adapter: new ReactSixteenAdapter() }); 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | end_of_line = lf 8 | indent_style = space 9 | indent_size = 2 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @mixer/arcade-machine-react 2 | 3 | :video_game: Input abstraction layer for gamepads, keyboards, and UWP apps in React. 4 | 5 | See [the docs](https://arcademachinedocs.z13.web.core.windows.net/) for more information and interactive examples! 6 | -------------------------------------------------------------------------------- /docs/demos/hello-world.component.scss: -------------------------------------------------------------------------------- 1 | .row { 2 | display: flex; 3 | } 4 | 5 | .box { 6 | margin: 1rem; 7 | width: 50px; 8 | height: 50px; 9 | background: #ccc; 10 | 11 | &:focus, 12 | &.arc-selected { 13 | background: red; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | sudo: required 3 | language: node_js 4 | node_js: 5 | - 10 6 | addons: 7 | chrome: stable 8 | before_script: 9 | - "sudo chown root /opt/google/chrome/chrome-sandbox" 10 | - "sudo chmod 4755 /opt/google/chrome/chrome-sandbox" 11 | -------------------------------------------------------------------------------- /docs/nav/title.component.scss: -------------------------------------------------------------------------------- 1 | .link { 2 | display: inline-block; 3 | width: 1em; 4 | text-align: right; 5 | margin-left: -1em; 6 | text-decoration: none; 7 | color: #ccc; 8 | position: relative; 9 | z-index: 1; 10 | 11 | &:hover { 12 | color: #000; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/demos/modal.component.scss: -------------------------------------------------------------------------------- 1 | .demo { 2 | position: relative; 3 | width: 500px; 4 | height: 300px; 5 | } 6 | 7 | .modal { 8 | position: absolute; 9 | top: 0; 10 | left: 0; 11 | right: 0; 12 | bottom: 0; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | background: rgba(#000, 0.5); 17 | } 18 | 19 | .inner { 20 | padding: 8px; 21 | background: #fff; 22 | border: 1px solid #000; 23 | } 24 | -------------------------------------------------------------------------------- /docs/index.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import * as React from 'react'; 4 | import { render } from 'react-dom'; 5 | import { App } from './app'; 6 | 7 | import '../node_modules/normalize.css/normalize.css'; 8 | import '../node_modules/highlight.js/styles/github.css'; 9 | import './index.scss'; 10 | 11 | const target = document.createElement('div'); 12 | document.body.appendChild(target); 13 | 14 | render(, target); 15 | -------------------------------------------------------------------------------- /docs/nav/title.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { NavNode } from './tree'; 3 | import * as styles from './title.component.scss'; 4 | 5 | export const Title: React.FC<{ node: NavNode }> = ({ node }) => 6 | React.createElement( 7 | `h${node.depth}`, 8 | { id: node.link }, 9 | <> 10 | 11 | # 12 | {' '} 13 | {node.title} 14 | , 15 | ); 16 | -------------------------------------------------------------------------------- /src/focus/native-element-store.ts: -------------------------------------------------------------------------------- 1 | import { IElementStore } from '.'; 2 | 3 | /** 4 | * NativeElementStore is an IElementStore that uses the browser's native focus 5 | * for dealing with the focused element. 6 | */ 7 | export class NativeElementStore implements IElementStore { 8 | public get element() { 9 | return document.activeElement as HTMLElement; 10 | } 11 | 12 | public set element(element: HTMLElement) { 13 | element.focus(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docs/nav/sidebar.component.scss: -------------------------------------------------------------------------------- 1 | .sidebar { 2 | position: fixed; 3 | top: 8px; 4 | left: 8px; 5 | width: 250px; 6 | font-size: 0.8em; 7 | 8 | ol { 9 | margin-left: 0.5rem; 10 | } 11 | 12 | li { 13 | list-style-type: none; 14 | } 15 | 16 | a { 17 | color: #000; 18 | text-decoration: none; 19 | display: inline-block; 20 | margin: 0.5rem 0; 21 | 22 | &:hover { 23 | color: #666; 24 | } 25 | 26 | &.active { 27 | font-weight: bold; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/input/fired-debouncer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * FiredDebouncer handles single "fired" states that happen from button presses. 3 | */ 4 | export class FiredDebouncer { 5 | private fired = false; 6 | 7 | constructor(private predicate: () => boolean) {} 8 | 9 | /** 10 | * Returns whether the key should be registered as pressed. 11 | */ 12 | public attempt(): boolean { 13 | const result = this.predicate(); 14 | const hadFired = this.fired; 15 | this.fired = result; 16 | 17 | return !hadFired && result; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/demos/autofocus.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ArcRoot, defaultOptions, AutoFocus } from '../../src'; 3 | import { basicGrid } from './demo-utils'; 4 | 5 | export default ArcRoot(() => { 6 | const [visible, setVisible] = React.useState(true); 7 | return ( 8 | <> 9 | 12 | {visible && {basicGrid(5, 3)}} 13 | 14 | ); 15 | }, defaultOptions()); 16 | -------------------------------------------------------------------------------- /docs/demos/hello-world.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ArcRoot, defaultOptions } from '../../src'; 3 | import * as styles from './hello-world.component.scss'; 4 | import { repeat } from './demo-utils'; 5 | 6 | export default ArcRoot( 7 | () => ( 8 | <> 9 | {repeat(3, i => ( 10 |
11 | {repeat(5, k => ( 12 |
13 | ))} 14 |
15 | ))} 16 | 17 | ), 18 | defaultOptions(), 19 | ); 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "noImplicitReturns": true, 5 | "noUnusedLocals": true, 6 | "noUnusedParameters": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "preserveConstEnums": true, 10 | "jsx": "react", 11 | "module": "esnext", 12 | "target": "es6", 13 | "outDir": "dist", 14 | "lib": ["es6", "dom"], 15 | "types": ["mocha", "winrt-uwp"], 16 | "moduleResolution": "node", 17 | "typeRoots": [ 18 | "node_modules/@types" 19 | ] 20 | }, 21 | "include": ["src"] 22 | } 23 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './arc-event'; 2 | export * from './components/arc-autofocus'; 3 | export * from './components/arc-exclude'; 4 | export * from './components/arc-focus-area'; 5 | export * from './components/arc-focus-trap'; 6 | export * from './components/arc-root'; 7 | export * from './components/arc-scope'; 8 | export * from './components/arc-scrollable'; 9 | export * from './focus/focus-by-distance'; 10 | export * from './focus/focus-by-raycast'; 11 | export * from './focus/focus-by-registry'; 12 | export * from './focus/native-element-store'; 13 | export * from './focus/virtual-element-store'; 14 | export * from './model'; 15 | -------------------------------------------------------------------------------- /src/input/input-method.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { Button } from '../model'; 3 | 4 | export interface IInputObservation { 5 | button: Button; 6 | event?: Event; 7 | } 8 | 9 | /** 10 | * IInputMethod is a class that provides a list of button presses the 11 | * user makes. 12 | */ 13 | export interface IInputMethod { 14 | /** 15 | * Returns an observable of the user's button presses. 16 | */ 17 | readonly observe: Observable; 18 | 19 | /** 20 | * Returns whether the input method is supported in the current browser. 21 | */ 22 | readonly isSupported: boolean; 23 | } 24 | -------------------------------------------------------------------------------- /docs/demos/demo-utils.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as styles from './hello-world.component.scss'; 3 | 4 | export const repeat = (n: number, fn: (i: number) => T): T[] => { 5 | const data: T[] = []; 6 | for (let i = 0; i < n; i++) { 7 | data.push(fn(i)); 8 | } 9 | 10 | return data; 11 | }; 12 | 13 | export const basicGrid = (width: number, height: number) => ( 14 | <> 15 | {repeat(height, i => ( 16 |
17 | {repeat(width, k => ( 18 |
19 | ))} 20 |
21 | ))} 22 | 23 | ); 24 | -------------------------------------------------------------------------------- /docs/demos/focus-areas.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ArcRoot, defaultOptions, FocusArea } from '../../src'; 3 | import * as styles from './hello-world.component.scss'; 4 | import { repeat } from './demo-utils'; 5 | 6 | export default ArcRoot( 7 | () => ( 8 | <> 9 | {repeat(3, i => ( 10 | 11 | Content Row {i + 1} 12 | 13 | {repeat(5, k => ( 14 |
15 | ))} 16 | 17 | 18 | ))} 19 | 20 | ), 21 | defaultOptions(), 22 | ); 23 | -------------------------------------------------------------------------------- /docs/demos/benchmarks/select-simple-element.tsx: -------------------------------------------------------------------------------- 1 | import { IBenchmark } from '../benchmarks'; 2 | import { basicGrid } from '../demo-utils'; 3 | 4 | export const selectSimpleElementBenchmark: IBenchmark<[HTMLElement, HTMLElement], boolean> = { 5 | name: 'Select simple element (base case, no arcade-machine)', 6 | fixture: () => basicGrid(2, 1), 7 | setup: state => { 8 | return [ 9 | state.container.querySelector('[data-box="0 0"]') as HTMLElement, 10 | state.container.querySelector('[data-box="1 0"]') as HTMLElement, 11 | ]; 12 | }, 13 | iterate: (_state, [a, b], toggle) => { 14 | if (toggle) { 15 | a.focus(); 16 | } else { 17 | b.focus(); 18 | } 19 | 20 | return !toggle; 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/input.ts: -------------------------------------------------------------------------------- 1 | import { merge } from 'rxjs'; 2 | import { publish, refCount } from 'rxjs/operators'; 3 | import { IInputMethod } from './input/input-method'; 4 | 5 | /** 6 | * InputService handles passing input from the external device (gamepad API 7 | * or keyboard) to the arc internals. 8 | */ 9 | export class InputService { 10 | /** 11 | * Observes the feed of keyboard/gamepad events. Listened to by the 12 | * focus service, and can be listened to by other consumers too. 13 | */ 14 | public readonly events = merge( 15 | ...this.inputMethods.filter(i => i.isSupported).map(i => i.observe), 16 | ).pipe( 17 | publish(), 18 | refCount(), 19 | ); 20 | 21 | constructor(private readonly inputMethods: IInputMethod[]) {} 22 | } 23 | -------------------------------------------------------------------------------- /src/scroll/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns the difference the page has to be moved horizontall to bring 3 | * the target rect into view. 4 | */ 5 | export function horizontalDelta(rect: ClientRect, reference: ClientRect) { 6 | return rect.left < reference.left 7 | ? rect.left - reference.left 8 | : rect.right > reference.right 9 | ? rect.right - reference.right 10 | : 0; 11 | } 12 | 13 | /** 14 | * Returns the difference the page has to be moved vertically to bring 15 | * the target rect into view. 16 | */ 17 | export function verticalDelta(rect: ClientRect, reference: ClientRect) { 18 | return rect.top < reference.top 19 | ? rect.top - reference.top 20 | : rect.bottom > reference.bottom 21 | ? rect.bottom - reference.bottom 22 | : 0; 23 | } 24 | -------------------------------------------------------------------------------- /docs/highlighted.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Highlight, { defaultProps } from 'prism-react-renderer'; 3 | import theme from 'prism-react-renderer/themes/duotoneLight'; 4 | 5 | export const Highlighted: React.FC<{ code: string }> = props => ( 6 | 7 | {({ className, style, tokens, getLineProps, getTokenProps }) => ( 8 |
 9 |         {tokens.map((line, i) => (
10 |           
11 | {line.map((token, key) => ( 12 | 13 | ))} 14 |
15 | ))} 16 |
17 | )} 18 |
19 | ); 20 | -------------------------------------------------------------------------------- /docs/demos/focus-scrollable.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ArcRoot, defaultOptions, Scrollable, VirtualElementStore } from '../../src'; 3 | import { basicGrid } from './demo-utils'; 4 | 5 | export default ArcRoot( 6 | () => ( 7 | <> 8 | Vertical Scrolling 9 | 10 |
{basicGrid(5, 5)}
11 |
12 | Horizontal Scrolling 13 | 14 |
15 |
{basicGrid(15, 2)}
16 |
17 |
18 | 19 | ), 20 | { ...defaultOptions(), elementStore: new VirtualElementStore() }, 21 | ); 22 | -------------------------------------------------------------------------------- /docs/demos/scope-override-focus.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ArcRoot, defaultOptions, Scope } from '../../src'; 3 | import * as styles from './hello-world.component.scss'; 4 | 5 | export default ArcRoot( 6 | () => ( 7 | <> 8 |
9 | 10 |
11 | 12 | 13 |
14 | 15 | 16 | 19 | 20 | ), 21 | defaultOptions(), 22 | ); 23 | -------------------------------------------------------------------------------- /src/root-store.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Holds the current root element. 3 | */ 4 | export class RootStore { 5 | private readonly roots: HTMLElement[]; 6 | 7 | constructor(root: HTMLElement) { 8 | this.roots = [root]; 9 | } 10 | 11 | public get element() { 12 | return this.roots[this.roots.length - 1]; 13 | } 14 | 15 | /** 16 | * Scopes to the given new root. 17 | */ 18 | public narrow(root: HTMLElement) { 19 | this.roots.push(root); 20 | } 21 | 22 | /** 23 | * Restores a scoped element. 24 | */ 25 | public restore(fromRoot: HTMLElement) { 26 | const index = this.roots.lastIndexOf(fromRoot); 27 | if (index < 1) { 28 | // tslint:disable-next-line 29 | console.warn('arcade-machine: attempted to release a root we did not own'); 30 | } 31 | 32 | this.roots.splice(index, 1); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/input/gamepad.ts: -------------------------------------------------------------------------------- 1 | import { Button } from '../model'; 2 | 3 | export interface IGamepadWrapper { 4 | /** 5 | * Map from a direction to a function that takes in a time (now) 6 | * and returns whether that direction fired 7 | */ 8 | readonly events: Map boolean>; 9 | 10 | /** 11 | * The actual Gamepad object that can be updated/accessed; 12 | */ 13 | pad: Gamepad; 14 | 15 | /** 16 | * Returns whether the gamepad is still connected; 17 | */ 18 | isConnected(): boolean; 19 | } 20 | 21 | /** 22 | * IDebouncer should be called whenever an input is held down. It returns 23 | * whether that input should fire a nevigational event. 24 | */ 25 | export interface IDebouncer { 26 | /** 27 | * Called with the current time, determines whether to fire a navigational 28 | * event. 29 | */ 30 | attempt(node: number): boolean; 31 | } 32 | -------------------------------------------------------------------------------- /test/karma.config.js: -------------------------------------------------------------------------------- 1 | module.exports = config => 2 | config.set({ 3 | frameworks: ['mocha'], 4 | files: [ 5 | `./karma.shim.js`, 6 | ], 7 | preprocessors: { 8 | "./karma.shim.js": ["webpack", 'sourcemap'] 9 | }, 10 | mime: { 11 | 'text/x-typescript': ['ts','tsx'] 12 | }, 13 | reporters: ['mocha'], 14 | logLevel: config.LOG_INFO, 15 | browsers: ['ChromeHeadless'], 16 | webpackServer: { 17 | logLevel: 'error', 18 | }, 19 | webpack: { 20 | mode: 'development', 21 | devtool: 'inline-source-map', 22 | resolve: { 23 | extensions: ['.ts', '.tsx', '.js', '.jsx'] 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.tsx?$/, 29 | exclude: /node_modules/, 30 | loader: 'ts-loader' 31 | } 32 | ] 33 | } 34 | } 35 | }); 36 | -------------------------------------------------------------------------------- /docs/demos/benchmarks/select-arcade-machine.tsx: -------------------------------------------------------------------------------- 1 | import { ArcRoot, Button, defaultOptions } from '../../../src'; 2 | import { IBenchmark } from '../benchmarks'; 3 | import { Subject } from 'rxjs'; 4 | import { basicGrid } from '../demo-utils'; 5 | 6 | const mockInputMethod = { 7 | observe: new Subject<{ button: Button }>(), 8 | isSupported: true, 9 | }; 10 | 11 | export const selectArcadeMachineBenchmark: IBenchmark = { 12 | name: 'Select simple with arcade machine (native store)', 13 | fixture: ArcRoot(() => basicGrid(2, 1), { 14 | ...defaultOptions(), 15 | inputs: [mockInputMethod], 16 | }), 17 | setup: state => { 18 | (state.container.querySelector('[data-box="0 0"]') as HTMLElement).focus(); 19 | }, 20 | iterate: (_state, _setup, toggle) => { 21 | mockInputMethod.observe.next({ button: toggle ? Button.Right : Button.Left }); 22 | return !toggle; 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /docs/index.scss: -------------------------------------------------------------------------------- 1 | $font-prefix: '../node_modules/@ibm/plex/'; 2 | 3 | @import '../node_modules/@ibm/plex/scss/serif/semibold/latin1'; 4 | @import '../node_modules/@ibm/plex/scss/sans/regular/latin1'; 5 | 6 | :root { 7 | --font-family-sans: 'IBM Plex Sans', sans-serif; 8 | --font-family-serif: 'IBM Plex Serif', serif; 9 | } 10 | 11 | * { 12 | margin: 0; 13 | padding: 0; 14 | } 15 | 16 | body { 17 | font-family: var(--font-family-sans); 18 | -webkit-font-smoothing: antialiased; 19 | } 20 | 21 | h1, h2, h3, h4, h5, h6 { 22 | font-family: var(--font-family-serif); 23 | font-weight: normal; 24 | margin: 3rem 0 1rem; 25 | } 26 | 27 | h1 { 28 | font-size: 3rem; 29 | } 30 | 31 | h2 { 32 | font-size: 2rem; 33 | } 34 | 35 | code { 36 | font-family: var(--font-family-serif); 37 | } 38 | 39 | pre { 40 | font-family: monospace; 41 | } 42 | 43 | .arc-selected { 44 | background: red !important; 45 | color: #fff; 46 | } 47 | -------------------------------------------------------------------------------- /docs/demos/benchmarks/select-raycast.tsx: -------------------------------------------------------------------------------- 1 | import { ArcRoot, Button, defaultOptions, FocusByRaycastStrategy } from '../../../src'; 2 | import { IBenchmark } from '../benchmarks'; 3 | import { Subject } from 'rxjs'; 4 | import { basicGrid } from '../demo-utils'; 5 | 6 | const mockInputMethod = { 7 | observe: new Subject<{ button: Button }>(), 8 | isSupported: true, 9 | }; 10 | 11 | export const selectArcadeMachineRaycastBenchmark: IBenchmark = { 12 | name: 'Select via raycast focus strategy (native store)', 13 | fixture: ArcRoot(() => basicGrid(2, 1), { 14 | ...defaultOptions(), 15 | focus: [new FocusByRaycastStrategy()], 16 | inputs: [mockInputMethod], 17 | }), 18 | setup: state => { 19 | (state.container.querySelector('[data-box="0 0"]') as HTMLElement).focus(); 20 | }, 21 | iterate: (_state, _setup, toggle) => { 22 | mockInputMethod.observe.next({ button: toggle ? Button.Right : Button.Left }); 23 | return !toggle; 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /docs/demos/benchmarks/select-arcade-machine-virtual.tsx: -------------------------------------------------------------------------------- 1 | import { ArcRoot, Button, defaultOptions, VirtualElementStore } from '../../../src'; 2 | import { IBenchmark } from '../benchmarks'; 3 | import { Subject } from 'rxjs'; 4 | import { basicGrid } from '../demo-utils'; 5 | 6 | const mockInputMethod = { 7 | observe: new Subject<{ button: Button }>(), 8 | isSupported: true, 9 | }; 10 | 11 | export const selectArcadeMachineVirtualBenchmark: IBenchmark = { 12 | name: 'Select simple with arcade machine (virtual store)', 13 | fixture: ArcRoot(() => basicGrid(2, 1), { 14 | ...defaultOptions(), 15 | elementStore: new VirtualElementStore(), 16 | inputs: [mockInputMethod], 17 | }), 18 | setup: state => { 19 | (state.container.querySelector('[data-box="0 0"]') as HTMLElement).focus(); 20 | }, 21 | iterate: (_state, _setup, toggle) => { 22 | mockInputMethod.observe.next({ button: toggle ? Button.Right : Button.Left }); 23 | return !toggle; 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /docs/demo.component.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | margin-top: 1rem; 3 | background: #eee; 4 | } 5 | 6 | .tabs { 7 | display: flex; 8 | 9 | li { 10 | list-style-type: none; 11 | margin: 0; 12 | padding: 0.75rem 2rem; 13 | color: #666; 14 | font-size: 0.9rem; 15 | cursor: pointer; 16 | 17 | &.active { 18 | background: #ccc; 19 | color: #000; 20 | } 21 | } 22 | } 23 | 24 | .code, 25 | .demo, 26 | .demoHidden { 27 | height: 500px; 28 | overflow-y: auto; 29 | } 30 | 31 | .demo { 32 | display: flex; 33 | align-items: center; 34 | justify-content: center; 35 | 36 | button { 37 | background: #aaa; 38 | padding: 8px; 39 | 40 | &:focus { 41 | background: red; 42 | color: #fff; 43 | outline: 0; 44 | } 45 | } 46 | } 47 | 48 | .demoHidden { 49 | display: flex; 50 | align-items: center; 51 | justify-content: center; 52 | font-size: 1.2em; 53 | cursor: pointer; 54 | color: #aaa; 55 | } 56 | 57 | .code :global .prism-code { 58 | margin: 0; 59 | } 60 | -------------------------------------------------------------------------------- /docs/demos/modal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ArcRoot, defaultOptions, FocusTrap, ArcScope, Button } from '../../src'; 3 | import * as styles from './modal.component.scss'; 4 | import { basicGrid } from './demo-utils'; 5 | 6 | export default ArcRoot(() => { 7 | const [open, setOpen] = React.useState(false); 8 | 9 | return ( 10 |
11 | 14 | {basicGrid(5, 2)} 15 | {open && ( 16 | 17 | ev.event === Button.Back && setOpen(false)}> 18 |
19 |
20 | This is a modal! 21 | {basicGrid(5, 2)} 22 | 25 |
26 |
27 |
28 |
29 | )} 30 |
31 | ); 32 | }, defaultOptions()); 33 | -------------------------------------------------------------------------------- /src/internal-types.ts: -------------------------------------------------------------------------------- 1 | import { ArcEvent } from './arc-event'; 2 | 3 | /** 4 | * Looks for the element by its selector, if given, or returns it. 5 | */ 6 | export const findElement = (container: HTMLElement, target?: string | HTMLElement) => 7 | typeof target === 'string' 8 | ? (container.querySelector(target) as HTMLElement | null) 9 | : target || null; 10 | 11 | /** 12 | * Returns the focusable element in the container, optionally filtering 13 | * to the given target. 14 | */ 15 | export const findFocusable = (container: HTMLElement, target?: string | HTMLElement) => 16 | findElement(container, target) || (container.querySelector('[tabIndex]') as HTMLElement | null); 17 | 18 | /** 19 | * returns whether stopPropogation has been called on the event. 20 | */ 21 | export function propogationStoped(ev: ArcEvent) { 22 | return (ev as any).propogationStopped; 23 | } 24 | 25 | /** 26 | * resets state (propogation and default prevention) of the event. 27 | */ 28 | export function resetEvent(ev: ArcEvent) { 29 | (ev as any).propogationStopped = false; 30 | (ev as any).defaultPrevented = false; 31 | } 32 | -------------------------------------------------------------------------------- /src/scroll/scroll-registry.ts: -------------------------------------------------------------------------------- 1 | export interface IScrollableContainer { 2 | element: HTMLElement; 3 | horizontal: boolean; 4 | vertical: boolean; 5 | } 6 | 7 | /** 8 | * The ScrollRegistry keeps tracks of what elements are registered as 9 | * being scrollable. 10 | */ 11 | export class ScrollRegistry { 12 | constructor( 13 | private readonly containers: Array> = [ 14 | { 15 | element: (document.scrollingElement as HTMLElement) || document.body, 16 | horizontal: false, 17 | vertical: true, 18 | }, 19 | ], 20 | ) {} 21 | 22 | /** 23 | * Registers the element as being an arcade-machine scroll container. 24 | */ 25 | public add(data: Readonly) { 26 | this.containers.push(data); 27 | } 28 | 29 | /** 30 | * Registers the element as being an arcade-machine scroll container. 31 | */ 32 | public remove(el: HTMLElement) { 33 | this.containers.splice(this.containers.findIndex(r => r.element === el), 1); 34 | } 35 | 36 | /** 37 | * Returns the list of registered scroll containers. 38 | */ 39 | public getScrollContainers(): ReadonlyArray> { 40 | return this.containers; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /docs/demos/forms.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ArcRoot, defaultOptions, VirtualElementStore } from '../../src'; 3 | import * as styles from './forms.component.scss'; 4 | import { basicGrid } from './demo-utils'; 5 | 6 | export default ArcRoot( 7 | () => ( 8 | <> 9 | {basicGrid(5, 1)} 10 |
11 |
{basicGrid(1, 3)}
12 |
13 | 14 | 15 | 16 | Xbox 17 | PlayStation 18 | 19 | Yes Please 20 | 21 |