diff --git a/packages/block-editor/src/components/block-rename/modal.js b/packages/block-editor/src/components/block-rename/modal.js index 8e9ffc1d81b63..f3db0d6c36252 100644 --- a/packages/block-editor/src/components/block-rename/modal.js +++ b/packages/block-editor/src/components/block-rename/modal.js @@ -8,9 +8,8 @@ import { TextControl, Modal, } from '@wordpress/components'; -import { useInstanceId } from '@wordpress/compose'; import { __, sprintf } from '@wordpress/i18n'; -import { useState } from '@wordpress/element'; +import { useState, useId } from '@wordpress/element'; import { speak } from '@wordpress/a11y'; /** @@ -23,8 +22,12 @@ export default function BlockRenameModal( { originalBlockName, onClose, onSave, + // Pattern Overrides is a WordPress-only feature but it also uses the Block Binding API. + // Ideally this should not be inside the block editor package, but we keep it here for simplicity. + hasOverridesWarning, } ) { const [ editedBlockName, setEditedBlockName ] = useState( blockName ); + const descriptionId = useId(); const nameHasChanged = editedBlockName !== blockName; const nameIsOriginal = editedBlockName === originalBlockName; @@ -34,11 +37,6 @@ export default function BlockRenameModal( { const autoSelectInputText = ( event ) => event.target.select(); - const dialogDescription = useInstanceId( - BlockRenameModal, - `block-editor-rename-modal__description` - ); - const handleSubmit = () => { const message = nameIsOriginal || nameIsEmpty @@ -66,14 +64,10 @@ export default function BlockRenameModal( { title={ __( 'Rename' ) } onRequestClose={ onClose } overlayClassName="block-editor-block-rename-modal" - aria={ { - describedby: dialogDescription, - } } focusOnMount="firstContentElement" + aria={ { describedby: descriptionId } } + size="small" > -

- { __( 'Enter a custom name for this block.' ) } -

{ e.preventDefault(); @@ -85,6 +79,9 @@ export default function BlockRenameModal( { handleSubmit(); } } > +

+ { __( 'Enter a custom name for this block.' ) } +

binding.source === 'core/pattern-overrides' + ); function onChange( newName ) { updateBlockAttributes( [ clientId ], { metadata: { - ...( metadata && metadata ), + ...metadata, name: newName, }, } ); @@ -59,6 +65,7 @@ export default function BlockRenameControl( { clientId } ) { setRenamingBlock( false ) } onSave={ ( newName ) => { // If the new value is the block's original name (e.g. `Group`) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 28475ded0efb0..63ec961b80f3c 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -8,6 +8,8 @@ - `ProgressBar`: Move the indicator width styles from emotion to a CSS variable ([#60388](https://github.com/WordPress/gutenberg/pull/60388)). - `Text`: Add `text-wrap: pretty;` to improve wrapping. ([#60164](https://github.com/WordPress/gutenberg/pull/60164)). - `Navigator`: Navigation to the active path doesn't create a new location history. ([#60561](https://github.com/WordPress/gutenberg/pull/60561)) +- `FormToggle`: Forwards ref to input. ([#60234](https://github.com/WordPress/gutenberg/pull/60234)). +- `ToggleControl`: Forwards ref to FormToggle. ([#60234](https://github.com/WordPress/gutenberg/pull/60234)). ### Bug Fix diff --git a/packages/components/src/form-toggle/index.tsx b/packages/components/src/form-toggle/index.tsx index 19cb828023ad8..372f646b846a6 100644 --- a/packages/components/src/form-toggle/index.tsx +++ b/packages/components/src/form-toggle/index.tsx @@ -2,6 +2,12 @@ * External dependencies */ import classnames from 'classnames'; +import type { ForwardedRef } from 'react'; + +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; /** * Internal dependencies @@ -31,7 +37,8 @@ export const noop = () => {}; * ``` */ export function FormToggle( - props: WordPressComponentProps< FormToggleProps, 'input', false > + props: WordPressComponentProps< FormToggleProps, 'input', false >, + ref: ForwardedRef< HTMLInputElement > ) { const { className, @@ -56,6 +63,7 @@ export function FormToggle( onChange={ onChange } disabled={ disabled } { ...additionalProps } + ref={ ref } /> @@ -63,4 +71,4 @@ export function FormToggle( ); } -export default FormToggle; +export default forwardRef( FormToggle ); diff --git a/packages/components/src/form-toggle/stories/index.story.tsx b/packages/components/src/form-toggle/stories/index.story.tsx index 0958b94c342c4..d987860955bdf 100644 --- a/packages/components/src/form-toggle/stories/index.story.tsx +++ b/packages/components/src/form-toggle/stories/index.story.tsx @@ -11,7 +11,7 @@ import { useState } from '@wordpress/element'; /** * Internal dependencies */ -import { FormToggle } from '..'; +import FormToggle from '..'; const meta: Meta< typeof FormToggle > = { component: FormToggle, diff --git a/packages/components/src/toggle-control/index.tsx b/packages/components/src/toggle-control/index.tsx index c991953d53e4b..ed62aea7755cd 100644 --- a/packages/components/src/toggle-control/index.tsx +++ b/packages/components/src/toggle-control/index.tsx @@ -1,12 +1,13 @@ /** * External dependencies */ -import type { ChangeEvent } from 'react'; +import type { ChangeEvent, ForwardedRef } from 'react'; import { css } from '@emotion/react'; /** * WordPress dependencies */ +import { forwardRef } from '@wordpress/element'; import { useInstanceId } from '@wordpress/compose'; /** @@ -41,15 +42,18 @@ import { space } from '../utils/space'; * }; * ``` */ -export function ToggleControl( { - __nextHasNoMarginBottom, - label, - checked, - help, - className, - onChange, - disabled, -}: WordPressComponentProps< ToggleControlProps, 'input', false > ) { +export function ToggleControl( + { + __nextHasNoMarginBottom, + label, + checked, + help, + className, + onChange, + disabled, + }: WordPressComponentProps< ToggleControlProps, 'input', false >, + ref: ForwardedRef< HTMLInputElement > +) { function onChangeToggle( event: ChangeEvent< HTMLInputElement > ) { onChange( event.target.checked ); } @@ -94,6 +98,7 @@ export function ToggleControl( { onChange={ onChangeToggle } aria-describedby={ describedBy } disabled={ disabled } + ref={ ref } /> - { isSupportedBlock && } { props.isSelected && isSupportedBlock && ( ) } @@ -47,22 +47,24 @@ const withPatternOverrideControls = createHigherOrderComponent( } ); -function BindingUpdater( props ) { - const postType = useSelect( - ( select ) => select( editorStore ).getCurrentPostType(), - [] - ); - useSetPatternBindings( props, postType ); - return null; -} - // Split into a separate component to avoid a store subscription // on every block. function ControlsWithStoreSubscription( props ) { const blockEditingMode = useBlockEditingMode(); - const isEditingPattern = useSelect( - ( select ) => - select( editorStore ).getCurrentPostType() === PATTERN_TYPES.user, + const { hasPatternOverridesSource, isEditingPattern } = useSelect( + ( select ) => { + const { getBlockBindingsSource } = unlock( select( blocksStore ) ); + + return { + // For editing link to the site editor if the theme and user permissions support it. + hasPatternOverridesSource: !! getBlockBindingsSource( + 'core/pattern-overrides' + ), + isEditingPattern: + select( editorStore ).getCurrentPostType() === + PATTERN_TYPES.user, + }; + }, [] ); @@ -73,14 +75,23 @@ function ControlsWithStoreSubscription( props ) { ( binding ) => binding.source === 'core/pattern-overrides' ); + const shouldShowPatternOverridesControls = + isEditingPattern && blockEditingMode === 'default'; const shouldShowResetOverridesControl = ! isEditingPattern && !! props.attributes.metadata?.name && blockEditingMode !== 'disabled' && hasPatternBindings; + if ( ! hasPatternOverridesSource ) { + return null; + } + return ( <> + { shouldShowPatternOverridesControls && ( + + ) } { shouldShowResetOverridesControl && ( ) } diff --git a/packages/patterns/src/api/index.js b/packages/patterns/src/api/index.js index 07f3b2321a127..448001f891fba 100644 --- a/packages/patterns/src/api/index.js +++ b/packages/patterns/src/api/index.js @@ -15,6 +15,7 @@ export function isOverridableBlock( block ) { Object.keys( PARTIAL_SYNCING_SUPPORTED_BLOCKS ).includes( block.name ) && + !! block.attributes.metadata?.name && !! block.attributes.metadata?.bindings && Object.values( block.attributes.metadata.bindings ).some( ( binding ) => binding.source === 'core/pattern-overrides' diff --git a/packages/patterns/src/components/allow-overrides-modal.js b/packages/patterns/src/components/allow-overrides-modal.js new file mode 100644 index 0000000000000..21d306022fd58 --- /dev/null +++ b/packages/patterns/src/components/allow-overrides-modal.js @@ -0,0 +1,98 @@ +/** + * WordPress dependencies + */ +import { + __experimentalHStack as HStack, + __experimentalVStack as VStack, + Button, + TextControl, + Modal, +} from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { useState, useId } from '@wordpress/element'; +import { speak } from '@wordpress/a11y'; + +export default function AllowOverridesModal( { + placeholder, + onClose, + onSave, +} ) { + const [ editedBlockName, setEditedBlockName ] = useState( '' ); + const descriptionId = useId(); + + const isNameValid = !! editedBlockName.trim(); + + const handleSubmit = () => { + const message = sprintf( + /* translators: %s: new name/label for the block */ + __( 'Block name changed to: "%s".' ), + editedBlockName + ); + + // Must be assertive to immediately announce change. + speak( message, 'assertive' ); + onSave( editedBlockName ); + + // Immediate close avoids ability to hit save multiple times. + onClose(); + }; + + return ( + + { + event.preventDefault(); + + if ( ! isNameValid ) { + return; + } + + handleSubmit(); + } } + > +

+ { __( 'Enter a custom name for this block.' ) } +

+ + + + + + + + + +
+ ); +} diff --git a/packages/patterns/src/components/pattern-overrides-controls.js b/packages/patterns/src/components/pattern-overrides-controls.js new file mode 100644 index 0000000000000..c55bcb8b2d7f8 --- /dev/null +++ b/packages/patterns/src/components/pattern-overrides-controls.js @@ -0,0 +1,138 @@ +/** + * WordPress dependencies + */ +import { useState, useId, useRef, flushSync } from '@wordpress/element'; +import { InspectorControls } from '@wordpress/block-editor'; +import { ToggleControl, BaseControl, Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { + PARTIAL_SYNCING_SUPPORTED_BLOCKS, + PATTERN_OVERRIDES_BINDING_SOURCE, +} from '../constants'; +import AllowOverridesModal from './allow-overrides-modal'; + +function removeBindings( bindings, syncedAttributes ) { + let updatedBindings = {}; + for ( const attributeName of syncedAttributes ) { + // Omit any bindings that's not the same source from the `updatedBindings` object. + if ( + bindings?.[ attributeName ]?.source !== + PATTERN_OVERRIDES_BINDING_SOURCE && + bindings?.[ attributeName ]?.source !== undefined + ) { + updatedBindings[ attributeName ] = bindings[ attributeName ]; + } + } + if ( ! Object.keys( updatedBindings ).length ) { + updatedBindings = undefined; + } + return updatedBindings; +} + +function addBindings( bindings, syncedAttributes ) { + const updatedBindings = { ...bindings }; + for ( const attributeName of syncedAttributes ) { + if ( ! bindings?.[ attributeName ] ) { + updatedBindings[ attributeName ] = { + source: PATTERN_OVERRIDES_BINDING_SOURCE, + }; + } + } + return updatedBindings; +} + +function PatternOverridesControls( { attributes, name, setAttributes } ) { + const controlId = useId(); + const toggleRef = useRef(); + const [ showAllowOverridesModal, setShowAllowOverridesModal ] = + useState( false ); + + const syncedAttributes = PARTIAL_SYNCING_SUPPORTED_BLOCKS[ name ]; + const attributeSources = syncedAttributes.map( + ( attributeName ) => + attributes.metadata?.bindings?.[ attributeName ]?.source + ); + const isConnectedToOtherSources = attributeSources.every( + ( source ) => source && source !== 'core/pattern-overrides' + ); + + function updateBindings( isChecked, customName ) { + const prevBindings = attributes?.metadata?.bindings; + const updatedBindings = isChecked + ? addBindings( prevBindings, syncedAttributes ) + : removeBindings( prevBindings, syncedAttributes ); + + const updatedMetadata = { + ...attributes.metadata, + bindings: updatedBindings, + }; + + if ( customName ) { + updatedMetadata.name = customName; + } + + setAttributes( { + metadata: updatedMetadata, + } ); + } + + // Avoid overwriting other (e.g. meta) bindings. + if ( isConnectedToOtherSources ) return null; + + const hasName = attributes.metadata?.name; + + return ( + <> + + + { hasName ? ( + + source === PATTERN_OVERRIDES_BINDING_SOURCE + ) } + onChange={ ( isChecked ) => { + updateBindings( isChecked ); + } } + ref={ toggleRef } + /> + ) : ( + + ) } + + + + { showAllowOverridesModal && ( + setShowAllowOverridesModal( false ) } + onSave={ ( newName ) => { + flushSync( () => { + updateBindings( true, newName ); + } ); + toggleRef.current?.focus(); + } } + /> + ) } + + ); +} + +export default PatternOverridesControls; diff --git a/packages/patterns/src/components/style.scss b/packages/patterns/src/components/style.scss index e913c924a2149..36c441ed20659 100644 --- a/packages/patterns/src/components/style.scss +++ b/packages/patterns/src/components/style.scss @@ -38,3 +38,8 @@ width: $grid-unit * 40; } } + +.pattern-overrides-control__allow-overrides-button { + width: 100%; + justify-content: center; +} diff --git a/packages/patterns/src/components/use-set-pattern-bindings.js b/packages/patterns/src/components/use-set-pattern-bindings.js deleted file mode 100644 index 261187a91088c..0000000000000 --- a/packages/patterns/src/components/use-set-pattern-bindings.js +++ /dev/null @@ -1,120 +0,0 @@ -/** - * WordPress dependencies - */ -import { usePrevious } from '@wordpress/compose'; -import { store as blocksStore } from '@wordpress/blocks'; -import { useEffect } from '@wordpress/element'; -import { useSelect } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import { PARTIAL_SYNCING_SUPPORTED_BLOCKS } from '../constants'; - -import { unlock } from '../lock-unlock'; - -function removeBindings( bindings, syncedAttributes ) { - let updatedBindings = {}; - for ( const attributeName of syncedAttributes ) { - // Omit any pattern override bindings from the `updatedBindings` object. - if ( - bindings?.[ attributeName ]?.source !== 'core/pattern-overrides' && - bindings?.[ attributeName ]?.source !== undefined - ) { - updatedBindings[ attributeName ] = bindings[ attributeName ]; - } - } - if ( ! Object.keys( updatedBindings ).length ) { - updatedBindings = undefined; - } - return updatedBindings; -} - -function addBindings( bindings, syncedAttributes ) { - const updatedBindings = { ...bindings }; - for ( const attributeName of syncedAttributes ) { - if ( ! bindings?.[ attributeName ] ) { - updatedBindings[ attributeName ] = { - source: 'core/pattern-overrides', - }; - } - } - return updatedBindings; -} - -export default function useSetPatternBindings( - { name, attributes, setAttributes }, - currentPostType -) { - const hasPatternOverridesSource = useSelect( ( select ) => { - const { getBlockBindingsSource } = unlock( select( blocksStore ) ); - - // For editing link to the site editor if the theme and user permissions support it. - return !! getBlockBindingsSource( 'core/pattern-overrides' ); - }, [] ); - - const metadataName = attributes?.metadata?.name ?? ''; - const prevMetadataName = usePrevious( metadataName ) ?? ''; - const bindings = attributes?.metadata?.bindings; - - useEffect( () => { - // Bindings should only be created when editing a wp_block post type, - // and also when there's a change to the user-given name for the block. - // Also check that the pattern overrides source is registered. - if ( - ! hasPatternOverridesSource || - currentPostType !== 'wp_block' || - metadataName === prevMetadataName - ) { - return; - } - - const syncedAttributes = PARTIAL_SYNCING_SUPPORTED_BLOCKS[ name ]; - const attributeSources = syncedAttributes.map( - ( attributeName ) => - attributes.metadata?.bindings?.[ attributeName ]?.source - ); - const isConnectedToOtherSources = attributeSources.every( - ( source ) => source && source !== 'core/pattern-overrides' - ); - - // Avoid overwriting other (e.g. meta) bindings. - if ( isConnectedToOtherSources ) { - return; - } - - // The user-given name for the block was deleted, remove the bindings. - if ( ! metadataName?.length && prevMetadataName?.length ) { - const updatedBindings = removeBindings( - bindings, - syncedAttributes - ); - setAttributes( { - metadata: { - ...attributes.metadata, - bindings: updatedBindings, - }, - } ); - } - - // The user-given name for the block was set, set the bindings. - if ( ! prevMetadataName?.length && metadataName.length ) { - const updatedBindings = addBindings( bindings, syncedAttributes ); - setAttributes( { - metadata: { - ...attributes.metadata, - bindings: updatedBindings, - }, - } ); - } - }, [ - hasPatternOverridesSource, - bindings, - prevMetadataName, - metadataName, - currentPostType, - name, - attributes.metadata, - setAttributes, - ] ); -} diff --git a/packages/patterns/src/constants.js b/packages/patterns/src/constants.js index 99d6a0fa975a8..99563a1a16787 100644 --- a/packages/patterns/src/constants.js +++ b/packages/patterns/src/constants.js @@ -22,3 +22,5 @@ export const PARTIAL_SYNCING_SUPPORTED_BLOCKS = { 'core/button': [ 'text', 'url', 'linkTarget', 'rel' ], 'core/image': [ 'id', 'url', 'title', 'alt' ], }; + +export const PATTERN_OVERRIDES_BINDING_SOURCE = 'core/pattern-overrides'; diff --git a/packages/patterns/src/private-apis.js b/packages/patterns/src/private-apis.js index 15ff161305f4a..05417de2b2c66 100644 --- a/packages/patterns/src/private-apis.js +++ b/packages/patterns/src/private-apis.js @@ -15,7 +15,7 @@ import { isOverridableBlock } from './api'; import RenamePatternModal from './components/rename-pattern-modal'; import PatternsMenuItems from './components'; import RenamePatternCategoryModal from './components/rename-pattern-category-modal'; -import useSetPatternBindings from './components/use-set-pattern-bindings'; +import PatternOverridesControls from './components/pattern-overrides-controls'; import ResetOverridesControl from './components/reset-overrides-control'; import { useAddPatternCategory } from './private-hooks'; import { @@ -38,7 +38,7 @@ lock( privateApis, { RenamePatternModal, PatternsMenuItems, RenamePatternCategoryModal, - useSetPatternBindings, + PatternOverridesControls, ResetOverridesControl, useAddPatternCategory, PATTERN_TYPES, diff --git a/test/e2e/specs/editor/various/pattern-overrides.spec.js b/test/e2e/specs/editor/various/pattern-overrides.spec.js index 2186e4612ff48..0e6b8e8172f19 100644 --- a/test/e2e/specs/editor/various/pattern-overrides.spec.js +++ b/test/e2e/specs/editor/various/pattern-overrides.spec.js @@ -82,6 +82,17 @@ test.describe( 'Pattern Overrides', () => { .getByRole( 'button', { name: 'Save' } ) .click(); + await editor.openDocumentSettingsSidebar(); + const editorSettings = page.getByRole( 'region', { + name: 'Editor settings', + } ); + await editorSettings + .getByRole( 'button', { name: 'Advanced' } ) + .click(); + await editorSettings + .getByRole( 'checkbox', { name: 'Allow overrides' } ) + .setChecked( true ); + await expect.poll( editor.getBlocks ).toMatchObject( [ { name: 'core/paragraph', @@ -212,10 +223,10 @@ test.describe( 'Pattern Overrides', () => { requestUtils, editor, } ) => { - const paragraphId = 'paragraph-id'; + const paragraphName = 'paragraph-name'; const { id } = await requestUtils.createBlock( { title: 'Pattern', - content: ` + content: `

Editable

`, status: 'publish', @@ -247,7 +258,7 @@ test.describe( 'Pattern Overrides', () => { name: 'core/paragraph', attributes: { content: 'edited Editable', - metadata: undefined, + metadata: { name: paragraphName }, }, }, ] );