├── .eslintignore ├── .gruntfile.env.default ├── tests ├── testem.json ├── video │ └── cursor.webm ├── html │ ├── iframe.html │ ├── list-selector.html │ ├── polling-tests.html │ ├── arrow-tests.html │ ├── click-tests.html │ ├── tab-tests.html │ ├── form-fields.html │ └── advanced-focus.html ├── js │ ├── click │ │ ├── click-mock-utils.js │ │ └── click-basic-tests.js │ ├── tab │ │ ├── tab-mock-utils.js │ │ ├── tab-order-tests.js │ │ ├── tab-button-continuous-tests.js │ │ └── tab-button-discrete-tests.js │ ├── scroll │ │ ├── scroll-mock-utils.js │ │ ├── scroll-bidirectional-oneaxes-tests.js │ │ ├── scroll-unidirectional-axes-tests.js │ │ └── scroll-unidirectional-button-tests.js │ ├── lib │ │ ├── test-utils.js │ │ ├── mocked-window.js │ │ └── gamepad-mock.js │ ├── arrows │ │ ├── arrows-mock-utils.js │ │ └── arrows-tests.js │ └── polling │ │ ├── gamepad-polling-tests.js │ │ └── action-repeat-tests.js └── all-tests.html ├── src ├── fonts │ └── Ubuntu-Medium.ttf ├── images │ ├── gamepad-icon.png │ ├── gamepad-icon-16px.png │ ├── gamepad-icon-32px.png │ ├── gamepad-icon-48px.png │ ├── gamepad-icon-128px.png │ ├── options-icon.svg │ ├── gamepad-icon.svg │ ├── thumbstick.svg │ └── button.svg ├── css │ ├── iframe-wrapper.css │ ├── common.css │ ├── focus-fix.css │ ├── action-launcher.css │ ├── list-selector.css │ ├── modal.css │ └── onscreen-keyboard.css ├── js │ ├── content_scripts │ │ ├── onscreen-numpad.js │ │ ├── templateRenderer.js │ │ ├── shadow-holder.js │ │ ├── search-keyboard.js │ │ ├── action-launcher.js │ │ ├── bindings.js │ │ ├── keyboards.js │ │ ├── input-mapper-background-utils.js │ │ ├── modalManager.js │ │ ├── select-operator.js │ │ ├── focus-overlay.js │ │ └── list-selector.js │ ├── shared │ │ ├── prefs.js │ │ ├── settings.js │ │ └── utils.js │ └── settings │ │ ├── draftHandlingButton.js │ │ ├── textInput.js │ │ ├── toggle.js │ │ ├── editableSection.js │ │ ├── settingsMainPanel.js │ │ ├── selectInput.js │ │ └── addBinding.js ├── manifest.json └── html │ └── settings.html ├── .stylelintrc.json ├── .eslintrc.json ├── templates └── LICENSE-banner.txt ├── .gitignore ├── .fluidlintallrc.json ├── .github └── workflows │ └── main.yml ├── docs ├── components │ ├── bindings.md │ ├── preferences.md │ └── navigator.md └── PUBLISHING.md ├── AUTHORS.md ├── LICENSE ├── PRIVACY_POLICY.md ├── package.json └── utils ├── bundleCss.js └── bundleSvgs.js /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/** 2 | node_modules/** 3 | -------------------------------------------------------------------------------- /.gruntfile.env.default: -------------------------------------------------------------------------------- 1 | GITHUB_ACCESS_TOKEN= 2 | -------------------------------------------------------------------------------- /tests/testem.json: -------------------------------------------------------------------------------- 1 | { 2 | "test_page": "tests/all-tests.html", 3 | "launch": "Chrome" 4 | } 5 | -------------------------------------------------------------------------------- /tests/video/cursor.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluid-lab/gamepad-navigator/HEAD/tests/video/cursor.webm -------------------------------------------------------------------------------- /src/fonts/Ubuntu-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluid-lab/gamepad-navigator/HEAD/src/fonts/Ubuntu-Medium.ttf -------------------------------------------------------------------------------- /src/images/gamepad-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluid-lab/gamepad-navigator/HEAD/src/images/gamepad-icon.png -------------------------------------------------------------------------------- /src/images/gamepad-icon-16px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluid-lab/gamepad-navigator/HEAD/src/images/gamepad-icon-16px.png -------------------------------------------------------------------------------- /src/images/gamepad-icon-32px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluid-lab/gamepad-navigator/HEAD/src/images/gamepad-icon-32px.png -------------------------------------------------------------------------------- /src/images/gamepad-icon-48px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluid-lab/gamepad-navigator/HEAD/src/images/gamepad-icon-48px.png -------------------------------------------------------------------------------- /src/images/gamepad-icon-128px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluid-lab/gamepad-navigator/HEAD/src/images/gamepad-icon-128px.png -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-fluid", 3 | "rules": { 4 | "selector-list-comma-newline-after": "always-multi-line" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/css/iframe-wrapper.css: -------------------------------------------------------------------------------- 1 | a.gamepad-navigator-iframe-wrapper { 2 | display: block; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | a.gamepad-navigator-iframe-wrapper:focus { 8 | background-color: white; 9 | border: 3px dashed white; 10 | mix-blend-mode: difference; 11 | } 12 | -------------------------------------------------------------------------------- /src/css/common.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: ubuntu, sans-serif; 3 | src: url("../fonts/Ubuntu-Medium.ttf"); 4 | } 5 | 6 | * { 7 | font-family: ubuntu, sans-serif; 8 | } 9 | 10 | .hidden { 11 | display: none; 12 | } 13 | 14 | .no-focus-indicator { 15 | outline: none; 16 | } 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2017": true, 4 | "browser": true 5 | }, 6 | "extends": "eslint-config-fluid", 7 | "rules": { 8 | "@stylistic/js/indent": [ 9 | "error", 10 | 4, 11 | { "SwitchCase": 1} 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/html/iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | IFrame Content 5 | 6 | 7 |

A paragraph of content in an iFrame.

8 | 9 |

A link within an iFrame

10 | 11 | 12 | -------------------------------------------------------------------------------- /src/css/focus-fix.css: -------------------------------------------------------------------------------- 1 | .gamepad-focus-overlay { 2 | height: 100%; 3 | left: 0; 4 | position: fixed; 5 | top: 0; 6 | width: 100%; 7 | z-index: 99998; 8 | } 9 | 10 | .gamepad-navigator-focus-overlay-pointer { 11 | border: 3px dashed white; 12 | content: ''; 13 | height: 0; 14 | mix-blend-mode: difference; 15 | pointer-events: none; 16 | position: absolute; 17 | width: 0; 18 | z-index: 99999; 19 | } 20 | -------------------------------------------------------------------------------- /templates/LICENSE-banner.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 The Gamepad Navigator Authors 2 | See the AUTHORS.md file at the top-level directory of this distribution and at 3 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 4 | 5 | Licensed under the BSD 3-Clause License. You may not use this file except in 6 | compliance with this License. 7 | 8 | You may obtain a copy of the BSD 3-Clause License at 9 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 10 | -------------------------------------------------------------------------------- /src/css/action-launcher.css: -------------------------------------------------------------------------------- 1 | .actionLauncher-actions { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 0.5rem; 5 | margin: 1rem; 6 | } 7 | 8 | .actionLauncher-actions-action { 9 | font-size: 1.25rem; 10 | padding: 0.75rem; 11 | } 12 | 13 | .actionLauncher-actions-action:hover { 14 | outline: aquamarine; 15 | } 16 | 17 | .actionLauncher-actions-action:focus { 18 | background-color: aquamarine; 19 | font-size: 1.4rem; 20 | outline: none; 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # Package Lock File 5 | package-lock.json 6 | 7 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 8 | .grunt 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Optional npm cache directory 14 | .npm 15 | 16 | # Optional eslint cache 17 | .eslintcache 18 | 19 | # Unpacked chrome extension files 20 | dist/ 21 | **/*.zip 22 | 23 | # Environment variable files 24 | *.env 25 | -------------------------------------------------------------------------------- /.fluidlintallrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "sources": { 3 | "js": ["./src/js/**/*.js", "tests/js/**/*.js", "./*.js"], 4 | "md": ["./*.md", "tests/**/*.md", "docs/**/*.md"], 5 | "json": ["./*.json", "./.*.json", "tests/**/*.json", "!audit-resolve.json"], 6 | "json5": ["./*.json5", "tests/**/*.json5"], 7 | "other": ["./.*"] 8 | }, 9 | "lintspaces": { 10 | "newlines": { 11 | "excludes": ["./node_modules/**/*", "./src/images/**/*", "./src/fonts/**/*", "./audit-resolve.json"] 12 | } 13 | }, 14 | "stylelint": { 15 | "options": { 16 | "configFile": ".stylelintrc.json" 17 | } 18 | }, 19 | "markdownlint": { 20 | "options": { 21 | "config": { 22 | "link-fragments": false 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/js/content_scripts/onscreen-numpad.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | (function (fluid) { 13 | "use strict"; 14 | 15 | fluid.defaults("gamepad.numpad.modal", { 16 | gradeNames: ["gamepad.osk.modal"], 17 | model: { 18 | label: "Gamepad Navigator: Number Pad", 19 | classNames: " gamepad-navigator-numpad" 20 | }, 21 | components: { 22 | osk: { 23 | type: "gamepad.osk.keyboard.numpad" 24 | } 25 | } 26 | }); 27 | 28 | })(fluid); 29 | -------------------------------------------------------------------------------- /tests/js/click/click-mock-utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | (function (fluid) { 14 | "use strict"; 15 | 16 | // Custom gamepad navigator component grade for simple click tests. 17 | fluid.defaults("gamepad.tests.click.inputMapper", { 18 | gradeNames: ["gamepad.inputMapper.base"], 19 | windowObject: window, 20 | model: { 21 | bindings: { 22 | buttons: { 23 | 0: { action: "click" } 24 | } 25 | } 26 | } 27 | }); 28 | })(fluid); 29 | -------------------------------------------------------------------------------- /src/js/shared/prefs.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | (function (fluid) { 13 | "use strict"; 14 | var gamepad = fluid.registerNamespace("gamepad"); 15 | fluid.registerNamespace("gamepad.prefs"); 16 | gamepad.prefs.defaults = { 17 | analogCutoff: 0.25, 18 | arrowModals: true, 19 | controlsOnAllMedia: true, 20 | fixFocus: false, 21 | linkExternalIframes: true, 22 | newTabOrWindowURL: "https://www.google.com/", 23 | openWindowOnStartup: true, 24 | pollingFrequency: 50, 25 | vibrate: true 26 | }; 27 | })(fluid); 28 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [23.x] 17 | 18 | env: 19 | HEADLESS: true 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | 29 | - name: Install Node.js dependencies 30 | run: npm install 31 | 32 | - name: Lint Code 33 | run: npm run lint 34 | 35 | - name: Node Tests 36 | run: xvfb-run --auto-servernum npm test 37 | 38 | - name: Cleanup after xvfb 39 | uses: bcomnes/cleanup-xvfb@v1 40 | 41 | - name: Install Security Audit Globally 42 | run: npm install -g npm-audit-resolver 43 | 44 | - name: Security Audit 45 | run: check-audit 46 | -------------------------------------------------------------------------------- /src/css/list-selector.css: -------------------------------------------------------------------------------- 1 | .gamepad-list-selector { 2 | display: flex; 3 | flex-direction: column; 4 | margin: 1rem; 5 | row-gap: 1rem; 6 | } 7 | 8 | .gamepad-list-selector-description { 9 | font-size: 1.5rem; 10 | text-align: center; 11 | } 12 | 13 | .gamepad-list-selector-items { 14 | border: 1px solid #999; 15 | border-radius: 0.25rem; 16 | display: flex; 17 | flex-direction: column; 18 | row-gap: 0; 19 | } 20 | 21 | .gamepad-list-selector-item { 22 | align-content: center; 23 | font-size: 1.25rem; 24 | height: 3rem; 25 | line-height: 1.5rem; 26 | padding: 1rem; 27 | } 28 | 29 | .gamepad-list-selector-item:not(:first-of-type) { 30 | border-top: 1px dashed #999; 31 | } 32 | 33 | .gamepad-list-selector-item:hover { 34 | outline: aquamarine; 35 | } 36 | 37 | .gamepad-list-selector-item:focus { 38 | background-color: aquamarine; 39 | font-size: 1.4rem; 40 | outline: none; 41 | } 42 | 43 | .gamepad-list-selector-item--selected { 44 | font-weight: bold; 45 | } 46 | 47 | .gamepad-list-selector-item--selected::after { 48 | content: " (selected)"; 49 | } 50 | -------------------------------------------------------------------------------- /tests/all-tests.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | All Gamepad Navigator Tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 23 | 24 | 25 |
26 |
27 |
28 | 29 | 30 | -------------------------------------------------------------------------------- /docs/components/bindings.md: -------------------------------------------------------------------------------- 1 | # Bindings 2 | 3 | Bindings are a hash that associates an input (such as a button or thumb stick) with an [action](actions.md). Here is a 4 | simple example: 5 | 6 | ```json 7 | { 8 | "bindings": { 9 | "buttons": { 10 | "0": { 11 | "action": "click" 12 | } 13 | } 14 | } 15 | } 16 | ``` 17 | 18 | This binds button 0 (on any controller) to the "click" action (see [the input mapper base grade](inputMapper.base.md) 19 | for details). A more complex example might include [action options](actions.md), as shown here: 20 | 21 | ```json 22 | { 23 | "bindings": { 24 | "axes": { 25 | "0": { 26 | "action": "scrollHorizontally", 27 | "scrollFactor": 10, 28 | "repeateRate": 0.1 29 | } 30 | } 31 | } 32 | } 33 | ``` 34 | 35 | This binds the first axis (on any controller) to the `scrollHorizontally` action (see 36 | [the input mapper base grade](inputMapper.base.md) for details). The additional action options specify how far we 37 | should scroll each time the action is executed, and how often the action should repeat while the axis is still held 38 | down. 39 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | 12 | 13 | # Authors 14 | 15 | ## Copyright Holders 16 | 17 | This is the list of Gamepad Navigator copyright holders. It does not list all individual contributors because some have 18 | assigned copyright to an institution or made only minor changes. Please see the version control system's revision 19 | history for details on contributions. 20 | 21 | Copyright (c) 2023 22 | 23 | - Divyanshu Mahajan 24 | - Tony Atkins 25 | - Fluid Project 26 | 27 | ## Contributors 28 | 29 | Individual contributions can be viewed on the 30 | [Contributors](https://github.com/fluid-lab/gamepad-navigator/graphs/contributors) page, or through the version control 31 | system's revision history. 32 | 33 | _**Note**: Individual authors may not hold copyright. See above "[Copyright Holders](#copyright-holders)" section for 34 | more information._ 35 | -------------------------------------------------------------------------------- /tests/js/tab/tab-mock-utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | (function (fluid) { 14 | "use strict"; 15 | 16 | fluid.registerNamespace("gamepad.tests.utils.buttons"); 17 | fluid.registerNamespace("gamepad.tests.utils.axes"); 18 | 19 | // Custom gamepad navigator component grade for tab navigation tests. 20 | fluid.defaults("gamepad.tests.tab.inputMapper", { 21 | gradeNames: ["gamepad.inputMapper.base"], 22 | windowObject: window, 23 | model: { 24 | bindings: { 25 | axes: { 26 | 2: { action: "thumbstickTabbing" } 27 | }, 28 | buttons: { 29 | 4: { action: "tabBackward" }, // Left Bumper. 30 | 5: { action: "tabForward" } // Right Bumper. 31 | } 32 | } 33 | } 34 | }); 35 | })(fluid); 36 | -------------------------------------------------------------------------------- /src/js/content_scripts/templateRenderer.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | (function (fluid) { 13 | "use strict"; 14 | var gamepad = gamepad || fluid.registerNamespace("gamepad"); 15 | 16 | fluid.defaults("gamepad.templateRenderer", { 17 | gradeNames: ["fluid.containerRenderingView"], 18 | markup: { 19 | container: "
" 20 | }, 21 | model: {}, 22 | invokers: { 23 | renderMarkup: { 24 | funcName: "gamepad.templateRenderer.render", 25 | args: ["{that}", "{that}.options.markup.container", "{that}.model"] 26 | } 27 | } 28 | }); 29 | 30 | gamepad.templateRenderer.render = function (that, markupTemplate, model) { 31 | var renderedContent = fluid.stringTemplate(markupTemplate, model); 32 | return renderedContent; 33 | }; 34 | })(fluid); 35 | -------------------------------------------------------------------------------- /tests/js/scroll/scroll-mock-utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | (function (fluid) { 14 | "use strict"; 15 | 16 | fluid.defaults("gamepad.tests.scroll.inputMapper", { 17 | gradeNames: ["gamepad.inputMapper.base"], 18 | windowObject: window, 19 | model: { 20 | bindings: { 21 | axes: { 22 | 0: { action: "scrollHorizontally", scrollFactor: 50}, 23 | 1: { action: "scrollVertically", scrollFactor: 50} 24 | }, 25 | buttons: { 26 | // D-Pad, up, down, left, right 27 | 12: { action: "scrollUp", scrollFactor: 50 }, 28 | 13: { action: "scrollDown", scrollFactor: 50 }, 29 | 14: { action: "scrollLeft", scrollFactor: 50 }, 30 | 15: { action: "scrollRight", scrollFactor: 50 } 31 | } 32 | } 33 | } 34 | }); 35 | })(fluid); 36 | -------------------------------------------------------------------------------- /src/js/settings/draftHandlingButton.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | (function (fluid) { 13 | "use strict"; 14 | fluid.defaults("gamepad.settings.draftHandlingButton", { 15 | gradeNames: ["gamepad.templateRenderer"], 16 | markup: { 17 | container: "" 18 | }, 19 | model: { 20 | label: "Draft Button", 21 | disabled: true 22 | }, 23 | modelRelay: { 24 | source: "{that}.model.disabled", 25 | target: "{that}.model.dom.container.attr.disabled" 26 | } 27 | }); 28 | 29 | fluid.defaults("gamepad.settings.draftHandlingButton.discard", { 30 | gradeNames: ["gamepad.settings.draftHandlingButton"], 31 | model: { 32 | label: "Discard Changes" 33 | } 34 | }); 35 | 36 | fluid.defaults("gamepad.settings.draftHandlingButton.save", { 37 | gradeNames: ["gamepad.settings.draftHandlingButton"], 38 | model: { 39 | label: "Save Changes" 40 | } 41 | }); 42 | })(fluid); 43 | -------------------------------------------------------------------------------- /tests/js/lib/test-utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | /* global gamepad */ 14 | 15 | (function (fluid) { 16 | "use strict"; 17 | 18 | fluid.registerNamespace("gamepad.tests.utils"); 19 | 20 | // TODO: This can probably be safely removed now. 21 | /** 22 | * 23 | * Returns a dummy gamepad input model which represents that no button/axes is 24 | * disturbed. 25 | * 26 | * @return {Object} - The gamepad input model (at rest). 27 | * 28 | */ 29 | gamepad.tests.utils.initializeModelAtRest = function () { 30 | var modelAtRest = { 31 | connected: true, 32 | axes: {}, 33 | buttons: {} 34 | }; 35 | 36 | // Initialize in accordance with the 18 buttons on the PS4 controller. 37 | for (var buttonNumber = 0; buttonNumber < 18; buttonNumber++) { 38 | modelAtRest.buttons[buttonNumber] = 0; 39 | } 40 | 41 | // Initialize in accordance with the 4 axes on the PS4 controller. 42 | for (var axesNumber = 0; axesNumber < 4; axesNumber++) { 43 | modelAtRest.axes[axesNumber] = 0; 44 | } 45 | 46 | return modelAtRest; 47 | }; 48 | })(fluid); 49 | -------------------------------------------------------------------------------- /docs/components/preferences.md: -------------------------------------------------------------------------------- 1 | # Preferences 2 | 3 | There are a few user-configurable "preferences" that are persisted to local storage and read on startup: 4 | 5 | | Key | Description | Allowed Values | Default | 6 | | --------------------- | ----------- | --------------- | ------- | 7 | | `analogCutoff` | An analog input such as a thumb stick or trigger must send a value above this number before the [input mapper](./inputMapper.base.md) will respond. | A number from `0` (all inputs allowed) to `1` (no input possible). | `0.25` | 8 | | `newTabOrWindowURL` | The URL to open when creating a new tab or window. | Any controllable URL (see below). | `https://www.google.com/` | 9 | | `openWindowOnStartup` | If no "safe" windows (see below) are open on startup, open one automatically. | `true` or `false` | `true` | 10 | | `pollingFrequency` | How often (in milliseconds) to check gamepad inputs. | A number value. | `50` | 11 | | `vibrate` | Whether to vibrate when an action cannot be completed (for example, if the user attempts to scroll down and they are already at the bottom of the page). | `true` or `false` | `true` | 12 | 13 | ## "Safe" Windows 14 | 15 | The code that allows the gamepad navigator to respond to inputs is not injected on browser internal pages such as your 16 | browser preferences. A new window that is created using a mouse or keyboard uses one of these "internal" pages as well. 17 | This is why we have two of the preferences outlined above. 18 | 19 | The `newTabOrWindowURL` preference gives us a "safe" URL to use when creating windows and tabs. The `openWindowOnStartup` 20 | setting is designed to ensure that there will always be a "safe" window available and focused on startup. 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020 The Gamepad Navigator Authors 4 | See the AUTHORS.md file at the top-level directory of this distribution and at 5 | https://github.com/fluid-lab/gamepad-navigator/blob/main/AUTHORS.md 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the 9 | following conditions are met: 10 | 11 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following 12 | disclaimer. 13 | 14 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following 15 | disclaimer in the documentation and/or other materials provided with the distribution. 16 | 17 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote 18 | products derived from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 21 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 25 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 26 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /src/css/modal.css: -------------------------------------------------------------------------------- 1 | .hidden { 2 | display: none; 3 | } 4 | 5 | .modal-outer-container { 6 | align-content: center; 7 | background-color: #000c; 8 | font-family: inherit; 9 | height: 100%; 10 | left: 0; 11 | position: fixed; 12 | top: 0; 13 | width: 100%; 14 | z-index: 99998; 15 | } 16 | 17 | .modal-inner-container { 18 | background-color: white; 19 | border-radius: 1rem; 20 | color: black; 21 | display: flex; 22 | flex-direction: column; 23 | height: 75%; 24 | left: 12.5%; 25 | position: fixed; 26 | top: 12.5%; 27 | width: 75%; 28 | z-index: 99997; 29 | } 30 | 31 | @media (max-height: 400px) { 32 | :host(.gamepad-navigator-modal-manager.modal-inner-container) { 33 | height: 95%; 34 | top: 2.5%; 35 | } 36 | } 37 | 38 | @media (max-width: 600px) { 39 | :host(.gamepad-navigator-modal-manager) .modal-inner-container { 40 | left: 2.5%; 41 | width: 95%; 42 | } 43 | } 44 | 45 | .modal-header { 46 | align-items: center; 47 | align-self: center; 48 | display: flex; 49 | flex-direction: row; 50 | } 51 | 52 | .modal-header h3 { 53 | font-size: 2rem; 54 | font-weight: bold; 55 | } 56 | 57 | .modal-header .modal-icon svg { 58 | height: 5rem; 59 | width: 5rem; 60 | } 61 | 62 | .modal-body { 63 | border: 1px solid #ccc; 64 | height: 100%; 65 | overflow-y: scroll; 66 | } 67 | 68 | .modal-footer { 69 | align-self: center; 70 | } 71 | 72 | .modal-footer button { 73 | border: 1px solid #999; 74 | border-radius: 0.5rem; 75 | font-size: 2rem; 76 | margin: 1rem; 77 | padding: 0.5rem; 78 | } 79 | 80 | modal-footer button:not([disabled]) { 81 | background-color: #ccc; 82 | color: black; 83 | } 84 | -------------------------------------------------------------------------------- /tests/html/list-selector.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | List Selector Tests 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /PRIVACY_POLICY.md: -------------------------------------------------------------------------------- 1 | 12 | 13 | # Privacy Policy 14 | 15 | The Gamepad Navigator allows the user to navigate Chromium-based browsers and web pages using a game controller. It 16 | provides various features that allow navigation using gamepad such as web page scrolling, tab navigation, click on 17 | buttons and hyperlinks, et cetera. Due to the nature of browser and web page navigation, the Gamepad Navigator requires 18 | permission to interact with the content in active web pages, browser windows, and tabs. 19 | 20 | ## Information the Gamepad Navigator collects 21 | 22 | The Gamepad Navigator makes use of user preferences entered using the extension configuration panel, which can be 23 | removed/deleted using panel buttons. To enable navigation features, the content of active websites is also parsed. 24 | 25 | ## How the Gamepad Navigator uses the information 26 | 27 | User preferences are used to determine which gamepad inputs activate browser actions. Page content is parsed to 28 | determine which focusable elements are available, so that the user can navigate between focusable elements as they would 29 | with the tab key on a keyboard. 30 | 31 | ## What information does the Gamepad Navigator share 32 | 33 | User preferences entered are stored locally on the user's computer and not shared with any site. Page content used for 34 | navigation is only retained temporarily in memory, and is not shared with any site. 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gamepad-navigator", 3 | "version": "1.0.0", 4 | "description": "A Chrome extension that allows you to navigate web pages and Chromium-based browsers using a game controller.", 5 | "contributors": [ 6 | { "name": "Divyanshu Mahajan" }, 7 | { 8 | "name": "Tony Atkins", 9 | "url": "https://duhrer.github.io/" 10 | } 11 | ], 12 | "license": "BSD-3-Clause", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/fluid-lab/gamepad-navigator.git" 16 | }, 17 | "bugs": { 18 | "url": "https://github.com/fluid-lab/gamepad-navigator/issues" 19 | }, 20 | "homepage": "https://github.com/fluid-lab/gamepad-navigator#readme", 21 | "scripts": { 22 | "lint": "fluid-lint-all", 23 | "build": "npm run build:grunt && npm run build:css && npm run build:svgs", 24 | "build:grunt": "grunt build", 25 | "build:css": "node ./utils/bundleCss.js", 26 | "build:svgs": "node ./utils/bundleSvgs.js", 27 | "postinstall": "npm run build", 28 | "test": "testem ci --file tests/testem.json" 29 | }, 30 | "dependencies": { 31 | "ally.js": "1.4.1", 32 | "fluid-osk": "0.1.0-dev.20231224T071757Z.a7d1871.GH-1", 33 | "infusion": "4.7.1" 34 | }, 35 | "devDependencies": { 36 | "dotenv": "16.4.5", 37 | "eslint": "9.14.0", 38 | "eslint-config-fluid": "2.1.3", 39 | "fluid-lint-all": "1.2.12", 40 | "grunt": "1.6.1", 41 | "grunt-banner": "0.6.0", 42 | "grunt-contrib-clean": "2.0.1", 43 | "grunt-contrib-compress": "2.0.0", 44 | "grunt-contrib-copy": "1.0.0", 45 | "grunt-json-prettify": "0.0.2", 46 | "grunt-prompt": "1.3.3", 47 | "node-fetch": "3.3.2", 48 | "npm-audit-resolver": "3.0.0-7", 49 | "testem": "3.15.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/images/options-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 10 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /utils/bundleCss.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | "use strict"; 3 | var fluid = require("infusion"); 4 | var fs = require("fs"); 5 | var path = require("path"); 6 | 7 | var gamepad = fluid.registerNamespace("gamepad"); 8 | 9 | fluid.defaults("gamepad.bundleCss", { 10 | gradeNames: ["fluid.component"], 11 | outputPath: "dist/js/content_scripts/styles.js", 12 | outputTemplate: "\"use strict\";\nvar gamepad = fluid.registerNamespace(\"gamepad\");\ngamepad.css = `%payload`;\n", 13 | pathsToBundle: { 14 | src: "src/css", 15 | osk: "node_modules/fluid-osk/src/css" 16 | }, 17 | excludes: [ 18 | "src/css/common.css", 19 | "src/css/configuration-panel.css" 20 | ], 21 | listeners: { 22 | "onCreate.processDirs": { 23 | funcName: "gamepad.bundleCss.processDirs", 24 | args: ["{that}"] 25 | } 26 | } 27 | }); 28 | 29 | gamepad.bundleCss.processDirs = function (that) { 30 | var payload = ""; 31 | 32 | var resolvedExcludes = that.options.excludes.map(function (relativePath) { 33 | return path.resolve(relativePath); 34 | }); 35 | 36 | fluid.each(that.options.pathsToBundle, function (pathToBundle) { 37 | var filenames = fs.readdirSync(pathToBundle); 38 | fluid.each(filenames, function (filename) { 39 | if (filename.toLowerCase().endsWith(".css")) { 40 | var filePath = path.resolve(pathToBundle, filename); 41 | if (!resolvedExcludes.includes(filePath)) { 42 | var fileContents = fs.readFileSync(filePath, { encoding: "utf8"}); 43 | payload += fileContents; 44 | } 45 | } 46 | }); 47 | }); 48 | 49 | var bundle = fluid.stringTemplate(that.options.outputTemplate, { payload: payload}); 50 | 51 | fs.writeFileSync(that.options.outputPath, bundle, { encoding: "utf8"}); 52 | 53 | fluid.log(fluid.logLevel.WARN, "bundled all CSS to '" + that.options.outputPath + "'"); 54 | }; 55 | 56 | gamepad.bundleCss(); 57 | -------------------------------------------------------------------------------- /tests/html/polling-tests.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Gamepad Navigator Polling Tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |

