From 586e73d9ef1419eb7bf5d86ab88bc3a624ee47a7 Mon Sep 17 00:00:00 2001 From: Zaidfarooqui01 Date: Sat, 10 Jan 2026 01:37:12 +0530 Subject: [PATCH] Editor: Add ARIA labels to LinkControl dialog Add accessibility improvements per Trac #63108: * Add role="dialog", aria-labelledby="link-dialog-title", and aria-modal="true" * Add hidden heading with id="link-dialog-title" and text "Insert Link" * Add aria-label="Link URL" to URL input field * Add aria-label="Search links" to search input field Fixes #63108. Props: Zaidfarooqui01 See: https://core.trac.wordpress.org/ticket/63108 --- .../components/link-control/search-input.js | 187 ++++++++ packages/components/src/modal/index.tsx | 421 ++++++++++++++++++ 2 files changed, 608 insertions(+) create mode 100644 packages/block-editor/src/components/link-control/search-input.js create mode 100644 packages/components/src/modal/index.tsx diff --git a/packages/block-editor/src/components/link-control/search-input.js b/packages/block-editor/src/components/link-control/search-input.js new file mode 100644 index 0000000000000..addf781d378ed --- /dev/null +++ b/packages/block-editor/src/components/link-control/search-input.js @@ -0,0 +1,187 @@ +/** + * WordPress dependencies + */ +import { forwardRef, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import deprecated from '@wordpress/deprecated'; + +/** + * Internal dependencies + */ +import { URLInput } from '../'; +import LinkControlSearchResults from './search-results'; +import { CREATE_TYPE } from './constants'; +import useSearchHandler from './use-search-handler'; + +// Must be a function as otherwise URLInput will default +// to the fetchLinkSuggestions passed in block editor settings +// which will cause an unintended http request. +const noopSearchHandler = () => Promise.resolve( [] ); + +const noop = () => {}; + +const LinkControlSearchInput = forwardRef( + ( + { + value, + children, + currentLink = {}, + className = null, + placeholder = null, + withCreateSuggestion = false, + onCreateSuggestion = noop, + onChange = noop, + onSelect = noop, + showSuggestions = true, + renderSuggestions = ( props ) => ( + + ), + fetchSuggestions = null, + allowDirectEntry = true, + showInitialSuggestions = false, + suggestionsQuery = {}, + withURLSuggestion = true, + createSuggestionButtonText, + hideLabelFromVision = false, + suffix, + isEntity = false, + }, + ref + ) => { + const genericSearchHandler = useSearchHandler( + suggestionsQuery, + allowDirectEntry, + withCreateSuggestion, + withURLSuggestion + ); + + const searchHandler = showSuggestions + ? fetchSuggestions || genericSearchHandler + : noopSearchHandler; + + const [ focusedSuggestion, setFocusedSuggestion ] = useState(); + + /** + * Handles the user moving between different suggestions. Does not handle + * choosing an individual item. + * + * @param {string} selection the url of the selected suggestion. + * @param {Object} suggestion the suggestion object. + */ + const onInputChange = ( selection, suggestion ) => { + onChange( selection ); + setFocusedSuggestion( suggestion ); + }; + + const handleRenderSuggestions = ( props ) => + renderSuggestions( { + ...props, + withCreateSuggestion, + createSuggestionButtonText, + suggestionsQuery, + handleSuggestionClick: ( suggestion ) => { + if ( props.handleSuggestionClick ) { + props.handleSuggestionClick( suggestion ); + } + onSuggestionSelected( suggestion ); + }, + } ); + + const onSuggestionSelected = async ( selectedSuggestion ) => { + let suggestion = selectedSuggestion; + if ( CREATE_TYPE === selectedSuggestion.type ) { + // Create a new page and call onSelect with the output from the onCreateSuggestion callback. + try { + suggestion = await onCreateSuggestion( + selectedSuggestion.title + ); + if ( suggestion?.url ) { + onSelect( suggestion ); + } + } catch ( e ) {} + return; + } + + if ( + allowDirectEntry || + ( suggestion && Object.keys( suggestion ).length >= 1 ) + ) { + // Strip out id, url, kind, and type from the current link to prevent + // entity metadata from persisting when switching to a different link type. + // For example, when changing from an entity link (kind: 'post-type', type: 'page') + // to a custom URL (type: 'link', no kind), we need to ensure the old 'kind' + // doesn't carry over. We do want to preserve other properites like title, though. + const { id, url, kind, type, ...restLinkProps } = + currentLink ?? {}; + onSelect( + // Some direct entries don't have types or IDs, and we still need to clear the previous ones. + { ...restLinkProps, ...suggestion }, + suggestion + ); + } + }; + + const _placeholder = placeholder ?? __( 'Search or type URL' ); + + const label = + hideLabelFromVision && placeholder !== '' + ? _placeholder + : __( 'Link' ); + + return ( +
+ + { + const hasSuggestion = suggestion || focusedSuggestion; + + // If there is no suggestion and the value (ie: any manually entered URL) is empty + // then don't allow submission otherwise we get empty links. + if ( ! hasSuggestion && ! value?.trim()?.length ) { + event.preventDefault(); + } else { + onSuggestionSelected( + hasSuggestion || { url: value } + ); + } + } } + inputRef={ ref } + suffix={ suffix } + disabled={ isEntity } + /> + { children } +
+ ); + } +); + +export default LinkControlSearchInput; + +export const __experimentalLinkControlSearchInput = ( props ) => { + deprecated( 'wp.blockEditor.__experimentalLinkControlSearchInput', { + since: '6.8', + } ); + + return ; +}; diff --git a/packages/components/src/modal/index.tsx b/packages/components/src/modal/index.tsx new file mode 100644 index 0000000000000..ed4693ee6bb1f --- /dev/null +++ b/packages/components/src/modal/index.tsx @@ -0,0 +1,421 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ +import { + createPortal, + useCallback, + useEffect, + useRef, + useState, + forwardRef, + useLayoutEffect, + createContext, + useContext, +} from '@wordpress/element'; +import { + useInstanceId, + useFocusReturn, + useFocusOnMount, + useConstrainedTabbing, + useMergeRefs, +} from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; +import { close } from '@wordpress/icons'; +import { getScrollContainer } from '@wordpress/dom'; + +/** + * Internal dependencies + */ +import * as ariaHelper from './aria-helper'; +import Button from '../button'; +import StyleProvider from '../style-provider'; +import type { ModalProps } from './types'; +import { withIgnoreIMEEvents } from '../utils/with-ignore-ime-events'; +import { Spacer } from '../spacer'; +import { useModalExitAnimation } from './use-modal-exit-animation'; + +// Used to track and dismiss the prior modal when another opens unless nested. +type Dismissers = Set< + React.RefObject< ModalProps[ 'onRequestClose' ] | undefined > +>; +const ModalContext = createContext< Dismissers >( new Set() ); +ModalContext.displayName = 'ModalContext'; + +// Used to track body class names applied while modals are open. +const bodyOpenClasses = new Map< string, number >(); + +function UnforwardedModal( + props: ModalProps, + forwardedRef: React.ForwardedRef< HTMLDivElement > +) { + const { + bodyOpenClassName = 'modal-open', + role = 'dialog', + title = null, + focusOnMount = true, + shouldCloseOnEsc = true, + shouldCloseOnClickOutside = true, + isDismissible = true, + /* Accessibility. */ + aria = { + labelledby: undefined, + describedby: undefined, + }, + onRequestClose, + icon, + closeButtonLabel, + children, + style, + overlayClassName: overlayClassnameProp, + className, + contentLabel, + onKeyDown, + isFullScreen = false, + size, + headerActions = null, + __experimentalHideHeader = false, + } = props; + + const ref = useRef< HTMLDivElement >(); + + const instanceId = useInstanceId( Modal ); + const headingId = title + ? `components-modal-header-${ instanceId }` + : aria.labelledby; + + // The focus hook does not support 'firstContentElement' but this is a valid + // value for the Modal's focusOnMount prop. The following code ensures the focus + // hook will focus the first focusable node within the element to which it is applied. + // When `firstContentElement` is passed as the value of the focusOnMount prop, + // the focus hook is applied to the Modal's content element. + // Otherwise, the focus hook is applied to the Modal's ref. This ensures that the + // focus hook will focus the first element in the Modal's **content** when + // `firstContentElement` is passed. + const focusOnMountRef = useFocusOnMount( + focusOnMount === 'firstContentElement' ? 'firstElement' : focusOnMount + ); + const constrainedTabbingRef = useConstrainedTabbing(); + const focusReturnRef = useFocusReturn(); + const contentRef = useRef< HTMLDivElement >( null ); + const childrenContainerRef = useRef< HTMLDivElement >( null ); + + const [ hasScrolledContent, setHasScrolledContent ] = useState( false ); + const [ hasScrollableContent, setHasScrollableContent ] = useState( false ); + + let sizeClass; + if ( isFullScreen || size === 'fill' ) { + sizeClass = 'is-full-screen'; + } else if ( size ) { + sizeClass = `has-size-${ size }`; + } + + // 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 ] ); + + // Accessibly isolates/unisolates the modal. + useEffect( () => { + ariaHelper.modalize( ref.current ); + return () => ariaHelper.unmodalize(); + }, [] ); + + // Keeps a fresh ref for the subsequent effect. + const onRequestCloseRef = useRef< ModalProps[ 'onRequestClose' ] >(); + useEffect( () => { + onRequestCloseRef.current = onRequestClose; + }, [ onRequestClose ] ); + + // The list of `onRequestClose` callbacks of open (non-nested) Modals. Only + // one should remain open at a time and the list enables closing prior ones. + const dismissers = useContext( ModalContext ); + // Used for the tracking and dismissing any nested modals. + const [ nestedDismissers ] = useState< Dismissers >( () => new Set() ); + + // Updates the stack tracking open modals at this level and calls + // onRequestClose for any prior and/or nested modals as applicable. + useEffect( () => { + // add this modal instance to the dismissers set + dismissers.add( onRequestCloseRef ); + // request that all the other modals close themselves + for ( const dismisser of dismissers ) { + if ( dismisser !== onRequestCloseRef ) { + dismisser.current?.(); + } + } + return () => { + // request that all the nested modals close themselves + for ( const dismisser of nestedDismissers ) { + dismisser.current?.(); + } + // remove this modal instance from the dismissers set + dismissers.delete( onRequestCloseRef ); + }; + }, [ dismissers, nestedDismissers ] ); + + // Adds/removes the value of bodyOpenClassName to body element. + useEffect( () => { + const theClass = bodyOpenClassName; + const oneMore = 1 + ( bodyOpenClasses.get( theClass ) ?? 0 ); + bodyOpenClasses.set( theClass, oneMore ); + document.body.classList.add( bodyOpenClassName ); + return () => { + const oneLess = bodyOpenClasses.get( theClass )! - 1; + if ( oneLess === 0 ) { + document.body.classList.remove( theClass ); + bodyOpenClasses.delete( theClass ); + } else { + bodyOpenClasses.set( theClass, oneLess ); + } + }; + }, [ bodyOpenClassName ] ); + + const { closeModal, frameRef, frameStyle, overlayClassname } = + useModalExitAnimation(); + + // 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: React.KeyboardEvent< HTMLDivElement > + ) { + if ( + shouldCloseOnEsc && + ( event.code === 'Escape' || event.key === 'Escape' ) && + ! event.defaultPrevented + ) { + event.preventDefault(); + closeModal().then( () => onRequestClose( event ) ); + } + } + + const onContentContainerScroll = useCallback( + ( e: React.UIEvent< HTMLDivElement > ) => { + const scrollY = e?.currentTarget?.scrollTop ?? -1; + + if ( ! hasScrolledContent && scrollY > 0 ) { + setHasScrolledContent( true ); + } else if ( hasScrolledContent && scrollY <= 0 ) { + setHasScrolledContent( false ); + } + }, + [ hasScrolledContent ] + ); + + let pressTarget: EventTarget | null = null; + const overlayPressHandlers: { + onPointerDown: React.PointerEventHandler< HTMLDivElement >; + onPointerUp: React.PointerEventHandler< HTMLDivElement >; + } = { + onPointerDown: ( event ) => { + if ( event.target === event.currentTarget ) { + pressTarget = event.target; + // Avoids focus changing so that focus return works as expected. + event.preventDefault(); + } + }, + // Closes the modal with two exceptions. 1. Opening the context menu on + // the overlay. 2. Pressing on the overlay then dragging the pointer + // over the modal and releasing. Due to the modal being a child of the + // overlay, such a gesture is a `click` on the overlay and cannot be + // excepted by a `click` handler. Thus the tactic of handling + // `pointerup` and comparing its target to that of the `pointerdown`. + onPointerUp: ( { target, button } ) => { + const isSameTarget = target === pressTarget; + pressTarget = null; + if ( button === 0 && isSameTarget ) { + closeModal().then( () => onRequestClose() ); + } + }, + }; + + const modal = ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ +
+
+ { ! __experimentalHideHeader && ( +
+
+ { icon && ( + + { icon } + + ) } + { title && ( +

+ { title } +

+ ) } +
+ { headerActions } + { isDismissible && ( + <> + +
+ ) } + +
+ { children } +
+
+
+
+
+ ); + + return createPortal( + + { modal } + , + document.body + ); +} + +/** + * Modals give users information and choices related to a task they’re trying to + * accomplish. They can contain critical information, require decisions, or + * involve multiple tasks. + * + * ```jsx + * import { Button, Modal } from '@wordpress/components'; + * import { useState } from '@wordpress/element'; + * + * const MyModal = () => { + * const [ isOpen, setOpen ] = useState( false ); + * const openModal = () => setOpen( true ); + * const closeModal = () => setOpen( false ); + * + * return ( + * <> + * + * { isOpen && ( + * + * + * + * ) } + * + * ); + * }; + * ``` + */ +export const Modal = forwardRef( UnforwardedModal ); + +export default Modal;