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();