Docs Block Developer Guide

Building a New Block — index.js

index.js is the block editor UI. It loads only inside the Gutenberg editor, never on the frontend. It must be a self-contained IIFE — no import, no export, no JSX.


IIFE Structure — Mandatory Pattern

( function () {
  'use strict';

  var registerBlockType = wp.blocks.registerBlockType;
  var el                = wp.element.createElement;
  var Fragment          = wp.element.Fragment;
  var InspectorControls = wp.blockEditor.InspectorControls;
  var PanelBody         = wp.components.PanelBody;
  var SelectControl     = wp.components.SelectControl;
  var TextControl       = wp.components.TextControl;
  var ToggleControl     = wp.components.ToggleControl;
  var useBlockProps     = wp.blockEditor.useBlockProps;

  // ── Panel Registry options ────────────────────────────────────────────
  // Read live option lists from the Panel Registry. These update automatically
  // when the registry is modified — no block edits required.
  var REGISTRY = window.wptPanelRegistry || {};

  function registryOptions( panel, field ) {
    var fields = REGISTRY[panel] && REGISTRY[panel].fields ? REGISTRY[panel].fields : [];
    for ( var i = 0; i < fields.length; i++ ) {
      if ( fields[i].key === field ) {
        return ( fields[i].options || [] ).map( function( v ) {
          return { label: v, value: v };
        });
      }
    }
    return [];
  }

  var HEADING_OPTIONS = registryOptions( 'structure', 'headingLevel' );
  var ROLE_OPTIONS    = registryOptions( 'structure', 'semanticRole' );
  var PADDING_OPTIONS = registryOptions( 'spacing',   'blockPadding' );
  var ALIGN_OPTIONS   = registryOptions( 'layout',    'textAlign'    );

  // ── Element Registry class builders ──────────────────────────────────
  // Always guard with window.wptElements before calling.
  // Returns '' as a safe fallback if the registry has not yet loaded.
  function headingClasses( attr ) {
    return window.wptElements
      ? window.wptElements.buildElementClasses( 'heading', attr.headingLevel || 'h2', attr.elementOverrides )
      : '';
  }

  function descClasses( attr ) {
    return window.wptElements
      ? window.wptElements.buildElementClasses( 'description', null, attr.elementOverrides )
      : '';
  }

  // ── Root class builder ────────────────────────────────────────────────
  function rootClass( attr ) {
    var c = [ 'wpt-feature-card' ];
    c.push( 'wpt-feature-card--theme-'  + ( attr.themeMode       || 'light' ) );
    c.push( 'wpt-feature-card--bg-'     + ( attr.backgroundColor || 'light' ) );
    if ( attr.hideOnMobile  ) { c.push( 'wpt-feature-card--hide-mobile'  ); }
    if ( attr.hideOnTablet  ) { c.push( 'wpt-feature-card--hide-tablet'  ); }
    if ( attr.hideOnDesktop ) { c.push( 'wpt-feature-card--hide-desktop' ); }
    return c.join( ' ' );
  }

  // ── Root inline style — spacing via CSS custom properties ─────────────
  // Spacing values travel as CSS custom properties, not modifier classes.
  // render.php mirrors this exact same map.
  function rootStyle( attr ) {
    var map = {
      'none': '0',
      'xs':   'var(--wpt-spacing-xs)',
      'sm':   'var(--wpt-spacing-sm)',
      'md':   'var(--wpt-spacing-md)',
      'lg':   'var(--wpt-spacing-lg)',
      'xl':   'var(--wpt-spacing-xl)',
      '2xl':  'var(--wpt-spacing-2xl)',
      '3xl':  'var(--wpt-spacing-3xl)',
      '4xl':  'var(--wpt-spacing-4xl)',
    };
    var style = {};
    if ( map[ attr.blockPadding ] ) { style['--wpt-feature-card-padding'] = map[ attr.blockPadding ]; }
    if ( map[ attr.blockGap ]     ) { style['--wpt-feature-card-gap']     = map[ attr.blockGap ];     }
    if ( attr.spacingBottom && attr.spacingBottom !== 'none' && map[ attr.spacingBottom ] ) {
      style['margin-block-end'] = map[ attr.spacingBottom ];
    }
    return style;
  }

  // ── Block registration ────────────────────────────────────────────────
  registerBlockType( 'wptruss/feature-card', {

    edit: function ( props ) {
      var attr    = props.attributes;
      var setAttr = props.setAttributes;
      var bp      = useBlockProps({ className: rootClass( attr ), style: rootStyle( attr ) });

      return el( Fragment, null,

        el( InspectorControls, null,

          // Panel 1 — Structure (always first, always open)
          el( PanelBody, { title: '🏗️ Structure', initialOpen: true },
            el( SelectControl, {
              label: 'Heading Level', value: attr.headingLevel || 'h2',
              options: HEADING_OPTIONS,
              onChange: function( v ) { setAttr({ headingLevel: v }); }
            }),
            el( SelectControl, {
              label: 'Semantic Wrapper', value: attr.semanticRole || 'section',
              options: ROLE_OPTIONS,
              onChange: function( v ) { setAttr({ semanticRole: v }); }
            })
          ),

          // Panel 2 — Design (always second)
          el( PanelBody, { title: '🎨 Design', initialOpen: false },
            el( SelectControl, {
              label: 'Theme Mode', value: attr.themeMode || 'light',
              options: [
                { label: 'Light', value: 'light' },
                { label: 'Dark',  value: 'dark'  },
              ],
              onChange: function( v ) { setAttr({ themeMode: v }); }
            }),
            el( SelectControl, {
              label: 'Background', value: attr.backgroundColor || 'light',
              options: [
                { label: 'Light',     value: 'light'     },
                { label: 'Surface',   value: 'surface'   },
                { label: 'Primary',   value: 'primary'   },
                { label: 'Secondary', value: 'secondary' },
                { label: 'Dark',      value: 'dark'      },
              ],
              onChange: function( v ) { setAttr({ backgroundColor: v }); }
            })
          ),

          // Panel 3 — Content (block-specific fields)
          el( PanelBody, { title: '📝 Content', initialOpen: false },
            el( TextControl, {
              label: 'Heading', value: attr.heading || '',
              onChange: function( v ) { setAttr({ heading: v }); }
            }),
            el( TextControl, {
              label: 'Description', value: attr.description || '',
              onChange: function( v ) { setAttr({ description: v }); }
            })
          ),

          // Panel 4 — Spacing
          el( PanelBody, { title: '↕ Spacing', initialOpen: false },
            el( SelectControl, {
              label: 'Block Padding', value: attr.blockPadding || '3xl',
              options: PADDING_OPTIONS,
              onChange: function( v ) { setAttr({ blockPadding: v }); }
            })
          ),

          // Panel 5 — Layout (always last)
          el( PanelBody, { title: '📱 Layout', initialOpen: false },
            el( SelectControl, {
              label: 'Text Align', value: attr.textAlign || 'left',
              options: ALIGN_OPTIONS,
              onChange: function( v ) { setAttr({ textAlign: v }); }
            }),
            el( ToggleControl, {
              label: 'Hide on Mobile',  checked: attr.hideOnMobile  || false,
              onChange: function( v ) { setAttr({ hideOnMobile:  v }); }
            }),
            el( ToggleControl, {
              label: 'Hide on Tablet',  checked: attr.hideOnTablet  || false,
              onChange: function( v ) { setAttr({ hideOnTablet:  v }); }
            }),
            el( ToggleControl, {
              label: 'Hide on Desktop', checked: attr.hideOnDesktop || false,
              onChange: function( v ) { setAttr({ hideOnDesktop: v }); }
            })
          )

        ),

        // ── Editor canvas ─────────────────────────────────────────────
        el( 'article', bp,
          el( attr.headingLevel || 'h3', {
            className: 'wpt-feature-card__heading ' + headingClasses( attr )
          }, attr.heading || 'Feature heading' ),
          el( 'p', {
            className: 'wpt-feature-card__description ' + descClasses( attr )
          }, attr.description || 'Feature description text.' )
        )

      );
    },

    save: function() {
      return null; // All blocks use render.php. save() always returns null.
    }

  });

}() );

