Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rename blocks in the Editor via Inline Editing in List View #53852

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions packages/block-editor/src/components/list-view/block-rename-ui.js
Original file line number Diff line number Diff line change
@@ -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 (
<Popover
anchorRef={ ref }
placement="overlay"
resize={ true }
variant="unstyled"
animate={ false }
className="block-editor-list-view-block-rename__popover"
role="dialog"
aria-labelledby={ dialogTitle }
aria-describedby={ dialogDescription }
onClose={ handleCancel }
>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want this dialog to have a modal or non-modal behavior?
Currently, tabbing is constrained within this dialog but it is possible to exit the dialog by arrow keys.
If we want a modal behavior, this dialog should have an aria-modal="true' attribute. However, I'm skeptical in giving this a dialog role in the first place, because it doesn't look like a dialog.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is correct and it looks like a bug, when renaming we are in a constrained mode, and this mode should not be escaped via arrow keys.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, modal is correct here. I do not want users to get the impression they can navigate away.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@draganescu useConstrainedTabbing handles constraining tabbing with the Tab key. That has nothing to do with screen readers arrows navigation in virtual buffer mode. To prevent screen reader users from exiting this UI by using arrows key navigation, we'd need to implement what the Modal component does:

  • Use aria-modal="true" on the dialog
  • Dynamically add aria-hidden="true" to all the modal element siblings, to make them sort of 'inert' and not perceivable by screen readers. See how it works here and here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would using the Modal component instead of Popover be a better solution? It should implement all of the modal-related functionality

Copy link
Contributor Author

@getdave getdave Nov 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds ideal but that @wordpress/modal component is highly opinionated regarding it's visual styling which makes this "inline" editing approach extremely difficult to achieve.

What we really need is a way to disable all styling and just leave the stacking and perhaps the backdrop in place. Is that doable?

Aside: if we decoupled the behaviour of all our components into hooks and allow implementors to provide "attach" those behaviours to custom UI ("bring your own markup") then it would be easier to achieve this kind of thing. Is that something we've explored?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand perfectly — in fact, we're in the process of discussing a new potential component that would replace Modal

What we really need is a way to disable all styling and just leave the stacking and perhaps the backdrop in place. Is that doable?

It's doable, but very hacky and prone to breaking as soon as Modal updates any of its internal implementation.

For that much, it would be probably better "steal" the relevant parts from Modal and add them to this particular implementation.

Aside: if we decoupled the behaviour of all our components into hooks and allow implementors to provide "attach" those behaviours to custom UI ("bring your own markup") then it would be easier to achieve this kind of thing. Is that something we've explored?

This was a behavior that was explored on a few components (example), but ultimately we saw little to no consumers taking advantage of it, and instead we received feedback about the approach just introducing one more layer of unnecessary complexity to our components.

<VisuallyHidden>
<h2 id={ dialogTitle }>Rename Block</h2>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i18n, and I think "Rename" would be sufficient. That's what we use in the option menu; would be good if they are the same.

Copy link
Contributor

@afercia afercia Nov 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this heading is a H2? It we want this dialog to have a modal behavior, this should be a H1.

<p id={ dialogDescription }>
{ __( 'Choose a custom name for this block.' ) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use "Enter a custom name for this block." which matches the visible modal as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes agreed. We'd need a whole pass at aligning the funcitonality.

If there is consensus on the approach I'll write a test which asserts that behaviour is the same between both implementations.

</p>
</VisuallyHidden>
<form
className="block-editor-list-view-block-rename__form"
onSubmit={ ( e ) => {
e.preventDefault();

onSubmit( inputValue );
} }
>
<InputControl
ref={ inputRef }
value={ inputValue }
label={ __( 'Block name' ) }
hideLabelFromVision
onChange={ ( nextValue ) => {
setInputValue( nextValue ?? '' );
} }
onFocus={ autoSelectInputText }
onKeyDown={ onKeyDownHandler }
aria-describedby={ inputDescriptionId }
required
/>
<VisuallyHidden>
<p id={ inputDescriptionId }>
{ __(
'Press the ENTER key to submit or the ESCAPE key to cancel.'
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could explore adding a visual submit button within the editing UI (perhaps similar to the one used in List View when you are creating a link) which would ensure that it's absolutely clear how to submit the change for both visual and non-visual users.

Question: Would that also necessitate a cancel button or could we make that hidden given that onBlur will also cancel the editing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell, there is no way to reach this state with a screen reader/keyboard only. A visually hidden description intended to be used with screen readers while not allowing this state to be accessed via a screen reader makes me feel like we're not truly addressing the accessibility need here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

) }
</p>
</VisuallyHidden>

<div className="block-editor-list-view-block-rename__actions">
<Button
type="submit"
label={ __( 'Save' ) }
icon={ keyboardReturn }
className="block-editor-list-view-block-rename__action block-editor-list-view-block-rename__action--submit"
/>
<Button
type="button"
onClick={ handleCancel }
label={ __( 'Cancel' ) }
icon={ close }
className="block-editor-list-view-block-rename__action block-editor-list-view-block-rename__action--cancel"
/>
</div>
</form>
</Popover>
);
}
);

