├── .babelrc
├── .gitignore
├── LICENSE
├── README.md
├── examples
└── youtube-react-tv
│ ├── .gitignore
│ ├── README.md
│ ├── example.gif
│ ├── index.html
│ ├── package.json
│ ├── src
│ ├── App.js
│ ├── List.js
│ ├── Search.js
│ └── Sidebar.js
│ ├── style.css
│ └── webpack.config.js
├── package.json
├── src
├── Focusable.jsx
├── Grid.jsx
├── HorizontalList.jsx
├── Navigation.jsx
├── VerticalList.jsx
└── index.js
└── webpack.config.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["stage-2", "env"],
3 | "plugins": [
4 | "transform-object-rest-spread",
5 | "transform-react-jsx",
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /build
3 | npm-debug.log
4 | yarn*
5 |
6 | \.DS_Store
7 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017-Present Gustavo Bennemann
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-key-navigation
2 | Similar to the "[Focus Management](http://bbc.github.io/tal/widgets/focus-management.html)" of the [BBC TAL](http://bbc.github.io/tal/).
3 |
4 | ## WIP
5 | - [x] Focusable Component
6 | - [x] onFocus
7 | - [x] onBlur
8 | - [x] onEnterDown
9 | - [x] Grid
10 | - [x] Horizontal List
11 | - [x] Vertical List
12 | - [ ] Horizontal List Scrollable
13 | - [ ] Vertical List Scrollable
14 | - [ ] Use Higher-Order Components?
15 | - [ ] Tests
16 | - [ ] Examples
17 | - [ ] Documentation
18 |
19 | ## Example
20 | ```jsx
21 | import React, { Component } from 'react';
22 | import ReactDOM from 'react-dom';
23 | import Navigation, {VerticalList, HorizontalList, Grid, Focusable} from 'react-key-navigation';
24 |
25 | class App extends Component {
26 | render() {
27 | return (
28 |
29 |
30 | {Array.from(Array(10000).keys()).map((v, i) => {
31 | return (
32 | console.log('focus ' + i)} onBlur={() => console.log('blur ' + i)} onEnterDown={() => console.log('enter ' + i)}>
33 | Element {i}
34 |
35 | );
36 | })}
37 |
38 |
39 | );
40 | }
41 | }
42 |
43 | ReactDOM.render(, document.getElementById('root'));
44 | ```
45 |
--------------------------------------------------------------------------------
/examples/youtube-react-tv/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | *.log
3 |
4 | node_modules
5 | package-lock.json
6 | yarn.lock
7 | bundle.js
8 | react-tv
9 |
--------------------------------------------------------------------------------
/examples/youtube-react-tv/README.md:
--------------------------------------------------------------------------------
1 | # youtube-react-tv
2 |
3 | An example of a youtube application using react-key-navigation and react-tv.
4 |
5 | Layout from Luke Chang js-spatial-navigation library.
6 |
7 | 
8 |
9 | ### Running
10 |
11 | Clone this repo.
12 |
13 | `> npm install`
14 |
15 | `> react-tv init`
16 |
17 | To run the example in the webOS emulator:
18 | `> react-tv run-webos`
19 |
20 | Or in the browser: `> npm run start-dev`
21 |
--------------------------------------------------------------------------------
/examples/youtube-react-tv/example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dead/react-key-navigation/8f2279c7ba90692c160109fabeb269a191c8e502/examples/youtube-react-tv/example.gif
--------------------------------------------------------------------------------
/examples/youtube-react-tv/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | youtube-react-tv
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/examples/youtube-react-tv/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "youtube-react-tv",
3 | "version": "0.1.0",
4 | "license": "MIT",
5 | "react-tv": {
6 | "files": [
7 | "index.html",
8 | "bundle.js",
9 | "style.css"
10 | ]
11 | },
12 | "scripts": {
13 | "build": "webpack",
14 | "start": "yarn build && react-tv run-webos",
15 | "start-dev": "webpack-dev-server --progress --colors"
16 | },
17 | "dependencies": {
18 | "randomstring": "^1.1.5",
19 | "react": "^16.0.0",
20 | "react-fontawesome": "^1.6.1",
21 | "react-key-navigation": "0.0.9",
22 | "react-tv": "^0.3.0-alpha.2"
23 | },
24 | "devDependencies": {
25 | "babel-core": "^6.4.5",
26 | "babel-loader": "^6.2.1",
27 | "babel-preset-env": "^1.6.1",
28 | "babel-preset-react": "^6.3.13",
29 | "css-loader": "^0.28.7",
30 | "style-loader": "^0.19.0",
31 | "webpack": "^1.12.12",
32 | "webpack-dev-server": "^1.12.1"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/examples/youtube-react-tv/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactTV from 'react-tv';
3 |
4 | import Sidebar from './Sidebar.js'
5 | import List from './List.js'
6 | import Search from './Search.js'
7 |
8 | import Navigation, { VerticalList, HorizontalList } from 'react-key-navigation'
9 |
10 | class ReactTVApp extends React.Component {
11 | constructor() {
12 | super();
13 |
14 | this.state = {
15 | active: null,
16 | }
17 |
18 | this.lists = ["Title 1", "Title 2", "Title 3", "Title 4"]
19 | }
20 |
21 | changeFocusTo(index) {
22 | this.setState({active: index});
23 | }
24 |
25 | onBlurLists() {
26 | this.setState({active: null});
27 | }
28 |
29 | render() {
30 | return (
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | this.onBlurLists()}>
39 | {this.lists.map((list, i) =>
40 | this.changeFocusTo(i)} visible={this.state.active !== null ? i >= this.state.active : true}/>
41 | )}
42 |
43 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 | }
51 |
52 | ReactTV.render(, document.querySelector('#root'));
53 |
--------------------------------------------------------------------------------
/examples/youtube-react-tv/src/List.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactTV from 'react-tv';
3 |
4 | import { Focusable, HorizontalList } from 'react-key-navigation';
5 |
6 | class ToogleItem extends React.Component {
7 | constructor() {
8 | super();
9 |
10 | this.state = {
11 | active: false
12 | }
13 | }
14 |
15 | render() {
16 | return (
17 | this.setState({active: true})}
18 | onBlur={() => this.setState({active: false})}>
19 |
20 |
21 | );
22 | }
23 | };
24 |
25 | export default class List extends React.Component {
26 | constructor() {
27 | super();
28 | this._lastFocus = null;
29 | }
30 |
31 | componentDidMount() {
32 | const width = (Math.floor(this.content.scrollWidth / this.content.clientWidth ) * this.content.clientWidth) + this.content.clientWidth + 20;
33 | if (this.content.getElementsByClassName('hz-list')[0]) {
34 | this.content.getElementsByClassName('hz-list')[0].style.width = width + 'px';
35 | }
36 | }
37 |
38 | onFocus(index) {
39 | if (this._lastFocus === index) {
40 | return;
41 | }
42 |
43 | if (this.props.onFocus) {
44 | this.props.onFocus();
45 | }
46 |
47 | if (this.content) {
48 | const items = this.content.getElementsByClassName('item');
49 | const offsetWidth = items[0].offsetWidth + 20;
50 | this.content.scrollLeft = offsetWidth * index;
51 | }
52 |
53 | this._lastFocus = index;
54 | }
55 |
56 | render() {
57 | return (
58 |
59 |
{this.props.title}
60 |
{ this.content = content}}>
61 | this.onFocus(index)}
64 | onBlur={() => { this._lastFocus = null }}>
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | );
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/examples/youtube-react-tv/src/Search.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactTV from 'react-tv';
3 |
4 | import { Focusable } from 'react-key-navigation'
5 |
6 | export default class Search extends React.Component {
7 | constructor() {
8 | super();
9 |
10 | this.state = {
11 | active: false
12 | };
13 | }
14 |
15 | onBlur() {
16 | this.setState({active: false});
17 | }
18 |
19 | onFocus() {
20 | this.setState({active: true});
21 | }
22 |
23 | onEnterDown(event, navigation) {
24 | console.log('enter pressed');
25 | navigation.forceFocus('sidebar');
26 | }
27 |
28 | render() {
29 | return (
30 | this.onFocus()} onBlur={() => this.onBlur()} onEnterDown={(e, n) => this.onEnterDown(e, n)} navDefault>
31 |
32 |
33 | );
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/examples/youtube-react-tv/src/Sidebar.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactTV from 'react-tv';
3 | import { Focusable, VerticalList } from 'react-key-navigation';
4 |
5 | class ToogleItem extends React.Component {
6 | constructor() {
7 | super();
8 |
9 | this.state = {
10 | active: false
11 | }
12 | }
13 |
14 | render() {
15 | return (
16 | this.setState({active: true})}
17 | onBlur={() => this.setState({active: false})}>
18 |
19 | {this.props.children}
20 |
21 |
22 | );
23 | }
24 | };
25 |
26 | export default class Sidebar extends React.Component {
27 | constructor() {
28 | super();
29 |
30 | this.state = {
31 | active: false
32 | }
33 | }
34 |
35 | setActive(status) {
36 | this.setState({active: status});
37 | }
38 |
39 | render() {
40 | return (
41 |
60 | );
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/examples/youtube-react-tv/style.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | padding: 0;
3 | margin: 0;
4 | height: 100%;
5 | background-color: #444;
6 | font-family: sans-serif;
7 | overflow: hidden;
8 | }
9 |
10 | :focus {
11 | outline: 0;
12 | }
13 |
14 | #container {
15 | height: 100%;
16 | }
17 |
18 | #sidebar {
19 | position: absolute;
20 | left: -300px;
21 | top: 0;
22 | width: 370px;
23 | height: 100%;
24 | box-shadow: 2px 0 20px 0 black;
25 | display: flex;
26 | align-items: center;
27 | justify-content: flex-end;
28 | transition: left 0.5s, background-color 0.3s;
29 | background-color: rgba(255, 255, 255, 0.1);
30 | z-index: 100;
31 | }
32 |
33 | #sidebar:hover {
34 | background-color: rgba(255, 255, 255, 0.2);
35 | }
36 |
37 | #sidebar.focused {
38 | left: 0;
39 | }
40 |
41 | #icons {
42 | margin-right: 22px;
43 | transition: opacity 0.5s;
44 | z-index: 10;
45 | }
46 |
47 | #icons div {
48 | text-align: center;
49 | margin: 30px 0;
50 | }
51 |
52 | #icons .fa {
53 | color: #ccc;
54 | font-size: 30px;
55 | }
56 |
57 | #sidebar.focused #icons {
58 | opacity: 0;
59 | }
60 |
61 | #menu {
62 | width: 100%;
63 | height: 100%;
64 | background-color: red;
65 | position: absolute;
66 | left: 0;
67 | top: 0;
68 | opacity: 0;
69 | transition: opacity 0.5s;
70 | box-sizing: border-box;
71 | padding-top: 70px;
72 | }
73 |
74 | #sidebar.focused #menu {
75 | opacity: 1;
76 | }
77 |
78 | #menu .item {
79 | height: 70px;
80 | line-height: 70px;
81 | color: white;
82 | font-size: 25px;
83 | padding-left: 90px;
84 | box-sizing: border-box;
85 | cursor: default;
86 | display: none;
87 | cursor: pointer;
88 | }
89 |
90 | #menu .item:hover {
91 | background-color: rgba(0, 0, 0, 0.3);
92 | }
93 |
94 | #menu .item-focus {
95 | background-color: white;
96 | color: red !important;
97 | }
98 |
99 | #menu .item .fa {
100 | width: 40px;
101 | }
102 |
103 | #sidebar.focused #menu .item {
104 | display: block;
105 | }
106 |
107 | .mainbox {
108 | width: 100%;
109 | height: 100%;
110 | box-sizing: border-box;
111 | padding: 40px 40px 0 120px;
112 | }
113 |
114 | #search-box-placeholder {
115 | width: 70%;
116 | height: 50px;
117 | line-height: 50px;
118 | background-color: #666;
119 | box-sizing: border-box;
120 | padding-left: 15px;
121 | cursor: pointer;
122 | font-size: 25px;
123 | color: #aaa;
124 | }
125 |
126 | #search-box-placeholder:hover,
127 | .search-box-placeholder-focus {
128 | color: black !important;
129 | background-color: white !important;
130 | }
131 |
132 | #content {
133 | height: 100%;
134 | position: relative;
135 | }
136 |
137 | #content .content {
138 | white-space: nowrap;
139 | font-size: 0;
140 | overflow: hidden;
141 | padding: 50px;
142 | margin: -50px;
143 | }
144 |
145 | #content h1 {
146 | font-size: 30px;
147 | height: 80px;
148 | padding: 0;
149 | margin: 0;
150 | line-height: 80px;
151 | }
152 |
153 | #content .item {
154 | display: inline-block;
155 | width: 200px;
156 | height: 200px;
157 | padding-bottom: 50px;
158 | background-color: #666;
159 | font-size: 1rem;
160 | margin-right: 20px;
161 | cursor: pointer;
162 | }
163 |
164 | #content .item-focus {
165 | background-color: white;
166 | transform: scale(1.08);
167 | transition: all .2s ease-in-out;
168 | }
169 |
170 | #content .animate {
171 | width: 25%;
172 | padding-bottom: 0;
173 | transition: padding-bottom 0.3s ease;
174 | }
175 |
176 | #content .placeholder {
177 | width: 25%;
178 | padding-bottom: calc(30% + 80px);
179 | }
180 |
181 | .contentgroup {
182 | width: 100%;
183 | z-index: 2;
184 | opacity: 1;
185 | transition: all .2s ease-in-out;
186 | }
187 |
188 | .contentgroup.fading-out {
189 | opacity: 0;
190 | display: none;
191 | }
192 |
--------------------------------------------------------------------------------
/examples/youtube-react-tv/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 |
4 | module.exports = {
5 | entry: './src/App.js',
6 | output: {path: __dirname, filename: 'bundle.js'},
7 | resolveLoader: {
8 | root: path.join(__dirname, 'node_modules'),
9 | },
10 | module: {
11 | loaders: [
12 | {
13 | test: /.jsx?$/,
14 | loader: 'babel-loader',
15 | exclude: /node_modules/,
16 | query: {
17 | presets: ['env', 'react'],
18 | },
19 | },
20 | {
21 | test: /\.css$/,
22 | loader: 'style!css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]'
23 | }
24 | ],
25 | },
26 | plugins: [
27 | new webpack.DefinePlugin({
28 | 'process.env': {
29 | NODE_ENV: JSON.stringify('production'),
30 | },
31 | }),
32 | ],
33 | };
34 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-key-navigation",
3 | "version": "0.0.13",
4 | "description": "Use the key to navigate around components",
5 | "main": "build/index.js",
6 | "bugs": {
7 | "url": "https://github.com/dead/react-key-navigation/issues"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/dead/react-key-navigation.git"
12 | },
13 | "scripts": {
14 | "test": "echo \"Error: no test specified\" && exit 1",
15 | "start": "webpack --watch",
16 | "build": "webpack --config webpack.config.js"
17 | },
18 | "author": {
19 | "name": "Gustavo Bennemann de Moura",
20 | "email": "gustavobenn@gmail.com",
21 | "url": "https://github.com/dead"
22 | },
23 | "peerDependencies": {
24 | "react": "^15.5.4"
25 | },
26 | "devDependencies": {
27 | "babel-cli": "^6.26.0",
28 | "babel-core": "^6.24.1",
29 | "babel-loader": "^7.0.0",
30 | "babel-plugin-transform-object-rest-spread": "^6.23.0",
31 | "babel-plugin-transform-react-jsx": "^6.24.1",
32 | "babel-preset-env": "^1.5.1",
33 | "babel-preset-react": "^6.24.1",
34 | "babel-preset-stage-2": "^6.24.1",
35 | "prop-types": "^15.6.2",
36 | "react": "^15.5.4",
37 | "webpack": "^2.6.1"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/Focusable.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | class Focusable extends Component {
5 | treePath = [];
6 | children = [];
7 | indexInParent = 0;
8 | focusableId = null;
9 | lastFocusChild = null;
10 | updateChildrenOrder = false;
11 | updateChildrenOrderNum = 0;
12 |
13 | state = {
14 | focusTo: null
15 | }
16 |
17 | constructor(props, context) {
18 | super(props, context);
19 | }
20 |
21 | isContainer() {
22 | return false;
23 | }
24 |
25 | hasChildren() {
26 | return this.children.length > 0;
27 | }
28 |
29 | getParent() {
30 | return this.context.parentFocusable;
31 | }
32 |
33 | addChild(child) {
34 | this.children.push(child);
35 | return this.children.length - 1;
36 | }
37 |
38 | removeChild(child) {
39 | this.context.navigationComponent.removeFocusableId(child.focusableId);
40 |
41 | const currentFocusedPath = this.context.navigationComponent.currentFocusedPath;
42 | if(!currentFocusedPath){
43 | return
44 | }
45 | const index = currentFocusedPath.indexOf(child);
46 |
47 | if (index > 0) {
48 | this.setState({ focusTo: currentFocusedPath[index - 1] })
49 | }
50 | }
51 |
52 | getDefaultChild() {
53 | if (this.lastFocusChild && this.props.retainLastFocus) {
54 | return this.lastFocusChild;
55 | }
56 |
57 | return 0;
58 | }
59 |
60 | getNextFocusFrom(direction) {
61 | return this.getNextFocus(direction, this.indexInParent);
62 | }
63 |
64 | getNextFocus(direction, focusedIndex) {
65 | if (!this.getParent()) {
66 | return null;
67 | }
68 |
69 | return this.getParent().getNextFocus(direction, focusedIndex);
70 | }
71 |
72 | getDefaultFocus() {
73 | if (this.isContainer()) {
74 | if (this.hasChildren()) {
75 | return this.children[this.getDefaultChild()].getDefaultFocus();
76 | }
77 |
78 | return null;
79 | }
80 |
81 | return this;
82 | }
83 |
84 | buildTreePath() {
85 | this.treePath.unshift(this);
86 |
87 | let parent = this.getParent();
88 | while (parent) {
89 | this.treePath.unshift(parent);
90 | parent = parent.getParent();
91 | }
92 | }
93 |
94 | focus() {
95 | this.treePath.map(component => {
96 | if (component.props.onFocus)
97 | component.props.onFocus(this.indexInParent, this.context.navigationComponent);
98 | });
99 | }
100 |
101 | blur() {
102 | if (this.props.onBlur) {
103 | this.props.onBlur(this.indexInParent, this.context.navigationComponent);
104 | }
105 | }
106 |
107 | nextChild(focusedIndex) {
108 | if (this.children.length === focusedIndex + 1) {
109 | return null;
110 | }
111 |
112 | return this.children[focusedIndex + 1];
113 | }
114 |
115 | previousChild(focusedIndex) {
116 | if (focusedIndex - 1 < 0) {
117 | return null;
118 | }
119 |
120 | return this.children[focusedIndex - 1];
121 | }
122 |
123 | getNavigator() {
124 | return this.context.navigationComponent;
125 | }
126 |
127 | // React Methods
128 | getChildContext() {
129 | return { parentFocusable: this };
130 | }
131 |
132 | componentDidMount() {
133 | this.focusableId = this.context.navigationComponent.addComponent(this, this.props.focusId);
134 |
135 | if (this.context.parentFocusable) {
136 | this.buildTreePath();
137 | this.indexInParent = this.getParent().addChild(this);
138 | }
139 |
140 | if (this.props.navDefault) {
141 | this.context.navigationComponent.setDefault(this);
142 | }
143 |
144 | if (this.props.forceFocus) {
145 | this.context.navigationComponent.focus(this);
146 | }
147 | }
148 |
149 | componentWillUnmount() {
150 | if (this.context.parentFocusable) {
151 | this.getParent().removeChild(this);
152 | }
153 |
154 | this.focusableId = null;
155 | }
156 |
157 | componentDidUpdate() {
158 | const parent = this.getParent();
159 | if (parent && parent.updateChildrenOrder) {
160 | if (parent.updateChildrenOrderNum === 0) {
161 | parent.children = [];
162 | }
163 |
164 | parent.updateChildrenOrderNum++;
165 | this.indexInParent = parent.addChild(this);
166 | }
167 |
168 | if (this.state.focusTo !== null) {
169 | this.context.navigationComponent.focus(this.state.focusTo.getDefaultFocus());
170 | this.setState({ focusTo: null });
171 | }
172 |
173 | this.updateChildrenOrder = false;
174 | }
175 |
176 | render() {
177 | const { focusId, rootNode, navDefault, forceFocus, retainLastFocus, onFocus, onBlur, onEnterDown, ...props } = this.props;
178 |
179 | if (this.children.length > 0) {
180 | this.updateChildrenOrder = true;
181 | this.updateChildrenOrderNum = 0;
182 | }
183 |
184 | return
185 | }
186 | }
187 |
188 | Focusable.contextTypes = {
189 | parentFocusable: PropTypes.object,
190 | navigationComponent: PropTypes.object,
191 | };
192 |
193 | Focusable.childContextTypes = {
194 | parentFocusable: PropTypes.object,
195 | };
196 |
197 | Focusable.defaultProps = {
198 | rootNode: false,
199 | navDefault: false,
200 | forceFocus: false,
201 | retainLastFocus: false,
202 | onFocus: PropTypes.function,
203 | onBlur: PropTypes.function,
204 | onEnterDown: PropTypes.function
205 | };
206 |
207 | export default Focusable;
208 |
--------------------------------------------------------------------------------
/src/Grid.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Focusable from './Focusable.jsx';
3 | import HorizontalList from './HorizontalList.jsx';
4 |
5 | class Grid extends Focusable {
6 | isContainer() {
7 | return true;
8 | }
9 |
10 | getNextFocus(direction, focusedIndex) {
11 | if (direction !== 'up' && direction !== 'down') {
12 | return super.getNextFocus(direction, this.indexInParent);
13 | }
14 |
15 | let nextFocus = null;
16 | if (direction === 'up') {
17 | nextFocus = this.previousChild(focusedIndex);
18 | } else if (direction === 'down') {
19 | nextFocus = this.nextChild(focusedIndex);
20 | }
21 |
22 | if (!nextFocus) {
23 | return super.getNextFocus(direction, this.indexInParent);
24 | }
25 |
26 | if (!nextFocus.isContainer()) {
27 | return null;
28 | }
29 |
30 | const currentPath = this.context.navigationComponent.currentFocusedPath;
31 |
32 | const row = nextFocus.indexInParent;
33 | let column = currentPath[currentPath.indexOf(this) + 2].indexInParent;
34 |
35 | if (this.children[row].children.length <= column) {
36 | column = this.children[row].children.length;
37 | }
38 |
39 | const next = this.children[row].children[column];
40 | if (next.isContainer()) {
41 | if (next.hasChildren()) {
42 | return next.getDefaultFocus();
43 | }
44 | else {
45 | return this.getNextFocus(direction, nextFocus.indexInParent);
46 | }
47 | }
48 |
49 | return next;
50 | }
51 |
52 | render() {
53 | let grid = new Array(this.props.rows);
54 | for (let i = 0; i < this.props.rows; i++) {
55 | grid[i] = new Array(this.props.columns);
56 | }
57 |
58 | React.Children.map(this.props.children, (child, i) => {
59 | const row = Math.floor(i / this.props.columns);
60 | const column = i % this.props.columns;
61 | grid[row][column] = child;
62 | });
63 |
64 | return (
65 |
66 | {grid.map((row, i) =>
67 |
68 | {row.map(column =>
69 | column
70 | )}
71 |
72 | )}
73 |
74 | );
75 | }
76 | }
77 |
78 | Grid.defaultProps = {
79 | columns: 0,
80 | rows: 0
81 | }
82 |
83 | export default Grid;
84 |
--------------------------------------------------------------------------------
/src/HorizontalList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Focusable from './Focusable.jsx';
3 |
4 | class HorizontalList extends Focusable {
5 | isContainer() {
6 | return true;
7 | }
8 |
9 | getNextFocus(direction, focusedIndex) {
10 | const remainInFocus = this.props.remainInFocus ? this.props.remainInFocus : false;
11 |
12 | if (direction !== 'left' && direction !== 'right') {
13 | if (remainInFocus)
14 | return null;
15 | return super.getNextFocus(direction, this.indexInParent);
16 | }
17 |
18 | let nextFocus = null;
19 | if (direction === 'left') {
20 | nextFocus = this.previousChild(focusedIndex);
21 | } else if (direction === 'right') {
22 | nextFocus = this.nextChild(focusedIndex);
23 | }
24 |
25 | if (!nextFocus) {
26 | return super.getNextFocus(direction, this.indexInParent);
27 | }
28 |
29 | if (nextFocus.isContainer()) {
30 | if (nextFocus.hasChildren()) {
31 | return nextFocus.getDefaultFocus();
32 | }
33 | else {
34 | return this.getNextFocus(direction, nextFocus.indexInParent);
35 | }
36 | }
37 |
38 | return nextFocus;
39 | }
40 |
41 | render() {
42 | const { focusId, rootNode, navDefault, forceFocus, retainLastFocus, onFocus, onBlur, onEnterDown, ...props } = this.props;
43 | return
44 | }
45 | }
46 |
47 | export default HorizontalList;
48 |
--------------------------------------------------------------------------------
/src/Navigation.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import VerticalList from './VerticalList.jsx';
5 |
6 | const reverseDirection = {
7 | 'up': 'down',
8 | 'down': 'up',
9 | 'left': 'right',
10 | 'right': 'left'
11 | }
12 |
13 | /*
14 | This component listen the window keys events.
15 | */
16 |
17 | class Navigation extends Component {
18 | currentFocusedPath = null;
19 | lastFocusedPath = null;
20 | lastDirection = null;
21 | pause = false;
22 | default = null;
23 | root = null;
24 | focusableComponents = {};
25 | focusableIds = 0;
26 |
27 | onKeyDown = (evt) => {
28 | if (this._pause || evt.altKey || evt.ctrlKey || evt.metaKey || evt.shiftKey) {
29 | return;
30 | }
31 |
32 | const preventDefault = function () {
33 | evt.preventDefault();
34 | evt.stopPropagation();
35 | return false;
36 | };
37 |
38 | const direction = this.props.keyMapping[evt.keyCode];
39 |
40 | if (!direction) {
41 | if (evt.keyCode === this.props.keyMapping['enter']) {
42 | if (this.currentFocusedPath) {
43 | if (!this.fireEvent(this.getLastFromPath(this.currentFocusedPath), 'enter-down')) {
44 | return preventDefault();
45 | }
46 | }
47 | }
48 | return;
49 | }
50 |
51 | let currentFocusedPath = this.currentFocusedPath;
52 | // console.log('currentFocusedPath', currentFocusedPath);
53 |
54 | if (!currentFocusedPath || currentFocusedPath.length === 0) {
55 | currentFocusedPath = this.lastFocusedPath;
56 |
57 | if (!currentFocusedPath || currentFocusedPath.length === 0) {
58 | //this.focusDefault();
59 | return preventDefault();
60 | }
61 | }
62 |
63 | this.focusNext(direction, currentFocusedPath);
64 | return preventDefault();
65 | }
66 |
67 | fireEvent(element, evt, evtProps) {
68 | switch (evt) {
69 | case 'willmove':
70 | if (element.props.onWillMove)
71 | element.props.onWillMove(evtProps);
72 | break;
73 | case 'onfocus':
74 | element.focus(evtProps);
75 | break;
76 | case 'onblur':
77 | element.blur(evtProps);
78 | break;
79 | case 'enter-down':
80 | if (element.props.onEnterDown)
81 | element.props.onEnterDown(evtProps, this);
82 | break;
83 | default:
84 | return false;
85 | }
86 |
87 | return true;
88 | }
89 |
90 | focusNext(direction, focusedPath) {
91 | const next = this.getLastFromPath(focusedPath).getNextFocusFrom(direction);
92 |
93 | if (next) {
94 | this.lastDirection = direction;
95 | this.focus(next);
96 | }
97 | }
98 |
99 | blur(nextTree) {
100 | if (this.currentFocusedPath === null)
101 | return;
102 |
103 | let changeNode = null;
104 |
105 | for (let i = 0; i < Math.min(nextTree.length, this.currentFocusedPath.length); ++i) {
106 | if (nextTree[i] !== this.currentFocusedPath[i]) {
107 | changeNode = i;
108 | break;
109 | }
110 | }
111 |
112 | if (changeNode === null)
113 | return;
114 |
115 | for (let i = changeNode; i < this.currentFocusedPath.length; ++i) {
116 | if (this.currentFocusedPath[i].focusableId === null) {
117 | continue;
118 | }
119 |
120 | this.currentFocusedPath[i].blur();
121 |
122 | if (i < this.currentFocusedPath.length - 1) {
123 | this.currentFocusedPath[i].lastFocusChild = this.currentFocusedPath[i + 1].indexInParent;
124 | }
125 | }
126 | }
127 |
128 | focus(next) {
129 | if (next === null) {
130 | console.warn('Trying to focus a null component');
131 | return;
132 | }
133 |
134 | this.blur(next.treePath);
135 | next.focus();
136 |
137 | const lastPath = this.currentFocusedPath;
138 | this.currentFocusedPath = next.treePath;
139 | this.lastFocusedPath = lastPath;
140 | }
141 |
142 | getLastFromPath(path) {
143 | return path[path.length - 1];
144 | }
145 |
146 | focusDefault() {
147 | if (this.default !== null) {
148 | this.focus(this.default.getDefaultFocus());
149 | } else {
150 | this.focus(this.root.getDefaultFocus());
151 | }
152 | }
153 |
154 | setDefault(component) {
155 | this.default = component;
156 | }
157 |
158 | addComponent(component, id = null) {
159 | if (this.focusableComponents[id]) {
160 | return id;
161 | // throw new Error('Focusable component with id "' + id + '" has already existed!');
162 | }
163 |
164 | if (!id) {
165 | id = 'focusable-' + this.focusableIds++;
166 | }
167 |
168 | this.focusableComponents[id] = component;
169 | return id;
170 | }
171 |
172 | forceFocus(focusableId) {
173 | if (!this.focusableComponents[focusableId]) {
174 | throw new Error('Focusable component with id "' + focusableId + '" doesn\'t exists!');
175 | }
176 |
177 | this.focus(this.focusableComponents[focusableId].getDefaultFocus());
178 | }
179 |
180 | removeFocusableId(focusableId) {
181 | if (this.focusableComponents[focusableId])
182 | delete this.focusableComponents[focusableId]
183 | }
184 |
185 | // React Functions
186 | componentDidMount() {
187 | window.addEventListener('keydown', this.onKeyDown);
188 | window.addEventListener('keyup', this.onKeyUp);
189 | this.focusDefault();
190 | }
191 |
192 | componentWillUnmount() {
193 | window.removeEventListener('keyup', this.onKeyUp);
194 | window.removeEventListener('keydown', this.onKeyDown);
195 | }
196 |
197 | getChildContext() {
198 | return { navigationComponent: this };
199 | }
200 |
201 | getRoot() {
202 | return this.root;
203 | }
204 |
205 | render() {
206 | return this.root = element} focusId='navigation'>
207 | {this.props.children}
208 | ;
209 | }
210 | }
211 |
212 | Navigation.defaultProps = {
213 | keyMapping: {
214 | '37': 'left',
215 | '38': 'up',
216 | '39': 'right',
217 | '40': 'down',
218 | 'enter': 13
219 | }
220 | };
221 |
222 | Navigation.childContextTypes = {
223 | navigationComponent: PropTypes.object
224 | };
225 |
226 | export default Navigation;
227 |
--------------------------------------------------------------------------------
/src/VerticalList.jsx:
--------------------------------------------------------------------------------
1 | import Focusable from './Focusable.jsx';
2 |
3 | class VerticalList extends Focusable {
4 | isContainer() {
5 | return true;
6 | }
7 |
8 | getNextFocus(direction, focusedIndex) {
9 | const remainInFocus = this.props.remainInFocus ? this.props.remainInFocus : false;
10 |
11 | if (direction !== 'up' && direction !== 'down') {
12 | if (remainInFocus)
13 | return null;
14 | return super.getNextFocus(direction, this.indexInParent);
15 | }
16 |
17 | let nextFocus = null;
18 | if (direction === 'up') {
19 | nextFocus = this.previousChild(focusedIndex);
20 | } else if (direction === 'down') {
21 | nextFocus = this.nextChild(focusedIndex);
22 | }
23 |
24 | if (!nextFocus) {
25 | return super.getNextFocus(direction, this.indexInParent);
26 | }
27 |
28 | if (nextFocus.isContainer()) {
29 | if (nextFocus.hasChildren()) {
30 | return nextFocus.getDefaultFocus();
31 | }
32 | else {
33 | return this.getNextFocus(direction, nextFocus.indexInParent);
34 | }
35 | }
36 |
37 | return nextFocus;
38 | }
39 | }
40 |
41 | export default VerticalList;
42 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import Focusable from './Focusable.jsx';
2 | import VerticalList from './VerticalList.jsx';
3 | import HorizontalList from './HorizontalList.jsx';
4 | import Grid from './Grid.jsx';
5 | import Navigation from './Navigation.jsx';
6 |
7 | export {
8 | Navigation as default,
9 | VerticalList,
10 | HorizontalList,
11 | Grid,
12 | Focusable
13 | };
14 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require('path');
2 |
3 | module.exports = {
4 | entry: './src/index.js',
5 | output: {
6 | path: path.resolve(__dirname, 'build'),
7 | filename: 'index.js',
8 | libraryTarget: 'commonjs2'
9 | },
10 | resolve: {
11 | extensions: ['.js', '.jsx'],
12 | },
13 | module: {
14 | rules: [
15 | {
16 | test: /\.js(x)$/,
17 | include: path.resolve(__dirname, 'src'),
18 | exclude: /(node_modules|build)/,
19 | use: {
20 | loader: 'babel-loader',
21 | options: {
22 | presets: ['env', 'stage-2']
23 | }
24 | }
25 | }
26 | ]
27 | },
28 | externals: {
29 | 'react': 'react'
30 | }
31 | };
32 |
--------------------------------------------------------------------------------