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