WordPress 6.5 で新登場したInteractivity APIを試してみた

Interactivity APIとは?

ゴリゴリaddEventListenerとかで書いてたJSがサクッと書けるようになるAPIです。(かなりざっくり)

これを使ったらブロック間のやりとりとかもできるようになるので、かなり用途が広いAPIだと思います。

今回はこれを使ってブロックを作ってみました。

ブロックつくってみた

https://github.com/chiilog/iapi-tabs

Interactivity APIを使ったタブブロックです。

実質3〜4日で作りました。Alpine.js とか触ったことある人だともっと理解が早そう。(私は触ったことなかった)

管理画面

表示

使い方

親ブロックとして使うブロックのblock.json のsupports"interactivity": true を入れます。

また、view.js と render.php を使うので、"viewScriptModule": "file:./view.js""render": "file:./render.php" を追加します。

いれるとこんな感じになります。

{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "chiilog-blocks/iapi-tabs",
	"version": "0.1.0",
	"title": "IAPI Tabs",
	"category": "widgets",
	"icon": "smiley",
	"description": "Example block scaffolded with Create Block tool.",
	"attributes": {
		"contents": {
			"type": "array",
			"default": []
		},
		"tabNavText": {
			"type": "string",
			"source": "text",
			"selector": ".wp-block-chiilog-blocks-iapi-tabs__button span"
		}
	},
	"supports": {
		"interactivity": true,
		"anchor": true
	},
	"textdomain": "chiilog-iapi-tabs",
	"editorScript": "file:./index.js",
	"editorStyle": "file:./index.css",
	"style": "file:./style-index.css",
	"viewScriptModule": "file:./view.js",
	"render": "file:./render.php"
}

これでAPIを使う準備はOK!

次はブロックを用意します。

私は先にrender.php で完成形のHTMLタグを入れただけのファイルを作りました。

<?php
/**
 * @var array    $attributes The block attributes.
 * @var string   $content    The block default content.
 * @var WP_Block $block      The block instance.
 *
 * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render
 */
?>
<div
	<?php echo get_block_wrapper_attributes(); ?>
>
	<div class="wp-block-chiilog-blocks-iapi-tabs__nav" role="tablist">
		<button
			role="tab"
			class="wp-block-chiilog-blocks-iapi-tabs__button"
			aria-selected="true"
			id="tab-1"
			aria-controls="panel-1"
			tabindex="0"
		>
			<span>ナビ1</span>
		</button>
		<button
			role="tab"
			class="wp-block-chiilog-blocks-iapi-tabs__button"
			aria-selected="false"
			id="tab-2"
			aria-controls="panel-2"
			tabindex="-1"
		>
			<span>ナビ2</span>
		</button>
		<button
			role="tab"
			class="wp-block-chiilog-blocks-iapi-tabs__button"
			aria-selected="false"
			id="tab-3"
			aria-controls="panel-3"
			tabindex="-1"
		>
			<span>ナビ3</span>
		</button>
	</div>
	<div
		id="panel-1"
		role="tabpanel"
		tabindex="0"
		aria-labelledby="tab-1"
		class="wp-block-chiilog-blocks-iapi-tabs__panel"
		aria-expanded="true"
		aria-hidden="false"
	>
		パネル1
	</div>
	<div
		id="panel-2"
		role="tabpanel"
		tabindex="0"
		aria-labelledby="tab-2"
		class="wp-block-chiilog-blocks-iapi-tabs__panel"
		aria-expanded="false"
		aria-hidden="true"
	>
		パネル2
	</div>
	<div
		id="panel-3"
		role="tabpanel"
		tabindex="0"
		aria-labelledby="tab-3"
		class="wp-block-chiilog-blocks-iapi-tabs__panel"
		aria-expanded="false"
		aria-hidden="true"
	>
		パネル3
	</div>
</div>

ただ動かしてみるだけならedit.js もこれベタっと貼っておくだけでもいいですね。私はAPI触るために作ってたのに、なぜかeditの中身も作り込んでいました。(夫に突っ込まれてedit別に作り込まなくてよかったことに気づいた)

ちなみに今回はeditの中身については省きます。

次はこのタグにAPI用のタグを仕込んでいきます。

親のdivにdata-wp-interactive="chiilog-iapi-tabs"を入れます。これの内部でbindなりclickなりいろんなJSが動きます。

<div
	<?php echo get_block_wrapper_attributes(); ?>
	data-wp-interactive="chiilog-iapi-tabs"
>
	...
</div>

こんな感じ。

このdata-wp-interactiveに指定したchiilog-iapi-tabsがストア名になります。

view.jsに以下のように書きます。

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

store( 'chiilog-iapi-tabs', {
	state: {},
	actions: {},
	callbacks: {},
} );

例えば以下のようにactionsの中に書いて、ボタンにclickの動作をつけるとコンソールにclickが出ます。

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

store( 'chiilog-iapi-tabs', {
	state: {},
	actions: {
		selectTab: () => {
			console.log( 'click' );
		},
	},
	callbacks: {},
} );
<button
	role="tab"
	class="tab-nav__button"
	data-wp-on--click="actions.selectTab"
	aria-selected="true"
	id="tab-1"
	aria-controls="panel-1"
	tabindex="0"
