├── .babelrc ├── .circleci └── config.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── README.md ├── __tests__ ├── spatial-navigation-test.js ├── with-focusable-test.js └── with-navigation-test.js ├── images └── example.gif ├── package.json ├── rollup.config.js └── src ├── index.js ├── navigation.js ├── spatial-navigation.js ├── with-focusable.js └── with-navigation.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["es2015", {"modules": false}], "react", "stage-2"], 3 | "env": { 4 | "test": { 5 | "presets": [["es2015"], "react", "stage-2"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:8 6 | 7 | working_directory: ~/repo 8 | 9 | steps: 10 | - checkout 11 | 12 | # Versions Check 13 | - run: 14 | name: Node version 15 | command: node -v 16 | - run: 17 | name: Yarn version 18 | command: yarn --version 19 | 20 | - restore_cache: 21 | keys: 22 | - v1-dependencies-{{ checksum "package.json" }} 23 | - v1-dependencies- 24 | 25 | - run: 26 | name: Install dependencies 27 | command: yarn install 28 | 29 | - run: 30 | name: Run lint 31 | command: yarn lint 32 | 33 | - run: 34 | name: Run tests 35 | command: yarn test 36 | 37 | - save_cache: 38 | paths: 39 | - node_modules 40 | key: v1-dependencies-{{ checksum "package.json" }} 41 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | max_line_length = 80 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | max_line_length = 0 14 | trim_trailing_whitespace = false 15 | 16 | [COMMIT_EDITMSG] 17 | max_line_length = 0 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | vendor 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const OFF = 0; 4 | const ERROR = 2; 5 | 6 | module.exports = { 7 | extends: 'fbjs', 8 | 9 | // Stop ESLint from looking for a configuration file in parent folders 10 | root: true, 11 | 12 | plugins: [ 13 | 'flowtype', 14 | 'react', 15 | ], 16 | 17 | // We're stricter than the default config, mostly. We'll override a few rules 18 | // and then enable some React specific ones. 19 | rules: { 20 | 'accessor-pairs': OFF, 21 | 'brace-style': [ERROR, '1tbs'], 22 | 'comma-dangle': [ERROR, 'always-multiline'], 23 | 'consistent-return': OFF, 24 | 'dot-location': [ERROR, 'property'], 25 | 'dot-notation': ERROR, 26 | 'eol-last': ERROR, 27 | 'eqeqeq': [ERROR, 'allow-null'], 28 | 'indent': OFF, 29 | 'jsx-quotes': [ERROR, 'prefer-double'], 30 | 'keyword-spacing': [ERROR, {after: true, before: true}], 31 | 'no-bitwise': OFF, 32 | 'no-inner-declarations': [ERROR, 'functions'], 33 | 'no-multi-spaces': ERROR, 34 | 'no-restricted-syntax': [ERROR, 'WithStatement'], 35 | 'no-shadow': ERROR, 36 | 'no-unused-expressions': ERROR, 37 | 'no-unused-vars': [ERROR, {args: 'none'}], 38 | 'no-useless-concat': OFF, 39 | 'quotes': [ERROR, 'single', {avoidEscape: true, allowTemplateLiterals: true }], 40 | 'space-before-blocks': ERROR, 41 | 'space-before-function-paren': OFF, 42 | 43 | // React & JSX 44 | // Our transforms set this automatically 45 | 'react/jsx-boolean-value': [ERROR, 'always'], 46 | 'react/jsx-no-undef': ERROR, 47 | // We don't care to do this 48 | 'react/jsx-sort-prop-types': OFF, 49 | 'react/jsx-tag-spacing': ERROR, 50 | 'react/jsx-uses-react': ERROR, 51 | 'react/no-is-mounted': OFF, 52 | // This isn't useful in our test code 53 | 'react/react-in-jsx-scope': ERROR, 54 | 'react/self-closing-comp': ERROR, 55 | // We don't care to do this 56 | 'react/jsx-wrap-multilines': [ERROR, {declaration: false, assignment: false}] 57 | }, 58 | 59 | globals: {}, 60 | }; 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | *.map 4 | 5 | dist 6 | node_modules 7 | package-lock.json 8 | yarn.lock 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [React-TV-Navigation was migrated to raphamorim/react-tv](https://github.com/raphamorim/react-tv) 2 | 3 | > Navigation for TVs using React-TV 4 | 5 | [![CircleCI](https://circleci.com/gh/react-tv/react-tv-navigation/tree/master.svg?style=svg)](https://circleci.com/gh/react-tv/react-tv-navigation/tree/master) 6 | 7 | tl;dr: [Based on Netflix TV Navigation System](https://medium.com/netflix-techblog/pass-the-remote-user-input-on-tv-devices-923f6920c9a8) 8 | 9 | ![React-TV Navigation Example](images/example.gif) 10 | 11 | [See code from this example](https://github.com/react-tv/react-tv/blob/master/examples/navigation/src/App.js) 12 | 13 | React-TV-Navigation is a separated package from React-TV renderer to manage focusable components. 14 | 15 | ## Installing 16 | 17 | ```bash 18 | yarn add react-tv-navigation 19 | ``` 20 | 21 | React and [React-TV](http://github.com/react-tv/react-tv) are peer-dependencies. 22 | 23 | ## `withFocusable` and `withNavigation` 24 | 25 | React-TV Navigation exports two functions: `withFocusable` and `withNavigation`. 26 | 27 | A declarative navigation system based on HOC's for focus and navigation control. 28 | 29 | ```jsx 30 | import React from 'react' 31 | import ReactTV from 'react-tv' 32 | import { withFocusable, withNavigation } from 'react-tv-navigation' 33 | 34 | const Item = ({focused, setFocus, focusPath}) => { 35 | focused = (focused) ? 'focused' : 'unfocused' 36 | return ( 37 |
{ setFocus() }} > 38 | It's {focused} Item 39 |
40 | ) 41 | } 42 | 43 | const Button = ({setFocus}) => { 44 | return ( 45 |
{ setFocus('item-1') }}> 46 | Back To First Item! 47 |
48 | ) 49 | } 50 | 51 | const FocusableItem = withFocusable(Item) 52 | const FocusableButton = withFocusable(Button) 53 | 54 | function App({currentFocusPath}) { 55 | return ( 56 |
57 |

Current FocusPath: '{currentFocusPath}'

, 58 | 59 | 60 | console.log('Pressed enter on Button!')}/> 63 |
64 | ) 65 | } 66 | 67 | const NavigableApp = withNavigation(App) 68 | 69 | ReactTV.render(, document.querySelector('#app')) 70 | ``` 71 | 72 | Soon we'll write a decent README.md :) 73 | 74 | #### License by MIT 75 | -------------------------------------------------------------------------------- /__tests__/spatial-navigation-test.js: -------------------------------------------------------------------------------- 1 | import SpatialNavigation from '../src/spatial-navigation'; 2 | 3 | describe('SpatialNavigation', () => { 4 | let setStateSpy; 5 | 6 | beforeEach(() => { 7 | setStateSpy = jest.fn(); 8 | SpatialNavigation.init(setStateSpy); 9 | }); 10 | 11 | afterEach(() => { 12 | SpatialNavigation.destroy(); 13 | }); 14 | 15 | describe('on initialize', () => { 16 | it('listens to sn:focused event', () => { 17 | const event = new CustomEvent('sn:focused', { 18 | detail: { sectionId: 'focusPath' }, 19 | }); 20 | document.dispatchEvent(event); 21 | 22 | expect(setStateSpy).toHaveBeenCalled(); 23 | }); 24 | 25 | describe('when focusing the same focused element', () => { 26 | beforeEach(() => { 27 | SpatialNavigation.focusedPath = 'focusPath'; 28 | }); 29 | 30 | it('does nothing', () => { 31 | const event = new CustomEvent('sn:focused', { 32 | detail: { sectionId: 'focusPath' }, 33 | }); 34 | document.dispatchEvent(event); 35 | 36 | expect(setStateSpy).not.toHaveBeenCalled(); 37 | }); 38 | }); 39 | }); 40 | 41 | describe('on destroy', () => { 42 | it('stops listening to sn:focused', () => { 43 | SpatialNavigation.destroy(); 44 | 45 | const event = new CustomEvent('sn:focused', { 46 | detail: { sectionId: 'focusPath' }, 47 | }); 48 | document.dispatchEvent(event); 49 | 50 | expect(setStateSpy).not.toHaveBeenCalled(); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /__tests__/with-focusable-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactTV from 'react-tv'; 3 | 4 | import Enzyme, { mount } from 'enzyme'; 5 | import Adapter from 'enzyme-adapter-react-16'; 6 | Enzyme.configure({ adapter: new Adapter() }); 7 | 8 | import SpatialNavigation from '../src/spatial-navigation'; 9 | import withFocusable from '../src/with-focusable'; 10 | 11 | describe('withFocusable', () => { 12 | const Component = () =>
; 13 | const renderComponent = ({ 14 | currentFocusPath, 15 | setFocus = jest.fn(), 16 | ...props 17 | }) => { 18 | const EnhancedComponent = withFocusable(Component); 19 | return mount( 20 | , 21 | { context: { currentFocusPath, setFocus } } 22 | ); 23 | }; 24 | 25 | let element; 26 | let component; 27 | 28 | beforeEach(() => { 29 | element = document.createElement('div'); 30 | spyOn(ReactTV, 'findDOMNode').and.returnValue(element); 31 | }); 32 | 33 | afterEach(() => { 34 | if (component) { 35 | component.unmount(); 36 | } 37 | }); 38 | 39 | it('injects focusPath as prop', () => { 40 | component = renderComponent({ focusPath: 'focusPath' }); 41 | expect(component.find(Component).prop('focusPath')).toEqual('focusPath'); 42 | }); 43 | 44 | describe('when element focusPath is the same as currentFocusPath', () => { 45 | it('injects focused prop as true', () => { 46 | const focusPath = 'focusPath1'; 47 | component = renderComponent({ currentFocusPath: focusPath, focusPath }); 48 | 49 | expect(component.find(Component).prop('focused')).toBe(true); 50 | }); 51 | }); 52 | 53 | describe('when element focusPath is different than currentFocusPath', () => { 54 | it('injects focused prop as true', () => { 55 | component = renderComponent({ 56 | currentFocusPath: 'focusPath1', 57 | focusPath: 'focusPath2', 58 | }); 59 | 60 | expect(component.find(Component).prop('focused')).toBe(false); 61 | }); 62 | }); 63 | 64 | describe('about setFocus injected prop', () => { 65 | it('injects function to children', () => { 66 | component = renderComponent({ focusPath: 'focusPath' }); 67 | expect(component.find(Component).prop('setFocus')).not.toBeFalsy(); 68 | }); 69 | 70 | it('binds configured focusPath as first parameter', () => { 71 | const setFocusSpy = jest.fn(); 72 | component = renderComponent({ 73 | focusPath: 'focusPath', 74 | setFocus: setFocusSpy, 75 | }); 76 | 77 | const setFocus = component.find(Component).prop('setFocus'); 78 | setFocus(); 79 | 80 | expect(setFocusSpy).toHaveBeenCalledWith('focusPath'); 81 | }); 82 | 83 | it('sends setFocus parameter as second parameter', () => { 84 | const setFocusSpy = jest.fn(); 85 | component = renderComponent({ 86 | focusPath: 'focusPath', 87 | setFocus: setFocusSpy, 88 | }); 89 | 90 | const setFocus = component.find(Component).prop('setFocus'); 91 | setFocus('otherFocusPath'); 92 | 93 | expect(setFocusSpy).toHaveBeenCalledWith('focusPath', 'otherFocusPath'); 94 | }); 95 | }); 96 | 97 | describe('lifecycle', () => { 98 | let onEnterPress; 99 | 100 | beforeEach(() => { 101 | onEnterPress = jest.fn(); 102 | spyOn(SpatialNavigation, 'addFocusable').and.callThrough(); 103 | spyOn(SpatialNavigation, 'removeFocusable').and.callThrough(); 104 | }); 105 | 106 | describe('when mounting component', () => { 107 | it('adds to focusable management', () => { 108 | component = renderComponent({ focusPath: 'focusPath' }); 109 | expect(SpatialNavigation.addFocusable) 110 | .toHaveBeenCalledWith( 111 | element, 112 | expect.objectContaining({ focusPath: 'focusPath' }) 113 | ); 114 | }); 115 | 116 | it('listens enter press event', () => { 117 | component = renderComponent({ focusPath: 'focusPath', onEnterPress }); 118 | 119 | const event = new CustomEvent('sn:enter-down'); 120 | element.dispatchEvent(event); 121 | expect(onEnterPress).toHaveBeenCalled(); 122 | }); 123 | }); 124 | 125 | describe('when unmounting component', () => { 126 | it('removes from focusable management', () => { 127 | component = renderComponent({ focusPath: 'focusPath' }); 128 | 129 | component.unmount(); 130 | expect(SpatialNavigation.removeFocusable) 131 | .toHaveBeenCalledWith(element, expect.anything()); 132 | }); 133 | 134 | it('stops listening to enter press event', () => { 135 | component = renderComponent({ focusPath: 'focusPath', onEnterPress }); 136 | component.unmount(); 137 | 138 | const event = new CustomEvent('sn:enter-down'); 139 | element.dispatchEvent(event); 140 | expect(onEnterPress).not.toHaveBeenCalled(); 141 | }); 142 | }); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /__tests__/with-navigation-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Enzyme, { mount } from 'enzyme'; 4 | import Adapter from 'enzyme-adapter-react-16'; 5 | Enzyme.configure({ adapter: new Adapter() }); 6 | 7 | import SpatialNavigation from '../src/spatial-navigation'; 8 | import withNavigation from '../src/with-navigation'; 9 | 10 | describe('withNavigation', () => { 11 | const Component = () =>
; 12 | const EnhancedComponent = withNavigation(Component); 13 | const renderComponent = () => mount(); 14 | 15 | let component; 16 | 17 | describe('#setFocus', () => { 18 | beforeEach(() => { 19 | spyOn(SpatialNavigation, 'setCurrentFocusedPath').and.callThrough(); 20 | component = renderComponent(); 21 | }); 22 | 23 | describe('for the same focusPath', () => { 24 | it('does nothing', () => { 25 | component.setState({ currentFocusPath: 'focusPath' }); 26 | component.children().props().setFocus('focusPath'); 27 | expect(SpatialNavigation.setCurrentFocusedPath).not.toHaveBeenCalled(); 28 | }); 29 | }); 30 | 31 | describe('for a different focusPath', () => { 32 | it('updates navigation currentFocusPath', () => { 33 | component.setState({ currentFocusPath: 'focusPath' }); 34 | component.children().props().setFocus('anotherFocusPath'); 35 | expect(SpatialNavigation.getCurrentFocusedPath()) 36 | .toEqual('anotherFocusPath'); 37 | }); 38 | 39 | it('updates currentFocusPath state', () => { 40 | component.setState({ currentFocusPath: 'focusPath' }); 41 | component.children().props().setFocus('anotherFocusPath'); 42 | expect(component.state().currentFocusPath).toEqual('anotherFocusPath'); 43 | }); 44 | }); 45 | }); 46 | 47 | describe('lifecycle', () => { 48 | beforeEach(() => { 49 | spyOn(SpatialNavigation, 'init'); 50 | spyOn(SpatialNavigation, 'destroy'); 51 | }); 52 | 53 | it('initializes after component mounts', () => { 54 | component = renderComponent(); 55 | expect(SpatialNavigation.init).toHaveBeenCalled(); 56 | }); 57 | 58 | it('initializes after component updates', () => { 59 | component = renderComponent(); 60 | 61 | SpatialNavigation.init.calls.reset(); 62 | expect(SpatialNavigation.init).not.toHaveBeenCalled(); 63 | component.setProps({ prop: 'test' }); 64 | expect(SpatialNavigation.init).toHaveBeenCalled(); 65 | }); 66 | 67 | it('destroys after component unmounts', () => { 68 | component = renderComponent(); 69 | 70 | component.unmount(); 71 | expect(SpatialNavigation.destroy).toHaveBeenCalled(); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /images/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/react-tv/react-tv-navigation/f5cc12dee2d146f3aa873ff02417bcad655bde29/images/example.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-tv-navigation", 3 | "version": "0.4.3", 4 | "description": "A react-tv-based implementation of Spatial Navigation by Luke Chang", 5 | "main": "dist/bundle.umd.js", 6 | "files": [ 7 | "dist/", 8 | "README.md" 9 | ], 10 | "scripts": { 11 | "prepublishOnly": "yarn build", 12 | "build": "rollup -c", 13 | "lint": "eslint ./", 14 | "test": "jest" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/react-tv/react-tv-navigation.git" 19 | }, 20 | "keywords": [ 21 | "react", 22 | "tv", 23 | "react-tv", 24 | "spatial", 25 | "navigation" 26 | ], 27 | "author": "Raphael Amorim ", 28 | "contributors": [ 29 | "Celio Latorraca ", 30 | "Raphael Amorim " 31 | ], 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/react-tv/react-tv-navigation/issues" 35 | }, 36 | "homepage": "https://github.com/react-tv/react-tv-navigation#readme", 37 | "peerDependencies": { 38 | "react": "^16.2.0", 39 | "react-tv": "^0.4.3" 40 | }, 41 | "devDependencies": { 42 | "babel-core": "^6.26.0", 43 | "babel-eslint": "^8.2.1", 44 | "babel-jest": "^22.1.0", 45 | "babel-preset-es2015": "^6.24.1", 46 | "babel-preset-react": "^6.24.1", 47 | "babel-preset-stage-2": "^6.24.1", 48 | "enzyme": "^3.3.0", 49 | "enzyme-adapter-react-16": "^1.1.1", 50 | "eslint": "^4.15.0", 51 | "eslint-config-fbjs": "^2.0.1", 52 | "eslint-plugin-babel": "^4.1.2", 53 | "eslint-plugin-flowtype": "^2.41.0", 54 | "eslint-plugin-jsx-a11y": "^6.0.3", 55 | "eslint-plugin-react": "^7.5.1", 56 | "eslint-plugin-relay": "^0.0.20", 57 | "jest": "^22.1.1", 58 | "prop-types": "^15.6.0", 59 | "react": "^16.2.0", 60 | "react-dom": "^16.2.0", 61 | "react-tv": "^0.4.3", 62 | "recompose": "^0.26.0", 63 | "rollup": "^0.54.0", 64 | "rollup-plugin-babel": "^3.0.3", 65 | "rollup-plugin-commonjs": "^8.2.6", 66 | "rollup-plugin-flow": "^1.1.1", 67 | "rollup-plugin-node-resolve": "^3.0.2", 68 | "rollup-plugin-uglify": "^3.0.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import babel from 'rollup-plugin-babel'; 4 | import flow from 'rollup-plugin-flow'; 5 | import uglify from 'rollup-plugin-uglify'; 6 | 7 | export default { 8 | input: 'src/index.js', 9 | output: { 10 | name: 'ReactTVNavigation', 11 | file: 'dist/bundle.umd.js', 12 | format: 'umd', 13 | globals: { 14 | react: 'React', 15 | 'react-tv': 'react-tv', 16 | }, 17 | }, 18 | plugins: [ 19 | flow(), 20 | babel({ 21 | exclude: 'node_modules/**', 22 | externalHelpers: false, 23 | }), 24 | commonjs(), 25 | resolve({ 26 | jsnext: true, 27 | main: true, 28 | browser: true, 29 | }), 30 | uglify(), 31 | ], 32 | external: ['react', 'react-tv'], 33 | }; 34 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as withFocusable } from './with-focusable'; 2 | export { default as withNavigation } from './with-navigation'; 3 | -------------------------------------------------------------------------------- /src/navigation.js: -------------------------------------------------------------------------------- 1 | /*eslint-disable*/ 2 | 3 | /** 4 | * Copyright (c) 2018-present, Raphael Amorim. 5 | * 6 | * This source code is licensed under the MIT license found in the 7 | * LICENSE file in the root directory of this source tree. 8 | */ 9 | 10 | /* 11 | Fork from luke-chang/js-spatial-navigation 12 | */ 13 | 14 | var GlobalConfig = { 15 | selector: '', // can be a valid except "@" syntax. 16 | straightOnly: false, 17 | straightOverlapThreshold: 0.5, 18 | rememberSource: false, 19 | disabled: false, 20 | defaultElement: '', // except "@" syntax. 21 | enterTo: '', // '', 'last-focused', 'default-element' 22 | leaveFor: null, // {left: , right: , 23 | // up: , down: } 24 | restrict: 'self-first', // 'self-first', 'self-only', 'none' 25 | // tabIndexIgnoreList: 26 | // 'a, input, select, textarea, button, iframe, [contentEditable=true]', 27 | tabIndexIgnoreList: [], 28 | navigableFilter: null 29 | }; 30 | 31 | /*********************/ 32 | /* Constant Variable */ 33 | /*********************/ 34 | var KEYMAPPING = { 35 | '37': 'left', 36 | '38': 'up', 37 | '39': 'right', 38 | '40': 'down' 39 | }; 40 | 41 | var REVERSE = { 42 | 'left': 'right', 43 | 'up': 'down', 44 | 'right': 'left', 45 | 'down': 'up' 46 | }; 47 | 48 | var EVENT_PREFIX = 'sn:'; 49 | var ID_POOL_PREFIX = 'section-'; 50 | 51 | /********************/ 52 | /* Private Variable */ 53 | /********************/ 54 | var _idPool = 0; 55 | var _ready = false; 56 | var _pause = false; 57 | var _sections = {}; 58 | var _sectionCount = 0; 59 | var _defaultSectionId = ''; 60 | var _lastSectionId = ''; 61 | var _duringFocusChange = false; 62 | 63 | /************/ 64 | /* Polyfill */ 65 | /************/ 66 | var elementMatchesSelector = 67 | Element.prototype.matches || 68 | Element.prototype.matchesSelector || 69 | Element.prototype.mozMatchesSelector || 70 | Element.prototype.webkitMatchesSelector || 71 | Element.prototype.msMatchesSelector || 72 | Element.prototype.oMatchesSelector || 73 | function (selector) { 74 | var matchedNodes = 75 | (this.parentNode || this.document).querySelectorAll(selector); 76 | return [].slice.call(matchedNodes).indexOf(this) >= 0; 77 | }; 78 | 79 | /*****************/ 80 | /* Core Function */ 81 | /*****************/ 82 | function getRect(elem) { 83 | var cr = elem.getBoundingClientRect(); 84 | var rect = { 85 | left: cr.left, 86 | top: cr.top, 87 | right: cr.right, 88 | bottom: cr.bottom, 89 | width: cr.width, 90 | height: cr.height 91 | }; 92 | rect.element = elem; 93 | rect.center = { 94 | x: rect.left + Math.floor(rect.width / 2), 95 | y: rect.top + Math.floor(rect.height / 2) 96 | }; 97 | rect.center.left = rect.center.right = rect.center.x; 98 | rect.center.top = rect.center.bottom = rect.center.y; 99 | return rect; 100 | } 101 | 102 | function partition(rects, targetRect, straightOverlapThreshold) { 103 | var groups = [[], [], [], [], [], [], [], [], []]; 104 | 105 | for (var i = 0; i < rects.length; i++) { 106 | var rect = rects[i]; 107 | var center = rect.center; 108 | var x, y, groupId; 109 | 110 | if (center.x < targetRect.left) { 111 | x = 0; 112 | } else if (center.x <= targetRect.right) { 113 | x = 1; 114 | } else { 115 | x = 2; 116 | } 117 | 118 | if (center.y < targetRect.top) { 119 | y = 0; 120 | } else if (center.y <= targetRect.bottom) { 121 | y = 1; 122 | } else { 123 | y = 2; 124 | } 125 | 126 | groupId = y * 3 + x; 127 | groups[groupId].push(rect); 128 | 129 | if ([0, 2, 6, 8].indexOf(groupId) !== -1) { 130 | var threshold = straightOverlapThreshold; 131 | 132 | if (rect.left <= targetRect.right - targetRect.width * threshold) { 133 | if (groupId === 2) { 134 | groups[1].push(rect); 135 | } else if (groupId === 8) { 136 | groups[7].push(rect); 137 | } 138 | } 139 | 140 | if (rect.right >= targetRect.left + targetRect.width * threshold) { 141 | if (groupId === 0) { 142 | groups[1].push(rect); 143 | } else if (groupId === 6) { 144 | groups[7].push(rect); 145 | } 146 | } 147 | 148 | if (rect.top <= targetRect.bottom - targetRect.height * threshold) { 149 | if (groupId === 6) { 150 | groups[3].push(rect); 151 | } else if (groupId === 8) { 152 | groups[5].push(rect); 153 | } 154 | } 155 | 156 | if (rect.bottom >= targetRect.top + targetRect.height * threshold) { 157 | if (groupId === 0) { 158 | groups[3].push(rect); 159 | } else if (groupId === 2) { 160 | groups[5].push(rect); 161 | } 162 | } 163 | } 164 | } 165 | 166 | return groups; 167 | } 168 | 169 | function generateDistanceFunction(targetRect) { 170 | return { 171 | nearPlumbLineIsBetter: function(rect) { 172 | var d; 173 | if (rect.center.x < targetRect.center.x) { 174 | d = targetRect.center.x - rect.right; 175 | } else { 176 | d = rect.left - targetRect.center.x; 177 | } 178 | return d < 0 ? 0 : d; 179 | }, 180 | nearHorizonIsBetter: function(rect) { 181 | var d; 182 | if (rect.center.y < targetRect.center.y) { 183 | d = targetRect.center.y - rect.bottom; 184 | } else { 185 | d = rect.top - targetRect.center.y; 186 | } 187 | return d < 0 ? 0 : d; 188 | }, 189 | nearTargetLeftIsBetter: function(rect) { 190 | var d; 191 | if (rect.center.x < targetRect.center.x) { 192 | d = targetRect.left - rect.right; 193 | } else { 194 | d = rect.left - targetRect.left; 195 | } 196 | return d < 0 ? 0 : d; 197 | }, 198 | nearTargetTopIsBetter: function(rect) { 199 | var d; 200 | if (rect.center.y < targetRect.center.y) { 201 | d = targetRect.top - rect.bottom; 202 | } else { 203 | d = rect.top - targetRect.top; 204 | } 205 | return d < 0 ? 0 : d; 206 | }, 207 | topIsBetter: function(rect) { 208 | return rect.top; 209 | }, 210 | bottomIsBetter: function(rect) { 211 | return -1 * rect.bottom; 212 | }, 213 | leftIsBetter: function(rect) { 214 | return rect.left; 215 | }, 216 | rightIsBetter: function(rect) { 217 | return -1 * rect.right; 218 | } 219 | }; 220 | } 221 | 222 | function prioritize(priorities) { 223 | var destPriority = null; 224 | for (var i = 0; i < priorities.length; i++) { 225 | if (priorities[i].group.length) { 226 | destPriority = priorities[i]; 227 | break; 228 | } 229 | } 230 | 231 | if (!destPriority) { 232 | return null; 233 | } 234 | 235 | var destDistance = destPriority.distance; 236 | 237 | destPriority.group.sort(function(a, b) { 238 | for (var i = 0; i < destDistance.length; i++) { 239 | var distance = destDistance[i]; 240 | var delta = distance(a) - distance(b); 241 | if (delta) { 242 | return delta; 243 | } 244 | } 245 | return 0; 246 | }); 247 | 248 | return destPriority.group; 249 | } 250 | 251 | function navigate(target, direction, candidates, config) { 252 | if (!target || !direction || !candidates || !candidates.length) { 253 | return null; 254 | } 255 | 256 | var rects = []; 257 | for (var i = 0; i < candidates.length; i++) { 258 | var rect = getRect(candidates[i]); 259 | if (rect) { 260 | rects.push(rect); 261 | } 262 | } 263 | if (!rects.length) { 264 | return null; 265 | } 266 | 267 | var targetRect = getRect(target); 268 | if (!targetRect) { 269 | return null; 270 | } 271 | 272 | var distanceFunction = generateDistanceFunction(targetRect); 273 | 274 | var groups = partition( 275 | rects, 276 | targetRect, 277 | config.straightOverlapThreshold 278 | ); 279 | 280 | var internalGroups = partition( 281 | groups[4], 282 | targetRect.center, 283 | config.straightOverlapThreshold 284 | ); 285 | 286 | var priorities; 287 | 288 | switch (direction) { 289 | case 'left': 290 | priorities = [ 291 | { 292 | group: internalGroups[0].concat(internalGroups[3]) 293 | .concat(internalGroups[6]), 294 | distance: [ 295 | distanceFunction.nearPlumbLineIsBetter, 296 | distanceFunction.topIsBetter 297 | ] 298 | }, 299 | { 300 | group: groups[3], 301 | distance: [ 302 | distanceFunction.nearPlumbLineIsBetter, 303 | distanceFunction.topIsBetter 304 | ] 305 | }, 306 | { 307 | group: groups[0].concat(groups[6]), 308 | distance: [ 309 | distanceFunction.nearHorizonIsBetter, 310 | distanceFunction.rightIsBetter, 311 | distanceFunction.nearTargetTopIsBetter 312 | ] 313 | } 314 | ]; 315 | break; 316 | case 'right': 317 | priorities = [ 318 | { 319 | group: internalGroups[2].concat(internalGroups[5]) 320 | .concat(internalGroups[8]), 321 | distance: [ 322 | distanceFunction.nearPlumbLineIsBetter, 323 | distanceFunction.topIsBetter 324 | ] 325 | }, 326 | { 327 | group: groups[5], 328 | distance: [ 329 | distanceFunction.nearPlumbLineIsBetter, 330 | distanceFunction.topIsBetter 331 | ] 332 | }, 333 | { 334 | group: groups[2].concat(groups[8]), 335 | distance: [ 336 | distanceFunction.nearHorizonIsBetter, 337 | distanceFunction.leftIsBetter, 338 | distanceFunction.nearTargetTopIsBetter 339 | ] 340 | } 341 | ]; 342 | break; 343 | case 'up': 344 | priorities = [ 345 | { 346 | group: internalGroups[0].concat(internalGroups[1]) 347 | .concat(internalGroups[2]), 348 | distance: [ 349 | distanceFunction.nearHorizonIsBetter, 350 | distanceFunction.leftIsBetter 351 | ] 352 | }, 353 | { 354 | group: groups[1], 355 | distance: [ 356 | distanceFunction.nearHorizonIsBetter, 357 | distanceFunction.leftIsBetter 358 | ] 359 | }, 360 | { 361 | group: groups[0].concat(groups[2]), 362 | distance: [ 363 | distanceFunction.nearPlumbLineIsBetter, 364 | distanceFunction.bottomIsBetter, 365 | distanceFunction.nearTargetLeftIsBetter 366 | ] 367 | } 368 | ]; 369 | break; 370 | case 'down': 371 | priorities = [ 372 | { 373 | group: internalGroups[6].concat(internalGroups[7]) 374 | .concat(internalGroups[8]), 375 | distance: [ 376 | distanceFunction.nearHorizonIsBetter, 377 | distanceFunction.leftIsBetter 378 | ] 379 | }, 380 | { 381 | group: groups[7], 382 | distance: [ 383 | distanceFunction.nearHorizonIsBetter, 384 | distanceFunction.leftIsBetter 385 | ] 386 | }, 387 | { 388 | group: groups[6].concat(groups[8]), 389 | distance: [ 390 | distanceFunction.nearPlumbLineIsBetter, 391 | distanceFunction.topIsBetter, 392 | distanceFunction.nearTargetLeftIsBetter 393 | ] 394 | } 395 | ]; 396 | break; 397 | default: 398 | return null; 399 | } 400 | 401 | if (config.straightOnly) { 402 | priorities.pop(); 403 | } 404 | 405 | var destGroup = prioritize(priorities); 406 | if (!destGroup) { 407 | return null; 408 | } 409 | 410 | var dest = null; 411 | if (config.rememberSource && 412 | config.previous && 413 | config.previous.destination === target && 414 | config.previous.reverse === direction) { 415 | for (var j = 0; j < destGroup.length; j++) { 416 | if (destGroup[j].element === config.previous.target) { 417 | dest = destGroup[j].element; 418 | break; 419 | } 420 | } 421 | } 422 | 423 | if (!dest) { 424 | dest = destGroup[0].element; 425 | } 426 | 427 | return dest; 428 | } 429 | 430 | /********************/ 431 | /* Private Function */ 432 | /********************/ 433 | function generateId() { 434 | var id; 435 | while(true) { 436 | id = ID_POOL_PREFIX + String(++_idPool); 437 | if (!_sections[id]) { 438 | break; 439 | } 440 | } 441 | return id; 442 | } 443 | 444 | function parseSelector(selector) { 445 | var result; 446 | if (typeof selector === 'string') { 447 | result = [].slice.call(document.querySelectorAll(selector)); 448 | } else if (typeof selector === 'object' && selector.length) { 449 | result = [].slice.call(selector); 450 | } else if (typeof selector === 'object' && selector.nodeType === 1) { 451 | result = [selector]; 452 | } else { 453 | result = []; 454 | } 455 | return result; 456 | } 457 | 458 | function matchSelector(elem, selector) { 459 | if (typeof selector === 'string') { 460 | return elementMatchesSelector.call(elem, selector); 461 | } else if (typeof selector === 'object' && selector.length) { 462 | return selector.indexOf(elem) >= 0; 463 | } else if (typeof selector === 'object' && selector.nodeType === 1) { 464 | return elem === selector; 465 | } 466 | return false; 467 | } 468 | 469 | function getCurrentFocusedElement() { 470 | var activeElement = document.activeElement; 471 | if (activeElement && activeElement !== document.body) { 472 | return activeElement; 473 | } 474 | } 475 | 476 | function extend(out) { 477 | out = out || {}; 478 | for (var i = 1; i < arguments.length; i++) { 479 | if (!arguments[i]) { 480 | continue; 481 | } 482 | for (var key in arguments[i]) { 483 | if (arguments[i].hasOwnProperty(key) && 484 | arguments[i][key] !== undefined) { 485 | out[key] = arguments[i][key]; 486 | } 487 | } 488 | } 489 | return out; 490 | } 491 | 492 | function exclude(elemList, excludedElem) { 493 | if (!Array.isArray(excludedElem)) { 494 | excludedElem = [excludedElem]; 495 | } 496 | for (var i = 0, index; i < excludedElem.length; i++) { 497 | index = elemList.indexOf(excludedElem[i]); 498 | if (index >= 0) { 499 | elemList.splice(index, 1); 500 | } 501 | } 502 | return elemList; 503 | } 504 | 505 | function isNavigable(elem, sectionId, verifySectionSelector) { 506 | if (! elem || !sectionId || 507 | !_sections[sectionId] || _sections[sectionId].disabled) { 508 | return false; 509 | } 510 | if ((elem.offsetWidth <= 0 && elem.offsetHeight <= 0) || 511 | elem.hasAttribute('disabled')) { 512 | return false; 513 | } 514 | if (verifySectionSelector && 515 | !matchSelector(elem, _sections[sectionId].selector)) { 516 | return false; 517 | } 518 | if (typeof _sections[sectionId].navigableFilter === 'function') { 519 | if (_sections[sectionId].navigableFilter(elem, sectionId) === false) { 520 | return false; 521 | } 522 | } else if (typeof GlobalConfig.navigableFilter === 'function') { 523 | if (GlobalConfig.navigableFilter(elem, sectionId) === false) { 524 | return false; 525 | } 526 | } 527 | return true; 528 | } 529 | 530 | function getSectionId(elem) { 531 | for (var id in _sections) { 532 | if (!_sections[id].disabled && 533 | matchSelector(elem, _sections[id].selector)) { 534 | return id; 535 | } 536 | } 537 | } 538 | 539 | function getSectionNavigableElements(sectionId) { 540 | return parseSelector(_sections[sectionId].selector).filter(function(elem) { 541 | return isNavigable(elem, sectionId); 542 | }); 543 | } 544 | 545 | function getSectionDefaultElement(sectionId) { 546 | var defaultElement = _sections[sectionId].defaultElement; 547 | if (!defaultElement) { 548 | return null; 549 | } 550 | if (typeof defaultElement === 'string') { 551 | defaultElement = parseSelector(defaultElement)[0]; 552 | } 553 | if (isNavigable(defaultElement, sectionId, true)) { 554 | return defaultElement; 555 | } 556 | return null; 557 | } 558 | 559 | function getSectionLastFocusedElement(sectionId) { 560 | var lastFocusedElement = _sections[sectionId] && _sections[sectionId].lastFocusedElement; 561 | if (!isNavigable(lastFocusedElement, sectionId, true)) { 562 | return null; 563 | } 564 | return lastFocusedElement; 565 | } 566 | 567 | function fireEvent(elem, type, details, cancelable) { 568 | if (arguments.length < 4) { 569 | cancelable = true; 570 | } 571 | var evt = document.createEvent('CustomEvent'); 572 | evt.initCustomEvent(EVENT_PREFIX + type, true, cancelable, details); 573 | return elem.dispatchEvent(evt); 574 | } 575 | 576 | function focusElement(elem, sectionId, direction) { 577 | if (!elem) { 578 | return false; 579 | } 580 | 581 | var currentFocusedElement = getCurrentFocusedElement(); 582 | 583 | var silentFocus = function() { 584 | if (currentFocusedElement) { 585 | currentFocusedElement.blur(); 586 | } 587 | elem.focus(); 588 | focusChanged(elem, sectionId); 589 | }; 590 | 591 | if (_duringFocusChange) { 592 | silentFocus(); 593 | return true; 594 | } 595 | 596 | _duringFocusChange = true; 597 | 598 | if (_pause) { 599 | silentFocus(); 600 | _duringFocusChange = false; 601 | return true; 602 | } 603 | 604 | if (currentFocusedElement) { 605 | var unfocusProperties = { 606 | nextElement: elem, 607 | nextSectionId: sectionId, 608 | direction: direction, 609 | native: false 610 | }; 611 | if (!fireEvent(currentFocusedElement, 'willunfocus', unfocusProperties)) { 612 | _duringFocusChange = false; 613 | return false; 614 | } 615 | currentFocusedElement.blur(); 616 | fireEvent(currentFocusedElement, 'unfocused', unfocusProperties, false); 617 | } 618 | 619 | var focusProperties = { 620 | previousElement: currentFocusedElement, 621 | sectionId: sectionId, 622 | direction: direction, 623 | native: false 624 | }; 625 | if (!fireEvent(elem, 'willfocus', focusProperties)) { 626 | _duringFocusChange = false; 627 | return false; 628 | } 629 | elem.focus(); 630 | fireEvent(elem, 'focused', focusProperties, false); 631 | 632 | _duringFocusChange = false; 633 | 634 | focusChanged(elem, sectionId); 635 | return true; 636 | } 637 | 638 | function focusChanged(elem, sectionId) { 639 | if (!sectionId) { 640 | sectionId = getSectionId(elem); 641 | } 642 | if (sectionId) { 643 | _sections[sectionId].lastFocusedElement = elem; 644 | _lastSectionId = sectionId; 645 | } 646 | } 647 | 648 | function focusExtendedSelector(selector, direction) { 649 | if (selector.charAt(0) == '@') { 650 | if (selector.length == 1) { 651 | return focusSection(); 652 | } else { 653 | var sectionId = selector.substr(1); 654 | return focusSection(sectionId); 655 | } 656 | } else { 657 | var next = parseSelector(selector)[0]; 658 | if (next) { 659 | var nextSectionId = getSectionId(next); 660 | if (isNavigable(next, nextSectionId)) { 661 | return focusElement(next, nextSectionId, direction); 662 | } 663 | } 664 | } 665 | return false; 666 | } 667 | 668 | function focusSection(sectionId) { 669 | var range = []; 670 | var addRange = function(id) { 671 | if (id && range.indexOf(id) < 0 && 672 | _sections[id] && !_sections[id].disabled) { 673 | range.push(id); 674 | } 675 | }; 676 | 677 | if (sectionId) { 678 | addRange(sectionId); 679 | } else { 680 | addRange(_defaultSectionId); 681 | addRange(_lastSectionId); 682 | Object.keys(_sections).map(addRange); 683 | } 684 | 685 | for (var i = 0; i < range.length; i++) { 686 | var id = range[i]; 687 | var next; 688 | 689 | if (_sections[id].enterTo == 'last-focused') { 690 | next = getSectionLastFocusedElement(id) || 691 | getSectionDefaultElement(id) || 692 | getSectionNavigableElements(id)[0]; 693 | } else { 694 | next = getSectionDefaultElement(id) || 695 | getSectionLastFocusedElement(id) || 696 | getSectionNavigableElements(id)[0]; 697 | } 698 | 699 | if (next) { 700 | return focusElement(next, id); 701 | } 702 | } 703 | 704 | return false; 705 | } 706 | 707 | function fireNavigatefailed(elem, direction) { 708 | fireEvent(elem, 'navigatefailed', { 709 | direction: direction 710 | }, false); 711 | } 712 | 713 | function gotoLeaveFor(sectionId, direction) { 714 | if (_sections[sectionId].leaveFor && 715 | _sections[sectionId].leaveFor[direction] !== undefined) { 716 | var next = _sections[sectionId].leaveFor[direction]; 717 | 718 | if (typeof next === 'string') { 719 | if (next === '') { 720 | return null; 721 | } 722 | return focusExtendedSelector(next, direction); 723 | } 724 | 725 | var nextSectionId = getSectionId(next); 726 | if (isNavigable(next, nextSectionId)) { 727 | return focusElement(next, nextSectionId, direction); 728 | } 729 | } 730 | return false; 731 | } 732 | 733 | function focusNext(direction, currentFocusedElement, currentSectionId) { 734 | var extSelector = 735 | currentFocusedElement.getAttribute('data-sn-' + direction); 736 | if (typeof extSelector === 'string') { 737 | if (extSelector === '' || 738 | !focusExtendedSelector(extSelector, direction)) { 739 | fireNavigatefailed(currentFocusedElement, direction); 740 | return false; 741 | } 742 | return true; 743 | } 744 | 745 | var sectionNavigableElements = {}; 746 | var allNavigableElements = []; 747 | for (var id in _sections) { 748 | sectionNavigableElements[id] = getSectionNavigableElements(id); 749 | allNavigableElements = 750 | allNavigableElements.concat(sectionNavigableElements[id]); 751 | } 752 | 753 | var config = extend({}, GlobalConfig, _sections[currentSectionId]); 754 | var next; 755 | 756 | if (config.restrict == 'self-only' || config.restrict == 'self-first') { 757 | var currentSectionNavigableElements = 758 | sectionNavigableElements[currentSectionId]; 759 | 760 | next = navigate( 761 | currentFocusedElement, 762 | direction, 763 | exclude(currentSectionNavigableElements, currentFocusedElement), 764 | config 765 | ); 766 | 767 | if (!next && config.restrict == 'self-first') { 768 | next = navigate( 769 | currentFocusedElement, 770 | direction, 771 | exclude(allNavigableElements, currentSectionNavigableElements), 772 | config 773 | ); 774 | } 775 | } else { 776 | next = navigate( 777 | currentFocusedElement, 778 | direction, 779 | exclude(allNavigableElements, currentFocusedElement), 780 | config 781 | ); 782 | } 783 | 784 | if (next) { 785 | _sections[currentSectionId].previous = { 786 | target: currentFocusedElement, 787 | destination: next, 788 | reverse: REVERSE[direction] 789 | }; 790 | 791 | var nextSectionId = getSectionId(next); 792 | 793 | if (currentSectionId != nextSectionId) { 794 | var result = gotoLeaveFor(currentSectionId, direction); 795 | if (result) { 796 | return true; 797 | } else if (result === null) { 798 | fireNavigatefailed(currentFocusedElement, direction); 799 | return false; 800 | } 801 | 802 | var enterToElement; 803 | switch (_sections[nextSectionId].enterTo) { 804 | case 'last-focused': 805 | enterToElement = getSectionLastFocusedElement(nextSectionId) || 806 | getSectionDefaultElement(nextSectionId); 807 | break; 808 | case 'default-element': 809 | enterToElement = getSectionDefaultElement(nextSectionId); 810 | break; 811 | } 812 | if (enterToElement) { 813 | next = enterToElement; 814 | } 815 | } 816 | 817 | return focusElement(next, nextSectionId, direction); 818 | } else if (gotoLeaveFor(currentSectionId, direction)) { 819 | return true; 820 | } 821 | 822 | fireNavigatefailed(currentFocusedElement, direction); 823 | return false; 824 | } 825 | 826 | function onKeyDown(evt) { 827 | if (!_sectionCount || _pause || 828 | evt.altKey || evt.ctrlKey || evt.metaKey || evt.shiftKey) { 829 | return; 830 | } 831 | 832 | var currentFocusedElement; 833 | var preventDefault = function() { 834 | evt.preventDefault(); 835 | evt.stopPropagation(); 836 | return false; 837 | }; 838 | 839 | var direction = KEYMAPPING[evt.keyCode]; 840 | if (!direction) { 841 | if (evt.keyCode == 13) { 842 | currentFocusedElement = getCurrentFocusedElement(); 843 | if (currentFocusedElement && getSectionId(currentFocusedElement)) { 844 | if (!fireEvent(currentFocusedElement, 'enter-down')) { 845 | return preventDefault(); 846 | } 847 | } 848 | } 849 | return; 850 | } 851 | 852 | currentFocusedElement = getCurrentFocusedElement(); 853 | 854 | if (!currentFocusedElement) { 855 | if (_lastSectionId) { 856 | currentFocusedElement = getSectionLastFocusedElement(_lastSectionId); 857 | } 858 | if (!currentFocusedElement) { 859 | focusSection(); 860 | return preventDefault(); 861 | } 862 | } 863 | 864 | var currentSectionId = getSectionId(currentFocusedElement); 865 | if (!currentSectionId) { 866 | return; 867 | } 868 | 869 | var willmoveProperties = { 870 | direction: direction, 871 | sectionId: currentSectionId, 872 | cause: 'keydown' 873 | }; 874 | 875 | if (fireEvent(currentFocusedElement, 'willmove', willmoveProperties)) { 876 | focusNext(direction, currentFocusedElement, currentSectionId); 877 | } 878 | 879 | return preventDefault(); 880 | } 881 | 882 | function onKeyUp(evt) { 883 | if (evt.altKey || evt.ctrlKey || evt.metaKey || evt.shiftKey) { 884 | return 885 | } 886 | if (!_pause && _sectionCount && evt.keyCode == 13) { 887 | var currentFocusedElement = getCurrentFocusedElement(); 888 | if (currentFocusedElement && getSectionId(currentFocusedElement)) { 889 | if (!fireEvent(currentFocusedElement, 'enter-up')) { 890 | evt.preventDefault(); 891 | evt.stopPropagation(); 892 | } 893 | } 894 | } 895 | } 896 | 897 | function onFocus(evt) { 898 | var target = evt.target; 899 | if (target !== window && target !== document && 900 | _sectionCount && !_duringFocusChange) { 901 | var sectionId = getSectionId(target); 902 | if (sectionId) { 903 | if (_pause) { 904 | focusChanged(target, sectionId); 905 | return; 906 | } 907 | 908 | var focusProperties = { 909 | sectionId: sectionId, 910 | native: true 911 | }; 912 | 913 | if (!fireEvent(target, 'willfocus', focusProperties)) { 914 | _duringFocusChange = true; 915 | target.blur(); 916 | _duringFocusChange = false; 917 | } else { 918 | fireEvent(target, 'focused', focusProperties, false); 919 | focusChanged(target, sectionId); 920 | } 921 | } 922 | } 923 | } 924 | 925 | function onBlur(evt) { 926 | var target = evt.target; 927 | if (target !== window && target !== document && !_pause && 928 | _sectionCount && !_duringFocusChange && getSectionId(target)) { 929 | var unfocusProperties = { 930 | native: true 931 | }; 932 | if (!fireEvent(target, 'willunfocus', unfocusProperties)) { 933 | _duringFocusChange = true; 934 | setTimeout(function() { 935 | target.focus(); 936 | _duringFocusChange = false; 937 | }); 938 | } else { 939 | fireEvent(target, 'unfocused', unfocusProperties, false); 940 | } 941 | } 942 | } 943 | 944 | /*******************/ 945 | /* Public Function */ 946 | /*******************/ 947 | var SpatialNavigation = { 948 | init: function() { 949 | if (!_ready) { 950 | window.addEventListener('keydown', onKeyDown); 951 | window.addEventListener('keyup', onKeyUp); 952 | window.addEventListener('focus', onFocus, true); 953 | window.addEventListener('blur', onBlur, true); 954 | _ready = true; 955 | } 956 | }, 957 | 958 | uninit: function() { 959 | window.removeEventListener('blur', onBlur, true); 960 | window.removeEventListener('focus', onFocus, true); 961 | window.removeEventListener('keyup', onKeyUp); 962 | window.removeEventListener('keydown', onKeyDown); 963 | SpatialNavigation.clear(); 964 | _idPool = 0; 965 | _ready = false; 966 | }, 967 | 968 | clear: function() { 969 | _sections = {}; 970 | _sectionCount = 0; 971 | _defaultSectionId = ''; 972 | _lastSectionId = ''; 973 | _duringFocusChange = false; 974 | }, 975 | 976 | // set(); 977 | // set(, ); 978 | set: function() { 979 | var sectionId, config; 980 | 981 | if (typeof arguments[0] === 'object') { 982 | config = arguments[0]; 983 | } else if (typeof arguments[0] === 'string' && 984 | typeof arguments[1] === 'object') { 985 | sectionId = arguments[0]; 986 | config = arguments[1]; 987 | if (!_sections[sectionId]) { 988 | throw new Error('Section "' + sectionId + '" doesn\'t exist!'); 989 | } 990 | } else { 991 | return; 992 | } 993 | 994 | for (var key in config) { 995 | if (GlobalConfig[key] !== undefined) { 996 | if (sectionId) { 997 | _sections[sectionId][key] = config[key]; 998 | } else if (config[key] !== undefined) { 999 | GlobalConfig[key] = config[key]; 1000 | } 1001 | } 1002 | } 1003 | 1004 | if (sectionId) { 1005 | // remove "undefined" items 1006 | _sections[sectionId] = extend({}, _sections[sectionId]); 1007 | } 1008 | }, 1009 | 1010 | // add(); 1011 | // add(, ); 1012 | add: function() { 1013 | var sectionId; 1014 | var config = {}; 1015 | 1016 | if (typeof arguments[0] === 'object') { 1017 | config = arguments[0]; 1018 | } else if (typeof arguments[0] === 'string' && 1019 | typeof arguments[1] === 'object') { 1020 | sectionId = arguments[0]; 1021 | config = arguments[1]; 1022 | } 1023 | 1024 | if (!sectionId) { 1025 | sectionId = (typeof config.id === 'string') ? config.id : generateId(); 1026 | } 1027 | 1028 | if (_sections[sectionId]) { 1029 | throw new Error('Section "' + sectionId + '" has already existed!'); 1030 | } 1031 | 1032 | _sections[sectionId] = {}; 1033 | _sectionCount++; 1034 | 1035 | SpatialNavigation.set(sectionId, config); 1036 | 1037 | return sectionId; 1038 | }, 1039 | 1040 | remove: function(sectionId) { 1041 | if (!sectionId || typeof sectionId !== 'string') { 1042 | throw new Error('Please assign the "sectionId"!'); 1043 | } 1044 | if (_sections[sectionId]) { 1045 | _sections[sectionId] = undefined; 1046 | _sections = extend({}, _sections); 1047 | _sectionCount--; 1048 | return true; 1049 | } 1050 | return false; 1051 | }, 1052 | 1053 | disable: function(sectionId) { 1054 | if (_sections[sectionId]) { 1055 | _sections[sectionId].disabled = true; 1056 | return true; 1057 | } 1058 | return false; 1059 | }, 1060 | 1061 | enable: function(sectionId) { 1062 | if (_sections[sectionId]) { 1063 | _sections[sectionId].disabled = false; 1064 | return true; 1065 | } 1066 | return false; 1067 | }, 1068 | 1069 | pause: function() { 1070 | _pause = true; 1071 | }, 1072 | 1073 | resume: function() { 1074 | _pause = false; 1075 | }, 1076 | 1077 | // focus([silent]) 1078 | // focus(, [silent]) 1079 | // focus(, [silent]) 1080 | // Note: "silent" is optional and default to false 1081 | focus: function(elem, silent) { 1082 | var result = false; 1083 | 1084 | if (silent === undefined && typeof elem === 'boolean') { 1085 | silent = elem; 1086 | elem = undefined; 1087 | } 1088 | 1089 | var autoPause = !_pause && silent; 1090 | 1091 | if (autoPause) { 1092 | SpatialNavigation.pause(); 1093 | } 1094 | 1095 | if (!elem) { 1096 | result = focusSection(); 1097 | } else { 1098 | if (typeof elem === 'string') { 1099 | if (_sections[elem]) { 1100 | result = focusSection(elem); 1101 | } else { 1102 | result = focusExtendedSelector(elem); 1103 | } 1104 | } else { 1105 | var nextSectionId = getSectionId(elem); 1106 | if (isNavigable(elem, nextSectionId)) { 1107 | result = focusElement(elem, nextSectionId); 1108 | } 1109 | } 1110 | } 1111 | 1112 | if (autoPause) { 1113 | SpatialNavigation.resume(); 1114 | } 1115 | 1116 | return result; 1117 | }, 1118 | 1119 | // move() 1120 | // move(, ) 1121 | move: function(direction, selector) { 1122 | direction = direction.toLowerCase(); 1123 | if (!REVERSE[direction]) { 1124 | return false; 1125 | } 1126 | 1127 | var elem = selector ? 1128 | parseSelector(selector)[0] : getCurrentFocusedElement(); 1129 | if (!elem) { 1130 | return false; 1131 | } 1132 | 1133 | var sectionId = getSectionId(elem); 1134 | if (!sectionId) { 1135 | return false; 1136 | } 1137 | 1138 | var willmoveProperties = { 1139 | direction: direction, 1140 | sectionId: sectionId, 1141 | cause: 'api' 1142 | }; 1143 | 1144 | if (!fireEvent(elem, 'willmove', willmoveProperties)) { 1145 | return false; 1146 | } 1147 | 1148 | return focusNext(direction, elem, sectionId); 1149 | }, 1150 | 1151 | // makeFocusable() 1152 | // makeFocusable() 1153 | makeFocusable: function(sectionId) { 1154 | var doMakeFocusable = function(section) { 1155 | var tabIndexIgnoreList = section.tabIndexIgnoreList !== undefined ? 1156 | section.tabIndexIgnoreList : GlobalConfig.tabIndexIgnoreList; 1157 | parseSelector(section.selector).forEach(function(elem) { 1158 | if (!matchSelector(elem, tabIndexIgnoreList)) { 1159 | if (!elem.getAttribute('tabindex')) { 1160 | elem.setAttribute('tabindex', '-1'); 1161 | } 1162 | } 1163 | }); 1164 | }; 1165 | 1166 | if (sectionId) { 1167 | if (_sections[sectionId]) { 1168 | doMakeFocusable(_sections[sectionId]); 1169 | } else { 1170 | throw new Error('Section "' + sectionId + '" doesn\'t exist!'); 1171 | } 1172 | } else { 1173 | for (var id in _sections) { 1174 | doMakeFocusable(_sections[id]); 1175 | } 1176 | } 1177 | }, 1178 | 1179 | setDefaultSection: function(sectionId) { 1180 | if (!sectionId) { 1181 | _defaultSectionId = ''; 1182 | } else if (!_sections[sectionId]) { 1183 | throw new Error('Section "' + sectionId + '" doesn\'t exist!'); 1184 | } else { 1185 | _defaultSectionId = sectionId; 1186 | } 1187 | }, 1188 | 1189 | getSectionId: getSectionId 1190 | }; 1191 | 1192 | export default SpatialNavigation 1193 | 1194 | /*eslint-enable*/ 1195 | -------------------------------------------------------------------------------- /src/spatial-navigation.js: -------------------------------------------------------------------------------- 1 | import Navigation from './navigation'; 2 | 3 | class SpatialNavigation { 4 | constructor() { 5 | this.handleFocused = this.handleFocused.bind(this); 6 | 7 | this.destroy(); 8 | this.bindFocusEvent(); 9 | } 10 | 11 | init(updateState) { 12 | if (!this.setState) { 13 | this.setState = updateState; 14 | } 15 | 16 | Navigation.init(); 17 | Navigation.focus(); 18 | this.bindFocusEvent(); 19 | } 20 | 21 | destroy() { 22 | this.focusedPath = null; 23 | this.setState = null; 24 | 25 | Navigation.uninit(); 26 | this.unbindFocusEvent(); 27 | } 28 | 29 | bindFocusEvent() { 30 | if (!this.listening) { 31 | this.listening = true; 32 | document.addEventListener('sn:focused', this.handleFocused); 33 | } 34 | } 35 | 36 | unbindFocusEvent() { 37 | document.removeEventListener('sn:focused', this.handleFocused); 38 | this.listening = false; 39 | } 40 | 41 | handleFocused(ev) { 42 | if (this.focusedPath !== ev.detail.sectionId) { 43 | this.setState(ev.detail.sectionId); 44 | Navigation.focus(ev.detail.sectionId); 45 | } 46 | } 47 | 48 | getCurrentFocusedPath() { 49 | return this.focusedPath; 50 | } 51 | 52 | setCurrentFocusedPath(focusPath) { 53 | this.focusedPath = focusPath; 54 | Navigation.focus(focusPath); 55 | } 56 | 57 | addFocusable(focusDOMElement, { focusPath, onEnterPressHandler }) { 58 | if (!focusDOMElement || Navigation.getSectionId(focusDOMElement)) { 59 | return; 60 | } 61 | 62 | this.removeFocusable(focusDOMElement, { onEnterPressHandler }); 63 | 64 | const params = [{ selector: focusDOMElement }]; 65 | if (focusPath) { 66 | params.unshift(focusPath); 67 | } 68 | 69 | focusDOMElement.addEventListener('sn:enter-down', onEnterPressHandler); 70 | const sectionId = Navigation.add(...params); 71 | Navigation.makeFocusable(sectionId); 72 | } 73 | 74 | removeFocusable(focusDOMElement, { onEnterPressHandler }) { 75 | const sectionId = Navigation.getSectionId(focusDOMElement); 76 | if (!sectionId) { 77 | return; 78 | } 79 | 80 | Navigation.remove(sectionId); 81 | focusDOMElement.removeEventListener('sn:enter-down', onEnterPressHandler); 82 | } 83 | } 84 | 85 | export default new SpatialNavigation(); 86 | -------------------------------------------------------------------------------- /src/with-focusable.js: -------------------------------------------------------------------------------- 1 | import ReactTV from 'react-tv'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import compose from 'recompose/compose'; 5 | import mapProps from 'recompose/mapProps'; 6 | import lifecycle from 'recompose/lifecycle'; 7 | import getContext from 'recompose/getContext'; 8 | import setPropTypes from 'recompose/setPropTypes'; 9 | import withHandlers from 'recompose/withHandlers'; 10 | 11 | import SpatialNavigation from './spatial-navigation'; 12 | 13 | const withFocusable = compose( 14 | setPropTypes({ 15 | focusPath: PropTypes.string.isRequired, 16 | }), 17 | getContext({ 18 | setFocus: PropTypes.func, 19 | currentFocusPath: PropTypes.string, 20 | }), 21 | mapProps(({ 22 | currentFocusPath, 23 | focusPath, 24 | setFocus = () => {}, 25 | ...props 26 | }) => ({ 27 | focused: currentFocusPath === focusPath, 28 | setFocus: setFocus.bind(null, focusPath), 29 | focusPath, 30 | ...props, 31 | })), 32 | withHandlers({ 33 | onEnterPressHandler: ({ onEnterPress = () => {} }) => onEnterPress, 34 | }), 35 | lifecycle({ 36 | addFocusable() { 37 | const { focusPath, onEnterPressHandler } = this.props; 38 | SpatialNavigation.addFocusable( 39 | ReactTV.findDOMNode(this), 40 | { focusPath, onEnterPressHandler } 41 | ); 42 | }, 43 | componentDidMount() { 44 | this.addFocusable(); 45 | }, 46 | componentDidUpdate() { 47 | this.addFocusable(); 48 | }, 49 | componentWillUnmount() { 50 | SpatialNavigation.removeFocusable( 51 | ReactTV.findDOMNode(this), 52 | { onEnterPressHandler: this.props.onEnterPressHandler } 53 | ); 54 | }, 55 | }), 56 | ); 57 | 58 | export default withFocusable; 59 | -------------------------------------------------------------------------------- /src/with-navigation.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | import compose from 'recompose/compose'; 4 | import lifecycle from 'recompose/lifecycle'; 5 | import withContext from 'recompose/withContext'; 6 | import withStateHandlers from 'recompose/withStateHandlers'; 7 | 8 | import SpatialNavigation from './spatial-navigation'; 9 | 10 | const withNavigation = compose( 11 | withStateHandlers( 12 | { 13 | currentFocusPath: SpatialNavigation.getCurrentFocusedPath(), 14 | }, 15 | { 16 | setFocus: ({ currentFocusPath }) => (focusPath, overwriteFocusPath) => { 17 | const newFocusPath = overwriteFocusPath || focusPath; 18 | if (currentFocusPath !== newFocusPath) { 19 | SpatialNavigation.setCurrentFocusedPath(newFocusPath); 20 | return { currentFocusPath: newFocusPath }; 21 | } 22 | }, 23 | } 24 | ), 25 | withContext( 26 | { setFocus: PropTypes.func, currentFocusPath: PropTypes.string }, 27 | ({ setFocus, currentFocusPath }) => ({ setFocus, currentFocusPath }), 28 | ), 29 | lifecycle({ 30 | componentDidMount() { 31 | SpatialNavigation.init(this.props.setFocus); 32 | }, 33 | componentDidUpdate() { 34 | SpatialNavigation.init(this.props.setFocus); 35 | }, 36 | componentWillUnmount() { 37 | SpatialNavigation.destroy(); 38 | }, 39 | }), 40 | ); 41 | 42 | export default withNavigation; 43 | --------------------------------------------------------------------------------