diff --git a/packages/block-editor/src/components/block-inspector/edit-contents.js b/packages/block-editor/src/components/block-inspector/edit-contents.js index d70fed67515054..405a0856ac6fdc 100644 --- a/packages/block-editor/src/components/block-inspector/edit-contents.js +++ b/packages/block-editor/src/components/block-inspector/edit-contents.js @@ -3,7 +3,7 @@ */ import { Button, __experimentalVStack as VStack } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { useSelect } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; import { isReusableBlock, isTemplatePart } from '@wordpress/blocks'; /** @@ -59,11 +59,15 @@ function InlineEditButton( { editContentOnlySection, stopEditingContentOnlySection, } ) { + const { selectBlock } = useDispatch( blockEditorStore ); const handleClick = () => { if ( ! editedContentOnlySection ) { editContentOnlySection( clientId ); + selectBlock( clientId ); } else { stopEditingContentOnlySection(); + // Keep the selected section pattern or content block selected after exiting. + selectBlock( clientId ); } }; 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 919e54badeba4d..a6641d93acdb8d 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 @@ -46,6 +46,7 @@ function ListViewBlockSelectButton( isExpanded, ariaDescribedBy, visibilityLabel, + isDisabled = false, }, ref ) { @@ -79,6 +80,8 @@ function ListViewBlockSelectButton( } return ( + // Disabled list view items intentionally omit href so TreeGrid skips them. + // eslint-disable-next-line jsx-a11y/anchor-is-valid diff --git a/packages/block-editor/src/components/list-view/block.js b/packages/block-editor/src/components/list-view/block.js index 64c9efe9df9c15..79c7d7b614b4ca 100644 --- a/packages/block-editor/src/components/list-view/block.js +++ b/packages/block-editor/src/components/list-view/block.js @@ -98,6 +98,7 @@ function ListViewBlock( { insertAfterBlock, insertBeforeBlock, showViewportModal, + stopEditingContentOnlySection, } = unlock( useDispatch( blockEditorStore ) ); const debouncedToggleBlockHighlight = useDebounce( @@ -125,21 +126,36 @@ function ListViewBlock( { const pasteStyles = usePasteStyles(); - const { block, blockName, allowRightClickOverrides } = useSelect( + const { + block, + blockName, + blockEditingMode, + allowRightClickOverrides, + editedSection, + } = useSelect( ( select ) => { - const { getBlock, getBlockName, getSettings } = unlock( - select( blockEditorStore ) - ); + const { + getBlock, + getBlockName, + getBlockEditingMode: getBlockEditingModeForClientId, + getSettings, + getEditedContentOnlySection, + } = unlock( select( blockEditorStore ) ); return { block: getBlock( clientId ), blockName: getBlockName( clientId ), + blockEditingMode: getBlockEditingModeForClientId( clientId ), allowRightClickOverrides: getSettings().allowRightClickOverrides, + editedSection: getEditedContentOnlySection(), }; }, [ clientId ] ); + + const isDisabled = blockEditingMode === 'disabled'; + const { canRename } = useBlockRename( blockName ); const showBlockActions = @@ -202,6 +218,13 @@ function ListViewBlock( { return; } + if ( editedSection && isMatch( 'core/block-editor/unselect', event ) ) { + event.stopPropagation(); + event.preventDefault(); + stopEditingContentOnlySection(); + return; + } + const isDeleteKey = [ BACKSPACE, DELETE ].includes( event.keyCode ); // If multiple blocks are selected, deselect all blocks when the user @@ -539,7 +562,8 @@ function ListViewBlock( { ); const hasSiblings = siblingBlockCount > 0; - const hasRenderedMovers = showBlockMovers && hasSiblings; + const canShowBlockActions = showBlockActions && ! isDisabled; + const hasRenderedMovers = showBlockMovers && hasSiblings && ! isDisabled; const moverCellClassName = clsx( 'block-editor-list-view-block__mover-cell', { 'is-visible': isHovered || isSelected } @@ -553,7 +577,7 @@ function ListViewBlock( { let colSpan; if ( hasRenderedMovers ) { colSpan = 2; - } else if ( ! showBlockActions ) { + } else if ( ! canShowBlockActions ) { colSpan = 3; } @@ -566,12 +590,13 @@ function ListViewBlock( { 'is-dragging': isDragged, 'has-single-cell': ! showBlockActions, 'is-synced': blockInformation?.isSynced, - 'is-draggable': canMoveBlock, + 'is-draggable': canMoveBlock && ! isDisabled, 'is-displacement-normal': displacement === 'normal', 'is-displacement-up': displacement === 'up', 'is-displacement-down': displacement === 'down', 'is-after-dragged-blocks': isAfterDraggedBlocks, 'is-nesting': isNesting, + 'is-disabled': isDisabled, } ); // Only include all selected blocks if the currently clicked on block @@ -582,19 +607,28 @@ function ListViewBlock( { ? selectedClientIds : [ clientId ]; - // Detect if there is a block in the canvas currently being edited and multi-selection is not happening. - const currentlyEditingBlockInCanvas = - isSelected && selectedClientIds.length === 1; + const getListViewBlockTabIndex = ( rovingTabIndex ) => { + if ( isDisabled ) { + return -1; + } + + // Detect if there is a block in the canvas currently being edited and multi-selection is not happening. + if ( isSelected && selectedClientIds.length === 1 ) { + return 0; + } + + return rovingTabIndex; + }; return ( { [ @@ -676,7 +713,7 @@ function ListViewBlock( { ) } - { showBlockActions && BlockSettingsMenu && ( + { canShowBlockActions && BlockSettingsMenu && ( onClick( event, { forceToggle: true } ) } + onClick={ + onClick + ? ( event ) => onClick( event, { forceToggle: true } ) + : undefined + } aria-hidden="true" data-testid="list-view-expander" > diff --git a/packages/block-editor/src/components/list-view/style.scss b/packages/block-editor/src/components/list-view/style.scss index 66a8ccd8577ce0..0f46d7d187a37b 100644 --- a/packages/block-editor/src/components/list-view/style.scss +++ b/packages/block-editor/src/components/list-view/style.scss @@ -447,6 +447,25 @@ box-shadow: 0 0 0 $radius-small var(--wp-admin-theme-color); } } + + &.is-disabled { + opacity: 0.2; + @media not ( prefers-reduced-motion ) { + transition: opacity 0.1s linear; + } + + &:hover { + opacity: 0.2; + } + + .block-editor-list-view__expander { + cursor: default; + } + + .block-editor-list-view-block-select-button:hover { + color: inherit; + } + } } // First level of indentation is aria-level 2, max indent is 8. diff --git a/packages/block-editor/src/components/list-view/use-list-view-client-ids.js b/packages/block-editor/src/components/list-view/use-list-view-client-ids.js index 8a1ccfcede4c12..d51412fdf2c3db 100644 --- a/packages/block-editor/src/components/list-view/use-list-view-client-ids.js +++ b/packages/block-editor/src/components/list-view/use-list-view-client-ids.js @@ -16,14 +16,14 @@ export default function useListViewClientIds( { blocks, rootClientId } ) { const { getDraggedBlockClientIds, getSelectedBlockClientIds, - getEnabledClientIdsTree, + getListViewClientIdsTree, } = unlock( select( blockEditorStore ) ); return { selectedClientIds: getSelectedBlockClientIds(), draggedClientIds: getDraggedBlockClientIds(), clientIdsTree: - blocks ?? getEnabledClientIdsTree( rootClientId ), + blocks ?? getListViewClientIdsTree( rootClientId ), }; }, [ blocks, rootClientId ] diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index 3cb08ed35775fe..e3e9c2f23a710b 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -159,16 +159,31 @@ export const getClientIdsTree = createSelector( ( state ) => [ state.blocks.order ] ); -function getEnabledClientIdsTreeUnmemoized( state, rootClientId ) { +/** + * Returns a tree of block objects filtered by a block inclusion callback. + * Excluded blocks are replaced by any included descendants. + * + * @param {Object} state Global application state. + * @param {?string} rootClientId Optional root client ID of block list. + * @param {Function} includesBlock Callback that returns whether to include a block. + * + * @return {Object[]} Tree of block objects with only clientID and innerBlocks set. + */ +function getFilteredClientIdsTreeUnmemoized( + state, + rootClientId, + includesBlock +) { const blockOrder = getBlockOrder( state, rootClientId ); const result = []; for ( const clientId of blockOrder ) { - const innerBlocks = getEnabledClientIdsTreeUnmemoized( + const innerBlocks = getFilteredClientIdsTreeUnmemoized( state, - clientId + clientId, + includesBlock ); - if ( getBlockEditingMode( state, clientId ) !== 'disabled' ) { + if ( includesBlock( state, clientId ) ) { result.push( { clientId, innerBlocks } ); } else { result.push( ...innerBlocks ); @@ -178,6 +193,100 @@ function getEnabledClientIdsTreeUnmemoized( state, rootClientId ) { return result; } +function getEnabledClientIdsTreeUnmemoized( state, rootClientId ) { + return getFilteredClientIdsTreeUnmemoized( + state, + rootClientId, + ( _state, clientId ) => + getBlockEditingMode( _state, clientId ) !== 'disabled' + ); +} + +/** + * Returns whether the nearest explicit block editing mode in the block's + * ancestry is disabled. + * + * @param {Object} state Global application state. + * @param {string} clientId The block client ID. + * + * @return {boolean} Whether an explicit parent block editing mode disables this + * block. + */ +function hasExplicitDisabledParent( state, clientId ) { + let parent = state.blocks.parents.get( clientId ); + + while ( parent !== undefined ) { + const parentBlockEditingMode = + state.blocks.blockEditingModes.get( parent ); + + if ( parentBlockEditingMode ) { + return parentBlockEditingMode === 'disabled'; + } + + parent = state.blocks.parents.get( parent ); + } + + return false; +} + +/** + * Returns the block tree displayed by List View. + * + * @param {Object} state Global application state. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {Object[]} Tree of block objects with only clientID and innerBlocks set. + */ +function getListViewClientIdsTreeUnmemoized( state, rootClientId ) { + return getFilteredClientIdsTreeUnmemoized( + state, + rootClientId, + ( _state, clientId ) => { + // Non-disabled blocks are always shown in List view. + if ( getBlockEditingMode( _state, clientId ) !== 'disabled' ) { + return true; + } + + const explicitBlockEditingMode = + _state.blocks.blockEditingModes.get( clientId ); + if ( explicitBlockEditingMode ) { + return explicitBlockEditingMode !== 'disabled'; + } + + if ( hasExplicitDisabledParent( _state, clientId ) ) { + return false; + } + + // When a contentOnly section is being edited, there's some special handling. + if ( _state.editedContentOnlySection ) { + // Blocks within the edited content only section generally have their block + // editing mode flipped from disabled to default for editing, any disabled + // blocks can still be excluded. + if ( isWithinEditedContentOnlySection( _state, clientId ) ) { + return false; + } + + // Blocks that are not in another section but are disabled are shown. + // These are blocks that would usually be visible. + const parentSectionBlock = getParentSectionBlock( + _state, + clientId + ); + if ( ! parentSectionBlock ) { + return true; + } + + // If a block is in another section, then it is only visible if its a content block. + if ( isContentBlock( getBlockName( _state, clientId ) ) ) { + return true; + } + } + + return false; + } + ); +} + /** * Returns a tree of block objects with only clientID and innerBlocks set. * Blocks with a 'disabled' editing mode are not included. @@ -195,6 +304,34 @@ export const getEnabledClientIdsTree = createRegistrySelector( () => ] ) ); +/** + * Returns the block tree displayed by List View. + * + * Blocks with a 'disabled' editing mode are usually not included. When a + * content-only section is being edited, List View keeps visible outside-section + * context blocks in the tree so they can be faded. Blocks that were already + * hidden because they are non-content blocks inside another content-only section + * remain hidden. + * + * @param {Object} state Global application state. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {Object[]} Tree of block objects with only clientID and innerBlocks set. + */ +export const getListViewClientIdsTree = createRegistrySelector( () => + createSelector( getListViewClientIdsTreeUnmemoized, ( state ) => [ + state.blocks.order, + state.derivedBlockEditingModes, + state.blocks.blockEditingModes, + state.blocks.parents, + state.blocks.byClientId, + state.blocks.attributes, + state.blockListSettings, + state.editedContentOnlySection, + state.settings, + ] ) +); + /** * Returns a list of a given block's ancestors, from top to bottom. Blocks with * a 'disabled' editing mode are excluded. diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js index 0e987318848300..ce6b6bb84479b2 100644 --- a/packages/block-editor/src/store/test/private-selectors.js +++ b/packages/block-editor/src/store/test/private-selectors.js @@ -11,6 +11,7 @@ import { getLastInsertedBlocksClientIds, isBlockSubtreeDisabled, getEnabledClientIdsTree, + getListViewClientIdsTree, getEnabledBlockParents, getExpandedBlock, isDragging, @@ -757,6 +758,427 @@ describe( 'private selectors', () => { }, ] ); } ); + + it( 'filters out disabled blocks when a content-only section is being edited', () => { + const state = { + ...baseState, + blocks: { + ...baseState.blocks, + blockEditingModes: new Map( [ + [ '', 'disabled' ], + [ + 'b26fc763-417d-4f01-b81c-2ec61e14a972', + 'contentOnly', + ], + [ + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + 'contentOnly', + ], + ] ), + }, + // The Group (ef45d5fd) is the section being edited. + editedContentOnlySection: + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + derivedBlockEditingModes: new Map( [ + [ '6cf70164-9097-4460-bcbf-200560546988', 'disabled' ], + ] ), + }; + expect( getEnabledClientIdsTree( state ) ).toEqual( [ + { + clientId: 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + innerBlocks: [ + { + clientId: 'b26fc763-417d-4f01-b81c-2ec61e14a972', + innerBlocks: [], + }, + { + clientId: '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + innerBlocks: [ + { + clientId: + 'b3247f75-fd94-4fef-97f9-5bfd162cc416', + innerBlocks: [], + }, + { + clientId: + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + innerBlocks: [], + }, + ], + }, + ], + }, + ] ); + } ); + } ); + + describe( 'getListViewClientIdsTree', () => { + const TEST_CONTENT_BLOCK = 'core/test-list-view-content-block'; + const TEST_STRUCTURE_BLOCK = 'core/test-list-view-structure-block'; + + const createBaseState = () => ( { + settings: {}, + blocks: { + byClientId: new Map( [ + [ + 'header', + { + name: TEST_STRUCTURE_BLOCK, + }, + ], + [ + 'edited-pattern', + { + name: TEST_STRUCTURE_BLOCK, + }, + ], + [ + 'edited-content', + { + name: TEST_CONTENT_BLOCK, + }, + ], + [ + 'other-pattern', + { + name: TEST_STRUCTURE_BLOCK, + }, + ], + [ + 'other-content', + { + name: TEST_CONTENT_BLOCK, + }, + ], + [ + 'other-structure', + { + name: TEST_STRUCTURE_BLOCK, + }, + ], + ] ), + attributes: new Map( [ + [ + 'edited-pattern', + { + metadata: { + patternName: 'test/edited-pattern', + }, + }, + ], + [ + 'other-pattern', + { + metadata: { + patternName: 'test/other-pattern', + }, + }, + ], + ] ), + order: new Map( [ + [ '', [ 'header', 'edited-pattern', 'other-pattern' ] ], + [ 'header', [] ], + [ 'edited-pattern', [ 'edited-content' ] ], + [ 'edited-content', [] ], + [ 'other-pattern', [ 'other-content', 'other-structure' ] ], + [ 'other-content', [] ], + [ 'other-structure', [] ], + ] ), + parents: new Map( [ + [ 'header', '' ], + [ 'edited-pattern', '' ], + [ 'edited-content', 'edited-pattern' ], + [ 'other-pattern', '' ], + [ 'other-content', 'other-pattern' ], + [ 'other-structure', 'other-pattern' ], + ] ), + blockEditingModes: new Map(), + }, + blockListSettings: new Map(), + editedContentOnlySection: 'edited-pattern', + derivedBlockEditingModes: new Map( [ + [ 'header', 'disabled' ], + [ 'other-pattern', 'disabled' ], + [ 'other-content', 'disabled' ], + [ 'other-structure', 'disabled' ], + ] ), + } ); + + beforeAll( () => { + registerBlockType( TEST_CONTENT_BLOCK, { + apiVersion: 3, + title: 'Test content block', + category: 'text', + attributes: { + content: { + type: 'string', + role: 'content', + }, + }, + save: () => null, + } ); + registerBlockType( TEST_STRUCTURE_BLOCK, { + apiVersion: 3, + title: 'Test structure block', + category: 'design', + save: () => null, + } ); + } ); + + afterAll( () => { + unregisterBlockType( TEST_CONTENT_BLOCK ); + unregisterBlockType( TEST_STRUCTURE_BLOCK ); + } ); + + beforeEach( () => { + getListViewClientIdsTree.registry = { + select: jest.fn( () => ( {} ) ), + }; + } ); + + it( 'includes disabled outside-section context while preserving the edited section', () => { + const state = createBaseState(); + + expect( getListViewClientIdsTree( state ) ).toEqual( [ + { + clientId: 'header', + innerBlocks: [], + }, + { + clientId: 'edited-pattern', + innerBlocks: [ + { + clientId: 'edited-content', + innerBlocks: [], + }, + ], + }, + { + clientId: 'other-pattern', + innerBlocks: [ + { + clientId: 'other-content', + innerBlocks: [], + }, + ], + }, + ] ); + } ); + + it( 'keeps descendants of explicitly disabled blocks hidden', () => { + const baseState = createBaseState(); + const state = { + ...baseState, + blocks: { + ...baseState.blocks, + byClientId: new Map( [ + ...baseState.blocks.byClientId, + [ + 'header-content', + { + name: TEST_CONTENT_BLOCK, + }, + ], + ] ), + order: new Map( [ + ...baseState.blocks.order, + [ 'header', [ 'header-content' ] ], + [ 'header-content', [] ], + ] ), + parents: new Map( [ + ...baseState.blocks.parents, + [ 'header-content', 'header' ], + ] ), + blockEditingModes: new Map( [ [ 'header', 'disabled' ] ] ), + }, + derivedBlockEditingModes: new Map( [ + ...baseState.derivedBlockEditingModes, + [ 'header-content', 'disabled' ], + ] ), + }; + + expect( getListViewClientIdsTree( state ) ).toEqual( [ + { + clientId: 'edited-pattern', + innerBlocks: [ + { + clientId: 'edited-content', + innerBlocks: [], + }, + ], + }, + { + clientId: 'other-pattern', + innerBlocks: [ + { + clientId: 'other-content', + innerBlocks: [], + }, + ], + }, + ] ); + } ); + + it( 'includes blocks with explicit modes inside explicitly disabled parents', () => { + const baseState = createBaseState(); + const state = { + ...baseState, + blocks: { + ...baseState.blocks, + byClientId: new Map( [ + ...baseState.blocks.byClientId, + [ + 'header-content', + { + name: TEST_CONTENT_BLOCK, + }, + ], + ] ), + order: new Map( [ + ...baseState.blocks.order, + [ 'header', [ 'header-content' ] ], + [ 'header-content', [] ], + ] ), + parents: new Map( [ + ...baseState.blocks.parents, + [ 'header-content', 'header' ], + ] ), + blockEditingModes: new Map( [ + [ 'header', 'disabled' ], + [ 'header-content', 'contentOnly' ], + ] ), + }, + derivedBlockEditingModes: new Map( [ + ...baseState.derivedBlockEditingModes, + [ 'header-content', 'disabled' ], + ] ), + }; + + expect( getListViewClientIdsTree( state ) ).toEqual( [ + { + clientId: 'header-content', + innerBlocks: [], + }, + { + clientId: 'edited-pattern', + innerBlocks: [ + { + clientId: 'edited-content', + innerBlocks: [], + }, + ], + }, + { + clientId: 'other-pattern', + innerBlocks: [ + { + clientId: 'other-content', + innerBlocks: [], + }, + ], + }, + ] ); + } ); + + it( 'keeps structural blocks hidden inside other content-only sections', () => { + const state = createBaseState(); + + expect( + getListViewClientIdsTree( state )[ 2 ].innerBlocks + ).toEqual( [ + { + clientId: 'other-content', + innerBlocks: [], + }, + ] ); + } ); + + it( 'uses section-block settings when deciding which disabled blocks were already hidden', () => { + const baseState = createBaseState(); + const state = { + ...baseState, + settings: { + disableContentOnlyForTemplateParts: true, + }, + blocks: { + ...baseState.blocks, + byClientId: new Map( [ + [ + 'edited-pattern', + { + name: TEST_STRUCTURE_BLOCK, + }, + ], + [ + 'edited-content', + { + name: TEST_CONTENT_BLOCK, + }, + ], + [ + 'template-part', + { + name: 'core/template-part', + }, + ], + [ + 'template-part-structure', + { + name: TEST_STRUCTURE_BLOCK, + }, + ], + ] ), + attributes: new Map( [ + [ + 'edited-pattern', + { + metadata: { + patternName: 'test/edited-pattern', + }, + }, + ], + ] ), + order: new Map( [ + [ '', [ 'edited-pattern', 'template-part' ] ], + [ 'edited-pattern', [ 'edited-content' ] ], + [ 'edited-content', [] ], + [ 'template-part', [ 'template-part-structure' ] ], + [ 'template-part-structure', [] ], + ] ), + parents: new Map( [ + [ 'edited-pattern', '' ], + [ 'edited-content', 'edited-pattern' ], + [ 'template-part', '' ], + [ 'template-part-structure', 'template-part' ], + ] ), + }, + derivedBlockEditingModes: new Map( [ + [ 'template-part', 'disabled' ], + [ 'template-part-structure', 'disabled' ], + ] ), + }; + + expect( getListViewClientIdsTree( state ) ).toEqual( [ + { + clientId: 'edited-pattern', + innerBlocks: [ + { + clientId: 'edited-content', + innerBlocks: [], + }, + ], + }, + { + clientId: 'template-part', + innerBlocks: [ + { + clientId: 'template-part-structure', + innerBlocks: [], + }, + ], + }, + ] ); + } ); } ); describe( 'getEnabledBlockParents', () => { diff --git a/test/e2e/specs/editor/various/list-view-spotlight-mode.spec.js b/test/e2e/specs/editor/various/list-view-spotlight-mode.spec.js new file mode 100644 index 00000000000000..6c9510826e3abd --- /dev/null +++ b/test/e2e/specs/editor/various/list-view-spotlight-mode.spec.js @@ -0,0 +1,303 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'List View Spotlight Mode', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllBlocks(); + } ); + + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllBlocks(); + } ); + + /** + * Helper function to create a Group block with two paragraphs and convert it to an unsynced pattern. + * + * @param {Object} editor Editor utilities + * @param {Object} page Page object + */ + async function createPatternWithContent( editor, page ) { + // Create a Group block with two paragraphs inside + await editor.insertBlock( { name: 'core/group' } ); + await editor.canvas + .locator( + 'role=button[name="Group: Gather blocks in a container."i]' + ) + .click(); + + // Add first paragraph inside the group + await editor.canvas.locator( 'role=button[name="Add block"i]' ).click(); + await page + .getByRole( 'listbox', { name: 'Blocks' } ) + .getByRole( 'option', { name: 'Paragraph' } ) + .click(); + await page.keyboard.type( 'Pattern paragraph 1' ); + await page.keyboard.press( 'Enter' ); + + // Add second paragraph inside the group + await page.keyboard.type( 'Pattern paragraph 2' ); + + // Create an unsynced pattern from the Group block + await editor.selectBlocks( + editor.canvas.getByRole( 'document', { + name: 'Block: Group', + } ) + ); + + // Create pattern from block options + await editor.showBlockToolbar(); + await page + .getByRole( 'toolbar', { name: 'Block tools' } ) + .getByRole( 'button', { name: 'Options' } ) + .click(); + await page.getByRole( 'menuitem', { name: 'Create pattern' } ).click(); + + const createPatternDialog = page.getByRole( 'dialog', { + name: 'add pattern', + } ); + await createPatternDialog + .getByRole( 'textbox', { name: 'Name' } ) + .fill( 'Test Pattern for Spotlight' ); + await createPatternDialog + .getByRole( 'checkbox', { name: 'Synced' } ) + .setChecked( false ); // Make it unsynced + + await page.keyboard.press( 'Enter' ); + } + + /** + * Helper function to enter spotlight mode and open list view. + * + * @param {Object} editor Editor utilities + * @param {Object} page Page object + * @param {Object} pageUtils Page utilities + */ + async function enterSpotlightModeAndOpenListView( + editor, + page, + pageUtils + ) { + // Enter spotlight mode by selecting the Group block and clicking "Edit pattern" in the toolbar. + await editor.selectBlocks( + editor.canvas.getByRole( 'document', { + name: 'Block: Group', + } ) + ); + + await editor.clickBlockToolbarButton( 'Edit pattern' ); + + // Open the list view + await pageUtils.pressKeys( 'access+o' ); + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + } ); + await expect( listView ).toBeVisible(); + } + + test( 'shows disabled blocks in list view and constrains keyboard navigation', async ( { + editor, + page, + pageUtils, + } ) => { + await createPatternWithContent( editor, page ); + + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.type( 'Block beneath pattern' ); + + await enterSpotlightModeAndOpenListView( editor, page, pageUtils ); + + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + } ); + + const groupBlock = listView.getByRole( 'gridcell', { + name: 'Test Pattern for Spotlight', + exact: true, + } ); + await expect( groupBlock ).toBeVisible(); + + // The block beneath the pattern is the only disabled row (outside the edited section). + const disabledBlockRow = listView + .locator( '[role=row].is-disabled' ) + .first(); + await expect( disabledBlockRow ).toBeVisible(); + const blockBeneathPattern = disabledBlockRow.locator( + '.block-editor-list-view-block-contents' + ); + await expect( blockBeneathPattern ).toBeVisible(); + await expect( blockBeneathPattern ).toHaveAttribute( + 'aria-disabled', + 'true' + ); + + await groupBlock + .locator( '.block-editor-list-view-block-contents' ) + .focus(); + await page.keyboard.press( 'End' ); + await expect( blockBeneathPattern ).not.toBeFocused(); + await expect( + listView.locator( '[role=row].is-disabled:has(:focus)' ) + ).toHaveCount( 0 ); + + // Keyboard navigation is constrained to the pattern. + await editor.canvas + .getByRole( 'document', { + name: 'Block: Paragraph', + } ) + .first() + .click(); + + await page.keyboard.press( 'ArrowDown' ); + + const patternParagraph2 = editor.canvas + .getByRole( 'document', { + name: 'Block: Paragraph', + } ) + .filter( { hasText: 'Pattern paragraph 2' } ); + await expect( patternParagraph2 ).toBeFocused(); + + // Attempting to navigate beyond the pattern is prevented. + await page.keyboard.press( 'ArrowDown' ); + await expect( patternParagraph2 ).toBeFocused(); + + const blockBeneath = editor.canvas + .getByRole( 'document', { + name: 'Block: Paragraph', + } ) + .filter( { hasText: 'Block beneath pattern' } ); + await expect( blockBeneath ).not.toBeFocused(); + } ); + + test( 'exits spotlight mode when clicking disabled block in list view', async ( { + editor, + page, + pageUtils, + } ) => { + await createPatternWithContent( editor, page ); + + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.type( 'Block beneath pattern' ); + + await enterSpotlightModeAndOpenListView( editor, page, pageUtils ); + + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + } ); + const disabledBlockRow = listView + .locator( '[role=row].is-disabled' ) + .first(); + await expect( disabledBlockRow ).toBeVisible(); + const disabledBlockButton = disabledBlockRow.locator( + '.block-editor-list-view-block-contents' + ); + await expect( disabledBlockButton ).toHaveAttribute( + 'aria-disabled', + 'true' + ); + await disabledBlockButton.click(); + + // Spotlight exited: no rows remain disabled. + await expect( + listView.locator( '[role=row].is-disabled' ) + ).toHaveCount( 0 ); + + const blockBeneath = editor.canvas + .getByRole( 'document', { + name: 'Block: Paragraph', + } ) + .filter( { hasText: 'Block beneath pattern' } ); + await expect( blockBeneath ).toBeVisible(); + await expect( blockBeneath ).toHaveClass( /is-selected/ ); + } ); + + test( 'exits spotlight mode when pressing Escape key', async ( { + editor, + page, + pageUtils, + } ) => { + await createPatternWithContent( editor, page ); + + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.type( 'Block beneath pattern' ); + + await enterSpotlightModeAndOpenListView( editor, page, pageUtils ); + + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + } ); + const disabledBlockRow = listView + .locator( '[role=row].is-disabled' ) + .first(); + await expect( disabledBlockRow ).toBeVisible(); + await expect( + disabledBlockRow.locator( '.block-editor-list-view-block-contents' ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + + await editor.canvas + .getByRole( 'document', { + name: 'Block: Paragraph', + } ) + .first() + .click(); + + await page.keyboard.press( 'Escape' ); + + // Spotlight exited: no rows remain disabled. + await expect( + listView.locator( '[role=row].is-disabled' ) + ).toHaveCount( 0 ); + + const blockBeneath = editor.canvas + .getByRole( 'document', { + name: 'Block: Paragraph', + } ) + .filter( { hasText: 'Block beneath pattern' } ); + + await blockBeneath.click(); + await expect( blockBeneath ).toBeFocused(); + } ); + + test( 'exits spotlight mode when pressing Escape key in list view', async ( { + editor, + page, + pageUtils, + } ) => { + await createPatternWithContent( editor, page ); + + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.type( 'Block beneath pattern' ); + + await enterSpotlightModeAndOpenListView( editor, page, pageUtils ); + + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + } ); + const disabledBlockRow = listView + .locator( '[role=row].is-disabled' ) + .first(); + await expect( disabledBlockRow ).toBeVisible(); + await expect( + disabledBlockRow.locator( '.block-editor-list-view-block-contents' ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + + const groupBlock = listView.getByRole( 'gridcell', { + name: 'Test Pattern for Spotlight', + exact: true, + } ); + await groupBlock + .locator( '.block-editor-list-view-block-contents' ) + .focus(); + + await page.keyboard.press( 'Escape' ); + + await expect( + listView.locator( '[role=row].is-disabled' ) + ).toHaveCount( 0 ); + } ); +} );