Skip to content

Instantly share code, notes, and snippets.

@parthnvaswani
Last active April 16, 2024 16:03
Show Gist options
  • Save parthnvaswani/7faff9d05d507d8f99452e72918b40da to your computer and use it in GitHub Desktop.
Save parthnvaswani/7faff9d05d507d8f99452e72918b40da to your computer and use it in GitHub Desktop.

Interactivity API

Interactivity API is a new API that allows developers to create interactive blocks in WordPress. It is built on top of the Block Editor and provides a way to create blocks that can interact with each other. Before the Interactivity API, developers had to rely on custom JavaScript code to create interactive blocks.

Some configurations are required to use the Interactivity API and its scripts. As @wordpress/intereactivity provides its scripts as js module and we can only import use a module script in another module script.

Custom Interactive Block

To create a custom interactive block using the Interactivity API, you need to follow these steps:

  1. Update @wordpress/scripts to version 27.6.0.
  2. Add @wordpress/interactivity as a dependency.
  3. Add --experimental-modules to the build and start wp-scripts command in package.json.
"scripts": {
    "build:blocks": "wp-scripts build --experimental-modules --config ./node_modules/@wordpress/scripts/config/webpack.config.js --webpack-src-dir=./assets/src/blocks/ --output-path=./assets/build/blocks/",
}
  1. Create a new block using the @wordpress/create-block command from inside the blocks folder.
npx @wordpress/create-block@latest example-block-interactive --template @wordpress/create-block-interactive-template --no-plugin

This will scaffold a new block with the necessary files and configurations to get started with the Interactivity API.

Files and Configurations

The following files and configurations are added to the block:

  1. block.json - Contains the block configuration.

    • interactivity supports is set to true.
    •   {
            ...
            "supports": {
                "interactivity": true
            }
            ...
        }
  2. render.php - Contains the block's server-side rendering logic.

    •   <?php
        $unique_id = wp_unique_id( 'p-' );
        ?>
      
        <div
            <?php echo get_block_wrapper_attributes(); ?>
            data-wp-interactive="create-block"
            <?php echo wp_interactivity_data_wp_context( array( 'isOpen' => false ) ); ?>
            data-wp-watch="callbacks.logIsOpen"
        >
            <button
                data-wp-on--click="actions.toggle"
                data-wp-bind--aria-expanded="context.isOpen"
                aria-controls="<?php echo esc_attr( $unique_id ); ?>"
            >
                <?php esc_html_e( 'Toggle', 'example-block-interactive' ); ?>
            </button>
      
            <p
                id="<?php echo esc_attr( $unique_id ); ?>"
                data-wp-bind--hidden="!context.isOpen"
            >
                <?php
                    esc_html_e( 'Example Block Interactive - hello from an interactive block!', 'example-block-interactive' );
                ?>
            </p>
        </div>
  3. view.js - Contains the block's client-side script.

    •   /**
         * WordPress dependencies
        */
        import { store, getContext } from '@wordpress/interactivity';
      
        store( 'create-block', {
            actions: {
                toggle: () => {
                    const context = getContext();
                    context.isOpen = ! context.isOpen;
                },
            },
            callbacks: {
                logIsOpen: () => {
                    const { isOpen } = getContext();
                    // Log the value of `isOpen` each time it changes.
                    console.log( `Is open: ${ isOpen }` );
                },
            },
        } );

Directives

  • wp-interactivity
    • Here data-wp-interactivity="create-block" is used to activate the interactivity for the element and its children. The create-block is the namespace for the store.
  • wp-context
    • Here <?php echo wp_interactivity_data_wp_context( array( 'isOpen' => false ) ); ?> is used to set the initial context for the block.
    • Here the isOpen state is set to false initially.
    • It'll look like this data-wp-context="{'isOpen':false}"
  • wp-watch
    • Here data-wp-watch="callbacks.logIsOpen" is used to watch the isOpen state and log it to the console.
    • It runs a callback when the node is created and runs it again when the state or context used in the callback changes.
  • wp-on
    • Here data-wp-on--click="actions.toggle" is used to bind the toggle action to the click event of the button.
  • wp-bind
    • Here data-wp-bind--aria-expanded="context.isOpen" is used to bind the aria-expanded attribute of the button to the isOpen state.
    • So, when the isOpen state is true, the aria-expanded attribute will be added to the button.
    • Here data-wp-bind--hidden="!context.isOpen" is used to bind the hidden attribute of the paragraph to the negation of the isOpen state.
    • So, when the isOpen state is false, the hidden attribute will be added to the paragraph.

