"use strict";
/**
*
*
*
* <i>Copyright (c) 2014 ITSA - https://github.com/itsa</i>
* New BSD License - http://choosealicense.com/licenses/bsd-3-clause/
*
* @module focusmanager
* @class FocusManager
* @since 0.0.1
*/
require("itsa-jsext");
require("itsa-dom");
const utils = require("itsa-utils"),
isNode = utils.isNode,
Event = require("itsa-event"),
async = utils.async,
SPECIAL_KEYS = [
"shiftKey",
"ctrlKey",
"metaKey",
"altKey"
],
NATIVE_FOCUSSABLE_ELEMENTS = {
BUTTON: true,
INPUT: true,
KEYGEN: true,
SELECT: true,
TEXTAREA: true
},
PAGE_SCROLL_KEYS = {
38: true, // arrowUp
40: true // arrowDown
},
MAIN_CLASS = "itsa-focuscontainer",
FOCUSCONTAINER_CLASS = "."+MAIN_CLASS,
NESTED = MAIN_CLASS+"-nested",
NESTED_CLASS = "."+NESTED,
BLUR = "blur";
if (!isNode) {
const nodeBlurred = function(e) {
const node = e.target;
if (node && node.hasAttribute && node.hasAttribute("itsa_dynamic_tabindex")) {
node.removeAttribute("itsa_dynamic_tabindex");
node.removeAttribute("tabIndex");
}
};
if (window.addEventListener) {
window.addEventListener(BLUR, nodeBlurred, true);
}
else {
window.attachEvent("on"+BLUR, nodeBlurred);
}
}
const specialKeyPressed = e => {
let specialKeyPressed = false;
SPECIAL_KEYS.some(value => {
specialKeyPressed = !!e[value];
return specialKeyPressed;
});
return specialKeyPressed;
};
const checkSpecialKeyMatch = (specialKeys, e) => {
let allMatch;
SPECIAL_KEYS.some(value => {
let partOfSpecialKeys = specialKeys.itsa_contains(value);
allMatch = (!!e[value]===partOfSpecialKeys);
return !allMatch;
});
return allMatch;
};
const directionMatch = (keys, e, forbiddenKeys) => {
let match = false;
const keyCode = e.keyCode;
keys.some(keyObj => {
if (keyObj.key===keyCode) {
// also check special key-match
match = !forbiddenKeys[keyCode] &&
((keyObj.special && checkSpecialKeyMatch(keyObj.special, e)) ||
(!keyObj.special && !specialKeyPressed(e)));
}
return match;
});
if (match && PAGE_SCROLL_KEYS[keyCode]) {
e.preventDefault(); // no scroll of the page!
}
return match;
};
const tabIndexesOrder = (a, b) => {
let tabA = parseInt(a.getAttribute("tabIndex"), 10),
tabB = parseInt(b.getAttribute("tabIndex"), 10);
if (tabB<tabA) {
// put the new item `b` before previous item `a`
return 1;
}
if (tabB>tabA) {
// put the new item `b` behind previous item `a`
return -1;
}
return 0;
};
const getFocussableNodes = (container, selector) => {
let nodes, nodesWithoutTabIndexes, nodesWithTabIndexes, len, nextTabIndex;
selector += ","+NESTED_CLASS;
nodes = Array.prototype.filter.call(container.itsa_getAll(selector), node => {
const insideContainer = node.itsa_inside(FOCUSCONTAINER_CLASS),
insideOwnSelector = node.itsa_inside(selector);
return ((insideContainer===container) &&
!node.hasAttribute("data-itsa-fm-nofocus") &&
(node.itsa_getStyle("display")!=="none") &&
(node.itsa_getStyle("visibility")!=="hidden") &&
!node.hasAttribute("disabled") &&
!node.hasAttribute("readonly") &&
!node.itsa_hasClass("disabled") &&
!node.itsa_hasClass("readonly") &&
(!insideOwnSelector || (insideContainer===insideOwnSelector) || !insideContainer.contains(insideOwnSelector)));
});
// now we create 2 intermediate arrays:
// 1. those who have the `tabIndex` not set
// 2. those who have the `tabIndex` set
nodesWithoutTabIndexes = [];
nodesWithTabIndexes = [];
nodes.forEach(node => {
let tabIndex = node.hasAttribute("itsa_dynamic_tabindex") ? undefined : node.getAttribute("tabIndex");
if (typeof tabIndex==="string") {
nodesWithTabIndexes.push(node);
}
else {
nodesWithoutTabIndexes.push(node);
}
});
// now, we order the nodelist with tabindexes:
Array.prototype.sort.call(nodesWithTabIndexes, tabIndexesOrder);
// Last step: we create a new list with all items in the right order:
len = nodes.length;
nodes.itsa_makeEmpty(); // reuse this array
while (nodes.length<len) {
if (nodesWithTabIndexes.length===0) {
// no more nodes with tabIndexes --> fill `nodes` with remaining nodesWithoutTabIndexes
Array.prototype.push.apply(nodes, nodesWithoutTabIndexes);
}
else if (nodesWithoutTabIndexes.length===0) {
// no more nodes with tabIndexes --> fill `nodes` with remaining nodesWithTabIndexes
Array.prototype.push.apply(nodes, nodesWithTabIndexes);
}
else {
nextTabIndex = parseInt(nodesWithTabIndexes[0].getAttribute("tabIndex"), 10);
if (nextTabIndex<=nodes.length) {
// take node from `nodesWithTabIndexes`, add it to the `nodes` array and remove it from the `nodesWithTabIndexes`-array:
nodes.push(nodesWithTabIndexes.shift());
}
else {
// take node from `nodesWithTabIndexes`, add it to the `nodes` array and remove it from the `nodesWithTabIndexes`-array:
nodes.push(nodesWithoutTabIndexes.shift());
}
}
}
return nodes;
};
const handleKey = function(component, e) {
let nextIndex, count, activeElement, focussableNodes, getNextIndex, tagName, forbiddenKeys,
parentcontainer, nestedcomponent, parentcomponent, getCurrentSelectedIndex;
const props = component.props,
selector = props.selector,
loop = props.loop,
transitionTime = props.transitionTime,
container = component._domNode,
disabled = props.disabled,
keyCode = e.keyCode;
getCurrentSelectedIndex = focussableNodes => {
let index = focussableNodes.indexOf(activeElement);
if (index>-1) {
return index;
}
// now we check if it a descendent
focussableNodes.some((node, i) => {
if (node.contains(activeElement)) {
index = i;
}
return (index>-1);
});
return index;
},
getNextIndex = (down, container, selector) => {
focussableNodes = getFocussableNodes(container, selector);
nextIndex = getCurrentSelectedIndex(focussableNodes) + (down ? 1 : -1);
count = focussableNodes.length;
if (down ? (nextIndex>=count) : (nextIndex<0)) {
nextIndex = (loop && (count>0)) && (down ? 0 : count-1);
}
return nextIndex;
};
(keyCode===9) && e.preventDefault();
if (disabled) {
return;
}
activeElement = document.activeElement;
if (activeElement.itsa_inside(FOCUSCONTAINER_CLASS)!==container) {
return;
}
// be aware that arrow-key NEVER should match when the focus is set on an input/text/editable element!
tagName = activeElement.tagName;
if (tagName==="INPUT") {
forbiddenKeys = {
37: true, // arrowLeft
39: true // arrowRight
};
}
else if (tagName==="TEXTAREA") {
forbiddenKeys = {
37: true, // arrowLeft
39: true // arrowRight
};
((activeElement.selectionStart!==0) || activeElement.selectionEnd) && (forbiddenKeys[38]=true); // arrowUp
(activeElement.selectionStart<activeElement.textLength) && (forbiddenKeys[40]=true); // arrowDown
}
else if (activeElement.isContentEditable) {
forbiddenKeys = {
37: true, // arrowLeft
38: true, // arrowUp
39: true, // arrowRight
40: true // arrowDown
};
}
else {
forbiddenKeys = {};
}
if (directionMatch(component._keysDown, e, forbiddenKeys)) {
// keyDown direction
nextIndex = getNextIndex(true, container, selector);
}
else if (directionMatch(component._keysUp, e, forbiddenKeys)) {
// keyUp direction
nextIndex = getNextIndex(false, container, selector);
}
if ((typeof nextIndex!=="number") && props.refocusOnEnterInput && (keyCode===13) && (e.target.tagName==="INPUT") && !e.target.hasAttribute("data-itsa-fm-nofocusonenter")) {
// 'enter-key' is pressed on an input-element
nextIndex = getNextIndex(true, container, selector);
}
if (typeof nextIndex==="number") {
// focussing active focuscontainer
setFocus({
component,
node: focussableNodes[nextIndex],
transitionTime,
focussableNodes
});
}
else if (directionMatch(component._keysLeave, e, forbiddenKeys)) {
// exitting nested focuscontainer
parentcontainer = container.itsa_inside(FOCUSCONTAINER_CLASS);
if (parentcontainer) {
parentcomponent = parentcontainer._itsa_focuscontainer;
setFocus({
component: parentcomponent,
node: container,
transitionTime: parentcomponent.props.transitionTime,
focussableNodes
});
}
}
else if ((activeElement.itsa_hasClass(NESTED)) && (activeElement!==container)) {
// nested container
nestedcomponent = activeElement._itsa_focuscontainer;
if (nestedcomponent) {
if (directionMatch(nestedcomponent._keysEnter, e, forbiddenKeys)) {
// focussing item inside nested focuscontainer
setFocus({
component: nestedcomponent,
index: nestedcomponent.state.index,
transitionTime: nestedcomponent.props.transitionTime,
focussableNodes
});
}
}
}
};
const setFocus = (config) => {
const payload = {
component: config.component,
nodeOrIndex: config.node || config.index,
transitionTime: config.transitionTime,
focussableNodes: config.focussableNodes
};
Event.emit("focusmanager:focus", payload);
};
const defaultFnSetFocus = function(e) {
let node, nextIndex;
const component = e.component,
nodeOrIndex = e.nodeOrIndex,
transitionTime = e.transitionTime,
props = component.props,
selector = props.selector,
container = component._domNode,
focussableNodes = e.focussableNodes || getFocussableNodes(container, selector);
if (focussableNodes.length===0) {
nextIndex = null;
node = container;
}
else {
if (typeof nodeOrIndex==="number") {
node = focussableNodes[nodeOrIndex];
nextIndex = nodeOrIndex;
}
else {
node = nodeOrIndex;
nextIndex = focussableNodes.indexOf(node);
}
if (!focussableNodes.itsa_contains(node)) {
nextIndex = 0;
node = focussableNodes[0];
}
}
if (node.setAttribute) {
if (!NATIVE_FOCUSSABLE_ELEMENTS[node.tagName] && !node.hasAttribute("tabIndex")) {
node.setAttribute("tabIndex", 0);
node.setAttribute("itsa_dynamic_tabindex", "true");
}
// store the index in case the component looses its focus by itself, then we can reset the right index later on:
component.setState({
index: nextIndex,
noResync: true // will not resync the component
});
return new Promise(resolve => {
// we need to go async, otherwise the `focus`-event won't trigger a resync,
// because `state.noResync` would still be `true`.
async(() => {
if (node===document.activeElement) {
node.itsa_scrollIntoView(null, null, transitionTime);
}
else {
if (props.scrollIntoView) {
node.itsa_focus && node.itsa_focus(null, null, transitionTime);
}
else {
node.focus();
}
}
resolve(true);
});
});
}
else {
return Promise.resolve(false);
}
};
Event.defineEvent("focusmanager:focus").defaultFn(defaultFnSetFocus);
module.exports = {
getFocussableNodes,
handleKey,
setFocus
};