├── .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 | [](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 | 
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 |
--------------------------------------------------------------------------------