diff --git a/packages/base-styles/_z-index.scss b/packages/base-styles/_z-index.scss index 76cfad0b424a1..f5b6331e6fa58 100644 --- a/packages/base-styles/_z-index.scss +++ b/packages/base-styles/_z-index.scss @@ -18,6 +18,7 @@ $z-layers: ( ".block-editor-inserter__tabs .components-tab-panel__tab-content": 0, // lower scrolling content ".block-editor-inserter__tabs .components-tab-panel__tabs": 1, // higher sticky element ".block-editor-inserter__search": 1, // higher sticky element + ".block-library-template-part__selection-search": 1, // higher sticky element // These next two share a stacking context ".interface-complementary-area .components-panel" : 0, // lower scrolling content diff --git a/packages/block-library/src/template-part/edit/selection-modal.js b/packages/block-library/src/template-part/edit/selection-modal.js index a9e0ed68255ce..68a4c488fa875 100644 --- a/packages/block-library/src/template-part/edit/selection-modal.js +++ b/packages/block-library/src/template-part/edit/selection-modal.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { useCallback, useMemo } from '@wordpress/element'; +import { useCallback, useMemo, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { useDispatch } from '@wordpress/data'; @@ -11,6 +11,10 @@ import { __experimentalBlockPatternsList as BlockPatternsList, store as blockEditorStore, } from '@wordpress/block-editor'; +import { + SearchControl, + __experimentalHStack as HStack, +} from '@wordpress/components'; /** * Internal dependencies @@ -21,6 +25,7 @@ import { useCreateTemplatePartFromBlocks, } from './utils/hooks'; import { createTemplatePartId } from './utils/create-template-part-id'; +import { searchPatterns } from './utils/search'; export default function TemplatePartSelectionModal( { setAttributes, @@ -29,6 +34,8 @@ export default function TemplatePartSelectionModal( { area, clientId, } ) { + const [ searchValue, setSearchValue ] = useState( '' ); + // When the templatePartId is undefined, // it means the user is creating a new one from the placeholder. const isReplacingTemplatePartContent = !! templatePartId; @@ -37,18 +44,24 @@ export default function TemplatePartSelectionModal( { templatePartId ); // We can map template parts to block patters to reuse the BlockPatternsList UI - const templartPartsAsBlockPatterns = useMemo( () => { - return templateParts.map( ( templatePart ) => ( { + const filteredTemplateParts = useMemo( () => { + const partsAsPatterns = templateParts.map( ( templatePart ) => ( { name: createTemplatePartId( templatePart.theme, templatePart.slug ), title: templatePart.title.rendered, blocks: parse( templatePart.content.raw ), templatePart, } ) ); - }, [ templateParts ] ); - const shownTemplateParts = useAsyncList( templartPartsAsBlockPatterns ); - const { createSuccessNotice } = useDispatch( noticesStore ); + + return searchPatterns( partsAsPatterns, searchValue ); + }, [ templateParts, searchValue ] ); + const shownTemplateParts = useAsyncList( filteredTemplateParts ); const blockPatterns = useAlternativeBlockPatterns( area, clientId ); - const shownBlockPatterns = useAsyncList( blockPatterns ); + const filteredBlockPatterns = useMemo( () => { + return searchPatterns( blockPatterns, searchValue ); + }, [ blockPatterns, searchValue ] ); + const shownBlockPatterns = useAsyncList( filteredBlockPatterns ); + + const { createSuccessNotice } = useDispatch( noticesStore ); const { replaceInnerBlocks } = useDispatch( blockEditorStore ); const onTemplatePartSelect = useCallback( ( templatePart ) => { @@ -75,41 +88,56 @@ export default function TemplatePartSelectionModal( { setAttributes ); + const hasTemplateParts = !! filteredTemplateParts.length; + const hasBlockPatterns = !! filteredBlockPatterns.length; + return ( - <> -
- { !! templartPartsAsBlockPatterns.length && ( -
-

{ __( 'Existing template parts' ) }

- { - onTemplatePartSelect( pattern.templatePart ); - } } - /> -
- ) } +
+
+ +
+ { hasTemplateParts && ( +
+

{ __( 'Existing template parts' ) }

+ { + onTemplatePartSelect( pattern.templatePart ); + } } + /> +
+ ) } - { !! blockPatterns.length && ( -
-

{ __( 'Patterns' ) }

- { - if ( isReplacingTemplatePartContent ) { - replaceInnerBlocks( clientId, blocks ); - } else { - createFromBlocks( blocks, pattern.title ); - } + { hasBlockPatterns && ( +
+

{ __( 'Patterns' ) }

+ { + if ( isReplacingTemplatePartContent ) { + replaceInnerBlocks( clientId, blocks ); + } else { + createFromBlocks( blocks, pattern.title ); + } - onClose(); - } } - /> -
- ) } -
- + onClose(); + } } + /> +
+ ) } + + { ! hasTemplateParts && ! hasBlockPatterns && ( + +

{ __( 'No results found.' ) }

+
+ ) } +
); } diff --git a/packages/block-library/src/template-part/edit/utils/search.js b/packages/block-library/src/template-part/edit/utils/search.js new file mode 100644 index 0000000000000..46db0a5ad14a7 --- /dev/null +++ b/packages/block-library/src/template-part/edit/utils/search.js @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import removeAccents from 'remove-accents'; + +/** + * Sanitizes the search input string. + * + * @param {string} input The search input to normalize. + * + * @return {string} The normalized search input. + */ +function normalizeSearchInput( input = '' ) { + // Disregard diacritics. + input = removeAccents( input ); + + // Trim & Lowercase. + input = input.trim().toLowerCase(); + + return input; +} + +/** + * Get the search rank for a given pattern and a specific search term. + * + * @param {Object} pattern Pattern to rank + * @param {string} searchValue Search term + * @return {number} A pattern search rank + */ +function getPatternSearchRank( pattern, searchValue ) { + const normalizedSearchValue = normalizeSearchInput( searchValue ); + const normalizedTitle = normalizeSearchInput( pattern.title ); + + let rank = 0; + + if ( normalizedSearchValue === normalizedTitle ) { + rank += 30; + } else if ( normalizedTitle.startsWith( normalizedSearchValue ) ) { + rank += 20; + } else { + const searchTerms = normalizedSearchValue.split( ' ' ); + const hasMatchedTerms = searchTerms.every( ( searchTerm ) => + normalizedTitle.includes( searchTerm ) + ); + + // Prefer pattern with every search word in the title. + if ( hasMatchedTerms ) { + rank += 10; + } + } + + return rank; +} + +/** + * Filters an pattern list given a search term. + * + * @param {Array} patterns Item list + * @param {string} searchValue Search input. + * + * @return {Array} Filtered pattern list. + */ +export function searchPatterns( patterns = [], searchValue = '' ) { + if ( ! searchValue ) { + return patterns; + } + + const rankedPatterns = patterns + .map( ( pattern ) => { + return [ pattern, getPatternSearchRank( pattern, searchValue ) ]; + } ) + .filter( ( [ , rank ] ) => rank > 0 ); + + rankedPatterns.sort( ( [ , rank1 ], [ , rank2 ] ) => rank2 - rank1 ); + return rankedPatterns.map( ( [ pattern ] ) => pattern ); +} diff --git a/packages/block-library/src/template-part/editor.scss b/packages/block-library/src/template-part/editor.scss index ca1011bf5522d..c17d37a6078e2 100644 --- a/packages/block-library/src/template-part/editor.scss +++ b/packages/block-library/src/template-part/editor.scss @@ -16,3 +16,11 @@ } } } + +.block-library-template-part__selection-search { + background: $white; + position: sticky; + top: 0; + padding: $grid-unit-20 0; + z-index: z-index(".block-library-template-part__selection-search"); +}