diff --git a/packages/edit-site/src/components/style-book/categories.ts b/packages/edit-site/src/components/style-book/categories.ts new file mode 100644 index 00000000000000..2c1b627c6d0c60 --- /dev/null +++ b/packages/edit-site/src/components/style-book/categories.ts @@ -0,0 +1,91 @@ +/** + * WordPress dependencies + */ +import { getCategories } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import type { + BlockExample, + StyleBookCategory, + CategoryExamples, +} from './types'; +import { + STYLE_BOOK_CATEGORIES, + STYLE_BOOK_THEME_SUBCATEGORIES, +} from './constants'; + +/** + * Returns category examples for a given category definition and list of examples. + * @param {StyleBookCategory} categoryDefinition The category definition. + * @param {BlockExample[]} examples An array of block examples. + * @return {CategoryExamples|undefined} An object containing the category examples. + */ +export function getExamplesByCategory( + categoryDefinition: StyleBookCategory, + examples: BlockExample[] +): CategoryExamples | undefined { + if ( ! categoryDefinition?.slug || ! examples?.length ) { + return; + } + + if ( categoryDefinition?.subcategories?.length ) { + return categoryDefinition.subcategories.reduce( + ( acc, subcategoryDefinition ) => { + const subcategoryExamples = getExamplesByCategory( + subcategoryDefinition, + examples + ); + if ( subcategoryExamples ) { + acc.subcategories = [ + ...acc.subcategories, + subcategoryExamples, + ]; + } + return acc; + }, + { + title: categoryDefinition.title, + slug: categoryDefinition.slug, + subcategories: [], + } + ); + } + + const blocksToInclude = categoryDefinition?.blocks || []; + const blocksToExclude = categoryDefinition?.exclude || []; + const categoryExamples = examples.filter( ( example ) => { + return ( + ! blocksToExclude.includes( example.name ) && + ( example.category === categoryDefinition.slug || + blocksToInclude.includes( example.name ) ) + ); + } ); + + if ( ! categoryExamples.length ) { + return; + } + + return { + title: categoryDefinition.title, + slug: categoryDefinition.slug, + examples: categoryExamples, + }; +} + +/** + * Returns category examples for a given category definition and list of examples. + * + * @return {StyleBookCategory[]} An array of top-level category definitions. + */ +export function getTopLevelStyleBookCategories(): StyleBookCategory[] { + const reservedCategories = [ + ...STYLE_BOOK_THEME_SUBCATEGORIES, + ...STYLE_BOOK_CATEGORIES, + ].map( ( { slug } ) => slug ); + const extraCategories = getCategories().filter( + ( { slug } ) => ! reservedCategories.includes( slug ) + ); + return [ ...STYLE_BOOK_CATEGORIES, ...extraCategories ]; +} diff --git a/packages/edit-site/src/components/style-book/constants.ts b/packages/edit-site/src/components/style-book/constants.ts new file mode 100644 index 00000000000000..fc06d8f1409f0d --- /dev/null +++ b/packages/edit-site/src/components/style-book/constants.ts @@ -0,0 +1,191 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { StyleBookCategory } from './types'; + +export const STYLE_BOOK_THEME_SUBCATEGORIES: Omit< + StyleBookCategory, + 'subcategories' +>[] = [ + { + slug: 'site-identity', + title: __( 'Site Identity' ), + blocks: [ 'core/site-logo', 'core/site-title', 'core/site-tagline' ], + }, + { + slug: 'design', + title: __( 'Design' ), + blocks: [ 'core/navigation', 'core/avatar', 'core/post-time-to-read' ], + exclude: [ 'core/home-link', 'core/navigation-link' ], + }, + { + slug: 'posts', + title: __( 'Posts' ), + blocks: [ + 'core/post-title', + 'core/post-excerpt', + 'core/post-author', + 'core/post-author-name', + 'core/post-author-biography', + 'core/post-date', + 'core/post-terms', + 'core/term-description', + 'core/query-title', + 'core/query-no-results', + 'core/query-pagination', + 'core/query-numbers', + ], + }, + { + slug: 'comments', + title: __( 'Comments' ), + blocks: [ + 'core/comments-title', + 'core/comments-pagination', + 'core/comments-pagination-numbers', + 'core/comments', + 'core/comments-author-name', + 'core/comment-content', + 'core/comment-date', + 'core/comment-edit-link', + 'core/comment-reply-link', + 'core/comment-template', + 'core/post-comments-count', + 'core/post-comments-link', + ], + }, +]; + +export const STYLE_BOOK_CATEGORIES: StyleBookCategory[] = [ + { + slug: 'text', + title: __( 'Text' ), + blocks: [ + 'core/post-content', + 'core/home-link', + 'core/navigation-link', + ], + }, + { + slug: 'colors', + title: __( 'Colors' ), + blocks: [ 'custom/colors' ], + }, + { + slug: 'theme', + title: __( 'Theme' ), + subcategories: STYLE_BOOK_THEME_SUBCATEGORIES, + }, + { + slug: 'media', + title: __( 'Media' ), + blocks: [ 'core/post-featured-image' ], + }, + { + slug: 'widgets', + title: __( 'Widgets' ), + blocks: [], + }, + { + slug: 'embed', + title: __( 'Embeds' ), + include: [], + }, +]; + +// The content area of the Style Book is rendered within an iframe so that global styles +// are applied to elements within the entire content area. To support elements that are +// not part of the block previews, such as headings and layout for the block previews, +// additional CSS rules need to be passed into the iframe. These are hard-coded below. +// Note that button styles are unset, and then focus rules from the `Button` component are +// applied to the `button` element, targeted via `.edit-site-style-book__example`. +// This is to ensure that browser default styles for buttons are not applied to the previews. +export const STYLE_BOOK_IFRAME_STYLES = ` + // Forming a "block formatting context" to prevent margin collapsing. + // @see https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Block_formatting_context + .is-root-container { + display: flow-root; + } + + body { + position: relative; + padding: 32px !important; + } + + .edit-site-style-book__examples { + max-width: 1200px; + margin: 0 auto; + } + + .edit-site-style-book__example { + max-width: 900px; + border-radius: 2px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 40px; + padding: 16px; + width: 100%; + box-sizing: border-box; + scroll-margin-top: 32px; + scroll-margin-bottom: 32px; + margin: 0 auto 40px auto; + } + + .edit-site-style-book__example.is-selected { + box-shadow: 0 0 0 1px var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); + } + + .edit-site-style-book__example:focus:not(:disabled) { + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); + outline: 3px solid transparent; + } + + .edit-site-style-book__examples.is-wide .edit-site-style-book__example { + flex-direction: row; + } + + .edit-site-style-book__subcategory-title, + .edit-site-style-book__example-title { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 11px; + font-weight: 500; + line-height: normal; + margin: 0; + text-align: left; + text-transform: uppercase; + } + + .edit-site-style-book__subcategory-title { + font-size: 16px; + margin-bottom: 40px; + border-bottom: 1px solid #ddd; + padding-bottom: 8px; + } + + .edit-site-style-book__examples.is-wide .edit-site-style-book__example-title { + text-align: right; + width: 120px; + } + + .edit-site-style-book__example-preview { + width: 100%; + } + + .edit-site-style-book__example-preview .block-editor-block-list__insertion-point, + .edit-site-style-book__example-preview .block-list-appender { + display: none; + } + + .edit-site-style-book__example-preview .is-root-container > .wp-block:first-child { + margin-top: 0; + } + .edit-site-style-book__example-preview .is-root-container > .wp-block:last-child { + margin-bottom: 0; + } +`; diff --git a/packages/edit-site/src/components/style-book/examples.ts b/packages/edit-site/src/components/style-book/examples.ts new file mode 100644 index 00000000000000..80807b10374c68 --- /dev/null +++ b/packages/edit-site/src/components/style-book/examples.ts @@ -0,0 +1,63 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { + getBlockType, + getBlockTypes, + getBlockFromExample, + createBlock, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import type { BlockExample } from './types'; + +/** + * Returns a list of examples for registered block types. + * + * @return {BlockExample[]} An array of block examples. + */ +export function getExamples(): BlockExample[] { + const nonHeadingBlockExamples = getBlockTypes() + .filter( ( blockType ) => { + const { name, example, supports } = blockType; + return ( + name !== 'core/heading' && + !! example && + supports.inserter !== false + ); + } ) + .map( ( blockType ) => ( { + name: blockType.name, + title: blockType.title, + category: blockType.category, + blocks: getBlockFromExample( blockType.name, blockType.example ), + } ) ); + const isHeadingBlockRegistered = !! getBlockType( 'core/heading' ); + + if ( ! isHeadingBlockRegistered ) { + return nonHeadingBlockExamples; + } + + // Use our own example for the Heading block so that we can show multiple + // heading levels. + const headingsExample = { + name: 'core/heading', + title: __( 'Headings' ), + category: 'text', + blocks: [ 1, 2, 3, 4, 5, 6 ].map( ( level ) => { + return createBlock( 'core/heading', { + content: sprintf( + // translators: %d: heading level e.g: "1", "2", "3" + __( 'Heading %d' ), + level + ), + level, + } ); + } ), + }; + + return [ headingsExample, ...nonHeadingBlockExamples ]; +} diff --git a/packages/edit-site/src/components/style-book/index.js b/packages/edit-site/src/components/style-book/index.js index 64503dcf7a6dbb..e68474e19f407f 100644 --- a/packages/edit-site/src/components/style-book/index.js +++ b/packages/edit-site/src/components/style-book/index.js @@ -12,13 +12,6 @@ import { privateApis as componentsPrivateApis, } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; -import { - getCategories, - getBlockType, - getBlockTypes, - getBlockFromExample, - createBlock, -} from '@wordpress/blocks'; import { BlockList, privateApis as blockEditorPrivateApis, @@ -37,6 +30,12 @@ import { ENTER, SPACE } from '@wordpress/keycodes'; */ import { unlock } from '../../lock-unlock'; import EditorCanvasContainer from '../editor-canvas-container'; +import { STYLE_BOOK_IFRAME_STYLES } from './constants'; +import { + getExamplesByCategory, + getTopLevelStyleBookCategories, +} from './categories'; +import { getExamples } from './examples'; const { ExperimentalBlockEditorProvider, @@ -48,126 +47,10 @@ const { mergeBaseAndUserConfigs } = unlock( editorPrivateApis ); const { Tabs } = unlock( componentsPrivateApis ); -// The content area of the Style Book is rendered within an iframe so that global styles -// are applied to elements within the entire content area. To support elements that are -// not part of the block previews, such as headings and layout for the block previews, -// additional CSS rules need to be passed into the iframe. These are hard-coded below. -// Note that button styles are unset, and then focus rules from the `Button` component are -// applied to the `button` element, targeted via `.edit-site-style-book__example`. -// This is to ensure that browser default styles for buttons are not applied to the previews. -const STYLE_BOOK_IFRAME_STYLES = ` - .edit-site-style-book__examples { - max-width: 900px; - margin: 0 auto; - } - - .edit-site-style-book__example { - border-radius: 2px; - cursor: pointer; - display: flex; - flex-direction: column; - gap: 40px; - margin-bottom: 40px; - padding: 16px; - width: 100%; - box-sizing: border-box; - scroll-margin-top: 32px; - scroll-margin-bottom: 32px; - } - - .edit-site-style-book__example.is-selected { - box-shadow: 0 0 0 1px var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); - } - - .edit-site-style-book__example:focus:not(:disabled) { - box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); - outline: 3px solid transparent; - } - - .edit-site-style-book__examples.is-wide .edit-site-style-book__example { - flex-direction: row; - } - - .edit-site-style-book__example-title { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; - font-size: 11px; - font-weight: 500; - line-height: normal; - margin: 0; - text-align: left; - text-transform: uppercase; - } - - .edit-site-style-book__examples.is-wide .edit-site-style-book__example-title { - text-align: right; - width: 120px; - } - - .edit-site-style-book__example-preview { - width: 100%; - } - - .edit-site-style-book__example-preview .block-editor-block-list__insertion-point, - .edit-site-style-book__example-preview .block-list-appender { - display: none; - } - - .edit-site-style-book__example-preview .is-root-container > .wp-block:first-child { - margin-top: 0; - } - .edit-site-style-book__example-preview .is-root-container > .wp-block:last-child { - margin-bottom: 0; - } -`; - function isObjectEmpty( object ) { return ! object || Object.keys( object ).length === 0; } -function getExamples() { - const nonHeadingBlockExamples = getBlockTypes() - .filter( ( blockType ) => { - const { name, example, supports } = blockType; - return ( - name !== 'core/heading' && - !! example && - supports.inserter !== false - ); - } ) - .map( ( blockType ) => ( { - name: blockType.name, - title: blockType.title, - category: blockType.category, - blocks: getBlockFromExample( blockType.name, blockType.example ), - } ) ); - - const isHeadingBlockRegistered = !! getBlockType( 'core/heading' ); - - if ( ! isHeadingBlockRegistered ) { - return nonHeadingBlockExamples; - } - - // Use our own example for the Heading block so that we can show multiple - // heading levels. - const headingsExample = { - name: 'core/heading', - title: __( 'Headings' ), - category: 'text', - blocks: [ 1, 2, 3, 4, 5, 6 ].map( ( level ) => { - return createBlock( 'core/heading', { - content: sprintf( - // translators: %d: heading level e.g: "1", "2", "3" - __( 'Heading %d' ), - level - ), - level, - } ); - } ), - }; - - return [ headingsExample, ...nonHeadingBlockExamples ]; -} - function StyleBook( { enableResizing = true, isSelected, @@ -184,17 +67,11 @@ function StyleBook( { const [ examples ] = useState( getExamples ); const tabs = useMemo( () => - getCategories() - .filter( ( category ) => - examples.some( - ( example ) => example.category === category.slug - ) + getTopLevelStyleBookCategories().filter( ( category ) => + examples.some( + ( example ) => example.category === category.slug ) - .map( ( category ) => ( { - name: category.slug, - title: category.title, - icon: category.icon, - } ) ), + ), [ examples ] ); const { base: baseConfig } = useContext( GlobalStylesContext ); @@ -248,8 +125,8 @@ function StyleBook( { { tabs.map( ( tab ) => ( { tab.title } @@ -257,12 +134,12 @@ function StyleBook( { { tabs.map( ( tab ) => ( { + const categoryDefinition = category + ? getTopLevelStyleBookCategories().find( + ( _category ) => _category.slug === category + ) + : null; + + const filteredExamples = categoryDefinition + ? getExamplesByCategory( categoryDefinition, examples ) + : { examples }; + return ( - { examples - .filter( ( example ) => - category ? example.category === category : true - ) - .map( ( example ) => ( + { !! filteredExamples?.examples?.length && + filteredExamples.examples.map( ( example ) => ( ) ) } + { !! filteredExamples?.subcategories?.length && + filteredExamples.subcategories.map( ( subcategory ) => ( + + +

