From a29d30449db0da3d0f479f3fbd8be770860b39ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ella=20van=C2=A0Durpe?= Date: Fri, 14 May 2021 18:00:34 +0300 Subject: [PATCH] Writing flow: extract tab behaviour to hook --- .../src/components/writing-flow/index.js | 150 ++--------------- .../components/writing-flow/use-last-focus.js | 17 -- .../components/writing-flow/use-tab-nav.js | 151 ++++++++++++++++++ 3 files changed, 164 insertions(+), 154 deletions(-) delete mode 100644 packages/block-editor/src/components/writing-flow/use-last-focus.js create mode 100644 packages/block-editor/src/components/writing-flow/use-tab-nav.js diff --git a/packages/block-editor/src/components/writing-flow/index.js b/packages/block-editor/src/components/writing-flow/index.js index f53445a1f3b50..b4c0e1daa1022 100644 --- a/packages/block-editor/src/components/writing-flow/index.js +++ b/packages/block-editor/src/components/writing-flow/index.js @@ -17,15 +17,7 @@ import { isEntirelySelected, isRTL, } from '@wordpress/dom'; -import { - UP, - DOWN, - LEFT, - RIGHT, - TAB, - isKeyboardEvent, - ESCAPE, -} from '@wordpress/keycodes'; +import { UP, DOWN, LEFT, RIGHT, isKeyboardEvent } from '@wordpress/keycodes'; import { useSelect, useDispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { useMergeRefs } from '@wordpress/compose'; @@ -35,25 +27,9 @@ import { useMergeRefs } from '@wordpress/compose'; */ import { isInSameBlock } from '../../utils/dom'; import useMultiSelection from './use-multi-selection'; -import useLastFocus from './use-last-focus'; +import useTabNav from './use-tab-nav'; import { store as blockEditorStore } from '../../store'; -/** - * Useful for positioning an element within the viewport so focussing the - * element does not scroll the page. - */ -const PREVENT_SCROLL_ON_FOCUS = { position: 'fixed' }; - -function isFormElement( element ) { - const { tagName } = element; - return ( - tagName === 'INPUT' || - tagName === 'BUTTON' || - tagName === 'SELECT' || - tagName === 'TEXTAREA' - ); -} - /** * Returns true if the element should consider edge navigation upon a keyboard * event of the given directional key code, or false otherwise. @@ -155,27 +131,17 @@ export function getClosestTabbable( */ export default function WritingFlow( { children } ) { const container = useRef(); - const focusCaptureBeforeRef = useRef(); - const focusCaptureAfterRef = useRef(); - const entirelySelected = useRef(); - // Reference that holds the a flag for enabling or disabling - // capturing on the focus capture elements. - const noCapture = useRef(); - // 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. const verticalRect = useRef(); - const { hasMultiSelection, isNavigationMode } = useSelect( ( select ) => { - const selectors = select( blockEditorStore ); - return { - hasMultiSelection: selectors.hasMultiSelection(), - isNavigationMode: selectors.isNavigationMode(), - }; - }, [] ); + const hasMultiSelection = useSelect( + ( select ) => select( blockEditorStore ).hasMultiSelection(), + [] + ); const { getSelectedBlockClientId, getMultiSelectedBlocksStartClientId, @@ -187,9 +153,7 @@ export default function WritingFlow( { children } ) { getBlockOrder, getSettings, } = useSelect( blockEditorStore ); - const { multiSelect, selectBlock, setNavigationMode } = useDispatch( - blockEditorStore - ); + const { multiSelect, selectBlock } = useDispatch( blockEditorStore ); function onMouseDown() { verticalRect.current = null; @@ -268,8 +232,6 @@ export default function WritingFlow( { children } ) { const isDown = keyCode === DOWN; const isLeft = keyCode === LEFT; const isRight = keyCode === RIGHT; - const isTab = keyCode === TAB; - const isEscape = keyCode === ESCAPE; const isReverse = isUp || isLeft; const isHorizontal = isLeft || isRight; const isVertical = isUp || isDown; @@ -282,18 +244,7 @@ export default function WritingFlow( { children } ) { const { defaultView } = ownerDocument; if ( hasMultiSelection ) { - if ( keyCode === TAB ) { - // Disable focus capturing on the focus capture element, so it - // doesn't refocus this element and so it allows default behaviour - // (moving focus to the next tabbable element). - noCapture.current = true; - - if ( isShift ) { - focusCaptureBeforeRef.current.focus(); - } else { - focusCaptureAfterRef.current.focus(); - } - } else if ( isNav ) { + if ( isNav ) { const action = isShift ? expandSelection : moveSelection; action( isReverse ); event.preventDefault(); @@ -302,44 +253,6 @@ export default function WritingFlow( { children } ) { return; } - const selectedBlockClientId = getSelectedBlockClientId(); - - // In Edit mode, Tab should focus the first tabbable element after the - // content, which is normally the sidebar (with block controls) and - // Shift+Tab should focus the first tabbable element before the content, - // which is normally the block toolbar. - // Arrow keys can be used, and Tab and arrow keys can be used in - // Navigation mode (press Esc), to navigate through blocks. - if ( selectedBlockClientId ) { - if ( isTab ) { - const direction = isShift ? 'findPrevious' : 'findNext'; - // Allow tabbing between form elements rendered in a block, - // such as inside a placeholder. Form elements are generally - // meant to be UI rather than part of the content. Ideally - // these are not rendered in the content and perhaps in the - // future they can be rendered in an iframe or shadow DOM. - if ( - isFormElement( target ) && - isFormElement( focus.tabbable[ direction ]( target ) ) - ) { - return; - } - - const next = isShift - ? focusCaptureBeforeRef - : focusCaptureAfterRef; - - // Disable focus capturing on the focus capture element, so it - // doesn't refocus this block and so it allows default behaviour - // (moving focus to the next tabbable element). - noCapture.current = true; - next.current.focus(); - return; - } else if ( isEscape ) { - setNavigationMode( true ); - } - } - // 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 @@ -403,6 +316,7 @@ export default function WritingFlow( { children } ) { const { keepCaretInsideBlock } = getSettings(); if ( isShift ) { + const selectedBlockClientId = getSelectedBlockClientId(); const selectionEndClientId = getMultiSelectedBlocksEndClientId(); const selectionBeforeEndClientId = getPreviousBlockClientId( selectionEndClientId || selectedBlockClientId @@ -459,49 +373,16 @@ export default function WritingFlow( { children } ) { } } - const lastFocus = useRef(); - - function onFocusCapture( event ) { - // Do not capture incoming focus if set by us in WritingFlow. - if ( noCapture.current ) { - noCapture.current = null; - } else if ( hasMultiSelection ) { - container.current.focus(); - } else if ( getSelectedBlockClientId() ) { - lastFocus.current.focus(); - } else { - setNavigationMode( true ); - - const isBefore = - // eslint-disable-next-line no-bitwise - event.target.compareDocumentPosition( container.current ) & - event.target.DOCUMENT_POSITION_FOLLOWING; - const action = isBefore ? 'findNext' : 'findPrevious'; - - focus.tabbable[ action ]( event.target ).focus(); - } - } - - // Don't allow tabbing to this element in Navigation mode. - const focusCaptureTabIndex = ! isNavigationMode ? '0' : undefined; + const [ before, ref, after ] = useTabNav(); // Disable reason: Wrapper itself is non-interactive, but must capture // bubbling events from children to determine focus transition intents. /* eslint-disable jsx-a11y/no-static-element-interactions */ return ( <> + { before }
-
{ children }
-
+ { after } ); /* eslint-enable jsx-a11y/no-static-element-interactions */ diff --git a/packages/block-editor/src/components/writing-flow/use-last-focus.js b/packages/block-editor/src/components/writing-flow/use-last-focus.js deleted file mode 100644 index bfee260c981c1..0000000000000 --- a/packages/block-editor/src/components/writing-flow/use-last-focus.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * WordPress dependencies - */ -import { useRefEffect } from '@wordpress/compose'; - -export default function useLastFocus( lastFocus ) { - return useRefEffect( ( node ) => { - function onFocusOut( event ) { - lastFocus.current = event.target; - } - - node.addEventListener( 'focusout', onFocusOut ); - return () => { - node.removeEventListener( 'focusout', onFocusOut ); - }; - }, [] ); -} diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.js b/packages/block-editor/src/components/writing-flow/use-tab-nav.js new file mode 100644 index 0000000000000..00528be96aa79 --- /dev/null +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.js @@ -0,0 +1,151 @@ +/** + * WordPress dependencies + */ +import { focus } from '@wordpress/dom'; +import { TAB, ESCAPE } from '@wordpress/keycodes'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useRefEffect, useMergeRefs } from '@wordpress/compose'; +import { useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; + +/** + * Useful for positioning an element within the viewport so focussing the + * element does not scroll the page. + */ +const PREVENT_SCROLL_ON_FOCUS = { position: 'fixed' }; + +function isFormElement( element ) { + const { tagName } = element; + return ( + tagName === 'INPUT' || + tagName === 'BUTTON' || + tagName === 'SELECT' || + tagName === 'TEXTAREA' + ); +} + +export default function useTabNav() { + const container = useRef(); + const focusCaptureBeforeRef = useRef(); + const focusCaptureAfterRef = useRef(); + const lastFocus = useRef(); + const { hasMultiSelection, getSelectedBlockClientId } = useSelect( + blockEditorStore + ); + const { setNavigationMode } = useDispatch( blockEditorStore ); + const isNavigationMode = useSelect( + ( select ) => select( blockEditorStore ).isNavigationMode(), + [] + ); + + // Don't allow tabbing to this element in Navigation mode. + const focusCaptureTabIndex = ! isNavigationMode ? '0' : undefined; + + // Reference that holds the a flag for enabling or disabling + // capturing on the focus capture elements. + const noCapture = useRef(); + + function onFocusCapture( event ) { + // Do not capture incoming focus if set by us in WritingFlow. + if ( noCapture.current ) { + noCapture.current = null; + } else if ( hasMultiSelection() ) { + container.current.focus(); + } else if ( getSelectedBlockClientId() ) { + lastFocus.current.focus(); + } else { + setNavigationMode( true ); + + const isBefore = + // eslint-disable-next-line no-bitwise + event.target.compareDocumentPosition( container.current ) & + event.target.DOCUMENT_POSITION_FOLLOWING; + const action = isBefore ? 'findNext' : 'findPrevious'; + + focus.tabbable[ action ]( event.target ).focus(); + } + } + + const before = ( +
+ ); + + const after = ( +
+ ); + + const ref = useRefEffect( ( node ) => { + function onKeyDown( event ) { + if ( event.keyCode === ESCAPE && ! hasMultiSelection() ) { + setNavigationMode( true ); + return; + } + + // In Edit mode, Tab should focus the first tabbable element after + // the content, which is normally the sidebar (with block controls) + // and Shift+Tab should focus the first tabbable element before the + // content, which is normally the block toolbar. + // Arrow keys can be used, and Tab and arrow keys can be used in + // Navigation mode (press Esc), to navigate through blocks. + if ( event.keyCode !== TAB ) { + return; + } + + const isShift = event.shiftKey; + const direction = isShift ? 'findPrevious' : 'findNext'; + + if ( ! hasMultiSelection() && ! getSelectedBlockClientId() ) { + return; + } + + // Allow tabbing between form elements rendered in a block, + // such as inside a placeholder. Form elements are generally + // meant to be UI rather than part of the content. Ideally + // these are not rendered in the content and perhaps in the + // future they can be rendered in an iframe or shadow DOM. + if ( + isFormElement( event.target ) && + isFormElement( focus.tabbable[ direction ]( event.target ) ) + ) { + return; + } + + const next = isShift ? focusCaptureBeforeRef : focusCaptureAfterRef; + + // Disable focus capturing on the focus capture element, so it + // doesn't refocus this block and so it allows default behaviour + // (moving focus to the next tabbable element). + noCapture.current = true; + next.current.focus(); + } + + function onFocusOut( event ) { + lastFocus.current = event.target; + } + + node.addEventListener( 'keydown', onKeyDown ); + node.addEventListener( 'focusout', onFocusOut ); + return () => { + node.removeEventListener( 'keydown', onKeyDown ); + node.removeEventListener( 'focusout', onFocusOut ); + }; + }, [] ); + + const mergedRefs = useMergeRefs( [ container, ref ] ); + + return [ before, mergedRefs, after ]; +}