Creating a Custom Gutenberg Block for Bootstrap CSS Grid

In this blog post, we will explore how to create a custom Gutenberg block for a Bootstrap CSS grid. This block allows users to configure various grid settings, such as gap, column gap, row gap, and alignment, using react-select for a flexible and user-friendly interface. Additionally, users can specify the number of columns and dynamically add or remove columns within the grid. Let’s break down the code step-by-step. Please see the previous article for how to create the parent container block.

Importing Dependencies

// Importing the necessary dependencies and options
import ReactSelect from 'react-select'; // Importing ReactSelect for multi-select dropdowns
import { gapOptions, columnGapOptions, rowGapOptions, alignItemsOptions } from '../columns/ColumnOptions'; // Importing options for gap, column gap, row gap, and alignment classes
// those import variables are arrays of objects alignItemsOptions = [ { label: 'align-items-center', value:'align-items-center'}, etc. // you could add more, do less, it's up to you, in this example, I'm choosing to add container and padding options to the container, you would do whatever works best for yourself.

Here, we import react-select for the multi-select dropdown component and the gapOptions, columnGapOptions, rowGapOptions, and alignItemsOptions which contain the options for the user to select from.

Defining the Block Function

// Defining the main function for the custom Gutenberg block
export default function gutenbergBlocksBootstrapCssGrid(wp) {
    // Extracting necessary utilities from the wp object
    const { registerBlockType } = wp.blocks; // Function to register a block
    const { __ } = wp.i18n; // Localization function
    const { InspectorControls, InnerBlocks } = wp.blockEditor; // Block editor components
    const { PanelBody, Button, TextControl } = wp.components; // UI components
    const { useState, useEffect, Fragment } = wp.element; // React hooks and Fragment component

This function serves as the main entry point for our custom grid block. We extract necessary WordPress and React utilities from the wp object passed to this function.

Edit Function

The edit function is crucial as it defines how the block behaves and appears in the editor. Let’s explore its components:

// Edit function defining the block's backend behavior and UI
const edit = ({ attributes, setAttributes, clientId }) => {
    const { gridClass = [], columns = 0 } = attributes; // Destructuring attributes

    // Function to get initial state based on gridClass attribute
    const getInitialState = (options) => options.filter((option) => gridClass.includes(option.value));

    // Initializing state for each class type based on gridClass attribute
    const [selectedGapClasses, setSelectedGapClasses] = useState(getInitialState(gapOptions));
    const [selectedColumnGapClasses, setSelectedColumnGapClasses] = useState(getInitialState(columnGapOptions));
    const [selectedRowGapClasses, setSelectedRowGapClasses] = useState(getInitialState(rowGapOptions));
    const [selectedAlignItemsClasses, setSelectedAlignItemsClasses] = useState(getInitialState(alignItemsOptions));
    const [columnsValue, setColumnsValue] = useState(columns);

We use the useState hook to manage the state of selected gap, column gap, row gap, and alignment classes. This ensures that any changes made by the user are immediately reflected in the block’s attributes.

// useEffect to update gridClass attribute and block editor class based on state changes
useEffect(() => {
    // Combining all selected classes into one array
    const combinedClasses = [
        ...selectedGapClasses,
        ...selectedColumnGapClasses,
        ...selectedRowGapClasses,
        ...selectedAlignItemsClasses
    ].map((option) => option.value).filter(Boolean); // Remove undefined values

    // Update gridClass attribute
    setAttributes({ gridClass: combinedClasses });

    // Update the class and style of the block editor element
    const blockEditor = document.querySelector(`.block-editor-block-list__block[data-block="${clientId}"] > .grid > .block-editor-inner-blocks > .block-editor-block-list__layout`);
    if (blockEditor) {
        blockEditor.className = `block-editor-block-list__layout grid ${combinedClasses.join(' ')}`;
        if (columnsValue > 0) {
            blockEditor.style.setProperty('--bs-columns', columnsValue.toString());
        } else {
            blockEditor.style.removeProperty('--bs-columns');
        }
    }
}, [selectedGapClasses, selectedColumnGapClasses, selectedRowGapClasses, selectedAlignItemsClasses, columnsValue]);

The useEffect hooks ensure that whenever the selected gap, column gap, row gap, alignment classes, or columns change, the block’s attributes are updated accordingly. This also updates the block’s class names and styles in the editor dynamically.

// Handler function to update state for class selectors
const handleClassChange = (setter) => (newValue) => {
    setter(newValue || []);
};

// Handler function to update the number of columns
const handleColumnsChange = (newColumns) => {
    const numColumns = parseInt(newColumns, 10);
    if (!Number.isNaN(numColumns) && numColumns >= 0 && numColumns <= 24) {
        setColumnsValue(numColumns);
        setAttributes({ columns: numColumns });
    }
};

These handler functions update the state when the user selects new options from the dropdowns or changes the number of columns.

// Function to add a new column block
const addColumn = () => {
    wp.data.dispatch('core/block-editor').insertBlocks(
        wp.blocks.createBlock('bs5/bootstrap-grid-column'),
        wp.data.select('core/block-editor').getBlock(clientId).innerBlocks.length,
        clientId
    );
};

// Function to remove the last column block
const removeColumn = () => {
    const { innerBlocks } = wp.data.select('core/block-editor').getBlock(clientId);

    if (innerBlocks.length > 0) {
        const lastBlockClientId = innerBlocks[innerBlocks.length - 1].clientId;
        wp.data.dispatch('core/block-editor').removeBlock(lastBlockClientId);
    }
};

These functions handle adding and removing columns within the grid block. The addColumn function inserts a new column block, while the removeColumn function removes the last column block.

    return (
        <Fragment>
            <InspectorControls>
                <PanelBody title={__('Grid Gap Settings')}>
                    <ReactSelect
                        isMulti
                        value={selectedGapClasses}
                        options={gapOptions}
                        onChange={handleClassChange(setSelectedGapClasses)}
                        className="react-select-container"
                        classNamePrefix="react-select"
                    />
                </PanelBody>
                <PanelBody title={__('Grid Column Gap Settings')}>
                    <ReactSelect
                        isMulti
                        value={selectedColumnGapClasses}
                        options={columnGapOptions}
                        onChange={handleClassChange(setSelectedColumnGapClasses)}
                        className="react-select-container"
                        classNamePrefix="react-select"
                        style={{ marginTop: '10px' }}
                    />
                </PanelBody>
                <PanelBody title={__('Grid Row Gap Settings')}>
                    <ReactSelect
                        isMulti
                        value={selectedRowGapClasses}
                        options={rowGapOptions}
                        onChange={handleClassChange(setSelectedRowGapClasses)}
                        className="react-select-container"
                        classNamePrefix="react-select"
                        style={{ marginTop: '10px' }}
                    />
                </PanelBody>
                <PanelBody title={__('Grid Alignment Settings')}>
                    <ReactSelect
                        isMulti
                        value={selectedAlignItemsClasses}
                        options={alignItemsOptions}
                        onChange={handleClassChange(setSelectedAlignItemsClasses)}
                        className="react-select-container"
                        classNamePrefix="react-select"
                        style={{ marginTop: '10px' }}
                    />
                </PanelBody>
                <PanelBody title={__('Number of Columns')}>
                    <TextControl
                        type="number"
                        value={columnsValue}
                        onChange={(value) => handleColumnsChange(value)}
                        min={0}
                        max={24}
                        placeholder={__('Set number of columns (max 24)')}
                    />
                </PanelBody>
            </InspectorControls>
            <div className={`grid ${gridClass.join(' ')}`}>
                <InnerBlocks
                    allowedBlocks={['bs5/bootstrap-grid-column']}
                    template={[['bs5/bootstrap-grid-column']]}
                    renderAppender={InnerBlocks.ButtonBlockAppender}
                />
            </div>
            <Button onClick={addColumn} className="btn btn-warning" style={{ marginTop: '10px' }}>
                {__('Add Grid Column')}
            </Button>
            <Button onClick={removeColumn} className="btn btn-danger" style={{ marginTop: '10px', marginLeft: '10px' }}>
                {__('Remove Grid Column')}
            </Button>
        </Fragment>
    );
};

The InspectorControls component renders the settings panel in the block editor sidebar. We use PanelBody to group related controls together. The ReactSelect component provides a user-friendly interface for selecting multiple gap, column gap, row gap, and alignment classes. The TextControl component allows users to specify the number of columns. The InnerBlocks component allows users to add child blocks within our custom grid block. The buttons at the bottom allow users to add or remove columns dynamically.

Save Function

The save function determines the block’s HTML output on the frontend.

// Save function defining the block's frontend output
const save = ({ attributes }) => {
    const { gridClass, columns } = attributes;

    return (
        <div className={`grid ${gridClass.join(' ')}`} style={columns > 0 ? { '--bs-columns': columns.toString() } : {}}>
            <InnerBlocks.Content />
        </div>
    );
};

In the save function, we simply render a div element with the selected grid classes and the columns style property, and include any inner blocks added by the user.

Registering the Block

Finally, we register the block with WordPress.

    // Registering the block type
    registerBlockType('bs5/bootstrap-grid', {
        title: __('Bootstrap Grid'), // Block title
        icon: 'layout', // Block icon
        category: 'layout', // Block category
        attributes: {
            gridClass: {
                type: 'array',
                default: [], // Default grid class
            },
            columns: {
                type: 'number',
                default: 0, // Default number of columns
            },
        },
        edit, // Edit function
        save, // Save function
    });
}