Gamepad Navigator Polling Tests

29 |

30 |
31 |

32 |
    33 | 34 | 35 | -------------------------------------------------------------------------------- /src/css/onscreen-keyboard.css: -------------------------------------------------------------------------------- 1 | .osk-keyboard { 2 | margin: auto; 3 | padding-bottom: clamp(0.125rem, 5vh, 2rem); 4 | padding-top: clamp(0.125rem, 5vh, 2rem); 5 | width: 90%; 6 | } 7 | 8 | .osk-text-input { 9 | margin: auto; 10 | margin-top: clamp(0.5rem, 2vh, 2rem); 11 | width: 90%; 12 | } 13 | 14 | button.osk-key { 15 | color: black; 16 | } 17 | 18 | .osk-key { 19 | background-color: #ccc; 20 | margin: clamp(0.1rem, 0.3vw, 0.75rem); 21 | } 22 | 23 | .osk-key:focus { 24 | border: 3px solid #339; 25 | } 26 | 27 | .onscreen-keyboard-modal .modal-inner-container { 28 | height: clamp(20rem, 80vh, 50rem); 29 | top: clamp(5rem, 10vh, 10rem); 30 | } 31 | 32 | .onscreen-keyboard-modal .osk-key-Space, 33 | .gamepad-navigator-searchKeyboard .osk-key-Space { 34 | margin: clamp(0.125rem, 0.5vw, 1rem); 35 | width: 88%; 36 | } 37 | 38 | .onscreen-keyboard-modal .osk-key-CapsLock, 39 | .onscreen-keyboard-modal .osk-key-Backspace, 40 | .onscreen-keyboard-modal .osk-key-Enter, 41 | .onscreen-keyboard-modal .osk-key-ShiftLeft, 42 | .onscreen-keyboard-modal .osk-key-ShiftRight, 43 | .gamepad-navigator-searchKeyboard .osk-key-CapsLock, 44 | .gamepad-navigator-searchKeyboard .osk-key-Backspace, 45 | .gamepad-navigator-searchKeyboard .osk-key-Enter, 46 | .gamepad-navigator-searchKeyboard .osk-key-ShiftLeft, 47 | .gamepad-navigator-searchKeyboard .osk-key-ShiftRight { 48 | width: clamp(1.1rem, 7vw, 6.75rem); 49 | } 50 | 51 | .onscreen-keyboard-modal .osk-key-ArrowLeft, 52 | .gamepad-navigator-searchKeyboard .osk-key-ArrowLeft { 53 | margin: clamp(0.125rem, 0.5vw, 1rem); 54 | } 55 | 56 | .gamepad-navigator-numpad .osk-row { 57 | justify-content: center; 58 | } 59 | 60 | .gamepad-navigator-numpad .osk-key-ArrowLeft { 61 | margin-left: inherit; 62 | } 63 | 64 | .gamepad-navigator-numpad .osk-key { 65 | height: clamp(0.75rem, 4.5vw, 4.5rem); 66 | margin: clamp(0.15rem, 0.45vw, 1rem); 67 | padding: clamp(0.2rem, 0.75vw, 0.75rem); 68 | width: clamp(0.75rem, 4.5vw, 4.5rem); 69 | } 70 | 71 | .gamepad-navigator-numpad .osk-text-input { 72 | width: clamp(5rem, 25vw, 25rem); 73 | } 74 | -------------------------------------------------------------------------------- /tests/html/arrow-tests.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Gamepad Navigator Arrow Tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |

    Gamepad Navigator Arrow Tests

    28 |

    29 |
    30 |

    31 |
      32 | 33 | 34 |
      35 |
      36 |
      37 | 38 | 39 | -------------------------------------------------------------------------------- /tests/js/lib/mocked-window.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | /* global gamepad */ 14 | 15 | (function (fluid) { 16 | "use strict"; 17 | fluid.defaults("gamepad.test.window", { 18 | gradeNames: ["fluid.modelComponent"], 19 | events: { 20 | gamepadconnected: null, 21 | gamepaddisconnected: null 22 | }, 23 | members: { 24 | // Required for our ally integration. 25 | MutationObserver: window.MutationObserver, 26 | navigator: { 27 | getGamepads: "{that}.getGamepads" 28 | } 29 | }, 30 | model: { 31 | gamepads: [{}] 32 | }, 33 | invokers: { 34 | addEventListener: { 35 | funcName: "gamepad.test.window.getGamepads.addEventListener", 36 | args: ["{that}", "{arguments}.0", "{arguments}.1"] // eventKey, eventFn 37 | }, 38 | getGamepads: { 39 | funcName: "gamepad.test.window.getGamepads", 40 | args: ["{that}"] 41 | } 42 | } 43 | }); 44 | 45 | gamepad.test.window.getGamepads = function (that) { 46 | var allGamepadMocks = []; 47 | fluid.each(that.model.gamepads, function (gamepadDef) { 48 | var singleGamepad = gamepad.tests.utils.generateGamepadMock(gamepadDef); 49 | allGamepadMocks.push(singleGamepad[0]); 50 | }); 51 | return allGamepadMocks; 52 | }; 53 | 54 | gamepad.test.window.getGamepads.addEventListener = function (that, eventKey, eventFn) { 55 | if (that.events[eventKey]) { 56 | that.events[eventKey].addListener(eventFn); 57 | } 58 | }; 59 | })(fluid); 60 | -------------------------------------------------------------------------------- /src/js/content_scripts/shadow-holder.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | (function (fluid) { 14 | "use strict"; 15 | 16 | var gamepad = fluid.registerNamespace("gamepad"); 17 | 18 | 19 | fluid.defaults("gamepad.shadowHolder", { 20 | gradeNames: ["gamepad.templateRenderer"], 21 | markup: { 22 | container: "
      ", 23 | styles: "" 24 | }, 25 | model: { 26 | shadowElement: false, 27 | 28 | // Inline all styles from JS-wrapped global namespaced variable. 29 | styles: gamepad.css 30 | }, 31 | events: { 32 | onShadowReady: null 33 | }, 34 | invokers: { 35 | "createShadow": { 36 | funcName: "gamepad.shadowHolder.createShadow", 37 | args: ["{that}"] 38 | } 39 | }, 40 | listeners: { 41 | "onCreate.createShadow": { 42 | func: "{that}.createShadow", 43 | args: [] 44 | } 45 | } 46 | }); 47 | 48 | gamepad.shadowHolder.createShadow = function (that) { 49 | var host = that.container[0]; 50 | var shadowElement = host.attachShadow({mode: "open"}); 51 | 52 | // We inline all styles here so that all modals get the common styles, 53 | // and to avoid managing multiple shadow elements. 54 | var safeModel = fluid.filterKeys(that.model, ["shadowElement", "lastExternalFocused", "selectElement"], true); 55 | shadowElement.innerHTML = fluid.stringTemplate(that.options.markup.styles, safeModel); 56 | 57 | that.applier.change("shadowElement", shadowElement); 58 | that.events.onShadowReady.fire(); 59 | }; 60 | })(fluid); 61 | -------------------------------------------------------------------------------- /utils/bundleSvgs.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | "use strict"; 3 | var fluid = require("infusion"); 4 | var fs = require("fs"); 5 | var path = require("path"); 6 | 7 | var gamepad = fluid.registerNamespace("gamepad"); 8 | 9 | fluid.defaults("gamepad.bundleSvgs", { 10 | gradeNames: ["fluid.component"], 11 | outputPath: "dist/js/content_scripts/svgs.js", 12 | outputTemplate: "\"use strict\";\nvar gamepad = fluid.registerNamespace(\"gamepad\");\nfluid.registerNamespace(\"gamepad.svg\");\n%payload\n", 13 | singleFileTemplate: "\ngamepad.svg[\"%filename\"] = `%svgText`;\n", 14 | pathsToBundle: { 15 | src: "src/images" 16 | }, 17 | excludes: [], 18 | listeners: { 19 | "onCreate.processDirs": { 20 | funcName: "gamepad.bundleSvgs.processDirs", 21 | args: ["{that}"] 22 | } 23 | } 24 | }); 25 | 26 | gamepad.bundleSvgs.processDirs = function (that) { 27 | var payload = ""; 28 | 29 | var resolvedExcludes = that.options.excludes.map(function (relativePath) { 30 | return path.resolve(relativePath); 31 | }); 32 | 33 | fluid.each(that.options.pathsToBundle, function (pathToBundle) { 34 | var filenames = fs.readdirSync(pathToBundle); 35 | fluid.each(filenames, function (filename) { 36 | if (filename.toLowerCase().endsWith(".svg")) { 37 | var filenameMinusExtension = path.basename(filename, ".svg"); 38 | var filePath = path.resolve(pathToBundle, filename); 39 | if (!resolvedExcludes.includes(filePath)) { 40 | var fileContents = fs.readFileSync(filePath, { encoding: "utf8"}); 41 | var filePayload = fluid.stringTemplate(that.options.singleFileTemplate, { filename: filenameMinusExtension, svgText: fileContents}); 42 | payload += filePayload; 43 | } 44 | } 45 | }); 46 | }); 47 | 48 | var bundle = fluid.stringTemplate(that.options.outputTemplate, { payload: payload}); 49 | 50 | fs.writeFileSync(that.options.outputPath, bundle, { encoding: "utf8"}); 51 | 52 | fluid.log(fluid.logLevel.WARN, "bundled all SVG files to '" + that.options.outputPath + "'"); 53 | }; 54 | 55 | gamepad.bundleSvgs(); 56 | -------------------------------------------------------------------------------- /src/js/settings/textInput.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | (function (fluid) { 13 | "use strict"; 14 | 15 | var gamepad = fluid.registerNamespace("gamepad"); 16 | 17 | fluid.defaults("gamepad.ui.textInput", { 18 | gradeNames: ["gamepad.templateRenderer"], 19 | model: { 20 | value: 0 21 | }, 22 | modelRelay: [{ 23 | source: "{that}.model.value", 24 | target: "dom.container.value" 25 | }], 26 | markup: { 27 | container: "" 28 | }, 29 | invokers: { 30 | handleInputChange: { 31 | funcName: "gamepad.ui.textInput.handleInputChange", 32 | args: ["{that}", "{arguments}.0"] 33 | } 34 | }, 35 | listeners: { 36 | "onCreate.bindChange": { 37 | this: "{that}.container", 38 | method: "change", 39 | args: ["{that}.handleInputChange"] 40 | } 41 | } 42 | }); 43 | 44 | gamepad.ui.textInput.handleInputChange = function (that, event) { 45 | that.applier.change("value", event.target.value); 46 | }; 47 | 48 | fluid.defaults("gamepad.ui.prefs.textInput", { 49 | gradeNames: ["gamepad.templateRenderer"], 50 | model: { 51 | label: "Text Input", 52 | description: "Enter some text!", 53 | value: 0 54 | }, 55 | markup: { 56 | container: "
      %label
      %description
      " 57 | }, 58 | selectors: { 59 | input: ".gamepad-text-input-container" 60 | }, 61 | components: { 62 | input: { 63 | container: "{that}.dom.input", 64 | type: "gamepad.ui.textInput", 65 | options: { 66 | model: { 67 | value: "{gamepad.ui.prefs.textInput}.model.value" 68 | } 69 | } 70 | } 71 | } 72 | }); 73 | })(fluid); 74 | -------------------------------------------------------------------------------- /src/images/gamepad-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 29 | -------------------------------------------------------------------------------- /src/js/settings/toggle.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | (function (fluid) { 13 | "use strict"; 14 | // var gamepad = fluid.registerNamespace("gamepad"); 15 | 16 | fluid.defaults("gamepad.ui.toggle", { 17 | gradeNames: ["gamepad.templateRenderer"], 18 | styles: { 19 | checked: "checked" 20 | }, 21 | model: { 22 | checked: true 23 | }, 24 | markup: { 25 | container: "
      " 26 | }, 27 | modelRelay: [ 28 | { 29 | source: "{that}.model.dom.container.click", 30 | target: "{that}.model.checked", 31 | singleTransform: "fluid.transforms.toggle" 32 | }, 33 | { 34 | source: "{that}.model.checked", 35 | target: { 36 | segs: ["dom", "container", "class", "{that}.options.styles.checked"] 37 | } 38 | } 39 | ] 40 | }); 41 | 42 | fluid.defaults("gamepad.ui.prefs.toggle", { 43 | gradeNames: ["gamepad.templateRenderer"], 44 | model: { 45 | label: "Toggle", 46 | checked: true 47 | }, 48 | markup: { 49 | container: "
      %label
      %description
      " 50 | }, 51 | selectors: { 52 | toggle: ".gamepad-toggle-container" 53 | }, 54 | components: { 55 | toggle: { 56 | container: "{that}.dom.toggle", 57 | type: "gamepad.ui.toggle", 58 | options: { 59 | model: { 60 | checked: "{gamepad.ui.prefs.toggle}.model.checked" 61 | } 62 | } 63 | } 64 | } 65 | }); 66 | 67 | fluid.defaults("gamepad.ui.bindingParam.toggle", { 68 | gradeNames: ["gamepad.ui.prefs.toggle"], 69 | markup: { 70 | container: "
      %label
      %description
      " 71 | } 72 | }); 73 | })(fluid); 74 | -------------------------------------------------------------------------------- /tests/html/click-tests.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Gamepad Navigator Click Tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |

      Gamepad Navigator Click Tests

      27 |

      28 |
      29 |

      30 |
        31 | 32 | 33 |
        34 |
        35 | 36 |

        37 |
        38 |
        39 | 40 |

        41 |
        42 | 43 |
        44 | 45 |
        46 | 47 | 48 |

        49 |
        50 | 51 | 52 | -------------------------------------------------------------------------------- /src/js/content_scripts/search-keyboard.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | (function (fluid) { 13 | "use strict"; 14 | var gamepad = fluid.registerNamespace("gamepad"); 15 | 16 | fluid.defaults("gamepad.searchKeyboard.searchButton", { 17 | gradeNames: ["gamepad.templateRenderer"], 18 | markup: { 19 | container: "" 20 | } 21 | }); 22 | 23 | fluid.defaults("gamepad.searchKeyboard.modal", { 24 | gradeNames: ["gamepad.osk.modal.base"], 25 | model: { 26 | label: "Gamepad Navigator: Search", 27 | classNames: " gamepad-navigator-searchKeyboard" 28 | }, 29 | 30 | invokers: { 31 | handleSearchButtonClick: { 32 | funcName: "gamepad.searchKeyboard.modal.handleSearchButtonClick", 33 | args: ["{that}", "{inputMapper}", "{arguments}.0"] 34 | } 35 | }, 36 | 37 | components: { 38 | input: { 39 | options: { 40 | model: { 41 | composition: "{gamepad.searchKeyboard.modal}.model.inputValue" 42 | } 43 | } 44 | }, 45 | searchButton: { 46 | container: "{that}.dom.modalFooter", 47 | type: "gamepad.searchKeyboard.searchButton", 48 | options: { 49 | listeners: { 50 | "onCreate.bindClick": { 51 | this: "{searchButton}.container", 52 | method: "click", 53 | args: ["{gamepad.searchKeyboard.modal}.handleSearchButtonClick"] 54 | } 55 | } 56 | } 57 | } 58 | } 59 | }); 60 | 61 | gamepad.searchKeyboard.modal.handleSearchButtonClick = function (that, inputMapper, event) { 62 | that.applier.change("activeModal", false); 63 | event.preventDefault(); 64 | 65 | if (that.model.inputValue && that.model.inputValue.trim().length) { 66 | var actionOptions = { 67 | action: "search", 68 | // See: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/search/query 69 | disposition: "NEW_TAB", 70 | text: that.model.inputValue.trim() 71 | }; 72 | 73 | gamepad.inputMapperUtils.background.postMessage(inputMapper, actionOptions); 74 | } 75 | }; 76 | })(fluid); 77 | -------------------------------------------------------------------------------- /src/js/shared/settings.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | /* globals chrome */ 13 | (function (fluid) { 14 | "use strict"; 15 | var gamepad = fluid.registerNamespace("gamepad"); 16 | 17 | fluid.registerNamespace("gamepad.settings"); 18 | 19 | gamepad.settings.loadSettings = async function (that) { 20 | gamepad.settings.loadPrefs(that); 21 | 22 | gamepad.settings.loadBindings(that); 23 | 24 | // In the similar function in input mapper, we add a listener for changes to values in local storage. As we 25 | // have code to ensure that there is only open settings panel, and since only the settings panel can update 26 | // stored values, we should safely be able to avoid listening for local storage changes here. 27 | }; 28 | 29 | gamepad.settings.loadPrefs = async function (that) { 30 | var storedPrefs = await gamepad.utils.getStoredKey("gamepad-prefs"); 31 | var prefsToSave = fluid.extend({}, gamepad.prefs.defaults, storedPrefs); 32 | 33 | var transaction = that.applier.initiate(); 34 | 35 | transaction.fireChangeRequest({ path: "prefs", type: "DELETE"}); 36 | transaction.fireChangeRequest({ path: "prefs", value: prefsToSave }); 37 | 38 | transaction.commit(); 39 | }; 40 | 41 | gamepad.settings.loadBindings = async function (that) { 42 | var storedBindings = await gamepad.utils.getStoredKey("gamepad-bindings"); 43 | var bindingsToSave = storedBindings || gamepad.bindings.defaults; 44 | 45 | var transaction = that.applier.initiate(); 46 | 47 | transaction.fireChangeRequest({ path: "bindings", type: "DELETE"}); 48 | transaction.fireChangeRequest({ path: "bindings", value: bindingsToSave }); 49 | 50 | transaction.commit(); 51 | }; 52 | 53 | gamepad.settings.savePrefs = function (prefs) { 54 | var prefsEqualDefaults = gamepad.utils.isDeeplyEqual(gamepad.prefs.defaults, prefs); 55 | if (prefsEqualDefaults) { 56 | chrome.storage.local.remove("gamepad-prefs"); 57 | } 58 | else { 59 | chrome.storage.local.set({ "gamepad-prefs": prefs }); 60 | } 61 | }; 62 | 63 | gamepad.settings.saveBindings = async function (bindings) { 64 | var bindingsEqualDefaults = gamepad.utils.isDeeplyEqual(gamepad.bindings.defaults, bindings); 65 | if (bindingsEqualDefaults) { 66 | chrome.storage.local.remove("gamepad-bindings"); 67 | } 68 | else { 69 | chrome.storage.local.set({ "gamepad-bindings": bindings }); 70 | } 71 | }; 72 | })(fluid); 73 | -------------------------------------------------------------------------------- /src/js/settings/editableSection.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | (function (fluid) { 13 | "use strict"; 14 | 15 | fluid.defaults("gamepad.settings.ui.editableSection", { 16 | gradeNames: ["gamepad.templateRenderer"], 17 | markup: { 18 | container: "

        %label

        " 19 | }, 20 | selectors: { 21 | body: ".gamepad-settings-editable-section-body", 22 | footer: ".gamepad-settings-editable-section-footer" 23 | }, 24 | model: { 25 | label: "Editable Section", 26 | classNames: "", 27 | draftClean: true 28 | }, 29 | components: { 30 | discardButton: { 31 | container: "{that}.dom.footer", 32 | type: "gamepad.settings.draftHandlingButton.discard", 33 | options: { 34 | model: { 35 | disabled: "{gamepad.settings.ui.editableSection}.model.draftClean" 36 | }, 37 | listeners: { 38 | "onCreate.bindClick": { 39 | this: "{gamepad.settings.draftHandlingButton}.container", 40 | method: "click", 41 | args: ["{gamepad.settings.ui.editableSection}.resetDraft"] 42 | } 43 | } 44 | } 45 | }, 46 | saveButton: { 47 | container: "{that}.dom.footer", 48 | type: "gamepad.settings.draftHandlingButton.save", 49 | options: { 50 | model: { 51 | disabled: "{gamepad.settings.ui.editableSection}.model.draftClean" 52 | }, 53 | listeners: { 54 | "onCreate.bindClick": { 55 | this: "{gamepad.settings.draftHandlingButton}.container", 56 | method: "click", 57 | args: ["{gamepad.settings.ui.editableSection}.saveDraft"] 58 | } 59 | } 60 | } 61 | } 62 | }, 63 | invokers: { 64 | saveDraft: { 65 | funcName: "fluid.notImplemented" 66 | }, 67 | resetDraft: { 68 | funcName: "fluid.notImplemented" 69 | } 70 | } 71 | }); 72 | })(fluid); 73 | -------------------------------------------------------------------------------- /tests/html/tab-tests.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Gamepad Navigator Tabbing Tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |

        Gamepad Navigator Tab Navigation Tests

        30 |

        31 |
        32 |

        33 |
          34 | 35 |
          36 | 37 |

          38 | 39 |

          40 | Without tabindex

          41 | 42 |

          46 |
          Five (tabindex=5)

          47 |

          48 | 49 |

          50 |
          Last (div with tabindex=0)

          51 | 52 |
          53 | 54 | 55 | -------------------------------------------------------------------------------- /src/js/shared/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | /* 14 | Copyright (c) 2023 The Gamepad Navigator Authors 15 | See the AUTHORS.md file at the top-level directory of this distribution and at 16 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 17 | 18 | Licensed under the BSD 3-Clause License. You may not use this file except in 19 | compliance with this License. 20 | 21 | You may obtain a copy of the BSD 3-Clause License at 22 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 23 | */ 24 | /* globals chrome */ 25 | (function (fluid) { 26 | "use strict"; 27 | 28 | var gamepad = fluid.registerNamespace("gamepad"); 29 | fluid.registerNamespace("gamepad.utils"); 30 | 31 | gamepad.utils.isDeeplyEqual = function (firstThing, secondThing) { 32 | if (typeof firstThing !== typeof secondThing) { 33 | return false; 34 | } 35 | else if (Array.isArray(firstThing)) { 36 | if (firstThing.length === secondThing.length) { 37 | for (var arrayIndex = 0; arrayIndex < firstThing.length; arrayIndex++) { 38 | var arrayItemsEqual = gamepad.utils.isDeeplyEqual(firstThing[arrayIndex], secondThing[arrayIndex]); 39 | if (!arrayItemsEqual) { return false; } 40 | } 41 | return true; 42 | } 43 | else { 44 | return false; 45 | } 46 | } 47 | else if (typeof firstThing === "object") { 48 | var firstThingKeys = Object.keys(firstThing); 49 | var secondThingKeys = Object.keys(secondThing); 50 | 51 | if (firstThingKeys.length !== secondThingKeys.length) { 52 | return false; 53 | } 54 | 55 | for (var keyIndex = 0; keyIndex < firstThingKeys.length; keyIndex++) { 56 | var key = firstThingKeys[keyIndex]; 57 | var objectPropertiesEqual = gamepad.utils.isDeeplyEqual(firstThing[key], secondThing[key]); 58 | if (!objectPropertiesEqual) { return false; } 59 | } 60 | 61 | return true; 62 | } 63 | else { 64 | return firstThing === secondThing; 65 | } 66 | }; 67 | 68 | gamepad.utils.getStoredKey = function (key) { 69 | var storagePromise = new Promise(function (resolve) { 70 | // Apparently, we have to use a callback and can't use `await` unless we make our own wrapper. 71 | chrome.storage.local.get([key], function (storedObject) { 72 | if (storedObject[key]) { 73 | resolve(storedObject[key]); 74 | } 75 | else { 76 | resolve(false); 77 | } 78 | }); 79 | }); 80 | return storagePromise; 81 | }; 82 | 83 | })(fluid); 84 | -------------------------------------------------------------------------------- /tests/js/tab/tab-order-tests.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | /* global gamepad, jqUnit, ally */ 14 | 15 | (function (fluid, $) { 16 | "use strict"; 17 | 18 | $(document).ready(function () { 19 | 20 | fluid.registerNamespace("gamepad.tests"); 21 | 22 | jqUnit.module("Gamepad Navigator Tab Navigation DOM Tests", { 23 | setup: function () { 24 | var testElementContainer = $(".test-elements"); 25 | /** 26 | * Get the list of tabbable elements in the order of their tabindex for 27 | * verification. 28 | */ 29 | gamepad.tests.tabbableElements = ally.query.tabsequence({ context: testElementContainer, strategy: "strict" }); 30 | } 31 | }); 32 | 33 | jqUnit.test("Verify the first element in the tabbable elements list.", function () { 34 | jqUnit.expect(1); 35 | 36 | // Get the first element from the tabbable elements list. 37 | var firstTabbableElement = gamepad.tests.tabbableElements[0], 38 | hasTabindexOne = firstTabbableElement.getAttribute("tabindex") === "1"; 39 | 40 | jqUnit.assertTrue("The first element should have tabindex=\"1\"", hasTabindexOne); 41 | }); 42 | 43 | jqUnit.test("Verify the last element in the tabbable elements list.", function () { 44 | jqUnit.expect(1); 45 | 46 | // Get the last element from the tabbable elements list. 47 | var lastTabbableElement = gamepad.tests.tabbableElements[gamepad.tests.tabbableElements.length - 1], 48 | isLastElement = lastTabbableElement.getAttribute("id") === "last"; 49 | 50 | jqUnit.assertTrue("The first element should be the last div with id=\"last\"", isLastElement); 51 | }); 52 | 53 | jqUnit.test("Verify presence and absence of elements according to their tabindex value.", function () { 54 | jqUnit.expect(2); 55 | 56 | // Check if the tabbable elements list contains elements with tabindex="-1". 57 | var elementWithNegativeTabindex = $("button[tabindex=\"-1\"]"), 58 | hasElementWithNegativeTabindex = gamepad.tests.tabbableElements.includes(elementWithNegativeTabindex[0]); 59 | jqUnit.assertFalse("The sorted elements array should not contain elements with tabindex=\"-1\"", hasElementWithNegativeTabindex); 60 | 61 | // Check if the tabbable elements list contains elements with tabindex="0". 62 | var elementWithTabindexZero = $("div[tabindex=\"0\"]"), 63 | hasElementWithTabindexZero = gamepad.tests.tabbableElements.includes(elementWithTabindexZero[0]); 64 | jqUnit.assertTrue("The sorted elements array should contain elements with tabindex=\"0\"", hasElementWithTabindexZero); 65 | }); 66 | }); 67 | })(fluid, jQuery); 68 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Gamepad Navigator", 3 | "version": "1.0.0", 4 | "description": "A Chrome extension that allows you to navigate web pages and Chromium-based browsers using a game controller.", 5 | "author": "The Gamepad Navigator Authors", 6 | "manifest_version": 3, 7 | "permissions": [ 8 | "storage", 9 | "tabs", 10 | "sessions", 11 | "search" 12 | ], 13 | "content_scripts": [ 14 | { 15 | "matches": [ 16 | "" 17 | ], 18 | "css": [ 19 | "css/focus-fix.css", 20 | "css/iframe-wrapper.css" 21 | ], 22 | "js": [ 23 | "js/lib/infusion/infusion-all.js", 24 | "js/lib/ally/ally.min.js", 25 | "js/lib/fluid-osk/templateRenderer.js", 26 | "js/lib/fluid-osk/keydefs.js", 27 | "js/lib/fluid-osk/key.js", 28 | "js/lib/fluid-osk/row.js", 29 | "js/lib/fluid-osk/keyboard.js", 30 | "js/lib/fluid-osk/keyboards.js", 31 | "js/lib/fluid-osk/inputs.js", 32 | "js/shared/prefs.js", 33 | "js/shared/utils.js", 34 | "js/shared/settings.js", 35 | "js/content_scripts/gamepad-navigator.js", 36 | "js/content_scripts/actions.js", 37 | "js/content_scripts/bindings.js", 38 | "js/content_scripts/styles.js", 39 | "js/content_scripts/svgs.js", 40 | "js/content_scripts/templateRenderer.js", 41 | "js/content_scripts/modal.js", 42 | "js/content_scripts/list-selector.js", 43 | "js/content_scripts/action-launcher.js", 44 | "js/content_scripts/keyboards.js", 45 | "js/content_scripts/onscreen-keyboard.js", 46 | "js/content_scripts/search-keyboard.js", 47 | "js/content_scripts/onscreen-numpad.js", 48 | "js/content_scripts/select-operator.js", 49 | "js/content_scripts/shadow-holder.js", 50 | "js/content_scripts/modalManager.js", 51 | "js/content_scripts/focus-overlay.js", 52 | "js/content_scripts/input-mapper-content-utils.js", 53 | "js/content_scripts/input-mapper-background-utils.js", 54 | "js/content_scripts/input-mapper-base.js", 55 | "js/content_scripts/input-mapper.js" 56 | ] 57 | } 58 | ], 59 | "background": { 60 | "service_worker": "js/background.js" 61 | }, 62 | "icons": { 63 | "16": "images/gamepad-icon-16px.png", 64 | "32": "images/gamepad-icon-32px.png", 65 | "48": "images/gamepad-icon-48px.png", 66 | "128": "images/gamepad-icon-128px.png" 67 | }, 68 | "action": { 69 | "default_icon": { 70 | "16": "images/gamepad-icon-16px.png", 71 | "32": "images/gamepad-icon-32px.png", 72 | "48": "images/gamepad-icon-48px.png", 73 | "128": "images/gamepad-icon-128px.png" 74 | }, 75 | "default_title": "Open Gamepad Navigator settings." 76 | }, 77 | "options_ui": { 78 | "open_in_tab": true, 79 | "page": "html/settings.html" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/js/content_scripts/action-launcher.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | /* global gamepad */ 14 | 15 | // TODO: Make this use the list selector. 16 | 17 | (function (fluid) { 18 | "use strict"; 19 | 20 | fluid.defaults("gamepad.actionLauncher", { 21 | gradeNames: ["gamepad.modal"], 22 | model: { 23 | classNames: " actionLauncher-modal", 24 | closeButtonLabel: "Cancel", 25 | label: "Gamepad Navigator: Launch Action", 26 | actions: gamepad.actions.launchable 27 | }, 28 | components: { 29 | actionsPanel: { 30 | container: "{that}.dom.modalBody", 31 | type: "gamepad.ui.listSelector", 32 | options: { 33 | model: { 34 | description: "Select an action to launch from the list below, or hit "Cancel" to close this modal.", 35 | items: "{gamepad.actionLauncher}.model.actions", 36 | prefs: "{gamepad.actionLauncher}.model.prefs" 37 | }, 38 | invokers: { 39 | handleItemClick: { 40 | funcName: "gamepad.actionLauncher.handleItemClick", 41 | args: ["{gamepad.modal}", "{gamepad.inputMapper}", "{arguments}.0", "{arguments}.1"] // modalComponent, inputMapperComponent, itemIndex, event 42 | } 43 | } 44 | } 45 | } 46 | } 47 | }); 48 | 49 | gamepad.actionLauncher.handleItemClick = function (modalComponent, inputMapperComponent, itemIndex, event) { 50 | event.preventDefault(); 51 | 52 | // Close modal and restore previous focus. 53 | modalComponent.closeModal(event); 54 | 55 | var actionDef = fluid.get(modalComponent, ["model", "actions", itemIndex]); 56 | 57 | var actionFn = fluid.get(inputMapperComponent, actionDef.action); 58 | if (actionFn) { 59 | // Simulate a button press so that actions that care about the input value (like scroll) will work. 60 | // We use one that is not ordinarily possible to trigger to avoid any possible conflict with bindings. 61 | inputMapperComponent.applier.change(["buttons", "999"], 1); 62 | 63 | // Call the action with our simulated button. All actions are called with: actionOptions, inputType, index 64 | actionFn( 65 | actionDef, 66 | "buttons", 67 | "999" 68 | ); 69 | 70 | // Release our fake button press after a delay. 71 | setTimeout(function () { 72 | var transaction = inputMapperComponent.applier.initiate(); 73 | transaction.fireChangeRequest({ path: ["buttons", "999"], type: "DELETE"}); 74 | transaction.commit(); 75 | }, 100); 76 | } 77 | }; 78 | })(fluid); 79 | -------------------------------------------------------------------------------- /tests/js/click/click-basic-tests.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | /* global gamepad, jqUnit */ 14 | 15 | (function (fluid, $) { 16 | "use strict"; 17 | 18 | /** 19 | * TODO: Add tests for links and other elements that involve navigation 20 | * between pages. 21 | */ 22 | 23 | $(document).ready(function () { 24 | 25 | fluid.registerNamespace("gamepad"); 26 | fluid.registerNamespace("gamepad.tests"); 27 | 28 | jqUnit.module("Gamepad Navigator Basic Click Tests"); 29 | 30 | jqUnit.test("Buttons can be clicked.", function () { 31 | jqUnit.expect(1); 32 | 33 | // Set the initial conditions and requirements. 34 | document.querySelector("button").addEventListener("click", function () { 35 | var button = document.querySelector("button"), 36 | timesClicked = button.getAttribute("timesClicked") || "0"; 37 | button.setAttribute("timesClicked", parseInt(timesClicked) + 1); 38 | }); 39 | 40 | // Initialize the webpage, i.e., focus on the button element. 41 | $("button").focus(); 42 | 43 | var inputMapper = gamepad.tests.click.inputMapper(); 44 | 45 | // Update the gamepad to click on the button element. 46 | inputMapper.applier.change("buttons.0", 1); 47 | 48 | // Check if the button has been clicked. 49 | jqUnit.assertEquals("The button should have been clicked.", "1", document.querySelector("button").getAttribute("timesClicked")); 50 | }); 51 | 52 | jqUnit.test("Radio buttons can be selected by click.", function () { 53 | jqUnit.expect(1); 54 | 55 | // Initialize the webpage, i.e., focus on the radio button. 56 | $("#radio-one").focus(); 57 | 58 | var inputMapper = gamepad.tests.click.inputMapper(); 59 | 60 | // Update the gamepad to click on the radio button. 61 | inputMapper.applier.change("buttons.0", 1); 62 | 63 | // Check if the radio button has been clicked. 64 | jqUnit.assertTrue("The radio button should have been selected after clicking.", document.querySelector("#radio-one").checked); 65 | }); 66 | 67 | jqUnit.test("Checkboxes can be selected by click.", function () { 68 | jqUnit.expect(1); 69 | 70 | // Initialize the webpage, i.e., focus on the checkbox element. 71 | $("#checkbox-one").focus(); 72 | 73 | var inputMapper = gamepad.tests.click.inputMapper(); 74 | 75 | // Update the gamepad to click on the checkbox element. 76 | inputMapper.applier.change("buttons.0", 1); 77 | 78 | // Verify if the checkbox element has been clicked. 79 | jqUnit.assertTrue("The radio button should have been selected after clicking.", document.querySelector("#checkbox-one").checked); 80 | }); 81 | }); 82 | })(fluid, jQuery); 83 | -------------------------------------------------------------------------------- /src/js/content_scripts/bindings.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | (function (fluid) { 13 | "use strict"; 14 | var gamepad = fluid.registerNamespace("gamepad"); 15 | fluid.registerNamespace("gamepad.bindings"); 16 | 17 | /* 18 | 19 | "Action options", which represent the action to be performed in bindings and in internal calls between functions. 20 | 21 | @typedef {Object} ActionOptions 22 | @property {String} action - The name of the action. 23 | @property {Boolean} [invert] - Whether to invert the direction of motion (for actions that have a direction, like scrolling). 24 | @property {Number} [repeatRate] - For actions that support continuous operation, how many seconds to wait before repeating the action if the same control is still depressed. 25 | @property {Number} [scrollFactor] - How far to scroll in a single action. 26 | @property {Boolean} [background] - For new windows/tabs, whether to open in the background. 27 | @property {String} [key] - For `sendKey`, the key to send. 28 | 29 | */ 30 | 31 | gamepad.bindings.defaults = { 32 | buttons: { 33 | // Cross on PS controller, A on Xbox 34 | 0: { 35 | action: "click" 36 | }, 37 | 38 | // Circle on PS controller, B on Xbox. 39 | 1: { 40 | action: "sendKey", 41 | key: "Escape" 42 | }, 43 | 44 | // Left Bumper. 45 | "4": { 46 | action: "tabBackward", 47 | repeatRate: 0.5 48 | }, 49 | // Right Bumper. 50 | "5": { 51 | action: "tabForward", 52 | repeatRate: 0.5 53 | }, 54 | 55 | // Select button. 56 | 8: { 57 | action: "openSearchKeyboard" 58 | }, 59 | 60 | // Start button. 61 | 9: { 62 | action: "openActionLauncher" 63 | }, 64 | 65 | // D-pad. 66 | // Up. 67 | 12: { 68 | action: "sendKey", 69 | key: "ArrowUp", 70 | repeatRate: 0.5 71 | }, 72 | // Down 73 | 13: { 74 | action: "sendKey", 75 | key: "ArrowDown", 76 | repeatRate: 0.5 77 | }, 78 | // Left 79 | 14: { 80 | action: "sendKey", 81 | key: "ArrowLeft", 82 | repeatRate: 0.5 83 | }, 84 | // Right. 85 | 15: { 86 | action: "sendKey", 87 | key: "ArrowRight", 88 | repeatRate: 0.5 89 | }, 90 | 91 | // "Badge" button. 92 | 16: { 93 | action: "openConfigPanel" 94 | } 95 | }, 96 | axes: { 97 | // Left thumbstick vertical axis. 98 | "1": { 99 | action: "scrollVertically", 100 | repeatRate: 0.15, 101 | scrollFactor: 20, 102 | invert: false 103 | } 104 | } 105 | }; 106 | })(fluid); 107 | -------------------------------------------------------------------------------- /src/js/settings/settingsMainPanel.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | /* globals chrome */ 13 | (function (fluid) { 14 | "use strict"; 15 | var gamepad = fluid.registerNamespace("gamepad"); 16 | fluid.defaults("gamepad.settings.ui.mainPanel", { 17 | gradeNames: ["gamepad.templateRenderer"], 18 | injectionType: "replaceWith", 19 | markup: { 20 | container: "
          " 21 | }, 22 | model: { 23 | prefs: gamepad.prefs.defaults, 24 | bindings: gamepad.bindings.defaults 25 | }, 26 | modelListeners: { 27 | prefs: { 28 | excludeSource: "init", 29 | funcName: "gamepad.settings.savePrefs", 30 | args: ["{that}.model.prefs"] 31 | }, 32 | bindings: { 33 | excludeSource: "init", 34 | funcName: "gamepad.settings.saveBindings", 35 | args: ["{that}.model.bindings"] 36 | } 37 | }, 38 | listeners: { 39 | "onCreate.loadSettings": { 40 | funcName: "gamepad.settings.loadSettings", 41 | args: ["{that}"] 42 | }, 43 | "onCreate.addSettingsChangeListener": { 44 | funcName: "gamepad.settings.ui.addSettingsChangeListener", 45 | args: ["{that}"] 46 | } 47 | }, 48 | components: { 49 | prefsPanel: { 50 | container: "{that}.container", 51 | type: "gamepad.settings.ui.prefsPanel", 52 | options: { 53 | model: { 54 | prefs: "{gamepad.settings.ui.mainPanel}.model.prefs" 55 | } 56 | } 57 | }, 58 | buttonsPanel: { 59 | container: "{that}.container", 60 | type: "gamepad.settings.ui.buttonsPanel", 61 | options: { 62 | model: { 63 | label: "Buttons / Triggers", 64 | bindings: "{gamepad.settings.ui.mainPanel}.model.bindings.buttons", 65 | prefs: "{gamepad.settings.ui.mainPanel}.model.prefs" 66 | } 67 | } 68 | }, 69 | axesPanel: { 70 | container: "{that}.container", 71 | type: "gamepad.settings.ui.axesPanel", 72 | options: { 73 | model: { 74 | label: "Axes (Thumb sticks)", 75 | bindings: "{gamepad.settings.ui.mainPanel}.model.bindings.axes", 76 | prefs: "{gamepad.settings.ui.mainPanel}.model.prefs" 77 | } 78 | } 79 | } 80 | } 81 | }); 82 | 83 | gamepad.settings.ui.addSettingsChangeListener = function (that) { 84 | chrome.storage.onChanged.addListener(function (changes) { 85 | if (changes["gamepad-prefs"]) { 86 | gamepad.settings.loadPrefs(that); 87 | } 88 | 89 | if (changes["gamepad-bindings"]) { 90 | gamepad.settings.loadBindings(that); 91 | } 92 | }); 93 | }; 94 | 95 | window.component = gamepad.settings.ui.mainPanel(".gamepad-settings-body"); 96 | })(fluid); 97 | -------------------------------------------------------------------------------- /tests/js/tab/tab-button-continuous-tests.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | /* global gamepad, jqUnit */ 14 | 15 | (function (fluid, $) { 16 | "use strict"; 17 | 18 | $(document).ready(function () { 19 | 20 | fluid.registerNamespace("gamepad.tests"); 21 | 22 | gamepad.tests.delayMs = 250; 23 | 24 | jqUnit.module("Gamepad Navigator Button (Continuous) Tab Navigation Tests"); 25 | 26 | jqUnit.test("Change the focus to one of the next elements in continuous forward tabbing using buttons.", function () { 27 | jqUnit.expect(1); 28 | 29 | // Set initial conditions i.e., focus on the first element. 30 | $("#first").focus(); 31 | 32 | var inputMapper = gamepad.tests.tab.inputMapper(); 33 | 34 | // Record the state of focused elements before polling. 35 | var beforePollingFocusedElementTabIndex = document.activeElement.getAttribute("tabindex"); 36 | 37 | /** 38 | * Update the gamepad to press button 5 (right bumper) for forward tab 39 | * navigation. 40 | */ 41 | inputMapper.applier.change("buttons.5", 1); 42 | 43 | jqUnit.stop(); 44 | 45 | // Wait for a few milliseconds for the navigator to change focus. 46 | setTimeout(function () { 47 | jqUnit.start(); 48 | 49 | // Record the index of the element currently focused. 50 | var afterPollingFocusedElementTabIndex = document.activeElement.getAttribute("tabindex"); 51 | 52 | // Check if the focus has moved in the forward direction. 53 | var hasTabbedForward = beforePollingFocusedElementTabIndex < afterPollingFocusedElementTabIndex; 54 | jqUnit.assertTrue("The focus should have moved in the forward direction.", hasTabbedForward); 55 | }, gamepad.tests.frequency * 5); 56 | }); 57 | 58 | jqUnit.test("Change the focus to one of the previous elements in continuous reverse tabbing using buttons.", function () { 59 | jqUnit.expect(1); 60 | 61 | // Set initial conditions i.e., focus on some element in the middle. 62 | $("#fifth").focus(); 63 | 64 | var inputMapper = gamepad.tests.tab.inputMapper(); 65 | 66 | // Record the state of focused elements before polling. 67 | var beforePollingFocusedElementTabIndex = document.activeElement.getAttribute("tabindex"); 68 | 69 | /** 70 | * Update the gamepad to press button 4 (right bumper) for reverse tab 71 | * navigation. 72 | */ 73 | inputMapper.applier.change("buttons.4", 1); 74 | 75 | jqUnit.stop(); 76 | 77 | // Wait for a few milliseconds for the navigator to change focus. 78 | setTimeout(function () { 79 | jqUnit.start(); 80 | 81 | // Record the index of the element currently focused. 82 | var afterPollingFocusedElementTabIndex = document.activeElement.getAttribute("tabindex"); 83 | 84 | // Check if the focus has moved to the previous elements. 85 | var hasTabbedBackward = beforePollingFocusedElementTabIndex > afterPollingFocusedElementTabIndex; 86 | jqUnit.assertTrue("The focus should have moved to the previous elements in the order.", hasTabbedBackward); 87 | }, gamepad.tests.frequency * 5); 88 | }); 89 | }); 90 | })(fluid, jQuery); 91 | -------------------------------------------------------------------------------- /tests/js/lib/gamepad-mock.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | /* global gamepad */ 14 | 15 | (function (fluid) { 16 | "use strict"; 17 | 18 | fluid.registerNamespace("gamepad.tests.utils"); 19 | 20 | /** 21 | * 22 | * Generate a mock of the standard Gamepad object. Optionally, it can take the 23 | * inputSpec as its parameter. The inputSpec should be in the following formats: 24 | * 25 | * { 26 | * buttons: { "2": 1 } 27 | * } 28 | * 29 | * The above inputSpec returns a gamepad mock with the value of button 2 set to 1. 30 | * 31 | * { 32 | * axes: { "1": 0.753 } 33 | * } 34 | * 35 | * The above inputSpec return a gamepad mock with the value of axes 2 set to 0.753. 36 | * 37 | * { 38 | * disconnected: false, 39 | * buttons: { 40 | * "5": 1, 41 | * "11": 1 42 | * }, 43 | * axes: { "3": 0.92 } 44 | * } 45 | * 46 | * The above inputSpec returns a gamepad mock with the value of button 5 set to 1, 47 | * button 11 set to 1, and of axes 3 set to 0.92. 48 | * 49 | * @param {Object} inputSpec - (optional) The object containing the desired value for 50 | * the gamepad inputs. 51 | * @return {Array} - The array containing the mock gamepad mock object. 52 | * 53 | */ 54 | gamepad.tests.utils.generateGamepadMock = function (inputSpec) { 55 | inputSpec = inputSpec || {}; 56 | var buttonsArray = [], 57 | axesArray = []; 58 | 59 | // Set button input values. 60 | for (var buttonIndex = 0; buttonIndex < 18; buttonIndex++) { 61 | var buttonIndexString = buttonIndex.toString(), 62 | defaultValue = { 63 | value: 0, 64 | pressed: false 65 | }; 66 | /** 67 | * If any button's input value is provided as an argument, use it. Otherwise, 68 | * set it to the default value of gamepad button. 69 | */ 70 | if ("buttons" in inputSpec && buttonIndexString in inputSpec.buttons) { 71 | buttonsArray.push({ 72 | value: inputSpec.buttons[buttonIndexString], 73 | pressed: inputSpec.buttons[buttonIndexString] > 0 ? true : false 74 | }); 75 | } 76 | else { 77 | buttonsArray.push(defaultValue); 78 | } 79 | } 80 | 81 | // Set axes input values. 82 | for (var axesIndex = 0; axesIndex < 4; axesIndex++) { 83 | var axesIndexString = axesIndex.toString(); 84 | /** 85 | * If any axes' input value is provided as an argument, use it. Otherwise, 86 | * set it to the default value of gamepad button. 87 | */ 88 | if ("axes" in inputSpec && axesIndexString in inputSpec.axes) { 89 | axesArray.push(inputSpec.axes[axesIndexString]); 90 | } 91 | else { 92 | axesArray.push(0); 93 | } 94 | } 95 | 96 | var disconnected = fluid.get(inputSpec, "disconnected") ? true : false; 97 | 98 | // According to the standard Gamepad specification. 99 | return [{ 100 | id: "Sony PlayStation 4 Controller Mock", 101 | index: 0, 102 | connected: !disconnected, 103 | timestamp: 100, 104 | axes: axesArray, 105 | buttons: buttonsArray 106 | }]; 107 | }; 108 | })(fluid); 109 | -------------------------------------------------------------------------------- /tests/js/arrows/arrows-mock-utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | /* global gamepad */ 14 | 15 | (function (fluid) { 16 | "use strict"; 17 | 18 | fluid.registerNamespace("gamepad.tests.utils.arrows"); 19 | 20 | // Custom gamepad navigator component grade for arrow button tests. 21 | fluid.defaults("gamepad.tests.arrows.buttonInputMapper", { 22 | gradeNames: ["gamepad.inputMapper.base"], 23 | invokers: { 24 | // Disable polling to reduce the need for timeouts and other async management. 25 | onConnected: { 26 | funcName: "fluid.identity" 27 | } 28 | }, 29 | model: { 30 | bindings: { 31 | buttons: { 32 | // D-Pad Buttons. No custom options, so use the default definitions. 33 | 12: { action: "sendKey", key: "ArrowUp"}, 34 | 13: { action: "sendKey", key: "ArrowDown"}, 35 | 14: { action: "sendKey", key: "ArrowLeft"}, 36 | 15: { action: "sendKey", key: "ArrowRight"} 37 | } 38 | } 39 | } 40 | }); 41 | 42 | // Custom gamepad navigator component grade for arrow axis tests. 43 | fluid.defaults("gamepad.tests.axisInputMapper", { 44 | gradeNames: ["gamepad.inputMapper.base"], 45 | invert: false, 46 | members: { count: 2 }, 47 | model: { 48 | bindings: { 49 | axes: { 50 | 0: { action: "thumbstickHorizontalArrows", scrollFactor: 50, invert: "{that}.options.invert", repeatRate: 0.4 }, 51 | 1: { action: "thumbstickVerticalArrows", scrollFactor: 50, invert: "{that}.options.invert", repeatRate: 0.4 } 52 | } 53 | } 54 | } 55 | }); 56 | 57 | // Client-side component to count arrows sent. 58 | fluid.defaults("gamepad.tests.utils.arrows.counter", { 59 | gradeNames: ["fluid.viewComponent"], 60 | model: { 61 | eventCount: { 62 | } 63 | }, 64 | selectors: { 65 | target: "#arrow-target" 66 | }, 67 | invokers: { 68 | handleKeydown: { 69 | funcName: "gamepad.tests.utils.arrows.counter.handleEvent", 70 | args: ["{that}", "keydown", "{arguments}.0"] // eventType, event 71 | }, 72 | handleKeyup: { 73 | funcName: "gamepad.tests.utils.arrows.counter.handleEvent", 74 | args: ["{that}", "keyup", "{arguments}.0"] // eventType, event 75 | }, 76 | focus: { 77 | this: "{that}.dom.target", 78 | method: "focus" 79 | } 80 | }, 81 | listeners: { 82 | "onCreate.bindTargetKeydown": { 83 | this: "{that}.dom.target", 84 | method: "keydown", 85 | args: ["{that}.handleKeydown"] 86 | }, 87 | "onCreate.bindTargetKeyup": { 88 | this: "{that}.dom.target", 89 | method: "keyup", 90 | args: ["{that}.handleKeyup"] 91 | } 92 | } 93 | }); 94 | 95 | gamepad.tests.utils.arrows.counter.handleEvent = function (that, eventType, event) { 96 | var keyCode = fluid.get(event, "code"); 97 | if (keyCode !== undefined) { 98 | var currentCount = fluid.get(that, ["model", "eventCount", eventType, keyCode]) || 0; 99 | that.applier.change(["eventCount", eventType, keyCode], currentCount + 1); 100 | } 101 | }; 102 | })(fluid); 103 | -------------------------------------------------------------------------------- /src/images/thumbstick.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 74 | -------------------------------------------------------------------------------- /src/html/settings.html: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | Gamepad Navigator Settings 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 |
          71 | 72 | 73 |

          Gamepad Navigator Settings

          74 |
          75 | 76 |
          77 |
          78 | 79 | 80 | -------------------------------------------------------------------------------- /tests/js/scroll/scroll-bidirectional-oneaxes-tests.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | /* global gamepad, jqUnit */ 14 | 15 | (function (fluid, $) { 16 | "use strict"; 17 | 18 | $(document).ready(function () { 19 | 20 | fluid.registerNamespace("gamepad"); 21 | fluid.registerNamespace("gamepad.tests"); 22 | 23 | gamepad.tests.windowObject = window; 24 | gamepad.tests.delayMs = 250; 25 | 26 | jqUnit.module("Gamepad Navigator Bidirectional One-axis Scrolling Tests"); 27 | 28 | jqUnit.test("Scroll horizontally", function () { 29 | jqUnit.expect(3); 30 | 31 | // Initialize the webpage, i.e., scroll the page to the left. 32 | $(window).scrollLeft(0); 33 | 34 | var inputMapper = gamepad.tests.scroll.inputMapper(); 35 | 36 | jqUnit.assertEquals("The initial horizontal scroll position should not be changed.", 0, window.scrollX); 37 | 38 | // Update the gamepad to tilt the axes for scrolling. 39 | inputMapper.applier.change("axes.0", 1); 40 | 41 | jqUnit.stop(); 42 | 43 | // Wait for a few milliseconds for the webpage to scroll. 44 | setTimeout(function () { 45 | jqUnit.start(); 46 | 47 | jqUnit.assertNotEquals("The page should have been scrolled towards the right.", 0, window.scrollX); 48 | var previousXCoordinate = window.scrollX; 49 | 50 | // Update the gamepad to tilt the axes in the opposite direction. 51 | inputMapper.applier.change("axes.0", 0); 52 | inputMapper.applier.change("axes.0", -1); 53 | 54 | // Return the current horizontal position of scroll for further testing. 55 | jqUnit.stop(); 56 | 57 | // Wait for a few milliseconds for the webpage to scroll. 58 | setTimeout(function () { 59 | jqUnit.start(); 60 | 61 | // Check if the webpage has scrolled towards the left. 62 | jqUnit.assertNotEquals("The page should have been scrolled towards the left.", previousXCoordinate, window.scrollX); 63 | }, gamepad.tests.delayMs); 64 | }, gamepad.tests.delayMs); 65 | }); 66 | 67 | jqUnit.test("Scroll vertically", function () { 68 | jqUnit.expect(3); 69 | 70 | // Initialize the webpage, i.e., scroll the page to the top. 71 | $(window).scrollTop(0); 72 | 73 | var inputMapper = gamepad.tests.scroll.inputMapper(); 74 | 75 | jqUnit.assertEquals("The initial vertical scroll position should not be changed.", 0, window.scrollY); 76 | 77 | // Update the gamepad to tilt the axes for scrolling. 78 | inputMapper.applier.change("axes.1", 1); 79 | 80 | jqUnit.stop(); 81 | 82 | setTimeout(function () { 83 | jqUnit.start(); 84 | 85 | jqUnit.assertNotEquals("The page should have been scrolled down.", 0, window.scrollY); 86 | 87 | var previousYCoordinate = window.scrollY; 88 | 89 | // Update the gamepad to tilt the axes in the opposite direction. 90 | inputMapper.applier.change("axes.1", 0); 91 | inputMapper.applier.change("axes.1", -1); 92 | 93 | jqUnit.stop(); 94 | 95 | // Wait for a few milliseconds for the webpage to scroll. 96 | setTimeout(function () { 97 | jqUnit.start(); 98 | 99 | // Check if the webpage has scrolled up. 100 | jqUnit.assertNotEquals("The page should have been scrolled up.", previousYCoordinate, window.scrollY); 101 | }, gamepad.tests.delayMs); 102 | }, gamepad.tests.delayMs); 103 | }); 104 | }); 105 | })(fluid, jQuery); 106 | -------------------------------------------------------------------------------- /src/js/content_scripts/keyboards.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | /* globals osk */ 13 | (function (fluid) { 14 | "use strict"; 15 | 16 | var gamepad = fluid.registerNamespace("gamepad"); 17 | 18 | fluid.defaults("gamepad.osk.keyboard.text", { 19 | gradeNames: ["osk.keyboard"], 20 | rowDefs: [ 21 | [osk.keydefsByCode.Backquote, osk.keydefsByCode.Digit1, osk.keydefsByCode.Digit2, osk.keydefsByCode.Digit3, osk.keydefsByCode.Digit4, osk.keydefsByCode.Digit5, osk.keydefsByCode.Digit6, osk.keydefsByCode.Digit7, osk.keydefsByCode.Digit8, osk.keydefsByCode.Digit9, osk.keydefsByCode.Digit0, osk.keydefsByCode.Minus, osk.keydefsByCode.Equal, osk.keydefsByCode.Backspace], 22 | [osk.keydefsByCode.KeyQ, osk.keydefsByCode.KeyW, osk.keydefsByCode.KeyE, osk.keydefsByCode.KeyR, osk.keydefsByCode.KeyT, osk.keydefsByCode.KeyY, osk.keydefsByCode.KeyU, osk.keydefsByCode.KeyI, osk.keydefsByCode.KeyO, osk.keydefsByCode.KeyP, osk.keydefsByCode.BracketLeft, osk.keydefsByCode.BracketRight, osk.keydefsByCode.Slash, osk.keydefsByCode.ArrowLeft, osk.keydefsByCode.ArrowRight], 23 | [osk.keydefsByCode.KeyA, osk.keydefsByCode.KeyS, osk.keydefsByCode.KeyD, osk.keydefsByCode.KeyF, osk.keydefsByCode.KeyG, osk.keydefsByCode.KeyH, osk.keydefsByCode.KeyJ, osk.keydefsByCode.KeyK, osk.keydefsByCode.KeyL, osk.keydefsByCode.Semicolon, osk.keydefsByCode.Quote, osk.keydefsByCode.Enter], 24 | [osk.keydefsByCode.KeyZ, osk.keydefsByCode.KeyX, osk.keydefsByCode.KeyC, osk.keydefsByCode.KeyV, osk.keydefsByCode.KeyB, osk.keydefsByCode.KeyN, osk.keydefsByCode.KeyM, osk.keydefsByCode.Comma, osk.keydefsByCode.Period, osk.keydefsByCode.ShiftRight, osk.keydefsByCode.CapsLock ], 25 | [osk.keydefsByCode.Space] 26 | ] 27 | }); 28 | 29 | 30 | fluid.registerNamespace("gamepad.osk.keyboard.numpad"); 31 | 32 | // Key definitions for the number pad that don't use the shift key or labels. 33 | gamepad.osk.keyboard.numpad.keydefs = { 34 | Backspace: { code: "Backspace", label: "Back", gradeNames: ["osk.key.noShiftLabel"], action: "backspace"}, 35 | Comma: { code: "Comma", label: ",", gradeNames: ["osk.key.noShiftLabel"] }, 36 | Digit0: { code: "Digit0", label: "0", gradeNames: ["osk.key.noShiftLabel"] }, 37 | Digit1: { code: "Digit1", label: "1", gradeNames: ["osk.key.noShiftLabel"] }, 38 | Digit2: { code: "Digit2", label: "2", gradeNames: ["osk.key.noShiftLabel"] }, 39 | Digit3: { code: "Digit3", label: "3", gradeNames: ["osk.key.noShiftLabel"] }, 40 | Digit4: { code: "Digit4", label: "4", gradeNames: ["osk.key.noShiftLabel"] }, 41 | Digit5: { code: "Digit5", label: "5", gradeNames: ["osk.key.noShiftLabel"] }, 42 | Digit6: { code: "Digit6", label: "6", gradeNames: ["osk.key.noShiftLabel"] }, 43 | Digit7: { code: "Digit7", label: "7", gradeNames: ["osk.key.noShiftLabel"] }, 44 | Digit8: { code: "Digit8", label: "8", gradeNames: ["osk.key.noShiftLabel"] }, 45 | Digit9: { code: "Digit9", label: "9", gradeNames: ["osk.key.noShiftLabel"] }, 46 | Period: { code: "Period", label: ".", gradeNames: ["osk.key.noShiftLabel"]} 47 | 48 | }; 49 | 50 | fluid.defaults("gamepad.osk.keyboard.numpad", { 51 | gradeNames: ["osk.keyboard"], 52 | rowDefs: [ 53 | [gamepad.osk.keyboard.numpad.keydefs.Digit1, gamepad.osk.keyboard.numpad.keydefs.Digit2, gamepad.osk.keyboard.numpad.keydefs.Digit3, gamepad.osk.keyboard.numpad.keydefs.Digit4, gamepad.osk.keyboard.numpad.keydefs.Digit5], 54 | [gamepad.osk.keyboard.numpad.keydefs.Digit6, gamepad.osk.keyboard.numpad.keydefs.Digit7, gamepad.osk.keyboard.numpad.keydefs.Digit8, gamepad.osk.keyboard.numpad.keydefs.Digit9, gamepad.osk.keyboard.numpad.keydefs.Digit0 ], 55 | [gamepad.osk.keyboard.numpad.keydefs.Comma, gamepad.osk.keyboard.numpad.keydefs.Period, osk.keydefsByCode.ArrowLeft, osk.keydefsByCode.ArrowRight, gamepad.osk.keyboard.numpad.keydefs.Backspace ] 56 | ] 57 | }); 58 | 59 | })(fluid); 60 | -------------------------------------------------------------------------------- /src/js/settings/selectInput.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | (function (fluid) { 13 | "use strict"; 14 | 15 | var gamepad = fluid.registerNamespace("gamepad"); 16 | 17 | fluid.defaults("gamepad.ui.select", { 18 | gradeNames: ["gamepad.templateRenderer"], 19 | markup: { 20 | container: "
          \n\n
          ", 21 | noneOption: "\n", 22 | option: "\n" 23 | }, 24 | selectors: { 25 | select: ".gamepad-select-input-input" 26 | }, 27 | model: { 28 | noneOption: false, 29 | noneDescription: "Select an option", 30 | 31 | selectedChoice: false, 32 | // Should be a map of "value": "text description" 33 | choices: { 34 | } 35 | }, 36 | modelRelay: [ 37 | { 38 | source: "{that}.model.dom.select.value", 39 | target: "{that}.model.selectedChoice" 40 | } 41 | ], 42 | modelListeners: { 43 | choices: { 44 | funcName: "gamepad.ui.select.renderOptions", 45 | args: ["{that}.dom.select", "{that}.options.markup.option", "{that}.model.choices", "{that}.model.selectedChoice", "{that}.model.noneOption", "{that}.options.markup.noneOption", "{that}.model.noneDescription"] // selectInputElement, optionTemplate, choices, selectedChoice, hasNoneOption, noneOptionTemplate, noneDescription 46 | } 47 | } 48 | }); 49 | 50 | gamepad.ui.select.renderOptions = function (selectInputElement, optionTemplate, choices, selectedChoice, hasNoneOption, noneOptionTemplate, noneDescription) { 51 | var renderedText = ""; 52 | 53 | if (hasNoneOption) { 54 | var noneOptionText = fluid.stringTemplate(noneOptionTemplate, { noneDescription }); 55 | renderedText += noneOptionText; 56 | } 57 | 58 | fluid.each(choices, function (description, value) { 59 | var selected = (value === selectedChoice) ? " selected" : ""; 60 | var singleOptionText = fluid.stringTemplate(optionTemplate, { selected, description, value}); 61 | renderedText += singleOptionText; 62 | }); 63 | 64 | $(selectInputElement).html(renderedText); 65 | }; 66 | 67 | fluid.defaults("gamepad.ui.bindingParam.selectInput", { 68 | gradeNames: ["gamepad.templateRenderer"], 69 | model: { 70 | label: "Select", 71 | description: "" 72 | }, 73 | markup: { 74 | container: 75 | "
          \n" + 76 | "\t
          %label
          \n" + 77 | "\t
          \n" + 78 | "\t
          %description
          \n" + 79 | "\t
          \n
          \n" + 80 | "
          \n" 81 | }, 82 | selectors: { 83 | select: ".gamepad-select-input-container" 84 | }, 85 | components: { 86 | select: { 87 | container: "{that}.dom.select", 88 | type: "gamepad.ui.select", 89 | options: { 90 | model: { 91 | choices: "{gamepad.ui.bindingParam.selectInput}.model.choices", 92 | noneDescription: "{gamepad.ui.bindingParam.selectInput}.model.noneDescription", 93 | noneOption: "{gamepad.ui.bindingParam.selectInput}.model.noneOption", 94 | selectedChoice: "{gamepad.ui.bindingParam.selectInput}.model.selectedChoice" 95 | } 96 | } 97 | } 98 | } 99 | }); 100 | })(fluid); 101 | -------------------------------------------------------------------------------- /src/js/content_scripts/input-mapper-background-utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | /* global gamepad, chrome */ 14 | 15 | (function (fluid) { 16 | "use strict"; 17 | 18 | fluid.registerNamespace("gamepad.inputMapperUtils.background"); 19 | 20 | // TODO: Add continuous / long-press browser tab navigation, if needed. 21 | // TODO: Add browser tab navigation for thumbsticks. 22 | 23 | /** 24 | * 25 | * Connect to the background script, send a message, and handle the response. 26 | * 27 | * @param {Object} that - The inputMapper component. 28 | * @param {String} actionOptions - The action payload to be transmitted. 29 | * 30 | */ 31 | gamepad.inputMapperUtils.background.postMessage = async function (that, actionOptions) { 32 | // We use this because chrome.runtime.sendMessage did not leave enough time to receive a response. 33 | var port = chrome.runtime.connect(); 34 | port.onMessage.addListener(function (response) { 35 | var vibrate = fluid.get(response, "vibrate"); 36 | if (vibrate) { 37 | that.vibrate(); 38 | } 39 | }); 40 | 41 | var wrappedActionOptions = fluid.copy(actionOptions); 42 | 43 | wrappedActionOptions.newTabOrWindowURL = that.model.prefs.newTabOrWindowURL; 44 | 45 | // Set the left pixel if the action is about changing "window size". 46 | if (actionOptions.action === "maximizeWindow" || actionOptions.action === "restoreWindowSize") { 47 | wrappedActionOptions.left = screen.availLeft; 48 | } 49 | 50 | port.postMessage(wrappedActionOptions); 51 | }; 52 | 53 | /** 54 | * 55 | * Sends message to the background script to zoom in/out on the webpage using 56 | * thumbsticks. 57 | * 58 | * @param {Object} that - The inputMapper component. 59 | * @param {Object} actionOptions - The parameters for this action. 60 | * @property {Boolean} invert - Whether the zooming should be in opposite order. 61 | * @param {String} inputType - The input type ("buttons" or "axes"). 62 | * @param {String|Number} index - Which button number or axis we're responding to. 63 | * 64 | */ 65 | gamepad.inputMapperUtils.background.thumbstickZoom = async function (that, actionOptions, inputType, index) { 66 | var inversionFactor = fluid.get(actionOptions, "invert") ? -1 : 1; 67 | 68 | var value = fluid.get(that.model, [inputType, index]); 69 | 70 | var polarisedValue = value * inversionFactor; 71 | var zoomType = polarisedValue > 0 ? "zoomOut" : "zoomIn"; 72 | 73 | var delegatedActionOptions = fluid.copy(actionOptions); 74 | delegatedActionOptions.action = zoomType; 75 | gamepad.inputMapperUtils.background.postMessage(that, delegatedActionOptions); 76 | }; 77 | 78 | /** 79 | * 80 | * Sends message to the background script to change the current window size using 81 | * thumbsticks. 82 | * 83 | * @param {Object} that - The inputMapper component. 84 | * @param {Object} actionOptions - The parameters for this action. 85 | * @property {Boolean} invert - Whether the zooming should be in opposite order. 86 | * @param {String} inputType - The input type ("buttons" or "axes"). 87 | * @param {String|Number} index - Which button number or axis we're responding to. 88 | * 89 | */ 90 | gamepad.inputMapperUtils.background.thumbstickWindowSize = function (that, actionOptions, inputType, index) { 91 | var invert = fluid.get(actionOptions, "invert") || false; 92 | 93 | var value = fluid.get(that.model, [inputType, index]); 94 | 95 | var inversionFactor = invert ? -1 : 1; 96 | var polarisedValue = value * inversionFactor; 97 | 98 | var delegatedAction = polarisedValue > 0 ? "maximizeWindow" : "restoreWindowSize"; 99 | 100 | var delegatedActionOptions = { 101 | action: delegatedAction, 102 | left: screen.availLeft 103 | }; 104 | gamepad.inputMapperUtils.background.postMessage(that, delegatedActionOptions); 105 | }; 106 | })(fluid); 107 | -------------------------------------------------------------------------------- /tests/html/form-fields.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Form Field Test Page 5 | 6 | 7 | 8 | 9 | 10 | 11 |

          Form Field Test Page

          12 | 13 |

          This page provides many examples of form elements, so that we can test them with the gamepad navigator.

          14 | 15 |

          Text Inputs

          16 | 17 |

          These inputs should be editable with the onscreen keyboard.

          18 | 19 |

          Input with No Type

          20 | 21 | 22 | 23 |

          Text Input

          24 | 25 | 26 | 27 |

          Search Input

          28 | 29 | 30 | 31 |

          Email Input

          32 | 33 | 34 | 35 |

          Password Input

          36 | 37 | 38 | 39 |

          Telephone Number Input

          40 | 41 | 42 | 43 |

          URL input

          44 | 45 | 46 | 47 |

          Text Area

          48 | 49 | 50 | 51 |

          Number Inputs

          52 | 53 |

          Number inputs should be controllable using arrow keys (which are bound to the d-pad by default)

          54 | 55 |

          Number Input, No Parameters

          56 | 57 | 58 | 59 |

          Number Input with Max / Min / Step

          60 | 61 | 62 | 63 |

          Range Input

          64 | 65 | 66 | 67 |

          "Decimal" Input Mode

          68 | 69 | 70 | 71 |

          Radio Buttons

          72 | 73 |

          No Radio Group, Nothing Selected

          74 | 75 |
          76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 |
          85 | 86 |

          No Radio Group, Button Selected

          87 | 88 |
          89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 |
          98 | 99 |

          Select Boxes

          100 | 101 |

          Select boxes should open with the "select operator" modal.

          102 | 103 |

          Select Input, Nothing Selected

          104 | 105 | 110 | 111 |

          Select Input, Value Selected

          112 | 113 | 118 | 119 |

          Select Input, Disabled Value Selected

          120 | 121 | 127 | 128 |

          Multi Select

          129 | 130 | 135 | 136 |

          Custom Select Input

          137 | 138 |
          139 | 140 | 155 | 156 | 157 | 158 | -------------------------------------------------------------------------------- /tests/js/scroll/scroll-unidirectional-axes-tests.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | /* global gamepad, jqUnit */ 14 | 15 | (function (fluid, $) { 16 | "use strict"; 17 | 18 | $(document).ready(function () { 19 | 20 | fluid.registerNamespace("gamepad"); 21 | fluid.registerNamespace("gamepad.tests"); 22 | 23 | gamepad.tests.delayMs = 250; 24 | 25 | jqUnit.module("Gamepad Navigator Unidirectional Axes Scrolling Tests"); 26 | 27 | jqUnit.test("Scroll down using axes input", function () { 28 | jqUnit.expect(2); 29 | 30 | // Initialize the webpage, i.e., scroll the page to the top. 31 | $(window).scrollTop(0); 32 | 33 | var inputMapper = gamepad.tests.scroll.inputMapper(); 34 | 35 | jqUnit.assertEquals("The initial vertical scroll position should not be changed.", 0, window.scrollY); 36 | 37 | // Update the gamepad to tilt axes 1 for for scrolling. 38 | inputMapper.applier.change("axes.1", 1); 39 | 40 | jqUnit.stop(); 41 | 42 | // Wait for a few milliseconds for the webpage to scroll. 43 | setTimeout(function () { 44 | jqUnit.start(); 45 | 46 | // Check if the gamepad has scrolled down. 47 | jqUnit.assertNotEquals("The page should have scrolled down.", 0, window.scrollY); 48 | }, gamepad.tests.delayMs); 49 | }); 50 | 51 | jqUnit.test("Scroll up using axes input", function () { 52 | jqUnit.expect(2); 53 | 54 | // Initialize the webpage, i.e., scroll the page towards the bottom. 55 | $(window).scrollTop(400); 56 | 57 | var inputMapper = gamepad.tests.scroll.inputMapper(); 58 | 59 | jqUnit.assertEquals("The initial vertical scroll position should not be changed.", 400, window.scrollY); 60 | 61 | // Update the gamepad to tilt axes 1 for for scrolling. 62 | inputMapper.applier.change("axes.1", -1); 63 | 64 | jqUnit.stop(); 65 | 66 | // Wait for a few milliseconds for the webpage to scroll. 67 | setTimeout(function () { 68 | jqUnit.start(); 69 | 70 | // Check if the gamepad has scrolled up. 71 | var hasScrolledUp = window.scrollY < 400; 72 | jqUnit.assertTrue("The page should have scrolled up.", hasScrolledUp); 73 | }, gamepad.tests.delayMs); 74 | }); 75 | 76 | jqUnit.test("Scroll right using axes input", function () { 77 | jqUnit.expect(2); 78 | 79 | // Initialize the webpage, i.e., scroll the page to the left. 80 | $(window).scrollLeft(0); 81 | 82 | var inputMapper = gamepad.tests.scroll.inputMapper(); 83 | 84 | jqUnit.assertEquals("The horizontal vertical scroll position should not be changed.", 0, window.scrollX); 85 | 86 | // Update the gamepad to tilt axes 0 for for scrolling. 87 | inputMapper.applier.change("axes.0", 1); 88 | 89 | jqUnit.stop(); 90 | 91 | // Wait for a few milliseconds for the webpage to scroll. 92 | setTimeout(function () { 93 | jqUnit.start(); 94 | 95 | // Check if the gamepad has scrolled towards the right. 96 | jqUnit.assertNotEquals("The page should have scrolled right.", 0, window.scrollX); 97 | }, gamepad.tests.delayMs); 98 | }); 99 | 100 | jqUnit.test("Scroll left using axes input", function () { 101 | jqUnit.expect(2); 102 | 103 | // Initialize the webpage, i.e., scroll the page towards the right. 104 | $(window).scrollLeft(400); 105 | 106 | var inputMapper = gamepad.tests.scroll.inputMapper(); 107 | 108 | jqUnit.assertEquals("The horizontal vertical scroll position should not be changed.", 400, window.scrollX); 109 | 110 | // Update the gamepad to tilt axes 0 for for scrolling. 111 | inputMapper.applier.change("axes.0", -1); 112 | 113 | jqUnit.stop(); 114 | 115 | // Wait for a few milliseconds for the webpage to scroll. 116 | setTimeout(function () { 117 | jqUnit.start(); 118 | 119 | // Check if the gamepad has scrolled towards the left. 120 | var hasScrolledLeft = window.scrollX < 400; 121 | jqUnit.assertTrue("The page should have scrolled left.", hasScrolledLeft); 122 | }, gamepad.tests.delayMs); 123 | }); 124 | }); 125 | })(fluid, jQuery); 126 | -------------------------------------------------------------------------------- /docs/components/navigator.md: -------------------------------------------------------------------------------- 1 | 12 | 13 | # `gamepad.navigator` 14 | 15 | This component listens to the `gamepadconnected` and `gamepaddisconnected` events in the browser. When at least one 16 | gamepad is connected, it reads inputs from them at a particular frequency and stores those input values in the 17 | component's model data. The component stops reading these inputs when the last gamepad is disconnected. 18 | 19 | ## Using this grade 20 | 21 | You can change the configuration options by either extending the component or by passing your own options. If no 22 | options are passed, the defaults are used. 23 | 24 | ``` javascript 25 | // Either create an instance of the navigator. 26 | var navigatorInstanceOne = gamepad.navigator(); 27 | 28 | // Otherwise create a custom component using the navigator grade. 29 | fluid.defaults("my.navigator.grade", { 30 | gradeNames: ["gamepad.navigator"] 31 | }); 32 | var navigatorInstanceTwo = my.navigator.grade(); 33 | ``` 34 | 35 | ## Component Options 36 | 37 | The following component configuration options are supported: 38 | 39 | | Option | Type | Default Value | Description | 40 | | :---: | :---: | :---: | :--- | 41 | | `windowObject` | `{Object}` | `window` | The global window object from which the navigator derives various methods to read gamepad inputs and attach listeners to it for various gamepad events. | 42 | | `frequency` | `{Integer}` | 50 | The frequency (in ms) at which the navigator will read gamepad inputs. | 43 | 44 | These options can be provided to the component in the following ways: 45 | 46 | ```javascript 47 | // Either pass the options inside the object as an argument while creating an instance of the navigator. 48 | var navigatorInstanceOne = gamepad.navigator({ 49 | windowObject: myCustomWindowObject, 50 | frequency: 100 51 | }); 52 | 53 | // Otherwise pass it as default options in a custom component using the navigator grade. 54 | fluid.defaults("my.navigator.grade", { 55 | gradeNames: ["gamepad.navigator"], 56 | windowObject: myCustomWindowObject, 57 | frequency: 100 58 | }); 59 | var navigatorInstanceTwo = my.navigator.grade(); 60 | ``` 61 | 62 | Though the `windowObject` is a configurable option, it should have all the properties and methods that the navigator 63 | uses. Otherwise, the navigator won't work and will throw errors. You can also use a modified `window` object, such as 64 | the gamepad mocks used in this package's tests, and pass it into the component if you want to achieve a different 65 | behavior. That'll be less likely to throw errors. 66 | 67 | ## Invokers 68 | 69 | ### `{navigator}.attachListener()` 70 | 71 | - Returns: Nothing. 72 | 73 | Attaches event listeners to the `windowObject` for the `gamepadconnected` and `gamepaddisconnected` events. When these 74 | events are fired, the navigator fires its native `onGamepadConnected` or `onGamepadDisconnected` events, depending upon 75 | the former event. These events have listeners attached to it, which start reading the gamepad inputs or stop reading it 76 | further. 77 | 78 | ### `{navigator}.onConnected()` 79 | 80 | - Returns: Nothing. 81 | 82 | Listens for the gamepad navigator component's event `onGamepadConnected`. When it is called, it initiates the gamepad 83 | polling invoker [`{navigator}.pollGamepads`](#navigatorpollgamepads) to be called at the same frequency as specified in 84 | the component's configurable option `frequency`. The interval ID is stored in the component's member 85 | `connectivityIntervalReference` for reference in other invokers and listeners present in the component. 86 | 87 | ### `{navigator}.pollGamepads()` 88 | 89 | - Returns: Nothing. 90 | 91 | Called periodically to read the gamepad inputs and to update the navigator component's model with those values. The 92 | inputs are read from all the connected gamepads and then combine them to provide the co-pilot mode experience. 93 | 94 | ### `{navigator}.onDisconnected()` 95 | 96 | - Returns: Nothing. 97 | 98 | Listens for the gamepad navigator component's event `onGamepadDisconnected`. When it is called, it stops the polling 99 | function interval loop and then checks whether any gamepad is still connected. If any gamepad is found connected, it 100 | will fire the `onGamepadConnected` event so that the navigator continues to read gamepad inputs. Otherwise, the 101 | navigator will restore the component's model to its initial state (when no gamepad was connected). 102 | 103 | ### `{navigator}.clearConnectivityInterval()` 104 | 105 | - Returns: Nothing. 106 | 107 | Listens for the navigator component's `onDestroy` event and will clear the polling function interval loop when called. 108 | -------------------------------------------------------------------------------- /tests/html/advanced-focus.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Advanced Focus Tester 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |

          Advanced Focus Tester

          13 | 14 |

          15 | This page demonstrates a range of advanced elements to confirm whether 16 | we can navigate to and control them. 17 |

          18 | 19 |

          Simple Elements

          20 | 21 |

          22 | Here are a few simple elements that we can easily work with. We use 23 | these to ensure that our approach to advanced elements does not break 24 | handling of simple elements. 25 |

          26 | 27 |

          28 | Here is a normal link (to 29 | wikipedia), which should be reachable via tab navigation. 30 |

          31 | 32 |

          33 | Here is an internal link (to the shadow content 34 | section), which should also be tabbable. 35 |

          36 | 37 | 38 | 39 | 40 | 41 |

          Media

          42 | 43 |

          44 | Specialised controls like <video> elements also make use of a 45 | shadow root, but do not expose their internals. We have a preference 46 | (enabled by default) to expose controls automatically. Clicking a 47 | media element with its controls exposed should play or pause. 48 |

          49 | 50 |

          Video, Controls Enabled by Default

          51 | 52 | 55 | 56 |

          Video, Controls Disabled by Default

          57 | 58 | 61 | 62 |

          Shadow Content

          63 | 64 |

          65 | Some elements make use of a "shadow root" to store their associated DOM 66 | elements. These cannot be found using many query strategies, and it is 67 | important that we find them and any "tabbable" sub-elements. This page 68 | provides a mix of normal and shadow elements to test tab focus. 69 |

          70 | 71 |

          Shadow Content, Open Container

          72 | 73 |
          74 | 75 |

          Shadow Content, Closed Container

          76 | 77 |
          78 | 79 |

          IFrame Content

          80 | 81 |

          There are also <iframe> elements, which present their own challenges.

          82 | 83 | 84 | 85 | 132 | 133 | -------------------------------------------------------------------------------- /tests/js/scroll/scroll-unidirectional-button-tests.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | /* global gamepad, jqUnit */ 14 | 15 | (function (fluid, $) { 16 | "use strict"; 17 | 18 | $(document).ready(function () { 19 | 20 | fluid.registerNamespace("gamepad"); 21 | fluid.registerNamespace("gamepad.tests"); 22 | 23 | gamepad.tests.windowObject = window; 24 | gamepad.tests.delayMs = 250; 25 | 26 | jqUnit.module("Gamepad Navigator Unidirectional Button Scrolling Tests"); 27 | 28 | jqUnit.test("Scroll down using button input", function () { 29 | jqUnit.expect(2); 30 | 31 | // Initialize the webpage, i.e., scroll the page to the top. 32 | $(window).scrollTop(0); 33 | 34 | var inputMapper = gamepad.tests.scroll.inputMapper(); 35 | 36 | jqUnit.assertEquals("The initial vertical scroll position should not be changed.", 0, window.scrollY); 37 | 38 | // Update the gamepad to press button 13 for scrolling. 39 | inputMapper.applier.change("buttons.13", 1); 40 | 41 | jqUnit.stop(); 42 | 43 | // Wait for a few milliseconds for the webpage to scroll. 44 | setTimeout(function () { 45 | jqUnit.start(); 46 | 47 | // Check if the gamepad has scrolled down. 48 | jqUnit.assertNotEquals("The page should have scrolled down.", 0, window.scrollY); 49 | }, gamepad.tests.delayMs); 50 | }); 51 | 52 | jqUnit.test("Scroll up using button input", function () { 53 | jqUnit.expect(2); 54 | 55 | // Initialize the webpage, i.e., scroll the page towards the bottom. 56 | $(window).scrollTop(400); 57 | 58 | jqUnit.assertEquals("The initial vertical scroll position should not be changed.", 400, window.scrollY); 59 | 60 | var inputMapper = gamepad.tests.scroll.inputMapper(); 61 | 62 | // Update the gamepad to press button 12 for scrolling. 63 | inputMapper.applier.change("buttons.12", 1); 64 | 65 | jqUnit.stop(); 66 | 67 | // Wait for a few milliseconds for the webpage to scroll. 68 | setTimeout(function () { 69 | jqUnit.start(); 70 | 71 | // Check if the gamepad has scrolled up. 72 | var hasScrolledUp = window.scrollY < 400; 73 | jqUnit.assertTrue("The page should have scrolled up.", hasScrolledUp); 74 | }, gamepad.tests.delayMs); 75 | }); 76 | 77 | jqUnit.test("Scroll right using button input", function () { 78 | jqUnit.expect(2); 79 | 80 | // Initialize the webpage, i.e., scroll the page to the left. 81 | $(window).scrollLeft(0); 82 | 83 | var inputMapper = gamepad.tests.scroll.inputMapper(); 84 | 85 | jqUnit.assertEquals("The horizontal vertical scroll position should not be changed.", 0, window.scrollX); 86 | 87 | // Update the gamepad to press button 15 for scrolling. 88 | inputMapper.applier.change("buttons.15", 1); 89 | 90 | jqUnit.stop(); 91 | 92 | // Wait for a few milliseconds for the webpage to scroll. 93 | setTimeout(function () { 94 | jqUnit.start(); 95 | 96 | // Check if the gamepad has scrolled towards the right. 97 | jqUnit.assertNotEquals("The page should have scrolled right.", 0, window.scrollX); 98 | }, gamepad.tests.delayMs); 99 | }); 100 | 101 | jqUnit.test("Scroll left using button input", function () { 102 | jqUnit.expect(2); 103 | 104 | // Initialize the webpage, i.e., scroll the page towards the right. 105 | $(window).scrollLeft(400); 106 | 107 | var inputMapper = gamepad.tests.scroll.inputMapper(); 108 | 109 | jqUnit.assertEquals("The horizontal vertical scroll position should not be changed.", 400, window.scrollX); 110 | 111 | // Update the gamepad to press button 14 for scrolling. 112 | inputMapper.applier.change("buttons.14", 1); 113 | 114 | jqUnit.stop(); 115 | 116 | // Wait for a few milliseconds for the webpage to scroll. 117 | setTimeout(function () { 118 | jqUnit.start(); 119 | 120 | // Check if the gamepad has scrolled towards the left. 121 | var hasScrolledLeft = window.scrollX < 400; 122 | jqUnit.assertTrue("The page should have scrolled left.", hasScrolledLeft); 123 | }, gamepad.tests.delayMs); 124 | }); 125 | }); 126 | })(fluid, jQuery); 127 | -------------------------------------------------------------------------------- /tests/js/polling/gamepad-polling-tests.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | /* global jqUnit */ 13 | (function (fluid) { 14 | "use strict"; 15 | var gamepad = fluid.registerNamespace("gamepad"); 16 | 17 | fluid.defaults("gamepad.test.repeatRate.navigator", { 18 | gradeNames: ["gamepad.navigator"] 19 | }); 20 | 21 | var navigator; 22 | var windowMock; 23 | jqUnit.module("Gamepad Navigator Gamepad Polling Tests", { 24 | setup: function () { 25 | windowMock = gamepad.test.window(); 26 | navigator = gamepad.test.repeatRate.navigator({ 27 | windowObject: windowMock 28 | }); 29 | } 30 | }); 31 | 32 | jqUnit.test("Polling should not occur when no gamepads are connected.", function () { 33 | var initialValue = fluid.get(navigator, "model.buttons.0") || 0; 34 | jqUnit.assertEquals("No button value should have been recorded on startup.", 0, initialValue); 35 | 36 | // Create the enclosing gamepad definition, but flag it as disconnected. 37 | windowMock.applier.change("gamepads.0", { disconnected: true }); 38 | // Now simulate a button press. 39 | windowMock.applier.change("gamepads.0.buttons.0", 1); 40 | 41 | jqUnit.stop(); 42 | setTimeout(function () { 43 | jqUnit.start(); 44 | 45 | var updatedValue = fluid.get(navigator, "model.buttons.0") || 0; 46 | 47 | jqUnit.assertEquals("There should still be no button value after several polling cycles have elapsed.", 0, updatedValue); 48 | }, (navigator.model.prefs.pollingFrequency * 2)); 49 | }); 50 | 51 | jqUnit.test("Polling should start when a gamepad is connected.", function () { 52 | var initialValue = fluid.get(navigator, "model.buttons.0") || 0; 53 | jqUnit.assertEquals("No button value should have been recorded on startup.", 0, initialValue); 54 | 55 | // Simulate a button press. 56 | windowMock.applier.change("gamepads.0.buttons.0", 1); 57 | windowMock.events.gamepadconnected.fire(); 58 | 59 | jqUnit.stop(); 60 | setTimeout(function () { 61 | jqUnit.start(); 62 | 63 | var updatedValue = fluid.get(navigator, "model.buttons.0") || 0; 64 | 65 | jqUnit.assertEquals("There should be a button value after several polling cycles have elapsed.", 1, updatedValue); 66 | }, (navigator.model.prefs.pollingFrequency * 2)); 67 | }); 68 | 69 | jqUnit.test("Polling should continue if only some gamepads are disconnected.", function () { 70 | var initialValue = fluid.get(navigator, "model.buttons.0") || 0; 71 | jqUnit.assertEquals("No button value should have been recorded on startup.", 0, initialValue); 72 | 73 | // Create a connected gamepad and simulate a button press. 74 | windowMock.applier.change("gamepads.0.buttons.0", 1); 75 | windowMock.events.gamepadconnected.fire(); 76 | 77 | windowMock.applier.change("gamepads.1.buttons.1", 1); 78 | windowMock.applier.change("gamepads.1.disconnected", true); 79 | 80 | windowMock.events.gamepaddisconnected.fire(); 81 | 82 | jqUnit.stop(); 83 | setTimeout(function () { 84 | jqUnit.start(); 85 | 86 | var updatedValue = fluid.get(navigator, "model.buttons.0") || 0; 87 | 88 | jqUnit.assertEquals("There should be a button value after several polling cycles have elapsed.", 1, updatedValue); 89 | }, (navigator.model.prefs.pollingFrequency * 2)); 90 | }); 91 | 92 | 93 | jqUnit.test("Polling should stop when the last gamepad is disconnected.", function () { 94 | var timeToWait = (navigator.model.prefs.pollingFrequency * 2); 95 | var initialValue = fluid.get(navigator, "model.buttons.0") || 0; 96 | jqUnit.assertEquals("No button value should have been recorded on startup.", 0, initialValue); 97 | 98 | // Create a connected gamepad and simulate a button press. 99 | windowMock.applier.change("gamepads.0.buttons.0", 1); 100 | windowMock.events.gamepadconnected.fire(); 101 | 102 | jqUnit.stop(); 103 | setTimeout(function () { 104 | jqUnit.start(); 105 | 106 | var updatedValue = fluid.get(navigator, "model.buttons.0") || 0; 107 | 108 | jqUnit.assertEquals("There should be a button value after several polling cycles have elapsed.", 1, updatedValue); 109 | 110 | windowMock.applier.change("gamepads.0.disconnected", true); 111 | windowMock.events.gamepaddisconnected.fire(); 112 | 113 | jqUnit.stop(); 114 | 115 | setTimeout(function () { 116 | jqUnit.start(); 117 | var valueAfterDisconnect = fluid.get(navigator, "model.buttons.0") || 0; 118 | 119 | jqUnit.assertEquals("The button value should have been cleared after controller disconnect.", 0, valueAfterDisconnect); 120 | }, timeToWait); 121 | }, timeToWait); 122 | }); 123 | })(fluid); 124 | -------------------------------------------------------------------------------- /tests/js/tab/tab-button-discrete-tests.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | /* global gamepad, jqUnit */ 14 | 15 | (function (fluid, $) { 16 | "use strict"; 17 | 18 | $(document).ready(function () { 19 | 20 | fluid.registerNamespace("gamepad.tests"); 21 | 22 | gamepad.tests.delayMs = 250; 23 | 24 | jqUnit.module("Gamepad Navigator Button (Discrete) Tab Navigation Tests"); 25 | 26 | jqUnit.test("Tab from the last element to the first in discrete forward tabbing using buttons.", function () { 27 | jqUnit.expect(1); 28 | 29 | // Set initial conditions i.e., focus on the last element. 30 | $("#last").focus(); 31 | 32 | var inputMapper = gamepad.tests.tab.inputMapper(); 33 | 34 | /** 35 | * Update the gamepad to press button 5 (right bumper) for forward tab 36 | * navigation. 37 | */ 38 | inputMapper.applier.change("buttons.5", 1); 39 | 40 | jqUnit.stop(); 41 | 42 | /** 43 | * Wait for a few milliseconds for the navigator to focus. 44 | * 45 | * This is a race condition as the tab navigation is asynchronous and uses 46 | * setInterval for continuous tabbing when button is pressed but not released. 47 | */ 48 | setTimeout(function () { 49 | jqUnit.start(); 50 | 51 | // Check if the first element is focused. 52 | jqUnit.assertEquals("The first element (with tabindex=1) should be focused.", document.querySelector("#first"), document.activeElement); 53 | }, gamepad.tests.delayMs); 54 | }); 55 | 56 | jqUnit.test("Tab from the first element to the last in discrete reverse tabbing using buttons.", function () { 57 | jqUnit.expect(1); 58 | 59 | // Set initial conditions i.e., focus on the first element. 60 | $("#first").focus(); 61 | 62 | var inputMapper = gamepad.tests.tab.inputMapper(); 63 | 64 | /** 65 | * Update the gamepad to press button 4 (left bumper) for reverse tab 66 | * navigation. 67 | */ 68 | inputMapper.applier.change("buttons.4", 1); 69 | 70 | jqUnit.stop(); 71 | 72 | /** 73 | * Wait for a few milliseconds for the navigator to focus. 74 | * 75 | * This is a race condition as the tab navigation is asynchronous and uses 76 | * setInterval for continuous tabbing when button is pressed but not released. 77 | */ 78 | setTimeout(function () { 79 | jqUnit.start(); 80 | 81 | // Check if the last element is focused. 82 | jqUnit.assertEquals("The last element should be focused.", document.querySelector("#last"), document.activeElement); 83 | }, gamepad.tests.delayMs); 84 | }); 85 | 86 | jqUnit.test("Change the focus to the next element in discrete forward tabbing using buttons.", function () { 87 | jqUnit.expect(1); 88 | 89 | // Set initial conditions i.e., focus on the first element. 90 | $("#first").focus(); 91 | 92 | var inputMapper = gamepad.tests.tab.inputMapper(); 93 | 94 | /** 95 | * Update the gamepad to press button 5 (right bumper) for forward tab 96 | * navigation. 97 | */ 98 | inputMapper.applier.change("buttons.5", 1); 99 | 100 | jqUnit.stop(); 101 | 102 | // Wait for a few milliseconds for the navigator to change focus. 103 | setTimeout(function () { 104 | jqUnit.start(); 105 | 106 | // Check if the focus has moved to the next element. 107 | jqUnit.assertEquals("The focus should have moved to the second element.", document.querySelector("#second"), document.activeElement); 108 | }, gamepad.tests.delayMs); 109 | }); 110 | 111 | jqUnit.test("Change the focus to the previous element in discrete reverse tabbing using buttons.", function () { 112 | jqUnit.expect(1); 113 | 114 | // Set initial conditions i.e., focus on some element in the middle. 115 | $("#fifth").focus(); 116 | 117 | var inputMapper = gamepad.tests.tab.inputMapper(); 118 | 119 | /** 120 | * Update the gamepad to press button 4 (right bumper) for reverse tab 121 | * navigation. 122 | */ 123 | inputMapper.applier.change("buttons.4", 1); 124 | 125 | jqUnit.stop(); 126 | 127 | // Wait for a few milliseconds for the navigator to change focus. 128 | setTimeout(function () { 129 | jqUnit.start(); 130 | 131 | // Check if the focus has moved to the previous element. 132 | jqUnit.assertEquals("The focus should have moved to the fourth element.", document.querySelector("#fourth"), document.activeElement); 133 | }, gamepad.tests.delayMs); 134 | }); 135 | }); 136 | })(fluid, jQuery); 137 | -------------------------------------------------------------------------------- /src/js/settings/addBinding.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | (function (fluid) { 13 | "use strict"; 14 | var gamepad = fluid.registerNamespace("gamepad"); 15 | 16 | fluid.defaults("gamepad.settings.ui.addBinding.selectWithNone", { 17 | gradeNames: ["gamepad.ui.select"], 18 | model: { 19 | noneOption: true, 20 | noneDescription: "Select an option" 21 | } 22 | }); 23 | 24 | fluid.defaults("gamepad.settings.ui.addBinding", { 25 | gradeNames: ["gamepad.templateRenderer"], 26 | injectionType: "replaceWith", 27 | markup: { 28 | container: "
          \nWhen the
          \nis pressed,\n
          \n\n
          " 29 | }, 30 | model: { 31 | hidden: false, 32 | 33 | action: "none", 34 | actionChoices: {}, 35 | 36 | index: "none", 37 | availableIndexChoices: {}, 38 | 39 | missingParams: true 40 | }, 41 | modelListeners: { 42 | hidden: { 43 | this: "{that}.container", 44 | method: "toggleClass", 45 | args: ["hidden", "{change}.value"] 46 | }, 47 | availableIndexChoices: { 48 | funcName: "gamepad.settings.ui.addBinding.checkIndexChoices", 49 | args: ["{that}"] 50 | }, 51 | action: { 52 | funcName: "gamepad.settings.ui.addBinding.checkForMissingParams", 53 | args: ["{that}"] 54 | }, 55 | index: { 56 | funcName: "gamepad.settings.ui.addBinding.checkForMissingParams", 57 | args: ["{that}"] 58 | } 59 | }, 60 | modelRelay: { 61 | source: "{that}.model.missingParams", 62 | target: "{that}.model.dom.addButton.attr.disabled" 63 | }, 64 | selectors: { 65 | actionSelect: ".gamepad-settings-add-binding-action-select", 66 | indexSelect: ".gamepad-settings-add-binding-index-select", 67 | addButton: ".gamepad-settings-add-binding-addButton" 68 | }, 69 | // TODO: These throw errors about length, whether here or in the subcomponent definition in the parent. Fix. 70 | invokers: { 71 | handleClick: { 72 | funcName: "gamepad.settings.ui.addBinding.notifyParent", 73 | args: ["{that}", "{arguments}.0", "{gamepad.settings.ui.bindingsPanel}"] // event, parentComponent 74 | } 75 | }, 76 | listeners: { 77 | "onCreate.bindAddButton": { 78 | this: "{that}.dom.addButton", 79 | method: "click", 80 | args: ["{that}.handleClick"] 81 | } 82 | }, 83 | components: { 84 | indexSelect: { 85 | container: "{that}.dom.indexSelect", 86 | type: "gamepad.settings.ui.addBinding.selectWithNone", 87 | options: { 88 | model: { 89 | noneDescription: "- select an input -", 90 | selectedChoice: "{gamepad.settings.ui.addBinding}.model.index", 91 | // Unlike the bindings, we can use availableIndexChoices directly. 92 | choices: "{gamepad.settings.ui.bindingsPanel}.model.availableIndexChoices" 93 | } 94 | } 95 | }, 96 | actionSelect: { 97 | container: "{that}.dom.actionSelect", 98 | type: "gamepad.settings.ui.addBinding.selectWithNone", 99 | options: { 100 | model: { 101 | noneDescription: "- select an action -", 102 | selectedChoice: "{gamepad.settings.ui.addBinding}.model.action", 103 | choices: "{gamepad.settings.ui.addBinding}.model.actionChoices" 104 | } 105 | } 106 | } 107 | } 108 | }); 109 | 110 | gamepad.settings.ui.addBinding.checkForMissingParams = function (that) { 111 | var missingParams = !that.model.index || that.model.index === "none" || !that.model.action || that.model.action === "none"; 112 | that.applier.change("missingParams", missingParams); 113 | }; 114 | 115 | gamepad.settings.ui.addBinding.notifyParent = function (that, event, parentComponent) { 116 | event.preventDefault(); 117 | 118 | parentComponent.addBinding(that.model); 119 | 120 | that.applier.change("action", false); 121 | that.applier.change("index", false); 122 | }; 123 | 124 | gamepad.settings.ui.addBinding.checkIndexChoices = function (that) { 125 | var hasAvailableIndexChoices = typeof that.model.availableIndexChoices === "object" && Object.keys(that.model.availableIndexChoices).length > 0; 126 | that.applier.change("hidden", !hasAvailableIndexChoices); 127 | }; 128 | })(fluid); 129 | -------------------------------------------------------------------------------- /tests/js/polling/action-repeat-tests.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | /* global jqUnit */ 13 | (function (fluid) { 14 | "use strict"; 15 | var gamepad = fluid.registerNamespace("gamepad"); 16 | 17 | jqUnit.module("Gamepad Navigator Repeat Rate Tests"); 18 | 19 | // Test instance of mapper with a non-repeating action bound to 20 | // button/axes 0, and a repeating action bound to button/axes 1. 21 | fluid.defaults("gamepad.test.repeatRate.inputMapper", { 22 | gradeNames: ["gamepad.inputMapper.base"], 23 | windowObject: window, 24 | model: { 25 | count: 0, 26 | bindings: { 27 | axes: { 0: { action: "count" }, 1: { action: "count", repeatRate: 0.3 }}, 28 | buttons: { 0: { action: "count" }, 1: { action: "count", repeatRate: 0.3 }} 29 | } 30 | }, 31 | invokers: { 32 | "count": { 33 | funcName: "gamepad.test.repeatRate.inputMapper.updateCount", 34 | args: ["{that}"] 35 | } 36 | } 37 | }); 38 | 39 | gamepad.test.repeatRate.inputMapper.updateCount = function (that) { 40 | var currentCount = that.model.count || 0; 41 | that.applier.change("count", currentCount + 1); 42 | }; 43 | 44 | fluid.each(["axes", "buttons"], function (inputType) { 45 | jqUnit.test("Non-repeating actions bound to " + inputType + " should not repeat.", function () { 46 | var inputMapper = gamepad.test.repeatRate.inputMapper(); 47 | 48 | jqUnit.assertEquals("No actions should have been executed on startup.", 0, inputMapper.model.count); 49 | 50 | inputMapper.applier.change([inputType, 0], 1); 51 | 52 | jqUnit.assertEquals("The initial " + inputType + " press should have fired an action.", 1, inputMapper.model.count); 53 | 54 | jqUnit.stop(); 55 | 56 | setTimeout(function () { 57 | jqUnit.start(); 58 | 59 | jqUnit.assertEquals("The action should not have been fired again.", 1, inputMapper.model.count); 60 | }, 500); 61 | }); 62 | }); 63 | 64 | fluid.each(["axes", "buttons"], function (inputType) { 65 | jqUnit.test("Repeating " + inputType + " actions should repeat.", function () { 66 | var inputMapper = gamepad.test.repeatRate.inputMapper(); 67 | 68 | jqUnit.assertEquals("No actions should have been executed on startup.", 0, inputMapper.model.count); 69 | 70 | inputMapper.applier.change([inputType, 1], 1); 71 | 72 | jqUnit.assertEquals("The initial " + inputType + " press should have fired an action.", 1, inputMapper.model.count); 73 | 74 | jqUnit.stop(); 75 | 76 | setTimeout(function () { 77 | jqUnit.start(); 78 | 79 | jqUnit.assertEquals("The action should have been fired again.", 2, inputMapper.model.count); 80 | }, 500); 81 | }); 82 | }); 83 | 84 | 85 | fluid.each(["axes", "buttons"], function (inputType) { 86 | jqUnit.test("Analog " + inputType + " should work properly with non-repeating actions.", function () { 87 | var inputMapper = gamepad.test.repeatRate.inputMapper(); 88 | 89 | jqUnit.assertEquals("No actions should have been executed on startup.", 0, inputMapper.model.count); 90 | 91 | inputMapper.applier.change([inputType, 0], 1); 92 | 93 | jqUnit.assertEquals("The initial " + inputType + " press should have fired an action.", 1, inputMapper.model.count); 94 | 95 | inputMapper.applier.change([inputType, 0], 0.75); 96 | 97 | jqUnit.assertEquals("A different " + inputType + " value above the threshold should not fire an action.", 1, inputMapper.model.count); 98 | 99 | jqUnit.stop(); 100 | 101 | setTimeout(function () { 102 | jqUnit.start(); 103 | 104 | jqUnit.assertEquals("The action should not have repeated.", 1, inputMapper.model.count); 105 | }, 500); 106 | }); 107 | }); 108 | 109 | fluid.each(["axes", "buttons"], function (inputType) { 110 | jqUnit.test("Analog " + inputType + " should work properly with repeating actions.", function () { 111 | var inputMapper = gamepad.test.repeatRate.inputMapper(); 112 | 113 | jqUnit.assertEquals("No actions should have been executed on startup.", 0, inputMapper.model.count); 114 | 115 | inputMapper.applier.change([inputType, 1], 1); 116 | 117 | jqUnit.assertEquals("The initial input should have fired an action.", 1, inputMapper.model.count); 118 | 119 | inputMapper.applier.change([inputType, 1], 0.75); 120 | 121 | jqUnit.assertEquals("A different analog value above the threshold should not fire an action.", 1, inputMapper.model.count); 122 | 123 | jqUnit.stop(); 124 | 125 | setTimeout(function () { 126 | jqUnit.start(); 127 | 128 | jqUnit.assertEquals("The action should eventually have been repeated.", 2, inputMapper.model.count); 129 | }, 500); 130 | }); 131 | }); 132 | })(fluid); 133 | -------------------------------------------------------------------------------- /src/js/content_scripts/modalManager.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | (function (fluid) { 14 | "use strict"; 15 | 16 | var gamepad = fluid.registerNamespace("gamepad"); 17 | 18 | 19 | fluid.defaults("gamepad.modalManager", { 20 | gradeNames: ["gamepad.shadowHolder"], 21 | markup: { 22 | container: "
          " 23 | }, 24 | model: { 25 | activeModal: false, 26 | fullscreen: false, 27 | lastExternalFocused: false, 28 | inputValue: "", 29 | inputType: "" 30 | }, 31 | components: { 32 | actionLauncher: { 33 | container: "{that}.model.shadowElement", 34 | type: "gamepad.actionLauncher", 35 | createOnEvent: "onShadowReady", 36 | options: { 37 | model: { 38 | hidden: "{gamepad.modalManager}.model.hideActionLauncher", 39 | prefs: "{gamepad.modalManager}.model.prefs" 40 | } 41 | } 42 | }, 43 | onscreenKeyboard: { 44 | container: "{that}.model.shadowElement", 45 | type: "gamepad.osk.modal", 46 | createOnEvent: "onShadowReady", 47 | options: { 48 | model: { 49 | hidden: "{gamepad.modalManager}.model.hideOnscreenKeyboard", 50 | prefs: "{gamepad.modalManager}.model.prefs", 51 | lastExternalFocused: "{gamepad.modalManager}.model.lastExternalFocused", 52 | inputValue: "{gamepad.modalManager}.model.inputValue" 53 | } 54 | } 55 | }, 56 | onscreenNumpad: { 57 | container: "{that}.model.shadowElement", 58 | type: "gamepad.numpad.modal", 59 | createOnEvent: "onShadowReady", 60 | options: { 61 | model: { 62 | hidden: "{gamepad.modalManager}.model.hideOnscreenNumpad", 63 | prefs: "{gamepad.modalManager}.model.prefs", 64 | inputValue: "{gamepad.modalManager}.model.inputValue" 65 | } 66 | } 67 | }, 68 | searchKeyboard: { 69 | container: "{that}.model.shadowElement", 70 | type: "gamepad.searchKeyboard.modal", 71 | createOnEvent: "onShadowReady", 72 | options: { 73 | model: { 74 | hidden: "{gamepad.modalManager}.model.hideSearchKeyboard", 75 | prefs: "{gamepad.modalManager}.model.prefs" 76 | } 77 | } 78 | }, 79 | selectOperator: { 80 | container: "{that}.model.shadowElement", 81 | type: "gamepad.selectOperator", 82 | createOnEvent: "onShadowReady", 83 | options: { 84 | model: { 85 | hidden: "{gamepad.modalManager}.model.hideSelectOperator", 86 | prefs: "{gamepad.modalManager}.model.prefs", 87 | selectElement: "{gamepad.modalManager}.model.selectElement" 88 | } 89 | } 90 | } 91 | }, 92 | modelListeners: { 93 | activeModal: { 94 | excludeSource: "init", 95 | funcName: "gamepad.modalManager.toggleModals", 96 | args: ["{that}"] 97 | }, 98 | fullscreen: { 99 | excludeSource: "init", 100 | funcName: "gamepad.modalManager.reattachToDOM", 101 | args: ["{that}"] 102 | } 103 | } 104 | }); 105 | 106 | gamepad.modalManager.reattachToDOM = function (that) { 107 | that.applier.change("activeModal", false); 108 | 109 | var toAttach = document.fullscreenElement || document.body; 110 | toAttach.appendChild(that.container[0]); 111 | }; 112 | 113 | gamepad.modalManager.toggleModals = function (that) { 114 | var transaction = that.applier.initiate(); 115 | var hideActionLauncher = that.model.activeModal !== "actionLauncher"; 116 | transaction.fireChangeRequest({ path: "hideActionLauncher", value: hideActionLauncher }); 117 | 118 | var hideOnscreenKeyboard = that.model.activeModal !== "onscreenKeyboard"; 119 | transaction.fireChangeRequest({ path: "hideOnscreenKeyboard", value: hideOnscreenKeyboard }); 120 | 121 | var hideSearchKeyboard = that.model.activeModal !== "searchKeyboard"; 122 | transaction.fireChangeRequest({ path: "hideSearchKeyboard", value: hideSearchKeyboard }); 123 | 124 | var hideSelectOperator = that.model.activeModal !== "selectOperator"; 125 | transaction.fireChangeRequest({ path: "hideSelectOperator", value: hideSelectOperator }); 126 | 127 | var hideOnscreenNumpad = that.model.activeModal !== "onscreenNumpad"; 128 | transaction.fireChangeRequest({ path: "hideOnscreenNumpad", value: hideOnscreenNumpad}); 129 | 130 | transaction.commit(); 131 | }; 132 | })(fluid); 133 | -------------------------------------------------------------------------------- /src/js/content_scripts/select-operator.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | (function (fluid) { 13 | "use strict"; 14 | var gamepad = fluid.registerNamespace("gamepad"); 15 | 16 | fluid.defaults("gamepad.selectOperator", { 17 | gradeNames: ["gamepad.modal"], 18 | model: { 19 | classNames: " selectOperator-modal", 20 | closeButtonLabel: "Cancel", 21 | label: "Gamepad Navigator: Select Option", 22 | selectElement: false, 23 | items: {} 24 | }, 25 | 26 | modelListeners: { 27 | selectElement: { 28 | funcName: "gamepad.selectOperator.generateItemsFromSelect", 29 | args: ["{that}"] 30 | } 31 | }, 32 | 33 | invokers: { 34 | handleItemClick: { 35 | funcName: "gamepad.selectOperator.handleItemClick", 36 | args: ["{that}", "{arguments}.0", "{arguments}.1"] // nodeIndex, event 37 | } 38 | }, 39 | 40 | components: { 41 | listSelector: { 42 | type: "gamepad.ui.listSelector", 43 | container: "{that}.dom.modalBody", 44 | options: { 45 | model: { 46 | description: "Select an option from the list below or hit "Cancel" to close this modal. Only selectable items are displayed.", 47 | items: "{gamepad.selectOperator}.model.items", 48 | prefs: "{gamepad.selectOperator}.model.prefs" 49 | }, 50 | invokers: { 51 | handleItemClick: { 52 | func: "{gamepad.selectOperator}.handleItemClick", 53 | args: ["{arguments}.0", "{arguments}.1"] // nodeIndex, event 54 | } 55 | } 56 | } 57 | } 58 | } 59 | }); 60 | 61 | gamepad.selectOperator.generateItemsFromSelect = function (that) { 62 | var selectElement = fluid.get(that.model, "selectElement"); 63 | 64 | if (selectElement) { 65 | var generatedItems = {}; 66 | 67 | for (var nodeIndex = 0; nodeIndex < selectElement.children.length; nodeIndex++) { 68 | var childNode = selectElement.children.item(nodeIndex); 69 | var isHidden = childNode.getAttribute("hidden") !== null ? true : false; 70 | var isDisabled = childNode.getAttribute("disabled") !== null ? true : false; 71 | var isSelected = childNode.getAttribute("selected") !== null ? true : false; 72 | 73 | if (childNode.nodeName === "OPTION" && !isHidden && !isDisabled) { 74 | generatedItems[nodeIndex] = { 75 | description: childNode.textContent, 76 | selected: isSelected 77 | }; 78 | } 79 | } 80 | 81 | var transaction = that.applier.initiate(); 82 | transaction.fireChangeRequest({ path: "items", type: "DELETE" }); 83 | transaction.fireChangeRequest({ path: "items", value: generatedItems }); 84 | transaction.commit(); 85 | } 86 | }; 87 | 88 | gamepad.selectOperator.handleItemClick = function (that, nodeIndexString, event) { 89 | var nodeIndex = parseInt(nodeIndexString); 90 | 91 | var selectElement = fluid.get(that, "model.selectElement"); 92 | if (selectElement) { 93 | if (selectElement.multiple) { 94 | // Toggle the clicked option without affecting any other selected values. 95 | var clickedOptionElement = selectElement.children[nodeIndex]; 96 | if (clickedOptionElement.selected) { 97 | clickedOptionElement.removeAttribute("selected"); 98 | } 99 | else { 100 | clickedOptionElement.setAttribute("selected", ""); 101 | } 102 | } 103 | else { 104 | for (var optionIndex = 0; optionIndex < selectElement.children.length; optionIndex++) { 105 | var optionElement = selectElement.children[optionIndex]; 106 | // Only select the option that was clicked. 107 | if (optionIndex === nodeIndex) { 108 | var optionValue = optionElement.value; 109 | 110 | // Just setting the value doesn't property trigger a change. 111 | $(selectElement).val(optionValue); 112 | 113 | optionElement.setAttribute("selected", ""); 114 | } 115 | // Clear the selected attribute for the rest. 116 | else { 117 | optionElement.removeAttribute("selected"); 118 | } 119 | } 120 | } 121 | selectElement.dispatchEvent(new Event("change")); 122 | } 123 | 124 | var transaction = that.applier.initiate(); 125 | transaction.fireChangeRequest({ path: "items", type: "DELETE" }); 126 | transaction.fireChangeRequest({ path: "items", value: {} }); 127 | transaction.fireChangeRequest({ path: "selectElement", value: false }); 128 | transaction.commit(); 129 | 130 | that.closeModal(event); 131 | selectElement.focus(); 132 | }; 133 | })(fluid); 134 | -------------------------------------------------------------------------------- /src/js/content_scripts/focus-overlay.js: -------------------------------------------------------------------------------- 1 | (function (fluid) { 2 | "use strict"; 3 | 4 | var gamepad = fluid.registerNamespace("gamepad"); 5 | 6 | fluid.defaults("gamepad.focusOverlay.pointer", { 7 | gradeNames: ["gamepad.templateRenderer"], 8 | markup: { 9 | container: "
          " 10 | }, 11 | listeners: { 12 | "onCreate.listenForWindowFocusEvents": { 13 | funcName: "gamepad.focusOverlay.pointer.listenForWindowFocusEvents", 14 | args: ["{that}"] 15 | } 16 | }, 17 | invokers: { 18 | handleFocusin: { 19 | funcName: "gamepad.focusOverlay.pointer.handleFocusin", 20 | args: ["{that}", "{arguments}.0"] // event 21 | } 22 | }, 23 | modelListeners: { 24 | modalManagerShadowElement: { 25 | funcName: "gamepad.focusOverlay.pointer.listenForModalFocusEvents", 26 | args: ["{that}"] 27 | } 28 | } 29 | }); 30 | 31 | gamepad.focusOverlay.pointer.listenForWindowFocusEvents = function (that) { 32 | window.addEventListener("focusin", that.handleFocusin); 33 | }; 34 | 35 | gamepad.focusOverlay.pointer.listenForModalFocusEvents = function (that) { 36 | var modalManagerShadowElement = fluid.get(that, "model.modalManagerShadowElement"); 37 | if (modalManagerShadowElement) { 38 | modalManagerShadowElement.addEventListener("focusin", that.handleFocusin); 39 | } 40 | }; 41 | 42 | gamepad.focusOverlay.pointer.handleFocusin = function (that) { 43 | var containerDomElement = that.container[0]; 44 | 45 | var activeElement = fluid.get(that.model, "modalManagerShadowElement.activeElement") || document.activeElement; 46 | 47 | var clientRect = activeElement.getBoundingClientRect(); 48 | 49 | // Our outline is three pixels, so we adjust everything accordingly. 50 | containerDomElement.style.left = (clientRect.x + window.scrollX - 3) + "px"; 51 | containerDomElement.style.top = (clientRect.y + window.scrollY - 3) + "px"; 52 | containerDomElement.style.height = (clientRect.height) + "px"; 53 | containerDomElement.style.width = (clientRect.width) + "px"; 54 | 55 | var elementStyles = getComputedStyle(activeElement); 56 | var borderRadiusValue = elementStyles.getPropertyValue("border-radius"); 57 | if (borderRadiusValue.length) { 58 | containerDomElement.style.borderRadius = borderRadiusValue; 59 | } 60 | else { 61 | containerDomElement.style.borderRadius = 0; 62 | } 63 | }; 64 | 65 | fluid.defaults("gamepad.focusOverlay", { 66 | gradeNames: ["gamepad.shadowHolder"], 67 | markup: { 68 | container: "
          " 69 | }, 70 | model: { 71 | shadowElement: false, 72 | 73 | modalManagerShadowElement: false, 74 | 75 | prefs: {}, 76 | hideFocusOverlay: true 77 | }, 78 | modelRelay: { 79 | hideFocusOverlay: { 80 | source: "{that}.model.hideFocusOverlay", 81 | target: "{that}.model.dom.container.attr.hidden" 82 | } 83 | }, 84 | components: { 85 | pointer: { 86 | container: "{that}.model.shadowElement", 87 | type: "gamepad.focusOverlay.pointer", 88 | createOnEvent: "onShadowReady", 89 | options: { 90 | model: { 91 | modalManagerShadowElement: "{gamepad.focusOverlay}.model.modalManagerShadowElement" 92 | } 93 | } 94 | } 95 | }, 96 | modelListeners: { 97 | prefs: { 98 | excludeSource: "init", 99 | funcName: "gamepad.focusOverlay.shouldDisplayOverlay", 100 | args: ["{that}"] 101 | }, 102 | modalManagerShadowElement: { 103 | funcName: "gamepad.focusOverlay.listenForModalFocusEvents", 104 | args: ["{that}"] 105 | } 106 | }, 107 | listeners: { 108 | "onCreate.listenForWindowFocusEvents": { 109 | funcName: "gamepad.focusOverlay.listenForWindowFocusEvents", 110 | args: ["{that}"] 111 | } 112 | }, 113 | invokers: { 114 | shouldDisplayOverlay: { 115 | funcName: "gamepad.focusOverlay.shouldDisplayOverlay", 116 | args: ["{that}", "{arguments}.0"] // event 117 | } 118 | } 119 | }); 120 | 121 | gamepad.focusOverlay.shouldDisplayOverlay = function (that) { 122 | var activeElement = fluid.get(that.model, "modalManagerShadowElement.activeElement") || document.activeElement; 123 | var fixFocus = fluid.get(that, "model.prefs.fixFocus") ? true : false; 124 | var hideFocusOverlay = !fixFocus || !activeElement; 125 | that.applier.change("hideFocusOverlay", hideFocusOverlay); 126 | }; 127 | 128 | gamepad.focusOverlay.listenForWindowFocusEvents = function (that) { 129 | window.addEventListener("focusin", that.shouldDisplayOverlay); 130 | window.addEventListener("focusout", that.shouldDisplayOverlay); 131 | }; 132 | 133 | gamepad.focusOverlay.listenForModalFocusEvents = function (that) { 134 | var modalManagerShadowElement = fluid.get(that, "model.modalManagerShadowElement"); 135 | if (modalManagerShadowElement) { 136 | modalManagerShadowElement.addEventListener("focusin", that.shouldDisplayOverlay); 137 | modalManagerShadowElement.addEventListener("focusout", that.shouldDisplayOverlay); 138 | } 139 | }; 140 | })(fluid); 141 | -------------------------------------------------------------------------------- /src/images/button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 35 | 37 | 42 | 46 | 50 | 54 | 61 | 66 | 70 | 74 | 78 | 85 | 86 | 113 | 114 | -------------------------------------------------------------------------------- /tests/js/arrows/arrows-tests.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | /* global gamepad, jqUnit */ 14 | 15 | (function (fluid, $) { 16 | "use strict"; 17 | 18 | $(document).ready(function () { 19 | 20 | fluid.registerNamespace("gamepad.tests"); 21 | 22 | var counter; 23 | jqUnit.module("Gamepad Navigator Arrow Tests", { 24 | setup: function () { 25 | counter = gamepad.tests.utils.arrows.counter("body"); 26 | }, 27 | teardown: function () { 28 | counter.destroy(); 29 | } 30 | }); 31 | 32 | var arrowButtons = { 33 | 12: "ArrowUp", 34 | 13: "ArrowDown", 35 | 14: "ArrowLeft", 36 | 15: "ArrowRight" 37 | }; 38 | 39 | fluid.each(arrowButtons, function (keyCode, button) { 40 | jqUnit.test("Arrow button tests: " + keyCode, function () { 41 | counter.focus(); 42 | var inputMapper = gamepad.tests.arrows.buttonInputMapper(); 43 | 44 | var initialKeydownValue = fluid.get(counter, ["model", "eventCount", "keydown", keyCode ]); 45 | jqUnit.assertUndefined("There should be no initial keydown counter value for " + keyCode, initialKeydownValue); 46 | 47 | var initialKeyupValue = fluid.get(counter, ["model", "eventCount", "keyup", keyCode ]); 48 | jqUnit.assertUndefined("There should be no initial keyup counter value for " + keyCode, initialKeyupValue); 49 | 50 | inputMapper.applier.change(["buttons", button], 1); 51 | 52 | var updatedKeydownValue = fluid.get(counter, ["model", "eventCount", "keydown", keyCode ]); 53 | jqUnit.assertEquals("The keydown counter value should have been updated for " + keyCode, 1, updatedKeydownValue); 54 | 55 | var updatedKeyupValue = fluid.get(counter, ["model", "eventCount", "keyup", keyCode ]); 56 | jqUnit.assertEquals("The keydown counter value should have been updated for " + keyCode, 1, updatedKeyupValue); 57 | }); 58 | }); 59 | 60 | var axisTestDefs = { 61 | "Horizontal Axis": { 62 | invert: false, 63 | axis: 0, 64 | negativeKey: "ArrowLeft", 65 | positiveKey: "ArrowRight" 66 | }, 67 | "Inverted Horizontal Axis": { 68 | invert: true, 69 | axis: 0, 70 | negativeKey: "ArrowRight", 71 | positiveKey: "ArrowLeft" 72 | }, 73 | "Vertical Axis": { 74 | invert: false, 75 | axis: 1, 76 | negativeKey: "ArrowUp", 77 | positiveKey: "ArrowDown" 78 | }, 79 | "Inverted Vertical Axis": { 80 | invert: true, 81 | axis: 1, 82 | negativeKey: "ArrowDown", 83 | positiveKey: "ArrowUp" 84 | } 85 | }; 86 | 87 | fluid.each(axisTestDefs, function (testDef, testDefKey) { 88 | jqUnit.test(testDefKey, function () { 89 | var negativeModelPath = ["model", "eventCount", "keydown", testDef.negativeKey]; 90 | var positiveModelPath = ["model", "eventCount", "keydown", testDef.positiveKey]; 91 | 92 | counter.focus(); 93 | var inputMapper = gamepad.tests.axisInputMapper({ 94 | invert: testDef.invert 95 | }); 96 | 97 | var initialNegativeValue = fluid.get(counter, negativeModelPath); 98 | jqUnit.assertUndefined("There should be no initial keydown counter value for the negative side of the axis.", initialNegativeValue); 99 | 100 | var initialPositiveValue = fluid.get(counter, positiveModelPath); 101 | jqUnit.assertUndefined("There should be no initial keydown counter value for the positive side of the axis.", initialPositiveValue); 102 | 103 | inputMapper.applier.change(["axes", testDef.axis], -1); 104 | 105 | jqUnit.stop(); 106 | setTimeout(function () { 107 | jqUnit.start(); 108 | var secondNegativeValue = fluid.get(counter, negativeModelPath); 109 | jqUnit.assertEquals("An arrow should have been sent from the negative side of the axis.", 1, secondNegativeValue); 110 | 111 | var secondPositiveValue = fluid.get(counter, positiveModelPath); 112 | jqUnit.assertUndefined("An arrow should not have been sent from the positive side of the axis.", secondPositiveValue); 113 | 114 | inputMapper.applier.change(["axes", testDef.axis], 0); 115 | inputMapper.applier.change(["axes", testDef.axis], 1); 116 | 117 | jqUnit.stop(); 118 | setTimeout(function () { 119 | jqUnit.start(); 120 | var thirdNegativeValue = fluid.get(counter, negativeModelPath); 121 | jqUnit.assertEquals("No additional arrows should have been sent from the negative side of the axis.", 1, thirdNegativeValue); 122 | 123 | var finalPositiveValue = fluid.get(counter, positiveModelPath); 124 | jqUnit.assertEquals("An arrow should have been sent from the positive side of the axis.", 1, finalPositiveValue); 125 | }, 50); 126 | }, 50); 127 | }); 128 | }); 129 | }); 130 | })(fluid, jQuery); 131 | -------------------------------------------------------------------------------- /docs/PUBLISHING.md: -------------------------------------------------------------------------------- 1 | 12 | 13 | # Publishing to the Chrome Web Store 14 | 15 | 1. Prepare the code and the `master` branch. 16 | 1. Update the `version` number in the [manifest](src/manifest.json#L3) and [package](package.json#L3) files. 17 | 1. The project follows [semantic versioning](https://semver.org/), i.e. the version is in the format 18 | "{MAJOR.MINOR.PATCH}[-dev]". For example, "1.0.15" and "1.1.9-dev". 19 | 1. The `version` number in [manifest.json](src/manifest.json) file should be completely numeric. However, other 20 | json files (for example, [package.json](package.json)) may contain alphabets and non-numeric characters with 21 | the version number. 22 | (See [Manifest Version](https://developer.chrome.com/extensions/manifest/version) for more details) 23 | 2. If the release is a **dev release**, use one of the following commands. 24 | 1. For bug fixes with no API or functionality changes, update the PATCH version: `grunt updateVersion --patch` 25 | 2. For minor API changes and functionality updates, update the MINOR version: `grunt updateVersion --minor` 26 | 3. For breaking API changes and new functionalities, update the MAJOR version: `grunt updateVersion --major` 27 | 3. For a **standard release**, add the command-line flag `--standard` to the above commands. 28 | 1. Dev release with PATCH version updates: `grunt updateVersion --patch --standard` 29 | 2. Dev release with MINOR version updates: `grunt updateVersion --minor --standard` 30 | 3. Dev release with MAJOR version updates: `grunt updateVersion --major --standard` 31 | 4. For a transition from a **dev release** to a **standard release** without updating the version number, use the 32 | command: `grunt updateVersion --standard` 33 | 5. Push the changes to the `master` branch. 34 | 2. Ensure that all of the code that should be published has been merged into the `master` branch. 35 | 3. Ensure that the code in the `master` branch is working as expected. 36 | 1. Lint: `npm run lint` 37 | 2. Run tests: `npm test` 38 | 3. Manual test build. 39 | 1. Create a build and load the generated unpacked extension into Chrome. 40 | (Refer to the [Installation](../README.md#installation) section for more details) 41 | 2. Test all of the available configuration options (Action, Speed Factor, et cetera) and ensure that they work 42 | on the browser correctly. 43 | 1. Refresh any browser tabs/windows that were open before installing the extension. 44 | 2. The actions and the associated configuration options should be tested individually and in combination to 45 | ensure that they are working correctly. For example, testing ***Scroll Vertically*** separately, then 46 | changing its ***Speed Factor***, and selecting the ***Invert Action*** option. 47 | 3. Test the same action and the associated configuration options with at least one different button, 48 | thumbstick, and trigger. 49 | 4. Multiple web pages should be tested to ensure that everything works correctly. 50 | 51 | 2. Create the release package: `grunt archive` 52 | 1. This will generate a zip file with the name "build_{tag_name}.zip" (for example, build_v0.1.0-dev.zip), which 53 | will be uploaded to the [Chrome Web Store](https://chrome.google.com/webstore/category/extensions). 54 | 55 | 3. Publish to the [Chrome Web Store](https://chrome.google.com/webstore/category/extensions). 56 | 1. Go to the Developer Dashboard on the Chrome Web Store and log in. 57 | 2. On the Developer Dashboard, click "Edit" (located on the Gamepad Navigator's right-hand side). 58 | 3. On the Gamepad Navigator edit page, click "Upload updated package" and upload the zip created in step 2 above. 59 | 4. Update the "Detailed description" field as necessary. Similar information is contained in this README. 60 | 5. Update the screenshots and videos if necessary. For example, if the images, icons, or the UI of the configuration 61 | panel has been changed. They will need to be the exact size requested. 62 | 6. Click "Preview Changes". 63 | 1. Verify that everything appears correct. Pay particular attention to anything that was changed. For example, 64 | version number/name, description, screenshots, et cetera. 65 | 7. If everything appears correct, publish the changes. 66 | 1. The actual publishing to the Chrome Web Store will take some time and may need to go through a review process. 67 | 68 | 4. Verify the published Gamepad Navigator Chrome extension. 69 | 1. Ensure that the contents of the Gamepad Navigator on the Chrome Web Store appear correct. Double-check the 70 | details like version number/name, descriptions, screenshots, et cetera. 71 | 2. Install the updated extension from the Chrome Web Store, and run through the manual testing again. (See step 72 | 1.3.3 above) 73 | 3. If there are any issues, fix them and repeat the process. 74 | 75 | 5. Create a GitHub release. 76 | 1. Set up the `.gruntfile.env` file. 77 | 1. Generate your GitHub access token. 78 | (Refer to the [GitHub Docs](https://tinyurl.com/yxsbzjme) for more details) 79 | 2. Create a copy of the `.gruntfile.env.default` file at the root level and rename the copy as `.gruntfile.env`. 80 | 3. Open the `.gruntfile.env` file and set the value of the `GITHUB_ACCESS_TOKEN` variable as your GitHub token 81 | generated in the above step. 82 | 2. Create a release on GitHub: `grunt release` 83 | 1. This will prompt the user to enter various details (release title, tag version, description, et cetera) on 84 | their terminal, and will create a new release as per those details. 85 | 3. Once the release is created successfully, announce it where required (for example, the fluid-work mailing list). 86 | -------------------------------------------------------------------------------- /src/js/content_scripts/list-selector.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2023 The Gamepad Navigator Authors 3 | See the AUTHORS.md file at the top-level directory of this distribution and at 4 | https://github.com/fluid-lab/gamepad-navigator/raw/main/AUTHORS.md. 5 | 6 | Licensed under the BSD 3-Clause License. You may not use this file except in 7 | compliance with this License. 8 | 9 | You may obtain a copy of the BSD 3-Clause License at 10 | https://github.com/fluid-lab/gamepad-navigator/blob/main/LICENSE 11 | */ 12 | 13 | (function (fluid) { 14 | "use strict"; 15 | var gamepad = fluid.registerNamespace("gamepad"); 16 | 17 | fluid.defaults("gamepad.ui.listSelector", { 18 | gradeNames: ["gamepad.templateRenderer"], 19 | markup: { 20 | container: "

          %description

          ", 21 | item: "
          %description
          " 22 | }, 23 | selectors: { 24 | items: ".gamepad-list-selector-items", 25 | activeItems: ".gamepad-list-selector-active-item" 26 | }, 27 | model: { 28 | description: "", 29 | focusedItemIndex: 0, 30 | items: {} 31 | }, 32 | invokers: { 33 | handleFocusChange: { 34 | funcName: "gamepad.ui.listSelector.handleFocusChange", 35 | args: ["{that}"] 36 | }, 37 | // This will be added by grades that extend this one. 38 | handleItemClick: { 39 | funcName: "fluid.notImplemented" 40 | }, 41 | handleKeydown: { 42 | funcName: "gamepad.ui.listSelector.handleKeydown", 43 | args: ["{that}", "{arguments}.0"] // event 44 | }, 45 | renderItems: { 46 | funcName: "gamepad.ui.listSelector.renderItems", 47 | args: ["{that}", "{that}.dom.items", "{that}.model.items"] // itemContainer, items 48 | } 49 | }, 50 | modelListeners: { 51 | items: { 52 | func: "{that}.renderItems" 53 | }, 54 | focusedItemIndex: { 55 | excludeSource: "init", 56 | func: "{that}.handleFocusChange" 57 | } 58 | }, 59 | listeners: { 60 | "onCreate.bindKeydown": { 61 | this: "{that}.container", 62 | method: "keydown", 63 | args: ["{that}.handleKeydown"] 64 | }, 65 | "onCreate.renderItems": { 66 | func: "{that}.renderItems" 67 | } 68 | } 69 | }); 70 | 71 | /** 72 | * 73 | * Render a list of items as focusable/clickable/keyboard operable elements. 74 | * @param {Object} that - The listSelector component. 75 | * @param {selectElement} enclosingElement - The select element to populate. 76 | * @param {Object.} items - A hash of items. Each property can either be a string (description) or an object with a description property. 77 | * 78 | */ 79 | gamepad.ui.listSelector.renderItems = function (that, enclosingElement, items) { 80 | enclosingElement.empty(); 81 | 82 | fluid.each(items, function (item, itemIndex) { 83 | var template = that.options.markup.item; 84 | 85 | var itemObject = typeof item === "string" ? { description: item } : item; 86 | itemObject.classNames = itemObject.selected ? " gamepad-list-selector-item--selected" : ""; 87 | var itemMarkup = fluid.stringTemplate(template, itemObject); 88 | var itemElement = $(itemMarkup); 89 | 90 | itemElement.on("click", function (event) { 91 | event.preventDefault(); 92 | 93 | that.handleItemClick(itemIndex, event); 94 | }); 95 | 96 | itemElement.on("keydown", function (event) { 97 | if (["Enter", "Space"].includes(event.code)) { 98 | event.preventDefault(); 99 | that.handleItemClick(itemIndex, event); 100 | } 101 | }); 102 | 103 | enclosingElement.append(itemElement); 104 | 105 | that.handleFocusChange(); 106 | }); 107 | }; 108 | 109 | gamepad.ui.listSelector.handleFocusChange = function (that) { 110 | var activeItems = that.locate("activeItems"); 111 | 112 | var useArrows = fluid.get(that, "model.prefs.arrowModals") || false; 113 | 114 | for (var itemIndex = 0; itemIndex < activeItems.length; itemIndex++) { 115 | var itemElement = activeItems[itemIndex]; 116 | if (useArrows && itemIndex !== that.model.focusedItemIndex) { 117 | itemElement.setAttribute("tabindex", -1); 118 | } 119 | else { 120 | itemElement.setAttribute("tabindex", 0); 121 | } 122 | } 123 | 124 | var toFocus = fluid.get(activeItems, that.model.focusedItemIndex); 125 | if (toFocus) { 126 | toFocus.focus(); 127 | } 128 | }; 129 | 130 | gamepad.ui.listSelector.handleKeydown = function (that, event) { 131 | if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.code)) { 132 | event.preventDefault(); 133 | 134 | var lastItemIndex = (Object.keys(that.model.items).length - 1); 135 | 136 | switch (event.code) { 137 | case "ArrowLeft": 138 | case "ArrowUp": 139 | var previousItemIndex = that.model.focusedItemIndex > 0 ? that.model.focusedItemIndex - 1 : lastItemIndex; 140 | that.applier.change("focusedItemIndex", previousItemIndex); 141 | break; 142 | case "ArrowRight": 143 | case "ArrowDown": 144 | var nextItemIndex = that.model.focusedItemIndex < lastItemIndex ? that.model.focusedItemIndex + 1 : 0; 145 | that.applier.change("focusedItemIndex", nextItemIndex); 146 | break; 147 | } 148 | } 149 | }; 150 | })(fluid); 151 | --------------------------------------------------------------------------------