diff --git a/packages/block-library/src/heading/edit.js b/packages/block-library/src/heading/edit.js index d4a353c1b0ead4..166a8920542772 100644 --- a/packages/block-library/src/heading/edit.js +++ b/packages/block-library/src/heading/edit.js @@ -23,6 +23,7 @@ import HeadingLevelDropdown from './heading-level-dropdown'; function HeadingEdit( { attributes, + clientId, setAttributes, mergeBlocks, onReplace, @@ -36,6 +37,7 @@ function HeadingEdit( { setAttributes( { level: newLevel } ) diff --git a/packages/block-library/src/heading/editor.scss b/packages/block-library/src/heading/editor.scss index 522266d9a9a9e3..05040868113b61 100644 --- a/packages/block-library/src/heading/editor.scss +++ b/packages/block-library/src/heading/editor.scss @@ -12,3 +12,7 @@ .block-library-heading-level-toolbar { border: none; } + +.block-library-heading__heading-level-checker { + margin: 0; +} diff --git a/packages/block-library/src/heading/heading-level-checker.js b/packages/block-library/src/heading/heading-level-checker.js new file mode 100644 index 00000000000000..a206c3251abc96 --- /dev/null +++ b/packages/block-library/src/heading/heading-level-checker.js @@ -0,0 +1,107 @@ +/** + * 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; + + // For accessibility + useEffect( () => { + if ( isIncorrectLevel ) speak( msg ); + }, [ isIncorrectLevel, selectedLevel ] ); + + 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 { + return null; + } + + return ( +
+ + { msg } + +
+ ); +}; + +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 5039a30217656d..6b3b7ecb8dbc5a 100644 --- a/packages/block-library/src/heading/heading-level-dropdown.js +++ b/packages/block-library/src/heading/heading-level-dropdown.js @@ -13,6 +13,7 @@ import { DOWN } from '@wordpress/keycodes'; /** * Internal dependencies */ +import HeadingLevelChecker from './heading-level-checker'; import HeadingLevelIcon from './heading-level-icon'; const HEADING_LEVELS = [ 1, 2, 3, 4, 5, 6 ]; @@ -29,6 +30,7 @@ const POPOVER_PROPS = { * * @typedef WPHeadingLevelDropdownProps * + * @property {any} clientId The current block client id. * @property {number} selectedLevel The chosen heading level. * @property {(newValue:number)=>any} onChange Callback to run when * toolbar value is changed. @@ -41,7 +43,11 @@ const POPOVER_PROPS = { * * @return {WPComponent} The toolbar. */ -export default function HeadingLevelDropdown( { selectedLevel, onChange } ) { +export default function HeadingLevelDropdown( { + clientId, + selectedLevel, + onChange, +} ) { return ( ( - - { - const isActive = targetLevel === selectedLevel; - return { - icon: ( - - ), - title: sprintf( - // translators: %s: heading level e.g: "1", "2", "3" - __( 'Heading %d' ), - targetLevel - ), - isActive, - onClick() { - onChange( targetLevel ); - }, - // Temporary workaround for macOS Firefox/Safari issue - // where clicking buttons in the heading level toolbar - // doesn't work. - // TODO: Replace this with a more general solution. - // https://github.com/WordPress/gutenberg/pull/20246#pullrequestreview-417338057 - onMouseDown( event ) { - event.preventDefault(); - event.currentTarget.focus(); - }, - }; - } ) } - /> - + <> + + { + const isActive = targetLevel === selectedLevel; + return { + icon: ( + + ), + title: sprintf( + // translators: %s: heading level e.g: "1", "2", "3" + __( 'Heading %d' ), + targetLevel + ), + isActive, + onClick() { + onChange( targetLevel ); + }, + // Temporary workaround for macOS Firefox/Safari issue + // where clicking buttons in the heading level toolbar + // doesn't work. + // TODO: Replace this with a more general solution. + // https://github.com/WordPress/gutenberg/pull/20246#pullrequestreview-417338057 + onMouseDown( event ) { + event.preventDefault(); + event.currentTarget.focus(); + }, + }; + } ) } + /> + + + ) } /> ); diff --git a/packages/editor/src/components/document-outline/index.js b/packages/editor/src/components/document-outline/index.js index 3609f51c42a637..89396e49815731 100644 --- a/packages/editor/src/components/document-outline/index.js +++ b/packages/editor/src/components/document-outline/index.js @@ -49,7 +49,7 @@ const multipleH1Headings = [ * * @return {Array} An array of heading blocks enhanced with the properties described above. */ -const computeOutlineHeadings = ( blocks = [], path = [] ) => { +export const computeOutlineHeadings = ( blocks = [], path = [] ) => { return flatMap( blocks, ( block = {} ) => { if ( block.name === 'core/heading' ) { return {