From 8dd9b1da04728ffe773a8241cd293f256312c30d Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Mon, 1 Jun 2020 20:57:00 -0500 Subject: [PATCH] Lots of improvements. --- .../block-library/src/heading/editor.scss | 12 +- .../src/heading/heading-level-checker.js | 117 ++++-------------- .../src/heading/heading-level-dropdown.js | 18 ++- .../src/heading/use-is-heading-level-valid.js | 85 +++++++++++++ 4 files changed, 136 insertions(+), 96 deletions(-) create mode 100644 packages/block-library/src/heading/use-is-heading-level-valid.js diff --git a/packages/block-library/src/heading/editor.scss b/packages/block-library/src/heading/editor.scss index 05040868113b6..9e001103788f4 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 a206c3251abc9..4f53ce9651612 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 6b3b7ecb8dbc5..04ae9fdb66c8e 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 0000000000000..1140fd9a7f26f --- /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; +}