UNPKG

@wordpress/block-editor

Version:
254 lines (238 loc) 9.47 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = useArrowNav; exports.getClosestTabbable = getClosestTabbable; exports.isNavigationCandidate = isNavigationCandidate; var _dom = require("@wordpress/dom"); var _keycodes = require("@wordpress/keycodes"); var _data = require("@wordpress/data"); var _compose = require("@wordpress/compose"); var _dom2 = require("../../utils/dom"); var _store = require("../../store"); /** * WordPress dependencies */ /** * Internal dependencies */ /** * Returns true if the element should consider edge navigation upon a keyboard * event of the given directional key code, or false otherwise. * * @param {Element} element HTML element to test. * @param {number} keyCode KeyboardEvent keyCode to test. * @param {boolean} hasModifier Whether a modifier is pressed. * * @return {boolean} Whether element should consider edge navigation. */ function isNavigationCandidate(element, keyCode, hasModifier) { const isVertical = keyCode === _keycodes.UP || keyCode === _keycodes.DOWN; const { tagName } = element; const elementType = element.getAttribute('type'); // Native inputs should not navigate vertically, unless they are simple types that don't need up/down arrow keys. if (isVertical && !hasModifier) { if (tagName === 'INPUT') { const verticalInputTypes = ['date', 'datetime-local', 'month', 'number', 'range', 'time', 'week']; return !verticalInputTypes.includes(elementType); } return true; } // Native inputs should not navigate horizontally, unless they are simple types that don't need left/right arrow keys. if (tagName === 'INPUT') { const simpleInputTypes = ['button', 'checkbox', 'number', 'color', 'file', 'image', 'radio', 'reset', 'submit']; return simpleInputTypes.includes(elementType); } // Native textareas should not navigate horizontally. return tagName !== 'TEXTAREA'; } /** * Returns the optimal tab target from the given focused element in the desired * direction. A preference is made toward text fields, falling back to the block * focus stop if no other candidates exist for the block. * * @param {Element} target Currently focused text field. * @param {boolean} isReverse True if considering as the first field. * @param {Element} containerElement Element containing all blocks. * @param {boolean} onlyVertical Whether to only consider tabbable elements * that are visually above or under the * target. * * @return {?Element} Optimal tab target, if one exists. */ function getClosestTabbable(target, isReverse, containerElement, onlyVertical) { // Since the current focus target is not guaranteed to be a text field, find // all focusables. Tabbability is considered later. let focusableNodes = _dom.focus.focusable.find(containerElement); if (isReverse) { focusableNodes.reverse(); } // Consider as candidates those focusables after the current target. It's // assumed this can only be reached if the target is focusable (on its // keydown event), so no need to verify it exists in the set. focusableNodes = focusableNodes.slice(focusableNodes.indexOf(target) + 1); let targetRect; if (onlyVertical) { targetRect = target.getBoundingClientRect(); } function isTabCandidate(node) { if (node.closest('[inert]')) { return; } // Skip if there's only one child that is content editable (and thus a // better candidate). if (node.children.length === 1 && (0, _dom2.isInSameBlock)(node, node.firstElementChild) && node.firstElementChild.getAttribute('contenteditable') === 'true') { return; } // Not a candidate if the node is not tabbable. if (!_dom.focus.tabbable.isTabbableIndex(node)) { return false; } // Skip focusable elements such as links within content editable nodes. if (node.isContentEditable && node.contentEditable !== 'true') { return false; } if (onlyVertical) { const nodeRect = node.getBoundingClientRect(); if (nodeRect.left >= targetRect.right || nodeRect.right <= targetRect.left) { return false; } } return true; } return focusableNodes.find(isTabCandidate); } function useArrowNav() { const { getMultiSelectedBlocksStartClientId, getMultiSelectedBlocksEndClientId, getSettings, hasMultiSelection, __unstableIsFullySelected } = (0, _data.useSelect)(_store.store); const { selectBlock } = (0, _data.useDispatch)(_store.store); return (0, _compose.useRefEffect)(node => { // Here a DOMRect is stored while moving the caret vertically so // vertical position of the start position can be restored. This is to // recreate browser behaviour across blocks. let verticalRect; function onMouseDown() { verticalRect = null; } function isClosestTabbableABlock(target, isReverse) { const closestTabbable = getClosestTabbable(target, isReverse, node); return closestTabbable && (0, _dom2.getBlockClientId)(closestTabbable); } function onKeyDown(event) { // Abort if navigation has already been handled (e.g. RichText // inline boundaries). if (event.defaultPrevented) { return; } const { keyCode, target, shiftKey, ctrlKey, altKey, metaKey } = event; const isUp = keyCode === _keycodes.UP; const isDown = keyCode === _keycodes.DOWN; const isLeft = keyCode === _keycodes.LEFT; const isRight = keyCode === _keycodes.RIGHT; const isReverse = isUp || isLeft; const isHorizontal = isLeft || isRight; const isVertical = isUp || isDown; const isNav = isHorizontal || isVertical; const hasModifier = shiftKey || ctrlKey || altKey || metaKey; const isNavEdge = isVertical ? _dom.isVerticalEdge : _dom.isHorizontalEdge; const { ownerDocument } = node; const { defaultView } = ownerDocument; if (!isNav) { return; } // If there is a multi-selection, the arrow keys should collapse the // selection to the start or end of the selection. if (hasMultiSelection()) { if (shiftKey) { return; } // Only handle if we have a full selection (not a native partial // selection). if (!__unstableIsFullySelected()) { return; } event.preventDefault(); if (isReverse) { selectBlock(getMultiSelectedBlocksStartClientId()); } else { selectBlock(getMultiSelectedBlocksEndClientId(), -1); } return; } // Abort if our current target is not a candidate for navigation // (e.g. preserve native input behaviors). if (!isNavigationCandidate(target, keyCode, hasModifier)) { return; } // When presing any key other than up or down, the initial vertical // position must ALWAYS be reset. The vertical position is saved so // it can be restored as well as possible on sebsequent vertical // arrow key presses. It may not always be possible to restore the // exact same position (such as at an empty line), so it wouldn't be // good to compute the position right before any vertical arrow key // press. if (!isVertical) { verticalRect = null; } else if (!verticalRect) { verticalRect = (0, _dom.computeCaretRect)(defaultView); } // In the case of RTL scripts, right means previous and left means // next, which is the exact reverse of LTR. const isReverseDir = (0, _dom.isRTL)(target) ? !isReverse : isReverse; const { keepCaretInsideBlock } = getSettings(); if (shiftKey) { if (isClosestTabbableABlock(target, isReverse) && isNavEdge(target, isReverse)) { node.contentEditable = true; // Firefox doesn't automatically move focus. node.focus(); } } else if (isVertical && (0, _dom.isVerticalEdge)(target, isReverse) && ( // When Alt is pressed, only intercept if the caret is also at // the horizontal edge. altKey ? (0, _dom.isHorizontalEdge)(target, isReverseDir) : true) && !keepCaretInsideBlock) { const closestTabbable = getClosestTabbable(target, isReverse, node, true); if (closestTabbable) { (0, _dom.placeCaretAtVerticalEdge)(closestTabbable, // When Alt is pressed, place the caret at the furthest // horizontal edge and the furthest vertical edge. altKey ? !isReverse : isReverse, altKey ? undefined : verticalRect); event.preventDefault(); } } else if (isHorizontal && defaultView.getSelection().isCollapsed && (0, _dom.isHorizontalEdge)(target, isReverseDir) && !keepCaretInsideBlock) { const closestTabbable = getClosestTabbable(target, isReverseDir, node); (0, _dom.placeCaretAtHorizontalEdge)(closestTabbable, isReverse); event.preventDefault(); } } node.addEventListener('mousedown', onMouseDown); node.addEventListener('keydown', onKeyDown); return () => { node.removeEventListener('mousedown', onMouseDown); node.removeEventListener('keydown', onKeyDown); }; }, []); } //# sourceMappingURL=use-arrow-nav.js.map