@wordpress/block-library
Version:
Block library for the WordPress editor.
366 lines (342 loc) • 9.77 kB
JavaScript
/**
* WordPress dependencies
*/
import {
Button,
__experimentalToolsPanel as ToolsPanel,
__experimentalToolsPanelItem as ToolsPanelItem,
__experimentalHStack as HStack,
CheckboxControl,
TextControl,
TextareaControl,
} from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
import { __unstableStripHTML as stripHTML } from '@wordpress/dom';
import {
privateApis as blockEditorPrivateApis,
store as blockEditorStore,
} from '@wordpress/block-editor';
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { external } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { useToolsPanelDropdownMenuProps } from '../../utils/hooks';
import { useHandleLinkChange } from './use-handle-link-change';
import { useEntityBinding } from './use-entity-binding';
import { getSuggestionsQuery } from '../link-ui';
import { useLinkPreview } from './use-link-preview';
import { useIsInvalidLink } from './use-is-invalid-link';
import { unlock } from '../../lock-unlock';
const { LinkPicker, isHashLink, isRelativePath } = unlock(
blockEditorPrivateApis
);
/**
* Get a human-readable entity type name.
*
* @param {string} type - The entity type
* @param {string} kind - The entity kind
* @return {string} Human-readable entity type name
*/
function getEntityTypeName( type, kind ) {
if ( kind === 'post-type' ) {
switch ( type ) {
case 'post':
return __( 'post' );
case 'page':
return __( 'page' );
default:
return type || __( 'post' );
}
}
if ( kind === 'taxonomy' ) {
switch ( type ) {
case 'category':
return __( 'category' );
case 'tag':
return __( 'tag' );
default:
return type || __( 'term' );
}
}
return type || __( 'item' );
}
/**
* Shared Controls component for Navigation Link and Navigation Submenu blocks.
*
* This component provides the inspector controls (ToolsPanel) that are identical
* between both navigation blocks.
*
* @param {Object} props - Component props
* @param {Object} props.attributes - Block attributes
* @param {Function} props.setAttributes - Function to update block attributes
* @param {string} props.clientId - Block client ID
* @param {boolean} props.isLinkEditable - Whether link editing should be allowed
*/
export function Controls( {
attributes,
setAttributes,
clientId,
isLinkEditable = true,
} ) {
const { label, url, description, rel, opensInNewTab } = attributes;
const dropdownMenuProps = useToolsPanelDropdownMenuProps();
// Use the entity binding hook for UI state (help text, link preview, etc.)
const { hasUrlBinding, isBoundEntityAvailable, entityRecord } =
useEntityBinding( {
clientId,
attributes,
} );
const [ isInvalid, isDraft ] = useIsInvalidLink(
attributes.kind,
attributes.type,
entityRecord?.id,
hasUrlBinding
);
let helpText = '';
if ( isInvalid || ( hasUrlBinding && ! isBoundEntityAvailable ) ) {
// Show invalid link help text for:
// 1. Invalid post-type links (trashed/deleted posts/pages) - via useIsInvalidLink
// 2. Missing bound taxonomy entities (deleted categories/tags) - useIsInvalidLink only checks post-types
helpText = getInvalidLinkHelpText();
} else if ( isDraft ) {
helpText = getDraftHelpText( {
type: attributes.type,
kind: attributes.kind,
} );
}
// Get the link change handler with built-in binding management
const handleLinkChange = useHandleLinkChange( {
clientId,
attributes,
setAttributes,
} );
const onNavigateToEntityRecord = useSelect(
( select ) =>
select( blockEditorStore ).getSettings().onNavigateToEntityRecord,
[]
);
const homeUrl = useSelect( ( select ) => {
return select( coreStore ).getEntityRecord( 'root', '__unstableBase' )
?.home;
}, [] );
const blockEditingMode = useSelect(
( select ) =>
select( blockEditorStore ).getBlockEditingMode( clientId ),
[ clientId ]
);
const isContentOnly = blockEditingMode === 'contentOnly';
const preview = useLinkPreview( {
url,
entityRecord,
type: attributes.type,
hasBinding: hasUrlBinding,
isEntityAvailable: isBoundEntityAvailable,
} );
// Check if URL is viewable (not hash link or other relative path like ./ or ../)
const isViewableUrl =
url &&
( ! isHashLink( url ) ||
( isRelativePath( url ) && ! url.startsWith( '/' ) ) );
// Construct full URL for viewing (prepend home URL for absolute paths starting with /)
const viewUrl =
isViewableUrl && url.startsWith( '/' ) && homeUrl ? homeUrl + url : url;
const entityTypeName = getEntityTypeName(
attributes.type,
attributes.kind
);
return (
<ToolsPanel
label={ __( 'Settings' ) }
resetAll={ () => {
setAttributes( {
label: '',
url: '',
description: '',
rel: '',
opensInNewTab: false,
} );
} }
dropdownMenuProps={ dropdownMenuProps }
>
<ToolsPanelItem
hasValue={ () => !! label }
label={ __( 'Text' ) }
onDeselect={ () => setAttributes( { label: '' } ) }
isShownByDefault
>
<TextControl
__next40pxDefaultSize
label={ __( 'Text' ) }
value={ label ? stripHTML( label ) : '' }
onChange={ ( labelValue ) => {
setAttributes( { label: labelValue } );
} }
autoComplete="off"
/>
</ToolsPanelItem>
{ isLinkEditable && (
<>
<ToolsPanelItem
hasValue={ () => !! url }
label={ __( 'Link to' ) }
onDeselect={ () =>
setAttributes( {
url: undefined,
id: undefined,
kind: undefined,
type: undefined,
} )
}
isShownByDefault
>
<LinkPicker
preview={ preview }
onSelect={ handleLinkChange }
suggestionsQuery={ getSuggestionsQuery(
attributes.type,
attributes.kind
) }
label={ __( 'Link to' ) }
help={ helpText ? helpText : undefined }
/>
</ToolsPanelItem>
{ url && (
<HStack
className="navigation-link-to__actions"
alignment="left"
justify="left"
style={ { gridColumn: '1 / -1' } }
>
{ hasUrlBinding &&
isBoundEntityAvailable &&
entityRecord?.id &&
attributes.kind === 'post-type' &&
onNavigateToEntityRecord && (
<Button
size="compact"
variant="secondary"
onClick={ () => {
onNavigateToEntityRecord( {
postId: entityRecord.id,
postType: attributes.type,
} );
} }
__next40pxDefaultSize
>
{ sprintf(
/* translators: %s: entity type (e.g., "page", "post", "category") */
__( 'Edit %s' ),
entityTypeName
) }
</Button>
) }
{ isViewableUrl && (
<Button
size="compact"
variant="secondary"
href={ viewUrl }
target="_blank"
icon={ external }
iconPosition="right"
__next40pxDefaultSize
>
{ sprintf(
/* translators: %s: entity type (e.g., "page", "post", "category") or "link" for external links */
__( 'View %s' ),
attributes.kind &&
attributes.type &&
attributes.kind !== 'custom'
? entityTypeName
: __( 'link' )
) }
</Button>
) }
</HStack>
) }
<ToolsPanelItem
hasValue={ () => !! opensInNewTab }
label={ __( 'Open in new tab' ) }
onDeselect={ () =>
setAttributes( { opensInNewTab: false } )
}
isShownByDefault
>
<CheckboxControl
label={ __( 'Open in new tab' ) }
checked={ opensInNewTab }
onChange={ ( value ) =>
setAttributes( { opensInNewTab: value } )
}
/>
</ToolsPanelItem>
</>
) }
<ToolsPanelItem
hasValue={ () => !! description }
label={ __( 'Description' ) }
onDeselect={ () => setAttributes( { description: '' } ) }
isShownByDefault={ ! isContentOnly }
>
<TextareaControl
label={ __( 'Description' ) }
value={ description || '' }
onChange={ ( descriptionValue ) => {
setAttributes( { description: descriptionValue } );
} }
help={ __(
'The description will be displayed in the menu if the current theme supports it.'
) }
/>
</ToolsPanelItem>
<ToolsPanelItem
hasValue={ () => !! rel }
label={ __( 'Rel attribute' ) }
onDeselect={ () => setAttributes( { rel: '' } ) }
isShownByDefault={ ! isContentOnly }
>
<TextControl
__next40pxDefaultSize
label={ __( 'Rel attribute' ) }
value={ rel || '' }
onChange={ ( relValue ) => {
setAttributes( { rel: relValue } );
} }
autoComplete="off"
help={ __(
'The relationship of the linked URL as space-separated link types.'
) }
/>
</ToolsPanelItem>
</ToolsPanel>
);
}
/**
* Returns help text for invalid links.
*
* @return {string} Error help text string (empty string if valid).
*/
export function getInvalidLinkHelpText() {
return __(
'This link is invalid and will not appear on your site. Please update the link.'
);
}
/**
* Returns the help text for links to draft entities
*
* @param {Object} props - Function props
* @param {string} props.type - The entity type
* @param {string} props.kind - The entity kind
* @return {string} Draft help text
*/
function getDraftHelpText( { type, kind } ) {
const entityType = getEntityTypeName( type, kind );
return sprintf(
/* translators: %1$s is the entity type (e.g., "page", "post", "category") */
__(
'This link is to a draft %1$s and will not appear on your site until the %1$s is published.'
),
entityType
);
}