@@ -194,13 +147,6 @@ const BlockInspector = ( { showNoBlockSelectedMessage = true } ) => {
}
return null;
}
- if ( topLevelLockedBlock ) {
- return (
-
);
@@ -260,9 +207,13 @@ const AnimatedContainer = ( {
);
};
-const BlockInspectorSingleBlock = ( { clientId, blockName } ) => {
+const BlockInspectorSingleBlock = ( {
+ clientId,
+ blockName,
+ isSectionBlock,
+} ) => {
const availableTabs = useInspectorControlsTabs( blockName );
- const showTabs = availableTabs?.length > 1;
+ const showTabs = ! isSectionBlock && availableTabs?.length > 1;
const hasBlockStyles = useSelect(
( select ) => {
@@ -274,6 +225,26 @@ const BlockInspectorSingleBlock = ( { clientId, blockName } ) => {
);
const blockInformation = useBlockDisplayInformation( clientId );
const borderPanelLabel = useBorderPanelLabel( { blockName } );
+ const contentClientIds = useSelect(
+ ( select ) => {
+ // Avoid unnecessary subscription.
+ if ( ! isSectionBlock ) {
+ return;
+ }
+
+ const {
+ getClientIdsOfDescendants,
+ getBlockName,
+ getBlockEditingMode,
+ } = select( blockEditorStore );
+ return getClientIdsOfDescendants( clientId ).filter(
+ ( current ) =>
+ getBlockName( current ) !== 'core/list-item' &&
+ getBlockEditingMode( current ) === 'contentOnly'
+ );
+ },
+ [ isSectionBlock, clientId ]
+ );
return (
@@ -296,35 +267,48 @@ const BlockInspectorSingleBlock = ( { clientId, blockName } ) => {
{ hasBlockStyles && (
) }
-
-
-
-
-
-
-
-
-
-
-
+
+ { contentClientIds && contentClientIds?.length > 0 && (
+
+
+
+ ) }
+
+ { ! isSectionBlock && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) }
>
) }
diff --git a/packages/block-editor/src/components/block-inspector/style.scss b/packages/block-editor/src/components/block-inspector/style.scss
index bdbf3660d9619e..92a9a0dd03ab35 100644
--- a/packages/block-editor/src/components/block-inspector/style.scss
+++ b/packages/block-editor/src/components/block-inspector/style.scss
@@ -49,4 +49,6 @@
.block-editor-block-inspector__tab-item {
flex: 1 1 0px;
+ display: flex;
+ justify-content: center;
}
diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js
index deda4e3b9d0897..783b45da932a3d 100644
--- a/packages/block-editor/src/components/block-list/block.js
+++ b/packages/block-editor/src/components/block-list/block.js
@@ -25,6 +25,7 @@ import {
getBlockDefaultClassName,
hasBlockSupport,
store as blocksStore,
+ privateApis as blocksPrivateApis,
} from '@wordpress/blocks';
import { withFilters } from '@wordpress/components';
import { withDispatch, useDispatch, useSelect } from '@wordpress/data';
@@ -46,6 +47,8 @@ import { PrivateBlockContext } from './private-block-context';
import { unlock } from '../../lock-unlock';
+const { isUnmodifiedBlockContent } = unlock( blocksPrivateApis );
+
/**
* Merges wrapper props with special handling for classNames and styles.
*
@@ -350,12 +353,48 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, registry ) => {
removeBlock( _clientId );
} else {
registry.batch( () => {
+ const firstBlock = getBlock( firstClientId );
+ const isFirstBlockContentUnmodified =
+ isUnmodifiedBlockContent( firstBlock );
+ const defaultBlockName = getDefaultBlockName();
+ const replacement = switchToBlockType(
+ firstBlock,
+ defaultBlockName
+ );
+ const canTransformToDefaultBlock =
+ !! replacement?.length &&
+ replacement.every( ( block ) =>
+ canInsertBlockType( block.name, _clientId )
+ );
+
if (
+ isFirstBlockContentUnmodified &&
+ canTransformToDefaultBlock
+ ) {
+ // Step 1: If the block is empty and can be transformed to the default block type.
+ replaceBlocks(
+ firstClientId,
+ replacement,
+ changeSelection
+ );
+ } else if (
+ isFirstBlockContentUnmodified &&
+ firstBlock.name === defaultBlockName
+ ) {
+ // Step 2: If the block is empty and is already the default block type.
+ removeBlock( firstClientId );
+ const nextBlockClientId =
+ getNextBlockClientId( clientId );
+ if ( nextBlockClientId ) {
+ selectBlock( nextBlockClientId );
+ }
+ } else if (
canInsertBlockType(
- getBlockName( firstClientId ),
+ firstBlock.name,
targetRootClientId
)
) {
+ // Step 3: If the block can be moved up.
moveBlocksToPosition(
[ firstClientId ],
_clientId,
@@ -363,21 +402,17 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, registry ) => {
getBlockIndex( _clientId )
);
} else {
- const replacement = switchToBlockType(
- getBlock( firstClientId ),
- getDefaultBlockName()
- );
-
- if (
- replacement &&
- replacement.length &&
+ const canLiftAndTransformToDefaultBlock =
+ !! replacement?.length &&
replacement.every( ( block ) =>
canInsertBlockType(
block.name,
targetRootClientId
)
- )
- ) {
+ );
+
+ if ( canLiftAndTransformToDefaultBlock ) {
+ // Step 4: If the block can be transformed to the default block type and moved up.
insertBlocks(
replacement,
getBlockIndex( _clientId ),
@@ -386,6 +421,7 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, registry ) => {
);
removeBlock( firstClientId, false );
} else {
+ // Step 5: Continue the default behavior.
switchToDefaultOrRemove();
}
}
@@ -549,6 +585,7 @@ function BlockListBlockProvider( props ) {
getBlockMode,
isSelectionEnabled,
getTemplateLock,
+ isSectionBlock: _isSectionBlock,
getBlockWithoutAttributes,
getBlockAttributes,
canRemoveBlock,
@@ -571,10 +608,7 @@ function BlockListBlockProvider( props ) {
__unstableSelectionHasUnmergeableBlock,
isBlockBeingDragged,
isDragging,
- hasBlockMovingClientId,
- canInsertBlockType,
__unstableHasActiveBlockOverlayActive,
- __unstableGetEditorMode,
getSelectedBlocksInitialCaretPosition,
} = unlock( select( blockEditorStore ) );
const blockWithoutAttributes =
@@ -632,7 +666,6 @@ function BlockListBlockProvider( props ) {
clientId,
checkDeep
);
- const movingClientId = hasBlockMovingClientId();
const blockEditingMode = getBlockEditingMode( clientId );
const multiple = hasBlockSupport( blockName, 'multiple', true );
@@ -646,14 +679,12 @@ function BlockListBlockProvider( props ) {
blocksWithSameName.length &&
blocksWithSameName[ 0 ] !== clientId;
- const editorMode = __unstableGetEditorMode();
-
return {
...previewContext,
mode: getBlockMode( clientId ),
isSelectionEnabled: isSelectionEnabled(),
isLocked: !! getTemplateLock( rootClientId ),
- templateLock: getTemplateLock( clientId ),
+ isSectionBlock: _isSectionBlock( clientId ),
canRemove,
canMove,
isSelected: _isSelected,
@@ -674,18 +705,15 @@ function BlockListBlockProvider( props ) {
) && hasSelectedInnerBlock( clientId ),
blockApiVersion: blockType?.apiVersion || 1,
blockTitle: match?.title || blockType?.title,
- editorMode,
isSubtreeDisabled:
blockEditingMode === 'disabled' &&
isBlockSubtreeDisabled( clientId ),
hasOverlay:
__unstableHasActiveBlockOverlayActive( clientId ) &&
! isDragging(),
- initialPosition:
- _isSelected &&
- ( editorMode === 'edit' || editorMode === 'zoom-out' ) // Don't recalculate the initialPosition when toggling in/out of zoom-out mode
- ? getSelectedBlocksInitialCaretPosition()
- : undefined,
+ initialPosition: _isSelected
+ ? getSelectedBlocksInitialCaretPosition()
+ : undefined,
isHighlighted: isBlockHighlighted( clientId ),
isMultiSelected,
isPartiallySelected:
@@ -694,13 +722,6 @@ function BlockListBlockProvider( props ) {
! __unstableSelectionHasUnmergeableBlock(),
isDragging: isBlockBeingDragged( clientId ),
hasChildSelected: isAncestorOfSelectedBlock,
- isBlockMovingMode: !! movingClientId,
- canInsertMovingBlock:
- movingClientId &&
- canInsertBlockType(
- getBlockName( movingClientId ),
- rootClientId
- ),
isEditingDisabled: blockEditingMode === 'disabled',
hasEditableOutline:
blockEditingMode !== 'disabled' &&
@@ -730,7 +751,6 @@ function BlockListBlockProvider( props ) {
themeSupportsLayout,
isTemporarilyEditingAsBlocks,
blockEditingMode,
- editorMode,
mayDisplayControls,
mayDisplayParentControls,
index,
@@ -745,9 +765,7 @@ function BlockListBlockProvider( props ) {
isReusable,
isDragging,
hasChildSelected,
- isBlockMovingMode,
- canInsertMovingBlock,
- templateLock,
+ isSectionBlock,
isEditingDisabled,
hasEditableOutline,
className,
@@ -785,16 +803,13 @@ function BlockListBlockProvider( props ) {
hasOverlay,
initialPosition,
blockEditingMode,
- editorMode,
isHighlighted,
isMultiSelected,
isPartiallySelected,
isReusable,
isDragging,
hasChildSelected,
- isBlockMovingMode,
- canInsertMovingBlock,
- templateLock,
+ isSectionBlock,
isEditingDisabled,
hasEditableOutline,
isTemporarilyEditingAsBlocks,
diff --git a/packages/block-editor/src/components/block-list/content.scss b/packages/block-editor/src/components/block-list/content.scss
index 3f4b4c508aeb02..6a88813b0c6049 100644
--- a/packages/block-editor/src/components/block-list/content.scss
+++ b/packages/block-editor/src/components/block-list/content.scss
@@ -80,7 +80,6 @@ _::-webkit-full-page-media, _:future, :root .has-multi-selection .block-editor-b
// since things like border-radius need to be able to be set on the block itself.
.block-editor-block-list__block.is-highlighted,
.block-editor-block-list__block.is-highlighted ~ .is-multi-selected,
- &.is-navigate-mode .block-editor-block-list__block.is-selected,
.block-editor-block-list__block:not([contenteditable="true"]):focus {
outline: none;
@@ -92,56 +91,12 @@ _::-webkit-full-page-media, _:future, :root .has-multi-selection .block-editor-b
}
}
- // Moving blocks using keyboard (Ellipsis > Move).
- & .is-block-moving-mode.block-editor-block-list__block.is-selected {
-
- &::after {
- content: "";
- position: absolute;
- z-index: 0;
- pointer-events: none;
- transition:
- border-color 0.1s linear,
- border-style 0.1s linear,
- box-shadow 0.1s linear;
- right: 0;
- left: 0;
- top: -$default-block-margin * 0.5;
- border-radius: $radius-small;
- border-top: 4px solid $gray-400;
- bottom: auto;
- box-shadow: none;
- }
- }
-
- & .is-block-moving-mode.can-insert-moving-block.block-editor-block-list__block.is-selected {
- &::after {
- border-color: var(--wp-admin-theme-color);
- }
- }
-
- // Ensure an accurate partial text selection.
- // To do this, we disable text selection on the main container, then re-enable it only on the
- // elements that actually get selected.
- // To keep in mind: user-select is currently inherited to all nodes inside.
- .has-multi-selection & {
- user-select: none;
- }
-
// Re-enable it on components inside.
[class^="components-"] {
user-select: text;
}
}
-.is-block-moving-mode.block-editor-block-list__block-selection-button {
- // Should be invisible but not unfocusable.
- opacity: 0;
- font-size: 1px;
- height: 1px;
- padding: 0;
-}
-
.block-editor-block-list__layout .block-editor-block-list__block {
// With `position: static`, Safari marks a full-width selection rectangle, including margins.
// With `position: relative`, Safari marks an inline selection rectangle, similar to that of
@@ -154,11 +109,9 @@ _::-webkit-full-page-media, _:future, :root .has-multi-selection .block-editor-b
overflow-wrap: break-word;
pointer-events: auto;
- user-select: text;
&.is-editing-disabled {
pointer-events: none;
- user-select: none;
}
&.has-negative-margin {
@@ -223,19 +176,6 @@ _::-webkit-full-page-media, _:future, :root .has-multi-selection .block-editor-b
background-color: transparent;
}
- // Reusable blocks clickthrough overlays.
- &.is-reusable > .block-editor-inner-blocks > .block-editor-block-list__layout.has-overlay {
- // Remove only the top click overlay.
- &::after {
- display: none;
- }
-
- // Restore it for subsequent.
- .block-editor-block-list__layout.has-overlay::after {
- display: block;
- }
- }
-
// Reusable blocks parent border.
&.is-reusable.has-child-selected::after {
box-shadow: 0 0 0 1px var(--wp-admin-theme-color);
@@ -307,7 +247,7 @@ _::-webkit-full-page-media, _:future, :root .has-multi-selection .block-editor-b
}
}
-.is-root-container:not([inert]) .block-editor-block-list__block.is-reusable.is-selected .block-editor-block-list__block.has-editable-outline::after {
+.is-root-container:not([inert]) .block-editor-block-list__block.is-selected .block-editor-block-list__block.has-editable-outline::after {
animation-name: block-editor-is-editable__animation;
animation-duration: 0.8s;
animation-timing-function: ease-out;
@@ -461,6 +401,18 @@ _::-webkit-full-page-media, _:future, :root .has-multi-selection .block-editor-b
margin-left: -1px;
margin-right: -1px;
transition: background-color 0.3s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: $default-font-size;
+ font-family: $default-font;
+ color: $black;
+ font-weight: normal;
+
+ .is-zoomed-out & {
+ // Scale the font size based on the zoom level.
+ font-size: calc(#{$default-font-size} * ( 2 - var(--wp-block-editor-iframe-zoom-out-scale) ));
+ }
&.is-dragged-over {
background: $gray-400;
diff --git a/packages/block-editor/src/components/block-list/index.js b/packages/block-editor/src/components/block-list/index.js
index ea6128f1534642..e2e019d4a9bf69 100644
--- a/packages/block-editor/src/components/block-list/index.js
+++ b/packages/block-editor/src/components/block-list/index.js
@@ -47,26 +47,17 @@ const pendingBlockVisibilityUpdatesPerRegistry = new WeakMap();
function Root( { className, ...settings } ) {
const isLargeViewport = useViewportMatch( 'medium' );
- const {
- isOutlineMode,
- isFocusMode,
- editorMode,
- temporarilyEditingAsBlocks,
- } = useSelect( ( select ) => {
- const {
- getSettings,
- __unstableGetEditorMode,
- getTemporarilyEditingAsBlocks,
- isTyping,
- } = unlock( select( blockEditorStore ) );
- const { outlineMode, focusMode } = getSettings();
- return {
- isOutlineMode: outlineMode && ! isTyping(),
- isFocusMode: focusMode,
- editorMode: __unstableGetEditorMode(),
- temporarilyEditingAsBlocks: getTemporarilyEditingAsBlocks(),
- };
- }, [] );
+ const { isOutlineMode, isFocusMode, temporarilyEditingAsBlocks } =
+ useSelect( ( select ) => {
+ const { getSettings, getTemporarilyEditingAsBlocks, isTyping } =
+ unlock( select( blockEditorStore ) );
+ const { outlineMode, focusMode } = getSettings();
+ return {
+ isOutlineMode: outlineMode && ! isTyping(),
+ isFocusMode: focusMode,
+ temporarilyEditingAsBlocks: getTemporarilyEditingAsBlocks(),
+ };
+ }, [] );
const registry = useRegistry();
const { setBlockVisibility } = useDispatch( blockEditorStore );
@@ -115,7 +106,6 @@ function Root( { className, ...settings } ) {
className: clsx( 'is-root-container', className, {
'is-outline-mode': isOutlineMode,
'is-focus-mode': isFocusMode && isLargeViewport,
- 'is-navigate-mode': editorMode === 'navigation',
} ),
},
settings
@@ -192,7 +182,8 @@ function Items( {
getTemplateLock,
getBlockEditingMode,
__unstableGetEditorMode,
- } = select( blockEditorStore );
+ isSectionBlock,
+ } = unlock( select( blockEditorStore ) );
const _order = getBlockOrder( rootClientId );
@@ -211,15 +202,16 @@ function Items( {
visibleBlocks: __unstableGetVisibleBlocks(),
isZoomOut: __unstableGetEditorMode() === 'zoom-out',
shouldRenderAppender:
+ ! isSectionBlock( rootClientId ) &&
+ getBlockEditingMode( rootClientId ) !== 'disabled' &&
+ ! getTemplateLock( rootClientId ) &&
hasAppender &&
__unstableGetEditorMode() !== 'zoom-out' &&
- ( hasCustomAppender
- ? ! getTemplateLock( rootClientId ) &&
- getBlockEditingMode( rootClientId ) !== 'disabled'
- : rootClientId === selectedBlockClientId ||
- ( ! rootClientId &&
- ! selectedBlockClientId &&
- ! _order.length ) ),
+ ( hasCustomAppender ||
+ rootClientId === selectedBlockClientId ||
+ ( ! rootClientId &&
+ ! selectedBlockClientId &&
+ ! _order.length ) ),
};
},
[ rootClientId, hasAppender, hasCustomAppender ]
diff --git a/packages/block-editor/src/components/block-list/use-block-props/index.js b/packages/block-editor/src/components/block-list/use-block-props/index.js
index c3a279a618b5da..45fc1d9eb5ea12 100644
--- a/packages/block-editor/src/components/block-list/use-block-props/index.js
+++ b/packages/block-editor/src/components/block-list/use-block-props/index.js
@@ -25,7 +25,6 @@ import {
} from '../../block-edit/context';
import { useFocusHandler } from './use-focus-handler';
import { useEventHandlers } from './use-selected-block-event-handlers';
-import { useNavModeExit } from './use-nav-mode-exit';
import { useZoomOutModeExit } from './use-zoom-out-mode-exit';
import { useBlockRefProvider } from './use-block-refs';
import { useIntersectionObserver } from './use-intersection-observer';
@@ -86,7 +85,6 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) {
name,
blockApiVersion,
blockTitle,
- editorMode,
isSelected,
isSubtreeDisabled,
hasOverlay,
@@ -98,13 +96,11 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) {
isReusable,
isDragging,
hasChildSelected,
- isBlockMovingMode,
- canInsertMovingBlock,
isEditingDisabled,
hasEditableOutline,
isTemporarilyEditingAsBlocks,
defaultClassName,
- templateLock,
+ isSectionBlock,
} = useContext( PrivateBlockContext );
// translators: %s: Type of block (i.e. Text, Image etc)
@@ -116,15 +112,14 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) {
useBlockRefProvider( clientId ),
useFocusHandler( clientId ),
useEventHandlers( { clientId, isSelected } ),
- useNavModeExit( clientId ),
- useZoomOutModeExit( { editorMode } ),
+ useZoomOutModeExit(),
useIsHovered( { clientId } ),
useIntersectionObserver(),
useMovingAnimation( { triggerAnimationOnChange: index, clientId } ),
useDisabled( { isDisabled: ! hasOverlay } ),
useFlashEditableBlocks( {
clientId,
- isEnabled: name === 'core/block' || templateLock === 'contentOnly',
+ isEnabled: isSectionBlock,
} ),
useScrollIntoView( { isSelected } ),
] );
@@ -182,8 +177,6 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) {
'is-reusable': isReusable,
'is-dragging': isDragging,
'has-child-selected': hasChildSelected,
- 'is-block-moving-mode': isBlockMovingMode,
- 'can-insert-moving-block': canInsertMovingBlock,
'is-editing-disabled': isEditingDisabled,
'has-editable-outline': hasEditableOutline,
'has-negative-margin': hasNegativeMargin,
diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js b/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js
index a6308f48005f9d..27f72d1a100d3e 100644
--- a/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js
+++ b/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js
@@ -68,7 +68,6 @@ export function useFocusFirstElement( { clientId, initialPosition } ) {
textInputs[ isReverse ? textInputs.length - 1 : 0 ] || ref.current;
if ( ! isInsideRootBlock( ref.current, target ) ) {
- ownerDocument.defaultView.getSelection().removeAllRanges();
ref.current.focus();
return;
}
diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-nav-mode-exit.js b/packages/block-editor/src/components/block-list/use-block-props/use-nav-mode-exit.js
deleted file mode 100644
index aa9c0a630c5bd7..00000000000000
--- a/packages/block-editor/src/components/block-list/use-block-props/use-nav-mode-exit.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { useSelect, useDispatch } from '@wordpress/data';
-import { useRefEffect } from '@wordpress/compose';
-
-/**
- * Internal dependencies
- */
-import { store as blockEditorStore } from '../../../store';
-
-/**
- * Allows navigation mode to be exited by clicking in the selected block.
- *
- * @param {string} clientId Block client ID.
- */
-export function useNavModeExit( clientId ) {
- const { isNavigationMode, isBlockSelected } = useSelect( blockEditorStore );
- const { setNavigationMode, selectBlock } = useDispatch( blockEditorStore );
- return useRefEffect(
- ( node ) => {
- function onMouseDown( event ) {
- // Don't select a block if it's already handled by a child
- // block.
- if ( isNavigationMode() && ! event.defaultPrevented ) {
- // Prevent focus from moving to the block.
- event.preventDefault();
-
- // When clicking on a selected block, exit navigation mode.
- if ( isBlockSelected( clientId ) ) {
- setNavigationMode( false );
- } else {
- selectBlock( clientId );
- }
- }
- }
-
- node.addEventListener( 'mousedown', onMouseDown );
-
- return () => {
- node.removeEventListener( 'mousedown', onMouseDown );
- };
- },
- [ clientId, isNavigationMode, isBlockSelected, setNavigationMode ]
- );
-}
diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-zoom-out-mode-exit.js b/packages/block-editor/src/components/block-list/use-block-props/use-zoom-out-mode-exit.js
index d0001bd3b33c68..494694952110bb 100644
--- a/packages/block-editor/src/components/block-list/use-block-props/use-zoom-out-mode-exit.js
+++ b/packages/block-editor/src/components/block-list/use-block-props/use-zoom-out-mode-exit.js
@@ -12,22 +12,27 @@ import { unlock } from '../../../lock-unlock';
/**
* Allows Zoom Out mode to be exited by double clicking in the selected block.
- *
- * @param {string} clientId Block client ID.
*/
-export function useZoomOutModeExit( { editorMode } ) {
- const { getSettings } = useSelect( blockEditorStore );
- const { __unstableSetEditorMode } = unlock(
+export function useZoomOutModeExit() {
+ const { getSettings, isZoomOut, __unstableGetEditorMode } = unlock(
+ useSelect( blockEditorStore )
+ );
+
+ const { __unstableSetEditorMode, resetZoomLevel } = unlock(
useDispatch( blockEditorStore )
);
return useRefEffect(
( node ) => {
- if ( editorMode !== 'zoom-out' ) {
- return;
- }
-
function onDoubleClick( event ) {
+ // In "compose" mode.
+ const composeMode =
+ __unstableGetEditorMode() === 'zoom-out' && isZoomOut();
+
+ if ( ! composeMode ) {
+ return;
+ }
+
if ( ! event.defaultPrevented ) {
event.preventDefault();
@@ -39,6 +44,7 @@ export function useZoomOutModeExit( { editorMode } ) {
__experimentalSetIsInserterOpened( false );
}
__unstableSetEditorMode( 'edit' );
+ resetZoomLevel();
}
}
@@ -48,6 +54,12 @@ export function useZoomOutModeExit( { editorMode } ) {
node.removeEventListener( 'dblclick', onDoubleClick );
};
},
- [ editorMode, getSettings, __unstableSetEditorMode ]
+ [
+ getSettings,
+ __unstableSetEditorMode,
+ __unstableGetEditorMode,
+ isZoomOut,
+ resetZoomLevel,
+ ]
);
}
diff --git a/packages/block-editor/src/components/block-list/use-in-between-inserter.js b/packages/block-editor/src/components/block-list/use-in-between-inserter.js
index bb307816fd1501..2b76804785a576 100644
--- a/packages/block-editor/src/components/block-list/use-in-between-inserter.js
+++ b/packages/block-editor/src/components/block-list/use-in-between-inserter.js
@@ -11,6 +11,7 @@ import { isRTL } from '@wordpress/i18n';
*/
import { store as blockEditorStore } from '../../store';
import { InsertionPointOpenRef } from '../block-tools/insertion-point';
+import { unlock } from '../../lock-unlock';
export function useInBetweenInserter() {
const openRef = useContext( InsertionPointOpenRef );
@@ -31,7 +32,8 @@ export function useInBetweenInserter() {
getBlockEditingMode,
getBlockName,
getBlockAttributes,
- } = useSelect( blockEditorStore );
+ getParentSectionBlock,
+ } = unlock( useSelect( blockEditorStore ) );
const { showInsertionPoint, hideInsertionPoint } =
useDispatch( blockEditorStore );
@@ -133,7 +135,8 @@ export function useInBetweenInserter() {
const clientId = element.id.slice( 'block-'.length );
if (
! clientId ||
- __unstableIsWithinBlockOverlay( clientId )
+ __unstableIsWithinBlockOverlay( clientId ) ||
+ !! getParentSectionBlock( clientId )
) {
return;
}
diff --git a/packages/block-editor/src/components/block-list/zoom-out-separator.js b/packages/block-editor/src/components/block-list/zoom-out-separator.js
index be5af549630607..9e0d087c2267cd 100644
--- a/packages/block-editor/src/components/block-list/zoom-out-separator.js
+++ b/packages/block-editor/src/components/block-list/zoom-out-separator.js
@@ -13,6 +13,7 @@ import {
import { useReducedMotion } from '@wordpress/compose';
import { useSelect } from '@wordpress/data';
import { useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
@@ -29,14 +30,16 @@ export function ZoomOutSeparator( {
const {
sectionRootClientId,
sectionClientIds,
- blockInsertionPoint,
+ insertionPoint,
blockInsertionPointVisible,
+ blockInsertionPoint,
} = useSelect( ( select ) => {
const {
- getBlockInsertionPoint,
+ getInsertionPoint,
getBlockOrder,
- isBlockInsertionPointVisible,
getSectionRootClientId,
+ isBlockInsertionPointVisible,
+ getBlockInsertionPoint,
} = unlock( select( blockEditorStore ) );
const root = getSectionRootClientId();
@@ -45,6 +48,7 @@ export function ZoomOutSeparator( {
sectionRootClientId: root,
sectionClientIds: sectionRootClientIds,
blockOrder: getBlockOrder( root ),
+ insertionPoint: getInsertionPoint(),
blockInsertionPoint: getBlockInsertionPoint(),
blockInsertionPointVisible: isBlockInsertionPointVisible(),
};
@@ -67,17 +71,30 @@ export function ZoomOutSeparator( {
return null;
}
+ const hasTopInsertionPoint =
+ insertionPoint?.index === 0 &&
+ clientId === sectionClientIds[ insertionPoint.index ];
+ const hasBottomInsertionPoint =
+ insertionPoint &&
+ insertionPoint.hasOwnProperty( 'index' ) &&
+ clientId === sectionClientIds[ insertionPoint.index - 1 ];
+ // We want to show the zoom out separator in either of these conditions:
+ // 1. If the inserter has an insertion index set
+ // 2. We are dragging a pattern over an insertion point
if ( position === 'top' ) {
isVisible =
- blockInsertionPointVisible &&
- blockInsertionPoint.index === 0 &&
- clientId === sectionClientIds[ blockInsertionPoint.index ];
+ hasTopInsertionPoint ||
+ ( blockInsertionPointVisible &&
+ blockInsertionPoint.index === 0 &&
+ clientId === sectionClientIds[ blockInsertionPoint.index ] );
}
if ( position === 'bottom' ) {
isVisible =
- blockInsertionPointVisible &&
- clientId === sectionClientIds[ blockInsertionPoint.index - 1 ];
+ hasBottomInsertionPoint ||
+ ( blockInsertionPointVisible &&
+ clientId ===
+ sectionClientIds[ blockInsertionPoint.index - 1 ] );
}
return (
@@ -103,7 +120,19 @@ export function ZoomOutSeparator( {
data-is-insertion-point="true"
onDragOver={ () => setIsDraggedOver( true ) }
onDragLeave={ () => setIsDraggedOver( false ) }
- >
+ >
+
+ { __( 'Drop pattern.' ) }
+
+
) }
);
diff --git a/packages/block-editor/src/components/block-navigation/dropdown.js b/packages/block-editor/src/components/block-navigation/dropdown.js
index 035a38604b0293..0bf8fd05320188 100644
--- a/packages/block-editor/src/components/block-navigation/dropdown.js
+++ b/packages/block-editor/src/components/block-navigation/dropdown.js
@@ -27,8 +27,7 @@ function BlockNavigationDropdownToggle( {
} ) {
return (
{
+ const { parentClientId, isVisible } = useSelect( ( select ) => {
const {
getBlockName,
getBlockParents,
getSelectedBlockClientId,
getBlockEditingMode,
- } = select( blockEditorStore );
+ getParentSectionBlock,
+ } = unlock( select( blockEditorStore ) );
const { hasBlockSupport } = select( blocksStore );
const selectedBlockClientId = getSelectedBlockClientId();
+ const parentSection = getParentSectionBlock( selectedBlockClientId );
const parents = getBlockParents( selectedBlockClientId );
- const _firstParentClientId = parents[ parents.length - 1 ];
- const parentBlockName = getBlockName( _firstParentClientId );
+ const _parentClientId = parentSection ?? parents[ parents.length - 1 ];
+ const parentBlockName = getBlockName( _parentClientId );
const _parentBlockType = getBlockType( parentBlockName );
return {
- firstParentClientId: _firstParentClientId,
+ parentClientId: _parentClientId,
isVisible:
- _firstParentClientId &&
- getBlockEditingMode( _firstParentClientId ) === 'default' &&
+ _parentClientId &&
+ getBlockEditingMode( _parentClientId ) !== 'disabled' &&
hasBlockSupport(
_parentBlockType,
'__experimentalParentSelector',
@@ -48,7 +51,7 @@ export default function BlockParentSelector() {
),
};
}, [] );
- const blockInformation = useBlockDisplayInformation( firstParentClientId );
+ const blockInformation = useBlockDisplayInformation( parentClientId );
// Allows highlighting the parent block outline when focusing or hovering
// the parent block selector within the child.
@@ -65,13 +68,13 @@ export default function BlockParentSelector() {
return (
selectBlock( firstParentClientId ) }
+ onClick={ () => selectBlock( parentClientId ) }
label={ sprintf(
/* translators: %s: Name of the block's parent. */
__( 'Select parent block: %s' ),
diff --git a/packages/block-editor/src/components/block-pattern-setup/setup-toolbar.js b/packages/block-editor/src/components/block-pattern-setup/setup-toolbar.js
index c551c2e1632bcf..d571e0fef5dcc3 100644
--- a/packages/block-editor/src/components/block-pattern-setup/setup-toolbar.js
+++ b/packages/block-editor/src/components/block-pattern-setup/setup-toolbar.js
@@ -18,8 +18,7 @@ import { VIEWMODES } from './constants';
const Actions = ( { onBlockPatternSelect } ) => (
@@ -36,8 +35,7 @@ const CarouselNavigation = ( {
} ) => (
setViewMode( VIEWMODES.carousel ) }
isPressed={ isCarouselView }
/>
setViewMode( VIEWMODES.grid ) }
diff --git a/packages/block-editor/src/components/block-patterns-paging/index.js b/packages/block-editor/src/components/block-patterns-paging/index.js
index ce1fa8b93df735..e4d7ce49b90f3e 100644
--- a/packages/block-editor/src/components/block-patterns-paging/index.js
+++ b/packages/block-editor/src/components/block-patterns-paging/index.js
@@ -38,24 +38,24 @@ export default function Pagination( {
className="block-editor-patterns__grid-pagination-previous"
>
changePage( 1 ) }
disabled={ currentPage === 1 }
aria-label={ __( 'First page' ) }
+ size="compact"
accessibleWhenDisabled
+ className="block-editor-patterns__grid-pagination-button"
>
Ā«
changePage( currentPage - 1 ) }
disabled={ currentPage === 1 }
aria-label={ __( 'Previous page' ) }
+ size="compact"
accessibleWhenDisabled
+ className="block-editor-patterns__grid-pagination-button"
>
ā¹
@@ -74,13 +74,13 @@ export default function Pagination( {
className="block-editor-patterns__grid-pagination-next"
>
changePage( currentPage + 1 ) }
disabled={ currentPage === numPages }
aria-label={ __( 'Next page' ) }
+ size="compact"
accessibleWhenDisabled
+ className="block-editor-patterns__grid-pagination-button"
>
āŗ
@@ -89,8 +89,9 @@ export default function Pagination( {
onClick={ () => changePage( numPages ) }
disabled={ currentPage === numPages }
aria-label={ __( 'Last page' ) }
- size="default"
+ size="compact"
accessibleWhenDisabled
+ className="block-editor-patterns__grid-pagination-button"
>
Ā»
diff --git a/packages/block-editor/src/components/block-patterns-paging/style.scss b/packages/block-editor/src/components/block-patterns-paging/style.scss
index f5f34d821233aa..85d39f9a36577c 100644
--- a/packages/block-editor/src/components/block-patterns-paging/style.scss
+++ b/packages/block-editor/src/components/block-patterns-paging/style.scss
@@ -4,37 +4,20 @@
border-top: 1px solid $gray-800;
padding: $grid-unit-05;
justify-content: center;
- .components-button.is-tertiary {
- width: auto;
- height: $button-size-compact;
- justify-content: center;
-
- &:disabled {
- color: $gray-600;
- background: none;
- }
-
- &:hover:not(:disabled) {
- color: $white;
- background-color: $gray-700;
- }
- }
}
}
.show-icon-labels {
- .block-editor-patterns__grid-pagination {
- .components-button {
- width: auto;
- // Hide the button icons when labels are set to display...
- span {
- display: none;
- }
- // ... and display labels.
- // Uses ::before as ::after is already used for active tab styling.
- &::before {
- content: attr(aria-label);
- }
+ .block-editor-patterns__grid-pagination-button {
+ width: auto;
+ // Hide the button icons when labels are set to display...
+ span {
+ display: none;
+ }
+ // ... and display labels.
+ // Uses ::before as ::after is already used for active tab styling.
+ &::before {
+ content: attr(aria-label);
}
}
}
diff --git a/packages/block-editor/src/components/block-quick-navigation/index.js b/packages/block-editor/src/components/block-quick-navigation/index.js
index 4f22c2a266722d..fdb3475b3e180f 100644
--- a/packages/block-editor/src/components/block-quick-navigation/index.js
+++ b/packages/block-editor/src/components/block-quick-navigation/index.js
@@ -59,8 +59,7 @@ function BlockQuickNavigationItem( { clientId, onSelect } ) {
return (
{
await selectBlock( clientId );
diff --git a/packages/block-editor/src/components/block-settings-menu-controls/index.js b/packages/block-editor/src/components/block-settings-menu-controls/index.js
index 39063db4f52e02..4ebce4172e9b37 100644
--- a/packages/block-editor/src/components/block-settings-menu-controls/index.js
+++ b/packages/block-editor/src/components/block-settings-menu-controls/index.js
@@ -4,12 +4,9 @@
import {
createSlotFill,
MenuGroup,
- MenuItem,
__experimentalStyleProvider as StyleProvider,
} from '@wordpress/components';
import { useSelect } from '@wordpress/data';
-import { pipe } from '@wordpress/compose';
-import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
@@ -96,18 +93,6 @@ const BlockSettingsMenuControlsSlot = ( { fillProps, clientIds = null } ) => {
/>
) }
{ fills }
- { fillProps?.canMove &&
- ! fillProps?.onlyBlock &&
- ! isContentOnly && (
-
- { __( 'Move to' ) }
-
- ) }
{ selectedClientIds.length === 1 && (
{
const {
- getBlockCount,
getBlockName,
getBlockRootClientId,
getPreviousBlockClientId,
@@ -86,7 +84,6 @@ export function BlockSettingsDropdown( {
return {
firstParentClientId: _firstParentClientId,
- onlyBlock: 1 === getBlockCount( _firstParentClientId ),
parentBlockType:
_firstParentClientId &&
( getActiveBlockVariation(
@@ -186,6 +183,9 @@ export function BlockSettingsDropdown( {
}
}
+ const shouldShowBlockParentMenuItem =
+ ! parentBlockIsSelected && !! firstParentClientId;
+
return (
(
-
- { ( { onClose } ) => (
- <>
-
- <__unstableBlockSettingsMenuFirstItem.Slot
- fillProps={ { onClose } }
- />
- { ! parentBlockIsSelected &&
- !! firstParentClientId && (
+ } ) => {
+ // It is possible that some plugins register fills for this menu
+ // even if Core doesn't render anything in the block settings menu.
+ // in which case, we may want to render the menu anyway.
+ // That said for now, we can start more conservative.
+ const isEmpty =
+ ! canRemove &&
+ ! canDuplicate &&
+ ! canInsertBlock &&
+ isContentOnly;
+
+ if ( isEmpty ) {
+ return null;
+ }
+
+ return (
+
+ { ( { onClose } ) => (
+ <>
+
+ <__unstableBlockSettingsMenuFirstItem.Slot
+ fillProps={ { onClose } }
+ />
+ { shouldShowBlockParentMenuItem && (
) }
- { count === 1 && (
-
- ) }
- { ! isContentOnly && (
-
- ) }
- { canDuplicate && (
-
- { __( 'Duplicate' ) }
-
- ) }
- { canInsertBlock && ! isContentOnly && (
- <>
+ { count === 1 && (
+
+ ) }
+ { ! isContentOnly && (
+
+ ) }
+ { canDuplicate && (
- { __( 'Add before' ) }
+ { __( 'Duplicate' ) }
+
+ ) }
+ { canInsertBlock && ! isContentOnly && (
+ <>
+
+ { __( 'Add before' ) }
+
+
+ { __( 'Add after' ) }
+
+ >
+ ) }
+
+ { canCopyStyles && ! isContentOnly && (
+
+
+
+ { __( 'Paste styles' ) }
+
+ ) }
+
+ { typeof children === 'function'
+ ? children( { onClose } )
+ : Children.map( ( child ) =>
+ cloneElement( child, { onClose } )
+ ) }
+ { canRemove && (
+
- { __( 'Add after' ) }
+ { __( 'Delete' ) }
- >
+
) }
-
- { canCopyStyles && ! isContentOnly && (
-
-
-
- { __( 'Paste styles' ) }
-
-
- ) }
-
- { typeof children === 'function'
- ? children( { onClose } )
- : Children.map( ( child ) =>
- cloneElement( child, { onClose } )
- ) }
- { canRemove && (
-
-
- { __( 'Delete' ) }
-
-
- ) }
- >
- ) }
-
- ) }
+ >
+ ) }
+
+ );
+ } }
);
}
diff --git a/packages/block-editor/src/components/block-switcher/index.js b/packages/block-editor/src/components/block-switcher/index.js
index 98e7f7b2d21420..79f33bd30d7537 100644
--- a/packages/block-editor/src/components/block-switcher/index.js
+++ b/packages/block-editor/src/components/block-switcher/index.js
@@ -35,36 +35,40 @@ function BlockSwitcherDropdownMenuContents( {
clientIds,
hasBlockStyles,
canRemove,
- isUsingBindings,
} ) {
const { replaceBlocks, multiSelect, updateBlockAttributes } =
useDispatch( blockEditorStore );
- const { possibleBlockTransformations, patterns, blocks } = useSelect(
- ( select ) => {
- const {
- getBlocksByClientId,
- getBlockRootClientId,
- getBlockTransformItems,
- __experimentalGetPatternTransformItems,
- } = select( blockEditorStore );
- const rootClientId = getBlockRootClientId(
- Array.isArray( clientIds ) ? clientIds[ 0 ] : clientIds
- );
- const _blocks = getBlocksByClientId( clientIds );
- return {
- blocks: _blocks,
- possibleBlockTransformations: getBlockTransformItems(
- _blocks,
- rootClientId
- ),
- patterns: __experimentalGetPatternTransformItems(
- _blocks,
- rootClientId
- ),
- };
- },
- [ clientIds ]
- );
+ const { possibleBlockTransformations, patterns, blocks, isUsingBindings } =
+ useSelect(
+ ( select ) => {
+ const {
+ getBlockAttributes,
+ getBlocksByClientId,
+ getBlockRootClientId,
+ getBlockTransformItems,
+ __experimentalGetPatternTransformItems,
+ } = select( blockEditorStore );
+ const rootClientId = getBlockRootClientId( clientIds[ 0 ] );
+ const _blocks = getBlocksByClientId( clientIds );
+ return {
+ blocks: _blocks,
+ possibleBlockTransformations: getBlockTransformItems(
+ _blocks,
+ rootClientId
+ ),
+ patterns: __experimentalGetPatternTransformItems(
+ _blocks,
+ rootClientId
+ ),
+ isUsingBindings: clientIds.every(
+ ( clientId ) =>
+ !! getBlockAttributes( clientId )?.metadata
+ ?.bindings
+ ),
+ };
+ },
+ [ clientIds ]
+ );
const blockVariationTransformations = useBlockVariationTransforms( {
clientIds,
blocks,
@@ -196,7 +200,7 @@ const BlockIndicator = ( { icon, showTitle, blockTitle } ) => (
>
);
-export const BlockSwitcher = ( { clientIds, disabled, isUsingBindings } ) => {
+export const BlockSwitcher = ( { clientIds } ) => {
const {
hasContentOnlyLocking,
canRemove,
@@ -205,6 +209,7 @@ export const BlockSwitcher = ( { clientIds, disabled, isUsingBindings } ) => {
invalidBlocks,
isReusable,
isTemplate,
+ isDisabled,
} = useSelect(
( select ) => {
const {
@@ -212,6 +217,7 @@ export const BlockSwitcher = ( { clientIds, disabled, isUsingBindings } ) => {
getBlocksByClientId,
getBlockAttributes,
canRemoveBlocks,
+ getBlockEditingMode,
} = select( blockEditorStore );
const { getBlockStyles, getBlockType, getActiveBlockVariation } =
select( blocksStore );
@@ -222,6 +228,7 @@ export const BlockSwitcher = ( { clientIds, disabled, isUsingBindings } ) => {
const [ { name: firstBlockName } ] = _blocks;
const _isSingleBlockSelected = _blocks.length === 1;
const blockType = getBlockType( firstBlockName );
+ const editingMode = getBlockEditingMode( clientIds[ 0 ] );
let _icon;
let _hasTemplateLock;
@@ -256,6 +263,7 @@ export const BlockSwitcher = ( { clientIds, disabled, isUsingBindings } ) => {
isTemplate:
_isSingleBlockSelected && isTemplatePart( _blocks[ 0 ] ),
hasContentOnlyLocking: _hasTemplateLock,
+ isDisabled: editingMode !== 'default',
};
},
[ clientIds ]
@@ -275,7 +283,7 @@ export const BlockSwitcher = ( { clientIds, disabled, isUsingBindings } ) => {
: __( 'Multiple blocks selected' );
const hideDropdown =
- disabled ||
+ isDisabled ||
( ! hasBlockStyles && ! canRemove ) ||
hasContentOnlyLocking;
@@ -339,7 +347,6 @@ export const BlockSwitcher = ( { clientIds, disabled, isUsingBindings } ) => {
clientIds={ clientIds }
hasBlockStyles={ hasBlockStyles }
canRemove={ canRemove }
- isUsingBindings={ isUsingBindings }
/>
) }
diff --git a/packages/block-editor/src/components/block-switcher/test/use-transformed.patterns.js b/packages/block-editor/src/components/block-switcher/test/use-transformed.patterns.js
index 05ce545667d464..4d63c763174791 100644
--- a/packages/block-editor/src/components/block-switcher/test/use-transformed.patterns.js
+++ b/packages/block-editor/src/components/block-switcher/test/use-transformed.patterns.js
@@ -20,15 +20,15 @@ describe( 'use-transformed-patterns', () => {
},
content: {
type: 'boolean',
- __experimentalRole: 'content',
+ role: 'content',
},
level: {
type: 'number',
- __experimentalRole: 'content',
+ role: 'content',
},
color: {
type: 'string',
- __experimentalRole: 'other',
+ role: 'other',
},
},
save() {},
diff --git a/packages/block-editor/src/components/block-switcher/test/utils.js b/packages/block-editor/src/components/block-switcher/test/utils.js
index 38009601e16468..eafe5e8a4d9378 100644
--- a/packages/block-editor/src/components/block-switcher/test/utils.js
+++ b/packages/block-editor/src/components/block-switcher/test/utils.js
@@ -18,15 +18,15 @@ describe( 'BlockSwitcher - utils', () => {
},
content: {
type: 'boolean',
- __experimentalRole: 'content',
+ role: 'content',
},
level: {
type: 'number',
- __experimentalRole: 'content',
+ role: 'content',
},
color: {
type: 'string',
- __experimentalRole: 'other',
+ role: 'other',
},
},
save() {},
diff --git a/packages/block-editor/src/components/block-switcher/utils.js b/packages/block-editor/src/components/block-switcher/utils.js
index ebd95fc460e33e..49257a2126cbe5 100644
--- a/packages/block-editor/src/components/block-switcher/utils.js
+++ b/packages/block-editor/src/components/block-switcher/utils.js
@@ -1,7 +1,7 @@
/**
* WordPress dependencies
*/
-import { __experimentalGetBlockAttributesNamesByRole as getBlockAttributesNamesByRole } from '@wordpress/blocks';
+import { getBlockAttributesNamesByRole } from '@wordpress/blocks';
/**
* Try to find a matching block by a block's name in a provided
diff --git a/packages/block-editor/src/components/block-toolbar/index.js b/packages/block-editor/src/components/block-toolbar/index.js
index 6c4789cb2924f2..2ac2cbb12ff352 100644
--- a/packages/block-editor/src/components/block-toolbar/index.js
+++ b/packages/block-editor/src/components/block-toolbar/index.js
@@ -35,6 +35,7 @@ import { store as blockEditorStore } from '../../store';
import __unstableBlockNameContext from './block-name-context';
import NavigableToolbar from '../navigable-toolbar';
import { useHasBlockToolbar } from './use-has-block-toolbar';
+import { unlock } from '../../lock-unlock';
/**
* Renders the block toolbar.
@@ -58,7 +59,6 @@ export function PrivateBlockToolbar( {
const {
blockClientId,
blockClientIds,
- isContentOnlyEditingMode,
isDefaultEditingMode,
blockType,
toolbarKey,
@@ -78,12 +78,14 @@ export function PrivateBlockToolbar( {
getBlockAttributes,
getBlockParentsByBlockName,
getTemplateLock,
- } = select( blockEditorStore );
+ getParentSectionBlock,
+ } = unlock( select( blockEditorStore ) );
const selectedBlockClientIds = getSelectedBlockClientIds();
const selectedBlockClientId = selectedBlockClientIds[ 0 ];
const parents = getBlockParents( selectedBlockClientId );
- const firstParentClientId = parents[ parents.length - 1 ];
- const parentBlockName = getBlockName( firstParentClientId );
+ const parentSection = getParentSectionBlock( selectedBlockClientId );
+ const parentClientId = parentSection ?? parents[ parents.length - 1 ];
+ const parentBlockName = getBlockName( parentClientId );
const parentBlockType = getBlockType( parentBlockName );
const editingMode = getBlockEditingMode( selectedBlockClientId );
const _isDefaultEditingMode = editingMode === 'default';
@@ -112,21 +114,19 @@ export function PrivateBlockToolbar( {
return {
blockClientId: selectedBlockClientId,
blockClientIds: selectedBlockClientIds,
- isContentOnlyEditingMode: editingMode === 'contentOnly',
isDefaultEditingMode: _isDefaultEditingMode,
blockType: selectedBlockClientId && getBlockType( _blockName ),
shouldShowVisualToolbar: isValid && isVisual,
- toolbarKey: `${ selectedBlockClientId }${ firstParentClientId }`,
+ toolbarKey: `${ selectedBlockClientId }${ parentClientId }`,
showParentSelector:
parentBlockType &&
- getBlockEditingMode( firstParentClientId ) === 'default' &&
+ getBlockEditingMode( parentClientId ) !== 'disabled' &&
hasBlockSupport(
parentBlockType,
'__experimentalParentSelector',
true
) &&
- selectedBlockClientIds.length === 1 &&
- _isDefaultEditingMode,
+ selectedBlockClientIds.length === 1,
isUsingBindings: _isUsingBindings,
hasParentPattern: _hasParentPattern,
hasContentOnlyLocking: _hasTemplateLock,
@@ -179,36 +179,26 @@ export function PrivateBlockToolbar( {
key={ toolbarKey }
>
- { ! isMultiToolbar &&
- isLargeViewport &&
- isDefaultEditingMode &&
}
+ { ! isMultiToolbar && isLargeViewport && (
+
+ ) }
{ ( shouldShowVisualToolbar || isMultiToolbar ) &&
- ( isDefaultEditingMode ||
- ( isContentOnlyEditingMode && ! hasParentPattern ) ||
- isSynced ) && (
+ ! hasParentPattern && (
-
+ { ! isMultiToolbar && isDefaultEditingMode && (
+
+ ) }
+
- { isDefaultEditingMode && (
- <>
- { ! isMultiToolbar && (
-
- ) }
-
- >
- ) }
) }
@@ -242,9 +232,7 @@ export function PrivateBlockToolbar( {
>
) }
- { isDefaultEditingMode && (
-
- ) }
+
);
diff --git a/packages/block-editor/src/components/block-toolbar/style.scss b/packages/block-editor/src/components/block-toolbar/style.scss
index 40d748dd0a1568..ae03eeed1a817c 100644
--- a/packages/block-editor/src/components/block-toolbar/style.scss
+++ b/packages/block-editor/src/components/block-toolbar/style.scss
@@ -37,6 +37,13 @@
border-right: $border-width solid $gray-300;
}
+ &.is-connected {
+ .block-editor-block-switcher .components-button::before {
+ background: color-mix(in srgb, var(--wp-block-synced-color) 10%, transparent);
+ border-radius: $radius-small;
+ }
+ }
+
&.is-synced,
&.is-connected {
.block-editor-block-switcher .components-button .block-editor-block-icon {
@@ -52,9 +59,18 @@
> :last-child,
> :last-child .components-toolbar-group,
- > :last-child .components-toolbar {
+ > :last-child .components-toolbar,
+ // If the last toolbar group is empty,
+ // we need to remove the double border from the penultimate one.
+ &:has(> :last-child:empty) > :nth-last-child(2),
+ &:has(> :last-child:empty) > :nth-last-child(2) .components-toolbar-group,
+ &:has(> :last-child:empty) > :nth-last-child(2) .components-toolbar {
border-right: none;
}
+
+ .components-toolbar-group:empty {
+ display: none;
+ }
}
.block-editor-block-contextual-toolbar {
diff --git a/packages/block-editor/src/components/block-toolbar/use-has-block-toolbar.js b/packages/block-editor/src/components/block-toolbar/use-has-block-toolbar.js
index c4e228f8a3c07b..80ce3691147834 100644
--- a/packages/block-editor/src/components/block-toolbar/use-has-block-toolbar.js
+++ b/packages/block-editor/src/components/block-toolbar/use-has-block-toolbar.js
@@ -7,7 +7,6 @@ import { getBlockType, hasBlockSupport } from '@wordpress/blocks';
* Internal dependencies
*/
import { store as blockEditorStore } from '../../store';
-import { useHasAnyBlockControls } from '../block-controls/use-has-block-controls';
/**
* Returns true if the block toolbar should be shown.
@@ -15,40 +14,29 @@ import { useHasAnyBlockControls } from '../block-controls/use-has-block-controls
* @return {boolean} Whether the block toolbar component will be rendered.
*/
export function useHasBlockToolbar() {
- const { isToolbarEnabled, isDefaultEditingMode } = useSelect(
- ( select ) => {
- const {
- getBlockEditingMode,
- getBlockName,
- getBlockSelectionStart,
- } = select( blockEditorStore );
+ const { isToolbarEnabled, isBlockDisabled } = useSelect( ( select ) => {
+ const { getBlockEditingMode, getBlockName, getBlockSelectionStart } =
+ select( blockEditorStore );
- // we only care about the 1st selected block
- // for the toolbar, so we use getBlockSelectionStart
- // instead of getSelectedBlockClientIds
- const selectedBlockClientId = getBlockSelectionStart();
+ // we only care about the 1st selected block
+ // for the toolbar, so we use getBlockSelectionStart
+ // instead of getSelectedBlockClientIds
+ const selectedBlockClientId = getBlockSelectionStart();
- const blockType =
- selectedBlockClientId &&
- getBlockType( getBlockName( selectedBlockClientId ) );
+ const blockType =
+ selectedBlockClientId &&
+ getBlockType( getBlockName( selectedBlockClientId ) );
- return {
- isToolbarEnabled:
- blockType &&
- hasBlockSupport( blockType, '__experimentalToolbar', true ),
- isDefaultEditingMode:
- getBlockEditingMode( selectedBlockClientId ) === 'default',
- };
- },
- []
- );
+ return {
+ isToolbarEnabled:
+ blockType &&
+ hasBlockSupport( blockType, '__experimentalToolbar', true ),
+ isBlockDisabled:
+ getBlockEditingMode( selectedBlockClientId ) === 'disabled',
+ };
+ }, [] );
- const hasAnyBlockControls = useHasAnyBlockControls();
-
- if (
- ! isToolbarEnabled ||
- ( ! isDefaultEditingMode && ! hasAnyBlockControls )
- ) {
+ if ( ! isToolbarEnabled || isBlockDisabled ) {
return false;
}
diff --git a/packages/block-editor/src/components/block-tools/block-selection-button.js b/packages/block-editor/src/components/block-tools/block-selection-button.js
deleted file mode 100644
index 9c6c22181ef2ac..00000000000000
--- a/packages/block-editor/src/components/block-tools/block-selection-button.js
+++ /dev/null
@@ -1,302 +0,0 @@
-/**
- * External dependencies
- */
-import clsx from 'clsx';
-
-/**
- * WordPress dependencies
- */
-import { dragHandle } from '@wordpress/icons';
-import { Button, Flex, FlexItem } from '@wordpress/components';
-import { useSelect, useDispatch } from '@wordpress/data';
-import { forwardRef, useEffect } from '@wordpress/element';
-import {
- BACKSPACE,
- DELETE,
- UP,
- DOWN,
- LEFT,
- RIGHT,
- TAB,
- ESCAPE,
- ENTER,
- SPACE,
-} from '@wordpress/keycodes';
-import {
- __experimentalGetAccessibleBlockLabel as getAccessibleBlockLabel,
- store as blocksStore,
-} from '@wordpress/blocks';
-import { speak } from '@wordpress/a11y';
-import { focus } from '@wordpress/dom';
-import { __ } from '@wordpress/i18n';
-
-/**
- * Internal dependencies
- */
-import BlockTitle from '../block-title';
-import BlockIcon from '../block-icon';
-import { store as blockEditorStore } from '../../store';
-import BlockDraggable from '../block-draggable';
-import { useBlockElement } from '../block-list/use-block-props/use-block-refs';
-
-/**
- * Block selection button component, displaying the label of the block. If the block
- * descends from a root block, a button is displayed enabling the user to select
- * the root block.
- *
- * @param {string} props Component props.
- * @param {string} props.clientId Client ID of block.
- * @param {Object} ref Reference to the component.
- *
- * @return {Component} The component to be rendered.
- */
-function BlockSelectionButton( { clientId, rootClientId }, ref ) {
- const selected = useSelect(
- ( select ) => {
- const {
- getBlock,
- getBlockIndex,
- hasBlockMovingClientId,
- getBlockListSettings,
- __unstableGetEditorMode,
- getNextBlockClientId,
- getPreviousBlockClientId,
- canMoveBlock,
- } = select( blockEditorStore );
- const { getActiveBlockVariation, getBlockType } =
- select( blocksStore );
- const index = getBlockIndex( clientId );
- const { name, attributes } = getBlock( clientId );
- const blockType = getBlockType( name );
- const orientation =
- getBlockListSettings( rootClientId )?.orientation;
- const match = getActiveBlockVariation( name, attributes );
-
- return {
- blockMovingMode: hasBlockMovingClientId(),
- editorMode: __unstableGetEditorMode(),
- icon: match?.icon || blockType.icon,
- label: getAccessibleBlockLabel(
- blockType,
- attributes,
- index + 1,
- orientation
- ),
- canMove: canMoveBlock( clientId, rootClientId ),
- getNextBlockClientId,
- getPreviousBlockClientId,
- };
- },
- [ clientId, rootClientId ]
- );
- const { label, icon, blockMovingMode, editorMode, canMove } = selected;
- const { setNavigationMode, removeBlock } = useDispatch( blockEditorStore );
-
- // Focus the breadcrumb in navigation mode.
- useEffect( () => {
- if ( editorMode === 'navigation' ) {
- ref.current.focus();
- speak( label );
- }
- }, [ label, editorMode ] );
- const blockElement = useBlockElement( clientId );
-
- const {
- hasBlockMovingClientId,
- getBlockIndex,
- getBlockRootClientId,
- getClientIdsOfDescendants,
- getSelectedBlockClientId,
- getMultiSelectedBlocksEndClientId,
- getPreviousBlockClientId,
- getNextBlockClientId,
- } = useSelect( blockEditorStore );
- const {
- selectBlock,
- clearSelectedBlock,
- setBlockMovingClientId,
- moveBlockToPosition,
- } = useDispatch( blockEditorStore );
-
- function onKeyDown( event ) {
- const { keyCode } = event;
- const isUp = keyCode === UP;
- const isDown = keyCode === DOWN;
- const isLeft = keyCode === LEFT;
- const isRight = keyCode === RIGHT;
- const isTab = keyCode === TAB;
- const isEscape = keyCode === ESCAPE;
- const isEnter = keyCode === ENTER;
- const isSpace = keyCode === SPACE;
- const isShift = event.shiftKey;
-
- if ( keyCode === BACKSPACE || keyCode === DELETE ) {
- removeBlock( clientId );
- event.preventDefault();
- return;
- }
-
- const selectedBlockClientId = getSelectedBlockClientId();
- const selectionEndClientId = getMultiSelectedBlocksEndClientId();
- const selectionBeforeEndClientId = getPreviousBlockClientId(
- selectionEndClientId || selectedBlockClientId
- );
- const selectionAfterEndClientId = getNextBlockClientId(
- selectionEndClientId || selectedBlockClientId
- );
-
- const navigateUp = ( isTab && isShift ) || isUp;
- const navigateDown = ( isTab && ! isShift ) || isDown;
- // Move out of current nesting level (no effect if at root level).
- const navigateOut = isLeft;
- // Move into next nesting level (no effect if the current block has no innerBlocks).
- const navigateIn = isRight;
-
- let focusedBlockUid;
- if ( navigateUp ) {
- focusedBlockUid = selectionBeforeEndClientId;
- } else if ( navigateDown ) {
- focusedBlockUid = selectionAfterEndClientId;
- } else if ( navigateOut ) {
- focusedBlockUid =
- getBlockRootClientId( selectedBlockClientId ) ??
- selectedBlockClientId;
- } else if ( navigateIn ) {
- focusedBlockUid =
- getClientIdsOfDescendants( selectedBlockClientId )[ 0 ] ??
- selectedBlockClientId;
- }
- const startingBlockClientId = hasBlockMovingClientId();
- if ( isEscape && startingBlockClientId && ! event.defaultPrevented ) {
- setBlockMovingClientId( null );
- event.preventDefault();
- }
- if ( ( isEnter || isSpace ) && startingBlockClientId ) {
- const sourceRoot = getBlockRootClientId( startingBlockClientId );
- const destRoot = getBlockRootClientId( selectedBlockClientId );
- const sourceBlockIndex = getBlockIndex( startingBlockClientId );
- let destinationBlockIndex = getBlockIndex( selectedBlockClientId );
- if (
- sourceBlockIndex < destinationBlockIndex &&
- sourceRoot === destRoot
- ) {
- destinationBlockIndex -= 1;
- }
- moveBlockToPosition(
- startingBlockClientId,
- sourceRoot,
- destRoot,
- destinationBlockIndex
- );
- selectBlock( startingBlockClientId );
- setBlockMovingClientId( null );
- }
- // Prevent the block from being moved into itself.
- if (
- startingBlockClientId &&
- selectedBlockClientId === startingBlockClientId &&
- navigateIn
- ) {
- return;
- }
- if ( navigateDown || navigateUp || navigateOut || navigateIn ) {
- if ( focusedBlockUid ) {
- event.preventDefault();
- selectBlock( focusedBlockUid );
- } else if ( isTab && selectedBlockClientId ) {
- let nextTabbable;
-
- if ( navigateDown ) {
- nextTabbable = blockElement;
- do {
- nextTabbable = focus.tabbable.findNext( nextTabbable );
- } while (
- nextTabbable &&
- blockElement.contains( nextTabbable )
- );
-
- if ( ! nextTabbable ) {
- nextTabbable =
- blockElement.ownerDocument.defaultView.frameElement;
- nextTabbable = focus.tabbable.findNext( nextTabbable );
- }
- } else {
- nextTabbable = focus.tabbable.findPrevious( blockElement );
- }
-
- if ( nextTabbable ) {
- event.preventDefault();
- nextTabbable.focus();
- clearSelectedBlock();
- }
- }
- }
- }
-
- const classNames = clsx(
- 'block-editor-block-list__block-selection-button',
- {
- 'is-block-moving-mode': !! blockMovingMode,
- }
- );
-
- const dragHandleLabel = __( 'Drag' );
- const showBlockDraggable = canMove && editorMode === 'navigation';
-
- return (
-
-
-
-
-
- { showBlockDraggable && (
-
-
- { ( draggableProps ) => (
-
- ) }
-
-
- ) }
- { editorMode === 'navigation' && (
-
- setNavigationMode( false )
- : undefined
- }
- onKeyDown={ onKeyDown }
- label={ label }
- showTooltip={ false }
- className="block-selection-button_select-button"
- >
-
-
-
- ) }
-
-
- );
-}
-
-export default forwardRef( BlockSelectionButton );
diff --git a/packages/block-editor/src/components/block-tools/block-toolbar-breadcrumb.js b/packages/block-editor/src/components/block-tools/block-toolbar-breadcrumb.js
deleted file mode 100644
index ae03bdb4f51647..00000000000000
--- a/packages/block-editor/src/components/block-tools/block-toolbar-breadcrumb.js
+++ /dev/null
@@ -1,51 +0,0 @@
-/**
- * External dependencies
- */
-import clsx from 'clsx';
-
-/**
- * WordPress dependencies
- */
-import { forwardRef } from '@wordpress/element';
-
-/**
- * Internal dependencies
- */
-import BlockSelectionButton from './block-selection-button';
-import { PrivateBlockPopover } from '../block-popover';
-import useBlockToolbarPopoverProps from './use-block-toolbar-popover-props';
-import useSelectedBlockToolProps from './use-selected-block-tool-props';
-
-function BlockToolbarBreadcrumb( { clientId, __unstableContentRef }, ref ) {
- const {
- capturingClientId,
- isInsertionPointVisible,
- lastClientId,
- rootClientId,
- } = useSelectedBlockToolProps( clientId );
-
- const popoverProps = useBlockToolbarPopoverProps( {
- contentElement: __unstableContentRef?.current,
- clientId,
- } );
-
- return (
-
-
-
- );
-}
-
-export default forwardRef( BlockToolbarBreadcrumb );
diff --git a/packages/block-editor/src/components/block-tools/index.js b/packages/block-editor/src/components/block-tools/index.js
index 24f60dbbf970aa..bad331561317f8 100644
--- a/packages/block-editor/src/components/block-tools/index.js
+++ b/packages/block-editor/src/components/block-tools/index.js
@@ -19,7 +19,6 @@ import {
default as InsertionPoint,
} from './insertion-point';
import BlockToolbarPopover from './block-toolbar-popover';
-import BlockToolbarBreadcrumb from './block-toolbar-breadcrumb';
import ZoomOutPopover from './zoom-out-popover';
import { store as blockEditorStore } from '../../store';
import usePopoverScroll from '../block-popover/use-popover-scroll';
@@ -35,7 +34,8 @@ function selector( select ) {
getSettings,
__unstableGetEditorMode,
isTyping,
- } = select( blockEditorStore );
+ isDragging,
+ } = unlock( select( blockEditorStore ) );
const clientId =
getSelectedBlockClientId() || getFirstMultiSelectedBlockClientId();
@@ -47,6 +47,7 @@ function selector( select ) {
hasFixedToolbar: getSettings().hasFixedToolbar,
isTyping: isTyping(),
isZoomOutMode: editorMode === 'zoom-out',
+ isDragging: isDragging(),
};
}
@@ -64,10 +65,9 @@ export default function BlockTools( {
__unstableContentRef,
...props
} ) {
- const { clientId, hasFixedToolbar, isTyping, isZoomOutMode } = useSelect(
- selector,
- []
- );
+ const { clientId, hasFixedToolbar, isTyping, isZoomOutMode, isDragging } =
+ useSelect( selector, [] );
+
const isMatch = useShortcutEventMatch();
const {
getBlocksByClientId,
@@ -78,7 +78,6 @@ export default function BlockTools( {
const { getGroupingBlockName } = useSelect( blocksStore );
const {
showEmptyBlockSideInserter,
- showBreadcrumb,
showBlockToolbarPopover,
showZoomOutToolbar,
} = useShowBlockTools();
@@ -223,14 +222,6 @@ export default function BlockTools( {
/>
) }
- { showBreadcrumb && (
-
- ) }
-
{ showZoomOutToolbar && (
- { isZoomOutMode && (
+ { isZoomOutMode && ! isDragging && (
diff --git a/packages/block-editor/src/components/block-tools/insertion-point.js b/packages/block-editor/src/components/block-tools/insertion-point.js
index 469f7e53908cb4..891a32eaa5dc9c 100644
--- a/packages/block-editor/src/components/block-tools/insertion-point.js
+++ b/packages/block-editor/src/components/block-tools/insertion-point.js
@@ -37,7 +37,6 @@ function InbetweenInsertionPointPopover( {
rootClientId,
isInserterShown,
isDistractionFree,
- isNavigationMode,
isZoomOutMode,
} = useSelect( ( select ) => {
const {
@@ -48,7 +47,6 @@ function InbetweenInsertionPointPopover( {
getPreviousBlockClientId,
getNextBlockClientId,
getSettings,
- isNavigationMode: _isNavigationMode,
__unstableGetEditorMode,
} = select( blockEditorStore );
const insertionPoint = getBlockInsertionPoint();
@@ -78,7 +76,6 @@ function InbetweenInsertionPointPopover( {
getBlockListSettings( insertionPoint.rootClientId )
?.orientation || 'vertical',
rootClientId: insertionPoint.rootClientId,
- isNavigationMode: _isNavigationMode(),
isDistractionFree: settings.isDistractionFree,
isInserterShown: insertionPoint?.__unstableWithInserter,
isZoomOutMode: __unstableGetEditorMode() === 'zoom-out',
@@ -144,7 +141,7 @@ function InbetweenInsertionPointPopover( {
},
};
- if ( isDistractionFree && ! isNavigationMode ) {
+ if ( isDistractionFree ) {
return null;
}
diff --git a/packages/block-editor/src/components/block-tools/style.scss b/packages/block-editor/src/components/block-tools/style.scss
index 9f1325d7f95a1a..fe9da021b31823 100644
--- a/packages/block-editor/src/components/block-tools/style.scss
+++ b/packages/block-editor/src/components/block-tools/style.scss
@@ -84,84 +84,6 @@
}
}
-/**
- * Block Label for Navigation/Selection Mode
- */
-
-.block-editor-block-list__block-selection-button {
- display: inline-flex;
- padding: 0 $grid-unit-15;
- z-index: z-index(".block-editor-block-list__block-selection-button");
-
- // Dark block UI appearance.
- border-radius: $radius-small;
- background-color: $gray-900;
-
- font-size: $default-font-size;
- height: $block-toolbar-height;
-
- .block-editor-block-list__block-selection-button__content {
- margin: auto;
- display: inline-flex;
- align-items: center;
-
- > .components-flex__item {
- margin-right: $grid-unit-15 * 0.5;
- }
- }
- .components-button.has-icon.block-selection-button_drag-handle {
- cursor: grab;
- padding: 0;
- height: $grid-unit-30;
- min-width: $grid-unit-30;
- margin-left: -2px;
-
- // Drag handle is smaller than the others.
- svg {
- min-width: 18px;
- min-height: 18px;
- }
- }
-
- .block-editor-block-icon {
- font-size: $default-font-size;
- color: $white;
- height: $block-toolbar-height;
- }
-
- // The button here has a special style to appear as a toolbar.
- .components-button {
- min-width: $button-size;
- color: $white;
- height: $block-toolbar-height;
-
- // When button is focused, it receives a box-shadow instead of the border.
- &:focus {
- box-shadow: none;
- border: none;
- }
-
- &:active {
- color: $white;
- }
-
- // Make sure the button has no hover style when it's disabled.
- &[aria-disabled="true"]:hover {
- color: $white;
- }
-
- display: flex;
- }
- .block-selection-button_select-button.components-button {
- padding: 0;
- }
-
- .block-editor-block-mover {
- background: unset;
- border: none;
- }
-}
-
// Hide the popover block editor list while dragging.
// Using a hacky animation to delay hiding the element.
// It's needed because if we hide the element immediately upon dragging,
@@ -178,14 +100,10 @@
.components-popover.block-editor-block-list__block-popover {
// Position the block toolbar.
- .block-editor-block-list__block-selection-button,
.block-editor-block-contextual-toolbar {
pointer-events: all;
margin-top: $grid-unit-10;
margin-bottom: $grid-unit-10;
- }
-
- .block-editor-block-contextual-toolbar {
border: $border-width solid $gray-900;
border-radius: $radius-small;
overflow: visible; // allow the parent selector to be visible
@@ -283,12 +201,9 @@
background: none;
border: none;
}
-}
-.block-editor-block-tools__zoom-out-mode-inserter-button {
- visibility: hidden;
-
- &.is-visible {
- visibility: visible;
+ // Make the spacing consistent between controls.
+ .components-button {
+ height: $button-size-next-default-40px;
}
}
diff --git a/packages/block-editor/src/components/block-tools/use-show-block-tools.js b/packages/block-editor/src/components/block-tools/use-show-block-tools.js
index 07e0ebd16a64b0..02a8f0583bcddf 100644
--- a/packages/block-editor/src/components/block-tools/use-show-block-tools.js
+++ b/packages/block-editor/src/components/block-tools/use-show-block-tools.js
@@ -22,7 +22,6 @@ export function useShowBlockTools() {
getBlock,
getBlockMode,
getSettings,
- hasMultiSelection,
__unstableGetEditorMode,
isTyping,
} = select( blockEditorStore );
@@ -42,29 +41,20 @@ export function useShowBlockTools() {
! isTyping() &&
editorMode === 'edit' &&
isEmptyDefaultBlock;
- const maybeShowBreadcrumb =
- hasSelectedBlock &&
- ! hasMultiSelection() &&
- editorMode === 'navigation';
-
const isZoomOut = editorMode === 'zoom-out';
const _showZoomOutToolbar =
isZoomOut &&
block?.attributes?.align === 'full' &&
- ! _showEmptyBlockSideInserter &&
- ! maybeShowBreadcrumb;
+ ! _showEmptyBlockSideInserter;
const _showBlockToolbarPopover =
! _showZoomOutToolbar &&
! getSettings().hasFixedToolbar &&
! _showEmptyBlockSideInserter &&
hasSelectedBlock &&
- ! isEmptyDefaultBlock &&
- ! maybeShowBreadcrumb;
+ ! isEmptyDefaultBlock;
return {
showEmptyBlockSideInserter: _showEmptyBlockSideInserter,
- showBreadcrumb:
- ! _showEmptyBlockSideInserter && maybeShowBreadcrumb,
showBlockToolbarPopover: _showBlockToolbarPopover,
showZoomOutToolbar: _showZoomOutToolbar,
};
diff --git a/packages/block-editor/src/components/block-tools/zoom-out-mode-inserter-button.js b/packages/block-editor/src/components/block-tools/zoom-out-mode-inserter-button.js
index 8ea80a53830135..961552caa66e01 100644
--- a/packages/block-editor/src/components/block-tools/zoom-out-mode-inserter-button.js
+++ b/packages/block-editor/src/components/block-tools/zoom-out-mode-inserter-button.js
@@ -6,17 +6,11 @@ import clsx from 'clsx';
/**
* WordPress dependencies
*/
-import { useState } from '@wordpress/element';
import { Button } from '@wordpress/components';
import { plus } from '@wordpress/icons';
import { _x } from '@wordpress/i18n';
-function ZoomOutModeInserterButton( { isVisible, onClick } ) {
- const [
- zoomOutModeInserterButtonHovered,
- setZoomOutModeInserterButtonHovered,
- ] = useState( false );
-
+function ZoomOutModeInserterButton( { onClick } ) {
return (
{
- setZoomOutModeInserterButtonHovered( true );
- } }
- onMouseOut={ () => {
- setZoomOutModeInserterButtonHovered( false );
- } }
label={ _x(
'Add pattern',
'Generic label for pattern inserter button'
diff --git a/packages/block-editor/src/components/block-tools/zoom-out-mode-inserters.js b/packages/block-editor/src/components/block-tools/zoom-out-mode-inserters.js
index 79f8be3f9cfe97..c279cb36782028 100644
--- a/packages/block-editor/src/components/block-tools/zoom-out-mode-inserters.js
+++ b/packages/block-editor/src/components/block-tools/zoom-out-mode-inserters.js
@@ -16,41 +16,39 @@ function ZoomOutModeInserters() {
const [ isReady, setIsReady ] = useState( false );
const {
hasSelection,
- blockInsertionPoint,
+ insertionPoint,
blockOrder,
blockInsertionPointVisible,
setInserterIsOpened,
sectionRootClientId,
selectedBlockClientId,
- hoveredBlockClientId,
} = useSelect( ( select ) => {
const {
getSettings,
- getBlockInsertionPoint,
+ getInsertionPoint,
getBlockOrder,
getSelectionStart,
getSelectedBlockClientId,
- getHoveredBlockClientId,
- isBlockInsertionPointVisible,
getSectionRootClientId,
+ isBlockInsertionPointVisible,
} = unlock( select( blockEditorStore ) );
const root = getSectionRootClientId();
return {
hasSelection: !! getSelectionStart().clientId,
- blockInsertionPoint: getBlockInsertionPoint(),
+ insertionPoint: getInsertionPoint(),
blockOrder: getBlockOrder( root ),
blockInsertionPointVisible: isBlockInsertionPointVisible(),
sectionRootClientId: root,
setInserterIsOpened:
getSettings().__experimentalSetIsInserterOpened,
selectedBlockClientId: getSelectedBlockClientId(),
- hoveredBlockClientId: getHoveredBlockClientId(),
};
}, [] );
- const { showInsertionPoint } = useDispatch( blockEditorStore );
+ // eslint-disable-next-line @wordpress/no-unused-vars-before-return
+ const { showInsertionPoint } = unlock( useDispatch( blockEditorStore ) );
// Defer the initial rendering to avoid the jumps due to the animation.
useEffect( () => {
@@ -62,25 +60,20 @@ function ZoomOutModeInserters() {
};
}, [] );
- if ( ! isReady ) {
+ if ( ! isReady || ! hasSelection ) {
return null;
}
return [ undefined, ...blockOrder ].map( ( clientId, index ) => {
const shouldRenderInsertionPoint =
- blockInsertionPointVisible && blockInsertionPoint.index === index;
+ blockInsertionPointVisible && insertionPoint?.index === index;
const previousClientId = clientId;
const nextClientId = blockOrder[ index ];
const isSelected =
- hasSelection &&
- ( selectedBlockClientId === previousClientId ||
- selectedBlockClientId === nextClientId );
-
- const isHovered =
- hoveredBlockClientId === previousClientId ||
- hoveredBlockClientId === nextClientId;
+ selectedBlockClientId === previousClientId ||
+ selectedBlockClientId === nextClientId;
return (
- { ! shouldRenderInsertionPoint && (
+ { ! shouldRenderInsertionPoint && isSelected && (
{
setInserterIsOpened( {
rootClientId: sectionRootClientId,
diff --git a/packages/block-editor/src/components/block-tools/zoom-out-toolbar.js b/packages/block-editor/src/components/block-tools/zoom-out-toolbar.js
index a3c46c4b4c970a..f2c073117d2ce8 100644
--- a/packages/block-editor/src/components/block-tools/zoom-out-toolbar.js
+++ b/packages/block-editor/src/components/block-tools/zoom-out-toolbar.js
@@ -1,8 +1,3 @@
-/**
- * External dependencies
- */
-import clsx from 'clsx';
-
/**
* WordPress dependencies
*/
@@ -20,13 +15,13 @@ import BlockDraggable from '../block-draggable';
import BlockMover from '../block-mover';
import Shuffle from '../block-toolbar/shuffle';
import NavigableToolbar from '../navigable-toolbar';
+import { unlock } from '../../lock-unlock';
export default function ZoomOutToolbar( { clientId, __unstableContentRef } ) {
const selected = useSelect(
( select ) => {
const {
getBlock,
- hasBlockMovingClientId,
getNextBlockClientId,
getPreviousBlockClientId,
canRemoveBlock,
@@ -62,7 +57,6 @@ export default function ZoomOutToolbar( { clientId, __unstableContentRef } ) {
}
return {
- blockMovingMode: hasBlockMovingClientId(),
isBlockTemplatePart,
isNextBlockTemplatePart,
isPrevBlockTemplatePart,
@@ -75,7 +69,6 @@ export default function ZoomOutToolbar( { clientId, __unstableContentRef } ) {
);
const {
- blockMovingMode,
isBlockTemplatePart,
isNextBlockTemplatePart,
isPrevBlockTemplatePart,
@@ -84,18 +77,15 @@ export default function ZoomOutToolbar( { clientId, __unstableContentRef } ) {
setIsInserterOpened,
} = selected;
- const { removeBlock, __unstableSetEditorMode } =
- useDispatch( blockEditorStore );
-
- const classNames = clsx( 'zoom-out-toolbar', {
- 'is-block-moving-mode': !! blockMovingMode,
- } );
+ const { removeBlock, __unstableSetEditorMode, resetZoomLevel } = unlock(
+ useDispatch( blockEditorStore )
+ );
const showBlockDraggable = canMove && ! isBlockTemplatePart;
return (
@@ -156,6 +147,7 @@ export default function ZoomOutToolbar( { clientId, __unstableContentRef } ) {
label={ __( 'Delete' ) }
onClick={ () => {
removeBlock( clientId );
+ __unstableContentRef.current?.focus();
} }
/>
) }
diff --git a/packages/block-editor/src/components/block-variation-picker/index.js b/packages/block-editor/src/components/block-variation-picker/index.js
index ecdf8b23bec3fe..f3687a305e84fd 100644
--- a/packages/block-editor/src/components/block-variation-picker/index.js
+++ b/packages/block-editor/src/components/block-variation-picker/index.js
@@ -64,8 +64,7 @@ function BlockVariationPicker( {
{ allowSkip && (
onSelect() }
>
diff --git a/packages/block-editor/src/components/block-variation-transforms/index.js b/packages/block-editor/src/components/block-variation-transforms/index.js
index 97a3f980541842..5850fc52b37b68 100644
--- a/packages/block-editor/src/components/block-variation-transforms/index.js
+++ b/packages/block-editor/src/components/block-variation-transforms/index.js
@@ -21,6 +21,7 @@ import { chevronDown } from '@wordpress/icons';
*/
import BlockIcon from '../block-icon';
import { store as blockEditorStore } from '../../store';
+import { unlock } from '../../lock-unlock';
function VariationsButtons( {
className,
@@ -35,8 +36,8 @@ function VariationsButtons( {
{ variations.map( ( variation ) => (
}
isPressed={ selectedValue === variation.name }
@@ -140,19 +141,28 @@ function VariationsToggleGroupControl( {
function __experimentalBlockVariationTransforms( { blockClientId } ) {
const { updateBlockAttributes } = useDispatch( blockEditorStore );
- const { activeBlockVariation, variations } = useSelect(
+ const { activeBlockVariation, variations, isContentOnly } = useSelect(
( select ) => {
const { getActiveBlockVariation, getBlockVariations } =
select( blocksStore );
- const { getBlockName, getBlockAttributes } =
+
+ const { getBlockName, getBlockAttributes, getBlockEditingMode } =
select( blockEditorStore );
+
const name = blockClientId && getBlockName( blockClientId );
+
+ const { hasContentRoleAttribute } = unlock( select( blocksStore ) );
+ const isContentBlock = hasContentRoleAttribute( name );
+
return {
activeBlockVariation: getActiveBlockVariation(
name,
getBlockAttributes( blockClientId )
),
variations: name && getBlockVariations( name, 'transform' ),
+ isContentOnly:
+ getBlockEditingMode( blockClientId ) === 'contentOnly' &&
+ ! isContentBlock,
};
},
[ blockClientId ]
@@ -181,8 +191,7 @@ function __experimentalBlockVariationTransforms( { blockClientId } ) {
} );
};
- // Skip rendering if there are no variations
- if ( ! variations?.length ) {
+ if ( ! variations?.length || isContentOnly ) {
return null;
}
diff --git a/packages/block-editor/src/components/button-block-appender/content.scss b/packages/block-editor/src/components/button-block-appender/content.scss
index e462278c07c104..f5486d3f6f6086 100644
--- a/packages/block-editor/src/components/button-block-appender/content.scss
+++ b/packages/block-editor/src/components/button-block-appender/content.scss
@@ -8,11 +8,6 @@
color: $gray-900;
box-shadow: inset 0 0 0 $border-width $gray-900;
- // Needs specificity to override button styles.
- &.components-button.components-button {
- padding: $grid-unit-15;
- }
-
.is-dark-theme & {
color: $light-gray-placeholder;
box-shadow: inset 0 0 0 $border-width $light-gray-placeholder;
diff --git a/packages/block-editor/src/components/button-block-appender/index.js b/packages/block-editor/src/components/button-block-appender/index.js
index c4a6854c6d6cc4..53b15e2fd2cfdd 100644
--- a/packages/block-editor/src/components/button-block-appender/index.js
+++ b/packages/block-editor/src/components/button-block-appender/index.js
@@ -60,8 +60,7 @@ function ButtonBlockAppender(
return (
+
+This feature is still experimental. āExperimentalā means this is an early implementation subject to drastic and breaking changes.
+
+
FontFamilyControl is a React component that renders a UI that allows users to select a font family.
The component renders a user interface that allows the user to select from a set of predefined font families as defined by the `typography.fontFamilies` presets.
Optionally, you can provide a `fontFamilies` prop that overrides the predefined font families.
@@ -10,7 +14,7 @@ Optionally, you can provide a `fontFamilies` prop that overrides the predefined
```jsx
import { useState } from 'react';
-import { FontFamilyControl } from '@wordpress/block-editor';
+import { __experimentalFontFamilyControl as FontFamilyControl } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
// ...
diff --git a/packages/block-editor/src/components/global-styles/border-panel.js b/packages/block-editor/src/components/global-styles/border-panel.js
index cc7a464f8634a9..aea8e67f5b5944 100644
--- a/packages/block-editor/src/components/global-styles/border-panel.js
+++ b/packages/block-editor/src/components/global-styles/border-panel.js
@@ -2,7 +2,7 @@
* WordPress dependencies
*/
import {
- __experimentalBorderBoxControl as BorderBoxControl,
+ BorderBoxControl,
__experimentalHasSplitBorders as hasSplitBorders,
__experimentalIsDefinedBorder as isDefinedBorder,
__experimentalToolsPanel as ToolsPanel,
diff --git a/packages/block-editor/src/components/global-styles/color-panel.js b/packages/block-editor/src/components/global-styles/color-panel.js
index 87c19adedbb27b..a55a7b331bd0dd 100644
--- a/packages/block-editor/src/components/global-styles/color-panel.js
+++ b/packages/block-editor/src/components/global-styles/color-panel.js
@@ -239,11 +239,7 @@ function ColorPanelDropdown( {
};
return (
-
+
onShadowChange( undefined ) }
>
@@ -80,32 +80,31 @@ export function ShadowPresets( { presets, activeShadow, onSelect } ) {
export function ShadowIndicator( { type, label, isActive, onSelect, shadow } ) {
return (
-
- { isActive && }
-
- }
- />
+
+
+ { isActive && }
+
+ }
+ />
+
);
}
@@ -143,11 +142,7 @@ function renderShadowToggle() {
};
return (
-
+
{
const {
getBlockName,
- isBlockSelected,
- hasSelectedInnerBlock,
__unstableGetEditorMode,
getTemplateLock,
getBlockRootClientId,
getBlockEditingMode,
getBlockSettings,
- isDragging,
getSectionRootClientId,
} = unlock( select( blockEditorStore ) );
let _isDropZoneDisabled;
@@ -213,8 +210,6 @@ export function useInnerBlocksProps( props = {}, options = {} ) {
const { hasBlockSupport, getBlockType } = select( blocksStore );
const blockName = getBlockName( clientId );
- const enableClickThrough =
- __unstableGetEditorMode() === 'navigation';
const blockEditingMode = getBlockEditingMode( clientId );
const parentClientId = getBlockRootClientId( clientId );
const [ defaultLayout ] = getBlockSettings( clientId, 'layout' );
@@ -236,12 +231,6 @@ export function useInnerBlocksProps( props = {}, options = {} ) {
'__experimentalExposeControlsToChildren',
false
),
- hasOverlay:
- blockName !== 'core/template' &&
- ! isBlockSelected( clientId ) &&
- ! hasSelectedInnerBlock( clientId, true ) &&
- enableClickThrough &&
- ! isDragging(),
name: blockName,
blockType: getBlockType( blockName ),
parentLock: getTemplateLock( parentClientId ),
@@ -254,7 +243,6 @@ export function useInnerBlocksProps( props = {}, options = {} ) {
);
const {
__experimentalCaptureToolbars,
- hasOverlay,
name,
blockType,
parentLock,
@@ -299,10 +287,7 @@ export function useInnerBlocksProps( props = {}, options = {} ) {
className: clsx(
props.className,
'block-editor-block-list__layout',
- __unstableDisableLayoutClassNames ? '' : layoutClassNames,
- {
- 'has-overlay': hasOverlay,
- }
+ __unstableDisableLayoutClassNames ? '' : layoutClassNames
),
children: clientId ? (
diff --git a/packages/block-editor/src/components/inserter/block-types-tab.js b/packages/block-editor/src/components/inserter/block-types-tab.js
index 50a8b46b46427c..844d5dd341437e 100644
--- a/packages/block-editor/src/components/inserter/block-types-tab.js
+++ b/packages/block-editor/src/components/inserter/block-types-tab.js
@@ -186,7 +186,7 @@ export function BlockTypesTab(
continue;
}
- if ( rootClientId && item.rootClientId === rootClientId ) {
+ if ( rootClientId && item.isAllowedInCurrentRoot ) {
itemsForCurrentRoot.push( item );
} else {
itemsRemaining.push( item );
diff --git a/packages/block-editor/src/components/inserter/hooks/use-block-types-state.js b/packages/block-editor/src/components/inserter/hooks/use-block-types-state.js
index 8db23267eee8f4..6f11060c75c494 100644
--- a/packages/block-editor/src/components/inserter/hooks/use-block-types-state.js
+++ b/packages/block-editor/src/components/inserter/hooks/use-block-types-state.js
@@ -2,19 +2,23 @@
* WordPress dependencies
*/
import {
+ getBlockType,
createBlock,
createBlocksFromInnerBlocksTemplate,
store as blocksStore,
parse,
} from '@wordpress/blocks';
-import { useSelect } from '@wordpress/data';
+import { useSelect, useDispatch } from '@wordpress/data';
import { useCallback, useMemo } from '@wordpress/element';
+import { store as noticesStore } from '@wordpress/notices';
+import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { store as blockEditorStore } from '../../../store';
-import { withRootClientIdOptionKey } from '../../../store/utils';
+import { isFiltered } from '../../../store/utils';
+import { unlock } from '../../../lock-unlock';
/**
* Retrieves the block types inserter state.
@@ -26,7 +30,7 @@ import { withRootClientIdOptionKey } from '../../../store/utils';
*/
const useBlockTypesState = ( rootClientId, onInsert, isQuick ) => {
const options = useMemo(
- () => ( { [ withRootClientIdOptionKey ]: ! isQuick } ),
+ () => ( { [ isFiltered ]: !! isQuick } ),
[ isQuick ]
);
const [ items ] = useSelect(
@@ -38,6 +42,10 @@ const useBlockTypesState = ( rootClientId, onInsert, isQuick ) => {
],
[ rootClientId, options ]
);
+ const { getClosestAllowedInsertionPoint } = unlock(
+ useSelect( blockEditorStore )
+ );
+ const { createErrorNotice } = useDispatch( noticesStore );
const [ categories, collections ] = useSelect( ( select ) => {
const { getCategories, getCollections } = select( blocksStore );
@@ -46,16 +54,29 @@ const useBlockTypesState = ( rootClientId, onInsert, isQuick ) => {
const onSelectItem = useCallback(
(
- {
- name,
- initialAttributes,
- innerBlocks,
- syncStatus,
- content,
- rootClientId: _rootClientId,
- },
+ { name, initialAttributes, innerBlocks, syncStatus, content },
shouldFocusBlock
) => {
+ const destinationClientId = getClosestAllowedInsertionPoint(
+ name,
+ rootClientId
+ );
+ if ( destinationClientId === null ) {
+ const title = getBlockType( name )?.title ?? name;
+ createErrorNotice(
+ sprintf(
+ /* translators: %s: block pattern title. */
+ __( 'Block "%s" can\'t be inserted.' ),
+ title
+ ),
+ {
+ type: 'snackbar',
+ id: 'inserter-notice',
+ }
+ );
+ return;
+ }
+
const insertedBlock =
syncStatus === 'unsynced'
? parse( content, {
@@ -66,15 +87,14 @@ const useBlockTypesState = ( rootClientId, onInsert, isQuick ) => {
initialAttributes,
createBlocksFromInnerBlocksTemplate( innerBlocks )
);
-
onInsert(
insertedBlock,
undefined,
shouldFocusBlock,
- _rootClientId
+ destinationClientId
);
},
- [ onInsert ]
+ [ onInsert, getClosestAllowedInsertionPoint, rootClientId ]
);
return [ items, categories, collections, onSelectItem ];
diff --git a/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js b/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js
index 24074ec5004565..de814152c620b0 100644
--- a/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js
+++ b/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js
@@ -71,7 +71,11 @@ function useInsertionPoint( {
selectBlockOnInsert = true,
} ) {
const registry = useRegistry();
- const { getSelectedBlock } = useSelect( blockEditorStore );
+ const {
+ getSelectedBlock,
+ getClosestAllowedInsertionPoint,
+ isBlockInsertionPointVisible,
+ } = unlock( useSelect( blockEditorStore ) );
const { destinationRootClientId, destinationIndex } = useSelect(
( select ) => {
const {
@@ -79,15 +83,24 @@ function useInsertionPoint( {
getBlockRootClientId,
getBlockIndex,
getBlockOrder,
- } = select( blockEditorStore );
+ getInsertionPoint,
+ } = unlock( select( blockEditorStore ) );
const selectedBlockClientId = getSelectedBlockClientId();
-
let _destinationRootClientId = rootClientId;
let _destinationIndex;
+ const insertionPoint = getInsertionPoint();
if ( insertionIndex !== undefined ) {
// Insert into a specific index.
_destinationIndex = insertionIndex;
+ } else if (
+ insertionPoint &&
+ insertionPoint.hasOwnProperty( 'index' )
+ ) {
+ _destinationRootClientId = insertionPoint?.rootClientId
+ ? insertionPoint.rootClientId
+ : rootClientId;
+ _destinationIndex = insertionPoint.index;
} else if ( clientId ) {
// Insert after a specific client ID.
_destinationIndex = getBlockIndex( clientId );
@@ -193,21 +206,30 @@ function useInsertionPoint( {
const onToggleInsertionPoint = useCallback(
( item ) => {
- if ( item?.hasOwnProperty( 'rootClientId' ) ) {
- showInsertionPoint(
- item.rootClientId,
- getIndex( {
- destinationRootClientId,
- destinationIndex,
- rootClientId: item.rootClientId,
- registry,
- } )
- );
+ if ( item && ! isBlockInsertionPointVisible() ) {
+ const allowedDestinationRootClientId =
+ getClosestAllowedInsertionPoint(
+ item.name,
+ destinationRootClientId
+ );
+ if ( allowedDestinationRootClientId !== null ) {
+ showInsertionPoint(
+ allowedDestinationRootClientId,
+ getIndex( {
+ destinationRootClientId,
+ destinationIndex,
+ rootClientId: allowedDestinationRootClientId,
+ registry,
+ } )
+ );
+ }
} else {
hideInsertionPoint();
}
},
[
+ getClosestAllowedInsertionPoint,
+ isBlockInsertionPointVisible,
showInsertionPoint,
hideInsertionPoint,
destinationRootClientId,
diff --git a/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js b/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js
index 6483dc58ae8b97..91b34c0ec72c3d 100644
--- a/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js
+++ b/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js
@@ -11,33 +11,52 @@ import { store as noticesStore } from '@wordpress/notices';
* Internal dependencies
*/
import { store as blockEditorStore } from '../../../store';
+import { unlock } from '../../../lock-unlock';
import { INSERTER_PATTERN_TYPES } from '../block-patterns-tab/utils';
+import { isFiltered } from '../../../store/utils';
/**
* Retrieves the block patterns inserter state.
*
* @param {Function} onInsert function called when inserter a list of blocks.
* @param {string=} rootClientId Insertion's root client ID.
- *
* @param {string} selectedCategory The selected pattern category.
+ * @param {boolean} isQuick For the quick inserter render only allowed patterns.
+ *
* @return {Array} Returns the patterns state. (patterns, categories, onSelect handler)
*/
-const usePatternsState = ( onInsert, rootClientId, selectedCategory ) => {
+const usePatternsState = (
+ onInsert,
+ rootClientId,
+ selectedCategory,
+ isQuick
+) => {
+ const options = useMemo(
+ () => ( { [ isFiltered ]: !! isQuick } ),
+ [ isQuick ]
+ );
const { patternCategories, patterns, userPatternCategories } = useSelect(
( select ) => {
- const { __experimentalGetAllowedPatterns, getSettings } =
- select( blockEditorStore );
+ const { getSettings, __experimentalGetAllowedPatterns } = unlock(
+ select( blockEditorStore )
+ );
const {
__experimentalUserPatternCategories,
__experimentalBlockPatternCategories,
} = getSettings();
return {
- patterns: __experimentalGetAllowedPatterns( rootClientId ),
+ patterns: __experimentalGetAllowedPatterns(
+ rootClientId,
+ options
+ ),
userPatternCategories: __experimentalUserPatternCategories,
patternCategories: __experimentalBlockPatternCategories,
};
},
- [ rootClientId ]
+ [ rootClientId, options ]
+ );
+ const { getClosestAllowedInsertionPointForPattern } = unlock(
+ useSelect( blockEditorStore )
);
const allCategories = useMemo( () => {
@@ -58,6 +77,15 @@ const usePatternsState = ( onInsert, rootClientId, selectedCategory ) => {
const { createSuccessNotice } = useDispatch( noticesStore );
const onClickPattern = useCallback(
( pattern, blocks ) => {
+ const destinationRootClientId = isQuick
+ ? rootClientId
+ : getClosestAllowedInsertionPointForPattern(
+ pattern,
+ rootClientId
+ );
+ if ( destinationRootClientId === null ) {
+ return;
+ }
const patternBlocks =
pattern.type === INSERTER_PATTERN_TYPES.user &&
pattern.syncStatus !== 'unsynced'
@@ -77,7 +105,9 @@ const usePatternsState = ( onInsert, rootClientId, selectedCategory ) => {
}
return clonedBlock;
} ),
- pattern.name
+ pattern.name,
+ false,
+ destinationRootClientId
);
createSuccessNotice(
sprintf(
@@ -87,11 +117,18 @@ const usePatternsState = ( onInsert, rootClientId, selectedCategory ) => {
),
{
type: 'snackbar',
- id: 'block-pattern-inserted-notice',
+ id: 'inserter-notice',
}
);
},
- [ createSuccessNotice, onInsert, selectedCategory ]
+ [
+ createSuccessNotice,
+ onInsert,
+ selectedCategory,
+ rootClientId,
+ getClosestAllowedInsertionPointForPattern,
+ isQuick,
+ ]
);
return [ patterns, allCategories, onClickPattern ];
diff --git a/packages/block-editor/src/components/inserter/media-tab/media-preview.js b/packages/block-editor/src/components/inserter/media-tab/media-preview.js
index 64088f45fa1c39..319c25c01831c8 100644
--- a/packages/block-editor/src/components/inserter/media-tab/media-preview.js
+++ b/packages/block-editor/src/components/inserter/media-tab/media-preview.js
@@ -26,6 +26,7 @@ import { moreVertical, external } from '@wordpress/icons';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as noticesStore } from '@wordpress/notices';
import { isBlobURL } from '@wordpress/blob';
+import { getFilename } from '@wordpress/url';
/**
* Internal dependencies
@@ -132,7 +133,8 @@ export function MediaPreview( { media, onClick, category } ) {
);
const { createErrorNotice, createSuccessNotice } =
useDispatch( noticesStore );
- const { getSettings } = useSelect( blockEditorStore );
+ const { getSettings, getBlock } = useSelect( blockEditorStore );
+ const { updateBlockAttributes } = useDispatch( blockEditorStore );
const onMediaInsert = useCallback(
( previewBlock ) => {
@@ -167,30 +169,51 @@ export function MediaPreview( { media, onClick, category } ) {
.fetch( url )
.then( ( response ) => response.blob() )
.then( ( blob ) => {
+ const fileName = getFilename( url ) || 'image.jpg';
+ const file = new File( [ blob ], fileName, {
+ type: blob.type,
+ } );
+
settings.mediaUpload( {
- filesList: [ blob ],
+ filesList: [ file ],
additionalData: { caption },
onFileChange( [ img ] ) {
if ( isBlobURL( img.url ) ) {
return;
}
- onClick( {
- ...clonedBlock,
- attributes: {
+
+ if ( ! getBlock( clonedBlock.clientId ) ) {
+ // Ensure the block is only inserted once.
+ onClick( {
+ ...clonedBlock,
+ attributes: {
+ ...clonedBlock.attributes,
+ id: img.id,
+ url: img.url,
+ },
+ } );
+
+ createSuccessNotice(
+ __( 'Image uploaded and inserted.' ),
+ { type: 'snackbar', id: 'inserter-notice' }
+ );
+ } else {
+ // For subsequent calls, update the existing block.
+ updateBlockAttributes( clonedBlock.clientId, {
...clonedBlock.attributes,
id: img.id,
url: img.url,
- },
- } );
- createSuccessNotice(
- __( 'Image uploaded and inserted.' ),
- { type: 'snackbar' }
- );
+ } );
+ }
+
setIsInserting( false );
},
allowedTypes: ALLOWED_MEDIA_TYPES,
onError( message ) {
- createErrorNotice( message, { type: 'snackbar' } );
+ createErrorNotice( message, {
+ type: 'snackbar',
+ id: 'inserter-notice',
+ } );
setIsInserting( false );
},
} );
@@ -205,7 +228,9 @@ export function MediaPreview( { media, onClick, category } ) {
getSettings,
onClick,
createSuccessNotice,
+ updateBlockAttributes,
createErrorNotice,
+ getBlock,
]
);
@@ -281,6 +306,7 @@ export function MediaPreview( { media, onClick, category } ) {
onClick( cloneBlock( block ) );
createSuccessNotice( __( 'Image inserted.' ), {
type: 'snackbar',
+ id: 'inserter-notice',
} );
setShowExternalUploadModal( false );
} }
diff --git a/packages/block-editor/src/components/inserter/menu.js b/packages/block-editor/src/components/inserter/menu.js
index faf2c20514f67a..4bc26196cb5243 100644
--- a/packages/block-editor/src/components/inserter/menu.js
+++ b/packages/block-editor/src/components/inserter/menu.js
@@ -114,9 +114,9 @@ function InserterMenu(
);
const onInsertPattern = useCallback(
- ( blocks, patternName ) => {
+ ( blocks, patternName, ...args ) => {
onToggleInsertionPoint( false );
- onInsertBlocks( blocks, { patternName } );
+ onInsertBlocks( blocks, { patternName }, ...args );
onSelect();
},
[ onInsertBlocks, onSelect ]
diff --git a/packages/block-editor/src/components/inserter/mobile-tab-navigation.js b/packages/block-editor/src/components/inserter/mobile-tab-navigation.js
index fa8191cc5eaaa0..5f34c3c21d832f 100644
--- a/packages/block-editor/src/components/inserter/mobile-tab-navigation.js
+++ b/packages/block-editor/src/components/inserter/mobile-tab-navigation.js
@@ -10,10 +10,7 @@ import {
__experimentalSpacer as Spacer,
__experimentalHeading as Heading,
__experimentalView as View,
- __experimentalNavigatorProvider as NavigatorProvider,
- __experimentalNavigatorScreen as NavigatorScreen,
- __experimentalNavigatorButton as NavigatorButton,
- __experimentalNavigatorBackButton as NavigatorBackButton,
+ Navigator,
FlexBlock,
} from '@wordpress/components';
import { Icon, chevronRight, chevronLeft } from '@wordpress/icons';
@@ -24,7 +21,7 @@ function ScreenHeader( { title } ) {
-
-
+
{ categories.map( ( category ) => (
-
-
+
) ) }
-
+
{ categories.map( ( category ) => (
-
{ children( category ) }
-
+
) ) }
-
+
);
}
diff --git a/packages/block-editor/src/components/inserter/quick-inserter.js b/packages/block-editor/src/components/inserter/quick-inserter.js
index 4a79ad6b1f083c..7c7f836842b418 100644
--- a/packages/block-editor/src/components/inserter/quick-inserter.js
+++ b/packages/block-editor/src/components/inserter/quick-inserter.js
@@ -47,10 +47,11 @@ export default function QuickInserter( {
onInsertBlocks,
true
);
-
const [ patterns ] = usePatternsState(
onInsertBlocks,
- destinationRootClientId
+ destinationRootClientId,
+ undefined,
+ true
);
const { setInserterIsOpened, insertionIndex } = useSelect(
@@ -86,10 +87,10 @@ export default function QuickInserter( {
// the insertion point can work as expected.
const onBrowseAll = () => {
setInserterIsOpened( {
- rootClientId,
- insertionIndex,
filterValue,
onSelect,
+ rootClientId,
+ insertionIndex,
} );
};
diff --git a/packages/block-editor/src/components/inserter/style.scss b/packages/block-editor/src/components/inserter/style.scss
index 3ce088901bce57..f3fa8d1e7df04b 100644
--- a/packages/block-editor/src/components/inserter/style.scss
+++ b/packages/block-editor/src/components/inserter/style.scss
@@ -257,39 +257,11 @@ $block-inserter-tabs-height: 44px;
svg {
fill: var(--wp-admin-theme-color);
}
-
- &::after {
- content: "";
- display: block;
- outline: none;
- position: absolute;
- top: 0;
- bottom: 0;
- left: 0;
- right: 0;
- border-radius: $radius-small;
- opacity: 0.04;
- background: var(--wp-admin-theme-color);
- height: 100%;
- }
- }
-
- &:focus-visible,
- &:focus:not(:disabled) {
- border-radius: $radius-small;
- box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);
- // Windows high contrast mode.
- outline: 2px solid transparent;
- outline-offset: 0;
}
&::before {
display: none;
}
-
- &::after {
- display: none;
- }
}
}
diff --git a/packages/block-editor/src/components/inserter/tabs.js b/packages/block-editor/src/components/inserter/tabs.js
deleted file mode 100644
index b46e4bfdaf0148..00000000000000
--- a/packages/block-editor/src/components/inserter/tabs.js
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * WordPress dependencies
- */
-import {
- Button,
- privateApis as componentsPrivateApis,
-} from '@wordpress/components';
-import { __ } from '@wordpress/i18n';
-import { forwardRef } from '@wordpress/element';
-import { closeSmall } from '@wordpress/icons';
-
-/**
- * Internal dependencies
- */
-import { unlock } from '../../lock-unlock';
-
-const { Tabs } = unlock( componentsPrivateApis );
-
-const blocksTab = {
- name: 'blocks',
- /* translators: Blocks tab title in the block inserter. */
- title: __( 'Blocks' ),
-};
-const patternsTab = {
- name: 'patterns',
- /* translators: Theme and Directory Patterns tab title in the block inserter. */
- title: __( 'Patterns' ),
-};
-
-const mediaTab = {
- name: 'media',
- /* translators: Media tab title in the block inserter. */
- title: __( 'Media' ),
-};
-
-function InserterTabs( { onSelect, children, onClose, selectedTab }, ref ) {
- const tabs = [ blocksTab, patternsTab, mediaTab ];
-
- return (
-
-
-
- onClose() }
- size="small"
- />
-
-
- { tabs.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
-
- { tabs.map( ( tab ) => (
-
- { children }
-
- ) ) }
-
-
- );
-}
-
-export default forwardRef( InserterTabs );
diff --git a/packages/block-editor/src/components/inserter/test/block-types-tab.native.js b/packages/block-editor/src/components/inserter/test/block-types-tab.native.js
deleted file mode 100644
index 925570130359a6..00000000000000
--- a/packages/block-editor/src/components/inserter/test/block-types-tab.native.js
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * External dependencies
- */
-import { render } from 'test/helpers';
-
-/**
- * WordPress dependencies
- */
-import { useSelect } from '@wordpress/data';
-
-/**
- * Internal dependencies
- */
-import items from './fixtures';
-import BlockTypesTab from '../block-types-tab';
-
-jest.mock( '../hooks/use-clipboard-block' );
-jest.mock( '@wordpress/data/src/components/use-select' );
-
-const selectMock = {
- getCategories: jest.fn().mockReturnValue( [] ),
- getCollections: jest.fn().mockReturnValue( [] ),
- getInserterItems: jest.fn().mockReturnValue( [] ),
- canInsertBlockType: jest.fn(),
- getBlockType: jest.fn(),
- getClipboard: jest.fn(),
- getSettings: jest.fn( () => ( { impressions: {} } ) ),
-};
-
-describe( 'BlockTypesTab component', () => {
- beforeEach( () => {
- useSelect.mockImplementation( ( callback ) =>
- callback( () => selectMock )
- );
- } );
-
- it( 'renders without crashing', () => {
- const component = render(
-
- );
- expect( component ).toBeTruthy();
- } );
-
- it( 'shows block items', () => {
- selectMock.getInserterItems.mockReturnValue( items );
-
- const blockItems = items.filter(
- ( { id, category } ) =>
- category !== 'reusable' && id !== 'core-embed/a-paragraph-embed'
- );
- const component = render(
-
- );
-
- blockItems.forEach( ( item ) => {
- expect( component.getByText( item.title ) ).toBeTruthy();
- } );
- } );
-} );
diff --git a/packages/block-editor/src/components/inspector-controls-tabs/index.js b/packages/block-editor/src/components/inspector-controls-tabs/index.js
index 601854373b7eb0..c8d9536aeb9caf 100644
--- a/packages/block-editor/src/components/inspector-controls-tabs/index.js
+++ b/packages/block-editor/src/components/inspector-controls-tabs/index.js
@@ -2,7 +2,8 @@
* WordPress dependencies
*/
import {
- Button,
+ Icon,
+ Tooltip,
privateApis as componentsPrivateApis,
} from '@wordpress/components';
import { store as preferencesStore } from '@wordpress/preferences';
@@ -43,27 +44,27 @@ export default function InspectorControlsTabs( {
- { tabs.map( ( tab ) => (
-
+ showIconLabels ? (
+
+ { tab.title }
+
+ ) : (
+
+
- { showIconLabels && tab.title }
-
- }
- />
- ) ) }
+
+
+
+ )
+ ) }
diff --git a/packages/block-editor/src/components/inspector-controls-tabs/style.scss b/packages/block-editor/src/components/inspector-controls-tabs/style.scss
index 863ac3d9bed03a..9c9b04f7b84734 100644
--- a/packages/block-editor/src/components/inspector-controls-tabs/style.scss
+++ b/packages/block-editor/src/components/inspector-controls-tabs/style.scss
@@ -1,7 +1,3 @@
-.show-icon-labels {
- .block-editor-block-inspector__tabs [role="tablist"] {
- .components-button {
- justify-content: center;
- }
- }
+.block-editor-block-inspector__tabs [role="tablist"] {
+ width: 100%;
}
diff --git a/packages/block-editor/src/components/inspector-popover-header/index.js b/packages/block-editor/src/components/inspector-popover-header/index.js
index d543ab0298cc62..cf6bf0d3d6796e 100644
--- a/packages/block-editor/src/components/inspector-popover-header/index.js
+++ b/packages/block-editor/src/components/inspector-popover-header/index.js
@@ -31,8 +31,7 @@ export default function InspectorPopoverHeader( {
{ actions.map( ( { label, icon, onClick } ) => (
)
}
- props
/>
{ errorMessage && (
@@ -475,16 +474,14 @@ function LinkControl( {
className="block-editor-link-control__search-actions"
>
{ __( 'Cancel' ) }
setSettingsOpen( ! settingsOpen ) }
diff --git a/packages/block-editor/src/components/list-view/block-contents.js b/packages/block-editor/src/components/list-view/block-contents.js
index 91bfbd7eddaa03..0ed2b162b127b8 100644
--- a/packages/block-editor/src/components/list-view/block-contents.js
+++ b/packages/block-editor/src/components/list-view/block-contents.js
@@ -1,12 +1,6 @@
-/**
- * External dependencies
- */
-import clsx from 'clsx';
-
/**
* WordPress dependencies
*/
-import { useSelect } from '@wordpress/data';
import { forwardRef } from '@wordpress/element';
/**
@@ -14,7 +8,6 @@ import { forwardRef } from '@wordpress/element';
*/
import ListViewBlockSelectButton from './block-select-button';
import BlockDraggable from '../block-draggable';
-import { store as blockEditorStore } from '../../store';
import { useListViewContext } from './context';
const ListViewBlockContents = forwardRef(
@@ -34,29 +27,9 @@ const ListViewBlockContents = forwardRef(
ref
) => {
const { clientId } = block;
-
- const { blockMovingClientId, selectedBlockInBlockEditor } = useSelect(
- ( select ) => {
- const { hasBlockMovingClientId, getSelectedBlockClientId } =
- select( blockEditorStore );
- return {
- blockMovingClientId: hasBlockMovingClientId(),
- selectedBlockInBlockEditor: getSelectedBlockClientId(),
- };
- },
- []
- );
-
const { AdditionalBlockContent, insertedBlock, setInsertedBlock } =
useListViewContext();
- const isBlockMoveTarget =
- blockMovingClientId && selectedBlockInBlockEditor === clientId;
-
- const className = clsx( 'block-editor-list-view-block-contents', {
- 'is-dropping-before': isBlockMoveTarget,
- } );
-
// Only include all selected blocks if the currently clicked on block
// is one of the selected blocks. This ensures that if a user attempts
// to drag a block that isn't part of the selection, they're still able
@@ -82,7 +55,7 @@ const ListViewBlockContents = forwardRef(
{ ( { draggable, onDragStart, onDragEnd } ) => (
) }
-
+
);
}
diff --git a/packages/block-editor/src/components/list-view/style.scss b/packages/block-editor/src/components/list-view/style.scss
index 05a04abfd110b4..2916622efabee9 100644
--- a/packages/block-editor/src/components/list-view/style.scss
+++ b/packages/block-editor/src/components/list-view/style.scss
@@ -41,6 +41,15 @@
&:hover {
color: var(--wp-admin-theme-color);
}
+
+ svg {
+ fill: currentColor;
+ // Optimizate for high contrast modes.
+ // See also https://blogs.windows.com/msedgedev/2020/09/17/styling-for-windows-high-contrast-with-new-standards-for-forced-colors/.
+ @media (forced-colors: active) {
+ fill: CanvasText;
+ }
+ }
}
&:not(.is-selected) .block-editor-list-view-block-select-button {
@@ -216,20 +225,15 @@
text-align: left;
position: relative;
white-space: nowrap;
-
- &.is-dropping-before::before {
- content: "";
- position: absolute;
- pointer-events: none;
- transition:
- border-color 0.1s linear,
- border-style 0.1s linear,
- box-shadow 0.1s linear;
- top: -2px;
- right: 0;
- left: 0;
- border-top: 4px solid var(--wp-admin-theme-color);
- }
+ border-radius: 2px;
+ box-sizing: border-box;
+ color: inherit;
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 400;
+ margin: 0;
+ text-decoration: none;
+ transition: box-shadow 0.1s linear;
.components-modal__content & {
padding-left: 0;
diff --git a/packages/block-editor/src/components/media-placeholder/content.scss b/packages/block-editor/src/components/media-placeholder/content.scss
index eeb2928df80baf..2f7bb2e673f12e 100644
--- a/packages/block-editor/src/components/media-placeholder/content.scss
+++ b/packages/block-editor/src/components/media-placeholder/content.scss
@@ -1,27 +1,11 @@
.block-editor-media-placeholder__url-input-form {
- display: flex;
-
- // Selector requires a lot of specificity to override base styles.
- input[type="url"].block-editor-media-placeholder__url-input-field {
- width: 100%;
- min-width: 200px;
-
- @include break-small() {
- width: 300px;
- }
-
- flex-grow: 1;
- border: none;
- border-radius: 0;
- margin: 2px;
+ min-width: 260px;
+ @include break-small() {
+ width: 300px;
}
}
-.block-editor-media-placeholder__url-input-submit-button {
- flex-shrink: 1;
-}
-
.block-editor-media-placeholder__cancel-button.is-link {
margin: 1em;
display: block;
diff --git a/packages/block-editor/src/components/media-placeholder/index.js b/packages/block-editor/src/components/media-placeholder/index.js
index 4d41289f324c0f..f16e4317227235 100644
--- a/packages/block-editor/src/components/media-placeholder/index.js
+++ b/packages/block-editor/src/components/media-placeholder/index.js
@@ -11,6 +11,8 @@ import {
FormFileUpload,
Placeholder,
DropZone,
+ __experimentalInputControl as InputControl,
+ __experimentalInputControlSuffixWrapper as InputControlSuffixWrapper,
withFilters,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
@@ -42,21 +44,23 @@ const InsertFromURLPopover = ( {
className="block-editor-media-placeholder__url-input-form"
onSubmit={ onSubmit }
>
-
-
+
+
+ }
/>
@@ -87,8 +91,7 @@ const URLSelectionUI = ( { src, onChangeSrc, onSelectURL } ) => {
return (
{
- setSrc( event.target.value );
- };
-
const onFilesUpload = ( files ) => {
if (
! handleUpload ||
@@ -389,8 +388,7 @@ export function MediaPlaceholder( {
return (
onCancel && (
)
@@ -419,8 +417,7 @@ export function MediaPlaceholder( {
onToggleFeaturedImage && (
{
return (
{
open();
@@ -477,8 +473,7 @@ export function MediaPlaceholder( {
const content = (
<>
(
( element ) => {
preserveWhiteSpace,
pastePlainText,
} = props.current;
- const { ownerDocument } = element;
- const { defaultView } = ownerDocument;
- const { anchorNode, focusNode } = defaultView.getSelection();
- const containsSelection =
- element.contains( anchorNode ) && element.contains( focusNode );
// The event listener is attached to the window, so we need to check if
- // the target is the element.
- if ( ! containsSelection ) {
+ // the target is the element or inside the element.
+ if ( ! element.contains( event.target ) ) {
return;
}
diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js
index 387f388b8fdad6..8a5d99a7eb9f0a 100644
--- a/packages/block-editor/src/components/rich-text/index.js
+++ b/packages/block-editor/src/components/rich-text/index.js
@@ -20,7 +20,7 @@ import {
removeFormat,
} from '@wordpress/rich-text';
import { Popover } from '@wordpress/components';
-import { store as blocksStore } from '@wordpress/blocks';
+import { getBlockBindingsSource } from '@wordpress/blocks';
import deprecated from '@wordpress/deprecated';
import { __, sprintf } from '@wordpress/i18n';
@@ -39,7 +39,6 @@ import FormatEdit from './format-edit';
import { getAllowedFormats } from './utils';
import { Content, valueToHTMLString } from './content';
import { withDeprecations } from './with-deprecations';
-import { unlock } from '../../lock-unlock';
import { canBindBlock } from '../../hooks/use-bindings-attributes';
import BlockContext from '../block-context';
@@ -165,7 +164,7 @@ export function RichTextWrapper(
isBlockSelected,
] );
- const { disableBoundBlock, bindingsPlaceholder } = useSelect(
+ const { disableBoundBlock, bindingsPlaceholder, bindingsLabel } = useSelect(
( select ) => {
if (
! blockBindings?.[ identifier ] ||
@@ -175,22 +174,39 @@ export function RichTextWrapper(
}
const relatedBinding = blockBindings[ identifier ];
- const { getBlockBindingsSource } = unlock( select( blocksStore ) );
const blockBindingsSource = getBlockBindingsSource(
relatedBinding.source
);
- const fieldsList = blockBindingsSource?.getFieldsList?.( {
- registry,
- context: blockContext,
- } );
+ const blockBindingsContext = {};
+ if ( blockBindingsSource?.usesContext?.length ) {
+ for ( const key of blockBindingsSource.usesContext ) {
+ blockBindingsContext[ key ] = blockContext[ key ];
+ }
+ }
const _disableBoundBlock =
! blockBindingsSource?.canUserEditValue?.( {
select,
- context: blockContext,
+ context: blockBindingsContext,
args: relatedBinding.args,
} );
+ // Don't modify placeholders if value is not empty.
+ if ( adjustedValue.length > 0 ) {
+ return {
+ disableBoundBlock: _disableBoundBlock,
+ // Null values will make them fall back to the default behavior.
+ bindingsPlaceholder: null,
+ bindingsLabel: null,
+ };
+ }
+
+ const { getBlockAttributes } = select( blockEditorStore );
+ const blockAttributes = getBlockAttributes( clientId );
+ const fieldsList = blockBindingsSource?.getFieldsList?.( {
+ select,
+ context: blockBindingsContext,
+ } );
const bindingKey =
fieldsList?.[ relatedBinding?.args?.key ]?.label ??
blockBindingsSource?.label;
@@ -202,22 +218,22 @@ export function RichTextWrapper(
__( 'Add %s' ),
bindingKey
);
+ const _bindingsLabel = _disableBoundBlock
+ ? relatedBinding?.args?.key || blockBindingsSource?.label
+ : sprintf(
+ /* translators: %s: source label or key */
+ __( 'Empty %s; start writing to edit its value' ),
+ relatedBinding?.args?.key || blockBindingsSource?.label
+ );
return {
disableBoundBlock: _disableBoundBlock,
bindingsPlaceholder:
- ( ! adjustedValue || adjustedValue.length === 0 ) &&
- _bindingsPlaceholder,
+ blockAttributes?.placeholder || _bindingsPlaceholder,
+ bindingsLabel: _bindingsLabel,
};
},
- [
- blockBindings,
- identifier,
- blockName,
- blockContext,
- registry,
- adjustedValue,
- ]
+ [ blockBindings, identifier, blockName, blockContext, adjustedValue ]
);
const shouldDisableEditing = readOnly || disableBoundBlock;
@@ -372,19 +388,7 @@ export function RichTextWrapper(
const inputEvents = useRef( new Set() );
function onFocus() {
- let element = anchorRef.current;
-
- if ( ! element ) {
- return;
- }
-
- // Writing flow might be editable, so we should make sure focus goes to
- // the root editable element.
- while ( element.parentElement?.isContentEditable ) {
- element = element.parentElement;
- }
-
- element.focus();
+ anchorRef.current?.focus();
}
const TagName = tagName;
@@ -421,7 +425,7 @@ export function RichTextWrapper(
aria-readonly={ shouldDisableEditing }
{ ...props }
aria-label={
- bindingsPlaceholder || props[ 'aria-label' ] || placeholder
+ bindingsLabel || props[ 'aria-label' ] || placeholder
}
{ ...autocompleteProps }
ref={ useMergeRefs( [
diff --git a/packages/block-editor/src/components/skip-to-selected-block/index.js b/packages/block-editor/src/components/skip-to-selected-block/index.js
index dee7f37550e379..2b52433480f3e6 100644
--- a/packages/block-editor/src/components/skip-to-selected-block/index.js
+++ b/packages/block-editor/src/components/skip-to-selected-block/index.js
@@ -28,8 +28,7 @@ export default function SkipToSelectedBlock() {
return selectedBlockClientId ? (
select( blockEditorStore ).__unstableGetEditorMode(),
[]
);
- const { __unstableSetEditorMode } = useDispatch( blockEditorStore );
+ const { __unstableSetEditorMode } = unlock(
+ useDispatch( blockEditorStore )
+ );
return (
(
(
<>
-
+
- { __( 'Edit' ) }
+ { __( 'Write' ) }
>
),
+ info: __( 'Focus on content.' ),
},
{
- value: 'navigation',
+ value: 'edit',
label: (
<>
{ selectIcon }
- { __( 'Select' ) }
+ { __( 'Design' ) }
>
),
+ info: __( 'Edit layout and styles.' ),
},
] }
/>
{ __(
- 'Tools provide different interactions for selecting, navigating, and editing blocks. Toggle between select and edit by pressing Escape and Enter.'
+ 'Tools provide different sets of interactions for blocks. Toggle between simplified content tools (Write) and advanced visual editing tools (Design).'
) }
>
diff --git a/packages/block-editor/src/components/tool-selector/style.scss b/packages/block-editor/src/components/tool-selector/style.scss
index 03774fe0f6b9d3..07ca91d346d907 100644
--- a/packages/block-editor/src/components/tool-selector/style.scss
+++ b/packages/block-editor/src/components/tool-selector/style.scss
@@ -8,3 +8,8 @@
color: $gray-700;
min-width: 280px;
}
+
+.block-editor-tool-selector__menu .components-menu-item__info {
+ margin-left: $grid-unit-30 + $grid-unit-15; // icon size + margin
+ text-align: left;
+}
diff --git a/packages/block-editor/src/components/url-input/button.js b/packages/block-editor/src/components/url-input/button.js
index 8170ea614ace26..560f5cd7d3d5db 100644
--- a/packages/block-editor/src/components/url-input/button.js
+++ b/packages/block-editor/src/components/url-input/button.js
@@ -3,7 +3,10 @@
*/
import { __ } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
-import { Button } from '@wordpress/components';
+import {
+ Button,
+ __experimentalInputControlSuffixWrapper as InputControlSuffixWrapper,
+} from '@wordpress/components';
import { link, keyboardReturn, arrowLeft } from '@wordpress/icons';
/**
@@ -38,8 +41,7 @@ class URLInputButton extends Component {
return (
-
+
+
+ }
/>
diff --git a/packages/block-editor/src/components/url-input/index.js b/packages/block-editor/src/components/url-input/index.js
index 25c033e88749bb..cb058be5c932f6 100644
--- a/packages/block-editor/src/components/url-input/index.js
+++ b/packages/block-editor/src/components/url-input/index.js
@@ -540,8 +540,7 @@ class URLInput extends Component {
>
{ suggestions.map( ( suggestion, index ) => (
{
return { isLoading: false, commands };
};
-const useActionsCommands = () => {
- const { clientIds } = useSelect( ( select ) => {
- const { getSelectedBlockClientIds } = select( blockEditorStore );
- const selectedBlockClientIds = getSelectedBlockClientIds();
-
- return {
- clientIds: selectedBlockClientIds,
- };
- }, [] );
-
- const { getBlockRootClientId, canMoveBlocks, getBlockCount } =
- useSelect( blockEditorStore );
-
- const { setBlockMovingClientId, setNavigationMode, selectBlock } =
- useDispatch( blockEditorStore );
-
- if ( ! clientIds || clientIds.length < 1 ) {
- return { isLoading: false, commands: [] };
- }
-
- const rootClientId = getBlockRootClientId( clientIds[ 0 ] );
-
- const canMove =
- canMoveBlocks( clientIds ) && getBlockCount( rootClientId ) !== 1;
-
- const commands = [];
-
- if ( canMove ) {
- commands.push( {
- name: 'move-to',
- label: __( 'Move to' ),
- callback: () => {
- setNavigationMode( true );
- selectBlock( clientIds[ 0 ] );
- setBlockMovingClientId( clientIds[ 0 ] );
- },
- icon: move,
- } );
- }
-
- return {
- isLoading: false,
- commands: commands.map( ( command ) => ( {
- ...command,
- name: 'core/block-editor/action-' + command.name,
- callback: ( { close } ) => {
- command.callback();
- close();
- },
- } ) ),
- };
-};
-
const useQuickActionsCommands = () => {
const { clientIds, isUngroupable, isGroupable } = useSelect( ( select ) => {
const {
@@ -344,10 +290,6 @@ export const useBlockCommands = () => {
name: 'core/block-editor/blockTransforms',
hook: useTransformCommands,
} );
- useCommandLoader( {
- name: 'core/block-editor/blockActions',
- hook: useActionsCommands,
- } );
useCommandLoader( {
name: 'core/block-editor/blockQuickActions',
hook: useQuickActionsCommands,
diff --git a/packages/block-editor/src/components/writing-flow/index.js b/packages/block-editor/src/components/writing-flow/index.js
index cea3e4b19707dd..7e6b36b0e22143 100644
--- a/packages/block-editor/src/components/writing-flow/index.js
+++ b/packages/block-editor/src/components/writing-flow/index.js
@@ -23,7 +23,6 @@ import useSelectionObserver from './use-selection-observer';
import useClickSelection from './use-click-selection';
import useInput from './use-input';
import useClipboardHandler from './use-clipboard-handler';
-import useEventRedirect from './use-event-redirect';
import { store as blockEditorStore } from '../../store';
export function useWritingFlow() {
@@ -66,7 +65,6 @@ export function useWritingFlow() {
},
[ hasMultiSelection ]
),
- useEventRedirect(),
] ),
after,
];
diff --git a/packages/block-editor/src/components/writing-flow/use-arrow-nav.js b/packages/block-editor/src/components/writing-flow/use-arrow-nav.js
index fda5a0133ee00c..44051b324ff64d 100644
--- a/packages/block-editor/src/components/writing-flow/use-arrow-nav.js
+++ b/packages/block-editor/src/components/writing-flow/use-arrow-nav.js
@@ -19,7 +19,6 @@ import { useRefEffect } from '@wordpress/compose';
*/
import { getBlockClientId, isInSameBlock } from '../../utils/dom';
import { store as blockEditorStore } from '../../store';
-import { getSelectionRoot } from './utils';
/**
* Returns true if the element should consider edge navigation upon a keyboard
@@ -191,7 +190,8 @@ export default function useArrowNav() {
return;
}
- const { keyCode, shiftKey, ctrlKey, altKey, metaKey } = event;
+ const { keyCode, target, shiftKey, ctrlKey, altKey, metaKey } =
+ event;
const isUp = keyCode === UP;
const isDown = keyCode === DOWN;
const isLeft = keyCode === LEFT;
@@ -233,11 +233,6 @@ export default function useArrowNav() {
return;
}
- const target =
- ownerDocument.activeElement === node
- ? getSelectionRoot( ownerDocument )
- : event.target;
-
// Abort if our current target is not a candidate for navigation
// (e.g. preserve native input behaviors).
if ( ! isNavigationCandidate( target, keyCode, hasModifier ) ) {
@@ -279,7 +274,6 @@ export default function useArrowNav() {
( altKey ? isHorizontalEdge( target, isReverseDir ) : true ) &&
! keepCaretInsideBlock
) {
- node.contentEditable = false;
const closestTabbable = getClosestTabbable(
target,
isReverse,
@@ -303,7 +297,6 @@ export default function useArrowNav() {
isHorizontalEdge( target, isReverseDir ) &&
! keepCaretInsideBlock
) {
- node.contentEditable = false;
const closestTabbable = getClosestTabbable(
target,
isReverseDir,
diff --git a/packages/block-editor/src/components/writing-flow/use-event-redirect.js b/packages/block-editor/src/components/writing-flow/use-event-redirect.js
deleted file mode 100644
index b8dcd7eda69697..00000000000000
--- a/packages/block-editor/src/components/writing-flow/use-event-redirect.js
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { useRefEffect } from '@wordpress/compose';
-
-/**
- * Internal dependencies
- */
-import { getSelectionRoot } from './utils';
-
-/**
- * Whenever content editable is enabled on writing flow, it will have focus, so
- * we need to dispatch some events to the root of the selection to ensure
- * compatibility with rich text. In the future, perhaps the rich text event
- * handlers should be attached to the window instead.
- *
- * Alternatively, we could try to find a way to always maintain rich text focus.
- */
-export default function useEventRedirect() {
- return useRefEffect( ( node ) => {
- function onInput( event ) {
- if ( event.target !== node ) {
- return;
- }
-
- const { ownerDocument } = node;
- const { defaultView } = ownerDocument;
- const prototype = Object.getPrototypeOf( event );
- const constructorName = prototype.constructor.name;
- const Constructor = defaultView[ constructorName ];
- const root = getSelectionRoot( ownerDocument );
-
- if ( ! root || root === node ) {
- return;
- }
-
- const init = {};
-
- for ( const key in event ) {
- init[ key ] = event[ key ];
- }
-
- init.bubbles = false;
-
- const newEvent = new Constructor( event.type, init );
- const cancelled = ! root.dispatchEvent( newEvent );
-
- if ( cancelled ) {
- event.preventDefault();
- }
- }
-
- const events = [
- 'beforeinput',
- 'input',
- 'compositionstart',
- 'compositionend',
- 'compositionupdate',
- 'keydown',
- ];
-
- events.forEach( ( eventType ) => {
- node.addEventListener( eventType, onInput );
- } );
-
- return () => {
- events.forEach( ( eventType ) => {
- node.removeEventListener( eventType, onInput );
- } );
- };
- }, [] );
-}
diff --git a/packages/block-editor/src/components/writing-flow/use-input.js b/packages/block-editor/src/components/writing-flow/use-input.js
index 31c5d769834c01..0f10cc9c2d1c75 100644
--- a/packages/block-editor/src/components/writing-flow/use-input.js
+++ b/packages/block-editor/src/components/writing-flow/use-input.js
@@ -16,7 +16,6 @@ import {
* Internal dependencies
*/
import { store as blockEditorStore } from '../../store';
-import { getSelectionRoot } from './utils';
/**
* Handles input for selections across blocks.
@@ -50,24 +49,7 @@ export default function useInput() {
// DOM. This will cause React errors (and the DOM should only be
// altered in a controlled fashion).
if ( node.contentEditable === 'true' ) {
- const selection = node.ownerDocument.defaultView.getSelection();
- const range = selection.rangeCount
- ? selection.getRangeAt( 0 )
- : null;
- const root = getSelectionRoot( node.ownerDocument );
-
- // If selection is contained within a nested editable, allow
- // input. We need to ensure that selection is maintained.
- if ( root ) {
- node.contentEditable = false;
- root.focus();
- selection.removeAllRanges();
- if ( range ) {
- selection.addRange( range );
- }
- } else {
- event.preventDefault();
- }
+ event.preventDefault();
}
}
@@ -77,23 +59,6 @@ export default function useInput() {
}
if ( ! hasMultiSelection() ) {
- const { ownerDocument } = node;
- if ( node === ownerDocument.activeElement ) {
- if ( event.key === 'End' || event.key === 'Home' ) {
- const selectionRoot = getSelectionRoot( ownerDocument );
- const selection =
- ownerDocument.defaultView.getSelection();
- selection.selectAllChildren( selectionRoot );
- const method =
- event.key === 'End'
- ? 'collapseToEnd'
- : 'collapseToStart';
- selection[ method ]();
- event.preventDefault();
- return;
- }
- }
-
if ( event.keyCode === ENTER ) {
if ( event.shiftKey || __unstableIsFullySelected() ) {
return;
diff --git a/packages/block-editor/src/components/writing-flow/use-select-all.js b/packages/block-editor/src/components/writing-flow/use-select-all.js
index 5a7acb3a8783a4..c56549acf54ad5 100644
--- a/packages/block-editor/src/components/writing-flow/use-select-all.js
+++ b/packages/block-editor/src/components/writing-flow/use-select-all.js
@@ -10,7 +10,6 @@ import { useRefEffect } from '@wordpress/compose';
* Internal dependencies
*/
import { store as blockEditorStore } from '../../store';
-import { getSelectionRoot } from './utils';
export default function useSelectAll() {
const { getBlockOrder, getSelectedBlockClientIds, getBlockRootClientId } =
@@ -24,27 +23,12 @@ export default function useSelectAll() {
return;
}
- const selectionRoot = getSelectionRoot( node.ownerDocument );
const selectedClientIds = getSelectedBlockClientIds();
- // Abort if there is selection, but it is not within a block.
- if ( selectionRoot && ! selectedClientIds.length ) {
- return;
- }
-
if (
- selectionRoot &&
selectedClientIds.length < 2 &&
- ! isEntirelySelected( selectionRoot )
+ ! isEntirelySelected( event.target )
) {
- if ( node === node.ownerDocument.activeElement ) {
- event.preventDefault();
- node.ownerDocument.defaultView
- .getSelection()
- .selectAllChildren( selectionRoot );
- return;
- }
-
return;
}
@@ -61,7 +45,6 @@ export default function useSelectAll() {
node.ownerDocument.defaultView
.getSelection()
.removeAllRanges();
- node.contentEditable = 'false';
selectBlock( rootClientId );
}
return;
diff --git a/packages/block-editor/src/components/writing-flow/use-selection-observer.js b/packages/block-editor/src/components/writing-flow/use-selection-observer.js
index 8ecba461d1025f..c7ce5d259d875a 100644
--- a/packages/block-editor/src/components/writing-flow/use-selection-observer.js
+++ b/packages/block-editor/src/components/writing-flow/use-selection-observer.js
@@ -107,12 +107,8 @@ function getRichTextElement( node ) {
export default function useSelectionObserver() {
const { multiSelect, selectBlock, selectionChange } =
useDispatch( blockEditorStore );
- const {
- getBlockParents,
- getBlockSelectionStart,
- isMultiSelecting,
- getSelectedBlockClientId,
- } = useSelect( blockEditorStore );
+ const { getBlockParents, getBlockSelectionStart, isMultiSelecting } =
+ useSelect( blockEditorStore );
return useRefEffect(
( node ) => {
const { ownerDocument } = node;
@@ -195,17 +191,10 @@ export default function useSelectionObserver() {
return;
}
- setContentEditableWrapper(
- node,
- !! ( startClientId && endClientId )
- );
-
const isSingularSelection = startClientId === endClientId;
if ( isSingularSelection ) {
if ( ! isMultiSelecting() ) {
- if ( getSelectedBlockClientId() !== startClientId ) {
- selectBlock( startClientId );
- }
+ selectBlock( startClientId );
} else {
multiSelect( startClientId, startClientId );
}
diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.js b/packages/block-editor/src/components/writing-flow/use-tab-nav.js
index b321d7c8d29957..3788c7021fd664 100644
--- a/packages/block-editor/src/components/writing-flow/use-tab-nav.js
+++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.js
@@ -2,7 +2,7 @@
* WordPress dependencies
*/
import { focus, isFormElement } from '@wordpress/dom';
-import { TAB, ESCAPE } from '@wordpress/keycodes';
+import { TAB } from '@wordpress/keycodes';
import { useSelect, useDispatch } from '@wordpress/data';
import { useRefEffect, useMergeRefs } from '@wordpress/compose';
import { useRef } from '@wordpress/element';
@@ -21,19 +21,9 @@ export default function useTabNav() {
const { hasMultiSelection, getSelectedBlockClientId, getBlockCount } =
useSelect( blockEditorStore );
- const { setNavigationMode, setLastFocus } = unlock(
- useDispatch( blockEditorStore )
- );
- const isNavigationMode = useSelect(
- ( select ) => select( blockEditorStore ).isNavigationMode(),
- []
- );
-
+ const { setLastFocus } = unlock( useDispatch( blockEditorStore ) );
const { getLastFocus } = unlock( useSelect( blockEditorStore ) );
- // Don't allow tabbing to this element in Navigation mode.
- const focusCaptureTabIndex = ! isNavigationMode ? '0' : undefined;
-
// Reference that holds the a flag for enabling or disabling
// capturing on the focus capture elements.
const noCaptureRef = useRef();
@@ -56,8 +46,6 @@ export default function useTabNav() {
.focus();
}
} else {
- setNavigationMode( true );
-
const canvasElement =
container.current.ownerDocument === event.target.ownerDocument
? container.current
@@ -82,7 +70,7 @@ export default function useTabNav() {
const before = (
);
@@ -90,7 +78,7 @@ export default function useTabNav() {
const after = (
);
@@ -101,12 +89,6 @@ export default function useTabNav() {
return;
}
- if ( event.keyCode === ESCAPE && ! hasMultiSelection() ) {
- event.preventDefault();
- setNavigationMode( true );
- return;
- }
-
// In Edit mode, Tab should focus the first tabbable element after
// the content, which is normally the sidebar (with block controls)
// and Shift+Tab should focus the first tabbable element before the
@@ -119,20 +101,6 @@ export default function useTabNav() {
const isShift = event.shiftKey;
const direction = isShift ? 'findPrevious' : 'findNext';
-
- if ( ! hasMultiSelection() && ! getSelectedBlockClientId() ) {
- // Preserve the behaviour of entering navigation mode when
- // tabbing into the content without a block selection.
- // `onFocusCapture` already did this previously, but we need to
- // do it again here because after clearing block selection,
- // focus land on the writing flow container and pressing Tab
- // will no longer send focus through the focus capture element.
- if ( event.target === node ) {
- setNavigationMode( true );
- }
- return;
- }
-
const nextTabbable = focus.tabbable[ direction ]( event.target );
// We want to constrain the tabbing to the block and its child blocks.
diff --git a/packages/block-editor/src/components/writing-flow/utils.js b/packages/block-editor/src/components/writing-flow/utils.js
index 0cd41eedd3f6f1..2a2010854ed205 100644
--- a/packages/block-editor/src/components/writing-flow/utils.js
+++ b/packages/block-editor/src/components/writing-flow/utils.js
@@ -116,33 +116,3 @@ function toPlainText( html ) {
// Merge any consecutive line breaks
return plainText.replace( /\n\n+/g, '\n\n' );
}
-
-/**
- * Gets the current content editable root element based on the selection.
- * @param {Document} ownerDocument
- * @return {Element|undefined} The content editable root element.
- */
-export function getSelectionRoot( ownerDocument ) {
- const { defaultView } = ownerDocument;
- const { anchorNode, focusNode } = defaultView.getSelection();
-
- if ( ! anchorNode || ! focusNode ) {
- return;
- }
-
- const anchorElement = (
- anchorNode.nodeType === anchorNode.ELEMENT_NODE
- ? anchorNode
- : anchorNode.parentElement
- ).closest( '[contenteditable]' );
-
- if ( ! anchorElement ) {
- return;
- }
-
- if ( ! anchorElement.contains( focusNode ) ) {
- return;
- }
-
- return anchorElement;
-}
diff --git a/packages/block-editor/src/content.scss b/packages/block-editor/src/content.scss
index 36d428dca6b762..1ef4e118fb1bbe 100644
--- a/packages/block-editor/src/content.scss
+++ b/packages/block-editor/src/content.scss
@@ -8,7 +8,6 @@
@import "./components/button-block-appender/content.scss";
@import "./components/default-block-appender/content.scss";
@import "./components/iframe/content.scss";
-@import "./components/inner-blocks/content.scss";
@import "./components/media-placeholder/content.scss";
@import "./components/plain-text/content.scss";
@import "./components/rich-text/content.scss";
diff --git a/packages/block-editor/src/hooks/block-bindings.js b/packages/block-editor/src/hooks/block-bindings.js
index ea0d4cbb7fb5bf..5c002613831ce1 100644
--- a/packages/block-editor/src/hooks/block-bindings.js
+++ b/packages/block-editor/src/hooks/block-bindings.js
@@ -2,7 +2,10 @@
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
-import { privateApis as blocksPrivateApis } from '@wordpress/blocks';
+import {
+ getBlockBindingsSource,
+ getBlockBindingsSources,
+} from '@wordpress/blocks';
import {
__experimentalItemGroup as ItemGroup,
__experimentalItem as Item,
@@ -12,7 +15,7 @@ import {
__experimentalVStack as VStack,
privateApis as componentsPrivateApis,
} from '@wordpress/components';
-import { useRegistry, useSelect } from '@wordpress/data';
+import { useSelect } from '@wordpress/data';
import { useContext, Fragment } from '@wordpress/element';
import { useViewportMatch } from '@wordpress/compose';
@@ -47,7 +50,6 @@ const useToolsPanelDropdownMenuProps = () => {
};
function BlockBindingsPanelDropdown( { fieldsList, attribute, binding } ) {
- const { getBlockBindingsSources } = unlock( blocksPrivateApis );
const registeredSources = getBlockBindingsSources();
const { updateBlockBindings } = useBlockBindingsUtils();
const currentKey = binding?.args?.key;
@@ -96,8 +98,7 @@ function BlockBindingsPanelDropdown( { fieldsList, attribute, binding } ) {
function BlockBindingsAttribute( { attribute, binding, fieldsList } ) {
const { source: sourceName, args } = binding || {};
- const sourceProps =
- unlock( blocksPrivateApis ).getBlockBindingsSource( sourceName );
+ const sourceProps = getBlockBindingsSource( sourceName );
const isSourceInvalid = ! sourceProps;
return (
@@ -186,7 +187,6 @@ function EditableBlockBindingsPanelItems( {
}
export const BlockBindingsPanel = ( { name: blockName, metadata } ) => {
- const registry = useRegistry();
const blockContext = useContext( BlockContext );
const { removeAllBlockBindings } = useBlockBindingsUtils();
const bindableAttributes = getBindableAttributes( blockName );
@@ -194,14 +194,13 @@ export const BlockBindingsPanel = ( { name: blockName, metadata } ) => {
// `useSelect` is used purposely here to ensure `getFieldsList`
// is updated whenever there are updates in block context.
- // `source.getFieldsList` may also call a selector via `registry.select`.
+ // `source.getFieldsList` may also call a selector via `select`.
const _fieldsList = {};
const { fieldsList, canUpdateBlockBindings } = useSelect(
( select ) => {
if ( ! bindableAttributes || bindableAttributes.length === 0 ) {
return EMPTY_OBJECT;
}
- const { getBlockBindingsSources } = unlock( blocksPrivateApis );
const registeredSources = getBlockBindingsSources();
Object.entries( registeredSources ).forEach(
( [ sourceName, { getFieldsList, usesContext } ] ) => {
@@ -214,7 +213,7 @@ export const BlockBindingsPanel = ( { name: blockName, metadata } ) => {
}
}
const sourceList = getFieldsList( {
- registry,
+ select,
context,
} );
// Only add source if the list is not empty.
@@ -234,7 +233,7 @@ export const BlockBindingsPanel = ( { name: blockName, metadata } ) => {
.canUpdateBlockBindings,
};
},
- [ blockContext, bindableAttributes, registry ]
+ [ blockContext, bindableAttributes ]
);
// Return early if there are no bindable attributes.
if ( ! bindableAttributes || bindableAttributes.length === 0 ) {
diff --git a/packages/block-editor/src/hooks/duotone.js b/packages/block-editor/src/hooks/duotone.js
index 51130ccb9ec2a4..dbd47831c42668 100644
--- a/packages/block-editor/src/hooks/duotone.js
+++ b/packages/block-editor/src/hooks/duotone.js
@@ -314,8 +314,11 @@ function useDuotoneStyles( {
}, [ isValidFilter, blockElement, colors ] );
}
+// Used for generating the instance ID
+const DUOTONE_BLOCK_PROPS_REFERENCE = {};
+
function useBlockProps( { clientId, name, style } ) {
- const id = useInstanceId( useBlockProps );
+ const id = useInstanceId( DUOTONE_BLOCK_PROPS_REFERENCE );
const selector = useMemo( () => {
const blockType = getBlockType( name );
diff --git a/packages/block-editor/src/hooks/layout-child.js b/packages/block-editor/src/hooks/layout-child.js
index 8beb50c1b82849..558d5e2cc626bf 100644
--- a/packages/block-editor/src/hooks/layout-child.js
+++ b/packages/block-editor/src/hooks/layout-child.js
@@ -17,6 +17,9 @@ import {
GridItemMovers,
} from '../components/grid';
+// Used for generating the instance ID
+const LAYOUT_CHILD_BLOCK_PROPS_REFERENCE = {};
+
function useBlockPropsChildLayoutStyles( { style } ) {
const shouldRenderChildLayoutStyles = useSelect( ( select ) => {
return ! select( blockEditorStore ).getSettings().disableLayoutStyles;
@@ -32,7 +35,7 @@ function useBlockPropsChildLayoutStyles( { style } ) {
} = layout;
const parentLayout = useLayout() || {};
const { columnCount, minimumColumnWidth } = parentLayout;
- const id = useInstanceId( useBlockPropsChildLayoutStyles );
+ const id = useInstanceId( LAYOUT_CHILD_BLOCK_PROPS_REFERENCE );
const selector = `.wp-container-content-${ id }`;
// Check that the grid layout attributes are of the correct type, so that we don't accidentally
diff --git a/packages/block-editor/src/hooks/layout.js b/packages/block-editor/src/hooks/layout.js
index 22d916d7b791bf..54a376a0c6a4f7 100644
--- a/packages/block-editor/src/hooks/layout.js
+++ b/packages/block-editor/src/hooks/layout.js
@@ -11,8 +11,8 @@ import { addFilter } from '@wordpress/hooks';
import { getBlockSupport, hasBlockSupport } from '@wordpress/blocks';
import { useSelect } from '@wordpress/data';
import {
- Button,
- ButtonGroup,
+ __experimentalToggleGroupControl as ToggleGroupControl,
+ __experimentalToggleGroupControlOption as ToggleGroupControlOption,
ToggleControl,
PanelBody,
privateApis as componentsPrivateApis,
@@ -315,21 +315,26 @@ export default {
function LayoutTypeSwitcher( { type, onChange } ) {
return (
-
+
{ getLayoutTypes().map( ( { name, label } ) => {
return (
- onChange( name ) }
- >
- { label }
-
+ value={ name }
+ label={ label }
+ />
);
} ) }
-
+
);
}
diff --git a/packages/block-editor/src/hooks/position.js b/packages/block-editor/src/hooks/position.js
index 95a9c2198e4c71..bf1b730cd67d1a 100644
--- a/packages/block-editor/src/hooks/position.js
+++ b/packages/block-editor/src/hooks/position.js
@@ -310,6 +310,9 @@ export default {
},
};
+// Used for generating the instance ID
+const POSITION_BLOCK_PROPS_REFERENCE = {};
+
function useBlockProps( { name, style } ) {
const hasPositionBlockSupport = hasBlockSupport(
name,
@@ -318,7 +321,7 @@ function useBlockProps( { name, style } ) {
const isPositionDisabled = useIsPositionDisabled( { name } );
const allowPositionStyles = hasPositionBlockSupport && ! isPositionDisabled;
- const id = useInstanceId( useBlockProps );
+ const id = useInstanceId( POSITION_BLOCK_PROPS_REFERENCE );
// Higher specificity to override defaults in editor UI.
const positionSelector = `.wp-container-${ id }.wp-container-${ id }`;
diff --git a/packages/block-editor/src/hooks/use-bindings-attributes.js b/packages/block-editor/src/hooks/use-bindings-attributes.js
index e1ebf5fda6b8ee..fdc617fda20c05 100644
--- a/packages/block-editor/src/hooks/use-bindings-attributes.js
+++ b/packages/block-editor/src/hooks/use-bindings-attributes.js
@@ -103,11 +103,7 @@ export const withBlockBindingSupport = createHigherOrderComponent(
const sources = useSelect( ( select ) =>
unlock( select( blocksStore ) ).getAllBlockBindingsSources()
);
- const { name, clientId } = props;
- const hasParentPattern = !! props.context[ 'pattern/overrides' ];
- const hasPatternOverridesDefaultBinding =
- props.attributes.metadata?.bindings?.[ DEFAULT_ATTRIBUTE ]
- ?.source === 'core/pattern-overrides';
+ const { name, clientId, context, setAttributes } = props;
const blockBindings = useMemo(
() =>
replacePatternOverrideDefaultBindings(
@@ -121,79 +117,87 @@ export const withBlockBindingSupport = createHigherOrderComponent(
// used purposely here to ensure `boundAttributes` is updated whenever
// there are attribute updates.
// `source.getValues` may also call a selector via `registry.select`.
- const boundAttributes = useSelect( () => {
- if ( ! blockBindings ) {
- return;
- }
-
- const attributes = {};
-
- const blockBindingsBySource = new Map();
-
- for ( const [ attributeName, binding ] of Object.entries(
- blockBindings
- ) ) {
- const { source: sourceName, args: sourceArgs } = binding;
- const source = sources[ sourceName ];
- if ( ! source || ! canBindAttribute( name, attributeName ) ) {
- continue;
+ const updatedContext = {};
+ const boundAttributes = useSelect(
+ ( select ) => {
+ if ( ! blockBindings ) {
+ return;
}
- blockBindingsBySource.set( source, {
- ...blockBindingsBySource.get( source ),
- [ attributeName ]: {
- args: sourceArgs,
- },
- } );
- }
+ const attributes = {};
- if ( blockBindingsBySource.size ) {
- for ( const [ source, bindings ] of blockBindingsBySource ) {
- // Populate context.
- const context = {};
+ const blockBindingsBySource = new Map();
- if ( source.usesContext?.length ) {
- for ( const key of source.usesContext ) {
- context[ key ] = blockContext[ key ];
- }
+ for ( const [ attributeName, binding ] of Object.entries(
+ blockBindings
+ ) ) {
+ const { source: sourceName, args: sourceArgs } = binding;
+ const source = sources[ sourceName ];
+ if (
+ ! source ||
+ ! canBindAttribute( name, attributeName )
+ ) {
+ continue;
}
- // Get values in batch if the source supports it.
- let values = {};
- if ( ! source.getValues ) {
- Object.keys( bindings ).forEach( ( attr ) => {
- // Default to the `key` or the source label when `getValues` doesn't exist
- values[ attr ] =
- bindings[ attr ].args?.key || source.label;
- } );
- } else {
- values = source.getValues( {
- registry,
- context,
- clientId,
- bindings,
- } );
+ // Populate context.
+ for ( const key of source.usesContext || [] ) {
+ updatedContext[ key ] = blockContext[ key ];
}
- for ( const [ attributeName, value ] of Object.entries(
- values
- ) ) {
- if (
- attributeName === 'url' &&
- ( ! value || ! isURLLike( value ) )
- ) {
- // Return null if value is not a valid URL.
- attributes[ attributeName ] = null;
+
+ blockBindingsBySource.set( source, {
+ ...blockBindingsBySource.get( source ),
+ [ attributeName ]: {
+ args: sourceArgs,
+ },
+ } );
+ }
+
+ if ( blockBindingsBySource.size ) {
+ for ( const [
+ source,
+ bindings,
+ ] of blockBindingsBySource ) {
+ // Get values in batch if the source supports it.
+ let values = {};
+ if ( ! source.getValues ) {
+ Object.keys( bindings ).forEach( ( attr ) => {
+ // Default to the the source label when `getValues` doesn't exist.
+ values[ attr ] = source.label;
+ } );
} else {
- attributes[ attributeName ] = value;
+ values = source.getValues( {
+ select,
+ context: updatedContext,
+ clientId,
+ bindings,
+ } );
+ }
+ for ( const [ attributeName, value ] of Object.entries(
+ values
+ ) ) {
+ if (
+ attributeName === 'url' &&
+ ( ! value || ! isURLLike( value ) )
+ ) {
+ // Return null if value is not a valid URL.
+ attributes[ attributeName ] = null;
+ } else {
+ attributes[ attributeName ] = value;
+ }
}
}
}
- }
- return attributes;
- }, [ blockBindings, name, clientId, blockContext, registry, sources ] );
+ return attributes;
+ },
+ [ blockBindings, name, clientId, updatedContext, sources ]
+ );
- const { setAttributes } = props;
+ const hasParentPattern = !! updatedContext[ 'pattern/overrides' ];
+ const hasPatternOverridesDefaultBinding =
+ props.attributes.metadata?.bindings?.[ DEFAULT_ATTRIBUTE ]
+ ?.source === 'core/pattern-overrides';
const _setAttributes = useCallback(
( nextAttributes ) => {
@@ -237,18 +241,10 @@ export const withBlockBindingSupport = createHigherOrderComponent(
source,
bindings,
] of blockBindingsBySource ) {
- // Populate context.
- const context = {};
-
- if ( source.usesContext?.length ) {
- for ( const key of source.usesContext ) {
- context[ key ] = blockContext[ key ];
- }
- }
-
source.setValues( {
- registry,
- context,
+ select: registry.select,
+ dispatch: registry.dispatch,
+ context: updatedContext,
clientId,
bindings,
} );
@@ -278,7 +274,7 @@ export const withBlockBindingSupport = createHigherOrderComponent(
blockBindings,
name,
clientId,
- blockContext,
+ updatedContext,
setAttributes,
sources,
hasPatternOverridesDefaultBinding,
@@ -292,6 +288,7 @@ export const withBlockBindingSupport = createHigherOrderComponent(
{ ...props }
attributes={ { ...props.attributes, ...boundAttributes } }
setAttributes={ _setAttributes }
+ context={ { ...context, ...updatedContext } }
/>
>
);
diff --git a/packages/block-editor/src/hooks/use-zoom-out.js b/packages/block-editor/src/hooks/use-zoom-out.js
index d7e21ec1be0578..2a1b43060c00a9 100644
--- a/packages/block-editor/src/hooks/use-zoom-out.js
+++ b/packages/block-editor/src/hooks/use-zoom-out.js
@@ -8,46 +8,40 @@ import { useEffect, useRef } from '@wordpress/element';
* Internal dependencies
*/
import { store as blockEditorStore } from '../store';
+import { unlock } from '../lock-unlock';
/**
- * A hook used to set the editor mode to zoomed out mode, invoking the hook sets the mode.
+ * A hook used to set the zoomed out view, invoking the hook sets the mode.
*
- * @param {boolean} zoomOut If we should enter into zoomOut mode or not
+ * @param {boolean} zoomOut If we should zoom out or not.
*/
export function useZoomOut( zoomOut = true ) {
- const { __unstableSetEditorMode } = useDispatch( blockEditorStore );
- const { __unstableGetEditorMode } = useSelect( blockEditorStore );
+ const { setZoomLevel } = unlock( useDispatch( blockEditorStore ) );
+ const { isZoomOut } = unlock( useSelect( blockEditorStore ) );
- const originalEditingModeRef = useRef( null );
- const mode = __unstableGetEditorMode();
+ const originalIsZoomOutRef = useRef( null );
useEffect( () => {
// Only set this on mount so we know what to return to when we unmount.
- if ( ! originalEditingModeRef.current ) {
- originalEditingModeRef.current = mode;
+ if ( ! originalIsZoomOutRef.current ) {
+ originalIsZoomOutRef.current = isZoomOut();
}
- return () => {
- // We need to use __unstableGetEditorMode() here and not `mode`, as mode may not update on unmount
- if (
- __unstableGetEditorMode() === 'zoom-out' &&
- __unstableGetEditorMode() !== originalEditingModeRef.current
- ) {
- __unstableSetEditorMode( originalEditingModeRef.current );
- }
- };
- }, [] );
-
- // The effect opens the zoom-out view if we want it open and it's not currently in zoom-out mode.
- useEffect( () => {
- if ( zoomOut && mode !== 'zoom-out' ) {
- __unstableSetEditorMode( 'zoom-out' );
+ // The effect opens the zoom-out view if we want it open and the canvas is not currently zoomed-out.
+ if ( zoomOut && isZoomOut() === false ) {
+ setZoomLevel( 50 );
} else if (
! zoomOut &&
- __unstableGetEditorMode() === 'zoom-out' &&
- originalEditingModeRef.current !== mode
+ isZoomOut() &&
+ originalIsZoomOutRef.current !== isZoomOut()
) {
- __unstableSetEditorMode( originalEditingModeRef.current );
+ setZoomLevel( originalIsZoomOutRef.current ? 50 : 100 );
}
- }, [ __unstableGetEditorMode, __unstableSetEditorMode, zoomOut ] ); // Mode is deliberately excluded from the dependencies so that the effect does not run when mode changes.
+
+ return () => {
+ if ( isZoomOut() && isZoomOut() !== originalIsZoomOutRef.current ) {
+ setZoomLevel( originalIsZoomOutRef.current ? 50 : 100 );
+ }
+ };
+ }, [ isZoomOut, setZoomLevel, zoomOut ] );
}
diff --git a/packages/block-editor/src/layouts/flex.js b/packages/block-editor/src/layouts/flex.js
index dc7e9d1a167a19..81718449695651 100644
--- a/packages/block-editor/src/layouts/flex.js
+++ b/packages/block-editor/src/layouts/flex.js
@@ -12,7 +12,6 @@ import {
arrowDown,
} from '@wordpress/icons';
import {
- Button,
ToggleControl,
Flex,
FlexItem,
@@ -110,7 +109,6 @@ export default {
) }
@@ -190,11 +188,7 @@ export default {
},
};
-function FlexLayoutVerticalAlignmentControl( {
- layout,
- onChange,
- isToolbar = false,
-} ) {
+function FlexLayoutVerticalAlignmentControl( { layout, onChange } ) {
const { orientation = 'horizontal' } = layout;
const defaultVerticalAlignment =
@@ -210,54 +204,17 @@ function FlexLayoutVerticalAlignmentControl( {
verticalAlignment: value,
} );
};
- if ( isToolbar ) {
- return (
-
- );
- }
-
- const verticalAlignmentOptions = [
- {
- value: 'flex-start',
- label: __( 'Align items top' ),
- },
- {
- value: 'center',
- label: __( 'Align items center' ),
- },
- {
- value: 'flex-end',
- label: __( 'Align items bottom' ),
- },
- ];
return (
-
- { __( 'Vertical alignment' ) }
-
- { verticalAlignmentOptions.map( ( value, icon, label ) => {
- return (
- onVerticalAlignmentChange( value ) }
- />
- );
- } ) }
-
-
+
);
}
diff --git a/packages/block-editor/src/private-apis.js b/packages/block-editor/src/private-apis.js
index 12f477a95a196b..7205bef5798ec1 100644
--- a/packages/block-editor/src/private-apis.js
+++ b/packages/block-editor/src/private-apis.js
@@ -47,7 +47,6 @@ import { PrivatePublishDateTimePicker } from './components/publish-date-time-pic
import useSpacingSizes from './components/spacing-sizes-control/hooks/use-spacing-sizes';
import useBlockDisplayTitle from './components/block-title/use-block-display-title';
import TabbedSidebar from './components/tabbed-sidebar';
-import { useBlockBindingsUtils } from './utils/block-bindings';
/**
* Private @wordpress/block-editor APIs.
@@ -92,6 +91,5 @@ lock( privateApis, {
useBlockDisplayTitle,
__unstableBlockStyleVariationOverridesWithConfig,
setBackgroundStyleDefaults,
- useBlockBindingsUtils,
sectionRootClientIdKey,
} );
diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js
index e91f997ca67837..ee11838395ec5c 100644
--- a/packages/block-editor/src/store/actions.js
+++ b/packages/block-editor/src/store/actions.js
@@ -1728,23 +1728,24 @@ export const __unstableSetEditorMode =
};
/**
- * Action that enables or disables the block moving mode.
+ * Set the block moving client ID.
*
- * @param {string|null} hasBlockMovingClientId Enable/Disable block moving mode.
+ * @deprecated
+ *
+ * @return {Object} Action object.
*/
-export const setBlockMovingClientId =
- ( hasBlockMovingClientId = null ) =>
- ( { dispatch } ) => {
- dispatch( { type: 'SET_BLOCK_MOVING_MODE', hasBlockMovingClientId } );
-
- if ( hasBlockMovingClientId ) {
- speak(
- __(
- 'Use the Tab key and Arrow keys to choose new block location. Use Left and Right Arrow keys to move between nesting levels. Once location is selected press Enter or Space to move the block.'
- )
- );
+export function setBlockMovingClientId() {
+ deprecated(
+ 'wp.data.dispatch( "core/block-editor" ).setBlockMovingClientId',
+ {
+ since: '6.7',
+ hint: 'Block moving mode feature has been removed',
}
+ );
+ return {
+ type: 'DO_NOTHING',
};
+}
/**
* Action that duplicates a list of blocks.
diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js
index dc57d61fd6b76c..5571db0ce91066 100644
--- a/packages/block-editor/src/store/private-actions.js
+++ b/packages/block-editor/src/store/private-actions.js
@@ -359,6 +359,20 @@ export function expandBlock( clientId ) {
};
}
+/**
+ * @param {Object} value
+ * @param {string} value.rootClientId The root client ID to insert at.
+ * @param {number} value.index The index to insert at.
+ *
+ * @return {Object} Action object.
+ */
+export function setInsertionPoint( value ) {
+ return {
+ type: 'SET_INSERTION_POINT',
+ value,
+ };
+}
+
/**
* Temporarily modify/unlock the content-only block for editions.
*
@@ -383,3 +397,26 @@ export const modifyContentLockBlock =
focusModeToRevert
);
};
+
+/**
+ * Sets the zoom level.
+ *
+ * @param {number} zoom the new zoom level
+ * @return {Object} Action object.
+ */
+export function setZoomLevel( zoom = 100 ) {
+ return {
+ type: 'SET_ZOOM_LEVEL',
+ zoom,
+ };
+}
+
+/**
+ * Resets the Zoom state.
+ * @return {Object} Action object.
+ */
+export function resetZoomLevel() {
+ return {
+ type: 'RESET_ZOOM_LEVEL',
+ };
+}
diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js
index 01ad8f69febc9e..c3228980310656 100644
--- a/packages/block-editor/src/store/private-selectors.js
+++ b/packages/block-editor/src/store/private-selectors.js
@@ -15,6 +15,8 @@ import {
getBlockName,
getTemplateLock,
getClientIdsWithDescendants,
+ isNavigationMode,
+ getBlockRootClientId,
} from './selectors';
import {
checkAllowListRecursive,
@@ -115,6 +117,7 @@ export const getEnabledClientIdsTree = createSelector(
state.settings.templateLock,
state.blockListSettings,
state.editorMode,
+ getSectionRootClientId( state ),
]
);
@@ -471,26 +474,57 @@ export function getExpandedBlock( state ) {
* with the provided client ID.
*
* @param {Object} state Global application state.
- * @param {Object} clientId Client Id of the block.
+ * @param {string} clientId Client Id of the block.
*
* @return {?string} Client ID of the ancestor block that is content locking the block.
*/
-export const getContentLockingParent = createSelector(
- ( state, clientId ) => {
- let current = clientId;
- let result;
- while ( ( current = state.blocks.parents.get( current ) ) ) {
- if (
- getBlockName( state, current ) === 'core/block' ||
- getTemplateLock( state, current ) === 'contentOnly'
- ) {
- result = current;
- }
+export const getContentLockingParent = ( state, clientId ) => {
+ let current = clientId;
+ let result;
+ while ( ! result && ( current = state.blocks.parents.get( current ) ) ) {
+ if ( getTemplateLock( state, current ) === 'contentOnly' ) {
+ result = current;
}
- return result;
- },
- ( state ) => [ state.blocks.parents, state.blockListSettings ]
-);
+ }
+ return result;
+};
+
+/**
+ * Retrieves the client ID of the parent section block.
+ *
+ * @param {Object} state Global application state.
+ * @param {string} clientId Client Id of the block.
+ *
+ * @return {?string} Client ID of the ancestor block that is content locking the block.
+ */
+export const getParentSectionBlock = ( state, clientId ) => {
+ let current = clientId;
+ let result;
+ while ( ! result && ( current = state.blocks.parents.get( current ) ) ) {
+ if ( isSectionBlock( state, current ) ) {
+ result = current;
+ }
+ }
+ return result;
+};
+
+/**
+ * Retrieves the client ID is a content locking parent
+ *
+ * @param {Object} state Global application state.
+ * @param {string} clientId Client Id of the block.
+ *
+ * @return {boolean} Whether the block is a content locking parent.
+ */
+export function isSectionBlock( state, clientId ) {
+ const sectionRootClientId = getSectionRootClientId( state );
+ const sectionClientIds = getBlockOrder( state, sectionRootClientId );
+ return (
+ getBlockName( state, clientId ) === 'core/block' ||
+ getTemplateLock( state, clientId ) === 'contentOnly' ||
+ ( isNavigationMode( state ) && sectionClientIds.includes( clientId ) )
+ );
+}
/**
* Retrieves the client ID of the block that is content locked but is
@@ -560,3 +594,93 @@ export function isZoomOutMode( state ) {
export function getSectionRootClientId( state ) {
return state.settings?.[ sectionRootClientIdKey ];
}
+
+/**
+ * Returns the zoom out state.
+ *
+ * @param {Object} state Global application state.
+ * @return {boolean} The zoom out state.
+ */
+export function getZoomLevel( state ) {
+ return state.zoomLevel;
+}
+
+/**
+ * Returns whether the editor is considered zoomed out.
+ *
+ * @param {Object} state Global application state.
+ * @return {boolean} Whether the editor is zoomed.
+ */
+export function isZoomOut( state ) {
+ return getZoomLevel( state ) < 100;
+}
+
+/**
+ * Finds the closest block where the block is allowed to be inserted.
+ *
+ * @param {Object} state Editor state.
+ * @param {string[] | string} name Block name or names.
+ * @param {string} clientId Default insertion point.
+ *
+ * @return {string} clientID of the closest container when the block name can be inserted.
+ */
+export function getClosestAllowedInsertionPoint( state, name, clientId = '' ) {
+ const blockNames = Array.isArray( name ) ? name : [ name ];
+ const areBlockNamesAllowedInClientId = ( id ) =>
+ blockNames.every( ( currentName ) =>
+ canInsertBlockType( state, currentName, id )
+ );
+
+ // If we're trying to insert at the root level and it's not allowed
+ // Try the section root instead.
+ if ( ! clientId ) {
+ if ( areBlockNamesAllowedInClientId( clientId ) ) {
+ return clientId;
+ }
+
+ const sectionRootClientId = getSectionRootClientId( state );
+ if (
+ sectionRootClientId &&
+ areBlockNamesAllowedInClientId( sectionRootClientId )
+ ) {
+ return sectionRootClientId;
+ }
+ return null;
+ }
+
+ // Traverse the block tree up until we find a place where we can insert.
+ let current = clientId;
+ while ( current !== null && ! areBlockNamesAllowedInClientId( current ) ) {
+ const parentClientId = getBlockRootClientId( state, current );
+ current = parentClientId;
+ }
+
+ return current;
+}
+
+export function getClosestAllowedInsertionPointForPattern(
+ state,
+ pattern,
+ clientId
+) {
+ const { allowedBlockTypes } = getSettings( state );
+ const isAllowed = checkAllowListRecursive(
+ getGrammar( pattern ),
+ allowedBlockTypes
+ );
+ if ( ! isAllowed ) {
+ return null;
+ }
+ const names = getGrammar( pattern ).map( ( { blockName: name } ) => name );
+ return getClosestAllowedInsertionPoint( state, names, clientId );
+}
+
+/**
+ * Where the point where the next block will be inserted into.
+ *
+ * @param {Object} state
+ * @return {Object} where the insertion point in the block editor is or null if none is set.
+ */
+export function getInsertionPoint( state ) {
+ return state.insertionPoint;
+}
diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js
index cd4569c45e5801..f6445f8a3681c9 100644
--- a/packages/block-editor/src/store/reducer.js
+++ b/packages/block-editor/src/store/reducer.js
@@ -1601,7 +1601,7 @@ export function blocksMode( state = {}, action ) {
*
* @return {Object} Updated state.
*/
-export function insertionPoint( state = null, action ) {
+export function insertionCue( state = null, action ) {
switch ( action.type ) {
case 'SHOW_INSERTION_POINT': {
const {
@@ -1795,11 +1795,6 @@ export const blockListSettings = ( state = {}, action ) => {
* @return {string} Updated state.
*/
export function editorMode( state = 'edit', action ) {
- // Let inserting block in navigation mode always trigger Edit mode.
- if ( action.type === 'INSERT_BLOCKS' && state === 'navigation' ) {
- return 'edit';
- }
-
if ( action.type === 'SET_EDITOR_MODE' ) {
return action.mode;
}
@@ -1807,26 +1802,6 @@ export function editorMode( state = 'edit', action ) {
return state;
}
-/**
- * Reducer returning whether the block moving mode is enabled or not.
- *
- * @param {string|null} state Current state.
- * @param {Object} action Dispatched action.
- *
- * @return {string|null} Updated state.
- */
-export function hasBlockMovingClientId( state = null, action ) {
- if ( action.type === 'SET_BLOCK_MOVING_MODE' ) {
- return action.hasBlockMovingClientId;
- }
-
- if ( action.type === 'SET_EDITOR_MODE' ) {
- return null;
- }
-
- return state;
-}
-
/**
* Reducer return an updated state representing the most recent block attribute
* update. The state is structured as an object where the keys represent the
@@ -2085,6 +2060,44 @@ export function hoveredBlockClientId( state = false, action ) {
return state;
}
+/**
+ * Reducer setting zoom out state.
+ *
+ * @param {boolean} state Current state.
+ * @param {Object} action Dispatched action.
+ *
+ * @return {number} Updated state.
+ */
+export function zoomLevel( state = 100, action ) {
+ switch ( action.type ) {
+ case 'SET_ZOOM_LEVEL':
+ return action.zoom;
+ case 'RESET_ZOOM_LEVEL':
+ return 100;
+ }
+
+ return state;
+}
+
+/**
+ * Reducer setting the insertion point
+ *
+ * @param {boolean} state Current state.
+ * @param {Object} action Dispatched action.
+ *
+ * @return {Object} Updated state.
+ */
+export function insertionPoint( state = null, action ) {
+ switch ( action.type ) {
+ case 'SET_INSERTION_POINT':
+ return action.value;
+ case 'SELECT_BLOCK':
+ return null;
+ }
+
+ return state;
+}
+
const combinedReducers = combineReducers( {
blocks,
isDragging,
@@ -2098,13 +2111,13 @@ const combinedReducers = combineReducers( {
blocksMode,
blockListSettings,
insertionPoint,
+ insertionCue,
template,
settings,
preferences,
lastBlockAttributesChange,
lastFocus,
editorMode,
- hasBlockMovingClientId,
expandedBlock,
highlightedBlock,
lastBlockInserted,
@@ -2118,6 +2131,7 @@ const combinedReducers = combineReducers( {
openedBlockSettingsMenu,
registeredInserterMediaCategories,
hoveredBlockClientId,
+ zoomLevel,
} );
function withAutomaticChangeReset( reducer ) {
diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js
index 30fdb76bdbe787..6cf6aae296141f 100644
--- a/packages/block-editor/src/store/selectors.js
+++ b/packages/block-editor/src/store/selectors.js
@@ -21,7 +21,7 @@ import { createSelector, createRegistrySelector } from '@wordpress/data';
* Internal dependencies
*/
import {
- withRootClientIdOptionKey,
+ isFiltered,
checkAllowListRecursive,
checkAllowList,
getAllPatternsDependants,
@@ -38,6 +38,8 @@ import {
getTemporarilyEditingAsBlocks,
getTemporarilyEditingFocusModeToRevert,
getSectionRootClientId,
+ isSectionBlock,
+ getParentSectionBlock,
} from './private-selectors';
/**
@@ -78,7 +80,9 @@ const EMPTY_ARRAY = [];
*/
const EMPTY_SET = new Set();
-const EMPTY_OBJECT = {};
+const DEFAULT_INSERTER_OPTIONS = {
+ [ isFiltered ]: true,
+};
/**
* Returns a block's name given its client ID, or null if no block exists with
@@ -1450,8 +1454,7 @@ export function isCaretWithinFormattedText() {
}
/**
- * Returns the insertion point, the index at which the new inserted block would
- * be placed. Defaults to the last index.
+ * Returns the location of the insertion cue. Defaults to the last index.
*
* @param {Object} state Editor state.
*
@@ -1462,11 +1465,11 @@ export const getBlockInsertionPoint = createSelector(
let rootClientId, index;
const {
- insertionPoint,
+ insertionCue,
selection: { selectionEnd },
} = state;
- if ( insertionPoint !== null ) {
- return insertionPoint;
+ if ( insertionCue !== null ) {
+ return insertionCue;
}
const { clientId } = selectionEnd;
@@ -1481,7 +1484,7 @@ export const getBlockInsertionPoint = createSelector(
return { rootClientId, index };
},
( state ) => [
- state.insertionPoint,
+ state.insertionCue,
state.selection.selectionEnd.clientId,
state.blocks.parents,
state.blocks.order,
@@ -1489,14 +1492,14 @@ export const getBlockInsertionPoint = createSelector(
);
/**
- * Returns true if we should show the block insertion point.
+ * Returns true if the block insertion point is visible.
*
* @param {Object} state Global application state.
*
* @return {?boolean} Whether the insertion point is visible or not.
*/
export function isBlockInsertionPointVisible( state ) {
- return state.insertionPoint !== null;
+ return state.insertionCue !== null;
}
/**
@@ -1537,6 +1540,59 @@ export function getTemplateLock( state, rootClientId ) {
return getBlockListSettings( state, rootClientId )?.templateLock ?? false;
}
+/**
+ * Determines if the given block type is visible in the inserter.
+ * Note that this is different than whether a block is allowed to be inserted.
+ * In some cases, the block is not allowed in a given position but
+ * it should still be visible in the inserter to be able to add it
+ * to a different position.
+ *
+ * @param {Object} state Editor state.
+ * @param {string|Object} blockNameOrType The block type object, e.g., the response
+ * from the block directory; or a string name of
+ * an installed block type, e.g.' core/paragraph'.
+ *
+ * @return {boolean} Whether the given block type is allowed to be inserted.
+ */
+const isBlockVisibleInTheInserter = ( state, blockNameOrType ) => {
+ let blockType;
+ let blockName;
+ if ( blockNameOrType && 'object' === typeof blockNameOrType ) {
+ blockType = blockNameOrType;
+ blockName = blockNameOrType.name;
+ } else {
+ blockType = getBlockType( blockNameOrType );
+ blockName = blockNameOrType;
+ }
+ if ( ! blockType ) {
+ return false;
+ }
+
+ const { allowedBlockTypes } = getSettings( state );
+
+ const isBlockAllowedInEditor = checkAllowList(
+ allowedBlockTypes,
+ blockName,
+ true
+ );
+ if ( ! isBlockAllowedInEditor ) {
+ return false;
+ }
+
+ // If parent blocks are not visible, child blocks should be hidden too.
+ if ( !! blockType.parent?.length ) {
+ return blockType.parent.some(
+ ( name ) =>
+ isBlockVisibleInTheInserter( state, name ) ||
+ // Exception for blocks with post-content parent,
+ // the root level is often consider as "core/post-content".
+ // This exception should only apply to the post editor ideally though.
+ name === 'core/post-content'
+ );
+ }
+ return true;
+};
+
/**
* Determines if the given block type is allowed to be inserted into the block list.
* This function is not exported and not memoized because using a memoized selector
@@ -1555,6 +1611,10 @@ const canInsertBlockTypeUnmemoized = (
blockName,
rootClientId = null
) => {
+ if ( ! isBlockVisibleInTheInserter( state, blockName ) ) {
+ return false;
+ }
+
let blockType;
if ( blockName && 'object' === typeof blockName ) {
blockType = blockName;
@@ -1562,23 +1622,14 @@ const canInsertBlockTypeUnmemoized = (
} else {
blockType = getBlockType( blockName );
}
- if ( ! blockType ) {
- return false;
- }
-
- const { allowedBlockTypes } = getSettings( state );
- const isBlockAllowedInEditor = checkAllowList(
- allowedBlockTypes,
- blockName,
- true
- );
- if ( ! isBlockAllowedInEditor ) {
+ const isLocked = !! getTemplateLock( state, rootClientId );
+ if ( isLocked ) {
return false;
}
- const isLocked = !! getTemplateLock( state, rootClientId );
- if ( isLocked ) {
+ const _isSectionBlock = !! isSectionBlock( state, rootClientId );
+ if ( _isSectionBlock ) {
return false;
}
@@ -1733,6 +1784,11 @@ export function canRemoveBlock( state, clientId ) {
return false;
}
+ const isBlockWithinSection = !! getParentSectionBlock( state, clientId );
+ if ( isBlockWithinSection ) {
+ return false;
+ }
+
return getBlockEditingMode( state, rootClientId ) !== 'disabled';
}
@@ -1959,6 +2015,7 @@ const buildBlockTypeItem =
description: blockType.description,
category: blockType.category,
keywords: blockType.keywords,
+ parent: blockType.parent,
variations: inserterVariations,
example: blockType.example,
utility: 1, // Deprecated.
@@ -1996,7 +2053,7 @@ const buildBlockTypeItem =
*/
export const getInserterItems = createRegistrySelector( ( select ) =>
createSelector(
- ( state, rootClientId = null, options = EMPTY_OBJECT ) => {
+ ( state, rootClientId = null, options = DEFAULT_INSERTER_OPTIONS ) => {
const buildReusableBlockInserterItem = ( reusableBlock ) => {
const icon = ! reusableBlock.wp_pattern_sync_status
? {
@@ -2044,56 +2101,7 @@ export const getInserterItems = createRegistrySelector( ( select ) =>
)
.map( buildBlockTypeInserterItem );
- if ( options[ withRootClientIdOptionKey ] ) {
- blockTypeInserterItems = blockTypeInserterItems.reduce(
- ( accumulator, item ) => {
- item.rootClientId = rootClientId ?? '';
-
- while (
- ! canInsertBlockTypeUnmemoized(
- state,
- item.name,
- item.rootClientId
- )
- ) {
- if ( ! item.rootClientId ) {
- let sectionRootClientId;
- try {
- sectionRootClientId =
- getSectionRootClientId( state );
- } catch ( e ) {}
- if (
- sectionRootClientId &&
- canInsertBlockTypeUnmemoized(
- state,
- item.name,
- sectionRootClientId
- )
- ) {
- item.rootClientId = sectionRootClientId;
- } else {
- delete item.rootClientId;
- }
- break;
- } else {
- const parentClientId = getBlockRootClientId(
- state,
- item.rootClientId
- );
- item.rootClientId = parentClientId;
- }
- }
-
- // We could also add non insertable items and gray them out.
- if ( item.hasOwnProperty( 'rootClientId' ) ) {
- accumulator.push( item );
- }
-
- return accumulator;
- },
- []
- );
- } else {
+ if ( options[ isFiltered ] !== false ) {
blockTypeInserterItems = blockTypeInserterItems.filter(
( blockType ) =>
canIncludeBlockTypeInInserter(
@@ -2102,6 +2110,19 @@ export const getInserterItems = createRegistrySelector( ( select ) =>
rootClientId
)
);
+ } else {
+ blockTypeInserterItems = blockTypeInserterItems
+ .filter( ( blockType ) =>
+ isBlockVisibleInTheInserter( state, blockType )
+ )
+ .map( ( blockType ) => ( {
+ ...blockType,
+ isAllowedInCurrentRoot: canIncludeBlockTypeInInserter(
+ state,
+ blockType,
+ rootClientId
+ ),
+ } ) );
}
const items = blockTypeInserterItems.reduce(
@@ -2373,37 +2394,50 @@ const getAllowedPatternsDependants = ( select ) => ( state, rootClientId ) => [
*/
export const __experimentalGetAllowedPatterns = createRegistrySelector(
( select ) => {
- return createSelector( ( state, rootClientId = null ) => {
- const { getAllPatterns } = unlock( select( STORE_NAME ) );
- const patterns = getAllPatterns();
- const { allowedBlockTypes } = getSettings( state );
- const parsedPatterns = patterns
- .filter( ( { inserter = true } ) => !! inserter )
- .map( ( pattern ) => {
- return {
- ...pattern,
- get blocks() {
- return getParsedPattern( pattern ).blocks;
- },
- };
- } );
-
- const availableParsedPatterns = parsedPatterns.filter(
- ( pattern ) =>
- checkAllowListRecursive(
- getGrammar( pattern ),
- allowedBlockTypes
- )
- );
- const patternsAllowed = availableParsedPatterns.filter(
- ( pattern ) =>
- getGrammar( pattern ).every( ( { blockName: name } ) =>
- canInsertBlockType( state, name, rootClientId )
- )
- );
+ return createSelector(
+ (
+ state,
+ rootClientId = null,
+ options = DEFAULT_INSERTER_OPTIONS
+ ) => {
+ const { getAllPatterns } = unlock( select( STORE_NAME ) );
+ const patterns = getAllPatterns();
+ const { allowedBlockTypes } = getSettings( state );
+ const parsedPatterns = patterns
+ .filter( ( { inserter = true } ) => !! inserter )
+ .map( ( pattern ) => {
+ return {
+ ...pattern,
+ get blocks() {
+ return getParsedPattern( pattern ).blocks;
+ },
+ };
+ } );
+
+ const availableParsedPatterns = parsedPatterns.filter(
+ ( pattern ) =>
+ checkAllowListRecursive(
+ getGrammar( pattern ),
+ allowedBlockTypes
+ )
+ );
+ const patternsAllowed = availableParsedPatterns.filter(
+ ( pattern ) =>
+ getGrammar( pattern ).every( ( { blockName: name } ) =>
+ options[ isFiltered ] !== false
+ ? canInsertBlockType(
+ state,
+ name,
+ rootClientId
+ )
+ : isBlockVisibleInTheInserter( state, name )
+ )
+ );
- return patternsAllowed;
- }, getAllowedPatternsDependants( select ) );
+ return patternsAllowed;
+ },
+ getAllowedPatternsDependants( select )
+ );
}
);
@@ -2467,7 +2501,7 @@ export const __experimentalGetPatternsByBlockTypes = createRegistrySelector(
* Determines the items that appear in the available pattern transforms list.
*
* For now we only handle blocks without InnerBlocks and take into account
- * the `__experimentalRole` property of blocks' attributes for the transformation.
+ * the `role` property of blocks' attributes for the transformation.
*
* We return the first set of possible eligible block patterns,
* by checking the `blockTypes` property. We still have to recurse through
@@ -2489,7 +2523,7 @@ export const __experimentalGetPatternTransformItems = createRegistrySelector(
}
/**
* For now we only handle blocks without InnerBlocks and take into account
- * the `__experimentalRole` property of blocks' attributes for the transformation.
+ * the `role` property of blocks' attributes for the transformation.
* Note that the blocks have been retrieved through `getBlock`, which doesn't
* return the inner blocks of an inner block controller, so we still need
* to check for this case too.
@@ -2674,12 +2708,17 @@ export function __unstableGetEditorMode( state ) {
/**
* Returns whether block moving mode is enabled.
*
- * @param {Object} state Editor state.
- *
- * @return {string} Client Id of moving block.
+ * @deprecated
*/
-export function hasBlockMovingClientId( state ) {
- return state.hasBlockMovingClientId;
+export function hasBlockMovingClientId() {
+ deprecated(
+ 'wp.data.select( "core/block-editor" ).hasBlockMovingClientId',
+ {
+ since: '6.7',
+ hint: 'Block moving mode feature has been removed',
+ }
+ );
+ return false;
}
/**
@@ -2862,11 +2901,9 @@ export function __unstableHasActiveBlockOverlayActive( state, clientId ) {
'__experimentalDisableBlockOverlay',
false
);
- const shouldEnableIfUnselected =
- editorMode === 'navigation' ||
- ( blockSupportDisable
- ? false
- : areInnerBlocksControlled( state, clientId ) );
+ const shouldEnableIfUnselected = blockSupportDisable
+ ? false
+ : areInnerBlocksControlled( state, clientId );
return (
shouldEnableIfUnselected &&
@@ -2886,6 +2923,14 @@ export function __unstableIsWithinBlockOverlay( state, clientId ) {
return false;
}
+function isWithinBlock( state, clientId, parentClientId ) {
+ let parent = state.blocks.parents.get( clientId );
+ while ( !! parent && parent !== parentClientId ) {
+ parent = state.blocks.parents.get( parent );
+ }
+ return parent === parentClientId;
+}
+
/**
* @typedef {import('../components/block-editing-mode').BlockEditingMode} BlockEditingMode
*/
@@ -2926,6 +2971,7 @@ export const getBlockEditingMode = createRegistrySelector(
if ( clientId === null ) {
clientId = '';
}
+
// In zoom-out mode, override the behavior set by
// __unstableSetBlockEditingMode to only allow editing the top-level
// sections.
@@ -2943,28 +2989,76 @@ export const getBlockEditingMode = createRegistrySelector(
state,
sectionRootClientId
);
- if ( ! sectionsClientIds?.includes( clientId ) ) {
+
+ // Sections are always contentOnly.
+ if ( sectionsClientIds?.includes( clientId ) ) {
+ return 'contentOnly';
+ }
+
+ return 'disabled';
+ }
+
+ if ( editorMode === 'navigation' ) {
+ const sectionRootClientId = getSectionRootClientId( state );
+
+ // The root section is "default mode"
+ if ( clientId === sectionRootClientId ) {
+ return 'default';
+ }
+
+ // Sections should always be contentOnly in navigation mode.
+ const sectionsClientIds = getBlockOrder(
+ state,
+ sectionRootClientId
+ );
+ if ( sectionsClientIds.includes( clientId ) ) {
+ return 'contentOnly';
+ }
+
+ // Blocks outside sections should be disabled.
+ const isWithinSectionRoot = isWithinBlock(
+ state,
+ clientId,
+ sectionRootClientId
+ );
+ if ( ! isWithinSectionRoot ) {
return 'disabled';
}
+
+ // The rest of the blocks depend on whether they are content blocks or not.
+ // This "flattens" the sections tree.
+ const name = getBlockName( state, clientId );
+ const { hasContentRoleAttribute } = unlock(
+ select( blocksStore )
+ );
+ const isContent = hasContentRoleAttribute( name );
+
+ return isContent ? 'contentOnly' : 'disabled';
}
+ // In normal mode, consider that an explicitely set editing mode takes over.
const blockEditingMode = state.blockEditingModes.get( clientId );
if ( blockEditingMode ) {
return blockEditingMode;
}
+
+ // In normal mode, top level is default mode.
if ( ! clientId ) {
return 'default';
}
+
const rootClientId = getBlockRootClientId( state, clientId );
const templateLock = getTemplateLock( state, rootClientId );
+ // If the parent of the block is contentOnly locked, check whether it's a content block.
if ( templateLock === 'contentOnly' ) {
const name = getBlockName( state, clientId );
- const isContent =
- select( blocksStore ).__experimentalHasContentRoleAttribute(
- name
- );
+ const { hasContentRoleAttribute } = unlock(
+ select( blocksStore )
+ );
+ const isContent = hasContentRoleAttribute( name );
return isContent ? 'contentOnly' : 'disabled';
}
+ // Otherwise, check if there's an ancestor that is contentOnly
const parentMode = getBlockEditingMode( state, rootClientId );
return parentMode === 'contentOnly' ? 'default' : parentMode;
}
diff --git a/packages/block-editor/src/store/test/private-actions.js b/packages/block-editor/src/store/test/private-actions.js
index 7576b95866306a..d54a519c9056b6 100644
--- a/packages/block-editor/src/store/test/private-actions.js
+++ b/packages/block-editor/src/store/test/private-actions.js
@@ -6,6 +6,7 @@ import {
showBlockInterface,
expandBlock,
__experimentalUpdateSettings,
+ setInsertionPoint,
setOpenedBlockSettingsMenu,
startDragging,
stopDragging,
@@ -123,4 +124,18 @@ describe( 'private actions', () => {
} );
} );
} );
+
+ describe( 'setInsertionPoint', () => {
+ it( 'should return the SET_INSERTION_POINT action', () => {
+ expect(
+ setInsertionPoint( {
+ rootClientId: '',
+ index: '123',
+ } )
+ ).toEqual( {
+ type: 'SET_INSERTION_POINT',
+ value: { rootClientId: '', index: '123' },
+ } );
+ } );
+ } );
} );
diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js
index 45432b750bb9eb..cbb75daa4baaa0 100644
--- a/packages/block-editor/src/store/test/private-selectors.js
+++ b/packages/block-editor/src/store/test/private-selectors.js
@@ -124,10 +124,10 @@ describe( 'private selectors', () => {
blockEditingModes: new Map( [] ),
};
- const __experimentalHasContentRoleAttribute = jest.fn( () => false );
+ const hasContentRoleAttribute = jest.fn( () => false );
getBlockEditingMode.registry = {
select: jest.fn( () => ( {
- __experimentalHasContentRoleAttribute,
+ hasContentRoleAttribute,
} ) ),
};
@@ -394,6 +394,10 @@ describe( 'private selectors', () => {
parents: new Map( [
[ '6cf70164-9097-4460-bcbf-200560546988', '' ],
] ),
+ order: new Map( [
+ [ '6cf70164-9097-4460-bcbf-200560546988', [] ],
+ [ '', [ '6cf70164-9097-4460-bcbf-200560546988' ] ],
+ ] ),
},
blockEditingModes: new Map(),
};
@@ -424,6 +428,21 @@ describe( 'private selectors', () => {
'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c',
],
] ),
+
+ order: new Map( [
+ [
+ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337',
+ [
+ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f',
+ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c',
+ ],
+ ],
+ [
+ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c',
+ [ '4c2b7140-fffd-44b4-b2a7-820c670a6514' ],
+ ],
+ [ '', [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337' ] ],
+ ] ),
},
blockEditingModes: new Map( [
[ '', 'disabled' ],
@@ -461,6 +480,21 @@ describe( 'private selectors', () => {
'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c',
],
] ),
+ order: new Map( [
+ [
+ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337',
+ [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f' ],
+ ],
+ [
+ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f',
+ [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c' ],
+ ],
+ [
+ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c',
+ [ '4c2b7140-fffd-44b4-b2a7-820c670a6514' ],
+ ],
+ [ '', [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337' ] ],
+ ] ),
},
blockEditingModes: new Map( [
[ '', 'disabled' ],
diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js
index cd472fa59ac724..1f1b9a9143d981 100644
--- a/packages/block-editor/src/store/test/reducer.js
+++ b/packages/block-editor/src/store/test/reducer.js
@@ -28,6 +28,7 @@ import {
isMultiSelecting,
preferences,
blocksMode,
+ insertionCue,
insertionPoint,
template,
blockListSettings,
@@ -2378,15 +2379,15 @@ describe( 'state', () => {
} );
} );
- describe( 'insertionPoint', () => {
+ describe( 'insertionCue', () => {
it( 'should default to null', () => {
- const state = insertionPoint( undefined, {} );
+ const state = insertionCue( undefined, {} );
expect( state ).toBe( null );
} );
it( 'should set insertion point', () => {
- const state = insertionPoint( null, {
+ const state = insertionCue( null, {
type: 'SHOW_INSERTION_POINT',
rootClientId: 'clientId1',
index: 0,
@@ -2403,7 +2404,7 @@ describe( 'state', () => {
rootClientId: 'clientId1',
index: 0,
} );
- const state = insertionPoint( original, {
+ const state = insertionCue( original, {
type: 'HIDE_INSERTION_POINT',
} );
@@ -3485,4 +3486,39 @@ describe( 'state', () => {
expect( state ).toBe( null );
} );
} );
+
+ describe( 'insertionPoint', () => {
+ it( 'should default to null', () => {
+ const state = insertionPoint( undefined, {} );
+
+ expect( state ).toBe( null );
+ } );
+
+ it( 'should set insertion point', () => {
+ const state = insertionPoint( null, {
+ type: 'SET_INSERTION_POINT',
+ value: {
+ rootClientId: 'clientId1',
+ index: 4,
+ },
+ } );
+
+ expect( state ).toEqual( {
+ rootClientId: 'clientId1',
+ index: 4,
+ } );
+ } );
+
+ it( 'should clear the insertion point on block selection', () => {
+ const original = deepFreeze( {
+ rootClientId: 'clientId1',
+ index: 4,
+ } );
+ const state = insertionPoint( original, {
+ type: 'SELECT_BLOCK',
+ } );
+
+ expect( state ).toBe( null );
+ } );
+ } );
} );
diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js
index 85006621c4701e..a08c2e0dde1508 100644
--- a/packages/block-editor/src/store/test/selectors.js
+++ b/packages/block-editor/src/store/test/selectors.js
@@ -15,6 +15,8 @@ import { select, dispatch } from '@wordpress/data';
*/
import * as selectors from '../selectors';
import { store } from '../';
+import { sectionRootClientIdKey } from '../private-keys';
+import { lock } from '../../lock-unlock';
const {
getBlockName,
@@ -2423,7 +2425,7 @@ describe( 'selectors', () => {
} )
),
},
- insertionPoint: {
+ insertionCue: {
rootClientId: undefined,
index: 0,
},
@@ -2464,7 +2466,7 @@ describe( 'selectors', () => {
} )
),
},
- insertionPoint: null,
+ insertionCue: null,
};
expect( getBlockInsertionPoint( state ) ).toEqual( {
@@ -2502,7 +2504,7 @@ describe( 'selectors', () => {
} )
),
},
- insertionPoint: null,
+ insertionCue: null,
};
const insertionPoint1 = getBlockInsertionPoint( state );
@@ -2544,7 +2546,7 @@ describe( 'selectors', () => {
} )
),
},
- insertionPoint: null,
+ insertionCue: null,
};
expect( getBlockInsertionPoint( state ) ).toEqual( {
@@ -2586,7 +2588,7 @@ describe( 'selectors', () => {
} )
),
},
- insertionPoint: null,
+ insertionCue: null,
};
expect( getBlockInsertionPoint( state ) ).toEqual( {
@@ -2628,7 +2630,7 @@ describe( 'selectors', () => {
} )
),
},
- insertionPoint: null,
+ insertionCue: null,
};
expect( getBlockInsertionPoint( state ) ).toEqual( {
@@ -2641,7 +2643,7 @@ describe( 'selectors', () => {
describe( 'isBlockInsertionPointVisible', () => {
it( 'should return false if no assigned insertion point', () => {
const state = {
- insertionPoint: null,
+ insertionCue: null,
};
expect( isBlockInsertionPointVisible( state ) ).toBe( false );
@@ -2649,7 +2651,7 @@ describe( 'selectors', () => {
it( 'should return true if assigned insertion point', () => {
const state = {
- insertionPoint: {
+ insertionCue: {
rootClientId: undefined,
index: 5,
},
@@ -2694,6 +2696,7 @@ describe( 'selectors', () => {
byClientId: new Map(),
attributes: new Map(),
parents: new Map(),
+ order: new Map(),
},
blockListSettings: {},
settings: {
@@ -2711,6 +2714,7 @@ describe( 'selectors', () => {
blocks: {
byClientId: new Map(),
attributes: new Map(),
+ order: new Map(),
},
blockListSettings: {},
settings: {
@@ -2728,6 +2732,7 @@ describe( 'selectors', () => {
byClientId: new Map(),
attributes: new Map(),
parents: new Map(),
+ order: new Map(),
},
blockListSettings: {},
settings: {},
@@ -2748,6 +2753,7 @@ describe( 'selectors', () => {
byClientId: new Map(),
attributes: new Map(),
parents: new Map(),
+ order: new Map(),
},
blockListSettings: {},
settings: {},
@@ -2772,6 +2778,7 @@ describe( 'selectors', () => {
} )
),
parents: new Map(),
+ order: new Map(),
},
blockListSettings: {},
settings: {},
@@ -2796,6 +2803,7 @@ describe( 'selectors', () => {
} )
),
parents: new Map(),
+ order: new Map(),
},
blockListSettings: {
block1: {},
@@ -2822,6 +2830,7 @@ describe( 'selectors', () => {
} )
),
parents: new Map(),
+ order: new Map(),
},
blockListSettings: {
block1: {},
@@ -2848,6 +2857,7 @@ describe( 'selectors', () => {
} )
),
parents: new Map(),
+ order: new Map(),
},
blockListSettings: {
block1: {
@@ -2876,6 +2886,7 @@ describe( 'selectors', () => {
} )
),
parents: new Map(),
+ order: new Map(),
},
blockListSettings: {
block1: {
@@ -2904,6 +2915,7 @@ describe( 'selectors', () => {
} )
),
parents: new Map(),
+ order: new Map(),
},
blockListSettings: {},
settings: {},
@@ -2932,6 +2944,7 @@ describe( 'selectors', () => {
} )
),
parents: new Map(),
+ order: new Map(),
},
blockListSettings: {
block1: {
@@ -2960,6 +2973,7 @@ describe( 'selectors', () => {
} )
),
parents: new Map(),
+ order: new Map(),
},
blockListSettings: {},
settings: {},
@@ -2976,6 +2990,7 @@ describe( 'selectors', () => {
byClientId: new Map(),
attributes: new Map(),
parents: new Map(),
+ order: new Map(),
},
blockListSettings: {},
settings: {},
@@ -2992,7 +3007,7 @@ describe( 'selectors', () => {
byClientId: new Map(
Object.entries( {
block1: { name: 'core/test-block-ancestor' },
- block2: { name: 'core/block' },
+ block2: { name: 'core/block1' },
} )
),
attributes: new Map(
@@ -3006,6 +3021,10 @@ describe( 'selectors', () => {
block2: 'block1',
} )
),
+ order: new Map( [
+ [ '', [ 'block1' ] ],
+ [ 'block1', [ 'block2' ] ],
+ ] ),
},
blockListSettings: {
block1: {},
@@ -3023,6 +3042,37 @@ describe( 'selectors', () => {
).toBe( true );
} );
+ it( 'should prevent blocks from being inserted within sections', () => {
+ const state = {
+ blocks: {
+ byClientId: new Map(
+ Object.entries( {
+ block1: { name: 'core/block' }, // reusable blocks are always sections.
+ } )
+ ),
+ attributes: new Map(
+ Object.entries( {
+ block1: {},
+ } )
+ ),
+ parents: new Map(
+ Object.entries( {
+ block1: '',
+ } )
+ ),
+ order: new Map( [ [ '', [ 'block1' ] ] ] ),
+ },
+ blockListSettings: {
+ block1: {},
+ },
+ settings: {},
+ blockEditingModes: new Map(),
+ };
+ expect(
+ canInsertBlockType( state, 'core/test-block-a', 'block1' )
+ ).toBe( false );
+ } );
+
it( 'should allow blocks to be inserted if both parent and ancestor restrictions are met', () => {
const state = {
blocks: {
@@ -3046,6 +3096,11 @@ describe( 'selectors', () => {
block3: 'block2',
} )
),
+ order: new Map( [
+ [ '', [ 'block1' ] ],
+ [ 'block1', [ 'block2' ] ],
+ [ 'block2', [ 'block3' ] ],
+ ] ),
},
blockListSettings: {
block1: {},
@@ -3086,6 +3141,11 @@ describe( 'selectors', () => {
block3: 'block2',
} )
),
+ order: new Map( [
+ [ '', [ 'block1' ] ],
+ [ 'block1', [ 'block2' ] ],
+ [ 'block2', [ 'block3' ] ],
+ ] ),
},
blockListSettings: {
block1: {},
@@ -3126,6 +3186,11 @@ describe( 'selectors', () => {
block3: 'block2',
} )
),
+ order: new Map( [
+ [ '', [ 'block1' ] ],
+ [ 'block1', [ 'block2' ] ],
+ [ 'block2', [ 'block3' ] ],
+ ] ),
},
blockListSettings: {
block1: {},
@@ -3159,11 +3224,14 @@ describe( 'selectors', () => {
block2: {},
} )
),
- parents: new Map(
- Object.entries( {
- block2: 'block1',
- } )
- ),
+ order: new Map( [
+ [ '', [ 'block1' ] ],
+ [ 'block1', [ 'block2' ] ],
+ ] ),
+ parents: new Map( [
+ [ 'block2', 'block1' ],
+ [ 'block1', '' ],
+ ] ),
},
blockListSettings: {
block1: {},
@@ -3203,6 +3271,10 @@ describe( 'selectors', () => {
block2: 'block1',
} )
),
+ order: new Map( [
+ [ '', [ 'block1' ] ],
+ [ 'block1', [ 'block2' ] ],
+ ] ),
},
blockListSettings: {
block1: {},
@@ -3240,6 +3312,7 @@ describe( 'selectors', () => {
} )
),
parents: new Map(),
+ order: new Map(),
},
blockListSettings: {
1: {
@@ -3273,6 +3346,7 @@ describe( 'selectors', () => {
} )
),
parents: new Map(),
+ order: new Map(),
},
blockListSettings: {
1: {
@@ -4310,12 +4384,28 @@ describe( 'getBlockEditingMode', () => {
settings: {},
blocks: {
byClientId: new Map( [
- [ '6cf70164-9097-4460-bcbf-200560546988', {} ], // Header
- [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', {} ], // Group
- [ 'b26fc763-417d-4f01-b81c-2ec61e14a972', {} ], // | Post Title
- [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', {} ], // | Post Content
- [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', {} ], // | | Paragraph
- [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', {} ], // | | Paragraph
+ [
+ '6cf70164-9097-4460-bcbf-200560546988',
+ { name: 'core/template-part' },
+ ], // Header
+ [
+ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337',
+ { name: 'core/group' },
+ ], // Group
+ [
+ 'b26fc763-417d-4f01-b81c-2ec61e14a972',
+ { name: 'core/post-title' },
+ ], // | Post Title
+ [
+ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f',
+ { name: 'core/group' },
+ ], // | Group
+ [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', { name: 'core/p' } ], // | | Paragraph
+ [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', { name: 'core/p' } ], // | | Paragraph
+ [
+ '9b9c5c3f-2e46-4f02-9e14-9fed515b958s',
+ { name: 'core/group' },
+ ], // | | Group
] ),
order: new Map( [
[
@@ -4339,10 +4429,12 @@ describe( 'getBlockEditingMode', () => {
[
'b3247f75-fd94-4fef-97f9-5bfd162cc416',
'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c',
+ '9b9c5c3f-2e46-4f02-9e14-9fed515b958s',
],
],
[ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', [] ],
[ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', [] ],
+ [ '9b9c5c3f-2e46-4f02-9e14-9fed515b958s', [] ],
] ),
parents: new Map( [
[ '6cf70164-9097-4460-bcbf-200560546988', '' ],
@@ -4363,6 +4455,10 @@ describe( 'getBlockEditingMode', () => {
'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c',
'9b9c5c3f-2e46-4f02-9e14-9fe9515b958f',
],
+ [
+ '9b9c5c3f-2e46-4f02-9e14-9fed515b958s',
+ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f',
+ ],
] ),
},
blockListSettings: {
@@ -4372,11 +4468,22 @@ describe( 'getBlockEditingMode', () => {
blockEditingModes: new Map( [] ),
};
- const __experimentalHasContentRoleAttribute = jest.fn( () => false );
+ const navigationModeStateWithRootSection = {
+ ...baseState,
+ editorMode: 'navigation',
+ settings: {
+ [ sectionRootClientIdKey ]: 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', // The group is the "main" container
+ },
+ };
+
+ const hasContentRoleAttribute = jest.fn( () => false );
+
+ const fauxPrivateAPIs = {};
+
+ lock( fauxPrivateAPIs, { hasContentRoleAttribute } );
+
getBlockEditingMode.registry = {
- select: jest.fn( () => ( {
- __experimentalHasContentRoleAttribute,
- } ) ),
+ select: jest.fn( () => fauxPrivateAPIs ),
};
it( 'should return default by default', () => {
@@ -4480,7 +4587,7 @@ describe( 'getBlockEditingMode', () => {
},
},
};
- __experimentalHasContentRoleAttribute.mockReturnValueOnce( false );
+ hasContentRoleAttribute.mockReturnValueOnce( false );
expect(
getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' )
).toBe( 'disabled' );
@@ -4496,9 +4603,69 @@ describe( 'getBlockEditingMode', () => {
},
},
};
- __experimentalHasContentRoleAttribute.mockReturnValueOnce( true );
+ hasContentRoleAttribute.mockReturnValueOnce( true );
expect(
getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' )
).toBe( 'contentOnly' );
} );
+
+ it( 'in navigation mode, the root section container is default', () => {
+ expect(
+ getBlockEditingMode(
+ navigationModeStateWithRootSection,
+ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337'
+ )
+ ).toBe( 'default' );
+ } );
+
+ it( 'in navigation mode, anything outside the section container is disabled', () => {
+ expect(
+ getBlockEditingMode(
+ navigationModeStateWithRootSection,
+ '6cf70164-9097-4460-bcbf-200560546988'
+ )
+ ).toBe( 'disabled' );
+ } );
+
+ it( 'in navigation mode, sections are contentOnly', () => {
+ expect(
+ getBlockEditingMode(
+ navigationModeStateWithRootSection,
+ 'b26fc763-417d-4f01-b81c-2ec61e14a972'
+ )
+ ).toBe( 'contentOnly' );
+ expect(
+ getBlockEditingMode(
+ navigationModeStateWithRootSection,
+ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f'
+ )
+ ).toBe( 'contentOnly' );
+ } );
+
+ it( 'in navigation mode, blocks with content attributes within sections are contentOnly', () => {
+ hasContentRoleAttribute.mockReturnValueOnce( true );
+ expect(
+ getBlockEditingMode(
+ navigationModeStateWithRootSection,
+ 'b3247f75-fd94-4fef-97f9-5bfd162cc416'
+ )
+ ).toBe( 'contentOnly' );
+
+ hasContentRoleAttribute.mockReturnValueOnce( true );
+ expect(
+ getBlockEditingMode(
+ navigationModeStateWithRootSection,
+ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c'
+ )
+ ).toBe( 'contentOnly' );
+ } );
+
+ it( 'in navigation mode, blocks without content attributes within sections are disabled', () => {
+ expect(
+ getBlockEditingMode(
+ navigationModeStateWithRootSection,
+ '9b9c5c3f-2e46-4f02-9e14-9fed515b958s'
+ )
+ ).toBe( 'disabled' );
+ } );
} );
diff --git a/packages/block-editor/src/store/utils.js b/packages/block-editor/src/store/utils.js
index b630912a5163d6..9b83a8f74cf9aa 100644
--- a/packages/block-editor/src/store/utils.js
+++ b/packages/block-editor/src/store/utils.js
@@ -10,9 +10,9 @@ import { parse as grammarParse } from '@wordpress/block-serialization-default-pa
import { selectBlockPatternsKey } from './private-keys';
import { unlock } from '../lock-unlock';
import { STORE_NAME } from './constants';
+import { getSectionRootClientId } from './private-selectors';
-export const withRootClientIdOptionKey = Symbol( 'withRootClientId' );
-
+export const isFiltered = Symbol( 'isFiltered' );
const parsedPatternCache = new WeakMap();
const grammarMapCache = new WeakMap();
@@ -117,5 +117,7 @@ export function getInsertBlockTypeDependants( state, rootClientId ) {
state.settings.allowedBlockTypes,
state.settings.templateLock,
state.blockEditingModes,
+ state.editorMode,
+ getSectionRootClientId( state ),
];
}
diff --git a/packages/block-editor/src/utils/block-bindings.js b/packages/block-editor/src/utils/block-bindings.js
index b3daf4f4b36b43..2deeb959371742 100644
--- a/packages/block-editor/src/utils/block-bindings.js
+++ b/packages/block-editor/src/utils/block-bindings.js
@@ -13,6 +13,55 @@ function isObjectEmpty( object ) {
return ! object || Object.keys( object ).length === 0;
}
+/**
+ * Contains utils to update the block `bindings` metadata.
+ *
+ * @typedef {Object} WPBlockBindingsUtils
+ *
+ * @property {Function} updateBlockBindings Updates the value of the bindings connected to block attributes.
+ * @property {Function} removeAllBlockBindings Removes the bindings property of the `metadata` attribute.
+ */
+
+/**
+ * Retrieves the existing utils needed to update the block `bindings` metadata.
+ * They can be used to create, modify, or remove connections from the existing block attributes.
+ *
+ * It contains the following utils:
+ * - `updateBlockBindings`: Updates the value of the bindings connected to block attributes. It can be used to remove a specific binding by setting the value to `undefined`.
+ * - `removeAllBlockBindings`: Removes the bindings property of the `metadata` attribute.
+ *
+ * @since 6.7.0 Introduced in WordPress core.
+ *
+ * @return {?WPBlockBindingsUtils} Object containing the block bindings utils.
+ *
+ * @example
+ * ```js
+ * import { useBlockBindingsUtils } from '@wordpress/block-editor'
+ * const { updateBlockBindings, removeAllBlockBindings } = useBlockBindingsUtils();
+ *
+ * // Update url and alt attributes.
+ * updateBlockBindings( {
+ * url: {
+ * source: 'core/post-meta',
+ * args: {
+ * key: 'url_custom_field',
+ * },
+ * },
+ * alt: {
+ * source: 'core/post-meta',
+ * args: {
+ * key: 'text_custom_field',
+ * },
+ * },
+ * } );
+ *
+ * // Remove binding from url attribute.
+ * updateBlockBindings( { url: undefined } );
+ *
+ * // Remove bindings from all attributes.
+ * removeAllBlockBindings();
+ * ```
+ */
export function useBlockBindingsUtils() {
const { clientId } = useBlockEditContext();
const { updateBlockAttributes } = useDispatch( blockEditorStore );
diff --git a/packages/block-editor/src/utils/index.js b/packages/block-editor/src/utils/index.js
index 6f53ba585e5ecb..1b5aa769a13b28 100644
--- a/packages/block-editor/src/utils/index.js
+++ b/packages/block-editor/src/utils/index.js
@@ -1,2 +1,3 @@
export { default as transformStyles } from './transform-styles';
export { default as getPxFromCssUnit } from './get-px-from-css-unit';
+export { useBlockBindingsUtils } from './block-bindings';
diff --git a/packages/block-library/src/audio/block.json b/packages/block-library/src/audio/block.json
index bee2ff6d534a70..9b77efee23cce2 100644
--- a/packages/block-library/src/audio/block.json
+++ b/packages/block-library/src/audio/block.json
@@ -10,24 +10,24 @@
"attributes": {
"blob": {
"type": "string",
- "__experimentalRole": "local"
+ "role": "local"
},
"src": {
"type": "string",
"source": "attribute",
"selector": "audio",
"attribute": "src",
- "__experimentalRole": "content"
+ "role": "content"
},
"caption": {
"type": "rich-text",
"source": "rich-text",
"selector": "figcaption",
- "__experimentalRole": "content"
+ "role": "content"
},
"id": {
"type": "number",
- "__experimentalRole": "content"
+ "role": "content"
},
"autoplay": {
"type": "boolean",
diff --git a/packages/block-library/src/avatar/index.js b/packages/block-library/src/avatar/index.js
index d318450aec3903..0b3ad9c62c4e30 100644
--- a/packages/block-library/src/avatar/index.js
+++ b/packages/block-library/src/avatar/index.js
@@ -16,6 +16,7 @@ export { metadata, name };
export const settings = {
icon,
edit,
+ example: {},
};
export const init = () => initBlock( { name, metadata, settings } );
diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js
index 5c90361e6bb435..104b07157cba74 100644
--- a/packages/block-library/src/block/edit.js
+++ b/packages/block-library/src/block/edit.js
@@ -32,7 +32,7 @@ import {
InnerBlocks,
} from '@wordpress/block-editor';
import { privateApis as patternsPrivateApis } from '@wordpress/patterns';
-import { store as blocksStore } from '@wordpress/blocks';
+import { getBlockBindingsSource } from '@wordpress/blocks';
/**
* Internal dependencies
@@ -196,7 +196,6 @@ function ReusableBlockEdit( {
( select ) => {
const { getBlocks, getSettings, getBlockEditingMode } =
select( blockEditorStore );
- const { getBlockBindingsSource } = unlock( select( blocksStore ) );
// For editing link to the site editor if the theme and user permissions support it.
return {
innerBlocks: getBlocks( patternClientId ),
diff --git a/packages/block-library/src/button/block.json b/packages/block-library/src/button/block.json
index d0f90b93467c9d..2c1c05baa20dd3 100644
--- a/packages/block-library/src/button/block.json
+++ b/packages/block-library/src/button/block.json
@@ -26,34 +26,34 @@
"source": "attribute",
"selector": "a",
"attribute": "href",
- "__experimentalRole": "content"
+ "role": "content"
},
"title": {
"type": "string",
"source": "attribute",
"selector": "a,button",
"attribute": "title",
- "__experimentalRole": "content"
+ "role": "content"
},
"text": {
"type": "rich-text",
"source": "rich-text",
"selector": "a,button",
- "__experimentalRole": "content"
+ "role": "content"
},
"linkTarget": {
"type": "string",
"source": "attribute",
"selector": "a",
"attribute": "target",
- "__experimentalRole": "content"
+ "role": "content"
},
"rel": {
"type": "string",
"source": "attribute",
"selector": "a",
"attribute": "rel",
- "__experimentalRole": "content"
+ "role": "content"
},
"placeholder": {
"type": "string"
diff --git a/packages/block-library/src/button/edit.js b/packages/block-library/src/button/edit.js
index d7b8e6486c3c66..3539fd54f4eece 100644
--- a/packages/block-library/src/button/edit.js
+++ b/packages/block-library/src/button/edit.js
@@ -9,7 +9,6 @@ import clsx from 'clsx';
import { NEW_TAB_TARGET, NOFOLLOW_REL } from './constants';
import { getUpdatedLinkAttributes } from './get-updated-link-attributes';
import removeAnchorTag from '../utils/remove-anchor-tag';
-import { unlock } from '../lock-unlock';
/**
* WordPress dependencies
@@ -45,7 +44,7 @@ import {
createBlock,
cloneBlock,
getDefaultBlockName,
- store as blocksStore,
+ getBlockBindingsSource,
} from '@wordpress/blocks';
import { useMergeRefs, useRefEffect } from '@wordpress/compose';
import { useSelect, useDispatch } from '@wordpress/data';
@@ -240,9 +239,9 @@ function ButtonEdit( props ) {
return {};
}
- const blockBindingsSource = unlock(
- select( blocksStore )
- ).getBlockBindingsSource( metadata?.bindings?.url?.source );
+ const blockBindingsSource = getBlockBindingsSource(
+ metadata?.bindings?.url?.source
+ );
return {
lockUrlControls:
diff --git a/packages/block-library/src/buttons/style.scss b/packages/block-library/src/buttons/style.scss
index 8492553bd50b81..e563f3957f3746 100644
--- a/packages/block-library/src/buttons/style.scss
+++ b/packages/block-library/src/buttons/style.scss
@@ -2,6 +2,8 @@
$blocks-block__margin: 0.5em;
.wp-block-buttons {
+ // This block has customizable padding, border-box makes that more predictable.
+ box-sizing: border-box;
&.is-vertical {
flex-direction: column;
diff --git a/packages/block-library/src/categories/block.json b/packages/block-library/src/categories/block.json
index bfd8461f8eda43..3609bdf9ab97c0 100644
--- a/packages/block-library/src/categories/block.json
+++ b/packages/block-library/src/categories/block.json
@@ -34,7 +34,7 @@
},
"label": {
"type": "string",
- "__experimentalRole": "content"
+ "role": "content"
},
"showLabel": {
"type": "boolean",
diff --git a/packages/block-library/src/categories/index.php b/packages/block-library/src/categories/index.php
index e15f662bdfbb9b..60a29713b4660d 100644
--- a/packages/block-library/src/categories/index.php
+++ b/packages/block-library/src/categories/index.php
@@ -49,7 +49,7 @@ function render_block_core_categories( $attributes, $content, $block ) {
$show_label = empty( $attributes['showLabel'] ) ? ' screen-reader-text' : '';
$default_label = $taxonomy->label;
- $label_text = ! empty( $attributes['label'] ) ? $attributes['label'] : $default_label;
+ $label_text = ! empty( $attributes['label'] ) ? wp_kses_post( $attributes['label'] ) : $default_label;
$wrapper_markup = '' . $label_text . ' %2$s
';
$items_markup = wp_dropdown_categories( $args );
$type = 'dropdown';
diff --git a/packages/block-library/src/comment-author-name/index.js b/packages/block-library/src/comment-author-name/index.js
index 4d85bbebe047be..5bcb6896564807 100644
--- a/packages/block-library/src/comment-author-name/index.js
+++ b/packages/block-library/src/comment-author-name/index.js
@@ -18,6 +18,7 @@ export const settings = {
icon,
edit,
deprecated,
+ example: {},
};
export const init = () => initBlock( { name, metadata, settings } );
diff --git a/packages/block-library/src/comment-content/index.js b/packages/block-library/src/comment-content/index.js
index 130f1d30125559..aefcef75acf8ae 100644
--- a/packages/block-library/src/comment-content/index.js
+++ b/packages/block-library/src/comment-content/index.js
@@ -16,6 +16,7 @@ export { metadata, name };
export const settings = {
icon,
edit,
+ example: {},
};
export const init = () => initBlock( { name, metadata, settings } );
diff --git a/packages/block-library/src/comment-date/index.js b/packages/block-library/src/comment-date/index.js
index fddae539acfa34..d95c0a958f9ed8 100644
--- a/packages/block-library/src/comment-date/index.js
+++ b/packages/block-library/src/comment-date/index.js
@@ -18,6 +18,7 @@ export const settings = {
icon,
edit,
deprecated,
+ example: {},
};
export const init = () => initBlock( { name, metadata, settings } );
diff --git a/packages/block-library/src/comment-edit-link/index.js b/packages/block-library/src/comment-edit-link/index.js
index 6639dda86a7a40..ffe8c98a75dfd9 100644
--- a/packages/block-library/src/comment-edit-link/index.js
+++ b/packages/block-library/src/comment-edit-link/index.js
@@ -16,6 +16,7 @@ export { metadata, name };
export const settings = {
icon,
edit,
+ example: {},
};
export const init = () => initBlock( { name, metadata, settings } );
diff --git a/packages/block-library/src/comment-reply-link/index.js b/packages/block-library/src/comment-reply-link/index.js
index c04f8ce7b1bba5..a8287f6b08ff35 100644
--- a/packages/block-library/src/comment-reply-link/index.js
+++ b/packages/block-library/src/comment-reply-link/index.js
@@ -16,6 +16,7 @@ export { metadata, name };
export const settings = {
edit,
icon,
+ example: {},
};
export const init = () => initBlock( { name, metadata, settings } );
diff --git a/packages/block-library/src/comments-pagination-next/block.json b/packages/block-library/src/comments-pagination-next/block.json
index 22e20bfa8dbf2d..3f7ebe677328d5 100644
--- a/packages/block-library/src/comments-pagination-next/block.json
+++ b/packages/block-library/src/comments-pagination-next/block.json
@@ -12,6 +12,11 @@
"type": "string"
}
},
+ "example": {
+ "attributes": {
+ "label": "Comments Next Page"
+ }
+ },
"usesContext": [ "postId", "comments/paginationArrow" ],
"supports": {
"reusable": false,
diff --git a/packages/block-library/src/comments-pagination-numbers/index.js b/packages/block-library/src/comments-pagination-numbers/index.js
index 3fd903e2d9ef48..f769f54b4ac034 100644
--- a/packages/block-library/src/comments-pagination-numbers/index.js
+++ b/packages/block-library/src/comments-pagination-numbers/index.js
@@ -16,6 +16,7 @@ export { metadata, name };
export const settings = {
icon,
edit,
+ example: {},
};
export const init = () => initBlock( { name, metadata, settings } );
diff --git a/packages/block-library/src/comments-pagination-previous/block.json b/packages/block-library/src/comments-pagination-previous/block.json
index 0871b000c569dd..eb5203af33c866 100644
--- a/packages/block-library/src/comments-pagination-previous/block.json
+++ b/packages/block-library/src/comments-pagination-previous/block.json
@@ -12,6 +12,11 @@
"type": "string"
}
},
+ "example": {
+ "attributes": {
+ "label": "Comments Previous Page"
+ }
+ },
"usesContext": [ "postId", "comments/paginationArrow" ],
"supports": {
"reusable": false,
diff --git a/packages/block-library/src/comments-title/index.js b/packages/block-library/src/comments-title/index.js
index 86bdab0dbccbff..69b8228eab892b 100644
--- a/packages/block-library/src/comments-title/index.js
+++ b/packages/block-library/src/comments-title/index.js
@@ -18,6 +18,7 @@ export const settings = {
icon,
edit,
deprecated,
+ example: {},
};
export const init = () => initBlock( { name, metadata, settings } );
diff --git a/packages/block-library/src/cover/edit/index.js b/packages/block-library/src/cover/edit/index.js
index ec62bd58a2c33a..804027708881b6 100644
--- a/packages/block-library/src/cover/edit/index.js
+++ b/packages/block-library/src/cover/edit/index.js
@@ -18,6 +18,7 @@ import {
useInnerBlocksProps,
__experimentalUseGradient,
store as blockEditorStore,
+ useBlockEditingMode,
} from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
import { useSelect, useDispatch } from '@wordpress/data';
@@ -278,6 +279,9 @@ function CoverEdit( {
const isImageBackground = IMAGE_BACKGROUND_TYPE === backgroundType;
const isVideoBackground = VIDEO_BACKGROUND_TYPE === backgroundType;
+ const blockEditingMode = useBlockEditingMode();
+ const hasNonContentControls = blockEditingMode === 'default';
+
const [ resizeListener, { height, width } ] = useResizeObserver();
const resizableBoxDimensions = useMemo( () => {
return {
@@ -447,7 +451,7 @@ function CoverEdit( {
<>
{ blockControls }
{ inspectorControls }
- { isSelected && (
+ { hasNonContentControls && isSelected && (
) }
- { isSelected && (
+ { hasNonContentControls && isSelected && (
) }
>
diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss
index 0669a082b1086f..a16d5a6c2c69c7 100644
--- a/packages/block-library/src/editor.scss
+++ b/packages/block-library/src/editor.scss
@@ -55,7 +55,6 @@
@import "./query-pagination-numbers/editor.scss";
@import "./post-featured-image/editor.scss";
@import "./post-comments-form/editor.scss";
-@import "./post-content/editor.scss";
@import "./editor-elements.scss";
:root .editor-styles-wrapper {
diff --git a/packages/block-library/src/embed/block.json b/packages/block-library/src/embed/block.json
index a42aafbab4b0b9..5bfb63b0fa9e94 100644
--- a/packages/block-library/src/embed/block.json
+++ b/packages/block-library/src/embed/block.json
@@ -9,21 +9,21 @@
"attributes": {
"url": {
"type": "string",
- "__experimentalRole": "content"
+ "role": "content"
},
"caption": {
"type": "rich-text",
"source": "rich-text",
"selector": "figcaption",
- "__experimentalRole": "content"
+ "role": "content"
},
"type": {
"type": "string",
- "__experimentalRole": "content"
+ "role": "content"
},
"providerNameSlug": {
"type": "string",
- "__experimentalRole": "content"
+ "role": "content"
},
"allowResponsive": {
"type": "boolean",
@@ -32,12 +32,12 @@
"responsive": {
"type": "boolean",
"default": false,
- "__experimentalRole": "content"
+ "role": "content"
},
"previewable": {
"type": "boolean",
"default": true,
- "__experimentalRole": "content"
+ "role": "content"
}
},
"supports": {
diff --git a/packages/block-library/src/file/block.json b/packages/block-library/src/file/block.json
index 0526120c4dfc1e..2c5e888c2aff64 100644
--- a/packages/block-library/src/file/block.json
+++ b/packages/block-library/src/file/block.json
@@ -13,10 +13,11 @@
},
"blob": {
"type": "string",
- "__experimentalRole": "local"
+ "role": "local"
},
"href": {
- "type": "string"
+ "type": "string",
+ "role": "content"
},
"fileId": {
"type": "string",
@@ -27,13 +28,15 @@
"fileName": {
"type": "rich-text",
"source": "rich-text",
- "selector": "a:not([download])"
+ "selector": "a:not([download])",
+ "role": "content"
},
"textLinkHref": {
"type": "string",
"source": "attribute",
"selector": "a:not([download])",
- "attribute": "href"
+ "attribute": "href",
+ "role": "content"
},
"textLinkTarget": {
"type": "string",
@@ -48,7 +51,8 @@
"downloadButtonText": {
"type": "rich-text",
"source": "rich-text",
- "selector": "a[download]"
+ "selector": "a[download]",
+ "role": "content"
},
"displayPreview": {
"type": "boolean"
diff --git a/packages/block-library/src/file/index.php b/packages/block-library/src/file/index.php
index 85cc840201da59..8ea668d56d8545 100644
--- a/packages/block-library/src/file/index.php
+++ b/packages/block-library/src/file/index.php
@@ -19,18 +19,7 @@
function render_block_core_file( $attributes, $content ) {
// If it's interactive, enqueue the script module and add the directives.
if ( ! empty( $attributes['displayPreview'] ) ) {
- $suffix = wp_scripts_get_suffix();
- if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) {
- $module_url = gutenberg_url( '/build-module/block-library/file/view.min.js' );
- }
-
- wp_register_script_module(
- '@wordpress/block-library/file',
- isset( $module_url ) ? $module_url : includes_url( "blocks/file/view{$suffix}.js" ),
- array( '@wordpress/interactivity' ),
- defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' )
- );
- wp_enqueue_script_module( '@wordpress/block-library/file' );
+ wp_enqueue_script_module( '@wordpress/block-library/file/view' );
$processor = new WP_HTML_Tag_Processor( $content );
$processor->next_tag();
diff --git a/packages/block-library/src/form-input/block.json b/packages/block-library/src/form-input/block.json
index 53aa0be6744cb9..386c90ac207ad4 100644
--- a/packages/block-library/src/form-input/block.json
+++ b/packages/block-library/src/form-input/block.json
@@ -23,7 +23,7 @@
"default": "Label",
"selector": ".wp-block-form-input__label-content",
"source": "rich-text",
- "__experimentalRole": "content"
+ "role": "content"
},
"inlineLabel": {
"type": "boolean",
@@ -41,7 +41,7 @@
"selector": ".wp-block-form-input__input",
"source": "attribute",
"attribute": "placeholder",
- "__experimentalRole": "content"
+ "role": "content"
},
"value": {
"type": "string",
diff --git a/packages/block-library/src/form-input/deprecated.js b/packages/block-library/src/form-input/deprecated.js
index 451cc704a42d55..d974cca387a188 100644
--- a/packages/block-library/src/form-input/deprecated.js
+++ b/packages/block-library/src/form-input/deprecated.js
@@ -41,7 +41,7 @@ const v2 = {
default: 'Label',
selector: '.wp-block-form-input__label-content',
source: 'html',
- __experimentalRole: 'content',
+ role: 'content',
},
inlineLabel: {
type: 'boolean',
@@ -59,7 +59,7 @@ const v2 = {
selector: '.wp-block-form-input__input',
source: 'attribute',
attribute: 'placeholder',
- __experimentalRole: 'content',
+ role: 'content',
},
value: {
type: 'string',
@@ -155,7 +155,7 @@ const v1 = {
default: 'Label',
selector: '.wp-block-form-input__label-content',
source: 'html',
- __experimentalRole: 'content',
+ role: 'content',
},
inlineLabel: {
type: 'boolean',
@@ -173,7 +173,7 @@ const v1 = {
selector: '.wp-block-form-input__input',
source: 'attribute',
attribute: 'placeholder',
- __experimentalRole: 'content',
+ role: 'content',
},
value: {
type: 'string',
diff --git a/packages/block-library/src/group/editor.scss b/packages/block-library/src/group/editor.scss
index 11beecbab0eb68..739a9cd0cf852e 100644
--- a/packages/block-library/src/group/editor.scss
+++ b/packages/block-library/src/group/editor.scss
@@ -39,9 +39,9 @@
&::after {
content: "";
display: flex;
- flex: 1 0 $grid-unit-60;
+ flex: 1 0 $button-size-next-default-40px;
pointer-events: none;
- min-height: $grid-unit-60 - $border-width - $border-width;
+ min-height: $button-size-next-default-40px - $border-width - $border-width;
border: $border-width dashed currentColor;
}
diff --git a/packages/block-library/src/heading/block.json b/packages/block-library/src/heading/block.json
index 6e43a18cfba452..2276bcbbb50172 100644
--- a/packages/block-library/src/heading/block.json
+++ b/packages/block-library/src/heading/block.json
@@ -15,7 +15,7 @@
"type": "rich-text",
"source": "rich-text",
"selector": "h1,h2,h3,h4,h5,h6",
- "__experimentalRole": "content"
+ "role": "content"
},
"level": {
"type": "number",
diff --git a/packages/block-library/src/heading/deprecated.js b/packages/block-library/src/heading/deprecated.js
index a97415712bf07c..76b175ac44fc40 100644
--- a/packages/block-library/src/heading/deprecated.js
+++ b/packages/block-library/src/heading/deprecated.js
@@ -259,7 +259,7 @@ const v5 = {
source: 'html',
selector: 'h1,h2,h3,h4,h5,h6',
default: '',
- __experimentalRole: 'content',
+ role: 'content',
},
level: {
type: 'number',
diff --git a/packages/block-library/src/image/block.json b/packages/block-library/src/image/block.json
index 6417879164a22b..f441a6e893290b 100644
--- a/packages/block-library/src/image/block.json
+++ b/packages/block-library/src/image/block.json
@@ -11,14 +11,14 @@
"attributes": {
"blob": {
"type": "string",
- "__experimentalRole": "local"
+ "role": "local"
},
"url": {
"type": "string",
"source": "attribute",
"selector": "img",
"attribute": "src",
- "__experimentalRole": "content"
+ "role": "content"
},
"alt": {
"type": "string",
@@ -26,13 +26,13 @@
"selector": "img",
"attribute": "alt",
"default": "",
- "__experimentalRole": "content"
+ "role": "content"
},
"caption": {
"type": "rich-text",
"source": "rich-text",
"selector": "figcaption",
- "__experimentalRole": "content"
+ "role": "content"
},
"lightbox": {
"type": "object",
@@ -45,14 +45,14 @@
"source": "attribute",
"selector": "img",
"attribute": "title",
- "__experimentalRole": "content"
+ "role": "content"
},
"href": {
"type": "string",
"source": "attribute",
"selector": "figure > a",
"attribute": "href",
- "__experimentalRole": "content"
+ "role": "content"
},
"rel": {
"type": "string",
@@ -68,7 +68,7 @@
},
"id": {
"type": "number",
- "__experimentalRole": "content"
+ "role": "content"
},
"width": {
"type": "string"
diff --git a/packages/block-library/src/image/deprecated.js b/packages/block-library/src/image/deprecated.js
index 135463a377131f..6c1db75c5e2aa5 100644
--- a/packages/block-library/src/image/deprecated.js
+++ b/packages/block-library/src/image/deprecated.js
@@ -559,7 +559,7 @@ const v6 = {
source: 'attribute',
selector: 'img',
attribute: 'src',
- __experimentalRole: 'content',
+ role: 'content',
},
alt: {
type: 'string',
@@ -567,27 +567,27 @@ const v6 = {
selector: 'img',
attribute: 'alt',
default: '',
- __experimentalRole: 'content',
+ role: 'content',
},
caption: {
type: 'string',
source: 'html',
selector: 'figcaption',
- __experimentalRole: 'content',
+ role: 'content',
},
title: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'title',
- __experimentalRole: 'content',
+ role: 'content',
},
href: {
type: 'string',
source: 'attribute',
selector: 'figure > a',
attribute: 'href',
- __experimentalRole: 'content',
+ role: 'content',
},
rel: {
type: 'string',
@@ -603,7 +603,7 @@ const v6 = {
},
id: {
type: 'number',
- __experimentalRole: 'content',
+ role: 'content',
},
width: {
type: 'number',
@@ -762,7 +762,7 @@ const v7 = {
source: 'attribute',
selector: 'img',
attribute: 'src',
- __experimentalRole: 'content',
+ role: 'content',
},
alt: {
type: 'string',
@@ -770,27 +770,27 @@ const v7 = {
selector: 'img',
attribute: 'alt',
default: '',
- __experimentalRole: 'content',
+ role: 'content',
},
caption: {
type: 'string',
source: 'html',
selector: 'figcaption',
- __experimentalRole: 'content',
+ role: 'content',
},
title: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'title',
- __experimentalRole: 'content',
+ role: 'content',
},
href: {
type: 'string',
source: 'attribute',
selector: 'figure > a',
attribute: 'href',
- __experimentalRole: 'content',
+ role: 'content',
},
rel: {
type: 'string',
@@ -806,7 +806,7 @@ const v7 = {
},
id: {
type: 'number',
- __experimentalRole: 'content',
+ role: 'content',
},
width: {
type: 'number',
@@ -962,7 +962,7 @@ const v8 = {
source: 'attribute',
selector: 'img',
attribute: 'src',
- __experimentalRole: 'content',
+ role: 'content',
},
alt: {
type: 'string',
@@ -970,27 +970,27 @@ const v8 = {
selector: 'img',
attribute: 'alt',
default: '',
- __experimentalRole: 'content',
+ role: 'content',
},
caption: {
type: 'string',
source: 'html',
selector: 'figcaption',
- __experimentalRole: 'content',
+ role: 'content',
},
title: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'title',
- __experimentalRole: 'content',
+ role: 'content',
},
href: {
type: 'string',
source: 'attribute',
selector: 'figure > a',
attribute: 'href',
- __experimentalRole: 'content',
+ role: 'content',
},
rel: {
type: 'string',
@@ -1006,7 +1006,7 @@ const v8 = {
},
id: {
type: 'number',
- __experimentalRole: 'content',
+ role: 'content',
},
width: {
type: 'string',
diff --git a/packages/block-library/src/image/edit.js b/packages/block-library/src/image/edit.js
index d44dc73abfd855..360c4b8e6127b8 100644
--- a/packages/block-library/src/image/edit.js
+++ b/packages/block-library/src/image/edit.js
@@ -7,7 +7,7 @@ import clsx from 'clsx';
* WordPress dependencies
*/
import { isBlobURL, createBlobURL } from '@wordpress/blob';
-import { store as blocksStore, createBlock } from '@wordpress/blocks';
+import { createBlock, getBlockBindingsSource } from '@wordpress/blocks';
import { Placeholder } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import {
@@ -28,7 +28,6 @@ import { useResizeObserver } from '@wordpress/compose';
/**
* Internal dependencies
*/
-import { unlock } from '../lock-unlock';
import { useUploadMediaFromBlobURL } from '../utils/hooks';
import Image from './image';
import { isValidFileType } from './utils';
@@ -372,9 +371,9 @@ export function ImageEdit( {
return {};
}
- const blockBindingsSource = unlock(
- select( blocksStore )
- ).getBlockBindingsSource( metadata?.bindings?.url?.source );
+ const blockBindingsSource = getBlockBindingsSource(
+ metadata?.bindings?.url?.source
+ );
return {
lockUrlControls:
diff --git a/packages/block-library/src/image/image.js b/packages/block-library/src/image/image.js
index 1673d36e463d5a..89bf31f92664b9 100644
--- a/packages/block-library/src/image/image.js
+++ b/packages/block-library/src/image/image.js
@@ -34,7 +34,7 @@ import { useEffect, useMemo, useState, useRef } from '@wordpress/element';
import { __, _x, sprintf, isRTL } from '@wordpress/i18n';
import { DOWN } from '@wordpress/keycodes';
import { getFilename } from '@wordpress/url';
-import { switchToBlockType, store as blocksStore } from '@wordpress/blocks';
+import { getBlockBindingsSource, switchToBlockType } from '@wordpress/blocks';
import { crop, overlayText, upload } from '@wordpress/icons';
import { store as noticesStore } from '@wordpress/notices';
import { store as coreStore } from '@wordpress/core-data';
@@ -476,7 +476,6 @@ export default function Image( {
if ( ! isSingleSelected ) {
return {};
}
- const { getBlockBindingsSource } = unlock( select( blocksStore ) );
const {
url: urlBinding,
alt: altBinding,
diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php
index abbb03c0952452..5d7815a1f2f3fb 100644
--- a/packages/block-library/src/image/index.php
+++ b/packages/block-library/src/image/index.php
@@ -70,19 +70,7 @@ function render_block_core_image( $attributes, $content, $block ) {
isset( $lightbox_settings['enabled'] ) &&
true === $lightbox_settings['enabled']
) {
- $suffix = wp_scripts_get_suffix();
- if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) {
- $module_url = gutenberg_url( '/build-module/block-library/image/view.min.js' );
- }
-
- wp_register_script_module(
- '@wordpress/block-library/image',
- isset( $module_url ) ? $module_url : includes_url( "blocks/image/view{$suffix}.js" ),
- array( '@wordpress/interactivity' ),
- defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' )
- );
-
- wp_enqueue_script_module( '@wordpress/block-library/image' );
+ wp_enqueue_script_module( '@wordpress/block-library/image/view' );
/*
* This render needs to happen in a filter with priority 15 to ensure that
diff --git a/packages/block-library/src/list-item/block.json b/packages/block-library/src/list-item/block.json
index a4bf2351d97509..6eb30cfe6d0af0 100644
--- a/packages/block-library/src/list-item/block.json
+++ b/packages/block-library/src/list-item/block.json
@@ -16,7 +16,7 @@
"type": "rich-text",
"source": "rich-text",
"selector": "li",
- "__experimentalRole": "content"
+ "role": "content"
}
},
"supports": {
diff --git a/packages/block-library/src/list/block.json b/packages/block-library/src/list/block.json
index ea07a0eb542df3..4a86def8d687b4 100644
--- a/packages/block-library/src/list/block.json
+++ b/packages/block-library/src/list/block.json
@@ -12,7 +12,7 @@
"ordered": {
"type": "boolean",
"default": false,
- "__experimentalRole": "content"
+ "role": "content"
},
"values": {
"type": "string",
@@ -21,7 +21,7 @@
"multiline": "li",
"__unstableMultilineWrapperTags": [ "ol", "ul" ],
"default": "",
- "__experimentalRole": "content"
+ "role": "content"
},
"type": {
"type": "string"
diff --git a/packages/block-library/src/list/deprecated.js b/packages/block-library/src/list/deprecated.js
index edb04dff27c904..13804b7040ed46 100644
--- a/packages/block-library/src/list/deprecated.js
+++ b/packages/block-library/src/list/deprecated.js
@@ -14,7 +14,7 @@ const v0 = {
ordered: {
type: 'boolean',
default: false,
- __experimentalRole: 'content',
+ role: 'content',
},
values: {
type: 'string',
@@ -23,7 +23,7 @@ const v0 = {
multiline: 'li',
__unstableMultilineWrapperTags: [ 'ol', 'ul' ],
default: '',
- __experimentalRole: 'content',
+ role: 'content',
},
type: {
type: 'string',
@@ -74,7 +74,7 @@ const v1 = {
ordered: {
type: 'boolean',
default: false,
- __experimentalRole: 'content',
+ role: 'content',
},
values: {
type: 'string',
@@ -83,7 +83,7 @@ const v1 = {
multiline: 'li',
__unstableMultilineWrapperTags: [ 'ol', 'ul' ],
default: '',
- __experimentalRole: 'content',
+ role: 'content',
},
type: {
type: 'string',
@@ -144,7 +144,7 @@ const v2 = {
ordered: {
type: 'boolean',
default: false,
- __experimentalRole: 'content',
+ role: 'content',
},
values: {
type: 'string',
@@ -153,7 +153,7 @@ const v2 = {
multiline: 'li',
__unstableMultilineWrapperTags: [ 'ol', 'ul' ],
default: '',
- __experimentalRole: 'content',
+ role: 'content',
},
type: {
type: 'string',
@@ -225,7 +225,7 @@ const v3 = {
ordered: {
type: 'boolean',
default: false,
- __experimentalRole: 'content',
+ role: 'content',
},
values: {
type: 'string',
@@ -234,7 +234,7 @@ const v3 = {
multiline: 'li',
__unstableMultilineWrapperTags: [ 'ol', 'ul' ],
default: '',
- __experimentalRole: 'content',
+ role: 'content',
},
type: {
type: 'string',
diff --git a/packages/block-library/src/media-text/block.json b/packages/block-library/src/media-text/block.json
index 42384c0c4478e0..0c2cfc4a14995a 100644
--- a/packages/block-library/src/media-text/block.json
+++ b/packages/block-library/src/media-text/block.json
@@ -18,7 +18,7 @@
"selector": "figure img",
"attribute": "alt",
"default": "",
- "__experimentalRole": "content"
+ "role": "content"
},
"mediaPosition": {
"type": "string",
@@ -26,14 +26,14 @@
},
"mediaId": {
"type": "number",
- "__experimentalRole": "content"
+ "role": "content"
},
"mediaUrl": {
"type": "string",
"source": "attribute",
"selector": "figure video,figure img",
"attribute": "src",
- "__experimentalRole": "content"
+ "role": "content"
},
"mediaLink": {
"type": "string"
@@ -52,7 +52,7 @@
"source": "attribute",
"selector": "figure a",
"attribute": "href",
- "__experimentalRole": "content"
+ "role": "content"
},
"rel": {
"type": "string",
@@ -68,7 +68,7 @@
},
"mediaType": {
"type": "string",
- "__experimentalRole": "content"
+ "role": "content"
},
"mediaWidth": {
"type": "number",
diff --git a/packages/block-library/src/media-text/deprecated.js b/packages/block-library/src/media-text/deprecated.js
index 54c6f863311ffe..24f239a41ed295 100644
--- a/packages/block-library/src/media-text/deprecated.js
+++ b/packages/block-library/src/media-text/deprecated.js
@@ -172,29 +172,29 @@ const v6Attributes = {
selector: 'figure img',
attribute: 'alt',
default: '',
- __experimentalRole: 'content',
+ role: 'content',
},
mediaId: {
type: 'number',
- __experimentalRole: 'content',
+ role: 'content',
},
mediaUrl: {
type: 'string',
source: 'attribute',
selector: 'figure video,figure img',
attribute: 'src',
- __experimentalRole: 'content',
+ role: 'content',
},
href: {
type: 'string',
source: 'attribute',
selector: 'figure a',
attribute: 'href',
- __experimentalRole: 'content',
+ role: 'content',
},
mediaType: {
type: 'string',
- __experimentalRole: 'content',
+ role: 'content',
},
};
diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php
index ec72b03b6906f0..fa9bb5a56f8012 100644
--- a/packages/block-library/src/navigation/index.php
+++ b/packages/block-library/src/navigation/index.php
@@ -622,18 +622,7 @@ private static function get_nav_element_directives( $is_interactive ) {
*/
private static function handle_view_script_module_loading( $attributes, $block, $inner_blocks ) {
if ( static::is_interactive( $attributes, $inner_blocks ) ) {
- $suffix = wp_scripts_get_suffix();
- if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) {
- $module_url = gutenberg_url( '/build-module/block-library/navigation/view.min.js' );
- }
-
- wp_register_script_module(
- '@wordpress/block-library/navigation',
- isset( $module_url ) ? $module_url : includes_url( "blocks/navigation/view{$suffix}.js" ),
- array( '@wordpress/interactivity' ),
- defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' )
- );
- wp_enqueue_script_module( '@wordpress/block-library/navigation' );
+ wp_enqueue_script_module( '@wordpress/block-library/navigation/view' );
}
}
@@ -1510,9 +1499,15 @@ function block_core_navigation_mock_parsed_block( $inner_blocks, $post ) {
*/
function block_core_navigation_insert_hooked_blocks( $inner_blocks, $post ) {
$mock_navigation_block = block_core_navigation_mock_parsed_block( $inner_blocks, $post );
- $hooked_blocks = get_hooked_blocks();
- $before_block_visitor = null;
- $after_block_visitor = null;
+
+ if ( function_exists( 'apply_block_hooks_to_content' ) ) {
+ $mock_navigation_block_markup = serialize_block( $mock_navigation_block );
+ return apply_block_hooks_to_content( $mock_navigation_block_markup, $post, 'insert_hooked_blocks' );
+ }
+
+ $hooked_blocks = get_hooked_blocks();
+ $before_block_visitor = null;
+ $after_block_visitor = null;
if ( ! empty( $hooked_blocks ) || has_filter( 'hooked_block_types' ) ) {
$before_block_visitor = make_before_block_visitor( $hooked_blocks, $post, 'insert_hooked_blocks' );
diff --git a/packages/block-library/src/paragraph/block.json b/packages/block-library/src/paragraph/block.json
index f16a7cf0411443..7e004019cbf282 100644
--- a/packages/block-library/src/paragraph/block.json
+++ b/packages/block-library/src/paragraph/block.json
@@ -15,7 +15,7 @@
"type": "rich-text",
"source": "rich-text",
"selector": "p",
- "__experimentalRole": "content"
+ "role": "content"
},
"dropCap": {
"type": "boolean",
diff --git a/packages/block-library/src/post-content/editor.scss b/packages/block-library/src/post-content/editor.scss
deleted file mode 100644
index 626774697aec5f..00000000000000
--- a/packages/block-library/src/post-content/editor.scss
+++ /dev/null
@@ -1,4 +0,0 @@
-// Disable text selection in the post content placeholder.
-.wp-block-post-content.wp-block-post-content {
- user-select: none;
-}
diff --git a/packages/block-library/src/post-navigation-link/block.json b/packages/block-library/src/post-navigation-link/block.json
index ce733759846fee..5f1b295119822a 100644
--- a/packages/block-library/src/post-navigation-link/block.json
+++ b/packages/block-library/src/post-navigation-link/block.json
@@ -34,6 +34,12 @@
"default": ""
}
},
+ "example": {
+ "attributes": {
+ "label": "Next post",
+ "arrow": "arrow"
+ }
+ },
"usesContext": [ "postType" ],
"supports": {
"reusable": false,
diff --git a/packages/block-library/src/post-navigation-link/variations.js b/packages/block-library/src/post-navigation-link/variations.js
index 945d6eb550f276..4f52b21338af1e 100644
--- a/packages/block-library/src/post-navigation-link/variations.js
+++ b/packages/block-library/src/post-navigation-link/variations.js
@@ -15,6 +15,12 @@ const variations = [
icon: next,
attributes: { type: 'next' },
scope: [ 'inserter', 'transform' ],
+ example: {
+ attributes: {
+ label: 'Next post',
+ arrow: 'arrow',
+ },
+ },
},
{
name: 'post-previous',
@@ -25,6 +31,12 @@ const variations = [
icon: previous,
attributes: { type: 'previous' },
scope: [ 'inserter', 'transform' ],
+ example: {
+ attributes: {
+ label: 'Previous post',
+ arrow: 'arrow',
+ },
+ },
},
];
diff --git a/packages/block-library/src/post-template/index.php b/packages/block-library/src/post-template/index.php
index 64cdd156a54310..9126355c096a57 100644
--- a/packages/block-library/src/post-template/index.php
+++ b/packages/block-library/src/post-template/index.php
@@ -64,11 +64,6 @@ function render_block_core_post_template( $attributes, $content, $block ) {
if ( in_the_loop() ) {
$query = clone $wp_query;
$query->rewind_posts();
-
- // If in a single post of any post type, default to the 'post' post type.
- if ( is_singular() ) {
- query_posts( array( 'post_type' => 'post' ) );
- }
} else {
$query = $wp_query;
}
diff --git a/packages/block-library/src/post-time-to-read/index.js b/packages/block-library/src/post-time-to-read/index.js
index 95b379f55f0b3f..039923161ca81d 100644
--- a/packages/block-library/src/post-time-to-read/index.js
+++ b/packages/block-library/src/post-time-to-read/index.js
@@ -12,6 +12,7 @@ export { metadata, name };
export const settings = {
icon,
edit,
+ example: {},
};
export const init = () => initBlock( { name, metadata, settings } );
diff --git a/packages/block-library/src/preformatted/block.json b/packages/block-library/src/preformatted/block.json
index a1726ee8b0d43c..c25b8ce37093a5 100644
--- a/packages/block-library/src/preformatted/block.json
+++ b/packages/block-library/src/preformatted/block.json
@@ -12,7 +12,7 @@
"source": "rich-text",
"selector": "pre",
"__unstablePreserveWhiteSpace": true,
- "__experimentalRole": "content"
+ "role": "content"
}
},
"supports": {
diff --git a/packages/block-library/src/pullquote/block.json b/packages/block-library/src/pullquote/block.json
index 0935f9759668d5..271bba74d0252a 100644
--- a/packages/block-library/src/pullquote/block.json
+++ b/packages/block-library/src/pullquote/block.json
@@ -11,13 +11,13 @@
"type": "rich-text",
"source": "rich-text",
"selector": "p",
- "__experimentalRole": "content"
+ "role": "content"
},
"citation": {
"type": "rich-text",
"source": "rich-text",
"selector": "cite",
- "__experimentalRole": "content"
+ "role": "content"
},
"textAlign": {
"type": "string"
diff --git a/packages/block-library/src/pullquote/deprecated.js b/packages/block-library/src/pullquote/deprecated.js
index 6e6f49da91c6a3..18e47997550782 100644
--- a/packages/block-library/src/pullquote/deprecated.js
+++ b/packages/block-library/src/pullquote/deprecated.js
@@ -75,14 +75,14 @@ const v5 = {
source: 'html',
selector: 'blockquote',
multiline: 'p',
- __experimentalRole: 'content',
+ role: 'content',
},
citation: {
type: 'string',
source: 'html',
selector: 'cite',
default: '',
- __experimentalRole: 'content',
+ role: 'content',
},
textAlign: {
type: 'string',
diff --git a/packages/block-library/src/query-no-results/block.json b/packages/block-library/src/query-no-results/block.json
index 8f3ba56adcc36a..2f656594afa306 100644
--- a/packages/block-library/src/query-no-results/block.json
+++ b/packages/block-library/src/query-no-results/block.json
@@ -8,6 +8,16 @@
"parent": [ "core/query" ],
"textdomain": "default",
"usesContext": [ "queryId", "query" ],
+ "example": {
+ "innerBlocks": [
+ {
+ "name": "core/paragraph",
+ "attributes": {
+ "content": "No posts were found."
+ }
+ }
+ ]
+ },
"supports": {
"align": true,
"reusable": false,
diff --git a/packages/block-library/src/query-pagination-numbers/index.js b/packages/block-library/src/query-pagination-numbers/index.js
index 3fd903e2d9ef48..f769f54b4ac034 100644
--- a/packages/block-library/src/query-pagination-numbers/index.js
+++ b/packages/block-library/src/query-pagination-numbers/index.js
@@ -16,6 +16,7 @@ export { metadata, name };
export const settings = {
icon,
edit,
+ example: {},
};
export const init = () => initBlock( { name, metadata, settings } );
diff --git a/packages/block-library/src/query-title/block.json b/packages/block-library/src/query-title/block.json
index de3e60214685c2..5d5c9113bda084 100644
--- a/packages/block-library/src/query-title/block.json
+++ b/packages/block-library/src/query-title/block.json
@@ -29,6 +29,11 @@
"default": true
}
},
+ "example": {
+ "attributes": {
+ "type": "search"
+ }
+ },
"supports": {
"align": [ "wide", "full" ],
"html": false,
diff --git a/packages/block-library/src/query/block.json b/packages/block-library/src/query/block.json
index 22bfa7b713801c..b2225192c6b218 100644
--- a/packages/block-library/src/query/block.json
+++ b/packages/block-library/src/query/block.json
@@ -5,6 +5,7 @@
"title": "Query Loop",
"category": "theme",
"description": "An advanced block that allows displaying post types based on different query parameters and visual configurations.",
+ "keywords": [ "posts", "list", "blog", "blogs", "custom post types" ],
"textdomain": "default",
"attributes": {
"queryId": {
diff --git a/packages/block-library/src/query/edit/inspector-controls/format-controls.js b/packages/block-library/src/query/edit/inspector-controls/format-controls.js
index d26fd9d81ce6f7..15c95f3bbba2e2 100644
--- a/packages/block-library/src/query/edit/inspector-controls/format-controls.js
+++ b/packages/block-library/src/query/edit/inspector-controls/format-controls.js
@@ -68,7 +68,7 @@ export default function FormatControls( { onChange, query: { format } } ) {
.filter( Boolean );
const suggestions = formats
- .filter( ( item ) => ! format.includes( item.value ) )
+ .filter( ( item ) => ! normalizedFormats.includes( item.value ) )
.map( ( item ) => item.label );
return (
diff --git a/packages/block-library/src/query/edit/inspector-controls/index.js b/packages/block-library/src/query/edit/inspector-controls/index.js
index 4085128e9aef1a..3128c3526926f9 100644
--- a/packages/block-library/src/query/edit/inspector-controls/index.js
+++ b/packages/block-library/src/query/edit/inspector-controls/index.js
@@ -321,7 +321,7 @@ export default function QueryInspectorControls( props ) {
dropdownMenuProps={ dropdownMenuProps }
>
perPage > 0 }
>
pages > 0 }
onDeselect={ () => setQuery( { pages: 0 } ) }
>
diff --git a/packages/block-library/src/query/edit/inspector-controls/pages-control.js b/packages/block-library/src/query/edit/inspector-controls/pages-control.js
index cde61453ea844d..06c6e32b66ad2a 100644
--- a/packages/block-library/src/query/edit/inspector-controls/pages-control.js
+++ b/packages/block-library/src/query/edit/inspector-controls/pages-control.js
@@ -8,7 +8,7 @@ export const PagesControl = ( { pages, onChange } ) => {
return (
{
diff --git a/packages/block-library/src/query/edit/inspector-controls/per-page-control.js b/packages/block-library/src/query/edit/inspector-controls/per-page-control.js
index 3e0dfbf50b70bd..933bb0851e6257 100644
--- a/packages/block-library/src/query/edit/inspector-controls/per-page-control.js
+++ b/packages/block-library/src/query/edit/inspector-controls/per-page-control.js
@@ -12,7 +12,7 @@ const PerPageControl = ( { perPage, offset = 0, onChange } ) => {
{
diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php
index d10db26529854e..043f351e11d7f1 100644
--- a/packages/block-library/src/query/index.php
+++ b/packages/block-library/src/query/index.php
@@ -24,27 +24,7 @@ function render_block_core_query( $attributes, $content, $block ) {
// Enqueue the script module and add the necessary directives if the block is
// interactive.
if ( $is_interactive ) {
- $suffix = wp_scripts_get_suffix();
- if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) {
- $module_url = gutenberg_url( '/build-module/block-library/query/view.min.js' );
- }
-
- wp_register_script_module(
- '@wordpress/block-library/query',
- isset( $module_url ) ? $module_url : includes_url( "blocks/query/view{$suffix}.js" ),
- array(
- array(
- 'id' => '@wordpress/interactivity',
- 'import' => 'static',
- ),
- array(
- 'id' => '@wordpress/interactivity-router',
- 'import' => 'dynamic',
- ),
- ),
- defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' )
- );
- wp_enqueue_script_module( '@wordpress/block-library/query' );
+ wp_enqueue_script_module( '@wordpress/block-library/query/view' );
$p = new WP_HTML_Tag_Processor( $content );
if ( $p->next_tag() ) {
diff --git a/packages/block-library/src/quote/block.json b/packages/block-library/src/quote/block.json
index 0f9ec97422f64b..2ae37f9f36f766 100644
--- a/packages/block-library/src/quote/block.json
+++ b/packages/block-library/src/quote/block.json
@@ -14,13 +14,13 @@
"selector": "blockquote",
"multiline": "p",
"default": "",
- "__experimentalRole": "content"
+ "role": "content"
},
"citation": {
"type": "rich-text",
"source": "rich-text",
"selector": "cite",
- "__experimentalRole": "content"
+ "role": "content"
},
"textAlign": {
"type": "string"
diff --git a/packages/block-library/src/quote/deprecated.js b/packages/block-library/src/quote/deprecated.js
index 77098b6e753139..4d3efd28e3a22c 100644
--- a/packages/block-library/src/quote/deprecated.js
+++ b/packages/block-library/src/quote/deprecated.js
@@ -70,14 +70,14 @@ const v4 = {
selector: 'blockquote',
multiline: 'p',
default: '',
- __experimentalRole: 'content',
+ role: 'content',
},
citation: {
type: 'string',
source: 'html',
selector: 'cite',
default: '',
- __experimentalRole: 'content',
+ role: 'content',
},
align: {
type: 'string',
@@ -138,14 +138,14 @@ const v3 = {
selector: 'blockquote',
multiline: 'p',
default: '',
- __experimentalRole: 'content',
+ role: 'content',
},
citation: {
type: 'string',
source: 'html',
selector: 'cite',
default: '',
- __experimentalRole: 'content',
+ role: 'content',
},
align: {
type: 'string',
diff --git a/packages/block-library/src/search/block.json b/packages/block-library/src/search/block.json
index dac4c6b488a97e..c5af5a29d21beb 100644
--- a/packages/block-library/src/search/block.json
+++ b/packages/block-library/src/search/block.json
@@ -10,7 +10,7 @@
"attributes": {
"label": {
"type": "string",
- "__experimentalRole": "content"
+ "role": "content"
},
"showLabel": {
"type": "boolean",
@@ -19,7 +19,7 @@
"placeholder": {
"type": "string",
"default": "",
- "__experimentalRole": "content"
+ "role": "content"
},
"width": {
"type": "number"
@@ -29,7 +29,7 @@
},
"buttonText": {
"type": "string",
- "__experimentalRole": "content"
+ "role": "content"
},
"buttonPosition": {
"type": "string",
diff --git a/packages/block-library/src/search/edit.js b/packages/block-library/src/search/edit.js
index e2f3bb3999e42c..d4ed5b7e3a4055 100644
--- a/packages/block-library/src/search/edit.js
+++ b/packages/block-library/src/search/edit.js
@@ -424,13 +424,12 @@ export default function SearchEdit( {
}
step={ 1 }
onChange={ ( newWidth ) => {
- const filteredWidth =
- widthUnit === '%' &&
- parseInt( newWidth, 10 ) > 100
- ? 100
- : newWidth;
+ const parsedNewWidth =
+ newWidth === ''
+ ? undefined
+ : parseInt( newWidth, 10 );
setAttributes( {
- width: parseInt( filteredWidth, 10 ),
+ width: parsedNewWidth,
} );
} }
onUnitChange={ ( newUnit ) => {
@@ -566,7 +565,11 @@ export default function SearchEdit( {
set_attribute( 'data-wp-bind--aria-hidden', '!context.isSearchInputVisible' );
$input->set_attribute( 'data-wp-bind--tabindex', 'state.tabindex' );
diff --git a/packages/block-library/src/site-logo/edit.js b/packages/block-library/src/site-logo/edit.js
index dc95d5906d7345..36c217c1bf0c79 100644
--- a/packages/block-library/src/site-logo/edit.js
+++ b/packages/block-library/src/site-logo/edit.js
@@ -564,6 +564,7 @@ export default function LogoEdit( {
iconId={ siteIconId }
canUserEdit={ canUserEdit }
/>
+ { canUserEdit && }
>
);
}
diff --git a/packages/block-library/src/social-links/editor.scss b/packages/block-library/src/social-links/editor.scss
index f9491cc068f159..11f1ed86d11220 100644
--- a/packages/block-library/src/social-links/editor.scss
+++ b/packages/block-library/src/social-links/editor.scss
@@ -101,19 +101,10 @@
.wp-block-social-links .block-list-appender {
position: static; // display inline.
- .block-editor-button-block-appender.components-button.components-button {
- padding: $grid-unit-10 - 2px;
- }
-}
-
-.wp-block-social-links {
- &.has-small-icon-size .block-editor-button-block-appender.components-button.components-button {
+ .block-editor-button-block-appender {
+ height: 1.5em;
+ width: 1.5em;
+ font-size: inherit;
padding: 0;
}
- &.has-large-icon-size .block-editor-button-block-appender.components-button.components-button {
- padding: $grid-unit-20 - 2px;
- }
- &.has-huge-icon-size .block-editor-button-block-appender.components-button.components-button {
- padding: $grid-unit-30 - 1px;
- }
}
diff --git a/packages/block-library/src/table-of-contents/block.json b/packages/block-library/src/table-of-contents/block.json
index 451d245d867b07..5eb6e729d3f03e 100644
--- a/packages/block-library/src/table-of-contents/block.json
+++ b/packages/block-library/src/table-of-contents/block.json
@@ -62,6 +62,57 @@
}
}
},
- "example": {},
+ "example": {
+ "innerBlocks": [
+ {
+ "name": "core/heading",
+ "attributes": {
+ "level": 2,
+ "content": "Heading"
+ }
+ },
+ {
+ "name": "core/heading",
+ "attributes": {
+ "level": 3,
+ "content": "Subheading"
+ }
+ },
+ {
+ "name": "core/heading",
+ "attributes": {
+ "level": 2,
+ "content": "Heading"
+ }
+ },
+ {
+ "name": "core/heading",
+ "attributes": {
+ "level": 3,
+ "content": "Subheading"
+ }
+ }
+ ],
+ "attributes": {
+ "headings": [
+ {
+ "content": "Heading",
+ "level": 2
+ },
+ {
+ "content": "Subheading",
+ "level": 3
+ },
+ {
+ "content": "Heading",
+ "level": 2
+ },
+ {
+ "content": "Subheading",
+ "level": 3
+ }
+ ]
+ }
+ },
"style": "wp-block-table-of-contents"
}
diff --git a/packages/block-library/src/term-description/index.js b/packages/block-library/src/term-description/index.js
index 0ff710a91f5d50..330ca05bd174e1 100644
--- a/packages/block-library/src/term-description/index.js
+++ b/packages/block-library/src/term-description/index.js
@@ -16,6 +16,7 @@ export { metadata, name };
export const settings = {
icon,
edit,
+ example: {},
};
export const init = () => initBlock( { name, metadata, settings } );
diff --git a/packages/block-library/src/verse/block.json b/packages/block-library/src/verse/block.json
index 387ff3dfe17123..81cccd72965b1a 100644
--- a/packages/block-library/src/verse/block.json
+++ b/packages/block-library/src/verse/block.json
@@ -13,7 +13,7 @@
"source": "rich-text",
"selector": "pre",
"__unstablePreserveWhiteSpace": true,
- "__experimentalRole": "content"
+ "role": "content"
},
"textAlign": {
"type": "string"
diff --git a/packages/block-library/src/verse/deprecated.js b/packages/block-library/src/verse/deprecated.js
index 7e3c96bc80cd98..bd4edc46738c5c 100644
--- a/packages/block-library/src/verse/deprecated.js
+++ b/packages/block-library/src/verse/deprecated.js
@@ -46,7 +46,7 @@ const v2 = {
selector: 'pre',
default: '',
__unstablePreserveWhiteSpace: true,
- __experimentalRole: 'content',
+ role: 'content',
},
textAlign: {
type: 'string',
diff --git a/packages/block-library/src/video/block.json b/packages/block-library/src/video/block.json
index 1d3dc75961e8f1..d2dcd95365c3b5 100644
--- a/packages/block-library/src/video/block.json
+++ b/packages/block-library/src/video/block.json
@@ -18,7 +18,7 @@
"type": "rich-text",
"source": "rich-text",
"selector": "figcaption",
- "__experimentalRole": "content"
+ "role": "content"
},
"controls": {
"type": "boolean",
@@ -29,7 +29,7 @@
},
"id": {
"type": "number",
- "__experimentalRole": "content"
+ "role": "content"
},
"loop": {
"type": "boolean",
@@ -58,14 +58,14 @@
},
"blob": {
"type": "string",
- "__experimentalRole": "local"
+ "role": "local"
},
"src": {
"type": "string",
"source": "attribute",
"selector": "video",
"attribute": "src",
- "__experimentalRole": "content"
+ "role": "content"
},
"playsInline": {
"type": "boolean",
@@ -74,7 +74,7 @@
"attribute": "playsinline"
},
"tracks": {
- "__experimentalRole": "content",
+ "role": "content",
"type": "array",
"items": {
"type": "object"
diff --git a/packages/blocks/README.md b/packages/blocks/README.md
index d724f986b0ca81..f4805e1c60b381 100644
--- a/packages/blocks/README.md
+++ b/packages/blocks/README.md
@@ -102,6 +102,47 @@ _Returns_
- `Object`: All block attributes.
+### getBlockAttributesNamesByRole
+
+Filter block attributes by `role` and return their names.
+
+_Parameters_
+
+- _name_ `string`: Block attribute's name.
+- _role_ `string`: The role of a block attribute.
+
+_Returns_
+
+- `string[]`: The attribute names that have the provided role.
+
+### getBlockBindingsSource
+
+Returns a registered block bindings source by its name.
+
+_Parameters_
+
+- _name_ `string`: Block bindings source name.
+
+_Returns_
+
+- `?Object`: Block bindings source.
+
+_Changelog_
+
+`6.7.0` Introduced in WordPress core.
+
+### getBlockBindingsSources
+
+Returns all registered block bindings sources.
+
+_Returns_
+
+- `Array`: Block bindings sources.
+
+_Changelog_
+
+`6.7.0` Introduced in WordPress core.
+
### getBlockContent
Given a block object, returns the Block's Inner HTML markup.
@@ -479,6 +520,40 @@ _Returns_
- `Array`: A list of blocks.
+### registerBlockBindingsSource
+
+Registers a new block bindings source with an object defining its behavior. Once registered, the source is available to be connected to the supported block attributes.
+
+_Usage_
+
+```js
+import { _x } from '@wordpress/i18n';
+import { registerBlockBindingsSource } from '@wordpress/blocks';
+
+registerBlockBindingsSource( {
+ name: 'plugin/my-custom-source',
+ label: _x( 'My Custom Source', 'block bindings source' ),
+ usesContext: [ 'postType' ],
+ getValues: getSourceValues,
+ setValues: updateMyCustomValuesInBatch,
+ canUserEditValue: () => true,
+} );
+```
+
+_Parameters_
+
+- _source_ `Object`: Properties of the source to be registered.
+- _source.name_ `string`: The unique and machine-readable name.
+- _source.label_ `[string]`: Human-readable label. Optional when it is defined in the server.
+- _source.usesContext_ `[Array]`: Optional array of context needed by the source only in the editor.
+- _source.getValues_ `[Function]`: Optional function to get the values from the source.
+- _source.setValues_ `[Function]`: Optional function to update multiple values connected to the source.
+- _source.canUserEditValue_ `[Function]`: Optional function to determine if the user can edit the value.
+
+_Changelog_
+
+`6.7.0` Introduced in WordPress core.
+
### registerBlockCollection
Registers a new block collection to group blocks in the same namespace in the inserter.
@@ -780,6 +855,26 @@ _Returns_
- `Array`: Updated Block list.
+### unregisterBlockBindingsSource
+
+Unregisters a block bindings source by providing its name.
+
+_Usage_
+
+```js
+import { unregisterBlockBindingsSource } from '@wordpress/blocks';
+
+unregisterBlockBindingsSource( 'plugin/my-custom-source' );
+```
+
+_Parameters_
+
+- _name_ `string`: The name of the block bindings source to unregister.
+
+_Changelog_
+
+`6.7.0` Introduced in WordPress core.
+
### unregisterBlockStyle
Unregisters a block style for the given block.
diff --git a/packages/blocks/src/api/index.js b/packages/blocks/src/api/index.js
index 803467cb2187e2..0b38b8e29e68a0 100644
--- a/packages/blocks/src/api/index.js
+++ b/packages/blocks/src/api/index.js
@@ -2,12 +2,7 @@
* Internal dependencies
*/
import { lock } from '../lock-unlock';
-import {
- registerBlockBindingsSource,
- unregisterBlockBindingsSource,
- getBlockBindingsSource,
- getBlockBindingsSources,
-} from './registration';
+import { isUnmodifiedBlockContent } from './utils';
// The blocktype is the most important concept within the block API. It defines
// all aspects of the block configuration and its interfaces, including `edit`
@@ -146,6 +141,10 @@ export {
unregisterBlockStyle,
registerBlockVariation,
unregisterBlockVariation,
+ registerBlockBindingsSource,
+ unregisterBlockBindingsSource,
+ getBlockBindingsSource,
+ getBlockBindingsSources,
} from './registration';
export {
isUnmodifiedBlock,
@@ -155,6 +154,7 @@ export {
getBlockLabel as __experimentalGetBlockLabel,
getAccessibleBlockLabel as __experimentalGetAccessibleBlockLabel,
__experimentalSanitizeBlockAttributes,
+ getBlockAttributesNamesByRole,
__experimentalGetBlockAttributesNamesByRole,
} from './utils';
@@ -177,9 +177,4 @@ export {
} from './constants';
export const privateApis = {};
-lock( privateApis, {
- registerBlockBindingsSource,
- unregisterBlockBindingsSource,
- getBlockBindingsSource,
- getBlockBindingsSources,
-} );
+lock( privateApis, { isUnmodifiedBlockContent } );
diff --git a/packages/blocks/src/api/registration.js b/packages/blocks/src/api/registration.js
index b0f5ae350759f0..31be38b861c284 100644
--- a/packages/blocks/src/api/registration.js
+++ b/packages/blocks/src/api/registration.js
@@ -767,14 +767,15 @@ export const unregisterBlockVariation = ( blockName, variationName ) => {
* behavior. Once registered, the source is available to be connected
* to the supported block attributes.
*
+ * @since 6.7.0 Introduced in WordPress core.
+ *
* @param {Object} source Properties of the source to be registered.
* @param {string} source.name The unique and machine-readable name.
- * @param {string} [source.label] Human-readable label.
- * @param {Array} [source.usesContext] Array of context needed by the source only in the editor.
- * @param {Function} [source.getValues] Function to get the values from the source.
- * @param {Function} [source.setValues] Function to update multiple values connected to the source.
- * @param {Function} [source.canUserEditValue] Function to determine if the user can edit the value.
- * @param {Function} [source.getFieldsList] Function to get the lists of fields to expose in the connections panel.
+ * @param {string} [source.label] Human-readable label. Optional when it is defined in the server.
+ * @param {Array} [source.usesContext] Optional array of context needed by the source only in the editor.
+ * @param {Function} [source.getValues] Optional function to get the values from the source.
+ * @param {Function} [source.setValues] Optional function to update multiple values connected to the source.
+ * @param {Function} [source.canUserEditValue] Optional function to determine if the user can edit the value.
*
* @example
* ```js
@@ -784,8 +785,9 @@ export const unregisterBlockVariation = ( blockName, variationName ) => {
* registerBlockBindingsSource( {
* name: 'plugin/my-custom-source',
* label: _x( 'My Custom Source', 'block bindings source' ),
- * getValues: () => getSourceValues(),
- * setValues: () => updateMyCustomValuesInBatch(),
+ * usesContext: [ 'postType' ],
+ * getValues: getSourceValues,
+ * setValues: updateMyCustomValuesInBatch,
* canUserEditValue: () => true,
* } );
* ```
@@ -903,7 +905,9 @@ export const registerBlockBindingsSource = ( source ) => {
};
/**
- * Unregisters a block bindings source
+ * Unregisters a block bindings source by providing its name.
+ *
+ * @since 6.7.0 Introduced in WordPress core.
*
* @param {string} name The name of the block bindings source to unregister.
*
@@ -924,7 +928,9 @@ export function unregisterBlockBindingsSource( name ) {
}
/**
- * Returns a registered block bindings source.
+ * Returns a registered block bindings source by its name.
+ *
+ * @since 6.7.0 Introduced in WordPress core.
*
* @param {string} name Block bindings source name.
*
@@ -937,6 +943,8 @@ export function getBlockBindingsSource( name ) {
/**
* Returns all registered block bindings sources.
*
+ * @since 6.7.0 Introduced in WordPress core.
+ *
* @return {Array} Block bindings sources.
*/
export function getBlockBindingsSources() {
diff --git a/packages/blocks/src/api/serializer.js b/packages/blocks/src/api/serializer.js
index 2e7246ce9584a9..f1fb28e9d9a361 100644
--- a/packages/blocks/src/api/serializer.js
+++ b/packages/blocks/src/api/serializer.js
@@ -10,6 +10,7 @@ import {
import { hasFilter, applyFilters } from '@wordpress/hooks';
import isShallowEqual from '@wordpress/is-shallow-equal';
import { removep } from '@wordpress/autop';
+import deprecated from '@wordpress/deprecated';
/**
* Internal dependencies
@@ -238,7 +239,17 @@ export function getCommentAttributes( blockType, attributes ) {
}
// Ignore all local attributes
+ if ( attributeSchema.role === 'local' ) {
+ return accumulator;
+ }
+
if ( attributeSchema.__experimentalRole === 'local' ) {
+ deprecated( '__experimentalRole attribute', {
+ since: '6.7',
+ version: '6.8',
+ alternative: 'role attribute',
+ hint: `Check the block.json of the ${ blockType?.name } block.`,
+ } );
return accumulator;
}
diff --git a/packages/blocks/src/api/test/serializer.js b/packages/blocks/src/api/test/serializer.js
index 7fed23041daaa6..3c1cbd6d1e74ff 100644
--- a/packages/blocks/src/api/test/serializer.js
+++ b/packages/blocks/src/api/test/serializer.js
@@ -155,7 +155,7 @@ describe( 'block serializer', () => {
attributes: {
blob: {
type: 'string',
- __experimentalRole: 'local',
+ role: 'local',
},
url: {
type: 'string',
diff --git a/packages/blocks/src/api/test/utils.js b/packages/blocks/src/api/test/utils.js
index 9bfef69c4c1428..ad76e89aafe5f0 100644
--- a/packages/blocks/src/api/test/utils.js
+++ b/packages/blocks/src/api/test/utils.js
@@ -13,7 +13,7 @@ import {
getAccessibleBlockLabel,
getBlockLabel,
__experimentalSanitizeBlockAttributes,
- __experimentalGetBlockAttributesNamesByRole,
+ getBlockAttributesNamesByRole,
} from '../utils';
const noop = () => {};
@@ -309,7 +309,7 @@ describe( 'sanitizeBlockAttributes', () => {
} );
} );
-describe( '__experimentalGetBlockAttributesNamesByRole', () => {
+describe( 'getBlockAttributesNamesByRole', () => {
beforeAll( () => {
registerBlockType( 'core/test-block-1', {
attributes: {
@@ -318,15 +318,15 @@ describe( '__experimentalGetBlockAttributesNamesByRole', () => {
},
content: {
type: 'boolean',
- __experimentalRole: 'content',
+ role: 'content',
},
level: {
type: 'number',
- __experimentalRole: 'content',
+ role: 'content',
},
color: {
type: 'string',
- __experimentalRole: 'other',
+ role: 'other',
},
},
save: noop,
@@ -357,42 +357,28 @@ describe( '__experimentalGetBlockAttributesNamesByRole', () => {
].forEach( unregisterBlockType );
} );
it( 'should return empty array if block has no attributes', () => {
- expect(
- __experimentalGetBlockAttributesNamesByRole( 'core/test-block-3' )
- ).toEqual( [] );
+ expect( getBlockAttributesNamesByRole( 'core/test-block-3' ) ).toEqual(
+ []
+ );
} );
it( 'should return all attribute names if no role is provided', () => {
- expect(
- __experimentalGetBlockAttributesNamesByRole( 'core/test-block-1' )
- ).toEqual(
+ expect( getBlockAttributesNamesByRole( 'core/test-block-1' ) ).toEqual(
expect.arrayContaining( [ 'align', 'content', 'level', 'color' ] )
);
} );
it( 'should return proper results with existing attributes and provided role', () => {
expect(
- __experimentalGetBlockAttributesNamesByRole(
- 'core/test-block-1',
- 'content'
- )
+ getBlockAttributesNamesByRole( 'core/test-block-1', 'content' )
).toEqual( expect.arrayContaining( [ 'content', 'level' ] ) );
expect(
- __experimentalGetBlockAttributesNamesByRole(
- 'core/test-block-1',
- 'other'
- )
+ getBlockAttributesNamesByRole( 'core/test-block-1', 'other' )
).toEqual( [ 'color' ] );
expect(
- __experimentalGetBlockAttributesNamesByRole(
- 'core/test-block-1',
- 'not-exists'
- )
+ getBlockAttributesNamesByRole( 'core/test-block-1', 'not-exists' )
).toEqual( [] );
// A block with no `role` in any attributes.
expect(
- __experimentalGetBlockAttributesNamesByRole(
- 'core/test-block-2',
- 'content'
- )
+ getBlockAttributesNamesByRole( 'core/test-block-2', 'content' )
).toEqual( [] );
} );
} );
diff --git a/packages/blocks/src/api/utils.js b/packages/blocks/src/api/utils.js
index a68937586f9273..7bace4ff84c29b 100644
--- a/packages/blocks/src/api/utils.js
+++ b/packages/blocks/src/api/utils.js
@@ -12,6 +12,7 @@ import { Component, isValidElement } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { __unstableStripHTML as stripHTML } from '@wordpress/dom';
import { RichTextData } from '@wordpress/rich-text';
+import deprecated from '@wordpress/deprecated';
/**
* Internal dependencies
@@ -29,6 +30,30 @@ extend( [ namesPlugin, a11yPlugin ] );
*/
const ICON_COLORS = [ '#191e23', '#f8f9f9' ];
+/**
+ * Determines whether the block's attribute is equal to the default attribute
+ * which means the attribute is unmodified.
+ * @param {Object} attributeDefinition The attribute's definition of the block type.
+ * @param {*} value The attribute's value.
+ * @return {boolean} Whether the attribute is unmodified.
+ */
+function isUnmodifiedAttribute( attributeDefinition, value ) {
+ // Every attribute that has a default must match the default.
+ if ( attributeDefinition.hasOwnProperty( 'default' ) ) {
+ return value === attributeDefinition.default;
+ }
+
+ // The rich text type is a bit different from the rest because it
+ // has an implicit default value of an empty RichTextData instance,
+ // so check the length of the value.
+ if ( attributeDefinition.type === 'rich-text' ) {
+ return ! value?.length;
+ }
+
+ // Every attribute that doesn't have a default should be undefined.
+ return value === undefined;
+}
+
/**
* Determines whether the block's attributes are equal to the default attributes
* which means the block is unmodified.
@@ -42,20 +67,7 @@ export function isUnmodifiedBlock( block ) {
( [ key, definition ] ) => {
const value = block.attributes[ key ];
- // Every attribute that has a default must match the default.
- if ( definition.hasOwnProperty( 'default' ) ) {
- return value === definition.default;
- }
-
- // The rich text type is a bit different from the rest because it
- // has an implicit default value of an empty RichTextData instance,
- // so check the length of the value.
- if ( definition.type === 'rich-text' ) {
- return ! value?.length;
- }
-
- // Every attribute that doesn't have a default should be undefined.
- return value === undefined;
+ return isUnmodifiedAttribute( definition, value );
}
);
}
@@ -72,6 +84,35 @@ export function isUnmodifiedDefaultBlock( block ) {
return block.name === getDefaultBlockName() && isUnmodifiedBlock( block );
}
+/**
+ * Determines whether the block content is unmodified. A block content is
+ * considered unmodified if all the attributes that have a role of 'content'
+ * are equal to the default attributes (or undefined).
+ * If the block does not have any attributes with a role of 'content', it
+ * will be considered unmodified if all the attributes are equal to the default
+ * attributes (or undefined).
+ *
+ * @param {WPBlock} block Block Object
+ * @return {boolean} Whether the block content is unmodified.
+ */
+export function isUnmodifiedBlockContent( block ) {
+ const contentAttributes = getBlockAttributesNamesByRole(
+ block.name,
+ 'content'
+ );
+
+ if ( contentAttributes.length === 0 ) {
+ return isUnmodifiedBlock( block );
+ }
+
+ return contentAttributes.every( ( key ) => {
+ const definition = getBlockType( block.name )?.attributes[ key ];
+ const value = block.attributes[ key ];
+
+ return isUnmodifiedAttribute( definition, value );
+ } );
+}
+
/**
* Function that checks if the parameter is a valid icon.
*
@@ -332,7 +373,7 @@ export function __experimentalSanitizeBlockAttributes( name, attributes ) {
*
* @return {string[]} The attribute names that have the provided role.
*/
-export function __experimentalGetBlockAttributesNamesByRole( name, role ) {
+export function getBlockAttributesNamesByRole( name, role ) {
const attributes = getBlockType( name )?.attributes;
if ( ! attributes ) {
return [];
@@ -341,12 +382,34 @@ export function __experimentalGetBlockAttributesNamesByRole( name, role ) {
if ( ! role ) {
return attributesNames;
}
- return attributesNames.filter(
- ( attributeName ) =>
- attributes[ attributeName ]?.__experimentalRole === role
- );
+
+ return attributesNames.filter( ( attributeName ) => {
+ const attribute = attributes[ attributeName ];
+ if ( attribute?.role === role ) {
+ return true;
+ }
+ if ( attribute?.__experimentalRole === role ) {
+ deprecated( '__experimentalRole attribute', {
+ since: '6.7',
+ version: '6.8',
+ alternative: 'role attribute',
+ hint: `Check the block.json of the ${ name } block.`,
+ } );
+ return true;
+ }
+ return false;
+ } );
}
+export const __experimentalGetBlockAttributesNamesByRole = ( ...args ) => {
+ deprecated( '__experimentalGetBlockAttributesNamesByRole', {
+ since: '6.7',
+ version: '6.8',
+ alternative: 'getBlockAttributesNamesByRole',
+ } );
+ return getBlockAttributesNamesByRole( ...args );
+};
+
/**
* Return a new object with the specified keys omitted.
*
diff --git a/packages/blocks/src/store/private-selectors.js b/packages/blocks/src/store/private-selectors.js
index 4cded8268ae97c..d5665323859e40 100644
--- a/packages/blocks/src/store/private-selectors.js
+++ b/packages/blocks/src/store/private-selectors.js
@@ -2,6 +2,7 @@
* WordPress dependencies
*/
import { createSelector } from '@wordpress/data';
+import deprecated from '@wordpress/deprecated';
/**
* Internal dependencies
@@ -209,3 +210,36 @@ export function getAllBlockBindingsSources( state ) {
export function getBlockBindingsSource( state, sourceName ) {
return state.blockBindingsSources[ sourceName ];
}
+
+/**
+ * Determines if any of the block type's attributes have
+ * the content role attribute.
+ *
+ * @param {Object} state Data state.
+ * @param {string} blockTypeName Block type name.
+ * @return {boolean} Whether block type has content role attribute.
+ */
+export const hasContentRoleAttribute = ( state, blockTypeName ) => {
+ const blockType = getBlockType( state, blockTypeName );
+ if ( ! blockType ) {
+ return false;
+ }
+
+ return Object.values( blockType.attributes ).some(
+ ( { role, __experimentalRole } ) => {
+ if ( role === 'content' ) {
+ return true;
+ }
+ if ( __experimentalRole === 'content' ) {
+ deprecated( '__experimentalRole attribute', {
+ since: '6.7',
+ version: '6.8',
+ alternative: 'role attribute',
+ hint: `Check the block.json of the ${ blockTypeName } block.`,
+ } );
+ return true;
+ }
+ return false;
+ }
+ );
+};
diff --git a/packages/blocks/src/store/reducer.js b/packages/blocks/src/store/reducer.js
index fbcec7a619cf63..7c7fb4763a1cb6 100644
--- a/packages/blocks/src/store/reducer.js
+++ b/packages/blocks/src/store/reducer.js
@@ -393,6 +393,13 @@ function getMergedUsesContext( existingUsesContext = [], newUsesContext = [] ) {
export function blockBindingsSources( state = {}, action ) {
switch ( action.type ) {
case 'ADD_BLOCK_BINDINGS_SOURCE':
+ // Only open this API in Gutenberg and for `core/post-meta` for the moment.
+ let getFieldsList;
+ if ( globalThis.IS_GUTENBERG_PLUGIN ) {
+ getFieldsList = action.getFieldsList;
+ } else if ( action.name === 'core/post-meta' ) {
+ getFieldsList = action.getFieldsList;
+ }
return {
...state,
[ action.name ]: {
@@ -404,8 +411,10 @@ export function blockBindingsSources( state = {}, action ) {
),
getValues: action.getValues,
setValues: action.setValues,
- canUserEditValue: action.canUserEditValue,
- getFieldsList: action.getFieldsList,
+ // Only set `canUserEditValue` if `setValues` is also defined.
+ canUserEditValue:
+ action.setValues && action.canUserEditValue,
+ getFieldsList,
},
};
case 'ADD_BOOTSTRAPPED_BLOCK_BINDINGS_SOURCE':
diff --git a/packages/blocks/src/store/selectors.js b/packages/blocks/src/store/selectors.js
index e97048e92b0c07..79e88073ba20de 100644
--- a/packages/blocks/src/store/selectors.js
+++ b/packages/blocks/src/store/selectors.js
@@ -8,11 +8,13 @@ import removeAccents from 'remove-accents';
*/
import { createSelector } from '@wordpress/data';
import { RichTextData } from '@wordpress/rich-text';
+import deprecated from '@wordpress/deprecated';
/**
* Internal dependencies
*/
import { getValueFromObjectPath, matchesAttributes } from './utils';
+import { hasContentRoleAttribute as privateHasContentRoleAttribute } from './private-selectors';
/** @typedef {import('../api/registration').WPBlockVariation} WPBlockVariation */
/** @typedef {import('../api/registration').WPBlockVariationScope} WPBlockVariationScope */
@@ -822,23 +824,11 @@ export const hasChildBlocksWithInserterSupport = ( state, blockName ) => {
} );
};
-/**
- * DO-NOT-USE in production.
- * This selector is created for internal/experimental only usage and may be
- * removed anytime without any warning, causing breakage on any plugin or theme invoking it.
- */
-export const __experimentalHasContentRoleAttribute = createSelector(
- ( state, blockTypeName ) => {
- const blockType = getBlockType( state, blockTypeName );
- if ( ! blockType ) {
- return false;
- }
-
- return Object.entries( blockType.attributes ).some(
- ( [ , { __experimentalRole } ] ) => __experimentalRole === 'content'
- );
- },
- ( state, blockTypeName ) => [
- state.blockTypes[ blockTypeName ]?.attributes,
- ]
-);
+export const __experimentalHasContentRoleAttribute = ( ...args ) => {
+ deprecated( '__experimentalHasContentRoleAttribute', {
+ since: '6.7',
+ version: '6.8',
+ hint: 'This is a private selector.',
+ } );
+ return privateHasContentRoleAttribute( ...args );
+};
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index d7e8b191229893..449abca7b6420c 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -2,6 +2,34 @@
## Unreleased
+### Bug Fixes
+
+- `ToolsPanel`: atomic one-step state update when (un)registering panels ([#65564](https://github.com/WordPress/gutenberg/pull/65564)).
+- `Navigator`: fix `isInitial` logic ([#65527](https://github.com/WordPress/gutenberg/pull/65527)).
+- `ToggleGroupControl`: Fix arrow key navigation in RTL ([#65735](https://github.com/WordPress/gutenberg/pull/65735)).
+- `ToggleGroupControl`: indicator doesn't jump around when the layout around it changes ([#65175](https://github.com/WordPress/gutenberg/pull/65175)).
+- `Composite`: fix legacy support for the store prop ([#65821](https://github.com/WordPress/gutenberg/pull/65821)).
+- `Composite`: make items tabbable if active element gets removed ([#65720](https://github.com/WordPress/gutenberg/pull/65720)).
+
+### Deprecations
+
+- `__experimentalBorderControl` can now be imported as a stable `BorderControl` ([#65475](https://github.com/WordPress/gutenberg/pull/65475)).
+- `__experimentalBorderBoxControl` can now be imported as a stable `BorderBoxControl` ([#65586](https://github.com/WordPress/gutenberg/pull/65586)).
+- `__experimentalNavigator*` components can now be imported as a stable `Navigator`. Similarly, the `__experimentalUseNavigator` hook can be imported as a stable `useNavigator` ([#65802](https://github.com/WordPress/gutenberg/pull/65802)).
+
+### Enhancements
+
+- `Tabs`: handle horizontal overflow and large tab lists gracefully ([#64371](https://github.com/WordPress/gutenberg/pull/64371)).
+- `BorderControl`: promote to stable ([#65475](https://github.com/WordPress/gutenberg/pull/65475)).
+- `BorderBoxControl`: promote to stable ([#65586](https://github.com/WordPress/gutenberg/pull/65586)).
+- `MenuGroup`: Simplify the MenuGroup styles within dropdown menus. ([#65561](https://github.com/WordPress/gutenberg/pull/65561)).
+- `DatePicker`: Use compact button size. ([#65653](https://github.com/WordPress/gutenberg/pull/65653)).
+- `Navigator`: add support for exit animation ([#64777](https://github.com/WordPress/gutenberg/pull/64777)).
+- `Guide`: Update finish button to use the new default size ([#65680](https://github.com/WordPress/gutenberg/pull/65680)).
+- `BorderControl`: Use `__next40pxDefaultSize` prop for Reset button ([#65682](https://github.com/WordPress/gutenberg/pull/65682)).
+- `Navigator`: stabilize APIs ([#64613](https://github.com/WordPress/gutenberg/pull/64613)).
+- `ToggleGroupControl`: indicator animation is now more lightweight and performant ([#65175](https://github.com/WordPress/gutenberg/pull/65175)).
+
## 28.8.0 (2024-09-19)
### Bug Fixes
@@ -9,11 +37,13 @@
- `Tabs`: restore vertical indicator ([#65385](https://github.com/WordPress/gutenberg/pull/65385)).
- `Tabs`: indicator positioning under RTL direction ([#64926](https://github.com/WordPress/gutenberg/pull/64926)).
- `Popover`: Update `toolbar` variant radius to match block toolbar ([#65263](https://github.com/WordPress/gutenberg/pull/65263)).
+- `MenuItemsChoice`: Allow menu items height to adapt to its content ([#65204](https://github.com/WordPress/gutenberg/pull/65204)).
- `BoxControl`: Unify input filed width whether linked or not ([#65348](https://github.com/WordPress/gutenberg/pull/65348)).
### Deprecations
- Deprecate `__unstableComposite`, `__unstableCompositeGroup`, `__unstableCompositeItem` and `__unstableUseCompositeState`. Consumers of the package should use the stable `Composite` component instead ([#63572](https://github.com/WordPress/gutenberg/pull/63572)).
+- `__experimentalBoxControl` can now be imported as a stable `BoxControl` ([#65469](https://github.com/WordPress/gutenberg/pull/65469)).
### New Features
@@ -34,6 +64,7 @@
- `Tooltip`: Adopt elevation scale ([#65159](https://github.com/WordPress/gutenberg/pull/65159)).
- `Modal`: add exit animation for internally triggered events ([#65203](https://github.com/WordPress/gutenberg/pull/65203)).
- `Card`: Adopt radius scale ([#65053](https://github.com/WordPress/gutenberg/pull/65053)).
+- `BoxControl`: promote to stable ([#65469](https://github.com/WordPress/gutenberg/pull/65469)).
### Bug Fixes
diff --git a/packages/components/src/autocomplete/index.tsx b/packages/components/src/autocomplete/index.tsx
index ef0fefe199c2e3..ad930d3affdd14 100644
--- a/packages/components/src/autocomplete/index.tsx
+++ b/packages/components/src/autocomplete/index.tsx
@@ -72,6 +72,9 @@ const getNodeText = ( node: React.ReactNode ): string => {
const EMPTY_FILTERED_OPTIONS: KeyedOption[] = [];
+// Used for generating the instance ID
+const AUTOCOMPLETE_HOOK_REFERENCE = {};
+
export function useAutocomplete( {
record,
onChange,
@@ -79,7 +82,7 @@ export function useAutocomplete( {
completers,
contentRef,
}: UseAutocompleteProps ) {
- const instanceId = useInstanceId( useAutocomplete );
+ const instanceId = useInstanceId( AUTOCOMPLETE_HOOK_REFERENCE );
const [ selectedIndex, setSelectedIndex ] = useState( 0 );
const [ filteredOptions, setFilteredOptions ] = useState<
diff --git a/packages/components/src/border-box-control/border-box-control/README.md b/packages/components/src/border-box-control/border-box-control/README.md
index 5ec2263bf16741..e67a1386103c1a 100644
--- a/packages/components/src/border-box-control/border-box-control/README.md
+++ b/packages/components/src/border-box-control/border-box-control/README.md
@@ -1,12 +1,7 @@
# BorderBoxControl
-
-This feature is still experimental. āExperimentalā means this is an early implementation subject to drastic and breaking changes.
-
-
-
-This component provides users with the ability to configure a single "flat"
-border or separate borders per side.
+An input control for the color, style, and width of the border of a box. The
+border can be customized as a whole, or individually for each side of the box.
## Development guidelines
@@ -28,7 +23,7 @@ show "Mixed" placeholder text.
```jsx
import { useState } from 'react';
-import { __experimentalBorderBoxControl as BorderBoxControl } from '@wordpress/components';
+import { BorderBoxControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
const colors = [
@@ -76,35 +71,35 @@ colors are organized by multiple origins.
Each color may be an object containing a `name` and `color` value.
-- Required: No
-- Default: `[]`
+- Required: No
+- Default: `[]`
### `disableCustomColors`: `boolean`
This toggles the ability to choose custom colors.
-- Required: No
+- Required: No
### `enableAlpha`: `boolean`
This controls whether the alpha channel will be offered when selecting
custom colors.
-- Required: No
-- Default: `false`
+- Required: No
+- Default: `false`
### `enableStyle`: `boolean`
This controls whether to support border style selections.
-- Required: No
-- Default: `true`
+- Required: No
+- Default: `true`
### `hideLabelFromVision`: `boolean`
Provides control over whether the label will only be visible to screen readers.
-- Required: No
+- Required: No
### `label`: `string`
@@ -113,7 +108,7 @@ If provided, a label will be generated using this as the content.
_Whether it is visible only to screen readers is controlled via
`hideLabelFromVision`._
-- Required: No
+- Required: No
### `onChange`: `( value?: Object ) => void`
@@ -123,7 +118,7 @@ borders, or `undefined`.
_Note: The will be `undefined` if a user clears all borders._
-- Required: Yes
+- Required: Yes
### `popoverPlacement`: `string`
@@ -133,21 +128,21 @@ By default, popovers are displayed relative to the button that initiated the pop
The available base placements are 'top', 'right', 'bottom', 'left'. Each of these base placements has an alignment in the form -start and -end. For example, 'right-start', or 'bottom-end'. These allow you to align the tooltip to the edges of the button, rather than centering it.
-- Required: No
+- Required: No
### `popoverOffset`: `number`
The space between the popover and the control wrapper.
-- Required: No
+- Required: No
### `size`: `string`
Size of the control.
-- Required: No
-- Default: `default`
-- Allowed values: `default`, `__unstable-large`
+- Required: No
+- Default: `default`
+- Allowed values: `default`, `__unstable-large`
### `value`: `Object`
@@ -158,6 +153,7 @@ properties or a "split" border which defines the previous properties but for
each side; `top`, `right`, `bottom`, and `left`.
Examples:
+
```js
const flatBorder = { color: '#72aee6', style: 'solid', width: '1px' };
const splitBorders = {
@@ -168,11 +164,11 @@ const splitBorders = {
};
```
-- Required: No
+- Required: No
### `__next40pxDefaultSize`: `boolean`
Start opting into the larger default height that will become the default size in a future version.
-- Required: No
-- Default: `false`
+- Required: No
+- Default: `false`
diff --git a/packages/components/src/border-box-control/border-box-control/component.tsx b/packages/components/src/border-box-control/border-box-control/component.tsx
index 26967ad7f63ddb..1dd3437aa50de4 100644
--- a/packages/components/src/border-box-control/border-box-control/component.tsx
+++ b/packages/components/src/border-box-control/border-box-control/component.tsx
@@ -147,22 +147,11 @@ const UnconnectedBorderBoxControl = (
};
/**
- * The `BorderBoxControl` effectively has two view states. The first, a "linked"
- * view, allows configuration of a flat border via a single `BorderControl`.
- * The second, a "split" view, contains a `BorderControl` for each side
- * as well as a visualizer for the currently selected borders. Each view also
- * contains a button to toggle between the two.
- *
- * When switching from the "split" view to "linked", if the individual side
- * borders are not consistent, the "linked" view will display any border
- * properties selections that are consistent while showing a mixed state for
- * those that aren't. For example, if all borders had the same color and style
- * but different widths, then the border dropdown in the "linked" view's
- * `BorderControl` would show that consistent color and style but the "linked"
- * view's width input would show "Mixed" placeholder text.
+ * An input control for the color, style, and width of the border of a box. The
+ * border can be customized as a whole, or individually for each side of the box.
*
* ```jsx
- * import { __experimentalBorderBoxControl as BorderBoxControl } from '@wordpress/components';
+ * import { BorderBoxControl } from '@wordpress/components';
* import { __ } from '@wordpress/i18n';
*
* const colors = [
diff --git a/packages/components/src/border-box-control/stories/index.story.tsx b/packages/components/src/border-box-control/stories/index.story.tsx
index 5b5d7f311208c0..5341dacab646eb 100644
--- a/packages/components/src/border-box-control/stories/index.story.tsx
+++ b/packages/components/src/border-box-control/stories/index.story.tsx
@@ -16,7 +16,7 @@ import Button from '../../button';
import { BorderBoxControl } from '../';
const meta: Meta< typeof BorderBoxControl > = {
- title: 'Components (Experimental)/BorderBoxControl',
+ title: 'Components/BorderBoxControl',
component: BorderBoxControl,
argTypes: {
onChange: { action: 'onChange' },
@@ -83,4 +83,5 @@ export const Default = Template.bind( {} );
Default.args = {
colors,
label: 'Borders',
+ enableStyle: true,
};
diff --git a/packages/components/src/border-control/border-control-dropdown/component.tsx b/packages/components/src/border-control/border-control-dropdown/component.tsx
index b2951054e624e7..0223de66a4c78b 100644
--- a/packages/components/src/border-control/border-control-dropdown/component.tsx
+++ b/packages/components/src/border-control/border-control-dropdown/component.tsx
@@ -7,7 +7,6 @@ import type { CSSProperties } from 'react';
* WordPress dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
-import { closeSmall } from '@wordpress/icons';
/**
* Internal dependencies
@@ -17,12 +16,10 @@ import Button from '../../button';
import ColorIndicator from '../../color-indicator';
import ColorPalette from '../../color-palette';
import Dropdown from '../../dropdown';
-import { HStack } from '../../h-stack';
import { VStack } from '../../v-stack';
import type { WordPressComponentProps } from '../../context';
import { contextConnect } from '../../context';
import { useBorderControlDropdown } from './hook';
-import { StyledLabel } from '../../base-control/styles/base-control-styles';
import DropdownContentWrapper from '../../dropdown/dropdown-content-wrapper';
import type { ColorObject } from '../../color-palette/types';
@@ -149,7 +146,6 @@ const BorderControlDropdown = (
popoverContentClassName,
popoverControlsClassName,
resetButtonClassName,
- showDropdownHeader,
size,
__unstablePopoverProps,
...otherProps
@@ -197,17 +193,6 @@ const BorderControlDropdown = (
<>
- { showDropdownHeader ? (
-
- { __( 'Border color' ) }
-
-
- ) : undefined }
{ __( 'Reset' ) }
diff --git a/packages/components/src/border-control/border-control/README.md b/packages/components/src/border-control/border-control/README.md
index 74a212d00026bd..fbd0c10e418d5a 100644
--- a/packages/components/src/border-control/border-control/README.md
+++ b/packages/components/src/border-control/border-control/README.md
@@ -1,10 +1,6 @@
-# BorderControl
+# BorderControl
-
-This feature is still experimental. āExperimentalā means this is an early implementation subject to drastic and breaking changes.
-
-
-This component provides control over a border's color, style, and width.
+An input control for a border's color, style, and width.
## Development guidelines
@@ -21,7 +17,7 @@ a "shape" abstraction.
```jsx
import { useState } from 'react';
-import { __experimentalBorderControl as BorderControl } from '@wordpress/components';
+import { BorderControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
const colors = [
@@ -58,41 +54,41 @@ colors are organized by multiple origins.
Each color may be an object containing a `name` and `color` value.
-- Required: No
-- Default: `[]`
+- Required: No
+- Default: `[]`
### `disableCustomColors`: `boolean`
This toggles the ability to choose custom colors.
-- Required: No
+- Required: No
### `disableUnits`: `boolean`
This controls whether unit selection should be disabled.
-- Required: No
+- Required: No
### `enableAlpha`: `boolean`
This controls whether the alpha channel will be offered when selecting
custom colors.
-- Required: No
-- Default: `false`
+- Required: No
+- Default: `true`
### `enableStyle`: `boolean`
This controls whether to support border style selection.
-- Required: No
-- Default: `true`
+- Required: No
+- Default: `true`
### `hideLabelFromVision`: `boolean`
Provides control over whether the label will only be visible to screen readers.
-- Required: No
+- Required: No
### `isCompact`: `boolean`
@@ -100,7 +96,7 @@ This flags the `BorderControl` to render with a more compact appearance. It
restricts the width of the control and prevents it from expanding to take up
additional space.
-- Required: No
+- Required: No
### `label`: `string`
@@ -109,7 +105,7 @@ If provided, a label will be generated using this as the content.
_Whether it is visible only to screen readers is controlled via
`hideLabelFromVision`._
-- Required: No
+- Required: No
### `onChange`: `( value?: Object ) => void`
@@ -118,7 +114,7 @@ that selects or clears, border color, style, or width.
_Note: the value may be `undefined` if a user clears all border properties._
-- Required: Yes
+- Required: Yes
### `shouldSanitizeBorder`: `boolean`
@@ -126,23 +122,16 @@ If opted into, sanitizing the border means that if no width or color have been
selected, the border style is also cleared and `undefined` is returned as the
new border value.
-- Required: No
-- Default: true
-
-### `showDropdownHeader`: `boolean`
-
-Whether or not to render a header for the border color and style picker
-dropdown. The header includes a label for the color picker and a close button.
-
-- Required: No
+- Required: No
+- Default: `true`
### `size`: `string`
Size of the control.
-- Required: No
-- Default: `default`
-- Allowed values: `default`, `__unstable-large`
+- Required: No
+- Default: `default`
+- Allowed values: `default`, `__unstable-large`
### `value`: `Object`
@@ -150,6 +139,7 @@ An object representing a border or `undefined`. Used to set the current border
configuration for this component.
Example:
+
```js
{
color: '#72aee6',
@@ -158,25 +148,25 @@ Example:
}
```
-- Required: No
+- Required: No
### `width`: `CSSProperties[ 'width' ]`
Controls the visual width of the `BorderControl`. It has no effect if the
`isCompact` prop is set to `true`.
-- Required: No
+- Required: No
### `withSlider`: `boolean`
Flags whether this `BorderControl` should also render a `RangeControl` for
additional control over a border's width.
-- Required: No
+- Required: No
### `__next40pxDefaultSize`: `boolean`
Start opting into the larger default height that will become the default size in a future version.
-- Required: No
-- Default: `false`
+- Required: No
+- Default: `false`
diff --git a/packages/components/src/border-control/border-control/component.tsx b/packages/components/src/border-control/border-control/component.tsx
index e2c96eaa9ffc0d..21be22c9dd55d8 100644
--- a/packages/components/src/border-control/border-control/component.tsx
+++ b/packages/components/src/border-control/border-control/component.tsx
@@ -91,7 +91,6 @@ const UnconnectedBorderControl = (
previousStyleSelection={
previousStyleSelection
}
- showDropdownHeader={ showDropdownHeader }
__experimentalIsRenderedInSidebar={
__experimentalIsRenderedInSidebar
}
@@ -141,7 +140,7 @@ const UnconnectedBorderControl = (
* a "shape" abstraction.
*
* ```jsx
- * import { __experimentalBorderControl as BorderControl } from '@wordpress/components';
+ * import { BorderControl } from '@wordpress/components';
* import { __ } from '@wordpress/i18n';
*
* const colors = [
diff --git a/packages/components/src/border-control/stories/index.story.tsx b/packages/components/src/border-control/stories/index.story.tsx
index 9a5349d302c276..0756a18ac5c0e5 100644
--- a/packages/components/src/border-control/stories/index.story.tsx
+++ b/packages/components/src/border-control/stories/index.story.tsx
@@ -16,7 +16,7 @@ import { BorderControl } from '..';
import type { Border } from '../types';
const meta: Meta< typeof BorderControl > = {
- title: 'Components (Experimental)/BorderControl',
+ title: 'Components/BorderControl',
component: BorderControl,
argTypes: {
onChange: {
@@ -93,6 +93,9 @@ export const Default = Template.bind( {} );
Default.args = {
colors,
label: 'Border',
+ enableAlpha: true,
+ enableStyle: true,
+ shouldSanitizeBorder: true,
};
/**
@@ -133,12 +136,3 @@ WithMultipleOrigins.args = {
...Default.args,
colors: multipleOriginColors,
};
-
-/**
- * Allow the alpha channel to be edited on each color.
- */
-export const WithAlphaEnabled = Template.bind( {} );
-WithAlphaEnabled.args = {
- ...Default.args,
- enableAlpha: true,
-};
diff --git a/packages/components/src/border-control/styles.ts b/packages/components/src/border-control/styles.ts
index 2c77a2d21465d6..a678b6f362308a 100644
--- a/packages/components/src/border-control/styles.ts
+++ b/packages/components/src/border-control/styles.ts
@@ -156,7 +156,6 @@ export const resetButton = css`
border-top: ${ CONFIG.borderWidth } solid ${ COLORS.gray[ 400 ] };
border-top-left-radius: 0;
border-top-right-radius: 0;
- height: 40px;
}
`;
diff --git a/packages/components/src/border-control/test/index.js b/packages/components/src/border-control/test/index.js
index c41dce687cc522..000a89e14a40b3 100644
--- a/packages/components/src/border-control/test/index.js
+++ b/packages/components/src/border-control/test/index.js
@@ -148,19 +148,6 @@ describe( 'BorderControl', () => {
expect( resetButton ).toBeInTheDocument();
} );
- it( 'should render color and style popover header', async () => {
- const user = userEvent.setup();
- const props = createProps( { showDropdownHeader: true } );
- render( );
- await openPopover( user );
-
- const headerLabel = screen.getByText( 'Border color' );
- const closeButton = getButton( 'Close border color' );
-
- expect( headerLabel ).toBeInTheDocument();
- expect( closeButton ).toBeInTheDocument();
- } );
-
it( 'should not render style options when opted out of', async () => {
const user = userEvent.setup();
const props = createProps( { enableStyle: false } );
@@ -346,10 +333,10 @@ describe( 'BorderControl', () => {
it( 'should take no action when color and style popover is closed', async () => {
const user = userEvent.setup();
- const props = createProps( { showDropdownHeader: true } );
+ const props = createProps();
render( );
await openPopover( user );
- await user.click( getButton( 'Close border color' ) );
+ await user.keyboard( 'Escape' );
expect( props.onChange ).not.toHaveBeenCalled();
} );
diff --git a/packages/components/src/border-control/types.ts b/packages/components/src/border-control/types.ts
index 5e028050d8e18e..8ab614907684d2 100644
--- a/packages/components/src/border-control/types.ts
+++ b/packages/components/src/border-control/types.ts
@@ -18,12 +18,19 @@ export type Border = {
export type ColorProps = Pick<
ColorPaletteProps,
- 'colors' | 'enableAlpha' | '__experimentalIsRenderedInSidebar'
+ 'colors' | '__experimentalIsRenderedInSidebar'
> & {
/**
* This toggles the ability to choose custom colors.
*/
disableCustomColors?: boolean;
+ /**
+ * This controls whether the alpha channel will be offered when selecting
+ * custom colors.
+ *
+ * @default true
+ */
+ enableAlpha?: boolean;
};
export type LabelProps = {
@@ -78,9 +85,8 @@ export type BorderControlProps = ColorProps &
*/
shouldSanitizeBorder?: boolean;
/**
- * Whether or not to show the header for the border color and style
- * picker dropdown. The header includes a label for the color picker
- * and a close button.
+ * @deprecated This prop no longer has any effect.
+ * @ignore
*/
showDropdownHeader?: boolean;
/**
@@ -139,9 +145,8 @@ export type DropdownProps = ColorProps &
*/
previousStyleSelection?: string;
/**
- * Whether or not to render a header for the border color and style picker
- * dropdown. The header includes a label for the color picker and a
- * close button.
+ * @deprecated This prop no longer has any effect.
+ * @ignore
*/
showDropdownHeader?: boolean;
};
diff --git a/packages/components/src/box-control/README.md b/packages/components/src/box-control/README.md
index b03b03a85466ae..77176b49eeb6d8 100644
--- a/packages/components/src/box-control/README.md
+++ b/packages/components/src/box-control/README.md
@@ -1,18 +1,14 @@
# BoxControl
-
-This feature is still experimental. āExperimentalā means this is an early implementation subject to drastic and breaking changes.
-
-
-BoxControl components let users set values for Top, Right, Bottom, and Left. This can be used as an input control for values like `padding` or `margin`.
+A control that lets users set values for top, right, bottom, and left. Can be used as an input control for values like `padding` or `margin`.
## Usage
```jsx
import { useState } from 'react';
-import { __experimentalBoxControl as BoxControl } from '@wordpress/components';
+import { BoxControl } from '@wordpress/components';
-const Example = () => {
+function Example() {
const [ values, setValues ] = useState( {
top: '50px',
left: '10%',
@@ -26,23 +22,24 @@ const Example = () => {
onChange={ ( nextValues ) => setValues( nextValues ) }
/>
);
-};
+}
```
## Props
+
### `allowReset`: `boolean`
If this property is true, a button to reset the box control is rendered.
-- Required: No
-- Default: `true`
+- Required: No
+- Default: `true`
### `splitOnAxis`: `boolean`
If this property is true, when the box control is unlinked, vertical and horizontal controls can be used instead of updating individual sides.
-- Required: No
-- Default: `false`
+- Required: No
+- Default: `false`
### `inputProps`: `object`
diff --git a/packages/components/src/box-control/index.tsx b/packages/components/src/box-control/index.tsx
index 9c3452d4ccb806..41e95aa88bea37 100644
--- a/packages/components/src/box-control/index.tsx
+++ b/packages/components/src/box-control/index.tsx
@@ -47,14 +47,14 @@ function useUniqueId( idProp?: string ) {
}
/**
- * BoxControl components let users set values for Top, Right, Bottom, and Left.
- * This can be used as an input control for values like `padding` or `margin`.
+ * A control that lets users set values for top, right, bottom, and left. Can be
+ * used as an input control for values like `padding` or `margin`.
*
* ```jsx
- * import { __experimentalBoxControl as BoxControl } from '@wordpress/components';
+ * import { BoxControl } from '@wordpress/components';
* import { useState } from '@wordpress/element';
*
- * const Example = () => {
+ * function Example() {
* const [ values, setValues ] = useState( {
* top: '50px',
* left: '10%',
diff --git a/packages/components/src/box-control/stories/index.story.tsx b/packages/components/src/box-control/stories/index.story.tsx
index 1b6604048f6d52..783f9d047b1bb0 100644
--- a/packages/components/src/box-control/stories/index.story.tsx
+++ b/packages/components/src/box-control/stories/index.story.tsx
@@ -14,7 +14,7 @@ import { useState } from '@wordpress/element';
import BoxControl from '../';
const meta: Meta< typeof BoxControl > = {
- title: 'Components (Experimental)/BoxControl',
+ title: 'Components/BoxControl',
component: BoxControl,
argTypes: {
values: { control: { type: null } },
diff --git a/packages/components/src/box-control/types.ts b/packages/components/src/box-control/types.ts
index eeb72df14bb9c1..5f4071aeed88a7 100644
--- a/packages/components/src/box-control/types.ts
+++ b/packages/components/src/box-control/types.ts
@@ -37,13 +37,13 @@ export type BoxControlProps = Pick<
/**
* Props for the internal `UnitControl` components.
*
- * @default `{ min: 0 }`
+ * @default { min: 0 }
*/
inputProps?: UnitControlPassthroughProps;
/**
* Heading label for the control.
*
- * @default `__( 'Box Control' )`
+ * @default __( 'Box Control' )
*/
label?: string;
/**
@@ -53,7 +53,7 @@ export type BoxControlProps = Pick<
/**
* The `top`, `right`, `bottom`, and `left` box dimension values to use when the control is reset.
*
- * @default `{ top: undefined, right: undefined, bottom: undefined, left: undefined }`
+ * @default { top: undefined, right: undefined, bottom: undefined, left: undefined }
*/
resetValues?: BoxControlValue;
/**
diff --git a/packages/components/src/composite/group-label.tsx b/packages/components/src/composite/group-label.tsx
index 17070dbb86bf81..7e3c6ffdc7759c 100644
--- a/packages/components/src/composite/group-label.tsx
+++ b/packages/components/src/composite/group-label.tsx
@@ -20,11 +20,13 @@ export const CompositeGroupLabel = forwardRef<
WordPressComponentProps< CompositeGroupLabelProps, 'div', false >
>( function CompositeGroupLabel( props, ref ) {
const context = useCompositeContext();
+
+ // @ts-expect-error The store prop is undocumented and only used by the
+ // legacy compat layer. The `store` prop is documented, but its type is
+ // obfuscated to discourage its use outside of the component's internals.
+ const store = ( props.store ?? context.store ) as Ariakit.CompositeStore;
+
return (
-
+
);
} );
diff --git a/packages/components/src/composite/group.tsx b/packages/components/src/composite/group.tsx
index ae21ca6f11dd92..bcfb47e684613d 100644
--- a/packages/components/src/composite/group.tsx
+++ b/packages/components/src/composite/group.tsx
@@ -20,11 +20,11 @@ export const CompositeGroup = forwardRef<
WordPressComponentProps< CompositeGroupProps, 'div', false >
>( function CompositeGroup( props, ref ) {
const context = useCompositeContext();
- return (
-
- );
+
+ // @ts-expect-error The store prop is undocumented and only used by the
+ // legacy compat layer. The `store` prop is documented, but its type is
+ // obfuscated to discourage its use outside of the component's internals.
+ const store = ( props.store ?? context.store ) as Ariakit.CompositeStore;
+
+ return ;
} );
diff --git a/packages/components/src/composite/hover.tsx b/packages/components/src/composite/hover.tsx
index ca0bd9d8f6aa12..1507a1879cc19f 100644
--- a/packages/components/src/composite/hover.tsx
+++ b/packages/components/src/composite/hover.tsx
@@ -20,11 +20,11 @@ export const CompositeHover = forwardRef<
WordPressComponentProps< CompositeHoverProps, 'div', false >
>( function CompositeHover( props, ref ) {
const context = useCompositeContext();
- return (
-
- );
+
+ // @ts-expect-error The store prop is undocumented and only used by the
+ // legacy compat layer. The `store` prop is documented, but its type is
+ // obfuscated to discourage its use outside of the component's internals.
+ const store = ( props.store ?? context.store ) as Ariakit.CompositeStore;
+
+ return ;
} );
diff --git a/packages/components/src/composite/index.tsx b/packages/components/src/composite/index.tsx
index e9e97072261fbf..8eb562f5bdab38 100644
--- a/packages/components/src/composite/index.tsx
+++ b/packages/components/src/composite/index.tsx
@@ -73,7 +73,10 @@ export const Composite = Object.assign(
},
ref
) {
- const store = Ariakit.useCompositeStore( {
+ // @ts-expect-error The store prop is undocumented and only used by the
+ // legacy compat layer.
+ const storeProp = props.store as Ariakit.CompositeStore;
+ const internalStore = Ariakit.useCompositeStore( {
activeId,
defaultActiveId,
setActiveId,
@@ -85,6 +88,8 @@ export const Composite = Object.assign(
rtl,
} );
+ const store = storeProp ?? internalStore;
+
const contextValue = useMemo(
() => ( {
store,
diff --git a/packages/components/src/composite/item.tsx b/packages/components/src/composite/item.tsx
index 6d75b90f0baaaa..edbf0b92e039af 100644
--- a/packages/components/src/composite/item.tsx
+++ b/packages/components/src/composite/item.tsx
@@ -20,9 +20,27 @@ export const CompositeItem = forwardRef<
WordPressComponentProps< CompositeItemProps, 'button', false >
>( function CompositeItem( props, ref ) {
const context = useCompositeContext();
+
+ // @ts-expect-error The store prop is undocumented and only used by the
+ // legacy compat layer. The `store` prop is documented, but its type is
+ // obfuscated to discourage its use outside of the component's internals.
+ const store = ( props.store ?? context.store ) as Ariakit.CompositeStore;
+
+ // If the active item is not connected, Composite may end up in a state
+ // where none of the items are tabbable. In this case, we force all items to
+ // be tabbable, so that as soon as an item received focus, it becomes active
+ // and Composite goes back to working as expected.
+ const tabbable = Ariakit.useStoreState( store, ( state ) => {
+ return (
+ state?.activeId !== null &&
+ ! store?.item( state?.activeId )?.element?.isConnected
+ );
+ } );
+
return (
diff --git a/packages/components/src/composite/legacy/test/index.tsx b/packages/components/src/composite/legacy/test/index.tsx
index c034d31442ca8d..a118dbcfbadbb3 100644
--- a/packages/components/src/composite/legacy/test/index.tsx
+++ b/packages/components/src/composite/legacy/test/index.tsx
@@ -232,7 +232,7 @@ describe.each( [
After
>
);
- renderAndValidate( );
+ await renderAndValidate( );
await press.Tab();
expect( screen.getByText( 'Before' ) ).toHaveFocus();
@@ -260,7 +260,7 @@ describe.each( [
);
};
- renderAndValidate( );
+ await renderAndValidate( );
const { item1, item2, item3 } = getOneDimensionalItems();
@@ -289,7 +289,7 @@ describe.each( [
);
};
- renderAndValidate( );
+ await renderAndValidate( );
const { item1, item2, item3 } = getOneDimensionalItems();
expect( item2 ).toBeEnabled();
@@ -310,7 +310,7 @@ describe.each( [
} ) }
/>
);
- renderAndValidate( );
+ await renderAndValidate( );
const { item1, item2, item3 } = getOneDimensionalItems();
expect( item1.id ).toMatch( 'test-id-1' );
@@ -327,7 +327,7 @@ describe.each( [
} ) }
/>
);
- renderAndValidate( );
+ await renderAndValidate( );
const { item2 } = getOneDimensionalItems();
await press.Tab();
@@ -341,37 +341,37 @@ describe.each( [
] )( '%s', ( _when, rtl ) => {
const { previous, next, first, last } = getKeys( rtl );
- function useOneDimensionalTest( initialState?: InitialState ) {
+ async function useOneDimensionalTest( initialState?: InitialState ) {
const Test = () => (
);
- renderAndValidate( );
+ await renderAndValidate( );
return getOneDimensionalItems();
}
- function useTwoDimensionalTest( initialState?: InitialState ) {
+ async function useTwoDimensionalTest( initialState?: InitialState ) {
const Test = () => (
);
- renderAndValidate( );
+ await renderAndValidate( );
return getTwoDimensionalItems();
}
- function useShiftTest( shift: boolean ) {
+ async function useShiftTest( shift: boolean ) {
const Test = () => (
);
- renderAndValidate( );
+ await renderAndValidate( );
return getShiftTestItems();
}
describe( 'In one dimension', () => {
test( 'All directions work with no orientation', async () => {
- const { item1, item2, item3 } = useOneDimensionalTest();
+ const { item1, item2, item3 } = await useOneDimensionalTest();
await press.Tab();
expect( item1 ).toHaveFocus();
@@ -406,7 +406,7 @@ describe.each( [
} );
test( 'Only left/right work with horizontal orientation', async () => {
- const { item1, item2, item3 } = useOneDimensionalTest( {
+ const { item1, item2, item3 } = await useOneDimensionalTest( {
orientation: 'horizontal',
} );
@@ -435,7 +435,7 @@ describe.each( [
} );
test( 'Only up/down work with vertical orientation', async () => {
- const { item1, item2, item3 } = useOneDimensionalTest( {
+ const { item1, item2, item3 } = await useOneDimensionalTest( {
orientation: 'vertical',
} );
@@ -464,7 +464,7 @@ describe.each( [
} );
test( 'Focus wraps with loop enabled', async () => {
- const { item1, item2, item3 } = useOneDimensionalTest( {
+ const { item1, item2, item3 } = await useOneDimensionalTest( {
loop: true,
} );
@@ -488,7 +488,7 @@ describe.each( [
describe( 'In two dimensions', () => {
test( 'All directions work as standard', async () => {
const { itemA1, itemA2, itemA3, itemB1, itemB2, itemC1, itemC3 } =
- useTwoDimensionalTest();
+ await useTwoDimensionalTest();
await press.Tab();
expect( itemA1 ).toHaveFocus();
@@ -524,7 +524,7 @@ describe.each( [
test( 'Focus wraps around rows/columns with loop enabled', async () => {
const { itemA1, itemA2, itemA3, itemB1, itemC1, itemC3 } =
- useTwoDimensionalTest( { loop: true } );
+ await useTwoDimensionalTest( { loop: true } );
await press.Tab();
expect( itemA1 ).toHaveFocus();
@@ -548,7 +548,7 @@ describe.each( [
test( 'Focus moves between rows/columns with wrap enabled', async () => {
const { itemA1, itemA2, itemA3, itemB1, itemC1, itemC3 } =
- useTwoDimensionalTest( { wrap: true } );
+ await useTwoDimensionalTest( { wrap: true } );
await press.Tab();
expect( itemA1 ).toHaveFocus();
@@ -577,7 +577,7 @@ describe.each( [
} );
test( 'Focus wraps around start/end with loop and wrap enabled', async () => {
- const { itemA1, itemC3 } = useTwoDimensionalTest( {
+ const { itemA1, itemC3 } = await useTwoDimensionalTest( {
loop: true,
wrap: true,
} );
@@ -595,7 +595,8 @@ describe.each( [
} );
test( 'Focus shifts if vertical neighbour unavailable when shift enabled', async () => {
- const { itemA1, itemB1, itemB2, itemC1 } = useShiftTest( true );
+ const { itemA1, itemB1, itemB2, itemC1 } =
+ await useShiftTest( true );
await press.Tab();
expect( itemA1 ).toHaveFocus();
@@ -616,7 +617,7 @@ describe.each( [
} );
test( 'Focus does not shift if vertical neighbour unavailable when shift not enabled', async () => {
- const { itemA1, itemB1, itemB2 } = useShiftTest( false );
+ const { itemA1, itemB1, itemB2 } = await useShiftTest( false );
await press.Tab();
expect( itemA1 ).toHaveFocus();
diff --git a/packages/components/src/composite/row.tsx b/packages/components/src/composite/row.tsx
index a082af03ad6785..1a88da557785e9 100644
--- a/packages/components/src/composite/row.tsx
+++ b/packages/components/src/composite/row.tsx
@@ -20,11 +20,11 @@ export const CompositeRow = forwardRef<
WordPressComponentProps< CompositeRowProps, 'div', false >
>( function CompositeRow( props, ref ) {
const context = useCompositeContext();
- return (
-
- );
+
+ // @ts-expect-error The store prop is undocumented and only used by the
+ // legacy compat layer. The `store` prop is documented, but its type is
+ // obfuscated to discourage its use outside of the component's internals.
+ const store = ( props.store ?? context.store ) as Ariakit.CompositeStore;
+
+ return ;
} );
diff --git a/packages/components/src/composite/stories/index.story.tsx b/packages/components/src/composite/stories/index.story.tsx
index d6e4999407e993..c5518375df8a6f 100644
--- a/packages/components/src/composite/stories/index.story.tsx
+++ b/packages/components/src/composite/stories/index.story.tsx
@@ -13,6 +13,7 @@ import { useContext, useMemo } from '@wordpress/element';
*/
import { createSlotFill, Provider as SlotFillProvider } from '../../slot-fill';
import { Composite } from '..';
+import { Tooltip } from '../../tooltip';
const meta: Meta< typeof Composite > = {
title: 'Components/Composite',
@@ -353,3 +354,44 @@ const Fill = ( { children } ) => {
},
},
};
+
+/**
+ * Combining the `Tooltip` and `Composite` component has a few caveats. And while there are a few ways to compose these two components, our recommendation is to render `Composite.Item` as a child of `Tooltip`.
+ *
+ * ```jsx
+ * // š“ Does not work
+ *
+ * Item
+ *
+ * }
+ * />
+ *
+ * // š¢ Good
+ *
+ *
+ * Item one
+ *
+ *
+ * ```
+ */
+export const WithTooltips: StoryObj< typeof Composite > = {
+ ...Default,
+ args: {
+ ...Default.args,
+ children: (
+ <>
+
+ Item one
+
+
+ Item two
+
+
+ Item three
+
+ >
+ ),
+ },
+};
diff --git a/packages/components/src/composite/test/index.tsx b/packages/components/src/composite/test/index.tsx
new file mode 100644
index 00000000000000..64619aaed01bd6
--- /dev/null
+++ b/packages/components/src/composite/test/index.tsx
@@ -0,0 +1,123 @@
+/**
+ * External dependencies
+ */
+import { queryByAttribute, render, screen } from '@testing-library/react';
+import { click, press, waitFor } from '@ariakit/test';
+import type { ComponentProps } from 'react';
+
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { Composite } from '..';
+
+// This is necessary because of how Ariakit calculates page up and
+// page down. Without this, nothing has a height, and so paging up
+// and down doesn't behave as expected in tests.
+
+let clientHeightSpy: jest.SpiedGetter<
+ typeof HTMLElement.prototype.clientHeight
+>;
+
+beforeAll( () => {
+ clientHeightSpy = jest
+ .spyOn( HTMLElement.prototype, 'clientHeight', 'get' )
+ .mockImplementation( function getClientHeight( this: HTMLElement ) {
+ if ( this.tagName === 'BODY' ) {
+ return window.outerHeight;
+ }
+ return 50;
+ } );
+} );
+
+afterAll( () => {
+ clientHeightSpy?.mockRestore();
+} );
+
+async function renderAndValidate( ...args: Parameters< typeof render > ) {
+ const view = render( ...args );
+ await waitFor( () => {
+ const activeButton = queryByAttribute(
+ 'data-active-item',
+ view.baseElement,
+ 'true'
+ );
+ expect( activeButton ).not.toBeNull();
+ } );
+ return view;
+}
+
+function RemoveItemTest( props: ComponentProps< typeof Composite > ) {
+ const [ showThirdItem, setShowThirdItem ] = useState( true );
+ return (
+ <>
+ Focus trap before composite
+
+ Item 1
+ Item 2
+ { showThirdItem && Item 3 }
+
+ setShowThirdItem( ( value ) => ! value ) }>
+ Toggle third item
+
+ >
+ );
+}
+
+describe( 'Composite', () => {
+ it( 'should remain focusable even when there are no elements in the DOM associated with the currently active ID', async () => {
+ await renderAndValidate( );
+
+ const toggleButton = screen.getByRole( 'button', {
+ name: 'Toggle third item',
+ } );
+
+ await press.Tab();
+ await press.Tab();
+
+ expect(
+ screen.getByRole( 'button', { name: 'Item 1' } )
+ ).toHaveFocus();
+
+ await press.ArrowRight();
+ await press.ArrowRight();
+
+ expect(
+ screen.getByRole( 'button', { name: 'Item 3' } )
+ ).toHaveFocus();
+
+ await click( toggleButton );
+
+ expect(
+ screen.queryByRole( 'button', { name: 'Item 3' } )
+ ).not.toBeInTheDocument();
+
+ await press.ShiftTab();
+
+ expect(
+ screen.getByRole( 'button', { name: 'Item 2' } )
+ ).toHaveFocus();
+
+ await click( toggleButton );
+
+ expect(
+ screen.getByRole( 'button', { name: 'Item 3' } )
+ ).toBeVisible();
+
+ await press.ShiftTab();
+
+ expect(
+ screen.getByRole( 'button', { name: 'Item 2' } )
+ ).toHaveFocus();
+
+ await press.ArrowRight();
+
+ expect(
+ screen.getByRole( 'button', { name: 'Item 3' } )
+ ).toHaveFocus();
+ } );
+} );
diff --git a/packages/components/src/composite/typeahead.tsx b/packages/components/src/composite/typeahead.tsx
index 771d58bcb6c25c..519c59ea374e5d 100644
--- a/packages/components/src/composite/typeahead.tsx
+++ b/packages/components/src/composite/typeahead.tsx
@@ -20,11 +20,11 @@ export const CompositeTypeahead = forwardRef<
WordPressComponentProps< CompositeTypeaheadProps, 'div', false >
>( function CompositeTypeahead( props, ref ) {
const context = useCompositeContext();
- return (
-
- );
+
+ // @ts-expect-error The store prop is undocumented and only used by the
+ // legacy compat layer. The `store` prop is documented, but its type is
+ // obfuscated to discourage its use outside of the component's internals.
+ const store = ( props.store ?? context.store ) as Ariakit.CompositeStore;
+
+ return ;
} );
diff --git a/packages/components/src/date-time/date/index.tsx b/packages/components/src/date-time/date/index.tsx
index 33fc736564d5e6..5a565ee38cec59 100644
--- a/packages/components/src/date-time/date/index.tsx
+++ b/packages/components/src/date-time/date/index.tsx
@@ -125,6 +125,7 @@ export function DatePicker( {
)
);
} }
+ size="compact"
/>
@@ -150,6 +151,7 @@ export function DatePicker( {
)
);
} }
+ size="compact"
/>
= {
icon: more,
children: ( { onClose } ) => (
<>
+
+ Standalone Item
+
Move Up
diff --git a/packages/components/src/dropdown/stories/index.story.tsx b/packages/components/src/dropdown/stories/index.story.tsx
index c6fe5014ffdc2a..0f07664787cc33 100644
--- a/packages/components/src/dropdown/stories/index.story.tsx
+++ b/packages/components/src/dropdown/stories/index.story.tsx
@@ -99,6 +99,7 @@ export const WithMenuItems: StoryObj< typeof Dropdown > = {
...Default.args,
renderContent: () => (
<>
+ Standalone Item
Item 1
Item 2
diff --git a/packages/components/src/dropdown/style.scss b/packages/components/src/dropdown/style.scss
index 8a5b0e0a0a6a28..d7ae7963f7ed8c 100644
--- a/packages/components/src/dropdown/style.scss
+++ b/packages/components/src/dropdown/style.scss
@@ -5,6 +5,16 @@
.components-dropdown__content {
.components-popover__content {
padding: $grid-unit-10;
+
+ &:has(.components-menu-group) {
+ padding: 0;
+
+ .components-dropdown-menu__menu > .components-menu-item__button,
+ > .components-menu-item__button {
+ margin: $grid-unit-10;
+ width: auto;
+ }
+ }
}
[role="menuitem"] {
@@ -13,22 +23,9 @@
.components-menu-group {
padding: $grid-unit-10;
- margin-top: 0;
- margin-bottom: 0;
- margin-left: -$grid-unit-10;
- margin-right: -$grid-unit-10;
-
- &:first-child {
- margin-top: -$grid-unit-10;
- }
-
- &:last-child {
- margin-bottom: -$grid-unit-10;
- }
}
.components-menu-group + .components-menu-group {
- margin-top: 0;
border-top: $border-width solid $gray-400;
padding: $grid-unit-10;
}
diff --git a/packages/components/src/guide/index.tsx b/packages/components/src/guide/index.tsx
index 0ca5957fd3a656..121c9f22330e88 100644
--- a/packages/components/src/guide/index.tsx
+++ b/packages/components/src/guide/index.tsx
@@ -164,6 +164,7 @@ function Guide( {
className="components-guide__finish-button"
variant="primary"
onClick={ onFinish }
+ __next40pxDefaultSize
>
{ finishButtonText }
diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts
index 32195ebc444ce6..e82d6da70279e8 100644
--- a/packages/components/src/index.ts
+++ b/packages/components/src/index.ts
@@ -33,14 +33,22 @@ export {
} from './autocomplete';
export { default as BaseControl, useBaseControlProps } from './base-control';
export {
+ /** @deprecated Import `BorderBoxControl` instead. */
BorderBoxControl as __experimentalBorderBoxControl,
+ BorderBoxControl,
hasSplitBorders as __experimentalHasSplitBorders,
isDefinedBorder as __experimentalIsDefinedBorder,
isEmptyBorder as __experimentalIsEmptyBorder,
} from './border-box-control';
-export { BorderControl as __experimentalBorderControl } from './border-control';
export {
+ /** @deprecated Import `BorderControl` instead. */
+ BorderControl as __experimentalBorderControl,
+ BorderControl,
+} from './border-control';
+export {
+ /** @deprecated Import `BoxControl` instead. */
default as __experimentalBoxControl,
+ default as BoxControl,
applyValueToSides as __experimentalApplyValueToSides,
} from './box-control';
export { default as Button } from './button';
@@ -121,11 +129,21 @@ export { default as __experimentalNavigationGroup } from './navigation/group';
export { default as __experimentalNavigationItem } from './navigation/item';
export { default as __experimentalNavigationMenu } from './navigation/menu';
export {
+ /** @deprecated Import `Navigator` instead. */
NavigatorProvider as __experimentalNavigatorProvider,
+ /** @deprecated Import `Navigator` and use `Navigator.Screen` instead. */
NavigatorScreen as __experimentalNavigatorScreen,
+ /** @deprecated Import `Navigator` and use `Navigator.Button` instead. */
NavigatorButton as __experimentalNavigatorButton,
+ /** @deprecated Import `Navigator` and use `Navigator.BackButton` instead. */
NavigatorBackButton as __experimentalNavigatorBackButton,
+ /** @deprecated Import `Navigator` and use `Navigator.BackButton` instead. */
NavigatorToParentButton as __experimentalNavigatorToParentButton,
+} from './navigator/legacy';
+export {
+ Navigator,
+ useNavigator,
+ /** @deprecated Import `useNavigator` instead. */
useNavigator as __experimentalUseNavigator,
} from './navigator';
export { default as Notice } from './notice';
diff --git a/packages/components/src/menu-group/style.scss b/packages/components/src/menu-group/style.scss
index d9412c504940b3..744e3f74c5b955 100644
--- a/packages/components/src/menu-group/style.scss
+++ b/packages/components/src/menu-group/style.scss
@@ -1,5 +1,4 @@
.components-menu-group + .components-menu-group {
- margin-top: $grid-unit-10;
padding-top: $grid-unit-10;
border-top: $border-width solid $gray-900;
@@ -10,6 +9,10 @@
}
}
+.components-menu-group:has(> div:empty) {
+ display: none;
+}
+
.components-menu-group__label {
padding: 0 $grid-unit-10;
margin-top: $grid-unit-05;
diff --git a/packages/components/src/menu-items-choice/style.scss b/packages/components/src/menu-items-choice/style.scss
index 5de8363be0d6e8..383eb4066ba86b 100644
--- a/packages/components/src/menu-items-choice/style.scss
+++ b/packages/components/src/menu-items-choice/style.scss
@@ -1,5 +1,7 @@
.components-menu-items-choice,
.components-menu-items-choice.components-button {
+ height: auto;
+
svg {
margin-right: $grid-unit-15;
}
diff --git a/packages/components/src/navigator/README.md b/packages/components/src/navigator/README.md
new file mode 100644
index 00000000000000..b56a82e0524eef
--- /dev/null
+++ b/packages/components/src/navigator/README.md
@@ -0,0 +1,176 @@
+# `Navigator`
+
+`Navigator` is a collection components that allow rendering nested views/panels/menus (via the `Navigator.Screen` component) and navigate between them (via the `Navigator.Button` and `Navigator.BackButton` components).
+
+## Usage
+
+```jsx
+import { Navigator } from '@wordpress/components';
+
+const MyNavigation = () => (
+
+
+ This is the home screen.
+
+ Navigate to child screen.
+
+
+
+ This is the child screen.
+ Go back
+
+
+);
+```
+
+### Hierarchical `path`s
+
+`Navigator` assumes that screens are organized hierarchically according to their `path`, which should follow a URL-like scheme where each path segment starts with and is separated by the `/` character.
+
+`Navigator` will treat "back" navigations as going to the parent screen ā it is, therefore, the responsibility of the consumer of the component to create the correct screen hierarchy.
+
+For example:
+
+- `/` is the root of all paths. There should always be a screen with `path="/"`;
+- `/parent/child` is a child of `/parent`;
+- `/parent/child/grand-child` is a child of `/parent/child`;
+- `/parent/:param` is a child of `/parent` as well;
+- if the current screen has a `path="/parent/child/grand-child"`, when going "back" `Navigator` will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) is found.
+
+### Height and animations
+
+Due to how `Navigator.Screen` animations work, it is recommended that the `Navigator` component is assigned a `height` to prevent some potential UI jumps while moving across screens.
+
+### Individual components
+
+`Navigator` is comprised of four individual components:
+
+- `Navigator`: a wrapper component and context provider. It holds the main logic for hiding and showing screens.
+- `Navigator.Screen`: represents a single view/screen/panel;
+- `Navigator.Button`: renders a button that allows navigating to a different `Navigator.Screen`;
+- `Navigator.BackButton`: renders a button that allows navigating to the parent `Navigator.Screen` (see the section above about hierarchical paths).
+
+For advanced usages, consumers can use the `useNavigator` hook.
+
+#### `Navigator`
+
+##### Props
+
+###### `initialPath`: `string`
+
+The initial active path.
+
+- Required: Yes
+
+###### `children`: `string`
+
+The children elements.
+
+- Required: Yes
+
+#### `Navigator.Screen`
+
+##### Props
+
+###### `path`: `string`
+
+The screen's path, matched against the current path stored in the navigator.
+
+`Navigator` assumes that screens are organized hierarchically according to their `path`, which should follow a URL-like scheme where each path segment starts with and is separated by the `/` character.
+
+`Navigator` will treat "back" navigations as going to the parent screen ā it is, therefore, the responsibility of the consumer of the component to create the correct screen hierarchy.
+
+For example:
+
+- `/` is the root of all paths. There should always be a screen with `path="/"`.
+- `/parent/child` is a child of `/parent`.
+- `/parent/child/grand-child` is a child of `/parent/child`.
+- `/parent/:param` is a child of `/parent` as well.
+- if the current screen has a `path` with value `/parent/child/grand-child`, when going "back" `Navigator` will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) is found.
+
+- Required: Yes
+
+###### `children`: `string`
+
+The children elements.
+
+- Required: Yes
+
+#### `Navigator.Button`
+
+##### Props
+
+###### `path`: `string`
+
+The path of the screen to navigate to. The value of this prop needs to be [a valid value for an HTML attribute](https://html.spec.whatwg.org/multipage/syntax.html#attributes-2).
+
+- Required: Yes
+
+###### `attributeName`: `string`
+
+The HTML attribute used to identify the `Navigator.Button`, which is used by `Navigator` to restore focus.
+
+- Required: No
+- Default: `id`
+
+###### `children`: `string`
+
+The children elements.
+
+- Required: No
+
+###### Inherited props
+
+`Navigator.Button` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href` and `target`.
+
+#### `Navigator.BackButton`
+
+##### Props
+
+###### `children`: `string`
+
+The children elements.
+
+- Required: No
+
+###### Inherited props
+
+`Navigator.BackButton` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href` and `target`.
+
+#### `useNavigator`
+
+You can retrieve a `navigator` instance by using the `useNavigator` hook.
+
+##### Props
+
+The `navigator` instance has a few properties:
+
+###### `goTo`: `( path: string, options: NavigateOptions ) => void`
+
+The `goTo` function allows navigating to a given path. The second argument can augment the navigation operations with different options.
+
+The available options are:
+
+- `focusTargetSelector`: `string`. An optional property used to specify the CSS selector used to restore focus on the matching element when navigating back;
+- `isBack`: `boolean`. An optional property used to specify whether the navigation should be considered as backwards (thus enabling focus restoration when possible, and causing the animation to be backwards too);
+- `skipFocus`: `boolean`. An optional property used to opt out of `Navigator`'s focus management, useful when the consumer of the component wants to manage focus themselves;
+
+###### `goBack`: `( path: string, options: NavigateOptions ) => void`
+
+The `goBack` function allows navigating to the parent screen. Parent/child navigation only works if the paths you define are hierarchical (see note above).
+
+When a match is not found, the function will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) is found.
+
+The available options are the same as for the `goTo` method, except for the `isBack` property, which is not available for the `goBack` method.
+
+###### `location`: `NavigatorLocation`
+
+The `location` object represents the current location, and has a few properties:
+
+- `path`: `string`. The path associated to the location.
+- `isBack`: `boolean`. A flag that is `true` when the current location was reached by navigating backwards.
+- `isInitial`: `boolean`. A flag that is `true` only for the initial location.
+
+###### `params`: `Record< string, string | string[] >`
+
+The parsed record of parameters from the current location. For example if the current screen path is `/product/:productId` and the location is `/product/123`, then `params` will be `{ productId: '123' }`.
diff --git a/packages/components/src/navigator/index.ts b/packages/components/src/navigator/index.ts
deleted file mode 100644
index 130edc2ae52eb8..00000000000000
--- a/packages/components/src/navigator/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export { NavigatorProvider } from './navigator-provider/component';
-export { NavigatorScreen } from './navigator-screen/component';
-export { NavigatorButton } from './navigator-button/component';
-export { NavigatorBackButton } from './navigator-back-button/component';
-export { NavigatorToParentButton } from './navigator-to-parent-button/component';
-export { useNavigator } from './use-navigator';
diff --git a/packages/components/src/navigator/index.tsx b/packages/components/src/navigator/index.tsx
new file mode 100644
index 00000000000000..1d9ae95441e01a
--- /dev/null
+++ b/packages/components/src/navigator/index.tsx
@@ -0,0 +1,131 @@
+/**
+ * Internal dependencies
+ */
+import { Navigator as TopLevelNavigator } from './navigator/component';
+import { NavigatorScreen } from './navigator-screen/component';
+import { NavigatorButton } from './navigator-button/component';
+import { NavigatorBackButton } from './navigator-back-button/component';
+export { useNavigator } from './use-navigator';
+
+/**
+ * The `Navigator` component allows rendering nested views/panels/menus
+ * (via the `Navigator.Screen` component) and navigate between them
+ * (via the `Navigator.Button` and `Navigator.BackButton` components).
+ *
+ * ```jsx
+ * import { Navigator } from '@wordpress/components';
+ *
+ * const MyNavigation = () => (
+ *
+ *
+ * This is the home screen.
+ *
+ * Navigate to child screen.
+ *
+ *
+ *
+ *
+ * This is the child screen.
+ *
+ * Go back
+ *
+ *
+ *
+ * );
+ * ```
+ */
+export const Navigator = Object.assign( TopLevelNavigator, {
+ /**
+ * The `Navigator.Screen` component represents a single view/screen/panel and
+ * should be used in combination with the `Navigator`, the `Navigator.Button`
+ * and the `Navigator.BackButton` components.
+ *
+ * @example
+ * ```jsx
+ * import { Navigator } from '@wordpress/components';
+ *
+ * const MyNavigation = () => (
+ *
+ *
+ * This is the home screen.
+ *
+ * Navigate to child screen.
+ *
+ *
+ *
+ *
+ * This is the child screen.
+ *
+ * Go back
+ *
+ *
+ *
+ * );
+ * ```
+ */
+ Screen: Object.assign( NavigatorScreen, {
+ displayName: 'Navigator.Screen',
+ } ),
+ /**
+ * The `Navigator.Button` component can be used to navigate to a screen and
+ * should be used in combination with the `Navigator`, the `Navigator.Screen`
+ * and the `Navigator.BackButton` components.
+ *
+ * @example
+ * ```jsx
+ * import { Navigator } from '@wordpress/components';
+ *
+ * const MyNavigation = () => (
+ *
+ *
+ * This is the home screen.
+ *
+ * Navigate to child screen.
+ *
+ *
+ *
+ *
+ * This is the child screen.
+ *
+ * Go back
+ *
+ *
+ *
+ * );
+ * ```
+ */
+ Button: Object.assign( NavigatorButton, {
+ displayName: 'Navigator.Button',
+ } ),
+ /**
+ * The `Navigator.BackButton` component can be used to navigate to a screen and
+ * should be used in combination with the `Navigator`, the `Navigator.Screen`
+ * and the `Navigator.Button` components.
+ *
+ * @example
+ * ```jsx
+ * import { Navigator } from '@wordpress/components';
+ *
+ * const MyNavigation = () => (
+ *
+ *
+ * This is the home screen.
+ *
+ * Navigate to child screen.
+ *
+ *
+ *
+ *
+ * This is the child screen.
+ *
+ * Go back
+ *
+ *
+ *
+ * );
+ * ```
+ */
+ BackButton: Object.assign( NavigatorBackButton, {
+ displayName: 'Navigator.BackButton',
+ } ),
+} );
diff --git a/packages/components/src/navigator/legacy.ts b/packages/components/src/navigator/legacy.ts
new file mode 100644
index 00000000000000..1caa5380fc049e
--- /dev/null
+++ b/packages/components/src/navigator/legacy.ts
@@ -0,0 +1,169 @@
+/**
+ * Internal dependencies
+ */
+import { Navigator as InternalNavigator } from './navigator/component';
+import { NavigatorScreen as InternalNavigatorScreen } from './navigator-screen/component';
+import { NavigatorButton as InternalNavigatorButton } from './navigator-button/component';
+import { NavigatorBackButton as InternalNavigatorBackButton } from './navigator-back-button/component';
+import { NavigatorToParentButton as InternalNavigatorToParentButton } from './navigator-to-parent-button/component';
+export { useNavigator } from './use-navigator';
+
+/**
+ * The `NavigatorProvider` component allows rendering nested views/panels/menus
+ * (via the `NavigatorScreen` component and navigate between them
+ * (via the `NavigatorButton` and `NavigatorBackButton` components).
+ *
+ * ```jsx
+ * import {
+ * __experimentalNavigatorProvider as NavigatorProvider,
+ * __experimentalNavigatorScreen as NavigatorScreen,
+ * __experimentalNavigatorButton as NavigatorButton,
+ * __experimentalNavigatorBackButton as NavigatorBackButton,
+ * } from '@wordpress/components';
+ *
+ * const MyNavigation = () => (
+ *
+ *
+ * This is the home screen.
+ *
+ * Navigate to child screen.
+ *
+ *
+ *
+ *
+ * This is the child screen.
+ *
+ * Go back
+ *
+ *
+ *
+ * );
+ * ```
+ */
+export const NavigatorProvider = Object.assign( InternalNavigator, {
+ displayName: 'NavigatorProvider',
+} );
+
+/**
+ * The `NavigatorScreen` component represents a single view/screen/panel and
+ * should be used in combination with the `NavigatorProvider`, the
+ * `NavigatorButton` and the `NavigatorBackButton` components.
+ *
+ * @example
+ * ```jsx
+ * import {
+ * __experimentalNavigatorProvider as NavigatorProvider,
+ * __experimentalNavigatorScreen as NavigatorScreen,
+ * __experimentalNavigatorButton as NavigatorButton,
+ * __experimentalNavigatorBackButton as NavigatorBackButton,
+ * } from '@wordpress/components';
+ *
+ * const MyNavigation = () => (
+ *
+ *
+ * This is the home screen.
+ *
+ * Navigate to child screen.
+ *
+ *
+ *
+ *
+ * This is the child screen.
+ *
+ * Go back
+ *
+ *
+ *
+ * );
+ * ```
+ */
+export const NavigatorScreen = Object.assign( InternalNavigatorScreen, {
+ displayName: 'NavigatorScreen',
+} );
+
+/**
+ * The `NavigatorButton` component can be used to navigate to a screen and should
+ * be used in combination with the `NavigatorProvider`, the `NavigatorScreen`
+ * and the `NavigatorBackButton` components.
+ *
+ * @example
+ * ```jsx
+ * import {
+ * __experimentalNavigatorProvider as NavigatorProvider,
+ * __experimentalNavigatorScreen as NavigatorScreen,
+ * __experimentalNavigatorButton as NavigatorButton,
+ * __experimentalNavigatorBackButton as NavigatorBackButton,
+ * } from '@wordpress/components';
+ *
+ * const MyNavigation = () => (
+ *
+ *
+ * This is the home screen.
+ *
+ * Navigate to child screen.
+ *
+ *
+ *
+ *
+ * This is the child screen.
+ *
+ * Go back
+ *
+ *
+ *
+ * );
+ * ```
+ */
+export const NavigatorButton = Object.assign( InternalNavigatorButton, {
+ displayName: 'NavigatorButton',
+} );
+
+/**
+ * The `NavigatorBackButton` component can be used to navigate to a screen and
+ * should be used in combination with the `NavigatorProvider`, the
+ * `NavigatorScreen` and the `NavigatorButton` components.
+ *
+ * @example
+ * ```jsx
+ * import {
+ * __experimentalNavigatorProvider as NavigatorProvider,
+ * __experimentalNavigatorScreen as NavigatorScreen,
+ * __experimentalNavigatorButton as NavigatorButton,
+ * __experimentalNavigatorBackButton as NavigatorBackButton,
+ * } from '@wordpress/components';
+ *
+ * const MyNavigation = () => (
+ *
+ *
+ * This is the home screen.
+ *
+ * Navigate to child screen.
+ *
+ *
+ *
+ *
+ * This is the child screen.
+ *
+ * Go back (to parent)
+ *
+ *
+ *
+ * );
+ * ```
+ */
+export const NavigatorBackButton = Object.assign( InternalNavigatorBackButton, {
+ displayName: 'NavigatorBackButton',
+} );
+
+/**
+ * _Note: this component is deprecated. Please use the `NavigatorBackButton`
+ * component instead._
+ *
+ * @deprecated
+ */
+export const NavigatorToParentButton = Object.assign(
+ InternalNavigatorToParentButton,
+ {
+ displayName: 'NavigatorToParentButton',
+ }
+);
diff --git a/packages/components/src/navigator/navigator-back-button/README.md b/packages/components/src/navigator/navigator-back-button/README.md
deleted file mode 100644
index 01d4221be536e5..00000000000000
--- a/packages/components/src/navigator/navigator-back-button/README.md
+++ /dev/null
@@ -1,15 +0,0 @@
-# `NavigatorBackButton`
-
-
-This feature is still experimental. āExperimentalā means this is an early implementation subject to drastic and breaking changes.
-
-
-The `NavigatorBackButton` component can be used to navigate to a screen and should be used in combination with the [`NavigatorProvider`](/packages/components/src/navigator/navigator-provider/README.md), the [`NavigatorScreen`](/packages/components/src/navigator/navigator-screen/README.md) and the [`NavigatorButton`](/packages/components/src/navigator/navigator-button/README.md) components (or the `useNavigator` hook).
-
-## Usage
-
-Refer to [the `NavigatorProvider` component](/packages/components/src/navigator/navigator-provider/README.md#usage) for a usage example.
-
-### Inherited props
-
-`NavigatorBackButton` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href` and `target`.
diff --git a/packages/components/src/navigator/navigator-back-button/component.tsx b/packages/components/src/navigator/navigator-back-button/component.tsx
index 88ed45b643a13d..b5c4de7df78a85 100644
--- a/packages/components/src/navigator/navigator-back-button/component.tsx
+++ b/packages/components/src/navigator/navigator-back-button/component.tsx
@@ -21,43 +21,7 @@ function UnconnectedNavigatorBackButton(
return
;
}
-/**
- * The `NavigatorBackButton` component can be used to navigate to a screen and
- * should be used in combination with the `NavigatorProvider`, the
- * `NavigatorScreen` and the `NavigatorButton` components (or the `useNavigator`
- * hook).
- *
- * @example
- * ```jsx
- * import {
- * __experimentalNavigatorProvider as NavigatorProvider,
- * __experimentalNavigatorScreen as NavigatorScreen,
- * __experimentalNavigatorButton as NavigatorButton,
- * __experimentalNavigatorBackButton as NavigatorBackButton,
- * } from '@wordpress/components';
- *
- * const MyNavigation = () => (
- *
- *
- * This is the home screen.
- *
- * Navigate to child screen.
- *
- *
- *
- *
- * This is the child screen.
- *
- * Go back (to parent)
- *
- *
- *
- * );
- * ```
- */
export const NavigatorBackButton = contextConnect(
UnconnectedNavigatorBackButton,
- 'NavigatorBackButton'
+ 'Navigator.BackButton'
);
-
-export default NavigatorBackButton;
diff --git a/packages/components/src/navigator/navigator-back-button/hook.ts b/packages/components/src/navigator/navigator-back-button/hook.ts
index 9ddc095292190a..d6fcd39647bff9 100644
--- a/packages/components/src/navigator/navigator-back-button/hook.ts
+++ b/packages/components/src/navigator/navigator-back-button/hook.ts
@@ -20,7 +20,7 @@ export function useNavigatorBackButton(
as = Button,
...otherProps
- } = useContextSystem( props, 'NavigatorBackButton' );
+ } = useContextSystem( props, 'Navigator.BackButton' );
const { goBack } = useNavigator();
const handleClick: React.MouseEventHandler< HTMLButtonElement > =
diff --git a/packages/components/src/navigator/navigator-button/README.md b/packages/components/src/navigator/navigator-button/README.md
deleted file mode 100644
index 72154ec317da44..00000000000000
--- a/packages/components/src/navigator/navigator-button/README.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# `NavigatorButton`
-
-
-This feature is still experimental. āExperimentalā means this is an early implementation subject to drastic and breaking changes.
-
-
-The `NavigatorButton` component can be used to navigate to a screen and should be used in combination with the [`NavigatorProvider`](/packages/components/src/navigator/navigator-provider/README.md), the [`NavigatorScreen`](/packages/components/src/navigator/navigator-screen/README.md) and the [`NavigatorBackButton`](/packages/components/src/navigator/navigator-back-button/README.md) components (or the `useNavigator` hook).
-
-## Usage
-
-Refer to [the `NavigatorProvider` component](/packages/components/src/navigator/navigator-provider/README.md#usage) for a usage example.
-
-## Props
-
-The component accepts the following props:
-
-### `attributeName`: `string`
-
-The HTML attribute used to identify the `NavigatorButton`, which is used by `Navigator` to restore focus.
-
-- Required: No
-- Default: `id`
-
-### `onClick`: `React.MouseEventHandler< HTMLElement >`
-
-The callback called in response to a `click` event.
-
-- Required: No
-
-### `path`: `string`
-
-The path of the screen to navigate to. The value of this prop needs to be [a valid value for an HTML attribute](https://html.spec.whatwg.org/multipage/syntax.html#attributes-2).
-
-- Required: Yes
-
-### Inherited props
-
-`NavigatorButton` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href` and `target`.
diff --git a/packages/components/src/navigator/navigator-button/component.tsx b/packages/components/src/navigator/navigator-button/component.tsx
index 1b84a2315c04d3..a6dc7963376723 100644
--- a/packages/components/src/navigator/navigator-button/component.tsx
+++ b/packages/components/src/navigator/navigator-button/component.tsx
@@ -21,42 +21,7 @@ function UnconnectedNavigatorButton(
return
;
}
-/**
- * The `NavigatorButton` component can be used to navigate to a screen and should
- * be used in combination with the `NavigatorProvider`, the `NavigatorScreen`
- * and the `NavigatorBackButton` components (or the `useNavigator` hook).
- *
- * @example
- * ```jsx
- * import {
- * __experimentalNavigatorProvider as NavigatorProvider,
- * __experimentalNavigatorScreen as NavigatorScreen,
- * __experimentalNavigatorButton as NavigatorButton,
- * __experimentalNavigatorBackButton as NavigatorBackButton,
- * } from '@wordpress/components';
- *
- * const MyNavigation = () => (
- *
- *
- * This is the home screen.
- *
- * Navigate to child screen.
- *
- *
- *
- *
- * This is the child screen.
- *
- * Go back
- *
- *
- *
- * );
- * ```
- */
export const NavigatorButton = contextConnect(
UnconnectedNavigatorButton,
- 'NavigatorButton'
+ 'Navigator.Button'
);
-
-export default NavigatorButton;
diff --git a/packages/components/src/navigator/navigator-button/hook.ts b/packages/components/src/navigator/navigator-button/hook.ts
index 3e39b05661e1b2..59d2aaa65662d7 100644
--- a/packages/components/src/navigator/navigator-button/hook.ts
+++ b/packages/components/src/navigator/navigator-button/hook.ts
@@ -25,7 +25,7 @@ export function useNavigatorButton(
as = Button,
attributeName = 'id',
...otherProps
- } = useContextSystem( props, 'NavigatorButton' );
+ } = useContextSystem( props, 'Navigator.Button' );
const escapedPath = escapeAttribute( path );
diff --git a/packages/components/src/navigator/navigator-provider/README.md b/packages/components/src/navigator/navigator-provider/README.md
deleted file mode 100644
index 35bf7a69720be2..00000000000000
--- a/packages/components/src/navigator/navigator-provider/README.md
+++ /dev/null
@@ -1,94 +0,0 @@
-# `NavigatorProvider`
-
-
-This feature is still experimental. āExperimentalā means this is an early implementation subject to drastic and breaking changes.
-
-
-The `NavigatorProvider` component allows rendering nested views/panels/menus (via the [`NavigatorScreen` component](/packages/components/src/navigator/navigator-screen/README.md)) and navigate between these different states (via the [`NavigatorButton`](/packages/components/src/navigator/navigator-button/README.md), [`NavigatorToParentButton`](/packages/components/src/navigator/navigator-to-parent-button/README.md) and [`NavigatorBackButton`](/packages/components/src/navigator/navigator-back-button/README.md) components or the `useNavigator` hook). The Global Styles sidebar is an example of this.
-
-## Usage
-
-```jsx
-import {
- __experimentalNavigatorProvider as NavigatorProvider,
- __experimentalNavigatorScreen as NavigatorScreen,
- __experimentalNavigatorButton as NavigatorButton,
- __experimentalNavigatorBackButton as NavigatorBackButton,
-} from '@wordpress/components';
-
-const MyNavigation = () => (
-
-
- This is the home screen.
-
- Navigate to child screen.
-
-
-
-
- This is the child screen.
- Go back
-
-
-);
-```
-
-**Important note**
-
-`Navigator` assumes that screens are organized hierarchically according to their `path`, which should follow a URL-like scheme where each path segment starts with and is separated by the `/` character.
-
-`Navigator` will treat "back" navigations as going to the parent screen ā it is therefore responsibility of the consumer of the component to create the correct screen hierarchy.
-
-For example:
-
-- `/` is the root of all paths. There should always be a screen with `path="/"`.
-- `/parent/child` is a child of `/parent`.
-- `/parent/child/grand-child` is a child of `/parent/child`.
-- `/parent/:param` is a child of `/parent` as well.
-- if the current screen has a `path` with value `/parent/child/grand-child`, when going "back" `Navigator` will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) is found.
-
-## Props
-
-The component accepts the following props:
-
-### `initialPath`: `string`
-
-The initial active path.
-
-- Required: No
-
-## The `navigator` object
-
-You can retrieve a `navigator` instance by using the `useNavigator` hook.
-
-The `navigator` instance has a few properties:
-
-### `goTo`: `( path: string, options: NavigateOptions ) => void`
-
-The `goTo` function allows navigating to a given path. The second argument can augment the navigation operations with different options.
-
-The available options are:
-
-- `focusTargetSelector`: `string`. An optional property used to specify the CSS selector used to restore focus on the matching element when navigating back;
-- `isBack`: `boolean`. An optional property used to specify whether the navigation should be considered as backwards (thus enabling focus restoration when possible, and causing the animation to be backwards too);
-- `skipFocus`: `boolean`. An optional property used to opt out of `Navigator`'s focus management, useful when the consumer of the component wants to manage focus themselves;
-
-### `goBack`: `( path: string, options: NavigateOptions ) => void`
-
-The `goBack` function allows navigating to the parent screen. Parent/child navigation only works if the paths you define are hierarchical (see note above).
-
-When a match is not found, the function will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) are found.
-
-The available options are the same as for the `goTo` method, except for the `isBack` property, which is not available for the `goBack` method.
-
-### `location`: `NavigatorLocation`
-
-The `location` object represent the current location, and has a few properties:
-
-- `path`: `string`. The path associated to the location.
-- `isBack`: `boolean`. A flag that is `true` when the current location was reached by navigating backwards.
-- `isInitial`: `boolean`. A flag that is `true` only for the initial location.
-
-### `params`: `Record< string, string | string[] >`
-
-The parsed record of parameters from the current location. For example if the current screen path is `/product/:productId` and the location is `/product/123`, then `params` will be `{ productId: '123' }`.
diff --git a/packages/components/src/navigator/navigator-screen/README.md b/packages/components/src/navigator/navigator-screen/README.md
deleted file mode 100644
index 583da461cd3c27..00000000000000
--- a/packages/components/src/navigator/navigator-screen/README.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# `NavigatorScreen`
-
-
-This feature is still experimental. āExperimentalā means this is an early implementation subject to drastic and breaking changes.
-
-
-The `NavigatorScreen` component represents a single view/screen/panel and should be used in combination with the [`NavigatorProvider`](/packages/components/src/navigator/navigator-provider/README.md), the [`NavigatorButton`](/packages/components/src/navigator/navigator-button/README.md) and the [`NavigatorBackButton`](/packages/components/src/navigator/navigator-back-button/README.md) components (or the `useNavigator` hook).
-
-## Usage
-
-Refer to [the `NavigatorProvider` component](/packages/components/src/navigator/navigator-provider/README.md#usage) for a usage example.
-
-## Props
-
-The component accepts the following props:
-
-### `path`: `string`
-
-The screen"s path, matched against the current path stored in the navigator.
-
-`Navigator` assumes that screens are organized hierarchically according to their `path`, which should follow a URL-like scheme where each path segment starts with and is separated by the `/` character.
-
-`Navigator` will treat "back" navigations as going to the parent screen ā it is therefore responsibility of the consumer of the component to create the correct screen hierarchy.
-
-For example:
-
-- `/` is the root of all paths. There should always be a screen with `path="/"`.
-- `/parent/child` is a child of `/parent`.
-- `/parent/child/grand-child` is a child of `/parent/child`.
-- `/parent/:param` is a child of `/parent` as well.
-- if the current screen has a `path` with value `/parent/child/grand-child`, when going "back" `Navigator` will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) is found.
-
-- Required: Yes
diff --git a/packages/components/src/navigator/navigator-screen/component.tsx b/packages/components/src/navigator/navigator-screen/component.tsx
index 5882f271d4518f..fe0d81b90a17be 100644
--- a/packages/components/src/navigator/navigator-screen/component.tsx
+++ b/packages/components/src/navigator/navigator-screen/component.tsx
@@ -15,7 +15,6 @@ import {
useId,
} from '@wordpress/element';
import { useMergeRefs } from '@wordpress/compose';
-import { isRTL as isRTLFn } from '@wordpress/i18n';
import { escapeAttribute } from '@wordpress/escape-html';
import warning from '@wordpress/warning';
@@ -29,6 +28,7 @@ import { View } from '../../view';
import { NavigatorContext } from '../context';
import * as styles from '../styles';
import type { NavigatorScreenProps } from '../types';
+import { useScreenAnimatePresence } from './use-screen-animate-presence';
function UnconnectedNavigatorScreen(
props: WordPressComponentProps< NavigatorScreenProps, 'div', false >,
@@ -36,21 +36,29 @@ function UnconnectedNavigatorScreen(
) {
if ( ! /^\//.test( props.path ) ) {
warning(
- 'wp.components.NavigatorScreen: the `path` should follow a URL-like scheme; it should start with and be separated by the `/` character.'
+ 'wp.components.Navigator.Screen: the `path` should follow a URL-like scheme; it should start with and be separated by the `/` character.'
);
}
const screenId = useId();
- const { children, className, path, ...otherProps } = useContextSystem(
- props,
- 'NavigatorScreen'
- );
+
+ const {
+ children,
+ className,
+ path,
+ onAnimationEnd: onAnimationEndProp,
+ ...otherProps
+ } = useContextSystem( props, 'Navigator.Screen' );
const { location, match, addScreen, removeScreen } =
useContext( NavigatorContext );
+ const { isInitial, isBack, focusTargetSelector, skipFocus } = location;
+
const isMatch = match === screenId;
const wrapperRef = useRef< HTMLDivElement >( null );
+ const skipAnimationAndFocusRestoration = !! isInitial && ! isBack;
+ // Register / unregister screen with the navigator context.
useEffect( () => {
const screen = {
id: screenId,
@@ -60,31 +68,28 @@ function UnconnectedNavigatorScreen(
return () => removeScreen( screen );
}, [ screenId, path, addScreen, removeScreen ] );
- const isRTL = isRTLFn();
- const { isInitial, isBack } = location;
+ // Animation.
+ const { animationStyles, shouldRenderScreen, screenProps } =
+ useScreenAnimatePresence( {
+ isMatch,
+ isBack,
+ onAnimationEnd: onAnimationEndProp,
+ skipAnimation: skipAnimationAndFocusRestoration,
+ } );
+
const cx = useCx();
const classes = useMemo(
- () =>
- cx(
- styles.navigatorScreen( {
- isInitial,
- isBack,
- isRTL,
- } ),
- className
- ),
- [ className, cx, isInitial, isBack, isRTL ]
+ () => cx( styles.navigatorScreen, animationStyles, className ),
+ [ className, cx, animationStyles ]
);
+ // Focus restoration
const locationRef = useRef( location );
-
useEffect( () => {
locationRef.current = location;
}, [ location ] );
-
- // Focus restoration
- const isInitialLocation = location.isInitial && ! location.isBack;
useEffect( () => {
+ const wrapperEl = wrapperRef.current;
// Only attempt to restore focus:
// - if the current location is not the initial one (to avoid moving focus on page load)
// - when the screen becomes visible
@@ -92,20 +97,20 @@ function UnconnectedNavigatorScreen(
// - if focus hasn't already been restored for the current location
// - if the `skipFocus` option is not set to `true`. This is useful when we trigger the navigation outside of NavigatorScreen.
if (
- isInitialLocation ||
+ skipAnimationAndFocusRestoration ||
! isMatch ||
- ! wrapperRef.current ||
+ ! wrapperEl ||
locationRef.current.hasRestoredFocus ||
- location.skipFocus
+ skipFocus
) {
return;
}
- const activeElement = wrapperRef.current.ownerDocument.activeElement;
+ const activeElement = wrapperEl.ownerDocument.activeElement;
// If an element is already focused within the wrapper do not focus the
// element. This prevents inputs or buttons from losing focus unnecessarily.
- if ( wrapperRef.current.contains( activeElement ) ) {
+ if ( wrapperEl.contains( activeElement ) ) {
return;
}
@@ -113,75 +118,42 @@ function UnconnectedNavigatorScreen(
// When navigating back, if a selector is provided, use it to look for the
// target element (assumed to be a node inside the current NavigatorScreen)
- if ( location.isBack && location.focusTargetSelector ) {
- elementToFocus = wrapperRef.current.querySelector(
- location.focusTargetSelector
- );
+ if ( isBack && focusTargetSelector ) {
+ elementToFocus = wrapperEl.querySelector( focusTargetSelector );
}
// If the previous query didn't run or find any element to focus, fallback
// to the first tabbable element in the screen (or the screen itself).
if ( ! elementToFocus ) {
- const [ firstTabbable ] = focus.tabbable.find( wrapperRef.current );
- elementToFocus = firstTabbable ?? wrapperRef.current;
+ const [ firstTabbable ] = focus.tabbable.find( wrapperEl );
+ elementToFocus = firstTabbable ?? wrapperEl;
}
locationRef.current.hasRestoredFocus = true;
elementToFocus.focus();
}, [
- isInitialLocation,
+ skipAnimationAndFocusRestoration,
isMatch,
- location.isBack,
- location.focusTargetSelector,
- location.skipFocus,
+ isBack,
+ focusTargetSelector,
+ skipFocus,
] );
const mergedWrapperRef = useMergeRefs( [ forwardedRef, wrapperRef ] );
- return isMatch ? (
-
+ return shouldRenderScreen ? (
+
{ children }
) : null;
}
-/**
- * The `NavigatorScreen` component represents a single view/screen/panel and
- * should be used in combination with the `NavigatorProvider`, the
- * `NavigatorButton` and the `NavigatorBackButton` components (or the `useNavigator`
- * hook).
- *
- * @example
- * ```jsx
- * import {
- * __experimentalNavigatorProvider as NavigatorProvider,
- * __experimentalNavigatorScreen as NavigatorScreen,
- * __experimentalNavigatorButton as NavigatorButton,
- * __experimentalNavigatorBackButton as NavigatorBackButton,
- * } from '@wordpress/components';
- *
- * const MyNavigation = () => (
- *
- *
- * This is the home screen.
- *
- * Navigate to child screen.
- *
- *
- *
- *
- * This is the child screen.
- *
- * Go back
- *
- *
- *
- * );
- * ```
- */
export const NavigatorScreen = contextConnect(
UnconnectedNavigatorScreen,
- 'NavigatorScreen'
+ 'Navigator.Screen'
);
-
-export default NavigatorScreen;
diff --git a/packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts b/packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts
new file mode 100644
index 00000000000000..af5a47ee12df4c
--- /dev/null
+++ b/packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts
@@ -0,0 +1,177 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ useState,
+ useEffect,
+ useLayoutEffect,
+ useCallback,
+} from '@wordpress/element';
+import { useReducedMotion } from '@wordpress/compose';
+import { isRTL as isRTLFn } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import * as styles from '../styles';
+
+// Possible values:
+// - 'INITIAL': the initial state
+// - 'ANIMATING_IN': start enter animation
+// - 'IN': enter animation has ended
+// - 'ANIMATING_OUT': start exit animation
+// - 'OUT': the exit animation has ended
+type AnimationStatus =
+ | 'INITIAL'
+ | 'ANIMATING_IN'
+ | 'IN'
+ | 'ANIMATING_OUT'
+ | 'OUT';
+
+// Allow an extra 20% of the total animation duration to account for potential
+// event loop delays.
+const ANIMATION_TIMEOUT_MARGIN = 1.2;
+
+const isEnterAnimation = (
+ animationDirection: 'end' | 'start',
+ animationStatus: AnimationStatus,
+ animationName: string
+) =>
+ animationStatus === 'ANIMATING_IN' &&
+ animationName === styles.ANIMATION_END_NAMES[ animationDirection ].in;
+
+const isExitAnimation = (
+ animationDirection: 'end' | 'start',
+ animationStatus: AnimationStatus,
+ animationName: string
+) =>
+ animationStatus === 'ANIMATING_OUT' &&
+ animationName === styles.ANIMATION_END_NAMES[ animationDirection ].out;
+
+export function useScreenAnimatePresence( {
+ isMatch,
+ skipAnimation,
+ isBack,
+ onAnimationEnd,
+}: {
+ isMatch: boolean;
+ skipAnimation: boolean;
+ isBack?: boolean;
+ onAnimationEnd?: React.AnimationEventHandler< Element >;
+} ) {
+ const isRTL = isRTLFn();
+ const prefersReducedMotion = useReducedMotion();
+
+ const [ animationStatus, setAnimationStatus ] =
+ useState< AnimationStatus >( 'INITIAL' );
+
+ // Start enter and exit animations when the screen is selected or deselected.
+ // The animation status is set to `IN` or `OUT` immediately if the animation
+ // should be skipped.
+ const becameSelected =
+ animationStatus !== 'ANIMATING_IN' &&
+ animationStatus !== 'IN' &&
+ isMatch;
+ const becameUnselected =
+ animationStatus !== 'ANIMATING_OUT' &&
+ animationStatus !== 'OUT' &&
+ ! isMatch;
+ useLayoutEffect( () => {
+ if ( becameSelected ) {
+ setAnimationStatus(
+ skipAnimation || prefersReducedMotion ? 'IN' : 'ANIMATING_IN'
+ );
+ } else if ( becameUnselected ) {
+ setAnimationStatus(
+ skipAnimation || prefersReducedMotion ? 'OUT' : 'ANIMATING_OUT'
+ );
+ }
+ }, [
+ becameSelected,
+ becameUnselected,
+ skipAnimation,
+ prefersReducedMotion,
+ ] );
+
+ // Animation attributes (derived state).
+ const animationDirection =
+ ( isRTL && isBack ) || ( ! isRTL && ! isBack ) ? 'end' : 'start';
+ const isAnimatingIn = animationStatus === 'ANIMATING_IN';
+ const isAnimatingOut = animationStatus === 'ANIMATING_OUT';
+ let animationType: 'in' | 'out' | undefined;
+ if ( isAnimatingIn ) {
+ animationType = 'in';
+ } else if ( isAnimatingOut ) {
+ animationType = 'out';
+ }
+
+ const onScreenAnimationEnd = useCallback(
+ ( e: React.AnimationEvent< HTMLElement > ) => {
+ onAnimationEnd?.( e );
+
+ if (
+ isExitAnimation(
+ animationDirection,
+ animationStatus,
+ e.animationName
+ )
+ ) {
+ // When the exit animation ends on an unselected screen, set the
+ // status to 'OUT' to remove the screen contents from the DOM.
+ setAnimationStatus( 'OUT' );
+ } else if (
+ isEnterAnimation(
+ animationDirection,
+ animationStatus,
+ e.animationName
+ )
+ ) {
+ // When the enter animation ends on a selected screen, set the
+ // status to 'IN' to ensure the screen is rendered in the DOM.
+ setAnimationStatus( 'IN' );
+ }
+ },
+ [ onAnimationEnd, animationStatus, animationDirection ]
+ );
+
+ // Fallback timeout to ensure that the logic is applied even if the
+ // `animationend` event is not triggered.
+ useEffect( () => {
+ let animationTimeout: number | undefined;
+
+ if ( isAnimatingOut ) {
+ animationTimeout = window.setTimeout( () => {
+ setAnimationStatus( 'OUT' );
+ animationTimeout = undefined;
+ }, styles.TOTAL_ANIMATION_DURATION.OUT * ANIMATION_TIMEOUT_MARGIN );
+ } else if ( isAnimatingIn ) {
+ animationTimeout = window.setTimeout( () => {
+ setAnimationStatus( 'IN' );
+ animationTimeout = undefined;
+ }, styles.TOTAL_ANIMATION_DURATION.IN * ANIMATION_TIMEOUT_MARGIN );
+ }
+
+ return () => {
+ if ( animationTimeout ) {
+ window.clearTimeout( animationTimeout );
+ animationTimeout = undefined;
+ }
+ };
+ }, [ isAnimatingOut, isAnimatingIn ] );
+
+ return {
+ animationStyles: styles.navigatorScreenAnimation,
+ // Render the screen's contents in the DOM not only when the screen is
+ // selected, but also while it is animating out.
+ shouldRenderScreen:
+ isMatch ||
+ animationStatus === 'IN' ||
+ animationStatus === 'ANIMATING_OUT',
+ screenProps: {
+ onAnimationEnd: onScreenAnimationEnd,
+ 'data-animation-direction': animationDirection,
+ 'data-animation-type': animationType,
+ 'data-skip-animation': skipAnimation || undefined,
+ },
+ } as const;
+}
diff --git a/packages/components/src/navigator/navigator-to-parent-button/README.md b/packages/components/src/navigator/navigator-to-parent-button/README.md
deleted file mode 100644
index 0100ea9b8d2e1f..00000000000000
--- a/packages/components/src/navigator/navigator-to-parent-button/README.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# `NavigatorToParentButton`
-
-
-This feature is still experimental. āExperimentalā means this is an early implementation subject to drastic and breaking changes.
-
-
-This component is deprecated. Please use the [`NavigatorBackButton`](/packages/components/src/navigator/navigator-back-button/README.md) component instead.
-
-The `NavigatorToParentButton` component can be used to navigate to a screen and should be used in combination with the [`NavigatorProvider`](/packages/components/src/navigator/navigator-provider/README.md), the [`NavigatorScreen`](/packages/components/src/navigator/navigator-screen/README.md) and the [`NavigatorButton`](/packages/components/src/navigator/navigator-button/README.md) components (or the `useNavigator` hook).
-
-## Usage
-
-Refer to [the `NavigatorProvider` component](/packages/components/src/navigator/navigator-provider/README.md#usage) for a usage example.
-
-### Inherited props
-
-`NavigatorToParentButton` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href` and `target`.
diff --git a/packages/components/src/navigator/navigator-to-parent-button/component.tsx b/packages/components/src/navigator/navigator-to-parent-button/component.tsx
index fcbadea03cf7bb..f1c2d27e2284a1 100644
--- a/packages/components/src/navigator/navigator-to-parent-button/component.tsx
+++ b/packages/components/src/navigator/navigator-to-parent-button/component.tsx
@@ -17,21 +17,16 @@ function UnconnectedNavigatorToParentButton(
) {
deprecated( 'wp.components.NavigatorToParentButton', {
since: '6.7',
- alternative: 'wp.components.NavigatorBackButton',
+ alternative: 'wp.components.Navigator.BackButton',
} );
return ;
}
/**
- * _Note: this component is deprecated. Please use the `NavigatorBackButton`
- * component instead._
- *
* @deprecated
*/
export const NavigatorToParentButton = contextConnect(
UnconnectedNavigatorToParentButton,
- 'NavigatorToParentButton'
+ 'Navigator.ToParentButton'
);
-
-export default NavigatorToParentButton;
diff --git a/packages/components/src/navigator/navigator-provider/component.tsx b/packages/components/src/navigator/navigator/component.tsx
similarity index 81%
rename from packages/components/src/navigator/navigator-provider/component.tsx
rename to packages/components/src/navigator/navigator/component.tsx
index ebcb247c574830..bd49b3682fb144 100644
--- a/packages/components/src/navigator/navigator-provider/component.tsx
+++ b/packages/components/src/navigator/navigator/component.tsx
@@ -21,7 +21,7 @@ import { View } from '../../view';
import { NavigatorContext } from '../context';
import * as styles from '../styles';
import type {
- NavigatorProviderProps,
+ NavigatorProps,
NavigatorLocation,
NavigatorContext as NavigatorContextType,
NavigateOptions,
@@ -66,7 +66,7 @@ function goTo(
options: NavigateOptions = {}
) {
const { focusSelectors } = state;
- const currentLocation = { ...state.currentLocation, isInitial: false };
+ const currentLocation = { ...state.currentLocation };
const {
// Default assignments
@@ -114,6 +114,7 @@ function goTo(
return {
currentLocation: {
...restOptions,
+ isInitial: false,
path,
isBack,
hasRestoredFocus: false,
@@ -129,7 +130,7 @@ function goToParent(
options: NavigateToParentOptions = {}
) {
const { screens, focusSelectors } = state;
- const currentLocation = { ...state.currentLocation, isInitial: false };
+ const currentLocation = { ...state.currentLocation };
const currentPath = currentLocation.path;
if ( currentPath === undefined ) {
return { currentLocation, focusSelectors };
@@ -212,8 +213,8 @@ function routerReducer(
};
}
-function UnconnectedNavigatorProvider(
- props: WordPressComponentProps< NavigatorProviderProps, 'div' >,
+function UnconnectedNavigator(
+ props: WordPressComponentProps< NavigatorProps, 'div' >,
forwardedRef: ForwardedRef< any >
) {
const {
@@ -221,7 +222,7 @@ function UnconnectedNavigatorProvider(
children,
className,
...otherProps
- } = useContextSystem( props, 'NavigatorProvider' );
+ } = useContextSystem( props, 'Navigator' );
const [ routerState, dispatch ] = useReducer(
routerReducer,
@@ -274,7 +275,7 @@ function UnconnectedNavigatorProvider(
const cx = useCx();
const classes = useMemo(
- () => cx( styles.navigatorProviderWrapper, className ),
+ () => cx( styles.navigatorWrapper, className ),
[ className, cx ]
);
@@ -287,42 +288,4 @@ function UnconnectedNavigatorProvider(
);
}
-/**
- * The `NavigatorProvider` component allows rendering nested views/panels/menus
- * (via the `NavigatorScreen` component and navigate between these different
- * view (via the `NavigatorButton` and `NavigatorBackButton` components or the
- * `useNavigator` hook).
- *
- * ```jsx
- * import {
- * __experimentalNavigatorProvider as NavigatorProvider,
- * __experimentalNavigatorScreen as NavigatorScreen,
- * __experimentalNavigatorButton as NavigatorButton,
- * __experimentalNavigatorBackButton as NavigatorBackButton,
- * } from '@wordpress/components';
- *
- * const MyNavigation = () => (
- *
- *
- * This is the home screen.
- *
- * Navigate to child screen.
- *
- *
- *
- *
- * This is the child screen.
- *
- * Go back
- *
- *
- *
- * );
- * ```
- */
-export const NavigatorProvider = contextConnect(
- UnconnectedNavigatorProvider,
- 'NavigatorProvider'
-);
-
-export default NavigatorProvider;
+export const Navigator = contextConnect( UnconnectedNavigator, 'Navigator' );
diff --git a/packages/components/src/navigator/stories/index.story.tsx b/packages/components/src/navigator/stories/index.story.tsx
index 30b9c71a368c1a..e9e342bb0d2eee 100644
--- a/packages/components/src/navigator/stories/index.story.tsx
+++ b/packages/components/src/navigator/stories/index.story.tsx
@@ -8,20 +8,20 @@ import type { Meta, StoryObj } from '@storybook/react';
*/
import Button from '../../button';
import { VStack } from '../../v-stack';
-import {
- NavigatorProvider,
- NavigatorScreen,
- NavigatorButton,
- NavigatorBackButton,
- useNavigator,
-} from '..';
import { HStack } from '../../h-stack';
-
-const meta: Meta< typeof NavigatorProvider > = {
- component: NavigatorProvider,
- // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
- subcomponents: { NavigatorScreen, NavigatorButton, NavigatorBackButton },
- title: 'Components (Experimental)/Navigator',
+import { Navigator, useNavigator } from '../';
+
+const meta: Meta< typeof Navigator > = {
+ component: Navigator,
+ subcomponents: {
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ Screen: Navigator.Screen,
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ Button: Navigator.Button,
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ BackButton: Navigator.BackButton,
+ },
+ title: 'Components/Navigator',
argTypes: {
as: { control: { type: null } },
children: { control: { type: null } },
@@ -36,14 +36,14 @@ const meta: Meta< typeof NavigatorProvider > = {
return (
<>
@@ -55,55 +55,55 @@ const meta: Meta< typeof NavigatorProvider > = {
};
export default meta;
-export const Default: StoryObj< typeof NavigatorProvider > = {
+export const Default: StoryObj< typeof Navigator > = {
args: {
initialPath: '/',
children: (
<>
-
+
This is the home screen.
-
+
Go to child screen.
-
+
-
+
Go to dynamic path screen with id 1.
-
+
-
+
Go to dynamic path screen with id 2.
-
+
-
+
-
+
This is the child screen.
-
+
Go back
-
+
-
Go to grand child screen.
-
+
-
+
-
+
This is the grand child screen.
-
+
Go back
-
-
+
+
-
+
-
+
>
),
},
@@ -119,14 +119,14 @@ function DynamicScreen() {
This screen can parse params dynamically. The current id is:{ ' ' }
{ params.id }
-
+
Go back
-
+
>
);
}
-export const WithNestedInitialPath: StoryObj< typeof NavigatorProvider > = {
+export const WithNestedInitialPath: StoryObj< typeof Navigator > = {
...Default,
args: {
...Default.args,
@@ -138,7 +138,7 @@ const NavigatorButtonWithSkipFocus = ( {
path,
onClick,
...props
-}: React.ComponentProps< typeof NavigatorButton > ) => {
+}: React.ComponentProps< typeof Navigator.Button > ) => {
const { goTo } = useNavigator();
return (
@@ -156,7 +156,7 @@ const NavigatorButtonWithSkipFocus = ( {
);
};
-export const SkipFocus: StoryObj< typeof NavigatorProvider > = {
+export const SkipFocus: StoryObj< typeof Navigator > = {
args: {
initialPath: '/',
children: (
@@ -167,21 +167,22 @@ export const SkipFocus: StoryObj< typeof NavigatorProvider > = {
outline: '1px solid black',
outlineOffset: '-1px',
marginBlockEnd: '1rem',
+ display: 'contents',
} }
>
-
+
Home screen
-
+
Go to child screen.
-
-
+
+
-
+
Child screen
-
+
Go back to home screen
-
-
+
+
diff --git a/packages/components/src/navigator/styles.ts b/packages/components/src/navigator/styles.ts
index 0203edbdf1816a..167d4ac07de3d6 100644
--- a/packages/components/src/navigator/styles.ts
+++ b/packages/components/src/navigator/styles.ts
@@ -3,69 +3,140 @@
*/
import { css, keyframes } from '@emotion/react';
-export const navigatorProviderWrapper = css`
+export const navigatorWrapper = css`
+ position: relative;
/* Prevents horizontal overflow while animating screen transitions */
- overflow-x: hidden;
- /* Mark this subsection of the DOM as isolated, providing performance benefits
- * by limiting calculations of layout, style and paint to a DOM subtree rather
- * than the entire page.
+ overflow-x: clip;
+ /*
+ * Mark this DOM subtree as isolated when it comes to layout calculations,
+ * providing performance benefits.
*/
- contain: content;
+ contain: layout;
+
+ display: grid;
+ grid-template-columns: 1fr;
+ grid-template-rows: 1fr;
+ align-items: start;
`;
-const fadeInFromRight = keyframes( {
- '0%': {
+const fadeIn = keyframes( {
+ from: {
opacity: 0,
- transform: `translateX( 50px )`,
},
- '100%': { opacity: 1, transform: 'none' },
} );
-const fadeInFromLeft = keyframes( {
- '0%': {
+const fadeOut = keyframes( {
+ to: {
opacity: 0,
- transform: `translateX( -50px )`,
},
- '100%': { opacity: 1, transform: 'none' },
} );
-type NavigatorScreenAnimationProps = {
- isInitial?: boolean;
- isBack?: boolean;
- isRTL: boolean;
+export const slideFromRight = keyframes( {
+ from: {
+ transform: 'translateX(100px)',
+ },
+} );
+
+export const slideToLeft = keyframes( {
+ to: {
+ transform: 'translateX(-80px)',
+ },
+} );
+
+export const slideFromLeft = keyframes( {
+ from: {
+ transform: 'translateX(-100px)',
+ },
+} );
+
+export const slideToRight = keyframes( {
+ to: {
+ transform: 'translateX(80px)',
+ },
+} );
+
+const FADE = {
+ DURATION: 70,
+ EASING: 'linear',
+ DELAY: {
+ IN: 70,
+ OUT: 40,
+ },
+};
+const SLIDE = {
+ DURATION: 300,
+ EASING: 'cubic-bezier(0.33, 0, 0, 1)',
+};
+
+export const TOTAL_ANIMATION_DURATION = {
+ IN: Math.max( FADE.DURATION + FADE.DELAY.IN, SLIDE.DURATION ),
+ OUT: Math.max( FADE.DURATION + FADE.DELAY.OUT, SLIDE.DURATION ),
};
-const navigatorScreenAnimation = ( {
- isInitial,
- isBack,
- isRTL,
-}: NavigatorScreenAnimationProps ) => {
- if ( isInitial && ! isBack ) {
- return;
- }
+export const ANIMATION_END_NAMES = {
+ end: {
+ in: slideFromRight.name,
+ out: slideToLeft.name,
+ },
+ start: {
+ in: slideFromLeft.name,
+ out: slideToRight.name,
+ },
+};
- const animationName =
- ( isRTL && isBack ) || ( ! isRTL && ! isBack )
- ? fadeInFromRight
- : fadeInFromLeft;
+const ANIMATION = {
+ end: {
+ in: css`
+ ${ FADE.DURATION }ms ${ FADE.EASING } ${ FADE.DELAY
+ .IN }ms both ${ fadeIn }, ${ SLIDE.DURATION }ms ${ SLIDE.EASING } both ${ slideFromRight }
+ `,
+ out: css`
+ ${ FADE.DURATION }ms ${ FADE.EASING } ${ FADE.DELAY
+ .OUT }ms both ${ fadeOut }, ${ SLIDE.DURATION }ms ${ SLIDE.EASING } both ${ slideToLeft }
+ `,
+ },
+ start: {
+ in: css`
+ ${ FADE.DURATION }ms ${ FADE.EASING } ${ FADE.DELAY
+ .IN }ms both ${ fadeIn }, ${ SLIDE.DURATION }ms ${ SLIDE.EASING } both ${ slideFromLeft }
+ `,
+ out: css`
+ ${ FADE.DURATION }ms ${ FADE.EASING } ${ FADE.DELAY
+ .OUT }ms both ${ fadeOut }, ${ SLIDE.DURATION }ms ${ SLIDE.EASING } both ${ slideToRight }
+ `,
+ },
+} as const;
+export const navigatorScreenAnimation = css`
+ z-index: 1;
- return css`
- animation-duration: 0.14s;
- animation-timing-function: ease-in-out;
- will-change: transform, opacity;
- animation-name: ${ animationName };
+ &[data-animation-type='out'] {
+ z-index: 0;
+ }
- @media ( prefers-reduced-motion ) {
- animation-duration: 0s;
+ @media not ( prefers-reduced-motion ) {
+ &:not( [data-skip-animation] ) {
+ ${ ( [ 'start', 'end' ] as const ).map( ( direction ) =>
+ ( [ 'in', 'out' ] as const ).map(
+ ( type ) => css`
+ &[data-animation-direction='${ direction }'][data-animation-type='${ type }'] {
+ animation: ${ ANIMATION[ direction ][ type ] };
+ }
+ `
+ )
+ ) }
}
- `;
-};
+ }
+`;
-export const navigatorScreen = ( props: NavigatorScreenAnimationProps ) => css`
+export const navigatorScreen = css`
/* Ensures horizontal overflow is visually accessible */
overflow-x: auto;
/* In case the root has a height, it should not be exceeded */
max-height: 100%;
+ box-sizing: border-box;
+
+ position: relative;
- ${ navigatorScreenAnimation( props ) }
+ grid-column: 1 / -1;
+ grid-row: 1 / -1;
`;
diff --git a/packages/components/src/navigator/test/index.tsx b/packages/components/src/navigator/test/index.tsx
index 820942a22644ba..cab6e9a4cdadff 100644
--- a/packages/components/src/navigator/test/index.tsx
+++ b/packages/components/src/navigator/test/index.tsx
@@ -14,14 +14,8 @@ import { useState } from '@wordpress/element';
* Internal dependencies
*/
import Button from '../../button';
-import {
- NavigatorProvider,
- NavigatorScreen,
- NavigatorButton,
- NavigatorBackButton,
- NavigatorToParentButton,
- useNavigator,
-} from '..';
+import { Navigator, useNavigator } from '..';
+import { NavigatorToParentButton } from '../legacy';
import type { NavigateOptions } from '../types';
const INVALID_HTML_ATTRIBUTE = {
@@ -76,11 +70,11 @@ function CustomNavigatorButton( {
path,
onClick,
...props
-}: Omit< ComponentPropsWithoutRef< typeof NavigatorButton >, 'onClick' > & {
+}: Omit< ComponentPropsWithoutRef< typeof Navigator.Button >, 'onClick' > & {
onClick?: CustomTestOnClickHandler;
} ) {
return (
- {
// Used to spy on the values passed to `navigator.goTo`.
onClick?.( { type: 'goTo', path } );
@@ -95,7 +89,7 @@ function CustomNavigatorGoToBackButton( {
path,
onClick,
...props
-}: Omit< ComponentPropsWithoutRef< typeof NavigatorButton >, 'onClick' > & {
+}: Omit< ComponentPropsWithoutRef< typeof Navigator.Button >, 'onClick' > & {
onClick?: CustomTestOnClickHandler;
} ) {
const { goTo } = useNavigator();
@@ -115,7 +109,7 @@ function CustomNavigatorGoToSkipFocusButton( {
path,
onClick,
...props
-}: Omit< ComponentPropsWithoutRef< typeof NavigatorButton >, 'onClick' > & {
+}: Omit< ComponentPropsWithoutRef< typeof Navigator.Button >, 'onClick' > & {
onClick?: CustomTestOnClickHandler;
} ) {
const { goTo } = useNavigator();
@@ -134,11 +128,14 @@ function CustomNavigatorGoToSkipFocusButton( {
function CustomNavigatorBackButton( {
onClick,
...props
-}: Omit< ComponentPropsWithoutRef< typeof NavigatorBackButton >, 'onClick' > & {
+}: Omit<
+ ComponentPropsWithoutRef< typeof Navigator.BackButton >,
+ 'onClick'
+> & {
onClick?: CustomTestOnClickHandler;
} ) {
return (
- {
// Used to spy on the values passed to `navigator.goBack`.
onClick?.( { type: 'goBack' } );
@@ -151,7 +148,10 @@ function CustomNavigatorBackButton( {
function CustomNavigatorToParentButton( {
onClick,
...props
-}: Omit< ComponentPropsWithoutRef< typeof NavigatorBackButton >, 'onClick' > & {
+}: Omit<
+ ComponentPropsWithoutRef< typeof Navigator.BackButton >,
+ 'onClick'
+> & {
onClick?: CustomTestOnClickHandler;
} ) {
return (
@@ -194,13 +194,13 @@ const ProductScreen = ( {
const { params } = useNavigator();
return (
-
+
{ SCREEN_TEXT.product }
Product ID is { params.productId }
{ BUTTON_TEXT.back }
-
+
);
};
@@ -215,8 +215,8 @@ const MyNavigation = ( {
const [ outerInputValue, setOuterInputValue ] = useState( '' );
return (
<>
-
-
+
+
{ SCREEN_TEXT.home }
{ /*
* A button useful to test focus restoration. This button is the first
@@ -254,9 +254,9 @@ const MyNavigation = ( {
>
{ BUTTON_TEXT.toInvalidHtmlPathScreen }
-
+
-
+
{ SCREEN_TEXT.child }
{ /*
* A button useful to test focus restoration. This button is the first
@@ -286,30 +286,30 @@ const MyNavigation = ( {
} }
value={ innerInputValue }
/>
-
+
-
+
{ SCREEN_TEXT.nested }
{ BUTTON_TEXT.back }
-
+
-
+
{ SCREEN_TEXT.invalidHtmlPath }
{ BUTTON_TEXT.back }
-
+
- { /* A `NavigatorScreen` with `path={ PATHS.NOT_FOUND }` is purposefully not included. */ }
-
+ { /* A `Navigator.Screen` with `path={ PATHS.NOT_FOUND }` is purposefully not included. */ }
+
Outer input
{
return (
<>
-
-
+
+
{ SCREEN_TEXT.home }
{ /*
* A button useful to test focus restoration. This button is the first
@@ -349,9 +349,9 @@ const MyHierarchicalNavigation = ( {
>
{ BUTTON_TEXT.toChildScreen }
-
+
-
+
{ SCREEN_TEXT.child }
{ /*
* A button useful to test focus restoration. This button is the first
@@ -370,9 +370,9 @@ const MyHierarchicalNavigation = ( {
>
{ BUTTON_TEXT.back }
-
+
-
+
{ SCREEN_TEXT.nested }
{ BUTTON_TEXT.backUsingGoTo }
-
+
{ BUTTON_TEXT.goToWithSkipFocus }
-
+
>
);
};
@@ -406,8 +406,8 @@ const MyDeprecatedNavigation = ( {
} ) => {
return (
<>
-
-
+
+
{ SCREEN_TEXT.home }
{ /*
* A button useful to test focus restoration. This button is the first
@@ -421,9 +421,9 @@ const MyDeprecatedNavigation = ( {
>
{ BUTTON_TEXT.toChildScreen }
-
+
-
+
{ SCREEN_TEXT.child }
{ /*
* A button useful to test focus restoration. This button is the first
@@ -442,17 +442,17 @@ const MyDeprecatedNavigation = ( {
>
{ BUTTON_TEXT.back }
-
+
-
+
{ SCREEN_TEXT.nested }
{ BUTTON_TEXT.back }
-
-
+
+
>
);
};
@@ -643,10 +643,10 @@ describe( 'Navigator', () => {
} );
it( 'should warn if the `path` prop does not follow the required format', () => {
- render( Test );
+ render( Test );
expect( console ).toHaveWarnedWith(
- 'wp.components.NavigatorScreen: the `path` should follow a URL-like scheme; it should start with and be separated by the `/` character.'
+ 'wp.components.Navigator.Screen: the `path` should follow a URL-like scheme; it should start with and be separated by the `/` character.'
);
} );
@@ -880,7 +880,7 @@ describe( 'Navigator', () => {
// Rendering `NavigatorToParentButton` logs a deprecation notice
expect( console ).toHaveWarnedWith(
- 'wp.components.NavigatorToParentButton is deprecated since version 6.7. Please use wp.components.NavigatorBackButton instead.'
+ 'wp.components.NavigatorToParentButton is deprecated since version 6.7. Please use wp.components.Navigator.BackButton instead.'
);
} );
diff --git a/packages/components/src/navigator/types.ts b/packages/components/src/navigator/types.ts
index 855787b4d0a193..aeb5fd3b12c7fb 100644
--- a/packages/components/src/navigator/types.ts
+++ b/packages/components/src/navigator/types.ts
@@ -86,7 +86,7 @@ export type NavigatorContext = Navigator & {
match?: string;
};
-export type NavigatorProviderProps = {
+export type NavigatorProps = {
/**
* The initial active path.
*/
@@ -100,6 +100,24 @@ export type NavigatorProviderProps = {
export type NavigatorScreenProps = {
/**
* The screen's path, matched against the current path stored in the navigator.
+ *
+ * `Navigator` assumes that screens are organized hierarchically according
+ * to their `path`, which should follow a URL-like scheme where each path
+ * segment starts with and is separated by the `/` character.
+ *
+ * `Navigator` will treat "back" navigations as going to the parent screen ā
+ * it is, therefore, the responsibility of the consumer of the component to
+ * create the correct screen hierarchy.
+ *
+ * For example:
+ * - `/` is the root of all paths. There should always be a screen with
+ * `path="/"`;
+ * - `/parent/child` is a child of `/parent`;
+ * - `/parent/child/grand-child` is a child of `/parent/child`;
+ * - `/parent/:param` is a child of `/parent` as well;
+ * - if the current screen has a `path="/parent/child/grand-child"`, when
+ * going "back" `Navigator` will try to recursively navigate the path
+ * hierarchy until a matching screen (or the root `/`) is found.
*/
path: string;
/**
diff --git a/packages/components/src/navigator/use-navigator.ts b/packages/components/src/navigator/use-navigator.ts
index 7ac35d73150d32..1ea99f3f1c857d 100644
--- a/packages/components/src/navigator/use-navigator.ts
+++ b/packages/components/src/navigator/use-navigator.ts
@@ -10,7 +10,10 @@ import { NavigatorContext } from './context';
import type { Navigator } from './types';
/**
- * Retrieves a `navigator` instance.
+ * Retrieves a `navigator` instance. This hook provides advanced functionality,
+ * such as imperatively navigating to a new location (with options like
+ * navigating back or skipping focus restoration) and accessing the current
+ * location and path parameters.
*/
export function useNavigator(): Navigator {
const { location, params, goTo, goBack, goToParent } =
diff --git a/packages/components/src/search-control/index.tsx b/packages/components/src/search-control/index.tsx
index aac905e137e025..c41eda9b209b6c 100644
--- a/packages/components/src/search-control/index.tsx
+++ b/packages/components/src/search-control/index.tsx
@@ -67,7 +67,7 @@ function UnforwardedSearchControl(
) {
// @ts-expect-error The `disabled` prop is not yet supported in the SearchControl component.
// Work with the design team (@WordPress/gutenberg-design) if you need this feature.
- delete restProps.disabled;
+ const { disabled, ...filteredRestProps } = restProps;
const searchRef = useRef< HTMLInputElement >( null );
const instanceId = useInstanceId(
@@ -117,7 +117,7 @@ function UnforwardedSearchControl(
/>
}
- { ...restProps }
+ { ...filteredRestProps }
/>
);
diff --git a/packages/components/src/select-control/stories/index.story.tsx b/packages/components/src/select-control/stories/index.story.tsx
index 018f519e6b6d43..5e57a4eaecd5ab 100644
--- a/packages/components/src/select-control/stories/index.story.tsx
+++ b/packages/components/src/select-control/stories/index.story.tsx
@@ -12,6 +12,7 @@ import { useState } from '@wordpress/element';
* Internal dependencies
*/
import SelectControl from '../';
+import { InputControlPrefixWrapper } from '../../input-control/input-prefix-wrapper';
const meta: Meta< typeof SelectControl > = {
title: 'Components/SelectControl',
@@ -64,6 +65,7 @@ const SelectControlWithState: StoryFn< typeof SelectControl > = ( props ) => {
export const Default = SelectControlWithState.bind( {} );
Default.args = {
__nextHasNoMarginBottom: true,
+ label: 'Label',
options: [
{ value: '', label: 'Select an Option', disabled: true },
{ value: 'a', label: 'Option A' },
@@ -76,7 +78,6 @@ export const WithLabelAndHelpText = SelectControlWithState.bind( {} );
WithLabelAndHelpText.args = {
...Default.args,
help: 'Help text to explain the select control.',
- label: 'Value',
};
/**
@@ -86,6 +87,7 @@ WithLabelAndHelpText.args = {
export const WithCustomChildren = SelectControlWithState.bind( {} );
WithCustomChildren.args = {
__nextHasNoMarginBottom: true,
+ label: 'Label',
children: (
<>
Option 1
@@ -104,8 +106,19 @@ WithCustomChildren.args = {
),
};
+/**
+ * By default, the prefix is aligned with the edge of the input border, with no padding.
+ * If you want to apply standard padding in accordance with the size variant, wrap the element in the `` component.
+ */
+export const WithPrefix = SelectControlWithState.bind( {} );
+WithPrefix.args = {
+ ...Default.args,
+ prefix: Prefix: ,
+};
+
export const Minimal = SelectControlWithState.bind( {} );
Minimal.args = {
...Default.args,
variant: 'minimal',
+ hideLabelFromVision: true,
};
diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx
index e5f113d93b7d0e..0f7e0d2c6ac75f 100644
--- a/packages/components/src/tabs/stories/index.story.tsx
+++ b/packages/components/src/tabs/stories/index.story.tsx
@@ -70,6 +70,112 @@ const Template: StoryFn< typeof Tabs > = ( props ) => {
export const Default = Template.bind( {} );
+export const SizeAndOverflowPlayground: StoryFn< typeof Tabs > = ( props ) => {
+ const [ fullWidth, setFullWidth ] = useState( false );
+ return (
+
+
+
+ This story helps understand how the TabList component
+ behaves under different conditions. The container below
+ (with the dotted red border) can be horizontally resized,
+ and it has a bit of padding to be out of the way of the
+ TabList.
+
+
+ The button will toggle between full width (adding{ ' ' }
+ width: 100%
) and the default width.
+
+
Try the following:
+
+
+ Small container that causes tabs to
+ overflow with scroll.
+
+
+ Large container that exceeds the normal
+ width of the tabs.
+
+
+
+ With width: 100%
+ { ' ' }
+ set on the TabList (tabs fill up the space).
+
+
+
+ Without width: 100%
+ { ' ' }
+ (defaults to auto
) set on the
+ TabList (tabs take up space proportional to
+ their content).
+
+
+
+
+
+
setFullWidth( ! fullWidth ) }
+ >
+ { fullWidth
+ ? 'Remove width: 100% from TabList'
+ : 'Set width: 100% in TabList' }
+
+
+
+
+
+ Label with multiple words
+
+ Short
+
+ Hippopotomonstrosesquippedaliophobia
+
+ Tab 4
+ Tab 5
+
+
+
+ Selected tab: Tab 1
+ (Label with multiple words)
+
+
+ Selected tab: Tab 2
+ (Short)
+
+
+ Selected tab: Tab 3
+ (Hippopotomonstrosesquippedaliophobia)
+
+
+ Selected tab: Tab 4
+
+
+ Selected tab: Tab 5
+
+
+
+ );
+};
+SizeAndOverflowPlayground.args = {
+ defaultTabId: 'tab4',
+};
+
const VerticalTemplate: StoryFn< typeof Tabs > = ( props ) => {
return (
diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts
index c00943b180f637..283d6421f5b768 100644
--- a/packages/components/src/tabs/styles.ts
+++ b/packages/components/src/tabs/styles.ts
@@ -16,32 +16,40 @@ export const TabListWrapper = styled.div`
align-items: stretch;
flex-direction: row;
text-align: center;
+ overflow-x: auto;
&[aria-orientation='vertical'] {
flex-direction: column;
text-align: start;
}
- @media not ( prefers-reduced-motion ) {
- &.is-animation-enabled::after {
- transition-property: transform;
- transition-duration: 0.2s;
- transition-timing-function: ease-out;
- }
+ :where( [aria-orientation='horizontal'] ) {
+ width: fit-content;
}
+
--direction-factor: 1;
- --direction-origin-x: left;
+ --direction-start: left;
+ --direction-end: right;
--indicator-start: var( --indicator-left );
&:dir( rtl ) {
--direction-factor: -1;
- --direction-origin-x: right;
+ --direction-start: right;
+ --direction-end: left;
--indicator-start: var( --indicator-right );
}
- &::after {
+
+ @media not ( prefers-reduced-motion ) {
+ &.is-animation-enabled::before {
+ transition-property: transform;
+ transition-duration: 0.2s;
+ transition-timing-function: ease-out;
+ }
+ }
+ &::before {
content: '';
position: absolute;
pointer-events: none;
- transform-origin: var( --direction-origin-x ) top;
+ transform-origin: var( --direction-start ) top;
// Windows high contrast mode.
outline: 2px solid transparent;
@@ -52,7 +60,31 @@ export const TabListWrapper = styled.div`
when scaling in the transform, see: https://stackoverflow.com/a/52159123 */
--antialiasing-factor: 100;
&:not( [aria-orientation='vertical'] ) {
- &::after {
+ --fade-width: 4rem;
+ --fade-gradient-base: transparent 0%, black var( --fade-width );
+ --fade-gradient-composed: var( --fade-gradient-base ), black 60%,
+ transparent 50%;
+ &.is-overflowing-first {
+ mask-image: linear-gradient(
+ to var( --direction-end ),
+ var( --fade-gradient-base )
+ );
+ }
+ &.is-overflowing-last {
+ mask-image: linear-gradient(
+ to var( --direction-start ),
+ var( --fade-gradient-base )
+ );
+ }
+ &.is-overflowing-first.is-overflowing-last {
+ mask-image: linear-gradient(
+ to right,
+ var( --fade-gradient-composed )
+ ),
+ linear-gradient( to left, var( --fade-gradient-composed ) );
+ }
+
+ &::before {
bottom: 0;
height: 0;
width: calc( var( --antialiasing-factor ) * 1px );
@@ -71,8 +103,7 @@ export const TabListWrapper = styled.div`
${ COLORS.theme.accent };
}
}
- &[aria-orientation='vertical']::after {
- z-index: -1;
+ &[aria-orientation='vertical']::before {
top: 0;
left: 0;
width: 100%;
@@ -87,14 +118,14 @@ export const TabListWrapper = styled.div`
export const Tab = styled( Ariakit.Tab )`
& {
+ scroll-margin: 24px;
+ flex-grow: 1;
+ flex-shrink: 0;
display: inline-flex;
align-items: center;
position: relative;
border-radius: 0;
- min-height: ${ space(
- 12
- ) }; // Avoid fixed height to allow for long strings that go in multiple lines.
- height: auto;
+ height: ${ space( 12 ) };
background: transparent;
border: none;
box-shadow: none;
@@ -104,7 +135,6 @@ export const Tab = styled( Ariakit.Tab )`
margin-left: 0;
font-weight: 500;
text-align: inherit;
- hyphens: auto;
color: ${ COLORS.theme.foreground };
&[aria-disabled='true'] {
@@ -123,7 +153,7 @@ export const Tab = styled( Ariakit.Tab )`
}
// Focus.
- &::before {
+ &::after {
content: '';
position: absolute;
top: ${ space( 3 ) };
@@ -146,7 +176,7 @@ export const Tab = styled( Ariakit.Tab )`
}
}
- &:focus-visible::before {
+ &:focus-visible::after {
opacity: 1;
}
}
@@ -156,6 +186,10 @@ export const Tab = styled( Ariakit.Tab )`
10
) }; // Avoid fixed height to allow for long strings that go in multiple lines.
}
+
+ [aria-orientation='horizontal'] & {
+ justify-content: center;
+ }
`;
export const TabPanel = styled( Ariakit.TabPanel )`
diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx
index 2977d6a6283708..ae8daf60fc237c 100644
--- a/packages/components/src/tabs/tablist.tsx
+++ b/packages/components/src/tabs/tablist.tsx
@@ -8,7 +8,8 @@ import { useStoreState } from '@ariakit/react';
* WordPress dependencies
*/
import warning from '@wordpress/warning';
-import { forwardRef, useState } from '@wordpress/element';
+import { forwardRef, useLayoutEffect, useState } from '@wordpress/element';
+import { useMergeRefs } from '@wordpress/compose';
/**
* Internal dependencies
@@ -20,33 +21,58 @@ import type { WordPressComponentProps } from '../context';
import clsx from 'clsx';
import { useTrackElementOffsetRect } from '../utils/element-rect';
import { useOnValueUpdate } from '../utils/hooks/use-on-value-update';
+import { useTrackOverflow } from './use-track-overflow';
+
+const SCROLL_MARGIN = 24;
export const TabList = forwardRef<
HTMLDivElement,
WordPressComponentProps< TabListProps, 'div', false >
>( function TabList( { children, ...otherProps }, ref ) {
- const context = useTabsContext();
+ const { store } = useTabsContext() ?? {};
+
+ const selectedId = useStoreState( store, 'selectedId' );
+ const activeId = useStoreState( store, 'activeId' );
+ const selectOnMove = useStoreState( store, 'selectOnMove' );
+ const items = useStoreState( store, 'items' );
+ const [ parent, setParent ] = useState< HTMLElement | null >();
+ const refs = useMergeRefs( [ ref, setParent ] );
+ const overflow = useTrackOverflow( parent, {
+ first: items?.at( 0 )?.element,
+ last: items?.at( -1 )?.element,
+ } );
- const tabStoreState = useStoreState( context?.store );
- const selectedId = tabStoreState?.selectedId;
- const indicatorPosition = useTrackElementOffsetRect(
- context?.store.item( selectedId )?.element
+ const selectedTabPosition = useTrackElementOffsetRect(
+ store?.item( selectedId )?.element
);
const [ animationEnabled, setAnimationEnabled ] = useState( false );
- useOnValueUpdate(
- selectedId,
- ( { previousValue } ) => previousValue && setAnimationEnabled( true )
- );
+ useOnValueUpdate( selectedId, ( { previousValue } ) => {
+ if ( previousValue ) {
+ setAnimationEnabled( true );
+ }
+ } );
- if ( ! context || ! tabStoreState ) {
- warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' );
- return null;
- }
+ // Make sure selected tab is scrolled into view.
+ useLayoutEffect( () => {
+ if ( ! parent || ! selectedTabPosition ) {
+ return;
+ }
+
+ const { scrollLeft: parentScroll } = parent;
+ const parentWidth = parent.getBoundingClientRect().width;
+ const { left: childLeft, width: childWidth } = selectedTabPosition;
- const { store } = context;
- const { activeId, selectOnMove } = tabStoreState;
- const { setActiveId } = store;
+ const parentRightEdge = parentScroll + parentWidth;
+ const childRightEdge = childLeft + childWidth;
+ const rightOverflow = childRightEdge + SCROLL_MARGIN - parentRightEdge;
+ const leftOverflow = parentScroll - ( childLeft - SCROLL_MARGIN );
+ if ( leftOverflow > 0 ) {
+ parent.scrollLeft = parentScroll - leftOverflow;
+ } else if ( rightOverflow > 0 ) {
+ parent.scrollLeft = parentScroll + rightOverflow;
+ }
+ }, [ parent, selectedTabPosition ] );
const onBlur = () => {
if ( ! selectOnMove ) {
@@ -58,35 +84,43 @@ export const TabList = forwardRef<
// that the selected tab will receive keyboard focus when tabbing back into
// the tablist.
if ( selectedId !== activeId ) {
- setActiveId( selectedId );
+ store?.setActiveId( selectedId );
}
};
+ if ( ! store ) {
+ warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' );
+ return null;
+ }
+
return (
{
- if ( event.pseudoElement === '::after' ) {
+ if ( event.pseudoElement === '::before' ) {
setAnimationEnabled( false );
}
} }
/>
}
onBlur={ onBlur }
+ tabIndex={ -1 }
{ ...otherProps }
style={ {
- '--indicator-top': indicatorPosition.top,
- '--indicator-right': indicatorPosition.right,
- '--indicator-left': indicatorPosition.left,
- '--indicator-width': indicatorPosition.width,
- '--indicator-height': indicatorPosition.height,
+ '--indicator-top': selectedTabPosition.top,
+ '--indicator-right': selectedTabPosition.right,
+ '--indicator-left': selectedTabPosition.left,
+ '--indicator-width': selectedTabPosition.width,
+ '--indicator-height': selectedTabPosition.height,
...otherProps.style,
} }
className={ clsx(
- animationEnabled ? 'is-animation-enabled' : '',
+ overflow.first && 'is-overflowing-first',
+ overflow.last && 'is-overflowing-last',
+ animationEnabled && 'is-animation-enabled',
otherProps.className
) }
>
diff --git a/packages/components/src/tabs/use-track-overflow.ts b/packages/components/src/tabs/use-track-overflow.ts
new file mode 100644
index 00000000000000..5f6504e6875212
--- /dev/null
+++ b/packages/components/src/tabs/use-track-overflow.ts
@@ -0,0 +1,76 @@
+/* eslint-disable jsdoc/require-param */
+/**
+ * WordPress dependencies
+ */
+import { useState, useEffect } from '@wordpress/element';
+import { useEvent } from '@wordpress/compose';
+
+/**
+ * Tracks if an element contains overflow and on which end by tracking the
+ * first and last child elements with an `IntersectionObserver` in relation
+ * to the parent element.
+ *
+ * Note that the returned value will only indicate whether the first or last
+ * element is currently "going out of bounds" but not whether it happens on
+ * the X or Y axis.
+ */
+export function useTrackOverflow(
+ parent: HTMLElement | undefined | null,
+ children: {
+ first: HTMLElement | undefined | null;
+ last: HTMLElement | undefined | null;
+ }
+) {
+ const [ first, setFirst ] = useState( false );
+ const [ last, setLast ] = useState( false );
+ const [ observer, setObserver ] = useState< IntersectionObserver >();
+
+ const callback: IntersectionObserverCallback = useEvent( ( entries ) => {
+ for ( const entry of entries ) {
+ if ( entry.target === children.first ) {
+ setFirst( ! entry.isIntersecting );
+ }
+ if ( entry.target === children.last ) {
+ setLast( ! entry.isIntersecting );
+ }
+ }
+ } );
+
+ useEffect( () => {
+ if ( ! parent || ! window.IntersectionObserver ) {
+ return;
+ }
+ const newObserver = new IntersectionObserver( callback, {
+ root: parent,
+ threshold: 0.9,
+ } );
+ setObserver( newObserver );
+
+ return () => newObserver.disconnect();
+ }, [ callback, parent ] );
+
+ useEffect( () => {
+ if ( ! observer ) {
+ return;
+ }
+
+ if ( children.first ) {
+ observer.observe( children.first );
+ }
+ if ( children.last ) {
+ observer.observe( children.last );
+ }
+
+ return () => {
+ if ( children.first ) {
+ observer.unobserve( children.first );
+ }
+ if ( children.last ) {
+ observer.unobserve( children.last );
+ }
+ };
+ }, [ children.first, children.last, observer ] );
+
+ return { first, last };
+}
+/* eslint-enable jsdoc/require-param */
diff --git a/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap b/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap
index e9b4f4ca22ab85..d2d98eaba85e6f 100644
--- a/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap
+++ b/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap
@@ -60,6 +60,55 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] =
outline-offset: -2px;
}
+@media not ( prefers-reduced-motion ) {
+ .emotion-8[data-indicator-animated]::before {
+ transition-property: transform,border-radius;
+ transition-duration: 0.2s;
+ transition-timing-function: ease-out;
+ }
+}
+
+.emotion-8::before {
+ content: '';
+ position: absolute;
+ pointer-events: none;
+ background: #1e1e1e;
+ outline: 2px solid transparent;
+ outline-offset: -3px;
+ --antialiasing-factor: 100;
+ border-radius: calc(
+ 1px /
+ (
+ var( --selected-width, 0 ) /
+ var( --antialiasing-factor )
+ )
+ )/1px;
+ left: -1px;
+ width: calc( var( --antialiasing-factor ) * 1px );
+ height: calc( var( --selected-height, 0 ) * 1px );
+ transform-origin: left top;
+ -webkit-transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX(
+ calc(
+ var( --selected-width, 0 ) / var( --antialiasing-factor )
+ )
+ );
+ -moz-transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX(
+ calc(
+ var( --selected-width, 0 ) / var( --antialiasing-factor )
+ )
+ );
+ -ms-transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX(
+ calc(
+ var( --selected-width, 0 ) / var( --antialiasing-factor )
+ )
+ );
+ transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX(
+ calc(
+ var( --selected-width, 0 ) / var( --antialiasing-factor )
+ )
+ );
+}
+
.emotion-10 {
display: -webkit-inline-box;
display: -webkit-inline-flex;
@@ -150,17 +199,7 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] =
line-height: 1;
}
-.emotion-15 {
- background: #1e1e1e;
- border-radius: 1px;
- position: absolute;
- inset: 0;
- z-index: 1;
- outline: 2px solid transparent;
- outline-offset: -3px;
-}
-
-.emotion-18 {
+.emotion-17 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
@@ -204,22 +243,22 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] =
}
@media not ( prefers-reduced-motion ) {
- .emotion-18 {
+ .emotion-17 {
-webkit-transition: background 160ms linear,color 160ms linear,font-weight 60ms linear;
transition: background 160ms linear,color 160ms linear,font-weight 60ms linear;
}
}
-.emotion-18::-moz-focus-inner {
+.emotion-17::-moz-focus-inner {
border: 0;
}
-.emotion-18[disabled] {
+.emotion-17[disabled] {
opacity: 0.4;
cursor: default;
}
-.emotion-18:active {
+.emotion-17:active {
background: #fff;
}
@@ -280,12 +319,6 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] =
-
-
{
if ( showTooltip && text ) {
return (
@@ -58,7 +51,6 @@ function ToggleGroupControlOptionBase(
>,
forwardedRef: ForwardedRef< any >
) {
- const shouldReduceMotion = useReducedMotion();
const toggleGroupControlContext = useToggleGroupControlContext();
const id = useInstanceId(
@@ -107,7 +99,6 @@ function ToggleGroupControlOptionBase(
),
[ cx, isDeselectable, isIcon, isPressed, size, className ]
);
- const backdropClasses = useMemo( () => cx( styles.backdropView ), [ cx ] );
const buttonOnClick = () => {
if ( isDeselectable && isPressed ) {
@@ -124,8 +115,15 @@ function ToggleGroupControlOptionBase(
ref: forwardedRef,
};
+ const labelRef = useRef< HTMLDivElement | null >( null );
+ useLayoutEffect( () => {
+ if ( isPressed && labelRef.current ) {
+ toggleGroupControlContext.setSelectedElement( labelRef.current );
+ }
+ }, [ isPressed, toggleGroupControlContext ] );
+
return (
-
+
) }
- { /* Animated backdrop using framer motion's shared layout animation */ }
- { isPressed ? (
-
-
-
- ) : null }
);
}
diff --git a/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts b/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts
index 86efc5224077f4..c0248f9b3f7f22 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts
+++ b/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts
@@ -70,7 +70,7 @@ export const buttonView = ( {
}
&:active {
- background: ${ CONFIG.toggleGroupControlBackgroundColor };
+ background: ${ CONFIG.controlBackgroundColor };
}
${ isDeselectable && deselectable }
@@ -119,14 +119,3 @@ const isIconStyles = ( {
padding-right: 0;
`;
};
-
-export const backdropView = css`
- background: ${ COLORS.gray[ 900 ] };
- border-radius: ${ CONFIG.radiusXSmall };
- position: absolute;
- inset: 0;
- z-index: 1;
- // Windows High Contrast mode will show this outline, but not the box-shadow.
- outline: 2px solid transparent;
- outline-offset: -3px;
-`;
diff --git a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
index b3f56bccd07c5f..7ce762b6e71df2 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
+++ b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
@@ -26,6 +26,7 @@ function UnforwardedToggleGroupControlAsButtonGroup(
size,
value: valueProp,
id: idProp,
+ setSelectedElement,
...otherProps
}: WordPressComponentProps<
ToggleGroupControlMainControlProps,
@@ -54,16 +55,23 @@ function UnforwardedToggleGroupControlAsButtonGroup(
} );
const groupContextValue = useMemo(
- () =>
- ( {
- baseId,
- value: selectedValue,
- setValue: setSelectedValue,
- isBlock: ! isAdaptiveWidth,
- isDeselectable: true,
- size,
- } ) as ToggleGroupControlContextProps,
- [ baseId, selectedValue, setSelectedValue, isAdaptiveWidth, size ]
+ (): ToggleGroupControlContextProps => ( {
+ baseId,
+ value: selectedValue,
+ setValue: setSelectedValue,
+ isBlock: ! isAdaptiveWidth,
+ isDeselectable: true,
+ size,
+ setSelectedElement,
+ } ),
+ [
+ baseId,
+ selectedValue,
+ setSelectedValue,
+ isAdaptiveWidth,
+ size,
+ setSelectedElement,
+ ]
);
return (
diff --git a/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx b/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx
index 6baadd65dc5ff6..342f9f128defd9 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx
+++ b/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx
@@ -10,6 +10,7 @@ import { useStoreState } from '@ariakit/react';
*/
import { useInstanceId } from '@wordpress/compose';
import { forwardRef, useMemo } from '@wordpress/element';
+import { isRTL } from '@wordpress/i18n';
/**
* Internal dependencies
@@ -32,6 +33,7 @@ function UnforwardedToggleGroupControlAsRadioGroup(
size,
value: valueProp,
id: idProp,
+ setSelectedElement,
...otherProps
}: WordPressComponentProps<
ToggleGroupControlMainControlProps,
@@ -65,21 +67,31 @@ function UnforwardedToggleGroupControlAsRadioGroup(
defaultValue,
value,
setValue: wrappedOnChangeProp,
+ rtl: isRTL(),
} );
const selectedValue = useStoreState( radio, 'value' );
const setValue = radio.setValue;
const groupContextValue = useMemo(
- () =>
- ( {
- baseId,
- isBlock: ! isAdaptiveWidth,
- size,
- value: selectedValue,
- setValue,
- } ) as ToggleGroupControlContextProps,
- [ baseId, isAdaptiveWidth, size, selectedValue, setValue ]
+ (): ToggleGroupControlContextProps => ( {
+ baseId,
+ isBlock: ! isAdaptiveWidth,
+ size,
+ // @ts-expect-error - This is wrong and we should fix it.
+ value: selectedValue,
+ // @ts-expect-error - This is wrong and we should fix it.
+ setValue,
+ setSelectedElement,
+ } ),
+ [
+ baseId,
+ isAdaptiveWidth,
+ selectedValue,
+ setSelectedElement,
+ setValue,
+ size,
+ ]
);
return (
diff --git a/packages/components/src/toggle-group-control/toggle-group-control/component.tsx b/packages/components/src/toggle-group-control/toggle-group-control/component.tsx
index 1c86c93548f6df..cdf8a2c04eb0b8 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control/component.tsx
+++ b/packages/components/src/toggle-group-control/toggle-group-control/component.tsx
@@ -2,13 +2,11 @@
* External dependencies
*/
import type { ForwardedRef } from 'react';
-import { LayoutGroup } from 'framer-motion';
/**
* WordPress dependencies
*/
-import { useInstanceId } from '@wordpress/compose';
-import { useMemo } from '@wordpress/element';
+import { useLayoutEffect, useMemo, useState } from '@wordpress/element';
/**
* Internal dependencies
@@ -22,6 +20,104 @@ import { VisualLabelWrapper } from './styles';
import * as styles from './styles';
import { ToggleGroupControlAsRadioGroup } from './as-radio-group';
import { ToggleGroupControlAsButtonGroup } from './as-button-group';
+import type { ElementOffsetRect } from '../../utils/element-rect';
+import { useTrackElementOffsetRect } from '../../utils/element-rect';
+import { useOnValueUpdate } from '../../utils/hooks/use-on-value-update';
+import { useEvent, useMergeRefs } from '@wordpress/compose';
+
+/**
+ * A utility used to animate something in a container component based on the "offset
+ * rect" (position relative to the container and size) of a subelement. For example,
+ * this is useful to render an indicator for the selected option of a component, and
+ * to animate it when the selected option changes.
+ *
+ * Takes in a container element and the up-to-date "offset rect" of the target
+ * subelement, obtained with `useTrackElementOffsetRect`. Then it does the following:
+ *
+ * - Adds CSS variables with rect information to the container, so that the indicator
+ * can be rendered and animated with them. These are kept up-to-date, enabling CSS
+ * transitions on change.
+ * - Sets an attribute (`data-subelement-animated` by default) when the tracked
+ * element changes, so that the target (e.g. the indicator) can be animated to its
+ * new size and position.
+ * - Removes the attribute when the animation is done.
+ *
+ * The need for the attribute is due to the fact that the rect might update in
+ * situations other than when the tracked element changes, e.g. the tracked element
+ * might be resized. In such cases, there is no need to animate the indicator, and
+ * the change in size or position of the indicator needs to be reflected immediately.
+ */
+function useAnimatedOffsetRect(
+ /**
+ * The container element.
+ */
+ container: HTMLElement | undefined,
+ /**
+ * The rect of the tracked element.
+ */
+ rect: ElementOffsetRect,
+ {
+ prefix = 'subelement',
+ dataAttribute = `${ prefix }-animated`,
+ transitionEndFilter = () => true,
+ }: {
+ /**
+ * The prefix used for the CSS variables, e.g. if `prefix` is `selected`, the
+ * CSS variables will be `--selected-top`, `--selected-left`, etc.
+ * @default 'subelement'
+ */
+ prefix?: string;
+ /**
+ * The name of the data attribute used to indicate that the animation is in
+ * progress. The `data-` prefix is added automatically.
+ *
+ * For example, if `dataAttribute` is `indicator-animated`, the attribute will
+ * be `data-indicator-animated`.
+ * @default `${ prefix }-animated`
+ */
+ dataAttribute?: string;
+ /**
+ * A function that is called with the transition event and returns a boolean
+ * indicating whether the animation should be stopped. The default is a function
+ * that always returns `true`.
+ *
+ * For example, if the animated element is the `::before` pseudo-element, the
+ * function can be written as `( event ) => event.pseudoElement === '::before'`.
+ * @default () => true
+ */
+ transitionEndFilter?: ( event: TransitionEvent ) => boolean;
+ } = {}
+) {
+ const setProperties = useEvent( () => {
+ ( Object.keys( rect ) as Array< keyof typeof rect > ).forEach(
+ ( property ) =>
+ property !== 'element' &&
+ container?.style.setProperty(
+ `--${ prefix }-${ property }`,
+ String( rect[ property ] )
+ )
+ );
+ } );
+ useLayoutEffect( () => {
+ setProperties();
+ }, [ rect, setProperties ] );
+ useOnValueUpdate( rect.element, ( { previousValue } ) => {
+ // Only enable the animation when moving from one element to another.
+ if ( rect.element && previousValue ) {
+ container?.setAttribute( `data-${ dataAttribute }`, '' );
+ }
+ } );
+ useLayoutEffect( () => {
+ function onTransitionEnd( event: TransitionEvent ) {
+ if ( transitionEndFilter( event ) ) {
+ container?.removeAttribute( `data-${ dataAttribute }` );
+ }
+ }
+ container?.addEventListener( 'transitionend', onTransitionEnd );
+ return () =>
+ container?.removeEventListener( 'transitionend', onTransitionEnd );
+ }, [ dataAttribute, container, transitionEndFilter ] );
+}
function UnconnectedToggleGroupControl(
props: WordPressComponentProps< ToggleGroupControlProps, 'div', false >,
@@ -44,10 +140,21 @@ function UnconnectedToggleGroupControl(
...otherProps
} = useContextSystem( props, 'ToggleGroupControl' );
- const baseId = useInstanceId( ToggleGroupControl, 'toggle-group-control' );
const normalizedSize =
__next40pxDefaultSize && size === 'default' ? '__unstable-large' : size;
+ const [ selectedElement, setSelectedElement ] = useState< HTMLElement >();
+ const [ controlElement, setControlElement ] = useState< HTMLElement >();
+ const refs = useMergeRefs( [ setControlElement, forwardedRef ] );
+ const selectedRect = useTrackElementOffsetRect(
+ value ? selectedElement : undefined
+ );
+ useAnimatedOffsetRect( controlElement, selectedRect, {
+ prefix: 'selected',
+ dataAttribute: 'indicator-animated',
+ transitionEndFilter: ( event ) => event.pseudoElement === '::before',
+ } );
+
const cx = useCx();
const classes = useMemo(
@@ -81,15 +188,16 @@ function UnconnectedToggleGroupControl(
) }
- { children }
+ { children }
);
diff --git a/packages/components/src/toggle-group-control/toggle-group-control/styles.ts b/packages/components/src/toggle-group-control/toggle-group-control/styles.ts
index 8d01c150a45eaf..bb6efe476b2b2c 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control/styles.ts
+++ b/packages/components/src/toggle-group-control/toggle-group-control/styles.ts
@@ -26,6 +26,47 @@ export const toggleGroupControl = ( {
${ toggleGroupControlSize( size ) }
${ ! isDeselectable && enclosingBorders( isBlock ) }
+
+ @media not ( prefers-reduced-motion ) {
+ &[data-indicator-animated]::before {
+ transition-property: transform, border-radius;
+ transition-duration: 0.2s;
+ transition-timing-function: ease-out;
+ }
+ }
+
+ &::before {
+ content: '';
+ position: absolute;
+ pointer-events: none;
+ background: ${ COLORS.gray[ 900 ] };
+
+ // Windows High Contrast mode will show this outline, but not the box-shadow.
+ outline: 2px solid transparent;
+ outline-offset: -3px;
+
+ /* Using a large value to avoid antialiasing rounding issues
+ when scaling in the transform, see: https://stackoverflow.com/a/52159123 */
+ --antialiasing-factor: 100;
+ /* Adjusting the border radius to match the scaling in the x axis. */
+ border-radius: calc(
+ ${ CONFIG.radiusXSmall } /
+ (
+ var( --selected-width, 0 ) /
+ var( --antialiasing-factor )
+ )
+ ) / ${ CONFIG.radiusXSmall };
+ left: -1px; // Correcting for border.
+ width: calc( var( --antialiasing-factor ) * 1px );
+ height: calc( var( --selected-height, 0 ) * 1px );
+ transform-origin: left top;
+ transform: translateX( calc( var( --selected-left, 0 ) * 1px ) )
+ scaleX(
+ calc(
+ var( --selected-width, 0 ) / var( --antialiasing-factor )
+ )
+ );
+ }
`;
const enclosingBorders = ( isBlock: ToggleGroupControlProps[ 'isBlock' ] ) => {
diff --git a/packages/components/src/toggle-group-control/types.ts b/packages/components/src/toggle-group-control/types.ts
index d49ef3cbb77cb4..2a4af680263dba 100644
--- a/packages/components/src/toggle-group-control/types.ts
+++ b/packages/components/src/toggle-group-control/types.ts
@@ -137,9 +137,11 @@ export type ToggleGroupControlContextProps = {
size: ToggleGroupControlProps[ 'size' ];
value: ToggleGroupControlProps[ 'value' ];
setValue: ( newValue: string | number | undefined ) => void;
+ setSelectedElement: ( element: HTMLElement | undefined ) => void;
};
export type ToggleGroupControlMainControlProps = Pick<
ToggleGroupControlProps,
'children' | 'isAdaptiveWidth' | 'label' | 'size' | 'onChange' | 'value'
->;
+> &
+ Pick< ToggleGroupControlContextProps, 'setSelectedElement' >;
diff --git a/packages/components/src/tools-panel/tools-panel/README.md b/packages/components/src/tools-panel/tools-panel/README.md
index df41b623eefb6c..1daa7537335e1c 100644
--- a/packages/components/src/tools-panel/tools-panel/README.md
+++ b/packages/components/src/tools-panel/tools-panel/README.md
@@ -60,7 +60,7 @@ import styled from '@emotion/styled';
* WordPress dependencies
*/
import {
- __experimentalBoxControl as BoxControl,
+ BoxControl,
__experimentalToolsPanel as ToolsPanel,
__experimentalToolsPanelItem as ToolsPanelItem,
__experimentalUnitControl as UnitControl,
@@ -91,8 +91,8 @@ export function DimensionPanel() {
return (
- Select dimensions or spacing related settings from the
- menu for additional controls.
+ Select dimensions or spacing related settings from the menu for
+ additional controls.
!! height }
@@ -154,8 +154,8 @@ export function DimensionPanel() {
Flags that the items in this ToolsPanel will be contained within an inner
wrapper element allowing the panel to lay them out accordingly.
-- Required: No
-- Default: `false`
+- Required: No
+- Default: `false`
### `dropdownMenuProps`: `{}`
@@ -176,7 +176,7 @@ The heading level of the panel's header.
Text to be displayed within the panel's header and as the `aria-label` for the
panel's dropdown menu.
-- Required: Yes
+- Required: Yes
### `panelId`: `string | null`
@@ -185,13 +185,13 @@ to restrict panel items. When a `panelId` is set, items can only register
themselves if the `panelId` is explicitly `null` or the item's `panelId` matches
exactly.
-- Required: No
+- Required: No
### `resetAll`: `( filters?: ResetAllFilter[] ) => void`
A function to call when the `Reset all` menu option is selected. As an argument, it receives an array containing the `resetAllFilter` callbacks of all the valid registered `ToolsPanelItems`.
-- Required: Yes
+- Required: Yes
### `shouldRenderPlaceholderItems`: `boolean`
@@ -201,5 +201,5 @@ placeholder content (instead of `null`) when they are toggled off and hidden.
Note that placeholder items won't apply the `className` that would be
normally applied to a visible `ToolsPanelItem` via the `className` prop.
-- Required: No
-- Default: `false`
+- Required: No
+- Default: `false`
diff --git a/packages/components/src/tools-panel/tools-panel/hook.ts b/packages/components/src/tools-panel/tools-panel/hook.ts
index 931bf2494e6e34..583a079ab20026 100644
--- a/packages/components/src/tools-panel/tools-panel/hook.ts
+++ b/packages/components/src/tools-panel/tools-panel/hook.ts
@@ -5,8 +5,8 @@ import {
useCallback,
useEffect,
useMemo,
+ useReducer,
useRef,
- useState,
} from '@wordpress/element';
/**
@@ -27,14 +27,40 @@ import type {
const DEFAULT_COLUMNS = 2;
+type PanelItemsState = {
+ panelItems: ToolsPanelItem[];
+ menuItemOrder: string[];
+ menuItems: ToolsPanelMenuItems;
+};
+
+type PanelItemsAction =
+ | { type: 'REGISTER_PANEL'; item: ToolsPanelItem }
+ | { type: 'UNREGISTER_PANEL'; label: string }
+ | {
+ type: 'UPDATE_VALUE';
+ group: ToolsPanelMenuItemKey;
+ label: string;
+ value: boolean;
+ }
+ | { type: 'TOGGLE_VALUE'; label: string }
+ | { type: 'RESET_ALL' };
+
+function emptyMenuItems(): ToolsPanelMenuItems {
+ return { default: {}, optional: {} };
+}
+
+function emptyState(): PanelItemsState {
+ return { panelItems: [], menuItemOrder: [], menuItems: emptyMenuItems() };
+}
+
const generateMenuItems = ( {
panelItems,
shouldReset,
currentMenuItems,
menuItemOrder,
}: ToolsPanelMenuItemsConfig ) => {
- const newMenuItems: ToolsPanelMenuItems = { default: {}, optional: {} };
- const menuItems: ToolsPanelMenuItems = { default: {}, optional: {} };
+ const newMenuItems: ToolsPanelMenuItems = emptyMenuItems();
+ const menuItems: ToolsPanelMenuItems = emptyMenuItems();
panelItems.forEach( ( { hasValue, isShownByDefault, label } ) => {
const group = isShownByDefault ? 'default' : 'optional';
@@ -75,9 +101,149 @@ const generateMenuItems = ( {
return menuItems;
};
+function panelItemsReducer(
+ panelItems: ToolsPanelItem[],
+ action: PanelItemsAction
+) {
+ switch ( action.type ) {
+ case 'REGISTER_PANEL': {
+ const newItems = [ ...panelItems ];
+ // If an item with this label has already been registered, remove it
+ // first. This can happen when an item is moved between the default
+ // and optional groups.
+ const existingIndex = newItems.findIndex(
+ ( oldItem ) => oldItem.label === action.item.label
+ );
+ if ( existingIndex !== -1 ) {
+ newItems.splice( existingIndex, 1 );
+ }
+ newItems.push( action.item );
+ return newItems;
+ }
+ case 'UNREGISTER_PANEL': {
+ const index = panelItems.findIndex(
+ ( item ) => item.label === action.label
+ );
+ if ( index !== -1 ) {
+ const newItems = [ ...panelItems ];
+ newItems.splice( index, 1 );
+ return newItems;
+ }
+ return panelItems;
+ }
+ default:
+ return panelItems;
+ }
+}
+
+function menuItemOrderReducer(
+ menuItemOrder: string[],
+ action: PanelItemsAction
+) {
+ switch ( action.type ) {
+ case 'REGISTER_PANEL': {
+ // Track the initial order of item registration. This is used for
+ // maintaining menu item order later.
+ if ( menuItemOrder.includes( action.item.label ) ) {
+ return menuItemOrder;
+ }
+
+ return [ ...menuItemOrder, action.item.label ];
+ }
+ default:
+ return menuItemOrder;
+ }
+}
+
+function menuItemsReducer( state: PanelItemsState, action: PanelItemsAction ) {
+ switch ( action.type ) {
+ case 'REGISTER_PANEL':
+ case 'UNREGISTER_PANEL':
+ // generate new menu items from original `menuItems` and updated `panelItems` and `menuItemOrder`
+ return generateMenuItems( {
+ currentMenuItems: state.menuItems,
+ panelItems: state.panelItems,
+ menuItemOrder: state.menuItemOrder,
+ shouldReset: false,
+ } );
+ case 'RESET_ALL':
+ return generateMenuItems( {
+ panelItems: state.panelItems,
+ menuItemOrder: state.menuItemOrder,
+ shouldReset: true,
+ } );
+ case 'UPDATE_VALUE': {
+ const oldValue = state.menuItems[ action.group ][ action.label ];
+ if ( action.value === oldValue ) {
+ return state.menuItems;
+ }
+ return {
+ ...state.menuItems,
+ [ action.group ]: {
+ ...state.menuItems[ action.group ],
+ [ action.label ]: action.value,
+ },
+ };
+ }
+ case 'TOGGLE_VALUE': {
+ const currentItem = state.panelItems.find(
+ ( item ) => item.label === action.label
+ );
+
+ if ( ! currentItem ) {
+ return state.menuItems;
+ }
+
+ const menuGroup = currentItem.isShownByDefault
+ ? 'default'
+ : 'optional';
+
+ const newMenuItems = {
+ ...state.menuItems,
+ [ menuGroup ]: {
+ ...state.menuItems[ menuGroup ],
+ [ action.label ]:
+ ! state.menuItems[ menuGroup ][ action.label ],
+ },
+ };
+ return newMenuItems;
+ }
+
+ default:
+ return state.menuItems;
+ }
+}
+
+function panelReducer( state: PanelItemsState, action: PanelItemsAction ) {
+ const panelItems = panelItemsReducer( state.panelItems, action );
+ const menuItemOrder = menuItemOrderReducer( state.menuItemOrder, action );
+ // `menuItemsReducer` is a bit unusual because it generates new state from original `menuItems`
+ // and the updated `panelItems` and `menuItemOrder`.
+ const menuItems = menuItemsReducer(
+ { panelItems, menuItemOrder, menuItems: state.menuItems },
+ action
+ );
+
+ return { panelItems, menuItemOrder, menuItems };
+}
+
+function resetAllFiltersReducer(
+ filters: ResetAllFilter[],
+ action: { type: 'REGISTER' | 'UNREGISTER'; filter: ResetAllFilter }
+) {
+ switch ( action.type ) {
+ case 'REGISTER':
+ return [ ...filters, action.filter ];
+ case 'UNREGISTER':
+ return filters.filter( ( f ) => f !== action.filter );
+ default:
+ return filters;
+ }
+}
+
const isMenuItemTypeEmpty = (
- obj?: ToolsPanelMenuItems[ ToolsPanelMenuItemKey ]
-) => obj && Object.keys( obj ).length === 0;
+ obj: ToolsPanelMenuItems[ ToolsPanelMenuItemKey ]
+) => Object.keys( obj ).length === 0;
export function useToolsPanel(
props: WordPressComponentProps< ToolsPanelProps, 'div' >
@@ -108,103 +274,43 @@ export function useToolsPanel(
}, [ wasResetting ] );
// Allow panel items to register themselves.
- const [ panelItems, setPanelItems ] = useState< ToolsPanelItem[] >( [] );
- const [ menuItemOrder, setMenuItemOrder ] = useState< string[] >( [] );
- const [ resetAllFilters, setResetAllFilters ] = useState<
- ResetAllFilter[]
- >( [] );
-
- const registerPanelItem = useCallback(
- ( item: ToolsPanelItem ) => {
- // Add item to panel items.
- setPanelItems( ( items ) => {
- const newItems = [ ...items ];
- // If an item with this label has already been registered, remove it
- // first. This can happen when an item is moved between the default
- // and optional groups.
- const existingIndex = newItems.findIndex(
- ( oldItem ) => oldItem.label === item.label
- );
- if ( existingIndex !== -1 ) {
- newItems.splice( existingIndex, 1 );
- }
- return [ ...newItems, item ];
- } );
-
- // Track the initial order of item registration. This is used for
- // maintaining menu item order later.
- setMenuItemOrder( ( items ) => {
- if ( items.includes( item.label ) ) {
- return items;
- }
+ const [ { panelItems, menuItems }, panelDispatch ] = useReducer(
+ panelReducer,
+ undefined,
+ emptyState
+ );
- return [ ...items, item.label ];
- } );
- },
- [ setPanelItems, setMenuItemOrder ]
+ const [ resetAllFilters, dispatchResetAllFilters ] = useReducer(
+ resetAllFiltersReducer,
+ []
);
+ const registerPanelItem = useCallback( ( item: ToolsPanelItem ) => {
+ // Add item to panel items.
+ panelDispatch( { type: 'REGISTER_PANEL', item } );
+ }, [] );
+
// Panels need to deregister on unmount to avoid orphans in menu state.
// This is an issue when panel items are being injected via SlotFills.
- const deregisterPanelItem = useCallback(
- ( label: string ) => {
- // When switching selections between components injecting matching
- // controls, e.g. both panels have a "padding" control, the
- // deregistration of the first panel doesn't occur until after the
- // registration of the next.
- setPanelItems( ( items ) => {
- const newItems = [ ...items ];
- const index = newItems.findIndex(
- ( item ) => item.label === label
- );
- if ( index !== -1 ) {
- newItems.splice( index, 1 );
- }
- return newItems;
- } );
- },
- [ setPanelItems ]
- );
-
- const registerResetAllFilter = useCallback(
- ( newFilter: ResetAllFilter ) => {
- setResetAllFilters( ( filters ) => {
- return [ ...filters, newFilter ];
- } );
- },
- [ setResetAllFilters ]
- );
+ const deregisterPanelItem = useCallback( ( label: string ) => {
+ // When switching selections between components injecting matching
+ // controls, e.g. both panels have a "padding" control, the
+ // deregistration of the first panel doesn't occur until after the
+ // registration of the next.
+ panelDispatch( { type: 'UNREGISTER_PANEL', label } );
+ }, [] );
+
+ const registerResetAllFilter = useCallback( ( filter: ResetAllFilter ) => {
+ dispatchResetAllFilters( { type: 'REGISTER', filter } );
+ }, [] );
const deregisterResetAllFilter = useCallback(
- ( filterToRemove: ResetAllFilter ) => {
- setResetAllFilters( ( filters ) => {
- return filters.filter(
- ( filter ) => filter !== filterToRemove
- );
- } );
+ ( filter: ResetAllFilter ) => {
+ dispatchResetAllFilters( { type: 'UNREGISTER', filter } );
},
- [ setResetAllFilters ]
+ []
);
- // Manage and share display state of menu items representing child controls.
- const [ menuItems, setMenuItems ] = useState< ToolsPanelMenuItems >( {
- default: {},
- optional: {},
- } );
-
- // Setup menuItems state as panel items register themselves.
- useEffect( () => {
- setMenuItems( ( prevState ) => {
- const items = generateMenuItems( {
- panelItems,
- shouldReset: false,
- currentMenuItems: prevState,
- menuItemOrder,
- } );
- return items;
- } );
- }, [ panelItems, setMenuItems, menuItemOrder ] );
-
// Updates the status of the panelās menu items. For default items the
// value represents whether it differs from the default and for optional
// items whether the item is shown.
@@ -214,38 +320,24 @@ export function useToolsPanel(
label: string,
group: ToolsPanelMenuItemKey = 'default'
) => {
- setMenuItems( ( items ) => {
- const newState = {
- ...items,
- [ group ]: {
- ...items[ group ],
- [ label ]: value,
- },
- };
- return newState;
- } );
+ panelDispatch( { type: 'UPDATE_VALUE', group, label, value } );
},
- [ setMenuItems ]
+ []
);
// Whether all optional menu items are hidden or not must be tracked
// in order to later determine if the panel display is empty and handle
// conditional display of a plus icon to indicate the presence of further
// menu items.
- const [ areAllOptionalControlsHidden, setAreAllOptionalControlsHidden ] =
- useState( false );
-
- useEffect( () => {
- if (
- isMenuItemTypeEmpty( menuItems?.default ) &&
- ! isMenuItemTypeEmpty( menuItems?.optional )
- ) {
- const allControlsHidden = ! Object.entries(
- menuItems.optional
- ).some( ( [ , isSelected ] ) => isSelected );
- setAreAllOptionalControlsHidden( allControlsHidden );
- }
- }, [ menuItems, setAreAllOptionalControlsHidden ] );
+ const areAllOptionalControlsHidden = useMemo( () => {
+ return (
+ isMenuItemTypeEmpty( menuItems.default ) &&
+ ! isMenuItemTypeEmpty( menuItems.optional ) &&
+ Object.values( menuItems.optional ).every(
+ ( isSelected ) => ! isSelected
+ )
+ );
+ }, [ menuItems ] );
const cx = useCx();
const classes = useMemo( () => {
@@ -253,9 +345,7 @@ export function useToolsPanel(
hasInnerWrapper &&
styles.ToolsPanelWithInnerWrapper( DEFAULT_COLUMNS );
const emptyStyle =
- isMenuItemTypeEmpty( menuItems?.default ) &&
- areAllOptionalControlsHidden &&
- styles.ToolsPanelHiddenInnerWrapper;
+ areAllOptionalControlsHidden && styles.ToolsPanelHiddenInnerWrapper;
return cx(
styles.ToolsPanel( DEFAULT_COLUMNS ),
@@ -263,42 +353,13 @@ export function useToolsPanel(
emptyStyle,
className
);
- }, [
- areAllOptionalControlsHidden,
- className,
- cx,
- hasInnerWrapper,
- menuItems,
- ] );
+ }, [ areAllOptionalControlsHidden, className, cx, hasInnerWrapper ] );
// Toggle the checked state of a menu item which is then used to determine
// display of the item within the panel.
- const toggleItem = useCallback(
- ( label: string ) => {
- const currentItem = panelItems.find(
- ( item ) => item.label === label
- );
-
- if ( ! currentItem ) {
- return;
- }
-
- const menuGroup = currentItem.isShownByDefault
- ? 'default'
- : 'optional';
-
- const newMenuItems = {
- ...menuItems,
- [ menuGroup ]: {
- ...menuItems[ menuGroup ],
- [ label ]: ! menuItems[ menuGroup ][ label ],
- },
- };
-
- setMenuItems( newMenuItems );
- },
- [ menuItems, panelItems, setMenuItems ]
- );
+ const toggleItem = useCallback( ( label: string ) => {
+ panelDispatch( { type: 'TOGGLE_VALUE', label } );
+ }, [] );
// Resets display of children and executes resetAll callback if available.
const resetAllItems = useCallback( () => {
@@ -308,20 +369,15 @@ export function useToolsPanel(
}
// Turn off display of all non-default items.
- const resetMenuItems = generateMenuItems( {
- panelItems,
- menuItemOrder,
- shouldReset: true,
- } );
- setMenuItems( resetMenuItems );
- }, [ panelItems, resetAllFilters, resetAll, setMenuItems, menuItemOrder ] );
+ panelDispatch( { type: 'RESET_ALL' } );
+ }, [ resetAllFilters, resetAll ] );
// Assist ItemGroup styling when there are potentially hidden placeholder
// items by identifying first & last items that are toggled on for display.
const getFirstVisibleItemLabel = ( items: ToolsPanelItem[] ) => {
const optionalItems = menuItems.optional || {};
const firstItem = items.find(
- ( item ) => item.isShownByDefault || !! optionalItems[ item.label ]
+ ( item ) => item.isShownByDefault || optionalItems[ item.label ]
);
return firstItem?.label;
@@ -332,6 +388,8 @@ export function useToolsPanel(
[ ...panelItems ].reverse()
);
+ const hasMenuItems = panelItems.length > 0;
+
const panelContext = useMemo(
() => ( {
areAllOptionalControlsHidden,
@@ -339,7 +397,7 @@ export function useToolsPanel(
deregisterResetAllFilter,
firstDisplayedItem,
flagItemCustomization,
- hasMenuItems: !! panelItems.length,
+ hasMenuItems,
isResetting: isResettingRef.current,
lastDisplayedItem,
menuItems,
@@ -359,7 +417,7 @@ export function useToolsPanel(
lastDisplayedItem,
menuItems,
panelId,
- panelItems,
+ hasMenuItems,
registerResetAllFilter,
registerPanelItem,
shouldRenderPlaceholderItems,
diff --git a/packages/components/src/utils/config-values.js b/packages/components/src/utils/config-values.js
index 2040f479a231c2..1bc3945f9b3b16 100644
--- a/packages/components/src/utils/config-values.js
+++ b/packages/components/src/utils/config-values.js
@@ -7,18 +7,13 @@ import { COLORS } from './colors-values';
const CONTROL_HEIGHT = '36px';
const CONTROL_PROPS = {
- controlSurfaceColor: COLORS.white,
- controlTextActiveColor: COLORS.theme.accent,
-
// These values should be shared with TextControl.
controlPaddingX: 12,
controlPaddingXSmall: 8,
controlPaddingXLarge: 12 * 1.3334, // TODO: Deprecate
controlBackgroundColor: COLORS.white,
- controlBoxShadow: 'transparent',
controlBoxShadowFocus: `0 0 0 0.5px ${ COLORS.theme.accent }`,
- controlDestructiveBorderColor: COLORS.alert.red,
controlHeight: CONTROL_HEIGHT,
controlHeightXSmall: `calc( ${ CONTROL_HEIGHT } * 0.6 )`,
controlHeightSmall: `calc( ${ CONTROL_HEIGHT } * 0.8 )`,
@@ -26,18 +21,9 @@ const CONTROL_PROPS = {
controlHeightXLarge: `calc( ${ CONTROL_HEIGHT } * 1.4 )`,
};
-const TOGGLE_GROUP_CONTROL_PROPS = {
- toggleGroupControlBackgroundColor: CONTROL_PROPS.controlBackgroundColor,
- toggleGroupControlBorderColor: COLORS.ui.border,
- toggleGroupControlBackdropBackgroundColor:
- CONTROL_PROPS.controlSurfaceColor,
- toggleGroupControlBackdropBorderColor: COLORS.ui.border,
- toggleGroupControlButtonColorActive: CONTROL_PROPS.controlBackgroundColor,
-};
-
// Using Object.assign to avoid creating circular references when emitting
// TypeScript type declarations.
-export default Object.assign( {}, CONTROL_PROPS, TOGGLE_GROUP_CONTROL_PROPS, {
+export default Object.assign( {}, CONTROL_PROPS, {
colorDivider: 'rgba(0, 0, 0, 0.1)',
colorScrollbarThumb: 'rgba(0, 0, 0, 0.2)',
colorScrollbarThumbHover: 'rgba(0, 0, 0, 0.5)',
diff --git a/packages/components/src/utils/element-rect.ts b/packages/components/src/utils/element-rect.ts
index 550ec35b0bc932..7c83db4428ca0f 100644
--- a/packages/components/src/utils/element-rect.ts
+++ b/packages/components/src/utils/element-rect.ts
@@ -3,16 +3,16 @@
* WordPress dependencies
*/
import { useLayoutEffect, useRef, useState } from '@wordpress/element';
-import { useResizeObserver } from '@wordpress/compose';
-/**
- * Internal dependencies
- */
-import { useEvent } from './hooks/use-event';
+import { useEvent, useResizeObserver } from '@wordpress/compose';
/**
* The position and dimensions of an element, relative to its offset parent.
*/
export type ElementOffsetRect = {
+ /**
+ * The element the rect belongs to.
+ */
+ element: HTMLElement | undefined;
/**
* The distance from the top edge of the offset parent to the top edge of
* the element.
@@ -47,6 +47,7 @@ export type ElementOffsetRect = {
* An `ElementOffsetRect` object with all values set to zero.
*/
export const NULL_ELEMENT_OFFSET_RECT = {
+ element: undefined,
top: 0,
right: 0,
bottom: 0,
@@ -79,9 +80,11 @@ export function getElementOffsetRect(
if ( rect.width === 0 || rect.height === 0 ) {
return;
}
+ const offsetParent = element.offsetParent;
const offsetParentRect =
- element.offsetParent?.getBoundingClientRect() ??
- NULL_ELEMENT_OFFSET_RECT;
+ offsetParent?.getBoundingClientRect() ?? NULL_ELEMENT_OFFSET_RECT;
+ const offsetParentScrollX = offsetParent?.scrollLeft ?? 0;
+ const offsetParentScrollY = offsetParent?.scrollTop ?? 0;
// Computed widths and heights have subpixel precision, and are not affected
// by distortions.
@@ -94,13 +97,22 @@ export function getElementOffsetRect(
const scaleY = computedHeight / rect.height;
return {
+ element,
// To obtain the adjusted values for the position:
// 1. Compute the element's position relative to the offset parent.
// 2. Correct for the scale factor.
- top: ( rect.top - offsetParentRect?.top ) * scaleY,
- right: ( offsetParentRect?.right - rect.right ) * scaleX,
- bottom: ( offsetParentRect?.bottom - rect.bottom ) * scaleY,
- left: ( rect.left - offsetParentRect?.left ) * scaleX,
+ // 3. Adjust for the scroll position of the offset parent.
+ top:
+ ( rect.top - offsetParentRect?.top ) * scaleY + offsetParentScrollY,
+ right:
+ ( offsetParentRect?.right - rect.right ) * scaleX -
+ offsetParentScrollX,
+ bottom:
+ ( offsetParentRect?.bottom - rect.bottom ) * scaleY -
+ offsetParentScrollY,
+ left:
+ ( rect.left - offsetParentRect?.left ) * scaleX +
+ offsetParentScrollX,
// Computed dimensions don't need any adjustments.
width: computedWidth,
height: computedHeight,
@@ -113,6 +125,9 @@ const POLL_RATE = 100;
* Tracks the position and dimensions of an element, relative to its offset
* parent. The element can be changed dynamically.
*
+ * When no element is provided (`null` or `undefined`), the hook will return
+ * a "null" rect, in which all values are `0` and `element` is `undefined`.
+ *
* **Note:** sometimes, the measurement will fail (see `getElementOffsetRect`'s
* documentation for more details). When that happens, this hook will attempt
* to measure again after a frame, and if that fails, it will poll every 100
@@ -149,10 +164,12 @@ export function useTrackElementOffsetRect(
}
} );
- useLayoutEffect(
- () => setElement( targetElement ),
- [ setElement, targetElement ]
- );
+ useLayoutEffect( () => {
+ setElement( targetElement );
+ if ( ! targetElement ) {
+ setIndicatorPosition( NULL_ELEMENT_OFFSET_RECT );
+ }
+ }, [ setElement, targetElement ] );
return indicatorPosition;
}
diff --git a/packages/components/src/utils/hooks/use-event.ts b/packages/components/src/utils/hooks/use-event.ts
deleted file mode 100644
index eefac9478a8b4f..00000000000000
--- a/packages/components/src/utils/hooks/use-event.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-/* eslint-disable jsdoc/require-param */
-/**
- * WordPress dependencies
- */
-import { useRef, useInsertionEffect, useCallback } from '@wordpress/element';
-
-/**
- * Any function.
- */
-export type AnyFunction = ( ...args: any ) => any;
-
-/**
- * Creates a stable callback function that has access to the latest state and
- * can be used within event handlers and effect callbacks. Throws when used in
- * the render phase.
- *
- * @example
- *
- * ```tsx
- * function Component(props) {
- * const onClick = useEvent(props.onClick);
- * React.useEffect(() => {}, [onClick]);
- * }
- * ```
- */
-export function useEvent< T extends AnyFunction >( callback?: T ) {
- const ref = useRef< AnyFunction | undefined >( () => {
- throw new Error( 'Cannot call an event handler while rendering.' );
- } );
- useInsertionEffect( () => {
- ref.current = callback;
- } );
- return useCallback< AnyFunction >(
- ( ...args ) => ref.current?.( ...args ),
- []
- ) as T;
-}
-/* eslint-enable jsdoc/require-param */
diff --git a/packages/components/src/utils/hooks/use-on-value-update.ts b/packages/components/src/utils/hooks/use-on-value-update.ts
index 5726f3977daf04..15cfc321359e7c 100644
--- a/packages/components/src/utils/hooks/use-on-value-update.ts
+++ b/packages/components/src/utils/hooks/use-on-value-update.ts
@@ -2,11 +2,8 @@
/**
* WordPress dependencies
*/
-import { useRef, useEffect } from '@wordpress/element';
-/**
- * Internal dependencies
- */
-import { useEvent } from './use-event';
+import { useEvent } from '@wordpress/compose';
+import { useRef, useLayoutEffect } from '@wordpress/element';
/**
* Context object for the `onUpdate` callback of `useOnValueUpdate`.
@@ -30,7 +27,7 @@ export function useOnValueUpdate< T >(
) {
const previousValueRef = useRef( value );
const updateCallbackEvent = useEvent( onUpdate );
- useEffect( () => {
+ useLayoutEffect( () => {
if ( previousValueRef.current !== value ) {
updateCallbackEvent( {
previousValue: previousValueRef.current,
diff --git a/packages/compose/CHANGELOG.md b/packages/compose/CHANGELOG.md
index 18c21a65b9b124..28269dca692a4f 100644
--- a/packages/compose/CHANGELOG.md
+++ b/packages/compose/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+### Bug Fixes
+
+- `useResizeObserver`: export legacy API at top-level for React Native ([#65588](https://github.com/WordPress/gutenberg/pull/65588)).
+
## 7.8.0 (2024-09-19)
### New Features
diff --git a/packages/compose/src/hooks/use-resize-observer/index.native.js b/packages/compose/src/hooks/use-resize-observer/index.native.js
new file mode 100644
index 00000000000000..79eb3e569e332a
--- /dev/null
+++ b/packages/compose/src/hooks/use-resize-observer/index.native.js
@@ -0,0 +1 @@
+export { default } from './legacy/index.native';
diff --git a/packages/compose/src/hooks/use-resize-observer/index.ts b/packages/compose/src/hooks/use-resize-observer/index.ts
index 2a76b2aa6ab590..1bd0f074cc49f2 100644
--- a/packages/compose/src/hooks/use-resize-observer/index.ts
+++ b/packages/compose/src/hooks/use-resize-observer/index.ts
@@ -1,53 +1,14 @@
-/**
- * WordPress dependencies
- */
-import { useRef } from '@wordpress/element';
/**
* Internal dependencies
*/
-import useEvent from '../use-event';
-import type { ObservedSize } from './_legacy';
-import _useLegacyResizeObserver from './_legacy';
+import { useResizeObserver as _useResizeObserver } from './use-resize-observer';
+import type { ObservedSize } from './legacy';
+import _useLegacyResizeObserver from './legacy';
/**
* External dependencies
*/
import type { ReactElement } from 'react';
-// This is the current implementation of `useResizeObserver`.
-//
-// The legacy implementation is still supported for backwards compatibility.
-// This is achieved by overloading the exported function with both signatures,
-// and detecting which API is being used at runtime.
-function _useResizeObserver< T extends HTMLElement >(
- callback: ResizeObserverCallback,
- resizeObserverOptions: ResizeObserverOptions = {}
-): ( element?: T | null ) => void {
- const callbackEvent = useEvent( callback );
-
- const observedElementRef = useRef< T | null >();
- const resizeObserverRef = useRef< ResizeObserver >();
- return useEvent( ( element?: T | null ) => {
- if ( element === observedElementRef.current ) {
- return;
- }
- observedElementRef.current = element;
-
- // Set up `ResizeObserver`.
- resizeObserverRef.current ??= new ResizeObserver( callbackEvent );
- const { current: resizeObserver } = resizeObserverRef;
-
- // Unobserve previous element.
- if ( observedElementRef.current ) {
- resizeObserver.unobserve( observedElementRef.current );
- }
-
- // Observe new element.
- if ( element ) {
- resizeObserver.observe( element, resizeObserverOptions );
- }
- } );
-}
-
/**
* Sets up a [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Resize_Observer_API)
* for an HTML or SVG element.
diff --git a/packages/compose/src/hooks/use-resize-observer/_legacy/index.native.js b/packages/compose/src/hooks/use-resize-observer/legacy/index.native.js
similarity index 100%
rename from packages/compose/src/hooks/use-resize-observer/_legacy/index.native.js
rename to packages/compose/src/hooks/use-resize-observer/legacy/index.native.js
diff --git a/packages/compose/src/hooks/use-resize-observer/_legacy/index.tsx b/packages/compose/src/hooks/use-resize-observer/legacy/index.tsx
similarity index 98%
rename from packages/compose/src/hooks/use-resize-observer/_legacy/index.tsx
rename to packages/compose/src/hooks/use-resize-observer/legacy/index.tsx
index b44bd841964164..fe765810982226 100644
--- a/packages/compose/src/hooks/use-resize-observer/_legacy/index.tsx
+++ b/packages/compose/src/hooks/use-resize-observer/legacy/index.tsx
@@ -10,7 +10,7 @@ import { useCallback, useRef, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
-import useResizeObserver from '../index';
+import { useResizeObserver } from '../use-resize-observer';
export type ObservedSize = {
width: number | null;
diff --git a/packages/compose/src/hooks/use-resize-observer/_legacy/test/index.native.js b/packages/compose/src/hooks/use-resize-observer/legacy/test/index.native.js
similarity index 100%
rename from packages/compose/src/hooks/use-resize-observer/_legacy/test/index.native.js
rename to packages/compose/src/hooks/use-resize-observer/legacy/test/index.native.js
diff --git a/packages/compose/src/hooks/use-resize-observer/use-resize-observer.ts b/packages/compose/src/hooks/use-resize-observer/use-resize-observer.ts
new file mode 100644
index 00000000000000..4c1031b9839dc3
--- /dev/null
+++ b/packages/compose/src/hooks/use-resize-observer/use-resize-observer.ts
@@ -0,0 +1,43 @@
+/**
+ * WordPress dependencies
+ */
+import { useRef } from '@wordpress/element';
+/**
+ * Internal dependencies
+ */
+import useEvent from '../use-event';
+
+// This is the current implementation of `useResizeObserver`.
+//
+// The legacy implementation is still supported for backwards compatibility.
+// This is achieved by overloading the exported function with both signatures,
+// and detecting which API is being used at runtime.
+export function useResizeObserver< T extends HTMLElement >(
+ callback: ResizeObserverCallback,
+ resizeObserverOptions: ResizeObserverOptions = {}
+): ( element?: T | null ) => void {
+ const callbackEvent = useEvent( callback );
+
+ const observedElementRef = useRef< T | null >();
+ const resizeObserverRef = useRef< ResizeObserver >();
+ return useEvent( ( element?: T | null ) => {
+ if ( element === observedElementRef.current ) {
+ return;
+ }
+
+ // Set up `ResizeObserver`.
+ resizeObserverRef.current ??= new ResizeObserver( callbackEvent );
+ const { current: resizeObserver } = resizeObserverRef;
+
+ // Unobserve previous element.
+ if ( observedElementRef.current ) {
+ resizeObserver.unobserve( observedElementRef.current );
+ }
+
+ // Observe new element.
+ observedElementRef.current = element;
+ if ( element ) {
+ resizeObserver.observe( element, resizeObserverOptions );
+ }
+ } );
+}
diff --git a/packages/core-commands/package.json b/packages/core-commands/package.json
index 8334ef97c9244d..a2d3c76ebe5d9c 100644
--- a/packages/core-commands/package.json
+++ b/packages/core-commands/package.json
@@ -37,6 +37,7 @@
"@wordpress/html-entities": "file:../html-entities",
"@wordpress/i18n": "file:../i18n",
"@wordpress/icons": "file:../icons",
+ "@wordpress/notices": "file:../notices",
"@wordpress/private-apis": "file:../private-apis",
"@wordpress/router": "file:../router",
"@wordpress/url": "file:../url"
diff --git a/packages/core-commands/src/admin-navigation-commands.js b/packages/core-commands/src/admin-navigation-commands.js
index 0ffa7ba7eb6285..c0d8bb084b46ad 100644
--- a/packages/core-commands/src/admin-navigation-commands.js
+++ b/packages/core-commands/src/admin-navigation-commands.js
@@ -1,9 +1,92 @@
/**
* WordPress dependencies
*/
-import { useCommand } from '@wordpress/commands';
+import { useCommand, useCommandLoader } from '@wordpress/commands';
import { __ } from '@wordpress/i18n';
import { plus } from '@wordpress/icons';
+import { getPath } from '@wordpress/url';
+import { store as coreStore } from '@wordpress/core-data';
+import { useSelect, useDispatch } from '@wordpress/data';
+import { useCallback, useMemo } from '@wordpress/element';
+import { store as noticesStore } from '@wordpress/notices';
+import { privateApis as routerPrivateApis } from '@wordpress/router';
+
+/**
+ * Internal dependencies
+ */
+import { unlock } from './lock-unlock';
+
+const { useHistory } = unlock( routerPrivateApis );
+
+function useAddNewPageCommand() {
+ const isSiteEditor = getPath( window.location.href )?.includes(
+ 'site-editor.php'
+ );
+ const history = useHistory();
+ const isBlockBasedTheme = useSelect( ( select ) => {
+ return select( coreStore ).getCurrentTheme()?.is_block_theme;
+ }, [] );
+ const { saveEntityRecord } = useDispatch( coreStore );
+ const { createErrorNotice } = useDispatch( noticesStore );
+
+ const createPageEntity = useCallback(
+ async ( { close } ) => {
+ try {
+ const page = await saveEntityRecord(
+ 'postType',
+ 'page',
+ {
+ status: 'draft',
+ },
+ {
+ throwOnError: true,
+ }
+ );
+ if ( page?.id ) {
+ history.push( {
+ postId: page.id,
+ postType: 'page',
+ canvas: 'edit',
+ } );
+ }
+ } catch ( error ) {
+ const errorMessage =
+ error.message && error.code !== 'unknown_error'
+ ? error.message
+ : __( 'An error occurred while creating the item.' );
+
+ createErrorNotice( errorMessage, {
+ type: 'snackbar',
+ } );
+ } finally {
+ close();
+ }
+ },
+ [ createErrorNotice, history, saveEntityRecord ]
+ );
+
+ const commands = useMemo( () => {
+ const addNewPage =
+ isSiteEditor && isBlockBasedTheme
+ ? createPageEntity
+ : () =>
+ ( document.location.href =
+ 'post-new.php?post_type=page' );
+ return [
+ {
+ name: 'core/add-new-page',
+ label: __( 'Add new page' ),
+ icon: plus,
+ callback: addNewPage,
+ },
+ ];
+ }, [ createPageEntity, isSiteEditor, isBlockBasedTheme ] );
+
+ return {
+ isLoading: false,
+ commands,
+ };
+}
export function useAdminNavigationCommands() {
useCommand( {
@@ -14,12 +97,9 @@ export function useAdminNavigationCommands() {
document.location.href = 'post-new.php';
},
} );
- useCommand( {
+
+ useCommandLoader( {
name: 'core/add-new-page',
- label: __( 'Add new page' ),
- icon: plus,
- callback: () => {
- document.location.href = 'post-new.php?post_type=page';
- },
+ hook: useAddNewPageCommand,
} );
}
diff --git a/packages/core-data/src/test/entity-provider.js b/packages/core-data/src/test/entity-provider.js
index 6b0b7bd5ef77a8..4dc0d8a51663e8 100644
--- a/packages/core-data/src/test/entity-provider.js
+++ b/packages/core-data/src/test/entity-provider.js
@@ -104,7 +104,7 @@ describe( 'useEntityBlockEditor', () => {
source: 'html',
selector: 'p',
default: '',
- __experimentalRole: 'content',
+ role: 'content',
},
},
title: 'block title',
diff --git a/packages/create-block-interactive-template/CHANGELOG.md b/packages/create-block-interactive-template/CHANGELOG.md
index 348c8466836c69..24dcfd52b7b586 100644
--- a/packages/create-block-interactive-template/CHANGELOG.md
+++ b/packages/create-block-interactive-template/CHANGELOG.md
@@ -1,9 +1,11 @@
-## Unreleased
-
## 2.8.0 (2024-09-19)
+### Enhancements
+
+- Added TypeScript variant of the template ([#64577](https://github.com/WordPress/gutenberg/pull/64577)).
+
## 2.7.0 (2024-09-05)
### Enhancements
diff --git a/packages/create-block-interactive-template/README.md b/packages/create-block-interactive-template/README.md
index 4417c647495c4c..b50adb49265245 100644
--- a/packages/create-block-interactive-template/README.md
+++ b/packages/create-block-interactive-template/README.md
@@ -1,6 +1,6 @@
# Create Block Interactive Template
-This is a template for [`@wordpress/create-block`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/create-block/README.md) to create interactive blocks
+This is a template for [`@wordpress/create-block`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/create-block/README.md) to create interactive blocks.
## Usage
diff --git a/packages/create-block-interactive-template/block-templates/README.md.mustache b/packages/create-block-interactive-template/block-templates/README.md.mustache
index 3e64ce8f629a3c..4a13743750f748 100644
--- a/packages/create-block-interactive-template/block-templates/README.md.mustache
+++ b/packages/create-block-interactive-template/block-templates/README.md.mustache
@@ -3,6 +3,4 @@
> **Note**
> Check the [Interactivity API Reference docs in the Block Editor handbook](https://developer.wordpress.org/block-editor/reference-guides/interactivity-api/) to learn more about the Interactivity API.
-{{#isBasicVariant}}
This block has been created with the `create-block-interactive-template` and shows a basic structure of an interactive block that uses the Interactivity API.
-{{/isBasicVariant}}
\ No newline at end of file
diff --git a/packages/create-block-interactive-template/block-templates/render.php.mustache b/packages/create-block-interactive-template/block-templates/render.php.mustache
index 3a41a2981cd8cf..4f84b30dbcdbdd 100644
--- a/packages/create-block-interactive-template/block-templates/render.php.mustache
+++ b/packages/create-block-interactive-template/block-templates/render.php.mustache
@@ -1,4 +1,3 @@
-{{#isBasicVariant}}
false,
+ 'darkText' => esc_html__( 'Switch to Light', '{{textdomain}}' ),
+ 'lightText' => esc_html__( 'Switch to Dark', '{{textdomain}}' ),
+ 'themeText' => esc_html__( 'Switch to Dark', '{{textdomain}}' ),
+ )
+);
?>
false ) ); ?>
data-wp-watch="callbacks.logIsOpen"
+ data-wp-class--dark-theme="state.isDark"
>
+
+
@@ -38,4 +54,3 @@ $unique_id = wp_unique_id( 'p-' );
?>
-{{/isBasicVariant}}
diff --git a/packages/create-block-interactive-template/block-templates/style.scss.mustache b/packages/create-block-interactive-template/block-templates/style.scss.mustache
index 1c73fa1c38ff94..c8aa9f232136e2 100644
--- a/packages/create-block-interactive-template/block-templates/style.scss.mustache
+++ b/packages/create-block-interactive-template/block-templates/style.scss.mustache
@@ -9,4 +9,19 @@
font-size: 1em;
background: #ffff001a;
padding: 1em;
+
+ &.dark-theme {
+ background: #333;
+ color: #fff;
+
+ button {
+ background: #555;
+ color: #fff;
+ border: 1px solid #777;
+ }
+
+ p {
+ color: #ddd;
+ }
+ }
}
diff --git a/packages/create-block-interactive-template/block-templates/view.js.mustache b/packages/create-block-interactive-template/block-templates/view.js.mustache
index b4bae3939461dd..3fcf1ba365d265 100644
--- a/packages/create-block-interactive-template/block-templates/view.js.mustache
+++ b/packages/create-block-interactive-template/block-templates/view.js.mustache
@@ -1,15 +1,23 @@
-{{#isBasicVariant}}
+{{#isDefaultVariant}}
/**
* WordPress dependencies
*/
-import { store, getContext } from "@wordpress/interactivity";
+import { store, getContext } from '@wordpress/interactivity';
-store( '{{namespace}}', {
+const { state } = store( '{{namespace}}', {
+ state: {
+ get themeText() {
+ return state.isDark ? state.darkText : state.lightText;
+ }
+ },
actions: {
- toggle: () => {
+ toggleOpen() {
const context = getContext();
context.isOpen = ! context.isOpen;
},
+ toggleTheme() {
+ state.isDark = ! state.isDark;
+ }
},
callbacks: {
logIsOpen: () => {
@@ -19,5 +27,4 @@ store( '{{namespace}}', {
},
},
} );
-
-{{/isBasicVariant}}
+{{/isDefaultVariant}}
diff --git a/packages/create-block-interactive-template/block-templates/view.ts.mustache b/packages/create-block-interactive-template/block-templates/view.ts.mustache
new file mode 100644
index 00000000000000..11670442d73704
--- /dev/null
+++ b/packages/create-block-interactive-template/block-templates/view.ts.mustache
@@ -0,0 +1,46 @@
+{{#isTypescriptVariant}}
+/**
+ * WordPress dependencies
+ */
+import { store, getContext } from '@wordpress/interactivity';
+
+type ServerState = {
+ state: {
+ isDark: boolean;
+ darkText: string;
+ lightText: string;
+ };
+};
+
+type Context = {
+ isOpen: boolean;
+};
+
+const storeDef = {
+ state: {
+ get themeText(): string {
+ return state.isDark ? state.darkText : state.lightText;
+ }
+ },
+ actions: {
+ toggleOpen() {
+ const context = getContext< Context >();
+ context.isOpen = ! context.isOpen;
+ },
+ toggleTheme() {
+ state.isDark = ! state.isDark;
+ }
+ },
+ callbacks: {
+ logIsOpen: () => {
+ const { isOpen } = getContext< Context >();
+ // Log the value of `isOpen` each time it changes.
+ console.log( `Is open: ${ isOpen }` );
+ },
+ },
+};
+
+type Store = ServerState & typeof storeDef;
+
+const { state } = store< Store >( '{{namespace}}', storeDef );
+{{/isTypescriptVariant}}
diff --git a/packages/create-block-interactive-template/index.js b/packages/create-block-interactive-template/index.js
index bb203b7023e281..94f615df2747f2 100644
--- a/packages/create-block-interactive-template/index.js
+++ b/packages/create-block-interactive-template/index.js
@@ -7,7 +7,7 @@ module.exports = {
defaultValues: {
slug: 'example-interactive',
title: 'Example Interactive',
- description: 'An interactive block with the Interactivity API',
+ description: 'An interactive block with the Interactivity API.',
dashicon: 'media-interactive',
npmDependencies: [ '@wordpress/interactivity' ],
customPackageJSON: { files: [ '[^.]*' ] },
@@ -24,7 +24,14 @@ module.exports = {
},
},
variants: {
- basic: {},
+ default: {},
+ typescript: {
+ slug: 'example-interactive-typescript',
+ title: 'Example Interactive TypeScript',
+ description:
+ 'An interactive block with the Interactivity API using TypeScript.',
+ viewScriptModule: 'file:./view.ts',
+ },
},
pluginTemplatesPath: join( __dirname, 'plugin-templates' ),
blockTemplatesPath: join( __dirname, 'block-templates' ),
diff --git a/packages/customize-widgets/src/components/error-boundary/index.js b/packages/customize-widgets/src/components/error-boundary/index.js
index 49867787afd059..0fff18a616d11c 100644
--- a/packages/customize-widgets/src/components/error-boundary/index.js
+++ b/packages/customize-widgets/src/components/error-boundary/index.js
@@ -11,12 +11,7 @@ import { doAction } from '@wordpress/hooks';
function CopyButton( { text, children } ) {
const ref = useCopyToClipboard( text );
return (
-
+
{ children }
);
diff --git a/packages/customize-widgets/src/components/inserter/index.js b/packages/customize-widgets/src/components/inserter/index.js
index 41fc037cf673c9..4f271bef9e9a3f 100644
--- a/packages/customize-widgets/src/components/inserter/index.js
+++ b/packages/customize-widgets/src/components/inserter/index.js
@@ -37,9 +37,7 @@ function Inserter( { setIsOpened } ) {
{ __( 'Add a block' ) }
setIsOpened( false ) }
aria-label={ __( 'Close inserter' ) }
diff --git a/packages/customize-widgets/src/components/welcome-guide/index.js b/packages/customize-widgets/src/components/welcome-guide/index.js
index 51c4d479be51ff..3f7b2f45d86b7a 100644
--- a/packages/customize-widgets/src/components/welcome-guide/index.js
+++ b/packages/customize-widgets/src/components/welcome-guide/index.js
@@ -43,9 +43,7 @@ export default function WelcomeGuide( { sidebar } ) {
) }
toggle( 'core/customize-widgets', 'welcomeGuide' )
diff --git a/packages/dataviews/README.md b/packages/dataviews/README.md
index 3ce895680a15c5..95b8fc898555c3 100644
--- a/packages/dataviews/README.md
+++ b/packages/dataviews/README.md
@@ -2,6 +2,10 @@
DataViews is a component that provides an API to render datasets using different types of layouts (table, grid, list, etc.).
+DataViews is data agnostic, it can work with data coming from a static (JSON file) or dynamic source (HTTP Request) āĀ it just requires the data to be an array of objects that have an unique identifier. Consumers are responsible to query the data source appropiately based on the DataViews props:
+
+![DataViews flow](https://developer.wordpress.org/files/2024/09/368600071-20aa078f-7c3d-406d-8dd0-8b764addd22a.png "DataViews flow")
+
## Installation
Install the module
diff --git a/packages/dataviews/src/components/dataform-combined-edit/index.tsx b/packages/dataviews/src/components/dataform-combined-edit/index.tsx
new file mode 100644
index 00000000000000..6b2a752fa8de52
--- /dev/null
+++ b/packages/dataviews/src/components/dataform-combined-edit/index.tsx
@@ -0,0 +1,66 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ __experimentalHStack as HStack,
+ __experimentalVStack as VStack,
+ __experimentalHeading as Heading,
+ __experimentalSpacer as Spacer,
+} from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import type { DataFormCombinedEditProps, NormalizedField } from '../../types';
+
+function Header( { title }: { title: string } ) {
+ return (
+
+
+
+ { title }
+
+
+
+
+ );
+}
+
+function DataFormCombinedEdit< Item >( {
+ field,
+ data,
+ onChange,
+ hideLabelFromVision,
+}: DataFormCombinedEditProps< Item > ) {
+ const className = 'dataforms-combined-edit';
+ const visibleChildren = ( field.children ?? [] )
+ .map( ( fieldId ) => field.fields.find( ( { id } ) => id === fieldId ) )
+ .filter(
+ ( childField ): childField is NormalizedField< Item > =>
+ !! childField
+ );
+ const children = visibleChildren.map( ( child ) => {
+ return (
+
+
+
+ );
+ } );
+
+ const Stack = field.direction === 'horizontal' ? HStack : VStack;
+
+ return (
+ <>
+ { ! hideLabelFromVision && }
+
+ { children }
+
+ >
+ );
+}
+
+export default DataFormCombinedEdit;
diff --git a/packages/dataviews/src/components/dataform-combined-edit/style.scss b/packages/dataviews/src/components/dataform-combined-edit/style.scss
new file mode 100644
index 00000000000000..0b59cbc9a47768
--- /dev/null
+++ b/packages/dataviews/src/components/dataform-combined-edit/style.scss
@@ -0,0 +1,12 @@
+.dataforms-layouts-panel__field-dropdown {
+ .dataforms-combined-edit {
+ border: none;
+ padding: 0;
+ }
+}
+
+.dataforms-combined-edit {
+ &__field {
+ flex: 1 1 auto;
+ }
+}
diff --git a/packages/dataviews/src/components/dataform/stories/index.story.tsx b/packages/dataviews/src/components/dataform/stories/index.story.tsx
index 7147b9c2342638..c929c21f1c21a9 100644
--- a/packages/dataviews/src/components/dataform/stories/index.story.tsx
+++ b/packages/dataviews/src/components/dataform/stories/index.story.tsx
@@ -7,6 +7,7 @@ import { useState } from '@wordpress/element';
* Internal dependencies
*/
import DataForm from '../index';
+import type { CombinedFormField } from '../../../types';
const meta = {
title: 'DataViews/DataForm',
@@ -76,6 +77,11 @@ const fields = [
{ value: 'published', label: 'Published' },
],
},
+ {
+ id: 'password',
+ label: 'Password',
+ type: 'text' as const,
+ },
];
export const Default = ( { type }: { type: 'panel' | 'regular' } ) => {
@@ -118,3 +124,62 @@ export const Default = ( { type }: { type: 'panel' | 'regular' } ) => {
/>
);
};
+
+const CombinedFieldsComponent = ( {
+ type = 'regular',
+ combinedFieldDirection = 'vertical',
+}: {
+ type: 'panel' | 'regular';
+ combinedFieldDirection: 'vertical' | 'horizontal';
+} ) => {
+ const [ post, setPost ] = useState( {
+ title: 'Hello, World!',
+ order: 2,
+ author: 1,
+ status: 'draft',
+ } );
+
+ const form = {
+ fields: [ 'title', 'status_and_visibility', 'order', 'author' ],
+ combinedFields: [
+ {
+ id: 'status_and_visibility',
+ label: 'Status & Visibility',
+ children: [ 'status', 'password' ],
+ direction: combinedFieldDirection,
+ render: ( { item } ) => item.status,
+ },
+ ] as CombinedFormField< any >[],
+ };
+
+ return (
+
+ setPost( ( prev ) => ( {
+ ...prev,
+ ...edits,
+ } ) )
+ }
+ />
+ );
+};
+
+export const CombinedFields = {
+ title: 'DataViews/CombinedFields',
+ render: CombinedFieldsComponent,
+ argTypes: {
+ ...meta.argTypes,
+ combinedFieldDirection: {
+ control: { type: 'select' },
+ description:
+ 'Chooses the direction of the combined field. "vertical" is the default layout.',
+ options: [ 'vertical', 'horizontal' ],
+ },
+ },
+};
diff --git a/packages/dataviews/src/components/dataviews-filters/style.scss b/packages/dataviews/src/components/dataviews-filters/style.scss
index ad834fb224e2e4..130ef8872615a5 100644
--- a/packages/dataviews/src/components/dataviews-filters/style.scss
+++ b/packages/dataviews/src/components/dataviews-filters/style.scss
@@ -160,7 +160,6 @@
}
.dataviews-filters__search-widget-listbox {
- max-height: $grid-unit * 23;
padding: $grid-unit-05;
overflow: auto;
}
diff --git a/packages/dataviews/src/components/dataviews-view-config/index.tsx b/packages/dataviews/src/components/dataviews-view-config/index.tsx
index 48fdf6906b0774..02e81b2b0913d8 100644
--- a/packages/dataviews/src/components/dataviews-view-config/index.tsx
+++ b/packages/dataviews/src/components/dataviews-view-config/index.tsx
@@ -8,6 +8,7 @@ import type { ChangeEvent } from 'react';
*/
import {
Button,
+ __experimentalDropdownContentWrapper as DropdownContentWrapper,
Dropdown,
__experimentalToggleGroupControl as ToggleGroupControl,
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
@@ -27,6 +28,7 @@ import { __, _x, sprintf } from '@wordpress/i18n';
import { memo, useContext, useMemo } from '@wordpress/element';
import { chevronDown, chevronUp, cog, seen, unseen } from '@wordpress/icons';
import warning from '@wordpress/warning';
+import { useInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
@@ -55,6 +57,8 @@ interface ViewTypeMenuProps {
defaultLayouts?: SupportedLayouts;
}
+const DATAVIEWS_CONFIG_POPOVER_PROPS = { placement: 'bottom-end', offset: 9 };
+
function ViewTypeMenu( {
defaultLayouts = { list: {}, grid: {}, table: {} },
}: ViewTypeMenuProps ) {
@@ -510,7 +514,7 @@ function SettingsSection( {
);
}
-function DataviewsViewConfigContent( {
+function DataviewsViewConfigDropdown( {
density,
setDensity,
}: {
@@ -518,25 +522,52 @@ function DataviewsViewConfigContent( {
setDensity: React.Dispatch< React.SetStateAction< number > >;
} ) {
const { view } = useContext( DataViewsContext );
+ const popoverId = useInstanceId(
+ _DataViewsViewConfig,
+ 'dataviews-view-config-dropdown'
+ );
+
return (
-
-
-
-
-
-
- { view.type === LAYOUT_GRID && (
- {
+ return (
+
- ) }
-
-
-
-
-
-
+ );
+ } }
+ renderContent={ () => (
+
+
+
+
+
+
+
+ { view.type === LAYOUT_GRID && (
+
+ ) }
+
+
+
+
+
+
+
+ ) }
+ />
);
}
@@ -552,28 +583,9 @@ function _DataViewsViewConfig( {
return (
<>
- {
- return (
-
- );
- } }
- renderContent={ () => (
-
- ) }
+
>
);
diff --git a/packages/dataviews/src/components/dataviews-view-config/style.scss b/packages/dataviews/src/components/dataviews-view-config/style.scss
index c7d07fe7866bcf..7fff110337ee3a 100644
--- a/packages/dataviews/src/components/dataviews-view-config/style.scss
+++ b/packages/dataviews/src/components/dataviews-view-config/style.scss
@@ -1,12 +1,9 @@
.dataviews-view-config {
- .components-popover__content {
- width: 320px;
- /* stylelint-disable-next-line property-no-unknown -- the linter needs to be updated to accepted the container-type property */
- container-type: inline-size;
- padding: $grid-unit-20;
- font-size: $default-font-size;
- line-height: $default-line-height;
- }
+ width: 320px;
+ /* stylelint-disable-next-line property-no-unknown -- the linter needs to be updated to accepted the container-type property */
+ container-type: inline-size;
+ font-size: $default-font-size;
+ line-height: $default-line-height;
}
.dataviews-view-config__sort-direction .components-toggle-group-control-option-base {
diff --git a/packages/dataviews/src/components/dataviews/style.scss b/packages/dataviews/src/components/dataviews/style.scss
index 8909c7cf1c7cfd..aa8fbcfb009c05 100644
--- a/packages/dataviews/src/components/dataviews/style.scss
+++ b/packages/dataviews/src/components/dataviews/style.scss
@@ -80,7 +80,6 @@
padding: $grid-unit-15 $grid-unit-30;
}
- .dataviews-view-grid,
.dataviews-no-results,
.dataviews-loading {
padding-left: $grid-unit-30;
diff --git a/packages/dataviews/src/dataforms-layouts/get-visible-fields.ts b/packages/dataviews/src/dataforms-layouts/get-visible-fields.ts
new file mode 100644
index 00000000000000..d95d59a88394e4
--- /dev/null
+++ b/packages/dataviews/src/dataforms-layouts/get-visible-fields.ts
@@ -0,0 +1,29 @@
+/**
+ * Internal dependencies
+ */
+import { normalizeCombinedFields } from '../normalize-fields';
+import type {
+ Field,
+ CombinedFormField,
+ NormalizedCombinedFormField,
+} from '../types';
+
+export function getVisibleFields< Item >(
+ fields: Field< Item >[],
+ formFields: string[] = [],
+ combinedFields?: CombinedFormField< Item >[]
+): Field< Item >[] {
+ const visibleFields: Array<
+ Field< Item > | NormalizedCombinedFormField< Item >
+ > = [ ...fields ];
+ if ( combinedFields ) {
+ visibleFields.push(
+ ...normalizeCombinedFields( combinedFields, fields )
+ );
+ }
+ return formFields
+ .map( ( fieldId ) =>
+ visibleFields.find( ( { id } ) => id === fieldId )
+ )
+ .filter( ( field ): field is Field< Item > => !! field );
+}
diff --git a/packages/dataviews/src/dataforms-layouts/panel/index.tsx b/packages/dataviews/src/dataforms-layouts/panel/index.tsx
index 9f118584998bd3..5d3bbc532ad457 100644
--- a/packages/dataviews/src/dataforms-layouts/panel/index.tsx
+++ b/packages/dataviews/src/dataforms-layouts/panel/index.tsx
@@ -17,7 +17,8 @@ import { closeSmall } from '@wordpress/icons';
* Internal dependencies
*/
import { normalizeFields } from '../../normalize-fields';
-import type { DataFormProps, NormalizedField, Field } from '../../types';
+import { getVisibleFields } from '../get-visible-fields';
+import type { DataFormProps, NormalizedField } from '../../types';
interface FormFieldProps< Item > {
data: Item;
@@ -44,12 +45,10 @@ function DropdownHeader( {
{ onClose && (
) }
@@ -144,13 +143,13 @@ export default function FormPanel< Item >( {
const visibleFields = useMemo(
() =>
normalizeFields(
- ( form.fields ?? [] )
- .map( ( fieldId ) =>
- fields.find( ( { id } ) => id === fieldId )
- )
- .filter( ( field ): field is Field< Item > => !! field )
+ getVisibleFields< Item >(
+ fields,
+ form.fields,
+ form.combinedFields
+ )
),
- [ fields, form.fields ]
+ [ fields, form.fields, form.combinedFields ]
);
return (
diff --git a/packages/dataviews/src/dataforms-layouts/panel/style.scss b/packages/dataviews/src/dataforms-layouts/panel/style.scss
index e6840bed9d6e0b..ae69c4ff45243a 100644
--- a/packages/dataviews/src/dataforms-layouts/panel/style.scss
+++ b/packages/dataviews/src/dataforms-layouts/panel/style.scss
@@ -44,16 +44,3 @@
.dataforms-layouts-panel__dropdown-header {
margin-bottom: $grid-unit-20;
}
-
-[class].dataforms-layouts-panel__dropdown-header-action {
- height: $icon-size;
-
- &.has-icon {
- min-width: $icon-size;
- padding: 0;
- }
-
- &:not(.has-icon) {
- text-decoration: underline;
- }
-}
diff --git a/packages/dataviews/src/dataforms-layouts/regular/index.tsx b/packages/dataviews/src/dataforms-layouts/regular/index.tsx
index 0ec427ae010032..57aa163b890e5f 100644
--- a/packages/dataviews/src/dataforms-layouts/regular/index.tsx
+++ b/packages/dataviews/src/dataforms-layouts/regular/index.tsx
@@ -8,7 +8,8 @@ import { useMemo } from '@wordpress/element';
* Internal dependencies
*/
import { normalizeFields } from '../../normalize-fields';
-import type { DataFormProps, Field } from '../../types';
+import { getVisibleFields } from '../get-visible-fields';
+import type { DataFormProps } from '../../types';
export default function FormRegular< Item >( {
data,
@@ -19,13 +20,13 @@ export default function FormRegular< Item >( {
const visibleFields = useMemo(
() =>
normalizeFields(
- ( form.fields ?? [] )
- .map( ( fieldId ) =>
- fields.find( ( { id } ) => id === fieldId )
- )
- .filter( ( field ): field is Field< Item > => !! field )
+ getVisibleFields< Item >(
+ fields,
+ form.fields,
+ form.combinedFields
+ )
),
- [ fields, form.fields ]
+ [ fields, form.fields, form.combinedFields ]
);
return (
diff --git a/packages/dataviews/src/dataviews-layouts/grid/style.scss b/packages/dataviews/src/dataviews-layouts/grid/style.scss
index 5fab362b0b47b6..6286ed94685a04 100644
--- a/packages/dataviews/src/dataviews-layouts/grid/style.scss
+++ b/packages/dataviews/src/dataviews-layouts/grid/style.scss
@@ -163,3 +163,11 @@
.dataviews-view-grid__card.is-selected .dataviews-selection-checkbox {
top: $grid-unit-10;
}
+
+/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */
+@container (max-width: 430px) {
+ .dataviews-view-grid {
+ padding-left: $grid-unit-30;
+ padding-right: $grid-unit-30;
+ }
+}
diff --git a/packages/dataviews/src/normalize-fields.ts b/packages/dataviews/src/normalize-fields.ts
index 2d1cc0402bc206..5ef219e45a4787 100644
--- a/packages/dataviews/src/normalize-fields.ts
+++ b/packages/dataviews/src/normalize-fields.ts
@@ -2,8 +2,14 @@
* Internal dependencies
*/
import getFieldTypeDefinition from './field-types';
-import type { Field, NormalizedField } from './types';
+import type {
+ CombinedFormField,
+ Field,
+ NormalizedField,
+ NormalizedCombinedFormField,
+} from './types';
import { getControl } from './dataform-controls';
+import DataFormCombinedEdit from './components/dataform-combined-edit';
/**
* Apply default values and normalize the fields config.
@@ -66,3 +72,29 @@ export function normalizeFields< Item >(
};
} );
}
+
+/**
+ * Apply default values and normalize the fields config.
+ *
+ * @param combinedFields combined field list.
+ * @param fields Fields config.
+ * @return Normalized fields config.
+ */
+export function normalizeCombinedFields< Item >(
+ combinedFields: CombinedFormField< Item >[],
+ fields: Field< Item >[]
+): NormalizedCombinedFormField< Item >[] {
+ return combinedFields.map( ( combinedField ) => {
+ return {
+ ...combinedField,
+ Edit: DataFormCombinedEdit,
+ fields: normalizeFields(
+ combinedField.children
+ .map( ( fieldId ) =>
+ fields.find( ( { id } ) => id === fieldId )
+ )
+ .filter( ( field ): field is Field< Item > => !! field )
+ ),
+ };
+ } );
+}
diff --git a/packages/dataviews/src/style.scss b/packages/dataviews/src/style.scss
index 087e812fffa192..26c6ecea645f43 100644
--- a/packages/dataviews/src/style.scss
+++ b/packages/dataviews/src/style.scss
@@ -6,6 +6,7 @@
@import "./components/dataviews-item-actions/style.scss";
@import "./components/dataviews-selection-checkbox/style.scss";
@import "./components/dataviews-view-config/style.scss";
+@import "./components/dataform-combined-edit/style.scss";
@import "./dataviews-layouts/grid/style.scss";
@import "./dataviews-layouts/list/style.scss";
diff --git a/packages/dataviews/src/types.ts b/packages/dataviews/src/types.ts
index e95a43994cd63d..bc44b57eaaecc6 100644
--- a/packages/dataviews/src/types.ts
+++ b/packages/dataviews/src/types.ts
@@ -174,14 +174,6 @@ export type Fields< Item > = Field< Item >[];
export type Data< Item > = Item[];
-/**
- * The form configuration.
- */
-export type Form = {
- type?: 'regular' | 'panel';
- fields?: string[];
-};
-
export type DataFormControlProps< Item > = {
data: Item;
field: NormalizedField< Item >;
@@ -524,9 +516,37 @@ export interface SupportedLayouts {
table?: Omit< ViewTable, 'type' >;
}
+export interface CombinedFormField< Item > extends CombinedField {
+ render?: ComponentType< { item: Item } >;
+}
+
+export interface DataFormCombinedEditProps< Item > {
+ field: NormalizedCombinedFormField< Item >;
+ data: Item;
+ onChange: ( value: Record< string, any > ) => void;
+ hideLabelFromVision?: boolean;
+}
+
+export type NormalizedCombinedFormField< Item > = CombinedFormField< Item > & {
+ fields: NormalizedField< Item >[];
+ Edit?: ComponentType< DataFormCombinedEditProps< Item > >;
+};
+
+/**
+ * The form configuration.
+ */
+export type Form< Item > = {
+ type?: 'regular' | 'panel';
+ fields?: string[];
+ /**
+ * The fields to combine.
+ */
+ combinedFields?: CombinedFormField< Item >[];
+};
+
export interface DataFormProps< Item > {
data: Item;
fields: Field< Item >[];
- form: Form;
+ form: Form< Item >;
onChange: ( value: Record< string, any > ) => void;
}
diff --git a/packages/dataviews/src/validation.ts b/packages/dataviews/src/validation.ts
index cc0b031f6c96c6..41969a7960af65 100644
--- a/packages/dataviews/src/validation.ts
+++ b/packages/dataviews/src/validation.ts
@@ -7,7 +7,7 @@ import type { Field, Form } from './types';
export function isItemValid< Item >(
item: Item,
fields: Field< Item >[],
- form: Form
+ form: Form< Item >
): boolean {
const _fields = normalizeFields(
fields.filter( ( { id } ) => !! form.fields?.includes( id ) )
diff --git a/packages/dependency-extraction-webpack-plugin/CHANGELOG.md b/packages/dependency-extraction-webpack-plugin/CHANGELOG.md
index 85498d539317f3..7c8f74d2906fed 100644
--- a/packages/dependency-extraction-webpack-plugin/CHANGELOG.md
+++ b/packages/dependency-extraction-webpack-plugin/CHANGELOG.md
@@ -2,6 +2,14 @@
## Unreleased
+### Enhancements
+
+- Detection of magic comments is now done before minification ([#65582](https://github.com/WordPress/gutenberg/pull/65582)).
+
+### Bug Fixes
+
+- Fix a bug where cycles in dependent modules could enter infinite recursion ([#65291](https://github.com/WordPress/gutenberg/pull/65291)).
+
## 6.8.0 (2024-09-19)
## 6.7.0 (2024-09-05)
diff --git a/packages/dependency-extraction-webpack-plugin/lib/index.js b/packages/dependency-extraction-webpack-plugin/lib/index.js
index 575882a1dfbebe..cf780d7370dcfc 100644
--- a/packages/dependency-extraction-webpack-plugin/lib/index.js
+++ b/packages/dependency-extraction-webpack-plugin/lib/index.js
@@ -162,6 +162,14 @@ class DependencyExtractionWebpackPlugin {
compiler.hooks.thisCompilation.tap(
this.constructor.name,
( compilation ) => {
+ compilation.hooks.processAssets.tap(
+ {
+ name: this.constructor.name,
+ stage: compiler.webpack.Compilation
+ .PROCESS_ASSETS_STAGE_OPTIMIZE_COMPATIBILITY,
+ },
+ () => this.checkForMagicComments( compilation )
+ );
compilation.hooks.processAssets.tap(
{
name: this.constructor.name,
@@ -174,6 +182,60 @@ class DependencyExtractionWebpackPlugin {
);
}
+ /**
+ * Check for magic comments before minification, so minification doesn't have to preserve them.
+ * @param {webpack.Compilation} compilation
+ */
+ checkForMagicComments( compilation ) {
+ // Accumulate all entrypoint chunks, some of them shared
+ const entrypointChunks = new Set();
+ for ( const entrypoint of compilation.entrypoints.values() ) {
+ for ( const chunk of entrypoint.chunks ) {
+ entrypointChunks.add( chunk );
+ }
+ }
+
+ // Process each entrypoint chunk independently
+ for ( const chunk of entrypointChunks ) {
+ const chunkFiles = Array.from( chunk.files );
+
+ const jsExtensionRegExp = this.useModules ? /\.m?js$/i : /\.js$/i;
+
+ const chunkJSFile = chunkFiles.find( ( f ) =>
+ jsExtensionRegExp.test( f )
+ );
+ if ( ! chunkJSFile ) {
+ // There's no JS file in this chunk, no work for us. Typically a `style.css` from cache group.
+ continue;
+ }
+
+ // Prepare to look for magic comments, in order to decide whether
+ // `wp-polyfill` is needed.
+ const processContentsForMagicComments = ( content ) => {
+ const magicComments = [];
+
+ if ( content.includes( '/* wp:polyfill */' ) ) {
+ magicComments.push( 'wp-polyfill' );
+ }
+
+ return magicComments;
+ };
+
+ // Go through the assets to process the sources.
+ // This allows us to look for magic comments.
+ chunkFiles.sort().forEach( ( filename ) => {
+ const asset = compilation.getAsset( filename );
+ const content = asset.source.buffer();
+
+ const wpMagicComments =
+ processContentsForMagicComments( content );
+ compilation.updateAsset( filename, ( v ) => v, {
+ wpMagicComments,
+ } );
+ } );
+ }
+ }
+
/** @param {webpack.Compilation} compilation */
addAssets( compilation ) {
const {
@@ -286,8 +348,11 @@ class DependencyExtractionWebpackPlugin {
// Prepare to look for magic comments, in order to decide whether
// `wp-polyfill` is needed.
- const processContentsForMagicComments = ( content ) => {
- if ( content.includes( '/* wp:polyfill */' ) ) {
+ const handleMagicComments = ( info ) => {
+ if ( ! info ) {
+ return;
+ }
+ if ( info.includes( 'wp-polyfill' ) ) {
chunkStaticDeps.add( 'wp-polyfill' );
}
};
@@ -299,7 +364,7 @@ class DependencyExtractionWebpackPlugin {
const content = asset.source.buffer();
processContentsForHash( content );
- processContentsForMagicComments( content );
+ handleMagicComments( asset.info.wpMagicComments );
} );
// Finalise hash.
@@ -369,6 +434,9 @@ class DependencyExtractionWebpackPlugin {
}
}
+ static #staticDepsCurrent = new WeakSet();
+ static #staticDepsCache = new WeakMap();
+
/**
* Can we trace a line of static dependencies from an entry to a module
*
@@ -378,6 +446,20 @@ class DependencyExtractionWebpackPlugin {
* @return {boolean} True if there is a static import path to the root
*/
static hasStaticDependencyPathToRoot( compilation, block ) {
+ if ( DependencyExtractionWebpackPlugin.#staticDepsCache.has( block ) ) {
+ return DependencyExtractionWebpackPlugin.#staticDepsCache.get(
+ block
+ );
+ }
+
+ if (
+ DependencyExtractionWebpackPlugin.#staticDepsCurrent.has( block )
+ ) {
+ return false;
+ }
+
+ DependencyExtractionWebpackPlugin.#staticDepsCurrent.add( block );
+
const incomingConnections = [
...compilation.moduleGraph.getIncomingConnections( block ),
].filter(
@@ -391,6 +473,13 @@ class DependencyExtractionWebpackPlugin {
// If we don't have non-entry, non-library incoming connections,
// we've reached a root of
if ( ! incomingConnections.length ) {
+ DependencyExtractionWebpackPlugin.#staticDepsCache.set(
+ block,
+ true
+ );
+ DependencyExtractionWebpackPlugin.#staticDepsCurrent.delete(
+ block
+ );
return true;
}
@@ -409,16 +498,28 @@ class DependencyExtractionWebpackPlugin {
// All the dependencies were Async, the module was reached via a dynamic import
if ( ! staticDependentModules.length ) {
+ DependencyExtractionWebpackPlugin.#staticDepsCache.set(
+ block,
+ false
+ );
+ DependencyExtractionWebpackPlugin.#staticDepsCurrent.delete(
+ block
+ );
return false;
}
// Continue to explore any static dependencies
- return staticDependentModules.some( ( parentStaticDependentModule ) =>
- DependencyExtractionWebpackPlugin.hasStaticDependencyPathToRoot(
- compilation,
- parentStaticDependentModule
- )
+ const result = staticDependentModules.some(
+ ( parentStaticDependentModule ) =>
+ DependencyExtractionWebpackPlugin.hasStaticDependencyPathToRoot(
+ compilation,
+ parentStaticDependentModule
+ )
);
+
+ DependencyExtractionWebpackPlugin.#staticDepsCache.set( block, result );
+ DependencyExtractionWebpackPlugin.#staticDepsCurrent.delete( block );
+ return result;
}
}
diff --git a/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap b/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap
index c4b450683572e8..f0d418851103ba 100644
--- a/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap
+++ b/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap
@@ -55,6 +55,21 @@ exports[`DependencyExtractionWebpackPlugin modules Webpack \`cyclic-dynamic-depe
]
`;
+exports[`DependencyExtractionWebpackPlugin modules Webpack \`cyclic-external-deps\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = `
+" array(array('id' => '@wordpress/interactivity', 'import' => 'dynamic')), 'version' => 'e1033c1bd62e8cb8d4c9', 'type' => 'module');
+"
+`;
+
+exports[`DependencyExtractionWebpackPlugin modules Webpack \`cyclic-external-deps\` should produce expected output: External modules should match snapshot 1`] = `
+[
+ {
+ "externalType": "module",
+ "request": "@wordpress/interactivity",
+ "userRequest": "@wordpress/interactivity",
+ },
+]
+`;
+
exports[`DependencyExtractionWebpackPlugin modules Webpack \`dynamic-import\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = `
" array(array('id' => '@wordpress/blob', 'import' => 'dynamic')), 'version' => '4f59b7847b70a07b2710', 'type' => 'module');
"
@@ -242,6 +257,13 @@ exports[`DependencyExtractionWebpackPlugin modules Webpack \`polyfill-magic-comm
exports[`DependencyExtractionWebpackPlugin modules Webpack \`polyfill-magic-comment\` should produce expected output: External modules should match snapshot 1`] = `[]`;
+exports[`DependencyExtractionWebpackPlugin modules Webpack \`polyfill-magic-comment-minified\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = `
+" array('wp-polyfill'), 'version' => '31d6cfe0d16ae931b73c', 'type' => 'module');
+"
+`;
+
+exports[`DependencyExtractionWebpackPlugin modules Webpack \`polyfill-magic-comment-minified\` should produce expected output: External modules should match snapshot 1`] = `[]`;
+
exports[`DependencyExtractionWebpackPlugin modules Webpack \`runtime-chunk-single\` should produce expected output: Asset file 'a.asset.php' should match snapshot 1`] = `
" array('@wordpress/blob'), 'version' => 'a1906cfc819b623c86f8', 'type' => 'module');
"
@@ -419,6 +441,24 @@ exports[`DependencyExtractionWebpackPlugin scripts Webpack \`cyclic-dynamic-depe
]
`;
+exports[`DependencyExtractionWebpackPlugin scripts Webpack \`cyclic-external-deps\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = `
+" array('wp-interactivity'), 'version' => '455f3cab924853d41b8b');
+"
+`;
+
+exports[`DependencyExtractionWebpackPlugin scripts Webpack \`cyclic-external-deps\` should produce expected output: External modules should match snapshot 1`] = `
+[
+ {
+ "externalType": "window",
+ "request": [
+ "wp",
+ "interactivity",
+ ],
+ "userRequest": "@wordpress/interactivity",
+ },
+]
+`;
+
exports[`DependencyExtractionWebpackPlugin scripts Webpack \`dynamic-import\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = `
" array('wp-blob'), 'version' => 'c0e8a6f22065ea096606');
"
@@ -633,6 +673,13 @@ exports[`DependencyExtractionWebpackPlugin scripts Webpack \`polyfill-magic-comm
exports[`DependencyExtractionWebpackPlugin scripts Webpack \`polyfill-magic-comment\` should produce expected output: External modules should match snapshot 1`] = `[]`;
+exports[`DependencyExtractionWebpackPlugin scripts Webpack \`polyfill-magic-comment-minified\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = `
+" array('wp-polyfill'), 'version' => '31d6cfe0d16ae931b73c');
+"
+`;
+
+exports[`DependencyExtractionWebpackPlugin scripts Webpack \`polyfill-magic-comment-minified\` should produce expected output: External modules should match snapshot 1`] = `[]`;
+
exports[`DependencyExtractionWebpackPlugin scripts Webpack \`runtime-chunk-single\` should produce expected output: Asset file 'a.asset.php' should match snapshot 1`] = `
" array('wp-blob'), 'version' => 'd3cda564b538b44d38ef');
"
diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-external-deps/a.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-external-deps/a.js
new file mode 100644
index 00000000000000..1f0edffe27efd3
--- /dev/null
+++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-external-deps/a.js
@@ -0,0 +1,8 @@
+/**
+ * Internal dependencies
+ */
+import { someFunction } from '.';
+
+someFunction();
+
+export const a = 'test';
diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-external-deps/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-external-deps/index.js
new file mode 100644
index 00000000000000..01d7eff466bfbd
--- /dev/null
+++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-external-deps/index.js
@@ -0,0 +1,18 @@
+/**
+ * Internal dependencies
+ */
+import { a } from './a';
+
+/**
+ * WordPress dependencies
+ */
+import { store } from '@wordpress/interactivity';
+
+export const someFunction = () => {
+ store( 'test', {
+ state: {
+ a,
+ },
+ } );
+ return a;
+};
diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-external-deps/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-external-deps/webpack.config.js
new file mode 100644
index 00000000000000..bfffff3ae78319
--- /dev/null
+++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-external-deps/webpack.config.js
@@ -0,0 +1,8 @@
+/**
+ * Internal dependencies
+ */
+const DependencyExtractionWebpackPlugin = require( '../../..' );
+
+module.exports = {
+ plugins: [ new DependencyExtractionWebpackPlugin() ],
+};
diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/polyfill-magic-comment-minified/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/polyfill-magic-comment-minified/index.js
new file mode 100644
index 00000000000000..d98678f44cb697
--- /dev/null
+++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/polyfill-magic-comment-minified/index.js
@@ -0,0 +1,3 @@
+/* wp:polyfill */
+
+// Nothing else, really.
diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/polyfill-magic-comment-minified/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/polyfill-magic-comment-minified/webpack.config.js
new file mode 100644
index 00000000000000..be01328d675d0a
--- /dev/null
+++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/polyfill-magic-comment-minified/webpack.config.js
@@ -0,0 +1,11 @@
+/**
+ * Internal dependencies
+ */
+const DependencyExtractionWebpackPlugin = require( '../../..' );
+
+module.exports = {
+ optimization: {
+ minimize: true,
+ },
+ plugins: [ new DependencyExtractionWebpackPlugin() ],
+};
diff --git a/packages/dom/src/dom/place-caret-at-edge.js b/packages/dom/src/dom/place-caret-at-edge.js
index 013a64d076e559..4075fc7c439586 100644
--- a/packages/dom/src/dom/place-caret-at-edge.js
+++ b/packages/dom/src/dom/place-caret-at-edge.js
@@ -67,14 +67,7 @@ export default function placeCaretAtEdge( container, isReverse, x ) {
return;
}
- const { ownerDocument } = container;
- const { defaultView } = ownerDocument;
- assertIsDefined( defaultView, 'defaultView' );
- const selection = defaultView.getSelection();
- assertIsDefined( selection, 'selection' );
-
if ( ! container.isContentEditable ) {
- selection.removeAllRanges();
return;
}
@@ -86,6 +79,11 @@ export default function placeCaretAtEdge( container, isReverse, x ) {
return;
}
+ const { ownerDocument } = container;
+ const { defaultView } = ownerDocument;
+ assertIsDefined( defaultView, 'defaultView' );
+ const selection = defaultView.getSelection();
+ assertIsDefined( selection, 'selection' );
selection.removeAllRanges();
selection.addRange( range );
}
diff --git a/packages/e2e-tests/plugins/block-bindings.php b/packages/e2e-tests/plugins/block-bindings.php
index 8951255d516bfc..ffbe50ab3cc902 100644
--- a/packages/e2e-tests/plugins/block-bindings.php
+++ b/packages/e2e-tests/plugins/block-bindings.php
@@ -8,66 +8,158 @@
*/
/**
-* Register custom fields and custom block bindings sources.
-*/
+ * Code necessary for testing block bindings:
+ * - Enqueues a custom script to register sources in the client.
+ * - Registers sources in the server.
+ * - Registers a custom post type and custom fields.
+ */
function gutenberg_test_block_bindings_registration() {
+ // Define fields list.
+ $upload_dir = wp_upload_dir();
+ $testing_url = $upload_dir['url'] . '/1024x768_e2e_test_image_size.jpeg';
+ $fields_list = array(
+ 'text_field' => array(
+ 'label' => 'Text Field Label',
+ 'value' => 'Text Field Value',
+ ),
+ 'url_field' => array(
+ 'label' => 'URL Field Label',
+ 'value' => $testing_url,
+ ),
+ 'empty_field' => array(
+ 'label' => 'Empty Field Label',
+ 'value' => '',
+ ),
+ );
+
+ // Enqueue a custom script for the plugin.
+ wp_enqueue_script(
+ 'gutenberg-test-block-bindings',
+ plugins_url( 'block-bindings/index.js', __FILE__ ),
+ array(
+ 'wp-blocks',
+ 'wp-private-apis',
+ ),
+ filemtime( plugin_dir_path( __FILE__ ) . 'block-bindings/index.js' ),
+ true
+ );
+
+ // Pass data to the script.
+ wp_localize_script(
+ 'gutenberg-test-block-bindings',
+ 'testingBindings',
+ array(
+ 'fieldsList' => $fields_list,
+ )
+ );
+
// Register custom block bindings sources.
register_block_bindings_source(
- 'core/server-source',
+ 'testing/complete-source',
+ array(
+ 'label' => 'Complete Source',
+ 'get_value_callback' => function ( $source_args ) use ( $fields_list ) {
+ if ( ! isset( $source_args['key'] ) || ! isset( $fields_list[ $source_args['key'] ] ) ) {
+ return null;
+ }
+ return $fields_list[ $source_args['key'] ]['value']; },
+ )
+ );
+ register_block_bindings_source(
+ 'testing/server-only-source',
array(
'label' => 'Server Source',
'get_value_callback' => function () {},
)
);
- // Register custom fields.
+ // Register "movie" custom post type.
+ register_post_type(
+ 'movie',
+ array(
+ 'label' => 'Movie',
+ 'public' => true,
+ 'supports' => array( 'title', 'editor', 'comments', 'revisions', 'trackbacks', 'author', 'excerpt', 'page-attributes', 'thumbnail', 'custom-fields', 'post-formats' ),
+ 'has_archive' => true,
+ 'show_in_rest' => true,
+ )
+ );
+
+ // Register global custom fields.
register_meta(
'post',
'text_custom_field',
array(
+ 'default' => 'Value of the text custom field',
'show_in_rest' => true,
- 'type' => 'string',
'single' => true,
- 'default' => 'Value of the text custom field',
+ 'type' => 'string',
)
);
register_meta(
'post',
'url_custom_field',
array(
+ 'default' => '#url-custom-field',
'show_in_rest' => true,
- 'type' => 'string',
'single' => true,
- 'default' => '#url-custom-field',
+ 'type' => 'string',
)
);
+ // Register CPT custom fields.
register_meta(
'post',
- 'empty_field',
+ 'movie_field',
array(
- 'show_in_rest' => true,
- 'type' => 'string',
- 'single' => true,
- 'default' => '',
+ 'label' => 'Movie field label',
+ 'default' => 'Movie field default value',
+ 'object_subtype' => 'movie',
+ 'show_in_rest' => true,
+ 'single' => true,
+ 'type' => 'string',
+ )
+ );
+ register_meta(
+ 'post',
+ 'field_with_only_label',
+ array(
+ 'label' => 'Field with only label',
+ 'object_subtype' => 'movie',
+ 'show_in_rest' => true,
+ 'single' => true,
+ 'type' => 'string',
+ )
+ );
+ register_meta(
+ 'post',
+ 'field_without_label_or_default',
+ array(
+ 'object_subtype' => 'movie',
+ 'show_in_rest' => true,
+ 'single' => true,
+ 'type' => 'string',
)
);
register_meta(
'post',
'_protected_field',
array(
- 'type' => 'string',
- 'single' => true,
- 'default' => 'protected field value',
+ 'default' => 'Protected field value',
+ 'object_subtype' => 'movie',
+ 'show_in_rest' => true,
+ 'single' => true,
+ 'type' => 'string',
)
);
register_meta(
'post',
'show_in_rest_false_field',
array(
- 'show_in_rest' => false,
- 'type' => 'string',
- 'single' => true,
- 'default' => 'show_in_rest false field value',
+ 'default' => 'show_in_rest false field value',
+ 'object_subtype' => 'movie',
+ 'show_in_rest' => false,
+ 'single' => true,
+ 'type' => 'string',
)
);
}
diff --git a/packages/e2e-tests/plugins/block-bindings/index.js b/packages/e2e-tests/plugins/block-bindings/index.js
new file mode 100644
index 00000000000000..c31502631307d0
--- /dev/null
+++ b/packages/e2e-tests/plugins/block-bindings/index.js
@@ -0,0 +1,49 @@
+const { registerBlockBindingsSource } = wp.blocks;
+const { fieldsList } = window.testingBindings || {};
+
+const getValues = ( { bindings } ) => {
+ const newValues = {};
+ for ( const [ attributeName, source ] of Object.entries( bindings ) ) {
+ newValues[ attributeName ] = fieldsList[ source.args.key ]?.value;
+ }
+ return newValues;
+};
+const setValues = ( { dispatch, bindings } ) => {
+ Object.values( bindings ).forEach( ( { args, newValue } ) => {
+ // Example of what could be done.
+ dispatch( 'core' ).editEntityRecord( 'postType', 'post', 1, {
+ meta: { [ args?.key ]: newValue },
+ } );
+ } );
+};
+
+registerBlockBindingsSource( {
+ name: 'testing/complete-source',
+ label: 'Complete Source',
+ getValues,
+ setValues,
+ canUserEditValue: () => true,
+ getFieldsList: () => fieldsList,
+} );
+
+registerBlockBindingsSource( {
+ name: 'testing/can-user-edit-false',
+ label: 'Can User Edit: False',
+ getValues,
+ setValues,
+ canUserEditValue: () => false,
+} );
+
+registerBlockBindingsSource( {
+ name: 'testing/can-user-edit-undefined',
+ label: 'Can User Edit: Undefined',
+ getValues,
+ setValues,
+} );
+
+registerBlockBindingsSource( {
+ name: 'testing/set-values-undefined',
+ label: 'Set Values: Undefined',
+ getValues,
+ canUserEditValue: () => true,
+} );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js
index 5a46908f77d87b..77f2f25c5f9a41 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js
@@ -41,13 +41,13 @@ directive(
'test-context',
( { context: { Provider }, props: { children } } ) => {
executionProof( 'context' );
- const value = {
+ const client = {
[ namespace ]: proxifyState( namespace, {
attribute: 'from context',
text: 'from context',
} ),
};
- return h( Provider, { value }, children );
+ return h( Provider, { value: { client } }, children );
},
{ priority: 8 }
);
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/block.json b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/block.json
new file mode 100644
index 00000000000000..c635846328b9e4
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/block.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 2,
+ "name": "test/get-server-context",
+ "title": "E2E Interactivity tests - getServerContext",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScriptModule": "file:./view.js",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/render.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/render.php
new file mode 100644
index 00000000000000..a71ced20dc46a1
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/render.php
@@ -0,0 +1,51 @@
+
+
+
+ modified
+ newProps
+
+
+
+>
+
+ >
+
+
+
+
+
+
+
+
'modify' ) ); ?>
+ data-wp-on--click="actions.attemptModification"
+ data-wp-text="context.result">
+ >
+ modify
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.asset.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.asset.php
new file mode 100644
index 00000000000000..bdaec8d1b67a9d
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.asset.php
@@ -0,0 +1,9 @@
+ array(
+ '@wordpress/interactivity',
+ array(
+ 'id' => '@wordpress/interactivity-router',
+ 'import' => 'dynamic',
+ ),
+ ),
+);
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js
new file mode 100644
index 00000000000000..83f016e2eac16a
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js
@@ -0,0 +1,46 @@
+/**
+ * WordPress dependencies
+ */
+import { store, getContext, getServerContext } from '@wordpress/interactivity';
+
+store( 'test/get-server-context', {
+ actions: {
+ *navigate( e ) {
+ e.preventDefault();
+ const { actions } = yield import(
+ '@wordpress/interactivity-router'
+ );
+ yield actions.navigate( e.target.href );
+ },
+ attemptModification() {
+ try {
+ getServerContext().prop = 'updated from client';
+ getContext().result = 'unexpectedly modified ā';
+ } catch ( e ) {
+ getContext().result = 'not modified ā
';
+ }
+ },
+ },
+ callbacks: {
+ updateServerContextParent() {
+ const ctx = getContext();
+ const { prop, newProp, nested, inherited } = getServerContext();
+ ctx.prop = prop;
+ ctx.newProp = newProp;
+ ctx.nested.prop = nested.prop;
+ ctx.nested.newProp = nested.newProp;
+ ctx.inherited.prop = inherited.prop;
+ ctx.inherited.newProp = inherited.newProp;
+ },
+ updateServerContextChild() {
+ const ctx = getContext();
+ const { prop, newProp, nested, inherited } = getServerContext();
+ ctx.prop = prop;
+ ctx.newProp = newProp;
+ ctx.nested.prop = nested.prop;
+ ctx.nested.newProp = nested.newProp;
+ ctx.inherited.prop = inherited.prop;
+ ctx.inherited.newProp = inherited.newProp;
+ },
+ },
+} );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/block.json b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/block.json
new file mode 100644
index 00000000000000..abf76eb9beddcc
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/block.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 2,
+ "name": "test/get-server-state",
+ "title": "E2E Interactivity tests - getServerState",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScriptModule": "file:./view.js",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php
new file mode 100644
index 00000000000000..abc4efd8272d5b
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
'modify' ) ); ?>
+ data-wp-on--click="actions.attemptModification"
+ data-wp-text="context.result">
+ >
+ modify
+
+
+
+
+ $link ) {
+ $i = $key += 1;
+ echo <<link $i
+HTML;
+ }
+ }
+ ?>
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.asset.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.asset.php
new file mode 100644
index 00000000000000..bdaec8d1b67a9d
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.asset.php
@@ -0,0 +1,9 @@
+ array(
+ '@wordpress/interactivity',
+ array(
+ 'id' => '@wordpress/interactivity-router',
+ 'import' => 'dynamic',
+ ),
+ ),
+);
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js
new file mode 100644
index 00000000000000..db2992ec4a5863
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js
@@ -0,0 +1,33 @@
+/**
+ * WordPress dependencies
+ */
+import { store, getServerState, getContext } from '@wordpress/interactivity';
+
+const { state } = store( 'test/get-server-state', {
+ actions: {
+ *navigate( e ) {
+ e.preventDefault();
+ const { actions } = yield import(
+ '@wordpress/interactivity-router'
+ );
+ yield actions.navigate( e.target.href );
+ },
+ attemptModification() {
+ try {
+ getServerState().prop = 'updated from client';
+ getContext().result = 'unexpectedly modified ā';
+ } catch ( e ) {
+ getContext().result = 'not modified ā
';
+ }
+ },
+ },
+ callbacks: {
+ updateState() {
+ const { prop, newProp, nested } = getServerState();
+ state.prop = prop;
+ state.newProp = newProp;
+ state.nested.prop = nested.prop;
+ state.nested.newProp = nested.newProp;
+ },
+ },
+} );
diff --git a/packages/edit-post/src/components/back-button/fullscreen-mode-close.js b/packages/edit-post/src/components/back-button/fullscreen-mode-close.js
index 626212cbab0542..ffb64a8ba07035 100644
--- a/packages/edit-post/src/components/back-button/fullscreen-mode-close.js
+++ b/packages/edit-post/src/components/back-button/fullscreen-mode-close.js
@@ -91,8 +91,7 @@ function FullscreenModeClose( { showTooltip, icon, href, initialPost } ) {
return (
{
- if ( isNewPost && postType === 'wp_block' ) {
- setIsModalOpen( true );
- }
- // We only want the modal to open when the page is first loaded.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [] );
+ const [ isModalOpen, setIsModalOpen ] = useState( () =>
+ isNewPost && postType === 'wp_block' ? true : false
+ );
if ( postType !== 'wp_block' || ! isNewPost ) {
return null;
@@ -88,8 +82,7 @@ export default function InitPatternModal() {
/>
-
+
+ showMetaBoxes &&
}
extraContent={
! isDistractionFree &&
diff --git a/packages/edit-post/src/components/preferences-modal/enable-custom-fields.js b/packages/edit-post/src/components/preferences-modal/enable-custom-fields.js
index c32e337be58c0e..b8125e96c7c2cf 100644
--- a/packages/edit-post/src/components/preferences-modal/enable-custom-fields.js
+++ b/packages/edit-post/src/components/preferences-modal/enable-custom-fields.js
@@ -39,9 +39,7 @@ export function CustomFieldsConfirmation( { willEnable } ) {
) }
Show & Reload Page
@@ -300,7 +300,7 @@ exports[`EnableCustomFieldsOption renders an unchecked checkbox and a confirmati
A page reload is required for this change. Make sure your content is saved before reloading.
Hide & Reload Page
diff --git a/packages/edit-post/src/store/actions.js b/packages/edit-post/src/store/actions.js
index d00f7472382f80..1dc0401baf21c3 100644
--- a/packages/edit-post/src/store/actions.js
+++ b/packages/edit-post/src/store/actions.js
@@ -8,7 +8,7 @@ import {
privateApis as editorPrivateApis,
} from '@wordpress/editor';
import deprecated from '@wordpress/deprecated';
-import { addFilter } from '@wordpress/hooks';
+import { addAction } from '@wordpress/hooks';
import { store as coreStore } from '@wordpress/core-data';
/**
@@ -478,21 +478,14 @@ export const initializeMetaBoxes =
metaBoxesInitialized = true;
// Save metaboxes on save completion, except for autosaves.
- addFilter(
- 'editor.__unstableSavePost',
+ addAction(
+ 'editor.savePost',
'core/edit-post/save-metaboxes',
- ( previous, options ) =>
- previous.then( () => {
- if ( options.isAutosave ) {
- return;
- }
-
- if ( ! select.hasMetaBoxes() ) {
- return;
- }
-
- return dispatch.requestMetaBoxUpdates();
- } )
+ async ( options ) => {
+ if ( ! options.isAutosave && select.hasMetaBoxes() ) {
+ await dispatch.requestMetaBoxUpdates();
+ }
+ }
);
dispatch( {
diff --git a/packages/edit-post/src/store/selectors.js b/packages/edit-post/src/store/selectors.js
index 5bea6e7d35eb62..8d85249e8100ba 100644
--- a/packages/edit-post/src/store/selectors.js
+++ b/packages/edit-post/src/store/selectors.js
@@ -506,7 +506,7 @@ export const __experimentalGetInsertionPoint = createRegistrySelector(
version: '6.7',
}
);
- return unlock( select( editorStore ) ).getInsertionPoint();
+ return unlock( select( editorStore ) ).getInserter();
}
);
diff --git a/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js b/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js
index 4993f12153b9e4..69f1925c7b0e44 100644
--- a/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js
+++ b/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js
@@ -36,8 +36,7 @@ function SuggestionListItem( {
diff --git a/packages/edit-site/src/components/add-new-template/index.js b/packages/edit-site/src/components/add-new-template/index.js
index 933ea6df1198bb..bb0acbe62cd659 100644
--- a/packages/edit-site/src/components/add-new-template/index.js
+++ b/packages/edit-site/src/components/add-new-template/index.js
@@ -107,8 +107,7 @@ function TemplateListItem( {
} ) {
return (
{ shouldShowCloseButton && (
-
+
)
diff --git a/packages/edit-site/src/components/editor/style.scss b/packages/edit-site/src/components/editor/style.scss
index 10efff92af6434..a6cc5084966947 100644
--- a/packages/edit-site/src/components/editor/style.scss
+++ b/packages/edit-site/src/components/editor/style.scss
@@ -69,6 +69,10 @@
background-color: hsla(0, 0%, 80%);
pointer-events: none;
+ svg {
+ fill: currentColor;
+ }
+
&.has-site-icon {
background-color: hsla(0, 0%, 100%, 0.6);
-webkit-backdrop-filter: saturate(180%) blur(15px);
diff --git a/packages/edit-site/src/components/error-boundary/warning.js b/packages/edit-site/src/components/error-boundary/warning.js
index b03c99f46f03b1..c4090c7e6b1190 100644
--- a/packages/edit-site/src/components/error-boundary/warning.js
+++ b/packages/edit-site/src/components/error-boundary/warning.js
@@ -9,12 +9,7 @@ import { useCopyToClipboard } from '@wordpress/compose';
function CopyButton( { text, children } ) {
const ref = useCopyToClipboard( text );
return (
-
+
{ children }
);
diff --git a/packages/edit-site/src/components/global-styles-sidebar/index.js b/packages/edit-site/src/components/global-styles-sidebar/index.js
index b314b5d7e75244..966005907cda4a 100644
--- a/packages/edit-site/src/components/global-styles-sidebar/index.js
+++ b/packages/edit-site/src/components/global-styles-sidebar/index.js
@@ -6,7 +6,7 @@ import {
FlexBlock,
Flex,
Button,
- __experimentalUseNavigator as useNavigator,
+ useNavigator,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { styles, seen, backup } from '@wordpress/icons';
diff --git a/packages/edit-site/src/components/global-styles-sidebar/style.scss b/packages/edit-site/src/components/global-styles-sidebar/style.scss
index b76192ddfcb5ca..4ca87bf200f178 100644
--- a/packages/edit-site/src/components/global-styles-sidebar/style.scss
+++ b/packages/edit-site/src/components/global-styles-sidebar/style.scss
@@ -22,14 +22,7 @@
flex-direction: column;
min-height: 100%;
- &__panel,
- &__navigator-provider {
- display: flex;
- flex-direction: column;
- flex: 1;
- }
-
- &__navigator-screen {
+ &__panel {
flex: 1;
}
}
diff --git a/packages/edit-site/src/components/global-styles/font-families.js b/packages/edit-site/src/components/global-styles/font-families.js
index 6a554b136317dd..5332478823c210 100644
--- a/packages/edit-site/src/components/global-styles/font-families.js
+++ b/packages/edit-site/src/components/global-styles/font-families.js
@@ -1,7 +1,7 @@
/**
* WordPress dependencies
*/
-import { __, _x } from '@wordpress/i18n';
+import { __ } from '@wordpress/i18n';
import {
__experimentalText as Text,
__experimentalItemGroup as ItemGroup,
@@ -61,14 +61,9 @@ function FontFamilies() {
) }
- { themeFonts.length > 0 && (
-
-
- {
- /* translators: Heading for a list of fonts provided by the theme. */
- _x( 'Theme', 'font source' )
- }
-
+ { [ ...themeFonts, ...customFonts ].length > 0 && (
+ <>
+ { __( 'Fonts' ) }
{ themeFonts.map( ( font ) => (
) ) }
-
- ) }
- { customFonts.length > 0 && (
-
-
- {
- /* translators: Heading for a list of fonts installed by the user. */
- _x( 'Custom', 'font source' )
- }
-
-
- { customFonts.map( ( font ) => (
-
- ) ) }
-
-
+ >
) }
{ ! hasFonts && (
diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/font-card.js b/packages/edit-site/src/components/global-styles/font-library-modal/font-card.js
index 579c6564fdf3e7..61f8c28c77144f 100644
--- a/packages/edit-site/src/components/global-styles/font-library-modal/font-card.js
+++ b/packages/edit-site/src/components/global-styles/font-library-modal/font-card.js
@@ -3,7 +3,7 @@
*/
import { _n, sprintf, isRTL } from '@wordpress/i18n';
import {
- __experimentalUseNavigator as useNavigator,
+ useNavigator,
__experimentalText as Text,
Button,
Flex,
diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js
index ce7b4c1766c64f..caf339091de752 100644
--- a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js
+++ b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js
@@ -13,9 +13,7 @@ import {
__experimentalText as Text,
__experimentalHStack as HStack,
__experimentalVStack as VStack,
- __experimentalNavigatorProvider as NavigatorProvider,
- __experimentalNavigatorScreen as NavigatorScreen,
- __experimentalNavigatorBackButton as NavigatorBackButton,
+ Navigator,
__experimentalHeading as Heading,
Notice,
SelectControl,
@@ -284,11 +282,11 @@ function FontCollection( { slug } ) {
{ ! isLoading && (
<>
-
-
+
@@ -378,11 +376,11 @@ function FontCollection( { slug } ) {
{ /* eslint-enable jsx-a11y/no-redundant-roles */ }
-
+
-
+
-
-
-
+
+
{ selectedFont && (
-
+
{ tabs.map( ( { id, title } ) => (
diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js b/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js
index 8cb023fff08e96..b7666a66afe0b3 100644
--- a/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js
+++ b/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js
@@ -6,10 +6,8 @@ import {
__experimentalConfirmDialog as ConfirmDialog,
__experimentalHStack as HStack,
__experimentalHeading as Heading,
- __experimentalNavigatorProvider as NavigatorProvider,
- __experimentalNavigatorScreen as NavigatorScreen,
- __experimentalNavigatorBackButton as NavigatorBackButton,
- __experimentalUseNavigator as useNavigator,
+ Navigator,
+ useNavigator,
__experimentalSpacer as Spacer,
__experimentalText as Text,
__experimentalVStack as VStack,
@@ -235,12 +233,12 @@ function InstalledFonts() {
{ ! isResolvingLibrary && (
<>
-
-
+
{ notice && (
) }
-
+
-
+
-
{ /* eslint-enable jsx-a11y/no-redundant-roles */ }
-
-
+
+
size.slug === slug );
+ // Navigate to the font sizes list if the font size is not available.
+ useEffect( () => {
+ if ( ! fontSize ) {
+ goTo( '/typography/font-sizes/', { isBack: true } );
+ }
+ }, [ fontSize, goTo ] );
+
+ if ( ! origin || ! slug || ! fontSize ) {
+ return null;
+ }
+
// Whether the font size is fluid. If not defined, use the global fluid value of the theme.
const isFluid =
- fontSize.fluid !== undefined ? !! fontSize.fluid : !! globalFluid;
+ fontSize?.fluid !== undefined ? !! fontSize.fluid : !! globalFluid;
// Whether custom fluid values are used.
- const isCustomFluid = typeof fontSize.fluid === 'object';
+ const isCustomFluid = typeof fontSize?.fluid === 'object';
const handleNameChange = ( value ) => {
updateFontSize( 'name', value );
@@ -107,9 +117,6 @@ function FontSize() {
};
const handleRemoveFontSize = () => {
- // Navigate to the font sizes list.
- goBack();
-
const newFontSizes = sizes.filter( ( size ) => size.slug !== slug );
setFontSizes( {
...fontSizes,
diff --git a/packages/edit-site/src/components/global-styles/header.js b/packages/edit-site/src/components/global-styles/header.js
index 1d9de84183aef7..1bbff8fb3d6a6d 100644
--- a/packages/edit-site/src/components/global-styles/header.js
+++ b/packages/edit-site/src/components/global-styles/header.js
@@ -7,7 +7,7 @@ import {
__experimentalSpacer as Spacer,
__experimentalHeading as Heading,
__experimentalView as View,
- __experimentalNavigatorBackButton as NavigatorBackButton,
+ Navigator,
} from '@wordpress/components';
import { isRTL, __ } from '@wordpress/i18n';
import { chevronRight, chevronLeft } from '@wordpress/icons';
@@ -18,7 +18,7 @@ function ScreenHeader( { title, description, onBack } ) {
- ;
+ return ;
}
function NavigationBackButtonAsItem( props ) {
- return ;
+ return ;
}
export { NavigationButtonAsItem, NavigationBackButtonAsItem };
diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/index.js b/packages/edit-site/src/components/global-styles/screen-revisions/index.js
index de27e92113b554..b980d199e7be30 100644
--- a/packages/edit-site/src/components/global-styles/screen-revisions/index.js
+++ b/packages/edit-site/src/components/global-styles/screen-revisions/index.js
@@ -3,7 +3,7 @@
*/
import { __, sprintf } from '@wordpress/i18n';
import {
- __experimentalUseNavigator as useNavigator,
+ useNavigator,
__experimentalConfirmDialog as ConfirmDialog,
Spinner,
} from '@wordpress/components';
@@ -72,7 +72,6 @@ function ScreenRevisions() {
);
const onCloseRevisions = () => {
- goTo( '/' ); // Return to global styles main panel.
const canvasContainerView =
editorCanvasContainerView === 'global-styles-revisions:style-book'
? 'style-book'
diff --git a/packages/edit-site/src/components/global-styles/screen-typeset.js b/packages/edit-site/src/components/global-styles/screen-typeset.js
deleted file mode 100644
index ce754121dfe1b5..00000000000000
--- a/packages/edit-site/src/components/global-styles/screen-typeset.js
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { __ } from '@wordpress/i18n';
-import { useSelect } from '@wordpress/data';
-import { store as editorStore } from '@wordpress/editor';
-import { __experimentalVStack as VStack } from '@wordpress/components';
-
-/**
- * Internal dependencies
- */
-import TypographyVariations from './variations/variations-typography';
-import ScreenHeader from './header';
-import FontFamilies from './font-families';
-
-function ScreenTypeset() {
- const fontLibraryEnabled = useSelect(
- ( select ) =>
- select( editorStore ).getEditorSettings().fontLibraryEnabled,
- []
- );
-
- return (
- <>
-
-
-
-
-
- { fontLibraryEnabled && }
-
-
- >
- );
-}
-
-export default ScreenTypeset;
diff --git a/packages/edit-site/src/components/global-styles/screen-typography.js b/packages/edit-site/src/components/global-styles/screen-typography.js
index c23592c51a6a2a..3739e3234258bd 100644
--- a/packages/edit-site/src/components/global-styles/screen-typography.js
+++ b/packages/edit-site/src/components/global-styles/screen-typography.js
@@ -11,8 +11,8 @@ import { store as editorStore } from '@wordpress/editor';
*/
import TypographyElements from './typography-elements';
import ScreenHeader from './header';
+import TypographyVariations from './variations/variations-typography';
import FontSizesCount from './font-sizes/font-sizes-count';
-import TypesetButton from './typeset-button';
import FontFamilies from './font-families';
function ScreenTypography() {
@@ -27,12 +27,12 @@ function ScreenTypography() {
-
+
{ fontLibraryEnabled && }
diff --git a/packages/edit-site/src/components/global-styles/shadows-edit-panel.js b/packages/edit-site/src/components/global-styles/shadows-edit-panel.js
index ec1dd1a900c3bf..61bed62cff3d64 100644
--- a/packages/edit-site/src/components/global-styles/shadows-edit-panel.js
+++ b/packages/edit-site/src/components/global-styles/shadows-edit-panel.js
@@ -15,7 +15,7 @@ import {
__experimentalUnitControl as UnitControl,
__experimentalGrid as Grid,
__experimentalDropdownContentWrapper as DropdownContentWrapper,
- __experimentalUseNavigator as useNavigator,
+ useNavigator,
__experimentalToggleGroupControl as ToggleGroupControl,
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
__experimentalConfirmDialog as ConfirmDialog,
@@ -96,6 +96,10 @@ export default function ShadowsEditPanel() {
const [ isRenameModalVisible, setIsRenameModalVisible ] = useState( false );
const [ shadowName, setShadowName ] = useState( selectedShadow.name );
+ if ( ! category || ! slug ) {
+ return null;
+ }
+
const onShadowChange = ( shadow ) => {
setSelectedShadow( { ...selectedShadow, shadow } );
const updatedShadows = shadows.map( ( s ) =>
diff --git a/packages/edit-site/src/components/global-styles/typeset-button.js b/packages/edit-site/src/components/global-styles/typeset-button.js
deleted file mode 100644
index bcd450def06f8e..00000000000000
--- a/packages/edit-site/src/components/global-styles/typeset-button.js
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { isRTL, __ } from '@wordpress/i18n';
-import {
- __experimentalItemGroup as ItemGroup,
- __experimentalVStack as VStack,
- __experimentalHStack as HStack,
- FlexItem,
-} from '@wordpress/components';
-import { store as coreStore } from '@wordpress/core-data';
-import { useSelect } from '@wordpress/data';
-import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor';
-import { privateApis as editorPrivateApis } from '@wordpress/editor';
-import { useMemo, useContext } from '@wordpress/element';
-import { Icon, chevronLeft, chevronRight } from '@wordpress/icons';
-
-/**
- * Internal dependencies
- */
-import FontLibraryProvider from './font-library-modal/context';
-import { getFontFamilies } from './utils';
-import { NavigationButtonAsItem } from './navigation-button';
-import Subtitle from './subtitle';
-import { unlock } from '../../lock-unlock';
-import {
- filterObjectByProperties,
- useCurrentMergeThemeStyleVariationsWithUserConfig,
-} from '../../hooks/use-theme-style-variations/use-theme-style-variations-by-property';
-
-const { GlobalStylesContext } = unlock( blockEditorPrivateApis );
-const { mergeBaseAndUserConfigs } = unlock( editorPrivateApis );
-
-function TypesetButton() {
- const propertiesToFilter = [ 'typography' ];
- const typographyVariations =
- useCurrentMergeThemeStyleVariationsWithUserConfig( propertiesToFilter );
- const hasTypographyVariations = typographyVariations?.length > 1;
- const { base, user: userConfig } = useContext( GlobalStylesContext );
- const config = mergeBaseAndUserConfigs( base, userConfig );
- const allFontFamilies = getFontFamilies( config );
- const hasFonts =
- allFontFamilies.filter( ( font ) => font !== null ).length > 0;
- const variations = useSelect( ( select ) => {
- return select(
- coreStore
- ).__experimentalGetCurrentThemeGlobalStylesVariations();
- }, [] );
- const userTypographyConfig = filterObjectByProperties(
- userConfig,
- 'typography'
- );
-
- const title = useMemo( () => {
- if ( Object.keys( userTypographyConfig ).length === 0 ) {
- return __( 'Default' );
- }
- const activeVariation = variations?.find( ( variation ) => {
- return (
- JSON.stringify(
- filterObjectByProperties( variation, 'typography' )
- ) === JSON.stringify( userTypographyConfig )
- );
- } );
- if ( activeVariation ) {
- return activeVariation.title;
- }
- return allFontFamilies.map( ( font ) => font?.name ).join( ', ' );
- }, [ allFontFamilies, userTypographyConfig, variations ] );
-
- return (
- hasTypographyVariations &&
- hasFonts && (
-
-
- { __( 'Typeset' ) }
-
-
-
-
- { title }
-
-
-
-
-
- )
- );
-}
-
-export default ( { ...props } ) => (
-
-
-
-);
diff --git a/packages/edit-site/src/components/global-styles/ui.js b/packages/edit-site/src/components/global-styles/ui.js
index 60d7e314d7776a..fbc3e461e6abb6 100644
--- a/packages/edit-site/src/components/global-styles/ui.js
+++ b/packages/edit-site/src/components/global-styles/ui.js
@@ -2,9 +2,8 @@
* WordPress dependencies
*/
import {
- __experimentalNavigatorProvider as NavigatorProvider,
- __experimentalNavigatorScreen as NavigatorScreen,
- __experimentalUseNavigator as useNavigator,
+ Navigator,
+ useNavigator,
createSlotFill,
DropdownMenu,
MenuGroup,
@@ -32,7 +31,6 @@ import {
} from './screen-block-list';
import ScreenBlock from './screen-block';
import ScreenTypography from './screen-typography';
-import ScreenTypeset from './screen-typeset';
import ScreenTypographyElement from './screen-typography-element';
import FontSize from './font-sizes/font-size';
import FontSizes from './font-sizes/font-sizes';
@@ -125,7 +123,7 @@ function GlobalStylesActionMenu() {
function GlobalStylesNavigationScreen( { className, ...props } ) {
return (
-
@@ -325,10 +310,6 @@ function GlobalStylesUI() {
-
-
-
-
@@ -403,7 +384,7 @@ function GlobalStylesUI() {
-
+
);
}
export { GlobalStylesMenuSlot };
diff --git a/packages/edit-site/src/components/layout/style.scss b/packages/edit-site/src/components/layout/style.scss
index b2d929a7943dbf..27a3f7b99bf7e2 100644
--- a/packages/edit-site/src/components/layout/style.scss
+++ b/packages/edit-site/src/components/layout/style.scss
@@ -114,14 +114,18 @@
width: calc(100% - #{$canvas-padding});
.edit-site-resizable-frame__inner-content {
- box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.8), 0 8px 10px -6px rgba(0, 0, 0, 0.8);
- transition: border-radius 0.4s;
+ box-shadow: $elevation-x-small;
+ transition: border-radius, box-shadow 0.4s;
// This ensure the radius work properly.
overflow: hidden;
.edit-site-layout:not(.is-full-canvas) & {
border-radius: $radius-large;
}
+
+ &:hover {
+ box-shadow: $elevation-large;
+ }
}
}
@@ -246,6 +250,7 @@ html.canvas-mode-edit-transition::view-transition-group(toggle) {
flex-grow: 1;
margin: 0;
overflow: hidden;
+ box-shadow: $elevation-x-small;
@include break-medium() {
border-radius: 8px;
margin: $canvas-padding $canvas-padding $canvas-padding 0;
diff --git a/packages/edit-site/src/components/page-patterns/fields.js b/packages/edit-site/src/components/page-patterns/fields.js
index ff9c0dbe81a047..88de0c1fa39b01 100644
--- a/packages/edit-site/src/components/page-patterns/fields.js
+++ b/packages/edit-site/src/components/page-patterns/fields.js
@@ -133,8 +133,7 @@ function TitleField( { item } ) {
title
) : (
setShouldShowHandle( true ) }
onMouseOut={ () => setShouldShowHandle( false ) }
handleComponent={ {
- left: canvasMode === 'view' && (
+ [ isRTL() ? 'right' : 'left' ]: canvasMode === 'view' && (
<>
{ /* Disable reason: role="separator" does in fact support aria-valuenow */ }
diff --git a/packages/edit-site/src/components/routes/link.js b/packages/edit-site/src/components/routes/link.js
index 4423eeeb1d6e8d..a34b37943a0799 100644
--- a/packages/edit-site/src/components/routes/link.js
+++ b/packages/edit-site/src/components/routes/link.js
@@ -33,14 +33,17 @@ export function useLink( params, state, shouldReplace = false ) {
...Object.keys( currentArgs )
);
+ let extraParams = {};
if ( isPreviewingTheme() ) {
- params = {
- ...params,
+ extraParams = {
wp_theme_preview: currentlyPreviewingTheme(),
};
}
- const newUrl = addQueryArgs( currentUrlWithoutArgs, params );
+ const newUrl = addQueryArgs( currentUrlWithoutArgs, {
+ ...params,
+ ...extraParams,
+ } );
return {
href: newUrl,
diff --git a/packages/edit-site/src/components/save-panel/index.js b/packages/edit-site/src/components/save-panel/index.js
index babcf72181a1af..9b00a39fd78948 100644
--- a/packages/edit-site/src/components/save-panel/index.js
+++ b/packages/edit-site/src/components/save-panel/index.js
@@ -150,8 +150,7 @@ export default function SavePanel() {
} ) }
>
setIsSaveViewOpened( true ) }
diff --git a/packages/edit-site/src/components/sidebar-button/index.js b/packages/edit-site/src/components/sidebar-button/index.js
index d7030597dac503..64c6efb891f462 100644
--- a/packages/edit-site/src/components/sidebar-button/index.js
+++ b/packages/edit-site/src/components/sidebar-button/index.js
@@ -11,8 +11,7 @@ import { Button } from '@wordpress/components';
export default function SidebarButton( props ) {
return (
diff --git a/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js b/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js
index 3e369db9b2a821..62956ccd18960d 100644
--- a/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js
+++ b/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js
@@ -88,8 +88,7 @@ function AddNewItemModalContent( { type, setIsAdding } ) {
/>
{
setIsAdding( false );
@@ -99,8 +98,7 @@ function AddNewItemModalContent( { type, setIsAdding } ) {
{ title }
diff --git a/packages/edit-site/src/components/sidebar-dataviews/default-views.js b/packages/edit-site/src/components/sidebar-dataviews/default-views.js
index 20f61e451b21fa..658fa319e9c667 100644
--- a/packages/edit-site/src/components/sidebar-dataviews/default-views.js
+++ b/packages/edit-site/src/components/sidebar-dataviews/default-views.js
@@ -13,7 +13,7 @@ import {
notAllowed,
} from '@wordpress/icons';
import { useSelect } from '@wordpress/data';
-import { store as coreStore, useEntityRecords } from '@wordpress/core-data';
+import { store as coreStore } from '@wordpress/core-data';
import { useMemo } from '@wordpress/element';
/**
@@ -68,50 +68,6 @@ const DEFAULT_POST_BASE = {
layout: defaultLayouts[ LAYOUT_LIST ].layout,
};
-export function useDefaultViewsWithItemCounts( { postType } ) {
- const defaultViews = useDefaultViews( { postType } );
- const { records, totalItems } = useEntityRecords( 'postType', postType, {
- per_page: -1,
- status: [ 'any', 'trash' ],
- } );
-
- return useMemo( () => {
- if ( ! defaultViews ) {
- return [];
- }
-
- // If there are no records, return the default views with no counts.
- if ( ! records ) {
- return defaultViews;
- }
-
- const counts = {
- drafts: records.filter( ( record ) => record.status === 'draft' )
- .length,
- future: records.filter( ( record ) => record.status === 'future' )
- .length,
- pending: records.filter( ( record ) => record.status === 'pending' )
- .length,
- private: records.filter( ( record ) => record.status === 'private' )
- .length,
- published: records.filter(
- ( record ) => record.status === 'publish'
- ).length,
- trash: records.filter( ( record ) => record.status === 'trash' )
- .length,
- };
-
- // All items excluding trashed items as per the default "all" status query.
- counts.all = totalItems ? totalItems - counts.trash : 0;
-
- // Filter out views with > 0 item counts.
- return defaultViews.map( ( _view ) => {
- _view.count = counts[ _view.slug ];
- return _view;
- } );
- }, [ defaultViews, records, totalItems ] );
-}
-
export function useDefaultViews( { postType } ) {
const labels = useSelect(
( select ) => {
diff --git a/packages/edit-site/src/components/sidebar-dataviews/index.js b/packages/edit-site/src/components/sidebar-dataviews/index.js
index 3f7f5b965fce71..86420c4eec1d1f 100644
--- a/packages/edit-site/src/components/sidebar-dataviews/index.js
+++ b/packages/edit-site/src/components/sidebar-dataviews/index.js
@@ -7,7 +7,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router';
/**
* Internal dependencies
*/
-import { useDefaultViewsWithItemCounts } from './default-views';
+import { useDefaultViews } from './default-views';
import { unlock } from '../../lock-unlock';
import DataViewItem from './dataview-item';
import CustomDataViewsList from './custom-dataviews-list';
@@ -18,9 +18,7 @@ export default function DataViewsSidebarContent() {
const {
params: { postType, activeView = 'all', isCustom = 'false' },
} = useLocation();
-
- const defaultViews = useDefaultViewsWithItemCounts( { postType } );
-
+ const defaultViews = useDefaultViews( { postType } );
if ( ! postType ) {
return null;
}
@@ -36,9 +34,6 @@ export default function DataViewsSidebarContent() {
slug={ dataview.slug }
title={ dataview.title }
icon={ dataview.icon }
- navigationItemSuffix={
- { dataview.count }
- }
type={ dataview.view.type }
isActive={
! isCustomBoolean &&
diff --git a/packages/edit-site/src/components/sidebar-dataviews/style.scss b/packages/edit-site/src/components/sidebar-dataviews/style.scss
index 3473c8e20e1a45..14e6bf1d03fca8 100644
--- a/packages/edit-site/src/components/sidebar-dataviews/style.scss
+++ b/packages/edit-site/src/components/sidebar-dataviews/style.scss
@@ -15,10 +15,6 @@
min-width: initial;
}
- .edit-site-sidebar-navigation-item.with-suffix {
- padding-right: $grid-unit-10;
- }
-
&:hover,
&:focus,
&[aria-current] {
diff --git a/packages/edit-site/src/components/site-hub/index.js b/packages/edit-site/src/components/site-hub/index.js
index 7fe929d20a1db2..8cb3502493cff0 100644
--- a/packages/edit-site/src/components/site-hub/index.js
+++ b/packages/edit-site/src/components/site-hub/index.js
@@ -62,8 +62,7 @@ const SiteHub = memo(
) }
>
openCommandCenter() }
@@ -149,8 +146,7 @@ export const SiteHubMobile = memo(
) }
>
openCommandCenter() }
diff --git a/packages/edit-site/src/components/style-book/categories.ts b/packages/edit-site/src/components/style-book/categories.ts
new file mode 100644
index 00000000000000..2c1b627c6d0c60
--- /dev/null
+++ b/packages/edit-site/src/components/style-book/categories.ts
@@ -0,0 +1,91 @@
+/**
+ * WordPress dependencies
+ */
+import { getCategories } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import type {
+ BlockExample,
+ StyleBookCategory,
+ CategoryExamples,
+} from './types';
+import {
+ STYLE_BOOK_CATEGORIES,
+ STYLE_BOOK_THEME_SUBCATEGORIES,
+} from './constants';
+
+/**
+ * Returns category examples for a given category definition and list of examples.
+ * @param {StyleBookCategory} categoryDefinition The category definition.
+ * @param {BlockExample[]} examples An array of block examples.
+ * @return {CategoryExamples|undefined} An object containing the category examples.
+ */
+export function getExamplesByCategory(
+ categoryDefinition: StyleBookCategory,
+ examples: BlockExample[]
+): CategoryExamples | undefined {
+ if ( ! categoryDefinition?.slug || ! examples?.length ) {
+ return;
+ }
+
+ if ( categoryDefinition?.subcategories?.length ) {
+ return categoryDefinition.subcategories.reduce(
+ ( acc, subcategoryDefinition ) => {
+ const subcategoryExamples = getExamplesByCategory(
+ subcategoryDefinition,
+ examples
+ );
+ if ( subcategoryExamples ) {
+ acc.subcategories = [
+ ...acc.subcategories,
+ subcategoryExamples,
+ ];
+ }
+ return acc;
+ },
+ {
+ title: categoryDefinition.title,
+ slug: categoryDefinition.slug,
+ subcategories: [],
+ }
+ );
+ }
+
+ const blocksToInclude = categoryDefinition?.blocks || [];
+ const blocksToExclude = categoryDefinition?.exclude || [];
+ const categoryExamples = examples.filter( ( example ) => {
+ return (
+ ! blocksToExclude.includes( example.name ) &&
+ ( example.category === categoryDefinition.slug ||
+ blocksToInclude.includes( example.name ) )
+ );
+ } );
+
+ if ( ! categoryExamples.length ) {
+ return;
+ }
+
+ return {
+ title: categoryDefinition.title,
+ slug: categoryDefinition.slug,
+ examples: categoryExamples,
+ };
+}
+
+/**
+ * Returns category examples for a given category definition and list of examples.
+ *
+ * @return {StyleBookCategory[]} An array of top-level category definitions.
+ */
+export function getTopLevelStyleBookCategories(): StyleBookCategory[] {
+ const reservedCategories = [
+ ...STYLE_BOOK_THEME_SUBCATEGORIES,
+ ...STYLE_BOOK_CATEGORIES,
+ ].map( ( { slug } ) => slug );
+ const extraCategories = getCategories().filter(
+ ( { slug } ) => ! reservedCategories.includes( slug )
+ );
+ return [ ...STYLE_BOOK_CATEGORIES, ...extraCategories ];
+}
diff --git a/packages/edit-site/src/components/style-book/constants.ts b/packages/edit-site/src/components/style-book/constants.ts
new file mode 100644
index 00000000000000..fc06d8f1409f0d
--- /dev/null
+++ b/packages/edit-site/src/components/style-book/constants.ts
@@ -0,0 +1,191 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { StyleBookCategory } from './types';
+
+export const STYLE_BOOK_THEME_SUBCATEGORIES: Omit<
+ StyleBookCategory,
+ 'subcategories'
+>[] = [
+ {
+ slug: 'site-identity',
+ title: __( 'Site Identity' ),
+ blocks: [ 'core/site-logo', 'core/site-title', 'core/site-tagline' ],
+ },
+ {
+ slug: 'design',
+ title: __( 'Design' ),
+ blocks: [ 'core/navigation', 'core/avatar', 'core/post-time-to-read' ],
+ exclude: [ 'core/home-link', 'core/navigation-link' ],
+ },
+ {
+ slug: 'posts',
+ title: __( 'Posts' ),
+ blocks: [
+ 'core/post-title',
+ 'core/post-excerpt',
+ 'core/post-author',
+ 'core/post-author-name',
+ 'core/post-author-biography',
+ 'core/post-date',
+ 'core/post-terms',
+ 'core/term-description',
+ 'core/query-title',
+ 'core/query-no-results',
+ 'core/query-pagination',
+ 'core/query-numbers',
+ ],
+ },
+ {
+ slug: 'comments',
+ title: __( 'Comments' ),
+ blocks: [
+ 'core/comments-title',
+ 'core/comments-pagination',
+ 'core/comments-pagination-numbers',
+ 'core/comments',
+ 'core/comments-author-name',
+ 'core/comment-content',
+ 'core/comment-date',
+ 'core/comment-edit-link',
+ 'core/comment-reply-link',
+ 'core/comment-template',
+ 'core/post-comments-count',
+ 'core/post-comments-link',
+ ],
+ },
+];
+
+export const STYLE_BOOK_CATEGORIES: StyleBookCategory[] = [
+ {
+ slug: 'text',
+ title: __( 'Text' ),
+ blocks: [
+ 'core/post-content',
+ 'core/home-link',
+ 'core/navigation-link',
+ ],
+ },
+ {
+ slug: 'colors',
+ title: __( 'Colors' ),
+ blocks: [ 'custom/colors' ],
+ },
+ {
+ slug: 'theme',
+ title: __( 'Theme' ),
+ subcategories: STYLE_BOOK_THEME_SUBCATEGORIES,
+ },
+ {
+ slug: 'media',
+ title: __( 'Media' ),
+ blocks: [ 'core/post-featured-image' ],
+ },
+ {
+ slug: 'widgets',
+ title: __( 'Widgets' ),
+ blocks: [],
+ },
+ {
+ slug: 'embed',
+ title: __( 'Embeds' ),
+ include: [],
+ },
+];
+
+// The content area of the Style Book is rendered within an iframe so that global styles
+// are applied to elements within the entire content area. To support elements that are
+// not part of the block previews, such as headings and layout for the block previews,
+// additional CSS rules need to be passed into the iframe. These are hard-coded below.
+// Note that button styles are unset, and then focus rules from the `Button` component are
+// applied to the `button` element, targeted via `.edit-site-style-book__example`.
+// This is to ensure that browser default styles for buttons are not applied to the previews.
+export const STYLE_BOOK_IFRAME_STYLES = `
+ // Forming a "block formatting context" to prevent margin collapsing.
+ // @see https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Block_formatting_context
+ .is-root-container {
+ display: flow-root;
+ }
+
+ body {
+ position: relative;
+ padding: 32px !important;
+ }
+
+ .edit-site-style-book__examples {
+ max-width: 1200px;
+ margin: 0 auto;
+ }
+
+ .edit-site-style-book__example {
+ max-width: 900px;
+ border-radius: 2px;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+ padding: 16px;
+ width: 100%;
+ box-sizing: border-box;
+ scroll-margin-top: 32px;
+ scroll-margin-bottom: 32px;
+ margin: 0 auto 40px auto;
+ }
+
+ .edit-site-style-book__example.is-selected {
+ box-shadow: 0 0 0 1px var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba));
+ }
+
+ .edit-site-style-book__example:focus:not(:disabled) {
+ box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba));
+ outline: 3px solid transparent;
+ }
+
+ .edit-site-style-book__examples.is-wide .edit-site-style-book__example {
+ flex-direction: row;
+ }
+
+ .edit-site-style-book__subcategory-title,
+ .edit-site-style-book__example-title {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+ font-size: 11px;
+ font-weight: 500;
+ line-height: normal;
+ margin: 0;
+ text-align: left;
+ text-transform: uppercase;
+ }
+
+ .edit-site-style-book__subcategory-title {
+ font-size: 16px;
+ margin-bottom: 40px;
+ border-bottom: 1px solid #ddd;
+ padding-bottom: 8px;
+ }
+
+ .edit-site-style-book__examples.is-wide .edit-site-style-book__example-title {
+ text-align: right;
+ width: 120px;
+ }
+
+ .edit-site-style-book__example-preview {
+ width: 100%;
+ }
+
+ .edit-site-style-book__example-preview .block-editor-block-list__insertion-point,
+ .edit-site-style-book__example-preview .block-list-appender {
+ display: none;
+ }
+
+ .edit-site-style-book__example-preview .is-root-container > .wp-block:first-child {
+ margin-top: 0;
+ }
+ .edit-site-style-book__example-preview .is-root-container > .wp-block:last-child {
+ margin-bottom: 0;
+ }
+`;
diff --git a/packages/edit-site/src/components/style-book/examples.ts b/packages/edit-site/src/components/style-book/examples.ts
new file mode 100644
index 00000000000000..80807b10374c68
--- /dev/null
+++ b/packages/edit-site/src/components/style-book/examples.ts
@@ -0,0 +1,63 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import {
+ getBlockType,
+ getBlockTypes,
+ getBlockFromExample,
+ createBlock,
+} from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import type { BlockExample } from './types';
+
+/**
+ * Returns a list of examples for registered block types.
+ *
+ * @return {BlockExample[]} An array of block examples.
+ */
+export function getExamples(): BlockExample[] {
+ const nonHeadingBlockExamples = getBlockTypes()
+ .filter( ( blockType ) => {
+ const { name, example, supports } = blockType;
+ return (
+ name !== 'core/heading' &&
+ !! example &&
+ supports.inserter !== false
+ );
+ } )
+ .map( ( blockType ) => ( {
+ name: blockType.name,
+ title: blockType.title,
+ category: blockType.category,
+ blocks: getBlockFromExample( blockType.name, blockType.example ),
+ } ) );
+ const isHeadingBlockRegistered = !! getBlockType( 'core/heading' );
+
+ if ( ! isHeadingBlockRegistered ) {
+ return nonHeadingBlockExamples;
+ }
+
+ // Use our own example for the Heading block so that we can show multiple
+ // heading levels.
+ const headingsExample = {
+ name: 'core/heading',
+ title: __( 'Headings' ),
+ category: 'text',
+ blocks: [ 1, 2, 3, 4, 5, 6 ].map( ( level ) => {
+ return createBlock( 'core/heading', {
+ content: sprintf(
+ // translators: %d: heading level e.g: "1", "2", "3"
+ __( 'Heading %d' ),
+ level
+ ),
+ level,
+ } );
+ } ),
+ };
+
+ return [ headingsExample, ...nonHeadingBlockExamples ];
+}
diff --git a/packages/edit-site/src/components/style-book/index.js b/packages/edit-site/src/components/style-book/index.js
index 64503dcf7a6dbb..7b85c320e20c99 100644
--- a/packages/edit-site/src/components/style-book/index.js
+++ b/packages/edit-site/src/components/style-book/index.js
@@ -12,13 +12,6 @@ import {
privateApis as componentsPrivateApis,
} from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
-import {
- getCategories,
- getBlockType,
- getBlockTypes,
- getBlockFromExample,
- createBlock,
-} from '@wordpress/blocks';
import {
BlockList,
privateApis as blockEditorPrivateApis,
@@ -37,6 +30,12 @@ import { ENTER, SPACE } from '@wordpress/keycodes';
*/
import { unlock } from '../../lock-unlock';
import EditorCanvasContainer from '../editor-canvas-container';
+import { STYLE_BOOK_IFRAME_STYLES } from './constants';
+import {
+ getExamplesByCategory,
+ getTopLevelStyleBookCategories,
+} from './categories';
+import { getExamples } from './examples';
const {
ExperimentalBlockEditorProvider,
@@ -48,126 +47,10 @@ const { mergeBaseAndUserConfigs } = unlock( editorPrivateApis );
const { Tabs } = unlock( componentsPrivateApis );
-// The content area of the Style Book is rendered within an iframe so that global styles
-// are applied to elements within the entire content area. To support elements that are
-// not part of the block previews, such as headings and layout for the block previews,
-// additional CSS rules need to be passed into the iframe. These are hard-coded below.
-// Note that button styles are unset, and then focus rules from the `Button` component are
-// applied to the `button` element, targeted via `.edit-site-style-book__example`.
-// This is to ensure that browser default styles for buttons are not applied to the previews.
-const STYLE_BOOK_IFRAME_STYLES = `
- .edit-site-style-book__examples {
- max-width: 900px;
- margin: 0 auto;
- }
-
- .edit-site-style-book__example {
- border-radius: 2px;
- cursor: pointer;
- display: flex;
- flex-direction: column;
- gap: 40px;
- margin-bottom: 40px;
- padding: 16px;
- width: 100%;
- box-sizing: border-box;
- scroll-margin-top: 32px;
- scroll-margin-bottom: 32px;
- }
-
- .edit-site-style-book__example.is-selected {
- box-shadow: 0 0 0 1px var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba));
- }
-
- .edit-site-style-book__example:focus:not(:disabled) {
- box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba));
- outline: 3px solid transparent;
- }
-
- .edit-site-style-book__examples.is-wide .edit-site-style-book__example {
- flex-direction: row;
- }
-
- .edit-site-style-book__example-title {
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
- font-size: 11px;
- font-weight: 500;
- line-height: normal;
- margin: 0;
- text-align: left;
- text-transform: uppercase;
- }
-
- .edit-site-style-book__examples.is-wide .edit-site-style-book__example-title {
- text-align: right;
- width: 120px;
- }
-
- .edit-site-style-book__example-preview {
- width: 100%;
- }
-
- .edit-site-style-book__example-preview .block-editor-block-list__insertion-point,
- .edit-site-style-book__example-preview .block-list-appender {
- display: none;
- }
-
- .edit-site-style-book__example-preview .is-root-container > .wp-block:first-child {
- margin-top: 0;
- }
- .edit-site-style-book__example-preview .is-root-container > .wp-block:last-child {
- margin-bottom: 0;
- }
-`;
-
function isObjectEmpty( object ) {
return ! object || Object.keys( object ).length === 0;
}
-function getExamples() {
- const nonHeadingBlockExamples = getBlockTypes()
- .filter( ( blockType ) => {
- const { name, example, supports } = blockType;
- return (
- name !== 'core/heading' &&
- !! example &&
- supports.inserter !== false
- );
- } )
- .map( ( blockType ) => ( {
- name: blockType.name,
- title: blockType.title,
- category: blockType.category,
- blocks: getBlockFromExample( blockType.name, blockType.example ),
- } ) );
-
- const isHeadingBlockRegistered = !! getBlockType( 'core/heading' );
-
- if ( ! isHeadingBlockRegistered ) {
- return nonHeadingBlockExamples;
- }
-
- // Use our own example for the Heading block so that we can show multiple
- // heading levels.
- const headingsExample = {
- name: 'core/heading',
- title: __( 'Headings' ),
- category: 'text',
- blocks: [ 1, 2, 3, 4, 5, 6 ].map( ( level ) => {
- return createBlock( 'core/heading', {
- content: sprintf(
- // translators: %d: heading level e.g: "1", "2", "3"
- __( 'Heading %d' ),
- level
- ),
- level,
- } );
- } ),
- };
-
- return [ headingsExample, ...nonHeadingBlockExamples ];
-}
-
function StyleBook( {
enableResizing = true,
isSelected,
@@ -184,17 +67,11 @@ function StyleBook( {
const [ examples ] = useState( getExamples );
const tabs = useMemo(
() =>
- getCategories()
- .filter( ( category ) =>
- examples.some(
- ( example ) => example.category === category.slug
- )
+ getTopLevelStyleBookCategories().filter( ( category ) =>
+ examples.some(
+ ( example ) => example.category === category.slug
)
- .map( ( category ) => ( {
- name: category.slug,
- title: category.title,
- icon: category.icon,
- } ) ),
+ ),
[ examples ]
);
const { base: baseConfig } = useContext( GlobalStylesContext );
@@ -245,24 +122,26 @@ function StyleBook( {
{ showTabs ? (
-
- { tabs.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
+
+
+ { tabs.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+
{ tabs.map( ( tab ) => (
{
+ const categoryDefinition = category
+ ? getTopLevelStyleBookCategories().find(
+ ( _category ) => _category.slug === category
+ )
+ : null;
+
+ const filteredExamples = categoryDefinition
+ ? getExamplesByCategory( categoryDefinition, examples )
+ : { examples };
+
return (
- { examples
- .filter( ( example ) =>
- category ? example.category === category : true
- )
- .map( ( example ) => (
+ { !! filteredExamples?.examples?.length &&
+ filteredExamples.examples.map( ( example ) => (
) ) }
+ { !! filteredExamples?.subcategories?.length &&
+ filteredExamples.subcategories.map( ( subcategory ) => (
+
+
+
+ { subcategory.title }
+
+
+
+
+ ) ) }
);
}
);
+const Subcategory = ( { examples, isSelected, onSelect } ) => {
+ return (
+ !! examples?.length &&
+ examples.map( ( example ) => (
+ {
+ onSelect?.( example.name );
+ } }
+ />
+ ) )
+ );
+};
+
const Example = ( { id, title, blocks, isSelected, onClick } ) => {
const originalSettings = useSelect(
( select ) => select( blockEditorStore ).getSettings(),
diff --git a/packages/edit-site/src/components/style-book/style.scss b/packages/edit-site/src/components/style-book/style.scss
index ab66ec288da310..3e0c6466438a22 100644
--- a/packages/edit-site/src/components/style-book/style.scss
+++ b/packages/edit-site/src/components/style-book/style.scss
@@ -17,12 +17,16 @@
}
}
-.edit-site-style-book__tabs {
- [role="tablist"] {
- background: $white;
- color: $gray-900;
- }
+.edit-site-style-book__tablist-container {
+ background: $white;
+ width: 100%;
+ padding-right: 56px;
+ display: flex;
+ position: absolute;
+ z-index: 1;
+}
+.edit-site-style-book__tabs {
[role="tabpanel"] {
bottom: 0;
left: 0;
diff --git a/packages/edit-site/src/components/style-book/test/categories.js b/packages/edit-site/src/components/style-book/test/categories.js
new file mode 100644
index 00000000000000..5629689e260f89
--- /dev/null
+++ b/packages/edit-site/src/components/style-book/test/categories.js
@@ -0,0 +1,171 @@
+/**
+ * Internal dependencies
+ */
+import {
+ getExamplesByCategory,
+ getTopLevelStyleBookCategories,
+} from '../categories';
+import { STYLE_BOOK_CATEGORIES } from '../constants';
+
+jest.mock( '@wordpress/blocks', () => {
+ return {
+ getCategories() {
+ return [
+ {
+ slug: 'text',
+ title: 'Text Registered',
+ icon: 'text',
+ },
+ {
+ slug: 'design',
+ title: 'Design Registered',
+ icon: 'design',
+ },
+ {
+ slug: 'funky',
+ title: 'Funky',
+ icon: 'funky',
+ },
+ ];
+ },
+ };
+} );
+
+// Fixtures
+const exampleThemeBlocks = [
+ {
+ name: 'core/post-content',
+ title: 'Post Content',
+ category: 'theme',
+ },
+ {
+ name: 'core/post-terms',
+ title: 'Post Terms',
+ category: 'theme',
+ },
+ {
+ name: 'core/home-link',
+ title: 'Home Link',
+ category: 'design',
+ },
+ {
+ name: 'custom/colors',
+ title: 'Colors',
+ category: 'colors',
+ },
+ {
+ name: 'core/site-logo',
+ title: 'Site Logo',
+ category: 'theme',
+ },
+ {
+ name: 'core/site-title',
+ title: 'Site Title',
+ category: 'theme',
+ },
+ {
+ name: 'core/site-tagline',
+ title: 'Site Tagline',
+ category: 'theme',
+ },
+ {
+ name: 'core/group',
+ title: 'Group',
+ category: 'design',
+ },
+ {
+ name: 'core/comments-pagination-numbers',
+ title: 'Comments Page Numbers',
+ category: 'theme',
+ },
+ {
+ name: 'core/post-featured-image',
+ title: 'Featured Image',
+ category: 'theme',
+ },
+];
+
+describe( 'utils', () => {
+ describe( 'getTopLevelStyleBookCategories', () => {
+ it( 'returns theme subcategories examples', () => {
+ expect( getTopLevelStyleBookCategories() ).toEqual( [
+ ...STYLE_BOOK_CATEGORIES,
+ {
+ slug: 'funky',
+ title: 'Funky',
+ icon: 'funky',
+ },
+ ] );
+ } );
+ } );
+
+ describe( 'getExamplesByCategory', () => {
+ it( 'returns theme subcategories examples', () => {
+ const themeCategory = STYLE_BOOK_CATEGORIES.find(
+ ( category ) => category.slug === 'theme'
+ );
+ const themeCategoryExamples = getExamplesByCategory(
+ themeCategory,
+ exampleThemeBlocks
+ );
+
+ expect( themeCategoryExamples.slug ).toEqual( 'theme' );
+
+ const siteIdentity = themeCategoryExamples.subcategories.find(
+ ( subcategory ) => subcategory.slug === 'site-identity'
+ );
+ expect( siteIdentity ).toEqual( {
+ title: 'Site Identity',
+ slug: 'site-identity',
+ examples: [
+ {
+ name: 'core/site-logo',
+ title: 'Site Logo',
+ category: 'theme',
+ },
+ {
+ name: 'core/site-title',
+ title: 'Site Title',
+ category: 'theme',
+ },
+ {
+ name: 'core/site-tagline',
+ title: 'Site Tagline',
+ category: 'theme',
+ },
+ ],
+ } );
+
+ const design = themeCategoryExamples.subcategories.find(
+ ( subcategory ) => subcategory.slug === 'design'
+ );
+ expect( design ).toEqual( {
+ title: 'Design',
+ slug: 'design',
+ examples: [
+ {
+ name: 'core/group',
+ title: 'Group',
+ category: 'design',
+ },
+ ],
+ } );
+
+ const posts = themeCategoryExamples.subcategories.find(
+ ( subcategory ) => subcategory.slug === 'posts'
+ );
+
+ expect( posts ).toEqual( {
+ title: 'Posts',
+ slug: 'posts',
+ examples: [
+ {
+ name: 'core/post-terms',
+ title: 'Post Terms',
+ category: 'theme',
+ },
+ ],
+ } );
+ } );
+ } );
+} );
diff --git a/packages/edit-site/src/components/style-book/types.ts b/packages/edit-site/src/components/style-book/types.ts
new file mode 100644
index 00000000000000..4729b38b1b2bb1
--- /dev/null
+++ b/packages/edit-site/src/components/style-book/types.ts
@@ -0,0 +1,27 @@
+type Block = {
+ name: string;
+ attributes: Record< string, unknown >;
+ innerBlocks?: Block[];
+};
+
+export type StyleBookCategory = {
+ title: string;
+ slug: string;
+ blocks?: string[];
+ exclude?: string[];
+ subcategories?: StyleBookCategory[];
+};
+
+export type BlockExample = {
+ name: string;
+ title: string;
+ category: string;
+ blocks: Block | Block[];
+};
+
+export type CategoryExamples = {
+ title: string;
+ slug: string;
+ examples?: BlockExample[];
+ subcategories?: CategoryExamples[];
+};
diff --git a/packages/edit-site/src/store/selectors.js b/packages/edit-site/src/store/selectors.js
index 3f7bbea0be7d25..ddef4a71d0a915 100644
--- a/packages/edit-site/src/store/selectors.js
+++ b/packages/edit-site/src/store/selectors.js
@@ -213,7 +213,7 @@ export const __experimentalGetInsertionPoint = createRegistrySelector(
version: '6.7',
}
);
- return unlock( select( editorStore ) ).getInsertionPoint();
+ return unlock( select( editorStore ) ).getInserter();
}
);
diff --git a/packages/edit-widgets/src/components/header/style.scss b/packages/edit-widgets/src/components/header/style.scss
index 6e5d8de8142f42..7bd3c41a6a22ad 100644
--- a/packages/edit-widgets/src/components/header/style.scss
+++ b/packages/edit-widgets/src/components/header/style.scss
@@ -82,6 +82,7 @@
padding-right: $grid-unit-10;
padding-left: $grid-unit-20;
overflow: hidden;
+ height: $header-height;
}
.edit-widgets-header__title {
diff --git a/packages/edit-widgets/src/components/sidebar/index.js b/packages/edit-widgets/src/components/sidebar/index.js
index f08ee81a0d19cc..79a47b18c7bf12 100644
--- a/packages/edit-widgets/src/components/sidebar/index.js
+++ b/packages/edit-widgets/src/components/sidebar/index.js
@@ -28,7 +28,7 @@ const SIDEBAR_ACTIVE_BY_DEFAULT = Platform.select( {
const BLOCK_INSPECTOR_IDENTIFIER = 'edit-widgets/block-inspector';
-// Widget areas were one called block areas, so use 'edit-widgets/block-areas'
+// Widget areas were once called block areas, so use 'edit-widgets/block-areas'
// for backwards compatibility.
const WIDGET_AREAS_IDENTIFIER = 'edit-widgets/block-areas';
@@ -192,10 +192,10 @@ export default function Sidebar() {
const { enableComplementaryArea } = useDispatch( interfaceStore );
- // `newSelectedTabId` could technically be falsey if no tab is selected (i.e.
+ // `newSelectedTabId` could technically be falsy if no tab is selected (i.e.
// the initial render) or when we don't want a tab displayed (i.e. the
// sidebar is closed). These cases should both be covered by the `!!` check
- // below, so we shouldn't need any additional falsey handling.
+ // below, so we shouldn't need any additional falsy handling.
const onTabSelect = useCallback(
( newSelectedTabId ) => {
if ( !! newSelectedTabId ) {
diff --git a/packages/editor/src/bindings/api.js b/packages/editor/src/bindings/api.js
index 2cfed5168a143e..84003fab7eaf7b 100644
--- a/packages/editor/src/bindings/api.js
+++ b/packages/editor/src/bindings/api.js
@@ -2,8 +2,8 @@
* WordPress dependencies
*/
import {
- privateApis as blocksPrivateApis,
store as blocksStore,
+ registerBlockBindingsSource,
} from '@wordpress/blocks';
import { dispatch } from '@wordpress/data';
@@ -25,7 +25,6 @@ import { unlock } from '../lock-unlock';
* ```
*/
export function registerCoreBlockBindingsSources() {
- const { registerBlockBindingsSource } = unlock( blocksPrivateApis );
registerBlockBindingsSource( patternOverrides );
registerBlockBindingsSource( postMeta );
}
diff --git a/packages/editor/src/bindings/pattern-overrides.js b/packages/editor/src/bindings/pattern-overrides.js
index 88c6c73bdc61c1..baa1f72f47694b 100644
--- a/packages/editor/src/bindings/pattern-overrides.js
+++ b/packages/editor/src/bindings/pattern-overrides.js
@@ -7,9 +7,9 @@ const CONTENT = 'content';
export default {
name: 'core/pattern-overrides',
- getValues( { registry, clientId, context, bindings } ) {
+ getValues( { select, clientId, context, bindings } ) {
const patternOverridesContent = context[ 'pattern/overrides' ];
- const { getBlockAttributes } = registry.select( blockEditorStore );
+ const { getBlockAttributes } = select( blockEditorStore );
const currentBlockAttributes = getBlockAttributes( clientId );
const overridesValues = {};
@@ -32,9 +32,9 @@ export default {
}
return overridesValues;
},
- setValues( { registry, clientId, bindings } ) {
+ setValues( { select, dispatch, clientId, bindings } ) {
const { getBlockAttributes, getBlockParentsByBlockName, getBlocks } =
- registry.select( blockEditorStore );
+ select( blockEditorStore );
const currentBlockAttributes = getBlockAttributes( clientId );
const blockName = currentBlockAttributes?.metadata?.name;
if ( ! blockName ) {
@@ -61,12 +61,10 @@ export default {
const syncBlocksWithSameName = ( blocks ) => {
for ( const block of blocks ) {
if ( block.attributes?.metadata?.name === blockName ) {
- registry
- .dispatch( blockEditorStore )
- .updateBlockAttributes(
- block.clientId,
- attributes
- );
+ dispatch( blockEditorStore ).updateBlockAttributes(
+ block.clientId,
+ attributes
+ );
}
syncBlocksWithSameName( block.innerBlocks );
}
@@ -77,27 +75,26 @@ export default {
}
const currentBindingValue =
getBlockAttributes( patternClientId )?.[ CONTENT ];
- registry
- .dispatch( blockEditorStore )
- .updateBlockAttributes( patternClientId, {
- [ CONTENT ]: {
- ...currentBindingValue,
- [ blockName ]: {
- ...currentBindingValue?.[ blockName ],
- ...Object.entries( attributes ).reduce(
- ( acc, [ key, value ] ) => {
- // TODO: We need a way to represent `undefined` in the serialized overrides.
- // Also see: https://github.com/WordPress/gutenberg/pull/57249#discussion_r1452987871
- // We use an empty string to represent undefined for now until
- // we support a richer format for overrides and the block bindings API.
- acc[ key ] = value === undefined ? '' : value;
- return acc;
- },
- {}
- ),
- },
+
+ dispatch( blockEditorStore ).updateBlockAttributes( patternClientId, {
+ [ CONTENT ]: {
+ ...currentBindingValue,
+ [ blockName ]: {
+ ...currentBindingValue?.[ blockName ],
+ ...Object.entries( attributes ).reduce(
+ ( acc, [ key, value ] ) => {
+ // TODO: We need a way to represent `undefined` in the serialized overrides.
+ // Also see: https://github.com/WordPress/gutenberg/pull/57249#discussion_r1452987871
+ // We use an empty string to represent undefined for now until
+ // we support a richer format for overrides and the block bindings API.
+ acc[ key ] = value === undefined ? '' : value;
+ return acc;
+ },
+ {}
+ ),
},
- } );
+ },
+ } );
},
canUserEditValue: () => true,
};
diff --git a/packages/editor/src/bindings/post-meta.js b/packages/editor/src/bindings/post-meta.js
index 572cd0b525a003..9198ac0fe41e1e 100644
--- a/packages/editor/src/bindings/post-meta.js
+++ b/packages/editor/src/bindings/post-meta.js
@@ -9,26 +9,63 @@ import { store as coreDataStore } from '@wordpress/core-data';
import { store as editorStore } from '../store';
import { unlock } from '../lock-unlock';
-function getMetadata( registry, context, registeredFields ) {
- let metaFields = {};
- const type = registry.select( editorStore ).getCurrentPostType();
- const { getEditedEntityRecord } = registry.select( coreDataStore );
+/**
+ * Gets a list of post meta fields with their values and labels
+ * to be consumed in the needed callbacks.
+ * If the value is not available based on context, like in templates,
+ * it falls back to the default value, label, or key.
+ *
+ * @param {Object} select The select function from the data store.
+ * @param {Object} context The context provided.
+ * @return {Object} List of post meta fields with their value and label.
+ *
+ * @example
+ * ```js
+ * {
+ * field_1_key: {
+ * label: 'Field 1 Label',
+ * value: 'Field 1 Value',
+ * },
+ * field_2_key: {
+ * label: 'Field 2 Label',
+ * value: 'Field 2 Value',
+ * },
+ * ...
+ * }
+ * ```
+ */
+function getPostMetaFields( select, context ) {
+ const { getEditedEntityRecord } = select( coreDataStore );
+ const { getRegisteredPostMeta } = unlock( select( coreDataStore ) );
+ let entityMetaValues;
+ // Try to get the current entity meta values.
if ( context?.postType && context?.postId ) {
- metaFields = getEditedEntityRecord(
+ entityMetaValues = getEditedEntityRecord(
'postType',
context?.postType,
context?.postId
).meta;
- } else if ( type === 'wp_template' ) {
- // Populate the `metaFields` object with the default values.
- Object.entries( registeredFields || {} ).forEach(
- ( [ key, props ] ) => {
- if ( props.default ) {
- metaFields[ key ] = props.default;
- }
- }
- );
+ }
+
+ const registeredFields = getRegisteredPostMeta( context?.postType );
+ const metaFields = {};
+ Object.entries( registeredFields || {} ).forEach( ( [ key, props ] ) => {
+ // Don't include footnotes or private fields.
+ if ( key !== 'footnotes' && key.charAt( 0 ) !== '_' ) {
+ metaFields[ key ] = {
+ label: props.title || key,
+ value:
+ // When using the entity value, an empty string IS a valid value.
+ entityMetaValues?.[ key ] ??
+ // When using the default, an empty string IS NOT a valid value.
+ ( props.default || undefined ),
+ };
+ }
+ } );
+
+ if ( ! Object.keys( metaFields || {} ).length ) {
+ return null;
}
return metaFields;
@@ -36,34 +73,33 @@ function getMetadata( registry, context, registeredFields ) {
export default {
name: 'core/post-meta',
- getValues( { registry, context, bindings } ) {
- const { getRegisteredPostMeta } = unlock(
- registry.select( coreDataStore )
- );
- const registeredFields = getRegisteredPostMeta( context?.postType );
- const metaFields = getMetadata( registry, context, registeredFields );
+ getValues( { select, context, bindings } ) {
+ const metaFields = getPostMetaFields( select, context );
const newValues = {};
for ( const [ attributeName, source ] of Object.entries( bindings ) ) {
// Use the value, the field label, or the field key.
- const metaKey = source.args.key;
- newValues[ attributeName ] =
- metaFields?.[ metaKey ] ??
- registeredFields?.[ metaKey ]?.title ??
- metaKey;
+ const fieldKey = source.args.key;
+ const { value: fieldValue, label: fieldLabel } =
+ metaFields?.[ fieldKey ] || {};
+ newValues[ attributeName ] = fieldValue ?? fieldLabel ?? fieldKey;
}
return newValues;
},
- setValues( { registry, context, bindings } ) {
+ setValues( { dispatch, context, bindings } ) {
const newMeta = {};
Object.values( bindings ).forEach( ( { args, newValue } ) => {
newMeta[ args.key ] = newValue;
} );
- registry
- .dispatch( coreDataStore )
- .editEntityRecord( 'postType', context?.postType, context?.postId, {
+
+ dispatch( coreDataStore ).editEntityRecord(
+ 'postType',
+ context?.postType,
+ context?.postId,
+ {
meta: newMeta,
- } );
+ }
+ );
},
canUserEditValue( { select, context, args } ) {
// Lock editing in query loop.
@@ -79,14 +115,9 @@ export default {
return false;
}
- // Check that the custom field is not protected and available in the REST API.
+ const fieldValue = getPostMetaFields( select, context )?.[ args.key ]
+ ?.value;
// Empty string or `false` could be a valid value, so we need to check if the field value is undefined.
- const fieldValue = select( coreDataStore ).getEntityRecord(
- 'postType',
- postType,
- context?.postId
- )?.meta?.[ args.key ];
-
if ( fieldValue === undefined ) {
return false;
}
@@ -109,32 +140,7 @@ export default {
return true;
},
- getFieldsList( { registry, context } ) {
- const { getRegisteredPostMeta } = unlock(
- registry.select( coreDataStore )
- );
- const registeredFields = getRegisteredPostMeta( context?.postType );
- const metaFields = getMetadata( registry, context, registeredFields );
-
- if ( ! metaFields || ! Object.keys( metaFields ).length ) {
- return null;
- }
-
- return Object.fromEntries(
- Object.entries( metaFields )
- // Remove footnotes or private keys from the list of fields.
- .filter(
- ( [ key ] ) =>
- key !== 'footnotes' && key.charAt( 0 ) !== '_'
- )
- // Return object with label and value.
- .map( ( [ key, value ] ) => [
- key,
- {
- label: registeredFields?.[ key ]?.title || key,
- value,
- },
- ] )
- );
+ getFieldsList( { select, context } ) {
+ return getPostMetaFields( select, context );
},
};
diff --git a/packages/editor/src/components/block-settings-menu/content-only-settings-menu.js b/packages/editor/src/components/block-settings-menu/content-only-settings-menu.js
index fcf7adfa77635c..af0e9b30ae83b4 100644
--- a/packages/editor/src/components/block-settings-menu/content-only-settings-menu.js
+++ b/packages/editor/src/components/block-settings-menu/content-only-settings-menu.js
@@ -17,21 +17,20 @@ import { __, _x } from '@wordpress/i18n';
*/
import { store as editorStore } from '../../store';
import { unlock } from '../../lock-unlock';
+import usePostContentBlocks from '../provider/use-post-content-blocks';
function ContentOnlySettingsMenuItems( { clientId, onClose } ) {
+ const postContentBlocks = usePostContentBlocks();
const { entity, onNavigateToEntityRecord, canEditTemplates } = useSelect(
( select ) => {
const {
- getBlockEditingMode,
getBlockParentsByBlockName,
getSettings,
getBlockAttributes,
+ getBlockParents,
} = select( blockEditorStore );
- const contentOnly =
- getBlockEditingMode( clientId ) === 'contentOnly';
- if ( ! contentOnly ) {
- return {};
- }
+ const { getCurrentTemplateId, getRenderingMode } =
+ select( editorStore );
const patternParent = getBlockParentsByBlockName(
clientId,
'core/block',
@@ -45,19 +44,20 @@ function ContentOnlySettingsMenuItems( { clientId, onClose } ) {
'wp_block',
getBlockAttributes( patternParent ).ref
);
- } else {
- const { getCurrentTemplateId } = select( editorStore );
- const templateId = getCurrentTemplateId();
- const { getContentLockingParent } = unlock(
- select( blockEditorStore )
+ } else if (
+ getRenderingMode() === 'template-locked' &&
+ ! getBlockParents( clientId ).some( ( parent ) =>
+ postContentBlocks.includes( parent )
+ )
+ ) {
+ record = select( coreStore ).getEntityRecord(
+ 'postType',
+ 'wp_template',
+ getCurrentTemplateId()
);
- if ( ! getContentLockingParent( clientId ) && templateId ) {
- record = select( coreStore ).getEntityRecord(
- 'postType',
- 'wp_template',
- templateId
- );
- }
+ }
+ if ( ! record ) {
+ return {};
}
const _canEditTemplates = select( coreStore ).canUser( 'create', {
kind: 'postType',
@@ -70,7 +70,7 @@ function ContentOnlySettingsMenuItems( { clientId, onClose } ) {
getSettings().onNavigateToEntityRecord,
};
},
- [ clientId ]
+ [ clientId, postContentBlocks ]
);
if ( ! entity ) {
diff --git a/packages/editor/src/components/document-tools/index.js b/packages/editor/src/components/document-tools/index.js
index 54121652bbf131..6a8c20c8d70551 100644
--- a/packages/editor/src/components/document-tools/index.js
+++ b/packages/editor/src/components/document-tools/index.js
@@ -38,10 +38,8 @@ function DocumentTools( { className, disableBlockTools = false } ) {
listViewShortcut,
inserterSidebarToggleRef,
listViewToggleRef,
- hasFixedToolbar,
showIconLabels,
} = useSelect( ( select ) => {
- const { getSettings } = select( blockEditorStore );
const { get } = select( preferencesStore );
const {
isListViewOpened,
@@ -60,7 +58,6 @@ function DocumentTools( { className, disableBlockTools = false } ) {
),
inserterSidebarToggleRef: getInserterSidebarToggleRef(),
listViewToggleRef: getListViewToggleRef(),
- hasFixedToolbar: getSettings().hasFixedToolbar,
showIconLabels: get( 'core', 'showIconLabels' ),
isDistractionFree: get( 'core', 'distractionFree' ),
isVisualMode: getEditorMode() === 'visual',
@@ -137,7 +134,7 @@ function DocumentTools( { className, disableBlockTools = false } ) {
) }
{ ( isWideViewport || ! showIconLabels ) && (
<>
- { isLargeViewport && ! hasFixedToolbar && (
+ { isLargeViewport && (
{
const { get } = select( preferencesStore );
const { getEditorSettings, getPostTypeLabel } = select( editorStore );
const editorSettings = getEditorSettings();
const postTypeLabel = getPostTypeLabel();
+ const { isZoomOut: _isZoomOut } = unlock( select( blockEditorStore ) );
return {
mode: select( editorStore ).getEditorMode(),
@@ -94,8 +97,7 @@ export default function EditorInterface( {
showBlockBreadcrumbs: get( 'core', 'showBlockBreadcrumbs' ),
// translators: Default label for the Document in the Block Breadcrumb.
documentLabel: postTypeLabel || _x( 'Document', 'noun' ),
- blockEditorMode:
- select( blockEditorStore ).__unstableGetEditorMode(),
+ isZoomOut: _isZoomOut(),
};
}, [] );
const isLargeViewport = useViewportMatch( 'medium' );
@@ -206,7 +208,7 @@ export default function EditorInterface( {
isLargeViewport &&
showBlockBreadcrumbs &&
isRichEditingEnabled &&
- blockEditorMode !== 'zoom-out' &&
+ ! isZoomOut &&
mode === 'visual' && (
)
diff --git a/packages/editor/src/components/entities-saved-states/index.js b/packages/editor/src/components/entities-saved-states/index.js
index 8c0d59573a44d9..ba84ef2b392f5b 100644
--- a/packages/editor/src/components/entities-saved-states/index.js
+++ b/packages/editor/src/components/entities-saved-states/index.js
@@ -128,11 +128,21 @@ export function EntitiesSavedStatesExtensible( {
aria-describedby={ renderDialog ? dialogDescription : undefined }
>
+
+ { __( 'Cancel' ) }
+
@@ -147,14 +157,6 @@ export function EntitiesSavedStatesExtensible( {
>
{ saveLabel }
-
- { __( 'Cancel' ) }
-
diff --git a/packages/editor/src/components/entities-saved-states/style.scss b/packages/editor/src/components/entities-saved-states/style.scss
index 981a0d92e5ff6b..e2c320678c322a 100644
--- a/packages/editor/src/components/entities-saved-states/style.scss
+++ b/packages/editor/src/components/entities-saved-states/style.scss
@@ -1,8 +1,8 @@
.entities-saved-states__panel-header {
box-sizing: border-box;
background: $white;
- padding-left: $grid-unit-10;
- padding-right: $grid-unit-10;
+ padding-left: $grid-unit-20;
+ padding-right: $grid-unit-20;
height: $header-height;
border-bottom: $border-width solid $gray-300;
}
diff --git a/packages/editor/src/components/header/index.js b/packages/editor/src/components/header/index.js
index fb034ba8bb8574..631643f26d4d5f 100644
--- a/packages/editor/src/components/header/index.js
+++ b/packages/editor/src/components/header/index.js
@@ -1,13 +1,13 @@
/**
* WordPress dependencies
*/
+import { store as blockEditorStore } from '@wordpress/block-editor';
import { useSelect } from '@wordpress/data';
import { useMediaQuery, useViewportMatch } from '@wordpress/compose';
import { __unstableMotion as motion } from '@wordpress/components';
import { store as preferencesStore } from '@wordpress/preferences';
import { useState } from '@wordpress/element';
import { PinnedItems } from '@wordpress/interface';
-import { store as blockEditorStore } from '@wordpress/block-editor';
/**
* Internal dependencies
@@ -53,39 +53,50 @@ function Header( {
const isLargeViewport = useViewportMatch( 'medium' );
const isTooNarrowForDocumentBar = useMediaQuery( '(max-width: 403px)' );
const {
+ postType,
isTextEditor,
isPublishSidebarOpened,
showIconLabels,
hasFixedToolbar,
+ hasBlockSelection,
isNestedEntity,
} = useSelect( ( select ) => {
const { get: getPreference } = select( preferencesStore );
const {
getEditorMode,
getEditorSettings,
+ getCurrentPostType,
isPublishSidebarOpened: _isPublishSidebarOpened,
} = select( editorStore );
- const { __unstableGetEditorMode } = select( blockEditorStore );
return {
+ postType: getCurrentPostType(),
isTextEditor: getEditorMode() === 'text',
isPublishSidebarOpened: _isPublishSidebarOpened(),
showIconLabels: getPreference( 'core', 'showIconLabels' ),
hasFixedToolbar: getPreference( 'core', 'fixedToolbar' ),
+ hasBlockSelection:
+ !! select( blockEditorStore ).getBlockSelectionStart(),
isNestedEntity:
!! getEditorSettings().onNavigateToPreviousEntityRecord,
- isZoomedOutView: __unstableGetEditorMode() === 'zoom-out',
};
}, [] );
+ const canBeZoomedOut = [ 'post', 'page', 'wp_template' ].includes(
+ postType
+ );
+
const [ isBlockToolsCollapsed, setIsBlockToolsCollapsed ] =
useState( true );
- const hasCenter = isBlockToolsCollapsed && ! isTooNarrowForDocumentBar;
+ const hasCenter =
+ ( ! hasBlockSelection || isBlockToolsCollapsed ) &&
+ ! isTooNarrowForDocumentBar;
const hasBackButton = useHasBackButton();
-
- // The edit-post-header classname is only kept for backward compatibilty
- // as some plugins might be relying on its presence.
+ /*
+ * The edit-post-header classname is only kept for backward compatability
+ * as some plugins might be relying on its presence.
+ */
return (
{ hasBackButton && (
@@ -127,13 +138,20 @@ function Header( {
className="editor-header__settings"
>
{ ! customSaveButton && ! isPublishSidebarOpened && (
- // This button isn't completely hidden by the publish sidebar.
- // We can't hide the whole toolbar when the publish sidebar is open because
- // we want to prevent mounting/unmounting the PostPublishButtonOrToggle DOM node.
- // We track that DOM node to return focus to the PostPublishButtonOrToggle
- // when the publish sidebar has been closed.
+ /*
+ * This button isn't completely hidden by the publish sidebar.
+ * We can't hide the whole toolbar when the publish sidebar is open because
+ * we want to prevent mounting/unmounting the PostPublishButtonOrToggle DOM node.
+ * We track that DOM node to return focus to the PostPublishButtonOrToggle
+ * when the publish sidebar has been closed.
+ */
) }
+
+ { canBeZoomedOut && isEditorIframed && isWideViewport && (
+
+ ) }
+
- { isEditorIframed && isWideViewport &&
}
-
{ ( isWideViewport || ! showIconLabels ) && (
) }
diff --git a/packages/editor/src/components/inserter-sidebar/index.js b/packages/editor/src/components/inserter-sidebar/index.js
index b98770b7afe8fa..5cace042fae58c 100644
--- a/packages/editor/src/components/inserter-sidebar/index.js
+++ b/packages/editor/src/components/inserter-sidebar/index.js
@@ -24,13 +24,13 @@ export default function InserterSidebar() {
const {
blockSectionRootClientId,
inserterSidebarToggleRef,
- insertionPoint,
+ inserter,
showMostUsedBlocks,
sidebarIsOpened,
} = useSelect( ( select ) => {
const {
getInserterSidebarToggleRef,
- getInsertionPoint,
+ getInserter,
isPublishSidebarOpened,
} = unlock( select( editorStore ) );
const {
@@ -52,7 +52,7 @@ export default function InserterSidebar() {
};
return {
inserterSidebarToggleRef: getInserterSidebarToggleRef(),
- insertionPoint: getInsertionPoint(),
+ inserter: getInserter(),
showMostUsedBlocks: get( 'core', 'mostUsedBlocks' ),
blockSectionRootClientId: getBlockSectionRootClientId(),
sidebarIsOpened: !! (
@@ -88,14 +88,11 @@ export default function InserterSidebar() {
showMostUsedBlocks={ showMostUsedBlocks }
showInserterHelpPanel
shouldFocusBlock={ isMobileViewport }
- rootClientId={
- blockSectionRootClientId ?? insertionPoint.rootClientId
- }
- __experimentalInsertionIndex={ insertionPoint.insertionIndex }
- onSelect={ insertionPoint.onSelect }
- __experimentalInitialTab={ insertionPoint.tab }
- __experimentalInitialCategory={ insertionPoint.category }
- __experimentalFilterValue={ insertionPoint.filterValue }
+ rootClientId={ blockSectionRootClientId }
+ onSelect={ inserter.onSelect }
+ __experimentalInitialTab={ inserter.tab }
+ __experimentalInitialCategory={ inserter.category }
+ __experimentalFilterValue={ inserter.filterValue }
onPatternCategorySelection={
sidebarIsOpened
? () => disableComplementaryArea( 'core' )
diff --git a/packages/editor/src/components/plugin-sidebar/index.js b/packages/editor/src/components/plugin-sidebar/index.js
index b9c0177e30fc42..56a954cadffb69 100644
--- a/packages/editor/src/components/plugin-sidebar/index.js
+++ b/packages/editor/src/components/plugin-sidebar/index.js
@@ -3,7 +3,6 @@
*/
import { useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
-import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts';
import { ComplementaryArea } from '@wordpress/interface';
/**
@@ -77,12 +76,9 @@ import { store as editorStore } from '../../store';
* ```
*/
export default function PluginSidebar( { className, ...props } ) {
- const { postTitle, shortcut } = useSelect( ( select ) => {
+ const { postTitle } = useSelect( ( select ) => {
return {
postTitle: select( editorStore ).getEditedPostAttribute( 'title' ),
- shortcut: select(
- keyboardShortcutsStore
- ).getShortcutRepresentation( 'core/editor/toggle-sidebar' ),
};
}, [] );
return (
@@ -91,7 +87,6 @@ export default function PluginSidebar( { className, ...props } ) {
className="editor-sidebar"
smallScreenTitle={ postTitle || __( '(no title)' ) }
scope="core"
- toggleShortcut={ shortcut }
{ ...props }
/>
);
diff --git a/packages/editor/src/components/post-publish-button/index.js b/packages/editor/src/components/post-publish-button/index.js
index 0460b27b616e81..71e18a4d6a9c82 100644
--- a/packages/editor/src/components/post-publish-button/index.js
+++ b/packages/editor/src/components/post-publish-button/index.js
@@ -2,7 +2,7 @@
* WordPress dependencies
*/
import { Button } from '@wordpress/components';
-import { Component, createRef } from '@wordpress/element';
+import { Component } from '@wordpress/element';
import { withSelect, withDispatch } from '@wordpress/data';
import { compose } from '@wordpress/compose';
@@ -17,7 +17,6 @@ const noop = () => {};
export class PostPublishButton extends Component {
constructor( props ) {
super( props );
- this.buttonNode = createRef();
this.createOnClick = this.createOnClick.bind( this );
this.closeEntitiesSavedStates =
@@ -28,21 +27,6 @@ export class PostPublishButton extends Component {
};
}
- componentDidMount() {
- if ( this.props.focusOnMount ) {
- // This timeout is necessary to make sure the `useEffect` hook of
- // `useFocusReturn` gets the correct element (the button that opens the
- // PostPublishPanel) otherwise it will get this button.
- this.timeoutID = setTimeout( () => {
- this.buttonNode.current.focus();
- }, 0 );
- }
- }
-
- componentWillUnmount() {
- clearTimeout( this.timeoutID );
- }
-
createOnClick( callback ) {
return ( ...args ) => {
const { hasNonPostEntityChanges, setEntitiesSavedStatesCallback } =
@@ -182,7 +166,6 @@ export class PostPublishButton extends Component {
return (
<>
{
+ this.cancelButtonNode.current.focus();
+ }, 0 );
+ }
+
+ componentWillUnmount() {
+ clearTimeout( this.timeoutID );
}
componentDidUpdate( prevProps ) {
@@ -85,15 +99,9 @@ export class PostPublishPanel extends Component {
/>
) : (
<>
-
+
>
) }
diff --git a/packages/editor/src/components/post-publish-panel/maybe-upload-media.js b/packages/editor/src/components/post-publish-panel/maybe-upload-media.js
index 8c8d757c583399..32ea69c425e0b5 100644
--- a/packages/editor/src/components/post-publish-panel/maybe-upload-media.js
+++ b/packages/editor/src/components/post-publish-panel/maybe-upload-media.js
@@ -98,8 +98,8 @@ function Image( { clientId, alt, url } ) {
animate={ { opacity: 1 } }
exit={ { opacity: 0, scale: 0 } }
style={ {
- width: '36px',
- height: '36px',
+ width: '32px',
+ height: '32px',
objectFit: 'cover',
borderRadius: '2px',
cursor: 'pointer',
@@ -256,7 +256,7 @@ export default function MaybeUploadMediaPanel() {
) : (
diff --git a/packages/editor/src/components/post-publish-panel/style.scss b/packages/editor/src/components/post-publish-panel/style.scss
index 9892cf5430f9a2..7b075717651781 100644
--- a/packages/editor/src/components/post-publish-panel/style.scss
+++ b/packages/editor/src/components/post-publish-panel/style.scss
@@ -68,12 +68,12 @@
}
.editor-post-publish-panel__header-publish-button {
- padding-right: $grid-unit-05;
+ padding-left: $grid-unit-05;
justify-content: center;
}
.editor-post-publish-panel__header-cancel-button {
- padding-left: $grid-unit-05;
+ padding-right: $grid-unit-05;
}
.editor-post-publish-panel__header-published {
diff --git a/packages/editor/src/components/post-publish-panel/test/__snapshots__/index.js.snap b/packages/editor/src/components/post-publish-panel/test/__snapshots__/index.js.snap
index 1dd75fffaa7b6a..b074159ac423d4 100644
--- a/packages/editor/src/components/post-publish-panel/test/__snapshots__/index.js.snap
+++ b/packages/editor/src/components/post-publish-panel/test/__snapshots__/index.js.snap
@@ -433,24 +433,24 @@ exports[`PostPublishPanel should render the pre-publish panel if post status is
class="editor-post-publish-panel__header"
>
@@ -586,24 +586,24 @@ exports[`PostPublishPanel should render the pre-publish panel if the post is not
class="editor-post-publish-panel__header"
>
@@ -783,24 +783,24 @@ exports[`PostPublishPanel should render the spinner if the post is being saved 1
class="editor-post-publish-panel__header"
>
diff --git a/packages/editor/src/components/preview-dropdown/index.js b/packages/editor/src/components/preview-dropdown/index.js
index ecc5bc610a3027..0fbb2beb62665e 100644
--- a/packages/editor/src/components/preview-dropdown/index.js
+++ b/packages/editor/src/components/preview-dropdown/index.js
@@ -26,7 +26,9 @@ import { ActionItem } from '@wordpress/interface';
* Internal dependencies
*/
import { store as editorStore } from '../../store';
+import { store as blockEditorStore } from '@wordpress/block-editor';
import PostPreviewButton from '../post-preview-button';
+import { unlock } from '../../lock-unlock';
export default function PreviewDropdown( { forceIsAutosaveable, disabled } ) {
const { deviceType, homeUrl, isTemplate, isViewable, showIconLabels } =
@@ -44,6 +46,14 @@ export default function PreviewDropdown( { forceIsAutosaveable, disabled } ) {
};
}, [] );
const { setDeviceType } = useDispatch( editorStore );
+ const { __unstableSetEditorMode } = useDispatch( blockEditorStore );
+ const { resetZoomLevel } = unlock( useDispatch( blockEditorStore ) );
+
+ const handleDevicePreviewChange = ( newDeviceType ) => {
+ setDeviceType( newDeviceType );
+ __unstableSetEditorMode( 'edit' );
+ resetZoomLevel();
+ };
const isMobile = useViewportMatch( 'medium', '<' );
if ( isMobile ) {
@@ -113,7 +123,7 @@ export default function PreviewDropdown( { forceIsAutosaveable, disabled } ) {
{ isTemplate && (
diff --git a/packages/editor/src/components/provider/disable-non-page-content-blocks.js b/packages/editor/src/components/provider/disable-non-page-content-blocks.js
index 9abb0e14079d5e..ae4fd1075fc261 100644
--- a/packages/editor/src/components/provider/disable-non-page-content-blocks.js
+++ b/packages/editor/src/components/provider/disable-non-page-content-blocks.js
@@ -3,52 +3,32 @@
*/
import { useSelect, useRegistry } from '@wordpress/data';
import { store as blockEditorStore } from '@wordpress/block-editor';
-import { useEffect, useMemo } from '@wordpress/element';
-import { applyFilters } from '@wordpress/hooks';
+import { useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
-import { store as editorStore } from '../../store';
-import { unlock } from '../../lock-unlock';
-
-const POST_CONTENT_BLOCK_TYPES = [
- 'core/post-title',
- 'core/post-featured-image',
- 'core/post-content',
-];
+import usePostContentBlocks from './use-post-content-blocks';
/**
* Component that when rendered, makes it so that the site editor allows only
* page content to be edited.
*/
export default function DisableNonPageContentBlocks() {
- const contentOnlyBlockTypes = useMemo(
- () => [
- ...applyFilters(
- 'editor.postContentBlockTypes',
- POST_CONTENT_BLOCK_TYPES
- ),
- 'core/template-part',
- ],
- []
- );
-
- // Note that there are two separate subscriptions because the result for each
- // returns a new array.
- const contentOnlyIds = useSelect(
+ const contentOnlyIds = usePostContentBlocks();
+ const templateParts = useSelect( ( select ) => {
+ const { getBlocksByName } = select( blockEditorStore );
+ return getBlocksByName( 'core/template-part' );
+ }, [] );
+ const disabledIds = useSelect(
( select ) => {
- const { getPostBlocksByName } = unlock( select( editorStore ) );
- return getPostBlocksByName( contentOnlyBlockTypes );
+ const { getBlockOrder } = select( blockEditorStore );
+ return templateParts.flatMap( ( clientId ) =>
+ getBlockOrder( clientId )
+ );
},
- [ contentOnlyBlockTypes ]
+ [ templateParts ]
);
- const disabledIds = useSelect( ( select ) => {
- const { getBlocksByName, getBlockOrder } = select( blockEditorStore );
- return getBlocksByName( 'core/template-part' ).flatMap( ( clientId ) =>
- getBlockOrder( clientId )
- );
- }, [] );
const registry = useRegistry();
@@ -61,6 +41,9 @@ export default function DisableNonPageContentBlocks() {
for ( const clientId of contentOnlyIds ) {
setBlockEditingMode( clientId, 'contentOnly' );
}
+ for ( const clientId of templateParts ) {
+ setBlockEditingMode( clientId, 'contentOnly' );
+ }
for ( const clientId of disabledIds ) {
setBlockEditingMode( clientId, 'disabled' );
}
@@ -72,12 +55,15 @@ export default function DisableNonPageContentBlocks() {
for ( const clientId of contentOnlyIds ) {
unsetBlockEditingMode( clientId );
}
+ for ( const clientId of templateParts ) {
+ unsetBlockEditingMode( clientId );
+ }
for ( const clientId of disabledIds ) {
unsetBlockEditingMode( clientId );
}
} );
};
- }, [ contentOnlyIds, disabledIds, registry ] );
+ }, [ templateParts, contentOnlyIds, disabledIds, registry ] );
return null;
}
diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js
index 11b1478d58434a..0c45dbc5e7199c 100644
--- a/packages/editor/src/components/provider/index.js
+++ b/packages/editor/src/components/provider/index.js
@@ -188,26 +188,19 @@ export const ExperimentalEditorProvider = withRegistryProvider(
const postContext = {};
// If it is a template, try to inherit the post type from the slug.
if ( post.type === 'wp_template' ) {
- if ( ! post.is_custom ) {
- const [ kind ] = post.slug.split( '-' );
- switch ( kind ) {
- case 'page':
- postContext.postType = 'page';
- break;
- case 'single':
- // Infer the post type from the slug.
- const postTypesSlugs =
- postTypes?.map( ( entity ) => entity.slug ) ||
- [];
- const match = post.slug.match(
- `^single-(${ postTypesSlugs.join(
- '|'
- ) })(?:-.+)?$`
- );
- if ( match ) {
- postContext.postType = match[ 1 ];
- }
- break;
+ if ( post.slug === 'page' ) {
+ postContext.postType = 'page';
+ } else if ( post.slug === 'single' ) {
+ postContext.postType = 'post';
+ } else if ( post.slug.split( '-' )[ 0 ] === 'single' ) {
+ // If the slug is single-{postType}, infer the post type from the slug.
+ const postTypesSlugs =
+ postTypes?.map( ( entity ) => entity.slug ) || [];
+ const match = post.slug.match(
+ `^single-(${ postTypesSlugs.join( '|' ) })(?:-.+)?$`
+ );
+ if ( match ) {
+ postContext.postType = match[ 1 ];
}
}
} else if (
diff --git a/packages/editor/src/components/provider/use-post-content-blocks.js b/packages/editor/src/components/provider/use-post-content-blocks.js
new file mode 100644
index 00000000000000..bdd277157e47e0
--- /dev/null
+++ b/packages/editor/src/components/provider/use-post-content-blocks.js
@@ -0,0 +1,42 @@
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+import { useMemo } from '@wordpress/element';
+import { applyFilters } from '@wordpress/hooks';
+
+/**
+ * Internal dependencies
+ */
+import { store as editorStore } from '../../store';
+import { unlock } from '../../lock-unlock';
+
+const POST_CONTENT_BLOCK_TYPES = [
+ 'core/post-title',
+ 'core/post-featured-image',
+ 'core/post-content',
+];
+
+export default function usePostContentBlocks() {
+ const contentOnlyBlockTypes = useMemo(
+ () => [
+ ...applyFilters(
+ 'editor.postContentBlockTypes',
+ POST_CONTENT_BLOCK_TYPES
+ ),
+ ],
+ []
+ );
+
+ // Note that there are two separate subscriptions because the result for each
+ // returns a new array.
+ const contentOnlyIds = useSelect(
+ ( select ) => {
+ const { getPostBlocksByName } = unlock( select( editorStore ) );
+ return getPostBlocksByName( contentOnlyBlockTypes );
+ },
+ [ contentOnlyBlockTypes ]
+ );
+
+ return contentOnlyIds;
+}
diff --git a/packages/editor/src/components/resizable-editor/resize-handle.js b/packages/editor/src/components/resizable-editor/resize-handle.js
index dbba31f6f998ba..ccd903d0f3a172 100644
--- a/packages/editor/src/components/resizable-editor/resize-handle.js
+++ b/packages/editor/src/components/resizable-editor/resize-handle.js
@@ -15,6 +15,11 @@ export default function ResizeHandle( { direction, resizeWidthBy } ) {
function handleKeyDown( event ) {
const { keyCode } = event;
+ if ( keyCode !== LEFT && keyCode !== RIGHT ) {
+ return;
+ }
+ event.preventDefault();
+
if (
( direction === 'left' && keyCode === LEFT ) ||
( direction === 'right' && keyCode === RIGHT )
diff --git a/packages/editor/src/components/visual-editor/index.js b/packages/editor/src/components/visual-editor/index.js
index 2ff115272d614b..88d2dac8ffd77c 100644
--- a/packages/editor/src/components/visual-editor/index.js
+++ b/packages/editor/src/components/visual-editor/index.js
@@ -174,17 +174,19 @@ function VisualEditor( {
hasRootPaddingAwareAlignments,
themeHasDisabledLayoutStyles,
themeSupportsLayout,
- isZoomOutMode,
+ isZoomedOut,
} = useSelect( ( select ) => {
- const { getSettings, __unstableGetEditorMode } =
- select( blockEditorStore );
+ const { getSettings, isZoomOut: _isZoomOut } = unlock(
+ select( blockEditorStore )
+ );
+
const _settings = getSettings();
return {
themeHasDisabledLayoutStyles: _settings.disableLayoutStyles,
themeSupportsLayout: _settings.supportsLayout,
hasRootPaddingAwareAlignments:
_settings.__experimentalFeatures?.useRootPaddingAwareAlignments,
- isZoomOutMode: __unstableGetEditorMode() === 'zoom-out',
+ isZoomedOut: _isZoomOut(),
};
}, [] );
@@ -336,7 +338,7 @@ function VisualEditor( {
] );
const zoomOutProps =
- isZoomOutMode && ! isTabletViewport
+ isZoomedOut && ! isTabletViewport
? {
scale: 'default',
frameSize: '48px',
@@ -355,7 +357,7 @@ function VisualEditor( {
// Disable resizing in mobile viewport.
! isMobileViewport &&
// Dsiable resizing in zoomed-out mode.
- ! isZoomOutMode;
+ ! isZoomedOut;
const shouldIframe =
! disableIframe || [ 'Tablet', 'Mobile' ].includes( deviceType );
diff --git a/packages/editor/src/components/zoom-out-toggle/index.js b/packages/editor/src/components/zoom-out-toggle/index.js
index e8c7b1e50510ab..b89bf15546f0d8 100644
--- a/packages/editor/src/components/zoom-out-toggle/index.js
+++ b/packages/editor/src/components/zoom-out-toggle/index.js
@@ -7,26 +7,43 @@ import { __ } from '@wordpress/i18n';
import { useDispatch, useSelect } from '@wordpress/data';
import { store as blockEditorStore } from '@wordpress/block-editor';
import { square as zoomOutIcon } from '@wordpress/icons';
+import { store as preferencesStore } from '@wordpress/preferences';
+
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../../lock-unlock';
const ZoomOutToggle = () => {
- const { isZoomOutMode } = useSelect( ( select ) => ( {
- isZoomOutMode:
- select( blockEditorStore ).__unstableGetEditorMode() === 'zoom-out',
+ const { isZoomOut, showIconLabels } = useSelect( ( select ) => ( {
+ isZoomOut: unlock( select( blockEditorStore ) ).isZoomOut(),
+ showIconLabels: select( preferencesStore ).get(
+ 'core',
+ 'showIconLabels'
+ ),
} ) );
- const { __unstableSetEditorMode } = useDispatch( blockEditorStore );
+ const { resetZoomLevel, setZoomLevel, __unstableSetEditorMode } = unlock(
+ useDispatch( blockEditorStore )
+ );
const handleZoomOut = () => {
- __unstableSetEditorMode( isZoomOutMode ? 'edit' : 'zoom-out' );
+ if ( isZoomOut ) {
+ resetZoomLevel();
+ } else {
+ setZoomLevel( 50 );
+ }
+ __unstableSetEditorMode( isZoomOut ? 'edit' : 'zoom-out' );
};
return (
);
};
diff --git a/packages/editor/src/dataviews/actions/delete-post.tsx b/packages/editor/src/dataviews/actions/delete-post.tsx
deleted file mode 100644
index 381c2964f943f6..00000000000000
--- a/packages/editor/src/dataviews/actions/delete-post.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { trash } from '@wordpress/icons';
-import { useDispatch } from '@wordpress/data';
-import { __, _n, sprintf } from '@wordpress/i18n';
-import { useState } from '@wordpress/element';
-import {
- Button,
- __experimentalText as Text,
- __experimentalHStack as HStack,
- __experimentalVStack as VStack,
-} from '@wordpress/components';
-// @ts-ignore
-import { privateApis as patternsPrivateApis } from '@wordpress/patterns';
-import type { Action } from '@wordpress/dataviews';
-import type { StoreDescriptor } from '@wordpress/data';
-
-/**
- * Internal dependencies
- */
-import {
- isTemplateRemovable,
- getItemTitle,
- isTemplateOrTemplatePart,
-} from './utils';
-// @ts-ignore
-import { store as editorStore } from '../../store';
-import { unlock } from '../../lock-unlock';
-import type { Post } from '../types';
-
-const { PATTERN_TYPES } = unlock( patternsPrivateApis );
-
-// This action is used for templates, patterns and template parts.
-// Every other post type uses the similar `trashPostAction` which
-// moves the post to trash.
-const deletePostAction: Action< Post > = {
- id: 'delete-post',
- label: __( 'Delete' ),
- isPrimary: true,
- icon: trash,
- isEligible( post ) {
- if ( isTemplateOrTemplatePart( post ) ) {
- return isTemplateRemovable( post );
- }
- // We can only remove user patterns.
- return post.type === PATTERN_TYPES.user;
- },
- supportsBulk: true,
- hideModalHeader: true,
- RenderModal: ( { items, closeModal, onActionPerformed } ) => {
- const [ isBusy, setIsBusy ] = useState( false );
- const { removeTemplates } = unlock(
- useDispatch( editorStore as StoreDescriptor )
- );
- return (
-
-
- { items.length > 1
- ? sprintf(
- // translators: %d: number of items to delete.
- _n(
- 'Delete %d item?',
- 'Delete %d items?',
- items.length
- ),
- items.length
- )
- : sprintf(
- // translators: %s: The template or template part's titles
- __( 'Delete "%s"?' ),
- getItemTitle( items[ 0 ] )
- ) }
-
-
-
- { __( 'Cancel' ) }
-
- {
- setIsBusy( true );
- await removeTemplates( items, {
- allowUndo: false,
- } );
- onActionPerformed?.( items );
- setIsBusy( false );
- closeModal?.();
- } }
- isBusy={ isBusy }
- disabled={ isBusy }
- accessibleWhenDisabled
- __next40pxDefaultSize
- >
- { __( 'Delete' ) }
-
-
-
- );
- },
-};
-
-export default deletePostAction;
diff --git a/packages/editor/src/dataviews/actions/reset-post.tsx b/packages/editor/src/dataviews/actions/reset-post.tsx
deleted file mode 100644
index d0b5521a34833d..00000000000000
--- a/packages/editor/src/dataviews/actions/reset-post.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { backup } from '@wordpress/icons';
-import { useDispatch } from '@wordpress/data';
-import { store as coreStore } from '@wordpress/core-data';
-import { __, sprintf } from '@wordpress/i18n';
-import { store as noticesStore } from '@wordpress/notices';
-import { useState } from '@wordpress/element';
-import {
- Button,
- __experimentalText as Text,
- __experimentalHStack as HStack,
- __experimentalVStack as VStack,
-} from '@wordpress/components';
-import type { Action } from '@wordpress/dataviews';
-import type { StoreDescriptor } from '@wordpress/data';
-
-/**
- * Internal dependencies
- */
-import { TEMPLATE_POST_TYPE, TEMPLATE_ORIGINS } from '../../store/constants';
-import { store as editorStore } from '../../store';
-import { unlock } from '../../lock-unlock';
-import type { Post, CoreDataError } from '../types';
-import { isTemplateOrTemplatePart, getItemTitle } from './utils';
-
-const resetPost: Action< Post > = {
- id: 'reset-post',
- label: __( 'Reset' ),
- isEligible: ( item ) => {
- return (
- isTemplateOrTemplatePart( item ) &&
- item?.source === TEMPLATE_ORIGINS.custom &&
- ( Boolean( item.type === 'wp_template' && item?.plugin ) ||
- item?.has_theme_file )
- );
- },
- icon: backup,
- supportsBulk: true,
- hideModalHeader: true,
- RenderModal: ( { items, closeModal, onActionPerformed } ) => {
- const [ isBusy, setIsBusy ] = useState( false );
- const { revertTemplate } = unlock(
- useDispatch( editorStore as StoreDescriptor )
- );
- const { saveEditedEntityRecord } = useDispatch( coreStore );
- const { createSuccessNotice, createErrorNotice } =
- useDispatch( noticesStore );
- const onConfirm = async () => {
- try {
- for ( const template of items ) {
- await revertTemplate( template, {
- allowUndo: false,
- } );
- await saveEditedEntityRecord(
- 'postType',
- template.type,
- template.id
- );
- }
- createSuccessNotice(
- items.length > 1
- ? sprintf(
- /* translators: The number of items. */
- __( '%s items reset.' ),
- items.length
- )
- : sprintf(
- /* translators: The template/part's name. */
- __( '"%s" reset.' ),
- getItemTitle( items[ 0 ] )
- ),
- {
- type: 'snackbar',
- id: 'revert-template-action',
- }
- );
- } catch ( error ) {
- let fallbackErrorMessage;
- if ( items[ 0 ].type === TEMPLATE_POST_TYPE ) {
- fallbackErrorMessage =
- items.length === 1
- ? __(
- 'An error occurred while reverting the template.'
- )
- : __(
- 'An error occurred while reverting the templates.'
- );
- } else {
- fallbackErrorMessage =
- items.length === 1
- ? __(
- 'An error occurred while reverting the template part.'
- )
- : __(
- 'An error occurred while reverting the template parts.'
- );
- }
-
- const typedError = error as CoreDataError;
- const errorMessage =
- typedError.message && typedError.code !== 'unknown_error'
- ? typedError.message
- : fallbackErrorMessage;
-
- createErrorNotice( errorMessage, { type: 'snackbar' } );
- }
- };
- return (
-
-
- { __( 'Reset to default and clear all customizations?' ) }
-
-
-
- { __( 'Cancel' ) }
-
- {
- setIsBusy( true );
- await onConfirm();
- onActionPerformed?.( items );
- setIsBusy( false );
- closeModal?.();
- } }
- isBusy={ isBusy }
- disabled={ isBusy }
- accessibleWhenDisabled
- >
- { __( 'Reset' ) }
-
-
-
- );
- },
-};
-
-export default resetPost;
diff --git a/packages/editor/src/dataviews/fields/index.ts b/packages/editor/src/dataviews/fields/index.ts
deleted file mode 100644
index b215172eaf7f02..00000000000000
--- a/packages/editor/src/dataviews/fields/index.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { __ } from '@wordpress/i18n';
-import type { Field } from '@wordpress/dataviews';
-
-/**
- * Internal dependencies
- */
-import type { BasePost } from '../types';
-import { getItemTitle } from '../actions/utils';
-
-export const titleField: Field< BasePost > = {
- type: 'text',
- id: 'title',
- label: __( 'Title' ),
- placeholder: __( 'No title' ),
- getValue: ( { item } ) => getItemTitle( item ),
-};
-
-export const orderField: Field< BasePost > = {
- type: 'integer',
- id: 'menu_order',
- label: __( 'Order' ),
- description: __( 'Determines the order of pages.' ),
-};
diff --git a/packages/editor/src/dataviews/store/private-actions.ts b/packages/editor/src/dataviews/store/private-actions.ts
index e685493641f3b8..10f2b9ce872d5a 100644
--- a/packages/editor/src/dataviews/store/private-actions.ts
+++ b/packages/editor/src/dataviews/store/private-actions.ts
@@ -8,11 +8,6 @@ import { doAction } from '@wordpress/hooks';
/**
* Internal dependencies
*/
-import duplicateTemplatePart from '../actions/duplicate-template-part';
-import resetPost from '../actions/reset-post';
-import trashPost from '../actions/trash-post';
-import renamePost from '../actions/rename-post';
-import restorePost from '../actions/restore-post';
import type { PostType } from '../types';
import { store as editorStore } from '../../store';
import { unlock } from '../../lock-unlock';
@@ -24,8 +19,13 @@ import {
reorderPage,
exportPattern,
permanentlyDeletePost,
+ restorePost,
+ trashPost,
+ renamePost,
+ resetPost,
+ deletePost,
} from '@wordpress/fields';
-import deletePost from '../actions/delete-post';
+import duplicateTemplatePart from '../actions/duplicate-template-part';
export function registerEntityAction< Item >(
kind: string,
@@ -117,8 +117,8 @@ export const registerPostTypeActions =
? reorderPage
: undefined,
postTypeConfig.slug === 'wp_block' ? exportPattern : undefined,
- resetPost,
restorePost,
+ resetPost,
deletePost,
trashPost,
permanentlyDeletePost,
diff --git a/packages/editor/src/hooks/pattern-overrides.js b/packages/editor/src/hooks/pattern-overrides.js
index 6f81f368351f38..8882856a89e0d9 100644
--- a/packages/editor/src/hooks/pattern-overrides.js
+++ b/packages/editor/src/hooks/pattern-overrides.js
@@ -6,7 +6,7 @@ import { privateApis as patternsPrivateApis } from '@wordpress/patterns';
import { createHigherOrderComponent } from '@wordpress/compose';
import { useBlockEditingMode } from '@wordpress/block-editor';
import { useSelect } from '@wordpress/data';
-import { store as blocksStore } from '@wordpress/blocks';
+import { getBlockBindingsSource } from '@wordpress/blocks';
/**
* Internal dependencies
@@ -58,7 +58,6 @@ function ControlsWithStoreSubscription( props ) {
const blockEditingMode = useBlockEditingMode();
const { hasPatternOverridesSource, isEditingSyncedPattern } = useSelect(
( select ) => {
- const { getBlockBindingsSource } = unlock( select( blocksStore ) );
const { getCurrentPostType, getEditedPostAttribute } =
select( editorStore );
diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js
index 59faa6b5b73624..fa720e1fc7d347 100644
--- a/packages/editor/src/store/actions.js
+++ b/packages/editor/src/store/actions.js
@@ -12,7 +12,11 @@ import {
import { store as noticesStore } from '@wordpress/notices';
import { store as coreStore } from '@wordpress/core-data';
import { store as blockEditorStore } from '@wordpress/block-editor';
-import { applyFilters } from '@wordpress/hooks';
+import {
+ applyFilters,
+ applyFiltersAsync,
+ doActionAsync,
+} from '@wordpress/hooks';
import { store as preferencesStore } from '@wordpress/preferences';
import { __ } from '@wordpress/i18n';
@@ -26,7 +30,7 @@ import {
getNotificationArgumentsForSaveFail,
getNotificationArgumentsForTrashFail,
} from './utils/notice-builder';
-
+import { unlock } from '../lock-unlock';
/**
* Returns an action generator used in signalling that editor has initialized with
* the specified post object and editor settings.
@@ -184,7 +188,7 @@ export const savePost =
}
const previousRecord = select.getCurrentPost();
- const edits = {
+ let edits = {
id: previousRecord.id,
...registry
.select( coreStore )
@@ -199,9 +203,9 @@ export const savePost =
let error = false;
try {
- error = await applyFilters(
- 'editor.__unstablePreSavePost',
- Promise.resolve( false ),
+ edits = await applyFiltersAsync(
+ 'editor.preSavePost',
+ edits,
options
);
} catch ( err ) {
@@ -236,14 +240,25 @@ export const savePost =
);
}
+ // Run the hook with legacy unstable name for backward compatibility
if ( ! error ) {
- await applyFilters(
- 'editor.__unstableSavePost',
- Promise.resolve(),
- options
- ).catch( ( err ) => {
+ try {
+ await applyFilters(
+ 'editor.__unstableSavePost',
+ Promise.resolve(),
+ options
+ );
+ } catch ( err ) {
error = err;
- } );
+ }
+ }
+
+ if ( ! error ) {
+ try {
+ await doActionAsync( 'editor.savePost', options );
+ } catch ( err ) {
+ error = err;
+ }
}
dispatch( { type: 'REQUEST_POST_UPDATE_FINISH', options } );
@@ -726,15 +741,32 @@ export function removeEditorPanel( panelName ) {
* use an object.
* @param {string} value.rootClientId The root client ID to insert at.
* @param {number} value.insertionIndex The index to insert at.
+ * @param {string} value.filterValue A query to filter the inserter results.
+ * @param {Function} value.onSelect A callback when an item is selected.
+ * @param {string} value.tab The tab to open in the inserter.
+ * @param {string} value.category The category to initialize in the inserter.
*
* @return {Object} Action object.
*/
-export function setIsInserterOpened( value ) {
- return {
- type: 'SET_IS_INSERTER_OPENED',
- value,
+export const setIsInserterOpened =
+ ( value ) =>
+ ( { dispatch, registry } ) => {
+ if (
+ typeof value === 'object' &&
+ value.hasOwnProperty( 'rootClientId' ) &&
+ value.hasOwnProperty( 'insertionIndex' )
+ ) {
+ unlock( registry.dispatch( blockEditorStore ) ).setInsertionPoint( {
+ rootClientId: value.rootClientId,
+ index: value.insertionIndex,
+ } );
+ }
+
+ dispatch( {
+ type: 'SET_IS_INSERTER_OPENED',
+ value,
+ } );
};
-}
/**
* Returns an action object used to open/close the list view.
diff --git a/packages/editor/src/store/private-selectors.js b/packages/editor/src/store/private-selectors.js
index 357a7344f631d4..9bc065436c10bb 100644
--- a/packages/editor/src/store/private-selectors.js
+++ b/packages/editor/src/store/private-selectors.js
@@ -37,13 +37,13 @@ const EMPTY_INSERTION_POINT = {
};
/**
- * Get the insertion point for the inserter.
+ * Get the inserter.
*
* @param {Object} state Global application state.
*
* @return {Object} The root client ID, index to insert at and starting filter value.
*/
-export const getInsertionPoint = createRegistrySelector( ( select ) =>
+export const getInserter = createRegistrySelector( ( select ) =>
createSelector(
( state ) => {
if ( typeof state.blockInserterPanel === 'object' ) {
diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js
index fae30c6fc271ec..206c60a159d04f 100644
--- a/packages/editor/src/store/test/actions.js
+++ b/packages/editor/src/store/test/actions.js
@@ -576,4 +576,80 @@ describe( 'Editor actions', () => {
).toBe( true );
} );
} );
+
+ describe( 'setIsInserterOpened', () => {
+ it( 'should open and close the inserter', () => {
+ const registry = createRegistryWithStores();
+
+ registry.dispatch( editorStore ).setIsInserterOpened( true );
+
+ expect( registry.select( editorStore ).isInserterOpened() ).toBe(
+ true
+ );
+
+ registry.dispatch( editorStore ).setIsInserterOpened( false );
+
+ expect( registry.select( editorStore ).isInserterOpened() ).toBe(
+ false
+ );
+ } );
+
+ it( 'the list view should close when the inserter is opened', () => {
+ const registry = createRegistryWithStores();
+
+ registry.dispatch( editorStore ).setIsListViewOpened( true );
+ expect( registry.select( editorStore ).isListViewOpened() ).toBe(
+ true
+ );
+ expect( registry.select( editorStore ).isInserterOpened() ).toBe(
+ false
+ );
+
+ registry.dispatch( editorStore ).setIsInserterOpened( true );
+ expect( registry.select( editorStore ).isInserterOpened() ).toBe(
+ true
+ );
+ expect( registry.select( editorStore ).isListViewOpened() ).toBe(
+ false
+ );
+ } );
+ } );
+
+ describe( 'setIsListViewOpened', () => {
+ it( 'should open and close the list view', () => {
+ const registry = createRegistryWithStores();
+
+ registry.dispatch( editorStore ).setIsListViewOpened( true );
+
+ expect( registry.select( editorStore ).isListViewOpened() ).toBe(
+ true
+ );
+
+ registry.dispatch( editorStore ).setIsListViewOpened( false );
+
+ expect( registry.select( editorStore ).isListViewOpened() ).toBe(
+ false
+ );
+ } );
+
+ it( 'the inserter should close when the list view is opened', () => {
+ const registry = createRegistryWithStores();
+
+ registry.dispatch( editorStore ).setIsInserterOpened( true );
+ expect( registry.select( editorStore ).isInserterOpened() ).toBe(
+ true
+ );
+ expect( registry.select( editorStore ).isListViewOpened() ).toBe(
+ false
+ );
+
+ registry.dispatch( editorStore ).setIsListViewOpened( true );
+ expect( registry.select( editorStore ).isListViewOpened() ).toBe(
+ true
+ );
+ expect( registry.select( editorStore ).isInserterOpened() ).toBe(
+ false
+ );
+ } );
+ } );
} );
diff --git a/packages/editor/src/store/test/reducer.js b/packages/editor/src/store/test/reducer.js
index b4fd013c6b4d42..3971ad30c9de74 100644
--- a/packages/editor/src/store/test/reducer.js
+++ b/packages/editor/src/store/test/reducer.js
@@ -18,7 +18,6 @@ import {
blockInserterPanel,
listViewPanel,
} from '../reducer';
-import { setIsInserterOpened } from '../actions';
describe( 'state', () => {
describe( 'hasSameKeys()', () => {
@@ -298,15 +297,6 @@ describe( 'state', () => {
expect( blockInserterPanel( true, {} ) ).toBe( true );
} );
- it( 'should set the open state of the inserter panel', () => {
- expect(
- blockInserterPanel( false, setIsInserterOpened( true ) )
- ).toBe( true );
- expect(
- blockInserterPanel( true, setIsInserterOpened( false ) )
- ).toBe( false );
- } );
-
it( 'should close the inserter when opening the list view panel', () => {
expect(
blockInserterPanel( true, {
@@ -349,17 +339,5 @@ describe( 'state', () => {
} )
).toBe( false );
} );
-
- it( 'should close the list view when opening the inserter panel', () => {
- expect( listViewPanel( true, setIsInserterOpened( true ) ) ).toBe(
- false
- );
- } );
-
- it( 'should not change the state when closing the inserter panel', () => {
- expect( listViewPanel( true, setIsInserterOpened( false ) ) ).toBe(
- true
- );
- } );
} );
} );
diff --git a/packages/fields/README.md b/packages/fields/README.md
index 842fab02606af8..b4e45103600da6 100644
--- a/packages/fields/README.md
+++ b/packages/fields/README.md
@@ -14,6 +14,10 @@ npm install @wordpress/fields --save
+### deletePost
+
+Undocumented declaration.
+
### duplicatePattern
Undocumented declaration.
@@ -42,6 +46,10 @@ Undocumented declaration.
Undocumented declaration.
+### renamePost
+
+Undocumented declaration.
+
### reorderPage
Undocumented declaration.
@@ -50,10 +58,22 @@ Undocumented declaration.
Undocumented declaration.
+### resetPost
+
+Undocumented declaration.
+
+### restorePost
+
+Undocumented declaration.
+
### titleField
Undocumented declaration.
+### trashPost
+
+Undocumented declaration.
+
### viewPost
Undocumented declaration.
diff --git a/packages/fields/package.json b/packages/fields/package.json
index 2e417c9f4de570..3da913d1ee9ae5 100644
--- a/packages/fields/package.json
+++ b/packages/fields/package.json
@@ -9,7 +9,6 @@
"gutenberg",
"dataviews"
],
- "private": true,
"homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/fields/README.md",
"repository": {
"type": "git",
@@ -33,6 +32,7 @@
],
"dependencies": {
"@babel/runtime": "^7.16.0",
+ "@wordpress/api-fetch": "file:../api-fetch",
"@wordpress/blob": "file:../blob",
"@wordpress/blocks": "file:../blocks",
"@wordpress/components": "file:../components",
diff --git a/packages/fields/src/actions/base-post/index.ts b/packages/fields/src/actions/base-post/index.ts
deleted file mode 100644
index 7541be86c48b1f..00000000000000
--- a/packages/fields/src/actions/base-post/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export { default as viewPost } from './view-post';
-export { default as reorderPage } from './reorder-page';
-export { default as reorderPageNative } from './reorder-page.native';
-export { default as duplicatePost } from './duplicate-post';
-export { default as duplicatePostNative } from './duplicate-post.native';
diff --git a/packages/fields/src/actions/common/index.ts b/packages/fields/src/actions/common/index.ts
deleted file mode 100644
index 3590b2e270892e..00000000000000
--- a/packages/fields/src/actions/common/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { default as viewPostRevisions } from './view-post-revisions';
-export { default as permanentlyDeletePost } from './permanently-delete-post';
diff --git a/packages/fields/src/actions/delete-post.tsx b/packages/fields/src/actions/delete-post.tsx
new file mode 100644
index 00000000000000..c5ab866e12479e
--- /dev/null
+++ b/packages/fields/src/actions/delete-post.tsx
@@ -0,0 +1,203 @@
+/**
+ * WordPress dependencies
+ */
+import { trash } from '@wordpress/icons';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { useState } from '@wordpress/element';
+import {
+ Button,
+ __experimentalText as Text,
+ __experimentalHStack as HStack,
+ __experimentalVStack as VStack,
+} from '@wordpress/components';
+// @ts-ignore
+import { privateApis as patternsPrivateApis } from '@wordpress/patterns';
+import type { Action } from '@wordpress/dataviews';
+import { decodeEntities } from '@wordpress/html-entities';
+
+/**
+ * Internal dependencies
+ */
+import {
+ getItemTitle,
+ isTemplateOrTemplatePart,
+ isTemplateRemovable,
+} from './utils';
+import type { Pattern, Template, TemplatePart } from '../types';
+import type { NoticeSettings } from '../mutation';
+import { deletePostWithNotices } from '../mutation';
+import { unlock } from '../lock-unlock';
+
+const { PATTERN_TYPES } = unlock( patternsPrivateApis );
+
+// This action is used for templates, patterns and template parts.
+// Every other post type uses the similar `trashPostAction` which
+// moves the post to trash.
+const deletePostAction: Action< Template | TemplatePart | Pattern > = {
+ id: 'delete-post',
+ label: __( 'Delete' ),
+ isPrimary: true,
+ icon: trash,
+ isEligible( post ) {
+ if ( isTemplateOrTemplatePart( post ) ) {
+ return isTemplateRemovable( post );
+ }
+ // We can only remove user patterns.
+ return post.type === PATTERN_TYPES.user;
+ },
+ supportsBulk: true,
+ hideModalHeader: true,
+ RenderModal: ( { items, closeModal, onActionPerformed } ) => {
+ const [ isBusy, setIsBusy ] = useState( false );
+ const isResetting = items.every(
+ ( item ) => isTemplateOrTemplatePart( item ) && item?.has_theme_file
+ );
+ return (
+
+
+ { items.length > 1
+ ? sprintf(
+ // translators: %d: number of items to delete.
+ _n(
+ 'Delete %d item?',
+ 'Delete %d items?',
+ items.length
+ ),
+ items.length
+ )
+ : sprintf(
+ // translators: %s: The template or template part's titles
+ __( 'Delete "%s"?' ),
+ getItemTitle( items[ 0 ] )
+ ) }
+
+
+
+ { __( 'Cancel' ) }
+
+ {
+ setIsBusy( true );
+ const notice: NoticeSettings<
+ Template | TemplatePart | Pattern
+ > = {
+ success: {
+ messages: {
+ getMessage: ( item ) => {
+ return isResetting
+ ? sprintf(
+ /* translators: The template/part's name. */
+ __( '"%s" reset.' ),
+ decodeEntities(
+ getItemTitle( item )
+ )
+ )
+ : sprintf(
+ /* translators: The template/part's name. */
+ __( '"%s" deleted.' ),
+ decodeEntities(
+ getItemTitle( item )
+ )
+ );
+ },
+ getBatchMessage: () => {
+ return isResetting
+ ? __( 'Items reset.' )
+ : __( 'Items deleted.' );
+ },
+ },
+ },
+ error: {
+ messages: {
+ getMessage: ( error ) => {
+ if ( error.size === 1 ) {
+ return [ ...error ][ 0 ];
+ }
+ return isResetting
+ ? __(
+ 'An error occurred while reverting the item.'
+ )
+ : __(
+ 'An error occurred while deleting the item.'
+ );
+ },
+ getBatchMessage: ( errors ) => {
+ if ( errors.size === 0 ) {
+ return isResetting
+ ? __(
+ 'An error occurred while reverting the items.'
+ )
+ : __(
+ 'An error occurred while deleting the items.'
+ );
+ }
+
+ if ( errors.size === 1 ) {
+ return isResetting
+ ? sprintf(
+ /* translators: %s: an error message */
+ __(
+ 'An error occurred while reverting the items: %s'
+ ),
+ [ ...errors ][ 0 ]
+ )
+ : sprintf(
+ /* translators: %s: an error message */
+ __(
+ 'An error occurred while deleting the items: %s'
+ ),
+ [ ...errors ][ 0 ]
+ );
+ }
+
+ return isResetting
+ ? sprintf(
+ /* translators: %s: a list of comma separated error messages */
+ __(
+ 'Some errors occurred while reverting the items: %s'
+ ),
+ [ ...errors ].join(
+ ','
+ )
+ )
+ : sprintf(
+ /* translators: %s: a list of comma separated error messages */
+ __(
+ 'Some errors occurred while deleting the items: %s'
+ ),
+ [ ...errors ].join(
+ ','
+ )
+ );
+ },
+ },
+ },
+ };
+
+ await deletePostWithNotices( items, notice, {
+ onActionPerformed,
+ } );
+ setIsBusy( false );
+ closeModal?.();
+ } }
+ isBusy={ isBusy }
+ disabled={ isBusy }
+ accessibleWhenDisabled
+ __next40pxDefaultSize
+ >
+ { __( 'Delete' ) }
+
+
+
+ );
+ },
+};
+
+export default deletePostAction;
diff --git a/packages/fields/src/actions/pattern/duplicate-pattern.tsx b/packages/fields/src/actions/duplicate-pattern.tsx
similarity index 91%
rename from packages/fields/src/actions/pattern/duplicate-pattern.tsx
rename to packages/fields/src/actions/duplicate-pattern.tsx
index 7c71a271997f15..bf2820f951dbad 100644
--- a/packages/fields/src/actions/pattern/duplicate-pattern.tsx
+++ b/packages/fields/src/actions/duplicate-pattern.tsx
@@ -9,8 +9,8 @@ import type { Action } from '@wordpress/dataviews';
/**
* Internal dependencies
*/
-import { unlock } from '../../lock-unlock';
-import type { Pattern } from '../../types';
+import { unlock } from '../lock-unlock';
+import type { Pattern } from '../types';
// Patterns.
const { CreatePatternModalContents, useDuplicatePatternProps } =
diff --git a/packages/fields/src/actions/base-post/duplicate-post.native.tsx b/packages/fields/src/actions/duplicate-post.native.tsx
similarity index 100%
rename from packages/fields/src/actions/base-post/duplicate-post.native.tsx
rename to packages/fields/src/actions/duplicate-post.native.tsx
diff --git a/packages/fields/src/actions/base-post/duplicate-post.tsx b/packages/fields/src/actions/duplicate-post.tsx
similarity index 96%
rename from packages/fields/src/actions/base-post/duplicate-post.tsx
rename to packages/fields/src/actions/duplicate-post.tsx
index 0035a40c009342..d153073f4b6c12 100644
--- a/packages/fields/src/actions/base-post/duplicate-post.tsx
+++ b/packages/fields/src/actions/duplicate-post.tsx
@@ -18,9 +18,9 @@ import type { Action } from '@wordpress/dataviews';
/**
* Internal dependencies
*/
-import { titleField } from '../../fields';
-import type { BasePost, CoreDataError } from '../../types';
-import { getItemTitle } from '../utils';
+import { titleField } from '../fields';
+import type { BasePost, CoreDataError } from '../types';
+import { getItemTitle } from './utils';
const fields = [ titleField ];
const formDuplicateAction = {
diff --git a/packages/fields/src/actions/pattern/export-pattern.native.tsx b/packages/fields/src/actions/export-pattern.native.tsx
similarity index 100%
rename from packages/fields/src/actions/pattern/export-pattern.native.tsx
rename to packages/fields/src/actions/export-pattern.native.tsx
diff --git a/packages/fields/src/actions/pattern/export-pattern.tsx b/packages/fields/src/actions/export-pattern.tsx
similarity index 95%
rename from packages/fields/src/actions/pattern/export-pattern.tsx
rename to packages/fields/src/actions/export-pattern.tsx
index b0f6c3335544c1..b6be83eeda84b4 100644
--- a/packages/fields/src/actions/pattern/export-pattern.tsx
+++ b/packages/fields/src/actions/export-pattern.tsx
@@ -15,8 +15,8 @@ import type { Action } from '@wordpress/dataviews';
/**
* Internal dependencies
*/
-import type { Pattern } from '../../types';
-import { getItemTitle } from '../utils';
+import type { Pattern } from '../types';
+import { getItemTitle } from './utils';
function getJsonFromItem( item: Pattern ) {
return JSON.stringify(
diff --git a/packages/fields/src/actions/index.ts b/packages/fields/src/actions/index.ts
index cf4fd6833f3fbe..08e22836e68fd1 100644
--- a/packages/fields/src/actions/index.ts
+++ b/packages/fields/src/actions/index.ts
@@ -1,3 +1,15 @@
-export * from './base-post';
-export * from './common';
-export * from './pattern';
+export { default as viewPost } from './view-post';
+export { default as reorderPage } from './reorder-page';
+export { default as reorderPageNative } from './reorder-page.native';
+export { default as duplicatePost } from './duplicate-post';
+export { default as duplicatePostNative } from './duplicate-post.native';
+export { default as renamePost } from './rename-post';
+export { default as resetPost } from './reset-post';
+export { default as duplicatePattern } from './duplicate-pattern';
+export { default as exportPattern } from './export-pattern';
+export { default as exportPatternNative } from './export-pattern.native';
+export { default as viewPostRevisions } from './view-post-revisions';
+export { default as permanentlyDeletePost } from './permanently-delete-post';
+export { default as restorePost } from './restore-post';
+export { default as trashPost } from './trash-post';
+export { default as deletePost } from './delete-post';
diff --git a/packages/fields/src/actions/pattern/index.ts b/packages/fields/src/actions/pattern/index.ts
deleted file mode 100644
index 827c2ce365c2c5..00000000000000
--- a/packages/fields/src/actions/pattern/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export { default as duplicatePattern } from './duplicate-pattern';
-export { default as exportPattern } from './export-pattern';
-export { default as exportPatternNative } from './export-pattern.native';
diff --git a/packages/fields/src/actions/common/permanently-delete-post.tsx b/packages/fields/src/actions/permanently-delete-post.tsx
similarity index 96%
rename from packages/fields/src/actions/common/permanently-delete-post.tsx
rename to packages/fields/src/actions/permanently-delete-post.tsx
index e0c1de96871f1f..afbb84ae12c74c 100644
--- a/packages/fields/src/actions/common/permanently-delete-post.tsx
+++ b/packages/fields/src/actions/permanently-delete-post.tsx
@@ -10,8 +10,8 @@ import { trash } from '@wordpress/icons';
/**
* Internal dependencies
*/
-import { getItemTitle, isTemplateOrTemplatePart } from '../utils';
-import type { CoreDataError, PostWithPermissions } from '../../types';
+import { getItemTitle, isTemplateOrTemplatePart } from './utils';
+import type { CoreDataError, PostWithPermissions } from '../types';
const permanentlyDeletePost: Action< PostWithPermissions > = {
id: 'permanently-delete',
diff --git a/packages/editor/src/dataviews/actions/rename-post.tsx b/packages/fields/src/actions/rename-post.tsx
similarity index 97%
rename from packages/editor/src/dataviews/actions/rename-post.tsx
rename to packages/fields/src/actions/rename-post.tsx
index ef9da271111ea2..da1fd46669f0df 100644
--- a/packages/editor/src/dataviews/actions/rename-post.tsx
+++ b/packages/fields/src/actions/rename-post.tsx
@@ -19,17 +19,16 @@ import { store as noticesStore } from '@wordpress/notices';
/**
* Internal dependencies
*/
-import {
- TEMPLATE_ORIGINS,
- TEMPLATE_PART_POST_TYPE,
- TEMPLATE_POST_TYPE,
-} from '../../store/constants';
-import { unlock } from '../../lock-unlock';
+
+import { unlock } from '../lock-unlock';
import {
getItemTitle,
isTemplateRemovable,
isTemplate,
isTemplatePart,
+ TEMPLATE_ORIGINS,
+ TEMPLATE_PART_POST_TYPE,
+ TEMPLATE_POST_TYPE,
} from './utils';
import type { CoreDataError, PostWithPermissions } from '../types';
diff --git a/packages/fields/src/actions/base-post/reorder-page.native.tsx b/packages/fields/src/actions/reorder-page.native.tsx
similarity index 100%
rename from packages/fields/src/actions/base-post/reorder-page.native.tsx
rename to packages/fields/src/actions/reorder-page.native.tsx
diff --git a/packages/fields/src/actions/base-post/reorder-page.tsx b/packages/fields/src/actions/reorder-page.tsx
similarity index 96%
rename from packages/fields/src/actions/base-post/reorder-page.tsx
rename to packages/fields/src/actions/reorder-page.tsx
index 7f3bca59c471ce..1820884d8d8c73 100644
--- a/packages/fields/src/actions/base-post/reorder-page.tsx
+++ b/packages/fields/src/actions/reorder-page.tsx
@@ -17,8 +17,8 @@ import type { Action, RenderModalProps } from '@wordpress/dataviews';
/**
* Internal dependencies
*/
-import type { CoreDataError, BasePost } from '../../types';
-import { orderField } from '../../fields';
+import type { CoreDataError, BasePost } from '../types';
+import { orderField } from '../fields';
const fields = [ orderField ];
const formOrderAction = {
diff --git a/packages/fields/src/actions/reset-post.tsx b/packages/fields/src/actions/reset-post.tsx
new file mode 100644
index 00000000000000..105d7b283b8334
--- /dev/null
+++ b/packages/fields/src/actions/reset-post.tsx
@@ -0,0 +1,300 @@
+/**
+ * WordPress dependencies
+ */
+import { backup } from '@wordpress/icons';
+import { dispatch, select, useDispatch } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import { __, sprintf } from '@wordpress/i18n';
+import { store as noticesStore } from '@wordpress/notices';
+import { useState } from '@wordpress/element';
+// @ts-ignore
+import { parse, __unstableSerializeAndClean } from '@wordpress/blocks';
+import {
+ Button,
+ __experimentalText as Text,
+ __experimentalHStack as HStack,
+ __experimentalVStack as VStack,
+} from '@wordpress/components';
+import type { Action } from '@wordpress/dataviews';
+import { addQueryArgs } from '@wordpress/url';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import {
+ getItemTitle,
+ isTemplateOrTemplatePart,
+ TEMPLATE_ORIGINS,
+ TEMPLATE_POST_TYPE,
+} from './utils';
+import type { CoreDataError, Template, TemplatePart } from '../types';
+
+const isTemplateRevertable = (
+ templateOrTemplatePart: Template | TemplatePart
+) => {
+ if ( ! templateOrTemplatePart ) {
+ return false;
+ }
+
+ return (
+ templateOrTemplatePart.source === TEMPLATE_ORIGINS.custom &&
+ ( Boolean( templateOrTemplatePart?.plugin ) ||
+ templateOrTemplatePart?.has_theme_file )
+ );
+};
+
+/**
+ * Copied - pasted from https://github.com/WordPress/gutenberg/blob/bf1462ad37d4637ebbf63270b9c244b23c69e2a8/packages/editor/src/store/private-actions.js#L233-L365
+ *
+ * @param {Object} template The template to revert.
+ * @param {Object} [options]
+ * @param {boolean} [options.allowUndo] Whether to allow the user to undo
+ * reverting the template. Default true.
+ */
+const revertTemplate = async (
+ template: TemplatePart | Template,
+ { allowUndo = true } = {}
+) => {
+ const noticeId = 'edit-site-template-reverted';
+ dispatch( noticesStore ).removeNotice( noticeId );
+ if ( ! isTemplateRevertable( template ) ) {
+ dispatch( noticesStore ).createErrorNotice(
+ __( 'This template is not revertable.' ),
+ {
+ type: 'snackbar',
+ }
+ );
+ return;
+ }
+
+ try {
+ const templateEntityConfig = select( coreStore ).getEntityConfig(
+ 'postType',
+ template.type
+ );
+
+ if ( ! templateEntityConfig ) {
+ dispatch( noticesStore ).createErrorNotice(
+ __(
+ 'The editor has encountered an unexpected error. Please reload.'
+ ),
+ { type: 'snackbar' }
+ );
+ return;
+ }
+
+ const fileTemplatePath = addQueryArgs(
+ `${ templateEntityConfig.baseURL }/${ template.id }`,
+ { context: 'edit', source: template.origin }
+ );
+
+ const fileTemplate = ( await apiFetch( {
+ path: fileTemplatePath,
+ } ) ) as any;
+ if ( ! fileTemplate ) {
+ dispatch( noticesStore ).createErrorNotice(
+ __(
+ 'The editor has encountered an unexpected error. Please reload.'
+ ),
+ { type: 'snackbar' }
+ );
+ return;
+ }
+
+ const serializeBlocks = ( { blocks: blocksForSerialization = [] } ) =>
+ __unstableSerializeAndClean( blocksForSerialization );
+
+ const edited = select( coreStore ).getEditedEntityRecord(
+ 'postType',
+ template.type,
+ template.id
+ ) as any;
+
+ // We are fixing up the undo level here to make sure we can undo
+ // the revert in the header toolbar correctly.
+ dispatch( coreStore ).editEntityRecord(
+ 'postType',
+ template.type,
+ template.id,
+ {
+ content: serializeBlocks, // Required to make the `undo` behave correctly.
+ blocks: edited.blocks, // Required to revert the blocks in the editor.
+ source: 'custom', // required to avoid turning the editor into a dirty state
+ },
+ {
+ undoIgnore: true, // Required to merge this edit with the last undo level.
+ }
+ );
+
+ const blocks = parse( fileTemplate?.content?.raw );
+
+ dispatch( coreStore ).editEntityRecord(
+ 'postType',
+ template.type,
+ fileTemplate.id,
+ {
+ content: serializeBlocks,
+ blocks,
+ source: 'theme',
+ }
+ );
+
+ if ( allowUndo ) {
+ const undoRevert = () => {
+ dispatch( coreStore ).editEntityRecord(
+ 'postType',
+ template.type,
+ edited.id,
+ {
+ content: serializeBlocks,
+ blocks: edited.blocks,
+ source: 'custom',
+ }
+ );
+ };
+
+ dispatch( noticesStore ).createSuccessNotice(
+ __( 'Template reset.' ),
+ {
+ type: 'snackbar',
+ id: noticeId,
+ actions: [
+ {
+ label: __( 'Undo' ),
+ onClick: undoRevert,
+ },
+ ],
+ }
+ );
+ }
+ } catch ( error: any ) {
+ const errorMessage =
+ error.message && error.code !== 'unknown_error'
+ ? error.message
+ : __( 'Template revert failed. Please reload.' );
+
+ dispatch( noticesStore ).createErrorNotice( errorMessage, {
+ type: 'snackbar',
+ } );
+ }
+};
+
+const resetPostAction: Action< Template | TemplatePart > = {
+ id: 'reset-post',
+ label: __( 'Reset' ),
+ isEligible: ( item ) => {
+ return (
+ isTemplateOrTemplatePart( item ) &&
+ item?.source === TEMPLATE_ORIGINS.custom &&
+ ( Boolean( item.type === 'wp_template' && item?.plugin ) ||
+ item?.has_theme_file )
+ );
+ },
+ icon: backup,
+ supportsBulk: true,
+ hideModalHeader: true,
+ RenderModal: ( { items, closeModal, onActionPerformed } ) => {
+ const [ isBusy, setIsBusy ] = useState( false );
+
+ const { saveEditedEntityRecord } = useDispatch( coreStore );
+ const { createSuccessNotice, createErrorNotice } =
+ useDispatch( noticesStore );
+ const onConfirm = async () => {
+ try {
+ for ( const template of items ) {
+ await revertTemplate( template, {
+ allowUndo: false,
+ } );
+ await saveEditedEntityRecord(
+ 'postType',
+ template.type,
+ template.id
+ );
+ }
+ createSuccessNotice(
+ items.length > 1
+ ? sprintf(
+ /* translators: The number of items. */
+ __( '%s items reset.' ),
+ items.length
+ )
+ : sprintf(
+ /* translators: The template/part's name. */
+ __( '"%s" reset.' ),
+ getItemTitle( items[ 0 ] )
+ ),
+ {
+ type: 'snackbar',
+ id: 'revert-template-action',
+ }
+ );
+ } catch ( error ) {
+ let fallbackErrorMessage;
+ if ( items[ 0 ].type === TEMPLATE_POST_TYPE ) {
+ fallbackErrorMessage =
+ items.length === 1
+ ? __(
+ 'An error occurred while reverting the template.'
+ )
+ : __(
+ 'An error occurred while reverting the templates.'
+ );
+ } else {
+ fallbackErrorMessage =
+ items.length === 1
+ ? __(
+ 'An error occurred while reverting the template part.'
+ )
+ : __(
+ 'An error occurred while reverting the template parts.'
+ );
+ }
+
+ const typedError = error as CoreDataError;
+ const errorMessage =
+ typedError.message && typedError.code !== 'unknown_error'
+ ? typedError.message
+ : fallbackErrorMessage;
+
+ createErrorNotice( errorMessage, { type: 'snackbar' } );
+ }
+ };
+ return (
+
+
+ { __( 'Reset to default and clear all customizations?' ) }
+
+
+
+ { __( 'Cancel' ) }
+
+ {
+ setIsBusy( true );
+ await onConfirm();
+ onActionPerformed?.( items );
+ setIsBusy( false );
+ closeModal?.();
+ } }
+ isBusy={ isBusy }
+ disabled={ isBusy }
+ accessibleWhenDisabled
+ >
+ { __( 'Reset' ) }
+
+
+
+ );
+ },
+};
+
+export default resetPostAction;
diff --git a/packages/editor/src/dataviews/actions/restore-post.tsx b/packages/fields/src/actions/restore-post.tsx
similarity index 100%
rename from packages/editor/src/dataviews/actions/restore-post.tsx
rename to packages/fields/src/actions/restore-post.tsx
diff --git a/packages/editor/src/dataviews/actions/trash-post.tsx b/packages/fields/src/actions/trash-post.tsx
similarity index 100%
rename from packages/editor/src/dataviews/actions/trash-post.tsx
rename to packages/fields/src/actions/trash-post.tsx
diff --git a/packages/fields/src/actions/common/view-post-revisions.tsx b/packages/fields/src/actions/view-post-revisions.tsx
similarity index 96%
rename from packages/fields/src/actions/common/view-post-revisions.tsx
rename to packages/fields/src/actions/view-post-revisions.tsx
index 617a5263a707d6..875b925b94f070 100644
--- a/packages/fields/src/actions/common/view-post-revisions.tsx
+++ b/packages/fields/src/actions/view-post-revisions.tsx
@@ -8,7 +8,7 @@ import type { Action } from '@wordpress/dataviews';
/**
* Internal dependencies
*/
-import type { Post } from '../../types';
+import type { Post } from '../types';
const viewPostRevisions: Action< Post > = {
id: 'view-post-revisions',
diff --git a/packages/fields/src/actions/base-post/view-post.tsx b/packages/fields/src/actions/view-post.tsx
similarity index 92%
rename from packages/fields/src/actions/base-post/view-post.tsx
rename to packages/fields/src/actions/view-post.tsx
index 8c581877e473bb..187faffafb5d3c 100644
--- a/packages/fields/src/actions/base-post/view-post.tsx
+++ b/packages/fields/src/actions/view-post.tsx
@@ -8,7 +8,7 @@ import type { Action } from '@wordpress/dataviews';
/**
* Internal dependencies
*/
-import type { BasePost } from '../../types';
+import type { BasePost } from '../types';
const viewPost: Action< BasePost > = {
id: 'view-post',
diff --git a/packages/fields/src/index.native.ts b/packages/fields/src/index.native.ts
index e4d3134d72f847..33a26e3c2e6e27 100644
--- a/packages/fields/src/index.native.ts
+++ b/packages/fields/src/index.native.ts
@@ -1,2 +1,2 @@
-export * from './actions/base-post/duplicate-post.native';
-export * from './actions/base-post/reorder-page.native';
+export * from './actions/duplicate-post.native';
+export * from './actions/reorder-page.native';
diff --git a/packages/fields/src/mutation/index.ts b/packages/fields/src/mutation/index.ts
new file mode 100644
index 00000000000000..80e399d74e9479
--- /dev/null
+++ b/packages/fields/src/mutation/index.ts
@@ -0,0 +1,184 @@
+/**
+ * WordPress dependencies
+ */
+import { store as noticesStore } from '@wordpress/notices';
+import { store as coreStore } from '@wordpress/core-data';
+import { dispatch } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import type { CoreDataError, Post } from '../types';
+
+const getErrorMessagesFromPromises = < T >(
+ allSettledResults: PromiseSettledResult< T >[]
+) => {
+ const errorMessages = new Set< string >();
+ // If there was at lease one failure.
+ if ( allSettledResults.length === 1 ) {
+ const typedError = allSettledResults[ 0 ] as {
+ reason?: CoreDataError;
+ };
+ if ( typedError.reason?.message ) {
+ errorMessages.add( typedError.reason.message );
+ }
+ } else {
+ const failedPromises = allSettledResults.filter(
+ ( { status } ) => status === 'rejected'
+ );
+ for ( const failedPromise of failedPromises ) {
+ const typedError = failedPromise as {
+ reason?: CoreDataError;
+ };
+ if ( typedError.reason?.message ) {
+ errorMessages.add( typedError.reason.message );
+ }
+ }
+ }
+ return errorMessages;
+};
+
+export type NoticeSettings< T extends Post > = {
+ success: {
+ id?: string;
+ type?: string;
+ messages: {
+ getMessage: ( posts: T ) => string;
+ getBatchMessage: ( posts: T[] ) => string;
+ };
+ };
+ error: {
+ id?: string;
+ type?: string;
+ messages: {
+ getMessage: ( errors: Set< string > ) => string;
+ getBatchMessage: ( errors: Set< string > ) => string;
+ };
+ };
+};
+
+export const deletePostWithNotices = async < T extends Post >(
+ posts: T[],
+ notice: NoticeSettings< T >,
+ callbacks: {
+ onActionPerformed?: ( posts: T[] ) => void;
+ onActionError?: () => void;
+ }
+) => {
+ const { createSuccessNotice, createErrorNotice } = dispatch( noticesStore );
+ const { deleteEntityRecord } = dispatch( coreStore );
+ const allSettledResults = await Promise.allSettled(
+ posts.map( ( post ) => {
+ return deleteEntityRecord(
+ 'postType',
+ post.type,
+ post.id,
+ { force: true },
+ { throwOnError: true }
+ );
+ } )
+ );
+ // If all the promises were fulfilled with success.
+ if ( allSettledResults.every( ( { status } ) => status === 'fulfilled' ) ) {
+ let successMessage;
+ if ( allSettledResults.length === 1 ) {
+ successMessage = notice.success.messages.getMessage( posts[ 0 ] );
+ } else {
+ successMessage = notice.success.messages.getBatchMessage( posts );
+ }
+ createSuccessNotice( successMessage, {
+ type: notice.success.type ?? 'snackbar',
+ id: notice.success.id,
+ } );
+ callbacks.onActionPerformed?.( posts );
+ } else {
+ const errorMessages = getErrorMessagesFromPromises( allSettledResults );
+ let errorMessage = '';
+ if ( allSettledResults.length === 1 ) {
+ errorMessage = notice.error.messages.getMessage( errorMessages );
+ } else {
+ errorMessage =
+ notice.error.messages.getBatchMessage( errorMessages );
+ }
+
+ createErrorNotice( errorMessage, {
+ type: notice.error.type ?? 'snackbar',
+ id: notice.error.id,
+ } );
+ callbacks.onActionError?.();
+ }
+};
+
+export const editPostWithNotices = async < T extends Post >(
+ postsWithUpdates: {
+ originalPost: T;
+ changes: Partial< T >;
+ }[],
+ notice: NoticeSettings< T >,
+ callbacks: {
+ onActionPerformed?: ( posts: T[] ) => void;
+ onActionError?: () => void;
+ }
+) => {
+ const { createSuccessNotice, createErrorNotice } = dispatch( noticesStore );
+ const { editEntityRecord, saveEditedEntityRecord } = dispatch( coreStore );
+ await Promise.allSettled(
+ postsWithUpdates.map( ( post ) => {
+ return editEntityRecord(
+ 'postType',
+ post.originalPost.type,
+ post.originalPost.id,
+ {
+ ...post.changes,
+ }
+ );
+ } )
+ );
+ const allSettledResults = await Promise.allSettled(
+ postsWithUpdates.map( ( post ) => {
+ return saveEditedEntityRecord(
+ 'postType',
+ post.originalPost.type,
+ post.originalPost.id,
+ {
+ throwOnError: true,
+ }
+ );
+ } )
+ );
+ // If all the promises were fulfilled with success.
+ if ( allSettledResults.every( ( { status } ) => status === 'fulfilled' ) ) {
+ let successMessage;
+ if ( allSettledResults.length === 1 ) {
+ successMessage = notice.success.messages.getMessage(
+ postsWithUpdates[ 0 ].originalPost
+ );
+ } else {
+ successMessage = notice.success.messages.getBatchMessage(
+ postsWithUpdates.map( ( post ) => post.originalPost )
+ );
+ }
+ createSuccessNotice( successMessage, {
+ type: notice.success.type ?? 'snackbar',
+ id: notice.success.id,
+ } );
+ callbacks.onActionPerformed?.(
+ postsWithUpdates.map( ( post ) => post.originalPost )
+ );
+ } else {
+ const errorMessages = getErrorMessagesFromPromises( allSettledResults );
+ let errorMessage = '';
+ if ( allSettledResults.length === 1 ) {
+ errorMessage = notice.error.messages.getMessage( errorMessages );
+ } else {
+ errorMessage =
+ notice.error.messages.getBatchMessage( errorMessages );
+ }
+
+ createErrorNotice( errorMessage, {
+ type: notice.error.type ?? 'snackbar',
+ id: notice.error.id,
+ } );
+ callbacks.onActionError?.();
+ }
+};
diff --git a/packages/fields/src/types.ts b/packages/fields/src/types.ts
index 664c2dd417201c..a5ed9596b07dfd 100644
--- a/packages/fields/src/types.ts
+++ b/packages/fields/src/types.ts
@@ -54,6 +54,7 @@ export interface TemplatePart extends CommonPost {
has_theme_file: boolean;
id: string;
area: string;
+ plugin?: string;
}
export interface Pattern extends CommonPost {
diff --git a/packages/fields/src/wordpress-editor.d.ts b/packages/fields/src/wordpress-editor.d.ts
deleted file mode 100644
index 915dacd5f05a9c..00000000000000
--- a/packages/fields/src/wordpress-editor.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-declare module '@wordpress/editor';
diff --git a/packages/fields/tsconfig.json b/packages/fields/tsconfig.json
index c55be59acf40f0..69dbd076d05747 100644
--- a/packages/fields/tsconfig.json
+++ b/packages/fields/tsconfig.json
@@ -7,6 +7,7 @@
"checkJs": false
},
"references": [
+ { "path": "../api-fetch" },
{ "path": "../components" },
{ "path": "../compose" },
{ "path": "../data" },
@@ -24,6 +25,5 @@
{ "path": "../hooks" },
{ "path": "../html-entities" }
],
- "include": [ "src" ],
- "exclude": [ "@wordpress/editor" ]
+ "include": [ "src" ]
}
diff --git a/packages/hooks/CHANGELOG.md b/packages/hooks/CHANGELOG.md
index 0e162b64513d26..060e061b5c2843 100644
--- a/packages/hooks/CHANGELOG.md
+++ b/packages/hooks/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+### New Features
+
+- added new `doActionAsync` and `applyFiltersAsync` functions to run hooks in async mode ([#64204](https://github.com/WordPress/gutenberg/pull/64204)).
+
## 4.8.0 (2024-09-19)
## 4.7.0 (2024-09-05)
diff --git a/packages/hooks/README.md b/packages/hooks/README.md
index 3e9897c79952cd..f80d2e63af37ba 100644
--- a/packages/hooks/README.md
+++ b/packages/hooks/README.md
@@ -41,7 +41,9 @@ One notable difference between the JS and PHP hooks API is that in the JS versio
- `removeAllActions( 'hookName' )`
- `removeAllFilters( 'hookName' )`
- `doAction( 'hookName', arg1, arg2, moreArgs, finalArg )`
+- `doActionAsync( 'hookName', arg1, arg2, moreArgs, finalArg )`
- `applyFilters( 'hookName', content, arg1, arg2, moreArgs, finalArg )`
+- `applyFiltersAsync( 'hookName', content, arg1, arg2, moreArgs, finalArg )`
- `doingAction( 'hookName' )`
- `doingFilter( 'hookName' )`
- `didAction( 'hookName' )`
diff --git a/packages/hooks/src/createCurrentHook.js b/packages/hooks/src/createCurrentHook.js
index 634901fe55f63a..3ada0322496004 100644
--- a/packages/hooks/src/createCurrentHook.js
+++ b/packages/hooks/src/createCurrentHook.js
@@ -11,11 +11,8 @@
function createCurrentHook( hooks, storeKey ) {
return function currentHook() {
const hooksStore = hooks[ storeKey ];
-
- return (
- hooksStore.__current[ hooksStore.__current.length - 1 ]?.name ??
- null
- );
+ const currentArray = Array.from( hooksStore.__current );
+ return currentArray.at( -1 )?.name ?? null;
};
}
diff --git a/packages/hooks/src/createDoingHook.js b/packages/hooks/src/createDoingHook.js
index 652ab06b4ba728..9fccf38171f332 100644
--- a/packages/hooks/src/createDoingHook.js
+++ b/packages/hooks/src/createDoingHook.js
@@ -24,13 +24,13 @@ function createDoingHook( hooks, storeKey ) {
// If the hookName was not passed, check for any current hook.
if ( 'undefined' === typeof hookName ) {
- return 'undefined' !== typeof hooksStore.__current[ 0 ];
+ return hooksStore.__current.size > 0;
}
- // Return the __current hook.
- return hooksStore.__current[ 0 ]
- ? hookName === hooksStore.__current[ 0 ].name
- : false;
+ // Find if the `hookName` hook is in `__current`.
+ return Array.from( hooksStore.__current ).some(
+ ( hook ) => hook.name === hookName
+ );
};
}
diff --git a/packages/hooks/src/createHooks.js b/packages/hooks/src/createHooks.js
index 361383a3a97fc9..1f9b1a8206b020 100644
--- a/packages/hooks/src/createHooks.js
+++ b/packages/hooks/src/createHooks.js
@@ -20,11 +20,11 @@ export class _Hooks {
constructor() {
/** @type {import('.').Store} actions */
this.actions = Object.create( null );
- this.actions.__current = [];
+ this.actions.__current = new Set();
/** @type {import('.').Store} filters */
this.filters = Object.create( null );
- this.filters.__current = [];
+ this.filters.__current = new Set();
this.addAction = createAddHook( this, 'actions' );
this.addFilter = createAddHook( this, 'filters' );
@@ -34,8 +34,10 @@ export class _Hooks {
this.hasFilter = createHasHook( this, 'filters' );
this.removeAllActions = createRemoveHook( this, 'actions', true );
this.removeAllFilters = createRemoveHook( this, 'filters', true );
- this.doAction = createRunHook( this, 'actions' );
- this.applyFilters = createRunHook( this, 'filters', true );
+ this.doAction = createRunHook( this, 'actions', false, false );
+ this.doActionAsync = createRunHook( this, 'actions', false, true );
+ this.applyFilters = createRunHook( this, 'filters', true, false );
+ this.applyFiltersAsync = createRunHook( this, 'filters', true, true );
this.currentAction = createCurrentHook( this, 'actions' );
this.currentFilter = createCurrentHook( this, 'filters' );
this.doingAction = createDoingHook( this, 'actions' );
diff --git a/packages/hooks/src/createRunHook.js b/packages/hooks/src/createRunHook.js
index c2bf6fd187ce08..f2a56dbdc0d717 100644
--- a/packages/hooks/src/createRunHook.js
+++ b/packages/hooks/src/createRunHook.js
@@ -3,15 +3,15 @@
* registered to a hook of the specified type, optionally returning the final
* value of the call chain.
*
- * @param {import('.').Hooks} hooks Hooks instance.
+ * @param {import('.').Hooks} hooks Hooks instance.
* @param {import('.').StoreKey} storeKey
- * @param {boolean} [returnFirstArg=false] Whether each hook callback is expected to
- * return its first argument.
+ * @param {boolean} returnFirstArg Whether each hook callback is expected to return its first argument.
+ * @param {boolean} async Whether the hook callback should be run asynchronously
*
* @return {(hookName:string, ...args: unknown[]) => undefined|unknown} Function that runs hook callbacks.
*/
-function createRunHook( hooks, storeKey, returnFirstArg = false ) {
- return function runHooks( hookName, ...args ) {
+function createRunHook( hooks, storeKey, returnFirstArg, async ) {
+ return function runHook( hookName, ...args ) {
const hooksStore = hooks[ storeKey ];
if ( ! hooksStore[ hookName ] ) {
@@ -42,26 +42,43 @@ function createRunHook( hooks, storeKey, returnFirstArg = false ) {
currentIndex: 0,
};
- hooksStore.__current.push( hookInfo );
-
- while ( hookInfo.currentIndex < handlers.length ) {
- const handler = handlers[ hookInfo.currentIndex ];
-
- const result = handler.callback.apply( null, args );
- if ( returnFirstArg ) {
- args[ 0 ] = result;
+ async function asyncRunner() {
+ try {
+ hooksStore.__current.add( hookInfo );
+ let result = returnFirstArg ? args[ 0 ] : undefined;
+ while ( hookInfo.currentIndex < handlers.length ) {
+ const handler = handlers[ hookInfo.currentIndex ];
+ result = await handler.callback.apply( null, args );
+ if ( returnFirstArg ) {
+ args[ 0 ] = result;
+ }
+ hookInfo.currentIndex++;
+ }
+ return returnFirstArg ? result : undefined;
+ } finally {
+ hooksStore.__current.delete( hookInfo );
}
-
- hookInfo.currentIndex++;
}
- hooksStore.__current.pop();
-
- if ( returnFirstArg ) {
- return args[ 0 ];
+ function syncRunner() {
+ try {
+ hooksStore.__current.add( hookInfo );
+ let result = returnFirstArg ? args[ 0 ] : undefined;
+ while ( hookInfo.currentIndex < handlers.length ) {
+ const handler = handlers[ hookInfo.currentIndex ];
+ result = handler.callback.apply( null, args );
+ if ( returnFirstArg ) {
+ args[ 0 ] = result;
+ }
+ hookInfo.currentIndex++;
+ }
+ return returnFirstArg ? result : undefined;
+ } finally {
+ hooksStore.__current.delete( hookInfo );
+ }
}
- return undefined;
+ return ( async ? asyncRunner : syncRunner )();
};
}
diff --git a/packages/hooks/src/index.js b/packages/hooks/src/index.js
index 653a9537145d91..1d13397e406c6b 100644
--- a/packages/hooks/src/index.js
+++ b/packages/hooks/src/index.js
@@ -25,7 +25,7 @@ import createHooks from './createHooks';
*/
/**
- * @typedef {Record & {__current: Current[]}} Store
+ * @typedef {Record & {__current: Set}} Store
*/
/**
@@ -48,7 +48,9 @@ const {
removeAllActions,
removeAllFilters,
doAction,
+ doActionAsync,
applyFilters,
+ applyFiltersAsync,
currentAction,
currentFilter,
doingAction,
@@ -70,7 +72,9 @@ export {
removeAllActions,
removeAllFilters,
doAction,
+ doActionAsync,
applyFilters,
+ applyFiltersAsync,
currentAction,
currentFilter,
doingAction,
diff --git a/packages/hooks/src/test/index.test.js b/packages/hooks/src/test/index.test.js
index 9b7eb3b8e0e223..5fdaf5fc7207a1 100644
--- a/packages/hooks/src/test/index.test.js
+++ b/packages/hooks/src/test/index.test.js
@@ -12,7 +12,9 @@ import {
removeAllActions,
removeAllFilters,
doAction,
+ doActionAsync,
applyFilters,
+ applyFiltersAsync,
currentAction,
currentFilter,
doingAction,
@@ -943,3 +945,151 @@ test( 'checking hasFilter with named callbacks and removeAllActions', () => {
expect( hasFilter( 'test.filter', 'my_callback' ) ).toBe( false );
expect( hasFilter( 'test.filter', 'my_second_callback' ) ).toBe( false );
} );
+
+describe( 'async filter', () => {
+ test( 'runs all registered handlers', async () => {
+ addFilter( 'test.async.filter', 'callback_plus1', ( value ) => {
+ return new Promise( ( r ) =>
+ setTimeout( () => r( value + 1 ), 10 )
+ );
+ } );
+ addFilter( 'test.async.filter', 'callback_times2', ( value ) => {
+ return new Promise( ( r ) =>
+ setTimeout( () => r( value * 2 ), 10 )
+ );
+ } );
+
+ expect( await applyFiltersAsync( 'test.async.filter', 2 ) ).toBe( 6 );
+ } );
+
+ test( 'aborts when handler throws an error', async () => {
+ const sqrt = jest.fn( async ( value ) => {
+ if ( value < 0 ) {
+ throw new Error( 'cannot pass negative value to sqrt' );
+ }
+ return Math.sqrt( value );
+ } );
+
+ const plus1 = jest.fn( async ( value ) => {
+ return value + 1;
+ } );
+
+ addFilter( 'test.async.filter', 'callback_sqrt', sqrt );
+ addFilter( 'test.async.filter', 'callback_plus1', plus1 );
+
+ await expect(
+ applyFiltersAsync( 'test.async.filter', -1 )
+ ).rejects.toThrow( 'cannot pass negative value to sqrt' );
+ expect( sqrt ).toHaveBeenCalledTimes( 1 );
+ expect( plus1 ).not.toHaveBeenCalled();
+ } );
+
+ test( 'is correctly tracked by doingFilter and didFilter', async () => {
+ addFilter( 'test.async.filter', 'callback_doing', async ( value ) => {
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ expect( doingFilter( 'test.async.filter' ) ).toBe( true );
+ return value;
+ } );
+
+ expect( doingFilter( 'test.async.filter' ) ).toBe( false );
+ expect( didFilter( 'test.async.filter' ) ).toBe( 0 );
+ await applyFiltersAsync( 'test.async.filter', 0 );
+ expect( doingFilter( 'test.async.filter' ) ).toBe( false );
+ expect( didFilter( 'test.async.filter' ) ).toBe( 1 );
+ } );
+
+ test( 'is correctly tracked when multiple filters run at once', async () => {
+ addFilter( 'test.async.filter1', 'callback_doing', async ( value ) => {
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ expect( doingFilter( 'test.async.filter1' ) ).toBe( true );
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ return value;
+ } );
+ addFilter( 'test.async.filter2', 'callback_doing', async ( value ) => {
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ expect( doingFilter( 'test.async.filter2' ) ).toBe( true );
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ return value;
+ } );
+
+ await Promise.all( [
+ applyFiltersAsync( 'test.async.filter1', 0 ),
+ applyFiltersAsync( 'test.async.filter2', 0 ),
+ ] );
+ } );
+} );
+
+describe( 'async action', () => {
+ test( 'runs all registered handlers sequentially', async () => {
+ const outputs = [];
+ const action1 = async () => {
+ outputs.push( 1 );
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ outputs.push( 2 );
+ };
+
+ const action2 = async () => {
+ outputs.push( 3 );
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ outputs.push( 4 );
+ };
+
+ addAction( 'test.async.action', 'action1', action1 );
+ addAction( 'test.async.action', 'action2', action2 );
+
+ await doActionAsync( 'test.async.action' );
+ expect( outputs ).toEqual( [ 1, 2, 3, 4 ] );
+ } );
+
+ test( 'aborts when handler throws an error', async () => {
+ const outputs = [];
+ const action1 = async () => {
+ throw new Error( 'aborting' );
+ };
+
+ const action2 = async () => {
+ outputs.push( 3 );
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ outputs.push( 4 );
+ };
+
+ addAction( 'test.async.action', 'action1', action1 );
+ addAction( 'test.async.action', 'action2', action2 );
+
+ await expect( doActionAsync( 'test.async.action' ) ).rejects.toThrow(
+ 'aborting'
+ );
+ expect( outputs ).toEqual( [] );
+ } );
+
+ test( 'is correctly tracked by doingAction and didAction', async () => {
+ addAction( 'test.async.action', 'callback_doing', async () => {
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ expect( doingAction( 'test.async.action' ) ).toBe( true );
+ } );
+
+ expect( doingAction( 'test.async.action' ) ).toBe( false );
+ expect( didAction( 'test.async.action' ) ).toBe( 0 );
+ await doActionAsync( 'test.async.action', 0 );
+ expect( doingAction( 'test.async.action' ) ).toBe( false );
+ expect( didAction( 'test.async.action' ) ).toBe( 1 );
+ } );
+
+ test( 'is correctly tracked when multiple actions run at once', async () => {
+ addAction( 'test.async.action1', 'callback_doing', async () => {
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ expect( doingAction( 'test.async.action1' ) ).toBe( true );
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ } );
+ addAction( 'test.async.action2', 'callback_doing', async () => {
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ expect( doingAction( 'test.async.action2' ) ).toBe( true );
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ } );
+
+ await Promise.all( [
+ doActionAsync( 'test.async.action1', 0 ),
+ doActionAsync( 'test.async.action2', 0 ),
+ ] );
+ } );
+} );
diff --git a/packages/icons/CHANGELOG.md b/packages/icons/CHANGELOG.md
index be047e2181d4a5..ddf850dd116819 100644
--- a/packages/icons/CHANGELOG.md
+++ b/packages/icons/CHANGELOG.md
@@ -6,7 +6,10 @@
### New Features
+- Add new `envelope` icon.
+
- Add new `bell` and `bell-unread` icons.
+- Add new `arrowUpLeft` and `arrowDownRight` icons.
## 10.7.0 (2024-09-05)
diff --git a/packages/icons/src/icon/stories/index.story.js b/packages/icons/src/icon/stories/index.story.js
index 8fda801f23884f..092434de43b4dc 100644
--- a/packages/icons/src/icon/stories/index.story.js
+++ b/packages/icons/src/icon/stories/index.story.js
@@ -47,7 +47,7 @@ const LibraryExample = () => {
const filteredIcons = filter.length
? Object.fromEntries(
Object.entries( availableIcons ).filter( ( [ name ] ) =>
- name.includes( filter )
+ name.toLowerCase().includes( filter.toLowerCase() )
)
)
: availableIcons;
diff --git a/packages/icons/src/index.js b/packages/icons/src/index.js
index 9ab41bd3620279..586911ffc746b2 100644
--- a/packages/icons/src/index.js
+++ b/packages/icons/src/index.js
@@ -10,9 +10,11 @@ export { default as alignNone } from './library/align-none';
export { default as alignRight } from './library/align-right';
export { default as archive } from './library/archive';
export { default as arrowDown } from './library/arrow-down';
+export { default as arrowDownRight } from './library/arrow-down-right';
export { default as arrowLeft } from './library/arrow-left';
export { default as arrowRight } from './library/arrow-right';
export { default as arrowUp } from './library/arrow-up';
+export { default as arrowUpLeft } from './library/arrow-up-left';
export { default as atSymbol } from './library/at-symbol';
export { default as aspectRatio } from './library/aspect-ratio';
export { default as audio } from './library/audio';
@@ -79,6 +81,7 @@ export { default as drawerLeft } from './library/drawer-left';
export { default as drawerRight } from './library/drawer-right';
export { default as download } from './library/download';
export { default as edit } from './library/edit';
+export { default as envelope } from './library/envelope';
export { default as external } from './library/external';
export { default as file } from './library/file';
export { default as filter } from './library/filter';
diff --git a/packages/icons/src/library/arrow-down-right.js b/packages/icons/src/library/arrow-down-right.js
new file mode 100644
index 00000000000000..3755b63873cefc
--- /dev/null
+++ b/packages/icons/src/library/arrow-down-right.js
@@ -0,0 +1,12 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/primitives';
+
+const arrowDownRight = (
+
+
+
+);
+
+export default arrowDownRight;
diff --git a/packages/icons/src/library/arrow-up-left.js b/packages/icons/src/library/arrow-up-left.js
new file mode 100644
index 00000000000000..1b3686f6ec1e62
--- /dev/null
+++ b/packages/icons/src/library/arrow-up-left.js
@@ -0,0 +1,12 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/primitives';
+
+const arrowUpLeft = (
+
+
+
+);
+
+export default arrowUpLeft;
diff --git a/packages/icons/src/library/envelope.js b/packages/icons/src/library/envelope.js
new file mode 100644
index 00000000000000..45064b35785ec1
--- /dev/null
+++ b/packages/icons/src/library/envelope.js
@@ -0,0 +1,16 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/primitives';
+
+const envelope = (
+
+
+
+);
+
+export default envelope;
diff --git a/packages/interactivity-router/README.md b/packages/interactivity-router/README.md
index 94b88e80886c90..efb52e59be2b5d 100644
--- a/packages/interactivity-router/README.md
+++ b/packages/interactivity-router/README.md
@@ -1,21 +1,32 @@
-# Interactivity Router
+# `@wordpress/interactivity-router`
-> **Note**
-> This package is a extension of the API shared at [Proposal: The Interactivity API ā A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/). As part of an [Open Source project](https://developer.wordpress.org/block-editor/getting-started/faq/#the-gutenberg-project) we encourage participation in helping shape this API and the [discussions in GitHub](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) is the best place to engage.
+The package `@wordpress/interactivity-router` enables loading content from other pages without a full page reload. Currently, the only supported mode is "region-based". Full "client-side navigation" is still in experimental phase.
-This package defines an Interactivity API store with the `core/router` namespace, exposing state and actions like `navigate` and `prefetch` to handle client-side navigations.
+The package defines an Interactivity API store with the `core/router` namespace, exposing state and 2 actions: `navigate` and `prefetch` to handle client-side navigation.
+
+The `@wordpress/interactivity-router` package was [introduced in WordPress Core in v6.5](https://make.wordpress.org/core/2024/02/19/merge-announcement-interactivity-api/). This means this package is already bundled in Core in any version of WordPress higher than v6.5.
+
+
## Usage
-The package is intended to be imported dynamically in the `view.js` files of interactive blocks.
+The package is intended to be imported dynamically in the `view.js` files of interactive blocks. This is done in in order to reduce the JS bundle size on the initial page load.
```js
+/* view.js */
+
import { store } from '@wordpress/interactivity';
-store( 'myblock', {
+// This is how you would typically use the navigate() action in your block.
+store( 'my-namespace/myblock', {
actions: {
- *navigate( e ) {
+ *goToPage( e ) {
e.preventDefault();
+
+ // We import the package dynamically to reduce the initial JS bundle size.
+ // Async actions are defined as generators so the import() must be called with `yield`.
const { actions } = yield import(
'@wordpress/interactivity-router'
);
@@ -25,52 +36,116 @@ store( 'myblock', {
} );
```
-## Frequently Asked Questions
+Now, you can call `actions.navigate()` in your block's `view.js` file to navigate to a different page or e.g. pass it to a `data-wp-on--click` attribute.
+
+When loaded, this package [adds the following state and actions](https://github.com/WordPress/gutenberg/blob/ed7d78652526270b63976d7a970dba46a2bfcbb0/packages/interactivity-router/src/index.ts#L212) to the `core/router` store:
+
+```js
+const { state, actions } = store( 'core/router', {
+ state: {
+ url: window.location.href,
+ navigation: {
+ hasStarted: false,
+ hasFinished: false,
+ texts: {
+ loading: '',
+ loaded: '',
+ },
+ message: '',
+ },
+ },
+ actions: {
+ *navigate(href, options) {...},
+ prefetch(url, options) {...},
+ }
+})
+```
+
+
+
+### Directives
+
+#### `data-wp-router-region`
+
+It defines a region that is updated on navigation. It requires a unique ID as the value and can only be used in root interactive elements, i.e., elements with `data-wp-interactive` that are not nested inside other elements with `data-wp-interactive`.
+
+Example:
+
+```html
+
+```
+
+### Actions
+
+#### `navigate`
+
+Navigates to the specified page.
-At this point, some of the questions you have about the Interactivity API may be:
+This function normalizes the passed `href`, fetches the page HTML if needed, and updates any interactive regions whose contents have changed in the new page. It also creates a new entry in the browser session history.
-### What is this?
+**Params**
-This is the base of a new standard to create interactive blocks. Read [the proposal](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/) to learn more about this.
+```js
+navigate( href: string, options: NavigateOptions = {} )
+```
-### Can I use it?
+- `href`: The page `href`.
+- `options`: Options object.
+ - `force`: If `true`, it forces re-fetching the URL. `navigate()` always caches the page, so if the page has been navigated to before, it will be used. Default is `false`.
+ - `html`: HTML string to be used instead of fetching the requested URL.
+ - `replace`: If `true`, it replaces the current entry in the browser session history. Default is `false`.
+ - `timeout`: Time until the navigation is aborted, in milliseconds. Default is `10000`.
+ - `loadingAnimation`: Whether an animation should be shown while navigating. Default to `true`.
+ - `screenReaderAnnouncement`: Whether a message for screen readers should be announced while navigating. Default to `true`.
-You can test it, but it's still very experimental.
+#### `prefetch`
-### How do I get started?
+Prefetches the page for the passed URL. The page is cached and can be used for navigation.
-The best place to start with the Interactivity API is this [**Getting started guide**](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/1-getting-started.md). There you'll will find a very quick start guide and the current requirements of the Interactivity API.
+The function normalizes the URL and stores internally the fetch promise, to avoid triggering a second fetch for an ongoing request.
-### Where can I ask questions?
+**Params**
-The [āInteractivity APIā category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) in Gutenberg repo discussions is the best place to ask questions about the Interactivity API.
+```js
+prefetch( url: string, options: PrefetchOptions = {} )
+```
-### Where can I share my feedback about the API?
+- `url`: The page `url`.
+- `options`: Options object.
-The [āInteractivity APIā category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) in Gutenberg repo discussions is also the best place to share your feedback about the Interactivity API.
+ - `force`: If `true`, forces fetching the URL again.
+ - `html`: HTML string to be used instead of fetching the requested URL.
+
+### State
+
+`state.url` is a reactive property synchronized with the current URL.
+Properties under `state.navigation` are meant for loading bar animations.
## Installation
Install the module:
```bash
-npm install @wordpress/interactivity --save
+npm install @wordpress/interactivity-router --save
```
-_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._
-
-## Docs & Examples
+This step is only required if you use the Interactivity API outside WordPress.
-**[Interactivity API Documentation](https://github.com/WordPress/gutenberg/tree/trunk/packages/interactivity/docs)** is the best place to learn about this proposal. Although it's still in progress, some key pages are already available:
+Within WordPress, the package is already bundled in Core. To ensure it's enqueued, add `@wordpress/interactivity-router` to the dependency array of the script module. This process is often done automatically with tools like [`wp-scripts`](https://developer.wordpress.org/block-editor/getting-started/devenv/get-started-with-wp-scripts/).
-- **[Getting Started Guide](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/1-getting-started.md)**: Follow this Getting Started guide to learn how to scaffold a new project and create your first interactive blocks.
-- **[API Reference](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/2-api-reference.md)**: Check this page for technical detailed explanations and examples of the directives and the store.
+Furthermore, this package assumes your code will run in an **ES2015+** environment. If you're using an environment with limited or no support for such language features and APIs, you should include the polyfill shipped in [`@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code.
-Here you have some more resources to learn/read more about the Interactivity API:
+## License
-- **[Interactivity API Discussions](https://github.com/WordPress/gutenberg/discussions/52882)**
-- [Proposal: The Interactivity API ā A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/)
-- Developer Hours sessions ([Americas](https://www.youtube.com/watch?v=RXNoyP2ZiS8&t=664s) & [APAC/EMEA](https://www.youtube.com/watch?v=6ghbrhyAcvA))
-- [wpmovies.dev](http://wpmovies.dev/) demo and its [wp-movies-demo](https://github.com/WordPress/wp-movies-demo) repo
+Interactivity API proposal, as part of Gutenberg and the WordPress project is free software, and is released under the terms of the GNU General Public License version 2 or (at your option) any later version. See [LICENSE.md](https://github.com/WordPress/gutenberg/blob/trunk/LICENSE.md) for complete license.
-
+
diff --git a/packages/interactivity-router/src/index.ts b/packages/interactivity-router/src/index.ts
index 3bd44c7aebd71f..b2e8e2d4395dcd 100644
--- a/packages/interactivity-router/src/index.ts
+++ b/packages/interactivity-router/src/index.ts
@@ -221,11 +221,6 @@ interface Store {
navigation: {
hasStarted: boolean;
hasFinished: boolean;
- message: string;
- texts?: {
- loading?: string;
- loaded?: string;
- };
};
};
actions: {
@@ -240,7 +235,6 @@ export const { state, actions } = store< Store >( 'core/router', {
navigation: {
hasStarted: false,
hasFinished: false,
- message: '',
},
},
actions: {
@@ -403,10 +397,16 @@ function a11ySpeak( messageKey: keyof typeof navigationTexts ) {
} catch {}
} else {
// Fallback to localized strings from Interactivity API state.
+ // @todo This block is for Core < 6.7.0. Remove when support is dropped.
+
+ // @ts-expect-error
if ( state.navigation.texts?.loading ) {
+ // @ts-expect-error
navigationTexts.loading = state.navigation.texts.loading;
}
+ // @ts-expect-error
if ( state.navigation.texts?.loaded ) {
+ // @ts-expect-error
navigationTexts.loaded = state.navigation.texts.loaded;
}
}
@@ -414,19 +414,11 @@ function a11ySpeak( messageKey: keyof typeof navigationTexts ) {
const message = navigationTexts[ messageKey ];
- if ( globalThis.IS_GUTENBERG_PLUGIN ) {
- import( '@wordpress/a11y' ).then(
- ( { speak } ) => speak( message ),
- // Ignore failures to load the a11y module.
- () => {}
- );
- } else {
- state.navigation.message =
- // Announce that the page has been loaded. If the message is the
- // same, we use a no-break space similar to the @wordpress/a11y
- // package: https://github.com/WordPress/gutenberg/blob/c395242b8e6ee20f8b06c199e4fc2920d7018af1/packages/a11y/src/filter-message.js#L20-L26
- message + ( state.navigation.message === message ? '\u00A0' : '' );
- }
+ import( '@wordpress/a11y' ).then(
+ ( { speak } ) => speak( message ),
+ // Ignore failures to load the a11y module.
+ () => {}
+ );
}
// Add click and prefetch to all links.
diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md
index 6989bcdc0c802c..42f311973709dd 100644
--- a/packages/interactivity/CHANGELOG.md
+++ b/packages/interactivity/CHANGELOG.md
@@ -6,6 +6,7 @@
### Enhancements
+- Improve TypeScript support for generators ([#64577](https://github.com/WordPress/gutenberg/pull/64577)).
- Refactor internal context proxies implementation ([#64713](https://github.com/WordPress/gutenberg/pull/64713)).
### Bug Fixes
diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx
index cde39d830499a2..340880954683da 100644
--- a/packages/interactivity/src/directives.tsx
+++ b/packages/interactivity/src/directives.tsx
@@ -142,14 +142,19 @@ export default () => {
const defaultEntry = context.find(
( { suffix } ) => suffix === 'default'
);
- const inheritedValue = useContext( inheritedContext );
+ const { client: inheritedClient, server: inheritedServer } =
+ useContext( inheritedContext );
const ns = defaultEntry!.namespace;
- const currentValue = useRef( proxifyState( ns, {} ) );
+ const client = useRef( proxifyState( ns, {} ) );
+ const server = useRef( proxifyState( ns, {}, { readOnly: true } ) );
// No change should be made if `defaultEntry` does not exist.
const contextStack = useMemo( () => {
- const result = { ...inheritedValue };
+ const result = {
+ client: { ...inheritedClient },
+ server: { ...inheritedServer },
+ };
if ( defaultEntry ) {
const { namespace, value } = defaultEntry;
// Check that the value is a JSON object. Send a console warning if not.
@@ -159,17 +164,22 @@ export default () => {
);
}
deepMerge(
- currentValue.current,
+ client.current,
deepClone( value ) as object,
false
);
- result[ namespace ] = proxifyContext(
- currentValue.current,
- inheritedValue[ namespace ]
+ deepMerge( server.current, deepClone( value ) as object );
+ result.client[ namespace ] = proxifyContext(
+ client.current,
+ inheritedClient[ namespace ]
+ );
+ result.server[ namespace ] = proxifyContext(
+ server.current,
+ inheritedServer[ namespace ]
);
}
return result;
- }, [ defaultEntry, inheritedValue ] );
+ }, [ defaultEntry, inheritedClient, inheritedServer ] );
return createElement( Provider, { value: contextStack }, children );
},
@@ -563,17 +573,24 @@ export default () => {
suffix === 'default' ? 'item' : kebabToCamelCase( suffix );
const itemContext = proxifyContext(
proxifyState( namespace, {} ),
- inheritedValue[ namespace ]
+ inheritedValue.client[ namespace ]
);
const mergedContext = {
- ...inheritedValue,
- [ namespace ]: itemContext,
+ client: {
+ ...inheritedValue.client,
+ [ namespace ]: itemContext,
+ },
+ server: { ...inheritedValue.server },
};
// Set the item after proxifying the context.
- mergedContext[ namespace ][ itemProp ] = item;
+ mergedContext.client[ namespace ][ itemProp ] = item;
- const scope = { ...getScope(), context: mergedContext };
+ const scope = {
+ ...getScope(),
+ context: mergedContext.client,
+ serverContext: mergedContext.server,
+ };
const key = eachKey
? getEvaluate( { scope } )( eachKey[ 0 ] )
: item;
diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx
index 215da8afef9b5b..6b55ec014aa799 100644
--- a/packages/interactivity/src/hooks.tsx
+++ b/packages/interactivity/src/hooks.tsx
@@ -93,7 +93,7 @@ interface DirectivesProps {
}
// Main context.
-const context = createContext< any >( {} );
+const context = createContext< any >( { client: {}, server: {} } );
// WordPress Directives.
const directiveCallbacks: Record< string, DirectiveCallback > = {};
@@ -190,9 +190,13 @@ const resolve = ( path: string, namespace: string ) => {
}
let resolvedStore = stores.get( namespace );
if ( typeof resolvedStore === 'undefined' ) {
- resolvedStore = store( namespace, undefined, {
- lock: universalUnlock,
- } );
+ resolvedStore = store(
+ namespace,
+ {},
+ {
+ lock: universalUnlock,
+ }
+ );
}
const current = {
...resolvedStore,
@@ -253,7 +257,9 @@ const Directives = ( {
// element ref, state and props.
const scope = useRef< Scope >( {} as Scope ).current;
scope.evaluate = useCallback( getEvaluate( { scope } ), [] );
- scope.context = useContext( context );
+ const { client, server } = useContext( context );
+ scope.context = client;
+ scope.serverContext = server;
/* eslint-disable react-hooks/rules-of-hooks */
scope.ref = previousScope?.ref || useRef( null );
/* eslint-enable react-hooks/rules-of-hooks */
diff --git a/packages/interactivity/src/index.ts b/packages/interactivity/src/index.ts
index 336c2a97226db7..9d013e4e744ed5 100644
--- a/packages/interactivity/src/index.ts
+++ b/packages/interactivity/src/index.ts
@@ -16,8 +16,8 @@ import { getNamespace } from './namespaces';
import { parseServerData, populateServerData } from './store';
import { proxifyState } from './proxies';
-export { store, getConfig } from './store';
-export { getContext, getElement } from './scopes';
+export { store, getConfig, getServerState } from './store';
+export { getContext, getServerContext, getElement } from './scopes';
export {
withScope,
useWatch,
diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts
index ec49c4b27c4adb..c91d8f6ab90a5b 100644
--- a/packages/interactivity/src/proxies/state.ts
+++ b/packages/interactivity/src/proxies/state.ts
@@ -46,6 +46,8 @@ const proxyToProps: WeakMap<
export const hasPropSignal = ( proxy: object, key: string ) =>
proxyToProps.has( proxy ) && proxyToProps.get( proxy )!.has( key );
+const readOnlyProxies = new WeakSet();
+
/**
* Returns the {@link PropSignal | `PropSignal`} instance associated with the
* specified prop in the passed proxy.
@@ -77,8 +79,11 @@ const getPropSignal = (
if ( get ) {
prop.setGetter( get );
} else {
+ const readOnly = readOnlyProxies.has( proxy );
prop.setValue(
- shouldProxy( value ) ? proxifyState( ns, value ) : value
+ shouldProxy( value )
+ ? proxifyState( ns, value, { readOnly } )
+ : value
);
}
}
@@ -148,6 +153,9 @@ const stateHandlers: ProxyHandler< object > = {
value: unknown,
receiver: object
): boolean {
+ if ( readOnlyProxies.has( receiver ) ) {
+ return false;
+ }
setNamespace( getNamespaceFromProxy( receiver ) );
try {
return Reflect.set( target, key, value, receiver );
@@ -161,6 +169,10 @@ const stateHandlers: ProxyHandler< object > = {
key: string,
desc: PropertyDescriptor
): boolean {
+ if ( readOnlyProxies.has( getProxyFromObject( target )! ) ) {
+ return false;
+ }
+
const isNew = ! ( key in target );
const result = Reflect.defineProperty( target, key, desc );
@@ -199,6 +211,10 @@ const stateHandlers: ProxyHandler< object > = {
},
deleteProperty( target: object, key: string ): boolean {
+ if ( readOnlyProxies.has( getProxyFromObject( target )! ) ) {
+ return false;
+ }
+
const result = Reflect.deleteProperty( target, key );
if ( result ) {
@@ -230,8 +246,10 @@ const stateHandlers: ProxyHandler< object > = {
* Returns the proxy associated with the given state object, creating it if it
* does not exist.
*
- * @param namespace The namespace that will be associated to this proxy.
- * @param obj The object to proxify.
+ * @param namespace The namespace that will be associated to this proxy.
+ * @param obj The object to proxify.
+ * @param options Options.
+ * @param options.readOnly Read-only.
*
* @throws Error if the object cannot be proxified. Use {@link shouldProxy} to
* check if a proxy can be created for a specific object.
@@ -240,8 +258,15 @@ const stateHandlers: ProxyHandler< object > = {
*/
export const proxifyState = < T extends object >(
namespace: string,
- obj: T
-): T => createProxy( namespace, obj, stateHandlers ) as T;
+ obj: T,
+ options?: { readOnly?: boolean }
+): T => {
+ const proxy = createProxy( namespace, obj, stateHandlers ) as T;
+ if ( options?.readOnly ) {
+ readOnlyProxies.add( proxy );
+ }
+ return proxy;
+};
/**
* Reads the value of the specified property without subscribing to it.
diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts
index 92500189fc8309..4b0d2b0a708c3a 100644
--- a/packages/interactivity/src/proxies/test/state-proxy.ts
+++ b/packages/interactivity/src/proxies/test/state-proxy.ts
@@ -9,7 +9,7 @@ import { effect } from '@preact/signals';
/**
* Internal dependencies
*/
-import { proxifyState, peek } from '../';
+import { proxifyState, peek, deepMerge } from '../';
import { setScope, resetScope, getContext, getElement } from '../../scopes';
import { setNamespace, resetNamespace } from '../../namespaces';
@@ -1265,5 +1265,202 @@ describe( 'Interactivity API', () => {
expect( x ).toBe( undefined );
} );
} );
+
+ describe( 'read-only', () => {
+ it( "should not allow modifying a prop's value", () => {
+ const readOnlyState = proxifyState(
+ 'test',
+ { prop: 'value', nested: { prop: 'value' } },
+ { readOnly: true }
+ );
+
+ expect( () => {
+ readOnlyState.prop = 'new value';
+ } ).toThrow();
+ expect( () => {
+ readOnlyState.nested.prop = 'new value';
+ } ).toThrow();
+ } );
+
+ it( 'should not allow modifying a prop descriptor', () => {
+ const readOnlyState = proxifyState(
+ 'test',
+ { prop: 'value', nested: { prop: 'value' } },
+ { readOnly: true }
+ );
+
+ expect( () => {
+ Object.defineProperty( readOnlyState, 'prop', {
+ get: () => 'value from getter',
+ writable: true,
+ enumerable: false,
+ } );
+ } ).toThrow();
+ expect( () => {
+ Object.defineProperty( readOnlyState.nested, 'prop', {
+ get: () => 'value from getter',
+ writable: true,
+ enumerable: false,
+ } );
+ } ).toThrow();
+ } );
+
+ it( 'should not allow adding new props', () => {
+ const readOnlyState = proxifyState< any >(
+ 'test',
+ { prop: 'value', nested: { prop: 'value' } },
+ { readOnly: true }
+ );
+
+ expect( () => {
+ readOnlyState.newProp = 'value';
+ } ).toThrow();
+ expect( () => {
+ readOnlyState.nested.newProp = 'value';
+ } ).toThrow();
+ } );
+
+ it( 'should not allow removing props', () => {
+ const readOnlyState = proxifyState< any >(
+ 'test',
+ { prop: 'value', nested: { prop: 'value' } },
+ { readOnly: true }
+ );
+
+ expect( () => {
+ delete readOnlyState.prop;
+ } ).toThrow();
+ expect( () => {
+ delete readOnlyState.nested.prop;
+ } ).toThrow();
+ } );
+
+ it( 'should not allow adding items to an array', () => {
+ const readOnlyState = proxifyState(
+ 'test',
+ { array: [ 1, 2, 3 ], nested: { array: [ 1, 2, 3 ] } },
+ { readOnly: true }
+ );
+
+ expect( () => readOnlyState.array.push( 4 ) ).toThrow();
+ expect( () => readOnlyState.nested.array.push( 4 ) ).toThrow();
+ } );
+
+ it( 'should not allow removing items from an array', () => {
+ const readOnlyState = proxifyState(
+ 'test',
+ { array: [ 1, 2, 3 ], nested: { array: [ 1, 2, 3 ] } },
+ { readOnly: true }
+ );
+
+ expect( () => readOnlyState.array.pop() ).toThrow();
+ expect( () => readOnlyState.nested.array.pop() ).toThrow();
+ } );
+
+ it( 'should allow subscribing to prop changes', () => {
+ const readOnlyState = proxifyState(
+ 'test',
+ {
+ prop: 'value',
+ nested: { prop: 'value' },
+ },
+ { readOnly: true }
+ );
+
+ const spy1 = jest.fn( () => readOnlyState.prop );
+ const spy2 = jest.fn( () => readOnlyState.nested.prop );
+
+ effect( spy1 );
+ effect( spy2 );
+ expect( spy1 ).toHaveBeenCalledTimes( 1 );
+ expect( spy2 ).toHaveBeenCalledTimes( 1 );
+ expect( spy1 ).toHaveLastReturnedWith( 'value' );
+ expect( spy2 ).toHaveLastReturnedWith( 'value' );
+
+ deepMerge( readOnlyState, { prop: 'new value' } );
+
+ expect( spy1 ).toHaveBeenCalledTimes( 2 );
+ expect( spy2 ).toHaveBeenCalledTimes( 1 );
+ expect( spy1 ).toHaveLastReturnedWith( 'new value' );
+ expect( spy2 ).toHaveLastReturnedWith( 'value' );
+
+ deepMerge( readOnlyState, { nested: { prop: 'new value' } } );
+
+ expect( spy1 ).toHaveBeenCalledTimes( 2 );
+ expect( spy2 ).toHaveBeenCalledTimes( 2 );
+ expect( spy1 ).toHaveLastReturnedWith( 'new value' );
+ expect( spy2 ).toHaveLastReturnedWith( 'new value' );
+ } );
+
+ it( 'should allow subscribing to new props', () => {
+ const readOnlyState = proxifyState< any >(
+ 'test',
+ {
+ prop: 'value',
+ nested: { prop: 'value' },
+ },
+ { readOnly: true }
+ );
+
+ const spy1 = jest.fn( () => readOnlyState.newProp );
+ const spy2 = jest.fn( () => readOnlyState.nested.newProp );
+
+ effect( spy1 );
+ effect( spy2 );
+ expect( spy1 ).toHaveBeenCalledTimes( 1 );
+ expect( spy2 ).toHaveBeenCalledTimes( 1 );
+ expect( spy1 ).toHaveLastReturnedWith( undefined );
+ expect( spy2 ).toHaveLastReturnedWith( undefined );
+
+ deepMerge( readOnlyState, { newProp: 'value' } );
+
+ expect( spy1 ).toHaveBeenCalledTimes( 2 );
+ expect( spy2 ).toHaveBeenCalledTimes( 1 );
+ expect( spy1 ).toHaveLastReturnedWith( 'value' );
+ expect( spy2 ).toHaveLastReturnedWith( undefined );
+
+ deepMerge( readOnlyState, { nested: { newProp: 'value' } } );
+
+ expect( spy1 ).toHaveBeenCalledTimes( 2 );
+ expect( spy2 ).toHaveBeenCalledTimes( 2 );
+ expect( spy1 ).toHaveLastReturnedWith( 'value' );
+ expect( spy2 ).toHaveLastReturnedWith( 'value' );
+ } );
+
+ it( 'should allow subscribing to array changes', () => {
+ const readOnlyState = proxifyState< any >(
+ 'test',
+ {
+ array: [ 1, 2, 3 ],
+ nested: { array: [ 1, 2, 3 ] },
+ },
+ { readOnly: true }
+ );
+
+ const spy1 = jest.fn( () => readOnlyState.array[ 0 ] );
+ const spy2 = jest.fn( () => readOnlyState.nested.array[ 0 ] );
+
+ effect( spy1 );
+ effect( spy2 );
+ expect( spy1 ).toHaveBeenCalledTimes( 1 );
+ expect( spy2 ).toHaveBeenCalledTimes( 1 );
+ expect( spy1 ).toHaveLastReturnedWith( 1 );
+ expect( spy2 ).toHaveLastReturnedWith( 1 );
+
+ deepMerge( readOnlyState, { array: [ 4, 5, 6 ] } );
+
+ expect( spy1 ).toHaveBeenCalledTimes( 2 );
+ expect( spy2 ).toHaveBeenCalledTimes( 1 );
+ expect( spy1 ).toHaveLastReturnedWith( 4 );
+ expect( spy2 ).toHaveLastReturnedWith( 1 );
+
+ deepMerge( readOnlyState, { nested: { array: [] } } );
+
+ expect( spy1 ).toHaveBeenCalledTimes( 2 );
+ expect( spy2 ).toHaveBeenCalledTimes( 2 );
+ expect( spy1 ).toHaveLastReturnedWith( 4 );
+ expect( spy2 ).toHaveLastReturnedWith( undefined );
+ } );
+ } );
} );
} );
diff --git a/packages/interactivity/src/scopes.ts b/packages/interactivity/src/scopes.ts
index 2e78755ec4bbe6..722305f6bee112 100644
--- a/packages/interactivity/src/scopes.ts
+++ b/packages/interactivity/src/scopes.ts
@@ -12,6 +12,7 @@ import type { Evaluate } from './hooks';
export interface Scope {
evaluate: Evaluate;
context: object;
+ serverContext: object;
ref: RefObject< HTMLElement >;
attributes: createElement.JSX.HTMLAttributes;
}
@@ -96,3 +97,46 @@ export const getElement = () => {
attributes: deepImmutable( attributes ),
} );
};
+
+/**
+ * Retrieves the part of the inherited context defined and updated from the
+ * server.
+ *
+ * The object returned is read-only, and includes the context defined in PHP
+ * with `wp_interactivity_data_wp_context()`, including the corresponding
+ * inherited properties. When `actions.navigate()` is called, this object is
+ * updated to reflect the changes in the new visited page, without affecting the
+ * context returned by `getContext()`. Directives can subscribe to those changes
+ * to update the context if needed.
+ *
+ * @example
+ * ```js
+ * store('...', {
+ * callbacks: {
+ * updateServerContext() {
+ * const context = getContext();
+ * const serverContext = getServerContext();
+ * // Override some property with the new value that came from the server.
+ * context.overridableProp = serverContext.overridableProp;
+ * },
+ * },
+ * });
+ * ```
+ *
+ * @param namespace Store namespace. By default, the namespace where the calling
+ * function exists is used.
+ * @return The server context content.
+ */
+export const getServerContext = < T extends object >(
+ namespace?: string
+): T => {
+ const scope = getScope();
+ if ( globalThis.SCRIPT_DEBUG ) {
+ if ( ! scope ) {
+ throw Error(
+ 'Cannot call `getServerContext()` when there is no scope. If you are using an async function, please consider using a generator instead. If you are using some sort of async callbacks, like `setTimeout`, please wrap the callback with `withScope(callback)`.'
+ );
+ }
+ }
+ return scope.serverContext[ namespace || getNamespace() ];
+};
diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts
index c74764b902e194..b147e0f61163bf 100644
--- a/packages/interactivity/src/store.ts
+++ b/packages/interactivity/src/store.ts
@@ -12,6 +12,7 @@ export const stores = new Map();
const rawStores = new Map();
const storeLocks = new Map();
const storeConfigs = new Map();
+const serverStates = new Map();
/**
* Get the defined config for the store with the passed namespace.
@@ -22,6 +23,39 @@ const storeConfigs = new Map();
export const getConfig = ( namespace?: string ) =>
storeConfigs.get( namespace || getNamespace() ) || {};
+/**
+ * Get the part of the state defined and updated from the server.
+ *
+ * The object returned is read-only, and includes the state defined in PHP with
+ * `wp_interactivity_state()`. When using `actions.navigate()`, this object is
+ * updated to reflect the changes in its properites, without affecting the state
+ * returned by `store()`. Directives can subscribe to those changes to update
+ * the state if needed.
+ *
+ * @example
+ * ```js
+ * const { state } = store('myStore', {
+ * callbacks: {
+ * updateServerState() {
+ * const serverState = getServerState();
+ * // Override some property with the new value that came from the server.
+ * state.overridableProp = serverState.overridableProp;
+ * },
+ * },
+ * });
+ * ```
+ *
+ * @param namespace Store's namespace from which to retrieve the server state.
+ * @return The server state for the given namespace.
+ */
+export const getServerState = ( namespace?: string ) => {
+ const ns = namespace || getNamespace();
+ if ( ! serverStates.has( ns ) ) {
+ serverStates.set( ns, proxifyState( ns, {}, { readOnly: true } ) );
+ }
+ return serverStates.get( ns );
+};
+
interface StoreOptions {
/**
* Property to block/unblock private store namespaces.
@@ -50,6 +84,42 @@ interface StoreOptions {
lock?: boolean | string;
}
+type Prettify< T > = { [ K in keyof T ]: T[ K ] } & {};
+type DeepPartial< T > = T extends object
+ ? { [ P in keyof T ]?: DeepPartial< T[ P ] > }
+ : T;
+type DeepPartialState< T extends { state: object } > = Omit< T, 'state' > & {
+ state?: DeepPartial< T[ 'state' ] >;
+};
+type ConvertGeneratorToPromise< T > = T extends (
+ ...args: infer A
+) => Generator< any, infer R, any >
+ ? ( ...args: A ) => Promise< R >
+ : never;
+type ConvertGeneratorsToPromises< T > = {
+ [ K in keyof T ]: T[ K ] extends ( ...args: any[] ) => any
+ ? ConvertGeneratorToPromise< T[ K ] > extends never
+ ? T[ K ]
+ : ConvertGeneratorToPromise< T[ K ] >
+ : T[ K ] extends object
+ ? Prettify< ConvertGeneratorsToPromises< T[ K ] > >
+ : T[ K ];
+};
+type ConvertPromiseToGenerator< T > = T extends (
+ ...args: infer A
+) => Promise< infer R >
+ ? ( ...args: A ) => Generator< any, R, any >
+ : never;
+type ConvertPromisesToGenerators< T > = {
+ [ K in keyof T ]: T[ K ] extends ( ...args: any[] ) => any
+ ? ConvertPromiseToGenerator< T[ K ] > extends never
+ ? T[ K ]
+ : ConvertPromiseToGenerator< T[ K ] >
+ : T[ K ] extends object
+ ? Prettify< ConvertPromisesToGenerators< T[ K ] > >
+ : T[ K ];
+};
+
export const universalUnlock =
'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
@@ -98,17 +168,34 @@ export const universalUnlock =
*
* @return A reference to the namespace content.
*/
-export function store< S extends object = {} >(
+
+// Overload for when the types are inferred.
+export function store< T extends object >(
+ namespace: string,
+ storePart: T,
+ options?: StoreOptions
+): Prettify< ConvertGeneratorsToPromises< T > >;
+
+// Overload for when types are passed via generics and they contain state.
+export function store< T extends { state: object } >(
+ namespace: string,
+ storePart: ConvertPromisesToGenerators< DeepPartialState< T > >,
+ options?: StoreOptions
+): Prettify< ConvertGeneratorsToPromises< T > >;
+
+// Overload for when types are passed via generics and they don't contain state.
+export function store< T extends object >(
namespace: string,
- storePart?: S,
+ storePart: ConvertPromisesToGenerators< T >,
options?: StoreOptions
-): S;
+): Prettify< ConvertGeneratorsToPromises< T > >;
+// Overload for when types are divided into multiple parts.
export function store< T extends object >(
namespace: string,
- storePart?: T,
+ storePart: ConvertPromisesToGenerators< DeepPartial< T > >,
options?: StoreOptions
-): T;
+): Prettify< ConvertGeneratorsToPromises< T > >;
export function store(
namespace: string,
@@ -187,6 +274,7 @@ export const populateServerData = ( data?: {
Object.entries( data!.state ).forEach( ( [ namespace, state ] ) => {
const st = store< any >( namespace, {}, { lock: universalUnlock } );
deepMerge( st.state, state, false );
+ deepMerge( getServerState( namespace ), state );
} );
}
if ( isPlainObject( data?.config ) ) {
diff --git a/packages/interactivity/src/test/store.ts b/packages/interactivity/src/test/store.ts
new file mode 100644
index 00000000000000..1092001db03143
--- /dev/null
+++ b/packages/interactivity/src/test/store.ts
@@ -0,0 +1,286 @@
+/**
+ * Internal dependencies
+ */
+import { store } from '../store';
+
+describe( 'Interactivity API', () => {
+ describe( 'store', () => {
+ it( 'dummy test', () => {
+ expect( true ).toBe( true );
+ } );
+
+ describe( 'types', () => {
+ describe( 'the whole store can be inferred', () => {
+ // eslint-disable-next-line no-unused-expressions
+ async () => {
+ const myStore = store( 'test', {
+ state: {
+ clientValue: 1,
+ get derived(): number {
+ return myStore.state.clientValue;
+ },
+ },
+ actions: {
+ sync( n: number ) {
+ return n;
+ },
+ *async( n: number ) {
+ const n1: number =
+ yield myStore.actions.sync( n );
+ return myStore.state.derived + n1 + n;
+ },
+ },
+ } );
+
+ myStore.state.clientValue satisfies number;
+ myStore.state.derived satisfies number;
+
+ // @ts-expect-error
+ myStore.state.nonExistent satisfies number;
+ myStore.actions.sync( 1 ) satisfies number;
+ myStore.actions.async( 1 ) satisfies Promise< number >;
+ ( await myStore.actions.async( 1 ) ) satisfies number;
+
+ // @ts-expect-error
+ myStore.actions.nonExistent() satisfies {};
+ };
+ } );
+
+ describe( 'the whole store can be manually typed', () => {
+ // eslint-disable-next-line no-unused-expressions
+ async () => {
+ interface Store {
+ state: {
+ clientValue: number;
+ serverValue: number;
+ readonly derived: number;
+ };
+ actions: {
+ sync: ( n: number ) => number;
+ async: ( n: number ) => Promise< number >;
+ };
+ }
+
+ const myStore = store< Store >( 'test', {
+ state: {
+ clientValue: 1,
+ // @ts-expect-error
+ nonExistent: 2,
+ get derived(): number {
+ return myStore.state.serverValue;
+ },
+ },
+ actions: {
+ sync( n ) {
+ return n;
+ },
+ *async( n ): Generator< unknown, number, unknown > {
+ const n1 = myStore.actions.sync( n );
+ return myStore.state.derived + n1 + n;
+ },
+ },
+ } );
+
+ myStore.state.clientValue satisfies number;
+ myStore.state.serverValue satisfies number;
+ myStore.state.derived satisfies number;
+ // @ts-expect-error
+ myStore.state.nonExistent satisfies number;
+ myStore.actions.sync( 1 ) satisfies number;
+ myStore.actions.async( 1 ) satisfies Promise< number >;
+ ( await myStore.actions.async( 1 ) ) satisfies number;
+ // @ts-expect-error
+ myStore.actions.nonExistent();
+ };
+ } );
+
+ describe( 'the server state can be typed and the rest inferred', () => {
+ // eslint-disable-next-line no-unused-expressions
+ async () => {
+ type ServerStore = {
+ state: {
+ serverValue: number;
+ };
+ };
+
+ const clientStore = {
+ state: {
+ clientValue: 1,
+ get derived(): number {
+ return myStore.state.serverValue;
+ },
+ },
+ actions: {
+ sync( n: number ) {
+ return n;
+ },
+ *async(
+ n: number
+ ): Generator< unknown, number, number > {
+ const n1: number =
+ yield myStore.actions.sync( n );
+ return myStore.state.derived + n1 + n;
+ },
+ },
+ };
+
+ type Store = ServerStore & typeof clientStore;
+
+ const myStore = store< Store >( 'test', clientStore );
+
+ myStore.state.clientValue satisfies number;
+ myStore.state.serverValue satisfies number;
+ myStore.state.derived satisfies number;
+ // @ts-expect-error
+ myStore.state.nonExistent satisfies number;
+ myStore.actions.sync( 1 ) satisfies number;
+ myStore.actions.async( 1 ) satisfies Promise< number >;
+ ( await myStore.actions.async( 1 ) ) satisfies number;
+ // @ts-expect-error
+ myStore.actions.nonExistent();
+ };
+ } );
+
+ describe( 'the state can be casted and the rest inferred', () => {
+ // eslint-disable-next-line no-unused-expressions
+ async () => {
+ type State = {
+ clientValue: number;
+ serverValue: number;
+ derived: number;
+ };
+
+ const myStore = store( 'test', {
+ state: {
+ clientValue: 1,
+ get derived(): number {
+ return myStore.state.serverValue;
+ },
+ } as State,
+ actions: {
+ sync( n: number ) {
+ return n;
+ },
+ *async(
+ n: number
+ ): Generator< unknown, number, number > {
+ const n1: number =
+ yield myStore.actions.sync( n );
+ return myStore.state.derived + n1 + n;
+ },
+ },
+ } );
+
+ myStore.state.clientValue satisfies number;
+ myStore.state.serverValue satisfies number;
+ myStore.state.derived satisfies number;
+ // @ts-expect-error
+ myStore.state.nonExistent satisfies number;
+ myStore.actions.sync( 1 ) satisfies number;
+ myStore.actions.async( 1 ) satisfies Promise< number >;
+ ( await myStore.actions.async( 1 ) ) satisfies number;
+ // @ts-expect-error
+ myStore.actions.nonExistent() satisfies {};
+ };
+ } );
+
+ describe( 'the whole store can be manually typed even if doesnt contain state', () => {
+ // eslint-disable-next-line no-unused-expressions
+ async () => {
+ interface Store {
+ actions: {
+ sync: ( n: number ) => number;
+ async: ( n: number ) => Promise< number >;
+ };
+ callbacks: {
+ existent: number;
+ };
+ }
+
+ const myStore = store< Store >( 'test', {
+ actions: {
+ sync( n ) {
+ return n;
+ },
+ *async( n ): Generator< unknown, number, number > {
+ const n1: number =
+ yield myStore.actions.sync( n );
+ return n1 + n;
+ },
+ },
+ callbacks: {
+ existent: 1,
+ // @ts-expect-error
+ nonExistent: 1,
+ },
+ } );
+
+ // @ts-expect-error
+ myStore.state.nonExistent satisfies number;
+ myStore.actions.sync( 1 ) satisfies number;
+ myStore.actions.async( 1 ) satisfies Promise< number >;
+ ( await myStore.actions.async( 1 ) ) satisfies number;
+ myStore.callbacks.existent satisfies number;
+ // @ts-expect-error
+ myStore.callbacks.nonExistent satisfies number;
+ // @ts-expect-error
+ myStore.actions.nonExistent() satisfies {};
+ };
+ } );
+
+ describe( 'the store can be divided into multiple parts', () => {
+ // eslint-disable-next-line no-unused-expressions
+ async () => {
+ type ServerState = {
+ state: {
+ serverValue: number;
+ };
+ };
+
+ const firstStorePart = {
+ state: {
+ clientValue1: 1,
+ },
+ actions: {
+ incrementValue1( n = 1 ) {
+ myStore.state.clientValue1 += n;
+ },
+ },
+ };
+
+ type FirstStorePart = typeof firstStorePart;
+
+ const secondStorePart = {
+ state: {
+ clientValue2: 'test',
+ },
+ actions: {
+ *asyncAction() {
+ return (
+ myStore.state.clientValue1 +
+ myStore.state.serverValue
+ );
+ },
+ },
+ };
+
+ type Store = ServerState &
+ FirstStorePart &
+ typeof secondStorePart;
+
+ const myStore = store< Store >( 'test', firstStorePart );
+ store( 'test', secondStorePart );
+
+ myStore.state.clientValue1 satisfies number;
+ myStore.state.clientValue2 satisfies string;
+ myStore.actions.incrementValue1( 1 );
+ myStore.actions.asyncAction() satisfies Promise< number >;
+ ( await myStore.actions.asyncAction() ) satisfies number;
+
+ // @ts-expect-error
+ myStore.state.nonExistent satisfies {};
+ };
+ } );
+ } );
+ } );
+} );
diff --git a/packages/interactivity/tsconfig.test.json b/packages/interactivity/tsconfig.test.json
new file mode 100644
index 00000000000000..6a90abc2ba2210
--- /dev/null
+++ b/packages/interactivity/tsconfig.test.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig.json",
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "noEmit": true,
+ "emitDeclarationOnly": false,
+ "types": [ "jest" ]
+ },
+ "references": [ { "path": "./tsconfig.json" } ],
+ "files": [ "src/test/store.ts" ],
+ "exclude": []
+}
diff --git a/packages/interface/src/components/complementary-area-toggle/index.js b/packages/interface/src/components/complementary-area-toggle/index.js
index b6690b7df5fc5d..2f8d8dd413674b 100644
--- a/packages/interface/src/components/complementary-area-toggle/index.js
+++ b/packages/interface/src/components/complementary-area-toggle/index.js
@@ -10,6 +10,25 @@ import { useDispatch, useSelect } from '@wordpress/data';
import { store as interfaceStore } from '../../store';
import complementaryAreaContext from '../complementary-area-context';
+/**
+ * Whether the role supports checked state.
+ *
+ * @param {import('react').AriaRole} role Role.
+ * @return {boolean} Whether the role supports checked state.
+ * @see https://www.w3.org/TR/wai-aria-1.1/#aria-checked
+ */
+function roleSupportsCheckedState( role ) {
+ return [
+ 'checkbox',
+ 'option',
+ 'radio',
+ 'switch',
+ 'menuitemcheckbox',
+ 'menuitemradio',
+ 'treeitem',
+ ].includes( role );
+}
+
function ComplementaryAreaToggle( {
as = Button,
scope,
@@ -17,6 +36,7 @@ function ComplementaryAreaToggle( {
icon,
selectedIcon,
name,
+ shortcut,
...props
} ) {
const ComponentToUse = as;
@@ -26,12 +46,18 @@ function ComplementaryAreaToggle( {
identifier,
[ identifier, scope ]
);
+
const { enableComplementaryArea, disableComplementaryArea } =
useDispatch( interfaceStore );
+
return (
{
if ( isSelected ) {
disableComplementaryArea( scope );
@@ -39,6 +65,7 @@ function ComplementaryAreaToggle( {
enableComplementaryArea( scope, identifier );
}
} }
+ shortcut={ shortcut }
{ ...props }
/>
);
diff --git a/packages/interface/src/components/complementary-area/index.js b/packages/interface/src/components/complementary-area/index.js
index 363a6ee9dea76c..d9fa8e71acb23a 100644
--- a/packages/interface/src/components/complementary-area/index.js
+++ b/packages/interface/src/components/complementary-area/index.js
@@ -275,6 +275,7 @@ function ComplementaryArea( {
showTooltip={ ! showIconLabels }
variant={ showIconLabels ? 'tertiary' : undefined }
size="compact"
+ shortcut={ toggleShortcut }
/>
) }
diff --git a/packages/list-reusable-blocks/src/components/import-dropdown/index.js b/packages/list-reusable-blocks/src/components/import-dropdown/index.js
index d20ba9fcf10999..fdad08f80d213c 100644
--- a/packages/list-reusable-blocks/src/components/import-dropdown/index.js
+++ b/packages/list-reusable-blocks/src/components/import-dropdown/index.js
@@ -17,8 +17,8 @@ function ImportDropdown( { onUpload } ) {
contentClassName="list-reusable-blocks-import-dropdown__content"
renderToggle={ ( { isOpen, onToggle } ) => (
{ children }
@@ -66,8 +65,7 @@ export function DotTip( {
Got it
@@ -27,7 +27,7 @@ exports[`DotTip should render correctly 1`] = `
-
+
+
{ tabs.map( ( tab ) => {
return (
-
-
+
);
} ) }
-
+
{ sections.length &&
sections.map( ( section ) => {
return (
-
@@ -151,7 +145,7 @@ export default function PreferencesModalTabs( { sections } ) {
size="small"
gap="6"
>
-
{ section.content }
-
+
);
} ) }
-
+
);
}
diff --git a/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-block-convert-button.js b/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-block-convert-button.js
index bceca4876654cf..e0d8592df4d8c3 100644
--- a/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-block-convert-button.js
+++ b/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-block-convert-button.js
@@ -191,8 +191,7 @@ export default function ReusableBlockConvertButton( {
/>
{
setIsModalOpen( false );
@@ -203,8 +202,7 @@ export default function ReusableBlockConvertButton( {
diff --git a/packages/rich-text/README.md b/packages/rich-text/README.md
index 3c3abc422fa5f9..033a4f2c747abe 100644
--- a/packages/rich-text/README.md
+++ b/packages/rich-text/README.md
@@ -430,7 +430,7 @@ Create an HTML string from a Rich Text value.
_Parameters_
-- _$1_ `Object`: Named argements.
+- _$1_ `Object`: Named arguments.
- _$1.value_ `RichTextValue`: Rich text value.
- _$1.preserveWhiteSpace_ `[boolean]`: Preserves newlines if true.
diff --git a/packages/rich-text/src/component/event-listeners/copy-handler.js b/packages/rich-text/src/component/event-listeners/copy-handler.js
index 1a92237bb4c5b4..0cc1594c3ab914 100644
--- a/packages/rich-text/src/component/event-listeners/copy-handler.js
+++ b/packages/rich-text/src/component/event-listeners/copy-handler.js
@@ -2,21 +2,18 @@
* Internal dependencies
*/
import { toHTMLString } from '../../to-html-string';
+import { isCollapsed } from '../../is-collapsed';
import { slice } from '../../slice';
-import { remove } from '../../remove';
import { getTextContent } from '../../get-text-content';
export default ( props ) => ( element ) => {
function onCopy( event ) {
- const { record, createRecord, handleChange } = props.current;
+ const { record } = props.current;
const { ownerDocument } = element;
- const { defaultView } = ownerDocument;
- const { anchorNode, focusNode, isCollapsed } =
- defaultView.getSelection();
- const containsSelection =
- element.contains( anchorNode ) && element.contains( focusNode );
-
- if ( isCollapsed || ! containsSelection ) {
+ if (
+ isCollapsed( record.current ) ||
+ ! element.contains( ownerDocument.activeElement )
+ ) {
return;
}
@@ -29,7 +26,7 @@ export default ( props ) => ( element ) => {
event.preventDefault();
if ( event.type === 'cut' ) {
- handleChange( remove( createRecord() ) );
+ ownerDocument.execCommand( 'delete' );
}
}
diff --git a/packages/rich-text/src/component/event-listeners/input-and-selection.js b/packages/rich-text/src/component/event-listeners/input-and-selection.js
index 11dcdb0d8ff9ab..621f1c59fab04e 100644
--- a/packages/rich-text/src/component/event-listeners/input-and-selection.js
+++ b/packages/rich-text/src/component/event-listeners/input-and-selection.js
@@ -114,13 +114,14 @@ export default ( props ) => ( element ) => {
return;
}
- const { anchorNode, focusNode } = defaultView.getSelection();
- const containsSelection =
- element.contains( anchorNode ) &&
- element.contains( focusNode ) &&
- ownerDocument.activeElement.contains( element );
-
- if ( ! containsSelection ) {
+ // Ensure the active element is the rich text element.
+ if ( ownerDocument.activeElement !== element ) {
+ // If it is not, we can stop listening for selection changes. We
+ // resume listening when the element is focused.
+ ownerDocument.removeEventListener(
+ 'selectionchange',
+ handleSelectionChange
+ );
return;
}
@@ -254,9 +255,5 @@ export default ( props ) => ( element ) => {
element.removeEventListener( 'compositionstart', onCompositionStart );
element.removeEventListener( 'compositionend', onCompositionEnd );
element.removeEventListener( 'focus', onFocus );
- ownerDocument.removeEventListener(
- 'selectionchange',
- handleSelectionChange
- );
};
};
diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js
index 0b0a269509e7ec..898bdfa73b330e 100644
--- a/packages/rich-text/src/create.js
+++ b/packages/rich-text/src/create.js
@@ -431,7 +431,7 @@ export function removeReservedCharacters( string ) {
/**
* Creates a Rich Text value from a DOM element and range.
*
- * @param {Object} $1 Named argements.
+ * @param {Object} $1 Named arguments.
* @param {Element} [$1.element] Element to create value from.
* @param {Range} [$1.range] Range to create value from.
* @param {boolean} [$1.isEditableTree]
@@ -591,7 +591,7 @@ function createFromElement( { element, range, isEditableTree } ) {
/**
* Gets the attributes of an element in object shape.
*
- * @param {Object} $1 Named argements.
+ * @param {Object} $1 Named arguments.
* @param {Element} $1.element Element to get attributes from.
*
* @return {Object|void} Attribute object or `undefined` if the element has no
diff --git a/packages/rich-text/src/to-html-string.js b/packages/rich-text/src/to-html-string.js
index 35089003f0b3fb..f770dfdefc128a 100644
--- a/packages/rich-text/src/to-html-string.js
+++ b/packages/rich-text/src/to-html-string.js
@@ -19,7 +19,7 @@ import { toTree } from './to-tree';
/**
* Create an HTML string from a Rich Text value.
*
- * @param {Object} $1 Named argements.
+ * @param {Object} $1 Named arguments.
* @param {RichTextValue} $1.value Rich text value.
* @param {boolean} [$1.preserveWhiteSpace] Preserves newlines if true.
*
diff --git a/packages/widgets/src/blocks/legacy-widget/edit/index.js b/packages/widgets/src/blocks/legacy-widget/edit/index.js
index f371786c106d6f..c5ca43211e58e6 100644
--- a/packages/widgets/src/blocks/legacy-widget/edit/index.js
+++ b/packages/widgets/src/blocks/legacy-widget/edit/index.js
@@ -11,13 +11,11 @@ import {
BlockControls,
InspectorControls,
BlockIcon,
- store as blockEditorStore,
} from '@wordpress/block-editor';
import { Flex, FlexBlock, Spinner, Placeholder } from '@wordpress/components';
import { brush as brushIcon } from '@wordpress/icons';
import { __ } from '@wordpress/i18n';
import { useState, useCallback } from '@wordpress/element';
-import { useSelect } from '@wordpress/data';
import { useEntityRecord } from '@wordpress/core-data';
/**
@@ -102,11 +100,6 @@ function NotEmpty( {
const { record: widgetType, hasResolved: hasResolvedWidgetType } =
useEntityRecord( 'root', 'widgetType', widgetTypeId );
- const isNavigationMode = useSelect(
- ( select ) => select( blockEditorStore ).isNavigationMode(),
- []
- );
-
const setInstance = useCallback( ( nextInstance ) => {
setAttributes( { instance: nextInstance } );
}, [] );
@@ -130,8 +123,7 @@ function NotEmpty( {
);
}
- const mode =
- idBase && ( isNavigationMode || ! isSelected ) ? 'preview' : 'edit';
+ const mode = idBase && ! isSelected ? 'preview' : 'edit';
return (
<>
diff --git a/phpunit/blocks/render-post-template-test.php b/phpunit/blocks/render-post-template-test.php
index 6241f6f0605164..e929e459654fe7 100644
--- a/phpunit/blocks/render-post-template-test.php
+++ b/phpunit/blocks/render-post-template-test.php
@@ -122,7 +122,7 @@ public function test_rendering_post_template_with_main_query_loop_already_starte
global $wp_query, $wp_the_query;
// Query block with post template block.
- $content = '';
+ $content = '';
$content .= '';
$content .= '';
$content .= '';
diff --git a/phpunit/bootstrap.php b/phpunit/bootstrap.php
index 5d078193f0c3bf..36746d05669519 100644
--- a/phpunit/bootstrap.php
+++ b/phpunit/bootstrap.php
@@ -24,12 +24,6 @@
define( 'GUTENBERG_DIR_TESTDATA', __DIR__ . '/data/' );
define( 'GUTENBERG_DIR_TESTFIXTURES', __DIR__ . '/fixtures/' );
-// Pretend that these are Core unit tests. This is needed so that
-// wp_theme_has_theme_json() does not cache its return value between each test.
-if ( ! defined( 'WP_RUN_CORE_TESTS' ) ) {
- define( 'WP_RUN_CORE_TESTS', true );
-}
-
// Require composer dependencies.
require_once dirname( __DIR__ ) . '/vendor/autoload.php';
diff --git a/readme.txt b/readme.txt
index c5ef1231b183ee..4bd7ddc36d6e9b 100644
--- a/readme.txt
+++ b/readme.txt
@@ -55,7 +55,7 @@ To report a security issue, please visit the [WordPress HackerOne](https://hacke
= Do I have to use the Gutenberg plugin to get access to these features? =
-Not necessarily. Each version of WordPress after 5.0 has included features from the Gutenberg plugin, which are known collectively as the WordPress Editor . You are likely already benefitting from stable features!
+Not necessarily. Each version of WordPress after 5.0 has included features from the Gutenberg plugin, which are known collectively as the WordPress Editor . You are likely already benefiting from stable features!
But if you want cutting edge beta features, including more experimental items, you will need to use the plugin. You can read more here to help decide whether the plugin is right for you.
diff --git a/storybook/main.js b/storybook/main.js
index 66e951b26b1de6..f111e9f5d8cd38 100644
--- a/storybook/main.js
+++ b/storybook/main.js
@@ -41,6 +41,7 @@ module.exports = {
disableTelemetry: true,
},
stories,
+ staticDirs: [ './static' ],
addons: [
{
name: '@storybook/addon-docs',
diff --git a/storybook/manager-head.html b/storybook/manager-head.html
index ebf2d6891ba0bb..dcafe36caefa72 100644
--- a/storybook/manager-head.html
+++ b/storybook/manager-head.html
@@ -2,9 +2,13 @@
( function redirectIfStoryMoved() {
const PREVIOUSLY_EXPERIMENTAL_COMPONENTS = [
'alignmentmatrixcontrol',
+ 'borderboxcontrol',
+ 'bordercontrol',
+ 'boxcontrol',
'customselectcontrol-v2',
'dimensioncontrol',
'navigation',
+ 'navigator',
'progressbar',
'theme',
];
diff --git a/storybook/static/wp-logo@2x.png b/storybook/static/wp-logo@2x.png
new file mode 100644
index 00000000000000..a95cd961902b01
Binary files /dev/null and b/storybook/static/wp-logo@2x.png differ
diff --git a/storybook/theme.js b/storybook/theme.js
index c13de5b53d4f53..9e340119eb48f8 100644
--- a/storybook/theme.js
+++ b/storybook/theme.js
@@ -6,6 +6,37 @@ import { create } from '@storybook/theming/create';
export default create( {
base: 'light',
brandTitle: 'WordPress Components',
- brandImage:
- 'https://s.w.org/style/images/about/WordPress-logotype-wmark.png',
+ brandImage: './wp-logo@2x.png',
+
+ // Typography
+ fontBase:
+ '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
+ fontCode: 'monospace',
+
+ //
+ colorPrimary: '#3858E9',
+ colorSecondary: '#3858E9',
+
+ // UI
+ appBg: '#ffffff',
+ appContentBg: '#ffffff',
+ appPreviewBg: '#ffffff',
+ appBorderColor: '#DCDCDE',
+ appBorderRadius: 4,
+
+ // Text colors
+ textColor: '#10162F',
+ textInverseColor: '#ffffff',
+
+ // Toolbar default and active colors
+ barTextColor: '#9E9E9E',
+ barSelectedColor: '#3858E9',
+ barHoverColor: '#3858E9',
+ barBg: '#ffffff',
+
+ // Form colors
+ inputBg: '#ffffff',
+ inputBorder: '#10162F',
+ inputTextColor: '#10162F',
+ inputBorderRadius: 2,
} );
diff --git a/test/e2e/specs/editor/various/__snapshots__/Copy-cut-paste-should-paste-link-to-formatted-text-1-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/Copy-cut-paste-should-paste-link-to-formatted-text-1-chromium.txt
new file mode 100644
index 00000000000000..73ed4090549910
--- /dev/null
+++ b/test/e2e/specs/editor/various/__snapshots__/Copy-cut-paste-should-paste-link-to-formatted-text-1-chromium.txt
@@ -0,0 +1,3 @@
+
+tes t
+
\ No newline at end of file
diff --git a/test/e2e/specs/editor/various/allowed-patterns.spec.js b/test/e2e/specs/editor/various/allowed-patterns.spec.js
index e592f776c61dd8..83d44403d60ee2 100644
--- a/test/e2e/specs/editor/various/allowed-patterns.spec.js
+++ b/test/e2e/specs/editor/various/allowed-patterns.spec.js
@@ -14,10 +14,7 @@ test.describe( 'Allowed Patterns', () => {
);
} );
- test( 'should show all patterns when all blocks are allowed', async ( {
- admin,
- page,
- } ) => {
+ test( 'should show all patterns by default', async ( { admin, page } ) => {
await admin.createNewPost();
await page
.getByRole( 'toolbar', { name: 'Document tools' } )
@@ -57,7 +54,7 @@ test.describe( 'Allowed Patterns', () => {
);
} );
- test( 'should show only allowed patterns', async ( {
+ test( 'should hide patterns with only hidden blocks', async ( {
admin,
page,
} ) => {
diff --git a/test/e2e/specs/editor/various/block-bindings.spec.js b/test/e2e/specs/editor/various/block-bindings.spec.js
deleted file mode 100644
index c556c469698ebd..00000000000000
--- a/test/e2e/specs/editor/various/block-bindings.spec.js
+++ /dev/null
@@ -1,2415 +0,0 @@
-/**
- * External dependencies
- */
-const path = require( 'path' );
-/**
- * WordPress dependencies
- */
-const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
-
-test.describe( 'Block bindings', () => {
- let imagePlaceholderSrc;
- let imageCustomFieldSrc;
- test.beforeAll( async ( { requestUtils } ) => {
- await requestUtils.activateTheme( 'emptytheme' );
- await requestUtils.activatePlugin( 'gutenberg-test-block-bindings' );
- await requestUtils.deleteAllMedia();
- const placeholderMedia = await requestUtils.uploadMedia(
- path.join( './test/e2e/assets', '10x10_e2e_test_image_z9T8jK.png' )
- );
- imagePlaceholderSrc = placeholderMedia.source_url;
- } );
-
- test.afterEach( async ( { requestUtils } ) => {
- await requestUtils.deleteAllPosts();
- } );
-
- test.afterAll( async ( { requestUtils } ) => {
- await requestUtils.deleteAllMedia();
- await requestUtils.activateTheme( 'twentytwentyone' );
- await requestUtils.deactivatePlugin( 'gutenberg-test-block-bindings' );
- } );
-
- test.describe( 'Template context', () => {
- test.beforeEach( async ( { admin, editor } ) => {
- await admin.visitSiteEditor( {
- postId: 'emptytheme//index',
- postType: 'wp_template',
- canvas: 'edit',
- } );
- await editor.openDocumentSettingsSidebar();
- } );
-
- test.describe( 'Paragraph', () => {
- test( 'should show the key of the custom field in post meta', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText(
- 'text_custom_field'
- );
- } );
-
- test( 'should show the key of the custom field in server sources with key', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/server-source',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText(
- 'text_custom_field'
- );
- } );
-
- test( 'should show the source label in server sources without key', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/server-source',
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText( 'Server Source' );
- } );
-
- test( 'should lock the appropriate controls with a registered source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await paragraphBlock.click();
-
- // Alignment controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Align text' } )
- ).toBeVisible();
-
- // Format controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeHidden();
-
- // Paragraph is not editable.
- await expect( paragraphBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
- } );
-
- test( 'should lock the appropriate controls when source is not defined', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'plugin/undefined-source',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await paragraphBlock.click();
-
- // Alignment controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Align text' } )
- ).toBeVisible();
-
- // Format controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeHidden();
-
- // Paragraph is not editable.
- await expect( paragraphBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
- } );
- } );
-
- test.describe( 'Heading', () => {
- test( 'should show the key of the custom field', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/heading',
- attributes: {
- content: 'heading default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const headingBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Heading',
- } );
- await expect( headingBlock ).toHaveText( 'text_custom_field' );
- } );
-
- test( 'should lock the appropriate controls with a registered source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/heading',
- attributes: {
- content: 'heading default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const headingBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Heading',
- } );
- await headingBlock.click();
-
- // Alignment controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Align text' } )
- ).toBeVisible();
-
- // Format controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeHidden();
-
- // Heading is not editable.
- await expect( headingBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
- } );
-
- test( 'should lock the appropriate controls when source is not defined', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/heading',
- attributes: {
- content: 'heading default content',
- metadata: {
- bindings: {
- content: {
- source: 'plugin/undefined-source',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const headingBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Heading',
- } );
- await headingBlock.click();
-
- // Alignment controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Align text' } )
- ).toBeVisible();
-
- // Format controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeHidden();
-
- // Heading is not editable.
- await expect( headingBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
- } );
- } );
-
- test.describe( 'Button', () => {
- test( 'should show the key of the custom field when text is bound', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- text: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- const buttonBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } );
- await expect( buttonBlock ).toHaveText( 'text_custom_field' );
- } );
-
- test( 'should lock text controls when text is bound to a registered source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- text: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- const buttonBlock = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' );
- await buttonBlock.click();
-
- // Alignment controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Align text' } )
- ).toBeVisible();
-
- // Format controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeHidden();
-
- // Button is not editable.
- await expect( buttonBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
-
- // Link controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Unlink' } )
- ).toBeVisible();
- } );
-
- test( 'should lock text controls when text is bound to an undefined source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- text: {
- source: 'plugin/undefined-source',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- const buttonBlock = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' );
- await buttonBlock.click();
-
- // Alignment controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Align text' } )
- ).toBeVisible();
-
- // Format controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeHidden();
-
- // Button is not editable.
- await expect( buttonBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
-
- // Link controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Unlink' } )
- ).toBeVisible();
- } );
-
- test( 'should lock url controls when url is bound to a registered source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- const buttonBlock = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' );
- await buttonBlock.click();
-
- // Format controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeVisible();
-
- // Button is editable.
- await expect( buttonBlock ).toHaveAttribute(
- 'contenteditable',
- 'true'
- );
-
- // Link controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Link' } )
- ).toBeHidden();
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Unlink' } )
- ).toBeHidden();
- } );
-
- test( 'should lock url controls when url is bound to an undefined source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- url: {
- source: 'plugin/undefined-source',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- const buttonBlock = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' );
- await buttonBlock.click();
-
- // Format controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeVisible();
-
- // Button is editable.
- await expect( buttonBlock ).toHaveAttribute(
- 'contenteditable',
- 'true'
- );
-
- // Link controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Link' } )
- ).toBeHidden();
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Unlink' } )
- ).toBeHidden();
- } );
-
- test( 'should lock url and text controls when both are bound', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- text: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- const buttonBlock = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' );
- await buttonBlock.click();
-
- // Alignment controls are visible.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Align text' } )
- ).toBeVisible();
-
- // Format controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeHidden();
-
- // Button is not editable.
- await expect( buttonBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
-
- // Link controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Link' } )
- ).toBeHidden();
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Unlink' } )
- ).toBeHidden();
- } );
- } );
-
- test.describe( 'Image', () => {
- test( 'should show the upload form when url is not bound', async ( {
- editor,
- } ) => {
- await editor.insertBlock( { name: 'core/image' } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
- await expect(
- imageBlock.getByRole( 'button', { name: 'Upload' } )
- ).toBeVisible();
- } );
-
- test( 'should NOT show the upload form when url is bound to a registered source', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
- await expect(
- imageBlock.getByRole( 'button', { name: 'Upload' } )
- ).toBeHidden();
- } );
-
- test( 'should NOT show the upload form when url is bound to an undefined source', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'plugin/undefined-source',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
- await expect(
- imageBlock.getByRole( 'button', { name: 'Upload' } )
- ).toBeHidden();
- } );
-
- test( 'should lock url controls when url is bound to a registered source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
-
- // Replace controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- ).toBeHidden();
-
- // Image placeholder doesn't show the upload button.
- await expect(
- imageBlock.getByRole( 'button', { name: 'Upload' } )
- ).toBeHidden();
-
- // Alt textarea is enabled and with the original value.
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- ).toBeEnabled();
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'default alt value' );
-
- // Title input is enabled and with the original value.
- await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', { name: 'Advanced' } )
- .click();
-
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- ).toBeEnabled();
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'default title value' );
- } );
-
- test( 'should lock url controls when url is bound to an undefined source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'plugin/undefined-source',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
-
- // Replace controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- ).toBeHidden();
-
- // Image placeholder doesn't show the upload button.
- await expect(
- imageBlock.getByRole( 'button', { name: 'Upload' } )
- ).toBeHidden();
-
- // Alt textarea is enabled and with the original value.
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- ).toBeEnabled();
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'default alt value' );
-
- // Title input is enabled and with the original value.
- await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', { name: 'Advanced' } )
- .click();
-
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- ).toBeEnabled();
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'default title value' );
- } );
-
- test( 'should disable alt textarea when alt is bound to a registered source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- alt: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
-
- // Replace controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- ).toBeVisible();
-
- // Alt textarea is disabled and with the custom field value.
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- ).toHaveAttribute( 'readonly' );
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'text_custom_field' );
-
- // Title input is enabled and with the original value.
- await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', { name: 'Advanced' } )
- .click();
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- ).toBeEnabled();
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'default title value' );
- } );
-
- test( 'should disable alt textarea when alt is bound to an undefined source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- alt: {
- source: 'plguin/undefined-source',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
-
- // Replace controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- ).toBeVisible();
-
- // Alt textarea is disabled and with the custom field value.
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- ).toHaveAttribute( 'readonly' );
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'default alt value' );
-
- // Title input is enabled and with the original value.
- await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', { name: 'Advanced' } )
- .click();
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- ).toBeEnabled();
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'default title value' );
- } );
-
- test( 'should disable title input when title is bound to a registered source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- title: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
-
- // Replace controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- ).toBeVisible();
-
- // Alt textarea is enabled and with the original value.
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- ).toBeEnabled();
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'default alt value' );
-
- // Title input is disabled and with the custom field value.
- await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', { name: 'Advanced' } )
- .click();
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- ).toHaveAttribute( 'readonly' );
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'text_custom_field' );
- } );
-
- test( 'should disable title input when title is bound to an undefined source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- title: {
- source: 'plugin/undefined-source',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
-
- // Replace controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- ).toBeVisible();
-
- // Alt textarea is enabled and with the original value.
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- ).toBeEnabled();
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'default alt value' );
-
- // Title input is disabled and with the custom field value.
- await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', { name: 'Advanced' } )
- .click();
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- ).toHaveAttribute( 'readonly' );
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'default title value' );
- } );
-
- test( 'Multiple bindings should lock the appropriate controls', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- alt: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
-
- // Replace controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- ).toBeHidden();
-
- // Image placeholder doesn't show the upload button.
- await expect(
- imageBlock.getByRole( 'button', { name: 'Upload' } )
- ).toBeHidden();
-
- // Alt textarea is disabled and with the custom field value.
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- ).toHaveAttribute( 'readonly' );
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'text_custom_field' );
-
- // Title input is enabled and with the original value.
- await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', { name: 'Advanced' } )
- .click();
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- ).toBeEnabled();
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'default title value' );
- } );
- } );
- } );
-
- test.describe( 'Post/page context', () => {
- test.beforeEach( async ( { admin } ) => {
- await admin.createNewPost( { title: 'Test bindings' } );
- } );
- test.describe( 'Paragraph', () => {
- test( 'should show the value of the custom field when exists', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'paragraph-binding',
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText(
- 'Value of the text custom field'
- );
-
- // Check the frontend shows the value of the custom field.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#paragraph-binding' )
- ).toHaveText( 'Value of the text custom field' );
- } );
-
- test( "should show the value of the key when custom field doesn't exist", async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'paragraph-binding',
- content: 'fallback value',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'non_existing_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText(
- 'non_existing_custom_field'
- );
- // Paragraph is not editable.
- await expect( paragraphBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
-
- // Check the frontend doesn't show the content.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#paragraph-binding' )
- ).toHaveText( 'fallback value' );
- } );
-
- test( 'should show the prompt placeholder in field with empty value', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'empty_field' },
- },
- },
- },
- },
- } );
-
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- // Aria-label is changed for empty paragraphs.
- name: 'Add empty_field',
- } );
-
- await expect( paragraphBlock ).toBeEmpty();
-
- const placeholder = paragraphBlock.locator( 'span' );
- await expect( placeholder ).toHaveAttribute(
- 'data-rich-text-placeholder',
- 'Add empty_field'
- );
- } );
-
- test( 'should not show the value of a protected meta field', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'paragraph-binding',
- content: 'fallback value',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: '_protected_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText( '_protected_field' );
- // Check the frontend doesn't show the content.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#paragraph-binding' )
- ).toHaveText( 'fallback value' );
- } );
-
- test( 'should not show the value of a meta field with `show_in_rest` false', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'paragraph-binding',
- content: 'fallback value',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'show_in_rest_false_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText(
- 'show_in_rest_false_field'
- );
- // Check the frontend doesn't show the content.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#paragraph-binding' )
- ).toHaveText( 'fallback value' );
- } );
-
- test( 'should add empty paragraph block when pressing enter', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- // Select the paragraph and press Enter at the end of it.
- const paragraph = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await editor.selectBlocks( paragraph );
- await page.keyboard.press( 'End' );
- await page.keyboard.press( 'Enter' );
- const [ initialParagraph, newEmptyParagraph ] =
- await editor.canvas
- .locator( '[data-type="core/paragraph"]' )
- .all();
- await expect( initialParagraph ).toHaveText(
- 'Value of the text custom field'
- );
- await expect( newEmptyParagraph ).toHaveText( '' );
- await expect( newEmptyParagraph ).toBeEditable();
- } );
-
- test( 'should NOT be possible to edit the value of the custom field when it is protected', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'protected-field-binding',
- content: 'fallback value',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: '_protected_field' },
- },
- },
- },
- },
- } );
-
- const protectedFieldBlock = editor.canvas.getByRole(
- 'document',
- {
- name: 'Block: Paragraph',
- }
- );
-
- await expect( protectedFieldBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
- } );
-
- test( 'should NOT be possible to edit the value of the custom field when it is not shown in the REST API', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'show-in-rest-false-binding',
- content: 'fallback value',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'show_in_rest_false_field' },
- },
- },
- },
- },
- } );
-
- const showInRestFalseBlock = editor.canvas.getByRole(
- 'document',
- {
- name: 'Block: Paragraph',
- }
- );
-
- await expect( showInRestFalseBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
- } );
- test( 'should show a selector for content', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- } );
- await page.getByLabel( 'Attributes options' ).click();
- const contentAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show content',
- } );
- await expect( contentAttribute ).toBeVisible();
- } );
- test( 'should use a selector to update the content', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'fallback value',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'undefined_field' },
- },
- },
- },
- },
- } );
- await page.getByRole( 'button', { name: 'content' } ).click();
-
- await page
- .getByRole( 'menuitemradio' )
- .filter( { hasText: 'text_custom_field' } )
- .click();
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText(
- 'Value of the text custom field'
- );
- } );
- } );
-
- test.describe( 'Heading', () => {
- test( 'should show the value of the custom field', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/heading',
- attributes: {
- anchor: 'heading-binding',
- content: 'heading default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const headingBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Heading',
- } );
- await expect( headingBlock ).toHaveText(
- 'Value of the text custom field'
- );
-
- // Check the frontend shows the value of the custom field.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#heading-binding' )
- ).toHaveText( 'Value of the text custom field' );
- } );
-
- test( 'should add empty paragraph block when pressing enter', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/heading',
- attributes: {
- anchor: 'heading-binding',
- content: 'heading default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
-
- // Select the heading and press Enter at the end of it.
- const heading = editor.canvas.getByRole( 'document', {
- name: 'Block: Heading',
- } );
- await editor.selectBlocks( heading );
- await page.keyboard.press( 'End' );
- await page.keyboard.press( 'Enter' );
- // Can't use `editor.getBlocks` because it doesn't return the meta value shown in the editor.
- const [ initialHeading, newEmptyParagraph ] =
- await editor.canvas.locator( '[data-block]' ).all();
- // First block should be the original block.
- await expect( initialHeading ).toHaveAttribute(
- 'data-type',
- 'core/heading'
- );
- await expect( initialHeading ).toHaveText(
- 'Value of the text custom field'
- );
- // Second block should be an empty paragraph block.
- await expect( newEmptyParagraph ).toHaveAttribute(
- 'data-type',
- 'core/paragraph'
- );
- await expect( newEmptyParagraph ).toHaveText( '' );
- await expect( newEmptyParagraph ).toBeEditable();
- } );
- test( 'should show a selector for content', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/heading',
- } );
- await page.getByLabel( 'Attributes options' ).click();
- const contentAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show content',
- } );
- await expect( contentAttribute ).toBeVisible();
- } );
- } );
-
- test.describe( 'Button', () => {
- test( 'should show the value of the custom field when text is bound', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- anchor: 'button-text-binding',
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- text: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- const buttonBlock = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' );
- await buttonBlock.click();
- await expect( buttonBlock ).toHaveText(
- 'Value of the text custom field'
- );
-
- // Check the frontend shows the value of the custom field.
- const previewPage = await editor.openPreviewPage();
- const buttonDom = previewPage.locator(
- '#button-text-binding a'
- );
- await expect( buttonDom ).toHaveText(
- 'Value of the text custom field'
- );
- await expect( buttonDom ).toHaveAttribute(
- 'href',
- '#default-url'
- );
- } );
-
- test( 'should use the value of the custom field when url is bound', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- anchor: 'button-url-binding',
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
-
- // Check the frontend shows the original value of the custom field.
- const previewPage = await editor.openPreviewPage();
- const buttonDom = previewPage.locator(
- '#button-url-binding a'
- );
- await expect( buttonDom ).toHaveText( 'button default text' );
- await expect( buttonDom ).toHaveAttribute(
- 'href',
- '#url-custom-field'
- );
- } );
-
- test( 'should use the values of the custom fields when text and url are bound', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- anchor: 'button-multiple-bindings',
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- text: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
-
- // Check the frontend uses the values of the custom fields.
- const previewPage = await editor.openPreviewPage();
- const buttonDom = previewPage.locator(
- '#button-multiple-bindings a'
- );
- await expect( buttonDom ).toHaveText(
- 'Value of the text custom field'
- );
- await expect( buttonDom ).toHaveAttribute(
- 'href',
- '#url-custom-field'
- );
- } );
-
- test( 'should add empty button block when pressing enter', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- anchor: 'button-text-binding',
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- text: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- await editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' )
- .click();
- await page.keyboard.press( 'End' );
- await page.keyboard.press( 'Enter' );
- const [ initialButton, newEmptyButton ] = await editor.canvas
- .locator( '[data-type="core/button"]' )
- .all();
- // First block should be the original block.
- await expect( initialButton ).toHaveText(
- 'Value of the text custom field'
- );
- // Second block should be an empty paragraph block.
- await expect( newEmptyButton ).toHaveText( '' );
- await expect( newEmptyButton ).toBeEditable();
- } );
- test( 'should show a selector for url, text, linkTarget and rel', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- },
- ],
- } );
- await editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' )
- .click();
- await page
- .getByRole( 'tabpanel', {
- name: 'Settings',
- } )
- .getByLabel( 'Attributes options' )
- .click();
- const urlAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show url',
- } );
- await expect( urlAttribute ).toBeVisible();
- const textAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show text',
- } );
- await expect( textAttribute ).toBeVisible();
- const linkTargetAttribute = page.getByRole(
- 'menuitemcheckbox',
- {
- name: 'Show linkTarget',
- }
- );
- await expect( linkTargetAttribute ).toBeVisible();
- const relAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show rel',
- } );
- await expect( relAttribute ).toBeVisible();
- } );
- } );
-
- test.describe( 'Image', () => {
- test.beforeAll( async ( { requestUtils } ) => {
- const customFieldMedia = await requestUtils.uploadMedia(
- path.join(
- './test/e2e/assets',
- '1024x768_e2e_test_image_size.jpeg'
- )
- );
- imageCustomFieldSrc = customFieldMedia.source_url;
- } );
-
- test.beforeEach( async ( { editor, page, requestUtils } ) => {
- const postId = await editor.publishPost();
- await requestUtils.rest( {
- method: 'POST',
- path: '/wp/v2/posts/' + postId,
- data: {
- meta: {
- url_custom_field: imageCustomFieldSrc,
- },
- },
- } );
- await page.reload();
- } );
- test( 'should show the value of the custom field when url is bound', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- anchor: 'image-url-binding',
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlockImg = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Image',
- } )
- .locator( 'img' );
- await expect( imageBlockImg ).toHaveAttribute(
- 'src',
- imageCustomFieldSrc
- );
-
- // Check the frontend uses the value of the custom field.
- const previewPage = await editor.openPreviewPage();
- const imageDom = previewPage.locator(
- '#image-url-binding img'
- );
- await expect( imageDom ).toHaveAttribute(
- 'src',
- imageCustomFieldSrc
- );
- await expect( imageDom ).toHaveAttribute(
- 'alt',
- 'default alt value'
- );
- await expect( imageDom ).toHaveAttribute(
- 'title',
- 'default title value'
- );
- } );
-
- test( 'should show value of the custom field in the alt textarea when alt is bound', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- anchor: 'image-alt-binding',
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- alt: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlockImg = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Image',
- } )
- .locator( 'img' );
- await imageBlockImg.click();
-
- // Image src is the placeholder.
- await expect( imageBlockImg ).toHaveAttribute(
- 'src',
- imagePlaceholderSrc
- );
-
- // Alt textarea should have the custom field value.
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'Value of the text custom field' );
-
- // Check the frontend uses the value of the custom field.
- const previewPage = await editor.openPreviewPage();
- const imageDom = previewPage.locator(
- '#image-alt-binding img'
- );
- await expect( imageDom ).toHaveAttribute(
- 'src',
- imagePlaceholderSrc
- );
- await expect( imageDom ).toHaveAttribute(
- 'alt',
- 'Value of the text custom field'
- );
- await expect( imageDom ).toHaveAttribute(
- 'title',
- 'default title value'
- );
- } );
-
- test( 'should show value of the custom field in the title input when title is bound', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- anchor: 'image-title-binding',
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- title: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlockImg = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Image',
- } )
- .locator( 'img' );
- await imageBlockImg.click();
-
- // Image src is the placeholder.
- await expect( imageBlockImg ).toHaveAttribute(
- 'src',
- imagePlaceholderSrc
- );
-
- // Title input should have the custom field value.
- const advancedButton = page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', {
- name: 'Advanced',
- } );
- const isAdvancedPanelOpen =
- await advancedButton.getAttribute( 'aria-expanded' );
- if ( isAdvancedPanelOpen === 'false' ) {
- await advancedButton.click();
- }
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'Value of the text custom field' );
-
- // Check the frontend uses the value of the custom field.
- const previewPage = await editor.openPreviewPage();
- const imageDom = previewPage.locator(
- '#image-title-binding img'
- );
- await expect( imageDom ).toHaveAttribute(
- 'src',
- imagePlaceholderSrc
- );
- await expect( imageDom ).toHaveAttribute(
- 'alt',
- 'default alt value'
- );
- await expect( imageDom ).toHaveAttribute(
- 'title',
- 'Value of the text custom field'
- );
- } );
-
- test( 'Multiple bindings should show the value of the custom fields', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- anchor: 'image-multiple-bindings',
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- alt: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlockImg = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Image',
- } )
- .locator( 'img' );
- await imageBlockImg.click();
-
- // Image src is the custom field value.
- await expect( imageBlockImg ).toHaveAttribute(
- 'src',
- imageCustomFieldSrc
- );
-
- // Alt textarea should have the custom field value.
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'Value of the text custom field' );
-
- // Title input should have the original value.
- const advancedButton = page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', {
- name: 'Advanced',
- } );
- const isAdvancedPanelOpen =
- await advancedButton.getAttribute( 'aria-expanded' );
- if ( isAdvancedPanelOpen === 'false' ) {
- await advancedButton.click();
- }
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'default title value' );
-
- // Check the frontend uses the values of the custom fields.
- const previewPage = await editor.openPreviewPage();
- const imageDom = previewPage.locator(
- '#image-multiple-bindings img'
- );
- await expect( imageDom ).toHaveAttribute(
- 'src',
- imageCustomFieldSrc
- );
- await expect( imageDom ).toHaveAttribute(
- 'alt',
- 'Value of the text custom field'
- );
- await expect( imageDom ).toHaveAttribute(
- 'title',
- 'default title value'
- );
- } );
- test( 'should show a selector for url, id, title and alt', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- } );
- await page
- .getByRole( 'tabpanel', {
- name: 'Settings',
- } )
- .getByLabel( 'Attributes options' )
- .click();
- const urlAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show url',
- } );
- await expect( urlAttribute ).toBeVisible();
- const idAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show id',
- } );
- await expect( idAttribute ).toBeVisible();
- const titleAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show title',
- } );
- await expect( titleAttribute ).toBeVisible();
- const altAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show alt',
- } );
- await expect( altAttribute ).toBeVisible();
- } );
- } );
-
- test.describe( 'Edit custom fields', () => {
- test( 'should be possible to edit the value of the custom field from the paragraph', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'paragraph-binding',
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
-
- await expect( paragraphBlock ).toHaveAttribute(
- 'contenteditable',
- 'true'
- );
- await paragraphBlock.fill( 'new value' );
- // Check that the paragraph content attribute didn't change.
- const [ paragraphBlockObject ] = await editor.getBlocks();
- expect( paragraphBlockObject.attributes.content ).toBe(
- 'paragraph default content'
- );
- // Check the value of the custom field is being updated by visiting the frontend.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#paragraph-binding' )
- ).toHaveText( 'new value' );
- } );
-
- // Related issue: https://github.com/WordPress/gutenberg/issues/62347
- test( 'should be possible to use symbols and numbers as the custom field value', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'paragraph-binding',
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
-
- await expect( paragraphBlock ).toHaveAttribute(
- 'contenteditable',
- 'true'
- );
- await paragraphBlock.fill( '$10.00' );
- // Check the value of the custom field is being updated by visiting the frontend.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#paragraph-binding' )
- ).toHaveText( '$10.00' );
- } );
-
- test( 'should be possible to edit the value of the url custom field from the button', async ( {
- editor,
- page,
- pageUtils,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- anchor: 'button-url-binding',
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
-
- // Edit the url.
- const buttonBlock = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' );
- await buttonBlock.click();
- await page
- .getByRole( 'button', { name: 'Edit link', exact: true } )
- .click();
- await page
- .getByPlaceholder( 'Search or type URL' )
- .fill( '#url-custom-field-modified' );
- await pageUtils.pressKeys( 'Enter' );
-
- // Check that the button url attribute didn't change.
- const [ buttonsObject ] = await editor.getBlocks();
- expect( buttonsObject.innerBlocks[ 0 ].attributes.url ).toBe(
- '#default-url'
- );
- // Check the value of the custom field is being updated by visiting the frontend.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#button-url-binding a' )
- ).toHaveAttribute( 'href', '#url-custom-field-modified' );
- } );
-
- test( 'should be possible to edit the value of the url custom field from the image', async ( {
- editor,
- page,
- pageUtils,
- requestUtils,
- } ) => {
- const customFieldMedia = await requestUtils.uploadMedia(
- path.join(
- './test/e2e/assets',
- '1024x768_e2e_test_image_size.jpeg'
- )
- );
- imageCustomFieldSrc = customFieldMedia.source_url;
-
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- anchor: 'image-url-binding',
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- } );
-
- // Edit image url.
- await page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- .click();
- await page
- .getByRole( 'button', { name: 'Edit link', exact: true } )
- .click();
- await page
- .getByPlaceholder( 'Search or type URL' )
- .fill( imageCustomFieldSrc );
- await pageUtils.pressKeys( 'Enter' );
-
- // Check that the image url attribute didn't change and still uses the placeholder.
- const [ imageBlockObject ] = await editor.getBlocks();
- expect( imageBlockObject.attributes.url ).toBe(
- imagePlaceholderSrc
- );
-
- // Check the value of the custom field is being updated by visiting the frontend.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#image-url-binding img' )
- ).toHaveAttribute( 'src', imageCustomFieldSrc );
- } );
-
- test( 'should be possible to edit the value of the text custom field from the image alt', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- anchor: 'image-alt-binding',
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- metadata: {
- bindings: {
- alt: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlockImg = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Image',
- } )
- .locator( 'img' );
- await imageBlockImg.click();
-
- // Edit the custom field value in the alt textarea.
- const altInputArea = page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' );
- await expect( altInputArea ).not.toHaveAttribute( 'readonly' );
- await altInputArea.fill( 'new value' );
-
- // Check that the image alt attribute didn't change.
- const [ imageBlockObject ] = await editor.getBlocks();
- expect( imageBlockObject.attributes.alt ).toBe(
- 'default alt value'
- );
- // Check the value of the custom field is being updated by visiting the frontend.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#image-alt-binding img' )
- ).toHaveAttribute( 'alt', 'new value' );
- } );
- } );
- } );
-
- test.describe( 'Sources registration', () => {
- test.beforeEach( async ( { admin } ) => {
- await admin.createNewPost( { title: 'Test bindings' } );
- } );
-
- test( 'should show the label of a source only registered in the server', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- metadata: {
- bindings: {
- content: {
- source: 'core/server-source',
- },
- },
- },
- },
- } );
-
- const bindingsPanel = page.locator(
- '.block-editor-bindings__panel'
- );
- await expect( bindingsPanel ).toContainText( 'Server Source' );
- } );
- } );
-} );
diff --git a/test/e2e/specs/editor/various/block-bindings/custom-sources.spec.js b/test/e2e/specs/editor/various/block-bindings/custom-sources.spec.js
new file mode 100644
index 00000000000000..d6563ce9cb5f5f
--- /dev/null
+++ b/test/e2e/specs/editor/various/block-bindings/custom-sources.spec.js
@@ -0,0 +1,1204 @@
+/**
+ * External dependencies
+ */
+const path = require( 'path' );
+/**
+ * WordPress dependencies
+ */
+const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
+
+test.describe( 'Registered sources', () => {
+ let imagePlaceholderSrc;
+ let testingImgSrc;
+ test.beforeAll( async ( { requestUtils } ) => {
+ await requestUtils.activateTheme(
+ 'gutenberg-test-themes/block-bindings'
+ );
+ await requestUtils.activatePlugin( 'gutenberg-test-block-bindings' );
+ await requestUtils.deleteAllMedia();
+ const placeholderMedia = await requestUtils.uploadMedia(
+ path.join( './test/e2e/assets', '10x10_e2e_test_image_z9T8jK.png' )
+ );
+ imagePlaceholderSrc = placeholderMedia.source_url;
+
+ const testingImgMedia = await requestUtils.uploadMedia(
+ path.join(
+ './test/e2e/assets',
+ '1024x768_e2e_test_image_size.jpeg'
+ )
+ );
+ testingImgSrc = testingImgMedia.source_url;
+ } );
+
+ test.beforeEach( async ( { admin } ) => {
+ await admin.createNewPost( { title: 'Test bindings' } );
+ } );
+
+ test.afterEach( async ( { requestUtils } ) => {
+ await requestUtils.deleteAllPosts();
+ } );
+
+ test.afterAll( async ( { requestUtils } ) => {
+ await requestUtils.deleteAllMedia();
+ await requestUtils.activateTheme( 'twentytwentyone' );
+ await requestUtils.deactivatePlugin( 'gutenberg-test-block-bindings' );
+ } );
+
+ test.describe( 'getValues', () => {
+ test( 'should show the returned value in paragraph content', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ anchor: 'connected-paragraph',
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText( 'Text Field Value' );
+
+ // Check the frontend shows the value of the custom field.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#connected-paragraph' )
+ ).toHaveText( 'Text Field Value' );
+ } );
+ test( 'should show the returned value in heading content', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/heading',
+ attributes: {
+ anchor: 'connected-heading',
+ content: 'heading default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const headingBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Heading',
+ } );
+ await expect( headingBlock ).toHaveText( 'Text Field Value' );
+
+ // Check the frontend shows the value of the custom field.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#connected-heading' )
+ ).toHaveText( 'Text Field Value' );
+ } );
+ test( 'should show the returned values in button attributes', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/buttons',
+ innerBlocks: [
+ {
+ name: 'core/button',
+ attributes: {
+ anchor: 'connected-button',
+ text: 'button default text',
+ url: '#default-url',
+ metadata: {
+ bindings: {
+ text: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ url: {
+ source: 'testing/complete-source',
+ args: { key: 'url_field' },
+ },
+ },
+ },
+ },
+ },
+ ],
+ } );
+
+ // Check the frontend uses the values of the custom fields.
+ const previewPage = await editor.openPreviewPage();
+ const buttonDom = previewPage.locator( '#connected-button a' );
+ await expect( buttonDom ).toHaveText( 'Text Field Value' );
+ await expect( buttonDom ).toHaveAttribute( 'href', testingImgSrc );
+ } );
+ test( 'should show the returned values in image attributes', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/image',
+ attributes: {
+ anchor: 'connected-image',
+ url: imagePlaceholderSrc,
+ alt: 'default alt value',
+ title: 'default title value',
+ metadata: {
+ bindings: {
+ url: {
+ source: 'testing/complete-source',
+ args: { key: 'url_field' },
+ },
+ alt: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const imageBlockImg = editor.canvas
+ .getByRole( 'document', {
+ name: 'Block: Image',
+ } )
+ .locator( 'img' );
+ await imageBlockImg.click();
+
+ // Image src is the custom field value.
+ await expect( imageBlockImg ).toHaveAttribute(
+ 'src',
+ testingImgSrc
+ );
+
+ // Alt textarea should have the custom field value.
+ const altValue = await page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByLabel( 'Alternative text' )
+ .inputValue();
+ expect( altValue ).toBe( 'Text Field Value' );
+
+ // Title input should have the original value.
+ const advancedButton = page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByRole( 'button', {
+ name: 'Advanced',
+ } );
+ const isAdvancedPanelOpen =
+ await advancedButton.getAttribute( 'aria-expanded' );
+ if ( isAdvancedPanelOpen === 'false' ) {
+ await advancedButton.click();
+ }
+ const titleValue = await page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByLabel( 'Title attribute' )
+ .inputValue();
+ expect( titleValue ).toBe( 'default title value' );
+
+ // Check the frontend uses the values of the custom fields.
+ const previewPage = await editor.openPreviewPage();
+ const imageDom = previewPage.locator( '#connected-image img' );
+ await expect( imageDom ).toHaveAttribute( 'src', testingImgSrc );
+ await expect( imageDom ).toHaveAttribute(
+ 'alt',
+ 'Text Field Value'
+ );
+ await expect( imageDom ).toHaveAttribute(
+ 'title',
+ 'default title value'
+ );
+ } );
+ test( 'should fall back to source label when `getValues` is undefined', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/server-only-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText( 'Server Source' );
+ } );
+ test( 'should fall back to null when `getValues` is undefined in URL attributes', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/image',
+ attributes: {
+ metadata: {
+ bindings: {
+ url: {
+ source: 'testing/server-only-source',
+ args: { key: 'url_field' },
+ },
+ },
+ },
+ },
+ } );
+ const imageBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Image',
+ } );
+ await expect(
+ imageBlock.locator( '.components-placeholder__fieldset' )
+ ).toHaveText( 'Connected to Server Source' );
+ } );
+ } );
+
+ test.describe( 'should lock editing', () => {
+ // Logic reused accross all the tests that check paragraph editing is locked.
+ async function testParagraphControlsAreLocked( {
+ source,
+ editor,
+ page,
+ } ) {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source,
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await paragraphBlock.click();
+
+ // Alignment controls exist.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', { name: 'Align text' } )
+ ).toBeVisible();
+
+ // Format controls don't exist.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', {
+ name: 'Bold',
+ } )
+ ).toBeHidden();
+
+ // Paragraph is not editable.
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'false'
+ );
+ }
+ test.describe( 'canUserEditValue returns false', () => {
+ test( 'paragraph', async ( { editor, page } ) => {
+ await testParagraphControlsAreLocked( {
+ source: 'testing/can-user-edit-false',
+ editor,
+ page,
+ } );
+ } );
+ test( 'heading', async ( { editor, page } ) => {
+ await editor.insertBlock( {
+ name: 'core/heading',
+ attributes: {
+ content: 'heading default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/can-user-edit-false',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const headingBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Heading',
+ } );
+ await headingBlock.click();
+
+ // Alignment controls exist.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', { name: 'Align text' } )
+ ).toBeVisible();
+
+ // Format controls don't exist.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', {
+ name: 'Bold',
+ } )
+ ).toBeHidden();
+
+ // Heading is not editable.
+ await expect( headingBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'false'
+ );
+ } );
+ test( 'button', async ( { editor, page } ) => {
+ await editor.insertBlock( {
+ name: 'core/buttons',
+ innerBlocks: [
+ {
+ name: 'core/button',
+ attributes: {
+ text: 'button default text',
+ url: '#default-url',
+ metadata: {
+ bindings: {
+ text: {
+ source: 'testing/can-user-edit-false',
+ args: { key: 'text_field' },
+ },
+ url: {
+ source: 'testing/can-user-edit-false',
+ args: { key: 'url_field' },
+ },
+ },
+ },
+ },
+ },
+ ],
+ } );
+ const buttonBlock = editor.canvas
+ .getByRole( 'document', {
+ name: 'Block: Button',
+ exact: true,
+ } )
+ .getByRole( 'textbox' );
+ await buttonBlock.click();
+
+ // Alignment controls are visible.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', { name: 'Align text' } )
+ ).toBeVisible();
+
+ // Format controls don't exist.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', {
+ name: 'Bold',
+ } )
+ ).toBeHidden();
+
+ // Button is not editable.
+ await expect( buttonBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'false'
+ );
+
+ // Link controls don't exist.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', { name: 'Link' } )
+ ).toBeHidden();
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', { name: 'Unlink' } )
+ ).toBeHidden();
+ } );
+ test( 'image', async ( { editor, page } ) => {
+ await editor.insertBlock( {
+ name: 'core/image',
+ attributes: {
+ url: imagePlaceholderSrc,
+ alt: 'default alt value',
+ title: 'default title value',
+ metadata: {
+ bindings: {
+ url: {
+ source: 'testing/can-user-edit-false',
+ args: { key: 'url_field' },
+ },
+ alt: {
+ source: 'testing/can-user-edit-false',
+ args: { key: 'text_field' },
+ },
+ title: {
+ source: 'testing/can-user-edit-false',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const imageBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Image',
+ } );
+ await imageBlock.click();
+
+ // Replace controls don't exist.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', {
+ name: 'Replace',
+ } )
+ ).toBeHidden();
+
+ // Image placeholder doesn't show the upload button.
+ await expect(
+ imageBlock.getByRole( 'button', { name: 'Upload' } )
+ ).toBeHidden();
+
+ // Alt textarea is disabled and with the custom field value.
+ await expect(
+ page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByLabel( 'Alternative text' )
+ ).toHaveAttribute( 'readonly' );
+ const altValue = await page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByLabel( 'Alternative text' )
+ .inputValue();
+ expect( altValue ).toBe( 'Text Field Value' );
+
+ // Title input is enabled and with the original value.
+ await page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByRole( 'button', { name: 'Advanced' } )
+ .click();
+ await expect(
+ page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByLabel( 'Title attribute' )
+ ).toHaveAttribute( 'readonly' );
+ const titleValue = await page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByLabel( 'Title attribute' )
+ .inputValue();
+ expect( titleValue ).toBe( 'Text Field Value' );
+ } );
+ } );
+ // The following tests just check the paragraph and assume is the case for the rest of the blocks.
+ test( 'canUserEditValue is not defined', async ( { editor, page } ) => {
+ await testParagraphControlsAreLocked( {
+ source: 'testing/can-user-edit-undefined',
+ editor,
+ page,
+ } );
+ } );
+ test( 'setValues is not defined', async ( { editor, page } ) => {
+ await testParagraphControlsAreLocked( {
+ source: 'testing/complete-source-undefined',
+ editor,
+ page,
+ } );
+ } );
+ test( 'source is not defined', async ( { editor, page } ) => {
+ await testParagraphControlsAreLocked( {
+ source: 'testing/undefined-source',
+ editor,
+ page,
+ } );
+ } );
+ } );
+
+ // Use `core/post-meta` source to test editing to avoid overcomplicating custom sources.
+ // It needs a source that can be consumed and edited from the server and the editor.
+ test.describe( 'setValues', () => {
+ test( 'should be possible to edit the value from paragraph content', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ anchor: 'connected-paragraph',
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: { key: 'text_custom_field' },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'true'
+ );
+ await paragraphBlock.fill( 'new value' );
+ // Check that the paragraph content attribute didn't change.
+ const [ paragraphBlockObject ] = await editor.getBlocks();
+ expect( paragraphBlockObject.attributes.content ).toBe(
+ 'paragraph default content'
+ );
+ // Check the value of the custom field is being updated by visiting the frontend.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#connected-paragraph' )
+ ).toHaveText( 'new value' );
+ } );
+ // Related issue: https://github.com/WordPress/gutenberg/issues/62347
+ test( 'should be possible to use symbols and numbers as the custom field value', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ anchor: 'paragraph-binding',
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: { key: 'text_custom_field' },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'true'
+ );
+ await paragraphBlock.fill( '$10.00' );
+ // Check the value of the custom field is being updated by visiting the frontend.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#paragraph-binding' )
+ ).toHaveText( '$10.00' );
+ } );
+ test( 'should be possible to edit the value of the url custom field from the button', async ( {
+ editor,
+ page,
+ pageUtils,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/buttons',
+ innerBlocks: [
+ {
+ name: 'core/button',
+ attributes: {
+ anchor: 'button-url-binding',
+ text: 'button default text',
+ url: '#default-url',
+ metadata: {
+ bindings: {
+ url: {
+ source: 'core/post-meta',
+ args: { key: 'url_custom_field' },
+ },
+ },
+ },
+ },
+ },
+ ],
+ } );
+
+ // Edit the url.
+ const buttonBlock = editor.canvas
+ .getByRole( 'document', {
+ name: 'Block: Button',
+ exact: true,
+ } )
+ .getByRole( 'textbox' );
+ await buttonBlock.click();
+ await page
+ .getByRole( 'button', { name: 'Edit link', exact: true } )
+ .click();
+ await page
+ .getByPlaceholder( 'Search or type URL' )
+ .fill( '#url-custom-field-modified' );
+ await pageUtils.pressKeys( 'Enter' );
+
+ // Check that the button url attribute didn't change.
+ const [ buttonsObject ] = await editor.getBlocks();
+ expect( buttonsObject.innerBlocks[ 0 ].attributes.url ).toBe(
+ '#default-url'
+ );
+ // Check the value of the custom field is being updated by visiting the frontend.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#button-url-binding a' )
+ ).toHaveAttribute( 'href', '#url-custom-field-modified' );
+ } );
+ test( 'should be possible to edit the value of the url custom field from the image', async ( {
+ editor,
+ page,
+ pageUtils,
+ requestUtils,
+ } ) => {
+ const customFieldMedia = await requestUtils.uploadMedia(
+ path.join(
+ './test/e2e/assets',
+ '1024x768_e2e_test_image_size.jpeg'
+ )
+ );
+ testingImgSrc = customFieldMedia.source_url;
+
+ await editor.insertBlock( {
+ name: 'core/image',
+ attributes: {
+ anchor: 'image-url-binding',
+ url: imagePlaceholderSrc,
+ alt: 'default alt value',
+ title: 'default title value',
+ metadata: {
+ bindings: {
+ url: {
+ source: 'core/post-meta',
+ args: { key: 'url_custom_field' },
+ },
+ },
+ },
+ },
+ } );
+
+ // Edit image url.
+ await page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', {
+ name: 'Replace',
+ } )
+ .click();
+ await page
+ .getByRole( 'button', { name: 'Edit link', exact: true } )
+ .click();
+ await page
+ .getByPlaceholder( 'Search or type URL' )
+ .fill( testingImgSrc );
+ await pageUtils.pressKeys( 'Enter' );
+
+ // Check that the image url attribute didn't change and still uses the placeholder.
+ const [ imageBlockObject ] = await editor.getBlocks();
+ expect( imageBlockObject.attributes.url ).toBe(
+ imagePlaceholderSrc
+ );
+
+ // Check the value of the custom field is being updated by visiting the frontend.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#image-url-binding img' )
+ ).toHaveAttribute( 'src', testingImgSrc );
+ } );
+ test( 'should be possible to edit the value of the text custom field from the image alt', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/image',
+ attributes: {
+ anchor: 'image-alt-binding',
+ url: imagePlaceholderSrc,
+ alt: 'default alt value',
+ metadata: {
+ bindings: {
+ alt: {
+ source: 'core/post-meta',
+ args: { key: 'text_custom_field' },
+ },
+ },
+ },
+ },
+ } );
+ const imageBlockImg = editor.canvas
+ .getByRole( 'document', {
+ name: 'Block: Image',
+ } )
+ .locator( 'img' );
+ await imageBlockImg.click();
+
+ // Edit the custom field value in the alt textarea.
+ const altInputArea = page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByLabel( 'Alternative text' );
+ await expect( altInputArea ).not.toHaveAttribute( 'readonly' );
+ await altInputArea.fill( 'new value' );
+
+ // Check that the image alt attribute didn't change.
+ const [ imageBlockObject ] = await editor.getBlocks();
+ expect( imageBlockObject.attributes.alt ).toBe(
+ 'default alt value'
+ );
+ // Check the value of the custom field is being updated by visiting the frontend.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#image-alt-binding img' )
+ ).toHaveAttribute( 'alt', 'new value' );
+ } );
+ } );
+
+ test.describe( 'getFieldsList', () => {
+ test( 'should be possible to update attribute value through bindings UI', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ } );
+ await page.getByLabel( 'Attributes options' ).click();
+ await page
+ .getByRole( 'menuitemcheckbox', {
+ name: 'Show content',
+ } )
+ .click();
+ await page.getByRole( 'button', { name: 'content' } ).click();
+ await page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Text Field Label' } )
+ .click();
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText( 'Text Field Value' );
+ } );
+ test( 'should be possible to connect the paragraph content', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ } );
+ await page.getByLabel( 'Attributes options' ).click();
+ const contentAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show content',
+ } );
+ await expect( contentAttribute ).toBeVisible();
+ } );
+ test( 'should be possible to connect the heading content', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/heading',
+ } );
+ await page.getByLabel( 'Attributes options' ).click();
+ const contentAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show content',
+ } );
+ await expect( contentAttribute ).toBeVisible();
+ } );
+ test( 'should be possible to connect the button supported attributes', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/buttons',
+ innerBlocks: [
+ {
+ name: 'core/button',
+ },
+ ],
+ } );
+ await editor.canvas
+ .getByRole( 'document', {
+ name: 'Block: Button',
+ exact: true,
+ } )
+ .getByRole( 'textbox' )
+ .click();
+ await page
+ .getByRole( 'tabpanel', {
+ name: 'Settings',
+ } )
+ .getByLabel( 'Attributes options' )
+ .click();
+ const urlAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show url',
+ } );
+ await expect( urlAttribute ).toBeVisible();
+ const textAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show text',
+ } );
+ await expect( textAttribute ).toBeVisible();
+ const linkTargetAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show linkTarget',
+ } );
+ await expect( linkTargetAttribute ).toBeVisible();
+ const relAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show rel',
+ } );
+ await expect( relAttribute ).toBeVisible();
+ // Check not supported attributes are not included.
+ const tagNameAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show tagName',
+ } );
+ await expect( tagNameAttribute ).toBeHidden();
+ } );
+ test( 'should be possible to connect the image supported attributes', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/image',
+ } );
+ await page
+ .getByRole( 'tabpanel', {
+ name: 'Settings',
+ } )
+ .getByLabel( 'Attributes options' )
+ .click();
+ const urlAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show url',
+ } );
+ await expect( urlAttribute ).toBeVisible();
+ const idAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show id',
+ } );
+ await expect( idAttribute ).toBeVisible();
+ const titleAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show title',
+ } );
+ await expect( titleAttribute ).toBeVisible();
+ const altAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show alt',
+ } );
+ await expect( altAttribute ).toBeVisible();
+ // Check not supported attributes are not included.
+ const linkClassAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show linkClass',
+ } );
+ await expect( linkClassAttribute ).toBeHidden();
+ } );
+ test( 'should show all the available fields in the dropdown UI', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'default value',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ await page.getByRole( 'button', { name: 'content' } ).click();
+ const textField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Text Field Label' } );
+ await expect( textField ).toBeVisible();
+ await expect( textField ).toBeChecked();
+ const urlField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'URL Field Label' } );
+ await expect( urlField ).toBeVisible();
+ await expect( urlField ).not.toBeChecked();
+ } );
+ test( 'should show the connected fields in the attributes panel', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'default value',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const contentButton = page.getByRole( 'button', {
+ name: 'content',
+ } );
+ await expect( contentButton ).toContainText( 'Text Field Label' );
+ } );
+ } );
+
+ test.describe( 'RichText workflows', () => {
+ test( 'should add empty paragraph block when pressing enter in paragraph', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ // Select the paragraph and press Enter at the end of it.
+ const paragraph = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await editor.selectBlocks( paragraph );
+ await page.keyboard.press( 'End' );
+ await page.keyboard.press( 'Enter' );
+ const [ initialParagraph, newEmptyParagraph ] = await editor.canvas
+ .locator( '[data-type="core/paragraph"]' )
+ .all();
+ await expect( initialParagraph ).toHaveText( 'Text Field Value' );
+ await expect( newEmptyParagraph ).toHaveText( '' );
+ await expect( newEmptyParagraph ).toBeEditable();
+ } );
+ test( 'should add empty paragraph block when pressing enter in heading', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/heading',
+ attributes: {
+ anchor: 'heading-binding',
+ content: 'heading default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+
+ // Select the heading and press Enter at the end of it.
+ const heading = editor.canvas.getByRole( 'document', {
+ name: 'Block: Heading',
+ } );
+ await editor.selectBlocks( heading );
+ await page.keyboard.press( 'End' );
+ await page.keyboard.press( 'Enter' );
+ // Can't use `editor.getBlocks` because it doesn't return the meta value shown in the editor.
+ const [ initialHeading, newEmptyParagraph ] = await editor.canvas
+ .locator( '[data-block]' )
+ .all();
+ // First block should be the original block.
+ await expect( initialHeading ).toHaveAttribute(
+ 'data-type',
+ 'core/heading'
+ );
+ await expect( initialHeading ).toHaveText( 'Text Field Value' );
+ // Second block should be an empty paragraph block.
+ await expect( newEmptyParagraph ).toHaveAttribute(
+ 'data-type',
+ 'core/paragraph'
+ );
+ await expect( newEmptyParagraph ).toHaveText( '' );
+ await expect( newEmptyParagraph ).toBeEditable();
+ } );
+ test( 'should add empty button block when pressing enter in button', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/buttons',
+ innerBlocks: [
+ {
+ name: 'core/button',
+ attributes: {
+ anchor: 'button-text-binding',
+ text: 'button default text',
+ url: '#default-url',
+ metadata: {
+ bindings: {
+ text: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ },
+ ],
+ } );
+ await editor.canvas
+ .getByRole( 'document', {
+ name: 'Block: Button',
+ exact: true,
+ } )
+ .getByRole( 'textbox' )
+ .click();
+ await page.keyboard.press( 'End' );
+ await page.keyboard.press( 'Enter' );
+ const [ initialButton, newEmptyButton ] = await editor.canvas
+ .locator( '[data-type="core/button"]' )
+ .all();
+ // First block should be the original block.
+ await expect( initialButton ).toHaveText( 'Text Field Value' );
+ // Second block should be an empty paragraph block.
+ await expect( newEmptyButton ).toHaveText( '' );
+ await expect( newEmptyButton ).toBeEditable();
+ } );
+ test( 'should show placeholder prompt when value is empty and can edit', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'empty_field' },
+ },
+ },
+ },
+ },
+ } );
+
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ // Aria-label is changed for empty paragraphs.
+ name: 'Empty empty_field; start writing to edit its value',
+ } );
+
+ await expect( paragraphBlock ).toBeEmpty();
+
+ const placeholder = paragraphBlock.locator( 'span' );
+ await expect( placeholder ).toHaveAttribute(
+ 'data-rich-text-placeholder',
+ 'Add Empty Field Label'
+ );
+ } );
+ test( 'should show source label when value is empty, cannot edit, and `getFieldsList` is undefined', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/can-user-edit-false',
+ args: { key: 'empty_field' },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ // Aria-label is changed for empty paragraphs.
+ name: 'empty_field',
+ } );
+ await expect( paragraphBlock ).toBeEmpty();
+ const placeholder = paragraphBlock.locator( 'span' );
+ await expect( placeholder ).toHaveAttribute(
+ 'data-rich-text-placeholder',
+ 'Can User Edit: False'
+ );
+ } );
+ test( 'should show placeholder attribute over bindings placeholder', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ placeholder: 'My custom placeholder',
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'empty_field' },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ // Aria-label is changed for empty paragraphs.
+ name: 'empty_field',
+ } );
+
+ await expect( paragraphBlock ).toBeEmpty();
+
+ const placeholder = paragraphBlock.locator( 'span' );
+ await expect( placeholder ).toHaveAttribute(
+ 'data-rich-text-placeholder',
+ 'My custom placeholder'
+ );
+ } );
+ } );
+
+ test( 'should show the label of a source only registered in the server in blocks connected', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/server-only-source',
+ },
+ },
+ },
+ },
+ } );
+
+ const contentButton = page.getByRole( 'button', {
+ name: 'content',
+ } );
+ await expect( contentButton ).toContainText( 'Server Source' );
+ } );
+ test( 'should show an "Invalid source" warning for not registered sources', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/undefined-source',
+ },
+ },
+ },
+ },
+ } );
+
+ const contentButton = page.getByRole( 'button', {
+ name: 'content',
+ } );
+ await expect( contentButton ).toContainText( 'Invalid source' );
+ } );
+} );
diff --git a/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js b/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js
new file mode 100644
index 00000000000000..d82def6feb66bf
--- /dev/null
+++ b/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js
@@ -0,0 +1,551 @@
+/**
+ * WordPress dependencies
+ */
+const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
+
+test.describe( 'Post Meta source', () => {
+ test.beforeAll( async ( { requestUtils } ) => {
+ await requestUtils.activateTheme(
+ 'gutenberg-test-themes/block-bindings'
+ );
+ await requestUtils.activatePlugin( 'gutenberg-test-block-bindings' );
+ } );
+
+ test.afterEach( async ( { requestUtils } ) => {
+ await requestUtils.deleteAllPosts();
+ } );
+
+ test.afterAll( async ( { requestUtils } ) => {
+ await requestUtils.deleteAllMedia();
+ await requestUtils.activateTheme( 'twentytwentyone' );
+ await requestUtils.deactivatePlugin( 'gutenberg-test-block-bindings' );
+ } );
+
+ test.describe( 'Movie CPT template', () => {
+ test.beforeEach( async ( { admin, editor } ) => {
+ await admin.visitSiteEditor( {
+ postId: 'gutenberg-test-themes/block-bindings//single-movie',
+ postType: 'wp_template',
+ canvas: 'edit',
+ } );
+ await editor.openDocumentSettingsSidebar();
+ } );
+
+ test.describe( 'Block attributes values', () => {
+ test( 'should not be possible to edit connected blocks', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'movie_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'false'
+ );
+ } );
+ test( 'should show the default value if it is defined', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'movie_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText(
+ 'Movie field default value'
+ );
+ } );
+ test( 'should fall back to the field label if the default value is not defined', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'field_with_only_label',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText(
+ 'Field with only label'
+ );
+ } );
+ test( 'should fall back to the field key if the field label is not defined', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'field_without_label_or_default',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText(
+ 'field_without_label_or_default'
+ );
+ } );
+ } );
+
+ test.describe( 'Attributes panel', () => {
+ test( 'should show the field label if it is defined', async ( {
+ editor,
+ page,
+ } ) => {
+ /**
+ * Create connection manually until this issue is solved:
+ * https://github.com/WordPress/gutenberg/pull/65604
+ *
+ * Once solved, block with the binding can be directly inserted.
+ */
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ } );
+ await page.getByLabel( 'Attributes options' ).click();
+ await page
+ .getByRole( 'menuitemcheckbox', {
+ name: 'Show content',
+ } )
+ .click();
+ const contentBinding = page.getByRole( 'button', {
+ name: 'content',
+ } );
+ await contentBinding.click();
+ await page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Movie field label' } )
+ .click();
+ await expect( contentBinding ).toContainText(
+ 'Movie field label'
+ );
+ } );
+ test( 'should fall back to the field key if the field label is not defined', async ( {
+ editor,
+ page,
+ } ) => {
+ /**
+ * Create connection manually until this issue is solved:
+ * https://github.com/WordPress/gutenberg/pull/65604
+ *
+ * Once solved, block with the binding can be directly inserted.
+ */
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ } );
+ await page.getByLabel( 'Attributes options' ).click();
+ await page
+ .getByRole( 'menuitemcheckbox', {
+ name: 'Show content',
+ } )
+ .click();
+ const contentBinding = page.getByRole( 'button', {
+ name: 'content',
+ } );
+ await contentBinding.click();
+ await page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'field_without_label_or_default' } )
+ .click();
+ await expect( contentBinding ).toContainText(
+ 'field_without_label_or_default'
+ );
+ } );
+ } );
+
+ test.describe( 'Fields list dropdown', () => {
+ // Insert block and open the dropdown for every test.
+ test.beforeEach( async ( { editor, page } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ } );
+ await page.getByLabel( 'Attributes options' ).click();
+ await page
+ .getByRole( 'menuitemcheckbox', {
+ name: 'Show content',
+ } )
+ .click();
+ await page
+ .getByRole( 'button', {
+ name: 'content',
+ } )
+ .click();
+ } );
+
+ test( 'should include movie fields in UI to connect attributes', async ( {
+ page,
+ } ) => {
+ const movieField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Movie field label' } );
+ await expect( movieField ).toBeVisible();
+ } );
+ test( 'should include global fields in UI to connect attributes', async ( {
+ page,
+ } ) => {
+ const globalField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'text_custom_field' } );
+ await expect( globalField ).toBeVisible();
+ } );
+ test( 'should not include protected fields', async ( { page } ) => {
+ // Ensure the fields have loaded by checking the field is visible.
+ const globalField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'text_custom_field' } );
+ await expect( globalField ).toBeVisible();
+ // Check the protected fields are not visible.
+ const protectedField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: '_protected_field' } );
+ await expect( protectedField ).toBeHidden();
+ const showInRestFalseField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'show_in_rest_false_field' } );
+ await expect( showInRestFalseField ).toBeHidden();
+ } );
+ test( 'should show the default value if it is defined', async ( {
+ page,
+ } ) => {
+ const fieldButton = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Movie field label' } );
+ await expect( fieldButton ).toContainText(
+ 'Movie field default value'
+ );
+ } );
+ test( 'should not show anything if the default value is not defined', async ( {
+ page,
+ } ) => {
+ const fieldButton = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Field with only label' } );
+ // Check it only contains the field label.
+ await expect( fieldButton ).toHaveText(
+ 'Field with only label'
+ );
+ } );
+ } );
+ } );
+
+ test.describe( 'Custom template', () => {
+ test.beforeEach( async ( { admin, editor } ) => {
+ await admin.visitSiteEditor( {
+ postId: 'gutenberg-test-themes/block-bindings//custom-template',
+ postType: 'wp_template',
+ canvas: 'edit',
+ } );
+ await editor.openDocumentSettingsSidebar();
+ } );
+
+ test( 'should not include post meta fields in UI to connect attributes', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'text_custom_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ await page
+ .getByRole( 'button', {
+ name: 'content',
+ } )
+ .click();
+ // Check the fields registered by other sources are there.
+ const customSourceField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Text Field Label' } );
+ await expect( customSourceField ).toBeVisible();
+ // Check the post meta fields are not visible.
+ const globalField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'text_custom_field' } );
+ await expect( globalField ).toBeHidden();
+ const movieField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Movie field label' } );
+ await expect( movieField ).toBeHidden();
+ } );
+ test( 'should show the key in attributes connected to post meta', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'text_custom_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText( 'text_custom_field' );
+ } );
+ } );
+
+ test.describe( 'Movie CPT post', () => {
+ test.beforeEach( async ( { admin } ) => {
+ // CHECK HOW TO CREATE A MOVIE.
+ await admin.createNewPost( {
+ postType: 'movie',
+ title: 'Test bindings',
+ } );
+ } );
+
+ test( 'should show the custom field value of that specific post', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ anchor: 'connected-paragraph',
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'movie_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText(
+ 'Movie field default value'
+ );
+ // Check the frontend shows the value of the custom field.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#connected-paragraph' )
+ ).toHaveText( 'Movie field default value' );
+ } );
+ test( 'should fall back to the key when custom field is not accessible', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'unaccessible_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText( 'unaccessible_field' );
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'false'
+ );
+ } );
+ test( 'should not show or edit the value of a protected field', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: '_protected_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText( '_protected_field' );
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'false'
+ );
+ } );
+ test( 'should not show or edit the value of a field with `show_in_rest` set to false', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'show_in_rest_false_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText(
+ 'show_in_rest_false_field'
+ );
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'false'
+ );
+ } );
+ test( 'should be possible to edit the value of the connected custom fields', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ anchor: 'connected-paragraph',
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'movie_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText(
+ 'Movie field default value'
+ );
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'true'
+ );
+ await paragraphBlock.fill( 'new value' );
+ // Check that the paragraph content attribute didn't change.
+ const [ paragraphBlockObject ] = await editor.getBlocks();
+ expect( paragraphBlockObject.attributes.content ).toBe(
+ 'fallback content'
+ );
+ // Check the value of the custom field is being updated by visiting the frontend.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#connected-paragraph' )
+ ).toHaveText( 'new value' );
+ } );
+ test( 'should be possible to connect movie fields through the attributes panel', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ } );
+ await page.getByLabel( 'Attributes options' ).click();
+ await page
+ .getByRole( 'menuitemcheckbox', {
+ name: 'Show content',
+ } )
+ .click();
+ await page
+ .getByRole( 'button', {
+ name: 'content',
+ } )
+ .click();
+ const movieField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Movie field label' } );
+ await expect( movieField ).toBeVisible();
+ } );
+ } );
+} );
diff --git a/test/e2e/specs/editor/various/block-deletion.spec.js b/test/e2e/specs/editor/various/block-deletion.spec.js
index 00b51a94668d50..9346412c46bcb2 100644
--- a/test/e2e/specs/editor/various/block-deletion.spec.js
+++ b/test/e2e/specs/editor/various/block-deletion.spec.js
@@ -287,16 +287,15 @@ test.describe( 'Block deletion', () => {
await expect.poll( editor.getBlocks ).toMatchObject( [
{ name: 'core/paragraph', attributes: { content: 'First' } },
{ name: 'core/paragraph', attributes: { content: 'Second' } },
+ { name: 'core/paragraph', attributes: { content: '' } },
] );
// Ensure that the newly created empty block is focused.
- await expect.poll( editor.getBlocks ).toHaveLength( 2 );
+ await expect.poll( editor.getBlocks ).toHaveLength( 3 );
await expect(
- editor.canvas
- .getByRole( 'document', {
- name: 'Block: Paragraph',
- } )
- .nth( 1 )
+ editor.canvas.getByRole( 'document', {
+ name: 'Empty block',
+ } )
).toBeFocused();
} );
diff --git a/test/e2e/specs/editor/various/block-moving-mode.spec.js b/test/e2e/specs/editor/various/block-moving-mode.spec.js
deleted file mode 100644
index 5b8ef6bdcd051b..00000000000000
--- a/test/e2e/specs/editor/various/block-moving-mode.spec.js
+++ /dev/null
@@ -1,155 +0,0 @@
-/**
- * WordPress dependencies
- */
-const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
-
-test.describe( 'Block moving mode', () => {
- test.beforeEach( async ( { admin } ) => {
- await admin.createNewPost();
- } );
-
- test.afterEach( async ( { requestUtils } ) => {
- await requestUtils.deleteAllPosts();
- } );
-
- test( 'can move block', async ( { editor, page } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: { content: 'First Paragraph' },
- } );
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: { content: 'Second Paragraph' },
- } );
-
- // Move the second block in front of the first block.
- await editor.showBlockToolbar();
- await page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Options' } )
- .click();
- await page.getByRole( 'menuitem', { name: 'Move to' } ).click();
- await page.keyboard.press( 'ArrowUp' );
- await page.keyboard.press( 'Enter' );
-
- await expect.poll( editor.getBlocks ).toMatchObject( [
- {
- name: 'core/paragraph',
- attributes: { content: 'Second Paragraph' },
- },
- {
- name: 'core/paragraph',
- attributes: { content: 'First Paragraph' },
- },
- ] );
- } );
-
- test( 'can move block in the nested block', async ( { editor, page } ) => {
- // Create two group blocks with some blocks.
- await editor.insertBlock( { name: 'core/group' } );
- await editor.canvas
- .locator(
- 'role=button[name="Group: Gather blocks in a container."i]'
- )
- .click();
- await page.keyboard.press( 'ArrowDown' );
- await page.keyboard.press( 'Enter' );
- await page.getByRole( 'option', { name: 'Paragraph' } ).click();
- await page.keyboard.type( 'First Paragraph' );
- await page.keyboard.press( 'Enter' );
- await page.keyboard.type( 'Second Paragraph' );
-
- await editor.insertBlock( { name: 'core/group' } );
- await editor.canvas
- .locator(
- 'role=button[name="Group: Gather blocks in a container."i]'
- )
- .click();
- await page.keyboard.press( 'ArrowDown' );
- await page.keyboard.press( 'Enter' );
- await page.getByRole( 'option', { name: 'Paragraph' } ).click();
- await page.keyboard.type( 'Third Paragraph' );
- await page.keyboard.press( 'Enter' );
- await page.keyboard.type( 'Fourth Paragraph' );
-
- // Move a paragraph block in the first group block into the second group block.
- const paragraphBlock = editor.canvas.locator(
- 'text="First Paragraph"'
- );
- await paragraphBlock.focus();
- await editor.showBlockToolbar();
- await page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Options' } )
- .click();
- await page.getByRole( 'menuitem', { name: 'Move to' } ).click();
- await page.keyboard.press( 'ArrowLeft' ); // Select the first group block.
- await page.keyboard.press( 'ArrowDown' ); // Select the second group block.
- await page.keyboard.press( 'ArrowRight' ); // Enter the second group block.
- await page.keyboard.press( 'ArrowDown' ); // Move down in the second group block.
- await page.keyboard.press( 'Enter' );
-
- await expect.poll( editor.getBlocks ).toMatchObject( [
- {
- name: 'core/group',
- innerBlocks: [
- {
- name: 'core/paragraph',
- attributes: { content: 'Second Paragraph' },
- },
- ],
- },
- {
- name: 'core/group',
- innerBlocks: [
- {
- name: 'core/paragraph',
- attributes: { content: 'Third Paragraph' },
- },
- {
- name: 'core/paragraph',
- attributes: { content: 'First Paragraph' },
- },
- {
- name: 'core/paragraph',
- attributes: { content: 'Fourth Paragraph' },
- },
- ],
- },
- ] );
- } );
-
- test( 'can not move inside its own block', async ( { editor, page } ) => {
- // Create a paragraph block and a group block.
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: { content: 'First Paragraph' },
- } );
- await editor.insertBlock( { name: 'core/group' } );
- await editor.canvas
- .locator(
- 'role=button[name="Group: Gather blocks in a container."i]'
- )
- .click();
- await page.keyboard.press( 'ArrowDown' );
- await page.keyboard.press( 'Enter' );
- await page.getByRole( 'option', { name: 'Paragraph' } ).click();
- await page.keyboard.type( 'Second Paragraph' );
- await page.keyboard.press( 'Enter' );
- await page.keyboard.type( 'Third Paragraph' );
-
- // Trying to move the group block into its own.
- const groupBlock = editor.canvas.locator(
- 'role=document[name="Block: Group"i]'
- );
- await groupBlock.focus();
- await editor.showBlockToolbar();
- await page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Options' } )
- .click();
- await page.getByRole( 'menuitem', { name: 'Move to' } ).click();
- await page.keyboard.press( 'ArrowRight' );
- await expect( groupBlock ).toHaveClass( /is-selected/ );
- } );
-} );
diff --git a/test/e2e/specs/editor/various/copy-cut-paste.spec.js b/test/e2e/specs/editor/various/copy-cut-paste.spec.js
index 33a65e6f5a1195..bca062b06416a1 100644
--- a/test/e2e/specs/editor/various/copy-cut-paste.spec.js
+++ b/test/e2e/specs/editor/various/copy-cut-paste.spec.js
@@ -565,6 +565,27 @@ test.describe( 'Copy/cut/paste', () => {
] );
} );
+ test( 'should paste link to formatted text', async ( {
+ page,
+ pageUtils,
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: { content: 'test ' },
+ } );
+ await page.keyboard.press( 'ArrowRight' );
+ await page.keyboard.press( 'ArrowRight' );
+ await pageUtils.pressKeys( 'shift+ArrowRight' );
+ await pageUtils.pressKeys( 'shift+ArrowRight' );
+ pageUtils.setClipboardData( {
+ plainText: 'https://wordpress.org/gutenberg',
+ html: 'https://wordpress.org/gutenberg',
+ } );
+ await pageUtils.pressKeys( 'primary+v' );
+ expect( await editor.getEditedPostContent() ).toMatchSnapshot();
+ } );
+
test( 'should auto-link', async ( { pageUtils, editor } ) => {
await editor.insertBlock( {
name: 'core/paragraph',
diff --git a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js
index 6a7125d04f7a6a..e1ca121040b974 100644
--- a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js
+++ b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js
@@ -17,108 +17,26 @@ test.describe( 'Order of block keyboard navigation', () => {
await editor.openDocumentSettingsSidebar();
} );
- test( 'permits tabbing through paragraph blocks in the expected order', async ( {
+ test( 'permits tabbing through the block toolbar of the paragraph block', async ( {
editor,
KeyboardNavigableBlocks,
page,
+ pageUtils,
} ) => {
- const paragraphBlocks = [ 'Paragraph 0', 'Paragraph 1', 'Paragraph 2' ];
-
- // Create 3 paragraphs blocks with some content.
- for ( const paragraphBlock of paragraphBlocks ) {
+ // Insert three paragraph blocks.
+ for ( let i = 0; i < 3; i++ ) {
await editor.insertBlock( { name: 'core/paragraph' } );
- await page.keyboard.type( paragraphBlock );
+ await page.keyboard.type( `Paragraph ${ i + 1 }` );
}
-
- // Select the middle block.
+ // Select the middle paragraph block.
await page.keyboard.press( 'ArrowUp' );
await editor.showBlockToolbar();
- await KeyboardNavigableBlocks.navigateToContentEditorTop();
- await KeyboardNavigableBlocks.tabThroughParagraphBlock( 'Paragraph 1' );
-
- // Repeat the same steps to ensure that there is no change introduced in how the focus is handled.
- // This prevents the previous regression explained in: https://github.com/WordPress/gutenberg/issues/11773.
- await KeyboardNavigableBlocks.navigateToContentEditorTop();
- await KeyboardNavigableBlocks.tabThroughParagraphBlock( 'Paragraph 1' );
- } );
-
- test( 'allows tabbing in navigation mode if no block is selected', async ( {
- editor,
- KeyboardNavigableBlocks,
- page,
- } ) => {
- const paragraphBlocks = [ '0', '1' ];
-
- // Create 2 paragraphs blocks with some content.
- for ( const paragraphBlock of paragraphBlocks ) {
- await editor.insertBlock( { name: 'core/paragraph' } );
- await page.keyboard.type( paragraphBlock );
- }
-
- // Clear the selected block.
- const paragraph = editor.canvas
- .locator( '[data-type="core/paragraph"]' )
- .getByText( '1' );
- const box = await paragraph.boundingBox();
- await page.mouse.click( box.x - 1, box.y );
-
- await page.keyboard.press( 'Tab' );
- await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Add title' );
-
- await page.keyboard.press( 'Tab' );
- await KeyboardNavigableBlocks.expectLabelToHaveFocus(
- 'Paragraph Block. Row 1. 0'
- );
-
- await page.keyboard.press( 'Tab' );
- await KeyboardNavigableBlocks.expectLabelToHaveFocus(
- 'Paragraph Block. Row 2. 1'
- );
-
- await page.keyboard.press( 'Tab' );
- await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Post' );
- } );
-
- test( 'allows tabbing in navigation mode if no block is selected (reverse)', async ( {
- editor,
- KeyboardNavigableBlocks,
- page,
- pageUtils,
- } ) => {
- const paragraphBlocks = [ '0', '1' ];
-
- // Create 2 paragraphs blocks with some content.
- for ( const paragraphBlock of paragraphBlocks ) {
- await editor.insertBlock( { name: 'core/paragraph' } );
- await page.keyboard.type( paragraphBlock );
- }
-
- // Clear the selected block.
- const paragraph = editor.canvas
- .locator( '[data-type="core/paragraph"]' )
- .getByText( '1' );
- const box = await paragraph.boundingBox();
- await page.mouse.click( box.x - 1, box.y );
-
- // Put focus behind the block list.
- await page.evaluate( () => {
- document
- .querySelector( '.interface-interface-skeleton__sidebar' )
- .focus();
- } );
-
- await pageUtils.pressKeys( 'shift+Tab' );
- await KeyboardNavigableBlocks.expectLabelToHaveFocus(
- 'Paragraph Block. Row 2. 1'
- );
-
await pageUtils.pressKeys( 'shift+Tab' );
+ await KeyboardNavigableBlocks.navigateThroughBlockToolbar();
+ await page.keyboard.press( 'Tab' );
await KeyboardNavigableBlocks.expectLabelToHaveFocus(
- 'Paragraph Block. Row 1. 0'
+ 'Block: Paragraph'
);
-
- await pageUtils.pressKeys( 'shift+Tab' );
- await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Add title' );
} );
test( 'should navigate correctly with multi selection', async ( {
@@ -208,31 +126,7 @@ class KeyboardNavigableBlocks {
expect( ariaLabel ).toBe( label );
}
- async navigateToContentEditorTop() {
- // Use 'Ctrl+`' to return to the top of the editor.
- await this.pageUtils.pressKeys( 'ctrl+`', { times: 5 } );
- }
-
- async tabThroughParagraphBlock( paragraphText ) {
- await this.tabThroughBlockToolbar();
-
- await this.page.keyboard.press( 'Tab' );
- await this.expectLabelToHaveFocus( 'Block: Paragraph' );
-
- const activeElement = this.editor.canvas.locator( ':focus' );
-
- await expect( activeElement ).toHaveText( paragraphText );
-
- await this.page.keyboard.press( 'Tab' );
- await this.expectLabelToHaveFocus( 'Block' );
-
- // Need to shift+tab here to end back in the block. If not, we'll be in the next region and it will only require 4 region jumps instead of 5.
- await this.pageUtils.pressKeys( 'shift+Tab' );
- await this.expectLabelToHaveFocus( 'Block: Paragraph' );
- }
-
- async tabThroughBlockToolbar() {
- await this.page.keyboard.press( 'Tab' );
+ async navigateThroughBlockToolbar() {
await this.expectLabelToHaveFocus( 'Paragraph' );
await this.page.keyboard.press( 'ArrowRight' );
diff --git a/test/e2e/specs/editor/various/pattern-overrides.spec.js b/test/e2e/specs/editor/various/pattern-overrides.spec.js
index 83f2f880f3bf1b..5fbd0e66b5fd02 100644
--- a/test/e2e/specs/editor/various/pattern-overrides.spec.js
+++ b/test/e2e/specs/editor/various/pattern-overrides.spec.js
@@ -150,7 +150,7 @@ test.describe( 'Pattern Overrides', () => {
name: 'Block: Paragraph',
} );
// Ensure the first pattern is selected.
- await editor.selectBlocks( patternBlocks.first() );
+ await patternBlocks.first().selectText();
await expect( paragraphs.first() ).not.toHaveAttribute(
'inert',
'true'
@@ -168,7 +168,7 @@ test.describe( 'Pattern Overrides', () => {
await page.keyboard.type( 'I would word it this way' );
// Ensure the second pattern is selected.
- await editor.selectBlocks( patternBlocks.last() );
+ await patternBlocks.last().selectText();
await patternBlocks
.last()
.getByRole( 'document', {
diff --git a/test/e2e/specs/editor/various/publish-panel.spec.js b/test/e2e/specs/editor/various/publish-panel.spec.js
index 534fea5289c9e1..1fe94ff334f3b2 100644
--- a/test/e2e/specs/editor/various/publish-panel.spec.js
+++ b/test/e2e/specs/editor/various/publish-panel.spec.js
@@ -58,7 +58,7 @@ test.describe( 'Post publish panel', () => {
).toBeFocused();
} );
- test( 'should move focus to the publish button in the panel', async ( {
+ test( 'should move focus to the cancel button in the panel', async ( {
editor,
page,
} ) => {
@@ -74,7 +74,7 @@ test.describe( 'Post publish panel', () => {
page
.getByRole( 'region', { name: 'Editor publish' } )
.locator( ':focus' )
- ).toHaveText( 'Publish' );
+ ).toHaveText( 'Cancel' );
} );
test( 'should focus on the post list after publishing', async ( {
diff --git a/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js b/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js
index a8e49f7a6b84dd..cfaf4e0be9188f 100644
--- a/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js
+++ b/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js
@@ -50,32 +50,6 @@ test.describe( 'Focus toolbar shortcut (alt + F10)', () => {
await expect( toolbarUtils.documentToolbarTooltip ).toBeHidden();
} );
- test( 'Focuses correct toolbar in default view options in select mode', async ( {
- editor,
- page,
- toolbarUtils,
- } ) => {
- // Test: Focus the document toolbar from title
- await toolbarUtils.useSelectMode();
- await toolbarUtils.moveToToolbarShortcut();
- await expect( toolbarUtils.documentToolbarButton ).toBeFocused();
-
- // Test: Focus the top level toolbar from empty block
- await editor.insertBlock( { name: 'core/paragraph' } );
- await toolbarUtils.useSelectMode();
- await toolbarUtils.moveToToolbarShortcut();
- await expect( toolbarUtils.documentToolbarButton ).toBeFocused();
-
- // Test: Focus the top level toolbar from paragraph block
- await editor.insertBlock( { name: 'core/paragraph' } );
- await page.keyboard.type(
- 'Focus top level toolbar from paragraph block in select mode.'
- );
- await toolbarUtils.useSelectMode();
- await toolbarUtils.moveToToolbarShortcut();
- await expect( toolbarUtils.documentToolbarButton ).toBeFocused();
- } );
-
test.describe( 'In Top Toolbar option:', () => {
test.beforeEach( async ( { editor } ) => {
// Ensure the fixed toolbar option is on
diff --git a/test/e2e/specs/editor/various/splitting-merging.spec.js b/test/e2e/specs/editor/various/splitting-merging.spec.js
index 29e7e5d64522c9..eba9f1d3163fd5 100644
--- a/test/e2e/specs/editor/various/splitting-merging.spec.js
+++ b/test/e2e/specs/editor/various/splitting-merging.spec.js
@@ -373,6 +373,103 @@ test.describe( 'splitting and merging blocks (@firefox, @webkit)', () => {
);
} );
+ // Fix for https://github.com/WordPress/gutenberg/issues/65174.
+ test( 'should handle unwrapping and merging blocks with empty contents', async ( {
+ editor,
+ page,
+ } ) => {
+ const emptyAlignedParagraph = {
+ name: 'core/paragraph',
+ attributes: { content: '', align: 'center', dropCap: false },
+ innerBlocks: [],
+ };
+ const emptyAlignedHeading = {
+ name: 'core/heading',
+ attributes: { content: '', textAlign: 'center', level: 2 },
+ innerBlocks: [],
+ };
+ const headingWithContent = {
+ name: 'core/heading',
+ attributes: { content: 'heading', level: 2 },
+ innerBlocks: [],
+ };
+ const placeholderBlock = { name: 'core/separator' };
+ await editor.insertBlock( {
+ name: 'core/group',
+ innerBlocks: [
+ emptyAlignedParagraph,
+ emptyAlignedHeading,
+ headingWithContent,
+ placeholderBlock,
+ ],
+ } );
+ await editor.canvas
+ .getByRole( 'document', { name: 'Empty block' } )
+ .focus();
+
+ await page.keyboard.press( 'Backspace' );
+ await expect
+ .poll( editor.getBlocks, 'Remove the default empty block' )
+ .toEqual( [
+ {
+ name: 'core/group',
+ attributes: { tagName: 'div' },
+ innerBlocks: [
+ emptyAlignedHeading,
+ headingWithContent,
+ expect.objectContaining( placeholderBlock ),
+ ],
+ },
+ ] );
+
+ // Move the caret to the beginning of the empty heading block.
+ await page.keyboard.press( 'ArrowDown' );
+ await page.keyboard.press( 'Backspace' );
+ await expect
+ .poll(
+ editor.getBlocks,
+ 'Convert the non-default empty block to a default block'
+ )
+ .toEqual( [
+ {
+ name: 'core/group',
+ attributes: { tagName: 'div' },
+ innerBlocks: [
+ emptyAlignedParagraph,
+ headingWithContent,
+ expect.objectContaining( placeholderBlock ),
+ ],
+ },
+ ] );
+ await page.keyboard.press( 'Backspace' );
+ await expect.poll( editor.getBlocks ).toEqual( [
+ {
+ name: 'core/group',
+ attributes: { tagName: 'div' },
+ innerBlocks: [
+ headingWithContent,
+ expect.objectContaining( placeholderBlock ),
+ ],
+ },
+ ] );
+
+ // Move the caret to the beginning of the "heading" heading block.
+ await page.keyboard.press( 'ArrowDown' );
+ await page.keyboard.press( 'Backspace' );
+ await expect
+ .poll( editor.getBlocks, 'Lift the non-empty non-default block' )
+ .toEqual( [
+ headingWithContent,
+ {
+ name: 'core/group',
+ attributes: { tagName: 'div' },
+ innerBlocks: [
+ expect.objectContaining( placeholderBlock ),
+ ],
+ },
+ ] );
+ } );
+
test.describe( 'test restore selection when merge produces more than one block', () => {
const snap1 = [
{
diff --git a/test/e2e/specs/editor/various/writing-flow.spec.js b/test/e2e/specs/editor/various/writing-flow.spec.js
index bd1552ad4cb66a..4077d6dcc58200 100644
--- a/test/e2e/specs/editor/various/writing-flow.spec.js
+++ b/test/e2e/specs/editor/various/writing-flow.spec.js
@@ -106,48 +106,6 @@ test.describe( 'Writing Flow (@firefox, @webkit)', () => {
] );
} );
- test( 'Should navigate between inner and root blocks in navigation mode', async ( {
- page,
- writingFlowUtils,
- } ) => {
- await writingFlowUtils.addDemoContent();
-
- // Switch to navigation mode.
- await page.keyboard.press( 'Escape' );
- // Arrow up to Columns block.
- await page.keyboard.press( 'ArrowUp' );
- await expect
- .poll( writingFlowUtils.getActiveBlockName )
- .toBe( 'core/columns' );
- // Arrow right into Column block.
- await page.keyboard.press( 'ArrowRight' );
- await expect
- .poll( writingFlowUtils.getActiveBlockName )
- .toBe( 'core/column' );
- // Arrow down to reach second Column block.
- await page.keyboard.press( 'ArrowDown' );
- // Arrow right again into Paragraph block.
- await page.keyboard.press( 'ArrowRight' );
- await expect
- .poll( writingFlowUtils.getActiveBlockName )
- .toBe( 'core/paragraph' );
- // Arrow left back to Column block.
- await page.keyboard.press( 'ArrowLeft' );
- await expect
- .poll( writingFlowUtils.getActiveBlockName )
- .toBe( 'core/column' );
- // Arrow left back to Columns block.
- await page.keyboard.press( 'ArrowLeft' );
- await expect
- .poll( writingFlowUtils.getActiveBlockName )
- .toBe( 'core/columns' );
- // Arrow up to first paragraph.
- await page.keyboard.press( 'ArrowUp' );
- await expect
- .poll( writingFlowUtils.getActiveBlockName )
- .toBe( 'core/paragraph' );
- } );
-
test( 'should navigate around inline boundaries', async ( {
editor,
page,
@@ -958,32 +916,6 @@ test.describe( 'Writing Flow (@firefox, @webkit)', () => {
` );
} );
- test( 'escape should set select mode and then focus the canvas', async ( {
- page,
- writingFlowUtils,
- } ) => {
- await page.keyboard.press( 'Enter' );
- await page.keyboard.type( 'Random Paragraph' );
-
- // First escape enters navigation mode.
- await page.keyboard.press( 'Escape' );
- const navigationButton = page.getByLabel(
- 'Paragraph Block. Row 1. Random Paragraph'
- );
- await expect( navigationButton ).toBeVisible();
- await expect
- .poll( writingFlowUtils.getActiveBlockName )
- .toBe( 'core/paragraph' );
-
- // Second escape should send focus to the canvas
- await page.keyboard.press( 'Escape' );
- // The navigation button should be hidden.
- await expect( navigationButton ).toBeHidden();
- await expect(
- page.getByRole( 'region', { name: 'Editor content' } )
- ).toBeFocused();
- } );
-
// Checks for regressions of https://github.com/WordPress/gutenberg/issues/40091.
test( 'does not deselect the block when selecting text outside the editor canvas', async ( {
editor,
@@ -1222,11 +1154,11 @@ class WritingFlowUtils {
'role=listbox[name="Blocks"i] >> role=option[name="Paragraph"i]'
);
await this.page.keyboard.type( '2nd col' ); // If this text is too long, it may wrap to a new line and cause test failure. That's why we're using "2nd" instead of "Second" here.
-
- await this.page.keyboard.press( 'Escape' ); // Enter navigation mode.
- await this.page.keyboard.press( 'ArrowLeft' ); // Move to the column block.
- await this.page.keyboard.press( 'ArrowLeft' ); // Move to the columns block.
- await this.page.keyboard.press( 'Enter' ); // Enter edit mode with the columns block selected.
+ await this.editor.showBlockToolbar();
+ await this.page.keyboard.press( 'Shift+Tab' ); // Move to toolbar to select parent
+ await this.page.keyboard.press( 'Enter' ); // Selects the column block.
+ await this.page.keyboard.press( 'Shift+Tab' ); // Move to toolbar to select parent
+ await this.page.keyboard.press( 'Enter' ); // Selects the columns block.
await this.page.keyboard.press( 'Enter' ); // Creates a paragraph after the columns block.
await this.page.keyboard.type( 'Second paragraph' );
}
diff --git a/test/e2e/specs/interactivity/get-sever-context.spec.ts b/test/e2e/specs/interactivity/get-sever-context.spec.ts
new file mode 100644
index 00000000000000..d7bc4075f97604
--- /dev/null
+++ b/test/e2e/specs/interactivity/get-sever-context.spec.ts
@@ -0,0 +1,166 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'getServerContext()', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ const parent = {
+ prop: 'parent',
+ nested: {
+ prop: 'parent',
+ },
+ inherited: {
+ prop: 'parent',
+ },
+ };
+
+ const parentModified = {
+ prop: 'parentModified',
+ nested: {
+ prop: 'parentModified',
+ },
+ inherited: {
+ prop: 'parentModified',
+ },
+ };
+
+ const parentNewProps = {
+ prop: 'parent',
+ newProp: 'parent',
+ nested: {
+ prop: 'parent',
+ newProp: 'parent',
+ },
+ inherited: {
+ prop: 'parent',
+ newProp: 'parent',
+ },
+ };
+
+ const child = {
+ prop: 'child',
+ nested: {
+ prop: 'child',
+ },
+ };
+
+ const childModified = {
+ prop: 'childModified',
+ nested: {
+ prop: 'childModified',
+ },
+ };
+
+ const childNewProps = {
+ prop: 'child',
+ newProp: 'child',
+ nested: {
+ prop: 'child',
+ newProp: 'child',
+ },
+ };
+
+ await utils.activatePlugins();
+ const link1 = await utils.addPostWithBlock( 'test/get-server-context', {
+ alias: 'getServerContext() - modified',
+ attributes: {
+ parentContext: parentModified,
+ childContext: childModified,
+ },
+ } );
+ const link2 = await utils.addPostWithBlock( 'test/get-server-context', {
+ alias: 'getServerContext() - new props',
+ attributes: {
+ parentContext: parentNewProps,
+ childContext: childNewProps,
+ },
+ } );
+ await utils.addPostWithBlock( 'test/get-server-context', {
+ alias: 'getServerContext() - main',
+ attributes: {
+ links: { modified: link1, newProps: link2 },
+ parentContext: parent,
+ childContext: child,
+ },
+ } );
+ } );
+
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'getServerContext() - main' ) );
+ } );
+
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'should update modified props on navigation', async ( { page } ) => {
+ const prop = page.getByTestId( 'prop' );
+ const nestedProp = page.getByTestId( 'nested.prop' );
+ const inheritedProp = page.getByTestId( 'inherited.prop' );
+
+ await expect( prop ).toHaveText( 'child' );
+ await expect( nestedProp ).toHaveText( 'child' );
+ await expect( inheritedProp ).toHaveText( 'parent' );
+
+ await page.getByTestId( 'modified' ).click();
+
+ await expect( prop ).toHaveText( 'childModified' );
+ await expect( nestedProp ).toHaveText( 'childModified' );
+ await expect( inheritedProp ).toHaveText( 'parentModified' );
+
+ await page.goBack();
+
+ await expect( prop ).toHaveText( 'child' );
+ await expect( nestedProp ).toHaveText( 'child' );
+ await expect( inheritedProp ).toHaveText( 'parent' );
+ } );
+
+ test( 'should add new props on navigation', async ( { page } ) => {
+ const newProp = page.getByTestId( 'newProp' );
+ const nestedNewProp = page.getByTestId( 'nested.newProp' );
+ const inheritedNewProp = page.getByTestId( 'inherited.newProp' );
+
+ await expect( newProp ).toBeEmpty();
+ await expect( nestedNewProp ).toBeEmpty();
+ await expect( inheritedNewProp ).toBeEmpty();
+
+ await page.getByTestId( 'newProps' ).click();
+
+ await expect( newProp ).toHaveText( 'child' );
+ await expect( nestedNewProp ).toHaveText( 'child' );
+ await expect( inheritedNewProp ).toHaveText( 'parent' );
+ } );
+
+ test( 'should keep new props on navigation', async ( { page } ) => {
+ const newProp = page.getByTestId( 'newProp' );
+ const nestedNewProp = page.getByTestId( 'nested.newProp' );
+ const inheritedNewProp = page.getByTestId( 'inherited.newProp' );
+
+ await page.getByTestId( 'newProps' ).click();
+
+ await expect( newProp ).toHaveText( 'child' );
+ await expect( nestedNewProp ).toHaveText( 'child' );
+ await expect( inheritedNewProp ).toHaveText( 'parent' );
+
+ await page.goBack();
+
+ await expect( newProp ).toHaveText( 'child' );
+ await expect( nestedNewProp ).toHaveText( 'child' );
+ await expect( inheritedNewProp ).toHaveText( 'parent' );
+ } );
+
+ test( 'should prevent any manual modifications', async ( { page } ) => {
+ const prop = page.getByTestId( 'prop' );
+ const button = page.getByTestId( 'tryToModifyServerContext' );
+
+ await expect( prop ).toHaveText( 'child' );
+ await expect( button ).toHaveText( 'modify' );
+
+ await button.click();
+
+ await expect( prop ).toHaveText( 'child' );
+ await expect( button ).toHaveText( 'not modified ā
' );
+ } );
+} );
diff --git a/test/e2e/specs/interactivity/get-sever-state.spec.ts b/test/e2e/specs/interactivity/get-sever-state.spec.ts
new file mode 100644
index 00000000000000..16406c1d824463
--- /dev/null
+++ b/test/e2e/specs/interactivity/get-sever-state.spec.ts
@@ -0,0 +1,119 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'getServerState()', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ const link1 = await utils.addPostWithBlock( 'test/get-server-state', {
+ alias: 'getServerState() - link 1',
+ attributes: {
+ state: {
+ prop: 'link 1',
+ newProp: 'link 1',
+ nested: {
+ prop: 'link 1',
+ newProp: 'link 1',
+ },
+ },
+ },
+ } );
+ const link2 = await utils.addPostWithBlock( 'test/get-server-state', {
+ alias: 'getServerState() - link 2',
+ attributes: {
+ state: {
+ prop: 'link 2',
+ newProp: 'link 2',
+ nested: {
+ prop: 'link 2',
+ newProp: 'link 2',
+ },
+ },
+ },
+ } );
+ await utils.addPostWithBlock( 'test/get-server-state', {
+ alias: 'getServerState() - main',
+ attributes: {
+ title: 'Main',
+ links: [ link1, link2 ],
+ state: {
+ prop: 'main',
+ nested: {
+ prop: 'main',
+ },
+ },
+ },
+ } );
+ } );
+
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'getServerState() - main' ) );
+ } );
+
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'should update existing state props on navigation', async ( {
+ page,
+ } ) => {
+ const prop = page.getByTestId( 'prop' );
+ const nestedProp = page.getByTestId( 'nested.prop' );
+
+ await expect( prop ).toHaveText( 'main' );
+ await expect( nestedProp ).toHaveText( 'main' );
+
+ await page.getByTestId( 'link 1' ).click();
+
+ await expect( prop ).toHaveText( 'link 1' );
+ await expect( nestedProp ).toHaveText( 'link 1' );
+
+ await page.goBack();
+ await expect( prop ).toHaveText( 'main' );
+ await expect( nestedProp ).toHaveText( 'main' );
+
+ await page.getByTestId( 'link 2' ).click();
+
+ await expect( prop ).toHaveText( 'link 2' );
+ await expect( nestedProp ).toHaveText( 'link 2' );
+ } );
+
+ test( 'should add new state props and keep them on navigation', async ( {
+ page,
+ } ) => {
+ const newProp = page.getByTestId( 'newProp' );
+ const nestedNewProp = page.getByTestId( 'nested.newProp' );
+
+ await expect( newProp ).toBeEmpty();
+ await expect( nestedNewProp ).toBeEmpty();
+
+ await page.getByTestId( 'link 1' ).click();
+
+ await expect( newProp ).toHaveText( 'link 1' );
+ await expect( nestedNewProp ).toHaveText( 'link 1' );
+
+ await page.goBack();
+ await expect( newProp ).toHaveText( 'link 1' );
+ await expect( nestedNewProp ).toHaveText( 'link 1' );
+
+ await page.getByTestId( 'link 2' ).click();
+
+ await expect( newProp ).toHaveText( 'link 2' );
+ await expect( nestedNewProp ).toHaveText( 'link 2' );
+ } );
+
+ test( 'should prevent any manual modifications', async ( { page } ) => {
+ const prop = page.getByTestId( 'prop' );
+ const button = page.getByTestId( 'tryToModifyServerState' );
+
+ await expect( prop ).toHaveText( 'main' );
+ await expect( button ).toHaveText( 'modify' );
+
+ await button.click();
+
+ await expect( prop ).toHaveText( 'main' );
+ await expect( button ).toHaveText( 'not modified ā
' );
+ } );
+} );
diff --git a/test/e2e/specs/site-editor/command-center.spec.js b/test/e2e/specs/site-editor/command-center.spec.js
index 5b049cda252a8b..19318081aa171b 100644
--- a/test/e2e/specs/site-editor/command-center.spec.js
+++ b/test/e2e/specs/site-editor/command-center.spec.js
@@ -28,10 +28,12 @@ test.describe( 'Site editor command palette', () => {
await page.keyboard.type( 'new page' );
await page.getByRole( 'option', { name: 'Add new page' } ).click();
await expect( page ).toHaveURL(
- '/wp-admin/post-new.php?post_type=page'
+ /\/wp-admin\/site-editor.php\?postId=(\d+)&postType=page&canvas=edit/
);
await expect(
- editor.canvas.getByRole( 'textbox', { name: 'Add title' } )
+ editor.canvas
+ .getByLabel( 'Block: Title' )
+ .locator( '[data-rich-text-placeholder="No title"]' )
).toBeVisible();
} );
diff --git a/test/e2e/specs/site-editor/navigation.spec.js b/test/e2e/specs/site-editor/navigation.spec.js
index 4db860b703892c..1b92ef2e850e67 100644
--- a/test/e2e/specs/site-editor/navigation.spec.js
+++ b/test/e2e/specs/site-editor/navigation.spec.js
@@ -83,19 +83,6 @@ test.describe( 'Site editor navigation', () => {
// The button role should have been removed from the iframe.
await expect( editorCanvasButton ).toBeHidden();
- // Test to make sure a Tab keypress works as expected.
- // As of this writing, we are in select mode and a tab
- // keypress will reveal the header template select mode
- // button. This test is not documenting that we _want_
- // that action, but checking that we are within the site
- // editor and keypresses work as intened.
- await pageUtils.pressKeys( 'Tab' );
- await expect(
- page.getByRole( 'button', {
- name: 'Template Part Block. Row 1. header',
- } )
- ).toBeFocused();
-
// Test: We can go back to the main navigation from the editor frame
// Move to the document toolbar
await pageUtils.pressKeys( 'alt+F10' );
diff --git a/test/e2e/specs/site-editor/style-book.spec.js b/test/e2e/specs/site-editor/style-book.spec.js
index c4e153e9b5e2fa..3f871d28ef941b 100644
--- a/test/e2e/specs/site-editor/style-book.spec.js
+++ b/test/e2e/specs/site-editor/style-book.spec.js
@@ -42,9 +42,6 @@ test.describe( 'Style Book', () => {
test( 'should have tabs containing block examples', async ( { page } ) => {
await expect( page.locator( 'role=tab[name="Text"i]' ) ).toBeVisible();
await expect( page.locator( 'role=tab[name="Media"i]' ) ).toBeVisible();
- await expect(
- page.locator( 'role=tab[name="Design"i]' )
- ).toBeVisible();
await expect(
page.locator( 'role=tab[name="Widgets"i]' )
).toBeVisible();
diff --git a/test/e2e/specs/widgets/customizing-widgets.spec.js b/test/e2e/specs/widgets/customizing-widgets.spec.js
index 53b4da7be2d61f..38e9d3ee2c58ab 100644
--- a/test/e2e/specs/widgets/customizing-widgets.spec.js
+++ b/test/e2e/specs/widgets/customizing-widgets.spec.js
@@ -465,16 +465,6 @@ test.describe( 'Widgets Customizer', () => {
await expect( paragraphBlock ).toBeVisible();
await paragraphBlock.focus();
-
- // Expect pressing the Escape key to enter navigation mode,
- // but not close the editor.
- await page.keyboard.press( 'Escape' );
- await expect(
- page.locator(
- 'css=.block-editor-block-list__layout.is-navigate-mode'
- )
- ).toBeVisible();
- await expect( paragraphBlock ).toBeVisible();
} );
test( 'should move (inner) blocks to another sidebar', async ( {
diff --git a/test/gutenberg-test-themes/block-bindings/index.php b/test/gutenberg-test-themes/block-bindings/index.php
new file mode 100644
index 00000000000000..0c6530acc1aaff
--- /dev/null
+++ b/test/gutenberg-test-themes/block-bindings/index.php
@@ -0,0 +1,9 @@
+
+Custom template
+
diff --git a/test/gutenberg-test-themes/block-bindings/templates/index.html b/test/gutenberg-test-themes/block-bindings/templates/index.html
new file mode 100644
index 00000000000000..ab136ac8df9a7d
--- /dev/null
+++ b/test/gutenberg-test-themes/block-bindings/templates/index.html
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/test/gutenberg-test-themes/block-bindings/templates/single-movie.html b/test/gutenberg-test-themes/block-bindings/templates/single-movie.html
new file mode 100644
index 00000000000000..cd05d5fe917fea
--- /dev/null
+++ b/test/gutenberg-test-themes/block-bindings/templates/single-movie.html
@@ -0,0 +1,2 @@
+
+
diff --git a/test/gutenberg-test-themes/block-bindings/templates/single.html b/test/gutenberg-test-themes/block-bindings/templates/single.html
new file mode 100644
index 00000000000000..cd05d5fe917fea
--- /dev/null
+++ b/test/gutenberg-test-themes/block-bindings/templates/single.html
@@ -0,0 +1,2 @@
+
+
diff --git a/test/gutenberg-test-themes/block-bindings/theme.json b/test/gutenberg-test-themes/block-bindings/theme.json
new file mode 100644
index 00000000000000..c996b014328398
--- /dev/null
+++ b/test/gutenberg-test-themes/block-bindings/theme.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "../../../schemas/json/theme.json",
+ "version": 3,
+ "settings": {
+ "appearanceTools": true,
+ "layout": {
+ "contentSize": "840px",
+ "wideSize": "1100px"
+ }
+ },
+ "customTemplates": [
+ {
+ "name": "custom-template",
+ "title": "Custom",
+ "postTypes": [ "post", "movie" ]
+ }
+ ]
+}
diff --git a/tools/webpack/script-modules.js b/tools/webpack/script-modules.js
index 18287c96d83c8a..021f11f5f5ed95 100644
--- a/tools/webpack/script-modules.js
+++ b/tools/webpack/script-modules.js
@@ -89,11 +89,11 @@ module.exports = {
},
output: {
devtoolNamespace: 'wp',
- filename: './build-module/[name].min.js',
+ filename: '[name].min.js',
library: {
type: 'module',
},
- path: join( __dirname, '..', '..' ),
+ path: join( __dirname, '..', '..', 'build-module' ),
environment: { module: true },
module: true,
chunkFormat: 'module',
@@ -102,7 +102,13 @@ module.exports = {
resolve: {
extensions: [ '.js', '.ts', '.tsx' ],
},
- plugins: [ ...plugins, new DependencyExtractionWebpackPlugin() ],
+ plugins: [
+ ...plugins,
+ new DependencyExtractionWebpackPlugin( {
+ combineAssets: true,
+ combinedOutputFile: `./assets.php`,
+ } ),
+ ],
watchOptions: {
ignored: [ '**/node_modules' ],
aggregateTimeout: 500,
diff --git a/tools/webpack/shared.js b/tools/webpack/shared.js
index c8c5b05c7d151b..f30d3a830f3eb1 100644
--- a/tools/webpack/shared.js
+++ b/tools/webpack/shared.js
@@ -25,7 +25,7 @@ const baseConfig = {
parallel: true,
terserOptions: {
output: {
- comments: /(translators:|wp:polyfill)/i,
+ comments: /translators:/i,
},
compress: {
passes: 2,
diff --git a/tsconfig.json b/tsconfig.json
index 3ab54f66019bca..8821ef4404e3b5 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -32,6 +32,7 @@
{ "path": "packages/i18n" },
{ "path": "packages/icons" },
{ "path": "packages/interactivity" },
+ { "path": "packages/interactivity/tsconfig.test.json" },
{ "path": "packages/interactivity-router" },
{ "path": "packages/is-shallow-equal" },
{ "path": "packages/keycodes" },