├── .eslintignore ├── _config.yml ├── .babelrc ├── resources └── images │ └── spatial-nav-example.gif ├── .eslintrc ├── index.html ├── src ├── index.js ├── measureLayout.js ├── visualDebugger.js ├── App-random-coords.js ├── withFocusable.js ├── App.js └── spatialNavigation.js ├── index.js ├── .editorconfig ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── __tests__ └── spatialNavigation.test.js ├── CONTRIBUTING.md ├── LICENSE ├── .gitignore ├── package.json ├── CODE_OF_CONDUCT.md ├── jest.config.js ├── CHANGELOG.md └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "env", 4 | "react" 5 | ], 6 | "plugins": ["transform-object-rest-spread"] 7 | } -------------------------------------------------------------------------------- /resources/images/spatial-nav-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NoriginMedia/react-spatial-navigation/HEAD/resources/images/spatial-nav-example.gif -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "norigin/react", 3 | "rules": {}, 4 | "parser": "babel-eslint", 5 | "parserOptions": { 6 | "ecmaVersion": 7 7 | }, 8 | "env": { 9 | "jest": true, 10 | "node": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Spatial Navigation Demo 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import withFocusable from './withFocusable'; 2 | import SpatialNavigation from './spatialNavigation'; 3 | 4 | const {init: initNavigation, setKeyMap} = SpatialNavigation; 5 | 6 | export { 7 | withFocusable, 8 | initNavigation, 9 | setKeyMap 10 | }; 11 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import {AppRegistry} from 'react-native'; 2 | import App from './src/App'; 3 | 4 | AppRegistry.registerComponent('spatialNavDemo', () => App); 5 | 6 | AppRegistry.runApplication('spatialNavDemo', { 7 | initialProps: {}, 8 | rootTag: document.getElementById('app') 9 | }); 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | root = true 5 | [*] 6 | # Change these settings to your own preference 7 | indent_style = space 8 | indent_size = 2 9 | # We recommend you to keep these unchanged 10 | end_of_line = lf 11 | charset = utf-8 12 | trim_trailing_whitespace = true 13 | insert_final_newline = true 14 | [*.md] 15 | trim_trailing_whitespace = false 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: '' 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. Click on '....' 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. 28 | -------------------------------------------------------------------------------- /__tests__/spatialNavigation.test.js: -------------------------------------------------------------------------------- 1 | import {getChildClosestToOrigin} from '../src/spatialNavigation'; 2 | 3 | class Child { 4 | constructor(top, left) { 5 | this.layout = {}; 6 | this.layout.top = top; 7 | this.layout.left = left; 8 | } 9 | } 10 | 11 | test('The getChildClosestToOrigin is working well', () => { 12 | const children = [ 13 | new Child(-11.43242342, 222), 14 | new Child(0, 1), 15 | new Child(-0.00001, 0.2), 16 | new Child(10, 12) 17 | ]; 18 | 19 | const childClosestToOrigin = new Child(-0.00001, 0.2); 20 | 21 | const result = getChildClosestToOrigin(children); 22 | 23 | expect(result).toEqual(childClosestToOrigin); 24 | }); 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 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 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Update the README.md with details of changes to the API, update an example App if needed, update the code examples in README.md if applicable, update the Dev Notes at the end of README.md if needed. 11 | 2. Once the Pull Request is reviewed, it will be merged by the Reviewer, otherwise you could do it yourself if you have a permission. Please use "Squash and Merge" and Delete your branch after. 12 | 13 | # Code of Conduct 14 | 15 | [Code of Conduct](https://github.com/NoriginMedia/react-spatial-navigation/blob/master/CODE_OF_CONDUCT.md) 16 | -------------------------------------------------------------------------------- /src/measureLayout.js: -------------------------------------------------------------------------------- 1 | const ELEMENT_NODE = 1; 2 | 3 | const getRect = (node) => { 4 | let {offsetParent} = node; 5 | const height = node.offsetHeight; 6 | const width = node.offsetWidth; 7 | let left = node.offsetLeft; 8 | let top = node.offsetTop; 9 | 10 | while (offsetParent && offsetParent.nodeType === ELEMENT_NODE) { 11 | left += offsetParent.offsetLeft - offsetParent.scrollLeft; 12 | top += offsetParent.offsetTop - offsetParent.scrollTop; 13 | ({offsetParent} = offsetParent); 14 | } 15 | 16 | return { 17 | height, 18 | left, 19 | top, 20 | width 21 | }; 22 | }; 23 | 24 | const measureLayout = (node, callback) => { 25 | const relativeNode = node && node.parentNode; 26 | 27 | if (node && relativeNode) { 28 | const relativeRect = getRect(relativeNode); 29 | const {height, left, top, width} = getRect(node); 30 | const x = left - relativeRect.left; 31 | const y = top - relativeRect.top; 32 | 33 | callback(x, y, width, height, left, top); 34 | } 35 | }; 36 | 37 | export default measureLayout; 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 NoriginMedia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | project.xcworkspace 24 | 25 | # Android/IntelliJ 26 | # 27 | .idea 28 | .gradle 29 | local.properties 30 | *.iml 31 | 32 | # node.js 33 | # 34 | node_modules/ 35 | npm-debug.log 36 | yarn-error.log 37 | 38 | # BUCK 39 | buck-out/ 40 | \.buckd/ 41 | *.keystore 42 | 43 | # fastlane 44 | # 45 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 46 | # screenshots whenever they are needed. 47 | # For more information about the recommended setup visit: 48 | # https://docs.fastlane.tools/best-practices/source-control/ 49 | 50 | */fastlane/report.xml 51 | */fastlane/Preview.html 52 | */fastlane/screenshots 53 | 54 | # Bundle artifact 55 | *.jsbundle 56 | 57 | .cache 58 | parcel 59 | .idea 60 | 61 | # dist shall be generated by the npm 'prepare' hook, which implicitly runs before 'npm publish' and before local/git installs. 62 | dist -------------------------------------------------------------------------------- /src/visualDebugger.js: -------------------------------------------------------------------------------- 1 | // We'll make VisualDebugger no-op for any environments lacking a DOM (e.g. SSR and React Native non-web platforms). 2 | const hasDOM = typeof window !== 'undefined' && window.document; 3 | 4 | const WIDTH = hasDOM ? window.innerWidth : 0; 5 | const HEIGHT = hasDOM ? window.innerHeight : 0; 6 | 7 | class VisualDebugger { 8 | constructor() { 9 | if (hasDOM) { 10 | this.debugCtx = VisualDebugger.createCanvas('sn-debug', 1010); 11 | this.layoutsCtx = VisualDebugger.createCanvas('sn-layouts', 1000); 12 | } 13 | } 14 | 15 | static createCanvas(id, zIndex) { 16 | const canvas = document.querySelector(`#${id}`) || document.createElement('canvas'); 17 | 18 | canvas.setAttribute('id', id); 19 | 20 | const ctx = canvas.getContext('2d'); 21 | 22 | canvas.style = `position: fixed; top: 0; left: 0; z-index: ${zIndex}`; 23 | 24 | document.body.appendChild(canvas); 25 | 26 | canvas.width = WIDTH; 27 | canvas.height = HEIGHT; 28 | 29 | return ctx; 30 | } 31 | 32 | clear() { 33 | if (!hasDOM) { 34 | return; 35 | } 36 | this.debugCtx.clearRect(0, 0, WIDTH, HEIGHT); 37 | } 38 | 39 | clearLayouts() { 40 | if (!hasDOM) { 41 | return; 42 | } 43 | this.layoutsCtx.clearRect(0, 0, WIDTH, HEIGHT); 44 | } 45 | 46 | drawLayout(layout, focusKey, parentFocusKey) { 47 | if (!hasDOM) { 48 | return; 49 | } 50 | this.layoutsCtx.strokeStyle = 'green'; 51 | this.layoutsCtx.strokeRect(layout.left, layout.top, layout.width, layout.height); 52 | this.layoutsCtx.font = '8px monospace'; 53 | this.layoutsCtx.fillStyle = 'red'; 54 | this.layoutsCtx.fillText(focusKey, layout.left, layout.top + 10); 55 | this.layoutsCtx.fillText(parentFocusKey, layout.left, layout.top + 25); 56 | this.layoutsCtx.fillText(`left: ${layout.left}`, layout.left, layout.top + 40); 57 | this.layoutsCtx.fillText(`top: ${layout.top}`, layout.left, layout.top + 55); 58 | } 59 | 60 | drawPoint(x, y, color = 'blue', size = 10) { 61 | if (!hasDOM) { 62 | return; 63 | } 64 | this.debugCtx.strokeStyle = color; 65 | this.debugCtx.lineWidth = 3; 66 | this.debugCtx.strokeRect(x - (size / 2), y - (size / 2), size, size); 67 | } 68 | } 69 | 70 | export default VisualDebugger; 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@noriginmedia/react-spatial-navigation", 3 | "version": "2.12.9", 4 | "description": "HOC-based Spatial Navigation (key navigation) solution for React", 5 | "main": "dist/index.js", 6 | "files": [ 7 | "dist" 8 | ], 9 | "scripts": { 10 | "eslint": "eslint src", 11 | "dist": "./node_modules/.bin/babel src --out-dir dist --copy-files --plugins=transform-object-rest-spread --presets=env,react", 12 | "start": "parcel index.html -d parcel", 13 | "test": "jest", 14 | "prepublishOnly": "npm run eslint", 15 | "prepare": "npm run dist", 16 | "publish": "npm publish --access public" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/NoriginMedia/react-spatial-navigation.git" 21 | }, 22 | "keywords": [ 23 | "react", 24 | "recompose", 25 | "spatial-navigation", 26 | "hoc" 27 | ], 28 | "author": "Dmitriy Bryokhin ", 29 | "contributors": [ 30 | "Dmitriy Bryokhin ", 31 | "Jamie Birch ", 32 | "Antonio Salvati ", 33 | "Enrico Bardelli " 34 | ], 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/NoriginMedia/react-spatial-navigation/issues" 38 | }, 39 | "homepage": "https://github.com/NoriginMedia/react-spatial-navigation#readme", 40 | "peerDependencies": { 41 | "react": ">=16.5.1", 42 | "react-dom": ">=16.5.1", 43 | "react-native-web": "^0.11.2" 44 | }, 45 | "dependencies": { 46 | "lodash": "^4.17.13", 47 | "prop-types": "^15.6.2", 48 | "recompose": "^0.30.0" 49 | }, 50 | "devDependencies": { 51 | "babel-cli": "^6.26.0", 52 | "babel-core": "^6.26.3", 53 | "babel-eslint": "8.2.3", 54 | "babel-jest": "^23.6.0", 55 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 56 | "babel-preset-env": "^1.7.0", 57 | "babel-preset-react": "^6.24.1", 58 | "eslint": "4.19.1", 59 | "eslint-config-norigin": "git+https://github.com/NoriginMedia/eslint-config-norigin.git#v3.7.5", 60 | "jest": "^23.6.0", 61 | "parcel-bundler": "^1.11.0", 62 | "pre-commit": "^1.2.2", 63 | "react": "^16.5.2", 64 | "react-dom": "^16.5.2", 65 | "react-native-web": "^0.11.2" 66 | }, 67 | "pre-commit": [ 68 | "eslint" 69 | ], 70 | "alias": { 71 | "react-native": "react-native-web" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/App-random-coords.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp */ 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import random from 'lodash/random'; 5 | import uniqueId from 'lodash/uniqueId'; 6 | import {View, StyleSheet} from 'react-native'; 7 | import withFocusable from './withFocusable'; 8 | import SpatialNavigation from './spatialNavigation'; 9 | 10 | const VIEW_HEIGHT = 720; 11 | const VIEW_WIDTH = 1280; 12 | 13 | const colors = [ 14 | '#337fdd', 15 | '#dd4558', 16 | '#7ddd6a', 17 | '#dddd4d', 18 | '#8299dd', 19 | '#edab83', 20 | '#60ed9e', 21 | '#d15fb6', 22 | '#c0ee33' 23 | ]; 24 | 25 | const squares = []; 26 | 27 | for (let i = 0; i < 20; i++) { 28 | const boxHeight = random(50, 200); 29 | const boxWidth = random(50, 200); 30 | 31 | squares.push({ 32 | id: uniqueId(), 33 | width: boxWidth, 34 | height: boxHeight, 35 | top: random(0, VIEW_HEIGHT - boxHeight - 20), 36 | left: random(0, VIEW_WIDTH - boxWidth - 20), 37 | backgroundColor: colors[random(0, colors.length - 1)] 38 | }); 39 | } 40 | 41 | SpatialNavigation.init({ 42 | debug: true, 43 | visualDebug: true 44 | }); 45 | 46 | // SpatialNavigation.setKeyMap(keyMap); -> Custom key map 47 | 48 | const styles = StyleSheet.create({ 49 | wrapper: { 50 | height: VIEW_HEIGHT, 51 | width: VIEW_WIDTH, 52 | backgroundColor: '#333333' 53 | }, 54 | box: { 55 | position: 'absolute' 56 | }, 57 | boxFocused: { 58 | borderWidth: 5, 59 | borderColor: '#e3ff3a', 60 | backgroundColor: 'white', 61 | zIndex: 999 62 | } 63 | }); 64 | 65 | const Box = ({top, left, width, height, backgroundColor, focused}) => { 66 | const style = { 67 | top, 68 | left, 69 | width, 70 | height, 71 | backgroundColor 72 | }; 73 | 74 | return (); 75 | }; 76 | 77 | Box.propTypes = { 78 | top: PropTypes.number.isRequired, 79 | left: PropTypes.number.isRequired, 80 | width: PropTypes.number.isRequired, 81 | height: PropTypes.number.isRequired, 82 | backgroundColor: PropTypes.string.isRequired, 83 | focused: PropTypes.bool.isRequired 84 | }; 85 | 86 | const BoxFocusable = withFocusable()(Box); 87 | 88 | class Spatial extends React.PureComponent { 89 | componentDidMount() { 90 | this.props.setFocus(); 91 | } 92 | 93 | render() { 94 | return ( 95 | {squares.map(({id, ...rest}) => ())} 99 | ); 100 | } 101 | } 102 | 103 | Spatial.propTypes = { 104 | setFocus: PropTypes.func.isRequired 105 | }; 106 | 107 | const SpatialFocusable = withFocusable()(Spatial); 108 | 109 | const App = () => ( 110 | 111 | ); 112 | 113 | export default App; 114 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at dmitriy.bryokhin@noriginmedia.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /src/withFocusable.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-find-dom-node */ 2 | import {findDOMNode} from 'react-dom'; 3 | import PropTypes from 'prop-types'; 4 | import uniqueId from 'lodash/uniqueId'; 5 | import noop from 'lodash/noop'; 6 | import omit from 'lodash/omit'; 7 | import compose from 'recompose/compose'; 8 | import lifecycle from 'recompose/lifecycle'; 9 | import withHandlers from 'recompose/withHandlers'; 10 | import withContext from 'recompose/withContext'; 11 | import withStateHandlers from 'recompose/withStateHandlers'; 12 | import getContext from 'recompose/getContext'; 13 | import pure from 'recompose/pure'; 14 | import mapProps from 'recompose/mapProps'; 15 | import SpatialNavigation, {ROOT_FOCUS_KEY} from './spatialNavigation'; 16 | 17 | const omitProps = (keys) => mapProps((props) => omit(props, keys)); 18 | 19 | const withFocusable = ({ 20 | forgetLastFocusedChild: configForgetLastFocusedChild = false, 21 | trackChildren: configTrackChildren = false, 22 | autoRestoreFocus: configAutoRestoreFocus, 23 | blockNavigationOut: configBlockNavigationOut = false 24 | } = {}) => compose( 25 | getContext({ 26 | /** 27 | * From the context provided by another higher-level 'withFocusable' component 28 | */ 29 | parentFocusKey: PropTypes.string 30 | }), 31 | 32 | withStateHandlers(({focusKey, parentFocusKey}) => { 33 | const realFocusKey = focusKey || uniqueId('sn:focusable-item-'); 34 | 35 | return { 36 | realFocusKey, 37 | 38 | /** 39 | * This method is used to imperatively set focus to a component. 40 | * It is blocked in the Native mode because the native engine decides what to focus by itself. 41 | */ 42 | setFocus: SpatialNavigation.isNativeMode() ? noop : SpatialNavigation.setFocus.bind(null, realFocusKey), 43 | 44 | navigateByDirection: SpatialNavigation.navigateByDirection, 45 | 46 | /** 47 | * In Native mode this is the only way to mark component as focused. 48 | * This method always steals focus onto current component no matter which arguments are passed in. 49 | */ 50 | stealFocus: SpatialNavigation.setFocus.bind(null, realFocusKey, realFocusKey), 51 | focused: false, 52 | hasFocusedChild: false, 53 | parentFocusKey: parentFocusKey || ROOT_FOCUS_KEY 54 | }; 55 | }, { 56 | onUpdateFocus: () => (focused = false) => ({ 57 | focused 58 | }), 59 | onUpdateHasFocusedChild: () => (hasFocusedChild = false) => ({ 60 | hasFocusedChild 61 | }) 62 | }), 63 | 64 | /** 65 | * Propagate own 'focusKey' as a 'parentFocusKey' to it's children 66 | */ 67 | withContext({ 68 | parentFocusKey: PropTypes.string 69 | }, ({realFocusKey}) => ({ 70 | parentFocusKey: realFocusKey 71 | })), 72 | 73 | withHandlers({ 74 | onEnterPressHandler: ({ 75 | onEnterPress = noop, 76 | ...rest 77 | }) => (details) => { 78 | onEnterPress(rest, details); 79 | }, 80 | onEnterReleaseHandler: ({ 81 | onEnterRelease = noop, 82 | ...rest 83 | }) => () => { 84 | onEnterRelease(rest); 85 | }, 86 | onArrowPressHandler: ({ 87 | onArrowPress = noop, 88 | ...rest 89 | }) => (direction, details) => onArrowPress(direction, rest, details), 90 | onBecameFocusedHandler: ({ 91 | onBecameFocused = noop, 92 | ...rest 93 | }) => (layout, details) => { 94 | onBecameFocused(layout, rest, details); 95 | }, 96 | onBecameBlurredHandler: ({ 97 | onBecameBlurred = noop, 98 | ...rest 99 | }) => (layout, details) => { 100 | onBecameBlurred(layout, rest, details); 101 | }, 102 | pauseSpatialNavigation: () => SpatialNavigation.pause, 103 | resumeSpatialNavigation: () => SpatialNavigation.resume, 104 | updateAllSpatialLayouts: () => SpatialNavigation.updateAllLayouts 105 | }), 106 | 107 | lifecycle({ 108 | componentDidMount() { 109 | const { 110 | realFocusKey: focusKey, 111 | parentFocusKey, 112 | preferredChildFocusKey, 113 | forgetLastFocusedChild = false, 114 | onEnterPressHandler, 115 | onEnterReleaseHandler, 116 | onArrowPressHandler, 117 | onBecameFocusedHandler, 118 | onBecameBlurredHandler, 119 | onUpdateFocus, 120 | onUpdateHasFocusedChild, 121 | trackChildren, 122 | focusable = true, 123 | autoRestoreFocus = true, 124 | blockNavigationOut = false 125 | } = this.props; 126 | 127 | const node = SpatialNavigation.isNativeMode() ? this : findDOMNode(this); 128 | 129 | SpatialNavigation.addFocusable({ 130 | focusKey, 131 | node, 132 | parentFocusKey, 133 | preferredChildFocusKey, 134 | onEnterPressHandler, 135 | onEnterReleaseHandler, 136 | onArrowPressHandler, 137 | onBecameFocusedHandler, 138 | onBecameBlurredHandler, 139 | onUpdateFocus, 140 | onUpdateHasFocusedChild, 141 | forgetLastFocusedChild: (configForgetLastFocusedChild || forgetLastFocusedChild), 142 | trackChildren: (configTrackChildren || trackChildren), 143 | blockNavigationOut: (configBlockNavigationOut || blockNavigationOut), 144 | autoRestoreFocus: configAutoRestoreFocus !== undefined ? configAutoRestoreFocus : autoRestoreFocus, 145 | focusable 146 | }); 147 | }, 148 | componentDidUpdate() { 149 | const { 150 | realFocusKey: focusKey, 151 | preferredChildFocusKey, 152 | focusable = true, 153 | blockNavigationOut = false 154 | } = this.props; 155 | 156 | const node = SpatialNavigation.isNativeMode() ? this : findDOMNode(this); 157 | 158 | SpatialNavigation.updateFocusable(focusKey, { 159 | node, 160 | preferredChildFocusKey, 161 | focusable, 162 | blockNavigationOut: (configBlockNavigationOut || blockNavigationOut) 163 | }); 164 | }, 165 | componentWillUnmount() { 166 | const {realFocusKey: focusKey} = this.props; 167 | 168 | SpatialNavigation.removeFocusable({ 169 | focusKey 170 | }); 171 | } 172 | }), 173 | 174 | pure, 175 | 176 | omitProps([ 177 | 'onBecameFocusedHandler', 178 | 'onBecameBlurredHandler', 179 | 'onEnterPressHandler', 180 | 'onEnterReleaseHandler', 181 | 'onArrowPressHandler', 182 | 'onUpdateFocus', 183 | 'onUpdateHasFocusedChild', 184 | 'forgetLastFocusedChild', 185 | 'trackChildren', 186 | 'autoRestoreFocus' 187 | ]) 188 | ); 189 | 190 | export default withFocusable; 191 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/0b/7php9hps6758rgmng8qltnz80000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | // clearMocks: false, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: "coverage", 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: null, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: null, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: null, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: null, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 64 | // maxWorkers: "50%", 65 | 66 | // An array of directory names to be searched recursively up from the requiring module's location 67 | // moduleDirectories: [ 68 | // "node_modules" 69 | // ], 70 | 71 | // An array of file extensions your modules use 72 | // moduleFileExtensions: [ 73 | // "js", 74 | // "json", 75 | // "jsx", 76 | // "ts", 77 | // "tsx", 78 | // "node" 79 | // ], 80 | 81 | // A map from regular expressions to module names that allow to stub out resources with a single module 82 | // moduleNameMapper: {}, 83 | 84 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 85 | // modulePathIgnorePatterns: [], 86 | 87 | // Activates notifications for test results 88 | // notify: false, 89 | 90 | // An enum that specifies notification mode. Requires { notify: true } 91 | // notifyMode: "failure-change", 92 | 93 | // A preset that is used as a base for Jest's configuration 94 | // preset: null, 95 | 96 | // Run tests from one or more projects 97 | // projects: null, 98 | 99 | // Use this configuration option to add custom reporters to Jest 100 | // reporters: undefined, 101 | 102 | // Automatically reset mock state between every test 103 | // resetMocks: false, 104 | 105 | // Reset the module registry before running each individual test 106 | // resetModules: false, 107 | 108 | // A path to a custom resolver 109 | // resolver: null, 110 | 111 | // Automatically restore mock state between every test 112 | // restoreMocks: false, 113 | 114 | // The root directory that Jest should scan for tests and modules within 115 | // rootDir: null, 116 | 117 | // A list of paths to directories that Jest should use to search for files in 118 | // roots: [ 119 | // "" 120 | // ], 121 | 122 | // Allows you to use a custom runner instead of Jest's default test runner 123 | // runner: "jest-runner", 124 | 125 | // The paths to modules that run some code to configure or set up the testing environment before each test 126 | // setupFiles: [], 127 | 128 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 129 | // setupFilesAfterEnv: [], 130 | 131 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 132 | // snapshotSerializers: [], 133 | 134 | // The test environment that will be used for testing 135 | // testEnvironment: "jest-environment-jsdom", 136 | 137 | // Options that will be passed to the testEnvironment 138 | // testEnvironmentOptions: {}, 139 | 140 | // Adds a location field to test results 141 | // testLocationInResults: false, 142 | 143 | // The glob patterns Jest uses to detect test files 144 | // testMatch: [ 145 | // "**/__tests__/**/*.[jt]s?(x)", 146 | // "**/?(*.)+(spec|test).[tj]s?(x)" 147 | // ], 148 | 149 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 150 | // testPathIgnorePatterns: [ 151 | // "/node_modules/" 152 | // ], 153 | 154 | // The regexp pattern or array of patterns that Jest uses to detect test files 155 | // testRegex: [], 156 | 157 | // This option allows the use of a custom results processor 158 | // testResultsProcessor: null, 159 | 160 | // This option allows use of a custom test runner 161 | // testRunner: "jasmine2", 162 | 163 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 164 | // testURL: "http://localhost", 165 | 166 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 167 | // timers: "real", 168 | 169 | // A map from regular expressions to paths to transformers 170 | // transform: null, 171 | 172 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 173 | // transformIgnorePatterns: [ 174 | // "/node_modules/" 175 | // ], 176 | 177 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 178 | // unmockedModulePathPatterns: undefined, 179 | 180 | // Indicates whether each individual test should be reported during the run 181 | // verbose: null, 182 | 183 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 184 | // watchPathIgnorePatterns: [], 185 | 186 | // Whether to use watchman for file crawling 187 | // watchman: true, 188 | }; 189 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [2.12.9] 8 | ### Changed 9 | - performance optimization: updateLayout is only called for components affected by navigation 10 | 11 | ## [2.12.8] 12 | ### Added 13 | - onEnterRelease event triggered when the Enter is released from the focused item 14 | 15 | ## [2.12.7] 16 | ### Added 17 | - SSR support, additional checks for `window` object to avoid errors on SSR environments. 18 | 19 | ## [2.12.6] 20 | ### Added 21 | - Added `updateAllSpatialLayouts` to allow updating components layout at any time that is required. 22 | 23 | ## [2.12.5] 24 | ### Added 25 | - Added `blockNavigationOut` to avoid focus out from the selected component. 26 | 27 | ## [2.12.4] 28 | ### Fixed 29 | - Fixed issue where this library didn't work in SSR environments due to references to DOM-only variables 30 | - Fixed few issues with referencing non-existing APIs in React Native environments 31 | 32 | ## [2.12.3] 33 | ### Added 34 | - added `throttleKeypresses` to prevent canceling of throttled events for individual key presses 35 | 36 | ## [2.12.2] 37 | ### Changed 38 | - update layouts at the beginning of smartNavigate instead of after setFocus 39 | 40 | ## [2.12.1] 41 | ### Fixed 42 | - Fixed regression with using `autoRestoreFocus` on components that are focused + getting unmounted and don't have parent 43 | 44 | ## [2.12.0] 45 | ### Added 46 | - added `autoRestoreFocus` prop to control whether parent component should restore focus on any available child when a currently focused child component is unmounted. 47 | 48 | ## [2.11.0] 49 | ### Changed 50 | - `onBecameBlurred` and `onBecameFocused` are always invoked synchonously with focus change and not on componentDidUpdate 51 | ### Added 52 | - `setFocus` and `navigateByDirection` accept an details object, this object is passed back on `onBecameBlurred` and `onBecameFocused` callbacks 53 | 54 | ## [2.10.0] 55 | ### Changed 56 | - Changed behaviour of `onBecameFocused`, now it's invoked also in case of stealFocus 57 | ### Added 58 | - Added `onBecameBlurred` with the same behaviour of `onBecameFocused` but invoked on component losing focus 59 | 60 | ## [2.9.3] 61 | ### Added 62 | - Added `KeyDetails` param on callback functions `onEnterPress` and `onArrowPress` 63 | 64 | ## [2.9.2] 65 | ### Fixed 66 | - Fixed issue #46 Focus jumps on wrong component: Removed `setTimeout` in `measureLayout` to avoid coordinates mismatches with DOM nodes. 67 | 68 | ## [2.9.1] 69 | ### Added 70 | - Added a testing library (`Jest`). 71 | - Added a private function `getNearestChild` that helps to find the nearest child by coordinates. 72 | - Added a unit test of `getNearestChild`. 73 | ### Removed 74 | - Removed old logic of finding the nearest child by coordinates of `getNextFocusKey` method (We use the `getNearestChild` function instead). 75 | 76 | ## [2.9.0] 77 | ### Added 78 | - Added smart focusing by direction (left, right, top, down), if you can't use buttons or focusing by key. Use `navigateByDirection` method for it. 79 | 80 | 81 | ## [2.8.4] 82 | ### Fixed 83 | - Fixed useless `logIndex` update. 84 | 85 | ## [2.8.3] 86 | ### Fixed 87 | - Fixed missing reference to a component in native mode 88 | 89 | ## [2.8.2] 90 | ### Added 91 | - Added guard checks for Enter press and Arrow press to check whether component exists 92 | 93 | ## [2.8.1] 94 | ### Added 95 | - Added a copy of "node" ref to "layout" to also have it onBecameFocused callback 96 | 97 | ## [2.8.0] 98 | ### Removed 99 | - `dist` folder is removed from source. It is generated only when publishing to NPM now. 100 | 101 | ## [2.7.2] 102 | ### Changed 103 | - Allowed components to be focused with `setFocus` even if they have `focusable={false}` 104 | 105 | ## [2.7.1] 106 | ### Added 107 | - `focusable` prop that enables component as a focusable target. Default is true. Usable when you need to temporarily disable focusable behaviour on the component. E.g. disabled button state. 108 | ### Changed 109 | - Moved `react` and `react-dom` to peer dependencies 110 | 111 | ## [2.6.0] 112 | ### Fixed 113 | - Key up triggers `.cancel()` instead of `.flush()` 114 | ### Added 115 | - Throttling now applies options to disable trailing functions 116 | 117 | ## [2.5.0] 118 | ### Fixed 119 | - Throttling is now only applied if the throttle option supplied was greater than 0 120 | ### Added 121 | - Key up now flushes any throttled input 122 | 123 | ## [2.4.0] 124 | ### Added 125 | - added support for `onArrowPress` property, it enables to add a custom behavior when arrows are pressed and can prevent the default navigation. 126 | 127 | ## [2.3.2] 128 | ### Fixed 129 | - Fixed an issue where the `lastFocusedChildKey` were not saved for all focusable parents when focus is jumping to another tree branch with `setFocus`. 130 | 131 | ## [2.3.1] 132 | ### Added 133 | - Added [throttle](https://github.com/NoriginMedia/react-spatial-navigation#initialization-config) property to throttle the function fired by the event listener. 134 | 135 | ## [2.3.0] 136 | ### Added 137 | - Added support for Native environment. Now if the service is initialized with `nativeMode` flag, it will skip creating window event listeners, measuring coordinates and other web-only features. It will still continue to register all focusable components and update `focused` flag on them. 138 | - Added new method `stealFocus` to `withFocusable` hoc. It works exactly the same as `setFocus` apart from that it doesn't care about arguments passed to this method. This is useful when binding it to a callback that passed some params back that you don't care about. 139 | 140 | ## [2.2.1] 141 | ### Changed 142 | - Improved the main navigation algorithm. Instead of calculating distance between center of the borders between 2 items in the direction of navigation, the new algorithm now prioritises the distance by the main coordinate and then takes into account the distance by the secondary coordinate. Inspired by this [algorithm](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox_OS_for_TV/TV_remote_control_navigation#Algorithm_design) 143 | 144 | ### Removed 145 | - Removed `propagateFocus` config option and prop for `withFocusable` HOC because it was always used for items with children items anyway 146 | 147 | ## [2.1.0] 148 | ### Added 149 | - Added more docs regarding preemptive `setFocus` on non-existent components 150 | - Added `preferredChildFocusKey` property to set focus on a specific component during focus propagation 151 | 152 | ### Changed 153 | - Save parent `lastFocusedChildKey` when a new component is focused 154 | 155 | ## [2.0.6] 156 | This release has few versions combined from v2.0.2. 157 | 158 | ### Removed 159 | - Implicit logic for setting focus to own focus key if target focus key component doesn't exist 160 | 161 | ### Changed 162 | - Optimized `onUpdateHasFocusChild` callback for `withFocusable` HOC. It is called only on components with `trackChildren` prop or config setting now 163 | - Updated docs to reflect the publishing to NPM 164 | 165 | ### Added 166 | - Published NPM package 167 | 168 | ## [2.0.2] 169 | ### Fixed 170 | - Orphan DOM nodes problem 171 | 172 | ## [2.0.1] 173 | ### Added 174 | - Added Debug and Visual debug modes 175 | 176 | ### Changed 177 | - Changed the way of how the sibling components are filtered in `smartNavigate` method. 178 | 179 | ## [2.0.0] 180 | ### Added 181 | - Added Documentation 182 | - Added this Changelog 183 | 184 | ### Changed 185 | - Refactored the way how the system stores the current focus key and updates changed components. Before it was stored in Context which caused performance bottleneck when each component got updated to compare Context current focus key with the each component's focus key. Now it is stored only in Spatial Navigation service and only 2 components are updated by directly calling state handlers on them. 186 | 187 | ### Removed 188 | - Removed Context 189 | 190 | ## [Older versions] 191 | Changelog not maintained. 192 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp */ 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import shuffle from 'lodash/shuffle'; 5 | import throttle from 'lodash/throttle'; 6 | import {View, Text, StyleSheet, TouchableOpacity, ScrollView} from 'react-native'; 7 | import withFocusable from './withFocusable'; 8 | import SpatialNavigation from './spatialNavigation'; 9 | 10 | SpatialNavigation.init({ 11 | debug: false, 12 | visualDebug: false 13 | }); 14 | 15 | // SpatialNavigation.setKeyMap(keyMap); -> Custom key map 16 | 17 | const KEY_ENTER = 'enter'; 18 | 19 | const styles = StyleSheet.create({ 20 | wrapper: { 21 | flex: 1, 22 | maxHeight: 400, 23 | maxWidth: 800, 24 | backgroundColor: '#333333', 25 | flexDirection: 'row' 26 | }, 27 | content: { 28 | flex: 1 29 | }, 30 | menu: { 31 | maxWidth: 60, 32 | flex: 1, 33 | alignItems: 'center', 34 | justifyContent: 'space-around' 35 | }, 36 | menuFocused: { 37 | backgroundColor: '#546e84' 38 | }, 39 | menuItem: { 40 | width: 50, 41 | height: 50, 42 | backgroundColor: '#f8f258' 43 | }, 44 | activeWrapper: { 45 | flex: 1, 46 | alignItems: 'center', 47 | justifyContent: 'center' 48 | }, 49 | activeProgram: { 50 | width: 160, 51 | height: 120 52 | }, 53 | activeProgramTitle: { 54 | padding: 20, 55 | color: 'white' 56 | }, 57 | programWrapper: { 58 | padding: 10, 59 | alignItems: 'center' 60 | }, 61 | program: { 62 | height: 100, 63 | width: 100 64 | }, 65 | programTitle: { 66 | color: 'white' 67 | }, 68 | categoryWrapper: { 69 | padding: 20 70 | }, 71 | categoryTitle: { 72 | color: 'white' 73 | }, 74 | categoriesWrapper: { 75 | flex: 1 76 | }, 77 | focusedBorder: { 78 | borderWidth: 6, 79 | borderColor: 'red', 80 | backgroundColor: 'white' 81 | } 82 | }); 83 | 84 | const categories = shuffle([{ 85 | title: 'Featured' 86 | }, { 87 | title: 'Cool' 88 | }, { 89 | title: 'Decent' 90 | }]); 91 | 92 | const programs = shuffle([{ 93 | title: 'Program 1', 94 | color: '#337fdd' 95 | }, { 96 | title: 'Program 2', 97 | color: '#dd4558' 98 | }, { 99 | title: 'Program 3', 100 | color: '#7ddd6a' 101 | }, { 102 | title: 'Program 4', 103 | color: '#dddd4d' 104 | }, { 105 | title: 'Program 5', 106 | color: '#8299dd' 107 | }, { 108 | title: 'Program 6', 109 | color: '#edab83' 110 | }, { 111 | title: 'Program 7', 112 | color: '#60ed9e' 113 | }, { 114 | title: 'Program 8', 115 | color: '#d15fb6' 116 | }, { 117 | title: 'Program 9', 118 | color: '#c0ee33' 119 | }]); 120 | 121 | const RETURN_KEY = 8; 122 | const B_KEY = 66; 123 | 124 | /* eslint-disable react/prefer-stateless-function */ 125 | class MenuItem extends React.PureComponent { 126 | render() { 127 | // console.log('Menu item rendered: ', this.props.realFocusKey); 128 | 129 | return (); 130 | } 131 | } 132 | 133 | MenuItem.propTypes = { 134 | focused: PropTypes.bool.isRequired 135 | 136 | // realFocusKey: PropTypes.string.isRequired 137 | }; 138 | 139 | const MenuItemFocusable = withFocusable()(MenuItem); 140 | 141 | class Menu extends React.PureComponent { 142 | constructor(props) { 143 | super(props); 144 | 145 | this.onPressKey = this.onPressKey.bind(this); 146 | } 147 | 148 | componentDidMount() { 149 | this.props.setFocus(); 150 | 151 | window.addEventListener('keydown', this.onPressKey); 152 | } 153 | 154 | componentWillUnmount() { 155 | window.removeEventListener('keydown', this.onPressKey); 156 | } 157 | 158 | onPressKey(event) { 159 | if (event.keyCode === RETURN_KEY) { 160 | this.props.setFocus(); 161 | } 162 | } 163 | 164 | render() { 165 | // console.log('Menu rendered: ', this.props.realFocusKey); 166 | 167 | return ( 168 | 169 | 170 | 171 | 172 | 173 | 174 | ); 175 | } 176 | } 177 | 178 | Menu.propTypes = { 179 | setFocus: PropTypes.func.isRequired, 180 | hasFocusedChild: PropTypes.bool.isRequired 181 | 182 | // realFocusKey: PropTypes.string.isRequired 183 | }; 184 | 185 | const MenuFocusable = withFocusable({ 186 | trackChildren: true 187 | })(Menu); 188 | 189 | class Content extends React.PureComponent { 190 | constructor(props) { 191 | super(props); 192 | 193 | this.state = { 194 | currentProgram: null, 195 | blockNavigationOut: false 196 | }; 197 | 198 | this.onPressKey = this.onPressKey.bind(this); 199 | this.onProgramPress = this.onProgramPress.bind(this); 200 | } 201 | 202 | componentDidMount() { 203 | window.addEventListener('keydown', this.onPressKey); 204 | } 205 | 206 | componentWillUnmount() { 207 | window.removeEventListener('keydown', this.onPressKey); 208 | } 209 | 210 | onPressKey(event) { 211 | if (event.keyCode === B_KEY) { 212 | const {blockNavigationOut: blocked} = this.state; 213 | 214 | console.warn(`blockNavigationOut: ${!blocked}. Press B to ${blocked ? 'block' : 'unblock '}`); 215 | this.setState((prevState) => ({blockNavigationOut: !prevState.blockNavigationOut})); 216 | } 217 | } 218 | 219 | onProgramPress(programProps, {pressedKeys} = {}) { 220 | if (pressedKeys && pressedKeys[KEY_ENTER] > 1) { 221 | return; 222 | } 223 | this.setState({ 224 | currentProgram: programProps 225 | }); 226 | } 227 | 228 | render() { 229 | const {blockNavigationOut} = this.state; 230 | 231 | // console.log('content rendered: ', this.props.realFocusKey); 232 | 233 | return ( 234 | 235 | 240 | ); 241 | } 242 | } 243 | 244 | Content.propTypes = { 245 | // realFocusKey: PropTypes.string.isRequired 246 | }; 247 | 248 | const ContentFocusable = withFocusable()(Content); 249 | 250 | class Active extends React.PureComponent { 251 | render() { 252 | const {program} = this.props; 253 | 254 | const style = { 255 | backgroundColor: program ? program.color : 'grey' 256 | }; 257 | 258 | return ( 259 | 260 | 261 | {program ? program.title : 'No Program'} 262 | 263 | ); 264 | } 265 | } 266 | 267 | Active.propTypes = { 268 | program: PropTypes.shape({ 269 | title: PropTypes.string.isRequired, 270 | color: PropTypes.string.isRequired 271 | }) 272 | }; 273 | 274 | Active.defaultProps = { 275 | program: null 276 | }; 277 | 278 | class Program extends React.PureComponent { 279 | render() { 280 | // console.log('Program rendered: ', this.props.realFocusKey); 281 | 282 | const {color, onPress, focused, title} = this.props; 283 | 284 | const style = { 285 | backgroundColor: color 286 | }; 287 | 288 | return ( 292 | 293 | 294 | {title} 295 | 296 | ); 297 | } 298 | } 299 | 300 | Program.propTypes = { 301 | title: PropTypes.string.isRequired, 302 | color: PropTypes.string.isRequired, 303 | onPress: PropTypes.func.isRequired, 304 | focused: PropTypes.bool.isRequired 305 | 306 | // realFocusKey: PropTypes.string.isRequired 307 | }; 308 | 309 | const ProgramFocusable = withFocusable()(Program); 310 | 311 | class Category extends React.PureComponent { 312 | constructor(props) { 313 | super(props); 314 | 315 | this.scrollRef = null; 316 | 317 | this.onProgramFocused = this.onProgramFocused.bind(this); 318 | this.onProgramArrowPress = this.onProgramArrowPress.bind(this); 319 | } 320 | 321 | onProgramFocused({x}) { 322 | this.scrollRef.scrollTo({ 323 | x 324 | }); 325 | } 326 | 327 | onProgramArrowPress(direction, {categoryIndex, programIndex}) { 328 | if (direction === 'right' && programIndex === programs.length - 1 && categoryIndex < categories.length - 1) { 329 | this.props.setFocus(`CATEGORY-${categoryIndex + 1}`); 330 | 331 | return false; 332 | } 333 | 334 | return true; 335 | } 336 | 337 | render() { 338 | // console.log('Category rendered: ', this.props.realFocusKey); 339 | 340 | return ( 341 | 342 | {this.props.title} 343 | 344 | { 347 | if (reference) { 348 | this.scrollRef = reference; 349 | } 350 | }} 351 | > 352 | {programs.map((program, index) => (( this.props.onProgramPress(program)} 356 | onEnterPress={this.props.onProgramPress} 357 | key={program.title} 358 | onBecameFocused={this.onProgramFocused} 359 | onArrowPress={this.onProgramArrowPress} 360 | programIndex={index} 361 | categoryIndex={this.props.categoryIndex} 362 | />)))} 363 | 364 | ); 365 | } 366 | } 367 | 368 | Category.propTypes = { 369 | title: PropTypes.string.isRequired, 370 | onProgramPress: PropTypes.func.isRequired, 371 | realFocusKey: PropTypes.string.isRequired, 372 | categoryIndex: PropTypes.number.isRequired, 373 | setFocus: PropTypes.func.isRequired 374 | }; 375 | 376 | const CategoryFocusable = withFocusable()(Category); 377 | 378 | class Categories extends React.PureComponent { 379 | constructor(props) { 380 | super(props); 381 | 382 | this.scrollRef = null; 383 | 384 | this.onCategoryFocused = this.onCategoryFocused.bind(this); 385 | } 386 | 387 | onCategoryFocused({y}) { 388 | this.scrollRef.scrollTo({ 389 | y 390 | }); 391 | } 392 | 393 | render() { 394 | // console.log('Categories rendered: ', this.props.realFocusKey); 395 | 396 | return ( { 398 | if (reference) { 399 | this.scrollRef = reference; 400 | } 401 | }} 402 | style={styles.categoriesWrapper} 403 | > 404 | {categories.map((category, index) => ())} 414 | ); 415 | } 416 | } 417 | 418 | Categories.propTypes = { 419 | onProgramPress: PropTypes.func.isRequired, 420 | realFocusKey: PropTypes.string.isRequired 421 | }; 422 | 423 | const CategoriesFocusable = withFocusable()(Categories); 424 | 425 | class Spatial extends React.PureComponent { 426 | constructor(props) { 427 | super(props); 428 | 429 | this.onWheel = this.onWheel.bind(this); 430 | this.throttledWheelHandler = throttle(this.throttledWheelHandler.bind(this), 500, {trailing: false}); 431 | } 432 | 433 | componentDidMount() { 434 | window.addEventListener('wheel', this.onWheel, {passive: false}); 435 | } 436 | 437 | componentWillUnmount() { 438 | window.removeEventListener('wheel', this.onWheel); 439 | } 440 | 441 | onWheel(event) { 442 | event.preventDefault(); 443 | this.throttledWheelHandler(event); 444 | } 445 | 446 | throttledWheelHandler(event) { 447 | event.preventDefault(); 448 | const {deltaY, deltaX} = event; 449 | const {navigateByDirection} = this.props; 450 | 451 | if (deltaY > 1) { 452 | navigateByDirection('down'); 453 | } else if (deltaY < 0) { 454 | navigateByDirection('up'); 455 | } else if (deltaX > 1) { 456 | navigateByDirection('right'); 457 | } else if (deltaX < 1) { 458 | navigateByDirection('left'); 459 | } 460 | } 461 | 462 | render() { 463 | return ( 464 | 467 | 470 | ); 471 | } 472 | } 473 | 474 | Spatial.propTypes = { 475 | navigateByDirection: PropTypes.func.isRequired 476 | }; 477 | 478 | const SpatialFocusable = withFocusable()(Spatial); 479 | 480 | const App = () => ( 481 | 482 | ); 483 | 484 | export default App; 485 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-spatial-navigation 2 | [![npm version](https://badge.fury.io/js/%40noriginmedia%2Freact-spatial-navigation.svg)](https://badge.fury.io/js/%40noriginmedia%2Freact-spatial-navigation) 3 | 4 | NOTE: This library is deprecated. 5 | 6 | Updated & new version of the Norigin Spatial Navigation open-source library (with React Hooks) can be found [here](https://github.com/NoriginMedia/Norigin-Spatial-Navigation). 7 | 8 | ## Motivation 9 | The main motivation to create this package was to bring the best Developer Experience and Performance when working with Key Navigation and React. Ideally you wouldn't want to have any logic to define the navigation in your app. It should be as easy as just to tell which components should be navigable. With this package all you have to do is to initialize it, wrap your components with the HOC and set initial focus. The spatial navigation system will automatically figure out which components to focus next when you navigate with the directional keys. 10 | 11 | ## Article 12 | [Smart TV Navigation with React](https://medium.com/norigintech/smart-tv-navigation-with-react-86bd5f3037b7) 13 | 14 | # Changelog 15 | [CHANGELOG.md](https://github.com/NoriginMedia/react-spatial-navigation/blob/master/CHANGELOG.md) 16 | 17 | # Table of Contents 18 | * [Example](#example) 19 | * [Installation](#installation) 20 | * [Usage](#usage) 21 | * [API](#api) 22 | * [Development](#development) 23 | * [TODOs](#todos) 24 | 25 | # Example 26 | ![Spatial Navigation example](resources/images/spatial-nav-example.gif) 27 | 28 | [Testbed Example](https://github.com/NoriginMedia/react-spatial-navigation/blob/master/src/App.js) 29 | 30 | # Installation 31 | ```bash 32 | npm i @noriginmedia/react-spatial-navigation --save 33 | ``` 34 | 35 | # Usage 36 | 37 | ## Initialization 38 | ```jsx 39 | // Somewhere at the root of the app 40 | 41 | import {initNavigation, setKeyMap} from '@noriginmedia/react-spatial-navigation'; 42 | 43 | initNavigation(); 44 | 45 | // Optional 46 | setKeyMap({ 47 | 'left': 9001, 48 | 'up': 9002, 49 | 'right': 9003, 50 | 'down': 9004, 51 | 'enter': 9005 52 | }); 53 | ``` 54 | 55 | ## Making component focusable 56 | ```jsx 57 | import {withFocusable} from '@noriginmedia/react-spatial-navigation'; 58 | 59 | ... 60 | 61 | const FocusableComponent = withFocusable()(Component); 62 | ``` 63 | 64 | ## Using config options 65 | ```jsx 66 | import {withFocusable} from '@noriginmedia/react-spatial-navigation'; 67 | 68 | ... 69 | 70 | const FocusableComponent = withFocusable({ 71 | trackChildren: true, 72 | forgetLastFocusedChild: true 73 | })(Component); 74 | ``` 75 | 76 | ## Using props on focusable components 77 | ```jsx 78 | import {withFocusable} from '@noriginmedia/react-spatial-navigation'; 79 | 80 | ... 81 | 82 | const FocusableComponent = withFocusable()(Component); 83 | 84 | const ParentComponent = (props) => ( 85 | ... 86 | 96 | ... 97 | ); 98 | ``` 99 | 100 | ## Using props inside wrapped components 101 | ### Basic usage 102 | ```jsx 103 | import {withFocusable} from '@noriginmedia/react-spatial-navigation'; 104 | 105 | const Component = ({focused, setFocus}) => ( 106 | 107 | { 109 | setFocus('SOME_ANOTHER_COMPONENT'); 110 | }} 111 | /> 112 | ); 113 | 114 | const FocusableComponent = withFocusable()(Component); 115 | ``` 116 | 117 | ### Setting initial focus on child component, tracking children 118 | ```jsx 119 | import React, {PureComponent} from 'react'; 120 | import {withFocusable} from '@noriginmedia/react-spatial-navigation'; 121 | 122 | ... 123 | 124 | class Menu extends PureComponent { 125 | componentDidMount() { 126 | // this.props.setFocus(); // If you need to focus first child automatically 127 | this.props.setFocus('MENU-6'); // If you need to focus specific item that you know focus key of 128 | } 129 | 130 | render() { 131 | return ( 132 | 133 | 134 | 135 | 136 | 137 | 138 | ); 139 | } 140 | } 141 | 142 | const MenuFocusable = withFocusable({ 143 | trackChildren: true 144 | })(Menu); 145 | ``` 146 | 147 | ### Using in Native environment 148 | Since in native environment the focus is controlled by the native engine, we can only "sync" with it by setting focus on the component itself when it gets focused. 149 | Native navigation system automatically converts all `Touchable` component to focusable components and enhances them with the callbacks such as `onFocus` and `onBlur`. 150 | Read more here: [React Native on TVs](https://facebook.github.io/react-native/docs/building-for-apple-tv). 151 | 152 | ```jsx 153 | import {withFocusable} from '@noriginmedia/react-spatial-navigation'; 154 | 155 | const Component = ({focused, stealFocus}) => ( 156 | 157 | 160 | ); 161 | 162 | const FocusableComponent = withFocusable()(Component); 163 | ``` 164 | 165 | # API 166 | 167 | ## Top level 168 | ### `initNavigation`: function 169 | Function that needs to be called to enable Spatial Navigation system and bind key event listeners. 170 | Accepts [Initialization Config](#initialization-config) as a param. 171 | 172 | ```jsx 173 | initNavigation({ 174 | debug: true, 175 | visualDebug: true 176 | }) 177 | ``` 178 | 179 | #### Initialization Config 180 | ##### `debug`: boolean 181 | Enable console debugging 182 | 183 | * **false (default)** 184 | * **true** 185 | 186 | ##### `visualDebug`: boolean 187 | Enable visual debugging (all layouts, reference points and siblings refernce points are printed on canvases) 188 | 189 | * **false (default)** 190 | * **true** 191 | 192 | ##### `nativeMode`: boolean 193 | Enable Native mode. It will block certain web-only functionality such as: 194 | - adding window key listeners 195 | - measuring DOM layout 196 | - `onBecameFocused` and `onBecameBlurred` callbacks doesn't return coordinates, but still has node ref to lazy measure layout 197 | - coordinates calculations when navigating 198 | - down-tree propagation 199 | - last focused child 200 | - preferred focus key 201 | 202 | Native mode should be only used to keep the tree of focusable components and to sync the `focused` flag to enable styling for focused components. 203 | In Native mode you can only `stealFocus` to some component to flag it as `focused`, normal `setFocus` method is blocked because it will not propagate to native layer. 204 | 205 | * **false (default)** 206 | * **true** 207 | 208 | ##### `throttle`: integer 209 | Enable to throttle the function fired by the event listener. 210 | 211 | * **0 (default)** 212 | 213 | ##### `throttleKeypresses`: boolean 214 | Prevent canceling of throttled events for individual key presses. Works only in combination with `throttle`. Useful when there are issues with the performance of handling rapidly firing navigational events. 215 | 216 | * **false (default)** 217 | 218 | ### `setKeyMap`: function 219 | Function to set custom key codes. 220 | ```jsx 221 | setKeyMap({ 222 | 'left': 9001, 223 | 'up': 9002, 224 | 'right': 9003, 225 | 'down': 9004, 226 | 'enter': 9005 227 | }); 228 | ``` 229 | 230 | ### `withFocusable`: function 231 | Main HOC wrapper function. Accepts [config](#config) as a param. 232 | ```jsx 233 | const FocusableComponent = withFocusable({...})(Component); 234 | ``` 235 | 236 | #### Config 237 | ##### `trackChildren`: boolean 238 | Determine whether to track when any child component is focused. Wrapped component can rely on `hasFocusedChild` prop when this mode is enabled. Otherwise `hasFocusedChild` will be always `false`. 239 | 240 | * **false (default)** - Disabled by default because it causes unnecessary render call when `hasFocusedChild` changes 241 | * **true** 242 | 243 | ##### `forgetLastFocusedChild`: boolean 244 | Determine whether this component should not remember the last focused child components. By default when focus goes away from the component and then it gets focused again, it will focus the last focused child. This functionality is enabled by default. 245 | 246 | * **false (default)** 247 | * **true** 248 | 249 | ##### `autoRestoreFocus`: boolean 250 | To determine whether parent component should focus the first available child component when currently focused child is unmounted. 251 | * **true (default)** 252 | * **false** 253 | 254 | ##### `blockNavigationOut`: boolean 255 | Disable the navigation out from the selected component. It can be useful when a user opens a popup (or screen) and you don't want to allow the user to focus other components outside this area. 256 | 257 | It doesn't block focus set programmatically by `setFocus`. 258 | * **false (default)** 259 | * **true** 260 | 261 | ## Props that can be applied to HOC 262 | All these props are optional. 263 | 264 | ### `trackChildren`: boolean 265 | Same as in [config](#config). 266 | 267 | ### `forgetLastFocusedChild`: boolean 268 | Same as in [config](#config). 269 | 270 | ### `autoRestoreFocus`: boolean 271 | Same as in [config](#config). 272 | 273 | ### `blockNavigationOut`: boolean 274 | Same as in [config](#config). 275 | 276 | ### `focusable`: boolean 277 | Determine whether this component should be focusable (in other words, whether it's *currently* participating in the spatial navigation tree). This allows a focusable component to be ignored as a navigation target despite being mounted (e.g. due to being off-screen, hidden, or temporarily disabled). 278 | 279 | Note that behaviour is undefined for trees of components in which an `focusable={false}` component has any `focusable={true}` components as descendants; it is recommended to ensure that all components in a given branch of the spatial navigation tree have a common `focusable` state. 280 | Also `focusable={false}` does not prevent component from being directly focused with `setFocus`. It only blocks "automatic" focus logic such as directional navigation, or focusing component as lastFocusedChild or preferredFocusChild. 281 | 282 | * **false** 283 | * **true (default)** 284 | 285 | ### `focusKey`: string 286 | String that is used as a component focus key. Should be **unique**, otherwise it will override previously stored component with the same focus key in the Spatial Navigation service storage of focusable components. If this is not specified, the focus key will be generated automatically. 287 | 288 | ### `onEnterPress`: function 289 | Callback function that is called when the item is currently focused and Enter (OK) key is pressed. 290 | 291 | ### `onEnterRelease`: function 292 | Callback function that is called when the item is currently focused and Enter (OK) key is released. 293 | 294 | Payload: 295 | 1. All the props passed to HOC is passed back to this callback. Useful to avoid creating callback functions during render. 296 | 2. [Details](#keydetails-object) - info about pressed keys 297 | 298 | ```jsx 299 | const onPress = ({prop1, prop2}, details) => {...}; 300 | const onRelease = ({prop1, prop2}) => {...}; 301 | ... 302 | 308 | ... 309 | ``` 310 | 311 | ### `onArrowPress`: function 312 | Callback function that is called when the item is currently focused and an arrow (LEFT, RIGHT, UP, DOWN) key is pressed. 313 | 314 | Payload: 315 | 1. The directional arrow (left, right, up, down): string 316 | 2. All the props passed to HOC is passed back to this callback. Useful to avoid creating callback functions during render. 317 | 3. [Details](#keydetails-object) - info about pressed keys 318 | 319 | Prevent default navigation: 320 | By returning `false` the default navigation behavior is prevented. 321 | 322 | ```jsx 323 | const onPress = (direction, {prop1, prop2}) => { 324 | ... 325 | return false; 326 | }; 327 | 328 | ... 329 | 334 | ... 335 | ``` 336 | 337 | ### `onBecameFocused`: function 338 | Callback function that is called when the item becomes focused directly or when any of the children components become focused. For example when you have nested tree of 5 focusable components, this callback will be called on every level of down-tree focus change. 339 | 340 | Payload: 341 | The first parameter is the component layout object. The second paramter is an object containing all the component props. The third parameter is a details object that was used when triggering the focus change, for example it contains the key event in case of arrow navigation. Useful to avoid creating callback functions during render. `x` and `y` are relative coordinates to parent DOM (**not the Focusable parent**) element. `left` and `top` are absolute coordinates on the screen. 342 | 343 | ```jsx 344 | const onFocused = ({width, height, x, y, top, left, node}, {prop1, prop2}, {event, other}) => {...}; 345 | 346 | ... 347 | 352 | ... 353 | ``` 354 | 355 | ### `onBecameBlurred`: function 356 | Callback function that is called when the item loses focus or when all the children components lose focus. For example when you have nested tree of 5 focusable components, this callback will be called on every level of down-tree focus change. 357 | 358 | Payload: 359 | The first parameter is the component layout object. The second paramter is an object containing all the component props. The third parameter is a details object that was used when triggering the focus change, for example it contains the key event in case of arrow navigation. Useful to avoid creating callback functions during render. `x` and `y` are relative coordinates to parent DOM (**not the Focusable parent**) element. `left` and `top` are absolute coordinates on the screen. 360 | 361 | ```jsx 362 | const onBlur = ({width, height, x, y, top, left, node}, {prop1, prop2}, {event, other}) => {...}; 363 | 364 | ... 365 | 370 | ... 371 | ``` 372 | 373 | ## Props passed to Wrapped Component 374 | ### `focusKey`: string 375 | Focus key that represents the focus key that was applied to HOC component. Might be `null` when not set. It is recommended to not rely on this prop ¯\\\_(ツ)_/¯ 376 | 377 | ### `realFocusKey`: string 378 | Focus key that is either the `focusKey` prop of the HOC, or automatically generated focus key like `sn:focusable-item-23`. 379 | 380 | ### `parentFocusKey`: string 381 | Focus key of the parent component. If it is a top level focusable component, this prop will be `SN:ROOT` 382 | 383 | ### `preferredChildFocusKey`: string 384 | Focus key of the child component focused during the focus propagation when the parent component is focused the first time or has `forgetLastFocusedChild` set 385 | 386 | ### `focused`: boolean 387 | Whether component is currently focused. It is only `true` if this exact component is focused, e.g. when this component propagates focus to child component, this value will be `false`. 388 | 389 | ### `hasFocusedChild`: boolean 390 | This prop indicates that the component currently has some focused child on any depth of the focusable tree. 391 | 392 | ### `setFocus`: function 393 | This method sets the focus to another component (when focus key is passed as param) or steals the focus to itself (when used w/o params). It is also possible to set focus to a non-existent component, and it will be automatically picked up when component with that focus key will get mounted. 394 | This preemptive setting of the focus might be useful when rendering lists of data. 395 | You can assign focus key with the item index and set it to e.g. first item, then as soon as it will be rendered, that item will get focused. 396 | In Native mode this method is ignored (`noop`). 397 | This method accepts a second parameter as a details object that will be passed back to the `onBecameFocused` and `onBecameBlurred` callbacks. 398 | 399 | ```jsx 400 | setFocus(); // set focus to self 401 | setFocus('SOME_COMPONENT', {event: keyEvent}); // set focus to another component if you know its focus key 402 | ``` 403 | ### `navigateByDirection`: function 404 | Move the focus by direction, if you can't use buttons or focusing by key. 405 | This method accepts a second parameter as a details object that will be passed back to the `onBecameFocused` and `onBecameBlurred` callbacks. 406 | 407 | ```jsx 408 | navigateByDirection('left'); // The focus is moved to left 409 | navigateByDirection('right'); // The focus is moved to right 410 | navigateByDirection('up'); // The focus is moved to up 411 | navigateByDirection('down', {event: keyEvent}); // The focus is moved to down 412 | ``` 413 | 414 | 415 | ### `stealFocus`: function 416 | This method works exactly like `setFocus`, but it always sets focus to current component no matter which params you pass in. 417 | This is the only way to set focus in Native mode. 418 | 419 | ```jsx 420 | 423 | ``` 424 | 425 | ### `pauseSpatialNavigation`: function 426 | This function pauses key listeners. Useful when you need to temporary disable navigation. (e.g. when player controls are hidden during video playback and you want to bind the keys to show controls again). 427 | 428 | ### `resumeSpatialNavigation`: function 429 | This function resumes key listeners if it was paused with [pauseSpatialNavigation](#pauseSpatialNavigation-function) 430 | 431 | ### `updateAllSpatialLayouts`: function 432 | This function update all components layouts. It can be used before setFocus is invoked, when the layouts have changed without being noticed by spatialNavigation service. 433 | 434 | ### Data Types 435 | 436 | ### `KeyDetails`: object 437 | This object contains informations about keys. 438 | ``` 439 | { 440 | pressedKeys: { 441 | [KEY]: number 442 | } 443 | } 444 | ``` 445 | `pressedKeys` contains a property for each pressed key in a given moment, the value is the number of keydown events fired before the keyup event. 446 | 447 | # Development 448 | ## Dev environment 449 | This library is using Parcel to serve the web build. 450 | 451 | To run the testbed app locally: 452 | ``` 453 | npm start 454 | ``` 455 | This will start local server on `localhost:1234` 456 | 457 | Source code is in `src/App.js` 458 | 459 | ## Dev notes 460 | ### General notes 461 | * Focusable component are stored as a Tree structure. Each component has the reference to its parent as `parentFocusKey`. 462 | * Current algorithm calculates distance between the border of the current component in the direction of key press to the border of the next component. 463 | 464 | ### `withFocusable` HOC 465 | * `realFocusKey` is created once at component mount in `withStateHandlers`. It either takes the `focusKey` prop value or is automatically generated. 466 | * `setFocus` method is bound with the current component `realFocusKey` so you can call it w/o params to focus component itself. Also the behaviour of this method can be described as an *attempt to set the focus*, because even if the target component doesn't exist yet, the target focus key will be stored and the focus will be picked up by the component with that focus key when it will get mounted. 467 | * `parentFocusKey` is propagated to children components through context. This is done because the focusable components tree is not necessary the same as the DOM tree. 468 | * On mount component adds itself to `spatialNavigation` service storage of all focusable components. 469 | * On unmount component removes itself from the service. 470 | 471 | ### `spatialNavigation` Service 472 | * New components are added in `addFocusable`and removed in `removeFocusable` 473 | * Main function to change focus in web environment is `setFocus`. First it decides next focus key (`getNextFocusKey`), then set focus to the new component (`setCurrentFocusedKey`), then the service updates all components that has focused child and finally updates layout (coordinates and dimensions) for all focusable component. 474 | * `getNextFocusKey` is used to determine the good candidate to focus when you call `setFocus`. This method will either return the target focus key for the component you are trying to focus, or go down by the focusable tree and select the best child component to focus. This function is recoursive and going down by the focusable tree. 475 | * `smartNavigate` is similar to the previous one, but is called in response to a key press event. It tries to focus the best sibling candidate in the direction of key press, or delegates this task to a focusable parent, that will do the same attempt for its sibling and so on. 476 | * In Native environment the only way to set focus is `stealFocus`. This service mostly works as a "sync" between native navigation system and JS to apply `focused` state and keep the tree structure of focusable components. All the layout and coordinates measurement features are disabled because native engine takes care of it. 477 | 478 | ## Contributing 479 | Please follow the [Contribution Guide](https://github.com/NoriginMedia/react-spatial-navigation/blob/master/CONTRIBUTING.md) 480 | 481 | # TODOs 482 | - [ ] Unit tests 483 | - [ ] Refactor with React Hooks instead of recompose. 484 | - [x] Native environment support 485 | - [x] Add custom navigation logic per component. I.e. possibility to override default decision making algorithm and decide where to navigate next based on direction. 486 | - [ ] Implement mouse support. On some TV devices (or in the Browser) it is possible to use mouse-like input, e.g. magic remote in LG TVs. This system should support switching between "key" and "pointer" modes and apply "focused" state accordingly. 487 | 488 | --- 489 | 490 | # License 491 | **MIT Licensed** 492 | -------------------------------------------------------------------------------- /src/spatialNavigation.js: -------------------------------------------------------------------------------- 1 | import filter from 'lodash/filter'; 2 | import first from 'lodash/first'; 3 | import sortBy from 'lodash/sortBy'; 4 | import findKey from 'lodash/findKey'; 5 | import forEach from 'lodash/forEach'; 6 | import forOwn from 'lodash/forOwn'; 7 | import lodashThrottle from 'lodash/throttle'; 8 | import difference from 'lodash/difference'; 9 | import measureLayout from './measureLayout'; 10 | import VisualDebugger from './visualDebugger'; 11 | 12 | export const ROOT_FOCUS_KEY = 'SN:ROOT'; 13 | 14 | const ADJACENT_SLICE_THRESHOLD = 0.2; 15 | 16 | /** 17 | * Adjacent slice is 5 times more important than diagonal 18 | */ 19 | const ADJACENT_SLICE_WEIGHT = 5; 20 | const DIAGONAL_SLICE_WEIGHT = 1; 21 | 22 | /** 23 | * Main coordinate distance is 5 times more important 24 | */ 25 | const MAIN_COORDINATE_WEIGHT = 5; 26 | 27 | const DIRECTION_LEFT = 'left'; 28 | const DIRECTION_RIGHT = 'right'; 29 | const DIRECTION_UP = 'up'; 30 | const DIRECTION_DOWN = 'down'; 31 | const KEY_ENTER = 'enter'; 32 | 33 | const DEFAULT_KEY_MAP = { 34 | [DIRECTION_LEFT]: 37, 35 | [DIRECTION_UP]: 38, 36 | [DIRECTION_RIGHT]: 39, 37 | [DIRECTION_DOWN]: 40, 38 | [KEY_ENTER]: 13 39 | }; 40 | 41 | const DEBUG_FN_COLORS = ['#0FF', '#FF0', '#F0F']; 42 | 43 | const THROTTLE_OPTIONS = { 44 | leading: true, 45 | trailing: false 46 | }; 47 | 48 | export const getChildClosestToOrigin = (children) => { 49 | const childrenClosestToOrigin = sortBy(children, ({layout}) => Math.abs(layout.left) + Math.abs(layout.top)); 50 | 51 | return first(childrenClosestToOrigin); 52 | }; 53 | 54 | /* eslint-disable no-nested-ternary */ 55 | class SpatialNavigation { 56 | /** 57 | * Used to determine the coordinate that will be used to filter items that are over the "edge" 58 | */ 59 | static getCutoffCoordinate(isVertical, isIncremental, isSibling, layout) { 60 | const itemX = layout.left; 61 | const itemY = layout.top; 62 | const itemWidth = layout.width; 63 | const itemHeight = layout.height; 64 | 65 | const coordinate = isVertical ? itemY : itemX; 66 | const itemSize = isVertical ? itemHeight : itemWidth; 67 | 68 | return isIncremental ? 69 | (isSibling ? coordinate : coordinate + itemSize) : 70 | (isSibling ? coordinate + itemSize : coordinate); 71 | } 72 | 73 | /** 74 | * Returns two corners (a and b) coordinates that are used as a reference points 75 | * Where "a" is always leftmost and topmost corner, and "b" is rightmost bottommost corner 76 | */ 77 | static getRefCorners(direction, isSibling, layout) { 78 | const itemX = layout.left; 79 | const itemY = layout.top; 80 | const itemWidth = layout.width; 81 | const itemHeight = layout.height; 82 | 83 | const result = { 84 | a: { 85 | x: 0, 86 | y: 0 87 | }, 88 | b: { 89 | x: 0, 90 | y: 0 91 | } 92 | }; 93 | 94 | switch (direction) { 95 | case DIRECTION_UP: { 96 | const y = isSibling ? itemY + itemHeight : itemY; 97 | 98 | result.a = { 99 | x: itemX, 100 | y 101 | }; 102 | 103 | result.b = { 104 | x: itemX + itemWidth, 105 | y 106 | }; 107 | 108 | break; 109 | } 110 | 111 | case DIRECTION_DOWN: { 112 | const y = isSibling ? itemY : itemY + itemHeight; 113 | 114 | result.a = { 115 | x: itemX, 116 | y 117 | }; 118 | 119 | result.b = { 120 | x: itemX + itemWidth, 121 | y 122 | }; 123 | 124 | break; 125 | } 126 | 127 | case DIRECTION_LEFT: { 128 | const x = isSibling ? itemX + itemWidth : itemX; 129 | 130 | result.a = { 131 | x, 132 | y: itemY 133 | }; 134 | 135 | result.b = { 136 | x, 137 | y: itemY + itemHeight 138 | }; 139 | 140 | break; 141 | } 142 | 143 | case DIRECTION_RIGHT: { 144 | const x = isSibling ? itemX : itemX + itemWidth; 145 | 146 | result.a = { 147 | x, 148 | y: itemY 149 | }; 150 | 151 | result.b = { 152 | x, 153 | y: itemY + itemHeight 154 | }; 155 | 156 | break; 157 | } 158 | 159 | default: 160 | break; 161 | } 162 | 163 | return result; 164 | } 165 | 166 | /** 167 | * Calculates if the sibling node is intersecting enough with the ref node by the secondary coordinate 168 | */ 169 | static isAdjacentSlice(refCorners, siblingCorners, isVerticalDirection) { 170 | const {a: refA, b: refB} = refCorners; 171 | const {a: siblingA, b: siblingB} = siblingCorners; 172 | const coordinate = isVerticalDirection ? 'x' : 'y'; 173 | 174 | const refCoordinateA = refA[coordinate]; 175 | const refCoordinateB = refB[coordinate]; 176 | const siblingCoordinateA = siblingA[coordinate]; 177 | const siblingCoordinateB = siblingB[coordinate]; 178 | 179 | const thresholdDistance = (refCoordinateB - refCoordinateA) * ADJACENT_SLICE_THRESHOLD; 180 | 181 | const intersectionLength = Math.max(0, Math.min(refCoordinateB, siblingCoordinateB) - 182 | Math.max(refCoordinateA, siblingCoordinateA)); 183 | 184 | return intersectionLength >= thresholdDistance; 185 | } 186 | 187 | static getPrimaryAxisDistance(refCorners, siblingCorners, isVerticalDirection) { 188 | const {a: refA} = refCorners; 189 | const {a: siblingA} = siblingCorners; 190 | const coordinate = isVerticalDirection ? 'y' : 'x'; 191 | 192 | return Math.abs(siblingA[coordinate] - refA[coordinate]); 193 | } 194 | 195 | static getSecondaryAxisDistance(refCorners, siblingCorners, isVerticalDirection) { 196 | const {a: refA, b: refB} = refCorners; 197 | const {a: siblingA, b: siblingB} = siblingCorners; 198 | const coordinate = isVerticalDirection ? 'x' : 'y'; 199 | 200 | const refCoordinateA = refA[coordinate]; 201 | const refCoordinateB = refB[coordinate]; 202 | const siblingCoordinateA = siblingA[coordinate]; 203 | const siblingCoordinateB = siblingB[coordinate]; 204 | 205 | const distancesToCompare = []; 206 | 207 | distancesToCompare.push(Math.abs(siblingCoordinateA - refCoordinateA)); 208 | distancesToCompare.push(Math.abs(siblingCoordinateA - refCoordinateB)); 209 | distancesToCompare.push(Math.abs(siblingCoordinateB - refCoordinateA)); 210 | distancesToCompare.push(Math.abs(siblingCoordinateB - refCoordinateB)); 211 | 212 | return Math.min(...distancesToCompare); 213 | } 214 | 215 | /** 216 | * Inspired by: https://developer.mozilla.org/en-US/docs/Mozilla/Firefox_OS_for_TV/TV_remote_control_navigation#Algorithm_design 217 | * Ref Corners are the 2 corners of the current component in the direction of navigation 218 | * They used as a base to measure adjacent slices 219 | */ 220 | sortSiblingsByPriority(siblings, currentLayout, direction, focusKey) { 221 | const isVerticalDirection = direction === DIRECTION_DOWN || direction === DIRECTION_UP; 222 | 223 | const refCorners = SpatialNavigation.getRefCorners(direction, false, currentLayout); 224 | 225 | return sortBy(siblings, (sibling) => { 226 | const siblingCorners = SpatialNavigation.getRefCorners(direction, true, sibling.layout); 227 | 228 | const isAdjacentSlice = SpatialNavigation.isAdjacentSlice(refCorners, siblingCorners, isVerticalDirection); 229 | 230 | const primaryAxisFunction = isAdjacentSlice ? 231 | SpatialNavigation.getPrimaryAxisDistance : 232 | SpatialNavigation.getSecondaryAxisDistance; 233 | 234 | const secondaryAxisFunction = isAdjacentSlice ? 235 | SpatialNavigation.getSecondaryAxisDistance : 236 | SpatialNavigation.getPrimaryAxisDistance; 237 | 238 | const primaryAxisDistance = primaryAxisFunction(refCorners, siblingCorners, isVerticalDirection); 239 | const secondaryAxisDistance = secondaryAxisFunction(refCorners, siblingCorners, isVerticalDirection); 240 | 241 | /** 242 | * The higher this value is, the less prioritised the candidate is 243 | */ 244 | const totalDistancePoints = (primaryAxisDistance * MAIN_COORDINATE_WEIGHT) + secondaryAxisDistance; 245 | 246 | /** 247 | * + 1 here is in case of distance is zero, but we still want to apply Adjacent priority weight 248 | */ 249 | const priority = (totalDistancePoints + 1) / (isAdjacentSlice ? ADJACENT_SLICE_WEIGHT : DIAGONAL_SLICE_WEIGHT); 250 | 251 | this.log( 252 | 'smartNavigate', 253 | `distance (primary, secondary, total weighted) for ${sibling.focusKey} relative to ${focusKey} is`, 254 | primaryAxisDistance, 255 | secondaryAxisDistance, 256 | totalDistancePoints 257 | ); 258 | 259 | this.log( 260 | 'smartNavigate', 261 | `priority for ${sibling.focusKey} relative to ${focusKey} is`, 262 | priority 263 | ); 264 | 265 | if (this.visualDebugger) { 266 | this.visualDebugger.drawPoint(siblingCorners.a.x, siblingCorners.a.y, 'yellow', 6); 267 | this.visualDebugger.drawPoint(siblingCorners.b.x, siblingCorners.b.y, 'yellow', 6); 268 | } 269 | 270 | return priority; 271 | }); 272 | } 273 | 274 | constructor() { 275 | /** 276 | * Storage for all focusable components 277 | */ 278 | this.focusableComponents = {}; 279 | 280 | /** 281 | * Storing current focused key 282 | */ 283 | this.focusKey = null; 284 | 285 | /** 286 | * This collection contains focus keys of the elements that are having a child focused 287 | * Might be handy for styling of certain parent components if their child is focused. 288 | */ 289 | this.parentsHavingFocusedChild = []; 290 | 291 | this.enabled = false; 292 | this.nativeMode = false; 293 | this.throttle = 0; 294 | this.throttleKeypresses = false; 295 | 296 | this.pressedKeys = {}; 297 | 298 | /** 299 | * Flag used to block key events from this service 300 | * @type {boolean} 301 | */ 302 | this.paused = false; 303 | 304 | this.keyDownEventListener = null; 305 | this.keyUpEventListener = null; 306 | this.keyMap = DEFAULT_KEY_MAP; 307 | 308 | this.onKeyEvent = this.onKeyEvent.bind(this); 309 | this.pause = this.pause.bind(this); 310 | this.resume = this.resume.bind(this); 311 | this.setFocus = this.setFocus.bind(this); 312 | this.updateAllLayouts = this.updateAllLayouts.bind(this); 313 | this.navigateByDirection = this.navigateByDirection.bind(this); 314 | this.init = this.init.bind(this); 315 | this.setKeyMap = this.setKeyMap.bind(this); 316 | 317 | this.debug = false; 318 | this.visualDebugger = null; 319 | 320 | this.logIndex = 0; 321 | } 322 | 323 | init({ 324 | debug: debug = false, 325 | visualDebug: visualDebug = false, 326 | nativeMode: nativeMode = false, 327 | throttle: throttle = 0, 328 | throttleKeypresses: throttleKeypresses = false 329 | } = {}) { 330 | if (!this.enabled) { 331 | this.enabled = true; 332 | this.nativeMode = nativeMode; 333 | this.throttleKeypresses = throttleKeypresses; 334 | 335 | this.debug = debug; 336 | 337 | if (!this.nativeMode) { 338 | if (Number.isInteger(throttle) && throttle > 0) { 339 | this.throttle = throttle; 340 | } 341 | this.bindEventHandlers(); 342 | if (visualDebug) { 343 | this.visualDebugger = new VisualDebugger(); 344 | this.startDrawLayouts(); 345 | } 346 | } 347 | } 348 | } 349 | 350 | startDrawLayouts() { 351 | const draw = () => { 352 | requestAnimationFrame(() => { 353 | this.visualDebugger.clearLayouts(); 354 | forOwn(this.focusableComponents, (component, focusKey) => { 355 | this.visualDebugger.drawLayout( 356 | component.layout, 357 | focusKey, 358 | component.parentFocusKey 359 | ); 360 | }); 361 | draw(); 362 | }); 363 | }; 364 | 365 | draw(); 366 | } 367 | 368 | destroy() { 369 | if (this.enabled) { 370 | this.enabled = false; 371 | this.nativeMode = false; 372 | this.throttle = 0; 373 | this.throttleKeypresses = false; 374 | this.focusKey = null; 375 | this.parentsHavingFocusedChild = []; 376 | this.focusableComponents = {}; 377 | this.paused = false; 378 | this.keyMap = DEFAULT_KEY_MAP; 379 | 380 | this.unbindEventHandlers(); 381 | } 382 | } 383 | 384 | getEventType(keyCode) { 385 | return findKey(this.getKeyMap(), (code) => keyCode === code); 386 | } 387 | 388 | bindEventHandlers() { 389 | // We check both because the React Native remote debugger implements window, but not window.addEventListener. 390 | if (typeof window !== 'undefined' && window.addEventListener) { 391 | this.keyDownEventListener = (event) => { 392 | if (this.paused === true) { 393 | return; 394 | } 395 | 396 | if (this.debug) { 397 | this.logIndex += 1; 398 | } 399 | 400 | const eventType = this.getEventType(event.keyCode); 401 | 402 | if (!eventType) { 403 | return; 404 | } 405 | 406 | this.pressedKeys[eventType] = this.pressedKeys[eventType] ? this.pressedKeys[eventType] + 1 : 1; 407 | 408 | event.preventDefault(); 409 | event.stopPropagation(); 410 | 411 | const details = { 412 | pressedKeys: this.pressedKeys 413 | }; 414 | 415 | if (eventType === KEY_ENTER && this.focusKey) { 416 | this.onEnterPress(details); 417 | 418 | return; 419 | } 420 | 421 | const preventDefaultNavigation = this.onArrowPress(eventType, details) === false; 422 | 423 | if (preventDefaultNavigation) { 424 | this.log('keyDownEventListener', 'default navigation prevented'); 425 | this.visualDebugger && this.visualDebugger.clear(); 426 | } else { 427 | this.onKeyEvent(event); 428 | } 429 | }; 430 | 431 | // Apply throttle only if the option we got is > 0 to avoid limiting the listener to every animation frame 432 | if (this.throttle) { 433 | this.keyDownEventListener = 434 | lodashThrottle(this.keyDownEventListener.bind(this), this.throttle, THROTTLE_OPTIONS); 435 | } 436 | 437 | // When throttling then make sure to only throttle key down and cancel any queued functions in case of key up 438 | this.keyUpEventListener = (event) => { 439 | const eventType = this.getEventType(event.keyCode); 440 | 441 | Reflect.deleteProperty(this.pressedKeys, eventType); 442 | 443 | if (this.throttle && !this.throttleKeypresses) { 444 | this.keyDownEventListener.cancel(); 445 | } 446 | 447 | if (eventType === KEY_ENTER && this.focusKey) { 448 | this.onEnterRelease(); 449 | } 450 | }; 451 | 452 | window.addEventListener('keyup', this.keyUpEventListener); 453 | window.addEventListener('keydown', this.keyDownEventListener); 454 | } 455 | } 456 | 457 | unbindEventHandlers() { 458 | // We check both because the React Native remote debugger implements window, but not window.removeEventListener. 459 | if (typeof window !== 'undefined' && window.removeEventListener) { 460 | window.removeEventListener('keydown', this.keyDownEventListener); 461 | this.keyDownEventListener = null; 462 | 463 | if (this.throttle) { 464 | window.removeEventListener('keyup', this.keyUpEventListener); 465 | this.keyUpEventListener = null; 466 | } 467 | } 468 | } 469 | 470 | onEnterPress(details) { 471 | const component = this.focusableComponents[this.focusKey]; 472 | 473 | /* Guard against last-focused component being unmounted at time of onEnterPress (e.g due to UI fading out) */ 474 | if (!component) { 475 | this.log('onEnterPress', 'noComponent'); 476 | 477 | return; 478 | } 479 | 480 | /* Suppress onEnterPress if the last-focused item happens to lose its 'focused' status. */ 481 | if (!component.focusable) { 482 | this.log('onEnterPress', 'componentNotFocusable'); 483 | 484 | return; 485 | } 486 | 487 | component.onEnterPressHandler && component.onEnterPressHandler(details); 488 | } 489 | 490 | onEnterRelease() { 491 | const component = this.focusableComponents[this.focusKey]; 492 | 493 | /* Guard against last-focused component being unmounted at time of onEnterRelease (e.g due to UI fading out) */ 494 | if (!component) { 495 | this.log('onEnterRelease', 'noComponent'); 496 | 497 | return; 498 | } 499 | 500 | /* Suppress onEnterRelease if the last-focused item happens to lose its 'focused' status. */ 501 | if (!component.focusable) { 502 | this.log('onEnterRelease', 'componentNotFocusable'); 503 | 504 | return; 505 | } 506 | 507 | component.onEnterReleaseHandler && component.onEnterReleaseHandler(); 508 | } 509 | 510 | onArrowPress(...args) { 511 | const component = this.focusableComponents[this.focusKey]; 512 | 513 | /* Guard against last-focused component being unmounted at time of onArrowPress (e.g due to UI fading out) */ 514 | if (!component) { 515 | this.log('onArrowPress', 'noComponent'); 516 | 517 | return undefined; 518 | } 519 | 520 | /* It's okay to navigate AWAY from an item that has lost its 'focused' status, so we don't inspect 521 | * component.focusable. */ 522 | 523 | return component && component.onArrowPressHandler && component.onArrowPressHandler(...args); 524 | } 525 | 526 | /** 527 | * Move focus by direction, if you can't use buttons or focusing by key. 528 | * 529 | * @param {string} direction 530 | * @param {object} details 531 | * 532 | * @example 533 | * navigateByDirection('right') // The focus is moved to right 534 | */ 535 | navigateByDirection(direction, details = {}) { 536 | if (this.paused === true) { 537 | return; 538 | } 539 | 540 | const validDirections = [DIRECTION_DOWN, DIRECTION_UP, DIRECTION_LEFT, DIRECTION_RIGHT]; 541 | 542 | if (validDirections.includes(direction)) { 543 | this.log('navigateByDirection', 'direction', direction); 544 | this.smartNavigate(direction, null, details); 545 | } else { 546 | this.log( 547 | 'navigateByDirection', 548 | `Invalid direction. You passed: \`${direction}\`, but you can use only these: `, 549 | validDirections 550 | ); 551 | } 552 | } 553 | 554 | onKeyEvent(event) { 555 | this.visualDebugger && this.visualDebugger.clear(); 556 | 557 | const direction = findKey(this.getKeyMap(), (code) => event.keyCode === code); 558 | 559 | this.smartNavigate(direction, null, {event}); 560 | } 561 | 562 | /** 563 | * This function navigates between siblings OR goes up by the Tree 564 | * Based on the Direction 565 | */ 566 | smartNavigate(direction, fromParentFocusKey, details) { 567 | this.log('smartNavigate', 'direction', direction); 568 | this.log('smartNavigate', 'fromParentFocusKey', fromParentFocusKey); 569 | this.log('smartNavigate', 'this.focusKey', this.focusKey); 570 | 571 | if (!this.nativeMode && !fromParentFocusKey) { 572 | forOwn(this.focusableComponents, (component) => { 573 | component.layoutUpdated = false; 574 | }); 575 | } 576 | 577 | const currentComponent = this.focusableComponents[fromParentFocusKey || this.focusKey]; 578 | 579 | this.log( 580 | 'smartNavigate', 'currentComponent', 581 | currentComponent ? currentComponent.focusKey : undefined, 582 | currentComponent ? currentComponent.node : undefined 583 | ); 584 | 585 | if (currentComponent) { 586 | this.updateLayout(currentComponent.focusKey); 587 | const {parentFocusKey, focusKey, layout} = currentComponent; 588 | 589 | const isVerticalDirection = direction === DIRECTION_DOWN || direction === DIRECTION_UP; 590 | const isIncrementalDirection = direction === DIRECTION_DOWN || direction === DIRECTION_RIGHT; 591 | 592 | const currentCutoffCoordinate = SpatialNavigation.getCutoffCoordinate( 593 | isVerticalDirection, 594 | isIncrementalDirection, 595 | false, 596 | layout 597 | ); 598 | 599 | /** 600 | * Get only the siblings with the coords on the way of our moving direction 601 | */ 602 | const siblings = filter(this.focusableComponents, (component) => { 603 | if (component.parentFocusKey === parentFocusKey && component.focusable) { 604 | this.updateLayout(component.focusKey); 605 | const siblingCutoffCoordinate = SpatialNavigation.getCutoffCoordinate( 606 | isVerticalDirection, 607 | isIncrementalDirection, 608 | true, 609 | component.layout 610 | ); 611 | 612 | return isIncrementalDirection ? 613 | siblingCutoffCoordinate >= currentCutoffCoordinate : 614 | siblingCutoffCoordinate <= currentCutoffCoordinate; 615 | } 616 | 617 | return false; 618 | }); 619 | 620 | if (this.debug) { 621 | this.log('smartNavigate', 'currentCutoffCoordinate', currentCutoffCoordinate); 622 | this.log( 623 | 'smartNavigate', 'siblings', `${siblings.length} elements:`, 624 | siblings.map((sibling) => sibling.focusKey).join(', '), 625 | siblings.map((sibling) => sibling.node) 626 | ); 627 | } 628 | 629 | if (this.visualDebugger) { 630 | const refCorners = SpatialNavigation.getRefCorners(direction, false, layout); 631 | 632 | this.visualDebugger.drawPoint(refCorners.a.x, refCorners.a.y); 633 | this.visualDebugger.drawPoint(refCorners.b.x, refCorners.b.y); 634 | } 635 | 636 | const sortedSiblings = this.sortSiblingsByPriority( 637 | siblings, 638 | layout, 639 | direction, 640 | focusKey 641 | ); 642 | 643 | const nextComponent = first(sortedSiblings); 644 | 645 | this.log( 646 | 'smartNavigate', 'nextComponent', 647 | nextComponent ? nextComponent.focusKey : undefined, 648 | nextComponent ? nextComponent.node : undefined 649 | ); 650 | 651 | if (nextComponent) { 652 | this.setFocus(nextComponent.focusKey, null, details); 653 | } else { 654 | const parentComponent = this.focusableComponents[parentFocusKey]; 655 | 656 | this.saveLastFocusedChildKey(parentComponent, focusKey); 657 | 658 | if (!parentComponent || !parentComponent.blockNavigationOut) { 659 | this.smartNavigate(direction, parentFocusKey, details); 660 | } 661 | } 662 | } 663 | } 664 | 665 | saveLastFocusedChildKey(component, focusKey) { 666 | if (component) { 667 | this.log('saveLastFocusedChildKey', `${component.focusKey} lastFocusedChildKey set`, focusKey); 668 | component.lastFocusedChildKey = focusKey; 669 | } 670 | } 671 | 672 | log(functionName, debugString, ...rest) { 673 | if (this.debug) { 674 | console.log( 675 | `%c${functionName}%c${debugString}`, 676 | `background: ${DEBUG_FN_COLORS[this.logIndex % DEBUG_FN_COLORS.length]}; color: black; padding: 1px 5px;`, 677 | 'background: #333; color: #BADA55; padding: 1px 5px;', 678 | ...rest 679 | ); 680 | } 681 | } 682 | 683 | /** 684 | * This function tries to determine the next component to Focus 685 | * It's either the target node OR the one down by the Tree if node has children components 686 | * Based on "targetFocusKey" that means the "intended component to focus" 687 | */ 688 | getNextFocusKey(targetFocusKey) { 689 | const targetComponent = this.focusableComponents[targetFocusKey]; 690 | 691 | /** 692 | * Security check, if component doesn't exist, stay on the same focusKey 693 | */ 694 | if (!targetComponent || this.nativeMode) { 695 | return targetFocusKey; 696 | } 697 | 698 | const children = filter( 699 | this.focusableComponents, 700 | (component) => component.parentFocusKey === targetFocusKey && component.focusable 701 | ); 702 | 703 | if (children.length > 0) { 704 | const {lastFocusedChildKey, preferredChildFocusKey} = targetComponent; 705 | 706 | this.log('getNextFocusKey', 'lastFocusedChildKey is', lastFocusedChildKey); 707 | this.log('getNextFocusKey', 'preferredChildFocusKey is', preferredChildFocusKey); 708 | 709 | /** 710 | * First of all trying to focus last focused child 711 | */ 712 | if (lastFocusedChildKey && 713 | !targetComponent.forgetLastFocusedChild && 714 | this.isParticipatingFocusableComponent(lastFocusedChildKey) 715 | ) { 716 | this.log('getNextFocusKey', 'lastFocusedChildKey will be focused', lastFocusedChildKey); 717 | 718 | return this.getNextFocusKey(lastFocusedChildKey); 719 | } 720 | 721 | /** 722 | * If there is no lastFocusedChild, trying to focus the preferred focused key 723 | */ 724 | if (preferredChildFocusKey && this.isParticipatingFocusableComponent(preferredChildFocusKey)) { 725 | this.log('getNextFocusKey', 'preferredChildFocusKey will be focused', preferredChildFocusKey); 726 | 727 | return this.getNextFocusKey(preferredChildFocusKey); 728 | } 729 | 730 | /** 731 | * Otherwise, trying to focus something by coordinates 732 | */ 733 | children.forEach((component) => this.updateLayout(component.focusKey)); 734 | const {focusKey: childKey} = getChildClosestToOrigin(children); 735 | 736 | this.log('getNextFocusKey', 'childKey will be focused', childKey); 737 | 738 | return this.getNextFocusKey(childKey); 739 | } 740 | 741 | /** 742 | * If no children, just return targetFocusKey back 743 | */ 744 | this.log('getNextFocusKey', 'targetFocusKey', targetFocusKey); 745 | 746 | return targetFocusKey; 747 | } 748 | 749 | addFocusable({ 750 | focusKey, 751 | node, 752 | parentFocusKey, 753 | onEnterPressHandler, 754 | onEnterReleaseHandler, 755 | onArrowPressHandler, 756 | onBecameFocusedHandler, 757 | onBecameBlurredHandler, 758 | forgetLastFocusedChild, 759 | trackChildren, 760 | onUpdateFocus, 761 | onUpdateHasFocusedChild, 762 | preferredChildFocusKey, 763 | autoRestoreFocus, 764 | focusable, 765 | blockNavigationOut 766 | }) { 767 | this.focusableComponents[focusKey] = { 768 | focusKey, 769 | node, 770 | parentFocusKey, 771 | onEnterPressHandler, 772 | onEnterReleaseHandler, 773 | onArrowPressHandler, 774 | onBecameFocusedHandler, 775 | onBecameBlurredHandler, 776 | onUpdateFocus, 777 | onUpdateHasFocusedChild, 778 | forgetLastFocusedChild, 779 | trackChildren, 780 | lastFocusedChildKey: null, 781 | preferredChildFocusKey, 782 | focusable, 783 | blockNavigationOut, 784 | autoRestoreFocus, 785 | layout: { 786 | x: 0, 787 | y: 0, 788 | width: 0, 789 | height: 0, 790 | left: 0, 791 | top: 0, 792 | 793 | /** 794 | * Node ref is also duplicated in layout to be reported in onBecameFocused callback 795 | * E.g. used in native environments to lazy-measure the layout on focus 796 | */ 797 | node 798 | }, 799 | layoutUpdated: false 800 | }; 801 | 802 | if (this.nativeMode) { 803 | return; 804 | } 805 | 806 | this.updateLayout(focusKey); 807 | 808 | /** 809 | * If for some reason this component was already focused before it was added, call the update 810 | */ 811 | if (focusKey === this.focusKey) { 812 | this.setFocus(focusKey); 813 | } 814 | } 815 | 816 | removeFocusable({focusKey}) { 817 | const componentToRemove = this.focusableComponents[focusKey]; 818 | 819 | if (componentToRemove) { 820 | const {parentFocusKey} = componentToRemove; 821 | 822 | Reflect.deleteProperty(this.focusableComponents, focusKey); 823 | 824 | const parentComponent = this.focusableComponents[parentFocusKey]; 825 | const isFocused = focusKey === this.focusKey; 826 | 827 | /** 828 | * If the component was stored as lastFocusedChild, clear lastFocusedChildKey from parent 829 | */ 830 | parentComponent && parentComponent.lastFocusedChildKey === focusKey && 831 | (parentComponent.lastFocusedChildKey = null); 832 | 833 | if (this.nativeMode) { 834 | return; 835 | } 836 | 837 | /** 838 | * If the component was also focused at this time, focus another one 839 | */ 840 | if (isFocused && parentComponent && parentComponent.autoRestoreFocus) { 841 | this.setFocus(parentFocusKey); 842 | } 843 | } 844 | } 845 | 846 | getNodeLayoutByFocusKey(focusKey) { 847 | const component = this.focusableComponents[focusKey]; 848 | 849 | if (component) { 850 | this.updateLayout(component.focusKey); 851 | 852 | return component.layout; 853 | } 854 | 855 | return null; 856 | } 857 | 858 | setCurrentFocusedKey(newFocusKey, details) { 859 | if (this.isFocusableComponent(this.focusKey) && newFocusKey !== this.focusKey) { 860 | const oldComponent = this.focusableComponents[this.focusKey]; 861 | const parentComponent = this.focusableComponents[oldComponent.parentFocusKey]; 862 | 863 | this.saveLastFocusedChildKey(parentComponent, this.focusKey); 864 | 865 | oldComponent.onUpdateFocus(false); 866 | oldComponent.onBecameBlurredHandler(this.getNodeLayoutByFocusKey(this.focusKey), details); 867 | } 868 | 869 | this.focusKey = newFocusKey; 870 | 871 | if (this.isFocusableComponent(this.focusKey)) { 872 | const newComponent = this.focusableComponents[this.focusKey]; 873 | 874 | newComponent.onUpdateFocus(true); 875 | newComponent.onBecameFocusedHandler(this.getNodeLayoutByFocusKey(this.focusKey), details); 876 | } 877 | } 878 | 879 | updateParentsHasFocusedChild(focusKey, details) { 880 | const parents = []; 881 | 882 | let currentComponent = this.focusableComponents[focusKey]; 883 | 884 | /** 885 | * Recursively iterate the tree up and find all the parents' focus keys 886 | */ 887 | while (currentComponent) { 888 | const {parentFocusKey} = currentComponent; 889 | 890 | const parentComponent = this.focusableComponents[parentFocusKey]; 891 | 892 | if (parentComponent) { 893 | const {focusKey: currentParentFocusKey} = parentComponent; 894 | 895 | parents.push(currentParentFocusKey); 896 | } 897 | 898 | currentComponent = parentComponent; 899 | } 900 | 901 | const parentsToRemoveFlag = difference(this.parentsHavingFocusedChild, parents); 902 | const parentsToAddFlag = difference(parents, this.parentsHavingFocusedChild); 903 | 904 | forEach(parentsToRemoveFlag, (parentFocusKey) => { 905 | const parentComponent = this.focusableComponents[parentFocusKey]; 906 | 907 | parentComponent && parentComponent.trackChildren && parentComponent.onUpdateHasFocusedChild(false); 908 | this.onIntermediateNodeBecameBlurred(parentFocusKey, details); 909 | }); 910 | 911 | forEach(parentsToAddFlag, (parentFocusKey) => { 912 | const parentComponent = this.focusableComponents[parentFocusKey]; 913 | 914 | parentComponent && parentComponent.trackChildren && parentComponent.onUpdateHasFocusedChild(true); 915 | this.onIntermediateNodeBecameFocused(parentFocusKey, details); 916 | }); 917 | 918 | this.parentsHavingFocusedChild = parents; 919 | } 920 | 921 | updateParentsLastFocusedChild(focusKey) { 922 | let currentComponent = this.focusableComponents[focusKey]; 923 | 924 | /** 925 | * Recursively iterate the tree up and update all the parent's lastFocusedChild 926 | */ 927 | while (currentComponent) { 928 | const {parentFocusKey} = currentComponent; 929 | 930 | const parentComponent = this.focusableComponents[parentFocusKey]; 931 | 932 | if (parentComponent) { 933 | this.saveLastFocusedChildKey(parentComponent, currentComponent.focusKey); 934 | } 935 | 936 | currentComponent = parentComponent; 937 | } 938 | } 939 | 940 | getKeyMap() { 941 | return this.keyMap; 942 | } 943 | 944 | setKeyMap(keyMap) { 945 | this.keyMap = { 946 | ...this.getKeyMap(), 947 | ...keyMap 948 | }; 949 | } 950 | 951 | isFocusableComponent(focusKey) { 952 | return !!this.focusableComponents[focusKey]; 953 | } 954 | 955 | /** 956 | * Checks whether the focusableComponent is actually participating in spatial navigation (in other words, is a 957 | * 'focusable' focusableComponent). Seems less confusing than calling it isFocusableFocusableComponent() 958 | */ 959 | isParticipatingFocusableComponent(focusKey) { 960 | return this.isFocusableComponent(focusKey) && this.focusableComponents[focusKey].focusable; 961 | } 962 | 963 | onIntermediateNodeBecameFocused(focusKey, details) { 964 | this.isParticipatingFocusableComponent(focusKey) && 965 | this.focusableComponents[focusKey].onBecameFocusedHandler(this.getNodeLayoutByFocusKey(focusKey), details); 966 | } 967 | 968 | onIntermediateNodeBecameBlurred(focusKey, details) { 969 | this.isParticipatingFocusableComponent(focusKey) && 970 | this.focusableComponents[focusKey].onBecameBlurredHandler(this.getNodeLayoutByFocusKey(focusKey), details); 971 | } 972 | 973 | pause() { 974 | this.paused = true; 975 | } 976 | 977 | resume() { 978 | this.paused = false; 979 | } 980 | 981 | setFocus(focusKey, overwriteFocusKey, details = {}) { 982 | if (!this.enabled) { 983 | return; 984 | } 985 | 986 | const targetFocusKey = overwriteFocusKey || focusKey; 987 | 988 | this.log('setFocus', 'targetFocusKey', targetFocusKey); 989 | 990 | const lastFocusedKey = this.focusKey; 991 | const newFocusKey = this.getNextFocusKey(targetFocusKey); 992 | 993 | this.log('setFocus', 'newFocusKey', newFocusKey); 994 | 995 | this.setCurrentFocusedKey(newFocusKey, details); 996 | this.updateParentsHasFocusedChild(newFocusKey, details); 997 | this.updateParentsLastFocusedChild(lastFocusedKey); 998 | } 999 | 1000 | updateAllLayouts() { 1001 | if (this.nativeMode) { 1002 | return; 1003 | } 1004 | 1005 | forOwn(this.focusableComponents, (component, focusKey) => { 1006 | this.updateLayout(focusKey); 1007 | }); 1008 | } 1009 | 1010 | updateLayout(focusKey) { 1011 | const component = this.focusableComponents[focusKey]; 1012 | 1013 | if (!component || this.nativeMode || component.layoutUpdated) { 1014 | return; 1015 | } 1016 | 1017 | const {node} = component; 1018 | 1019 | measureLayout(node, (x, y, width, height, left, top) => { 1020 | component.layout = { 1021 | x, 1022 | y, 1023 | width, 1024 | height, 1025 | left, 1026 | top, 1027 | node 1028 | }; 1029 | }); 1030 | } 1031 | 1032 | updateFocusable(focusKey, {node, preferredChildFocusKey, focusable, blockNavigationOut}) { 1033 | if (this.nativeMode) { 1034 | return; 1035 | } 1036 | 1037 | const component = this.focusableComponents[focusKey]; 1038 | 1039 | if (component) { 1040 | component.preferredChildFocusKey = preferredChildFocusKey; 1041 | component.focusable = focusable; 1042 | component.blockNavigationOut = blockNavigationOut; 1043 | 1044 | if (node) { 1045 | component.node = node; 1046 | } 1047 | } 1048 | } 1049 | 1050 | isNativeMode() { 1051 | return this.nativeMode; 1052 | } 1053 | } 1054 | 1055 | /** 1056 | * Export singleton 1057 | */ 1058 | export default new SpatialNavigation(); 1059 | --------------------------------------------------------------------------------