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 6753cd87cf5b4d..adca17deb603b1 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 @@ -9,6 +9,7 @@ import { placeCaretAtHorizontalEdge, placeCaretAtVerticalEdge, isRTL, + isFormElement, } from '@wordpress/dom'; import { UP, DOWN, LEFT, RIGHT } from '@wordpress/keycodes'; import { useDispatch, useSelect } from '@wordpress/data'; @@ -17,7 +18,7 @@ import { useRefEffect } from '@wordpress/compose'; /** * Internal dependencies */ -import { getBlockClientId, isInSameBlock } from '../../utils/dom'; +import { getBlockClientId } from '../../utils/dom'; import { store as blockEditorStore } from '../../store'; /** @@ -114,14 +115,18 @@ export function getClosestTabbable( } function isTabCandidate( node ) { - // Skip if there's only one child that is content editable (and thus a - // better candidate). + // If it's a block and there are nested focusable nodes, skip because + // there are better candidates. if ( - node.children.length === 1 && - isInSameBlock( node, node.firstElementChild ) && - node.firstElementChild.getAttribute( 'contenteditable' ) === 'true' + getBlockClientId( node ) && + focus.focusable + .find( node ) + // Exclude form elements for now because primary+a cannot be + // used to select the parent element. + .filter( ( element ) => ! isFormElement( element ) ).length !== + 0 ) { - return; + return false; } // Not a candidate if the node is not tabbable. 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 c56549acf54ad5..1ef4cbbd33bc1e 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,6 +10,7 @@ import { useRefEffect } from '@wordpress/compose'; * Internal dependencies */ import { store as blockEditorStore } from '../../store'; +import { isInsideRootBlock, getBlockClientId } from '../../utils/dom'; export default function useSelectAll() { const { getBlockOrder, getSelectedBlockClientIds, getBlockRootClientId } = @@ -34,7 +35,27 @@ export default function useSelectAll() { event.preventDefault(); + const { ownerDocument } = event.target; const [ firstSelectedClientId ] = selectedClientIds; + const activeClientId = getBlockClientId( + ownerDocument.activeElement + ); + + // Handle the case when an appender is selected. + if ( + activeClientId && + activeClientId !== firstSelectedClientId && + ! isInsideRootBlock( + ownerDocument.getElementById( + 'block-' + firstSelectedClientId + ), + ownerDocument.activeElement + ) + ) { + selectBlock( activeClientId ); + return; + } + const rootClientId = getBlockRootClientId( firstSelectedClientId ); const blockClientIds = getBlockOrder( rootClientId ); diff --git a/test/e2e/specs/editor/blocks/columns.spec.js b/test/e2e/specs/editor/blocks/columns.spec.js index 56552d63032df5..3ad5ff48885b85 100644 --- a/test/e2e/specs/editor/blocks/columns.spec.js +++ b/test/e2e/specs/editor/blocks/columns.spec.js @@ -379,7 +379,11 @@ test.describe( 'Columns', () => { } ); } ); - test( 'should arrow up into empty columns', async ( { editor, page } ) => { + test( 'should arrow up into empty columns', async ( { + editor, + page, + pageUtils, + } ) => { await editor.insertBlock( { name: 'core/columns', innerBlocks: [ { name: 'core/column' }, { name: 'core/column' } ], @@ -389,7 +393,7 @@ test.describe( 'Columns', () => { } ); await page.keyboard.press( 'ArrowUp' ); - await page.keyboard.press( 'ArrowUp' ); + await pageUtils.pressKeys( 'primary+a' ); await page.keyboard.press( 'Delete' ); await expect.poll( editor.getBlocks ).toMatchObject( [ diff --git a/test/e2e/specs/editor/blocks/list.spec.js b/test/e2e/specs/editor/blocks/list.spec.js index e068d360311d7c..262714194f5160 100644 --- a/test/e2e/specs/editor/blocks/list.spec.js +++ b/test/e2e/specs/editor/blocks/list.spec.js @@ -233,9 +233,8 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await editor.canvas - .locator( 'role=button[name="Add default block"i]' ) - .click(); + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.press( 'Enter' ); await page.keyboard.type( '* ' ); await expect( editor.canvas.locator( '[data-type="core/list"]' ) @@ -408,7 +407,9 @@ test.describe( 'List (@firefox)', () => { await page.keyboard.press( 'Enter' ); await editor.clickBlockToolbarButton( 'Indent' ); await page.keyboard.type( 'two' ); - await pageUtils.pressKeys( 'ArrowUp', { times: 4 } ); + // Select all escalates: text → block → siblings → parent, repeat + // until the top-level list block is selected. + await pageUtils.pressKeys( 'primary+a', { times: 5 } ); await editor.transformBlockTo( 'core/paragraph' ); await expect.poll( editor.getEditedPostContent ).toBe( @@ -593,7 +594,6 @@ test.describe( 'List (@firefox)', () => { // Move the caret in front of "1" in the first list item. await page.keyboard.press( 'ArrowRight' ); - await page.keyboard.press( 'ArrowRight' ); await page.keyboard.press( 'Backspace' ); await expect.poll( editor.getBlocks ).toMatchObject( [ @@ -866,11 +866,7 @@ test.describe( 'List (@firefox)', () => { ); } ); - test( 'should outdent with children', async ( { - editor, - page, - pageUtils, - } ) => { + test( 'should outdent with children', async ( { editor, page } ) => { await editor.insertBlock( { name: 'core/list' } ); await page.keyboard.type( 'a' ); await page.keyboard.press( 'Enter' ); @@ -896,7 +892,7 @@ test.describe( 'List (@firefox)', () => { ` ); - await pageUtils.pressKeys( 'ArrowUp', { times: 3 } ); + await page.keyboard.press( 'ArrowUp' ); await editor.clickBlockToolbarButton( 'Outdent' ); await expect.poll( editor.getEditedPostContent ).toBe( @@ -1083,7 +1079,6 @@ test.describe( 'List (@firefox)', () => { test( 'should place the caret in the right place with nested list', async ( { editor, page, - pageUtils, } ) => { await editor.canvas .locator( 'role=button[name="Add default block"i]' ) @@ -1091,7 +1086,7 @@ test.describe( 'List (@firefox)', () => { await page.keyboard.type( '* 1' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( ' a' ); - await pageUtils.pressKeys( 'ArrowUp', { times: 2 } ); + await page.keyboard.press( 'ArrowUp' ); await page.keyboard.press( 'Enter' ); // The caret should land in the second item. await page.keyboard.type( '2' ); @@ -1517,10 +1512,7 @@ test.describe( 'List (@firefox)', () => { test( 'Backspace', async ( { editor, page } ) => { await editor.insertBlock( start ); - // Navigate to the start of the third item. - await page.keyboard.press( 'ArrowDown' ); - await page.keyboard.press( 'ArrowDown' ); - await page.keyboard.press( 'ArrowDown' ); + // Navigate to the start of item "2". await page.keyboard.press( 'ArrowDown' ); await page.keyboard.press( 'ArrowDown' ); await page.keyboard.press( 'ArrowDown' ); @@ -1536,9 +1528,6 @@ test.describe( 'List (@firefox)', () => { test( 'Delete (forward)', async ( { editor, page } ) => { await editor.insertBlock( start ); - // Navigate to the end of the second item. - await page.keyboard.press( 'ArrowDown' ); - await page.keyboard.press( 'ArrowDown' ); await page.keyboard.press( 'ArrowDown' ); await page.keyboard.press( 'ArrowDown' ); await page.keyboard.press( 'ArrowRight' ); @@ -1582,7 +1571,6 @@ test.describe( 'List (@firefox)', () => { ], } ); - await page.keyboard.press( 'ArrowDown' ); await page.keyboard.press( 'ArrowDown' ); await page.keyboard.press( 'ArrowDown' ); await page.keyboard.press( 'Backspace' ); @@ -1641,7 +1629,6 @@ test.describe( 'List (@firefox)', () => { ], } ); - await page.keyboard.press( 'ArrowDown' ); await page.keyboard.press( 'ArrowDown' ); await page.keyboard.press( 'Backspace' ); @@ -1685,7 +1672,6 @@ test.describe( 'List (@firefox)', () => { ], } ); - await page.keyboard.press( 'ArrowDown' ); await page.keyboard.press( 'ArrowDown' ); await page.keyboard.press( 'Enter' ); await page.keyboard.press( 'Backspace' ); diff --git a/test/e2e/specs/editor/blocks/navigation.spec.js b/test/e2e/specs/editor/blocks/navigation.spec.js index aebe714712d4e1..59687387b3aaa5 100644 --- a/test/e2e/specs/editor/blocks/navigation.spec.js +++ b/test/e2e/specs/editor/blocks/navigation.spec.js @@ -426,8 +426,9 @@ test.describe( 'Navigation block', () => { await navigation.useLinkShortcut(); await navigation.addSubmenuPage( 'Dog' ); - await pageUtils.pressKeys( 'ArrowUp' ); - await pageUtils.pressKeys( 'ArrowRight' ); + // Navigate to the Dog link content using ArrowLeft (ArrowUp skips + // blocks with nested focusables). + await pageUtils.pressKeys( 'ArrowLeft', { times: 2 } ); } ); await test.step( 'can use the shortcut to open the preview with the keyboard and escape keypress sends focus back to the navigation link block in the submenu', async () => { @@ -512,8 +513,9 @@ test.describe( 'Navigation block', () => { */ // Exit the toolbar await page.keyboard.press( 'Escape' ); - // Move to the submenu item - await pageUtils.pressKeys( 'ArrowUp', { times: 2 } ); + // Move to the submenu item (only one ArrowUp needed - skips the + // submenu wrapper directly to Cat's content) + await page.keyboard.press( 'ArrowUp' ); await page.keyboard.press( 'Home' ); // Check we're on our submenu link @@ -575,9 +577,9 @@ test.describe( 'Navigation block', () => { await navigation.addCustomURL( 'https://wordpress.org' ); await navigation.expectToHaveTextSelected( 'wordpress.org' ); - await pageUtils.pressKeys( 'ArrowUp', { times: 2 } ); + // One ArrowUp to get to Dog (skips wrapper) + await page.keyboard.press( 'ArrowUp' ); await navigation.checkLabelFocus( 'Dog' ); - await pageUtils.pressKeys( 'ArrowUp', { times: 1 } ); await pageUtils.pressKeys( 'access+z' ); await pageUtils.pressKeys( 'ArrowDown' ); await navigation.checkLabelFocus( 'example.com' ); diff --git a/test/e2e/specs/editor/blocks/quote.spec.js b/test/e2e/specs/editor/blocks/quote.spec.js index 497fc86ed94fe5..156572f479bd8e 100644 --- a/test/e2e/specs/editor/blocks/quote.spec.js +++ b/test/e2e/specs/editor/blocks/quote.spec.js @@ -196,9 +196,10 @@ test.describe( 'Quote', () => { test( 'and renders only one paragraph for the cite, if the quote is void', async ( { editor, page, + pageUtils, } ) => { await editor.insertBlock( { name: 'core/quote' } ); - await page.keyboard.press( 'ArrowUp' ); + await pageUtils.pressKeys( 'primary+a' ); await editor.clickBlockToolbarButton( 'Add citation' ); await page.keyboard.type( 'cite' ); await editor.clickBlockOptionsMenuItem( 'Ungroup' ); @@ -215,11 +216,11 @@ test.describe( 'Quote', () => { test( 'and renders a void paragraph if both the cite and quote are void', async ( { editor, - page, + pageUtils, } ) => { await editor.insertBlock( { name: 'core/quote' } ); // Select the quote - await page.keyboard.press( 'ArrowUp' ); + await pageUtils.pressKeys( 'primary+a' ); await editor.clickBlockOptionsMenuItem( 'Ungroup' ); expect( await editor.getEditedPostContent() ).toBe( '' ); } ); @@ -334,7 +335,7 @@ test.describe( 'Quote', () => { } ) => { await editor.insertBlock( { name: 'core/quote' } ); await page.keyboard.type( '1' ); - await page.keyboard.press( 'ArrowUp' ); + await pageUtils.pressKeys( 'primary+a', { times: 2 } ); await editor.clickBlockToolbarButton( 'Add citation' ); await page.keyboard.type( '2' ); expect( await editor.getEditedPostContent() ).toBe( @@ -365,7 +366,7 @@ test.describe( 'Quote', () => { } ) => { await editor.insertBlock( { name: 'core/quote' } ); await page.keyboard.type( '1' ); - await page.keyboard.press( 'ArrowUp' ); + await pageUtils.pressKeys( 'primary+a', { times: 2 } ); await editor.clickBlockToolbarButton( 'Add citation' ); await page.keyboard.type( '2' ); await pageUtils.pressKeys( 'Shift+ArrowUp' ); diff --git a/test/e2e/specs/editor/plugins/block-variations.spec.js b/test/e2e/specs/editor/plugins/block-variations.spec.js index 4c52595c4b4097..7bcbdb976bc3a8 100644 --- a/test/e2e/specs/editor/plugins/block-variations.spec.js +++ b/test/e2e/specs/editor/plugins/block-variations.spec.js @@ -137,7 +137,7 @@ test.describe( 'Block variations', () => { await page.getByRole( 'option', { name: 'Large Quote' } ).click(); // Select the quote block. - await page.keyboard.press( 'ArrowUp' ); + await pageUtils.pressKeys( 'primary+a' ); await expect( page diff --git a/test/e2e/specs/editor/plugins/inner-blocks-allowed-blocks.spec.js b/test/e2e/specs/editor/plugins/inner-blocks-allowed-blocks.spec.js index 0736e800f3f681..dc3e9f824eed9c 100644 --- a/test/e2e/specs/editor/plugins/inner-blocks-allowed-blocks.spec.js +++ b/test/e2e/specs/editor/plugins/inner-blocks-allowed-blocks.spec.js @@ -114,6 +114,7 @@ test.describe( 'Allowed Blocks Setting on InnerBlocks', () => { test( 'correctly applies dynamic allowed blocks restrictions', async ( { editor, page, + pageUtils, } ) => { await editor.canvas .locator( 'role=button[name="Add default block"i]' ) @@ -139,8 +140,8 @@ test.describe( 'Allowed Blocks Setting on InnerBlocks', () => { // Insert list block. await blockListBox.getByRole( 'option', { name: 'List' } ).click(); - // Select the list wrapper and then parent block. - await page.keyboard.press( 'ArrowUp' ); + // Use primary+a to select up to list, then click parent selector. + await pageUtils.pressKeys( 'primary+a' ); await editor.clickBlockToolbarButton( 'Select parent block: Allowed Blocks Dynamic' ); diff --git a/test/e2e/specs/editor/various/block-switcher.spec.js b/test/e2e/specs/editor/various/block-switcher.spec.js index 490ca7483d17fe..fee3c5f0a3352a 100644 --- a/test/e2e/specs/editor/various/block-switcher.spec.js +++ b/test/e2e/specs/editor/various/block-switcher.spec.js @@ -17,7 +17,7 @@ test.describe( 'Block Switcher', () => { .getByRole( 'button', { name: 'Add default block' } ) .click(); await page.keyboard.type( '- List content' ); - await page.keyboard.press( 'ArrowUp' ); + await pageUtils.pressKeys( 'primary+a', { times: 2 } ); await pageUtils.pressKeys( 'alt+F10' ); const blockSwitcher = page @@ -64,7 +64,7 @@ test.describe( 'Block Switcher', () => { .getByRole( 'button', { name: 'Add default block' } ) .click(); await page.keyboard.type( '- List content' ); - await page.keyboard.press( 'ArrowUp' ); + await pageUtils.pressKeys( 'primary+a', { times: 2 } ); await pageUtils.pressKeys( 'alt+F10' ); const blockSwitcher = page diff --git a/test/e2e/specs/editor/various/splitting-merging.spec.js b/test/e2e/specs/editor/various/splitting-merging.spec.js index b0080faf4761e0..9e99b602cbcfbe 100644 --- a/test/e2e/specs/editor/various/splitting-merging.spec.js +++ b/test/e2e/specs/editor/various/splitting-merging.spec.js @@ -545,7 +545,7 @@ test.describe( 'splitting and merging blocks (@firefox, @webkit)', () => { await page.keyboard.type( 'item 1' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'item 2' ); - await pageUtils.pressKeys( 'ArrowUp', { times: 3 } ); + await pageUtils.pressKeys( 'ArrowUp', { times: 2 } ); await page.keyboard.press( 'Delete' ); expect( await editor.getBlocks() ).toMatchObject( snap1 ); diff --git a/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js b/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js index 237dd294508b0f..12f2e488c3be18 100644 --- a/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js +++ b/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js @@ -64,7 +64,8 @@ test.describe( 'Toolbar roving tabindex', () => { await ToolbarRovingTabindexUtils.wrapCurrentBlockWithGroup( 'List' ); await ToolbarRovingTabindexUtils.testGroupKeyboardNavigation( 'Block: List', - 'List' + 'List', + { hasInnerBlocks: true } ); // ensures table block toolbar uses roving tabindex @@ -88,7 +89,9 @@ test.describe( 'Toolbar roving tabindex', () => { ); await ToolbarRovingTabindexUtils.wrapCurrentBlockWithGroup( 'Table' ); await ToolbarRovingTabindexUtils.testGroupKeyboardNavigation( - 'Block: Table', + // ArrowRight from Group enters the table cell directly, + // not the Table block wrapper. + 'Body cell text', 'Table' ); @@ -181,9 +184,19 @@ class ToolbarRovingTabindexUtils { await this.page.click( `role=menuitem[name="Group"]` ); } - async testGroupKeyboardNavigation( currentBlockLabel, currentBlockTitle ) { + async testGroupKeyboardNavigation( + currentBlockLabel, + currentBlockTitle, + { hasInnerBlocks = false } = {} + ) { await this.expectLabelToHaveFocus( 'Block: Group' ); await this.page.keyboard.press( 'ArrowRight' ); + if ( hasInnerBlocks ) { + // ArrowRight enters a nested inner block (e.g. list-item + // inside list). Use primary+a to escalate selection back + // to the expected parent block. + await this.pageUtils.pressKeys( 'primary+a', { times: 2 } ); + } await this.expectLabelToHaveFocus( currentBlockLabel ); await this.pageUtils.pressKeys( 'shift+Tab' ); await this.expectLabelToHaveFocus( 'Select parent block: Group' ); diff --git a/test/e2e/specs/editor/various/writing-flow.spec.js b/test/e2e/specs/editor/various/writing-flow.spec.js index ccf6d708944731..180ca053e000cf 100644 --- a/test/e2e/specs/editor/various/writing-flow.spec.js +++ b/test/e2e/specs/editor/various/writing-flow.spec.js @@ -42,25 +42,10 @@ test.describe( 'Writing Flow (@firefox, @webkit)', () => { await expect( activeElementLocator ).toBeFocused(); await expect( activeElementLocator ).toHaveText( '2nd col' ); - // Arrow up in inner blocks should navigate through (1) column wrapper, - // (2) text fields. - await page.keyboard.press( 'ArrowUp' ); - await expect - .poll( writingFlowUtils.getActiveBlockName ) - .toBe( 'core/column' ); - await page.keyboard.press( 'ArrowUp' ); - const activeElementBlockType = await editor.canvas - .locator( ':root' ) - .evaluate( () => - document.activeElement.getAttribute( 'data-type' ) - ); - expect( activeElementBlockType ).toBe( 'core/columns' ); - await expect - .poll( writingFlowUtils.getActiveBlockName ) - .toBe( 'core/columns' ); - - // Arrow up from focused (columns) block wrapper exits nested context - // to prior text input. + // Arrow up skips non-empty blocks and column/columns wrappers, + // navigating directly to the prior text input. Since columns + // are side by side, "1st col" and "2nd col" are on the same + // visual line, so ArrowUp goes to "First paragraph". await page.keyboard.press( 'ArrowUp' ); await expect .poll( writingFlowUtils.getActiveBlockName ) @@ -106,6 +91,35 @@ test.describe( 'Writing Flow (@firefox, @webkit)', () => { ] ); } ); + test( 'should not select list wrapper when pressing arrow up from list', async ( { + editor, + page, + writingFlowUtils, + } ) => { + // Insert a paragraph block first. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'First paragraph' }, + } ); + + // Insert a list block. + await editor.insertBlock( { name: 'core/list' } ); + await page.keyboard.type( 'List item' ); + + // The caret is now inside the list item. + // Press ArrowUp - should skip the list wrapper and go to the paragraph. + await page.keyboard.press( 'ArrowUp' ); + + // Verify we're in the paragraph, NOT the list wrapper. + await expect + .poll( writingFlowUtils.getActiveBlockName ) + .toBe( 'core/paragraph' ); + + // Verify the focused element has the paragraph content. + const activeElementLocator = editor.canvas.locator( ':focus' ); + await expect( activeElementLocator ).toHaveText( 'First paragraph' ); + } ); + test( 'should navigate around inline boundaries', async ( { editor, page,