From e179812635b3e61047622ac03e9e37915bacd132 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 14 Dec 2025 17:28:58 +1100 Subject: [PATCH 01/22] Add ListViewBlock with spotlight fading effect and related state management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduced state management for spotlight mode in ListViewBlock to determine when blocks should fade based on selected content. * Added CSS styles for the new fading effect in spotlight mode, improving visual consistency with the canvas editing experience. * Added ESC key handling to exit content-only pattern editing mode in useArrowNav. * Implemented boundary checks to prevent navigation and tabbing outside of edited content-only sections. non-content blocks. carrying it through all 8+ incremental branches — pure boilerplate. --- .../block-inspector/edit-contents.js | 6 +- .../use-block-props/use-focus-handler.js | 35 ++- .../src/components/list-view/block.js | 80 +++++- .../src/components/list-view/style.scss | 17 ++ .../list-view/use-block-selection.js | 21 +- .../components/writing-flow/use-arrow-nav.js | 21 +- .../components/writing-flow/use-tab-nav.js | 23 +- .../src/store/private-selectors.js | 153 ++++++++++- .../src/store/test/private-selectors.js | 63 +++++ .../various/list-view-spotlight-mode.spec.js | 253 ++++++++++++++++++ 10 files changed, 653 insertions(+), 19 deletions(-) create mode 100644 test/e2e/specs/editor/various/list-view-spotlight-mode.spec.js 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/block-list/use-block-props/use-focus-handler.js b/packages/block-editor/src/components/block-list/use-block-props/use-focus-handler.js index 02d366b7d5cd9a..97971ea3fecff4 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/use-focus-handler.js +++ b/packages/block-editor/src/components/block-list/use-block-props/use-focus-handler.js @@ -24,6 +24,26 @@ const { subscribeDelegatedListener } = unlock( composePrivateApis ); export function useFocusHandler( clientId ) { const { isBlockSelected } = useSelect( blockEditorStore ); const { selectBlock, selectionChange } = useDispatch( blockEditorStore ); + const { stopEditingContentOnlySection } = unlock( + useDispatch( blockEditorStore ) + ); + const { editedSection, isWithinEditedSection } = useSelect( + ( select ) => { + const { + getEditedContentOnlySection, + isWithinEditedContentOnlySection, + } = unlock( select( blockEditorStore ) ); + const editedContentOnlySection = getEditedContentOnlySection(); + + return { + editedSection: editedContentOnlySection, + isWithinEditedSection: editedContentOnlySection + ? isWithinEditedContentOnlySection( clientId ) + : false, + }; + }, + [ clientId ] + ); return useRefEffect( ( node ) => { @@ -60,11 +80,24 @@ export function useFocusHandler( clientId ) { return; } + // If we're in spotlight mode and clicking outside the edited section, + // exit spotlight mode instead of selecting the block. + if ( editedSection && ! isWithinEditedSection ) { + stopEditingContentOnlySection(); + return; + } + selectBlock( clientId ); } return subscribeDelegatedListener( node, 'focusin', onFocus ); }, - [ isBlockSelected, selectBlock ] + [ + isBlockSelected, + selectBlock, + editedSection, + isWithinEditedSection, + stopEditingContentOnlySection, + ] ); } diff --git a/packages/block-editor/src/components/list-view/block.js b/packages/block-editor/src/components/list-view/block.js index 64c9efe9df9c15..5fe04746a30c5c 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,46 @@ function ListViewBlock( { const pasteStyles = usePasteStyles(); - const { block, blockName, allowRightClickOverrides } = useSelect( + const { + block, + blockName, + allowRightClickOverrides, + isSpotlightActive, + editedSection, + isWithinEditedSection, + } = useSelect( ( select ) => { - const { getBlock, getBlockName, getSettings } = unlock( - select( blockEditorStore ) - ); + const { + getBlock, + getBlockName, + getSettings, + hasBlockSpotlight, + getEditedContentOnlySection, + isWithinEditedContentOnlySection, + } = unlock( select( blockEditorStore ) ); + const editedContentOnlySection = getEditedContentOnlySection(); return { block: getBlock( clientId ), blockName: getBlockName( clientId ), allowRightClickOverrides: getSettings().allowRightClickOverrides, + isSpotlightActive: hasBlockSpotlight(), + editedSection: editedContentOnlySection, + isWithinEditedSection: editedContentOnlySection + ? isWithinEditedContentOnlySection( clientId ) + : false, }; }, [ clientId ] ); + + const shouldFadeInSpotlight = editedSection + ? ! isWithinEditedSection + : isSpotlightActive && ! ( isSelected || isBranchSelected ); + const shouldDisableInteractions = + !! editedSection && ! isWithinEditedSection; + const { canRename } = useBlockRename( blockName ); const showBlockActions = @@ -405,16 +431,41 @@ function ListViewBlock( { } const onMouseEnter = useCallback( () => { + // Disable hover when section editing excludes this block. + if ( shouldDisableInteractions ) { + return; + } setIsHovered( true ); debouncedToggleBlockHighlight( clientId, true ); - }, [ clientId, setIsHovered, debouncedToggleBlockHighlight ] ); + }, [ + clientId, + setIsHovered, + debouncedToggleBlockHighlight, + shouldDisableInteractions, + ] ); const onMouseLeave = useCallback( () => { + // Disable hover when section editing excludes this block. + if ( shouldDisableInteractions ) { + return; + } setIsHovered( false ); debouncedToggleBlockHighlight( clientId, false ); - }, [ clientId, setIsHovered, debouncedToggleBlockHighlight ] ); + }, [ + clientId, + setIsHovered, + debouncedToggleBlockHighlight, + shouldDisableInteractions, + ] ); const selectEditorBlock = useCallback( ( event ) => { + // If we're editing a section and clicking outside it, exit section editing. + if ( shouldDisableInteractions ) { + stopEditingContentOnlySection(); + event.preventDefault(); + return; + } + // For keyboard activation (Enter/Space on a link), transfer focus // to the canvas with the caret at the end of the block. // For mouse clicks, keep focus in the list view so that subsequent @@ -423,7 +474,12 @@ function ListViewBlock( { selectBlock( event, clientId, isKeyboardActivation ? -1 : null ); event.preventDefault(); }, - [ clientId, selectBlock ] + [ + clientId, + selectBlock, + shouldDisableInteractions, + stopEditingContentOnlySection, + ] ); const updateFocusAndSelection = useCallback( @@ -439,6 +495,12 @@ function ListViewBlock( { const toggleExpanded = useCallback( ( event ) => { + // Prevent expanding/collapsing blocks outside the edited section. + if ( shouldDisableInteractions ) { + event.preventDefault(); + event.stopPropagation(); + return; + } // Prevent shift+click from opening link in a new window when toggling. event.preventDefault(); event.stopPropagation(); @@ -448,7 +510,7 @@ function ListViewBlock( { expand( clientId ); } }, - [ clientId, expand, collapse, isExpanded ] + [ clientId, expand, collapse, isExpanded, shouldDisableInteractions ] ); // Allow right-clicking an item in the List View to open up the block settings dropdown. @@ -572,6 +634,7 @@ function ListViewBlock( { 'is-displacement-down': displacement === 'down', 'is-after-dragged-blocks': isAfterDraggedBlocks, 'is-nesting': isNesting, + 'is-faded-in-spotlight': shouldFadeInSpotlight, } ); // Only include all selected blocks if the currently clicked on block @@ -609,6 +672,7 @@ function ListViewBlock( { colSpan={ colSpan } ref={ cellRef } aria-selected={ !! isSelected } + aria-disabled={ shouldDisableInteractions ? 'true' : undefined } > { ( { ref, tabIndex, onFocus } ) => (
diff --git a/packages/block-editor/src/components/list-view/style.scss b/packages/block-editor/src/components/list-view/style.scss index 66a8ccd8577ce0..596bacb2a90abf 100644 --- a/packages/block-editor/src/components/list-view/style.scss +++ b/packages/block-editor/src/components/list-view/style.scss @@ -447,6 +447,23 @@ box-shadow: 0 0 0 $radius-small var(--wp-admin-theme-color); } } + + /* + * Spotlight mode. Fade out blocks unless they contain a selected block. + * This matches the visual behavior in the canvas when editing patterns. + * See: packages/block-editor/src/components/block-list/content.scss + */ + &.is-faded-in-spotlight { + opacity: 0.2; + @media not ( prefers-reduced-motion ) { + transition: opacity 0.1s linear; + } + + // Disable hover effects for faded blocks. + &:hover { + opacity: 0.2; + } + } } // First level of indentation is aria-level 2, max indent is 8. diff --git a/packages/block-editor/src/components/list-view/use-block-selection.js b/packages/block-editor/src/components/list-view/use-block-selection.js index 134cc9d4369ac7..7a5c3061b18db6 100644 --- a/packages/block-editor/src/components/list-view/use-block-selection.js +++ b/packages/block-editor/src/components/list-view/use-block-selection.js @@ -13,10 +13,13 @@ import { store as blocksStore } from '@wordpress/blocks'; */ import { store as blockEditorStore } from '../../store'; import { getCommonDepthClientIds } from './utils'; +import { unlock } from '../../lock-unlock'; export default function useBlockSelection() { - const { clearSelectedBlock, multiSelect, selectBlock } = - useDispatch( blockEditorStore ); + const blockEditorActions = useDispatch( blockEditorStore ); + const { clearSelectedBlock, multiSelect, selectBlock } = blockEditorActions; + const { stopEditingContentOnlySection } = unlock( blockEditorActions ); + const { getBlockName, getBlockParents, @@ -24,7 +27,8 @@ export default function useBlockSelection() { getSelectedBlockClientIds, hasMultiSelection, hasSelectedBlock, - } = useSelect( blockEditorStore ); + getEditedContentOnlySection, + } = unlock( useSelect( blockEditorStore ) ); const { getBlockType } = useSelect( blocksStore ); @@ -48,6 +52,15 @@ export default function useBlockSelection() { event.keyCode === HOME || event.keyCode === END ); + // When Escape is pressed, exit the editing mode instead of just clearing selection. + if ( isOnlyDeselection ) { + const editedContentOnlySection = getEditedContentOnlySection(); + if ( editedContentOnlySection ) { + stopEditingContentOnlySection(); + return; + } + } + // Handle clicking on a block when no blocks are selected, and return early. if ( ! isKeyPress && @@ -164,6 +177,8 @@ export default function useBlockSelection() { hasSelectedBlock, multiSelect, selectBlock, + getEditedContentOnlySection, + stopEditingContentOnlySection, ] ); 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 e7f0176f8620ab..7ac4b2fe621583 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 @@ -11,7 +11,7 @@ import { isRTL, isFormElement, } from '@wordpress/dom'; -import { UP, DOWN, LEFT, RIGHT } from '@wordpress/keycodes'; +import { UP, DOWN, LEFT, RIGHT, ESCAPE } from '@wordpress/keycodes'; import { useDispatch, useSelect } from '@wordpress/data'; import { useRefEffect } from '@wordpress/compose'; @@ -20,6 +20,7 @@ import { useRefEffect } from '@wordpress/compose'; */ import { getBlockClientId } from '../../utils/dom'; import { store as blockEditorStore } from '../../store'; +import { unlock } from '../../lock-unlock'; /** * Returns true if the element should consider edge navigation upon a keyboard @@ -170,8 +171,11 @@ export default function useArrowNav() { getSettings, hasMultiSelection, __unstableIsFullySelected, - } = useSelect( blockEditorStore ); - const { selectBlock } = useDispatch( blockEditorStore ); + getEditedContentOnlySection, + } = unlock( useSelect( blockEditorStore ) ); + const blockEditorActions = useDispatch( blockEditorStore ); + const { selectBlock } = blockEditorActions; + const { stopEditingContentOnlySection } = unlock( blockEditorActions ); return useRefEffect( ( node ) => { // Here a DOMRect is stored while moving the caret vertically so // vertical position of the start position can be restored. This is to @@ -213,6 +217,16 @@ export default function useArrowNav() { const { ownerDocument } = node; const { defaultView } = ownerDocument; + // Handle Escape key to exit content-only pattern editing mode. + if ( keyCode === ESCAPE ) { + const editedContentOnlySection = getEditedContentOnlySection(); + if ( editedContentOnlySection ) { + event.preventDefault(); + stopEditingContentOnlySection(); + return; + } + } + if ( ! isNav ) { return; } @@ -315,6 +329,7 @@ export default function useArrowNav() { isReverseDir, node ); + placeCaretAtHorizontalEdge( closestTabbable, isReverse ); event.preventDefault(); } diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.js b/packages/block-editor/src/components/writing-flow/use-tab-nav.js index 339fe4de980580..7ee973afc89f95 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.js +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.js @@ -11,7 +11,11 @@ import { useRef } from '@wordpress/element'; * Internal dependencies */ import { store as blockEditorStore } from '../../store'; -import { isInSameBlock, isInsideRootBlock } from '../../utils/dom'; +import { + getBlockClientId, + isInSameBlock, + isInsideRootBlock, +} from '../../utils/dom'; import { unlock } from '../../lock-unlock'; export default function useTabNav() { @@ -27,6 +31,8 @@ export default function useTabNav() { getLastFocus, getSectionRootClientId, isZoomOut, + getEditedContentOnlySection, + isWithinEditedContentOnlySection, } = unlock( useSelect( blockEditorStore ) ); const { setLastFocus } = unlock( useDispatch( blockEditorStore ) ); @@ -138,6 +144,21 @@ export default function useTabNav() { const direction = isShift ? 'findPrevious' : 'findNext'; const nextTabbable = focus.tabbable[ direction ]( target ); + // Prevent tabbing outside the pattern boundary. + if ( nextTabbable ) { + const editedContentOnlySection = getEditedContentOnlySection(); + if ( editedContentOnlySection ) { + const nextBlockClientId = getBlockClientId( nextTabbable ); + if ( + nextBlockClientId && + ! isWithinEditedContentOnlySection( nextBlockClientId ) + ) { + event.preventDefault(); + return; + } + } + } + // We want to constrain the tabbing to the block and its child blocks. // If the preceding form element is within a different block, // such as two sibling image blocks in the placeholder state, diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index 3cb08ed35775fe..c9e26d5e61ed13 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -43,6 +43,142 @@ import { BLOCK_VISIBILITY_VIEWPORTS } from '../components/block-visibility/const const { isContentBlock } = unlock( blocksPrivateApis ); +/** + * Walks up the block parent chain to find the first ancestor whose clientId + * is in the given list. Returns the matching ancestor clientId, or undefined. + * + * @param {Object} state Block editor state. + * @param {string} clientId Starting block clientId. + * @param {string[]} clientIds List of clientIds to match against. + * @return {string|undefined} The matching ancestor clientId, or undefined. + */ +function findParentInClientIdsList( state, clientId, clientIds ) { + if ( ! clientIds.length ) { + return; + } + let parent = state.blocks.parents.get( clientId ); + while ( parent !== undefined ) { + if ( clientIds.includes( parent ) ) { + return parent; + } + parent = state.blocks.parents.get( parent ); + } +} + +/** + * Returns the set of block clientIds that should be visible in List View when + * a content-only section is being edited, even though they are 'disabled' for + * editing. Blocks that were already hidden before editing started (non-content + * blocks inside a contentOnly parent) are excluded. + * + * Returns null when no section is being edited. + * + * @param {Object} state Block editor state. + * @return {Set|null} Set of visible-despite-disabled clientIds, or null. + */ +export const getListViewBlockVisibility = createSelector( + ( state ) => { + if ( ! state.editedContentOnlySection ) { + return null; + } + + // Compute contentOnly parents (same logic as getDerivedBlockEditingModesForTree). + const isIsolatedEditor = state.settings?.[ isIsolatedEditorKey ]; + const disableContentOnlyForUnsyncedPatterns = + state.settings?.disableContentOnlyForUnsyncedPatterns; + + const contentOnlyTemplateLockedClientIds = Object.keys( + state.blockListSettings ?? {} + ).filter( + ( clientId ) => + state.blockListSettings[ clientId ]?.templateLock === + 'contentOnly' + ); + + const templatePartClientIds = []; + Object.keys( state.blocks.controlledInnerBlocks ?? {} ).forEach( + ( clientId ) => { + if ( + state.blocks.byClientId?.get( clientId )?.name === + 'core/template-part' + ) { + templatePartClientIds.push( clientId ); + } + } + ); + + const unsyncedPatternClientIds = + isIsolatedEditor || disableContentOnlyForUnsyncedPatterns + ? [] + : Array.from( + ( state.blocks.attributes ?? new Map() ).keys() + ).filter( + ( clientId ) => + state.blocks.attributes.get( clientId )?.metadata + ?.patternName + ); + + const contentOnlyParents = [ + ...contentOnlyTemplateLockedClientIds, + ...unsyncedPatternClientIds, + ...( isIsolatedEditor ? [] : templatePartClientIds ), + ]; + + const visibility = new Set(); + + for ( const [ clientId, mode ] of state.derivedBlockEditingModes ) { + if ( mode !== 'disabled' ) { + continue; + } + + // Blocks inside the edited section have 'default' mode, so any + // 'disabled' block here is outside the section. Skip the few that + // might be disabled for other reasons (e.g. disabled-ancestor + // inheritance within the section) by checking ancestry. + if ( + clientId === state.editedContentOnlySection || + !! findParentInClientIdsList( state, clientId, [ + state.editedContentOnlySection, + ] ) + ) { + continue; + } + + // Non-content blocks inside a contentOnly parent were hidden before + // editing started — keep them hidden. + if ( contentOnlyParents.length ) { + const hasContentOnlyParent = !! findParentInClientIdsList( + state, + clientId, + contentOnlyParents + ); + if ( + hasContentOnlyParent && + ! isContentBlock( + state.blocks.byClientId.get( clientId )?.name + ) + ) { + continue; + } + } + + visibility.add( clientId ); + } + + return visibility; + }, + ( state ) => [ + state.editedContentOnlySection, + state.derivedBlockEditingModes, + state.blockListSettings, + state.blocks.attributes, + state.blocks.controlledInnerBlocks, + state.blocks.parents, + state.blocks.byClientId, + state.settings, + ] +); + export { getBlockSettings } from './get-block-settings'; /** @@ -161,6 +297,7 @@ export const getClientIdsTree = createSelector( function getEnabledClientIdsTreeUnmemoized( state, rootClientId ) { const blockOrder = getBlockOrder( state, rootClientId ); + const listViewBlockVisibility = getListViewBlockVisibility( state ); const result = []; for ( const clientId of blockOrder ) { @@ -168,7 +305,17 @@ function getEnabledClientIdsTreeUnmemoized( state, rootClientId ) { state, clientId ); - if ( getBlockEditingMode( state, clientId ) !== 'disabled' ) { + const isDisabled = + getBlockEditingMode( state, clientId ) === 'disabled'; + + // When editing a content-only section, disabled blocks that were + // visible before editing still appear as nodes in List View — just + // faded. Blocks that were already hidden (non-content blocks inside + // other patterns) remain hidden. + const showDespiteDisabled = + isDisabled && listViewBlockVisibility?.has( clientId ); + + if ( ! isDisabled || showDespiteDisabled ) { result.push( { clientId, innerBlocks } ); } else { result.push( ...innerBlocks ); @@ -180,7 +327,8 @@ function getEnabledClientIdsTreeUnmemoized( state, rootClientId ) { /** * Returns a tree of block objects with only clientID and innerBlocks set. - * Blocks with a 'disabled' editing mode are not included. + * Blocks with a 'disabled' editing mode are not included, unless a content-only + * section is being edited, in which case all blocks are included to show context. * * @param {Object} state Global application state. * @param {?string} rootClientId Optional root client ID of block list. @@ -192,6 +340,7 @@ export const getEnabledClientIdsTree = createRegistrySelector( () => state.blocks.order, state.derivedBlockEditingModes, state.blocks.blockEditingModes, + getListViewBlockVisibility( state ), ] ) ); diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js index 0e987318848300..87a1357009ca24 100644 --- a/packages/block-editor/src/store/test/private-selectors.js +++ b/packages/block-editor/src/store/test/private-selectors.js @@ -757,6 +757,69 @@ describe( 'private selectors', () => { }, ] ); } ); + + it( 'should include disabled blocks when a content-only section is being edited', () => { + const state = { + ...baseState, + // getListViewBlockVisibility needs these slices; empty means no + // contentOnly parents exist in this fixture. + blockListSettings: {}, + blocks: { + ...baseState.blocks, + attributes: new Map(), + controlledInnerBlocks: {}, + }, + // The Group (ef45d5fd) is the section being edited. + editedContentOnlySection: + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + blockEditingModes: new Map( [ + [ '', 'disabled' ], + [ 'b26fc763-417d-4f01-b81c-2ec61e14a972', 'contentOnly' ], + [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', 'contentOnly' ], + ] ), + // The edited Group itself has 'default' mode (set by the reducer) + // so it does not appear in derivedBlockEditingModes. Only the + // Header (6cf70164), which is outside the edited section, is disabled. + derivedBlockEditingModes: new Map( [ + [ '6cf70164-9097-4460-bcbf-200560546988', 'disabled' ], + ] ), + // listViewBlockVisibility is now a memoized selector — not stored + // in state. getListViewBlockVisibility computes it on demand. + }; + // When editing a content-only section, list view shows blocks that were + // visible before editing, preserving context without revealing previously + // hidden structural blocks from other patterns. + expect( getEnabledClientIdsTree( state ) ).toEqual( [ + { + clientId: '6cf70164-9097-4460-bcbf-200560546988', + innerBlocks: [], + }, + { + 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( '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..3d36fdc30d62c2 --- /dev/null +++ b/test/e2e/specs/editor/various/list-view-spotlight-mode.spec.js @@ -0,0 +1,253 @@ +/** + * 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( 'should show disabled blocks in list view and constrain 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 faded row (outside the edited section). + const fadedBlockRow = listView + .locator( '[role=row].is-faded-in-spotlight' ) + .first(); + await expect( fadedBlockRow ).toBeVisible(); + const blockBeneathPattern = fadedBlockRow + .getByRole( 'gridcell' ) + .first(); + await expect( blockBeneathPattern ).toBeVisible(); + + // Keyboard navigation should be 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 should be 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( 'should exit spotlight mode when clicking faded 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 fadedBlockRow = listView + .locator( '[role=row].is-faded-in-spotlight' ) + .first(); + await expect( fadedBlockRow ).toBeVisible(); + const blockBeneathPattern = fadedBlockRow + .getByRole( 'gridcell' ) + .first(); + + // Faded blocks have aria-disabled="true" (they're outside the edited section). + // The intended UX is that clicking a faded block exits section editing. We use + // force: true because Playwright skips disabled elements by default; this + // asserts that the click handler runs and spotlight mode exits. + const fadedBlockButton = blockBeneathPattern.locator( + '.block-editor-list-view-block-contents' + ); + // eslint-disable-next-line playwright/no-force-option + await fadedBlockButton.click( { force: true } ); + + // Spotlight exited: no rows should be faded. + await expect( + listView.locator( '[role=row].is-faded-in-spotlight' ) + ).toHaveCount( 0 ); + + const blockBeneath = editor.canvas + .getByRole( 'document', { + name: 'Block: Paragraph', + } ) + .filter( { hasText: 'Block beneath pattern' } ); + await expect( blockBeneath ).toBeVisible(); + } ); + + test( 'should exit 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 fadedBlockRow = listView + .locator( '[role=row].is-faded-in-spotlight' ) + .first(); + await expect( fadedBlockRow ).toBeVisible(); + + await editor.canvas + .getByRole( 'document', { + name: 'Block: Paragraph', + } ) + .first() + .click(); + + await page.keyboard.press( 'Escape' ); + + // Spotlight exited: no rows should be faded. + await expect( + listView.locator( '[role=row].is-faded-in-spotlight' ) + ).toHaveCount( 0 ); + + const blockBeneath = editor.canvas + .getByRole( 'document', { + name: 'Block: Paragraph', + } ) + .filter( { hasText: 'Block beneath pattern' } ); + + await blockBeneath.click(); + await expect( blockBeneath ).toBeFocused(); + } ); +} ); From dee19948ff103df8de88c91dab24b5bf94b212df Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 16 Apr 2026 14:44:44 +1000 Subject: [PATCH 02/22] Update blockEditingModes in private selectors test to ensure consistency with new state structure --- .../src/store/test/private-selectors.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js index 87a1357009ca24..e2dd97616ef98a 100644 --- a/packages/block-editor/src/store/test/private-selectors.js +++ b/packages/block-editor/src/store/test/private-selectors.js @@ -768,15 +768,21 @@ describe( 'private selectors', () => { ...baseState.blocks, attributes: new Map(), controlledInnerBlocks: {}, + 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', - blockEditingModes: new Map( [ - [ '', 'disabled' ], - [ 'b26fc763-417d-4f01-b81c-2ec61e14a972', 'contentOnly' ], - [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', 'contentOnly' ], - ] ), // The edited Group itself has 'default' mode (set by the reducer) // so it does not appear in derivedBlockEditingModes. Only the // Header (6cf70164), which is outside the edited section, is disabled. From 27a388012a15e30baf3c16627879b4fc3cad2d15 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 16 Jun 2026 20:01:47 +1000 Subject: [PATCH 03/22] Improve pattern editing list view isolation --- .../list-view/block-select-button.js | 3 +- .../src/components/list-view/block.js | 46 ++- .../list-view/use-block-selection.js | 20 +- .../list-view/use-list-view-client-ids.js | 4 +- .../components/writing-flow/use-tab-nav.js | 20 +- .../src/store/private-selectors.js | 233 +++++---------- .../src/store/test/private-selectors.js | 274 ++++++++++++++++-- .../various/list-view-spotlight-mode.spec.js | 58 +++- 8 files changed, 441 insertions(+), 217 deletions(-) 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..7e607d9f756dfc 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, + isFocusable = true, }, ref ) { @@ -94,7 +95,7 @@ function ListViewBlockSelectButton( onDragStart={ onDragStartHandler } onDragEnd={ onDragEnd } draggable={ draggable } - href={ `#block-${ clientId }` } + href={ isFocusable ? `#block-${ clientId }` : undefined } aria-describedby={ ariaDescribedBy } aria-expanded={ isExpanded } > diff --git a/packages/block-editor/src/components/list-view/block.js b/packages/block-editor/src/components/list-view/block.js index 5fe04746a30c5c..20e51ba8c2fd61 100644 --- a/packages/block-editor/src/components/list-view/block.js +++ b/packages/block-editor/src/components/list-view/block.js @@ -228,6 +228,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 @@ -462,6 +469,7 @@ function ListViewBlock( { // If we're editing a section and clicking outside it, exit section editing. if ( shouldDisableInteractions ) { stopEditingContentOnlySection(); + selectBlock( event, clientId, null ); event.preventDefault(); return; } @@ -516,6 +524,11 @@ function ListViewBlock( { // Allow right-clicking an item in the List View to open up the block settings dropdown. const onContextMenu = useCallback( ( event ) => { + if ( shouldDisableInteractions ) { + event.preventDefault(); + return; + } + const { ownerDocument } = settingsRef?.current || {}; if ( ! ownerDocument || ! ownerDocument.hasFocus() ) { return; @@ -530,7 +543,12 @@ function ListViewBlock( { event.preventDefault(); } }, - [ allowRightClickOverrides, settingsRef, showBlockActions ] + [ + allowRightClickOverrides, + settingsRef, + shouldDisableInteractions, + showBlockActions, + ] ); const onMouseDown = useCallback( @@ -601,7 +619,9 @@ function ListViewBlock( { ); const hasSiblings = siblingBlockCount > 0; - const hasRenderedMovers = showBlockMovers && hasSiblings; + const canShowBlockActions = showBlockActions && ! shouldDisableInteractions; + const hasRenderedMovers = + showBlockMovers && hasSiblings && ! shouldDisableInteractions; const moverCellClassName = clsx( 'block-editor-list-view-block__mover-cell', { 'is-visible': isHovered || isSelected } @@ -615,7 +635,7 @@ function ListViewBlock( { let colSpan; if ( hasRenderedMovers ) { colSpan = 2; - } else if ( ! showBlockActions ) { + } else if ( ! canShowBlockActions ) { colSpan = 3; } @@ -649,6 +669,18 @@ function ListViewBlock( { const currentlyEditingBlockInCanvas = isSelected && selectedClientIds.length === 1; + const getListViewBlockTabIndex = ( rovingTabIndex ) => { + if ( shouldDisableInteractions ) { + return -1; + } + + if ( currentlyEditingBlockInCanvas ) { + return 0; + } + + return rovingTabIndex; + }; + return ( { ( { ref, tabIndex, onFocus } ) => (
@@ -687,14 +718,13 @@ function ListViewBlock( { siblingBlockCount={ siblingBlockCount } level={ level } ref={ ref } - tabIndex={ - currentlyEditingBlockInCanvas ? 0 : tabIndex - } + tabIndex={ getListViewBlockTabIndex( tabIndex ) } onFocus={ onFocus } isExpanded={ canEditBlock ? isExpanded : undefined } selectedClientIds={ selectedClientIds } ariaDescribedBy={ descriptionId } visibilityLabel={ blockVisibilityDescription } + isFocusable={ ! shouldDisableInteractions } /> { [ @@ -740,7 +770,7 @@ function ListViewBlock( { ) } - { showBlockActions && BlockSettingsMenu && ( + { canShowBlockActions && BlockSettingsMenu && (