>
	<span>ナビ1</span>
</button>

無事clickが動作していることを確認したので、ベタ書きしていた記述をブロックに置き換えます。

<?php
/**
 * @var array $attributes The block attributes.
 * @var string $content The block content.
 * @var WP_Block $block The block object.
 *
 * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render
 */

$navItems = $attributes['contents'];
?>
<div
	<?php echo get_block_wrapper_attributes(); ?>
	data-wp-interactive="chiilog-iapi-tabs"
>
	<div class="wp-block-chiilog-blocks-iapi-tabs__nav" role="tablist">
		<?php
		if ( $navItems ) :
			foreach ( $navItems as $index => $navItem ) :
				$tabNumber = $index + 1;
				?>
				<button
					role="tab"
					class="wp-block-chiilog-blocks-iapi-tabs__button"
					data-wp-on--click="actions.selectTab"
					aria-selected="<?php echo esc_attr( $index === 0 ? 'true' : 'false' ); ?>"
					id="tab-<?php echo esc_attr( $tabNumber ); ?>"
					aria-controls="panel-<?php echo esc_attr( $tabNumber ); ?>"
					tabindex="<?php echo esc_attr( $index === 0 ? '0' : '-1' ); ?>"
				>
					<?php echo esc_html( $navItem['tabNavText'] ); ?>
				</button>
				<?php
			endforeach;
		endif;
		?>
	</div>
	<div class="wp-block-chiilog-blocks-iapi-tabs__panels">
		<?php echo do_blocks( $content ); ?>
	</div>
</div>

この時点ではaria-selectedとかはまだほぼベタ打ちのままです。

パネル部分はインナーブロックの中にパネルブロック(作成したブロック。インナーブロックはこのパネルブロックしか配置できないように設定)を置く形にしています。

おおよその形にはなったので、タブのJSを書いていきます。

いつものJSならクリックしたナビ以外のaria-selectedを変えたりtabindexを変えたりして実装してるのですが、今回はInteractivity APIを使って実装していくので、

  • state.currentTab でカレントのタブを管理
  • ボタンそれぞれにdata-wp-context='{ "position": (number) }' をつけてタブの番号を付与
  • state.currentTabとcontext.positionが同一であればカレントにする

という処理をしました。

state.currentTabはwp_interactivity_stateで管理するため、render.phpに追加します。

wp_interactivity_state( 'chiilog-iapi-tabs', array (
	'currentTab' => 0
));

ボタンにもAPIのディレクティブをつけていきます。

<button
	role="tab"
	class="wp-block-chiilog-blocks-iapi-tabs__button"
	data-wp-on--click="actions.changeCurrentTab"
	data-wp-bind--aria-selected="state.tabSelected"
	id="tab-<?php echo esc_attr( $tabNumber ); ?>"
	aria-controls="panel-<?php echo esc_attr( $tabNumber ); ?>"
	data-wp-bind--tabindex="state.tabIndex"
	data-wp-context='{ "position": <?php echo esc_attr( $index ); ?> }'
>
	<?php echo esc_html( $navItem['tabNavText'] ); ?>
</button>

さて、次はパネルです。パネルはインナーブロックで管理しているので、ボタンのようにrender.php内で処理することはできません。

なので、パネルブロックにディレクティブをつけるためにrender_blockのフィルターフックを使います。

今回は単一のブロックのみでの動作なので、render_block_{$this->name}のフィルターを使いました。

function add_directives_to_inner_blocks( $block_content, $block ) {
	$panels = new WP_HTML_Tag_Processor( $block_content );
	$panelCount = 0;

	while ( $panels->next_tag() ) {
		foreach ( $panels->class_list() as $class_name ) {
			if ( $class_name === 'wp-block-chiilog-blocks-iapi-tabs-panel' ) {
				$panels->set_attribute( 'data-wp-bind--aria-expanded', 'state.panelExpanded' );
				$panels->set_attribute( 'data-wp-bind--aria-hidden', 'state.panelHidden' );
				$panels->set_attribute( 'data-wp-context', '{ "position": ' . $panelCount . ' }' );
				$panelCount++;
			}
		}
	}

	return $panels->get_updated_html();
}
add_filter( 'render_block_chiilog-blocks/iapi-tabs', 'add_directives_to_inner_blocks', 10, 2 );

ブロック間でデータのやりとりをしたいときはよく使うことになるんだろうなと思います。

WP_HTML_Tag_Processorはブロック作るに限らず色々使い道があるので(aタグをspanに変えたいとか)覚えておくとよさそう。

これで描写側のセットは完了したので、あとはview.jsでJSをゴリゴリしていくだけです。

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

const { state, actions } = store( 'chiilog-iapi-tabs', {
	state: {
		get panelExpanded() {
			const ctx = getContext();
			return ctx.position === state.currentTab;
		},
		get panelHidden() {
			const ctx = getContext();
			return ctx.position !== state.currentTab;
		},
		get tabSelected() {
			const ctx = getContext();
			return ctx.position === state.currentTab;
		},
		get tabIndex() {
			const ctx = getContext();
			return ctx.position === state.currentTab ? 0 : -1;
		},
	},
	actions: {
		changeCurrentTab: () => {
			const ctx = getContext();
			state.currentTab = ctx.position;
		},
	},
	callbacks: {},
} );

