├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature-request.md ├── pull_request_template.md └── workflows │ └── github-actions.yml ├── .gitignore ├── .npmignore ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── ADRs │ ├── ADR-001-navigation-overrides.md │ ├── ADR-002-rewrite-version-3.md │ ├── ADR-003-handling-duplicate-node-ids.md │ ├── ADR-004-cancellable-movement.md │ └── ADR-005-esm-distribution.md ├── handle-key-event.png ├── index-align.md ├── process-lifecycles.md ├── recipes.md ├── test-diagrams │ ├── fig-1.png │ ├── fig-2.png │ ├── fig-3.png │ ├── fig-4.png │ ├── fig-5.png │ └── fig-6.png └── usage.md ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── build.test.js ├── cancellable-movement.test.js ├── events.test.js ├── focus-on-empty-node.test.js ├── handle-key-event.test.js ├── index-align.test.js ├── index.ts ├── insert-tree.test.js ├── interfaces.ts ├── key-codes.ts ├── lrud.test.js ├── multiple-instance-interaction.test.js ├── on-active-change.test.js ├── overrides.test.js ├── register.test.js ├── scenarios.test.js ├── unregister.test.js ├── utils.test.js └── utils.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "extends": [ 5 | "plugin:@typescript-eslint/recommended", 6 | "standard" 7 | ], 8 | "parserOptions": { 9 | "ecmaVersion": 2018, 10 | "sourceType": "module" 11 | }, 12 | "rules": { 13 | "@typescript-eslint/no-var-requires": "off" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Select '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here, such as specific device information. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Motivation and Context 7 | 8 | 9 | 10 | ## How Has This Been Tested? 11 | 12 | 13 | 14 | 15 | ## Screenshots (if appropriate): 16 | 17 | ## Types of changes 18 | 19 | - [ ] Bug fix (non-breaking change which fixes an issue) 20 | - [ ] New feature (non-breaking change which adds functionality) 21 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 22 | 23 | ## Checklist: 24 | 25 | 26 | - [ ] My code follows the code style of this project. 27 | - [ ] My change requires a change to the documentation. 28 | - [ ] I have updated the documentation accordingly. 29 | - [ ] I have read the **CONTRIBUTING** document. 30 | - [ ] I have added tests to cover my changes. 31 | - [ ] All new and existing tests passed. 32 | -------------------------------------------------------------------------------- /.github/workflows/github-actions.yml: -------------------------------------------------------------------------------- 1 | name: github-actions 2 | on: [push, pull_request] 3 | jobs: 4 | check-tests-pass: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - uses: actions/setup-node@v3 9 | with: 10 | node-version: '14' 11 | - run: npm ci 12 | - run: npm run test 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .rpt2_cache 3 | dist 4 | .idea 5 | 6 | # Created by https://www.gitignore.io/api/node,macos 7 | 8 | ### macOS ### 9 | *.DS_Store 10 | .AppleDouble 11 | .LSOverride 12 | 13 | # Thumbnails 14 | ._* 15 | 16 | # Files that might appear in the root of a volume 17 | .DocumentRevisions-V100 18 | .fseventsd 19 | .Spotlight-V100 20 | .TemporaryItems 21 | .Trashes 22 | .VolumeIcon.icns 23 | .com.apple.timemachine.donotpresent 24 | 25 | # Directories potentially created on remote AFP share 26 | .AppleDB 27 | .AppleDesktop 28 | Network Trash Folder 29 | Temporary Items 30 | .apdisk 31 | 32 | ### Node ### 33 | # Logs 34 | logs 35 | *.log 36 | npm-debug.log* 37 | yarn-debug.log* 38 | yarn-error.log* 39 | 40 | # Runtime data 41 | pids 42 | *.pid 43 | *.seed 44 | *.pid.lock 45 | 46 | # Directory for instrumented libs generated by jscoverage/JSCover 47 | lib-cov 48 | 49 | # Coverage directory used by tools like istanbul 50 | coverage 51 | 52 | # nyc test coverage 53 | .nyc_output 54 | 55 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 56 | .grunt 57 | 58 | # Bower dependency directory (https://bower.io/) 59 | bower_components 60 | 61 | # node-waf configuration 62 | .lock-wscript 63 | 64 | # Compiled binary addons (http://nodejs.org/api/addons.html) 65 | build/Release 66 | 67 | # Dependency directories 68 | node_modules/ 69 | jspm_packages/ 70 | 71 | # Typescript v1 declaration files 72 | typings/ 73 | 74 | # Optional npm cache directory 75 | .npm 76 | 77 | # Optional eslint cache 78 | .eslintcache 79 | 80 | # Optional REPL history 81 | .node_repl_history 82 | 83 | # Output of 'npm pack' 84 | *.tgz 85 | 86 | # Yarn Integrity file 87 | .yarn-integrity 88 | 89 | # dotenv environment variables file 90 | .env 91 | 92 | 93 | # End of https://www.gitignore.io/api/node,macos 94 | 95 | umd 96 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbc/lrud/92c050db5294f4d7d074b160fbbc259ae679a6e3/.npmignore -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # All changes should be reviewed by Lovely Horse 2 | * @bbc/lovely-horse -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Welcome to `lrud`, "a spatial navigation library for devices with input via directional controls". Thanks for your interest in contributing. 4 | 5 | Here's how to get involved... 6 | 7 | ## Reporting Issues 8 | Before opening a new issue, first check that there is not already an [open issue or Pull Request](https://github.com/bbc/lrud/issues?utf8=%E2%9C%93&q=is%3Aopen) that addresses it. 9 | 10 | If there is, make relevant comments and add your reaction. Use a reaction in place of a "+1" comment: 11 | * 👍 - upvote 12 | * 👎 - downvote 13 | 14 | If you cannot find an existing issue that describes your bug or feature, create a new issue using the guidelines below. 15 | 16 | 1. Pick an appropriate template for the type of issue [from here](https://github.com/bbc/lrud/issues/choose) 17 | 2. Provide as much detail as possible 18 | 3. Follow your issue in the issue tracking workflow 19 | 20 | ## Contributing Code 21 | If you do not have push access to the repository, please [fork it](https://help.github.com/en/articles/fork-a-repo). You should then work on your own `master` branch. 22 | 23 | Otherwise, you may clone this repository and create a working branch with a _kebab-case_ name reflecting what you are working on (e.g. `fix-the-thing`). 24 | 25 | Follow the setup instructions in the [README](../README.md). 26 | 27 | Ensure all your code is thoroughly tested and that this testing is detailed in the pull request. 28 | 29 | ## Pull Request Process 30 | 1. Read and complete all relevant sections of the PR template 31 | 2. You may merge the Pull Request once you have the sign-off of two other developers -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LRUD ![Build Status](https://github.com/bbc/lrud/actions/workflows/github-actions.yml/badge.svg?branch=master) 2 | 3 | A spatial navigation library for devices with input via directional controls 4 | 5 | ## :nut_and_bolt: Maintenance Mode :nut_and_bolt: 6 | LRUD is now in maintenance mode; existing bugs will be fixed, but new features will not be added. A new library called [LRUD Spatial](https://github.com/bbc/lrud-spatial) is now available to the Open Source community. 7 | 8 | ## Upgrading from V2 9 | 10 | **v3 is a major rewrite, covering many new features. However, it unfortunately breaks some backwards compatibility.** 11 | 12 | We are currently in the process of writing more detailed docs for an upgrade process. However, the main things to note at the minute at; 13 | 14 | - changes in events, which ones are emitted and what they are emitted with 15 | - removal of `grid` in favour of `isIndexAligned` behaviour 16 | 17 | ## Getting Started 18 | 19 | ```bash 20 | git clone git@github.com:bbc/lrud.git lrud 21 | cd lrud 22 | npm install 23 | ``` 24 | 25 | Lrud is written in [Typescript](https://www.typescriptlang.org/) and makes use of [mitt](https://github.com/developit/mitt). 26 | 27 | ## Usage 28 | 29 | ```bash 30 | npm install lrud 31 | ``` 32 | 33 | ```js 34 | const { Lrud } = require('Lrud') 35 | 36 | // create an instance, register some nodes and assign a default focus 37 | var navigation = new Lrud() 38 | navigation 39 | .registerNode('root', { orientation: 'vertical' }) 40 | .registerNode('item-a', { parent: 'root', isFocusable: true }) 41 | .registerNode('item-b', { parent: 'root', isFocusable: true }) 42 | .assignFocus('item-a') 43 | 44 | // handle a key event 45 | document.addEventListener('keypress', (event) => { 46 | navigation.handleKeyEvent(event) 47 | }); 48 | 49 | // Lrud will output an event when it handles a move 50 | navigation.on('move', (moveEvent) => { 51 | myApp.doSomethingOnNodeFocus(moveEvent.enter) 52 | }) 53 | ``` 54 | 55 | See [usage docs](./docs/usage.md) for details full API details. 56 | 57 | For more "full" examples, covering common use cases, check [the recipes](./docs/recipes.md) 58 | 59 | ## Running the tests 60 | 61 | All code is written in Typescript, so we make use of a `tsconfig.json` and `jest.config.js` to ensure tests run correctly. 62 | 63 | Test files are split up fairly arbitrarily, aiming to have larger sets of tests broken into their own file. 64 | 65 | ```bash 66 | npm test 67 | ``` 68 | 69 | To run a specific test file, use `npx jest` from the project root. 70 | 71 | ```bash 72 | npx jest src/lrud.test.js 73 | ``` 74 | 75 | You can also run all the tests with verbose output. This is useful for listing out test scenarios to ensure that behaviour is covered. 76 | 77 | ```bash 78 | npm run test:verbose 79 | ``` 80 | 81 | You can also run all the tests with coverage output 82 | 83 | ```bash 84 | npm run test:coverage 85 | ``` 86 | 87 | Several of the tests have associated diagrams, in order to better explain what is being tested. These can be found in `./docs/test-diagrams`. 88 | 89 | We also have a specific test file (`src/build.test.js`) in order to ensure that we haven't broken the Typescript/rollup.js build. 90 | 91 | ## Versioning 92 | 93 | ```bash 94 | npm version 95 | npm publish 96 | ``` 97 | 98 | ## Built with 99 | 100 | - [Typescript](https://www.typescriptlang.org/) 101 | - [rollup.js](https://rollupjs.org/) 102 | - [mitt](https://github.com/developit/mitt) 103 | 104 | ## Inspiration 105 | 106 | * [BBC - TV Application Layer (TAL)](http://bbc.github.io/tal/widgets/focus-management.html) 107 | * [Netflix - Pass the Remote](https://medium.com/netflix-techblog/pass-the-remote-user-input-on-tv-devices-923f6920c9a8) 108 | * [Mozilla - Implementing TV remote control navigation](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox_OS_for_TV/TV_remote_control_navigation) 109 | 110 | ## Alternatives 111 | 112 | * [tal](https://github.com/bbc/tal) 113 | * [react-tv-navigation](https://github.com/react-tv/react-tv-navigation) 114 | * [react-key-navigation](https://github.com/dead/react-key-navigation) 115 | * [js-spatial-navigation](https://github.com/luke-chang/js-spatial-navigation) 116 | 117 | # License 118 | 119 | 120 | LRUD is part of the BBC TAL libraries, and available to everyone under the terms of the Apache 2 open source licence (Apache-2.0). Take a look at the LICENSE file in the code. 121 | 122 | Copyright (c) 2018 BBC 123 | 124 | -------------------------------------------------------------------------------- /docs/ADRs/ADR-001-navigation-overrides.md: -------------------------------------------------------------------------------- 1 | ## ADR 001 - Adding Navigation Overrides 2 | 3 | Originally Added: Feb 28, 2019 4 | 5 | ### Context 6 | 7 | LRUD navigation trees already work very well when every component you need to render is included inside the same tree. e.g if you can render an entire page and all elements are inside the same tree, eventually pointing to the same root container. 8 | 9 | The current SSR request/response system has to build multiple LRUD trees, however. 10 | 11 | This is because the Partial Page Update System (PPUS) inside our mountains relys on a "Region" system (where each Region defines both its markup and LRUD navigation tree) and the ability to "hot swap" these Regions in and out of a page, along with their navigation nodes. 12 | 13 | When new Regions are loaded in, there is a "primary tree" that is rebuilt from all the navigation nodes of each loaded-in Region. 14 | 15 | Because this "primary tree" is made up purely from the concatonated nodes of the Region trees, none of the Regions have information about how they relate to _each other_. For example, no 2 Regions can currently be horizontally aligned next to each other. 16 | 17 | Alongside this, there are _also_ situations arising where desired journeys around the page don't align exactly with the layout. For example, any given page may want to land on node "X" from node "Y" when the user presses "UP", even through in the navigation tree those 2 nodes are horizontally aligned next to each other (product/UX requirements, etc.) 18 | 19 | ### Decision 20 | 21 | We want to implement an `overrides` system into an LRUD instance. 22 | 23 | The overrides can live alongside the `navigation` object. 24 | 25 | It will be an array of objects, each representing an override for a direction from a node to another node. 26 | 27 | For example, a given override may represent "when on node 'X', and the user presses 'UP', go to node 'Y'" 28 | 29 | Because the overrides live as a separate data item on an instance, and are checked at run time, they can be updated/added/removed as and when needed, based on app state. 30 | 31 | ### Status 32 | 33 | Approved 34 | 35 | ### Consequences 36 | 37 | - LRUD now "handles" more information than just a navigation tree. This is extra complexity, and as it is an extra data item, any LRUD implementation that currently moves data around will also have to move around the `overrides` 38 | 39 | - naive overrides can cause unexpected behaviour in LRUD itself. For example, setting an override target to `X` will _actually_ cause the final focus to end up on the first focusable child of `X`. While this does make sense, it can be somewhat unintuitive at first 40 | -------------------------------------------------------------------------------- /docs/ADRs/ADR-002-rewrite-version-3.md: -------------------------------------------------------------------------------- 1 | ## ADR 002 - Adding Navigation Overrides 2 | 3 | ### Context 4 | 5 | After much usage of LRUD V2, we (Lovely Horse 🐴) have ended up with a laundry list of things we want LRUD to do that V2 doesn't support. We also have a desire for a more maintainable codebase that utilises more up-to-date terminology for the expected behaviour of LRUD. 6 | 7 | The list of desired functionality currently sits at; 8 | 9 | - a real tree structure 10 | - cleaner/easier to understand "grid" functionality 11 | - supporting a concept of "column span"/"column width" 12 | - real definition of which node is the “root” node 13 | - all focusable nodes to maintain an "index" for easier understanding or sorting 14 | - better handling of unregistering 15 | 16 | ### Decision 17 | 18 | We have decided to re-write LRUD from the ground up, maintaining many of the concepts from V2, while addressing the list of desired functionality. 19 | 20 | This will also give us an opportunity to re-write LRUD into Typescript, further increasing the maintainability of the codebase in future. 21 | 22 | ### Status 23 | 24 | Approved 25 | 26 | ### Consequences 27 | 28 | - user land usages of LRUD will need to update their code in order to make use of the new version. We are planning to keep breaking changes to a minimum, but some changes will be necessary. 29 | 30 | - slightly increased library size, affecting response payload sizes. The increase in size is small enough (an increase of 2.6kb when minified) that we deem this acceptable. Furthermore, the changes mean that current "workaround" code in service land can be removed, reducing payload size in other areas. 31 | 32 | - slightly increased runtime computation. Usage of a real tree in memory requires extra computation. Dedicated testing will take place to ensure LRUD is still performant enough on low powered devices, but initial testing of 92 test cases in 2.4s suggests this is well within limits. 33 | 34 | ### Further Reading 35 | 36 | - [Paper Doc discussing what LRUD is and why we want to change some things](https://paper.dropbox.com/doc/SSR-Controller-Module-LRUD-V3--Aca6ZBsM4Uv8zEN44j5o4TsvAg-y0v9YqarEOXNP7R2151RK) -------------------------------------------------------------------------------- /docs/ADRs/ADR-003-handling-duplicate-node-ids.md: -------------------------------------------------------------------------------- 1 | ## ADR 003 - Handling duplicate node IDs 2 | 3 | ### Context 4 | 5 | The registration of a node requires an ID to be given. 6 | 7 | This ID is then used internally in LRUD for retrieving/manipulating the specific Node. 8 | 9 | IDs are also "surfaced" and used as part of the API, in functions such as `getNode()`. 10 | 11 | Questions have been raised around whether or not LRUD should support _duplicate_ IDs. 12 | 13 | **Is it technically feasible?** 14 | 15 | Yes. The actual "internal" ID of a node could be the combination of its own ID and all its parents. This means we could handle duplicate IDs as long as no 2 IDs were both duplicates _and_ siblings. 16 | 17 | OR, we could make it so all IDs that are registered are actually registered as the ID concatonated with a UUID, etc. 18 | 19 | So the question is, should we? 20 | 21 | **Pros** 22 | 23 | - the data that is used to build a navigation tree can be even dumber, in that it can allow internal IDs in itself. For example, 2 footers on the same page but under different parents would be valid, e.g 24 | 25 | ```js 26 | nav.registerNode('footer', { parent: 'left_column' }) 27 | nav.registerNode('footer', { parent: 'right_column' }) 28 | ``` 29 | 30 | **Cons** 31 | 32 | - Implicit confusion. By allowing duplicate IDs, the tree is now filled with multiple of the same ID at different levels. This will add confusion to any process attempting to parse the tree (both human and machine process) 33 | - Complicates the surface of the API. For example, the user can currently call `getNode()`. If duplicate IDs exist in the tree, these methods must handle this. 34 | - Complicates service-land code that makes use of LRUD. Lots of code exists in services that do something along the lines of "register a node with ID x and then later on, use the ID of X to get that node back out". This is done using calls such as `getNode()` (as explained above). This means service land will now need to start keeping track of which nodes live under which parents, etc. which immediately becomes a huge headache. 35 | - Complicates the internal state and processes of LRUD. It means that the internal pathing mechanisms and state of LRUD must complicate to handle duplicate IDs across different parents. 36 | 37 | ### Decision 38 | 39 | We have decided that until the need arrises, we will _not_ allow duplicate IDs. 40 | 41 | If `registerNode()` is called with an ID that has already been registered against the navigation instance, an exception will be thrown. 42 | 43 | ### Status 44 | 45 | Approved 46 | 47 | ### Consequences 48 | 49 | As discussed above, it means the registration data and processes of registering must ensure that no duplicate IDs are used. 50 | -------------------------------------------------------------------------------- /docs/ADRs/ADR-004-cancellable-movement.md: -------------------------------------------------------------------------------- 1 | ## ADR 003 - Handling duplicate node IDs 2 | 3 | ### Context 4 | 5 | There has been interest from other teams using LRUD that the ability to _cancel_ a movement as it's happening would be useful. 6 | 7 | For example, focussing on a node and pressing down would ordinarily take you to the specified node, but perhaps the developer wants to run some business logic at that point that would mean that _actually we don't want the move to happen._ 8 | 9 | ### Decision 10 | 11 | We have decided to implement this feature in LRUD. It will be useful for the specific team that requested the feature, and as further discussions have happened, all interested parties agree that there are valid use cases in many different scenarios for such a feature. 12 | 13 | ### Status 14 | 15 | Approved 16 | 17 | ### Consequences 18 | 19 | It makes LRUD internally more complex (and alongside that makes the final bundle larger too). However, it is only marginally increasing the bundle size, and we feel the complexity is managable and well understood. 20 | 21 | ### Further Reading 22 | 23 | - [Github issue discussing topic of cancellable movement](https://github.com/bbc/lrud/issues/25) 24 | -------------------------------------------------------------------------------- /docs/ADRs/ADR-005-esm-distribution.md: -------------------------------------------------------------------------------- 1 | ## ADR 005 - ESM Distribution 2 | 3 | ### Context 4 | 5 | We want to use Lrud as a `tap-static` module, which requires converting to `amd` format. To do so we use `esm-2-amd`, which means we need to distribute lrud as ESM. Currently it's not possible as it's only built to CJS. 6 | 7 | ### Decision 8 | 9 | The distribution folder structure will change with two subfolders, `cjs` and `esm`. The type definitions will also be distributed in another subfolder, `types`. Rollup will still be used to create the CJS and ESM format. 10 | 11 | ### Status 12 | 13 | Approved 14 | 15 | ### Consequences 16 | 17 | The final distribution size will increase. 18 | 19 | This change won't affect how current users consume lrud, as the package `main` is updated to point to the CJS min file. 20 | -------------------------------------------------------------------------------- /docs/handle-key-event.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbc/lrud/92c050db5294f4d7d074b160fbbc259ae679a6e3/docs/handle-key-event.png -------------------------------------------------------------------------------- /docs/index-align.md: -------------------------------------------------------------------------------- 1 | # Index Alignment 2 | 3 | ## What? 4 | 5 | Index alignment is used to replicate the behaviour of a "grid" - consider the following scenario: 6 | 7 | - there are 2 rows of items, each having 2 items. 8 | - the user is focused on the 2nd item of the 1st row 9 | - when they press "down", the typical behaviour would be to go to the 1st item of the 2nd row 10 | - This is because row 2 has never been focused before, and thus its `activeChild` is its first child. 11 | 12 | In this scenario, we want the focus to _actually_ go to the **2nd** item of the 2nd row. e.g. we want the **indexes to be aligned between the rows**. 13 | 14 | ## How? 15 | 16 | Registering the scenario above as a Lrud tree would look like the following: 17 | 18 | ```js 19 | const { Lrud } = require('lrud'); 20 | 21 | const navigation = new Lrud(); 22 | 23 | navigation 24 | .register('root', { orientation: 'vertical' }) 25 | .register('row-1', { orientation: 'horizontal' }) 26 | .register('row-1_item-1', { isFocusable: true, parent: 'row-1' }) 27 | .register('row-1_item-2', { isFocusable: true, parent: 'row-1' }) 28 | .register('row-2', { orientation: 'horizontal' }) 29 | .register('row-2_item-1', { isFocusable: true, parent: 'row-2' }) 30 | .register('row-2_item-2', { isFocusable: true, parent: 'row-2' }) 31 | ``` 32 | 33 | In order to achieve our index alignment that we desire between the 2 rows, all we need to do is tag their parent (in this case, the `root` node) as `isIndexAlign: true`. 34 | 35 | ```js 36 | navigation 37 | .register('root', { orientation: 'vertical', isIndexAlign: true }) 38 | .register('row-1', { orientation: 'horizontal' }) 39 | .register('row-1_item-1', { isFocusable: true, parent: 'row-1' }) 40 | .register('row-1_item-2', { isFocusable: true, parent: 'row-1' }) 41 | .register('row-2', { orientation: 'horizontal' }) 42 | .register('row-2_item-1', { isFocusable: true, parent: 'row-2' }) 43 | .register('row-2_item-2', { isFocusable: true, parent: 'row-2' }) 44 | ``` 45 | 46 | Now, when the user enters the 2nd row (whose active child, as its never been focused on, is its first item) they will instead focus on the node that has the same index as the node they just left. 47 | 48 | ## Column spans? 49 | 50 | Lrud supports the idea of a "column span". This is useful for situations where there are rows of content with different numbers of items. 51 | 52 | Consider the following Lrud navigation: 53 | 54 | ```js 55 | navigation 56 | .register('root', { orientation: 'vertical', isIndexAlign: true }) 57 | .register('row-1', { orientation: 'horizontal' }) 58 | .register('row-1_item-1', { isFocusable: true, parent: 'row-1' }) 59 | .register('row-1_item-2', { isFocusable: true, parent: 'row-1' }) 60 | .register('row-1_item-3', { isFocusable: true, parent: 'row-1' }) 61 | .register('row-1_item-4', { isFocusable: true, parent: 'row-1' }) 62 | .register('row-2', { orientation: 'horizontal' }) 63 | .register('row-2_item-1', { isFocusable: true, parent: 'row-2' }) 64 | .register('row-2_item-2', { isFocusable: true, parent: 'row-2' }) 65 | ``` 66 | 67 | When focused on `row-1_item-2` and handling a "down" event, the naive behaviour would be to focus on `row-2_item-2`. 68 | 69 | However, perhaps _visually_ each item on row 2 takes up the effective width of 2 items on row 1. 70 | 71 | In order to make Lrud understand that these indexes need to align, the following change can be made: 72 | 73 | ```js 74 | navigation 75 | .register('root', { orientation: 'vertical', isIndexAlign: true }) 76 | .register('row-1', { orientation: 'horizontal' }) 77 | .register('row-1_item-1', { isFocusable: true, parent: 'row-1', index: 0 }) 78 | .register('row-1_item-2', { isFocusable: true, parent: 'row-1', index: 1 }) 79 | .register('row-1_item-3', { isFocusable: true, parent: 'row-1', index: 2 }) 80 | .register('row-1_item-4', { isFocusable: true, parent: 'row-1', index: 3 }) 81 | .register('row-2', { orientation: 'horizontal' }) 82 | .register('row-2_item-1', { isFocusable: true, parent: 'row-2', indexRange: [0, 1] }) 83 | .register('row-2_item-2', { isFocusable: true, parent: 'row-2', indexRange: [2, 3] }) 84 | ``` 85 | 86 | Note the `indexRange` values on the 2nd row items. Every definition of an `indexRange` should be an array with 2 values - the inclusive lower and upper bound of indexes that this node is covering. 87 | 88 | ## Nested Grids 89 | 90 | LRUD has limited support for nested grid functionality. 91 | 92 | A nested grid is where we want 2 grids, that are each behaving as grids independently, to be index aligned _between_ each other. 93 | 94 | Consider the following scenario. 95 | 96 | ```js 97 | navigation.registerNode('root', { orientation: 'horizontal' }) 98 | 99 | navigation 100 | .registerNode('grid1', { parent: 'root', orientation: 'vertical', isIndexAlign: true }) 101 | .registerNode('grid1_row1', { parent: 'grid1', orientation: 'horizontal' }) 102 | .registerNode('grid1_item1', { parent: 'grid1_row1', isFocusable: true }) 103 | .registerNode('grid1_item2', { parent: 'grid1_row1', isFocusable: true }) 104 | .registerNode('grid1_item3', { parent: 'grid1_row1', isFocusable: true }) 105 | .registerNode('grid1_row2', { parent: 'grid1', orientation: 'horizontal' }) 106 | .registerNode('grid1_item4', { parent: 'grid1_row2', isFocusable: true }) 107 | .registerNode('grid1_item5', { parent: 'grid1_row2', isFocusable: true }) 108 | .registerNode('grid1_item6', { parent: 'grid1_row2', isFocusable: true }) 109 | 110 | navigation 111 | .registerNode('grid2', { parent: 'root', orientation: 'vertical', isIndexAlign: true }) 112 | .registerNode('grid2_row1', { parent: 'grid2', orientation: 'horizontal' }) 113 | .registerNode('grid2_item1', { parent: 'grid2_row1', isFocusable: true }) 114 | .registerNode('grid2_item2', { parent: 'grid2_row1', isFocusable: true }) 115 | .registerNode('grid2_item3', { parent: 'grid2_row1', isFocusable: true }) 116 | .registerNode('grid2_row2', { parent: 'grid2', orientation: 'horizontal' }) 117 | .registerNode('grid2_item4', { parent: 'grid2_row2', isFocusable: true }) 118 | .registerNode('grid2_item5', { parent: 'grid2_row2', isFocusable: true }) 119 | .registerNode('grid2_item6', { parent: 'grid2_row2', isFocusable: true }) 120 | ``` 121 | 122 | We have 2 "grids", each with 2 rows and 3 items per row. As the `root` node is `orientation: 'horizontal'`, these 2 grids would be sat next to each other horizontally. 123 | 124 | If the user was focused on `grid1_item6` (the last item of the 2nd row of the grid on the left) and the user pressed `right`, ordinally, LRUD would then put your focus onto `grid2_item1` (the first focusable `activeChild` of the grid on the right). 125 | 126 | But what if we wanted the grids themselves to be index aligned between each other? 127 | 128 | It may make sense to _instead_ have the focus land on `grid2_item4` (the _first_ item of 2nd row of the grid on the right). This would be as though the users focus had "hopped over" to the 2nd grid, and landed in the "closest" place. 129 | 130 | In order to make this happen, all we have to do is add an `isIndexAlign: true` to the root node (the parent of the 2 grids). 131 | 132 | ### Nested Grid Limitations 133 | 134 | Nested grids currently only work 1 level deep, and support for nested grids working with index ranges varies from minimal to untested. 135 | 136 | If you encounter a scenario with a nested grid that you think should work and isn't doing, feel free to open a Github issue. -------------------------------------------------------------------------------- /docs/process-lifecycles.md: -------------------------------------------------------------------------------- 1 | # Process Lifecycles 2 | 3 | ## Handle Key Event 4 | 5 | This diagram shows the control flow for the handling of a key event in LRUD. 6 | 7 | ![Handle key event lifecycle](./handle-key-event.png "Handle key event lifecycle") -------------------------------------------------------------------------------- /docs/recipes.md: -------------------------------------------------------------------------------- 1 | # Recipes 2 | 3 | Below you can find the Lrud setup for various common scenarios. Hopefully these should help illuminate various points of registering nodes in order to get the desired behaviour. 4 | 5 | ## Recipe 1 - A "keyboard" 6 | 7 | A miniature version of a search keyboard - utilising a grid and some buttons that are wider than others. 8 | 9 | ```js 10 | navigation.registerNode('keyboard', { orientation: 'vertical', isIndexAlign: true }) 11 | navigation 12 | .registerNode('row-1', { orientation: 'horizontal' }) // note we don't explicitly set the parent - so Lrud assumes the root node 13 | .registerNode('A', { parent: 'row-1', isFocusable: true }) 14 | .registerNode('B', { parent: 'row-1', isFocusable: true }) 15 | .registerNode('C', { parent: 'row-1', isFocusable: true }) 16 | .registerNode('D', { parent: 'row-1', isFocusable: true }) 17 | .registerNode('E', { parent: 'row-1', isFocusable: true }) 18 | .registerNode('F', { parent: 'row-1', isFocusable: true }) 19 | 20 | navigation 21 | .registerNode('row-2', { orientation: 'horizontal' }) 22 | .registerNode('G', { parent: 'row-2', isFocusable: true }) 23 | .registerNode('H', { parent: 'row-2', isFocusable: true }) 24 | .registerNode('I', { parent: 'row-2', isFocusable: true }) 25 | .registerNode('J', { parent: 'row-2', isFocusable: true }) 26 | .registerNode('K', { parent: 'row-2', isFocusable: true }) 27 | .registerNode('L', { parent: 'row-2', isFocusable: true }) 28 | 29 | navigation 30 | .registerNode('row-3', { orientation: 'horizontal' }) 31 | .registerNode('Space', { parent: 'row-3', indexRange: [1, 3], isFocusable: true }) // these buttons are wider, so are given index ranges 32 | .registerNode('Delete', { parent: 'row-3', indexRange: [4, 6], isFocusable: true }) // these buttons are wider, so are given index ranges 33 | ``` 34 | 35 | ## Recipe 2 - A series of wrapping rows 36 | 37 | Representing multiple horizontal rows of content that a user could be browsing, where navigating past the end of a row should return the user focus to the start of that row. _But_, the rows are _not_ a grid, and going down from the middle of one row should put the user focus to the start of the next row. 38 | 39 | ```js 40 | navigation.registerNode('root', { orientation: 'vertical' }) 41 | 42 | navigation 43 | .registerNode('row-1', { parent: 'root', orientation: 'horizontal', isWrapping: true }) 44 | .registerNode('row-1-item-1', { parent: 'row-1', isFocusable: true }) 45 | .registerNode('row-1-item-2', { parent: 'row-1', isFocusable: true }) 46 | 47 | navigation 48 | .registerNode('row-2', { parent: 'root', orientation: 'horizontal', isWrapping: true }) 49 | .registerNode('row-2-item-1', { parent: 'row-2', isFocusable: true }) 50 | .registerNode('row-2-item-2', { parent: 'row-2', isFocusable: true }) 51 | ``` 52 | 53 | ## Recipe 3 - Moving between nested `isIndexAlign: true` nodes e.g nested grids 54 | 55 | See `docs/test-diagrams/fig-2.png` for the diagram of how this looks rendered out. 56 | 57 | We sometimes want to have 2 nodes that are affected by their parent's `isIndexAlign: true`, that are _themselves_ also `isIndexAlign: true`. 58 | 59 | This could be thought of as "nested grids". 60 | 61 | ```js 62 | const navigation = new Lrud() 63 | 64 | navigation.registerNode('root', { orientation: 'vertical', isIndexAlign: true }) 65 | 66 | navigation 67 | .registerNode('grid-a', { parent: 'root', orientation: 'vertical', isIndexAlign: true }) 68 | .registerNode('grid-a-row-1', { parent: 'grid-a', orientation: 'horizontal' }) 69 | .registerNode('grid-a-row-1-col-1', { parent: 'grid-a-row-1', isFocusable: true }) 70 | .registerNode('grid-a-row-1-col-2', { parent: 'grid-a-row-1', isFocusable: true }) 71 | .registerNode('grid-a-row-2', { parent: 'grid-a', orientation: 'horizontal' }) 72 | .registerNode('grid-a-row-2-col-1', { parent: 'grid-a-row-2', isFocusable: true }) 73 | .registerNode('grid-a-row-2-col-2', { parent: 'grid-a-row-2', isFocusable: true }) 74 | 75 | navigation 76 | .registerNode('grid-b', { parent: 'root', orientation: 'vertical', isIndexAlign: true }) 77 | .registerNode('grid-b-row-1', { parent: 'grid-b', orientation: 'horizontal' }) 78 | .registerNode('grid-b-row-1-col-1', { parent: 'grid-b-row-1', isFocusable: true }) 79 | .registerNode('grid-b-row-1-col-2', { parent: 'grid-b-row-1', isFocusable: true }) 80 | .registerNode('grid-b-row-2', { parent: 'grid-b', orientation: 'horizontal' }) 81 | .registerNode('grid-b-row-2-col-1', { parent: 'grid-b-row-2', isFocusable: true }) 82 | .registerNode('grid-b-row-2-col-2', { parent: 'grid-b-row-2', isFocusable: true }) 83 | ``` 84 | 85 | ## Recipe 4 - Cancelling moves due to external business logic 86 | 87 | Perhaps you have a system where you only want a user to be able to navigate to a specific section of a page/app if some external logic authorizes and allows that move. 88 | 89 | Thanks to `shouldCancel` functions, we can block that movement. 90 | 91 | ```js 92 | const shouldCancelEnterItem = () => { 93 | return !userPermissions.canSelectItem(); 94 | } 95 | 96 | navigation.registerNode('root', { orientation: 'horizontal' }) 97 | 98 | navigation 99 | .registerNode('left-col', { orientation: 'vertical' }) 100 | .registerNode('item-1', { parent: 'left-col', isFocusable: true }) 101 | .registerNode('item-2', { parent: 'left-col', isFocusable: true }) 102 | 103 | navigation 104 | .registerNode('right-col', { orientation: 'vertical' }) 105 | .registerNode('item-a', { parent: 'right-col', shouldCancelEnter: shouldCancelEnterItem, isFocusable: true }) 106 | .registerNode('item-b', { parent: 'right-col', shouldCancelEnter: shouldCancelEnterItem, isFocusable: true }) 107 | 108 | navigation.assignFocus('item-1') 109 | ``` 110 | 111 | With the setup above, if the user attempted to select `item-a`, or `item-b`, `shouldCancelEnterItem()` would be run. If this function returned `true`, that movement would be blocked, and focus would remain on `item-1`. 112 | 113 | ## Recipe 5 - Error Modal Popup 114 | 115 | Leaving `orientation` undefined in parent node allows creating closed boxes, from which focus can not "jump out". It's the best to think about modal popups with semi transparent overlay here. It may contain Ok/Cancel buttons and focus must be moved only around those buttons. The rest of the page is still visible in the background and LRUD navigation tree may stay untouched. 116 | 117 | Following example simulates such popup. Press `enter` on any child node of `mainPage` to move focus to `errorPopup` and on any popup button to move focus back to `mainPage`. Note that you can navigate only within the `mainPage` or `errorPopup`. You cannot move focus between those two regions without pressing `enter`. 118 | 119 | ```js 120 | const toMainPage = () => navigation.assignFocus('mainPage') 121 | const toErrorPopup = () => navigation.assignFocus('errorPopup') 122 | 123 | navigation.registerNode('root', { orientation: undefined }) 124 | 125 | navigation.registerNode('mainPage', { parent: 'root', orientation: 'vertical', isIndexAlign: true }) 126 | .registerNode('row0', { parent: 'mainPage', orientation: 'horizontal' }) 127 | .registerNode('card0', { parent: 'row0', isFocusable: true, onSelect: toErrorPopup }) 128 | .registerNode('card1', { parent: 'row0', isFocusable: true, onSelect: toErrorPopup }) 129 | .registerNode('card2', { parent: 'row0', isFocusable: true, onSelect: toErrorPopup }) 130 | .registerNode('row1', { parent: 'mainPage', orientation: 'horizontal' }) 131 | .registerNode('card3', { parent: 'row1', isFocusable: true, onSelect: toErrorPopup }) 132 | .registerNode('card4', { parent: 'row1', isFocusable: true, onSelect: toErrorPopup }) 133 | .registerNode('card5', { parent: 'row1', isFocusable: true, onSelect: toErrorPopup }) 134 | 135 | navigation.registerNode('errorPopup', { parent: 'root', orientation: 'horizontal' }) 136 | .registerNode('okButton', { parent: 'errorPopup', isFocusable: true, onSelect: toMainPage }) 137 | .registerNode('cancelButon', { parent: 'errorPopup', isFocusable: true, onSelect: toMainPage }) 138 | 139 | navigation.assignFocus('card0') 140 | ``` 141 | -------------------------------------------------------------------------------- /docs/test-diagrams/fig-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbc/lrud/92c050db5294f4d7d074b160fbbc259ae679a6e3/docs/test-diagrams/fig-1.png -------------------------------------------------------------------------------- /docs/test-diagrams/fig-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbc/lrud/92c050db5294f4d7d074b160fbbc259ae679a6e3/docs/test-diagrams/fig-2.png -------------------------------------------------------------------------------- /docs/test-diagrams/fig-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbc/lrud/92c050db5294f4d7d074b160fbbc259ae679a6e3/docs/test-diagrams/fig-3.png -------------------------------------------------------------------------------- /docs/test-diagrams/fig-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbc/lrud/92c050db5294f4d7d074b160fbbc259ae679a6e3/docs/test-diagrams/fig-4.png -------------------------------------------------------------------------------- /docs/test-diagrams/fig-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbc/lrud/92c050db5294f4d7d074b160fbbc259ae679a6e3/docs/test-diagrams/fig-5.png -------------------------------------------------------------------------------- /docs/test-diagrams/fig-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbc/lrud/92c050db5294f4d7d074b160fbbc259ae679a6e3/docs/test-diagrams/fig-6.png -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | LRUD handles the registering of new navigation nodes, the handling of key events, and the emitting of events based on internal state changes. 4 | 5 | These nodes represent 2D space in an "abstract" manner - there is no visual component to LRUD, but its data represents spatial grids, lists, and items. 6 | 7 | For some examples of common desired behaviour and how to achieve it, see our [common recipes](./recipes.md). 8 | 9 | ## Creating a new instance 10 | 11 | ```js 12 | const { Lrud } = require('Lrud') 13 | 14 | const navigation = new Lrud() 15 | ``` 16 | 17 | ## Registering a node 18 | 19 | A node can be added to the navigation tree by calling `navigation.registerNode` with the `id` of the node and any node options 20 | 21 | ```js 22 | navigation.registerNode('root', {}) 23 | ``` 24 | 25 | The very first node that is registered will become the *root node*. From here, any node that is registered without a parent will automatically become a child of the root node. 26 | 27 | ```js 28 | navigation 29 | .registerNode('root', { orientation: 'horizontal' }) 30 | .registerNode('alpha', { isFocusable: true }) 31 | 32 | // ...is the same as 33 | 34 | navigation 35 | .registerNode('root', { orientation: 'horizontal' }) 36 | .registerNode('alpha', { isFocusable: true, parent: 'root' }) 37 | ``` 38 | 39 | Nodes must be registered with _unique_ IDs. If `registerNode()` is called with an ID that has already been registered against the navigation instance, an exception will be thrown. 40 | 41 | ## Registration options 42 | 43 | Most options affect behaviour for when LRUD is handling key events and assigning focus. 44 | 45 | ### `orientation` 46 | 47 | `"vertical" | "horizontal"` 48 | 49 | Any node that has children should be registered with an `orientation`. A node of vertical orientation will handle up/down key events while horizontal will handle left/right key events. 50 | 51 | ```js 52 | navigation 53 | .registerNode('row', { orientation: 'horizontal' }) 54 | .registerNode('item-1', { parent: 'row', isFocusable: true }) 55 | .registerNode('item-2', { parent: 'row', isFocusable: true }) 56 | .registerNode('item-3', { parent: 'row', isFocusable: true }) 57 | ``` 58 | 59 | If focus was on `item-2`, a `left` keypress will put focus to `item-1`, and `right` keypress will put focus to `item-3`. This is because the `row` is set to `orientation: horizontal`. If the `row` was set to `orientation: vertical`, it would respond to key presses of `up` and `down`, respectively. 60 | 61 | If `orientation` is not defined than focus can not be moved within that node. This is a very powerful feature, that allows creating closed boxes, from which focus can not "jump out". It's the best to think about modal popups with semi transparent overlay here. It may contain Ok/Cancel buttons and focus must be moved only around those buttons. The rest of the page is still visible in the background and LRUD navigation tree may stay untouched. See the [Recipe 5 - Error Modal Popup](./recipes.md#recipe-5---error-modal-popup). 62 | 63 | ### `isWrapping` 64 | 65 | `boolean` 66 | 67 | Used in conjunction with orientation to make a list wrap at the top/bottom or left/right depending on orientation. 68 | 69 | ```js 70 | navigation 71 | .registerNode('row', { orientation: 'horizontal', isWrapping: true }) 72 | .registerNode('item-1', { parent: 'row', isFocusable: true }) 73 | .registerNode('item-2', { parent: 'row', isFocusable: true }) 74 | .registerNode('item-3', { parent: 'row', isFocusable: true }) 75 | ``` 76 | 77 | In the above example, if the user was focused on `item-3`, and LRUD handled an event with a direction of `right`, usually focus would remain on `item-3`. However, because the `row` is set to `isWrapping: true`, focus will actually reset to `item-1`. 78 | 79 | ### `index` 80 | 81 | `number` 82 | 83 | The zero-based index of the node, relative to its siblings. Index is kept coherent and compact. If `index` value is provided, then the child is inserted at that position and all other children indices, that are greater or equal to provided index, are shifted up by one. If `index` of the registering child is greater that current size of children list or is not given at all, it will be set as the _next_ index under its parent. 84 | 85 | ```js 86 | navigation 87 | .registerNode('root', { isIndexCoherent: true }) 88 | .registerNode('A', { parent: 'root' }) // order: |A| 89 | .registerNode('B', { parent: 'root' }) // order: A, |B| 90 | .registerNode('C', { parent: 'root' }) // order: A, B, |C| 91 | .registerNode('D', { parent: 'root' }) // order: A, B, C, |D| 92 | 93 | // ...gives the same result as 94 | 95 | navigation 96 | .registerNode('A', { parent: 'root', index: 0 }) // order: |A| 97 | .registerNode('C', { parent: 'root', index: 1 }) // order: A, |C| 98 | .registerNode('B', { parent: 'root', index: 1 }) // order: A, |B|, C 99 | .registerNode('D', { parent: 'root', index: 9 }) // order: A, B, C, |D| 100 | ``` 101 | 102 | `index` is used when calculating the "next" or "previous" node in a list. 103 | 104 | ### `isIndexAlign` 105 | 106 | `boolean` 107 | 108 | To be used in conjunction with orientation to give a node index alignment functionality. 109 | 110 | Index alignment ensures that when focus would move from one of this nodes descendants to another, LRUD will attempt to ensure that the focused node matches the index of the node that was left, e.g to make 2 lists behave as a "grid". 111 | 112 | For further details, see the [docs on index alignment](./index-align.md). 113 | 114 | ### `indexRange` 115 | 116 | `number[]` 117 | 118 | An array with 2 elements. Value `[0]` is the lower bound of matching indexes, `[1]` is the upper bound. 119 | 120 | Used in conjunction with `isIndexAlign` behaviour, allows a node to replicate the effects of a "column span" by assuming the role of multiple indexes relative to its siblings. 121 | 122 | For further details, see the [docs on index alignment](./index-align.md). 123 | 124 | ### `isStopPropagate` 125 | 126 | `boolean` 127 | 128 | Allows dealing with a situation when focusable parent contains focusable children. 129 | 130 | By default, the leaf is picked to be focused, "the deepest" focusable candidate. In such case parent node is simply not a hindrance. Setting `isStopPropagate` to `true` inverts this behaviour. Parent node grabs focus instead of passing it to the children. 131 | 132 | ```js 133 | navigation 134 | .registerNode('row-1', { orientation: 'horizontal', isFocusable: true }) 135 | .registerNode('row-2', { orientation: 'horizontal', isFocusable: true }) 136 | .registerNode('row-2-item-1', { parent: 'row-2', isFocusable: true }) 137 | .registerNode('row-3', { orientation: 'horizontal', isFocusable: true, isStopPropagate: true }) 138 | .registerNode('row-3-item-2', { parent: 'row-3', isFocusable: true }) 139 | ``` 140 | 141 | In the above example, if the user was focused on `row-1`, and LRUD handled an event with a direction of `down`, than `row-2-item-1` will be focused, because by default `row-2` passes focus to the children. Next event with a direction of `down` will move focus to `row-3`. This is because, `row-3` has `isStopPropagate` set to `true` which allowed him to grab the focus instead of passing it to the `row-3-item-2`, even if it's a focusable leaf. 142 | 143 | --- 144 | 145 | Several functions can also be given as registration options to a node. These functions will be called at specific state change points for the node. See our [Process Lifecycles doc](./process-lifecycles.md) for further details on the "lifecycle" of a move event in LRUD. 146 | 147 | ### `onFocus` 148 | 149 | `function` 150 | 151 | If given, the `onFocus` function will be called when the node gets focussed on. 152 | 153 | ### `onBlur` 154 | 155 | `function` 156 | 157 | If given, the `onBlur` function will be called when the node if the node had focus and a new node gains focus. 158 | 159 | ### `onSelect` 160 | 161 | `function` 162 | 163 | If given, the `onSelect` function will be called when the node is focused and a key event of "ENTER" is handled. 164 | 165 | ### `onActive` 166 | 167 | `function` 168 | 169 | If given, the `onActive` function will be called when the node is made active by either itself or one of its descendants gaining focus. 170 | 171 | ### `onInactive` 172 | 173 | `function` 174 | 175 | If given, the `onInactive` function will be called when the node was active and due to an updated focus, is no longer active. 176 | 177 | ### `onLeave` 178 | 179 | `function` 180 | 181 | If given, the `onLeave` function will be called when the node was focused and the handling of a key event means the node is no longer focused. 182 | 183 | ### `onEnter` 184 | 185 | `function` 186 | 187 | If given, the `onEnter` function will be called when the node was not focused and the handling of a key event means the node is now focused. 188 | 189 | ### `shouldCancelLeave` 190 | 191 | `function` 192 | 193 | If given, the `shouldCancelLeave` function will be called when a move is being processed, and the node is being _left_. If `shouldCancelLeave` returns `true`, the move will be cancelled. 194 | 195 | ### `shouldCancelEnter` 196 | 197 | `function` 198 | 199 | If given, the `shouldCancelEnter` function will be called when a move is being processed, and the node is being _entered_. If `shouldCancelEnter` returns `true`, the move will be cancelled. 200 | 201 | ### `onLeaveCancelled` 202 | 203 | `function` 204 | 205 | If given, the `onLeaveCancelled` function will be called if this node has a matching `shouldCancelLeave`, and when _that_ function returns `true`. 206 | 207 | ### `onEnterCancelled` 208 | 209 | `function` 210 | 211 | If given, the `onEnterCancelled` function will be called if this node has a matching `shouldCancelEnter`, and when _that_ function returns `true`. 212 | 213 | ## Unregistering a node 214 | 215 | A node can be removed from the navigation tree by calling `navigation.unregisterNode()` with the id of the node. 216 | 217 | Unregistering a node will also remove all of its children and trigger events correctly. Overrides pointing to the removed node (and any of its direct and indirect children) will be removed as well. 218 | 219 | If an unregister causes the current focused node to be removed, focus will be moved to the _last_ node that could be focused. This also works when unregistering a branch. 220 | 221 | Unregistering the root node will cause the tree to become empty and also remove all overrides that have been set (see Overrides, below). 222 | 223 | ### Unregistering Options 224 | 225 | A config object can be given to `unregisterNode(, )` to force specific behaviour. 226 | 227 | - `forceRefocus:boolean` When `true`, the default behaviour of finding a new node to focus on if unregistering the current 228 | focused node will continue to work as normal. This value also defaults to `true`. Pass as `false` to stop the auto-refocus 229 | behaviour. Remember, if you are unregistering the current focused node, and passing `forceRefocus` as `false`, you need to manually call `assignFocus()` afterwards or the user will be left in limbo! 230 | 231 | ## Moving a node 232 | 233 | A node can be moved from its current parent to the new parent by calling `navigation.moveNode()` with the id of the node to be moved and the id of the new parent node. 234 | 235 | This method has few advantages over regular unregisterNode/registerNode operation: 236 | 237 | * It maintains the currently focused node. If moved node (or its direct or indirect child) is a currently focused node, then it will stay focused. 238 | * It maintains overrides pointing to the moved node or its direct and indirect children. 239 | 240 | ### Moving Options 241 | 242 | A config object can be given to `moveNode(, , )` to force specific behaviour. 243 | 244 | - `index:number` Defines position at which node should be inserted into new parent's children list. Despite the given value, index of new parent's children is kept coherent and compact. 245 | - `maintainIndex:boolean` When `true`, the node will be inserted into new parent's children list at the same position as it was under old parent, if possible. Otherwise, the node will be appended to new parent's children list. Despite the node's current index value, index of new parent's children is kept coherent and compact. If `index` value is specified in options, the `maintainIndex` takes no effect, it's ignored. 246 | 247 | ## Assigning Focus 248 | 249 | You can give focus to a particular node by calling `navigation.assignFocus()` with the node id 250 | 251 | ```js 252 | navigation.assignFocus('list') 253 | ``` 254 | 255 | If the node that has been assigned focus is **not** focusable, LRUD will attempt to find the first active child of the node that _is_ focusable, and focus on that instead. 256 | 257 | ## Handling Key Events 258 | 259 | Once focus has been assigned against the LRUD instance, LRUD can begin handling key events. 260 | 261 | Every key event represents a user moving the "cursor"/"focus" in a given _direction_. The direction is based on the `event.keyCode` value - LRUD maintains an internal mapping of `keyCode` values to semantic directions. 262 | 263 | You can pass key events into LRUD using the `navigation.handleKeyEvent` function: 264 | 265 | ```js 266 | document.onkeydown = function (event) { 267 | navigation.handleKeyEvent(event) 268 | } 269 | ``` 270 | 271 | ### Handling Key Events Options 272 | 273 | A config object can be given to `handleKeyEvent(, )` to force specific behaviour. 274 | 275 | - `forceFocus:boolean` When `true`, if there's no focused node, then LRUD will attempt to find a first focusable candidate (the node that is focusable or contains at least one child that is a focusable candidate). Such node, when found, will be automatically focused and focus state of navigation tree is auto-initialized. The default value is `false`. 276 | 277 | ## Events 278 | 279 | LRUD emits events in response to key events. See the [TAL docs](http://bbc.github.io/tal/widgets/focus-management.html) for an explanation of 'focused' and 'active' nodes. Each of these callbacks is called with the node that changed state. 280 | 281 | * `navigation.on('focus', function)` - Focus was given to a node. 282 | * `navigation.on('blur', function)` - Focus was taken from a node. 283 | * `navigation.on('active', function)` - The node has become active. 284 | * `navigation.on('inactive', function)` - The node has become inactive. 285 | * `navigation.on('select', function)` - The current focused node was selected. 286 | * `navigation.on('cancelled', function)` - A movement has been cancelled. 287 | 288 | A special event of `move` is emitted after handling a key event. 289 | 290 | * `navigation.on('move', function)` - Triggered when focus is changed within a list 291 | 292 | The `move` event callback is called with a move event in the following shape: 293 | 294 | ```js 295 | { 296 | leave: // the node that was focused that we're now leaving 297 | enter: // the node that is now focused that we're entering 298 | offset: -1 : 1 // 1 if direction was RIGHT or DOWN, -1 if direction was LEFT or UP 299 | } 300 | ``` 301 | 302 | Common usages for handling this move event include changing the style of a given DOM node to match a "focus" style, or to handle a DOM animation between the `leave` and `enter` nodes. 303 | 304 | ```js 305 | 306 | navigation.on('move', moveEvent => { 307 | const focusedDomNode = document.getElementById(moveEvent.enter.id); 308 | focusedDomNode.classList.add('focused'); 309 | }) 310 | 311 | ``` 312 | 313 | To unregister an event callback, simply call the .off() method 314 | 315 | ```js 316 | navigation.off('focus', function); 317 | ``` 318 | 319 | ## Overrides 320 | 321 | LRUD supports an override system, for times when correct product/UX behaviour requires focus to change in a way that is not strictly in accordance with the structure of the navigation tree. 322 | 323 | New overrides can be registered with `navigation.registerOverride(, , , )`, where options may contain parameters: 324 | 325 | - `forceOverride:boolean` When `true`, the existing override from source node in given direction will be overwritten. 326 | 327 | 328 | To unregister override its enough to call `navigation.unregisterOverride(, )`. 329 | 330 | The override below represents that, when LRUD is bubbling its key event and when it hits the `box-1` node, and direction of travel is `down`, STOP the propagation of the bubble event and focus directly on `box-2`. 331 | 332 | ```js 333 | navigation.registerOverride( 334 | 'box-1', // the ID to trigger the override on 335 | 'box-2', // the ID of the node we want to focus on 336 | 'down' // the direction of travel in order for the override to trigger 337 | ) 338 | ``` 339 | 340 | ## Modifying Node Focusability 341 | 342 | Ability of a node to be focused can be modified using `navigation.setNodeFocusable` 343 | 344 | ```js 345 | navigation.registerNode('root', { isFocusable: true }) 346 | navigation.setNodeFocusable('root', false) 347 | ``` 348 | 349 | # Tree and Partial Tree Insertion & Registering 350 | 351 | LRUD supports the ability to register an entire tree at once. 352 | 353 | ```js 354 | const instance = new Lrud(); 355 | const tree = { 356 | id: 'root', 357 | orientation: 'horizontal', 358 | children: [ 359 | { id: 'alpha', isFocusable: true }, 360 | { id: 'beta', isFocusable: true }, 361 | { id: 'charlie', isFocusable: true }, 362 | ] 363 | } 364 | 365 | instance.registerTree(tree); 366 | // `instance` now has the above tree registered, and has correctly setup active children, indexes, etc. 367 | ``` 368 | 369 | ## `insertTree()` and nested tree registration 370 | 371 | LRUD also supports the ability to register a tree/insert a tree into an already existing node branch. 372 | 373 | If no parent is given on the top level node of the passed tree, the tree will be inserted under the root node, as per standard `registerNode()` behaviour. 374 | 375 | Otherwise, if a `parent` _is_ given, the tree will be inserted under that parent. 376 | 377 | ### Inserting a tree under the root node 378 | ```js 379 | const instance = new Lrud(); 380 | instance 381 | .registerNode('root', { orientation: 'horizontal' }) 382 | .registerNode('alpha', { isFocusable: true }) 383 | .registerNode('beta', { isFocusable: true }) 384 | 385 | const tree = { 386 | id: 'charlie', 387 | orientation: 'vertical', 388 | children: [ 389 | { id: 'charlie_1', isFocusable: true }, 390 | { id: 'charlie_2', isFocusable: true }, 391 | ] 392 | } 393 | instance.registerTree(tree); 394 | /* 395 | the full tree of `instance` now looks like: 396 | { 397 | id: 'root', 398 | orientation: 'horizontal', 399 | children: [ 400 | { id: 'alpha', isFocusable: true } 401 | { id: 'beta', isFocusable: true } 402 | { 403 | id: 'charlie', 404 | orientation: 'vertical' 405 | children: [ 406 | { id: 'charlie_1', isFocusable: true } 407 | { id: 'charlie_2', isFocusable: true } 408 | ] 409 | } 410 | ] 411 | } 412 | */ 413 | ``` 414 | 415 | ### Inserting a tree under a specified branch 416 | 417 | ```js 418 | const instance = new Lrud(); 419 | instance 420 | .registerNode('root', { orientation: 'horizontal' }) 421 | .registerNode('alpha', { isFocusable: true }) 422 | .registerNode('beta', { orientation: 'vertical' }) 423 | 424 | const tree = { 425 | id: 'charlie', 426 | orientation: 'vertical', 427 | parent: 'beta', 428 | children: [ 429 | { id: 'charlie_1', isFocusable: true }, 430 | { id: 'charlie_2', isFocusable: true }, 431 | ] 432 | } 433 | instance.registerTree(tree); 434 | /* 435 | the full tree of `instance` now looks like: 436 | { 437 | id: 'root', 438 | orientation: 'horizontal', 439 | children: [ 440 | { id: 'alpha', isFocusable: true } 441 | { 442 | id: 'beta', 443 | orientation: 'vertical', 444 | children: [ 445 | { 446 | id: 'charlie', 447 | orientation: 'vertical' 448 | children: [ 449 | { id: 'charlie_1', isFocusable: true } 450 | { id: 'charlie_2', isFocusable: true } 451 | ] 452 | } 453 | ] 454 | } 455 | ] 456 | } 457 | */ 458 | ``` 459 | # F.A.Q 460 | 461 | > Q: A node that should be focusabled is never receiving focus - whats happening? 462 | 463 | A: Ensure that the parent nodes, etc. have the correct orientation in order to be able to jump inbetween nodes. 464 | 465 | > Q: All my parents have orientations, everything is setup in the navigation tree, and I STILL can't focus on the node I expect. 466 | 467 | A: Ensure the node has either `isFocusable: true` or a `selectAction` registered against it. A node needs either one of these in order to be considered "focusable". `isFocusable` is prioritised over `selectAction` so a node with `isFocusable: false` will never be focusable. 468 | 469 | > Q: What is the different between `onBlur/onFocus` and `onLeave/onEnter`? 470 | 471 | A: `onBlur` and `onFocus` can/will be called at any point in time for when a node loses focus. This includes a direct call to `assignFocus()` from user land. On the other hand, `onLeave` and `onEnter` will only be called via the result of a `handleKeyEvent()` 472 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'roots': [ 3 | '/src' 4 | ], 5 | 'collectCoverageFrom': [ 6 | 'src/**/*.{js,jsx,ts}', 7 | '!/node_modules/', 8 | '!/path/to/dir/', 9 | '!/dist/' 10 | ], 11 | 'transform': { 12 | '^.+\\.tsx?$': 'ts-jest' 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lrud", 3 | "version": "8.0.0", 4 | "description": "Left, Right, Up, Down. A spatial navigation library for devices with input via directional controls", 5 | "main": "dist/cjs/index.min.js", 6 | "module": "dist/esm/index.js", 7 | "types": "dist/types/index.d.ts", 8 | "homepage": "https://github.com/bbc/lrud", 9 | "license": "Apache-2.0", 10 | "files": [ 11 | "dist" 12 | ], 13 | "keywords": [ 14 | "tv", 15 | "navigation", 16 | "tv-apps", 17 | "react", 18 | "react-tv", 19 | "focus", 20 | "focus management", 21 | "spatial navigation", 22 | "smart tv" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "git@github.com:bbc/lrud.git" 27 | }, 28 | "devDependencies": { 29 | "@types/jest": "^26.0.22", 30 | "@typescript-eslint/eslint-plugin": "^4.19.0", 31 | "@typescript-eslint/parser": "^4.19.0", 32 | "eslint": "^7.22.0", 33 | "eslint-config-standard": "^16.0.2", 34 | "eslint-plugin-import": "^2.22.1", 35 | "eslint-plugin-node": "^11.1.0", 36 | "eslint-plugin-promise": "^4.3.1", 37 | "jest": "^26.6.3", 38 | "rimraf": "^2.6.2", 39 | "rollup": "^1.25.1", 40 | "rollup-plugin-commonjs": "^9.1.2", 41 | "rollup-plugin-node-resolve": "^4.2.4", 42 | "rollup-plugin-typescript": "^1.0.1", 43 | "rollup-plugin-typescript2": "^0.30.0", 44 | "rollup-plugin-uglify": "^6.0.4", 45 | "ts-jest": "^26.5.4", 46 | "tslib": "^2.1.0", 47 | "typescript": "^4.2.3" 48 | }, 49 | "dependencies": { 50 | "mitt": "^1.2.0" 51 | }, 52 | "scripts": { 53 | "preversion": "npm test", 54 | "postversion": "git push && git push --tags", 55 | "clean": "rimraf dist", 56 | "lint": "eslint src/*.ts src/**/*.ts", 57 | "test": "npm run build && jest", 58 | "test:verbose": "jest --verbose", 59 | "test:coverage": "jest --coverage", 60 | "prepare": "npm run build", 61 | "prebuild": "npm run clean", 62 | "build": "rollup -c", 63 | "watch": "npm run build -- -w" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescriptPlugin from 'rollup-plugin-typescript2' 2 | import nodeResolve from 'rollup-plugin-node-resolve' 3 | import { uglify } from 'rollup-plugin-uglify' 4 | 5 | export default [ 6 | { 7 | input: 'src/index.ts', 8 | output: { 9 | file: 'dist/cjs/index.js', 10 | format: 'cjs' 11 | }, 12 | plugins: [ 13 | typescriptPlugin({ 14 | useTsconfigDeclarationDir: true 15 | }), 16 | nodeResolve() 17 | ] 18 | }, 19 | { 20 | input: 'dist/cjs/index.js', 21 | output: { 22 | file: 'dist/cjs/index.min.js', 23 | format: 'cjs' 24 | }, 25 | plugins: [ 26 | uglify() 27 | ] 28 | }, 29 | { 30 | input: 'src/index.ts', 31 | output: { 32 | file: 'dist/esm/index.js', 33 | format: 'esm' 34 | }, 35 | plugins: [ 36 | typescriptPlugin({ 37 | useTsconfigDeclarationDir: true 38 | }), 39 | nodeResolve() 40 | ] 41 | } 42 | ] 43 | -------------------------------------------------------------------------------- /src/build.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const { Lrud } = require('../dist/cjs/index') 4 | 5 | describe('build test', () => { 6 | test('ensure LRUD build is in correct format', () => { 7 | const tree = new Lrud() 8 | 9 | expect(tree.registerNode).not.toBeUndefined() 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/cancellable-movement.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const { Lrud } = require('./index') 4 | 5 | describe('cancellable movement - functions on the node', () => { 6 | it('should cancel movement when leaving via shouldCancelLeave on the leave node and fire onCancelled on the node and on the instance', () => { 7 | const onCancelledMock = jest.fn() 8 | const onCancelledNavigationMock = jest.fn() 9 | 10 | const navigation = new Lrud() 11 | .registerNode('root', { orientation: 'horizontal' }) 12 | .registerNode('a', { isFocusable: true }) 13 | .registerNode('b', { 14 | isFocusable: true, 15 | shouldCancelLeave: (leave, enter) => { 16 | if (leave.id === 'b' && enter.id === 'c') { 17 | return true 18 | } 19 | }, 20 | onLeaveCancelled: onCancelledMock 21 | }) 22 | .registerNode('c', { isFocusable: true }) 23 | .registerNode('d', { isFocusable: true }) 24 | 25 | navigation.on('cancelled', onCancelledNavigationMock) 26 | 27 | navigation.assignFocus('b') 28 | 29 | expect(navigation.currentFocusNode.id).toEqual('b') 30 | 31 | navigation.handleKeyEvent({ direction: 'right' }) 32 | 33 | expect(navigation.currentFocusNode.id).toEqual('b') // still `b`, as the move should have been cancelled 34 | expect(onCancelledMock.mock.calls.length).toEqual(1) 35 | expect(onCancelledNavigationMock.mock.calls.length).toEqual(1) 36 | }) 37 | 38 | it('should cancel movement when entering via shouldCancelEnter on the enter node and fire onCancelled on the node and on the instance', () => { 39 | const onCancelledMock = jest.fn() 40 | const onCancelledNavigationMock = jest.fn() 41 | 42 | const navigation = new Lrud() 43 | .registerNode('root', { orientation: 'horizontal' }) 44 | .registerNode('a', { isFocusable: true }) 45 | .registerNode('b', { 46 | isFocusable: true, 47 | shouldCancelEnter: (leave, enter) => { 48 | if (leave.id === 'a' && enter.id === 'b') { 49 | return true 50 | } 51 | }, 52 | onEnterCancelled: onCancelledMock 53 | }) 54 | 55 | navigation.on('cancelled', onCancelledNavigationMock) 56 | navigation.assignFocus('a') 57 | 58 | expect(navigation.currentFocusNode.id).toEqual('a') 59 | 60 | navigation.handleKeyEvent({ direction: 'right' }) 61 | 62 | expect(navigation.currentFocusNode.id).toEqual('a') // still `b`, as the move should have been cancelled 63 | expect(onCancelledMock.mock.calls.length).toEqual(1) 64 | expect(onCancelledNavigationMock.mock.calls.length).toEqual(1) 65 | 66 | // try it again 67 | navigation.handleKeyEvent({ direction: 'right' }) 68 | expect(onCancelledMock.mock.calls.length).toEqual(2) 69 | expect(onCancelledNavigationMock.mock.calls.length).toEqual(2) 70 | }) 71 | 72 | it('should not cancel when shouldCancelLeave returns false', () => { 73 | const navigation = new Lrud() 74 | .registerNode('root', { orientation: 'horizontal' }) 75 | .registerNode('a', { isFocusable: true }) 76 | .registerNode('b', { 77 | isFocusable: true, 78 | shouldCancelLeave: (leave) => { 79 | if (leave.id === 'x') { 80 | return true 81 | } 82 | } 83 | }) 84 | .registerNode('c', { isFocusable: true }) 85 | .registerNode('d', { isFocusable: true }) 86 | 87 | navigation.assignFocus('a') 88 | 89 | navigation.handleKeyEvent({ direction: 'right' }) 90 | expect(navigation.currentFocusNode.id).toEqual('b') 91 | 92 | navigation.handleKeyEvent({ direction: 'right' }) 93 | expect(navigation.currentFocusNode.id).toEqual('c') 94 | 95 | navigation.handleKeyEvent({ direction: 'right' }) 96 | expect(navigation.currentFocusNode.id).toEqual('d') 97 | }) 98 | 99 | it('should not cancel when shouldCancelEnter returns false', () => { 100 | const navigation = new Lrud() 101 | .registerNode('root', { orientation: 'horizontal' }) 102 | .registerNode('a', { isFocusable: true }) 103 | .registerNode('b', { 104 | isFocusable: true, 105 | shouldCancelEnter: (leave) => { 106 | if (leave.id === 'x') { 107 | return true 108 | } 109 | } 110 | }) 111 | .registerNode('c', { isFocusable: true }) 112 | .registerNode('d', { isFocusable: true }) 113 | 114 | navigation.assignFocus('a') 115 | 116 | navigation.handleKeyEvent({ direction: 'right' }) 117 | expect(navigation.currentFocusNode.id).toEqual('b') 118 | 119 | navigation.handleKeyEvent({ direction: 'right' }) 120 | expect(navigation.currentFocusNode.id).toEqual('c') 121 | 122 | navigation.handleKeyEvent({ direction: 'right' }) 123 | expect(navigation.currentFocusNode.id).toEqual('d') 124 | }) 125 | 126 | it('should cancel when shouldCancelLeave returns true, no onLeaveCancelled callback', () => { 127 | const navigation = new Lrud() 128 | .registerNode('root', { orientation: 'horizontal' }) 129 | .registerNode('a', { isFocusable: true }) 130 | .registerNode('b', { 131 | isFocusable: true, 132 | shouldCancelLeave: () => true 133 | }) 134 | .registerNode('c', { isFocusable: true }) 135 | 136 | navigation.assignFocus('b') 137 | 138 | navigation.handleKeyEvent({ direction: 'left' }) 139 | 140 | expect(navigation.currentFocusNode.id).toEqual('b') 141 | }) 142 | 143 | it('should cancel when shouldEnterLeave returns true, no onEnterCancelled callback', () => { 144 | const navigation = new Lrud() 145 | .registerNode('root', { orientation: 'horizontal' }) 146 | .registerNode('a', { isFocusable: true }) 147 | .registerNode('b', { 148 | isFocusable: true, 149 | shouldCancelEnter: () => true 150 | }) 151 | .registerNode('c', { isFocusable: true }) 152 | 153 | navigation.assignFocus('a') 154 | 155 | navigation.handleKeyEvent({ direction: 'right' }) 156 | 157 | expect(navigation.currentFocusNode.id).toEqual('a') 158 | }) 159 | }) 160 | -------------------------------------------------------------------------------- /src/events.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const { Lrud } = require('./index') 4 | 5 | describe('event scenarios', () => { 6 | test('active events should be fired', () => { 7 | const navigation = new Lrud() 8 | .registerNode('root') 9 | .registerNode('left') 10 | .registerNode('a', { parent: 'left', isFocusable: true }) 11 | .registerNode('b', { parent: 'left', isFocusable: true }) 12 | .registerNode('right') 13 | .registerNode('c', { parent: 'right', isFocusable: true }) 14 | .registerNode('d', { parent: 'right', isFocusable: true }) 15 | 16 | const activeSpy = jest.fn() 17 | navigation.on('active', activeSpy) 18 | 19 | navigation.assignFocus('b') 20 | 21 | expect(activeSpy).toHaveBeenCalledTimes(2) 22 | expect(activeSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: 'b' })) 23 | expect(activeSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: 'left' })) 24 | }) 25 | 26 | test('active & inactive events should be fired - bubbling', () => { 27 | const navigation = new Lrud() 28 | .registerNode('root') 29 | .registerNode('left') 30 | .registerNode('a', { parent: 'left', isFocusable: true }) 31 | .registerNode('b', { parent: 'left', isFocusable: true }) 32 | .registerNode('right') 33 | .registerNode('c', { parent: 'right', isFocusable: true }) 34 | .registerNode('d', { parent: 'right', isFocusable: true }) 35 | 36 | const activeSpy = jest.fn() 37 | navigation.on('active', activeSpy) 38 | 39 | const inactiveSpy = jest.fn() 40 | navigation.on('inactive', inactiveSpy) 41 | 42 | navigation.assignFocus('a') 43 | navigation.assignFocus('d') 44 | 45 | expect(activeSpy).toHaveBeenCalledTimes(4) 46 | // 'a' is focused, 'a' became active 47 | expect(activeSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: 'a' })) 48 | // bubbling to parent of 'a', 'left' became active 49 | expect(activeSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: 'left' })) 50 | // changing focus from 'a' to 'd', 'd' became active 51 | expect(activeSpy).toHaveBeenNthCalledWith(3, expect.objectContaining({ id: 'd' })) 52 | // bubbling to parent of 'd', 'right' became active 53 | expect(activeSpy).toHaveBeenNthCalledWith(4, expect.objectContaining({ id: 'right' })) 54 | 55 | expect(inactiveSpy).toHaveBeenCalledTimes(1) 56 | // changing focus from 'a' to 'd', 'left' (parent of 'a') became inactive, because 'right' (parent of 'd') is active now 57 | expect(inactiveSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: 'left' })) 58 | }) 59 | 60 | test('active events should be fired when recalculating focus', () => { 61 | const navigation = new Lrud() 62 | .registerNode('root', { orientation: 'vertical' }) 63 | .registerNode('left') 64 | .registerNode('a', { parent: 'left', isFocusable: true }) 65 | .registerNode('b', { parent: 'left', isFocusable: true }) 66 | .registerNode('right') 67 | .registerNode('c', { parent: 'right', isFocusable: true }) 68 | .registerNode('d', { parent: 'right', isFocusable: true }) 69 | 70 | navigation.assignFocus('b') 71 | 72 | const activeSpy = jest.fn() 73 | navigation.on('active', activeSpy) 74 | navigation.unregisterNode('left') 75 | 76 | expect(navigation.currentFocusNode.id).toEqual('c') 77 | 78 | // only called once, as `c` is already the activeChild of `right` 79 | expect(activeSpy).toHaveBeenCalledWith(expect.objectContaining({ id: 'right' })) 80 | }) 81 | 82 | test('`move` should be fired once per key handle - moving right', () => { 83 | const navigation = new Lrud() 84 | .registerNode('root', { orientation: 'horizontal' }) 85 | .registerNode('a', { isFocusable: true }) 86 | .registerNode('b', { isFocusable: true }) 87 | .registerNode('c', { isFocusable: true }) 88 | .registerNode('d', { isFocusable: true }) 89 | 90 | navigation.assignFocus('a') 91 | 92 | const moveSpy = jest.fn() 93 | navigation.on('move', moveSpy) 94 | 95 | navigation.handleKeyEvent({ direction: 'right' }) 96 | 97 | expect(moveSpy).toHaveBeenCalledWith({ 98 | leave: expect.objectContaining({ id: 'a' }), 99 | enter: expect.objectContaining({ id: 'b' }), 100 | direction: 'right', 101 | offset: 1 102 | }) 103 | }) 104 | 105 | test('`move` should be fired once per key handle - moving left', () => { 106 | const navigation = new Lrud() 107 | .registerNode('root', { orientation: 'horizontal' }) 108 | .registerNode('a', { isFocusable: true }) 109 | .registerNode('b', { isFocusable: true }) 110 | .registerNode('c', { isFocusable: true }) 111 | .registerNode('d', { isFocusable: true }) 112 | 113 | navigation.assignFocus('b') 114 | 115 | const moveSpy = jest.fn() 116 | navigation.on('move', moveSpy) 117 | 118 | navigation.handleKeyEvent({ direction: 'left' }) 119 | 120 | expect(moveSpy).toHaveBeenCalledWith({ 121 | leave: expect.objectContaining({ id: 'b' }), 122 | enter: expect.objectContaining({ id: 'a' }), 123 | direction: 'left', 124 | offset: -1 125 | }) 126 | }) 127 | 128 | test('standard blur and focus should fire after calling assign focus', () => { 129 | const navigation = new Lrud() 130 | .registerNode('root', { orientation: 'horizontal' }) 131 | .registerNode('a', { isFocusable: true }) 132 | .registerNode('b', { isFocusable: true }) 133 | .registerNode('c', { isFocusable: true }) 134 | .registerNode('d', { isFocusable: true }) 135 | 136 | const blurSpy = jest.fn() 137 | navigation.on('blur', blurSpy) 138 | 139 | const focusSpy = jest.fn() 140 | navigation.on('focus', focusSpy) 141 | 142 | navigation.assignFocus('a') 143 | expect(focusSpy).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' })) 144 | 145 | navigation.assignFocus('b') 146 | expect(blurSpy).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' })) 147 | expect(focusSpy).toHaveBeenCalledWith(expect.objectContaining({ id: 'b' })) 148 | }) 149 | 150 | test('standard blur and focus should fire after doing a move', () => { 151 | const navigation = new Lrud() 152 | .registerNode('root', { orientation: 'horizontal' }) 153 | .registerNode('a', { isFocusable: true }) 154 | .registerNode('b', { isFocusable: true }) 155 | .registerNode('c', { isFocusable: true }) 156 | .registerNode('d', { isFocusable: true }) 157 | 158 | const blurSpy = jest.fn() 159 | navigation.on('blur', blurSpy) 160 | 161 | const focusSpy = jest.fn() 162 | navigation.on('focus', focusSpy) 163 | 164 | navigation.assignFocus('a') 165 | expect(focusSpy).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' })) 166 | 167 | navigation.handleKeyEvent({ direction: 'right' }) 168 | 169 | expect(blurSpy).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' })) 170 | expect(focusSpy).toHaveBeenCalledWith(expect.objectContaining({ id: 'b' })) 171 | }) 172 | 173 | test('`onLeave` and `onEnter` functions should fire on a node', () => { 174 | let hasLeft = false 175 | let hasEntered = false 176 | 177 | const navigation = new Lrud() 178 | .registerNode('root', { orientation: 'horizontal' }) 179 | .registerNode('a', { 180 | isFocusable: true, 181 | onLeave: () => { 182 | hasLeft = true 183 | } 184 | }) 185 | .registerNode('b', { 186 | isFocusable: true, 187 | onEnter: () => { 188 | hasEntered = true 189 | } 190 | }) 191 | 192 | navigation.assignFocus('a') 193 | 194 | navigation.handleKeyEvent({ direction: 'right' }) 195 | 196 | expect(hasLeft).toEqual(true) 197 | expect(hasEntered).toEqual(true) 198 | }) 199 | 200 | test('node onFocus', () => { 201 | let hasRun = false 202 | 203 | const navigation = new Lrud() 204 | .registerNode('root', { orientation: 'horizontal' }) 205 | .registerNode('a', { isFocusable: true }) 206 | .registerNode('b', { 207 | isFocusable: true, 208 | onFocus: () => { 209 | hasRun = true 210 | } 211 | }) 212 | 213 | navigation.assignFocus('a') 214 | 215 | navigation.handleKeyEvent({ direction: 'right' }) 216 | 217 | expect(hasRun).toEqual(true) 218 | }) 219 | 220 | test('node onBlur', () => { 221 | let hasRun = false 222 | 223 | const navigation = new Lrud() 224 | .registerNode('root', { orientation: 'horizontal' }) 225 | .registerNode('a', { 226 | isFocusable: true, 227 | onBlur: () => { 228 | hasRun = true 229 | } 230 | }) 231 | .registerNode('b', { 232 | isFocusable: true 233 | }) 234 | 235 | navigation.assignFocus('a') 236 | 237 | navigation.handleKeyEvent({ direction: 'right' }) 238 | 239 | expect(hasRun).toEqual(true) 240 | expect(navigation.currentFocusNode.id).toEqual('b') 241 | }) 242 | 243 | test('node onBlur - unregistering', () => { 244 | let hasRun = false 245 | 246 | const navigation = new Lrud() 247 | .registerNode('root', { orientation: 'horizontal' }) 248 | .registerNode('a', { 249 | isFocusable: true, 250 | onBlur: () => { 251 | hasRun = true 252 | } 253 | }) 254 | 255 | navigation.assignFocus('a') 256 | 257 | navigation.unregisterNode('a') 258 | 259 | expect(hasRun).toEqual(true) 260 | expect(navigation.currentFocusNode).toBeUndefined() 261 | }) 262 | 263 | test('node onActive - leaf, parent\'s activeChild not set', () => { 264 | let activeChild = null 265 | 266 | const navigation = new Lrud() 267 | .registerNode('root', { orientation: 'horizontal' }) 268 | .registerNode('row-a', { orientation: 'vertical' }) 269 | .registerNode('A', { isFocusable: true, parent: 'row-a' }) 270 | .registerNode('row-b', { orientation: 'vertical' }) 271 | .registerNode('B', { isFocusable: false, parent: 'row-b' }) 272 | .registerNode('C', { isFocusable: true, parent: 'row-b', onActive: () => { activeChild = 'C' } }) 273 | 274 | navigation.assignFocus('A') 275 | 276 | navigation.handleKeyEvent({ direction: 'right' }) 277 | 278 | expect(activeChild).toEqual('C') 279 | }) 280 | 281 | test('node onActive - leaf, changing current parent\'s activeChild', () => { 282 | let activeChild = null 283 | 284 | const navigation = new Lrud() 285 | .registerNode('root', { orientation: 'horizontal' }) 286 | .registerNode('row-a', { orientation: 'vertical' }) 287 | .registerNode('A', { isFocusable: true, parent: 'row-a' }) 288 | .registerNode('row-b', { orientation: 'vertical' }) 289 | .registerNode('B', { isFocusable: true, parent: 'row-b' }) 290 | .registerNode('C', { isFocusable: true, parent: 'row-b', onActive: () => { activeChild = 'C' } }) 291 | 292 | navigation.assignFocus('A') 293 | 294 | navigation.handleKeyEvent({ direction: 'right' }) 295 | navigation.handleKeyEvent({ direction: 'down' }) 296 | 297 | expect(activeChild).toEqual('C') 298 | }) 299 | 300 | test('node onInActive - branch', () => { 301 | let inactiveChild = null 302 | 303 | const navigation = new Lrud() 304 | .registerNode('root', { orientation: 'horizontal' }) 305 | .registerNode('row-a', { orientation: 'vertical', onInactive: () => { inactiveChild = 'row-a' } }) 306 | .registerNode('A', { isFocusable: true, parent: 'row-a' }) 307 | .registerNode('row-b', { orientation: 'vertical' }) 308 | .registerNode('B', { isFocusable: true, parent: 'row-b' }) 309 | .registerNode('C', { isFocusable: true, parent: 'row-b' }) 310 | 311 | navigation.assignFocus('A') 312 | 313 | navigation.handleKeyEvent({ direction: 'right' }) 314 | navigation.handleKeyEvent({ direction: 'down' }) 315 | 316 | expect(inactiveChild).toEqual('row-a') 317 | }) 318 | 319 | test('node onSelect', () => { 320 | const onSelectMock = jest.fn() 321 | 322 | const navigation = new Lrud() 323 | .registerNode('root') 324 | .registerNode('a', { isFocusable: true, onSelect: onSelectMock }) 325 | 326 | navigation.assignFocus('a') 327 | 328 | navigation.handleKeyEvent({ direction: 'enter' }) 329 | 330 | expect(onSelectMock).toBeCalledTimes(1) 331 | }) 332 | 333 | test('node onMove - forward', () => { 334 | const onMoveSpy = jest.fn() 335 | 336 | const navigation = new Lrud() 337 | .registerNode('root', { orientation: 'horizontal', onMove: onMoveSpy }) 338 | .registerNode('a', { isFocusable: true }) 339 | .registerNode('b', { isFocusable: true }) 340 | .registerNode('c', { isFocusable: true }) 341 | 342 | navigation.assignFocus('b') 343 | 344 | navigation.handleKeyEvent({ direction: 'right' }) 345 | 346 | expect(onMoveSpy).toHaveBeenCalledWith({ 347 | node: expect.objectContaining({ id: 'root' }), 348 | leave: expect.objectContaining({ id: 'b' }), 349 | enter: expect.objectContaining({ id: 'c' }), 350 | direction: 'right', 351 | offset: 1 352 | }) 353 | }) 354 | 355 | test('node onMove - backward', () => { 356 | const onMoveSpy = jest.fn() 357 | 358 | const navigation = new Lrud() 359 | .registerNode('root', { orientation: 'horizontal', onMove: onMoveSpy }) 360 | .registerNode('a', { isFocusable: true }) 361 | .registerNode('b', { isFocusable: true }) 362 | .registerNode('c', { isFocusable: true }) 363 | 364 | navigation.assignFocus('b') 365 | 366 | navigation.handleKeyEvent({ direction: 'left' }) 367 | 368 | expect(onMoveSpy).toHaveBeenCalledWith({ 369 | node: expect.objectContaining({ id: 'root' }), 370 | leave: expect.objectContaining({ id: 'b' }), 371 | enter: expect.objectContaining({ id: 'a' }), 372 | direction: 'left', 373 | offset: -1 374 | }) 375 | }) 376 | 377 | test('instance emit select', () => { 378 | const navigation = new Lrud() 379 | .registerNode('root') 380 | .registerNode('a', { isFocusable: true }) 381 | 382 | navigation.assignFocus('a') 383 | 384 | const onSelectMock = jest.fn() 385 | navigation.on('select', onSelectMock) 386 | 387 | navigation.handleKeyEvent({ direction: 'enter' }) 388 | 389 | expect(onSelectMock).toBeCalledTimes(1) 390 | }) 391 | 392 | test('node onSelect & instance emit select', () => { 393 | const onSelectMock = jest.fn() 394 | 395 | const navigation = new Lrud() 396 | .registerNode('root') 397 | .registerNode('a', { isFocusable: true, onSelect: onSelectMock }) 398 | 399 | navigation.assignFocus('a') 400 | navigation.on('select', onSelectMock) 401 | 402 | navigation.handleKeyEvent({ direction: 'enter' }) 403 | 404 | expect(onSelectMock).toBeCalledTimes(2) 405 | }) 406 | 407 | test('select not fired on callback when removed', () => { 408 | const navigation = new Lrud() 409 | .registerNode('root') 410 | .registerNode('a', { isFocusable: true }) 411 | 412 | navigation.assignFocus('a') 413 | 414 | const onSelectMock = jest.fn() 415 | navigation.on('select', onSelectMock) 416 | 417 | navigation.handleKeyEvent({ direction: 'enter' }) 418 | 419 | navigation.off('select', onSelectMock) 420 | 421 | navigation.handleKeyEvent({ direction: 'enter' }) 422 | 423 | expect(onSelectMock).toBeCalledTimes(1) 424 | }) 425 | 426 | test('should do nothing on enter when there\'s no currently focused node', () => { 427 | const onSelectMock = jest.fn() 428 | 429 | const navigation = new Lrud() 430 | .registerNode('root') 431 | .registerNode('a', { isFocusable: true, onSelect: onSelectMock }) 432 | 433 | const result = navigation.handleKeyEvent({ direction: 'enter' }) 434 | 435 | expect(result).toBeUndefined() 436 | expect(onSelectMock).not.toBeCalled() 437 | expect(navigation.currentFocusNode).toBeUndefined() 438 | }) 439 | }) 440 | -------------------------------------------------------------------------------- /src/focus-on-empty-node.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const { Lrud } = require('./index') 4 | 5 | describe('Focusing on empty nodes', () => { 6 | it('when focusing on branch, should jump to first child with focusable nodes', () => { 7 | const navigation = new Lrud() 8 | .registerNode('root', { orientation: 'vertical' }) 9 | .registerNode('branch1', { orientation: 'horizontal' }) 10 | .registerNode('branch2', { orientation: 'horizontal' }) 11 | 12 | navigation.register('item', { parent: 'branch2', isFocusable: true }) 13 | 14 | navigation.assignFocus('root') 15 | 16 | expect(navigation.currentFocusNode.id).toEqual('item') 17 | }) 18 | 19 | it('when focusing on branch, should jump to first child with focusable nodes - multiple empty preceding siblings', () => { 20 | const navigation = new Lrud() 21 | .registerNode('root', { orientation: 'vertical' }) 22 | .registerNode('branch1', { orientation: 'horizontal' }) 23 | .registerNode('branch2', { orientation: 'horizontal' }) 24 | .registerNode('branch3', { orientation: 'horizontal' }) 25 | .registerNode('branch4', { orientation: 'horizontal' }) 26 | 27 | navigation.register('item', { parent: 'branch4', isFocusable: true }) 28 | 29 | navigation.assignFocus('root') 30 | 31 | expect(navigation.currentFocusNode.id).toEqual('item') 32 | }) 33 | 34 | it('find focusable node when dead branches first', () => { 35 | const navigation = new Lrud() 36 | .registerNode('root', { orientation: 'vertical' }) 37 | .registerNode('branch1', { orientation: 'horizontal' }) 38 | .registerNode('branch2', { orientation: 'horizontal', parent: 'branch1' }) 39 | .registerNode('branch3', { orientation: 'horizontal', parent: 'branch2' }) 40 | 41 | navigation.registerNode('branch4', { orientation: 'horizontal' }) 42 | 43 | navigation.register('item', { parent: 'branch4', isFocusable: true }) 44 | 45 | navigation.assignFocus('root') 46 | 47 | expect(navigation.currentFocusNode.id).toEqual('item') 48 | }) 49 | 50 | it('find focusable node when multiple dead branches first', () => { 51 | const navigation = new Lrud() 52 | .registerNode('root', { orientation: 'vertical' }) 53 | .registerNode('branch1', { orientation: 'horizontal' }) 54 | .registerNode('branch2', { orientation: 'horizontal', parent: 'branch1' }) 55 | .registerNode('branch3', { orientation: 'horizontal', parent: 'branch2' }) 56 | .registerNode('branch4', { orientation: 'horizontal' }) 57 | .registerNode('branch5', { orientation: 'horizontal', parent: 'branch4' }) 58 | .registerNode('branch6', { orientation: 'horizontal', parent: 'branch5' }) 59 | .registerNode('branch7', { orientation: 'horizontal' }) 60 | 61 | navigation.register('item', { parent: 'branch7', isFocusable: true }) 62 | 63 | navigation.assignFocus('root') 64 | 65 | expect(navigation.currentFocusNode.id).toEqual('item') 66 | }) 67 | 68 | it('if assigning focus on a branch that has no focusable children, throw exception', () => { 69 | const navigation = new Lrud() 70 | .registerNode('root', { orientation: 'vertical' }) 71 | .registerNode('branch1', { orientation: 'horizontal' }) 72 | .registerNode('branch2', { orientation: 'horizontal', parent: 'branch1' }) 73 | .registerNode('branch3', { orientation: 'horizontal', parent: 'branch2' }) 74 | .registerNode('branch4', { orientation: 'horizontal' }) 75 | .registerNode('branch5', { orientation: 'horizontal', parent: 'branch4' }) 76 | .registerNode('branch6', { orientation: 'horizontal', parent: 'branch5' }) 77 | 78 | expect(() => { 79 | navigation.assignFocus('root') 80 | }).toThrow() 81 | }) 82 | 83 | it('should handle a move to an empty branch - vertical - dont change the focus', () => { 84 | const navigation = new Lrud() 85 | .registerNode('root', { orientation: 'vertical' }) 86 | .registerNode('node1', { orientation: 'horizontal', parent: 'root' }) 87 | .registerNode('node2', { orientation: 'horizontal', parent: 'root' }) 88 | 89 | navigation.register('item1', { parent: 'node1', selectAction: {} }) 90 | 91 | navigation.assignFocus('root') 92 | 93 | navigation.handleKeyEvent({ direction: 'down' }) 94 | 95 | expect(navigation.currentFocusNode.id).toEqual('item1') 96 | }) 97 | 98 | it('should handle a move to an empty branch - vertical - dont change the focus', () => { 99 | const navigation = new Lrud() 100 | .registerNode('root', { orientation: 'vertical' }) 101 | .registerNode('node1', { orientation: 'horizontal', parent: 'root' }) 102 | .registerNode('node2', { orientation: 'horizontal', parent: 'root' }) 103 | 104 | navigation.register('item1', { parent: 'node1', selectAction: {} }) 105 | 106 | navigation.assignFocus('item1') 107 | 108 | navigation.handleKeyEvent({ direction: 'right' }) 109 | 110 | expect(navigation.currentFocusNode.id).toEqual('item1') 111 | }) 112 | 113 | it('should jump over empty branches when moving - vertical', () => { 114 | const navigation = new Lrud() 115 | .registerNode('root', { orientation: 'vertical' }) 116 | .registerNode('node1', { orientation: 'horizontal', parent: 'root' }) 117 | .registerNode('node2', { orientation: 'horizontal', parent: 'root' }) 118 | .registerNode('node3', { orientation: 'horizontal', parent: 'root' }) 119 | 120 | navigation 121 | .register('item1', { parent: 'node1', isFocusable: true }) 122 | .register('item3', { parent: 'node3', isFocusable: true }) 123 | 124 | navigation.assignFocus('item1') 125 | 126 | navigation.handleKeyEvent({ direction: 'down' }) 127 | 128 | expect(navigation.currentFocusNode.id).toEqual('item3') 129 | }) 130 | 131 | it('should jump over multiple empty branches - vertical', () => { 132 | const navigation = new Lrud() 133 | .registerNode('root', { orientation: 'vertical' }) 134 | .registerNode('branch1', { orientation: 'horizontal' }) 135 | .registerNode('item1', { parent: 'branch1', isFocusable: true }) 136 | .registerNode('branch2', { orientation: 'horizontal' }) 137 | .registerNode('branch3', { orientation: 'horizontal' }) 138 | .registerNode('branch4', { orientation: 'horizontal' }) 139 | .registerNode('item4', { parent: 'branch4', isFocusable: true }) 140 | 141 | navigation.assignFocus('item1') 142 | 143 | navigation.handleKeyEvent({ direction: 'down' }) 144 | 145 | expect(navigation.currentFocusNode.id).toEqual('item4') 146 | }) 147 | 148 | it('should jump over multiple empty branches - horizontal', () => { 149 | const navigation = new Lrud() 150 | .registerNode('root', { orientation: 'horizontal' }) 151 | .registerNode('branch1', { orientation: 'horizontal' }) 152 | .registerNode('item1', { parent: 'branch1', isFocusable: true }) 153 | .registerNode('branch2', { orientation: 'horizontal' }) 154 | .registerNode('branch3', { orientation: 'horizontal' }) 155 | .registerNode('branch4', { orientation: 'horizontal' }) 156 | .registerNode('item4', { parent: 'branch4', isFocusable: true }) 157 | 158 | navigation.assignFocus('item1') 159 | 160 | navigation.handleKeyEvent({ direction: 'right' }) 161 | 162 | expect(navigation.currentFocusNode.id).toEqual('item4') 163 | }) 164 | 165 | /** 166 | * @see https://github.com/bbc/lrud/issues/81 167 | */ 168 | test('should jump to first focusable node in other branch', () => { 169 | const navigation = new Lrud() 170 | .registerNode('root', { orientation: 'vertical' }) 171 | .registerNode('a', { parent: 'root', orientation: 'vertical' }) 172 | .registerNode('aa', { parent: 'a', isFocusable: true }) 173 | .registerNode('ab', { parent: 'a' }) 174 | .registerNode('b', { parent: 'root', orientation: 'vertical' }) 175 | .registerNode('ba', { parent: 'b', isFocusable: true }) 176 | .registerNode('bb', { parent: 'b', isFocusable: true }) 177 | 178 | navigation.assignFocus('aa') 179 | 180 | navigation.handleKeyEvent({ direction: 'down' }) 181 | 182 | expect(navigation.currentFocusNode.id).toEqual('ba') 183 | }) 184 | 185 | /** 186 | * @see https://github.com/bbc/lrud/issues/82 187 | */ 188 | test('should focus node with only non focusable children', () => { 189 | const navigation = new Lrud() 190 | .registerNode('root', { orientation: 'vertical' }) 191 | .registerNode('a', { parent: 'root', isFocusable: true }) 192 | .registerNode('aa', { parent: 'a' }) 193 | 194 | navigation.assignFocus('a') 195 | 196 | expect(navigation.currentFocusNode.id).toEqual('a') 197 | }) 198 | 199 | /** 200 | * @see https://github.com/bbc/lrud/issues/76 201 | */ 202 | test('should jump over wrong orientation to first focusable node in other branch - vertical', () => { 203 | const navigation = new Lrud() 204 | .registerNode('root', { orientation: 'vertical' }) 205 | .registerNode('a', { parent: 'root', orientation: 'horizontal' }) 206 | .registerNode('aa', { parent: 'a' }) 207 | .registerNode('ab', { parent: 'a', isFocusable: true }) 208 | .registerNode('ac', { parent: 'a' }) 209 | .registerNode('b', { parent: 'root', isFocusable: true }) 210 | 211 | navigation.assignFocus('b') 212 | 213 | navigation.handleKeyEvent({ direction: 'up' }) 214 | 215 | expect(navigation.currentFocusNode.id).toEqual('ab') 216 | }) 217 | 218 | /** 219 | * @see https://github.com/bbc/lrud/issues/56 220 | */ 221 | test('should jump over wrong orientation to first focusable node in other branch - horizontal', () => { 222 | const navigation = new Lrud() 223 | .registerNode('root', { orientation: 'horizontal' }) 224 | .registerNode('a', { parent: 'root', orientation: 'vertical' }) 225 | .registerNode('aa', { parent: 'a', isFocusable: true }) 226 | .registerNode('b', { parent: 'root', orientation: 'vertical' }) 227 | .registerNode('ba', { parent: 'b', orientation: 'horizontal' }) 228 | .registerNode('bb', { parent: 'b', orientation: 'horizontal' }) 229 | .registerNode('bba', { parent: 'bb', isFocusable: true }) 230 | 231 | navigation.assignFocus('aa') 232 | 233 | navigation.handleKeyEvent({ direction: 'right' }) 234 | 235 | expect(navigation.currentFocusNode.id).toEqual('bba') 236 | }) 237 | }) 238 | -------------------------------------------------------------------------------- /src/handle-key-event.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const { Lrud } = require('./index') 4 | 5 | describe('handleKeyEvent()', () => { 6 | test('simple horizontal list - move to a sibling', () => { 7 | const navigation = new Lrud() 8 | .registerNode('root', { orientation: 'horizontal' }) 9 | .registerNode('child_1', { parent: 'root', isFocusable: true }) 10 | .registerNode('child_2', { parent: 'root', isFocusable: true }) 11 | .registerNode('child_3', { parent: 'root', isFocusable: true }) 12 | 13 | navigation.assignFocus('child_1') 14 | 15 | navigation.handleKeyEvent({ direction: 'right' }) 16 | 17 | expect(navigation.currentFocusNode.id).toEqual('child_2') 18 | }) 19 | 20 | test('already focused on the last sibling, and no more branches - leave focus where it is', () => { 21 | const navigation = new Lrud() 22 | .registerNode('root', { orientation: 'horizontal' }) 23 | .registerNode('child_1', { parent: 'root', isFocusable: true }) 24 | .registerNode('child_2', { parent: 'root', isFocusable: true }) 25 | .registerNode('child_3', { parent: 'root', isFocusable: true }) 26 | 27 | navigation.assignFocus('child_3') 28 | 29 | navigation.handleKeyEvent({ direction: 'right' }) 30 | 31 | expect(navigation.currentFocusNode.id).toEqual('child_3') 32 | }) 33 | 34 | test('already focused on the last sibling, but the parent wraps - focus needs to go to the first sibling', () => { 35 | const navigation = new Lrud() 36 | .registerNode('root', { orientation: 'horizontal', isWrapping: true }) 37 | .registerNode('child_1', { parent: 'root', isFocusable: true }) 38 | .registerNode('child_2', { parent: 'root', isFocusable: true }) 39 | .registerNode('child_3', { parent: 'root', isFocusable: true }) 40 | 41 | navigation.assignFocus('child_3') 42 | 43 | navigation.handleKeyEvent({ direction: 'right' }) 44 | 45 | expect(navigation.currentFocusNode.id).toEqual('child_1') 46 | }) 47 | 48 | test('already focused on the first sibling, but the parent wraps - focus needs to go to the last sibling', () => { 49 | const navigation = new Lrud() 50 | .registerNode('root', { orientation: 'horizontal', isWrapping: true }) 51 | .registerNode('child_1', { parent: 'root', isFocusable: true }) 52 | .registerNode('child_2', { parent: 'root', isFocusable: true }) 53 | .registerNode('child_3', { parent: 'root', isFocusable: true }) 54 | 55 | navigation.assignFocus('child_1') 56 | 57 | navigation.handleKeyEvent({ direction: 'left' }) 58 | 59 | expect(navigation.currentFocusNode.id).toEqual('child_3') 60 | }) 61 | 62 | test('moving across a simple horizontal list twice - fire focus events', () => { 63 | const navigation = new Lrud() 64 | .registerNode('root', { orientation: 'horizontal' }) 65 | .registerNode('child_1', { parent: 'root', isFocusable: true }) 66 | .registerNode('child_2', { parent: 'root', isFocusable: true }) 67 | .registerNode('child_3', { parent: 'root', isFocusable: true }) 68 | 69 | const spy = jest.fn() 70 | navigation.on('focus', spy) 71 | 72 | navigation.assignFocus('child_1') 73 | 74 | navigation.handleKeyEvent({ direction: 'right' }) 75 | 76 | expect(navigation.currentFocusNode.id).toEqual('child_2') 77 | expect(spy).toHaveBeenCalledWith(expect.objectContaining({ id: 'child_2' })) 78 | 79 | navigation.handleKeyEvent({ direction: 'right' }) 80 | 81 | expect(navigation.currentFocusNode.id).toEqual('child_3') 82 | expect(spy).toHaveBeenCalledWith(expect.objectContaining({ id: 'child_3' })) 83 | }) 84 | 85 | test('moving across a simple horizontal list, forwards then backwards - fire focus events', () => { 86 | const navigation = new Lrud() 87 | .registerNode('root', { orientation: 'horizontal' }) 88 | .registerNode('child_1', { parent: 'root', isFocusable: true }) 89 | .registerNode('child_2', { parent: 'root', isFocusable: true }) 90 | .registerNode('child_3', { parent: 'root', isFocusable: true }) 91 | 92 | const spy = jest.fn() 93 | navigation.on('focus', spy) 94 | 95 | navigation.assignFocus('child_1') 96 | 97 | navigation.handleKeyEvent({ direction: 'right' }) 98 | 99 | expect(navigation.currentFocusNode.id).toEqual('child_2') 100 | expect(spy).toHaveBeenCalledWith(expect.objectContaining({ id: 'child_2' })) 101 | 102 | navigation.handleKeyEvent({ direction: 'left' }) 103 | 104 | expect(navigation.currentFocusNode.id).toEqual('child_1') 105 | expect(spy).toHaveBeenCalledWith(expect.objectContaining({ id: 'child_1' })) 106 | }) 107 | 108 | test('should jump between activeChild for 2 vertical panes side-by-side', () => { 109 | const navigation = new Lrud() 110 | .registerNode('root', { orientation: 'horizontal' }) 111 | .registerNode('l', { orientation: 'vertical' }) 112 | .registerNode('l-1', { parent: 'l', isFocusable: true }) 113 | .registerNode('l-2', { parent: 'l', isFocusable: true }) 114 | .registerNode('l-3', { parent: 'l', isFocusable: true }) 115 | .registerNode('r', { orientation: 'vertical' }) 116 | .registerNode('r-1', { parent: 'r', isFocusable: true }) 117 | .registerNode('r-2', { parent: 'r', isFocusable: true }) 118 | .registerNode('r-3', { parent: 'r', isFocusable: true }) 119 | 120 | navigation.assignFocus('l-2') 121 | 122 | // go down one... 123 | navigation.handleKeyEvent({ direction: 'down' }) 124 | expect(navigation.currentFocusNode.id).toEqual('l-3') 125 | 126 | // jump across to right pane, first focusable... 127 | navigation.handleKeyEvent({ direction: 'right' }) 128 | expect(navigation.currentFocusNode.id).toEqual('r-1') 129 | 130 | // go down one... 131 | navigation.handleKeyEvent({ direction: 'down' }) 132 | expect(navigation.currentFocusNode.id).toEqual('r-2') 133 | 134 | // go back left again... 135 | navigation.handleKeyEvent({ direction: 'left' }) 136 | expect(navigation.currentFocusNode.id).toEqual('l-3') 137 | }) 138 | 139 | test('moving between 2 vertical wrappers inside a vertical wrapper, non-index aligned [fig-3]', () => { 140 | const navigation = new Lrud() 141 | .registerNode('root', { orientation: 'vertical' }) 142 | .registerNode('list-a', { orientation: 'vertical' }) 143 | .registerNode('list-a-box-1', { parent: 'list-a', isFocusable: true }) 144 | .registerNode('list-a-box-2', { parent: 'list-a', isFocusable: true }) 145 | .registerNode('list-a-box-3', { parent: 'list-a', isFocusable: true }) 146 | .registerNode('list-b', { orientation: 'vertical' }) 147 | .registerNode('list-b-box-1', { parent: 'list-b', isFocusable: true }) 148 | .registerNode('list-b-box-2', { parent: 'list-b', isFocusable: true }) 149 | .registerNode('list-b-box-3', { parent: 'list-b', isFocusable: true }) 150 | 151 | navigation.assignFocus('list-a-box-1') 152 | 153 | navigation.handleKeyEvent({ direction: 'down' }) 154 | expect(navigation.currentFocusNode.id).toEqual('list-a-box-2') 155 | 156 | navigation.handleKeyEvent({ direction: 'down' }) 157 | expect(navigation.currentFocusNode.id).toEqual('list-a-box-3') 158 | 159 | navigation.handleKeyEvent({ direction: 'down' }) 160 | expect(navigation.currentFocusNode.id).toEqual('list-b-box-1') 161 | 162 | navigation.handleKeyEvent({ direction: 'down' }) 163 | expect(navigation.currentFocusNode.id).toEqual('list-b-box-2') 164 | }) 165 | 166 | test('no focused node, should not fail and do nothing', () => { 167 | const navigation = new Lrud() 168 | .registerNode('root') 169 | .registerNode('a', { orientation: 'vertical' }) 170 | .registerNode('aa', { parent: 'a' }) 171 | .registerNode('ab', { parent: 'a', isFocusable: true }) 172 | .registerNode('ac', { parent: 'a' }) 173 | 174 | expect(() => { 175 | navigation.handleKeyEvent({ direction: 'down' }) 176 | }).not.toThrow() 177 | expect(navigation.currentFocusNode).toBeUndefined() 178 | }) 179 | 180 | test('no focused node, should not fail and force focusing first focusable node', () => { 181 | const navigation = new Lrud() 182 | .registerNode('root') 183 | .registerNode('a', { orientation: 'vertical' }) 184 | .registerNode('aa', { parent: 'a' }) 185 | .registerNode('ab', { parent: 'a', isFocusable: true }) 186 | .registerNode('ac', { parent: 'a' }) 187 | .registerNode('b', { isFocusable: true }) 188 | 189 | expect(() => { 190 | navigation.handleKeyEvent({ direction: 'down' }, { forceFocus: true }) 191 | }).not.toThrow() 192 | expect(navigation.currentFocusNode.id).toEqual('ab') 193 | }) 194 | 195 | test('should not fail when forcing focus, but there\'s no node to be focused', () => { 196 | const navigation = new Lrud() 197 | .registerNode('root') 198 | 199 | let focusedNode 200 | expect(() => { 201 | focusedNode = navigation.handleKeyEvent({ direction: 'down' }, { forceFocus: true }) 202 | }).not.toThrow() 203 | expect(focusedNode).toBeUndefined() 204 | }) 205 | 206 | test('should detect direction basing on key event', () => { 207 | const navigation = new Lrud() 208 | .registerNode('root', { orientation: 'horizontal' }) 209 | .registerNode('a', { parent: 'root', isFocusable: true }) 210 | .registerNode('b', { parent: 'root', isFocusable: true }) 211 | 212 | navigation.assignFocus('a') 213 | 214 | const result = navigation.handleKeyEvent({ keyCode: 5 }) 215 | 216 | expect(result.id).toEqual('b') 217 | }) 218 | 219 | test('should do nothing when direction can not be determined', () => { 220 | const navigation = new Lrud() 221 | .registerNode('root', { orientation: 'horizontal' }) 222 | .registerNode('a', { parent: 'root', isFocusable: true }) 223 | .registerNode('b', { parent: 'root', isFocusable: true }) 224 | 225 | navigation.assignFocus('a') 226 | 227 | const result = navigation.handleKeyEvent({}) 228 | 229 | expect(result).toBeUndefined() 230 | expect(navigation.currentFocusNode.id).toEqual('a') 231 | }) 232 | 233 | test('should do nothing when key event is not defined', () => { 234 | const navigation = new Lrud() 235 | .registerNode('root', { orientation: 'horizontal' }) 236 | .registerNode('a', { parent: 'root', isFocusable: true }) 237 | .registerNode('b', { parent: 'root', isFocusable: true }) 238 | 239 | navigation.assignFocus('a') 240 | 241 | const result = navigation.handleKeyEvent(undefined) 242 | 243 | expect(result).toBeUndefined() 244 | expect(navigation.currentFocusNode.id).toEqual('a') 245 | }) 246 | 247 | /* 248 | * This is very useful feature, it allows to define closed boxes from which focus can not jump out. 249 | * For example modal popups, that transparently overlays main page and has Ok/Cancel buttons. Focus should stay 250 | * withing this popup, but the rest of the LRUD tree may stay untouched. 251 | */ 252 | test('should do nothing when parent orientation is not defined', () => { 253 | const navigation = new Lrud() 254 | .registerNode('root', { orientation: undefined }) 255 | .registerNode('a', { parent: 'root', isFocusable: true }) 256 | .registerNode('b', { parent: 'root', isFocusable: true }) 257 | .registerNode('c', { parent: 'root', isFocusable: true }) 258 | 259 | navigation.assignFocus('b') 260 | 261 | expect(navigation.handleKeyEvent({ direction: 'down' })).toBeUndefined() 262 | expect(navigation.currentFocusNode.id).toEqual('b') 263 | 264 | expect(navigation.handleKeyEvent({ direction: 'up' })).toBeUndefined() 265 | expect(navigation.currentFocusNode.id).toEqual('b') 266 | 267 | expect(navigation.handleKeyEvent({ direction: 'left' })).toBeUndefined() 268 | expect(navigation.currentFocusNode.id).toEqual('b') 269 | 270 | expect(navigation.handleKeyEvent({ direction: 'right' })).toBeUndefined() 271 | expect(navigation.currentFocusNode.id).toEqual('b') 272 | 273 | expect(navigation.handleKeyEvent({ direction: '*' })).toBeUndefined() 274 | expect(navigation.currentFocusNode.id).toEqual('b') 275 | }) 276 | }) 277 | -------------------------------------------------------------------------------- /src/insert-tree.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | const { Lrud } = require('./index') 3 | 4 | describe('insertTree()', () => { 5 | test('should insert a simple tree into an empty instance', () => { 6 | const navigation = new Lrud() 7 | const tree = { 8 | id: 'root', 9 | orientation: 'horizontal', 10 | children: [ 11 | { id: 'child_a', isFocusable: true }, 12 | { id: 'child_b', isFocusable: true } 13 | ] 14 | } 15 | 16 | navigation.insertTree(tree) 17 | navigation.assignFocus('child_a') 18 | 19 | const root = navigation.nodes.root 20 | expect(root).toBeTruthy() 21 | expect(root.children.length).toEqual(2) 22 | expect(root.children[0].id).toEqual('child_a') 23 | expect(root.children[1].id).toEqual('child_b') 24 | expect(navigation.currentFocusNode.id).toEqual('child_a') 25 | }) 26 | 27 | test('should insert a simple tree into an existing branch of lrud', () => { 28 | const tree = { 29 | id: 'alpha', 30 | orientation: 'horizontal', 31 | children: [ 32 | { id: 'child_a', isFocusable: true }, 33 | { id: 'child_b', isFocusable: true } 34 | ] 35 | } 36 | 37 | const navigation = new Lrud() 38 | .registerNode('root', { orientation: 'horizontal' }) 39 | .registerNode('alpha', { isFocusable: true }) 40 | .registerNode('beta', { isFocusable: true }) 41 | 42 | const root = navigation.nodes.root 43 | expect(root).toBeTruthy() 44 | expect(root.children.length).toEqual(2) 45 | 46 | let alpha = root.children[0] 47 | expect(alpha.id).toEqual('alpha') 48 | expect(alpha.isFocusable).toEqual(true) 49 | expect(alpha.children).toBeUndefined() 50 | 51 | let beta = root.children[1] 52 | expect(beta.id).toEqual('beta') 53 | expect(beta.isFocusable).toEqual(true) 54 | expect(beta.children).toBeUndefined() 55 | 56 | navigation.insertTree(tree) 57 | 58 | alpha = root.children[0] 59 | expect(alpha.children.length).toEqual(2) 60 | expect(alpha.children[0]).toMatchObject({ id: 'child_a', isFocusable: true }) 61 | expect(alpha.children[1]).toMatchObject({ id: 'child_b', isFocusable: true }) 62 | 63 | beta = root.children[1] 64 | expect(beta.children).toBeUndefined() 65 | }) 66 | 67 | test('simple tree, inserting into existing branch, maintain index order', () => { 68 | const tree = { 69 | id: 'beta', 70 | orientation: 'horizontal', 71 | children: [ 72 | { id: 'child_a', isFocusable: true }, 73 | { id: 'child_b', isFocusable: true } 74 | ] 75 | } 76 | 77 | const navigation = new Lrud() 78 | .registerNode('root', { orientation: 'horizontal' }) 79 | .registerNode('alpha', { isFocusable: true }) 80 | .registerNode('beta', { isFocusable: true }) 81 | .registerNode('charlie', { isFocusable: true }) 82 | 83 | const root = navigation.nodes.root 84 | expect(root.children[0]).toMatchObject({ id: 'alpha', index: 0 }) 85 | expect(root.children[1]).toMatchObject({ id: 'beta', index: 1 }) 86 | expect(root.children[2]).toMatchObject({ id: 'charlie', index: 2 }) 87 | 88 | navigation.insertTree(tree) 89 | 90 | expect(root.children[0]).toMatchObject({ id: 'alpha', index: 0 }) 91 | expect(root.children[1]).toMatchObject({ id: 'beta', index: 1 }) 92 | expect(root.children[2]).toMatchObject({ id: 'charlie', index: 2 }) 93 | }) 94 | 95 | test('simple tree, inserting into existing branch, DONT maintain index order', () => { 96 | const tree = { 97 | id: 'beta', 98 | orientation: 'horizontal', 99 | children: [ 100 | { id: 'child_a', isFocusable: true }, 101 | { id: 'child_b', isFocusable: true } 102 | ] 103 | } 104 | 105 | const navigation = new Lrud() 106 | .registerNode('root', { orientation: 'horizontal' }) 107 | .registerNode('alpha', { isFocusable: true }) 108 | .registerNode('beta', { isFocusable: true }) 109 | .registerNode('charlie', { isFocusable: true }) 110 | 111 | const root = navigation.nodes.root 112 | expect(root.children[0]).toMatchObject({ id: 'alpha', index: 0 }) 113 | expect(root.children[1]).toMatchObject({ id: 'beta', index: 1 }) 114 | expect(root.children[2]).toMatchObject({ id: 'charlie', index: 2 }) 115 | 116 | navigation.insertTree(tree, { maintainIndex: false }) 117 | 118 | // note that beta is now at the end, as it was picked and re-inserted 119 | expect(root.children[0]).toMatchObject({ id: 'alpha', index: 0 }) 120 | expect(root.children[1]).toMatchObject({ id: 'charlie', index: 1 }) 121 | expect(root.children[2]).toMatchObject({ id: 'beta', index: 2 }) 122 | }) 123 | 124 | test('insert a tree under the root node of the existing tree, as no parent given on the top node of the tree', () => { 125 | const tree = { 126 | id: 'charlie', 127 | orientation: 'horizontal', 128 | children: [ 129 | { id: 'child_a', isFocusable: true }, 130 | { id: 'child_b', isFocusable: true } 131 | ] 132 | } 133 | 134 | const navigation = new Lrud() 135 | .registerNode('root', { orientation: 'horizontal' }) 136 | .registerNode('alpha', { isFocusable: true }) 137 | .registerNode('beta', { isFocusable: true }) 138 | 139 | navigation.insertTree(tree) 140 | 141 | const root = navigation.nodes.root 142 | expect(root.children[0]).toMatchObject({ id: 'alpha', index: 0 }) 143 | expect(root.children[1]).toMatchObject({ id: 'beta', index: 1 }) 144 | 145 | const charlie = root.children[2] 146 | expect(charlie).toMatchObject({ id: 'charlie', index: 2 }) 147 | expect(charlie.children).toBeTruthy() 148 | }) 149 | 150 | test('insert a tree under a branch that ISNT the root node', () => { 151 | const tree = { 152 | id: 'charlie', 153 | parent: 'beta', 154 | orientation: 'horizontal', 155 | children: [ 156 | { id: 'child_a', isFocusable: true }, 157 | { id: 'child_b', isFocusable: true } 158 | ] 159 | } 160 | 161 | const navigation = new Lrud() 162 | .registerNode('root', { orientation: 'horizontal' }) 163 | .registerNode('alpha', { isFocusable: true }) 164 | .registerNode('beta', { orientation: 'vertical' }) 165 | 166 | navigation.insertTree(tree) 167 | 168 | const root = navigation.nodes.root 169 | expect(root.children[0]).toMatchObject({ id: 'alpha' }) 170 | expect(root.children[1]).toMatchObject({ id: 'beta' }) 171 | expect(root.children[1].children[0]).toMatchObject({ id: 'charlie' }) 172 | expect(root.children[1].children[0].children).toBeTruthy() 173 | }) 174 | 175 | /** 176 | * @see https://github.com/bbc/lrud/issues/84 177 | */ 178 | test('should correctly maintain index when replacing first child', () => { 179 | const navigation = new Lrud() 180 | .registerNode('root') 181 | .registerNode('a') 182 | .registerNode('b') 183 | .registerNode('c') 184 | 185 | navigation.insertTree({ id: 'a', isFocusable: true }) 186 | 187 | // expect top node was replaced with inserted tree 188 | expect(navigation.getNode('a').isFocusable).toEqual(true) 189 | 190 | // expect index of the top node parent is maintained 191 | expect(navigation.getNode('a').index).toEqual(0) 192 | }) 193 | 194 | test('coherent index, keep parent\'s children indices coherent if index is not maintained', () => { 195 | const navigation = new Lrud() 196 | .registerNode('root') 197 | .registerNode('a') 198 | .registerNode('b') 199 | .registerNode('c') 200 | .registerNode('d') 201 | 202 | navigation.insertTree({ id: 'c', index: 1, isFocusable: true }, { maintainIndex: false }) 203 | 204 | // expect top node was replaced with inserted tree 205 | expect(navigation.getNode('c').isFocusable).toEqual(true) 206 | 207 | // original 'c' was unregistered, so 'd' was shifted down to '2', 208 | // but than new 'c' was inserted at '1', so 'd' was shifted up back to '3' and 'b' was shifted up to '2' 209 | expect(navigation.getNode('a').index).toEqual(0) 210 | expect(navigation.getNode('b').index).toEqual(2) 211 | expect(navigation.getNode('c').index).toEqual(1) 212 | expect(navigation.getNode('d').index).toEqual(3) 213 | }) 214 | 215 | test('coherent index, should maintain original child index overriding provided index', () => { 216 | const navigation = new Lrud() 217 | .registerNode('root') 218 | .registerNode('a') 219 | .registerNode('b') 220 | 221 | navigation.insertTree({ id: 'a', index: 5, isFocusable: true }) 222 | 223 | // expect top node was replaced with inserted tree 224 | expect(navigation.getNode('a').isFocusable).toEqual(true) 225 | 226 | // existing 'a' node was unregistered, but its index is reassigned and kept by re-registered 'a' node 227 | expect(navigation.getNode('a').index).toEqual(0) 228 | }) 229 | 230 | test('should not fail when tree is not defined', () => { 231 | const navigation = new Lrud() 232 | .registerNode('root') 233 | 234 | expect(() => navigation.insertTree(undefined)).not.toThrow() 235 | }) 236 | }) 237 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-vars: "off", @typescript-eslint/no-explicit-any: "off", no-use-before-define: "off" */ 2 | 3 | export enum Directions { 4 | LEFT = 'left', 5 | RIGHT = 'right', 6 | UP = 'up', 7 | DOWN = 'down', 8 | ENTER = 'enter', 9 | UNSPECIFIED = '*' 10 | } 11 | 12 | export type Direction = `${Directions}` 13 | 14 | export enum Orientations { 15 | VERTICAL = 'vertical', 16 | HORIZONTAL = 'horizontal' 17 | } 18 | 19 | export type Orientation = `${Orientations}` 20 | 21 | export type NodeId = string 22 | 23 | export type NodeIndex = number 24 | 25 | export type NodeIndexRange = [NodeIndex, NodeIndex] 26 | 27 | export interface Tree { 28 | children? :NodeType[] 29 | } 30 | 31 | export interface Node extends Tree { 32 | id: NodeId 33 | parent?: Node 34 | index?: NodeIndex 35 | activeChild?: Node 36 | indexRange?: NodeIndexRange 37 | selectAction?: any 38 | isFocusable?: boolean 39 | isWrapping?: boolean 40 | isStopPropagate?: boolean 41 | orientation?: Orientation 42 | isIndexAlign?: boolean 43 | overrides?: { [direction in Direction]?: Node } 44 | overrideSources?: { direction: Direction, node: Node }[] 45 | onLeave?: (leave: Node) => void 46 | onEnter?: (enter: Node) => void 47 | shouldCancelLeave?: (leave: Node, enter: Node) => boolean 48 | onLeaveCancelled?: (currentFocusNode: Node, focusableNode: Node) => void 49 | shouldCancelEnter?: (leave: Node, enter: Node) => boolean 50 | onEnterCancelled?: (currentFocusNode: Node, focusableNode: Node) => void 51 | onSelect?: (node: Node) => void 52 | onInactive?: (node: Node) => void 53 | onActive?: (node: Node) => void 54 | onActiveChildChange?: (event: { node: Node, leave: Node, enter: Node }) => void 55 | onBlur?: (node: Node) => void 56 | onFocus?: (node: Node) => void 57 | onMove?: (event: { node: Node, leave: Node, enter: Node, direction: Direction, offset: -1 | 1 }) => void 58 | } 59 | 60 | export interface NodeConfig extends Tree, Omit { 61 | id?: NodeId 62 | parent?: NodeId 63 | } 64 | 65 | export type NodesBag = { [id in NodeId]: Node } 66 | 67 | export interface KeyEvent { 68 | keyCode?: number 69 | direction?: Direction 70 | } 71 | 72 | export interface HandleKeyEventOptions { 73 | forceFocus?: boolean 74 | } 75 | 76 | export interface InsertTreeOptions { 77 | maintainIndex?: boolean 78 | } 79 | 80 | export interface UnregisterNodeOptions { 81 | forceRefocus?: boolean 82 | } 83 | 84 | export interface MoveNodeOptions { 85 | index?: NodeIndex, 86 | maintainIndex?: boolean 87 | } 88 | 89 | export interface RegisterOverrideOptions { 90 | forceOverride?: boolean 91 | } 92 | -------------------------------------------------------------------------------- /src/key-codes.ts: -------------------------------------------------------------------------------- 1 | import { Direction, Directions } from './interfaces' 2 | 3 | export const KeyCodes: { [keyCode: number]: Direction } = { 4 | 4: Directions.LEFT, 5 | 21: Directions.LEFT, 6 | 37: Directions.LEFT, 7 | 214: Directions.LEFT, 8 | 205: Directions.LEFT, 9 | 218: Directions.LEFT, 10 | 5: Directions.RIGHT, 11 | 22: Directions.RIGHT, 12 | 39: Directions.RIGHT, 13 | 213: Directions.RIGHT, 14 | 206: Directions.RIGHT, 15 | 217: Directions.RIGHT, 16 | 29460: Directions.UP, 17 | 19: Directions.UP, 18 | 38: Directions.UP, 19 | 211: Directions.UP, 20 | 203: Directions.UP, 21 | 215: Directions.UP, 22 | 29461: Directions.DOWN, 23 | 20: Directions.DOWN, 24 | 40: Directions.DOWN, 25 | 212: Directions.DOWN, 26 | 204: Directions.DOWN, 27 | 216: Directions.DOWN, 28 | 29443: Directions.ENTER, 29 | 13: Directions.ENTER, 30 | 67: Directions.ENTER, 31 | 32: Directions.ENTER, 32 | 23: Directions.ENTER, 33 | 195: Directions.ENTER 34 | } 35 | -------------------------------------------------------------------------------- /src/multiple-instance-interaction.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const { Lrud } = require('./index') 4 | 5 | describe('registerTree()', () => { 6 | test('register a tree from one instance into another instance', () => { 7 | const Alpha = new Lrud() 8 | const Beta = new Lrud() 9 | 10 | // register nodes against A 11 | Alpha.registerNode('root') 12 | Alpha.registerNode('a', { isFocusable: true }) 13 | Alpha.registerNode('b', { isFocusable: true }) 14 | 15 | // register A's tree against B 16 | Beta.registerTree(Alpha.getRootNode()) 17 | 18 | // B should now have the correct nodes in its tree 19 | expect(Beta.nodes.root).toBeTruthy() 20 | expect(Beta.nodes.root.children.length).toEqual(2) 21 | expect(Beta.nodes.root.children[0].id).toEqual('a') 22 | expect(Beta.nodes.root.children[1].id).toEqual('b') 23 | }) 24 | 25 | test('should not fail when tree is not defined', () => { 26 | const navigation = new Lrud() 27 | navigation.registerNode('root') 28 | 29 | expect(() => navigation.registerTree(undefined)).not.toThrow() 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/on-active-change.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const { Lrud } = require('./index') 4 | 5 | describe('onActiveChildChange() tests', () => { 6 | test('horizontal list, active changes are on leaves', () => { 7 | let moveObject 8 | const onActiveChildChange = (changeData) => { 9 | moveObject = changeData 10 | } 11 | 12 | const navigation = new Lrud() 13 | .registerNode('root', { orientation: 'horizontal', onActiveChildChange }) 14 | .registerNode('a', { isFocusable: true }) 15 | .registerNode('b', { isFocusable: true }) 16 | .registerNode('c', { isFocusable: true }) 17 | .registerNode('d', { isFocusable: true }) 18 | 19 | navigation.assignFocus('b') 20 | 21 | navigation.handleKeyEvent({ direction: 'right' }) 22 | 23 | expect(navigation.currentFocusNode.id).toEqual('c') 24 | 25 | expect(moveObject.node.id).toEqual('root') 26 | expect(moveObject.leave.id).toEqual('b') 27 | expect(moveObject.enter.id).toEqual('c') 28 | }) 29 | 30 | test('nested changes', () => { 31 | const spy = jest.fn() 32 | 33 | const navigation = new Lrud() 34 | .registerNode('root', { orientation: 'horizontal', onActiveChildChange: spy }) 35 | .registerNode('left-col', { parent: 'root', orientation: 'vertical', onActiveChildChange: spy }) 36 | .registerNode('a', { isFocusable: true, parent: 'left-col' }) 37 | .registerNode('b', { isFocusable: true, parent: 'left-col' }) 38 | .registerNode('c', { isFocusable: true, parent: 'left-col' }) 39 | .registerNode('right-col', { parent: 'root', orientation: 'vertical', onActiveChildChange: spy }) 40 | .registerNode('d', { isFocusable: true, parent: 'right-col' }) 41 | .registerNode('e', { isFocusable: true, parent: 'right-col' }) 42 | .registerNode('f', { isFocusable: true, parent: 'right-col' }) 43 | 44 | navigation.assignFocus('a') 45 | 46 | navigation.handleKeyEvent({ direction: 'down' }) 47 | navigation.handleKeyEvent({ direction: 'right' }) 48 | 49 | // 1st assigning focus to 'a' 50 | expect(spy.mock.calls[0][0].node.id).toEqual('left-col') 51 | expect(spy.mock.calls[0][0].leave).toBeFalsy() 52 | expect(spy.mock.calls[0][0].enter.id).toEqual('a') 53 | // ...bubbling 54 | expect(spy.mock.calls[1][0].node.id).toEqual('root') 55 | expect(spy.mock.calls[1][0].leave).toBeFalsy() 56 | expect(spy.mock.calls[1][0].enter.id).toEqual('left-col') 57 | 58 | // 2nd is by going down to 'b' 59 | expect(spy.mock.calls[2][0].node.id).toEqual('left-col') 60 | expect(spy.mock.calls[2][0].leave.id).toEqual('a') 61 | expect(spy.mock.calls[2][0].enter.id).toEqual('b') 62 | 63 | // 3nd is by going right to 'd' 64 | expect(spy.mock.calls[3][0].node.id).toEqual('right-col') 65 | expect(spy.mock.calls[3][0].leave).toBeFalsy() 66 | expect(spy.mock.calls[3][0].enter.id).toEqual('d') 67 | // ...bubbling 68 | expect(spy.mock.calls[4][0].node.id).toEqual('root') 69 | expect(spy.mock.calls[4][0].leave.id).toEqual('left-col') 70 | expect(spy.mock.calls[4][0].enter.id).toEqual('right-col') 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /src/overrides.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const { Lrud } = require('./index') 4 | 5 | describe('overrides', () => { 6 | test('horizontal list with an override - override targets a leaf', () => { 7 | const navigation = new Lrud() 8 | .registerNode('root', { orientation: 'horizontal' }) 9 | .registerNode('a', { isFocusable: true }) 10 | .registerNode('b', { isFocusable: true }) 11 | .registerNode('c', { isFocusable: true }) 12 | .registerNode('d', { isFocusable: true }) 13 | 14 | navigation.assignFocus('c') 15 | 16 | navigation.registerOverride('c', 'a', 'down') 17 | 18 | // this would normally do nothing, but our override puts it to a 19 | navigation.handleKeyEvent({ direction: 'down' }) 20 | expect(navigation.currentFocusNode.id).toEqual('a') 21 | }) 22 | 23 | test('override targets a branch', () => { 24 | const navigation = new Lrud() 25 | .registerNode('root', { orientation: 'horizontal' }) 26 | .registerNode('left', { orientation: 'vertical' }) 27 | .registerNode('a', { parent: 'left', isFocusable: true }) 28 | .registerNode('right', { orientation: 'vertical' }) 29 | .registerNode('b', { parent: 'right', isFocusable: true }) 30 | 31 | navigation.assignFocus('a') 32 | 33 | navigation.registerOverride('a', 'right', 'right') 34 | 35 | navigation.handleKeyEvent({ direction: 'right' }) 36 | expect(navigation.currentFocusNode.id).toEqual('b') 37 | }) 38 | 39 | test('ignores override if direction does not match', () => { 40 | const navigation = new Lrud() 41 | .registerNode('root', { orientation: 'horizontal' }) 42 | .registerNode('a', { orientation: 'horizontal' }) 43 | .registerNode('aa', { parent: 'a', isFocusable: true }) 44 | .registerNode('ab', { parent: 'a', isFocusable: true }) 45 | .registerNode('b', { orientation: 'horizontal' }) 46 | .registerNode('ba', { parent: 'b', isFocusable: true }) 47 | 48 | navigation.assignFocus('aa') 49 | 50 | navigation.registerOverride('aa', 'ba', 'down') 51 | 52 | navigation.handleKeyEvent({ direction: 'right' }) 53 | expect(navigation.currentFocusNode.id).toEqual('ab') 54 | }) 55 | 56 | test('registering an override without source node should throw an exception', () => { 57 | const navigation = new Lrud() 58 | .registerNode('a') 59 | 60 | expect(() => { 61 | navigation.registerOverride(undefined, 'a', 'down') 62 | }).toThrow(Error) 63 | }) 64 | 65 | test('registering an override for not existing source node should throw an exception', () => { 66 | const navigation = new Lrud() 67 | .registerNode('a') 68 | 69 | expect(() => { 70 | navigation.registerOverride('notExisting', 'a', 'down') 71 | }).toThrow(Error) 72 | }) 73 | 74 | test('registering an override without target node should throw an exception', () => { 75 | const navigation = new Lrud() 76 | .registerNode('a') 77 | 78 | expect(() => { 79 | navigation.registerOverride('a', undefined, 'down') 80 | }).toThrow(Error) 81 | }) 82 | 83 | test('registering an override for not existing target node should throw an exception', () => { 84 | const navigation = new Lrud() 85 | .registerNode('a') 86 | 87 | expect(() => { 88 | navigation.registerOverride('a', 'notExisting', 'down') 89 | }).toThrow(Error) 90 | }) 91 | 92 | test('registering an override without direction should throw an exception', () => { 93 | const navigation = new Lrud() 94 | .registerNode('a') 95 | .registerNode('b') 96 | 97 | expect(() => { 98 | navigation.registerOverride('a', 'b', undefined) 99 | }).toThrow(Error) 100 | }) 101 | 102 | test('registering an override for not supported direction should throw an exception', () => { 103 | const navigation = new Lrud() 104 | .registerNode('a') 105 | .registerNode('b') 106 | 107 | expect(() => { 108 | navigation.registerOverride('a', 'b', 'notSupported') 109 | }).toThrow(Error) 110 | }) 111 | 112 | test('multiple overrides registered with the same source and direction - throw an exception', () => { 113 | const navigation = new Lrud() 114 | .registerNode('root', { orientation: 'horizontal' }) 115 | .registerNode('a', { isFocusable: true }) 116 | .registerNode('b', { isFocusable: true }) 117 | .registerNode('c', { isFocusable: true }) 118 | .registerNode('d', { isFocusable: true }) 119 | .registerOverride('c', 'a', 'down') 120 | 121 | navigation.assignFocus('c') 122 | 123 | expect(() => { 124 | navigation.registerOverride('c', 'b', 'down') 125 | }).toThrow(Error) 126 | }) 127 | 128 | test('multiple overrides registered with the same source and direction - forced overwrite', () => { 129 | const navigation = new Lrud() 130 | .registerNode('root', { orientation: 'horizontal' }) 131 | .registerNode('a', { isFocusable: true }) 132 | .registerNode('b', { isFocusable: true }) 133 | .registerNode('c', { isFocusable: true }) 134 | .registerNode('d', { isFocusable: true }) 135 | .registerOverride('c', 'a', 'down') 136 | 137 | navigation.assignFocus('c') 138 | 139 | expect(() => { 140 | navigation.registerOverride('c', 'b', 'down', { forceOverride: true }) 141 | }).not.toThrow(Error) 142 | 143 | navigation.handleKeyEvent({ direction: 'down' }) 144 | expect(navigation.currentFocusNode.id).toEqual('b') 145 | }) 146 | 147 | test('unregistering an override', () => { 148 | const navigation = new Lrud() 149 | .registerNode('a') 150 | .registerNode('b') 151 | .registerNode('c') 152 | .registerNode('d') 153 | .registerOverride('a', 'd', 'down') 154 | .registerOverride('b', 'c', 'up') 155 | .registerOverride('b', 'd', 'down') 156 | .registerOverride('c', 'd', 'down') 157 | 158 | expect(navigation.nodes.a.overrides.down).toMatchObject({ id: 'd' }) 159 | expect(navigation.nodes.b.overrides.up).toMatchObject({ id: 'c' }) 160 | expect(navigation.nodes.b.overrides.down).toMatchObject({ id: 'd' }) 161 | expect(navigation.nodes.c.overrides.down).toMatchObject({ id: 'd' }) 162 | expect(navigation.nodes.c.overrideSources).toEqual([ 163 | { direction: 'up', node: expect.objectContaining({ id: 'b' }) } 164 | ]) 165 | expect(navigation.nodes.d.overrideSources).toEqual([ 166 | { direction: 'down', node: expect.objectContaining({ id: 'a' }) }, 167 | { direction: 'down', node: expect.objectContaining({ id: 'b' }) }, 168 | { direction: 'down', node: expect.objectContaining({ id: 'c' }) } 169 | ]) 170 | 171 | navigation.unregisterOverride('b', 'down') 172 | 173 | expect(navigation.nodes.a.overrides.down).toMatchObject({ id: 'd' }) 174 | expect(navigation.nodes.b.overrides.up).toMatchObject({ id: 'c' }) 175 | expect(navigation.nodes.b.overrides.down).toBeUndefined() 176 | expect(navigation.nodes.c.overrides.down).toMatchObject({ id: 'd' }) 177 | expect(navigation.nodes.c.overrideSources).toEqual([ 178 | { direction: 'up', node: expect.objectContaining({ id: 'b' }) } 179 | ]) 180 | expect(navigation.nodes.d.overrideSources).toEqual(expect.arrayContaining([ 181 | { direction: 'down', node: expect.objectContaining({ id: 'a' }) }, 182 | { direction: 'down', node: expect.objectContaining({ id: 'c' }) } 183 | ])) 184 | expect(navigation.nodes.d.overrideSources.length).toEqual(2) 185 | }) 186 | 187 | test('unregistering an override without source node should not fail', () => { 188 | const navigation = new Lrud() 189 | .registerNode('a') 190 | .registerNode('b') 191 | .registerOverride('a', 'b', 'down') 192 | 193 | expect(() => { 194 | navigation.unregisterOverride(undefined, 'down') 195 | }).not.toThrow(Error) 196 | }) 197 | 198 | test('registering an override for not existing source node should not fail', () => { 199 | const navigation = new Lrud() 200 | .registerNode('a') 201 | .registerNode('b') 202 | .registerOverride('a', 'b', 'down') 203 | 204 | expect(() => { 205 | navigation.unregisterOverride('notExisting', 'down') 206 | }).not.toThrow(Error) 207 | }) 208 | 209 | test('registering an override without direction should not fail', () => { 210 | const navigation = new Lrud() 211 | .registerNode('a') 212 | .registerNode('b') 213 | .registerOverride('a', 'b', 'down') 214 | 215 | expect(() => { 216 | navigation.unregisterOverride('a', undefined) 217 | }).not.toThrow(Error) 218 | }) 219 | 220 | test('registering an override for not supported direction should not fail', () => { 221 | const navigation = new Lrud() 222 | .registerNode('a') 223 | .registerNode('b') 224 | .registerOverride('a', 'b', 'down') 225 | 226 | expect(() => { 227 | navigation.unregisterOverride('a', 'notSupported') 228 | }).not.toThrow(Error) 229 | }) 230 | }) 231 | -------------------------------------------------------------------------------- /src/register.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const { Lrud } = require('./index') 4 | 5 | describe('registerNode()', () => { 6 | test('registering the very first registered node sets it to the root node', () => { 7 | const navigation = new Lrud() 8 | .registerNode('root', { selectAction: true }) 9 | 10 | expect(navigation.rootNode.id).toEqual('root') 11 | 12 | expect(navigation.nodes.root.selectAction).toEqual(true) 13 | }) 14 | 15 | test('registering a node (after the root node) without a parent puts it under the root node', () => { 16 | const navigation = new Lrud() 17 | .registerNode('alpha', { selectAction: 1 }) 18 | .registerNode('beta', { selectAction: 1 }) 19 | .registerNode('charlie', { selectAction: 2 }) 20 | 21 | const root = navigation.rootNode 22 | expect(root.id).toEqual('alpha') 23 | expect(root.children[0].id).toEqual('beta') 24 | expect(root.children[1].id).toEqual('charlie') 25 | }) 26 | 27 | test('registering a node with a nested parent', () => { 28 | const navigation = new Lrud() 29 | .registerNode('alpha', { selectAction: 1 }) 30 | .registerNode('beta', { selectAction: 2 }) 31 | .registerNode('charlie', { selectAction: 3, parent: 'beta' }) 32 | 33 | const root = navigation.rootNode 34 | expect(root.id).toEqual('alpha') 35 | expect(root.children[0].children[0].parent.id).toEqual('beta') 36 | }) 37 | 38 | test('registering a node with a nested parent where the previous node have the same ending', () => { 39 | const navigation = new Lrud() 40 | .registerNode('alpha', { selectAction: 1 }) 41 | .registerNode('beta_beta', { selectAction: 3 }) 42 | .registerNode('beta', { selectAction: 2 }) 43 | .registerNode('charlie', { selectAction: 4, parent: 'beta' }) 44 | 45 | const root = navigation.rootNode 46 | expect(root.id).toEqual('alpha') 47 | expect(root.children[1].id).toEqual('beta') 48 | expect(root.children[1].children[0].id).toEqual('charlie') 49 | }) 50 | 51 | test('registering a node with a deeply nested parent', () => { 52 | const navigation = new Lrud() 53 | .registerNode('root') 54 | .registerNode('region-a', { parent: 'root' }) 55 | .registerNode('region-b', { parent: 'root' }) 56 | .registerNode('content-grid', { parent: 'region-b' }) 57 | .registerNode('PID-X', { parent: 'content-grid' }) 58 | .registerNode('PID-Y', { parent: 'content-grid' }) 59 | .registerNode('PID-Z', { parent: 'content-grid' }) 60 | 61 | const root = navigation.rootNode 62 | expect(root.children[0].id).toEqual('region-a') 63 | expect(root.children[1].id).toEqual('region-b') 64 | }) 65 | 66 | // reword this 67 | test('registering a new node with a parent that has no children should not set parent.activeChild to itself', () => { 68 | const navigation = new Lrud() 69 | .registerNode('root') 70 | .registerNode('alpha', { parent: 'root' }) 71 | .registerNode('beta', { parent: 'root' }) 72 | .registerNode('charlie', { parent: 'alpha' }) 73 | .registerNode('delta', { parent: 'charlie' }) 74 | .registerNode('echo', { parent: 'root' }) 75 | 76 | expect(navigation.getNode('root').activeChild).toBeUndefined() 77 | expect(navigation.getNode('alpha').activeChild).toBeUndefined() 78 | expect(navigation.getNode('charlie').activeChild).toBeUndefined() 79 | }) 80 | 81 | test('registering a node should add the index to the node', () => { 82 | const navigation = new Lrud() 83 | .registerNode('root') 84 | .registerNode('a') 85 | .registerNode('b') 86 | .registerNode('b-1', { parent: 'b' }) 87 | .registerNode('b-2', { parent: 'b' }) 88 | .registerNode('c') 89 | 90 | expect(navigation.getNode('a').index).toEqual(0) 91 | expect(navigation.getNode('b').index).toEqual(1) 92 | expect(navigation.getNode('b-1').index).toEqual(0) 93 | expect(navigation.getNode('b-2').index).toEqual(1) 94 | expect(navigation.getNode('c').index).toEqual(2) 95 | }) 96 | 97 | test('can chain registers together', () => { 98 | const navigation = new Lrud() 99 | .registerNode('root') 100 | .registerNode('a') 101 | .registerNode('b') 102 | .registerNode('c') 103 | 104 | const root = navigation.rootNode 105 | expect(root.children[0].id).toEqual('a') 106 | expect(root.children[1].id).toEqual('b') 107 | expect(root.children[2].id).toEqual('c') 108 | }) 109 | 110 | test('registering a node that already exists should throw an error', () => { 111 | const navigation = new Lrud() 112 | .registerNode('root') 113 | 114 | const node = navigation.getNode('root') 115 | 116 | expect(node.id).toEqual('root') 117 | 118 | expect(() => { 119 | navigation.registerNode('root') 120 | }).toThrow() 121 | }) 122 | 123 | test('coherent index, should assign last index value if no index value provided', () => { 124 | const navigation = new Lrud() 125 | .registerNode('root') 126 | .registerNode('a') 127 | .registerNode('b') 128 | .registerNode('c') 129 | 130 | expect(navigation.getNode('c').index).toEqual(2) 131 | }) 132 | 133 | test('coherent index, should assign last index value even if given index is greater than children size', () => { 134 | const navigation = new Lrud() 135 | .registerNode('root') 136 | .registerNode('a') 137 | .registerNode('b') 138 | .registerNode('c', { index: 3 }) 139 | 140 | expect(navigation.getNode('c').index).toEqual(2) 141 | }) 142 | 143 | test('coherent index, should insert node at a given position', () => { 144 | const navigation = new Lrud() 145 | .registerNode('root') 146 | .registerNode('a') 147 | .registerNode('b') 148 | .registerNode('c', { index: 1 }) 149 | 150 | expect(navigation.getNode('c').index).toEqual(1) 151 | expect(navigation.getNode('b').index).toEqual(2) 152 | }) 153 | 154 | test('should force using id given as a method parameter', () => { 155 | const navigation = new Lrud() 156 | .registerNode('root') 157 | .registerNode('a', { id: 'b', parent: 'root' }) 158 | 159 | expect(navigation.getNode('a')).toBeDefined() 160 | expect(navigation.getNode('b')).toBeUndefined() 161 | }) 162 | 163 | test('should ignore children provided in node configuration', () => { 164 | const navigation = new Lrud() 165 | .registerNode('root') 166 | .registerNode('a', { 167 | parent: 'root', 168 | children: { aa: { isFocusable: true } } 169 | }) 170 | 171 | expect(navigation.getNode('a').children).toBeUndefined() 172 | }) 173 | 174 | test('should do nothing when registering node under not existing parent', () => { 175 | const navigation = new Lrud() 176 | .registerNode('root') 177 | .registerNode('a') 178 | .registerNode('b') 179 | 180 | let result 181 | expect(() => { 182 | result = navigation.registerNode('c', { index: 1, parent: 'd' }) 183 | }).not.toThrow() 184 | 185 | expect(result).toEqual(navigation) 186 | }) 187 | }) 188 | -------------------------------------------------------------------------------- /src/scenarios.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const { Lrud } = require('./index') 4 | 5 | describe('test scenarios', () => { 6 | test('assigning focus to a branch should actually assign focus to the first active child', () => { 7 | const navigation = new Lrud() 8 | .registerNode('root', { isIndexAlign: true }) 9 | .registerNode('row-1', { orientation: 'horizontal' }) 10 | .registerNode('A', { parent: 'row-1', isFocusable: true }) 11 | .registerNode('B', { parent: 'row-1', isFocusable: true }) 12 | .registerNode('C', { parent: 'row-1', isFocusable: true }) 13 | .registerNode('D', { parent: 'row-1', isFocusable: true }) 14 | 15 | navigation.assignFocus('root') 16 | expect(navigation.currentFocusNode.id).toEqual('A') 17 | }) 18 | 19 | test('navigating around a (mini) keyboard', () => { 20 | const navigation = new Lrud() 21 | .registerNode('keyboard', { orientation: 'vertical', isIndexAlign: true }) 22 | .registerNode('row-1', { orientation: 'horizontal' }) 23 | .registerNode('A', { parent: 'row-1', isFocusable: true }) 24 | .registerNode('B', { parent: 'row-1', isFocusable: true }) 25 | .registerNode('C', { parent: 'row-1', isFocusable: true }) 26 | .registerNode('D', { parent: 'row-1', isFocusable: true }) 27 | .registerNode('row-2', { orientation: 'horizontal' }) 28 | .registerNode('E', { parent: 'row-2', isFocusable: true }) 29 | .registerNode('F', { parent: 'row-2', isFocusable: true }) 30 | .registerNode('G', { parent: 'row-2', isFocusable: true }) 31 | .registerNode('H', { parent: 'row-2', isFocusable: true }) 32 | .registerNode('row-3', { orientation: 'horizontal' }) 33 | .registerNode('I', { parent: 'row-3', isFocusable: true }) 34 | .registerNode('J', { parent: 'row-3', isFocusable: true }) 35 | .registerNode('K', { parent: 'row-3', isFocusable: true }) 36 | .registerNode('L', { parent: 'row-3', isFocusable: true }) 37 | 38 | navigation.assignFocus('keyboard') 39 | expect(navigation.currentFocusNode.id).toEqual('A') 40 | 41 | navigation.handleKeyEvent({ direction: 'right' }) 42 | expect(navigation.currentFocusNode.id).toEqual('B') 43 | 44 | navigation.handleKeyEvent({ direction: 'down' }) 45 | expect(navigation.currentFocusNode.id).toEqual('F') 46 | 47 | navigation.handleKeyEvent({ direction: 'right' }) 48 | expect(navigation.currentFocusNode.id).toEqual('G') 49 | 50 | navigation.handleKeyEvent({ direction: 'down' }) 51 | expect(navigation.currentFocusNode.id).toEqual('K') 52 | }) 53 | 54 | test('setting up a keyboard and forgetting to set the keyboard itself as vertical', () => { 55 | const navigation = new Lrud() 56 | .registerNode('keyboard', { isIndexAlign: true }) 57 | .registerNode('row-1', { orientation: 'horizontal' }) 58 | .registerNode('A', { parent: 'row-1', isFocusable: true }) 59 | .registerNode('B', { parent: 'row-1', isFocusable: true }) 60 | .registerNode('C', { parent: 'row-1', isFocusable: true }) 61 | .registerNode('row-2', { orientation: 'horizontal' }) 62 | .registerNode('D', { parent: 'row-2', isFocusable: true }) 63 | .registerNode('E', { parent: 'row-2', isFocusable: true }) 64 | .registerNode('F', { parent: 'row-2', isFocusable: true }) 65 | 66 | navigation.assignFocus('keyboard') 67 | expect(navigation.currentFocusNode.id).toEqual('A') 68 | 69 | // nothing to bubble to, so should still be on A 70 | navigation.handleKeyEvent({ direction: 'down' }) 71 | expect(navigation.currentFocusNode.id).toEqual('A') 72 | }) 73 | 74 | test('keyboard with space and delete', () => { 75 | const navigation = new Lrud() 76 | .registerNode('keyboard', { orientation: 'vertical', isIndexAlign: true }) 77 | .registerNode('row-1', { orientation: 'horizontal' }) 78 | .registerNode('A', { parent: 'row-1', isFocusable: true }) 79 | .registerNode('B', { parent: 'row-1', isFocusable: true }) 80 | .registerNode('C', { parent: 'row-1', isFocusable: true }) 81 | .registerNode('D', { parent: 'row-1', isFocusable: true }) 82 | .registerNode('E', { parent: 'row-1', isFocusable: true }) 83 | .registerNode('F', { parent: 'row-1', isFocusable: true }) 84 | .registerNode('row-2', { orientation: 'horizontal' }) 85 | .registerNode('G', { parent: 'row-2', isFocusable: true }) 86 | .registerNode('H', { parent: 'row-2', isFocusable: true }) 87 | .registerNode('I', { parent: 'row-2', isFocusable: true }) 88 | .registerNode('J', { parent: 'row-2', isFocusable: true }) 89 | .registerNode('K', { parent: 'row-2', isFocusable: true }) 90 | .registerNode('L', { parent: 'row-2', isFocusable: true }) 91 | .registerNode('row-3', { orientation: 'horizontal' }) 92 | .registerNode('Space', { parent: 'row-3', indexRange: [0, 2], isFocusable: true }) 93 | .registerNode('Delete', { parent: 'row-3', indexRange: [3, 5], isFocusable: true }) 94 | 95 | navigation.assignFocus('keyboard') 96 | expect(navigation.currentFocusNode.id).toEqual('A') 97 | 98 | navigation.handleKeyEvent({ direction: 'right' }) 99 | expect(navigation.currentFocusNode.id).toEqual('B') 100 | 101 | navigation.handleKeyEvent({ direction: 'down' }) 102 | expect(navigation.currentFocusNode.id).toEqual('H') 103 | 104 | navigation.handleKeyEvent({ direction: 'down' }) 105 | expect(navigation.currentFocusNode.id).toEqual('Space') 106 | 107 | navigation.handleKeyEvent({ direction: 'right' }) 108 | expect(navigation.currentFocusNode.id).toEqual('Delete') 109 | 110 | navigation.handleKeyEvent({ direction: 'up' }) 111 | expect(navigation.currentFocusNode.id).toEqual('J') 112 | 113 | navigation.handleKeyEvent({ direction: 'right' }) 114 | expect(navigation.currentFocusNode.id).toEqual('K') 115 | 116 | navigation.handleKeyEvent({ direction: 'down' }) 117 | expect(navigation.currentFocusNode.id).toEqual('Delete') 118 | 119 | // the active child of row-2 is `K`, AND `K` is inside the indexRange we're leaving 120 | // there we should end up back at `K` 121 | navigation.handleKeyEvent({ direction: 'up' }) 122 | expect(navigation.currentFocusNode.id).toEqual('K') 123 | 124 | navigation.handleKeyEvent({ direction: 'down' }) 125 | expect(navigation.currentFocusNode.id).toEqual('Delete') 126 | 127 | navigation.handleKeyEvent({ direction: 'left' }) 128 | expect(navigation.currentFocusNode.id).toEqual('Space') 129 | 130 | // the activeChild of row-2 is still `K`, and that index is outside of our indexRange we're 131 | // leaving - therefore, we go to the first value of the index range, which in this case 132 | // is `G` 133 | navigation.handleKeyEvent({ direction: 'up' }) 134 | expect(navigation.currentFocusNode.id).toEqual('G') 135 | 136 | navigation.handleKeyEvent({ direction: 'right' }) 137 | expect(navigation.currentFocusNode.id).toEqual('H') 138 | 139 | navigation.handleKeyEvent({ direction: 'right' }) 140 | expect(navigation.currentFocusNode.id).toEqual('I') 141 | 142 | navigation.handleKeyEvent({ direction: 'down' }) 143 | expect(navigation.currentFocusNode.id).toEqual('Space') 144 | 145 | navigation.handleKeyEvent({ direction: 'up' }) 146 | expect(navigation.currentFocusNode.id).toEqual('I') 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /src/unregister.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const { Lrud } = require('./index') 4 | 5 | describe('unregisterNode()', () => { 6 | test('unregistering a leaf should remove it', () => { 7 | const navigation = new Lrud() 8 | .registerNode('root', { orientation: 'vertical' }) 9 | .registerNode('NODE_A', { isFocusable: true }) 10 | .registerNode('NODE_B', { isFocusable: true }) 11 | 12 | expect(navigation.rootNode.children[0]).not.toBeUndefined() 13 | expect(navigation.rootNode.children[0].id).toEqual('NODE_A') 14 | expect(navigation.rootNode.children.length).toEqual(2) 15 | 16 | navigation.unregisterNode('NODE_A') 17 | 18 | expect(navigation.rootNode.children[0]).not.toBeUndefined() 19 | expect(navigation.rootNode.children[0].id).toEqual('NODE_B') 20 | expect(navigation.rootNode.children.length).toEqual(1) 21 | 22 | expect(navigation.getNode('NODE_A')).toBeUndefined() 23 | 24 | expect(Object.keys(navigation.nodes)).toEqual([ 25 | 'root', 26 | 'NODE_B' 27 | ]) 28 | }) 29 | 30 | test('unregister a whole branch', () => { 31 | const navigation = new Lrud() 32 | .registerNode('root', { selectAction: 1 }) 33 | .registerNode('BOX_A', { selectAction: 2 }) 34 | .registerNode('BOX_B', { selectAction: 3 }) 35 | .registerNode('NODE_1', { selectAction: 11, parent: 'BOX_A' }) 36 | .registerNode('NODE_2', { selectAction: 12, parent: 'BOX_A' }) 37 | .registerNode('NODE_3', { selectAction: 13, parent: 'BOX_A' }) 38 | .registerNode('NODE_4', { selectAction: 24, parent: 'BOX_B' }) 39 | .registerNode('NODE_5', { selectAction: 25, parent: 'BOX_B' }) 40 | .registerNode('NODE_6', { selectAction: 26, parent: 'BOX_B' }) 41 | 42 | expect(navigation.rootNode.children.length).toEqual(2) 43 | expect(navigation.rootNode.children[0].id).toEqual('BOX_A') 44 | expect(navigation.rootNode.children[0].children.length).toEqual(3) 45 | expect(navigation.rootNode.children[0].children).toEqual([ 46 | expect.objectContaining({ id: 'NODE_1' }), 47 | expect.objectContaining({ id: 'NODE_2' }), 48 | expect.objectContaining({ id: 'NODE_3' }) 49 | ]) 50 | expect(navigation.rootNode.children[1].id).toEqual('BOX_B') 51 | expect(navigation.rootNode.children[1].children.length).toEqual(3) 52 | expect(navigation.rootNode.children[1].children).toEqual([ 53 | expect.objectContaining({ id: 'NODE_4' }), 54 | expect.objectContaining({ id: 'NODE_5' }), 55 | expect.objectContaining({ id: 'NODE_6' }) 56 | ]) 57 | 58 | navigation.unregisterNode('BOX_B') 59 | 60 | expect(navigation.rootNode.children.length).toEqual(1) 61 | expect(Object.keys(navigation.nodes)).toEqual([ 62 | 'root', 63 | 'BOX_A', 64 | 'NODE_1', 65 | 'NODE_2', 66 | 'NODE_3' 67 | ]) 68 | }) 69 | 70 | test('if unregistering the focused node, recalculate focus', () => { 71 | const navigation = new Lrud() 72 | .registerNode('root', { orientation: 'horizontal' }) 73 | .registerNode('NODE_1', { parent: 'root', isFocusable: true }) 74 | .registerNode('NODE_2', { parent: 'root', isFocusable: true }) 75 | .registerNode('NODE_3', { parent: 'root', isFocusable: true }) 76 | 77 | navigation.assignFocus('NODE_3') 78 | 79 | navigation.unregisterNode('NODE_3') 80 | 81 | expect(navigation.currentFocusNode.id).toEqual('NODE_1') 82 | }) 83 | 84 | test('if unregistering a parent or parent branch of the focused node, recalculate focus', () => { 85 | const navigation = new Lrud() 86 | .registerNode('root', { orientation: 'vertical' }) 87 | .registerNode('BOX_A', { parent: 'root', orientation: 'vertical' }) 88 | .registerNode('BOX_B', { parent: 'root', orientation: 'vertical' }) 89 | .registerNode('NODE_1', { parent: 'BOX_A', isFocusable: true }) 90 | .registerNode('NODE_2', { parent: 'BOX_A', isFocusable: true }) 91 | .registerNode('NODE_3', { parent: 'BOX_B', isFocusable: true }) 92 | .registerNode('NODE_4', { parent: 'BOX_B', isFocusable: true }) 93 | 94 | // so we're focused on the first element of the left pane 95 | // and we unregister the entire left pane 96 | // so focus should go to the first element of the right pane 97 | navigation.assignFocus('NODE_1') 98 | navigation.unregisterNode('BOX_A') 99 | 100 | expect(navigation.currentFocusNode.id).toEqual('NODE_3') 101 | }) 102 | 103 | test('unregistering a node should trigger a `blur` event with that node', () => { 104 | const navigation = new Lrud() 105 | .registerNode('root') 106 | .registerNode('BOX_A', { parent: 'root' }) 107 | .registerNode('BOX_B', { parent: 'root' }) 108 | .registerNode('NODE_1', { parent: 'BOX_A' }) 109 | .registerNode('NODE_2', { parent: 'BOX_A' }) 110 | .registerNode('NODE_3', { parent: 'BOX_A' }) 111 | .registerNode('NODE_4', { parent: 'BOX_B' }) 112 | .registerNode('NODE_5', { parent: 'BOX_B' }) 113 | .registerNode('NODE_6', { parent: 'BOX_B' }) 114 | 115 | const spy = jest.fn() 116 | navigation.on('blur', spy) 117 | 118 | navigation.unregisterNode('BOX_B') 119 | 120 | // should trigger with the details of BOX_B 121 | expect(spy).toHaveBeenCalledWith(expect.objectContaining({ id: 'BOX_B' })) 122 | }) 123 | 124 | test('unregistering a branch with only 1 leaf should reset focus properly one level up', () => { 125 | const navigation = new Lrud() 126 | .registerNode('root', { orientation: 'vertical' }) 127 | .registerNode('a', { parent: 'root', orientation: 'vertical' }) 128 | .registerNode('b', { parent: 'root', orientation: 'vertical' }) 129 | .registerNode('a-1', { parent: 'a', isFocusable: true }) 130 | .registerNode('a-2', { parent: 'a', isFocusable: true }) 131 | .registerNode('a-3', { parent: 'a', isFocusable: true }) 132 | .registerNode('b-1', { parent: 'b', isFocusable: true }) 133 | 134 | navigation.assignFocus('b-1') 135 | 136 | navigation.unregisterNode('b') 137 | 138 | // so now we should be focused on `a-1`, as its the first relevant thing to be focused on 139 | expect(navigation.currentFocusNode.id).toEqual('a-1') 140 | }) 141 | 142 | test('unregistering the only leaf of a long line of single branches should reset focus properly [fig-4]', () => { 143 | const navigation = new Lrud() 144 | .registerNode('root', { orientation: 'vertical' }) 145 | .registerNode('a', { parent: 'root', orientation: 'vertical' }) 146 | .registerNode('a-1', { parent: 'a', isFocusable: true }) 147 | .registerNode('b', { parent: 'root', orientation: 'vertical' }) 148 | .registerNode('c', { parent: 'b', orientation: 'vertical' }) 149 | .registerNode('d', { parent: 'c', orientation: 'vertical' }) 150 | .registerNode('e', { parent: 'd', orientation: 'vertical' }) 151 | .registerNode('e-1', { parent: 'e', isFocusable: true }) 152 | 153 | navigation.assignFocus('e-1') 154 | 155 | navigation.unregisterNode('e-1') 156 | 157 | // we have to dig up to the first thing that has children, and then dig down for the next child 158 | // so basically our focus should now be on `a-1` 159 | expect(navigation.currentFocusNode.id).toEqual('a-1') 160 | }) 161 | 162 | test('unregistering a node that is the target of an override should unregister the override', () => { 163 | const navigation = new Lrud() 164 | .registerNode('root', { orientation: 'horizontal' }) 165 | .registerNode('NODE_A', { isFocusable: true }) 166 | .registerNode('NODE_B', { isFocusable: true }) 167 | .registerNode('NODE_C', { isFocusable: true }) 168 | .registerNode('NODE_D', { isFocusable: true }) 169 | .registerOverride('NODE_A', 'NODE_B', 'up') 170 | .registerOverride('NODE_A', 'NODE_D', 'down') 171 | .registerOverride('NODE_C', 'NODE_D', 'up') 172 | 173 | navigation.assignFocus('NODE_A') 174 | 175 | expect(navigation.nodes.NODE_A.overrides.up).toMatchObject({ id: 'NODE_B' }) 176 | expect(navigation.nodes.NODE_A.overrides.down).toMatchObject({ id: 'NODE_D' }) 177 | expect(navigation.nodes.NODE_C.overrides.up).toMatchObject({ id: 'NODE_D' }) 178 | 179 | navigation.unregisterNode('NODE_B') 180 | 181 | expect(navigation.nodes.NODE_A.overrides.up).toBeUndefined() 182 | expect(navigation.nodes.NODE_A.overrides.down).toMatchObject({ id: 'NODE_D' }) 183 | expect(navigation.nodes.NODE_C.overrides.up).toMatchObject({ id: 'NODE_D' }) 184 | }) 185 | 186 | /** 187 | * @see https://github.com/bbc/lrud/issues/86 188 | */ 189 | test('unregistering a node should unregister the overrides of its children', () => { 190 | const navigation = new Lrud() 191 | .registerNode('root') 192 | .registerNode('a', { parent: 'root' }) 193 | .registerNode('ab', { parent: 'a' }) 194 | .registerNode('b', { parent: 'root' }) 195 | .registerOverride('ab', 'b', 'right') 196 | 197 | expect(navigation.nodes.b.overrideSources) 198 | .toEqual([{ direction: 'right', node: expect.objectContaining({ id: 'ab' }) }]) 199 | 200 | navigation.unregister('a') 201 | 202 | expect(navigation.nodes.b.overrideSources).toBeUndefined() 203 | }) 204 | 205 | test('unregistering a node that is the id of an override should unregister the override', () => { 206 | const navigation = new Lrud() 207 | .registerNode('root', { orientation: 'horizontal' }) 208 | .registerNode('NODE_A', { isFocusable: true }) 209 | .registerNode('NODE_B', { isFocusable: true }) 210 | .registerNode('NODE_C', { isFocusable: true }) 211 | .registerNode('NODE_D', { isFocusable: true }) 212 | .registerOverride('NODE_A', 'NODE_B', 'up') 213 | .registerOverride('NODE_C', 'NODE_D', 'up') 214 | 215 | navigation.assignFocus('NODE_A') 216 | 217 | expect(navigation.nodes.NODE_A.overrides.up).toMatchObject({ id: 'NODE_B' }) 218 | expect(navigation.nodes.NODE_B.overrideSources).toEqual([{ direction: 'up', node: expect.objectContaining({ id: 'NODE_A' }) }]) 219 | expect(navigation.nodes.NODE_C.overrides.up).toMatchObject({ id: 'NODE_D' }) 220 | expect(navigation.nodes.NODE_D.overrideSources).toEqual([{ direction: 'up', node: expect.objectContaining({ id: 'NODE_C' }) }]) 221 | 222 | navigation.unregisterNode('NODE_C') 223 | 224 | expect(navigation.nodes.NODE_A.overrides.up).toMatchObject({ id: 'NODE_B' }) 225 | expect(navigation.nodes.NODE_B.overrideSources).toEqual([{ direction: 'up', node: expect.objectContaining({ id: 'NODE_A' }) }]) 226 | expect(navigation.nodes.NODE_D.overrideSources).toBeUndefined() 227 | }) 228 | 229 | test('unregistering the root node should leave an empty tree', () => { 230 | const navigation = new Lrud() 231 | .registerNode('root', { orientation: 'vertical' }) 232 | .registerNode('left', { orientation: 'vertical' }) 233 | .registerNode('right', { orientation: 'vertical' }) 234 | 235 | navigation.unregisterNode('root') 236 | 237 | expect(navigation.nodes).toMatchObject({}) 238 | }) 239 | 240 | test('unregistering the focused node when there is nothing else that can be focused on', () => { 241 | const navigation = new Lrud() 242 | .registerNode('root', { orientation: 'vertical' }) 243 | .registerNode('row1', { orientation: 'horizontal', parent: 'root' }) 244 | .registerNode('item1', { isFocusable: true, parent: 'row1' }) 245 | 246 | navigation.assignFocus('item1') 247 | 248 | // nothing else to focus on, but we shouldn't throw an exception 249 | expect(() => { 250 | navigation.unregisterNode('item1') 251 | }).not.toThrow() 252 | 253 | // activeChild should be cleaned along whole path 254 | expect(navigation.getNode('root').activeChild).toBeUndefined() 255 | expect(navigation.getNode('row1').activeChild).toBeUndefined() 256 | }) 257 | 258 | test('unregistering the focused node when there is nothing else that can be focused on - more nesting', () => { 259 | const navigation = new Lrud() 260 | .registerNode('root', { orientation: 'vertical' }) 261 | .registerNode('boxa', { orientation: 'horizontal', parent: 'root' }) 262 | .registerNode('boxb', { orientation: 'horizontal', parent: 'boxa' }) 263 | .registerNode('boxc', { orientation: 'horizontal', parent: 'boxb' }) 264 | .registerNode('item1', { isFocusable: true, parent: 'boxc' }) 265 | 266 | navigation.assignFocus('item1') 267 | 268 | // nothing else to focus on, but we shouldn't throw an exception 269 | expect(() => { 270 | navigation.unregisterNode('item1') 271 | }).not.toThrow() 272 | 273 | // activeChild should be cleaned along whole path 274 | expect(navigation.getNode('root').activeChild).toBeUndefined() 275 | expect(navigation.getNode('boxa').activeChild).toBeUndefined() 276 | expect(navigation.getNode('boxb').activeChild).toBeUndefined() 277 | expect(navigation.getNode('boxc').activeChild).toBeUndefined() 278 | }) 279 | 280 | test('unregistering the focused node when there is other node to focus', () => { 281 | const navigation = new Lrud() 282 | .registerNode('root', { orientation: 'vertical' }) 283 | .registerNode('a', { parent: 'root', orientation: 'horizontal' }) 284 | .registerNode('aa', { parent: 'a', orientation: 'horizontal' }) 285 | .registerNode('aaa', { parent: 'aa' }) 286 | .registerNode('aab', { parent: 'aa', isFocusable: true }) 287 | .registerNode('b', { parent: 'root' }) 288 | .registerNode('c', { parent: 'root', isFocusable: true }) 289 | 290 | navigation.assignFocus('aab') 291 | 292 | // there's `c` that might be focused 293 | expect(() => { 294 | navigation.unregisterNode('aab') 295 | }).not.toThrow() 296 | 297 | // expect `c` to be focused 298 | expect(navigation.currentFocusNode.id).toEqual('c') 299 | 300 | // invalid activeChild on the branch pointing to the unregistered node should be cleared, 301 | // where invalid activeChild is a node that doesn't lay on currentFocusNode path 302 | expect(navigation.getNode('root').activeChild).toEqual(expect.objectContaining({ id: 'c' })) 303 | expect(navigation.getNode('a').activeChild).toBeUndefined() 304 | expect(navigation.getNode('aa').activeChild).toBeUndefined() 305 | }) 306 | 307 | test('unregistering the root node and re-registering should give a clean tree and internal state', () => { 308 | const navigation = new Lrud() 309 | .registerNode('root', { orientation: 'horizontal' }) 310 | .registerNode('node1', { orientation: 'vertical', parent: 'root' }) 311 | .registerNode('container', { orientation: 'vertical', parent: 'node1' }) 312 | .registerNode('item', { selectAction: {}, parent: 'container' }) 313 | 314 | navigation.unregisterNode('root') 315 | 316 | navigation 317 | .registerNode('root', { orientation: 'horizontal' }) 318 | .registerNode('node2', { orientation: 'vertical', parent: 'root' }) 319 | .registerNode('container', { orientation: 'vertical', parent: 'node2' }) 320 | .registerNode('item', { selectAction: {}, parent: 'container' }) 321 | 322 | expect(navigation.rootNode).toBeTruthy() 323 | expect(navigation.rootNode.children.length).toEqual(1) 324 | expect(navigation.rootNode.children[0].id).toEqual('node2') 325 | expect(navigation.rootNode.children[0].children[0].id).toEqual('container') 326 | expect(navigation.rootNode.children[0].children[0].children[0].id).toEqual('item') 327 | }) 328 | 329 | test('unregistering nodes that start with the same string', () => { 330 | const navigation = new Lrud() 331 | .registerNode('root') 332 | .registerNode('brand') 333 | .registerNode('brand-content') 334 | 335 | navigation.unregisterNode('brand') 336 | 337 | expect(Object.keys(navigation.nodes)).toEqual([ 338 | 'root', 339 | 'brand-content' 340 | ]) 341 | 342 | expect(navigation.getNode('brand-content')).toBeTruthy() 343 | }) 344 | 345 | test('unregistering a node should remove it and all its children from the tree and internal state', () => { 346 | const navigation = new Lrud() 347 | .registerNode('root') 348 | .registerNode('x') 349 | .registerNode('x-1', { parent: 'x', isFocusable: true }) 350 | .registerNode('x-2', { parent: 'x', isFocusable: true }) 351 | .registerNode('xx') 352 | .registerNode('xx-1', { parent: 'xx', isFocusable: true }) 353 | .registerNode('xx-2', { parent: 'xx', isFocusable: true }) 354 | 355 | navigation.unregisterNode('x') 356 | 357 | expect(Object.keys(navigation.nodes)).toEqual([ 358 | 'root', 359 | 'xx', 360 | 'xx-1', 361 | 'xx-2' 362 | ]) 363 | expect(navigation.getNode('xx-2')).toBeTruthy() 364 | }) 365 | 366 | test('unregistering a focusable node should remove it from the nodes list', () => { 367 | const navigation = new Lrud() 368 | .registerNode('root') 369 | .registerNode('x') 370 | .registerNode('x-1', { parent: 'x', isFocusable: true }) 371 | .registerNode('x-2', { parent: 'x', isFocusable: true }) 372 | .registerNode('xx') 373 | .registerNode('xx-1', { parent: 'xx', isFocusable: true }) 374 | .registerNode('xx-2', { parent: 'xx', isFocusable: true }) 375 | 376 | navigation.unregisterNode('x-1') 377 | 378 | expect(Object.keys(navigation.nodes)).toEqual([ 379 | 'root', 380 | 'x', 381 | 'x-2', 382 | 'xx', 383 | 'xx-1', 384 | 'xx-2' 385 | ]) 386 | }) 387 | 388 | test('unregistering a node that has children that are focusable should remove and its children from all relevant internal state', () => { 389 | // the node from the nodePathList, and the children from the nodePathList & focusableNodePathList 390 | const navigation = new Lrud() 391 | .registerNode('root') 392 | .registerNode('x') 393 | .registerNode('x-1', { parent: 'x', isFocusable: true }) 394 | .registerNode('x-2', { parent: 'x', isFocusable: true }) 395 | .registerNode('xx') 396 | .registerNode('xx-1', { parent: 'xx', isFocusable: true }) 397 | .registerNode('xx-2', { parent: 'xx', isFocusable: true }) 398 | 399 | // ensure state is correct after registration (for sanity sake...) 400 | expect(Object.keys(navigation.nodes)).toEqual([ 401 | 'root', 402 | 'x', 403 | 'x-1', 404 | 'x-2', 405 | 'xx', 406 | 'xx-1', 407 | 'xx-2' 408 | ]) 409 | 410 | // now we unregister the parent node, and ensure its children and it are gone from relevant paths 411 | navigation.unregisterNode('xx') 412 | 413 | expect(Object.keys(navigation.nodes)).toEqual([ 414 | 'root', 415 | 'x', 416 | 'x-1', 417 | 'x-2' 418 | ]) 419 | }) 420 | 421 | test('unregistering a pibling of the focused node should not recalculate focus', () => { 422 | const navigation = new Lrud() 423 | .register('root', { orientation: 'horizontal' }) 424 | .register('node1', { orientation: 'vertical', parent: 'root' }) 425 | .register('item1', { parent: 'node1', selectAction: {} }) 426 | .register('node2', { orientation: 'vertical', parent: 'root' }) 427 | 428 | const onBlur = jest.fn() 429 | navigation.register('item2', { 430 | parent: 'node2', 431 | selectAction: {}, 432 | onBlur 433 | }) 434 | 435 | navigation.assignFocus('node2') 436 | 437 | expect(navigation.currentFocusNode.id).toEqual('item2') 438 | 439 | navigation.unregisterNode('item1') 440 | 441 | expect(navigation.currentFocusNode.id).toEqual('item2') 442 | expect(onBlur).not.toBeCalled() 443 | }) 444 | 445 | test('unregistering a pibling of the focused node should not remove focus - forceRefocus false', () => { 446 | const navigation = new Lrud() 447 | .register('root', { orientation: 'horizontal' }) 448 | .register('node1', { orientation: 'vertical', parent: 'root' }) 449 | .register('item1', { parent: 'node1', selectAction: {} }) 450 | .register('node2', { orientation: 'vertical', parent: 'root' }) 451 | .register('item2', { parent: 'node2', selectAction: {} }) 452 | 453 | navigation.assignFocus('node2') 454 | 455 | expect(navigation.currentFocusNode.id).toEqual('item2') 456 | 457 | navigation.unregisterNode('item1', { forceRefocus: false }) 458 | 459 | expect(navigation.currentFocusNode.id).toEqual('item2') 460 | }) 461 | 462 | test('unregistering node that was focused but now is not should clean parent\'s activeChild', () => { 463 | const navigation = new Lrud() 464 | .registerNode('root') 465 | .registerNode('a', { parent: 'root' }) 466 | .registerNode('aa', { parent: 'a', isFocusable: true }) 467 | .registerNode('b', { parent: 'root' }) 468 | .registerNode('ba', { parent: 'b', isFocusable: true }) 469 | 470 | navigation.assignFocus('aa') 471 | navigation.assignFocus('ba') 472 | 473 | navigation.unregisterNode('aa') 474 | 475 | expect(navigation.getNode('a').activeChild).toBeUndefined() 476 | }) 477 | 478 | test('unregistering with forceRefocus false should not do a refocus - removing focused node', () => { 479 | const navigation = new Lrud() 480 | .register('root') 481 | .register('node1', { isFocusable: true }) 482 | .register('node2', { isFocusable: true }) 483 | .register('node3', { isFocusable: true }) 484 | 485 | navigation.assignFocus('node2') 486 | 487 | navigation.unregisterNode('node2', { forceRefocus: false }) 488 | 489 | expect(navigation.currentFocusNode).toBeUndefined() 490 | expect(navigation.rootNode.activeChild).toBeUndefined() 491 | }) 492 | 493 | test('unregistering with forceRefocus false should not do a refocus - removing parent of focused node', () => { 494 | const navigation = new Lrud() 495 | .register('root') 496 | .register('box1') 497 | .register('node1', { isFocusable: true, parent: 'box1' }) 498 | .register('node2', { isFocusable: true, parent: 'box1' }) 499 | .register('box2') 500 | .register('node4', { isFocusable: true, parent: 'box2' }) 501 | .register('node5', { isFocusable: true, parent: 'box2' }) 502 | 503 | navigation.assignFocus('node2') 504 | 505 | navigation.unregisterNode('box1', { forceRefocus: false }) 506 | 507 | expect(navigation.currentFocusNode).toBeUndefined() 508 | expect(navigation.rootNode.activeChild).toBeUndefined() 509 | }) 510 | 511 | test('should do nothing when unregistering not existing node', () => { 512 | const navigation = new Lrud() 513 | .register('root') 514 | .register('a') 515 | 516 | let result 517 | expect(() => { 518 | result = navigation.unregisterNode('b') 519 | }).not.toThrow() 520 | 521 | expect(result).toEqual(navigation) 522 | }) 523 | }) 524 | -------------------------------------------------------------------------------- /src/utils.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const { 4 | closestIndex, 5 | insertChildNode, 6 | isNodeFocusable, 7 | isDirectionAndOrientationMatching, 8 | getDirectionForKeyCode, 9 | findChildWithMatchingIndexRange, 10 | findChildWithClosestIndex, 11 | prepareNode, 12 | removeChildNode, 13 | toValidDirection, 14 | toValidOrientation, 15 | traverseNodeSubtree 16 | } = require('./utils') 17 | const { Lrud } = require('./index') 18 | 19 | describe('closestIndex()', () => { 20 | it('find the closest when number exists in array as first value', () => { 21 | const values = [1, 3, 5, 7, 9] 22 | const match = closestIndex(values, 1) 23 | 24 | expect(match).toEqual(1) 25 | }) 26 | 27 | it('find the closest when number exists in array as last value', () => { 28 | const values = [1, 3, 5, 7, 9] 29 | const match = closestIndex(values, 9) 30 | 31 | expect(match).toEqual(9) 32 | }) 33 | 34 | it('find the closest when number exists in array as middle value', () => { 35 | const values = [1, 3, 5, 7, 9] 36 | const match = closestIndex(values, 5) 37 | 38 | expect(match).toEqual(5) 39 | }) 40 | 41 | it('find the closest, number not in array, obviously above a value', () => { 42 | const values = [1, 10, 20] 43 | const match = closestIndex(values, 11) 44 | 45 | expect(match).toEqual(10) 46 | }) 47 | 48 | it('find the closest, number not in array, obviously below a value', () => { 49 | const values = [1, 10, 20] 50 | const match = closestIndex(values, 9) 51 | 52 | expect(match).toEqual(10) 53 | }) 54 | 55 | it('find the closest, number is between 2 values in array - round down', () => { 56 | const values = [1, 3, 5] 57 | const match = closestIndex(values, 2) 58 | 59 | expect(match).toEqual(1) 60 | }) 61 | }) 62 | 63 | describe('isNodeFocusable()', () => { 64 | it('node should be focusable, it has a selectAction', () => { 65 | const node = { 66 | id: 'node', selectAction: true 67 | } 68 | 69 | expect(isNodeFocusable(node)).toEqual(true) 70 | }) 71 | 72 | it('node should be focusable, it has isFocusable true', () => { 73 | const node = { 74 | id: 'node', isFocusable: true 75 | } 76 | 77 | expect(isNodeFocusable(node)).toEqual(true) 78 | }) 79 | 80 | it('node should not be focusable, it has isFocusable but its false', () => { 81 | const node = { 82 | id: 'node', isFocusable: false 83 | } 84 | 85 | expect(isNodeFocusable(node)).toEqual(false) 86 | }) 87 | 88 | it('node should not be focusable, it has isFocusable undefined', () => { 89 | const node = { 90 | id: 'node', isFocusable: undefined 91 | } 92 | 93 | expect(isNodeFocusable(node)).toEqual(false) 94 | }) 95 | 96 | it('node should not be focusable, it has isFocusable null', () => { 97 | const node = { 98 | id: 'node', isFocusable: null 99 | } 100 | 101 | expect(isNodeFocusable(node)).toEqual(false) 102 | }) 103 | 104 | it('node should not be focusable, it has neither a selectAction nor isFocusable', () => { 105 | const node = { 106 | id: 'node' 107 | } 108 | 109 | expect(isNodeFocusable(node)).toEqual(false) 110 | }) 111 | 112 | it('node should not be focusable, it has isFocusable false, and a selectAction', () => { 113 | const node = { 114 | id: 'node', isFocusable: false, selectAction: true 115 | } 116 | 117 | expect(isNodeFocusable(node)).toEqual(false) 118 | }) 119 | 120 | test('should not fail when node does not exists', () => { 121 | const notExistingNode = undefined 122 | expect(() => isNodeFocusable(notExistingNode)).not.toThrow() 123 | expect(isNodeFocusable(notExistingNode)).toEqual(false) 124 | }) 125 | }) 126 | 127 | describe('isDirectionAndOrientationMatching()', () => { 128 | test('vertical and up is true', () => { 129 | expect(isDirectionAndOrientationMatching('vertical', 'up')).toEqual(true) 130 | }) 131 | test('vertical and down is true', () => { 132 | expect(isDirectionAndOrientationMatching('vertical', 'down')).toEqual(true) 133 | }) 134 | test('vertical and * is true', () => { 135 | expect(isDirectionAndOrientationMatching('vertical', '*')).toEqual(true) 136 | }) 137 | test('horizontal and left is true', () => { 138 | expect(isDirectionAndOrientationMatching('horizontal', 'left')).toEqual(true) 139 | }) 140 | test('horizontal and right is true', () => { 141 | expect(isDirectionAndOrientationMatching('horizontal', 'right')).toEqual(true) 142 | }) 143 | test('horizontal and * is true', () => { 144 | expect(isDirectionAndOrientationMatching('horizontal', '*')).toEqual(true) 145 | }) 146 | test('vertical and left is false', () => { 147 | expect(isDirectionAndOrientationMatching('vertical', 'left')).toEqual(false) 148 | }) 149 | test('vertical and right is false', () => { 150 | expect(isDirectionAndOrientationMatching('vertical', 'right')).toEqual(false) 151 | }) 152 | test('horizontal and up is false', () => { 153 | expect(isDirectionAndOrientationMatching('horizontal', 'up')).toEqual(false) 154 | }) 155 | test('horizontal and down is false', () => { 156 | expect(isDirectionAndOrientationMatching('horizontal', 'down')).toEqual(false) 157 | }) 158 | test('undefined orientation and any valid direction is false', () => { 159 | expect(isDirectionAndOrientationMatching(undefined, '*')).toEqual(false) 160 | }) 161 | test('any valid orientation and undefined direction is false', () => { 162 | expect(isDirectionAndOrientationMatching('horizontal', undefined)).toEqual(false) 163 | }) 164 | }) 165 | 166 | describe('findChildWithMatchingIndexRange()', () => { 167 | it('has a child with an index range that encompasses the index', () => { 168 | const node = { 169 | id: 'a', 170 | children: [ 171 | { id: 'x', indexRange: [0, 1] }, 172 | { id: 'y', indexRange: [2, 3] } 173 | ] 174 | } 175 | 176 | const found = findChildWithMatchingIndexRange(node, 2) 177 | 178 | expect(found.id).toEqual('y') 179 | }) 180 | 181 | it('has a child with an index range that encompasses the index, first child', () => { 182 | const node = { 183 | id: 'a', 184 | children: [ 185 | { id: 'x', indexRange: [0, 1] }, 186 | { id: 'y', indexRange: [2, 3] } 187 | ] 188 | } 189 | 190 | const found = findChildWithMatchingIndexRange(node, 0) 191 | 192 | expect(found.id).toEqual('x') 193 | }) 194 | 195 | it('does not have a child with an index range that encompasses the index', () => { 196 | const node = { 197 | id: 'a', 198 | children: [ 199 | { id: 'x', indexRange: [0, 1] }, 200 | { id: 'y', indexRange: [2, 3] } 201 | ] 202 | } 203 | 204 | const found = findChildWithMatchingIndexRange(node, 6) 205 | 206 | expect(found).toBeUndefined() 207 | }) 208 | 209 | it('does not have a child', () => { 210 | const node = { 211 | id: 'a' 212 | } 213 | 214 | const found = findChildWithMatchingIndexRange(node, 6) 215 | 216 | expect(found).toBeUndefined() 217 | }) 218 | 219 | test('should not fail when node does not exists', () => { 220 | const notExistingNode = undefined 221 | expect(() => findChildWithMatchingIndexRange(notExistingNode, 0)).not.toThrow() 222 | }) 223 | }) 224 | 225 | describe('findChildWithClosestIndex()', () => { 226 | it('find the child with the exact index', () => { 227 | const node = { 228 | id: 'root', 229 | children: [ 230 | { id: 'a', index: 0, isFocusable: true }, 231 | { id: 'b', index: 1, isFocusable: true }, 232 | { id: 'c', index: 2, isFocusable: true } 233 | ] 234 | } 235 | 236 | const found = findChildWithClosestIndex(node, 1) 237 | 238 | expect(found.id).toEqual('b') 239 | }) 240 | 241 | it('find the child with closest index, lower', () => { 242 | const node = { 243 | id: 'root', 244 | children: [ 245 | { id: 'a', index: 0, isFocusable: true }, 246 | { id: 'b', index: 1, isFocusable: true }, 247 | { id: 'c', index: 2, isFocusable: true } 248 | ] 249 | } 250 | 251 | const found = findChildWithClosestIndex(node, 5) 252 | 253 | expect(found.id).toEqual('c') 254 | }) 255 | 256 | it('find the child with closest index, higher', () => { 257 | const node = { 258 | id: 'root', 259 | children: [ 260 | { id: 'a', index: 0, isFocusable: false }, 261 | { id: 'b', index: 1, isFocusable: true }, 262 | { id: 'c', index: 2, isFocusable: true } 263 | ] 264 | } 265 | 266 | const found = findChildWithClosestIndex(node, -1) 267 | 268 | expect(found.id).toEqual('b') 269 | }) 270 | 271 | it('find the child via an indexRange, so return the active child (when its focusable)', () => { 272 | const node = { 273 | id: 'root', 274 | children: [ 275 | { id: 'a', isFocusable: true, index: 0 }, 276 | { id: 'b', isFocusable: true, index: 1 }, 277 | { id: 'c', isFocusable: true, index: 2 } 278 | ] 279 | } 280 | node.activeChild = node.children[1] 281 | 282 | const found = findChildWithClosestIndex(node, 0, [1, 2]) 283 | 284 | expect(found.id).toEqual('b') 285 | }) 286 | 287 | it('find the child via an indexRange, but the active ISNT in the index range, so do it by the passed index', () => { 288 | const node = { 289 | id: 'root', 290 | children: [ 291 | { id: 'a', index: 0, isFocusable: true }, 292 | { id: 'b', index: 1, isFocusable: true }, 293 | { id: 'c', index: 2, isFocusable: true }, 294 | { id: 'd', index: 5, isFocusable: true } 295 | ] 296 | } 297 | node.activeChild = node.children[0] 298 | 299 | const found = findChildWithClosestIndex(node, 5, [1, 2]) 300 | 301 | expect(found.id).toEqual('d') 302 | }) 303 | 304 | it('does not have any children', () => { 305 | const node = { 306 | id: 'root' 307 | } 308 | 309 | const found = findChildWithClosestIndex(node, 1) 310 | 311 | expect(found).toBeUndefined() 312 | }) 313 | 314 | it('should return null if no focusable child', () => { 315 | const node = { 316 | id: 'root', 317 | isFocusable: true, 318 | children: [ 319 | { id: 'a', index: 0 }, 320 | { id: 'b', index: 1 }, 321 | { id: 'c', index: 2 }, 322 | { id: 'd', index: 3 } 323 | ] 324 | } 325 | node.activeChild = node.children[0] 326 | 327 | const found = findChildWithClosestIndex(node, 1) 328 | 329 | expect(found).toBeUndefined() 330 | }) 331 | 332 | it('should return closest focusable child', () => { 333 | const node = { 334 | id: 'root', 335 | isFocusable: true, 336 | children: [ 337 | { id: 'a', index: 0 }, 338 | { id: 'b', index: 1 }, 339 | { id: 'c', index: 2 }, 340 | { id: 'd', index: 3, isFocusable: true } 341 | ] 342 | } 343 | node.activeChild = node.children[0] 344 | 345 | const found = findChildWithClosestIndex(node, 1) 346 | 347 | expect(found).toEqual(node.children[3]) 348 | }) 349 | 350 | test('should not fail when node does not exists', () => { 351 | const notExistingNode = undefined 352 | expect(() => findChildWithClosestIndex(notExistingNode, 0)).not.toThrow() 353 | }) 354 | }) 355 | 356 | describe('getDirectionForKeyCode()', () => { 357 | it('get direction for known keycode', () => { 358 | const direction = getDirectionForKeyCode(4) 359 | expect(direction).toEqual('left') 360 | }) 361 | 362 | it('get direction for unknown keycode', () => { 363 | const direction = getDirectionForKeyCode(999999999) 364 | expect(direction).toBeUndefined() 365 | }) 366 | 367 | test('should not fail when keycode is not defined', () => { 368 | let direction = '*' 369 | 370 | expect(() => { 371 | direction = getDirectionForKeyCode(undefined) 372 | }).not.toThrow() 373 | 374 | expect(direction).toBeUndefined() 375 | }) 376 | }) 377 | 378 | describe('insertChildNode()', () => { 379 | test('should not fail when one of the arguments is not defined', () => { 380 | expect(() => { 381 | insertChildNode(undefined, { id: 'node' }) 382 | }).not.toThrow() 383 | 384 | expect(() => { 385 | insertChildNode({ id: 'node' }, undefined) 386 | }).not.toThrow() 387 | }) 388 | 389 | test('should append child if index is not defined', () => { 390 | const parent = { 391 | id: 'parent', 392 | children: [ 393 | { id: 'child_0', index: 0 }, 394 | { id: 'child_1', index: 1 } 395 | ] 396 | } 397 | 398 | const child = { 399 | id: 'new_child' 400 | } 401 | 402 | insertChildNode(parent, child) 403 | 404 | expect(child.parent.id).toEqual('parent') 405 | expect(parent.children).toEqual([ 406 | expect.objectContaining({ id: 'child_0', index: 0 }), 407 | expect.objectContaining({ id: 'child_1', index: 1 }), 408 | expect.objectContaining({ id: 'new_child', index: 2 }) 409 | ]) 410 | }) 411 | 412 | test('should append child if index is greater than parent\'s children length', () => { 413 | const parent = { 414 | id: 'parent', 415 | children: [ 416 | { id: 'child_0', index: 0 }, 417 | { id: 'child_1', index: 1 } 418 | ] 419 | } 420 | 421 | const child = { 422 | id: 'new_child', 423 | index: 3 424 | } 425 | 426 | insertChildNode(parent, child) 427 | 428 | expect(child.parent.id).toEqual('parent') 429 | expect(parent.children).toEqual([ 430 | expect.objectContaining({ id: 'child_0', index: 0 }), 431 | expect.objectContaining({ id: 'child_1', index: 1 }), 432 | expect.objectContaining({ id: 'new_child', index: 2 }) 433 | ]) 434 | }) 435 | 436 | test('should shift nodes indices by one', () => { 437 | const parent = { 438 | id: 'parent', 439 | children: [ 440 | { id: 'child_0', index: 0 }, 441 | { id: 'child_1', index: 1 } 442 | ] 443 | } 444 | 445 | const child = { 446 | id: 'new_child', 447 | index: 1 448 | } 449 | 450 | insertChildNode(parent, child) 451 | 452 | expect(child.parent.id).toEqual('parent') 453 | expect(parent.children).toEqual([ 454 | expect.objectContaining({ id: 'child_0', index: 0 }), 455 | expect.objectContaining({ id: 'new_child', index: 1 }), 456 | expect.objectContaining({ id: 'child_1', index: 2 }) 457 | ]) 458 | }) 459 | 460 | test('should insert node at first position', () => { 461 | const parent = { 462 | id: 'parent', 463 | children: [ 464 | { id: 'child_0', index: 0 }, 465 | { id: 'child_1', index: 1 } 466 | ] 467 | } 468 | 469 | const child = { 470 | id: 'new_child', 471 | index: 0 472 | } 473 | 474 | insertChildNode(parent, child) 475 | 476 | expect(child.parent.id).toEqual('parent') 477 | expect(parent.children).toEqual([ 478 | expect.objectContaining({ id: 'new_child', index: 0 }), 479 | expect.objectContaining({ id: 'child_0', index: 1 }), 480 | expect.objectContaining({ id: 'child_1', index: 2 }) 481 | ]) 482 | }) 483 | }) 484 | 485 | describe('removeChildNode()', () => { 486 | test('should not fail when one of the arguments is not defined', () => { 487 | expect(() => { 488 | removeChildNode(undefined, { id: 'root' }) 489 | }).not.toThrow() 490 | 491 | expect(() => { 492 | removeChildNode({ id: 'root' }, undefined) 493 | }).not.toThrow() 494 | }) 495 | 496 | test('should not fail and do nothing when parent has no children', () => { 497 | const parent = { 498 | id: 'parent' 499 | } 500 | 501 | expect(() => { 502 | removeChildNode(parent, { id: 'some_child' }) 503 | }).not.toThrow() 504 | }) 505 | 506 | test('should not fail and do nothing when parent doesn\'t contain removed child', () => { 507 | const parent = { 508 | id: 'parent', 509 | children: [ 510 | { id: 'child', index: 0 } 511 | ] 512 | } 513 | 514 | expect(() => { 515 | removeChildNode(parent, { id: 'some_child' }) 516 | }).not.toThrow() 517 | 518 | expect(parent.children).toBeDefined() 519 | expect(parent.children[0].id).toEqual('child') 520 | }) 521 | 522 | test('should remove child and reindex left children', () => { 523 | const parent = { 524 | id: 'parent', 525 | children: [ 526 | { id: 'child_0', index: 0 }, 527 | { id: 'child_1', index: 1 }, 528 | { id: 'child_2', index: 2 } 529 | ] 530 | } 531 | 532 | expect(() => { 533 | removeChildNode(parent, parent.children[1]) 534 | }).not.toThrow() 535 | 536 | expect(parent.children).toEqual([ 537 | expect.objectContaining({ id: 'child_0', index: 0 }), 538 | expect.objectContaining({ id: 'child_2', index: 1 }) 539 | ]) 540 | }) 541 | }) 542 | 543 | describe('toValidDirection()', () => { 544 | test('should correctly convert to valid direction', () => { 545 | expect(toValidDirection(undefined)).toBeUndefined() 546 | expect(toValidDirection('wrong')).toBeUndefined() 547 | 548 | expect(toValidDirection('left')).toEqual('left') 549 | expect(toValidDirection('LEFT')).toEqual('left') 550 | expect(toValidDirection('LeFt')).toEqual('left') 551 | 552 | expect(toValidDirection('right')).toEqual('right') 553 | expect(toValidDirection('RIGHT')).toEqual('right') 554 | expect(toValidDirection('RiGhT')).toEqual('right') 555 | 556 | expect(toValidDirection('up')).toEqual('up') 557 | expect(toValidDirection('UP')).toEqual('up') 558 | expect(toValidDirection('Up')).toEqual('up') 559 | 560 | expect(toValidDirection('down')).toEqual('down') 561 | expect(toValidDirection('DOWN')).toEqual('down') 562 | expect(toValidDirection('DoWn')).toEqual('down') 563 | 564 | expect(toValidDirection('enter')).toEqual('enter') 565 | expect(toValidDirection('ENTER')).toEqual('enter') 566 | expect(toValidDirection('EnTeR')).toEqual('enter') 567 | 568 | expect(toValidDirection('*')).toEqual('*') 569 | }) 570 | }) 571 | 572 | describe('toValidOrientation()', () => { 573 | test('should correctly convert to valid orientation', () => { 574 | expect(toValidOrientation(undefined)).toBeUndefined() 575 | expect(toValidOrientation('wrong')).toBeUndefined() 576 | 577 | expect(toValidOrientation('horizontal')).toEqual('horizontal') 578 | expect(toValidOrientation('HORIZONTAL')).toEqual('horizontal') 579 | expect(toValidOrientation('HoRiZoNtAl')).toEqual('horizontal') 580 | 581 | expect(toValidOrientation('vertical')).toEqual('vertical') 582 | expect(toValidOrientation('VERTICAL')).toEqual('vertical') 583 | expect(toValidOrientation('VeRtIcAl')).toEqual('vertical') 584 | }) 585 | }) 586 | 587 | describe('prepareNode()', () => { 588 | test('should correctly create node', () => { 589 | const baseNode = { id: 'node', parent: undefined, index: undefined, children: undefined, activeChild: undefined } 590 | 591 | expect(() => prepareNode(undefined)).toThrow('Node ID has to be defined') 592 | 593 | expect(prepareNode('node')).toEqual(baseNode) 594 | expect(prepareNode('node', {})).toEqual(baseNode) 595 | // parent property is not copied, it's computed in registerNode method 596 | expect(prepareNode('node', { parent: 'parent' })).toEqual(baseNode) 597 | 598 | expect(prepareNode('node', { index: 'index' })).toEqual(baseNode) 599 | expect(prepareNode('node', { index: 1 })).toEqual({ ...baseNode, index: 1 }) 600 | expect(prepareNode('node', { orientation: 'horizontal' })).toEqual({ ...baseNode, orientation: 'horizontal' }) 601 | expect(prepareNode('node', { indexRange: [0, 1] })).toEqual({ ...baseNode, indexRange: [0, 1] }) 602 | expect(prepareNode('node', { selectAction: 'action' })).toEqual({ ...baseNode, selectAction: 'action' }) 603 | expect(prepareNode('node', { isFocusable: true })).toEqual({ ...baseNode, isFocusable: true }) 604 | expect(prepareNode('node', { isWrapping: true })).toEqual({ ...baseNode, isWrapping: true }) 605 | expect(prepareNode('node', { isStopPropagate: true })).toEqual({ ...baseNode, isStopPropagate: true }) 606 | expect(prepareNode('node', { isIndexAlign: true })).toEqual({ ...baseNode, isIndexAlign: true }) 607 | 608 | const mock = jest.fn() 609 | expect(prepareNode('node', { onLeave: mock })).toEqual({ ...baseNode, onLeave: mock }) 610 | expect(prepareNode('node', { onEnter: mock })).toEqual({ ...baseNode, onEnter: mock }) 611 | expect(prepareNode('node', { shouldCancelLeave: mock })).toEqual({ ...baseNode, shouldCancelLeave: mock }) 612 | expect(prepareNode('node', { onLeaveCancelled: mock })).toEqual({ ...baseNode, onLeaveCancelled: mock }) 613 | expect(prepareNode('node', { shouldCancelEnter: mock })).toEqual({ ...baseNode, shouldCancelEnter: mock }) 614 | expect(prepareNode('node', { onEnterCancelled: mock })).toEqual({ ...baseNode, onEnterCancelled: mock }) 615 | expect(prepareNode('node', { onSelect: mock })).toEqual({ ...baseNode, onSelect: mock }) 616 | expect(prepareNode('node', { onInactive: mock })).toEqual({ ...baseNode, onInactive: mock }) 617 | expect(prepareNode('node', { onActive: mock })).toEqual({ ...baseNode, onActive: mock }) 618 | expect(prepareNode('node', { onActiveChildChange: mock })).toEqual({ ...baseNode, onActiveChildChange: mock }) 619 | expect(prepareNode('node', { onBlur: mock })).toEqual({ ...baseNode, onBlur: mock }) 620 | expect(prepareNode('node', { onFocus: mock })).toEqual({ ...baseNode, onFocus: mock }) 621 | expect(prepareNode('node', { onMove: mock })).toEqual({ ...baseNode, onMove: mock }) 622 | }) 623 | }) 624 | 625 | describe('traverseNodeSubtree()', () => { 626 | test('should correctly traverse tree using preorder DFS', () => { 627 | const navigation = new Lrud() 628 | .registerNode('root', { orientation: 'vertical' }) 629 | .registerNode('a', { parent: 'root', orientation: 'horizontal' }) 630 | .registerNode('aa', { parent: 'a', orientation: 'horizontal' }) 631 | .registerNode('aaa', { parent: 'aa' }) 632 | .registerNode('aab', { parent: 'aa' }) 633 | .registerNode('b', { parent: 'root', orientation: 'vertical' }) 634 | .registerNode('ba', { parent: 'b' }) 635 | .registerNode('bb', { parent: 'b' }) 636 | .registerNode('c', { parent: 'root' }) 637 | 638 | const nodeProcessor = jest.fn() 639 | 640 | traverseNodeSubtree(navigation.rootNode, nodeProcessor) 641 | 642 | expect(nodeProcessor).toHaveBeenCalledTimes(9) 643 | expect(nodeProcessor).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: 'root' })) 644 | expect(nodeProcessor).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: 'a' })) 645 | expect(nodeProcessor).toHaveBeenNthCalledWith(3, expect.objectContaining({ id: 'aa' })) 646 | expect(nodeProcessor).toHaveBeenNthCalledWith(4, expect.objectContaining({ id: 'aaa' })) 647 | expect(nodeProcessor).toHaveBeenNthCalledWith(5, expect.objectContaining({ id: 'aab' })) 648 | expect(nodeProcessor).toHaveBeenNthCalledWith(6, expect.objectContaining({ id: 'b' })) 649 | expect(nodeProcessor).toHaveBeenNthCalledWith(7, expect.objectContaining({ id: 'ba' })) 650 | expect(nodeProcessor).toHaveBeenNthCalledWith(8, expect.objectContaining({ id: 'bb' })) 651 | expect(nodeProcessor).toHaveBeenNthCalledWith(9, expect.objectContaining({ id: 'c' })) 652 | }) 653 | 654 | test('should interrupt traversing tree on node processor result', () => { 655 | const navigation = new Lrud() 656 | .registerNode('root', { orientation: 'vertical' }) 657 | .registerNode('a', { parent: 'root', orientation: 'horizontal' }) 658 | .registerNode('aa', { parent: 'a', orientation: 'horizontal' }) 659 | .registerNode('aaa', { parent: 'aa' }) 660 | .registerNode('aab', { parent: 'aa' }) 661 | .registerNode('b', { parent: 'root', orientation: 'vertical' }) 662 | .registerNode('ba', { parent: 'b' }) 663 | .registerNode('bb', { parent: 'b' }) 664 | .registerNode('c', { parent: 'root' }) 665 | 666 | // Interrupt when reaching node 'aaa' 667 | const nodeProcessor = jest.fn().mockImplementation(node => node.id === 'aaa') 668 | 669 | traverseNodeSubtree(navigation.rootNode, nodeProcessor) 670 | 671 | expect(nodeProcessor).toHaveBeenCalledTimes(4) 672 | expect(nodeProcessor).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: 'root' })) 673 | expect(nodeProcessor).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: 'a' })) 674 | expect(nodeProcessor).toHaveBeenNthCalledWith(3, expect.objectContaining({ id: 'aa' })) 675 | expect(nodeProcessor).toHaveBeenNthCalledWith(4, expect.objectContaining({ id: 'aaa' })) 676 | }) 677 | }) 678 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { KeyCodes } from './key-codes' 2 | import { 3 | Direction, 4 | Directions, 5 | Node, 6 | NodeConfig, 7 | NodeId, 8 | NodeIndex, 9 | NodeIndexRange, 10 | Orientation, 11 | Orientations, 12 | Tree 13 | } from './interfaces' 14 | 15 | /** 16 | * Given an array of values and a goal, return the value from values which is closest to the goal. 17 | * 18 | * @param {number[]} values 19 | * @param {number} goal 20 | */ 21 | export const closestIndex = (values: NodeIndex[], goal: NodeIndex): number => values.reduce(function (prev, curr) { 22 | return (Math.abs(curr - goal) < Math.abs(prev - goal) ? curr : prev) 23 | }) 24 | 25 | /** 26 | * Checks if a given node is focusable. 27 | * 28 | * @param {object} node - node to check against focusability 29 | */ 30 | export const isNodeFocusable = (node: Node): boolean => { 31 | if (!node) { 32 | return false 33 | } 34 | return node.isFocusable != null ? node.isFocusable : !!node.selectAction 35 | } 36 | 37 | /** 38 | * Given a keyCode, lookup and return the direction from the keycodes mapping file. 39 | * 40 | * @param {number} keyCode - key code for which corresponding direction is searched 41 | */ 42 | export const getDirectionForKeyCode = (keyCode: number): Direction | undefined => { 43 | return KeyCodes[keyCode] 44 | } 45 | 46 | /** 47 | * Given an orientation and a direction, do they match? 48 | * 49 | * I.e an orientation `horizontal` and direction `left` or `right` is considered matching. 50 | * 51 | * Direction CAN be passed as `*` (wildcard). this will always return true. 52 | * 53 | * @param {string} orientation - orientation to match with the direction 54 | * @param {string} direction - direction to match with the orientation 55 | */ 56 | export const isDirectionAndOrientationMatching = (orientation: Orientation, direction: Direction): boolean => { 57 | if (!orientation || !direction) { 58 | return false 59 | } 60 | 61 | const validOrientation = toValidOrientation(orientation) 62 | const validDirection = toValidDirection(direction) 63 | 64 | return ( 65 | (validDirection === Directions.UNSPECIFIED) || 66 | (validOrientation === Orientations.VERTICAL && (validDirection === Directions.UP || validDirection === Directions.DOWN)) || 67 | (validOrientation === Orientations.HORIZONTAL && (validDirection === Directions.LEFT || validDirection === Directions.RIGHT)) 68 | ) 69 | } 70 | 71 | /** 72 | * Returns a child from the given node whose indexRange encompasses the given index. 73 | * 74 | * @param {object} node - node, which children index ranges will be examined 75 | * @param {number} index - examined index value 76 | */ 77 | export const findChildWithMatchingIndexRange = (node: Node, index: NodeIndex): Node | undefined => { 78 | if (!node || !node.children) { 79 | return undefined 80 | } 81 | 82 | for (let i = 0; i < node.children.length; i++) { 83 | const child = node.children[i] 84 | if (child.indexRange && (child.indexRange[0] <= index && child.indexRange[1] >= index)) { 85 | return child 86 | } 87 | } 88 | 89 | return undefined 90 | } 91 | 92 | /** 93 | * Returns a child from the given node whose index is numerically closest to the given index. 94 | * 95 | * If an indexRange is provided, first check if the node's activeChild is inside 96 | * the indexRange. If it is, return the activeChild node instead 97 | * 98 | * @param {object} node 99 | * @param {number} index 100 | * @param {number[]} indexRange 101 | */ 102 | export const findChildWithClosestIndex = (node: Node, index: NodeIndex, indexRange?: NodeIndexRange): Node | undefined => { 103 | if (!node || !node.children) { 104 | return undefined 105 | } 106 | 107 | // if we have an indexRange, and the nodes active child is inside that index range, 108 | // just return the active child 109 | if ( 110 | indexRange && node.activeChild && 111 | node.activeChild.index >= indexRange[0] && node.activeChild.index <= indexRange[1] && 112 | isNodeFocusable(node.activeChild) 113 | ) { 114 | return node.activeChild 115 | } 116 | 117 | const indices = [] 118 | for (let i = 0; i < node.children.length; i++) { 119 | if (isNodeFocusable(node.children[i]) || node.children[i].children) { 120 | indices.push(i) 121 | } 122 | } 123 | 124 | if (indices.length <= 0) { 125 | return undefined 126 | } 127 | return node.children[closestIndex(indices, index)] 128 | } 129 | 130 | /** 131 | * Inserts given child to the parent's children, keeping indices coherent and compact. 132 | * 133 | * @param parent - node to which new child is about to be added 134 | * @param newChild - node to be added to the parent 135 | */ 136 | export const insertChildNode = (parent: Node, newChild: Node): void => { 137 | if (!parent || !newChild) { 138 | return 139 | } 140 | 141 | if (!parent.children) { 142 | parent.children = [] 143 | } 144 | 145 | newChild.parent = parent 146 | 147 | if (typeof newChild.index !== 'number' || newChild.index > parent.children.length) { 148 | newChild.index = parent.children.length 149 | parent.children.push(newChild) 150 | } else { 151 | // Inserting new child, from now on all further children needs to have index value increased by one 152 | parent.children.splice(newChild.index, 0, newChild) 153 | for (let i = newChild.index + 1; i < parent.children.length; i++) { 154 | parent.children[i].index = i 155 | } 156 | } 157 | } 158 | 159 | /** 160 | * Removes given child from the parent's children, keeping indices coherent and compact. 161 | * 162 | * @param parent - node from which children given child is about to be removed 163 | * @param child - node to be removed from parent's children 164 | */ 165 | export const removeChildNode = (parent: Node, child: Node): void => { 166 | if (!child || !parent || !parent.children) { 167 | return 168 | } 169 | 170 | let removedChildIndex = -1 171 | for (let i = 0; i < parent.children.length; i++) { 172 | if (parent.children[i] === child) { 173 | removedChildIndex = i 174 | } else if (removedChildIndex !== -1) { 175 | parent.children[i].index = i - 1 176 | } 177 | } 178 | 179 | if (removedChildIndex !== -1) { 180 | if (parent.children.length === 1) { 181 | parent.children = undefined 182 | } else { 183 | parent.children.splice(removedChildIndex, 1) 184 | } 185 | } 186 | } 187 | 188 | /** 189 | * Returns valid orientation parameter. 190 | * 191 | * TypeScript defines type appropriately, but in JavaScript anything can be passed. Method doesn't imply that user 192 | * might be malicious, it just tries to unify provided string values, by making them a valid Orientation type. 193 | * 194 | * @param orientation - orientation to correct 195 | */ 196 | export const toValidOrientation = (orientation: Orientation): Orientation | undefined => { 197 | if (!orientation) { 198 | return undefined 199 | } 200 | for (const orientationKey of Object.keys(Orientations)) { 201 | if (Orientations[orientationKey] === orientation.toLowerCase()) { 202 | return Orientations[orientationKey] 203 | } 204 | } 205 | return undefined 206 | } 207 | 208 | /** 209 | * Returns valid direction parameter. 210 | * 211 | * TypeScript defines type appropriately, but in JavaScript anything can be passed. Method doesn't imply that user 212 | * might be malicious, it just tries to unify provided string values, by making them a valid Orientation type. 213 | * 214 | * @param direction - direction to correct 215 | */ 216 | export const toValidDirection = (direction: Direction): Direction | undefined => { 217 | if (!direction) { 218 | return undefined 219 | } 220 | for (const directionKey of Object.keys(Directions)) { 221 | if (Directions[directionKey] === direction.toLowerCase()) { 222 | return Directions[directionKey] 223 | } 224 | } 225 | return undefined 226 | } 227 | 228 | /** 229 | * Creates node object from given node parameters. 230 | * 231 | * Node creation method is optimized for JavaScript engine. 232 | * Objects created in "the same way" allows JavaScript engine reusing existing HiddenClass transition chain. 233 | * Moreover most used properties are defined "at the beginning" making access to them a bit faster. 234 | * 235 | * @param {string} nodeId - id of the node 236 | * @param {object} [nodeConfig] - node parameters 237 | */ 238 | export const prepareNode = (nodeId: NodeId, nodeConfig: NodeConfig = {}): Node => { 239 | if (!nodeId) { 240 | throw Error('Node ID has to be defined') 241 | } 242 | 243 | return { 244 | id: nodeId, 245 | parent: undefined, 246 | children: undefined, 247 | activeChild: undefined, 248 | overrides: undefined, 249 | overrideSources: undefined, 250 | index: (typeof nodeConfig.index === 'number') ? nodeConfig.index : undefined, 251 | orientation: nodeConfig.orientation, 252 | indexRange: nodeConfig.indexRange, 253 | selectAction: nodeConfig.selectAction, 254 | isFocusable: nodeConfig.isFocusable, 255 | isWrapping: nodeConfig.isWrapping, 256 | isStopPropagate: nodeConfig.isStopPropagate, 257 | isIndexAlign: nodeConfig.isIndexAlign, 258 | onLeave: nodeConfig.onLeave, 259 | onEnter: nodeConfig.onEnter, 260 | shouldCancelLeave: nodeConfig.shouldCancelLeave, 261 | onLeaveCancelled: nodeConfig.onLeaveCancelled, 262 | shouldCancelEnter: nodeConfig.shouldCancelEnter, 263 | onEnterCancelled: nodeConfig.onEnterCancelled, 264 | onSelect: nodeConfig.onSelect, 265 | onInactive: nodeConfig.onInactive, 266 | onActive: nodeConfig.onActive, 267 | onActiveChildChange: nodeConfig.onActiveChildChange, 268 | onBlur: nodeConfig.onBlur, 269 | onFocus: nodeConfig.onFocus, 270 | onMove: nodeConfig.onMove 271 | } 272 | } 273 | 274 | /** 275 | * Traverses through node subtree (including the node) with iterative preorder deep first search tree traversal algorithm. 276 | * 277 | * DFS is implemented without recursion to avoid putting methods to the stack. This allows traversing deep trees without 278 | * the need of allocating memory for recursive method calls. 279 | * 280 | * For some processes, when node meeting some condition is searched, there's no need to traverse through whole tree. 281 | * To address that, given nodeProcessor may return boolean value. when true is returned, than traversal algorithm 282 | * is interrupted immediately. 283 | * 284 | * E.g. Given tree: 285 | * root 286 | * / \ 287 | * A B 288 | * / \ 289 | * AA BA 290 | * / \ 291 | * AAA AAB 292 | * 293 | * Traversal path: root -> A -> AA -> AAA -> AAB -> B -> BA 294 | * 295 | * @param {object} startNode - node that is the root of traversed subtree 296 | * @param {function} nodeProcessor - callback executed for traversed node, if true is returned then subtree traversal is interrupted 297 | */ 298 | export const traverseNodeSubtree = >(startNode: NodeType, nodeProcessor: (node: NodeType) => boolean | void): void => { 299 | const stack: NodeType[] = [startNode] 300 | const dummyThis = Object.create(null) 301 | 302 | let traversedNode: NodeType 303 | 304 | while (stack.length > 0) { 305 | traversedNode = stack.pop() 306 | 307 | if (nodeProcessor.call(dummyThis, traversedNode)) { 308 | return 309 | } 310 | 311 | if (traversedNode.children) { 312 | for (let i = traversedNode.children.length - 1; i >= 0; i--) { 313 | stack.push(traversedNode.children[i]) 314 | } 315 | } 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationDir": "dist/types", 5 | "outDir": "dist/esm", 6 | "moduleResolution": "node", 7 | "target": "es5", 8 | "module": "es2015", 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "types": ["jest"] 12 | }, 13 | "include": [ 14 | "src/**/*" 15 | ], 16 | "exclude": ["node_modules"] 17 | } 18 | --------------------------------------------------------------------------------