カスタムブロックの設定サイドバーに「投稿を1つ選ばせたい」「キーワードを好きなだけ追加させたい」という UI を置きたくなることがあります。候補が数個なら SelectControl で足りますが、投稿やユーザーのように数十・数百ある対象をプルダウンから探させるのは現実的ではありません。この記事では @wordpress/components の ComboboxControl(入力して絞り込める単一選択)と FormTokenField(値をタグとして複数入力)を取り上げ、props の意味から useSelect で取得した投稿一覧をつなぐ実践例までを解説します。
目次
SelectControl では足りなくなる場面
SelectControl は HTML の select 要素そのものなので、選択肢はすべて一覧に並びます。「見出しレベル」「枠線のスタイル」のように候補が固定されていて数個しかない設定なら、これがいちばん軽くて分かりやすい選択です。一方で、サイト内の投稿すべてやタグすべてを候補にすると、開いた瞬間に長大なリストが出てきて目的の項目にたどり着けません。
そこで使うのが ComboboxControl と FormTokenField です。どちらもテキスト入力と候補リストを組み合わせたコントロールで、前者は1つだけ選ぶ、後者は複数をタグ(トークン)として貯めるという違いがあります。選ぶときの目安は次のとおりです。
| コンポーネント | 向いている場面 |
|---|---|
SelectControl | 候補が数個〜十数個で固定されていて、そこから1つ選ぶ設定 |
ComboboxControl | 候補が数十〜数百あり、文字を入力して絞り込みながら1つ選びたい設定 |
FormTokenField | キーワードやタグのように、値を複数追加・削除させたい設定 |
どちらも置き場所は @wordpress/block-editor の InspectorControls の中、さらに PanelBody で囲んだ内側が基本です。コントロール自体は @wordpress/components からインポートします。
ComboboxControl で検索付きのセレクトを作る
ComboboxControl は、入力欄に文字を打つと候補が絞り込まれ、そこから1つを選べるコントロールです。選択肢の渡し方は SelectControl と同じで、{ label, value } というオブジェクトの配列を options に渡します。label が画面に表示される文字、value が実際に保存される値です。
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import { ComboboxControl } from '@wordpress/components';
const AREA_OPTIONS = [
{ label: '北海道', value: 'hokkaido' },
{ label: '東京都', value: 'tokyo' },
{ label: '大阪府', value: 'osaka' },
{ label: '沖縄県', value: 'okinawa' },
];
function AreaPicker() {
const [ area, setArea ] = useState();
return (
<ComboboxControl
__next40pxDefaultSize
__nextHasNoMarginBottom
label={ __( 'エリア', 'my-blocks' ) }
value={ area }
options={ AREA_OPTIONS }
onChange={ ( value ) => setArea( value ) }
/>
);
}
入力欄に打った文字による候補の絞り込みは、渡した options に対してコンポーネント側で行われます。つまり手元にすべての候補がある場合は、上のように options を渡しておくだけで検索が効きます。候補をサーバーから取り直したい場合に使うのが、次に説明する onFilterValueChange です。
主要な props
| prop | 説明 |
|---|---|
label | 入力欄の上に表示されるラベル |
value | 現在選ばれている値。options のいずれかの value と一致させる。未選択なら null や undefined |
options | 選択肢の配列。各項目は label(表示名)と value(保存される値)を持つ |
onChange | 選択が変わったときに呼ばれる関数。選ばれた項目の value が渡る。リセットされたときは null が渡る |
onFilterValueChange | 入力欄の文字が変わるたびに呼ばれる関数。入力中の文字列が渡る |
allowReset | 選択を解除するリセットボタンを表示するかどうか(既定は true) |
help | 入力欄の下に表示される補足説明 |
placeholder | 未入力のときに薄く表示される文字 |
hideLabelFromVision | true にするとラベルを画面から隠し、スクリーンリーダーにだけ読ませる |
__next40pxDefaultSize | 新しい既定サイズ(高さ 40px)を先に有効にするオプトイン |
__nextHasNoMarginBottom | コントロールが自前で持つ下マージンを外す新しいスタイルへのオプトイン |
アンダースコアで始まる2つは、@wordpress/components が進めている見た目の刷新を先に取り込むための prop です。付けずに使うと、新しいバージョンのブロックエディターではコンソールに非推奨(deprecated)の警告が出ます。動作が止まるわけではありませんが、新しく書くコードでは付けておくと将来デフォルトが切り替わったときに見た目がずれません。JSX では prop 名だけを書けば true を渡したことになります。
onChange には null が渡ることがある
ComboboxControl で注意したいのが onChange の引数です。項目を選んだときは、その項目の value(文字列)がそのまま渡ってきます。一方、allowReset のリセットボタンで選択を解除したときは null が渡ります。allowReset は既定で有効なので、何も設定していなくてもリセットは起こりうると考えておく必要があります。
そのため、受け取った値をそのまま属性に入れる書き方では、リセットした瞬間に属性へ null が入ります。属性を type: "number" で定義していると型が合わなくなるので、次のように値の有無で分岐させておくと安全です。undefined を渡した属性は block.json の default(未指定なら値なし)に戻ります。
onChange={ ( value ) =>
setAttributes( {
// null(リセット)のときは属性を未設定に戻す
selectedPostId: value ? Number( value ) : undefined,
} )
}
FormTokenField で複数の値をタグとして入力する
FormTokenField は、入力した文字を Enter で確定すると、それがタグのような「トークン」になって並んでいくコントロールです。投稿編集画面のタグ入力欄と同じ操作感で、トークンの横にある × ボタンで個別に削除できます。候補を suggestions に渡しておくと、入力中に候補が表示されます。
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import { FormTokenField } from '@wordpress/components';
const SUGGESTIONS = [ 'HTML', 'CSS', 'JavaScript', 'PHP', 'WordPress' ];
function KeywordField() {
// トークンは「文字列の配列」で管理する
const [ keywords, setKeywords ] = useState( [] );
return (
<FormTokenField
__nextHasNoMarginBottom
label={ __( 'キーワード', 'my-blocks' ) }
value={ keywords }
suggestions={ SUGGESTIONS }
onChange={ ( tokens ) => setKeywords( tokens ) }
/>
);
}
いちばん大事なのは、onChange に渡ってくるのがトークンの配列だという点です。1つ追加したときも1つ削除したときも、その時点のトークン全部を含んだ新しい配列が渡ります。差分ではないので、受け取った配列をそのまま状態や属性に入れれば済みます。ブロック属性で扱う場合は、当然 type: "array" で定義することになります。
主要な props
| prop | 説明 |
|---|---|
label | 入力欄の上に表示されるラベル(既定は Add item) |
value | 現在のトークン。文字列の配列を渡す |
suggestions | 入力中に表示する候補。こちらも文字列の配列 |
onChange | トークンが増減したときに呼ばれる関数。変更後のトークン配列がまるごと渡る |
onInputChange | 入力欄の文字が変わるたびに呼ばれる関数。入力中の文字列が渡る |
maxLength | 追加できるトークンの上限。上限に達すると入力欄が使えなくなる |
maxSuggestions | 一度に表示する候補の最大件数(既定は 100) |
placeholder | トークンが1つもないときに表示される文字 |
disabled | true にすると入力も削除もできなくなる |
tokenizeOnSpace | true にするとスペースキーでもトークンが確定する(既定は false) |
displayTransform | トークンを表示するときに通す変換関数 |
saveTransform | トークンを保存するときに通す変換関数(既定は前後の空白を削除) |
suggestions が文字列の配列である点は、{ label, value } を渡す ComboboxControl との大きな違いです。「表示するのはタグ名だが、保存したいのは ID」という場合は、コンポーネントに ID を教える方法がないため、名前と ID の対応表を自分で持っておいて変換する形になります。次の実践例ではその書き方も含めて見ていきます。
実践例:投稿を1つ選び、タグを複数付けられるブロック
ここまでの2つを組み合わせて、「関連記事として投稿を1つ選び、表示用のタグを複数付けられる」ブロックを作ってみます。候補は @wordpress/core-data のストアから useSelect で取得します。まずは属性の定義からです。
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "my-blocks/related-post",
"title": "関連記事",
"category": "widgets",
"icon": "admin-links",
"textdomain": "my-blocks",
"attributes": {
"selectedPostId": {
"type": "number"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"default": []
}
},
"editorScript": "file:./index.js",
"render": "file:./render.php"
}
selectedPostId は投稿 ID なので type: "number"、tags はトークンの配列なので type: "array" です。配列の属性には default: [] を書いておきます。既定値がないと初期状態が undefined になり、FormTokenField の value に渡した瞬間にエラーの原因になるためです。
続いて edit.js です。投稿一覧とタグ一覧をそれぞれ getEntityRecords() で取得し、ComboboxControl の options と FormTokenField の suggestions に流し込みます。
import { __ } from '@wordpress/i18n';
import { useState, useMemo } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import {
PanelBody,
ComboboxControl,
FormTokenField,
} from '@wordpress/components';
export default function Edit( { attributes, setAttributes } ) {
const { selectedPostId, tags } = attributes;
const blockProps = useBlockProps();
// 入力中の文字。サーバー側の検索に使う
const [ search, setSearch ] = useState( '' );
const { posts, terms } = useSelect(
( select ) => {
const { getEntityRecords } = select( coreStore );
return {
posts: getEntityRecords( 'postType', 'post', {
per_page: 20,
status: 'publish',
search, // 空文字なら最新の20件が返る
_fields: 'id,title',
} ),
terms: getEntityRecords( 'taxonomy', 'post_tag', {
per_page: -1,
_fields: 'id,name',
} ),
};
},
[ search ]
);
// { label, value } の配列に変換する。取得前は undefined なので空配列にしておく
const postOptions = useMemo(
() =>
( posts ?? [] ).map( ( post ) => ( {
label: post.title.rendered || __( '(タイトルなし)', 'my-blocks' ),
value: String( post.id ), // value は文字列に揃える
} ) ),
[ posts ]
);
// FormTokenField の候補は「文字列の配列」
const tagSuggestions = useMemo(
() => ( terms ?? [] ).map( ( term ) => term.name ),
[ terms ]
);
return (
<>
<InspectorControls>
<PanelBody title={ __( '関連記事の設定', 'my-blocks' ) }>
<ComboboxControl
__next40pxDefaultSize
__nextHasNoMarginBottom
label={ __( '紹介する投稿', 'my-blocks' ) }
help={ __( 'タイトルの一部を入力して絞り込めます', 'my-blocks' ) }
value={
selectedPostId ? String( selectedPostId ) : null
}
options={ postOptions }
onFilterValueChange={ ( value ) => setSearch( value ) }
onChange={ ( value ) =>
setAttributes( {
selectedPostId: value
? Number( value )
: undefined,
} )
}
/>
<FormTokenField
__nextHasNoMarginBottom
label={ __( '表示するタグ', 'my-blocks' ) }
value={ tags }
suggestions={ tagSuggestions }
maxLength={ 5 }
onChange={ ( tokens ) =>
setAttributes( { tags: tokens } )
}
/>
</PanelBody>
</InspectorControls>
<div { ...blockProps }>
{ selectedPostId
? __( '選択中の投稿ID: ', 'my-blocks' ) + selectedPostId
: __( 'サイドバーから投稿を選んでください', 'my-blocks' ) }
</div>
</>
);
}
ポイントは3つあります。1つ目は value の型を揃えていることです。options の value を String( post.id ) で文字列にしているので、現在値も String( selectedPostId ) で文字列にして渡します。属性へ保存するときだけ Number() で数値に戻す、という往復になります。
2つ目は onFilterValueChange で検索語を状態に持ち、それを getEntityRecords() のクエリの search に渡していることです。投稿が何百件もあるサイトでは全件を先読みできないので、入力に応じてサーバー側で絞り込んだ候補を取り直します。useSelect の第2引数に [ search ] を入れておくと、検索語が変わったタイミングで取得し直されます。
3つ目は、取得結果が届く前は getEntityRecords() が undefined を返すことです。そのまま .map() を呼ぶとエラーになるため、posts ?? [] のように空配列を挟んでいます。
保存した値はダイナミックブロックの render.php で使います。$attributes に属性がそのまま入っているので、投稿 ID からタイトルとリンクを組み立てるだけです。
<?php
$post_id = isset( $attributes['selectedPostId'] ) ? (int) $attributes['selectedPostId'] : 0;
$tags = isset( $attributes['tags'] ) ? (array) $attributes['tags'] : array();
// 投稿が選ばれていなければ何も出力しない
if ( ! $post_id ) {
return;
}
?>
<div <?php echo get_block_wrapper_attributes(); ?>>
<a href="<?php echo esc_url( get_permalink( $post_id ) ); ?>">
<?php echo esc_html( get_the_title( $post_id ) ); ?>
</a>
<?php if ( $tags ) : ?>
<ul class="related-post__tags">
<?php foreach ( $tags as $tag ) : ?>
<li><?php echo esc_html( $tag ); ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
タグを名前ではなく ID で保存したい場合は、FormTokenField が文字列しか扱えないぶんを自分で埋めます。terms から「名前 → ID」の対応表を作っておき、onChange で受け取ったトークン名を ID に変換してから保存する、という形です。
// 名前から ID を引ける Map を作っておく
const termIdByName = useMemo( () => {
const map = new Map();
( terms ?? [] ).forEach( ( term ) => map.set( term.name, term.id ) );
return map;
}, [ terms ] );
<FormTokenField
__nextHasNoMarginBottom
label={ __( '表示するタグ', 'my-blocks' ) }
// 保存済みの ID を表示用の名前に戻す
value={ tagIds.map(
( id ) =>
( terms ?? [] ).find( ( term ) => term.id === id )?.name ?? ''
) }
suggestions={ tagSuggestions }
onChange={ ( tokens ) =>
setAttributes( {
// 候補にない入力は Map に無いので取り除く
tagIds: tokens
.map( ( token ) => termIdByName.get( token ) )
.filter( ( id ) => id !== undefined ),
} )
}
/>
選んだ値がうまく保存されないとき
この2つのコントロールは、値の型と「現在値を必ず渡す」という2点でつまずきがちです。エディターを開き直したら選択が消えている、入力してもトークンが増えない、といった症状はだいたい次のどれかに当てはまります。
属性の型と渡している値が食い違っている
ComboboxControl の onChange に渡ってくるのは、options に指定した value です。これを String( post.id ) で文字列にしているなら、属性が type: "number" のところへそのまま入れると型が食い違います。エディター上では動いているように見えても、リロードして属性を読み直したときに値が捨てられ、選択が空に戻ることがあります。Number( value ) で変換するか、割り切って属性側を type: "string" で定義し、使うときに (int) でキャストするか、どちらかに統一してください。
逆方向も同じです。属性に数値で入っている ID をそのまま value に渡すと、options 側の文字列の value と一致せず、保存されているのに入力欄が空に見える、という状態になります。
value を渡していないので選択が残らない
どちらのコントロールも、現在の値を自分で保持しない制御コンポーネントです。onChange だけ書いて value を渡さないと、選んだ直後は表示が変わったように見えても、再レンダリングのたびに空の状態へ戻ります。「選べるけれど残らない」ときは、まず value(FormTokenField なら配列)を渡しているかを確認してください。
また onChange={ setAttributes( { ... } ) } のように書くと、関数ではなく実行結果を渡すことになり、レンダリングのたびに属性を更新しようとして正しく動きません。onChange={ ( value ) => setAttributes( { ... } ) } と、必ずアロー関数で包みます。
FormTokenField に文字列以外を渡している
ComboboxControl のつもりで suggestions={ [ { label: 'CSS', value: 'css' } ] } と書いてしまうのもよくある間違いです。suggestions は文字列の配列なので、オブジェクトを渡すと候補が正しく表示されません。取得した投稿やタームから map() で名前だけを取り出した配列を渡します。
value も同様で、基本は文字列の配列です。そして onChange が返すのはあくまでトークンの配列なので、属性は type: "array" でなければなりません。type: "string" のまま配列を入れると、保存と読み込みのどこかで値が落ちます。default: [] を書き忘れて undefined のまま value に渡すのも避けてください。
候補が1件も出てこない
useSelect と getEntityRecords() の組み合わせでは、データが届くまでの間 undefined が返ります。この状態で options や suggestions に undefined を渡すと候補は空のままなので、?? [] で空配列にしておくのが基本です。しばらく待っても空のままなら、クエリの条件(status や per_page)や、そのポストタイプが REST API に公開されているか(show_in_rest)を疑ってください。
ComboboxControl で「最初の数件しか検索に引っかからない」ときは、per_page で取得件数を絞ったまま、入力値をサーバーに渡していないケースがほとんどです。onFilterValueChange で受け取った文字列をクエリの search に渡し、useSelect の依存配列にもその状態を入れているか確認しましょう。
まとめ
ComboboxControl は候補が多いときの単一選択、FormTokenField は複数の値をタグとして入力させたいときのコントロールです。前者は { label, value } の配列を options に渡し、onChange では選ばれた value(リセット時は null)を受け取ります。後者は suggestions も value も文字列の配列で、onChange には変更後のトークン配列がまるごと渡ります。
ブロック属性と組み合わせるときは、ComboboxControl なら値の型を文字列と数値のどちらかに統一すること、FormTokenField なら属性を type: "array" にして default: [] を添えることが、値を失わないための要点です。候補の取得は useSelect と getEntityRecords() に任せ、届くまでは空配列を渡しておく——この形を押さえておけば、投稿・ユーザー・タームなど何を候補にする場合でも同じ書き方で応用できます。