diff --git a/src/components/editor-toolbar/aria-helper.js b/src/components/editor-toolbar/aria-helper.js index ac38747be..7c48d0fb6 100644 --- a/src/components/editor-toolbar/aria-helper.js +++ b/src/components/editor-toolbar/aria-helper.js @@ -1,6 +1,11 @@ /** * This file was sourced from the Gutenberg project and converted from - * TypeScript to JavaScript. + * TypeScript to JavaScript, then diverged from it. `modalize` walks each + * modal element's ancestor path rather than only `document.body`'s children, + * because our popovers render into slots nested within body-level containers + * rather than as direct body children. `unmodalize` reverses a batch by the + * handle `modalize` returns rather than popping the most recent one, because + * our modals do not always close in reverse-open order. * * @see https://github.com/WordPress/gutenberg/blob/3f0a805c568f92622faf4b71b24eeb5f39b5bca8/packages/components/src/modal/aria-helper.ts */ @@ -13,38 +18,91 @@ const LIVE_REGION_ARIA_ROLES = new Set( [ 'timer', ] ); -const hiddenElementsByDepth = []; +const hiddenElementBatches = []; /** * Hides all elements in the body element from screen-readers except - * the provided element and elements that should not be hidden from - * screen-readers. + * the provided elements, their ancestors, and elements that should not be + * hidden from screen-readers. + * + * Elements are hidden by walking from each modal element up to the body and + * hiding the other children of every ancestor along the way. Hiding only the + * body's children would be insufficient, as a modal element is not + * necessarily a body child—popovers render into slots nested within + * body-level containers. * * The reason we do this is because `aria-modal="true"` currently is bugged * in Safari, and support is spotty in other browsers overall. In the future * we should consider removing these helper functions in favor of * `aria-modal="true"`. * - * @param {Element} modalElement The element that should not be hidden. + * @param {...Element} modalElements The elements that should not be hidden. + * + * @return {Set} A handle identifying the elements hidden by this call, + * to be passed to `unmodalize`. */ -export function modalize( modalElement ) { - const elements = Array.from( document.body.children ); - const hiddenElements = []; - hiddenElementsByDepth.push( hiddenElements ); - for ( const element of elements ) { - if ( element === modalElement ) { - continue; +export function modalize( ...modalElements ) { + // A Set, so overlapping ancestor chains do not record an element twice, and + // so `unmodalize` can remove this batch by identity regardless of the order + // in which overlapping modals close. + const hiddenElements = new Set(); + hiddenElementBatches.push( hiddenElements ); + + const elements = modalElements.filter( + ( element ) => element?.isConnected + ); + // Ancestors of a modal element must stay accessible, otherwise the modal + // element is hidden along with them. + const visibleElements = new Set(); + for ( const modalElement of elements ) { + for ( + let element = modalElement; + element && element !== document.body; + element = element.parentElement + ) { + visibleElements.add( element ); } + } - if ( elementShouldBeHidden( element ) ) { - element.setAttribute( 'aria-hidden', 'true' ); - hiddenElements.push( element ); + for ( const modalElement of elements ) { + for ( + let element = modalElement; + element?.parentElement && element !== document.body; + element = element.parentElement + ) { + for ( const sibling of element.parentElement.children ) { + if ( + visibleElements.has( sibling ) || + ! elementShouldBeHidden( sibling ) + ) { + continue; + } + + // Record the sibling even when another batch already hid it, so + // `unmodalize` reveals it only once every batch that hid it has + // been reversed. Leave `aria-hidden` present in that case, and + // never touch an element hidden by something other than a batch + // (e.g. authored markup)—`isBatchHidden` distinguishes the two. + if ( ! sibling.hasAttribute( 'aria-hidden' ) ) { + sibling.setAttribute( 'aria-hidden', 'true' ); + hiddenElements.add( sibling ); + } else if ( isBatchHidden( sibling ) ) { + hiddenElements.add( sibling ); + } + } } } + + return hiddenElements; } + /** * Determines if the passed element should not be hidden from screen readers. * + * A `aria-hidden` element that a batch already hid is still eligible, so an + * overlapping modal records it too; `modalize` guards the attribute write + * separately. An element hidden by anything else is left untouched. + * * @param {Element} element The element that should be checked. * * @return {boolean} Whether the element should not be hidden from screen-readers. @@ -54,22 +112,51 @@ export function elementShouldBeHidden( element ) { return ! ( element.tagName === 'SCRIPT' || element.hasAttribute( 'hidden' ) || - element.hasAttribute( 'aria-hidden' ) || + ( element.hasAttribute( 'aria-hidden' ) && + ! isBatchHidden( element ) ) || element.hasAttribute( 'aria-live' ) || ( role && LIVE_REGION_ARIA_ROLES.has( role ) ) ); } /** - * Accessibly reveals the elements hidden by the latest modal. + * Determines whether an element's `aria-hidden` was set by an active batch, + * as opposed to authored markup or another mechanism. + * + * @param {Element} element The element to check. + * + * @return {boolean} Whether a current batch hid the element. */ -export function unmodalize() { - const hiddenElements = hiddenElementsByDepth.pop(); - if ( ! hiddenElements ) { +function isBatchHidden( element ) { + return hiddenElementBatches.some( ( batch ) => batch.has( element ) ); +} + +/** + * Accessibly reveals the elements hidden by a modal. + * + * The batch to reveal is identified by the handle `modalize` returned, rather + * than assuming the most recent modal closes first. Modals do not always close + * in the reverse of the order they opened—the block inserter and the block + * settings menu can be open together—and React runs effect cleanups in + * hook-declaration order, not reverse-open order. + * + * @param {Set} handle The handle returned by `modalize`. + */ +export function unmodalize( handle ) { + const index = hiddenElementBatches.lastIndexOf( handle ); + if ( index === -1 ) { return; } - for ( const element of hiddenElements ) { - element.removeAttribute( 'aria-hidden' ); + hiddenElementBatches.splice( index, 1 ); + + for ( const element of handle ) { + // Keep the element hidden if another open modal still hides it. + const stillHidden = hiddenElementBatches.some( ( batch ) => + batch.has( element ) + ); + if ( ! stillHidden ) { + element.removeAttribute( 'aria-hidden' ); + } } } diff --git a/src/components/editor-toolbar/index.jsx b/src/components/editor-toolbar/index.jsx index 1b3ee54a8..886094046 100644 --- a/src/components/editor-toolbar/index.jsx +++ b/src/components/editor-toolbar/index.jsx @@ -27,6 +27,7 @@ import { store as editorStore } from '@wordpress/editor'; import './style.scss'; import { useModalize } from './use-modalize'; import { useModalDialogState } from '../editor/use-modal-dialog-state'; +import { OVERLAY_SLOT_NAME } from '../popover-slots/containers'; import { getGBKit } from '../../utils/bridge'; import NativeInserter from '../native-inserter'; import { useScrollIndicators } from './use-scroll-indicators'; @@ -147,6 +148,12 @@ const EditorToolbar = ( { className } ) => { onClose={ onCloseSettings } onFocusOutside={ onFocusOutside } role="dialog" + // Render outside the container clipping popovers to the + // viewport. This menu fills the viewport and never + // overflows, and WebKit renders a `position: fixed` + // element semi-transparent when an ancestor clips its + // overflow. See `../popover-slots`. + __unstableSlotName={ OVERLAY_SLOT_NAME } > <>
diff --git a/src/components/editor-toolbar/test/aria-helper.test.js b/src/components/editor-toolbar/test/aria-helper.test.js new file mode 100644 index 000000000..9f6124989 --- /dev/null +++ b/src/components/editor-toolbar/test/aria-helper.test.js @@ -0,0 +1,120 @@ +/** + * External dependencies + */ +import { describe, it, expect, afterEach } from 'vitest'; + +/** + * Internal dependencies + */ +import { modalize, unmodalize } from '../aria-helper'; + +const ariaHidden = ( id ) => + document.getElementById( id ).getAttribute( 'aria-hidden' ); + +afterEach( () => { + document.body.innerHTML = ''; +} ); + +describe( 'modalize / unmodalize', () => { + it( 'hides siblings outside the modal element and reveals them on unmodalize', () => { + document.body.innerHTML = ` +
editor
+ + `; + const handle = modalize( document.getElementById( 'modal' ) ); + + expect( ariaHidden( 'root' ) ).toBe( 'true' ); + expect( ariaHidden( 'modal' ) ).toBe( null ); + + unmodalize( handle ); + expect( ariaHidden( 'root' ) ).toBe( null ); + } ); + + it( 'keeps shared siblings hidden until every overlapping modal is reversed', () => { + // Both modals keep the same two containers reachable and therefore hide + // the same siblings—the real call pattern once `useModalize` always + // modalizes the clip and overlay containers. + document.body.innerHTML = ` +
editor
+
+
+ `; + const clip = document.getElementById( 'clip' ); + const overlay = document.getElementById( 'overlay' ); + + const first = modalize( clip, overlay ); + const second = modalize( clip, overlay ); + + // The second modal records #root even though the first already hid it. + expect( ariaHidden( 'root' ) ).toBe( 'true' ); + expect( second.has( document.getElementById( 'root' ) ) ).toBe( true ); + + // Closing the first modal while the second is open must NOT reveal #root. + unmodalize( first ); + expect( ariaHidden( 'root' ) ).toBe( 'true' ); + + // Closing the last modal reveals it. + unmodalize( second ); + expect( ariaHidden( 'root' ) ).toBe( null ); + } ); + + it( 'reverses batches by identity regardless of close order', () => { + document.body.innerHTML = ` +
editor
+
+
+ `; + const clip = document.getElementById( 'clip' ); + const overlay = document.getElementById( 'overlay' ); + + const first = modalize( clip, overlay ); + const second = modalize( clip, overlay ); + + // Close in the SAME order they opened (not reverse); still correct. + unmodalize( first ); + expect( ariaHidden( 'root' ) ).toBe( 'true' ); + unmodalize( second ); + expect( ariaHidden( 'root' ) ).toBe( null ); + } ); + + it( 'never reveals an element hidden by authored markup, not a batch', () => { + document.body.innerHTML = ` + + + `; + const handle = modalize( document.getElementById( 'modal' ) ); + + // #root was already aria-hidden and no batch owns it, so it is left + // alone and not recorded. + expect( handle.has( document.getElementById( 'root' ) ) ).toBe( false ); + + unmodalize( handle ); + // Its authored aria-hidden survives. + expect( ariaHidden( 'root' ) ).toBe( 'true' ); + } ); + + it( 'does not hide live regions or scripts', () => { + document.body.innerHTML = ` + +
status
+
live
+ `; + const handle = modalize( document.getElementById( 'modal' ) ); + + expect( ariaHidden( 'status' ) ).toBe( null ); + expect( ariaHidden( 'live' ) ).toBe( null ); + + unmodalize( handle ); + } ); + + it( 'ignores an unknown handle', () => { + document.body.innerHTML = `
editor
`; + const handle = modalize( document.getElementById( 'modal' ) ); + + unmodalize( new Set() ); // unknown handle: no-op + expect( ariaHidden( 'root' ) ).toBe( 'true' ); + + unmodalize( handle ); + expect( ariaHidden( 'root' ) ).toBe( null ); + } ); +} ); diff --git a/src/components/editor-toolbar/use-modalize.js b/src/components/editor-toolbar/use-modalize.js index e95fdb25b..1f83c5863 100644 --- a/src/components/editor-toolbar/use-modalize.js +++ b/src/components/editor-toolbar/use-modalize.js @@ -7,47 +7,38 @@ import { useEffect } from '@wordpress/element'; * Internal dependencies */ import * as ariaHelper from './aria-helper'; - -/** @typedef {import('@wordpress/element').RefObject} RefObject */ +import { + getClipContainer, + getOverlayContainer, +} from '../popover-slots/containers'; /** - * Conditionally applies the `aria-hidden` attribute to all direct decendents of - * the body element, except for the element provided. + * While a modal is visible, hides everything from screen readers except the + * containers popovers render into. * - * @param {boolean} isModalVisible A boolean indicating whether the modal is visible. - * @param {RefObject} elementRef A reference to the DOM element to be modalized. - */ -export function useModalize( isModalVisible, elementRef = defaultPopover() ) { - useEffect( () => { - if ( isModalVisible ) { - ariaHelper.modalize( elementRef.current ); - } else { - ariaHelper.unmodalize(); - } - }, [ elementRef, isModalVisible ] ); -} - -const popoverFallbackContainerRef = { current: null }; - -/** - * Retrieves or initializes the fallback container for popovers. + * Every editor popover renders into one of the two containers, and neither + * holds editor content, so keeping both reachable while hiding the rest of the + * document is what an open popover needs. * - * This function checks if the `popoverFallbackContainerRef` is already defined. - * If not, it attempts to find an element with the class name - * 'components-popover__fallback-container' in the document and assigns it to - * `popoverFallbackContainerRef`. It then returns an object with the current - * `popoverFallbackContainerRef`. + * Multiple modals may be open at once (the block inserter and the block + * settings menu, for example), so each call is reversed by the handle + * `modalize` returns rather than by close order. See `./aria-helper.js`. * - * @return {Object} An object containing the current `popoverFallbackContainerRef`. + * @param {boolean} isModalVisible Whether the modal is visible. */ -function defaultPopover() { - if ( popoverFallbackContainerRef.current ) { - return popoverFallbackContainerRef; - } +export function useModalize( isModalVisible ) { + useEffect( () => { + if ( ! isModalVisible ) { + return; + } - popoverFallbackContainerRef.current = document.getElementById( - 'popover-fallback-container' - ); + const handle = ariaHelper.modalize( + getClipContainer(), + getOverlayContainer() + ); - return popoverFallbackContainerRef; + return () => { + ariaHelper.unmodalize( handle ); + }; + }, [ isModalVisible ] ); } diff --git a/src/components/layout/index.jsx b/src/components/layout/index.jsx index 2cc5fc722..4addd66b1 100644 --- a/src/components/layout/index.jsx +++ b/src/components/layout/index.jsx @@ -3,6 +3,7 @@ */ import { ErrorBoundary, AutosaveMonitor } from '@wordpress/editor'; import { SnackbarNotices } from '@wordpress/notices'; +import { SlotFillProvider } from '@wordpress/components'; /** * Internal dependencies @@ -11,6 +12,7 @@ import Editor from '../editor'; import { onEditorContentChanged } from '../../utils/bridge'; import EditorLoadNotice from '../editor-load-notice'; import OfflineIndicator from '../offline-indicator'; +import PopoverSlots from '../popover-slots'; import './style.scss'; /** @@ -26,15 +28,18 @@ export default function Layout( props ) { return ( - - - - - - + + + + + + + + + ); } diff --git a/src/components/popover-slots/containers.js b/src/components/popover-slots/containers.js new file mode 100644 index 000000000..18b3cd487 --- /dev/null +++ b/src/components/popover-slots/containers.js @@ -0,0 +1,40 @@ +/** + * Name of the slot rendering popovers that must not be clipped—namely the + * full-screen block settings menu. Pass it to a `Popover` via + * `__unstableSlotName`. + * + * @type {string} + */ +export const OVERLAY_SLOT_NAME = 'gutenberg-kit-overlay'; + +/** + * Retrieves the container clipping popovers to the viewport. + * + * @return {HTMLElement} The clipping container element. + */ +export function getClipContainer() { + return getContainer( 'popover-clip-container' ); +} + +/** + * Retrieves the container rendering popovers that must not be clipped. + * + * @return {HTMLElement} The overlay container element. + */ +export function getOverlayContainer() { + return getContainer( 'popover-overlay-container' ); +} + +/** + * Retrieves a body-level popover container by ID. + * + * The containers are declared in `index.html` so their styles apply before the + * editor mounts. + * + * @param {string} id The container element ID. + * + * @return {HTMLElement|null} The container element, or null if absent. + */ +function getContainer( id ) { + return document.getElementById( id ); +} diff --git a/src/components/popover-slots/index.jsx b/src/components/popover-slots/index.jsx new file mode 100644 index 000000000..1bf187de6 --- /dev/null +++ b/src/components/popover-slots/index.jsx @@ -0,0 +1,57 @@ +/** + * WordPress dependencies + */ +import { createPortal } from '@wordpress/element'; +import { Popover } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import './style.scss'; +import { + OVERLAY_SLOT_NAME, + getClipContainer, + getOverlayContainer, +} from './containers'; + +/** + * Renders the slots that receive the editor's popovers. + * + * Popovers otherwise render into Gutenberg's fallback container, a body child + * positioned relative to the viewport. A popover anchored near the right + * edge—such as the block toolbar's "Options" menu after the toolbar is + * scrolled—extends past the viewport and grows the document's scrollable + * width, letting the canvas be swiped horizontally to reveal the empty + * background behind it. + * + * Two slots are rendered rather than one because the two kinds of popovers + * have opposing requirements: + * + * - Dropdown popovers must be clipped to the viewport. They render into the + * default slot, which lives in a fixed, `overflow: hidden` container. Being + * out of flow, that container clips the overflow without contributing to + * the document's scrollable size, so the document remains the scroll + * container. + * - The full-screen block settings menu must not be clipped. WebKit renders a + * `position: fixed` element semi-transparent when an ancestor clips its + * overflow, so the settings menu opts into its own slot in an unclipped + * container via `__unstableSlotName`. It fills the viewport and never + * overflows, so it does not need clipping. + * + * Both containers are body children so `modalize` can keep the active popover + * accessible while marking the rest of the document inert. See + * `../editor-toolbar/use-modalize.js`. + * + * @return {Element} The rendered popover slots. + */ +export default function PopoverSlots() { + return ( + <> + { createPortal( , getClipContainer() ) } + { createPortal( + , + getOverlayContainer() + ) } + + ); +} diff --git a/src/components/popover-slots/style.scss b/src/components/popover-slots/style.scss new file mode 100644 index 000000000..cfea66ff9 --- /dev/null +++ b/src/components/popover-slots/style.scss @@ -0,0 +1,44 @@ +// Clips popovers to the viewport so they cannot grow the document's +// scrollable width. `position: fixed` takes the container out of flow, which +// both sizes it to the viewport and keeps it from contributing to the +// document's scrollable size—leaving the document as the scroll container. +// +// `overflow-x` on the body or the root element does not clip popovers: the +// value propagates to the viewport rather than clipping out-of-flow +// descendants. Clipping on `#root` misses them entirely, as popovers render +// into a sibling of `#root` rather than a descendant. +// +// Stacks above the overlay container (below). Dropdowns opened from within the +// full-screen block settings menu—color and typography controls, etc.—render +// here, and the settings menu is opaque, so they must paint above it to be +// visible. They are anchored on-screen within the menu and so stay within the +// viewport clip. +#popover-clip-container { + inset: 0; + overflow: hidden; + position: fixed; + // The container spans the viewport, so it must not intercept touches + // intended for the editor. Its slot content restores pointer events. + pointer-events: none; + z-index: 1000001; // One above the overlay container +} + +// Restore pointer events on the slot content, not only on `.components-popover` +// roots—a fill may portal in sibling markup that is not itself a popover. +#popover-clip-container > * { + pointer-events: auto; +} + +// Popovers that must not be clipped—namely the full-screen block settings +// menu. This container does not clip, so it does not stack a WebKit +// compositing bug: an `overflow: hidden` ancestor rendered the fixed, +// full-screen settings menu semi-transparent. (The block inserter is also a +// fixed, full-screen popover but renders into the clipped container above +// without that artifact—so the trigger is narrower than any clipped fixed +// element; the menu is kept clear of clipping as a precaution.) The menu fills +// the viewport and never overflows, so clipping would gain nothing regardless. +// See `./index.jsx`. +#popover-overlay-container { + position: relative; + z-index: 1000000; // Mirrors Gutenberg's `.components-popover` z-index +} diff --git a/src/index.html b/src/index.html index 0f65f6355..22a045e3d 100644 --- a/src/index.html +++ b/src/index.html @@ -12,9 +12,17 @@
+
+
+