Aryan Jasala

Custom Gutenberg blocks: create-block, InnerBlocks, context and patterns

Blocks nested inside a parent block

A custom Gutenberg block is a plugin with a build step. This covers scaffolding one with @wordpress/create-block, what each file in src is for, registering it from PHP, adding sidebar controls, nesting with InnerBlocks, and the four APIs that save you from writing a second block: styles, variations, context and patterns.

Four APIs, no second block
Registered inWhat it gives
Block stylesJavaScriptis-style- class
VariationsJavaScriptextra inserter entries
ContextJavaScriptdata from parent
PatternsPHPprewritten block markup

Patterns are registered in PHP with no build step; a style adds only the class, you still write the CSS.

Each of these saves you from writing a second block, and only patterns are registered in PHP.

Why JSX

JSX is JavaScript that lets you write markup inline. React DOM escapes anything passed through it before rendering, so a dynamic tag you did not define does not get injected. That is one whole class of XSS closed by the tooling rather than by you remembering. ๐Ÿ˜€

WordPress runs webpack and babel over it, turning the JSX into JavaScript a browser accepts.

Scaffolding a block

Blocks are registered from a plugin, so you need a plugin first. Run this in the plugins directory:

npx @wordpress/create-block testblock

That writes the plugin and registers testblock inside it. What lives in src:

  • block.json -> the block’s properties, name, icon and metadata
  • edit.js -> how the block behaves in the editor
  • save.js -> what gets rendered on the front end
  • index.js -> maps edit and save together and registers the block

None of that is production code yet. It is source:

npm run build

That produces build, which holds the files WordPress actually loads. ๐Ÿ“ฆ

Registering it from PHP

register_block_type() on init, pointing at the build directory:

function movie_library_block_init() {
    register_block_type( __DIR__ . '/build' );
    register_block_type( __DIR__ . '/build2' );
}
add_action( 'init', 'movie_library_block_init' );

Call it more than once to register more than one block from the same plugin. Internally it registers through WP_Block_Type_Registry, and everything lands in that class’s registered_block_types property, which is what to dump when a block refuses to appear. ๐Ÿ”

Controls in the sidebar

InspectorControls puts fields in the editor sidebar, and @wordpress/components supplies the fields:

import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';

export default function Edit( { attributes, setAttributes } ) {
    return (
        <p { ...useBlockProps() }>
            <InspectorControls>
                <PanelBody title={ __( 'Test settings', 'movie-library' ) } initialOpen={ true }>
                    <TextControl
                        label={ __( 'Setting 1', 'movie-library' ) }
                        value={ attributes.setting }
                        onChange={ ( setting ) => setAttributes( { setting } ) }
                    />
                </PanelBody>
            </InspectorControls>
            { __( 'Hello from the editor', 'movie-library' ) }
        </p>
    );
}

onChange writes through setAttributes, and the attribute has to exist in block.json before it will hold a value. Every label goes through __(), which only works if the text domain is set up correctly.

Nesting with InnerBlocks

InnerBlocks is what makes a block able to contain blocks, the way group and columns do:

import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';

<InnerBlocks />

Four props do the work:

  • allowedBlocks -> restricts what the inserter offers inside your block
  • orientation -> horizontal or vertical. It does not lay anything out, it decides which way the mover arrows point
  • template -> the default content a fresh block starts with
  • templateLock -> four values, not three. all blocks every operation. insert allows moving but not adding or removing. contentOnly blocks everything and additionally hides blocks with no content from the list view, and it is the only one a child block cannot override. false opts out of a lock a parent imposed
const ALLOWED_BLOCKS = [ 'core/image', 'core/paragraph' ];

const TEMPLATE = [ [ 'core/columns', {}, [
    [ 'core/column', {}, [
        [ 'core/paragraph', { content: 'Paragraph 1' } ],
    ] ],
] ] ];

<InnerBlocks allowedBlocks={ ALLOWED_BLOCKS } template={ TEMPLATE } />

parent and ancestor

Both go in block.json and both restrict where a block can be used. The difference is how far they look:

"parent": [ "core/columns" ]
"ancestor": [ "core/columns" ]

parent requires that block to be the direct parent. ancestor only requires it somewhere above in the tree. Reach for ancestor unless you genuinely need the block one level down, because parent is the one that makes a block mysteriously unavailable. ๐Ÿ™ƒ

Block styles

To restyle a core block without touching core, register a style:

wp.blocks.registerBlockStyle( 'core/quote', {
    name: 'fancy-quote',
    label: 'Fancy Quote',
} );

Selecting it adds the class is-style-fancy-quote and nothing else. The class is the whole feature. You still write the CSS. ๐ŸŽจ

Variations

When two blocks differ only by attributes, they are one block with variations. This is how the embed block covers a few dozen providers:

variations: [
    {
        name: 'test1',
        isDefault: true,
        title: __( 'Test 1' ),
        icon: 'smiley',
        attributes: { providerNameSlug: 'wordpress' },
    },
    {
        name: 'test2',
        title: __( 'Test 2' ),
        icon: 'smiley',
        attributes: { providerNameSlug: 'google' },
        keywords: [ __( 'search' ) ],
    },
],

Three entries in the inserter, one block in the markup, and only the attribute value differs. ๐Ÿงฌ

Block context

Context is how a parent hands data down without either block knowing much about the other. Parent provides:

registerBlockType( metadata.name, {
    edit: Edit,
    attributes: {
        movie: { type: 'number', default: '' },
    },
    providesContext: {
        'movie-library/movie': 'movie',
    },
} );

Child consumes:

registerBlockType( metadata.name, {
    edit: Edit,
    save,
    usesContext: [ 'movie-library/movie' ],
} );

And in the child’s edit function it arrives as a prop:

export default function Edit( { context } ) {
    console.log( context['movie-library/movie'] );
}

Patterns

A pattern is prewritten block markup offered in the inserter. Registered in PHP, no build step:

register_block_pattern(
    'movie-library/test-pattern',
    array(
        'title'       => __( 'Test pattern', 'movie-library' ),
        'description' => __( 'Two paragraphs', 'movie-library' ),
        'categories'  => array( 'text' ),
        'content'     => '<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph -->',
    )
);

The content value is literal block markup, comment delimiters included.

What this does not cover

Dynamic blocks rendered in PHP, apiVersion differences, block supports, and deprecations, which is the API you need the moment you change a saved block’s markup after people have used it. โš ๏ธ