diff --git a/package.json b/package.json index 9688e591..64b33491 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@babel/preset-env": "^7.6.3", "@babel/runtime": "^7.6.3", "@webcomponents/custom-elements": "^1.3.0", + "arrow-key-navigation": "^1.0.1", "babel-loader": "^8.0.6", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "blurhash": "^1.1.3", diff --git a/src/routes/_store/observers/leftRightFocusObservers.js b/src/routes/_store/observers/leftRightFocusObservers.js index 5a8d9009..206b5ee7 100644 --- a/src/routes/_store/observers/leftRightFocusObservers.js +++ b/src/routes/_store/observers/leftRightFocusObservers.js @@ -1,123 +1,24 @@ // Makes it so the left and right arrows change focus, ala Tab/Shift+Tab. This is mostly designed // for KaiOS devices. +import { importArrowKeyNavigation } from '../../_utils/asyncModules' + +let arrowKeyNav + export function leftRightFocusObservers (store) { if (!process.browser) { return } - function getDialogParent (element) { - let parent = element.parentElement - while (parent) { - if (parent.classList.contains('modal-dialog')) { - return parent - } - parent = parent.parentElement - } - } - - function getFocusableElements (activeElement) { - const query = ` - a, - button, - textarea, - input[type=text], - input[type=number], - input[type=search], - input[type=radio], - input[type=checkbox], - select, - [tabindex="0"] - ` - - // Respect focus trap inside of dialogs - const dialogParent = getDialogParent(activeElement) - const root = dialogParent || document - - return Array.from(root.querySelectorAll(query)) - .filter(element => { - if (element === activeElement) { - return true - } - return !element.disabled && - element.getAttribute('tabindex') !== '-1' && - (element.offsetWidth > 0 || element.offsetHeight > 0) - }) - } - - function shouldIgnoreEvent (activeElement, key) { - const isTextarea = activeElement.tagName === 'TEXTAREA' - const isTextInput = activeElement.tagName === 'INPUT' && - ['text', 'search', 'number', 'email', 'url'].includes(activeElement.getAttribute('type').toLowerCase()) - - if (!isTextarea && !isTextInput) { - return false - } - - const { selectionStart, selectionEnd } = activeElement - // if the cursor is at the start or end of the textarea and the user wants to navigate out of it, - // then do so - if (key === 'ArrowLeft' && selectionStart === selectionEnd && selectionStart === 0) { - return false - } else if (key === 'ArrowRight' && selectionStart === selectionEnd && selectionStart === activeElement.value.length) { - return false - } - return true - } - - function focusNextOrPrevious (event, key) { - const { activeElement } = document - if (shouldIgnoreEvent(activeElement, key)) { - return - } - const focusable = getFocusableElements(activeElement) - const index = focusable.indexOf(activeElement) - let element - if (key === 'ArrowLeft') { - console.log('focus previous') - element = focusable[index - 1] || focusable[0] - } else { // ArrowRight - console.log('focus next') - element = focusable[index + 1] || focusable[focusable.length - 1] - } - element.focus() - event.preventDefault() - event.stopPropagation() - } - - function handleEnter (event) { - const { activeElement } = document - if (activeElement.tagName === 'INPUT' && ['checkbox', 'radio'].includes(activeElement.getAttribute('type'))) { - // Explicitly override "enter" on an input and make it fire the checkbox/radio - activeElement.click() - event.preventDefault() - event.stopPropagation() - } - } - - 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 - } - const { key } = event - switch (key) { - case 'ArrowLeft': - case 'ArrowRight': { - focusNextOrPrevious(event, key) - break - } - case 'Enter': { - handleEnter(event) - break - } - } - } - - store.observe('leftRightChangesFocus', leftRightChangesFocus => { + store.observe('leftRightChangesFocus', async leftRightChangesFocus => { if (leftRightChangesFocus) { - window.addEventListener('keydown', keyListener) - } else { - window.removeEventListener('keydown', keyListener) + if (!arrowKeyNav) { + arrowKeyNav = await importArrowKeyNavigation() + } + arrowKeyNav.setFocusTrapTest(element => element.classList.contains('modal-dialog')) + arrowKeyNav.register() + } else if (arrowKeyNav) { + arrowKeyNav.unregister() } }) } diff --git a/src/routes/_utils/asyncModules.js b/src/routes/_utils/asyncModules.js index 60743c1b..d8308447 100644 --- a/src/routes/_utils/asyncModules.js +++ b/src/routes/_utils/asyncModules.js @@ -59,3 +59,7 @@ export const importVirtualListStore = () => import( export const importPageLifecycle = () => import( /* webpackChunkName: 'page-lifecycle' */ 'page-lifecycle/dist/lifecycle.mjs' ).then(getDefault) + +export const importArrowKeyNavigation = () => import( + /* webpackChunkName: 'arrow-key-navigation' */ 'arrow-key-navigation' +) diff --git a/yarn.lock b/yarn.lock index fcc8ccc5..a2570d5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1225,6 +1225,11 @@ array-unique@^0.3.2: resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= +arrow-key-navigation@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/arrow-key-navigation/-/arrow-key-navigation-1.0.1.tgz#557f5c0034791acc04091843718888e18c9cd61a" + integrity sha512-/RZFi4p3MCr6Y2y2luNh8eP7CwlVsoq+F2oiNoE+jSHgRWUbc+fLaI2/8NWqDF6XZLu5GdTvXkN70KRpN0/5Hw== + asar@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/asar/-/asar-2.0.1.tgz#8518a1c62c238109c15a5f742213e83a09b9fd38"