From 1ca31148146e3d3a028d771d1d107f4a92e26fcb Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Wed, 29 Jul 2026 18:00:50 +0400 Subject: [PATCH 1/5] ToC: Extract toolbar component --- .../src/table-of-contents/edit.js | 106 ++++++++++-------- 1 file changed, 62 insertions(+), 44 deletions(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index de65c0af92b1f3..d46e38dd4611e6 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -41,48 +41,12 @@ import { useToolsPanelDropdownMenuProps } from '../utils/hooks'; /** @typedef {import('./utils').HeadingData} HeadingData */ -/** - * Table of Contents block edit component. - * - * @param {Object} props The props. - * @param {Object} props.attributes The block attributes. - * @param {HeadingData[]} props.attributes.headings The list of data for each heading in the post. - * @param {boolean} props.attributes.onlyIncludeCurrentPage Whether to only include headings from the current page (if the post is paginated). - * @param {number|undefined} props.attributes.maxLevel The maximum heading level to include, or null to include all levels. - * @param {boolean} props.attributes.ordered Whether to display as an ordered list (true) or unordered list (false). - * @param {string} props.clientId The client id. - * @param {(attributes: Object) => void} props.setAttributes The set attributes function. - * - * @return {Component} The component. - */ -export default function TableOfContentsEdit( { - attributes: { - headings = [], - onlyIncludeCurrentPage, - maxLevel, - ordered = true, - }, +function TableOfContentsToolbar( { clientId, + headingTree, + ordered, setAttributes, } ) { - useObserveHeadings( clientId ); - - const blockProps = useBlockProps(); - const instanceId = useInstanceId( - TableOfContentsEdit, - 'table-of-contents' - ); - - // If a user clicks to a link prevent redirection and show a warning. - const { createWarningNotice } = useDispatch( noticeStore ); - const showRedirectionPreventedNotice = ( event ) => { - event.preventDefault(); - createWarningNotice( __( 'Links are disabled in the editor.' ), { - id: `block-library/core/table-of-contents/redirection-prevented/${ instanceId }`, - type: 'snackbar', - } ); - }; - const canInsertList = useSelect( ( select ) => { const { getBlockRootClientId, canInsertBlockType } = @@ -93,13 +57,10 @@ export default function TableOfContentsEdit( { }, [ clientId ] ); - const { replaceBlocks } = useDispatch( blockEditorStore ); - const dropdownMenuProps = useToolsPanelDropdownMenuProps(); - const headingTree = linearToNestedHeadingList( headings ); - const toolbarControls = ( - + return ( + <> ) } + + ); +} + +/** + * Table of Contents block edit component. + * + * @param {Object} props The props. + * @param {Object} props.attributes The block attributes. + * @param {HeadingData[]} props.attributes.headings The list of data for each heading in the post. + * @param {boolean} props.attributes.onlyIncludeCurrentPage Whether to only include headings from the current page (if the post is paginated). + * @param {number|undefined} props.attributes.maxLevel The maximum heading level to include, or null to include all levels. + * @param {boolean} props.attributes.ordered Whether to display as an ordered list (true) or unordered list (false). + * @param {string} props.clientId The client id. + * @param {(attributes: Object) => void} props.setAttributes The set attributes function. + * + * @return {Component} The component. + */ +export default function TableOfContentsEdit( { + attributes: { + headings = [], + onlyIncludeCurrentPage, + maxLevel, + ordered = true, + }, + clientId, + setAttributes, +} ) { + useObserveHeadings( clientId ); + + const blockProps = useBlockProps(); + const instanceId = useInstanceId( + TableOfContentsEdit, + 'table-of-contents' + ); + + // If a user clicks to a link prevent redirection and show a warning. + const { createWarningNotice } = useDispatch( noticeStore ); + const showRedirectionPreventedNotice = ( event ) => { + event.preventDefault(); + createWarningNotice( __( 'Links are disabled in the editor.' ), { + id: `block-library/core/table-of-contents/redirection-prevented/${ instanceId }`, + type: 'snackbar', + } ); + }; + + const dropdownMenuProps = useToolsPanelDropdownMenuProps(); + const headingTree = linearToNestedHeadingList( headings ); + + const toolbarControls = ( + + ); From df1198d1a681f3375f924e48f297501a41ea4c47 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Wed, 29 Jul 2026 18:08:52 +0400 Subject: [PATCH 2/5] Use new 'Detach' convention --- .../src/table-of-contents/edit.js | 108 +++++++++++------- 1 file changed, 64 insertions(+), 44 deletions(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index d46e38dd4611e6..628f102ece46df 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -15,11 +15,12 @@ import { SelectControl, ToolbarButton, ToolbarGroup, + __experimentalConfirmDialog as ConfirmDialog, __experimentalToolsPanel as ToolsPanel, __experimentalToolsPanelItem as ToolsPanelItem, } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; -import { renderToString } from '@wordpress/element'; +import { renderToString, useState } from '@wordpress/element'; import { __, isRTL } from '@wordpress/i18n'; import { useInstanceId } from '@wordpress/compose'; import { store as noticeStore } from '@wordpress/notices'; @@ -58,48 +59,69 @@ function TableOfContentsToolbar( { [ clientId ] ); const { replaceBlocks } = useDispatch( blockEditorStore ); + const [ isConfirmingDetach, setIsConfirmingDetach ] = useState( false ); return ( <> - - setAttributes( { ordered: false } ) } - isActive={ ordered === false } - /> - setAttributes( { ordered: true } ) } - isActive={ ordered === true } - /> - - { canInsertList && ( + - replaceBlocks( - clientId, - createBlock( 'core/list', { - ordered, - values: renderToString( - - ), - } ) - ) + icon={ + isRTL() ? formatListBulletsRTL : formatListBullets + } + title={ __( 'Unordered' ) } + description={ __( 'Convert to unordered list' ) } + onClick={ () => setAttributes( { ordered: false } ) } + isActive={ ordered === false } + /> + - { __( 'Convert to static list' ) } - + title={ __( 'Ordered' ) } + description={ __( 'Convert to ordered list' ) } + onClick={ () => setAttributes( { ordered: true } ) } + isActive={ ordered === true } + /> + { canInsertList && ( + + setIsConfirmingDetach( true ) } + > + { __( 'Detach' ) } + + + ) } + + { isConfirmingDetach && ( + { + setIsConfirmingDetach( false ); + replaceBlocks( + clientId, + createBlock( 'core/list', { + ordered, + values: renderToString( + + ), + } ) + ); + } } + onCancel={ () => setIsConfirmingDetach( false ) } + size="medium" + > + { __( + 'The table of contents lists the headings in the post. Detaching will enable you to edit, reorder, or remove entries. However, new headings will no longer be added automatically.' + ) } + ) } ); @@ -151,14 +173,12 @@ export default function TableOfContentsEdit( { const headingTree = linearToNestedHeadingList( headings ); const toolbarControls = ( - - - + ); const inspectorControls = ( From 783117e9e55a459b4ce77e31f5b25c54ac910635 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Wed, 29 Jul 2026 18:25:33 +0400 Subject: [PATCH 3/5] Use List v2 conventions --- .../src/table-of-contents/edit.js | 20 ++++---- .../src/table-of-contents/utils.ts | 46 +++++++++++++++++++ 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index 628f102ece46df..91fdceeae7dd9e 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -20,7 +20,7 @@ import { __experimentalToolsPanelItem as ToolsPanelItem, } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; -import { renderToString, useState } from '@wordpress/element'; +import { useState } from '@wordpress/element'; import { __, isRTL } from '@wordpress/i18n'; import { useInstanceId } from '@wordpress/compose'; import { store as noticeStore } from '@wordpress/notices'; @@ -36,7 +36,7 @@ import { * Internal dependencies */ import TableOfContentsList from './list'; -import { linearToNestedHeadingList } from './utils'; +import { createListItemBlocks, linearToNestedHeadingList } from './utils'; import { useObserveHeadings } from './hooks'; import { useToolsPanelDropdownMenuProps } from '../utils/hooks'; @@ -104,15 +104,11 @@ function TableOfContentsToolbar( { setIsConfirmingDetach( false ); replaceBlocks( clientId, - createBlock( 'core/list', { - ordered, - values: renderToString( - - ), - } ) + createBlock( + 'core/list', + { ordered }, + createListItemBlocks( headingTree, ordered ) + ) ); } } onCancel={ () => setIsConfirmingDetach( false ) } @@ -261,7 +257,7 @@ export default function TableOfContentsEdit( { // If there are no headings or the only heading is empty. // Note that the toolbar controls are intentionally omitted since the - // "Convert to static list" option is useless to the placeholder state. + // "Detach" option is useless to the placeholder state. if ( headings.length === 0 ) { return ( <> diff --git a/packages/block-library/src/table-of-contents/utils.ts b/packages/block-library/src/table-of-contents/utils.ts index 3e2d115bbfbd67..f217bee63340f8 100644 --- a/packages/block-library/src/table-of-contents/utils.ts +++ b/packages/block-library/src/table-of-contents/utils.ts @@ -1,3 +1,10 @@ +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; +import type { Block } from '@wordpress/blocks'; +import { escapeAttribute, escapeHTML } from '@wordpress/escape-html'; + export interface HeadingData { /** The plain text content of the heading. */ content: string; @@ -69,3 +76,42 @@ export function linearToNestedHeadingList( return nestedHeadingList; } + +/** + * Converts a nested heading list into `core/list-item` blocks, with + * sub-headings nested in a `core/list` block inside their parent item. + * + * @param nestedHeadingList The nested list of headings. + * @param ordered Whether nested lists are ordered. + * + * @return The list item blocks. + */ +export function createListItemBlocks( + nestedHeadingList: NestedHeadingData[], + ordered: boolean +): Block[] { + return nestedHeadingList.map( + ( { heading: { content, link }, children } ) => + createBlock( + 'core/list-item', + { + // Headings are plain text, and links can carry query args on + // paginated posts, so both need escaping to become markup. + content: link + ? `${ escapeHTML( + content + ) }` + : escapeHTML( content ), + }, + children + ? [ + createBlock( + 'core/list', + { ordered }, + createListItemBlocks( children, ordered ) + ), + ] + : [] + ) + ); +} From 3dfedb95d3e2d2795a026ccb2976ce8f74b03f34 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Wed, 29 Jul 2026 18:26:11 +0400 Subject: [PATCH 4/5] Drop block edit JSDoc --- .../block-library/src/table-of-contents/edit.js | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index 91fdceeae7dd9e..1420442c45a3bd 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -123,20 +123,6 @@ function TableOfContentsToolbar( { ); } -/** - * Table of Contents block edit component. - * - * @param {Object} props The props. - * @param {Object} props.attributes The block attributes. - * @param {HeadingData[]} props.attributes.headings The list of data for each heading in the post. - * @param {boolean} props.attributes.onlyIncludeCurrentPage Whether to only include headings from the current page (if the post is paginated). - * @param {number|undefined} props.attributes.maxLevel The maximum heading level to include, or null to include all levels. - * @param {boolean} props.attributes.ordered Whether to display as an ordered list (true) or unordered list (false). - * @param {string} props.clientId The client id. - * @param {(attributes: Object) => void} props.setAttributes The set attributes function. - * - * @return {Component} The component. - */ export default function TableOfContentsEdit( { attributes: { headings = [], From edb1d7ee7f5f8587b08984ea7da13b4c931bd43f Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 30 Jul 2026 09:06:20 +0400 Subject: [PATCH 5/5] Feedback --- packages/block-library/src/table-of-contents/edit.js | 2 +- .../block-library/src/table-of-contents/utils.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index 1420442c45a3bd..47fcce1a202106 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -115,7 +115,7 @@ function TableOfContentsToolbar( { size="medium" > { __( - 'The table of contents lists the headings in the post. Detaching will enable you to edit, reorder, or remove entries. However, new headings will no longer be added automatically.' + 'The Table of Contents block lists the headings in the post. Detaching will enable you to edit, reorder, or remove entries. However, new headings will no longer be added automatically.' ) } ) } diff --git a/packages/block-library/src/table-of-contents/utils.ts b/packages/block-library/src/table-of-contents/utils.ts index f217bee63340f8..0894cf3ff6e7aa 100644 --- a/packages/block-library/src/table-of-contents/utils.ts +++ b/packages/block-library/src/table-of-contents/utils.ts @@ -3,7 +3,7 @@ */ import { createBlock } from '@wordpress/blocks'; import type { Block } from '@wordpress/blocks'; -import { escapeAttribute, escapeHTML } from '@wordpress/escape-html'; +import { escapeAttribute, escapeEditableHTML } from '@wordpress/escape-html'; export interface HeadingData { /** The plain text content of the heading. */ @@ -98,12 +98,12 @@ export function createListItemBlocks( // Headings are plain text, and links can carry query args on // paginated posts, so both need escaping to become markup. content: link - ? `${ escapeHTML( - content - ) }` - : escapeHTML( content ), + ? `${ escapeEditableHTML( content ) }` + : escapeEditableHTML( content ), }, - children + children?.length ? [ createBlock( 'core/list',