UNPKG

@wordpress/block-library

Version:
655 lines (601 loc) 19.8 kB
/** * External dependencies */ import clsx from 'clsx'; /** * WordPress dependencies */ import { createBlock } from '@wordpress/blocks'; import { useSelect, useDispatch } from '@wordpress/data'; import { ToolbarButton, ToolbarGroup, VisuallyHidden, } from '@wordpress/components'; import { displayShortcut, isKeyboardEvent } from '@wordpress/keycodes'; import { __, sprintf } from '@wordpress/i18n'; import { BlockControls, InspectorControls, RichText, useBlockProps, store as blockEditorStore, getColorClassName, useInnerBlocksProps, useBlockEditingMode, } from '@wordpress/block-editor'; import { isURL, prependHTTP } from '@wordpress/url'; import { useState, useEffect, useRef, useCallback } from '@wordpress/element'; import { decodeEntities } from '@wordpress/html-entities'; import { link as linkIcon, addSubmenu } from '@wordpress/icons'; import { store as coreStore } from '@wordpress/core-data'; import { useMergeRefs, useInstanceId } from '@wordpress/compose'; /** * Internal dependencies */ import { getColors } from '../navigation/edit/utils'; import { Controls, LinkUI, useEntityBinding, MissingEntityHelpText, useHandleLinkChange, } from './shared'; const DEFAULT_BLOCK = { name: 'core/navigation-link' }; const NESTING_BLOCK_NAMES = [ 'core/navigation-link', 'core/navigation-submenu', ]; /** * A React hook to determine if it's dragging within the target element. * * @typedef {import('@wordpress/element').RefObject} RefObject * * @param {RefObject<HTMLElement>} elementRef The target elementRef object. * * @return {boolean} Is dragging within the target element. */ const useIsDraggingWithin = ( elementRef ) => { const [ isDraggingWithin, setIsDraggingWithin ] = useState( false ); useEffect( () => { const { ownerDocument } = elementRef.current; function handleDragStart( event ) { // Check the first time when the dragging starts. handleDragEnter( event ); } // Set to false whenever the user cancel the drag event by either releasing the mouse or press Escape. function handleDragEnd() { setIsDraggingWithin( false ); } function handleDragEnter( event ) { // Check if the current target is inside the item element. if ( elementRef.current.contains( event.target ) ) { setIsDraggingWithin( true ); } else { setIsDraggingWithin( false ); } } // Bind these events to the document to catch all drag events. // Ideally, we can also use `event.relatedTarget`, but sadly that // doesn't work in Safari. ownerDocument.addEventListener( 'dragstart', handleDragStart ); ownerDocument.addEventListener( 'dragend', handleDragEnd ); ownerDocument.addEventListener( 'dragenter', handleDragEnter ); return () => { ownerDocument.removeEventListener( 'dragstart', handleDragStart ); ownerDocument.removeEventListener( 'dragend', handleDragEnd ); ownerDocument.removeEventListener( 'dragenter', handleDragEnter ); }; }, [ elementRef ] ); return isDraggingWithin; }; const useIsInvalidLink = ( kind, type, id, enabled ) => { const isPostType = kind === 'post-type' || type === 'post' || type === 'page'; const hasId = Number.isInteger( id ); const blockEditingMode = useBlockEditingMode(); const { postStatus, isDeleted } = useSelect( ( select ) => { if ( ! isPostType ) { return { postStatus: null, isDeleted: false }; } // Fetching the posts status is an "expensive" operation. Especially for sites with large navigations. // When the block is rendered in a template or other disabled contexts we can skip this check in order // to avoid all these additional requests that don't really add any value in that mode. if ( blockEditingMode === 'disabled' || ! enabled ) { return { postStatus: null, isDeleted: false }; } const { getEntityRecord, hasFinishedResolution } = select( coreStore ); const entityRecord = getEntityRecord( 'postType', type, id ); const hasResolved = hasFinishedResolution( 'getEntityRecord', [ 'postType', type, id, ] ); // If resolution has finished and entityRecord is undefined, the entity was deleted. const deleted = hasResolved && entityRecord === undefined; return { postStatus: entityRecord?.status, isDeleted: deleted, }; }, [ isPostType, blockEditingMode, enabled, type, id ] ); // Check Navigation Link validity if: // 1. Link is 'post-type'. // 2. It has an id. // 3. It's neither null, nor undefined, as valid items might be either of those while loading. // If those conditions are met, check if // 1. The post status is trash (trashed). // 2. The entity doesn't exist (deleted). // If either of those is true, invalidate. const isInvalid = isPostType && hasId && ( isDeleted || ( postStatus && 'trash' === postStatus ) ); const isDraft = 'draft' === postStatus; return [ isInvalid, isDraft ]; }; function getMissingText( type ) { let missingText = ''; switch ( type ) { case 'post': /* translators: label for missing post in navigation link block */ missingText = __( 'Select post' ); break; case 'page': /* translators: label for missing page in navigation link block */ missingText = __( 'Select page' ); break; case 'category': /* translators: label for missing category in navigation link block */ missingText = __( 'Select category' ); break; case 'tag': /* translators: label for missing tag in navigation link block */ missingText = __( 'Select tag' ); break; default: /* translators: label for missing values in navigation link block */ missingText = __( 'Add link' ); } return missingText; } export default function NavigationLinkEdit( { attributes, isSelected, setAttributes, insertBlocksAfter, mergeBlocks, onReplace, context, clientId, } ) { const { id, label, type, url, description, kind, metadata } = attributes; const { maxNestingLevel } = context; const { replaceBlock, __unstableMarkNextChangeAsNotPersistent, selectBlock, } = useDispatch( blockEditorStore ); // Have the link editing ui open on mount when lacking a url and selected. const [ isLinkOpen, setIsLinkOpen ] = useState( isSelected && ! url ); // Use internal state instead of a ref to make sure that the component // re-renders when the popover's anchor updates. const [ popoverAnchor, setPopoverAnchor ] = useState( null ); const listItemRef = useRef( null ); const isDraggingWithin = useIsDraggingWithin( listItemRef ); const itemLabelPlaceholder = __( 'Add label…' ); const ref = useRef(); const linkUIref = useRef(); // A link is "new" only if it has an undefined label // After the link is created, even if no label is provided, it's set to an empty string. const isNewLink = useRef( label === undefined ); // Track whether we should focus the submenu appender when closing the link UI const shouldSelectSubmenuAppenderOnClose = useRef( false ); const { isAtMaxNesting, isTopLevelLink, isParentOfSelectedBlock, hasChildren, validateLinkStatus, parentBlockClientId, isSubmenu, } = useSelect( ( select ) => { const { getBlockCount, getBlockName, getBlockRootClientId, hasSelectedInnerBlock, getBlockParentsByBlockName, getSelectedBlockClientId, } = select( blockEditorStore ); const rootClientId = getBlockRootClientId( clientId ); const parentBlockName = getBlockName( rootClientId ); const isTopLevel = parentBlockName === 'core/navigation'; const selectedBlockClientId = getSelectedBlockClientId(); const rootNavigationClientId = isTopLevel ? rootClientId : getBlockParentsByBlockName( clientId, 'core/navigation' )[ 0 ]; // Get the immediate parent - if it's a submenu, use it; otherwise use the navigation block const parentBlockId = parentBlockName === 'core/navigation-submenu' ? rootClientId : rootNavigationClientId; // Enable when the root Navigation block is selected or any of its inner blocks. const enableLinkStatusValidation = selectedBlockClientId === rootNavigationClientId || hasSelectedInnerBlock( rootNavigationClientId, true ); return { isAtMaxNesting: getBlockParentsByBlockName( clientId, NESTING_BLOCK_NAMES ) .length >= maxNestingLevel, isTopLevelLink: isTopLevel, isParentOfSelectedBlock: hasSelectedInnerBlock( clientId, true ), hasChildren: !! getBlockCount( clientId ), validateLinkStatus: enableLinkStatusValidation, parentBlockClientId: parentBlockId, isSubmenu: parentBlockName === 'core/navigation-submenu', }; }, [ clientId, maxNestingLevel ] ); const { getBlocks } = useSelect( blockEditorStore ); // URL binding logic const { hasUrlBinding, isBoundEntityAvailable } = useEntityBinding( { clientId, attributes, } ); const handleLinkChange = useHandleLinkChange( { clientId, attributes, setAttributes, } ); const [ isInvalid, isDraft ] = useIsInvalidLink( kind, type, id, validateLinkStatus ); /** * Transform to submenu block. */ const transformToSubmenu = useCallback( () => { let innerBlocks = getBlocks( clientId ); if ( innerBlocks.length === 0 ) { innerBlocks = [ createBlock( 'core/navigation-link' ) ]; selectBlock( innerBlocks[ 0 ].clientId ); } const newSubmenu = createBlock( 'core/navigation-submenu', attributes, innerBlocks ); replaceBlock( clientId, newSubmenu ); }, [ getBlocks, clientId, selectBlock, replaceBlock, attributes ] ); // On mount, if this is a new link without a URL and it's selected, // select the parent block (submenu or navigation) instead to keep the appender visible. // This helps us return focus to the appender if the user closes the link ui without creating a link. // If we leave focus on this block, then when we close the link without creating a link, focus will // be lost during the new block selection process. useEffect( () => { if ( isNewLink.current && isSelected ) { selectBlock( parentBlockClientId ); } }, [] ); // eslint-disable-line react-hooks/exhaustive-deps useEffect( () => { // If block has inner blocks, transform to Submenu. if ( hasChildren ) { // This side-effect should not create an undo level as those should // only be created via user interactions. __unstableMarkNextChangeAsNotPersistent(); transformToSubmenu(); } }, [ hasChildren, __unstableMarkNextChangeAsNotPersistent, transformToSubmenu, ] ); // Handle link UI when a new link is created useEffect( () => { // We know if a link was just created from our link UI if // 1. isNewLink.current is true // 2. url has a value // 3. isLinkOpen is true if ( ! isNewLink.current || ! url || ! isLinkOpen ) { return; } // Ensure this only runs once isNewLink.current = false; // We just created a link and the block is now selected. // If the label looks like a URL, focus and select the label text. if ( isURL( prependHTTP( label ) ) && /^.+\.[a-z]+/.test( label ) ) { // Focus and select the label text. selectLabelText(); } else { // If the link was just created, we want to select the block so the inspector controls // are accurate. selectBlock( clientId, null ); // Edge case: When the created link is the first child of a submenu, the focus will have // originated from the add submenu toolbar button. In this case, we need to return focus // to the submenu appender if the user closes the link ui using the keyboard. // Check if this is the first and only child of a newly created submenu. if ( isSubmenu ) { const parentBlocks = getBlocks( parentBlockClientId ); // If this is the only child, then this is a new submenu. // Set the flag to select the submenu appender when the link ui is closed. if ( parentBlocks.length === 1 && parentBlocks[ 0 ].clientId === clientId ) { shouldSelectSubmenuAppenderOnClose.current = true; } } } }, [ url, isLinkOpen, isNewLink, label ] ); /** * Focus the Link label text and select it. */ function selectLabelText() { ref.current.focus(); const { ownerDocument } = ref.current; const { defaultView } = ownerDocument; const selection = defaultView.getSelection(); const range = ownerDocument.createRange(); // Get the range of the current ref contents so we can add this range to the selection. range.selectNodeContents( ref.current ); selection.removeAllRanges(); selection.addRange( range ); } /** * Removes the current link if set. */ function removeLink() { // Reset all attributes that comprise the link. // It is critical that all attributes are reset // to their default values otherwise this may // in advertently trigger side effects because // the values will have "changed". setAttributes( { url: undefined, label: undefined, id: undefined, kind: undefined, type: undefined, opensInNewTab: false, } ); // Close the link editing UI. setIsLinkOpen( false ); } const { textColor, customTextColor, backgroundColor, customBackgroundColor, } = getColors( context, ! isTopLevelLink ); function onKeyDown( event ) { if ( isKeyboardEvent.primary( event, 'k' ) ) { // Required to prevent the command center from opening, // as it shares the CMD+K shortcut. // See https://github.com/WordPress/gutenberg/pull/59845. event.preventDefault(); // If this link is a child of a parent submenu item, the parent submenu item event will also open, closing this popover event.stopPropagation(); setIsLinkOpen( true ); } } const instanceId = useInstanceId( NavigationLinkEdit ); const hasMissingEntity = hasUrlBinding && ! isBoundEntityAvailable; const missingEntityDescriptionId = hasMissingEntity ? sprintf( 'navigation-link-edit-%d-desc', instanceId ) : undefined; const blockProps = useBlockProps( { ref: useMergeRefs( [ setPopoverAnchor, listItemRef ] ), className: clsx( 'wp-block-navigation-item', { 'is-editing': isSelected || isParentOfSelectedBlock, 'is-dragging-within': isDraggingWithin, 'has-link': !! url, 'has-child': hasChildren, 'has-text-color': !! textColor || !! customTextColor, [ getColorClassName( 'color', textColor ) ]: !! textColor, 'has-background': !! backgroundColor || customBackgroundColor, [ getColorClassName( 'background-color', backgroundColor ) ]: !! backgroundColor, } ), 'aria-describedby': missingEntityDescriptionId, 'aria-invalid': hasMissingEntity, style: { color: ! textColor && customTextColor, backgroundColor: ! backgroundColor && customBackgroundColor, }, onKeyDown, } ); const innerBlocksProps = useInnerBlocksProps( { ...blockProps, className: 'remove-outline', // Remove the outline from the inner blocks container. }, { defaultBlock: DEFAULT_BLOCK, directInsert: true, renderAppender: false, } ); const needsValidLink = ( ! url && ! ( hasUrlBinding && isBoundEntityAvailable ) ) || isInvalid || isDraft || ( hasUrlBinding && ! isBoundEntityAvailable ); if ( needsValidLink ) { blockProps.onClick = () => { setIsLinkOpen( true ); }; } const classes = clsx( 'wp-block-navigation-item__content', { 'wp-block-navigation-link__placeholder': needsValidLink, } ); const missingText = getMissingText( type ); /* translators: Whether the navigation link is Invalid or a Draft. */ const placeholderText = `(${ isInvalid ? __( 'Invalid' ) : __( 'Draft' ) })`; return ( <> <BlockControls> <ToolbarGroup> <ToolbarButton name="link" icon={ linkIcon } title={ __( 'Link' ) } shortcut={ displayShortcut.primary( 'k' ) } onClick={ () => { setIsLinkOpen( true ); } } /> { ! isAtMaxNesting && ( <ToolbarButton name="submenu" icon={ addSubmenu } title={ __( 'Add submenu' ) } onClick={ transformToSubmenu } /> ) } </ToolbarGroup> </BlockControls> <InspectorControls> <Controls attributes={ attributes } setAttributes={ setAttributes } clientId={ clientId } /> </InspectorControls> <div { ...blockProps }> { hasMissingEntity && ( <VisuallyHidden id={ missingEntityDescriptionId }> <MissingEntityHelpText type={ type } kind={ kind } /> </VisuallyHidden> ) } { /* eslint-disable jsx-a11y/anchor-is-valid */ } <a className={ classes }> { /* eslint-enable */ } { ! url && ! metadata?.bindings?.url ? ( <div className="wp-block-navigation-link__placeholder-text"> <span>{ missingText }</span> </div> ) : ( <> { ! isInvalid && ! isDraft && ( <> <RichText ref={ ref } identifier="label" className="wp-block-navigation-item__label" value={ label } onChange={ ( labelValue ) => setAttributes( { label: labelValue, } ) } onMerge={ mergeBlocks } onReplace={ onReplace } __unstableOnSplitAtEnd={ () => insertBlocksAfter( createBlock( 'core/navigation-link' ) ) } aria-label={ __( 'Navigation link text' ) } placeholder={ itemLabelPlaceholder } withoutInteractiveFormatting /> { description && ( <span className="wp-block-navigation-item__description"> { description } </span> ) } </> ) } { ( isInvalid || isDraft ) && ( <div className={ clsx( 'wp-block-navigation-link__placeholder-text', 'wp-block-navigation-link__label', { 'is-invalid': isInvalid, 'is-draft': isDraft, } ) } > <span> { // Some attributes are stored in an escaped form. It's a legacy issue. // Ideally they would be stored in a raw, unescaped form. // Unescape is used here to "recover" the escaped characters // so they display without encoding. // See `updateAttributes` for more details. `${ decodeEntities( label ) } ${ isInvalid || isDraft ? placeholderText : '' }`.trim() } </span> </div> ) } </> ) } { isLinkOpen && ( <LinkUI ref={ linkUIref } clientId={ clientId } link={ attributes } onClose={ () => { setIsLinkOpen( false ); // If there is no link and no binding, remove the auto-inserted block. // This avoids empty blocks which can provided a poor UX. // Don't remove if binding exists (even if entity is unavailable) so user can fix it. if ( ! url && ! hasUrlBinding ) { onReplace( [] ); return; } // Edge case: If this is the first child of a new submenu, focus the submenu's appender if ( shouldSelectSubmenuAppenderOnClose.current ) { shouldSelectSubmenuAppenderOnClose.current = false; // The appender is the next sibling in the DOM after the current block if ( listItemRef.current?.nextElementSibling ) { const appenderButton = listItemRef.current.nextElementSibling.querySelector( '.block-editor-button-block-appender' ); if ( appenderButton ) { appenderButton.focus(); } } } } } anchor={ popoverAnchor } onRemove={ removeLink } onChange={ handleLinkChange } /> ) } </a> <div { ...innerBlocksProps } /> </div> </> ); }