なんということでしょう。書いたのはこれだけ!タブみたいなシンプルなやつだったからというのはあるけど、めちゃくちゃ簡単に動作追加ができました。

ここで終わったと思った?残念!まだ続きます

ふと翌日に複数配置したらどうなるんだ?って配置してみたら、なんとまあ連動して全部動く!

これが例えば決済ボタンだとか、ページで1つしか配置できないようにしているものならsupportsでmultiple: false にしておけばいいけど、これはタブブロック。複数配置することも考えられるもの。あと、ついでにボタンとかパネルについてるidもユニークにしておかねばならぬ。

というわけで、ブロックのclientIdをattributes: tabClientIdに保存して使うことにしました。(save.js でやってたらこんな回りくどいことしなくてももっと簡単だったはず)

<button
	role="tab"
	class="wp-block-chiilog-blocks-iapi-tabs__button"
	data-wp-on--click="actions.changeCurrentTab"
	data-wp-bind--aria-selected="state.tabSelected"
	id="tab-<?php echo esc_attr( $tabClientId ) . '-' . esc_attr( $tabNumber ); ?>"
	aria-controls="panel-<?php echo esc_attr( $tabClientId ) . '-' . esc_attr( $tabNumber ); ?>"
	data-wp-bind--tabindex="state.tabIndex"
	data-wp-context='{ "position": <?php echo esc_attr( $index ); ?> }'
>
	<?php echo esc_html( $navItem['tabNavText'] ); ?>
</button>

肝心のブロックが全部一緒に動いちゃう部分ですが、これは

wp_interactivity_state( 'chiilog-iapi-tabs', array (
	'currentTab' => 0
));

で最初にステートを定義しているのが問題でした。なので、このカレントの管理をブロック自身のコンテキストに持たせるようにしました。

<?php
/**
 * @var array $attributes The block attributes.
 * @var string $content The block content.
 * @var WP_Block $block The block object.
 *
 * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render
 */

$navItems    = $attributes['contents'];
$tabClientId = $attributes['tabClientId'];

$context = array(
	'currentTab'  => 0,
);

?>
<div
	<?php echo get_block_wrapper_attributes(); ?>
	data-wp-interactive="chiilog-iapi-tabs"
	data-tab-client-id="<?php echo esc_attr( $tabClientId ); ?>"
	<?php echo wp_interactivity_data_wp_context( $context ); ?>
>
	...
</div>

view.js でもステートを参照していたので、changeCurrentTab をstate.currentTabから context.currentTabに差し替えます。

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

const { state, actions } = store( `chiilog-iapi-tabs`, {
	state: {
		get panelExpanded() {
			const ctx = getContext();
			return ctx.position === ctx.currentTab;
		},
		get panelHidden() {
			const ctx = getContext();
			return ctx.position !== ctx.currentTab;
		},
		get tabSelected() {
			const ctx = getContext();
			return ctx.position === ctx.currentTab;
		},
		get tabIndex() {
			const ctx = getContext();
			return ctx.position === ctx.currentTab ? 0 : -1;
		},
	},
	actions: {
		changeCurrentTab: () => {
			const ctx = getContext();
			ctx.currentTab = ctx.position;
		},
	},
	callbacks: {},
} );

これでどれだけタブを置いても一緒にカレントが動いてしまうことはなくなりました!

作ってみての感想

最初は難しそうだなーできるかなーと思ってたんですが、案外サクッとできてしまいました。組み合わせてswiperと連動させたりとかもできるのかな…どきどき。

とは言え、書くのにはJSの知識が必要なので、しっかりJS勉強しておかないといけないなあという感じです。まだまだ知識不足感は否めません。

あと、実装するうえで結構ChatGPTに助けてもらいました。

エラー文の解説もそうですが、コードをベタっと貼り付けて「ここがこうなってるんだけどどうして?」って聞くとわりといい返事を返してくれたので、問題の解消のヒント(ときには答え)にかなり役立ちました。頼れる相棒です。答えに「それはホントか?」って思ったら根拠を調べたりするので勉強にもなります。

ちなみに、ChatGPT自体にInteractivity API自体の知識はまだないのでこの辺は聞いてません。

実装中のメモはzennでまとめています。

https://zenn.dev/chiilog/scraps/1129dfe7a551d7

参考にしたサイト、GitHub等

Interactivity APIとはなんぞや?でまずは触ったCookbookのレシピ

上記をもとに作られたより強力なスライダーのリポジトリ。WP_HTML_Tag_Processorまわり等はこちらを参考にしました。

https://github.com/ryanwelcher/iapi-gallery-slider

タブのeditを実装するにあたり、キタジマさんのSnow Monkey Blocksのタブブロックをめちゃくちゃ参考にさせてもらいました。

https://github.com/inc2734/snow-monkey-blocks/tree/master/src/blocks/tabs

ありがとうございました!