// Remote-control navigation. // // webOS TVs have no tab key and no pointer worth designing for, so focus moves // geometrically: pressing Right picks the nearest focusable element whose // centre lies to the right, biased towards ones on the same row. Anything with // the `focusable` class joins in, which keeps the markup free of tab indices. (function (global) { 'use strict'; var KEY = { LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40, ENTER: 13, BACK: 461, ESCAPE: 27, RED: 403, GREEN: 404, YELLOW: 405, BLUE: 406, }; function visible(el) { if (el.disabled || el.classList.contains('hidden')) { return false; } var rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function candidates() { var all = document.querySelectorAll('.focusable'); var out = []; for (var i = 0; i < all.length; i++) { if (visible(all[i])) { out.push(all[i]); } } return out; } function centre(el) { var r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, rect: r }; } // Minimum travel before a neighbour counts as being in that direction, so // elements that merely overlap slightly do not steal focus. var MIN_TRAVEL = 4; // How much drifting off-axis costs. High enough that a row of buttons is // traversed in order rather than diagonally. var ACROSS_PENALTY = 3; function move(direction) { var current = document.activeElement; var list = candidates(); if (!list.length) { return; } if (!current || !current.classList || !current.classList.contains('focusable')) { focus(list[0]); return; } var from = centre(current); var best = null; var bestScore = Infinity; for (var i = 0; i < list.length; i++) { if (list[i] === current) { continue; } var to = centre(list[i]); var dx = to.x - from.x; var dy = to.y - from.y; var along; var across; if (direction === 'right') { along = dx; across = dy; } else if (direction === 'left') { along = -dx; across = dy; } else if (direction === 'down') { along = dy; across = dx; } else { along = -dy; across = dx; } if (along < MIN_TRAVEL) { continue; } var s = along + Math.abs(across) * ACROSS_PENALTY; if (s < bestScore) { bestScore = s; best = list[i]; } } if (best) { focus(best); } } function focus(el) { if (!el) { return; } el.focus(); // Keep the focused control clear of the sticky header. if (el.scrollIntoView) { var rect = el.getBoundingClientRect(); if (rect.top < 120 || rect.bottom > global.innerHeight - 40) { el.scrollIntoView({ block: 'center' }); } } } function focusFirstIn(container) { if (!container) { return; } var list = container.querySelectorAll('.focusable'); for (var i = 0; i < list.length; i++) { if (visible(list[i])) { focus(list[i]); return; } } } var backHandler = null; document.addEventListener('keydown', function (e) { switch (e.keyCode) { case KEY.LEFT: move('left'); break; case KEY.RIGHT: move('right'); break; case KEY.UP: move('up'); break; case KEY.DOWN: move('down'); break; case KEY.BACK: case KEY.ESCAPE: if (backHandler && backHandler()) { break; } // Nothing wanted the Back press: leave the app the way the platform // expects rather than trapping the user inside it. if (global.webOS && global.webOS.platformBack) { global.webOS.platformBack(); } else { global.close(); } break; default: return; } e.preventDefault(); }); global.Nav = { KEY: KEY, focus: focus, focusFirstIn: focusFirstIn, onBack: function (fn) { backHandler = fn; }, }; })(window);