We define the block’s title, icon, category, and attributes. The edit and save functions are passed to manage the block’s behavior in the editor and on the frontend.

Complete Code

Here’s the complete code for our custom Gutenberg grid block:

import ReactSelect from 'react-select';
import { gapOptions, columnGapOptions, rowGapOptions, alignItemsOptions } from '../columns/ColumnOptions';

export default function gutenbergBlocksBootstrapCssGrid(wp) {
    const { registerBlockType } = wp.blocks;
    const { __ } = wp.i18n;
    const { InspectorControls, InnerBlocks } = wp.blockEditor;
    const { PanelBody, Button, TextControl } = wp.components;
    const { useState, useEffect, Fragment } = wp.element;

    const edit = ({ attributes, setAttributes, clientId }) => {
        const { gridClass = [], columns = 0 } = attributes;

        const getInitialState = (options) => options.filter((option) => gridClass.includes(option.value));

        const [selectedGapClasses, setSelectedGapClasses] = useState(getInitialState(gapOptions));
        const [selectedColumnGapClasses, setSelectedColumnGapClasses] = useState(getInitialState(columnGapOptions));
        const [selectedRowGapClasses, setSelectedRowGapClasses] = useState(getInitialState(rowGapOptions));
        const [selectedAlignItemsClasses, setSelectedAlignItemsClasses] = useState(getInitialState(alignItemsOptions));
        const [columnsValue, setColumnsValue] = useState(columns);

        useEffect(() => {
            const combinedClasses = [
                ...selectedGapClasses,
                ...selectedColumnGapClasses,
                ...selectedRowGapClasses,
                ...selectedAlignItemsClasses
            ].map((option) => option.value).filter(Boolean);

            setAttributes({ gridClass: combinedClasses });

            const blockEditor = document.querySelector(`.block-editor-block-list__block[data-block="${clientId}"] > .grid > .block-editor-inner-blocks > .block-editor-block-list__layout`);
            if (blockEditor) {
                blockEditor.className = `block-editor-block-list__layout grid ${combinedClasses.join(' ')}`;
                if (columnsValue > 0) {
                    blockEditor.style.setProperty('--bs-columns', columnsValue.toString());
                } else {
                    blockEditor.style.removeProperty('--bs-columns');
                }
            }
        }, [selectedGapClasses, selectedColumnGapClasses, selectedRowGapClasses, selectedAlignItemsClasses, columnsValue]);

        const handleClassChange = (setter) => (newValue) => {
            setter(newValue || []);
        };

        const handleColumnsChange = (newColumns) => {
            const numColumns = parseInt(newColumns, 10);
            if (!Number.isNaN(numColumns) && numColumns >= 0 && numColumns <= 24) {
                setColumnsValue(numColumns);
                setAttributes({ columns: numColumns });
            }
        };

        const addColumn = () => {
            wp.data.dispatch('core/block-editor').insertBlocks(
                wp.blocks.createBlock('bs5/bootstrap-grid-column'),
                wp.data.select('core/block-editor').getBlock(clientId).innerBlocks.length,
                clientId
            );
        };

        const removeColumn = () => {
            const { innerBlocks } = wp.data.select('core/block-editor').getBlock(clientId);
            if (innerBlocks.length > 0) {
                const lastBlockClientId = innerBlocks[innerBlocks.length - 1].clientId;
                wp.data.dispatch('core/block-editor').removeBlock(lastBlockClientId);
            }
        };

        return (
            <Fragment>
                <InspectorControls>
                    <PanelBody title={__('Grid Gap Settings')}>
                        <ReactSelect
                            isMulti
                            value={selectedGapClasses}
                            options={gapOptions}
                            onChange={handleClassChange(setSelectedGapClasses)}
                            className="react-select-container"
                            classNamePrefix="react-select"
                        />
                    </PanelBody>
                    <PanelBody title={__('Grid Column Gap Settings')}>
                        <ReactSelect
                            isMulti
                            value={selectedColumnGapClasses}
                            options={columnGapOptions}
                            onChange={handleClassChange(setSelectedColumnGapClasses)}
                            className="react-select-container"
                            classNamePrefix="react-select"
                            style={{ marginTop: '10px' }}
                        />
                    </PanelBody>
                    <PanelBody title={__('Grid Row Gap Settings')}>
                        <ReactSelect
                            isMulti
                            value={selectedRowGapClasses}
                            options={rowGapOptions}
                            onChange={handleClassChange(setSelectedRowGapClasses)}
                            className="react-select-container"
                            classNamePrefix="react-select"
                            style={{ marginTop: '10px' }}
                        />
                    </PanelBody>
                    <PanelBody title={__('Grid Alignment Settings')}>
                        <ReactSelect
                            isMulti
                            value={selectedAlignItemsClasses}
                            options={alignItemsOptions}
                            onChange={handleClassChange(setSelectedAlignItemsClasses)}
                            className="react-select-container"
                            classNamePrefix="react-select"
                            style={{ marginTop: '10px' }}
                        />
                    </PanelBody>
                    <PanelBody title={__('Number of Columns')}>
                        <TextControl
                            type="number"
                            value={columnsValue}
                            onChange={(value) => handleColumnsChange(value)}
                            min={0}
                            max={24}
                            placeholder={__('Set number of columns (max 24)')}
                        />
                    </PanelBody>
                </InspectorControls>
                <div className={`grid ${gridClass.join(' ')}`}>
                    <InnerBlocks
                        allowedBlocks={['bs5/bootstrap-grid-column']}
                        template={[['bs5/bootstrap-grid-column']]}
                        renderAppender={InnerBlocks.ButtonBlockAppender}
                    />
                </div>
                <Button onClick={addColumn} className="btn btn-warning" style={{ marginTop: '10px' }}>
                    {__('Add Grid Column')}
                </Button>
                <Button onClick={removeColumn} className="btn btn-danger" style={{ marginTop: '10px', marginLeft: '10px' }}>
                    {__('Remove Grid Column')}
                </Button>
            </Fragment>
        );
    };

    const save = ({ attributes }) => {
        const { gridClass, columns } = attributes;

        return (
            <div className={`grid ${gridClass.join(' ')}`} style={columns > 0 ? { '--bs-columns': columns.toString() } : {}}>
                <InnerBlocks.Content />
            </div>
        );
    };

    registerBlockType('bs5/bootstrap-grid', {
        title: __('Bootstrap Grid'),
        icon: 'layout',
        category: 'layout',
        attributes: {
            gridClass: {
                type: 'array',
                default: [],
            },
            columns: {
                type: 'number',
                default: 0,
            },
        },
        edit,
        save,
    });
}

In this blog post, we’ve detailed the creation of a custom Gutenberg block for a Bootstrap CSS grid. We broke down the code into manageable sections, explaining the purpose and functionality of each part. The final code snippet encapsulates the entire block, ready to be used in your WordPress projects. Stay tuned for more posts in this series as we continue to explore and create powerful custom blocks!