diff --git a/packages/block-editor/src/components/list-view/block-rename-ui.js b/packages/block-editor/src/components/list-view/block-rename-ui.js new file mode 100644 index 0000000000000..87da4ffa455a1 --- /dev/null +++ b/packages/block-editor/src/components/list-view/block-rename-ui.js @@ -0,0 +1,163 @@ +/** + * WordPress dependencies + */ +import { + __experimentalInputControl as InputControl, + Popover, + VisuallyHidden, + Button, +} from '@wordpress/components'; +import { speak } from '@wordpress/a11y'; +import { useInstanceId, useFocusOnMount } from '@wordpress/compose'; +import { useState, forwardRef, useEffect } from '@wordpress/element'; +import { ENTER, ESCAPE } from '@wordpress/keycodes'; +import { __, sprintf } from '@wordpress/i18n'; +import { keyboardReturn, close } from '@wordpress/icons'; + +const ListViewBlockRenameUI = forwardRef( + ( { blockTitle, onSubmit, onCancel }, ref ) => { + const inputRef = useFocusOnMount(); + + const inputDescriptionId = useInstanceId( + ListViewBlockRenameUI, + `block-editor-list-view-block-node__input-description` + ); + + const dialogTitle = useInstanceId( + ListViewBlockRenameUI, + `block-editor-list-view-block-rename-dialog__title` + ); + + const dialogDescription = useInstanceId( + ListViewBlockRenameUI, + `block-editor-list-view-block-rename-dialog__description` + ); + + // Local state for value of input **pre-submission**. + const [ inputValue, setInputValue ] = useState( blockTitle ); + + const onKeyDownHandler = ( event ) => { + // Trap events to input when editing to avoid + // default list view key handing (e.g. arrow + // keys for navigation). + event.stopPropagation(); + + // Handle ENTER and ESC exits editing mode. + if ( event.keyCode === ENTER || event.keyCode === ESCAPE ) { + if ( event.keyCode === ESCAPE ) { + handleCancel(); + } + + if ( event.keyCode === ENTER ) { + handleSubmit(); + } + } + }; + + const handleCancel = () => { + // Reset the input's local state to avoid + // stale values. + setInputValue( blockTitle ); + + onCancel(); + + // Must be assertive to immediately announce change. + speak( __( 'Leaving block name edit mode' ), 'assertive' ); + }; + + const handleSubmit = () => { + let successAnnouncement; + + if ( inputValue === '' ) { + successAnnouncement = __( 'Block name reset.' ); + } else { + successAnnouncement = sprintf( + /* translators: %s: new name/label for the block */ + __( 'Block name updated to: "%s".' ), + inputValue + ); + } + + // Must be assertive to immediately announce change. + speak( successAnnouncement, 'assertive' ); + + // Submit changes only for ENTER. + onSubmit( inputValue ); + }; + + const autoSelectInputText = ( event ) => event.target.select(); + + useEffect( () => { + speak( __( 'Entering block name edit mode' ), 'assertive' ); + }, [] ); + + return ( + + +

Rename Block

+

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

+
+
{ + e.preventDefault(); + + onSubmit( inputValue ); + } } + > + { + setInputValue( nextValue ?? '' ); + } } + onFocus={ autoSelectInputText } + onKeyDown={ onKeyDownHandler } + aria-describedby={ inputDescriptionId } + required + /> + +

+ { __( + 'Press the ENTER key to submit or the ESCAPE key to cancel.' + ) } +

