diff --git a/packages/block-editor/src/components/block-tools/selected-block-popover.js b/packages/block-editor/src/components/block-tools/selected-block-popover.js index b4d14296e823d4..6e3c77fe1ca186 100644 --- a/packages/block-editor/src/components/block-tools/selected-block-popover.js +++ b/packages/block-editor/src/components/block-tools/selected-block-popover.js @@ -10,7 +10,6 @@ import { useRef, useEffect } from '@wordpress/element'; import { isUnmodifiedDefaultBlock } from '@wordpress/blocks'; import { useDispatch, useSelect } from '@wordpress/data'; import { useShortcut } from '@wordpress/keyboard-shortcuts'; -import { useViewportMatch } from '@wordpress/compose'; /** * Internal dependencies @@ -22,26 +21,20 @@ import BlockPopover from '../block-popover'; import useBlockToolbarPopoverProps from './use-block-toolbar-popover-props'; import Inserter from '../inserter'; import { unlock } from '../../lock-unlock'; +import { privateApis } from '../../private-apis'; function selector( select ) { const { __unstableGetEditorMode, - isMultiSelecting, hasMultiSelection, isTyping, - isBlockInterfaceHidden, - getSettings, getLastMultiSelectedBlockClientId, } = unlock( select( blockEditorStore ) ); return { editorMode: __unstableGetEditorMode(), hasMultiSelection: hasMultiSelection(), - isMultiSelecting: isMultiSelecting(), isTyping: isTyping(), - isBlockInterfaceHidden: isBlockInterfaceHidden(), - hasFixedToolbar: getSettings().hasFixedToolbar, - isDistractionFree: getSettings().isDistractionFree, lastClientId: hasMultiSelection() ? getLastMultiSelectedBlockClientId() : null, @@ -52,21 +45,16 @@ function SelectedBlockPopover( { clientId, rootClientId, isEmptyDefaultBlock, - showContents, // we may need to mount an empty popover because we reuse capturingClientId, __unstablePopoverSlot, __unstableContentRef, } ) { - const { - editorMode, - hasMultiSelection, - isMultiSelecting, - isTyping, - isBlockInterfaceHidden, - hasFixedToolbar, - isDistractionFree, - lastClientId, - } = useSelect( selector, [] ); + const { editorMode, hasMultiSelection, isTyping, lastClientId } = useSelect( + selector, + [] + ); + + const { useShouldContextualToolbarShow } = unlock( privateApis ); const isInsertionPointVisible = useSelect( ( select ) => { const { @@ -85,8 +73,10 @@ function SelectedBlockPopover( { }, [ clientId ] ); - const isLargeViewport = useViewportMatch( 'medium' ); const isToolbarForced = useRef( false ); + const { shouldShowContextualToolbar, canFocusHiddenToolbar } = + useShouldContextualToolbarShow( clientId ); + const { stopTyping } = useDispatch( blockEditorStore ); const showEmptyBlockSideInserter = @@ -94,20 +84,6 @@ function SelectedBlockPopover( { const shouldShowBreadcrumb = ! hasMultiSelection && ( editorMode === 'navigation' || editorMode === 'zoom-out' ); - const shouldShowContextualToolbar = - editorMode === 'edit' && - ! hasFixedToolbar && - isLargeViewport && - ! isMultiSelecting && - ! showEmptyBlockSideInserter && - ! isTyping && - ! isBlockInterfaceHidden; - const canFocusHiddenToolbar = - editorMode === 'edit' && - ! shouldShowContextualToolbar && - ! hasFixedToolbar && - ! isDistractionFree && - ! isEmptyDefaultBlock; useShortcut( 'core/block-editor/focus-toolbar', @@ -179,7 +155,7 @@ function SelectedBlockPopover( { resize={ false } { ...popoverProps } > - { shouldShowContextualToolbar && showContents && ( + { shouldShowContextualToolbar && ( { + if ( shouldUseKeyboardFocusShortcut ) { + focusToolbar(); + } + }; + // Focus on toolbar when pressing alt+F10 when the toolbar is visible. - useShortcut( 'core/block-editor/focus-toolbar', focusToolbar ); + useShortcut( 'core/block-editor/focus-toolbar', focusToolbarViaShortcut ); useEffect( () => { if ( initialFocusOnMount ) { @@ -147,6 +154,7 @@ function useToolbarFocus( function NavigableToolbar( { children, focusOnMount, + shouldUseKeyboardFocusShortcut = true, __experimentalInitialIndex: initialIndex, __experimentalOnIndexChange: onIndexChange, ...props @@ -159,7 +167,8 @@ function NavigableToolbar( { focusOnMount, isAccessibleToolbar, initialIndex, - onIndexChange + onIndexChange, + shouldUseKeyboardFocusShortcut ); if ( isAccessibleToolbar ) { diff --git a/packages/block-editor/src/private-apis.js b/packages/block-editor/src/private-apis.js index 81fb256c1f2833..95681bb1943538 100644 --- a/packages/block-editor/src/private-apis.js +++ b/packages/block-editor/src/private-apis.js @@ -10,6 +10,7 @@ import ResizableBoxPopover from './components/resizable-box-popover'; import { ComposedPrivateInserter as PrivateInserter } from './components/inserter'; import { PrivateListView } from './components/list-view'; import BlockInfo from './components/block-info-slot-fill'; +import { useShouldContextualToolbarShow } from './utils/use-should-contextual-toolbar-show'; /** * Private @wordpress/block-editor APIs. @@ -24,4 +25,5 @@ lock( privateApis, { PrivateListView, ResizableBoxPopover, BlockInfo, + useShouldContextualToolbarShow, } ); diff --git a/packages/block-editor/src/utils/use-should-contextual-toolbar-show.js b/packages/block-editor/src/utils/use-should-contextual-toolbar-show.js new file mode 100644 index 00000000000000..c07e6fc07be605 --- /dev/null +++ b/packages/block-editor/src/utils/use-should-contextual-toolbar-show.js @@ -0,0 +1,75 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { isUnmodifiedDefaultBlock } from '@wordpress/blocks'; +import { useViewportMatch } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../store'; +import { unlock } from '../lock-unlock'; + +/** + * Returns true if the contextual block toolbar should show, or false if it should be hidden. + * + * @param {string} clientId The client ID of the block. + * + * @return {boolean} Whether the block toolbar is hidden. + */ +export function useShouldContextualToolbarShow( clientId ) { + const isLargeViewport = useViewportMatch( 'medium' ); + + const { shouldShowContextualToolbar, canFocusHiddenToolbar } = useSelect( + ( select ) => { + const { + __unstableGetEditorMode, + isMultiSelecting, + isTyping, + isBlockInterfaceHidden, + getBlock, + getSettings, + isNavigationMode, + } = unlock( select( blockEditorStore ) ); + + const isEditMode = __unstableGetEditorMode() === 'edit'; + const hasFixedToolbar = getSettings().hasFixedToolbar; + const isDistractionFree = getSettings().isDistractionFree; + const hasClientId = !! clientId; + const isEmptyDefaultBlock = isUnmodifiedDefaultBlock( + getBlock( clientId ) || {} + ); + + const _shouldShowContextualToolbar = + isEditMode && + ! hasFixedToolbar && + ( ! isDistractionFree || isNavigationMode() ) && + isLargeViewport && + ! isMultiSelecting() && + ! isTyping() && + hasClientId && + ! isEmptyDefaultBlock && + ! isBlockInterfaceHidden(); + + const _canFocusHiddenToolbar = + isEditMode && + hasClientId && + ! _shouldShowContextualToolbar && + ! hasFixedToolbar && + ! isDistractionFree && + ! isEmptyDefaultBlock; + + return { + shouldShowContextualToolbar: _shouldShowContextualToolbar, + canFocusHiddenToolbar: _canFocusHiddenToolbar, + }; + }, + [ clientId, isLargeViewport ] + ); + + return { + shouldShowContextualToolbar, + canFocusHiddenToolbar, + }; +} diff --git a/packages/e2e-test-utils-playwright/src/editor/index.ts b/packages/e2e-test-utils-playwright/src/editor/index.ts index 68ad10bd107024..395fbb4f98b696 100644 --- a/packages/e2e-test-utils-playwright/src/editor/index.ts +++ b/packages/e2e-test-utils-playwright/src/editor/index.ts @@ -18,6 +18,7 @@ import { selectBlocks } from './select-blocks'; import { setContent } from './set-content'; import { showBlockToolbar } from './show-block-toolbar'; import { saveSiteEditorEntities } from './site-editor'; +import { setIsFixedToolbar } from './set-is-fixed-toolbar'; import { transformBlockTo } from './transform-block-to'; type EditorConstructorProps = { @@ -68,6 +69,9 @@ export class Editor { setContent: typeof setContent = setContent.bind( this ); /** @borrows showBlockToolbar as this.showBlockToolbar */ showBlockToolbar: typeof showBlockToolbar = showBlockToolbar.bind( this ); + /** @borrows setIsFixedToolbar as this.setIsFixedToolbar */ + setIsFixedToolbar: typeof setIsFixedToolbar = + setIsFixedToolbar.bind( this ); /** @borrows transformBlockTo as this.transformBlockTo */ transformBlockTo: typeof transformBlockTo = transformBlockTo.bind( this ); } diff --git a/packages/e2e-test-utils-playwright/src/editor/set-is-fixed-toolbar.ts b/packages/e2e-test-utils-playwright/src/editor/set-is-fixed-toolbar.ts new file mode 100644 index 00000000000000..2f66fac8692618 --- /dev/null +++ b/packages/e2e-test-utils-playwright/src/editor/set-is-fixed-toolbar.ts @@ -0,0 +1,21 @@ +/** + * Internal dependencies + */ +import type { Editor } from './index'; + +/** + * Toggles the fixed toolbar option. + * + * @param this + * @param isFixed Boolean value true/false for on/off. + */ +export async function setIsFixedToolbar( this: Editor, isFixed: boolean ) { + await this.page.evaluate( ( _isFixed ) => { + const { select, dispatch } = window.wp.data; + const isCurrentlyFixed = + select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' ); + if ( isCurrentlyFixed !== _isFixed ) { + dispatch( 'core/edit-post' ).toggleFeature( 'fixedToolbar' ); + } + }, isFixed ); +} diff --git a/packages/edit-post/src/components/header/header-toolbar/index.js b/packages/edit-post/src/components/header/header-toolbar/index.js index 55ffd265a96cfa..e28591f78e601f 100644 --- a/packages/edit-post/src/components/header/header-toolbar/index.js +++ b/packages/edit-post/src/components/header/header-toolbar/index.js @@ -8,6 +8,7 @@ import { NavigableToolbar, ToolSelector, store as blockEditorStore, + privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; import { EditorHistoryRedo, @@ -23,6 +24,7 @@ import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; * Internal dependencies */ import { store as editPostStore } from '../../../store'; +import { unlock } from '../../../private-apis'; const preventDefault = ( event ) => { event.preventDefault(); @@ -39,15 +41,27 @@ function HeaderToolbar() { showIconLabels, isListViewOpen, listViewShortcut, + selectedBlockId, + hasFixedToolbar, } = useSelect( ( select ) => { - const { hasInserterItems, getBlockRootClientId, getBlockSelectionEnd } = - select( blockEditorStore ); + const { + hasInserterItems, + getBlockRootClientId, + getBlockSelectionEnd, + getSelectedBlockClientId, + getFirstMultiSelectedBlockClientId, + getSettings, + } = select( blockEditorStore ); const { getEditorSettings } = select( editorStore ); const { getEditorMode, isFeatureActive, isListViewOpened } = select( editPostStore ); const { getShortcutRepresentation } = select( keyboardShortcutsStore ); return { + hasFixedToolbar: getSettings().hasFixedToolbar, + selectedBlockId: + getSelectedBlockClientId() || + getFirstMultiSelectedBlockClientId(), // This setting (richEditingEnabled) should not live in the block editor's setting. isInserterEnabled: getEditorMode() === 'visual' && @@ -64,9 +78,19 @@ function HeaderToolbar() { ), }; }, [] ); + + const { useShouldContextualToolbarShow } = unlock( blockEditorPrivateApis ); + const isLargeViewport = useViewportMatch( 'medium' ); const isWideViewport = useViewportMatch( 'wide' ); - + const { shouldShowContextualToolbar, canFocusHiddenToolbar } = + useShouldContextualToolbarShow( selectedBlockId ); + // If there's a block toolbar to be focused, disable the focus shortcut for the document toolbar. + // There's a fixed block toolbar when the fixed toolbar option is enabled or when the browser width is less than the large viewport. + const blockToolbarCanBeFocused = + shouldShowContextualToolbar || + canFocusHiddenToolbar || + ( ( hasFixedToolbar || ! isLargeViewport ) && selectedBlockId ); /* translators: accessibility text for the editor toolbar */ const toolbarAriaLabel = __( 'Document tools' ); @@ -114,6 +138,7 @@ function HeaderToolbar() {
{ + await use( new ToolbarUtils( { page, pageUtils } ) ); + }, +} ); + +test.describe( 'Focus toolbar shortcut (alt + F10)', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'Focuses correct toolbar in default view options in edit mode', async ( { + editor, + page, + toolbarUtils, + } ) => { + // Test: Focus the top level toolbar from title + await toolbarUtils.moveToToolbarShortcut(); + await expect( toolbarUtils.documentToolbarButton ).toBeFocused(); + + // Test: Focus document toolbar from empty block + await editor.insertBlock( { name: 'core/paragraph' } ); + await toolbarUtils.moveToToolbarShortcut(); + await expect( toolbarUtils.documentToolbarButton ).toBeFocused(); + + // Test: Focus block toolbar from block content when block toolbar isn't visible + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.type( + "Focus to block toolbar when block toolbar isn't visible" + ); + await toolbarUtils.moveToToolbarShortcut(); + await expect( toolbarUtils.blockToolbarParagraphButton ).toBeFocused(); + await expect( toolbarUtils.documentToolbarTooltip ).not.toBeVisible(); + + // Test: Focus block toolbar from block content when block toolbar is visible + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.type( + 'Focus to block toolbar when block toolbar is visible' + ); + // We need to force the toolbar to show. Otherwise, the bug from + // https://github.com/WordPress/gutenberg/pull/49644 won't surface in the e2e tests. + await editor.showBlockToolbar(); + await toolbarUtils.moveToToolbarShortcut(); + await expect( toolbarUtils.blockToolbarParagraphButton ).toBeFocused(); + await expect( toolbarUtils.documentToolbarTooltip ).not.toBeVisible(); + } ); + + 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 + await editor.setIsFixedToolbar( true ); + } ); + + test( 'Focuses the correct toolbar in edit mode', async ( { + editor, + page, + toolbarUtils, + } ) => { + // Test: Focus the document toolbar from title + await toolbarUtils.moveToToolbarShortcut(); + await expect( toolbarUtils.documentToolbarButton ).toBeFocused(); + + // Test: Focus the block toolbar from empty block + await editor.insertBlock( { name: 'core/paragraph' } ); + await toolbarUtils.moveToToolbarShortcut(); + await expect( + toolbarUtils.blockToolbarShowDocumentButton + ).toBeFocused(); + await expect( + toolbarUtils.documentToolbarTooltip + ).not.toBeVisible(); + + // Test: Focus the block toolbar from paragraph block with content + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.type( + 'Focus the block toolbar from paragraph block with content' + ); + await toolbarUtils.moveToToolbarShortcut(); + await expect( + toolbarUtils.blockToolbarShowDocumentButton + ).toBeFocused(); + await expect( + toolbarUtils.documentToolbarTooltip + ).not.toBeVisible(); + } ); + + test( 'Focuses the correct toolbar 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 block toolbar from empty block + await editor.insertBlock( { name: 'core/paragraph' } ); + await toolbarUtils.useSelectMode(); + await toolbarUtils.moveToToolbarShortcut(); + await expect( + toolbarUtils.blockToolbarShowDocumentButton + ).toBeFocused(); + await expect( + toolbarUtils.documentToolbarTooltip + ).not.toBeVisible(); + + // Test: Focus the block toolbar from paragraph in select mode + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.type( + 'Focus the block toolbar from paragraph in select mode' + ); + await toolbarUtils.useSelectMode(); + await toolbarUtils.moveToToolbarShortcut(); + await expect( + toolbarUtils.blockToolbarShowDocumentButton + ).toBeFocused(); + await expect( + toolbarUtils.documentToolbarTooltip + ).not.toBeVisible(); + } ); + } ); + + test.describe( 'Smaller than large viewports', () => { + test.use( { + // Make the viewport small enough to trigger the fixed toolbar + viewport: { + width: 700, + height: 700, + }, + } ); + + test.beforeEach( async ( { editor } ) => { + // Ensure the fixed toolbar option is off + await editor.setIsFixedToolbar( false ); + } ); + + test( 'Focuses the correct toolbar in edit mode', async ( { + editor, + page, + toolbarUtils, + } ) => { + // Test: Focus the document toolbar from title + await toolbarUtils.moveToToolbarShortcut(); + await expect( toolbarUtils.documentToolbarButton ).toBeFocused(); + + // Test: Focus the block toolbar from empty block + await editor.insertBlock( { name: 'core/paragraph' } ); + await toolbarUtils.moveToToolbarShortcut(); + await expect( + toolbarUtils.blockToolbarParagraphButton + ).toBeFocused(); + await expect( + toolbarUtils.documentToolbarTooltip + ).not.toBeVisible(); + + // Test: Focus the block toolbar from paragraph block with content + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.type( + 'Focus the block toolbar from paragraph block with content' + ); + await toolbarUtils.moveToToolbarShortcut(); + await expect( + toolbarUtils.blockToolbarParagraphButton + ).toBeFocused(); + await expect( + toolbarUtils.documentToolbarTooltip + ).not.toBeVisible(); + } ); + + test( 'Focuses the correct toolbar 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 block toolbar from empty block + await editor.insertBlock( { name: 'core/paragraph' } ); + await toolbarUtils.useSelectMode(); + await toolbarUtils.moveToToolbarShortcut(); + await expect( + toolbarUtils.blockToolbarParagraphButton + ).toBeFocused(); + await expect( + toolbarUtils.documentToolbarTooltip + ).not.toBeVisible(); + + // Test: Focus the block toolbar from paragraph in select mode + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.type( + 'Focus the block toolbar from paragraph in select mode' + ); + await toolbarUtils.useSelectMode(); + await toolbarUtils.moveToToolbarShortcut(); + await expect( + toolbarUtils.blockToolbarParagraphButton + ).toBeFocused(); + await expect( + toolbarUtils.documentToolbarTooltip + ).not.toBeVisible(); + } ); + } ); +} ); + +class ToolbarUtils { + constructor( { page, pageUtils } ) { + this.page = page; + this.pageUtils = pageUtils; + + this.documentToolbarButton = this.page.getByRole( 'button', { + name: 'Toggle block inserter', + exact: true, + } ); + this.documentToolbarTooltip = this.page.locator( + 'text=Toggle block inserter' + ); + this.blockToolbarParagraphButton = this.page.getByRole( 'button', { + name: 'Paragraph', + exact: true, + } ); + this.blockToolbarShowDocumentButton = this.page.getByRole( 'button', { + name: 'Show document tools', + exact: true, + } ); + } + + async useSelectMode() { + await this.page.keyboard.press( 'Escape' ); + } + + async moveToToolbarShortcut() { + await this.pageUtils.pressKeys( 'alt+F10' ); + } +}