diff --git a/packages/edit-site/src/components/page-patterns/grid-item.js b/packages/edit-site/src/components/page-patterns/grid-item.js index 7f40fbce9035c..d8e3b2fe16d53 100644 --- a/packages/edit-site/src/components/page-patterns/grid-item.js +++ b/packages/edit-site/src/components/page-patterns/grid-item.js @@ -18,13 +18,14 @@ import { Flex, } from '@wordpress/components'; import { useDispatch } from '@wordpress/data'; -import { useState, useId } from '@wordpress/element'; +import { useState, useId, memo } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { Icon, header, footer, symbolFilled as uncategorized, + symbol, moreHorizontal, lockSmall, } from '@wordpress/icons'; @@ -37,13 +38,13 @@ import { DELETE, BACKSPACE } from '@wordpress/keycodes'; */ import RenameMenuItem from './rename-menu-item'; import DuplicateMenuItem from './duplicate-menu-item'; -import { PATTERNS, TEMPLATE_PARTS, USER_PATTERNS } from './utils'; +import { PATTERNS, TEMPLATE_PARTS, USER_PATTERNS, SYNC_TYPES } from './utils'; import { store as editSiteStore } from '../../store'; import { useLink } from '../routes/link'; const templatePartIcons = { header, footer, uncategorized }; -export default function GridItem( { categoryId, composite, icon, item } ) { +function GridItem( { categoryId, item, ...props } ) { const descriptionId = useId(); const [ isDeleteDialogOpen, setIsDeleteDialogOpen ] = useState( false ); @@ -122,9 +123,9 @@ export default function GridItem( { categoryId, composite, icon, item } ) { ariaDescriptions.push( __( 'Theme patterns cannot be edited.' ) ); } - const itemIcon = templatePartIcons[ categoryId ] - ? templatePartIcons[ categoryId ] - : icon; + const itemIcon = + templatePartIcons[ categoryId ] || + ( item.syncStatus === SYNC_TYPES.full ? symbol : undefined ); const confirmButtonText = hasThemeFile ? __( 'Clear' ) : __( 'Delete' ); const confirmPrompt = hasThemeFile @@ -142,7 +143,10 @@ export default function GridItem( { categoryId, composite, icon, item } ) { className={ previewClassNames } role="option" as="div" - { ...composite } + // Even though still incomplete, passing ids helps performance. + // @see https://reakit.io/docs/composite/#performance. + id={ `edit-site-patterns-${ item.name }` } + { ...props } onClick={ item.type !== PATTERNS ? onClick : undefined } onKeyDown={ isCustomPattern ? onKeyDown : undefined } aria-label={ item.title } @@ -180,7 +184,7 @@ export default function GridItem( { categoryId, composite, icon, item } ) { spacing={ 3 } className="edit-site-patterns__pattern-title" > - { icon && ( + { itemIcon && ( ); } + +export default memo( GridItem ); diff --git a/packages/edit-site/src/components/page-patterns/grid.js b/packages/edit-site/src/components/page-patterns/grid.js index 3f6e5fd01f72f..b9b7053c112ce 100644 --- a/packages/edit-site/src/components/page-patterns/grid.js +++ b/packages/edit-site/src/components/page-patterns/grid.js @@ -4,36 +4,56 @@ import { __unstableComposite as Composite, __unstableUseCompositeState as useCompositeState, + __experimentalText as Text, } from '@wordpress/components'; +import { useRef } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ import GridItem from './grid-item'; -export default function Grid( { categoryId, label, icon, items } ) { - const composite = useCompositeState( { orientation: 'vertical' } ); +const PAGE_SIZE = 100; + +export default function Grid( { categoryId, items, ...props } ) { + const composite = useCompositeState( { wrap: true } ); + const gridRef = useRef(); if ( ! items?.length ) { return null; } + const list = items.slice( 0, PAGE_SIZE ); + const restLength = items.length - PAGE_SIZE; + return ( - - { items.map( ( item ) => ( - - ) ) } - + <> + + { list.map( ( item ) => ( + + ) ) } + + { restLength > 0 && ( + + { sprintf( + /* translators: %d: number of patterns */ + __( '+ %d more patterns discoverable by searching' ), + restLength + ) } + + ) } + ); } diff --git a/packages/edit-site/src/components/page-patterns/header.js b/packages/edit-site/src/components/page-patterns/header.js new file mode 100644 index 0000000000000..1237b85d6c978 --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/header.js @@ -0,0 +1,69 @@ +/** + * WordPress dependencies + */ +import { + __experimentalVStack as VStack, + __experimentalHeading as Heading, + __experimentalText as Text, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { store as editorStore } from '@wordpress/editor'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import usePatternCategories from '../sidebar-navigation-screen-patterns/use-pattern-categories'; +import { + USER_PATTERN_CATEGORY, + USER_PATTERNS, + TEMPLATE_PARTS, + PATTERNS, +} from './utils'; + +export default function PatternsHeader( { + categoryId, + type, + titleId, + descriptionId, +} ) { + const { patternCategories } = usePatternCategories(); + const templatePartAreas = useSelect( + ( select ) => + select( editorStore ).__experimentalGetDefaultTemplatePartAreas(), + [] + ); + + let title, description; + if ( categoryId === USER_PATTERN_CATEGORY && type === USER_PATTERNS ) { + title = __( 'My Patterns' ); + description = ''; + } else if ( type === TEMPLATE_PARTS ) { + const templatePartArea = templatePartAreas.find( + ( area ) => area.area === categoryId + ); + title = templatePartArea?.label; + description = templatePartArea?.description; + } else if ( type === PATTERNS ) { + const patternCategory = patternCategories.find( + ( category ) => category.name === categoryId + ); + title = patternCategory?.label; + description = patternCategory?.description; + } + + if ( ! title ) return null; + + return ( + + + { title } + + { description ? ( + + { description } + + ) : null } + + ); +} diff --git a/packages/edit-site/src/components/page-patterns/index.js b/packages/edit-site/src/components/page-patterns/index.js index 961ed51f39e5d..d90fc74844244 100644 --- a/packages/edit-site/src/components/page-patterns/index.js +++ b/packages/edit-site/src/components/page-patterns/index.js @@ -32,7 +32,12 @@ export default function PagePatterns() { title={ __( 'Patterns content' ) } hideTitleFromUI > - + ); diff --git a/packages/edit-site/src/components/page-patterns/patterns-list.js b/packages/edit-site/src/components/page-patterns/patterns-list.js index d59596f20e795..bc2a18bf39456 100644 --- a/packages/edit-site/src/components/page-patterns/patterns-list.js +++ b/packages/edit-site/src/components/page-patterns/patterns-list.js @@ -1,51 +1,87 @@ /** * WordPress dependencies */ - +import { useState, useDeferredValue, useId } from '@wordpress/element'; import { SearchControl, - __experimentalHeading as Heading, - __experimentalText as Text, __experimentalVStack as VStack, Flex, FlexBlock, + __experimentalToggleGroupControl as ToggleGroupControl, + __experimentalToggleGroupControlOption as ToggleGroupControlOption, + __experimentalHeading as Heading, + __experimentalText as Text, } from '@wordpress/components'; import { __, isRTL } from '@wordpress/i18n'; -import { symbol, chevronLeft, chevronRight } from '@wordpress/icons'; +import { chevronLeft, chevronRight } from '@wordpress/icons'; import { privateApis as routerPrivateApis } from '@wordpress/router'; -import { useViewportMatch } from '@wordpress/compose'; +import { useViewportMatch, useAsyncList } from '@wordpress/compose'; /** * Internal dependencies */ +import PatternsHeader from './header'; import Grid from './grid'; import NoPatterns from './no-patterns'; import usePatterns from './use-patterns'; import SidebarButton from '../sidebar-button'; import useDebouncedInput from '../../utils/use-debounced-input'; import { unlock } from '../../lock-unlock'; +import { SYNC_TYPES, USER_PATTERN_CATEGORY } from './utils'; const { useLocation, useHistory } = unlock( routerPrivateApis ); +const SYNC_FILTERS = { + all: __( 'All' ), + [ SYNC_TYPES.full ]: __( 'Synced' ), + [ SYNC_TYPES.unsynced ]: __( 'Standard' ), +}; + +const SYNC_DESCRIPTIONS = { + all: '', + [ SYNC_TYPES.full ]: __( + 'Patterns that are kept in sync across your site.' + ), + [ SYNC_TYPES.unsynced ]: __( + 'Patterns that can be changed freely without affecting your site.' + ), +}; + export default function PatternsList( { categoryId, type } ) { const location = useLocation(); const history = useHistory(); const isMobileViewport = useViewportMatch( 'medium', '<' ); const [ filterValue, setFilterValue, delayedFilterValue ] = useDebouncedInput( '' ); + const deferredFilterValue = useDeferredValue( delayedFilterValue ); - const [ patterns, isResolving ] = usePatterns( - type, - categoryId, - delayedFilterValue - ); + const [ syncFilter, setSyncFilter ] = useState( 'all' ); + const deferredSyncedFilter = useDeferredValue( syncFilter ); + const { patterns, isResolving } = usePatterns( type, categoryId, { + search: deferredFilterValue, + syncStatus: + deferredSyncedFilter === 'all' ? undefined : deferredSyncedFilter, + } ); - const { syncedPatterns, unsyncedPatterns } = patterns; - const hasPatterns = !! syncedPatterns.length || !! unsyncedPatterns.length; + const id = useId(); + const titleId = `${ id }-title`; + const descriptionId = `${ id }-description`; + + const hasPatterns = patterns.length; + const title = SYNC_FILTERS[ syncFilter ]; + const description = SYNC_DESCRIPTIONS[ syncFilter ]; + const shownPatterns = useAsyncList( patterns ); return ( - + + + { isMobileViewport && ( ) } - + setFilterValue( value ) } @@ -71,46 +107,48 @@ export default function PatternsList( { categoryId, type } ) { __nextHasNoMarginBottom /> + { categoryId === USER_PATTERN_CATEGORY && ( + setSyncFilter( value ) } + __nextHasNoMarginBottom + > + { Object.entries( SYNC_FILTERS ).map( + ( [ key, label ] ) => ( + + ) + ) } + + ) } - { isResolving && __( 'Loading' ) } - { ! isResolving && !! syncedPatterns.length && ( - <> - - - { __( 'Synced' ) } - - - { __( - 'Patterns that are kept in sync across the site' - ) } + { syncFilter !== 'all' && ( + + + { title } + + { description ? ( + + { description } - - - + ) : null } + ) } - { ! isResolving && !! unsyncedPatterns.length && ( - <> - - - { __( 'Standard' ) } - - - { __( - 'Patterns that can be changed freely without affecting the site' - ) } - - - - + { hasPatterns && ( + ) } { ! isResolving && ! hasPatterns && } diff --git a/packages/edit-site/src/components/page-patterns/style.scss b/packages/edit-site/src/components/page-patterns/style.scss index 7a7bf026b9c62..64dcb61ac1a74 100644 --- a/packages/edit-site/src/components/page-patterns/style.scss +++ b/packages/edit-site/src/components/page-patterns/style.scss @@ -1,6 +1,13 @@ .edit-site-patterns { - background: rgba(0, 0, 0, 0.05); + background: rgba(0, 0, 0, 0.15); margin: $header-height 0 0; + .components-base-control { + width: 100%; + @include break-medium { + width: auto; + } + } + .components-text { color: $gray-600; } @@ -12,25 +19,63 @@ @include break-medium { margin: 0; } -} -.edit-site-patterns__grid { - column-gap: $grid-unit-30; - @include break-large() { - column-count: 2; + .edit-site-patterns__search-block { + min-width: fit-content; + flex-grow: 1; } + // The increased specificity here is to overcome component styles + // without relying on internal component class names. + .edit-site-patterns__search { + input[type="search"] { + height: $button-size-next-default-40px; + background: $gray-800; + color: $gray-200; + + &:focus { + background: $gray-800; + } + } + + svg { + fill: $gray-600; + } + } + + .edit-site-patterns__sync-status-filter { + background: $gray-800; + border: none; + height: $button-size-next-default-40px; + min-width: max-content; + width: 100%; + max-width: 100%; + + @include break-medium { + width: 300px; + } + } + .edit-site-patterns__sync-status-filter-option:active { + background: $gray-700; + color: $gray-100; + } +} + +.edit-site-patterns__grid { + display: grid; + grid-template-columns: 1fr; + gap: $grid-unit-40; // Small top padding required to avoid cutting off the visible outline // when hovering items. padding-top: $border-width-focus-fallback; margin-bottom: $grid-unit-40; - + @include break-large { + grid-template-columns: 1fr 1fr; + } .edit-site-patterns__pattern { break-inside: avoid-column; display: flex; flex-direction: column; - margin-bottom: $grid-unit-60; - .edit-site-patterns__preview { border-radius: $radius-block-ui; cursor: pointer; @@ -68,26 +113,13 @@ } .edit-site-patterns__preview { - flex: 1; + flex: 0 1 auto; margin-bottom: $grid-unit-20; } } -// The increased specificity here is to overcome component styles -// without relying on internal component class names. -.edit-site-patterns__search { - &#{&} input[type="search"] { - background: $gray-800; - color: $gray-200; - - &:focus { - background: $gray-800; - } - } - - svg { - fill: $gray-600; - } +.edit-site-patterns__load-more { + align-self: center; } .edit-site-patterns__pattern-title { diff --git a/packages/edit-site/src/components/page-patterns/use-patterns.js b/packages/edit-site/src/components/page-patterns/use-patterns.js index 295d1eee8e410..0bcc52c85cb62 100644 --- a/packages/edit-site/src/components/page-patterns/use-patterns.js +++ b/packages/edit-site/src/components/page-patterns/use-patterns.js @@ -4,7 +4,6 @@ import { parse } from '@wordpress/blocks'; import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; -import { useMemo } from '@wordpress/element'; /** * Internal dependencies @@ -15,7 +14,6 @@ import { SYNC_TYPES, TEMPLATE_PARTS, USER_PATTERNS, - USER_PATTERN_CATEGORY, filterOutDuplicatesByName, } from './utils'; import { unlock } from '../../lock-unlock'; @@ -43,106 +41,64 @@ const templatePartToPattern = ( templatePart ) => ( { const templatePartHasCategory = ( item, category ) => item.templatePart.area === category; -const useTemplatePartsAsPatterns = ( - categoryId, - postType = TEMPLATE_PARTS, - filterValue = '' +const selectTemplatePartsAsPatterns = ( + select, + { categoryId, search = '' } = {} ) => { - const { templateParts, isResolving } = useSelect( - ( select ) => { - if ( postType !== TEMPLATE_PARTS ) { - return { - templateParts: EMPTY_PATTERN_LIST, - isResolving: false, - }; - } - - const { getEntityRecords, isResolving: _isResolving } = - select( coreStore ); - const query = { per_page: -1 }; - const rawTemplateParts = getEntityRecords( - 'postType', - postType, - query - ); - const partsAsPatterns = rawTemplateParts?.map( ( templatePart ) => - templatePartToPattern( templatePart ) - ); - - return { - templateParts: partsAsPatterns, - isResolving: _isResolving( 'getEntityRecords', [ - 'postType', - 'wp_template_part', - query, - ] ), - }; - }, - [ postType ] + const { getEntityRecords, getIsResolving } = select( coreStore ); + const query = { per_page: -1 }; + const rawTemplateParts = + getEntityRecords( 'postType', TEMPLATE_PARTS, query ) ?? + EMPTY_PATTERN_LIST; + const templateParts = rawTemplateParts.map( ( templatePart ) => + templatePartToPattern( templatePart ) ); - const filteredTemplateParts = useMemo( () => { - if ( ! templateParts ) { - return EMPTY_PATTERN_LIST; - } + const isResolving = getIsResolving( 'getEntityRecords', [ + 'postType', + 'wp_template_part', + query, + ] ); - return searchItems( templateParts, filterValue, { - categoryId, - hasCategory: templatePartHasCategory, - } ); - }, [ templateParts, filterValue, categoryId ] ); + const patterns = searchItems( templateParts, search, { + categoryId, + hasCategory: templatePartHasCategory, + } ); - return { templateParts: filteredTemplateParts, isResolving }; + return { patterns, isResolving }; }; -const useThemePatterns = ( - categoryId, - postType = PATTERNS, - filterValue = '' -) => { - const blockPatterns = useSelect( ( select ) => { - const { getSettings } = unlock( select( editSiteStore ) ); - const settings = getSettings(); - return ( - settings.__experimentalAdditionalBlockPatterns ?? - settings.__experimentalBlockPatterns - ); +const selectThemePatterns = ( select, { categoryId, search = '' } = {} ) => { + const { getSettings } = unlock( select( editSiteStore ) ); + const settings = getSettings(); + const blockPatterns = + settings.__experimentalAdditionalBlockPatterns ?? + settings.__experimentalBlockPatterns; + + const restBlockPatterns = select( coreStore ).getBlockPatterns(); + + let patterns = [ + ...( blockPatterns || [] ), + ...( restBlockPatterns || [] ), + ] + .filter( + ( pattern ) => ! CORE_PATTERN_SOURCES.includes( pattern.source ) + ) + .filter( filterOutDuplicatesByName ) + .map( ( pattern ) => ( { + ...pattern, + keywords: pattern.keywords || [], + type: 'pattern', + blocks: parse( pattern.content ), + } ) ); + + patterns = searchItems( patterns, search, { + categoryId, + hasCategory: ( item, currentCategory ) => + item.categories?.includes( currentCategory ), } ); - const restBlockPatterns = useSelect( ( select ) => - select( coreStore ).getBlockPatterns() - ); - - const patterns = useMemo( - () => - [ ...( blockPatterns || [] ), ...( restBlockPatterns || [] ) ] - .filter( - ( pattern ) => - ! CORE_PATTERN_SOURCES.includes( pattern.source ) - ) - .filter( filterOutDuplicatesByName ) - .map( ( pattern ) => ( { - ...pattern, - keywords: pattern.keywords || [], - type: 'pattern', - blocks: parse( pattern.content ), - } ) ), - [ blockPatterns, restBlockPatterns ] - ); - - const filteredPatterns = useMemo( () => { - if ( postType !== PATTERNS ) { - return EMPTY_PATTERN_LIST; - } - - return searchItems( patterns, filterValue, { - categoryId, - hasCategory: ( item, currentCategory ) => - item.categories?.includes( currentCategory ), - } ); - }, [ patterns, filterValue, categoryId, postType ] ); - - return filteredPatterns; + return { patterns, isResolving: false }; }; const reusableBlockToPattern = ( reusableBlock ) => ( { @@ -156,88 +112,58 @@ const reusableBlockToPattern = ( reusableBlock ) => ( { reusableBlock, } ); -const useUserPatterns = ( - categoryId, - categoryType = PATTERNS, - filterValue = '' -) => { - const postType = categoryType === PATTERNS ? USER_PATTERNS : categoryType; - const unfilteredPatterns = useSelect( - ( select ) => { - if ( - postType !== USER_PATTERNS || - categoryId !== USER_PATTERN_CATEGORY - ) { - return EMPTY_PATTERN_LIST; - } +const selectUserPatterns = ( select, { search = '', syncStatus } = {} ) => { + const { getEntityRecords, getIsResolving } = select( coreStore ); - const { getEntityRecords } = select( coreStore ); - const records = getEntityRecords( 'postType', postType, { - per_page: -1, - } ); + const query = { per_page: -1 }; + const records = getEntityRecords( 'postType', USER_PATTERNS, query ); - if ( ! records ) { - return EMPTY_PATTERN_LIST; - } + let patterns = records + ? records.map( ( record ) => reusableBlockToPattern( record ) ) + : EMPTY_PATTERN_LIST; + const isResolving = getIsResolving( 'getEntityRecords', [ + 'postType', + USER_PATTERNS, + query, + ] ); - return records.map( ( record ) => - reusableBlockToPattern( record ) - ); - }, - [ postType, categoryId ] - ); + if ( syncStatus ) { + patterns = patterns.filter( + ( pattern ) => pattern.syncStatus === syncStatus + ); + } - const filteredPatterns = useMemo( () => { - if ( ! unfilteredPatterns.length ) { - return EMPTY_PATTERN_LIST; - } - - return searchItems( unfilteredPatterns, filterValue, { - // We exit user pattern retrieval early if we aren't in the - // catch-all category for user created patterns, so it has - // to be in the category. - hasCategory: () => true, - } ); - }, [ unfilteredPatterns, filterValue ] ); - - const patterns = { syncedPatterns: [], unsyncedPatterns: [] }; - - filteredPatterns.forEach( ( pattern ) => { - if ( pattern.syncStatus === SYNC_TYPES.full ) { - patterns.syncedPatterns.push( pattern ); - } else { - patterns.unsyncedPatterns.push( pattern ); - } + patterns = searchItems( patterns, search, { + // We exit user pattern retrieval early if we aren't in the + // catch-all category for user created patterns, so it has + // to be in the category. + hasCategory: () => true, } ); - return patterns; + return { patterns, isResolving }; }; -export const usePatterns = ( categoryType, categoryId, filterValue ) => { - const blockPatterns = useThemePatterns( - categoryId, - categoryType, - filterValue - ); - - const { syncedPatterns = [], unsyncedPatterns = [] } = useUserPatterns( - categoryId, - categoryType, - filterValue - ); - - const { templateParts, isResolving } = useTemplatePartsAsPatterns( - categoryId, - categoryType, - filterValue +export const usePatterns = ( + categoryType, + categoryId, + { search = '', syncStatus } +) => { + return useSelect( + ( select ) => { + if ( categoryType === TEMPLATE_PARTS ) { + return selectTemplatePartsAsPatterns( select, { + categoryId, + search, + } ); + } else if ( categoryType === PATTERNS ) { + return selectThemePatterns( select, { categoryId, search } ); + } else if ( categoryType === USER_PATTERNS ) { + return selectUserPatterns( select, { search, syncStatus } ); + } + return { patterns: EMPTY_PATTERN_LIST, isResolving: false }; + }, + [ categoryId, categoryType, search, syncStatus ] ); - - const patterns = { - syncedPatterns: [ ...templateParts, ...syncedPatterns ], - unsyncedPatterns: [ ...blockPatterns, ...unsyncedPatterns ], - }; - - return [ patterns, isResolving ]; }; export default usePatterns; diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-my-patterns.js b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-my-patterns.js index e3d5cc297164a..37f0b0f8a4e06 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-my-patterns.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-my-patterns.js @@ -6,18 +6,19 @@ import { useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; export default function useMyPatterns() { - const myPatterns = useSelect( ( select ) => - select( coreStore ).getEntityRecords( 'postType', 'wp_block', { - per_page: -1, - } ) + const myPatternsCount = useSelect( + ( select ) => + select( coreStore ).getEntityRecords( 'postType', 'wp_block', { + per_page: -1, + } )?.length ?? 0 ); return { myPatterns: { - count: myPatterns?.length || 0, + count: myPatternsCount, name: 'my-patterns', label: __( 'My patterns' ), }, - hasPatterns: !! myPatterns?.length, + hasPatterns: myPatternsCount > 0, }; }