UNPKG

arrow-key-navigation

Version:

Add left/right key navigation to a KaiOS app or web app

203 lines (201 loc) 7.83 kB
/** * Makes it so the left and right arrows change focus, ala Tab/Shift+Tab. This is mostly designed * for KaiOS devices. */ /* global document, addEventListener, removeEventListener, getSelection */ // TODO: email/number types are a special type, in that they return selectionStart/selectionEnd as null // As far as I can tell, there is no way to actually get the caret position from these inputs. So we // don't do the proper caret handling for those inputs, unfortunately. // https://html.spec.whatwg.org/multipage/input.html#do-not-apply var textInputTypes = ['text', 'search', 'url', 'password', 'tel']; var checkboxRadioInputTypes = ['checkbox', 'radio']; var focusTrapTest = undefined; // This query is adapted from a11y-dialog // https://github.com/edenspiekermann/a11y-dialog/blob/cf4ed81/a11y-dialog.js#L6-L18 var focusablesQuery = 'a[href], area[href], input, select, textarea, ' + 'button, iframe, object, embed, [contenteditable], [tabindex], ' + 'video[controls], audio[controls], summary'; function getActiveElement() { var activeElement = document.activeElement; while (activeElement.shadowRoot) { activeElement = activeElement.shadowRoot.activeElement; } return activeElement; } function isFocusable(element) { return element.matches(focusablesQuery) && !element.disabled && !/^-/.test(element.getAttribute('tabindex') || '') && !element.hasAttribute('inert') && // see https://github.com/GoogleChrome/inert-polyfill (element.offsetWidth > 0 || element.offsetHeight > 0); } function getFocusTrapParent(element) { if (!focusTrapTest) { return; } var parent = element.parentElement; while (parent) { if (focusTrapTest(parent)) { return parent; } parent = parent.parentElement; } } function shouldIgnoreEvent(activeElement, forwardDirection) { var tagName = activeElement.tagName; var isTextarea = tagName === 'TEXTAREA'; var isTextInput = tagName === 'INPUT' && textInputTypes.indexOf(activeElement.getAttribute('type').toLowerCase()) !== -1; var isContentEditable = activeElement.hasAttribute('contenteditable'); if (!isTextarea && !isTextInput && !isContentEditable) { return false; } var selectionStart; var selectionEnd; var len; if (isContentEditable) { var selection = getSelection(); selectionStart = selection.anchorOffset; selectionEnd = selection.focusOffset; len = activeElement.textContent.length; } else { selectionStart = activeElement.selectionStart; selectionEnd = activeElement.selectionEnd; len = activeElement.value.length; } // if the cursor is inside of a textarea/input, then don't focus to the next/previous element // unless the cursor is at the beginning or the end if (!forwardDirection && selectionStart === selectionEnd && selectionStart === 0) { return false; } else if (forwardDirection && selectionStart === selectionEnd && selectionStart === len) { return false; } return true; } function getNextCandidateNodeForShadowDomPolyfill(root, targetElement, forwardDirection, filter) { // When the shadydom polyfill is running, we can't use TreeWalker on ShadowRoots because // they aren't real Nodes. So we do this workaround where we run TreeWalker on the // children instead. var nodes = Array.prototype.slice.call(root.querySelectorAll('*')); var idx = nodes.indexOf(targetElement); if (forwardDirection) { nodes = nodes.slice(idx + 1); } else { if (idx === -1) { idx = nodes.length; } nodes = nodes.slice(0, idx); nodes.reverse(); } for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; if (node instanceof HTMLElement && filter.acceptNode(node) === NodeFilter.FILTER_ACCEPT) { return node; } } return undefined; } function getNextCandidateNode(root, targetElement, forwardDirection, filter) { var walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, filter); if (targetElement) { walker.currentNode = targetElement; } if (forwardDirection) { return walker.nextNode(); } else if (targetElement) { return walker.previousNode(); } // iterating backwards through shadow root, use last child return walker.lastChild(); } function isShadowDomPolyfill() { return typeof ShadowRoot !== 'undefined' && // ShadowRoot.polyfill is just a hack for our unit tests ('polyfill' in ShadowRoot || !ShadowRoot.toString().includes('[native code]')); } function getNextNode(root, targetElement, forwardDirection) { var filter = { acceptNode: function (node) { return (node === targetElement || node.shadowRoot || isFocusable(node)) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; } }; // TODO: remove this when we don't need to support the Shadow DOM polyfill var nextNode = isShadowDomPolyfill() && root instanceof ShadowRoot ? getNextCandidateNodeForShadowDomPolyfill(root, targetElement, forwardDirection, filter) : getNextCandidateNode(root, targetElement, forwardDirection, filter); if (nextNode && nextNode.shadowRoot) { // push into the shadow DOM return getNextNode(nextNode.shadowRoot, null, forwardDirection); } if (!nextNode && root.host) { // pop out of the shadow DOM return getNextNode(root.host.getRootNode(), root.host, forwardDirection); } return nextNode; } function focusNextOrPrevious(event, key) { var activeElement = getActiveElement(); var forwardDirection = key === 'ArrowRight'; if (shouldIgnoreEvent(activeElement, forwardDirection)) { return; } var root = getFocusTrapParent(activeElement) || activeElement.getRootNode(); var nextNode = getNextNode(root, activeElement, forwardDirection); if (nextNode && nextNode !== activeElement) { nextNode.focus(); event.preventDefault(); } } function handleEnter(event) { var activeElement = getActiveElement(); if (activeElement.tagName === 'INPUT' && checkboxRadioInputTypes.indexOf(activeElement.getAttribute('type').toLowerCase()) !== -1) { // Explicitly override "enter" on an input and make it fire the checkbox/radio activeElement.click(); event.preventDefault(); } } function keyListener(event) { if (event.altKey || event.metaKey || event.ctrlKey) { return; // ignore e.g. Alt-Left and Ctrl-Right, which are used to switch browser tabs or navigate back/forward } var key = event.key; switch (key) { case 'ArrowLeft': case 'ArrowRight': { focusNextOrPrevious(event, key); break; } case 'Enter': { handleEnter(event); break; } } } /** * Start listening for keyboard events. Attaches a listener to the window. */ function register() { addEventListener('keydown', keyListener); } /** * Stop listening for keyboard events. Unattaches a listener to the window. */ function unregister() { removeEventListener('keydown', keyListener); } /** * Set a focus trap test to identify any focus traps in the DOM, i.e. a top-level DOM node that indicates the root * of a focus trap. Once this is set, if focus changes within the focus trap, then will not leave the focus trap. * @param test: the test function * @see https://w3c.github.io/aria-practices/examples/dialog-modal/dialog.html */ function setFocusTrapTest(test) { focusTrapTest = test; } export { register, setFocusTrapTest, unregister }; //# sourceMappingURL=index.js.map