Output

Screen.Recording.2024-04-16.at.9.03.18.PM.mov

Core Blocks with Interactivity API

If we want to create a core/button that plays a core/video. We can use the Interactivity API to add interactivity directives to the core blocks. And add custom script to make them interactive.

To use the Interactivity API with core blocks, you need to follow these steps:

  1. Update @wordpress/scripts to version 27.6.0.
  2. Add @wordpress/interactivity as a dependency.
  3. Add --experimental-modules to the build and start wp-scripts command in package.json.
"scripts": {
    "build:js": "wp-scripts build --experimental-modules --config ./webpack.config.js",
    "start:js": "wp-scripts start --experimental-modules --config ./webpack.config.js",
}
  1. Update webpack configuration to enable module output.
/**
 * External dependencies
 */
const fs = require( 'fs' );
const path = require( 'path' );
const CssMinimizerPlugin = require( 'css-minimizer-webpack-plugin' );
const RemoveEmptyScriptsPlugin = require( 'webpack-remove-empty-scripts' );

/**
 * WordPress dependencies
 */
const { getAsBooleanFromENV } = require( '@wordpress/scripts/utils' );

// Check if the --experimental-modules flag is set.
const hasExperimentalModulesFlag = getAsBooleanFromENV( 'WP_EXPERIMENTAL_MODULES' );
let scriptConfig, moduleConfig;

if ( hasExperimentalModulesFlag ) {
	[ scriptConfig, moduleConfig ] = require( '@wordpress/scripts/config/webpack.config' );
} else {
	scriptConfig = require( '@wordpress/scripts/config/webpack.config' );
}

// Extend the default config.
const sharedConfig = {
	...scriptConfig,
	output: {
		path: path.resolve( process.cwd(), 'assets', 'build', 'js' ),
		filename: '[name].js',
		chunkFilename: '[name].js',
	},
	plugins: [
		...scriptConfig.plugins
			.map(
				( plugin ) => {
					if ( plugin.constructor.name === 'MiniCssExtractPlugin' ) {
						plugin.options.filename = '../css/[name].css';
					}
					return plugin;
				},
			),
		new RemoveEmptyScriptsPlugin(),
	],
	optimization: {
		...scriptConfig.optimization,
		splitChunks: {
			...scriptConfig.optimization.splitChunks,
		},
		minimizer: scriptConfig.optimization.minimizer.concat( [ new CssMinimizerPlugin() ] ),
	},
};

const styles = {
	...sharedConfig,
	entry: () => {
		const entries = {};
		const dir = './assets/src/css';

		if ( ! fs.existsSync( dir ) ) {
			return entries;
		}

		if ( fs.readdirSync( dir ).length === 0 ) {
			return entries;
		}

		fs.readdirSync( dir ).forEach( ( fileName ) => {
			const fullPath = `${ dir }/${ fileName }`;
			if ( ! fs.lstatSync( fullPath ).isDirectory() ) {
				entries[ fileName.replace( /\.[^/.]+$/, '' ) ] = fullPath;
			}
		} );

		return entries;
	},
	module: {
		...sharedConfig.module,
	},
	plugins: [
		...sharedConfig.plugins.filter(
			( plugin ) => plugin.constructor.name !== 'DependencyExtractionWebpackPlugin',
		),
	],

};

const scripts = {
	...sharedConfig,
	entry: {
		accordion: path.resolve( process.cwd(), 'assets', 'src', 'js', 'accordion.js' ),
		video: path.resolve( process.cwd(), 'assets', 'src', 'js', 'video.js' ),
	},
};

// Add module scripts configuration if the --experimental-modules flag is set.
let moduleScripts = {};
if ( hasExperimentalModulesFlag ) {
	moduleScripts = {
		...moduleConfig,
		entry: {
			'core-video': path.resolve( process.cwd(), 'assets', 'src', 'js', 'modules', 'core-video.js' ),
		},
		output: {
			...moduleConfig.output,
			path: path.resolve( process.cwd(), 'assets', 'build', 'js', 'modules' ),
			filename: '[name].js',
			chunkFilename: '[name].js',
		},
	};
}

