├── .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 | ![youtube-react-tv](https://github.com/dead/react-key-navigation/raw/master/examples/youtube-react-tv/example.gif) 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 | --------------------------------------------------------------------------------