Element Registry API

The Element Registry exposes a single method on window.wptElements:

window.wptElements.buildElementClasses( elementType, variant, overrides )
Argument Type Description
elementType string Element handle: 'heading', 'description', 'button', 'badge', 'image', 'eyebrow', 'icon'
variant string null Variant key for that element (e.g. 'h2' for heading). Pass null if the element has no variants.
overrides object The block’s elementOverrides attribute. Pass directly from attr.elementOverrides.

Always guard the call. The element registry loads asynchronously. An unguarded call will throw if index.js evaluates before the registry is ready:

// ✅ Correct — guarded, returns '' as safe fallback
function headingClasses( attr ) {
  return window.wptElements
    ? window.wptElements.buildElementClasses( 'heading', attr.headingLevel || 'h2', attr.elementOverrides )
    : '';
}

// ❌ Wrong — throws if registry loads after index.js
function headingClasses( attr ) {
  return window.wptElements.buildElementClasses( 'heading', attr.headingLevel, attr.elementOverrides );
}

Five-Panel Inspector Order

Always follow this order. The validator checks panel title strings and flags deviations:

Position Title initialOpen Purpose
1 🏗️ Structure true Heading level, semantic role
2 🎨 Design false Theme mode, background colour
3 📝 Content false Block-specific editable fields
4 ↕ Spacing false Padding, gap, spacing bottom
5 📱 Layout false Text alignment, responsive visibility

Mandatory index.js Rules

  • All code inside a single IIFE: ( function () { 'use strict'; ... }() );
  • No import, no export, no export default
  • No JSX — use wp.element.createElement (aliased to el)
  • No fetch(), XMLHttpRequest, or eval()
  • No .innerHTML or .outerHTML DOM assignments
  • save() always returns null — server render only
  • All conditional renders use ternary (condition ? el(...) : null) — never condition && el(...)
  • All array attributes guarded with Array.isArray() before .map()
  • Never pass dynamic strings into () — build the string outside, pass a literal string to ()