diff --git a/packages/block-editor/src/components/block-patterns-list/index.js b/packages/block-editor/src/components/block-patterns-list/index.js index ec2aad3da2b7a..302fe731d2187 100644 --- a/packages/block-editor/src/components/block-patterns-list/index.js +++ b/packages/block-editor/src/components/block-patterns-list/index.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ @@ -8,9 +13,11 @@ import { __unstableUseCompositeState as useCompositeState, __unstableCompositeItem as CompositeItem, Tooltip, + __experimentalHStack as HStack, } from '@wordpress/components'; import { useInstanceId } from '@wordpress/compose'; import { __ } from '@wordpress/i18n'; +import { Icon, symbol } from '@wordpress/icons'; /** * Internal dependencies @@ -63,14 +70,20 @@ function BlockPattern( { } } > { onClick( pattern, blocks ); onHover?.( null ); @@ -91,11 +104,23 @@ function BlockPattern( { blocks={ blocks } viewportWidth={ viewportWidth } /> - { ! showTooltip && ( -
- { pattern.title } -
- ) } + + + { pattern.id && ! pattern.syncStatus && ( +
+ +
+ ) } + { ( ! showTooltip || pattern.id ) && ( +
+ { pattern.title } +
+ ) } +
+ { !! pattern.description && ( { pattern.description } diff --git a/packages/block-editor/src/components/block-patterns-list/style.scss b/packages/block-editor/src/components/block-patterns-list/style.scss index ab80fc71d36df..e3b38deff5ef7 100644 --- a/packages/block-editor/src/components/block-patterns-list/style.scss +++ b/packages/block-editor/src/components/block-patterns-list/style.scss @@ -11,7 +11,7 @@ min-height: 100px; } - &[draggable="true"] .block-editor-block-preview__container { + &[draggable="true"] { cursor: grab; } } @@ -27,22 +27,39 @@ } .block-editor-block-patterns-list__item-title { - padding-top: $grid-unit-10; - font-size: 12px; - text-align: center; + text-align: left; + flex-grow: 1; } &:hover .block-editor-block-preview__container { - box-shadow: 0 0 0 2px var(--wp-admin-theme-color); + box-shadow: 0 0 0 2px $gray-900; } &:focus .block-editor-block-preview__container { - @include button-style-outset__focus(var(--wp-admin-theme-color)); + @include button-style-outset__focus($gray-900); } + &.block-editor-block-patterns-list__list-item-synced { + &:hover, + &:focus { + .block-editor-block-preview__container { + box-shadow: + 0 0 0 2px var(--wp-block-synced-color), + 0 15px 25px rgb(0 0 0 / 7%); + } + } + } + + .block-editor-patterns__pattern-details { + align-items: center; + margin-top: $grid-unit-10; + } - &:hover .block-editor-block-patterns-list__item-title, - &:focus .block-editor-block-patterns-list__item-title { - color: var(--wp-admin-theme-color); + .block-editor-patterns__pattern-icon-wrapper { + min-width: 24px; + height: 24px; + .block-editor-patterns__pattern-icon { + fill: var(--wp-block-synced-color); + } } } diff --git a/packages/block-editor/src/components/block-patterns-paging/index.js b/packages/block-editor/src/components/block-patterns-paging/index.js new file mode 100644 index 0000000000000..610ff304e186e --- /dev/null +++ b/packages/block-editor/src/components/block-patterns-paging/index.js @@ -0,0 +1,92 @@ +/** + * WordPress dependencies + */ +import { + __experimentalVStack as VStack, + __experimentalHStack as HStack, + __experimentalText as Text, + Button, +} from '@wordpress/components'; +import { __, _x, _n, sprintf } from '@wordpress/i18n'; + +export default function Pagination( { + currentPage, + numPages, + changePage, + totalItems, +} ) { + return ( + + + { + // translators: %s: Total number of patterns. + sprintf( + // translators: %s: Total number of patterns. + _n( '%s item', '%s items', totalItems ), + totalItems + ) + } + + + + + + + + { sprintf( + // translators: %1$s: Current page number, %2$s: Total number of pages. + _x( '%1$s of %2$s', 'paging' ), + currentPage, + numPages + ) } + + + + + + + + ); +} diff --git a/packages/block-editor/src/components/block-patterns-paging/style.scss b/packages/block-editor/src/components/block-patterns-paging/style.scss new file mode 100644 index 0000000000000..7de651f1511b6 --- /dev/null +++ b/packages/block-editor/src/components/block-patterns-paging/style.scss @@ -0,0 +1,42 @@ +.block-editor-patterns__grid-pagination { + border-top: 1px solid $gray-800; + padding: $grid-unit-05; + + .components-button.is-tertiary { + width: auto; + height: $button-size-compact; + justify-content: center; + + &:disabled { + color: $gray-600; + background: none; + } + + &:hover:not(:disabled) { + color: $white; + background-color: $gray-700; + } + } +} + +.show-icon-labels { + .block-editor-patterns__grid-pagination { + flex-direction: column; + .block-editor-patterns__grid-pagination-previous, + .block-editor-patterns__grid-pagination-next { + flex-direction: column; + } + .components-button { + width: auto; + // Hide the button icons when labels are set to display... + span { + display: none; + } + // ... and display labels. + // Uses ::before as ::after is already used for active tab styling. + &::before { + content: attr(aria-label); + } + } + } +} diff --git a/packages/block-editor/src/components/inserter/block-patterns-explorer/explorer.js b/packages/block-editor/src/components/inserter/block-patterns-explorer/explorer.js index 914177941821f..4a4bcab3397ae 100644 --- a/packages/block-editor/src/components/inserter/block-patterns-explorer/explorer.js +++ b/packages/block-editor/src/components/inserter/block-patterns-explorer/explorer.js @@ -2,33 +2,69 @@ * WordPress dependencies */ import { Modal } from '@wordpress/components'; -import { useState } from '@wordpress/element'; +import { useState, useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; +import { usePrevious } from '@wordpress/compose'; /** * Internal dependencies */ import PatternExplorerSidebar from './sidebar'; import PatternList from './patterns-list'; +import { usePatternsCategories } from '../block-patterns-tab'; +import { store as blockEditorStore } from '../../../store'; -function PatternsExplorer( { initialCategory, patternCategories } ) { - const [ filterValue, setFilterValue ] = useState( '' ); +function PatternsExplorer( { initialCategory, rootClientId } ) { + const [ searchValue, setSearchValue ] = useState( '' ); + const [ patternSourceFilter, setPatternSourceFilter ] = useState( 'all' ); + const patternSyncFilter = useSelect( ( select ) => { + const { getSettings } = select( blockEditorStore ); + const settings = getSettings(); + return settings.patternsSyncFilter || 'all'; + }, [] ); const [ selectedCategory, setSelectedCategory ] = useState( initialCategory?.name ); + + const previousSyncFilter = usePrevious( patternSyncFilter ); + + // If the sync filter changes, we need to select the "All" category to avoid + // showing a confusing no results screen. + useEffect( () => { + if ( patternSyncFilter && patternSyncFilter !== previousSyncFilter ) { + setSelectedCategory( initialCategory?.name ); + } + }, [ + patternSyncFilter, + previousSyncFilter, + patternSourceFilter, + initialCategory?.name, + ] ); + + const patternCategories = usePatternsCategories( + rootClientId, + patternSourceFilter, + patternSyncFilter + ); + return (
); diff --git a/packages/block-editor/src/components/inserter/block-patterns-explorer/patterns-list.js b/packages/block-editor/src/components/inserter/block-patterns-explorer/patterns-list.js index fda1a00c1a07d..fa65507be4ec4 100644 --- a/packages/block-editor/src/components/inserter/block-patterns-explorer/patterns-list.js +++ b/packages/block-editor/src/components/inserter/block-patterns-explorer/patterns-list.js @@ -1,9 +1,9 @@ /** * WordPress dependencies */ -import { useMemo, useEffect } from '@wordpress/element'; +import { useMemo, useEffect, useRef } from '@wordpress/element'; import { _n, sprintf } from '@wordpress/i18n'; -import { useDebounce, useAsyncList } from '@wordpress/compose'; +import { useDebounce } from '@wordpress/compose'; import { __experimentalHeading as Heading } from '@wordpress/components'; import { speak } from '@wordpress/a11y'; @@ -16,8 +16,14 @@ import useInsertionPoint from '../hooks/use-insertion-point'; import usePatternsState from '../hooks/use-patterns-state'; import InserterListbox from '../../inserter-listbox'; import { searchItems } from '../search-items'; - -const INITIAL_INSERTER_RESULTS = 2; +import BlockPatternsPaging from '../../block-patterns-paging'; +import usePatternsPaging from '../hooks/use-patterns-paging'; +import { allPatternsCategory, isPatternFiltered } from '../block-patterns-tab'; +import { BlockPatternsSyncFilter } from '../block-patterns-sync-filter'; +import { + PATTERN_TYPES, + PATTERN_SOURCE_FILTERS, +} from '../block-patterns-source-filter'; function PatternsListHeader( { filterValue, filteredBlockPatternsLength } ) { if ( ! filterValue ) { @@ -43,12 +49,19 @@ function PatternsListHeader( { filterValue, filteredBlockPatternsLength } ) { ); } -function PatternList( { filterValue, selectedCategory, patternCategories } ) { +function PatternList( { + searchValue, + patternSourceFilter, + selectedCategory, + patternCategories, + patternSyncFilter, +} ) { + const container = useRef(); const debouncedSpeak = useDebounce( speak, 500 ); const [ destinationRootClientId, onInsertBlocks ] = useInsertionPoint( { shouldFocusBlock: true, } ); - const [ allPatterns, , onSelectBlockPattern ] = usePatternsState( + const { patterns: allPatterns, onClickPattern } = usePatternsState( onInsertBlocks, destinationRootClientId ); @@ -62,30 +75,54 @@ function PatternList( { filterValue, selectedCategory, patternCategories } ) { ); const filteredBlockPatterns = useMemo( () => { - if ( ! filterValue ) { - return allPatterns.filter( ( pattern ) => - selectedCategory === 'uncategorized' - ? ! pattern.categories?.length || - pattern.categories.every( - ( category ) => - ! registeredPatternCategories.includes( - category - ) - ) - : pattern.categories?.includes( selectedCategory ) - ); + const filteredPatterns = allPatterns.filter( ( pattern ) => { + if ( + isPatternFiltered( + pattern, + patternSourceFilter, + patternSyncFilter + ) + ) { + return false; + } + + if ( selectedCategory === allPatternsCategory.name ) { + return true; + } + + if ( selectedCategory === 'uncategorized' ) { + const hasKnownCategory = pattern.categories.some( + ( category ) => + registeredPatternCategories.includes( category ) + ); + + return ! pattern.categories?.length || ! hasKnownCategory; + } + + return pattern.categories?.includes( selectedCategory ); + } ); + + if ( ! searchValue ) { + return filteredPatterns; } - return searchItems( allPatterns, filterValue ); + + return searchItems( + filteredPatterns, + searchValue, + patternSourceFilter + ); }, [ - filterValue, + searchValue, + patternSourceFilter, allPatterns, selectedCategory, registeredPatternCategories, + patternSyncFilter, ] ); // Announce search results on change. useEffect( () => { - if ( ! filterValue ) { + if ( ! searchValue ) { return; } const count = filteredBlockPatterns.length; @@ -95,31 +132,45 @@ function PatternList( { filterValue, selectedCategory, patternCategories } ) { count ); debouncedSpeak( resultsFoundMessage ); - }, [ filterValue, debouncedSpeak, filteredBlockPatterns.length ] ); + }, [ searchValue, debouncedSpeak, filteredBlockPatterns.length ] ); - const currentShownPatterns = useAsyncList( filteredBlockPatterns, { - step: INITIAL_INSERTER_RESULTS, - } ); + const pagingProps = usePatternsPaging( + filteredBlockPatterns, + selectedCategory, + container, + patternSourceFilter + ); const hasItems = !! filteredBlockPatterns?.length; return ( -
+
{ hasItems && ( ) } { ! hasItems && } + { patternSourceFilter === PATTERN_TYPES.user && + ! searchValue && } { hasItems && ( ) } + { pagingProps.numPages > 1 && ( + + ) }
); diff --git a/packages/block-editor/src/components/inserter/block-patterns-explorer/sidebar.js b/packages/block-editor/src/components/inserter/block-patterns-explorer/sidebar.js index 7143134222122..06ea794aa4dac 100644 --- a/packages/block-editor/src/components/inserter/block-patterns-explorer/sidebar.js +++ b/packages/block-editor/src/components/inserter/block-patterns-explorer/sidebar.js @@ -4,6 +4,12 @@ import { Button, SearchControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import { default as BlockPatternsSourceFilter } from '../block-patterns-source-filter'; +import { allPatternsCategory } from '../block-patterns-tab'; + function PatternCategoriesList( { selectedCategory, patternCategories, @@ -31,14 +37,14 @@ function PatternCategoriesList( { ); } -function PatternsExplorerSearch( { filterValue, setFilterValue } ) { +function PatternsExplorerSearch( { searchValue, setSearchValue } ) { const baseClassName = 'block-editor-block-patterns-explorer__search'; return (
@@ -50,17 +56,26 @@ function PatternExplorerSidebar( { selectedCategory, patternCategories, onClickCategory, - filterValue, - setFilterValue, + patternSourceFilter, + setPatternSourceFilter, + searchValue, + setSearchValue, } ) { const baseClassName = 'block-editor-block-patterns-explorer__sidebar'; return (
+ { + setPatternSourceFilter( value ); + onClickCategory( allPatternsCategory.name ); + } } /> - { ! filterValue && ( + { ! searchValue && ( { + patternSourceFilters[ value ] = label; + return patternSourceFilters; + }, + {} +); + +export default function BlockPatternsSourceFilter( { onChange, value } ) { + return ( + + ); +} diff --git a/packages/block-editor/src/components/inserter/block-patterns-sync-filter.js b/packages/block-editor/src/components/inserter/block-patterns-sync-filter.js new file mode 100644 index 0000000000000..07c4c0d8fa1b1 --- /dev/null +++ b/packages/block-editor/src/components/inserter/block-patterns-sync-filter.js @@ -0,0 +1,48 @@ +/** + * WordPress dependencies + */ +import { SelectControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useDispatch, useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; +export const SYNC_TYPES = { + full: 'fully', + unsynced: 'unsynced', +}; + +const patternSyncOptions = [ + { value: 'all', label: __( 'All' ) }, + { value: SYNC_TYPES.full, label: __( 'Synced' ) }, + { value: SYNC_TYPES.unsynced, label: __( 'Standard' ) }, +]; + +export function BlockPatternsSyncFilter() { + const { updateSettings } = useDispatch( blockEditorStore ); + + const syncFilter = useSelect( ( select ) => { + const { getSettings } = select( blockEditorStore ); + const settings = getSettings(); + return settings.patternsSyncFilter || 'all'; + }, [] ); + + const handleUpdateSyncFilter = ( value ) => { + updateSettings( { + patternsSyncFilter: value, + } ); + }; + + return ( + handleUpdateSyncFilter( value ) } + aria-label={ __( 'Filter patterns by sync type' ) } + /> + ); +} diff --git a/packages/block-editor/src/components/inserter/block-patterns-tab.js b/packages/block-editor/src/components/inserter/block-patterns-tab.js index f66d27ac06170..e5432fd9373f5 100644 --- a/packages/block-editor/src/components/inserter/block-patterns-tab.js +++ b/packages/block-editor/src/components/inserter/block-patterns-tab.js @@ -8,8 +8,8 @@ import { useRef, useEffect, } from '@wordpress/element'; -import { _x, __, isRTL } from '@wordpress/i18n'; -import { useAsyncList, useViewportMatch } from '@wordpress/compose'; +import { _x, __, _n, isRTL, sprintf } from '@wordpress/i18n'; +import { useViewportMatch, usePrevious } from '@wordpress/compose'; import { __experimentalItemGroup as ItemGroup, __experimentalItem as Item, @@ -19,6 +19,8 @@ import { } from '@wordpress/components'; import { Icon, chevronRight, chevronLeft } from '@wordpress/icons'; import { focus } from '@wordpress/dom'; +import { speak } from '@wordpress/a11y'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -27,29 +29,77 @@ import usePatternsState from './hooks/use-patterns-state'; import BlockPatternList from '../block-patterns-list'; import PatternsExplorerModal from './block-patterns-explorer/explorer'; import MobileTabNavigation from './mobile-tab-navigation'; +import BlockPatternsPaging from '../block-patterns-paging'; +import usePatternsPaging from './hooks/use-patterns-paging'; +import { + PATTERN_TYPES, + default as BlockPatternsSourceFilter, +} from './block-patterns-source-filter'; +import { + BlockPatternsSyncFilter, + SYNC_TYPES, +} from './block-patterns-sync-filter'; +import { store as blockEditorStore } from '../../store'; const noop = () => {}; -// Preferred order of pattern categories. Any other categories should -// be at the bottom without any re-ordering. -const patternCategoriesOrder = [ - 'custom', - 'featured', - 'posts', - 'text', - 'gallery', - 'call-to-action', - 'banner', - 'header', - 'footer', -]; +export const allPatternsCategory = { + name: 'allPatterns', + label: __( 'All categories' ), +}; -function usePatternsCategories( rootClientId ) { - const [ allPatterns, allCategories ] = usePatternsState( +export function isPatternFiltered( pattern, sourceFilter, syncFilter ) { + if ( + sourceFilter === PATTERN_TYPES.theme && + pattern.name.startsWith( 'core/block' ) + ) { + return true; + } + if ( sourceFilter === PATTERN_TYPES.user && ! pattern.id ) { + return true; + } + if ( + sourceFilter === PATTERN_TYPES.user && + syncFilter === SYNC_TYPES.full && + pattern.syncStatus !== '' + ) { + return true; + } + if ( + sourceFilter === PATTERN_TYPES.user && + syncFilter === SYNC_TYPES.unsynced && + pattern.syncStatus !== 'unsynced' + ) { + return true; + } + return false; +} + +export function usePatternsCategories( + rootClientId, + sourceFilter = 'all', + syncFilter +) { + const { patterns: allPatterns, allCategories } = usePatternsState( undefined, rootClientId ); + const filteredPatterns = useMemo( + () => + sourceFilter === 'all' + ? allPatterns + : allPatterns.filter( + ( pattern ) => + ! isPatternFiltered( + pattern, + sourceFilter, + syncFilter + ) + ), + [ sourceFilter, syncFilter, allPatterns ] + ); + const hasRegisteredCategory = useCallback( ( pattern ) => { if ( ! pattern.categories || ! pattern.categories.length ) { @@ -67,22 +117,14 @@ function usePatternsCategories( rootClientId ) { const populatedCategories = useMemo( () => { const categories = allCategories .filter( ( category ) => - allPatterns.some( ( pattern ) => + filteredPatterns.some( ( pattern ) => pattern.categories?.includes( category.name ) ) ) - .sort( ( { name: aName }, { name: bName } ) => { - // Sort categories according to `patternCategoriesOrder`. - let aIndex = patternCategoriesOrder.indexOf( aName ); - let bIndex = patternCategoriesOrder.indexOf( bName ); - // All other categories should come after that. - if ( aIndex < 0 ) aIndex = patternCategoriesOrder.length; - if ( bIndex < 0 ) bIndex = patternCategoriesOrder.length; - return aIndex - bIndex; - } ); + .sort( ( a, b ) => a.label.localeCompare( b.label ) ); if ( - allPatterns.some( + filteredPatterns.some( ( pattern ) => ! hasRegisteredCategory( pattern ) ) && ! categories.find( @@ -94,9 +136,25 @@ function usePatternsCategories( rootClientId ) { label: _x( 'Uncategorized' ), } ); } - + if ( filteredPatterns.length > 0 ) { + categories.unshift( { + name: allPatternsCategory.name, + label: allPatternsCategory.label, + } ); + } + speak( + sprintf( + /* translators: %d: number of categories . */ + _n( + '%d category button displayed.', + '%d category buttons displayed.', + categories.length + ), + categories.length + ) + ); return categories; - }, [ allCategories, allPatterns, hasRegisteredCategory ] ); + }, [ allCategories, filteredPatterns, hasRegisteredCategory ] ); return populatedCategories; } @@ -107,6 +165,7 @@ export function BlockPatternsCategoryDialog( { onHover, category, showTitlesAsTooltip, + patternFilter, } ) { const container = useRef(); @@ -129,6 +188,7 @@ export function BlockPatternsCategoryDialog( { onHover={ onHover } category={ category } showTitlesAsTooltip={ showTitlesAsTooltip } + patternFilter={ patternFilter } />
); @@ -140,16 +200,39 @@ export function BlockPatternsCategoryPanel( { onHover = noop, category, showTitlesAsTooltip, + patternFilter, } ) { - const [ allPatterns, , onClick ] = usePatternsState( + const { patterns: allPatterns, onClickPattern } = usePatternsState( onInsert, rootClientId ); - - const availableCategories = usePatternsCategories( rootClientId ); + const patternSyncFilter = useSelect( ( select ) => { + const { getSettings } = select( blockEditorStore ); + const settings = getSettings(); + return settings.patternsSyncFilter || 'all'; + }, [] ); + const availableCategories = usePatternsCategories( + rootClientId, + patternFilter, + patternSyncFilter + ); + const container = useRef(); const currentCategoryPatterns = useMemo( () => allPatterns.filter( ( pattern ) => { + if ( + isPatternFiltered( + pattern, + patternFilter, + patternSyncFilter + ) + ) { + return false; + } + + if ( category.name === allPatternsCategory.name ) { + return true; + } if ( category.name !== 'uncategorized' ) { return pattern.categories?.includes( category.name ); } @@ -166,35 +249,56 @@ export function BlockPatternsCategoryPanel( { return availablePatternCategories.length === 0; } ), - [ allPatterns, availableCategories, category.name ] + [ + allPatterns, + availableCategories, + category.name, + patternFilter, + patternSyncFilter, + ] ); - const categoryPatternsList = useAsyncList( currentCategoryPatterns ); + const pagingProps = usePatternsPaging( + currentCategoryPatterns, + category, + container + ); // Hide block pattern preview on unmount. useEffect( () => () => onHover( null ), [] ); - if ( ! currentCategoryPatterns.length ) { - return null; - } - return ( -
+
{ category.label }

{ category.description }

- + { patternFilter === PATTERN_TYPES.user && ( + + ) } + { ! currentCategoryPatterns.length && ( +
{ __( 'No results found' ) }
+ ) } + { currentCategoryPatterns.length > 0 && ( + + ) } + { pagingProps.numPages > 1 && ( + + ) }
); } @@ -206,24 +310,60 @@ function BlockPatternsTabs( { rootClientId, } ) { const [ showPatternsExplorer, setShowPatternsExplorer ] = useState( false ); - const categories = usePatternsCategories( rootClientId ); + const [ patternSourceFilter, setPatternSourceFilter ] = useState( 'all' ); + const patternSyncFilter = useSelect( ( select ) => { + const { getSettings } = select( blockEditorStore ); + const settings = getSettings(); + return settings.patternsSyncFilter; + }, [] ); + const previousSyncFilter = usePrevious( patternSyncFilter ); + + // If the sync filter changes, we need to select the "All" category to avoid + // showing a confusing no results screen. + useEffect( () => { + if ( patternSyncFilter && patternSyncFilter !== previousSyncFilter ) { + onSelectCategory( allPatternsCategory, patternSourceFilter ); + } + }, [ + patternSyncFilter, + previousSyncFilter, + onSelectCategory, + patternSourceFilter, + ] ); + + const categories = usePatternsCategories( + rootClientId, + patternSourceFilter, + patternSyncFilter + ); + const initialCategory = selectedCategory || categories[ 0 ]; const isMobile = useViewportMatch( 'medium', '<' ); return ( <> { ! isMobile && (
-