diff --git a/packages/components/src/modal/index.tsx b/packages/components/src/modal/index.tsx index ed00dd2624452c..d9c7b602b83920 100644 --- a/packages/components/src/modal/index.tsx +++ b/packages/components/src/modal/index.tsx @@ -14,6 +14,7 @@ import { useRef, useState, forwardRef, + useLayoutEffect, } from '@wordpress/element'; import { useInstanceId, @@ -25,6 +26,7 @@ import { } from '@wordpress/compose'; import { __ } from '@wordpress/i18n'; import { close } from '@wordpress/icons'; +import { getScrollContainer } from '@wordpress/dom'; /** * Internal dependencies @@ -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++; @@ -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 @@ -172,10 +208,18 @@ function UnforwardedModal(
{ ! __experimentalHideHeader && (
@@ -208,7 +252,7 @@ function UnforwardedModal( ) }
) } - { children } +
{ children }
diff --git a/packages/components/src/modal/style.scss b/packages/components/src/modal/style.scss index 2374a7e57993d4..f434147ecef620 100644 --- a/packages/components/src/modal/style.scss +++ b/packages/components/src/modal/style.scss @@ -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; + } } diff --git a/packages/components/src/modal/types.ts b/packages/components/src/modal/types.ts index 45fe0580d5329b..6169e42a8a2d4c 100644 --- a/packages/components/src/modal/types.ts +++ b/packages/components/src/modal/types.ts @@ -50,6 +50,8 @@ export type ModalProps = { className?: string; /** * Label on the close button. + * + * @default `__( 'Close' )` */ closeButtonLabel?: string; /** diff --git a/packages/compose/src/hooks/use-constrained-tabbing/index.js b/packages/compose/src/hooks/use-constrained-tabbing/index.js index ba637951563f4c..97b8a2a0a5eb52 100644 --- a/packages/compose/src/hooks/use-constrained-tabbing/index.js +++ b/packages/compose/src/hooks/use-constrained-tabbing/index.js @@ -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' ); diff --git a/packages/dom/README.md b/packages/dom/README.md index 3241ab46479ece..5aebde58ee140a 100644 --- a/packages/dom/README.md +++ b/packages/dom/README.md @@ -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_ diff --git a/packages/dom/src/dom/get-scroll-container.js b/packages/dom/src/dom/get-scroll-container.js index 3646572503e2f5..6c472e6195496a 100644 --- a/packages/dom/src/dom/get-scroll-container.js +++ b/packages/dom/src/dom/get-scroll-container.js @@ -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. * diff --git a/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap b/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap index 0c07df56490d6c..2ad8e457bae303 100644 --- a/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap +++ b/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap @@ -43,856 +43,858 @@ exports[`KeyboardShortcutHelpModal should match snapshot when the modal is activ -
- -
-
-

+
- Global shortcuts -

- +
+
-
  • -
    - Navigate to the nearest toolbar. -
    -
    - - - Alt - - + - - F10 - - -
    -
  • -
  • + -
  • -
    -

    - Selection shortcuts -

    - +
    +
    -
  • -
    - Select all text when typing. Press again to select all blocks. -
    -
    +
      +
    • - + Select all text when typing. Press again to select all blocks. +
    +
    - Ctrl - - + - - A - - -
    -
  • -
  • -
    - Clear selection. -
    -
    - + + Ctrl + + + + + A + + +
    +
  • +
  • +
    + Clear selection. +
    +
    - escape - - -
    -
  • - -
    -
    -

    + + escape + + + + + +

    +
    - Block shortcuts - -
    -
    -

    + + / + + + + + +

    +
    - Text formatting - -
    + aria-label="Shift + Alt + 1 6" + class="edit-post-keyboard-shortcut-help-modal__shortcut-key-combination" + > + + Shift + + + + + Alt + + + + + 1-6 + + + + + + + `; diff --git a/packages/edit-post/src/components/preferences-modal/test/__snapshots__/index.js.snap b/packages/edit-post/src/components/preferences-modal/test/__snapshots__/index.js.snap index c0d2d8b7a6b73c..67d5ee4e184366 100644 --- a/packages/edit-post/src/components/preferences-modal/test/__snapshots__/index.js.snap +++ b/packages/edit-post/src/components/preferences-modal/test/__snapshots__/index.js.snap @@ -102,425 +102,427 @@ exports[`EditPostPreferencesModal should match snapshot when the modal is active -
    +
    - - - -
    -
    -
    +
    - -

    - Publishing -

    -

    - Change options related to publishing. -

    -
    -
    +

    + Publishing +

    +

    + Change options related to publishing. +

    +
    - - - - - - + + + + + + +
    +

    + Review settings, such as visibility and tags. +

    -

    - Review settings, such as visibility and tags. -

    -
    - -
    - +
    -

    - Appearance -

    -

    - Customize options related to the block editor interface and editing flow. -

    - -
    +

    + Appearance +

    +

    + Customize options related to the block editor interface and editing flow. +

    +
    - - - - - - + + + + + + +
    +

    + Reduce visual distractions by hiding the toolbar and other elements to focus on writing. +

    -

    - Reduce visual distractions by hiding the toolbar and other elements to focus on writing. -

    -
    -
    - - - - - - + + + + + + +
    +

    + Highlights the current block and fades other content. +

    -

    - Highlights the current block and fades other content. -

    -
    -
    - - - - - - + + + + + + +
    +

    + Show text instead of icons on buttons. +

    -

    - Show text instead of icons on buttons. -

    - -
    - - - - - - + + + + + + +
    +

    + Opens the block list view sidebar by default. +

    -

    - Opens the block list view sidebar by default. -

    - -
    - - - - - - + + + + + + +
    +

    + Make the editor look like your theme. +

    -

    - Make the editor look like your theme. -

    - -
    - - - - - - + + + + + + +
    +

    + Shows block breadcrumbs at the bottom of the editor. +

    -

    - Shows block breadcrumbs at the bottom of the editor. -

    - - + + @@ -721,198 +723,200 @@ exports[`EditPostPreferencesModal should match snapshot when the modal is active -
    +
    -
    - -
    -
    -
    +
    -
    - - Blocks - -
    -
    -
    +
    - - + +
    -
    - -
    -
    -
    +
    -
    - - Panels - -
    -
    -
    +
    - - + +
    -
    - + +
    + diff --git a/test/e2e/specs/editor/various/a11y.spec.js b/test/e2e/specs/editor/various/a11y.spec.js index 6ff397fc9cab87..4e37c08b08e0cf 100644 --- a/test/e2e/specs/editor/various/a11y.spec.js +++ b/test/e2e/specs/editor/various/a11y.spec.js @@ -3,7 +3,16 @@ */ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); -test.describe( 'a11y', () => { +test.use( { + // Make the viewport tall enough so that some tabs panels within the + // Preferences modal are not scrollable and other tab panels are. + viewport: { + width: 1280, + height: 1024, + }, +} ); + +test.describe( 'a11y (@firefox, @webkit)', () => { test.beforeEach( async ( { admin } ) => { await admin.createNewPost(); } ); @@ -38,9 +47,13 @@ test.describe( 'a11y', () => { page, pageUtils, } ) => { - // Open keyboard help modal. + // Open keyboard shortcuts modal. await pageUtils.pressKeyWithModifier( 'access', 'h' ); + const modalContent = page.locator( + 'role=dialog[name="Keyboard shortcuts"i] >> role=document' + ); + const closeButton = page.locator( 'role=dialog[name="Keyboard shortcuts"i] >> role=button[name="Close"i]' ); @@ -49,10 +62,15 @@ test.describe( 'a11y', () => { // See: https://github.com/WordPress/gutenberg/issues/9410 await expect( closeButton ).not.toBeFocused(); + // Open keyboard shortcuts modal. await page.keyboard.press( 'Tab' ); + await expect( modalContent ).toBeFocused(); - // Ensure the Close button of the modal is focused after tabbing. + await page.keyboard.press( 'Tab' ); await expect( closeButton ).toBeFocused(); + + await page.keyboard.press( 'Tab' ); + await expect( modalContent ).toBeFocused(); } ); test( 'should return focus to the first tabbable in a modal after blurring a tabbable', async ( { @@ -93,4 +111,90 @@ test.describe( 'a11y', () => { ) ).toBeFocused(); } ); + + test( 'should make the modal content focusable when it is scrollable', async ( { + page, + } ) => { + // Open the top bar Options menu. + await page.click( + 'role=region[name="Editor top bar"i] >> role=button[name="Options"i]' + ); + + // Open the Preferences modal. + await page.click( + 'role=menu[name="Options"i] >> role=menuitem[name="Preferences"i]' + ); + + const preferencesModal = page.locator( + 'role=dialog[name="Preferences"i]' + ); + const preferencesModalContent = + preferencesModal.locator( 'role=document' ); + const closeButton = preferencesModal.locator( + 'role=button[name="Close"i]' + ); + const generalTab = preferencesModal.locator( + 'role=tab[name="General"i]' + ); + const blocksTab = preferencesModal.locator( + 'role=tab[name="Blocks"i]' + ); + const panelsTab = preferencesModal.locator( + 'role=tab[name="Panels"i]' + ); + + // Check initial focus is on the modal dialog container. + await expect( preferencesModal ).toBeFocused(); + + // Check the General tab panel is visible by default. + await expect( + preferencesModal.locator( 'role=tabpanel[name="General"i]' ) + ).toBeVisible(); + + async function clickAndFocusTab( tab ) { + // Some browsers, e.g. Safari, don't set focus after a click. We need + // to ensure focus is set to start tabbing from a predictable place + // in the UI. This isn't part of the user flow we want to test. + await tab.click(); + await tab.focus(); + } + + // The General tab panel content is short and not scrollable. + // Check it's not focusable. + await clickAndFocusTab( generalTab ); + await page.keyboard.press( 'Shift+Tab' ); + await expect( closeButton ).toBeFocused(); + await page.keyboard.press( 'Shift+Tab' ); + await expect( preferencesModalContent ).not.toBeFocused(); + + // The Blocks tab panel content is long and scrollable. + // Check it's focusable. + await clickAndFocusTab( blocksTab ); + await page.keyboard.press( 'Shift+Tab' ); + await expect( closeButton ).toBeFocused(); + await page.keyboard.press( 'Shift+Tab' ); + await expect( preferencesModalContent ).toBeFocused(); + + // Make the Blocks tab panel content shorter by searching for a block + // that doesn't exist. The content only shows 'No blocks found' and it's + // not scrollable any longer. Check it's not focusable. + await clickAndFocusTab( blocksTab ); + await page.type( + 'role=searchbox[name="Search for a block"i]', + 'qwerty' + ); + await clickAndFocusTab( blocksTab ); + await page.keyboard.press( 'Shift+Tab' ); + await expect( closeButton ).toBeFocused(); + await page.keyboard.press( 'Shift+Tab' ); + await expect( preferencesModalContent ).not.toBeFocused(); + + // The Panels tab panel content is short and not scrollable. + // Check it's not focusable. + await clickAndFocusTab( panelsTab ); + await page.keyboard.press( 'Shift+Tab' ); + await expect( closeButton ).toBeFocused(); + await page.keyboard.press( 'Shift+Tab' ); + await expect( preferencesModalContent ).not.toBeFocused(); + } ); } );