Creating a Gutenberg Custom Block

After working on mostly Drupal projects for the past two years, I’m getting caught back up on the WordPress side of things and digging into Gutenberg and the Full Site Editor. I’ve built custom blocks with ACF in the past, and while those are great, I’ve been trying to learn how to get blocks working with JavaScript.

For my first block, I needed a hero banner that would display the post title overlaying the featured image, similar to the Cover block but without the editor needing to (re)enter the title and image.

I spun up a starting custom block with npx use @wordpress/create-block, which gave me a new plugin with build scripts included. There’s a lot that’s bundled in here (as I discovered the hard way when I forgot to delete the node_modules folder before copying the plugin to a second project only for the copy to take over twenty minutes), but it was a quick and painless way to scaffold a new block.

Next I edited the block.json file so that the new block would support wide and full layout options:

"supports": {
  "html": false,
  "align": [ "wide", "full" ]
}

I also added an attribute, isLink, that would indicate whether the banner should link to the post.

"attributes": {
  "isLink": {
    "type": "boolean",
    "default": false
  }
}

In my src/index.js file, in addition to registering the block type, which was already scaffolded, I registered an additional style as well. I wanted to create a smaller version of the banner that could be used as a post teaser in a query loop.

registerBlockStyle('myplugin-banner/myplugin-banner', {
    name: 'small',
    label: 'Small'
});

With this in place, I needed to create the block markup when editing and saving. WordPress already had a Post Title block and Post Featured Image block, so all I really needed was a block that would wrap around those, add some additional markup, and make the title and/or featured image (I ended up going with just the featured image) link to the post when the parent block’s isLink attribute was true. I also needed to add the control itself so that the editor could toggle isLink on and off.

The best resource for figuring out how to do most of this was looking at the block-library for core blocks as well as the WordPress documentation. To create a block that included a nested featured image and post title block, I needed InnerBlocks from @wordpress/block-editor.

const ALLOWED_BLOCKS = ['core/post-featured-image', 'core/post-title'];
export default function Edit() {
  return (
    <div {...useBlockProps()}>
      <InnerBlocks
        allowedBlocks={ ALLOWED_BLOCKS }
        template={[
          [ 'core/post-featured-image', {} ],
          [ 'core/post-title', {'level': 1} ],
        ]}
        templateLock='all'
      />
    </div>
  )
}

allowedBlocks sets what block types are allowed to be nested in the block. template sets what blocks, and optionally, what attributes on those blocks, are added whenever a new parent block is added. templateLock='all' prevents nested blocks from being added, moved, or removed.

With that, I could add CSS to my src/style.scss to overlay the title on the featured image.

To add the link functionality, I needed the InspectorControls component from @wordpress/block-editor to add a new control to the sidebar. I also needed PanelBody and ToggleControl from @wordpress/components so I could reuse the existing WordPress UI. ToggleControl is a React component that accepts a custom onChange function. The simplest is to have it call setAttributes() to update a block attribute. attributes and setAttributes seem to work like any React useState hook.

Following a comment in a helpful GitHub thread, I learned that @wordpress/data manages state similar to Redux and can be used to dispatch an update to an inner block.

const featuredImage = select( 'core/block-editor' )
  .getBlocksByClientId(clientId)[0]
  .innerBlocks[0];

gets the Block Editor’s Data, which then gets an array of block objects for the current client ID (so an array of one object, the current block). The block object’s innerBlocks property is an array of block objects nested within the current block. Since my template is locked, I assumed that the first inner block would always be the featured image block.

dispatch('core/block-editor')
  .updateBlockAttributes(featuredImage.clientId, {
    isLink: isLinkNow
});

then dispatches to the block editor’s data an action to update the inner block found, setting its isLink attribute to the new value.

Altogether then, my edit function looked like this:

export default function Edit({
	attributes: {isLink},
	setAttributes,
	clientId
}) {
	const onChangeLink = () => {
		const isLinkNow = !isLink;
		setAttributes( { isLink: isLinkNow });
		const featuredImage = select( 'core/block-editor' )
          .getBlocksByClientId(clientId)[0]
          .innerBlocks[0];
		dispatch('core/block-editor')
          .updateBlockAttributes(featuredImage.clientId, {
			isLink: isLinkNow
		});
	};
	return (
		<>
			<InspectorControls>
				<PanelBody title={ __( 'Link settings' ) }>
					<ToggleControl
						label={ __( 'Make banner a link' ) }
						onChange={ onChangeLink }
						checked={ isLink }
					/>
				</PanelBody>
			</InspectorControls>
			<div {...useBlockProps()}>
				<InnerBlocks
					allowedBlocks={ ALLOWED_BLOCKS }
					template={[
						[ 'core/post-featured-image', {} ],
						[ 'core/post-title', {'level': 1} ],
					]}
					templateLock='all'
				/>
			</div>
		</>
	);
}

My save.js was much easier. All that needed to do was render the InnerBlock’s content.

export default function save() {
	return (
		<div {...useBlockProps.save()}>
			<InnerBlocks.Content />
		</div>
	);
}

With that in place, I had a working block I could use and add to a post type’s template.

References