+
+ +
+
+ +
+ ); + } +); + +export default ListViewBlockRenameUI; diff --git a/packages/block-editor/src/components/list-view/block-select-button.js b/packages/block-editor/src/components/list-view/block-select-button.js index cec5d4699c7a2..ca74c078b76d4 100644 --- a/packages/block-editor/src/components/list-view/block-select-button.js +++ b/packages/block-editor/src/components/list-view/block-select-button.js @@ -13,7 +13,7 @@ import { __experimentalTruncate as Truncate, Tooltip, } from '@wordpress/components'; -import { forwardRef } from '@wordpress/element'; +import { forwardRef, useRef, useState } from '@wordpress/element'; import { Icon, lockSmall as lock, pinSmall } from '@wordpress/icons'; import { SPACE, ENTER, BACKSPACE, DELETE } from '@wordpress/keycodes'; import { useSelect, useDispatch } from '@wordpress/data'; @@ -28,13 +28,16 @@ import useBlockDisplayInformation from '../use-block-display-information'; import useBlockDisplayTitle from '../block-title/use-block-display-title'; import ListViewExpander from './expander'; import { useBlockLock } from '../block-lock'; -import { store as blockEditorStore } from '../../store'; import useListViewImages from './use-list-view-images'; +import ListViewBlockRenameUI from './block-rename-ui'; +import { store as blockEditorStore } from '../../store'; + +const SINGLE_CLICK = 1; function ListViewBlockSelectButton( { className, - block: { clientId }, + block: { clientId, attributes: blockAttributes, name: blockName }, onClick, onToggleExpanded, tabIndex, @@ -49,6 +52,7 @@ function ListViewBlockSelectButton( }, ref ) { + const blockNameElementRef = useRef(); const blockInformation = useBlockDisplayInformation( clientId ); const blockTitle = useBlockDisplayTitle( { clientId, @@ -64,7 +68,9 @@ function ListViewBlockSelectButton( getBlocksByClientId, canRemoveBlocks, } = useSelect( blockEditorStore ); - const { duplicateBlocks, removeBlocks } = useDispatch( blockEditorStore ); + const { duplicateBlocks, removeBlocks, updateBlockAttributes } = + useDispatch( blockEditorStore ); + const isMatch = useShortcutEventMatch(); const isSticky = blockInformation?.positionType === 'sticky'; const images = useListViewImages( { clientId, isExpanded } ); @@ -77,6 +83,14 @@ function ListViewBlockSelectButton( ) : ''; + const [ isRenamingBlock, setBlockBeingRenamed ] = useState( false ); + + const supportsBlockNaming = hasBlockSupport( + blockName, + 'renaming', + true // default value + ); + // The `href` attribute triggers the browser's native HTML drag operations. // When the link is dragged, the element's outerHTML is set in DataTransfer object as text/html. // We need to clear any HTML drag data to prevent `pasteHandler` from firing @@ -193,7 +207,24 @@ function ListViewBlockSelectButton( 'block-editor-list-view-block-select-button', className ) } - onClick={ onClick } + onClick={ ( event ) => { + // Avoid click delays for blocks that don't support naming interaction. + if ( ! supportsBlockNaming ) { + onClick( event ); + return; + } + + if ( event.detail === SINGLE_CLICK ) { + onClick( event ); + } + } } + onDoubleClick={ ( event ) => { + event.preventDefault(); + if ( ! supportsBlockNaming ) { + return; + } + setBlockBeingRenamed( true ); + } } onKeyDown={ onKeyDownHandler } ref={ ref } tabIndex={ tabIndex } @@ -218,9 +249,12 @@ function ListViewBlockSelectButton( justify="flex-start" spacing={ 1 } > - - { blockTitle } - +
+ { blockTitle } +
{ blockInformation?.anchor && ( + + { isRenamingBlock && ( + setBlockBeingRenamed( false ) } + onSubmit={ ( newName ) => { + if ( newName === undefined ) { + setBlockBeingRenamed( false ); + } + + setBlockBeingRenamed( false ); + updateBlockAttributes( clientId, { + // Include existing metadata (if present) to avoid overwriting existing. + metadata: { + ...( blockAttributes?.metadata && + blockAttributes?.metadata ), + name: newName, + }, + } ); + } } + /> + ) } ); } diff --git a/packages/block-editor/src/components/list-view/style.scss b/packages/block-editor/src/components/list-view/style.scss index 7afa7a1c98431..458476d750f12 100644 --- a/packages/block-editor/src/components/list-view/style.scss +++ b/packages/block-editor/src/components/list-view/style.scss @@ -1,3 +1,6 @@ + +$list-view-item-content-height: 32px; + .block-editor-list-view-tree { width: 100%; border-collapse: collapse; @@ -11,6 +14,61 @@ } } + +// Render in Popover. +// Todo: find a better way to match height and avoid important. +.block-editor-list-view-block-rename__form { + position: relative; + + .components-input-control__input { + height: $list-view-item-content-height !important; // force match height of block title UI. + min-height: $list-view-item-content-height !important; // force match height of block title UI. + padding-right: 30px !important; // allow space for submit button. + } + + // Cancel focused styles as contrast is already sufficiently high. + .components-input-control__backdrop { + box-shadow: none !important; + border-color: initial !important; + } + + .block-editor-list-view-block-rename__actions { + position: absolute; + top: 0; + right: 0; + z-index: 9999; + + .block-editor-list-view-block-rename__action { + height: $list-view-item-content-height; + + &:not(:focus) { + border: 0; + clip: rect(1px, 1px, 1px, 1px); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + word-wrap: normal; + } + } + + .block-editor-list-view-block-rename__action--submit { + position: relative; + top: -2px; // Ensure that the submit button is always visible. + } + } +} + + +.block-editor-list-view-block-rename__popover { + // Important required to overide inline positioning + // until such time as we can specify a horizontal offset. + left: -8px !important; // todo: use input padding instead. +} + + .block-editor-list-view-leaf { // Use position relative for row animation. position: relative; @@ -131,7 +189,7 @@ align-items: center; width: 100%; height: auto; - padding: ($grid-unit-15 * 0.5) $grid-unit-05 ($grid-unit-15 * 0.5) 0; + padding: 2px $grid-unit-05 2px 0; text-align: left; border-radius: $radius-block-ui; position: relative; @@ -301,11 +359,16 @@ .block-editor-list-view-block-select-button__label-wrapper { min-width: 120px; + } + .block-editor-list-view-block-select-button__title { flex: 1; position: relative; + height: $list-view-item-content-height; + display: flex; + align-items: center; .components-truncate { position: absolute; diff --git a/test/e2e/specs/editor/various/block-renaming.spec.js b/test/e2e/specs/editor/various/block-renaming.spec.js index 4150be64bd33d..da3f7ac65798d 100644 --- a/test/e2e/specs/editor/various/block-renaming.spec.js +++ b/test/e2e/specs/editor/various/block-renaming.spec.js @@ -14,13 +14,6 @@ test.describe( 'Block Renaming', () => { page, pageUtils, } ) => { - // Turn on block list view by default. - await page.evaluate( () => { - window.wp.data - .dispatch( 'core/preferences' ) - .set( 'core/edit-site', 'showListViewByDefault', true ); - } ); - const listView = page.getByRole( 'treegrid', { name: 'Block navigation structure', } ); @@ -168,6 +161,82 @@ test.describe( 'Block Renaming', () => { } ); } ); + test.describe( 'List View renaming', () => { + test( 'should allow renaming via double click in List View', async ( { + editor, + page, + pageUtils, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'First Paragraph' }, + } ); + + await pageUtils.pressKeys( 'access+o' ); + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + } ); + + await expect( listView ).toBeVisible(); + + const listViewNode = listView.getByRole( 'link', { + name: 'Paragraph', + } ); + + // double click to trigger rename + await listViewNode.dblclick(); + + const renameModal = page.getByRole( 'dialog', { + name: 'Rename block', + } ); + + // Check the Modal is perceivable. + await expect( renameModal ).toBeVisible(); + + const nameInput = renameModal.getByRole( 'textbox', { + name: 'Block name', + } ); + + // Check focus is transferred into modal. + await expect( nameInput ).toBeFocused(); + + const saveButton = renameModal.getByRole( 'button', { + name: 'Save', + type: 'submit', + } ); + + // await expect( saveButton ).toBeDisabled(); + + await expect( nameInput ).toHaveValue( 'Paragraph' ); + + await nameInput.fill( 'My new name' ); + + await expect( saveButton ).toBeEnabled(); + + await saveButton.click(); + + await expect( renameModal ).toBeHidden(); + + // Check that focus is transferred back to original "Rename" menu item. + await expect( + listView.getByRole( 'link', { + name: 'My new name', + } ) + ).toBeFocused(); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + metadata: { + name: 'My new name', + }, + }, + }, + ] ); + } ); + } ); + test.describe( 'Block inspector renaming', () => { test( 'allows renaming of blocks that support the feature via "Advanced" section of block inspector tools', async ( { editor,