diff --git a/components/higher-order/with-constrained-tabbing/README.md b/components/higher-order/with-constrained-tabbing/README.md new file mode 100644 index 0000000000000..3e29122b8faf2 --- /dev/null +++ b/components/higher-order/with-constrained-tabbing/README.md @@ -0,0 +1,7 @@ +# withConstrainedTabbing + +`withConstrainedTabbing` is a React [higher-order component](https://facebook.github.io/react/docs/higher-order-components.html) adding the ability to constrain keyboard navigation with the Tab key within a component. For accessibility reasons, some UI components need to constrain Tab navigation, for example modal dialogs or similar UI. Use of this component is recommended only in cases where a way to navigate away from the wrapped component is implemented by other means, usually by pressing the Escape key or using a specific UI control, e.g. a "Close" button. + +## Usage + +Wrap your original component with `withConstrainedTabbing`. diff --git a/components/higher-order/with-constrained-tabbing/index.js b/components/higher-order/with-constrained-tabbing/index.js new file mode 100644 index 0000000000000..9ce58b976e033 --- /dev/null +++ b/components/higher-order/with-constrained-tabbing/index.js @@ -0,0 +1,62 @@ +/** + * WordPress dependencies + */ +import { + Component, + createRef, + createHigherOrderComponent, +} from '@wordpress/element'; +import { keycodes } from '@wordpress/utils'; +import { focus } from '@wordpress/dom'; + +const { TAB } = keycodes; + +const withConstrainedTabbing = createHigherOrderComponent( + ( WrappedComponent ) => class extends Component { + constructor() { + super( ...arguments ); + + this.focusContainRef = createRef(); + this.handleTabBehaviour = this.handleTabBehaviour.bind( this ); + } + + handleTabBehaviour( event ) { + if ( event.keyCode !== TAB ) { + return; + } + + const tabbables = focus.tabbable.find( this.focusContainRef.current ); + if ( ! tabbables.length ) { + return; + } + const firstTabbable = tabbables[ 0 ]; + const lastTabbable = tabbables[ tabbables.length - 1 ]; + + if ( event.shiftKey && event.target === firstTabbable ) { + event.preventDefault(); + lastTabbable.focus(); + } else if ( ! event.shiftKey && event.target === lastTabbable ) { + event.preventDefault(); + firstTabbable.focus(); + } + } + + render() { + // Disable reason: this component is non-interactive, but must capture + // events from the wrapped component to determine when the Tab key is used. + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( +
+ +
+ ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + } + }, + 'withConstrainedTabbing' +); + +export default withConstrainedTabbing; diff --git a/components/index.js b/components/index.js index 0d20eb59de186..1351d6fb7a452 100644 --- a/components/index.js +++ b/components/index.js @@ -60,6 +60,7 @@ export { default as ifCondition } from './higher-order/if-condition'; export { default as navigateRegions } from './higher-order/navigate-regions'; export { default as withAPIData } from './higher-order/with-api-data'; export { default as withContext } from './higher-order/with-context'; +export { default as withConstrainedTabbing } from './higher-order/with-constrained-tabbing'; export { default as withFallbackStyles } from './higher-order/with-fallback-styles'; export { default as withFilters } from './higher-order/with-filters'; export { default as withFocusOutside } from './higher-order/with-focus-outside'; diff --git a/components/popover/index.js b/components/popover/index.js index c53eab10bab57..4ba6fd0e7df68 100644 --- a/components/popover/index.js +++ b/components/popover/index.js @@ -18,12 +18,13 @@ import { ESCAPE } from '@wordpress/keycodes'; import './style.scss'; import { computePopoverPosition } from './utils'; import withFocusReturn from '../higher-order/with-focus-return'; +import withConstrainedTabbing from '../higher-order/with-constrained-tabbing'; import PopoverDetectOutside from './detect-outside'; import IconButton from '../icon-button'; import ScrollLock from '../scroll-lock'; import { Slot, Fill } from '../slot-fill'; -const FocusManaged = withFocusReturn( ( { children } ) => children ); +const FocusManaged = withConstrainedTabbing( withFocusReturn( ( { children } ) => children ) ); /** * Name of slot in which popover should fill.