+ { subcategory.title } +

+
+ +
+ ) ) }
); } ); +const Subcategory = ( { examples, isSelected, onSelect } ) => { + return ( + !! examples?.length && + examples.map( ( example ) => ( + { + onSelect?.( example.name ); + } } + /> + ) ) + ); +}; + const Example = ( { id, title, blocks, isSelected, onClick } ) => { const originalSettings = useSelect( ( select ) => select( blockEditorStore ).getSettings(), diff --git a/packages/edit-site/src/components/style-book/test/categories.js b/packages/edit-site/src/components/style-book/test/categories.js new file mode 100644 index 00000000000000..5629689e260f89 --- /dev/null +++ b/packages/edit-site/src/components/style-book/test/categories.js @@ -0,0 +1,171 @@ +/** + * Internal dependencies + */ +import { + getExamplesByCategory, + getTopLevelStyleBookCategories, +} from '../categories'; +import { STYLE_BOOK_CATEGORIES } from '../constants'; + +jest.mock( '@wordpress/blocks', () => { + return { + getCategories() { + return [ + { + slug: 'text', + title: 'Text Registered', + icon: 'text', + }, + { + slug: 'design', + title: 'Design Registered', + icon: 'design', + }, + { + slug: 'funky', + title: 'Funky', + icon: 'funky', + }, + ]; + }, + }; +} ); + +// Fixtures +const exampleThemeBlocks = [ + { + name: 'core/post-content', + title: 'Post Content', + category: 'theme', + }, + { + name: 'core/post-terms', + title: 'Post Terms', + category: 'theme', + }, + { + name: 'core/home-link', + title: 'Home Link', + category: 'design', + }, + { + name: 'custom/colors', + title: 'Colors', + category: 'colors', + }, + { + name: 'core/site-logo', + title: 'Site Logo', + category: 'theme', + }, + { + name: 'core/site-title', + title: 'Site Title', + category: 'theme', + }, + { + name: 'core/site-tagline', + title: 'Site Tagline', + category: 'theme', + }, + { + name: 'core/group', + title: 'Group', + category: 'design', + }, + { + name: 'core/comments-pagination-numbers', + title: 'Comments Page Numbers', + category: 'theme', + }, + { + name: 'core/post-featured-image', + title: 'Featured Image', + category: 'theme', + }, +]; + +describe( 'utils', () => { + describe( 'getTopLevelStyleBookCategories', () => { + it( 'returns theme subcategories examples', () => { + expect( getTopLevelStyleBookCategories() ).toEqual( [ + ...STYLE_BOOK_CATEGORIES, + { + slug: 'funky', + title: 'Funky', + icon: 'funky', + }, + ] ); + } ); + } ); + + describe( 'getExamplesByCategory', () => { + it( 'returns theme subcategories examples', () => { + const themeCategory = STYLE_BOOK_CATEGORIES.find( + ( category ) => category.slug === 'theme' + ); + const themeCategoryExamples = getExamplesByCategory( + themeCategory, + exampleThemeBlocks + ); + + expect( themeCategoryExamples.slug ).toEqual( 'theme' ); + + const siteIdentity = themeCategoryExamples.subcategories.find( + ( subcategory ) => subcategory.slug === 'site-identity' + ); + expect( siteIdentity ).toEqual( { + title: 'Site Identity', + slug: 'site-identity', + examples: [ + { + name: 'core/site-logo', + title: 'Site Logo', + category: 'theme', + }, + { + name: 'core/site-title', + title: 'Site Title', + category: 'theme', + }, + { + name: 'core/site-tagline', + title: 'Site Tagline', + category: 'theme', + }, + ], + } ); + + const design = themeCategoryExamples.subcategories.find( + ( subcategory ) => subcategory.slug === 'design' + ); + expect( design ).toEqual( { + title: 'Design', + slug: 'design', + examples: [ + { + name: 'core/group', + title: 'Group', + category: 'design', + }, + ], + } ); + + const posts = themeCategoryExamples.subcategories.find( + ( subcategory ) => subcategory.slug === 'posts' + ); + + expect( posts ).toEqual( { + title: 'Posts', + slug: 'posts', + examples: [ + { + name: 'core/post-terms', + title: 'Post Terms', + category: 'theme', + }, + ], + } ); + } ); + } ); +} ); diff --git a/packages/edit-site/src/components/style-book/types.ts b/packages/edit-site/src/components/style-book/types.ts new file mode 100644 index 00000000000000..4729b38b1b2bb1 --- /dev/null +++ b/packages/edit-site/src/components/style-book/types.ts @@ -0,0 +1,27 @@ +type Block = { + name: string; + attributes: Record< string, unknown >; + innerBlocks?: Block[]; +}; + +export type StyleBookCategory = { + title: string; + slug: string; + blocks?: string[]; + exclude?: string[]; + subcategories?: StyleBookCategory[]; +}; + +export type BlockExample = { + name: string; + title: string; + category: string; + blocks: Block | Block[]; +}; + +export type CategoryExamples = { + title: string; + slug: string; + examples?: BlockExample[]; + subcategories?: CategoryExamples[]; +}; diff --git a/test/e2e/specs/site-editor/style-book.spec.js b/test/e2e/specs/site-editor/style-book.spec.js index c4e153e9b5e2fa..3f871d28ef941b 100644 --- a/test/e2e/specs/site-editor/style-book.spec.js +++ b/test/e2e/specs/site-editor/style-book.spec.js @@ -42,9 +42,6 @@ test.describe( 'Style Book', () => { test( 'should have tabs containing block examples', async ( { page } ) => { await expect( page.locator( 'role=tab[name="Text"i]' ) ).toBeVisible(); await expect( page.locator( 'role=tab[name="Media"i]' ) ).toBeVisible(); - await expect( - page.locator( 'role=tab[name="Design"i]' ) - ).toBeVisible(); await expect( page.locator( 'role=tab[name="Widgets"i]' ) ).toBeVisible();