const customExports = [ scripts, styles ];

if ( hasExperimentalModulesFlag ) {
	customExports.push( moduleScripts );
}

module.exports = customExports;
  • This configuration will enable module output for the scripts present in the assets/src/js/modules folder.
  1. Create a block extensions class to add interactivity directives to the core blocks by checking the classes of the blocks.
<?php
/**
 * Media Text Interactive.
 *
 * @package Elementary-Theme
 */

namespace Elementary_Theme\Block_Extensions;

use WP_HTML_Tag_Processor;
use Elementary_Theme\Traits\Singleton;

/**
 * Class Media_Text_Interactive
 */
class Media_Text_Interactive {

	use Singleton;

	/**
	 * Constructor.
	 */
	protected function __construct() {

		$this->setup_hooks();

	}

	/**
	 * Setup hooks.
	 *
	 * @return void
	 */
	public function setup_hooks() {

		add_filter( 'render_block_core/button', array( $this, 'render_block_core_button' ), 10, 2 );
		add_filter( 'render_block_core/columns', array( $this, 'render_block_core_columns' ), 10, 2 );
		add_filter( 'render_block_core/video', array( $this, 'render_block_core_video' ), 10, 2 );

	}

	/**
	 * Render block core/button.
	 *
	 * @param string $block_content Block content.
	 * @param array  $block Block.
	 * @return string
	 */
	public function render_block_core_button( $block_content, $block ) {
		if ( ! isset( $block['attrs']['className'] ) || ! str_contains( $block['attrs']['className'], 'elementary-media-text-interactive' ) ) {
			return $block_content;
		}

		$p = new WP_HTML_Tag_Processor( $block_content );
		$p->next_tag();

		$p->set_attribute( 'data-wp-on--click', 'actions.play' );
		return $p->get_updated_html();

	}

	/**
	 * Render block core/columns.
	 *
	 * @param string $block_content Block content.
	 * @param array  $block Block.
	 * @return string
	 */
	public function render_block_core_columns( $block_content, $block ) {
		if ( ! isset( $block['attrs']['className'] ) || ! str_contains( $block['attrs']['className'], 'elementary-media-text-interactive' ) ) {
			return $block_content;
		}

		wp_enqueue_script_module(
			'@elementary/media-text-interactive',
			sprintf( '%s/js/modules/media-text-interactive.js', ELEMENTARY_THEME_BUILD_URI ),
			[
				'@wordpress/interactivity',
			]
		); // enqueue the module script using the `wp_enqueue_script_module` function.

		$p = new WP_HTML_Tag_Processor( $block_content );
		$p->next_tag();

		$p->set_attribute( 'data-wp-interactive', '{ "namespace": "elementary/media-text-interactive" }' );
		$p->set_attribute( 'data-wp-context', '{ "isPlaying": false }' );
		return $p->get_updated_html();
	}

	/**
	 * Render block core/video.
	 *
	 * @param string $block_content Block content.
	 * @param array  $block Block.
	 * @return string
	 */
	public function render_block_core_video( $block_content, $block ) {
		if ( ! isset( $block['attrs']['className'] ) || ! str_contains( $block['attrs']['className'], 'elementary-media-text-interactive' ) ) {
			return $block_content;
		}

		$p = new WP_HTML_Tag_Processor( $block_content );
		$p->next_tag();

		$p->set_attribute( 'data-wp-watch', 'callbacks.playVideo' );
		return $p->get_updated_html();
	}
}
  1. Create a module script to add interactivity directives to the core blocks.
/**
 * Custom module script required for the media text interactive pattern.
 */

/**
 * WordPress dependencies
 */
import { store, getContext, getElement } from '@wordpress/interactivity';

store( 'elementary/media-text-interactive', {
	actions: {
		/**
		 * Update the video play state.
		 *
		 * @return {void}
		 */
		play() {
			const context = getContext();
			context.isPlaying = true;
		},
	},
	callbacks: {
		/**
		 * Play the video.
		 *
		 * @return {void}
		 */
		playVideo() {
			const context = getContext();
			const { ref } = getElement();
			if ( context.isPlaying ) {
				ref.querySelector( 'video' )?.play();
				context.isPlaying = false;
			}
		},
	},
} );

Output

Screen.Recording.2024-04-16.at.9.16.51.PM.mov

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment