Skip to content

Commit

Permalink
Fix constrained tabbing failures with Safari and Firefox (#47426)
Browse files Browse the repository at this point in the history
* Make the modal content container focusable when scrollable.

* Add test, improve existing a11y tests and run them with all browsers.

* Fix useConstrainedTabbing edge cases.

* Add basic focus style for the scrollable section.

* Update snapshots.

* Clean up a11y test.

* Refine test.

* Remove scrollableContentLabel prop.

* Restore conditional aria-label for the scrollable section.

* Update test after change to closeButtonLabel default.

* Add comment to clarify test.
  • Loading branch information
afercia authored Feb 14, 2023
1 parent 9ca8b5e commit 6e7ddd6
Show file tree
Hide file tree
Showing 9 changed files with 1,448 additions and 1,265 deletions.
46 changes: 45 additions & 1 deletion packages/components/src/modal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
useRef,
useState,
forwardRef,
useLayoutEffect,
} from '@wordpress/element';
import {
useInstanceId,
Expand All @@ -25,6 +26,7 @@ import {
} from '@wordpress/compose';
import { __ } from '@wordpress/i18n';
import { close } from '@wordpress/icons';
import { getScrollContainer } from '@wordpress/dom';

/**
* Internal dependencies
Expand Down Expand Up @@ -76,8 +78,26 @@ function UnforwardedModal(
const constrainedTabbingRef = useConstrainedTabbing();
const focusReturnRef = useFocusReturn();
const focusOutsideProps = useFocusOutside( onRequestClose );
const contentRef = useRef< HTMLDivElement >( null );
const childrenContainerRef = useRef< HTMLDivElement >( null );

const [ hasScrolledContent, setHasScrolledContent ] = useState( false );
const [ hasScrollableContent, setHasScrollableContent ] = useState( false );

// Determines whether the Modal content is scrollable and updates the state.
const isContentScrollable = useCallback( () => {
if ( ! contentRef.current ) {
return;
}

const closestScrollContainer = getScrollContainer( contentRef.current );

if ( contentRef.current === closestScrollContainer ) {
setHasScrollableContent( true );
} else {
setHasScrollableContent( false );
}
}, [ contentRef ] );

useEffect( () => {
openModalCount++;
Expand All @@ -97,6 +117,22 @@ function UnforwardedModal(
};
}, [ bodyOpenClassName ] );

// Calls the isContentScrollable callback when the Modal children container resizes.
useLayoutEffect( () => {
if ( ! window.ResizeObserver || ! childrenContainerRef.current ) {
return;
}

const resizeObserver = new ResizeObserver( isContentScrollable );
resizeObserver.observe( childrenContainerRef.current );

isContentScrollable();

return () => {
resizeObserver.disconnect();
};
}, [ isContentScrollable, childrenContainerRef ] );

function handleEscapeKeyDown( event: KeyboardEvent< HTMLDivElement > ) {
if (
// Ignore keydowns from IMEs
Expand Down Expand Up @@ -172,10 +208,18 @@ function UnforwardedModal(
<div
className={ classnames( 'components-modal__content', {
'hide-header': __experimentalHideHeader,
'is-scrollable': hasScrollableContent,
'has-scrolled-content': hasScrolledContent,
} ) }
role="document"
onScroll={ onContentContainerScroll }
ref={ contentRef }
aria-label={
hasScrollableContent
? __( 'Scrollable section' )
: undefined
}
tabIndex={ hasScrollableContent ? 0 : undefined }
>
{ ! __experimentalHideHeader && (
<div className="components-modal__header">
Expand Down Expand Up @@ -208,7 +252,7 @@ function UnforwardedModal(
) }
</div>
) }
{ children }
<div ref={ childrenContainerRef }>{ children }</div>
</div>
</div>
</StyleProvider>
Expand Down
8 changes: 8 additions & 0 deletions packages/components/src/modal/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,12 @@
margin-top: 0;
padding-top: $grid-unit-30;
}

&.is-scrollable:focus-visible {
box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);

// Windows High Contrast mode will show this outline, but not the box-shadow.
outline: 2px solid transparent;
outline-offset: -2px;
}
}
2 changes: 2 additions & 0 deletions packages/components/src/modal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export type ModalProps = {
className?: string;
/**
* Label on the close button.
*
* @default `__( 'Close' )`
*/
closeButtonLabel?: string;
/**
Expand Down
25 changes: 21 additions & 4 deletions packages/compose/src/hooks/use-constrained-tabbing/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,31 @@ function useConstrainedTabbing() {
/** @type {HTMLElement} */ ( target )
) || null;

// If the element that is about to receive focus is outside the
// area, move focus to a div and insert it at the start or end of
// the area, depending on the direction. Without preventing default
// behaviour, the browser will then move focus to the next element.
// When the target element contains the element that is about to
// receive focus, for example when the target is a tabbable
// container, browsers may disagree on where to move focus next.
// In this case we can't rely on native browsers behavior. We need
// to manage focus instead.
// See https://github.com/WordPress/gutenberg/issues/46041.
if (
/** @type {HTMLElement} */ ( target ).contains( nextElement )
) {
event.preventDefault();
/** @type {HTMLElement} */ ( nextElement )?.focus();
return;
}

// If the element that is about to receive focus is inside the
// area, rely on native browsers behavior and let tabbing follow
// the native tab sequence.
if ( node.contains( nextElement ) ) {
return;
}

// If the element that is about to receive focus is outside the
// area, move focus to a div and insert it at the start or end of
// the area, depending on the direction. Without preventing default
// behaviour, the browser will then move focus to the next element.
const domAction = shiftKey ? 'append' : 'prepend';
const { ownerDocument } = node;
const trap = ownerDocument.createElement( 'div' );
Expand Down
3 changes: 2 additions & 1 deletion packages/dom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ _Returns_

### getScrollContainer

Given a DOM node, finds the closest scrollable container node.
Given a DOM node, finds the closest scrollable container node or the node
itself, if scrollable.

_Parameters_

Expand Down
3 changes: 2 additions & 1 deletion packages/dom/src/dom/get-scroll-container.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
import getComputedStyle from './get-computed-style';

/**
* Given a DOM node, finds the closest scrollable container node.
* Given a DOM node, finds the closest scrollable container node or the node
* itself, if scrollable.
*
* @param {Element | null} node Node from which to start.
*
Expand Down
Loading

1 comment on commit 6e7ddd6

@github-actions
Copy link

@github-actions github-actions bot commented on 6e7ddd6 Feb 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flaky tests detected in 6e7ddd6.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/4171628299
📝 Reported issues:

Please sign in to comment.