Skip to content
Merged
131 changes: 109 additions & 22 deletions src/components/editor-toolbar/aria-helper.js
Original file line number Diff line number Diff line change
@@ -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
*/
Expand All @@ -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<Element>} 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.
Expand All @@ -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<Element>} 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' );
}
}
}
7 changes: 7 additions & 0 deletions src/components/editor-toolbar/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }
>
<>
<div className="block-settings-menu__header">
Expand Down
120 changes: 120 additions & 0 deletions src/components/editor-toolbar/test/aria-helper.test.js
Original file line number Diff line number Diff line change
@@ -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 = `
<div id="root">editor</div>
<div id="modal"><span id="modal-child">m</span></div>
`;
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 = `
<div id="root">editor</div>
<div id="clip"></div>
<div id="overlay"></div>
`;
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 = `
<div id="root">editor</div>
<div id="clip"></div>
<div id="overlay"></div>
`;
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 = `
<div id="root" aria-hidden="true">pre-hidden</div>
<div id="modal"><span id="modal-child">m</span></div>
`;
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 = `
<div id="modal">m</div>
<div id="status" role="status">status</div>
<div id="live" aria-live="polite">live</div>
`;
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 = `<div id="root">editor</div><div id="modal">m</div>`;
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 );
} );
} );
61 changes: 26 additions & 35 deletions src/components/editor-toolbar/use-modalize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ] );
}
Loading
Loading