export default ListViewBlockRenameUI;
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -49,6 +52,7 @@ function ListViewBlockSelectButton(
},
ref
) {
const blockNameElementRef = useRef();
const blockInformation = useBlockDisplayInformation( clientId );
const blockTitle = useBlockDisplayTitle( {
clientId,
Expand All @@ -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 } );
Expand All @@ -77,6 +83,14 @@ function ListViewBlockSelectButton(
)
: '';

const [ isRenamingBlock, setBlockBeingRenamed ] = useState( false );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just an idea, but would it be worth moving this state up to the root of the list view and pass isRenamingBlock / setBlockBeingRenamed down via useListViewContext? In that case, we'd likely store the clientId rather than a boolean.

Something I observed while working on a list view performance issue over in #54900 is that we can improve the performance of the list view a little bit if we can find opportunities to use a single piece of state at the root of the list view, rather than having each button manage things on its own. Part of the reason (I think!) is that due to the windowing logic of the list view, there's a fair bit of stuff for the list view items to do as they're mounted and unmounted, so when we can defer things to the root of the list view, we can free up the individual buttons a bit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh that's counterintuative but useful knowledge. Thank you.

I would have expected that moving state up would result in entire tree's being re-rendered for changes to a single node.

I'm happy to do this if we get a consensus on this as a UX.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's definitely not a blocker and I've found the performance issues with the list view can be quite subtle and wind up being a trade-off of where we want to place the performance hit.

I would have expected that moving state up would result in entire tree's being re-rendered for changes to a single node.

Yes — it's a tricky one, because local state is typically better for when we're making a change to one row and we don't want to re-render other rows, as you mention, whereas state at the root is helpful for when we want to reduce what's happening at the individual row level. To take the idea one step further (if focusing purely on performance), we could potentially move the ListViewBlockRenameUI up to also be rendered at the root of the list view, but have its Popover be positioned to the selected block — in that case, we'd need to go looking for the desired element to anchor the popover to when it is opened.

For now, especially since some of these performance optimisations can result in harder to read code, I think it's probably fine to just leave things with local to the block-select-button state. I mostly wanted to add a couple of comments so that if we do notice any performance issues further down the track, we've got a bit of a discussion going here so we know where to look 🙂

Separately to all this, I have an open issue (#55114) for improving the fluidity of the list view windowing logic that I'd like to look into at some point — the goal there being to more gracefully show/hide list view items so that if there is a performance issue on mount, it's not as apparent to a user.

For now, though, feel free to close out this particular discussion thread!


const supportsBlockNaming = hasBlockSupport(
blockName,
'renaming',
true // default value
);
Comment on lines +88 to +92
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a subtle thing, but would it be worth moving this check into the click handler rather than on render, as we do for the other hasBlockSupport checks in the keyboard event handlers? The list view buttons currently have quite a bit of logic in them, so we can sometimes improve performance a little bit (albeit marginally) by deferring checks until needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great advice. Thank you.


// 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
Expand Down Expand Up @@ -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 }
Expand All @@ -218,9 +249,12 @@ function ListViewBlockSelectButton(
justify="flex-start"
spacing={ 1 }
>
<span className="block-editor-list-view-block-select-button__title">
<Truncate ellipsizeMode="auto">{ blockTitle }</Truncate>
</span>
<div
ref={ blockNameElementRef }
className="block-editor-list-view-block-select-button__title"
>
{ blockTitle }
</div>
{ blockInformation?.anchor && (
<span className="block-editor-list-view-block-select-button__anchor-wrapper">
<Truncate
Expand Down Expand Up @@ -260,6 +294,29 @@ function ListViewBlockSelectButton(
) }
</HStack>
</Button>

{ isRenamingBlock && (
<ListViewBlockRenameUI
ref={ blockNameElementRef }
blockTitle={ blockTitle }
onCancel={ () => 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,
},
} );
} }
/>
) }
</>
);
}
Expand Down
65 changes: 64 additions & 1 deletion packages/block-editor/src/components/list-view/style.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@

$list-view-item-content-height: 32px;

.block-editor-list-view-tree {
width: 100%;
border-collapse: collapse;
Expand All @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still necessary?

}

// Cancel focused styles as contrast is already sufficiently high.
.components-input-control__backdrop {
box-shadow: none !important;
border-color: initial !important;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be transparent, which would give us this:

CleanShot 2023-10-11 at 10 50 11

}

.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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably use a negative $grid-unit-10 here.

}


.block-editor-list-view-leaf {
// Use position relative for row animation.
position: relative;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -301,11 +359,16 @@

.block-editor-list-view-block-select-button__label-wrapper {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not used anymore, right?

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;
Expand Down
Loading
Loading