diff --git a/packages/block-library/src/heading/editor.scss b/packages/block-library/src/heading/editor.scss
index 05040868113b61..9e001103788f43 100644
--- a/packages/block-library/src/heading/editor.scss
+++ b/packages/block-library/src/heading/editor.scss
@@ -7,12 +7,22 @@
min-width: 230px;
}
+.block-library-heading__heading-level-dropdown-button-invalid-indicator {
+ background-color: $alert-yellow;
+ height: 8px;
+ width: 8px;
+ position: absolute;
+ bottom: 6px;
+ left: 20px;
+ border-radius: 50%;
+}
+
// The dropdown already has a border, so we can remove the one on the heading
// level toolbar.
.block-library-heading-level-toolbar {
border: none;
}
-.block-library-heading__heading-level-checker {
+.block-library-heading__heading-level-checker-warning {
margin: 0;
}
diff --git a/packages/block-library/src/heading/heading-level-checker.js b/packages/block-library/src/heading/heading-level-checker.js
index a206c3251abc96..4f53ce9651612f 100644
--- a/packages/block-library/src/heading/heading-level-checker.js
+++ b/packages/block-library/src/heading/heading-level-checker.js
@@ -1,107 +1,38 @@
-/**
- * External dependencies
- */
-import { countBy, flatMap, get } from 'lodash';
-
/**
* WordPress dependencies
*/
import { speak } from '@wordpress/a11y';
-import { __ } from '@wordpress/i18n';
import { Notice } from '@wordpress/components';
import { useEffect } from '@wordpress/element';
-import { compose } from '@wordpress/compose';
-import { withSelect } from '@wordpress/data';
-
-// copy from packages/editor/src/components/document-outline/index.js
-/**
- * Returns an array of heading blocks enhanced with the following properties:
- * path - An array of blocks that are ancestors of the heading starting from a top-level node.
- * Can be an empty array if the heading is a top-level node (is not nested inside another block).
- * level - An integer with the heading level.
- * isEmpty - Flag indicating if the heading has no content.
- *
- * @param {?Array} blocks An array of blocks.
- * @param {?Array} path An array of blocks that are ancestors of the blocks passed as blocks.
- *
- * @return {Array} An array of heading blocks enhanced with the properties described above.
- */
-export const computeOutlineHeadings = ( blocks = [], path = [] ) => {
- return flatMap( blocks, ( block = {} ) => {
- if ( block.name === 'core/heading' ) {
- return {
- ...block,
- path,
- level: block.attributes.level,
- };
- }
- return computeOutlineHeadings( block.innerBlocks, [ ...path, block ] );
- } );
-};
-
-export const HeadingLevelChecker = ( {
- blocks = [],
- title,
- isTitleSupported,
- selectedHeadingId,
-} ) => {
- const headings = computeOutlineHeadings( blocks );
-
- // Iterate headings to find prevHeadingLevel and selectedLevel
- let prevHeadingLevel = 1;
- let selectedLevel = 1;
- let i = 0;
- for ( i = 0; i < headings.length; i++ ) {
- if ( headings[ i ].clientId === selectedHeadingId ) {
- selectedLevel = headings[ i ].level;
- if ( i >= 1 ) {
- prevHeadingLevel = headings[ i - 1 ].level;
- }
- }
- }
-
- const titleNode = document.querySelector( '.editor-post-title__input' );
- const hasTitle = isTitleSupported && title && titleNode;
- const countByLevel = countBy( headings, 'level' );
- const hasMultipleH1 = countByLevel[ 1 ] > 1;
- const isIncorrectLevel = selectedLevel > prevHeadingLevel + 1;
+import { __ } from '@wordpress/i18n';
- // For accessibility
+const INVALID_LEVEL_MESSAGE = __(
+ 'The selected heading level may be invalid. See the content structure tool for more info.'
+);
+
+export default function HeadingLevelChecker( {
+ selectedLevel,
+ levelIsInvalid,
+} ) {
+ // For accessibility, announce the invalid heading level to screen readers.
+ // The selectedLevel value is included in the dependency array so that the
+ // message will be replayed if a new level is selected, but the new level is
+ // still invalid.
useEffect( () => {
- if ( isIncorrectLevel ) speak( msg );
- }, [ isIncorrectLevel, selectedLevel ] );
+ if ( levelIsInvalid ) speak( INVALID_LEVEL_MESSAGE );
+ }, [ selectedLevel, levelIsInvalid ] );
- let msg = '';
- if ( isIncorrectLevel ) {
- msg = __( 'This heading level is incorrect.' );
- } else if ( selectedLevel === 1 && hasMultipleH1 ) {
- msg = __( 'Multiple H1 headings found.' );
- } else if ( selectedLevel === 1 && hasTitle && ! hasMultipleH1 ) {
- msg = __( 'H1 is already used for the post title.' );
- } else {
+ if ( ! levelIsInvalid ) {
return null;
}
return (
-
-
- { msg }
-
-
+
+ { INVALID_LEVEL_MESSAGE }
+
);
-};
-
-export default compose(
- withSelect( ( select ) => {
- const { getBlocks } = select( 'core/block-editor' );
- const { getEditedPostAttribute } = select( 'core/editor' );
- const { getPostType } = select( 'core' );
- const postType = getPostType( getEditedPostAttribute( 'type' ) );
-
- return {
- blocks: getBlocks(),
- title: getEditedPostAttribute( 'title' ),
- isTitleSupported: get( postType, [ 'supports', 'title' ], false ),
- };
- } )
-)( HeadingLevelChecker );
+}
diff --git a/packages/block-library/src/heading/heading-level-dropdown.js b/packages/block-library/src/heading/heading-level-dropdown.js
index 6b3b7ecb8dbc5a..04ae9fdb66c8ed 100644
--- a/packages/block-library/src/heading/heading-level-dropdown.js
+++ b/packages/block-library/src/heading/heading-level-dropdown.js
@@ -15,6 +15,7 @@ import { DOWN } from '@wordpress/keycodes';
*/
import HeadingLevelChecker from './heading-level-checker';
import HeadingLevelIcon from './heading-level-icon';
+import useIsHeadingLevelValid from './use-is-heading-level-valid';
const HEADING_LEVELS = [ 1, 2, 3, 4, 5, 6 ];
@@ -48,6 +49,8 @@ export default function HeadingLevelDropdown( {
selectedLevel,
onChange,
} ) {
+ const levelIsInvalid = useIsHeadingLevelValid( clientId, selectedLevel );
+
return (
}
+ className="block-library-heading__heading-level-dropdown-button"
+ icon={
+ <>
+
+ { levelIsInvalid && (
+
+ ) }
+ >
+ }
label={ __( 'Change heading level' ) }
onClick={ onToggle }
onKeyDown={ openOnArrowDown }
@@ -113,7 +124,10 @@ export default function HeadingLevelDropdown( {
} ) }
/>
-
+
>
) }
/>
diff --git a/packages/block-library/src/heading/use-is-heading-level-valid.js b/packages/block-library/src/heading/use-is-heading-level-valid.js
new file mode 100644
index 00000000000000..1140fd9a7f26f8
--- /dev/null
+++ b/packages/block-library/src/heading/use-is-heading-level-valid.js
@@ -0,0 +1,85 @@
+/**
+ * External dependencies
+ */
+import { flatMap } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+
+// Copied from packages/editor/src/components/document-outline/index.js.
+/**
+ * Returns an array of heading blocks enhanced with the following properties:
+ * path - An array of blocks that are ancestors of the heading starting from a top-level node.
+ * Can be an empty array if the heading is a top-level node (is not nested inside another block).
+ * level - An integer with the heading level.
+ * isEmpty - Flag indicating if the heading has no content.
+ *
+ * @param {?Array} blocks An array of blocks.
+ * @param {?Array} path An array of blocks that are ancestors of the blocks passed as blocks.
+ *
+ * @return {Array} An array of heading blocks enhanced with the properties described above.
+ */
+function computeOutlineHeadings( blocks = [], path = [] ) {
+ // We don't polyfill native JS [].flatMap yet, so we have to use Lodash.
+ return flatMap( blocks, ( block = {} ) => {
+ if ( block.name === 'core/heading' ) {
+ return {
+ ...block,
+ path,
+ level: block.attributes.level,
+ };
+ }
+ return computeOutlineHeadings( block.innerBlocks, [ ...path, block ] );
+ } );
+}
+
+export default function useIsHeadingLevelValid(
+ currentBlockClientId,
+ selectedLevel
+) {
+ const { headings, isTitleSupported, titleIsNotEmpty } = useSelect(
+ ( select ) => {
+ const { getPostType } = select( 'core' );
+ const { getBlocks } = select( 'core/block-editor' );
+ const { getEditedPostAttribute } = select( 'core/editor' );
+ const postType = getPostType( getEditedPostAttribute( 'type' ) );
+
+ return {
+ headings: computeOutlineHeadings( getBlocks() ?? [] ),
+ isTitleSupported: postType?.supports?.title ?? false,
+ titleIsNotEmpty: !! getEditedPostAttribute( 'title' ),
+ };
+ },
+ []
+ );
+
+ // Default the assumed previous level to H1.
+ let prevLevel = 1;
+
+ const currentHeadingIndex = headings.findIndex(
+ ( { clientId } ) => clientId === currentBlockClientId
+ );
+
+ // If the current block isn't the first Heading block in the content, set
+ // prevLevel to the level of the closest Heading block preceding it.
+ if ( currentHeadingIndex > 0 ) {
+ prevLevel = headings[ currentHeadingIndex - 1 ].level;
+ }
+
+ const titleNode = document.getElementsByClassName(
+ 'editor-post-title__input'
+ )[ 0 ];
+ const hasTitle = isTitleSupported && titleIsNotEmpty && titleNode;
+ const hasMultipleH1 =
+ headings.filter( ( { level } ) => level === 1 ).length > 1;
+ const levelIsDuplicateH1 = hasMultipleH1 && selectedLevel === 1;
+ const levelAndPostTitleAreBothH1 =
+ selectedLevel === 1 && hasTitle && ! hasMultipleH1;
+ const levelIsTooDeep = selectedLevel > prevLevel + 1;
+ const levelIsInvalid =
+ levelIsDuplicateH1 || levelAndPostTitleAreBothH1 || levelIsTooDeep;
+
+ return levelIsInvalid;
+}