Instantly share code, notes, and snippets.
KotKarol/spatial-nav-fork-webos Secret
Created
March 28, 2023 09:15
-
Star
(1)
1
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
Save KotKarol/3d9b0856073405fb581410f9c06dd0f8 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* A javascript-based implementation of Spatial Navigation. | |
* | |
* Copyright (c) 2022 Luke Chang. | |
* https://github.com/luke-chang/js-spatial-navigation | |
* | |
* Licensed under the MPL 2.0. | |
*/ | |
;(function ($) { | |
'use strict' | |
/************************/ | |
/* Global Configuration */ | |
/************************/ | |
// Note: an <extSelector> can be one of following types: | |
// - a valid selector string for "querySelectorAll" or jQuery (if it exists) | |
// - a NodeList or an array containing DOM elements | |
// - a single DOM element | |
// - a jQuery object | |
// - a string "@<sectionId>" to indicate the specified section | |
// - a string "@" to indicate the default section | |
var GlobalConfig = { | |
selector: '', // can be a valid <extSelector> except "@" syntax. | |
straightOnly: false, | |
straightOverlapThreshold: 0.5, | |
rememberSource: false, | |
disabled: false, | |
defaultElement: '', // <extSelector> except "@" syntax. | |
enterTo: '', // '', 'last-focused', 'default-element' | |
leaveFor: null, // {left: <extSelector>, right: <extSelector>, | |
// up: <extSelector>, down: <extSelector>} | |
restrict: 'self-first', // 'self-first', 'self-only', 'none' | |
tabIndexIgnoreList: | |
'a, input, select, textarea, button, iframe, [contentEditable=true]', | |
navigableFilter: null, | |
} | |
var KEYMAPPING = { | |
37: 'left', | |
38: 'up', | |
39: 'right', | |
40: 'down', | |
} | |
var RC_ENTER = 13 | |
/*********************/ | |
/* Constant Variable */ | |
/*********************/ | |
var REVERSE = { | |
left: 'right', | |
up: 'down', | |
right: 'left', | |
down: 'up', | |
} | |
var EVENT_PREFIX = 'sn:' | |
var ID_POOL_PREFIX = 'section-' | |
/********************/ | |
/* Private Variable */ | |
/********************/ | |
var _idPool = 0 | |
var _ready = false | |
var _pause = false | |
var _sections = {} | |
var _sectionCount = 0 | |
var _defaultSectionId = '' | |
var _lastSectionId = '' | |
var _duringFocusChange = false | |
/************/ | |
/* Polyfill */ | |
/************/ | |
var elementMatchesSelector = | |
Element.prototype.matches || | |
Element.prototype.matchesSelector || | |
Element.prototype.mozMatchesSelector || | |
Element.prototype.webkitMatchesSelector || | |
Element.prototype.msMatchesSelector || | |
Element.prototype.oMatchesSelector || | |
function (selector) { | |
var matchedNodes = (this.parentNode || this.document).querySelectorAll( | |
selector | |
) | |
return [].slice.call(matchedNodes).indexOf(this) >= 0 | |
} | |
/*****************/ | |
/* Core Function */ | |
/*****************/ | |
function getRect(elem) { | |
var cr = elem.getBoundingClientRect() | |
var rect = { | |
left: cr.left, | |
top: cr.top, | |
right: cr.right, | |
bottom: cr.bottom, | |
width: cr.width, | |
height: cr.height, | |
} | |
rect.element = elem | |
rect.center = { | |
x: rect.left + Math.floor(rect.width / 2), | |
y: rect.top + Math.floor(rect.height / 2), | |
} | |
rect.center.left = rect.center.right = rect.center.x | |
rect.center.top = rect.center.bottom = rect.center.y | |
return rect | |
} | |
function partition(rects, targetRect, straightOverlapThreshold) { | |
var groups = [[], [], [], [], [], [], [], [], []] | |
for (var i = 0; i < rects.length; i++) { | |
var rect = rects[i] | |
var center = rect.center | |
var x, y, groupId | |
if (center.x < targetRect.left) { | |
x = 0 | |
} else if (center.x <= targetRect.right) { | |
x = 1 | |
} else { | |
x = 2 | |
} | |
if (center.y < targetRect.top) { | |
y = 0 | |
} else if (center.y <= targetRect.bottom) { | |
y = 1 | |
} else { | |
y = 2 | |
} | |
groupId = y * 3 + x | |
groups[groupId].push(rect) | |
if ([0, 2, 6, 8].indexOf(groupId) !== -1) { | |
var threshold = straightOverlapThreshold | |
if (rect.left <= targetRect.right - targetRect.width * threshold) { | |
if (groupId === 2) { | |
groups[1].push(rect) | |
} else if (groupId === 8) { | |
groups[7].push(rect) | |
} | |
} | |
if (rect.right >= targetRect.left + targetRect.width * threshold) { | |
if (groupId === 0) { | |
groups[1].push(rect) | |
} else if (groupId === 6) { | |
groups[7].push(rect) | |
} | |
} | |
if (rect.top <= targetRect.bottom - targetRect.height * threshold) { | |
if (groupId === 6) { | |
groups[3].push(rect) | |
} else if (groupId === 8) { | |
groups[5].push(rect) | |
} | |
} | |
if (rect.bottom >= targetRect.top + targetRect.height * threshold) { | |
if (groupId === 0) { | |
groups[3].push(rect) | |
} else if (groupId === 2) { | |
groups[5].push(rect) | |
} | |
} | |
} | |
} | |
return groups | |
} | |
function generateDistanceFunction(targetRect) { | |
return { | |
nearPlumbLineIsBetter: function (rect) { | |
var d | |
if (rect.center.x < targetRect.center.x) { | |
d = targetRect.center.x - rect.right | |
} else { | |
d = rect.left - targetRect.center.x | |
} | |
return d < 0 ? 0 : d | |
}, | |
nearHorizonIsBetter: function (rect) { | |
var d | |
if (rect.center.y < targetRect.center.y) { | |
d = targetRect.center.y - rect.bottom | |
} else { | |
d = rect.top - targetRect.center.y | |
} | |
return d < 0 ? 0 : d | |
}, | |
nearTargetLeftIsBetter: function (rect) { | |
var d | |
if (rect.center.x < targetRect.center.x) { | |
d = targetRect.left - rect.right | |
} else { | |
d = rect.left - targetRect.left | |
} | |
return d < 0 ? 0 : d | |
}, | |
nearTargetTopIsBetter: function (rect) { | |
var d | |
if (rect.center.y < targetRect.center.y) { | |
d = targetRect.top - rect.bottom | |
} else { | |
d = rect.top - targetRect.top | |
} | |
return d < 0 ? 0 : d | |
}, | |
topIsBetter: function (rect) { | |
return rect.top | |
}, | |
bottomIsBetter: function (rect) { | |
return -1 * rect.bottom | |
}, | |
leftIsBetter: function (rect) { | |
return rect.left | |
}, | |
rightIsBetter: function (rect) { | |
return -1 * rect.right | |
}, | |
} | |
} | |
function prioritize(priorities) { | |
var destPriority = null | |
for (var i = 0; i < priorities.length; i++) { | |
if (priorities[i].group.length) { | |
destPriority = priorities[i] | |
break | |
} | |
} | |
if (!destPriority) { | |
return null | |
} | |
var destDistance = destPriority.distance | |
destPriority.group.sort(function (a, b) { | |
for (var i = 0; i < destDistance.length; i++) { | |
var distance = destDistance[i] | |
var delta = distance(a) - distance(b) | |
if (delta) { | |
return delta | |
} | |
} | |
return 0 | |
}) | |
return destPriority.group | |
} | |
function navigate(target, direction, candidates, config) { | |
if (!target || !direction || !candidates || !candidates.length) { | |
return null | |
} | |
var rects = [] | |
for (var i = 0; i < candidates.length; i++) { | |
var rect = getRect(candidates[i]) | |
if (rect) { | |
rects.push(rect) | |
} | |
} | |
if (!rects.length) { | |
return null | |
} | |
var targetRect = getRect(target) | |
if (!targetRect) { | |
return null | |
} | |
var distanceFunction = generateDistanceFunction(targetRect) | |
var groups = partition(rects, targetRect, config.straightOverlapThreshold) | |
var internalGroups = partition( | |
groups[4], | |
targetRect.center, | |
config.straightOverlapThreshold | |
) | |
var priorities | |
switch (direction) { | |
case 'left': | |
priorities = [ | |
{ | |
group: internalGroups[0] | |
.concat(internalGroups[3]) | |
.concat(internalGroups[6]), | |
distance: [ | |
distanceFunction.nearPlumbLineIsBetter, | |
distanceFunction.topIsBetter, | |
], | |
}, | |
{ | |
group: groups[3], | |
distance: [ | |
distanceFunction.nearPlumbLineIsBetter, | |
distanceFunction.topIsBetter, | |
], | |
}, | |
{ | |
group: groups[0].concat(groups[6]), | |
distance: [ | |
distanceFunction.nearHorizonIsBetter, | |
distanceFunction.rightIsBetter, | |
distanceFunction.nearTargetTopIsBetter, | |
], | |
}, | |
] | |
break | |
case 'right': | |
priorities = [ | |
{ | |
group: internalGroups[2] | |
.concat(internalGroups[5]) | |
.concat(internalGroups[8]), | |
distance: [ | |
distanceFunction.nearPlumbLineIsBetter, | |
distanceFunction.topIsBetter, | |
], | |
}, | |
{ | |
group: groups[5], | |
distance: [ | |
distanceFunction.nearPlumbLineIsBetter, | |
distanceFunction.topIsBetter, | |
], | |
}, | |
{ | |
group: groups[2].concat(groups[8]), | |
distance: [ | |
distanceFunction.nearHorizonIsBetter, | |
distanceFunction.leftIsBetter, | |
distanceFunction.nearTargetTopIsBetter, | |
], | |
}, | |
] | |
break | |
case 'up': | |
priorities = [ | |
{ | |
group: internalGroups[0] | |
.concat(internalGroups[1]) | |
.concat(internalGroups[2]), | |
distance: [ | |
distanceFunction.nearHorizonIsBetter, | |
distanceFunction.leftIsBetter, | |
], | |
}, | |
{ | |
group: groups[1], | |
distance: [ | |
distanceFunction.nearHorizonIsBetter, | |
distanceFunction.leftIsBetter, | |
], | |
}, | |
{ | |
group: groups[0].concat(groups[2]), | |
distance: [ | |
distanceFunction.nearPlumbLineIsBetter, | |
distanceFunction.bottomIsBetter, | |
distanceFunction.nearTargetLeftIsBetter, | |
], | |
}, | |
] | |
break | |
case 'down': | |
priorities = [ | |
{ | |
group: internalGroups[6] | |
.concat(internalGroups[7]) | |
.concat(internalGroups[8]), | |
distance: [ | |
distanceFunction.nearHorizonIsBetter, | |
distanceFunction.leftIsBetter, | |
], | |
}, | |
{ | |
group: groups[7], | |
distance: [ | |
distanceFunction.nearHorizonIsBetter, | |
distanceFunction.leftIsBetter, | |
], | |
}, | |
{ | |
group: groups[6].concat(groups[8]), | |
distance: [ | |
distanceFunction.nearPlumbLineIsBetter, | |
distanceFunction.topIsBetter, | |
distanceFunction.nearTargetLeftIsBetter, | |
], | |
}, | |
] | |
break | |
default: | |
return null | |
} | |
if (config.straightOnly) { | |
priorities.pop() | |
} | |
var destGroup = prioritize(priorities) | |
if (!destGroup) { | |
return null | |
} | |
var dest = null | |
if ( | |
config.rememberSource && | |
config.previous && | |
config.previous.destination === target && | |
config.previous.reverse === direction | |
) { | |
for (var j = 0; j < destGroup.length; j++) { | |
if (destGroup[j].element === config.previous.target) { | |
dest = destGroup[j].element | |
break | |
} | |
} | |
} | |
if (!dest) { | |
dest = destGroup[0].element | |
} | |
return dest | |
} | |
/********************/ | |
/* Private Function */ | |
/********************/ | |
function generateId() { | |
var id | |
while (true) { | |
id = ID_POOL_PREFIX + String(++_idPool) | |
if (!_sections[id]) { | |
break | |
} | |
} | |
return id | |
} | |
function parseSelector(selector) { | |
var result = [] | |
try { | |
if (selector) { | |
if ($) { | |
result = $(selector).get() | |
} else if (typeof selector === 'string') { | |
result = [].slice.call(document.querySelectorAll(selector)) | |
} else if (typeof selector === 'object' && selector.length) { | |
result = [].slice.call(selector) | |
} else if (typeof selector === 'object' && selector.nodeType === 1) { | |
result = [selector] | |
} | |
} | |
} catch (err) { | |
console.error(err) | |
} | |
return result | |
} | |
function matchSelector(elem, selector) { | |
if ($) { | |
return $(elem).is(selector) | |
} else if (typeof selector === 'string') { | |
return elementMatchesSelector.call(elem, selector) | |
} else if (typeof selector === 'object' && selector.length) { | |
return selector.indexOf(elem) >= 0 | |
} else if (typeof selector === 'object' && selector.nodeType === 1) { | |
return elem === selector | |
} | |
return false | |
} | |
function getCurrentFocusedElement() { | |
var activeElement = document.activeElement | |
if (activeElement && activeElement !== document.body) { | |
return activeElement | |
} | |
} | |
function extend(out) { | |
out = out || {} | |
for (var i = 1; i < arguments.length; i++) { | |
if (!arguments[i]) { | |
continue | |
} | |
for (var key in arguments[i]) { | |
if ( | |
arguments[i].hasOwnProperty(key) && | |
arguments[i][key] !== undefined | |
) { | |
out[key] = arguments[i][key] | |
} | |
} | |
} | |
return out | |
} | |
function exclude(elemList, excludedElem) { | |
if (!Array.isArray(excludedElem)) { | |
excludedElem = [excludedElem] | |
} | |
for (var i = 0, index; i < excludedElem.length; i++) { | |
index = elemList.indexOf(excludedElem[i]) | |
if (index >= 0) { | |
elemList.splice(index, 1) | |
} | |
} | |
return elemList | |
} | |
function isNavigable(elem, sectionId, verifySectionSelector) { | |
if ( | |
!elem || | |
!sectionId || | |
!_sections[sectionId] || | |
_sections[sectionId].disabled | |
) { | |
return false | |
} | |
if ( | |
(elem.offsetWidth <= 0 && elem.offsetHeight <= 0) || | |
elem.hasAttribute('disabled') | |
) { | |
return false | |
} | |
if ( | |
verifySectionSelector && | |
!matchSelector(elem, _sections[sectionId].selector) | |
) { | |
return false | |
} | |
if (typeof _sections[sectionId].navigableFilter === 'function') { | |
if (_sections[sectionId].navigableFilter(elem, sectionId) === false) { | |
return false | |
} | |
} else if (typeof GlobalConfig.navigableFilter === 'function') { | |
if (GlobalConfig.navigableFilter(elem, sectionId) === false) { | |
return false | |
} | |
} | |
return true | |
} | |
function getSectionId(elem) { | |
for (var id in _sections) { | |
if ( | |
!_sections[id].disabled && | |
matchSelector(elem, _sections[id].selector) | |
) { | |
return id | |
} | |
} | |
} | |
function getSectionNavigableElements(sectionId) { | |
return parseSelector(_sections[sectionId].selector).filter(function (elem) { | |
return isNavigable(elem, sectionId) | |
}) | |
} | |
function getSectionDefaultElement(sectionId) { | |
var defaultElement = parseSelector( | |
_sections[sectionId].defaultElement | |
).find(function (elem) { | |
return isNavigable(elem, sectionId, true) | |
}) | |
if (!defaultElement) { | |
return null | |
} | |
return defaultElement | |
} | |
function getSectionLastFocusedElement(sectionId) { | |
var lastFocusedElement = _sections[sectionId].lastFocusedElement | |
if (!isNavigable(lastFocusedElement, sectionId, true)) { | |
return null | |
} | |
return lastFocusedElement | |
} | |
function fireEvent(elem, type, details, cancelable) { | |
if (arguments.length < 4) { | |
cancelable = true | |
} | |
var evt = document.createEvent('CustomEvent') | |
evt.initCustomEvent(EVENT_PREFIX + type, true, cancelable, details) | |
return elem.dispatchEvent(evt) | |
} | |
function focusElement(elem, sectionId, direction) { | |
if (!elem) { | |
return false | |
} | |
var currentFocusedElement = getCurrentFocusedElement() | |
var silentFocus = function () { | |
if (currentFocusedElement) { | |
currentFocusedElement.blur() | |
} | |
elem.focus() | |
focusChanged(elem, sectionId) | |
} | |
if (_duringFocusChange) { | |
silentFocus() | |
return true | |
} | |
_duringFocusChange = true | |
if (_pause) { | |
silentFocus() | |
_duringFocusChange = false | |
return true | |
} | |
if (currentFocusedElement) { | |
var unfocusProperties = { | |
nextElement: elem, | |
nextSectionId: sectionId, | |
direction: direction, | |
native: false, | |
} | |
if (!fireEvent(currentFocusedElement, 'willunfocus', unfocusProperties)) { | |
_duringFocusChange = false | |
return false | |
} | |
currentFocusedElement.blur() | |
fireEvent(currentFocusedElement, 'unfocused', unfocusProperties, false) | |
} | |
var focusProperties = { | |
previousElement: currentFocusedElement, | |
sectionId: sectionId, | |
direction: direction, | |
native: false, | |
} | |
if (!fireEvent(elem, 'willfocus', focusProperties)) { | |
_duringFocusChange = false | |
return false | |
} | |
elem.focus() | |
fireEvent(elem, 'focused', focusProperties, false) | |
_duringFocusChange = false | |
focusChanged(elem, sectionId) | |
return true | |
} | |
function focusChanged(elem, sectionId) { | |
if (!sectionId) { | |
sectionId = getSectionId(elem) | |
} | |
if (sectionId) { | |
_sections[sectionId].lastFocusedElement = elem | |
_lastSectionId = sectionId | |
} | |
} | |
function focusExtendedSelector(selector, direction) { | |
if (selector.charAt(0) == '@') { | |
if (selector.length == 1) { | |
return focusSection() | |
} else { | |
var sectionId = selector.substr(1) | |
return focusSection(sectionId) | |
} | |
} else { | |
var next = parseSelector(selector)[0] | |
if (next) { | |
var nextSectionId = getSectionId(next) | |
if (isNavigable(next, nextSectionId)) { | |
return focusElement(next, nextSectionId, direction) | |
} | |
} | |
} | |
return false | |
} | |
function focusSection(sectionId) { | |
var range = [] | |
var addRange = function (id) { | |
if ( | |
id && | |
range.indexOf(id) < 0 && | |
_sections[id] && | |
!_sections[id].disabled | |
) { | |
range.push(id) | |
} | |
} | |
if (sectionId) { | |
addRange(sectionId) | |
} else { | |
addRange(_defaultSectionId) | |
addRange(_lastSectionId) | |
Object.keys(_sections).map(addRange) | |
} | |
for (var i = 0; i < range.length; i++) { | |
var id = range[i] | |
var next | |
if (_sections[id].enterTo == 'last-focused') { | |
next = | |
getSectionLastFocusedElement(id) || | |
getSectionDefaultElement(id) || | |
getSectionNavigableElements(id)[0] | |
} else { | |
next = | |
getSectionDefaultElement(id) || | |
getSectionLastFocusedElement(id) || | |
getSectionNavigableElements(id)[0] | |
} | |
if (next) { | |
return focusElement(next, id) | |
} | |
} | |
return false | |
} | |
function fireNavigatefailed(elem, direction) { | |
fireEvent( | |
elem, | |
'navigatefailed', | |
{ | |
direction: direction, | |
}, | |
false | |
) | |
} | |
function gotoLeaveFor(sectionId, direction) { | |
if ( | |
_sections[sectionId].leaveFor && | |
_sections[sectionId].leaveFor[direction] !== undefined | |
) { | |
var next = _sections[sectionId].leaveFor[direction] | |
if (typeof next === 'string') { | |
if (next === '') { | |
return null | |
} | |
return focusExtendedSelector(next, direction) | |
} | |
if ($ && next instanceof $) { | |
next = next.get(0) | |
} | |
var nextSectionId = getSectionId(next) | |
if (isNavigable(next, nextSectionId)) { | |
return focusElement(next, nextSectionId, direction) | |
} | |
} | |
return false | |
} | |
function focusNext(direction, currentFocusedElement, currentSectionId) { | |
var extSelector = currentFocusedElement.getAttribute('data-sn-' + direction) | |
if (typeof extSelector === 'string') { | |
if ( | |
extSelector === '' || | |
!focusExtendedSelector(extSelector, direction) | |
) { | |
fireNavigatefailed(currentFocusedElement, direction) | |
return false | |
} | |
return true | |
} | |
var sectionNavigableElements = {} | |
var allNavigableElements = [] | |
for (var id in _sections) { | |
sectionNavigableElements[id] = getSectionNavigableElements(id) | |
allNavigableElements = allNavigableElements.concat( | |
sectionNavigableElements[id] | |
) | |
} | |
var config = extend({}, GlobalConfig, _sections[currentSectionId]) | |
var next | |
if (config.restrict == 'self-only' || config.restrict == 'self-first') { | |
var currentSectionNavigableElements = | |
sectionNavigableElements[currentSectionId] | |
next = navigate( | |
currentFocusedElement, | |
direction, | |
exclude(currentSectionNavigableElements, currentFocusedElement), | |
config | |
) | |
if (!next && config.restrict == 'self-first') { | |
next = navigate( | |
currentFocusedElement, | |
direction, | |
exclude(allNavigableElements, currentSectionNavigableElements), | |
config | |
) | |
} | |
} else { | |
next = navigate( | |
currentFocusedElement, | |
direction, | |
exclude(allNavigableElements, currentFocusedElement), | |
config | |
) | |
} | |
if (next) { | |
_sections[currentSectionId].previous = { | |
target: currentFocusedElement, | |
destination: next, | |
reverse: REVERSE[direction], | |
} | |
var nextSectionId = getSectionId(next) | |
if (currentSectionId != nextSectionId) { | |
var result = gotoLeaveFor(currentSectionId, direction) | |
if (result) { | |
return true | |
} else if (result === null) { | |
fireNavigatefailed(currentFocusedElement, direction) | |
return false | |
} | |
var enterToElement | |
switch (_sections[nextSectionId].enterTo) { | |
case 'last-focused': | |
enterToElement = | |
getSectionLastFocusedElement(nextSectionId) || | |
getSectionDefaultElement(nextSectionId) | |
break | |
case 'default-element': | |
enterToElement = getSectionDefaultElement(nextSectionId) | |
break | |
} | |
if (enterToElement) { | |
next = enterToElement | |
} | |
} | |
return focusElement(next, nextSectionId, direction) | |
} else if (gotoLeaveFor(currentSectionId, direction)) { | |
return true | |
} | |
fireNavigatefailed(currentFocusedElement, direction) | |
return false | |
} | |
function onKeyDown(evt) { | |
if ( | |
!_sectionCount || | |
_pause || | |
evt.altKey || | |
evt.ctrlKey || | |
evt.metaKey || | |
evt.shiftKey | |
) { | |
return | |
} | |
var currentFocusedElement | |
var preventDefault = function () { | |
evt.preventDefault() | |
evt.stopPropagation() | |
return false | |
} | |
var direction = KEYMAPPING[evt.keyCode] | |
if (!direction) { | |
if (evt.keyCode == RC_ENTER) { | |
currentFocusedElement = getCurrentFocusedElement() | |
if (currentFocusedElement && getSectionId(currentFocusedElement)) { | |
if (!fireEvent(currentFocusedElement, 'enter-down')) { | |
return preventDefault() | |
} | |
} | |
} | |
return | |
} | |
currentFocusedElement = getCurrentFocusedElement() | |
if (!currentFocusedElement) { | |
if (_lastSectionId) { | |
currentFocusedElement = getSectionLastFocusedElement(_lastSectionId) | |
} | |
if (!currentFocusedElement) { | |
focusSection() | |
return preventDefault() | |
} | |
} | |
var currentSectionId = getSectionId(currentFocusedElement) | |
if (!currentSectionId) { | |
return | |
} | |
var willmoveProperties = { | |
direction: direction, | |
sectionId: currentSectionId, | |
cause: 'keydown', | |
} | |
if (fireEvent(currentFocusedElement, 'willmove', willmoveProperties)) { | |
focusNext(direction, currentFocusedElement, currentSectionId) | |
} | |
return preventDefault() | |
} | |
function onKeyUp(evt) { | |
if (evt.altKey || evt.ctrlKey || evt.metaKey || evt.shiftKey) { | |
return | |
} | |
if (!_pause && _sectionCount && evt.keyCode == 13) { | |
var currentFocusedElement = getCurrentFocusedElement() | |
if (currentFocusedElement && getSectionId(currentFocusedElement)) { | |
if (!fireEvent(currentFocusedElement, 'enter-up')) { | |
evt.preventDefault() | |
evt.stopPropagation() | |
} | |
} | |
} | |
} | |
function onFocus(evt) { | |
var target = evt.target | |
if ( | |
target !== window && | |
target !== document && | |
_sectionCount && | |
!_duringFocusChange | |
) { | |
var sectionId = getSectionId(target) | |
if (sectionId) { | |
if (_pause) { | |
focusChanged(target, sectionId) | |
return | |
} | |
var focusProperties = { | |
sectionId: sectionId, | |
native: true, | |
} | |
if (!fireEvent(target, 'willfocus', focusProperties)) { | |
_duringFocusChange = true | |
target.blur() | |
_duringFocusChange = false | |
} else { | |
fireEvent(target, 'focused', focusProperties, false) | |
focusChanged(target, sectionId) | |
} | |
} | |
} | |
} | |
function onBlur(evt) { | |
var target = evt.target | |
if ( | |
target !== window && | |
target !== document && | |
!_pause && | |
_sectionCount && | |
!_duringFocusChange && | |
getSectionId(target) | |
) { | |
var unfocusProperties = { | |
native: true, | |
} | |
if (!fireEvent(target, 'willunfocus', unfocusProperties)) { | |
_duringFocusChange = true | |
setTimeout(function () { | |
target.focus() | |
_duringFocusChange = false | |
}) | |
} else { | |
fireEvent(target, 'unfocused', unfocusProperties, false) | |
} | |
} | |
} | |
/*******************/ | |
/* Public Function */ | |
/*******************/ | |
var SpatialNavigation = { | |
init: function (keyConfig) { | |
if (!_ready) { | |
if (keyConfig) { | |
KEYMAPPING = { ...keyConfig.nav } | |
RC_ENTER = keyConfig.ok | |
} | |
window.addEventListener('keydown', onKeyDown) | |
window.addEventListener('keyup', onKeyUp) | |
window.addEventListener('focus', onFocus, true) | |
window.addEventListener('blur', onBlur, true) | |
_ready = true | |
} | |
}, | |
uninit: function () { | |
window.removeEventListener('blur', onBlur, true) | |
window.removeEventListener('focus', onFocus, true) | |
window.removeEventListener('keyup', onKeyUp) | |
window.removeEventListener('keydown', onKeyDown) | |
SpatialNavigation.clear() | |
_idPool = 0 | |
_ready = false | |
}, | |
clear: function () { | |
_sections = {} | |
_sectionCount = 0 | |
_defaultSectionId = '' | |
_lastSectionId = '' | |
_duringFocusChange = false | |
}, | |
// set(<config>); | |
// set(<sectionId>, <config>); | |
set: function () { | |
var sectionId, config | |
if (typeof arguments[0] === 'object') { | |
config = arguments[0] | |
} else if ( | |
typeof arguments[0] === 'string' && | |
typeof arguments[1] === 'object' | |
) { | |
sectionId = arguments[0] | |
config = arguments[1] | |
if (!_sections[sectionId]) { | |
throw new Error('Section "' + sectionId + '" doesn\'t exist!') | |
} | |
} else { | |
return | |
} | |
for (var key in config) { | |
if (GlobalConfig[key] !== undefined) { | |
if (sectionId) { | |
_sections[sectionId][key] = config[key] | |
} else if (config[key] !== undefined) { | |
GlobalConfig[key] = config[key] | |
} | |
} | |
} | |
if (sectionId) { | |
// remove "undefined" items | |
_sections[sectionId] = extend({}, _sections[sectionId]) | |
} | |
}, | |
// add(<config>); | |
// add(<sectionId>, <config>); | |
add: function () { | |
var sectionId | |
var config = {} | |
if (typeof arguments[0] === 'object') { | |
config = arguments[0] | |
} else if ( | |
typeof arguments[0] === 'string' && | |
typeof arguments[1] === 'object' | |
) { | |
sectionId = arguments[0] | |
config = arguments[1] | |
} | |
if (!sectionId) { | |
sectionId = typeof config.id === 'string' ? config.id : generateId() | |
} | |
if (_sections[sectionId]) { | |
throw new Error('Section "' + sectionId + '" has already existed!') | |
} | |
_sections[sectionId] = {} | |
_sectionCount++ | |
SpatialNavigation.set(sectionId, config) | |
return sectionId | |
}, | |
remove: function (sectionId) { | |
if (!sectionId || typeof sectionId !== 'string') { | |
throw new Error('Please assign the "sectionId"!') | |
} | |
if (_sections[sectionId]) { | |
_sections[sectionId] = undefined | |
_sections = extend({}, _sections) | |
_sectionCount-- | |
if (_lastSectionId === sectionId) { | |
_lastSectionId = '' | |
} | |
return true | |
} | |
return false | |
}, | |
disable: function (sectionId) { | |
if (_sections[sectionId]) { | |
_sections[sectionId].disabled = true | |
return true | |
} | |
return false | |
}, | |
enable: function (sectionId) { | |
if (_sections[sectionId]) { | |
_sections[sectionId].disabled = false | |
return true | |
} | |
return false | |
}, | |
pause: function () { | |
_pause = true | |
}, | |
resume: function () { | |
_pause = false | |
}, | |
// focus([silent]) | |
// focus(<sectionId>, [silent]) | |
// focus(<extSelector>, [silent]) | |
// Note: "silent" is optional and default to false | |
focus: function (elem, silent) { | |
var result = false | |
if (silent === undefined && typeof elem === 'boolean') { | |
silent = elem | |
elem = undefined | |
} | |
var autoPause = !_pause && silent | |
if (autoPause) { | |
SpatialNavigation.pause() | |
} | |
if (!elem) { | |
result = focusSection() | |
} else { | |
if (typeof elem === 'string') { | |
if (_sections[elem]) { | |
result = focusSection(elem) | |
} else { | |
result = focusExtendedSelector(elem) | |
} | |
} else { | |
if ($ && elem instanceof $) { | |
elem = elem.get(0) | |
} | |
var nextSectionId = getSectionId(elem) | |
if (isNavigable(elem, nextSectionId)) { | |
result = focusElement(elem, nextSectionId) | |
} | |
} | |
} | |
if (autoPause) { | |
SpatialNavigation.resume() | |
} | |
return result | |
}, | |
// move(<direction>) | |
// move(<direction>, <selector>) | |
move: function (direction, selector) { | |
direction = direction.toLowerCase() | |
if (!REVERSE[direction]) { | |
return false | |
} | |
var elem = selector | |
? parseSelector(selector)[0] | |
: getCurrentFocusedElement() | |
if (!elem) { | |
return false | |
} | |
var sectionId = getSectionId(elem) | |
if (!sectionId) { | |
return false | |
} | |
var willmoveProperties = { | |
direction: direction, | |
sectionId: sectionId, | |
cause: 'api', | |
} | |
if (!fireEvent(elem, 'willmove', willmoveProperties)) { | |
return false | |
} | |
return focusNext(direction, elem, sectionId) | |
}, | |
// makeFocusable() | |
// makeFocusable(<sectionId>) | |
makeFocusable: function (sectionId) { | |
var doMakeFocusable = function (section) { | |
var tabIndexIgnoreList = | |
section.tabIndexIgnoreList !== undefined | |
? section.tabIndexIgnoreList | |
: GlobalConfig.tabIndexIgnoreList | |
parseSelector(section.selector).forEach(function (elem) { | |
if (!matchSelector(elem, tabIndexIgnoreList)) { | |
if (!elem.getAttribute('tabindex')) { | |
elem.setAttribute('tabindex', '-1') | |
} | |
} | |
}) | |
} | |
if (sectionId) { | |
if (_sections[sectionId]) { | |
doMakeFocusable(_sections[sectionId]) | |
} else { | |
throw new Error('Section "' + sectionId + '" doesn\'t exist!') | |
} | |
} else { | |
for (var id in _sections) { | |
doMakeFocusable(_sections[id]) | |
} | |
} | |
}, | |
setDefaultSection: function (sectionId) { | |
if (!sectionId) { | |
_defaultSectionId = '' | |
} else if (!_sections[sectionId]) { | |
throw new Error('Section "' + sectionId + '" doesn\'t exist!') | |
} else { | |
_defaultSectionId = sectionId | |
} | |
}, | |
} | |
window.SpatialNavigation = SpatialNavigation | |
/**********************/ | |
/* CommonJS Interface */ | |
/**********************/ | |
if (typeof module === 'object') { | |
module.exports = SpatialNavigation | |
} | |
/********************/ | |
/* jQuery Interface */ | |
/********************/ | |
if ($) { | |
$.SpatialNavigation = function () { | |
SpatialNavigation.init() | |
if (arguments.length > 0) { | |
if ($.isPlainObject(arguments[0])) { | |
return SpatialNavigation.add(arguments[0]) | |
} else if ( | |
$.type(arguments[0]) === 'string' && | |
$.isFunction(SpatialNavigation[arguments[0]]) | |
) { | |
return SpatialNavigation[arguments[0]].apply( | |
SpatialNavigation, | |
[].slice.call(arguments, 1) | |
) | |
} | |
} | |
return $.extend({}, SpatialNavigation) | |
} | |
$.fn.SpatialNavigation = function () { | |
var config | |
if ($.isPlainObject(arguments[0])) { | |
config = arguments[0] | |
} else { | |
config = { | |
id: arguments[0], | |
} | |
} | |
config.selector = this | |
SpatialNavigation.init() | |
if (config.id) { | |
SpatialNavigation.remove(config.id) | |
} | |
SpatialNavigation.add(config) | |
SpatialNavigation.makeFocusable(config.id) | |
return this | |
} | |
} | |
})(window.jQuery) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment