@wordpress/block-library
Version:
Block library for the WordPress editor.
655 lines (601 loc) • 19.8 kB
JavaScript
/**
* 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>
</>
);
}