How to enable and use theInteractivity API on your projects

Last updated on Oct 11, 2024

How to enable and use theInteractivity API on your projects

In the ever-evolving world of web development, creating interactive features within WordPress blocks has been streamlined thanks to the Interactivity API. This API introduces an abstraction layer similar to popular JavaScript frameworks, making it easier to attach common events, such as clicks, with minimal custom code. As a result, developers can now build more efficient interactive blocks while maintaining a more manageable codebase.

The Interactivity API has several noteworthy features:

  • Custom Stores: Simplify creating custom stores using the store function.
  • Simplified Bindings: Reduce boilerplate code for binding data and actions.
  • Interactivity Directives: Facilitate adding interactivity to blocks declaratively.
  • Modular Approach: Promotes a clean separation of concerns and code reusability.
  • Enhanced Developer Experience: Streamlined integration and improved error handling.

However, some configuration is necessary to leverage the Interactivity API’s full potential and its scripts. Given that @wordpress/interactivity provides its scripts as a JS module, we can only import and use a module script within another module script. Below are the steps required to add interactivity to custom and core blocks.

Discussing details on various features like creating the store, types of directives available, and accessing different stores are beyond the scope of this article. Please refer to the provided links at the end of the article to learn more about the interactivity API.

How to add Interactivity API to Custom Blocks

Interactivity API requires at least WordPress 6.5. The following guide is for adding an interactive block in a plugin that was NOT created using the wordpress/create-block tool and the scaffolding template create-block-interactive-template.

A newly created plugin with these tools will have all the latest dependencies. So, you can directly run the block scaffold command mentioned in step 4.

This section can be referred to while adding Interactivity API to the blocks that are created by us. i.e. Custom Blocks.

Creating a custom interactive block using the Interactivity API involves several steps. Follow this guide to get started:

1. Update @wordpress/scripts to version 27.6.0.

2. Add @wordpress/interactivity as a dependency.

...
"dependencies": {
    "@wordpress/interactivity": "^5.4.0"
},
"devDependencies": {
    ...
    "@wordpress/scripts": "27.6.0",
    ...
},
...

3. Add --experimental-modules to 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/",
}

4. Create a new block using the @wordpress/create-block command 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
    • Activates 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.

You can test out the code directly by cloning this features-plugin-skeleton branch.

Output

Next, let’s apply this to a real-world use case.

How to add Interactivity API to Core Blocks

This section can be referred to while adding Interactivity API to the blocks that are not in our control. i.e. Core Blocks and Third-party Blocks.

Imagine wanting to create a core/button that plays a core/video. Using the Interactivity API, you can add interactivity directives to the core blocks and add custom scripts to make them interactive relative to one another.

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.

...
"dependencies": {
    "@wordpress/interactivity": "^5.4.0"
},
"devDependencies": {
    ...
    "@wordpress/scripts": "27.6.0",
    ...
},
...

3. Add --experimental-modules to 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",
}

4. Update webpack configuration to enable module output.

Import getAsBooleanFromENV and use it to get the WP_EXPERIMENTAL_MODULES env variable to check if --experimental-modules flag is set.

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

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

If --experimental-modules flag is set then import both scriptConfig and moduleConfig else import the scriptConfig .

let scriptConfig, moduleConfig;

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

Replace the defaultConfig with scriptConfig inside sharedConfig object.

// 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() ] ),
    },
};

Check if --experimental-module flag is set and create a moduleScripts config using moduleConfig to enable module output for the scripts to the assets/build/js/modules folder.

// 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',
        },
    };
}

Combine the script, style, and module script configs and export them.

const customExports = [ scripts, styles ];

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

module.exports = customExports;

Finally, the file will look like this if you have started from the theme-elementary webpack.config.js

/**
 * 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;

5. Add interactivity directives to the core block markup by checking the classes of the blocks. You can add it inside inc/classes/block-extensions/class-media-text-interactive.php .

/**
 * Render block core/button.
 *
 * @param string $block_content Block content.
 * @param array $block Block.
 * @return string
 */
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();

}

add_filter( 'render_block_core/button', 'render_block_core_button', 10, 2 );

/**
 * Render block core/columns.
 *
 * @param string $block_content Block content.
 * @param array $block Block.
 * @return string
 */
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();
}

add_filter( 'render_block_core/columns', 'render_block_core_columns', 10, 2 );

/**
 * Render block core/video.
 *
 * @param string $block_content Block content.
 * @param array $block Block.
 * @return string
 */
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();
}

add_filter( 'render_block_core/video', 'render_block_core_video', 10, 2 );

6. Create a module script to add interactivity to the core blocks. In theme-elementary you can add it inside assets/src/js/modules/core-video.js.

/**
 * 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;
            }
        },
    },
} );

You can test out the code directly by cloning this theme-elementary branch.

7. Add the class elementary-media-text-interactive to buttonvideo and columns.

Output

References and further reading

On this page

Contributors

Parth Vaswani

Parth

Parth Vaswani

Software Engineer

Divyaraj Masani

Divyaraj

Divyaraj Masani

Not Available

Comments

Leave a Reply