カスタムブロックの設定項目が増えてくると、サイドバーの設定パネルだけでは手狭になってきます。項目数が多くて延々とスクロールすることになったり、テンプレートや画像を一覧から選ばせたいのに幅が足りなかったり。そんなときに使うのが @wordpress/components の Modal と Popover です。この記事では、2つの違いと使い分け、ツールバーのボタンから設定画面をポップアップ表示する実装、そして「表示が崩れる・出てこない」ときの見どころまでをまとめます。
目次
サイドバーに収まらない設定をどこに置くか
カスタムブロックの設定 UI は、通常はエディター右側のサイドバー(InspectorControls)か、ブロック上部のツールバー(BlockControls)に置きます。数個のトグルやセレクトなら、これで十分です。
ただ、サイドバーの幅はおよそ 280px しかありません。ここに10項目以上のフォームを詰め込むと、どこに何があるのか分からなくなります。カードのレイアウトを図で選ばせたい、登録済みのアイコンを一覧から選ばせたい、といった「面積が要る UI」も同じで、サイドバーの中では窮屈です。
そこで、いったん画面の上に浮かせて表示するという選択肢が出てきます。浮かせ方には2種類あり、画面中央に大きく重ねるのが Modal、特定の要素にくっついて小さく出るのが Popover です。どちらも @wordpress/components パッケージに含まれていて、ブロックエディターの各所で実際に使われています。
自作の div ではなく用意された部品を使う
「重ねて出すだけなら position: fixed の <div> で書けるのでは」と思うかもしれません。見た目だけならそのとおりですが、実際にはそれ以外の面倒がついてきます。
Modal は開いたときにフォーカスを中へ移し、Esc キーや外側のクリックで閉じる要求を出し、role="dialog" と aria-labelledby を付け、開いている間は body にクラスを付けて背後のスクロールを止めます。これらを自分で書くと、それなりの量になります。しかも、エディターの他の部分と見た目やキーボード操作が揃っていないと、使う人には「なんだか挙動が違う画面」として引っかかります。
@wordpress/components の部品を使えば、こうした振る舞いは最初から入っています。WordPress 本体のアップデートで細かい挙動が改善されれば、それも自動的に反映されます。特別な理由がない限り、自作せずに用意されたものを使うのが得策です。
Modal と Popover の違い
まず2つの性格の違いを整理しておきます。どちらも「浮かせて出す」点は同じですが、想定している用途がはっきり分かれています。
| 観点 | Modal | Popover |
|---|---|---|
| 表示位置 | 画面中央に重ねる | 指定した要素に吸い付く |
| 背後の操作 | オーバーレイで覆い、操作できない | そのまま操作できる |
| 大きさ | size で指定。全画面も可能 | 中身に合わせた小さめのサイズ |
| ヘッダー・閉じるボタン | title と閉じるアイコンが付く | 付かない(中身は自分で組む) |
| 向いている用途 | 項目数の多い設定フォーム、一覧からの選択、確認ダイアログ | 色の選択、補足説明、小さなメニュー |
判断に迷ったら、「今の作業をいったん中断させてよいか」で考えると決めやすくなります。設定を決めきるまで本文の編集に戻ってほしくないなら Modal、本文を見ながら気軽に開け閉めしてほしいなら Popover です。
もうひとつの目安は面積です。Popover は中身の大きさに合わせて出るので、フォーム項目を10個も並べると画面からはみ出して使いにくくなります。縦に長くなりそうだと感じた時点で Modal に切り替えてください。
Modal の基本の使い方
Modal は @wordpress/components から読み込みます。開閉の状態は自分で持つ必要があるので、useState と組み合わせるのが基本形です。
import { useState } from 'react';
import { Button, Modal } from '@wordpress/components';
import { useBlockProps } from '@wordpress/block-editor';
export default function Edit() {
// 開いているかどうかを自分で管理する
const [ isOpen, setIsOpen ] = useState( false );
return (
<div { ...useBlockProps() }>
<Button variant="secondary" onClick={ () => setIsOpen( true ) }>
設定を開く
</Button>
{ /* 開いているときだけ Modal をレンダーする */ }
{ isOpen && (
<Modal
title="ブロックの詳細設定"
onRequestClose={ () => setIsOpen( false ) }
>
<p>ここに設定用の UI を置きます。</p>
</Modal>
) }
</div>
);
}
ポイントは { isOpen && ( ... ) } の部分です。Modal には「閉じた状態」がありません。レンダーされれば必ず開いた状態で表示されるので、閉じたいときはそもそもレンダーしない、という書き方になります。isOpen のような props で出し分けるのではなく、条件分岐で Modal 自体を出したり消したりする点を押さえてください。
もうひとつ重要なのが onRequestClose です。名前のとおり「閉じてほしい」という要求を受け取るだけのコールバックで、Modal が自分で閉じるわけではありません。閉じるアイコンのクリック、Esc キー、オーバーレイのクリックのいずれでもこの関数が呼ばれるので、ここで state を false に戻して初めて閉じます。公式ドキュメント上も唯一の必須 props になっています。
よく使う props
公式ドキュメントに載っているもののうち、カスタムブロック開発で使う機会が多いものをまとめます。
| props | 型 | 初期値 | 説明 |
|---|---|---|---|
onRequestClose | function | — | 閉じる要求が出たときに呼ばれる。必須 |
title | string | — | ヘッダーに表示する見出し |
size | small / medium / large / fill | — | あらかじめ決められた幅で表示する |
isFullScreen | boolean | false | true で全画面表示にする |
isDismissible | boolean | true | false にすると閉じるアイコンを出さない |
shouldCloseOnEsc | boolean | true | Esc キーで閉じる要求を出すか |
shouldCloseOnClickOutside | boolean | true | 外側のクリックで閉じる要求を出すか |
className | string | — | コンテンツ部分に付けるクラス名 |
overlayClassName | string | — | 背景のオーバーレイに付けるクラス名 |
focusOnMount | boolean / firstElement / firstContentElement | true | 開いたときにどこへフォーカスを移すか |
headerActions | React ノード | null | ヘッダーに追加で置く要素 |
size で幅を決める
size は自由な数値ではなく、決められたキーワードで指定します。確認ダイアログのように文言が短いものは small、通常の設定フォームなら medium、一覧をグリッドで並べるなら large か fill という具合です。
<Modal
title="テンプレートを選択"
size="large"
onRequestClose={ () => setIsOpen( false ) }
>
{ /* 一覧をグリッドで並べる */ }
</Modal>
CSS で細かく調整したいときは className を付けて、エディター用のスタイルシートから指定します。Modal は DOM 上ではブロックの外側(ドキュメントの末尾付近)に描画されるため、ブロックのクラスを起点にした子孫セレクターは効きません。className で専用のクラスを渡し、そのクラスを起点に書いてください。
閉じ方を制限したいとき
入力途中で誤って閉じてしまうのを防ぎたい場面では、shouldCloseOnClickOutside を false にします。オーバーレイをクリックしても閉じなくなるので、閉じるアイコンか、自分で置いたボタンからしか閉じられません。
さらに isDismissible={ false } を付けると閉じるアイコン自体が消えます。ただし、これを使うときは必ず自前の閉じる手段を用意してください。shouldCloseOnEsc も false にしたうえで閉じるアイコンも消すと、キーボードだけを使う人がモーダルから抜け出せなくなります。どうしても操作を強制したい確認ダイアログ以外では、既定のまま使うのが無難です。
ツールバーのボタンから設定画面を開く
ここまでを踏まえて、実際のカスタムブロックに組み込んでみます。ブロック上部のツールバーに歯車のボタンを置き、そこから設定用のモーダルを開く形にします。よく使う設定はサイドバーに残しておき、細かい設定だけをモーダルに逃がす、という分担です。
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "webool/notice-box",
"title": "お知らせボックス",
"category": "design",
"icon": "megaphone",
"attributes": {
"heading": { "type": "string", "default": "" },
"variant": { "type": "string", "default": "info" },
"linkUrl": { "type": "string", "default": "" },
"linkLabel": { "type": "string", "default": "" }
},
"textdomain": "webool",
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css"
}
属性は4つです。見出しと表示スタイルはサイドバーで扱う想定として、リンク関連の2つをモーダルの中で設定させます。
import { useState } from 'react';
import {
Button,
Modal,
TextControl,
SelectControl,
Flex,
FlexItem,
} from '@wordpress/components';
import {
useBlockProps,
BlockControls,
RichText,
} from '@wordpress/block-editor';
import { ToolbarGroup, ToolbarButton } from '@wordpress/components';
import { cog } from '@wordpress/icons';
export default function Edit( { attributes, setAttributes } ) {
const { heading, variant, linkUrl, linkLabel } = attributes;
const [ isOpen, setIsOpen ] = useState( false );
const blockProps = useBlockProps( {
className: `webool-notice webool-notice--${ variant }`,
} );
return (
<>
{ /* ブロックツールバーに「詳細設定」ボタンを追加する */ }
<BlockControls>
<ToolbarGroup>
<ToolbarButton
icon={ cog }
label="詳細設定"
onClick={ () => setIsOpen( true ) }
/>
</ToolbarGroup>
</BlockControls>
<div { ...blockProps }>
<RichText
tagName="p"
className="webool-notice__heading"
value={ heading }
onChange={ ( value ) => setAttributes( { heading: value } ) }
placeholder="お知らせの本文"
/>
</div>
{ isOpen && (
<Modal
title="お知らせボックスの詳細設定"
size="medium"
className="webool-notice-modal"
onRequestClose={ () => setIsOpen( false ) }
>
<SelectControl
label="表示スタイル"
value={ variant }
options={ [
{ label: 'お知らせ', value: 'info' },
{ label: '注意', value: 'warning' },
{ label: 'エラー', value: 'error' },
] }
onChange={ ( value ) =>
setAttributes( { variant: value } )
}
/>
<TextControl
label="リンク先 URL"
value={ linkUrl }
help="空欄のままにするとボタンを表示しません。"
onChange={ ( value ) =>
setAttributes( { linkUrl: value } )
}
/>
<TextControl
label="ボタンのラベル"
value={ linkLabel }
onChange={ ( value ) =>
setAttributes( { linkLabel: value } )
}
/>
<Flex justify="flex-end">
<FlexItem>
<Button
variant="primary"
onClick={ () => setIsOpen( false ) }
>
閉じる
</Button>
</FlexItem>
</Flex>
</Modal>
) }
</>
);
}
ブロックを選択するとツールバーに歯車のアイコンが出て、押すと画面中央にモーダルが開きます。中の TextControl や SelectControl は、サイドバーに置くときとまったく同じ書き方です。setAttributes をその場で呼んでいるので、入力したそばからブロックの表示に反映されます。
末尾の「閉じる」ボタンは、onRequestClose と同じく state を false に戻しているだけです。設定を確定させる「保存」と、やめる「キャンセル」を分けたい場合は、モーダルを開いた時点の値を控えておき、キャンセル時に setAttributes で書き戻す作りにします。Modal 自体にそうした機能はないので、自分で組み立てる部分です。
なお、Modal を返す位置は BlockControls や InspectorControls の中ではなく外にしてください。これらはエディターの別の場所へ中身を送り込む仕組みなので、その中にモーダルを書くと、ツールバーやサイドバーが表示されている状況でしか描画されません。ブロックの選択が外れた瞬間にモーダルごと消える、といった分かりにくい不具合の原因になります。
Popover の基本の使い方
Popover も考え方は Modal と同じで、開いているときだけレンダーします。違うのは、画面中央ではなく「どの要素にくっつけるか」を決める点です。
import { useState } from 'react';
import { Button, Popover } from '@wordpress/components';
function HelpButton() {
const [ isVisible, setIsVisible ] = useState( false );
return (
<Button
variant="secondary"
onClick={ () => setIsVisible( ( state ) => ! state ) }
>
ヘルプ
{ isVisible && <Popover>この設定は投稿一覧には反映されません。</Popover> }
</Button>
);
}
これは公式ドキュメントに載っている最小の例とほぼ同じ形で、Popover をボタンの中に書いています。この書き方だと、Popover は自分が置かれた位置の親要素を基準にして表示位置を決めます。手軽ですが、トリガーとなる要素と Popover の中身の関係が入り組んでくると扱いづらくなります。
anchor で吸い付かせる要素を指定する
くっつける相手をはっきり指定したいときは anchor を使います。ここに渡すのは DOM 要素です。公式ドキュメントでは「単なる ref ではなく state に保持することで、変化が正しく反映される」と案内されています。そのため、useState の更新関数をそのまま ref に渡すのが定番の書き方です。
import { useState } from 'react';
import { Button, Popover } from '@wordpress/components';
function HelpButton() {
const [ isVisible, setIsVisible ] = useState( false );
// setPopoverAnchor をそのまま ref に渡すと、
// マウント時に DOM 要素が state へ入る
const [ popoverAnchor, setPopoverAnchor ] = useState( null );
return (
<>
<Button
ref={ setPopoverAnchor }
variant="secondary"
onClick={ () => setIsVisible( ( state ) => ! state ) }
>
ヘルプ
</Button>
{ isVisible && (
<Popover
anchor={ popoverAnchor }
placement="bottom-start"
offset={ 8 }
onClose={ () => setIsVisible( false ) }
>
<p style={ { maxWidth: '240px', padding: '8px 12px' } }>
この設定は投稿一覧には反映されません。
</p>
</Popover>
) }
</>
);
}
anchor にボタンの DOM 要素が入ることで、Popover はそのボタンを基準に位置を計算します。placement="bottom-start" なら「下側・左端揃え」、offset={ 8 } でボタンとの間に 8px の隙間が空きます。
古い記事では anchorRef や position という props を見かけますが、これらは非推奨または旧来の指定方法として扱われています。新しく書くコードでは anchor と placement を使ってください。
よく使う props
| props | 型 | 初期値 | 説明 |
|---|---|---|---|
anchor | Element / VirtualElement / null | — | 基準にする要素。state で持つのが推奨 |
placement | top / bottom-start など | bottom-start | 基準要素に対する表示位置 |
offset | number / オブジェクト | — | 基準要素との距離(px) |
onClose | function | — | 閉じるべきときに呼ばれる |
onFocusOutside | function | — | フォーカスが外へ出たときに呼ばれる |
focusOnMount | boolean / firstElement | firstElement | 開いたときのフォーカスの移し先 |
variant | toolbar / unstyled | — | 見た目のバリエーション |
noArrow | boolean | true | 基準要素を指す矢印を出すかどうか |
flip | boolean | true | はみ出すときに反対側へ回り込むか |
shift | boolean | false | はみ出すときに位置をずらすか |
animate | boolean | true | 開くときにアニメーションするか |
expandOnMobile | boolean | — | モバイル幅で全画面表示にする |
placement に指定できる値は top / right / bottom / left の4方向に、それぞれ -start と -end を付けた12種類と、基準要素に重ねて表示する overlay です。既定値の bottom-start は「ボタンの下に、左端を揃えて出す」という、メニューでよく見る配置になります。
variant="toolbar" はツールバーの近くで使うための見た目で、コントラストが強めのスタイルになります。反対に variant="unstyled" は枠線も背景も付かない状態で、見た目を完全に自分で作りたいときに使います。
閉じる処理は onClose と onFocusOutside で受ける
Popover も自分では閉じません。Modal の onRequestClose にあたるのが onClose と onFocusOutside で、どちらも受け取ったら state を戻します。
onFocusOutside は、開いているポップオーバーからフォーカスが外へ出たときに呼ばれます。中にフォームがある場合は、こちらも一緒に指定しておくと「別の場所をクリックしたのにポップオーバーが開きっぱなし」という状態を避けられます。
実践|ボタンの下にカラーパレットを出す
Popover の使いどころとして分かりやすいのが色の選択です。サイドバーまで移動せずに、その場で色を選んで閉じられます。中身には @wordpress/components の ColorPalette を入れます。
import { useState } from 'react';
import { Button, Popover, ColorPalette } from '@wordpress/components';
const COLORS = [
{ name: 'ブルー', color: '#2563eb' },
{ name: 'グリーン', color: '#16a34a' },
{ name: 'オレンジ', color: '#ea580c' },
{ name: 'グレー', color: '#64748b' },
];
export default function ColorPickerButton( { value, onChange } ) {
const [ isVisible, setIsVisible ] = useState( false );
const [ popoverAnchor, setPopoverAnchor ] = useState( null );
return (
<>
<Button
ref={ setPopoverAnchor }
variant="secondary"
onClick={ () => setIsVisible( ( state ) => ! state ) }
>
背景色を選ぶ
</Button>
{ isVisible && (
<Popover
anchor={ popoverAnchor }
placement="bottom-start"
offset={ 8 }
onClose={ () => setIsVisible( false ) }
onFocusOutside={ () => setIsVisible( false ) }
>
<div style={ { padding: '12px', width: '220px' } }>
<ColorPalette
colors={ COLORS }
value={ value }
onChange={ ( color ) => {
onChange( color );
setIsVisible( false );
} }
/>
</div>
</Popover>
) }
</>
);
}
色を選んだ時点で setIsVisible( false ) を呼び、ポップオーバーを閉じています。選ぶ操作が1回で終わる UI では、この「選んだら閉じる」がいちばん自然です。逆に複数の項目を続けて触らせたいなら、閉じるのは外側をクリックしたときだけにして、選択のたびには閉じないようにします。
切り出したコンポーネントは、edit.js 側から value に属性の値を、onChange に setAttributes を呼ぶ関数を渡して使います。属性の読み書きは呼び出し側で完結するので、ポップオーバー側は色の値だけを扱う形にでき、他のブロックでも使い回せます。
思ったところに出ない・見た目が崩れるとき
Modal と Popover は、書き方を少し間違えるだけで「何も出ない」「素の HTML のような見た目になる」といった状態になります。よく引っかかるところを順に見ていきます。
枠線も背景もない素の表示になる
要素は出ているのに、モーダルらしい枠や影がまったく付いていない場合は、@wordpress/components のスタイルシートが読み込まれていません。このパッケージのスタイルは WordPress に wp-components というハンドル名で登録されており、これが読み込まれていないと、コンポーネントが出力するクラス名に対応する CSS が存在しない状態になります。
ブロックエディターの編集画面では、この wp-components がすでに読み込まれているため、通常のカスタムブロックで困ることはあまりありません。問題になりやすいのは、同じコンポーネントをプラグインの独自管理画面などブロックエディター以外で使ったときです。その場合は、スクリプトを読み込む処理で明示的に指定します。
add_action( 'admin_enqueue_scripts', function () {
// 独自の管理画面で @wordpress/components を使う場合
wp_enqueue_style( 'wp-components' );
} );
ボタンを押しても何も起きない
クリックしても反応がないときは、まず useState の値がちゃんと切り替わっているかを確かめます。onClick={ setIsOpen( true ) } のように関数を渡すつもりで呼び出してしまっているのは典型的なミスで、この書き方だとレンダーのたびに実行されてしまい、クリックしても何も起きません。onClick={ () => setIsOpen( true ) } のように関数を渡します。
state は切り替わっているのに表示されない場合は、条件分岐の書き忘れを疑ってください。{ isOpen && ... } を書かずに Modal をそのまま置いていると、逆に常に開きっぱなしになります。「最初から開いていて閉じられない」という症状が出ていたら、まずここです。
閉じるボタンを押しても閉じない
onRequestClose は「閉じてほしい」という通知を受け取るだけで、Modal が自分で消えることはありません。ここで state を false に戻していなければ、何度クリックしても開いたままです。
Popover でも同じで、onClose や onFocusOutside を書いていなければ外側をクリックしても閉じません。開閉の状態を持っているのは自分のコンポーネントであって、Modal や Popover ではない、と考えると分かりやすくなります。
Popover が見当違いの場所に出る
ポップオーバーが画面の左上や、まったく関係ない位置に出る場合は anchor がうまく渡っていません。useRef で取った ref オブジェクトをそのまま anchor に渡していないか確認してください。公式ドキュメントが案内しているのは、useState の更新関数を ref に渡し、state に入った DOM 要素のほうを anchor に渡す書き方です。ref のままだと、要素が入った時点で再レンダーが起きないため、位置の計算が追いつきません。
もうひとつ、anchor に渡す相手が自作コンポーネントの場合は、そのコンポーネントが ref を実際の DOM 要素まで渡しているかも確認が必要です。渡していなければ null のままなので、まずは Button や素の <div> のような確実に DOM 要素になるものに付けて切り分けます。
Modal をサイドバーやツールバーの中に書いている
InspectorControls と BlockControls は、中身をエディターの別の場所へ送り込むための入れ物です。この中に Modal を書くと、サイドバーが閉じている・ブロックの選択が外れているといった状況で送り先が消え、モーダルも一緒に消えます。
モーダルを開くきっかけとなるボタンはこれらの中に置いて構いませんが、Modal 本体はブロックの edit が返す要素の中、これらの外側に置いてください。この記事の実装例でも、BlockControls にはボタンだけを入れ、Modal は同じ階層に並べています。
モーダルの中の要素に CSS が効かない
Modal と Popover は、書いた場所とは関係なくドキュメントの末尾付近に描画されます。ブロックのラッパークラスを起点にした .wp-block-webool-notice .my-field { ... } のようなセレクターが効かないのはこのためです。
スタイルを当てたいときは className(Modal なら overlayClassName も)で専用のクラス名を渡し、そのクラスを起点にセレクターを書きます。エディター側の見た目なので、block.json の editorStyle で読み込むスタイルシートに書くのが適切です。
まとめ
サイドバーに収まらない設定は、@wordpress/components の Modal と Popover に逃がせます。画面中央に重ねて背後の操作を止めるのが Modal、特定の要素に吸い付いて小さく出るのが Popover です。項目数が多い設定フォームや一覧からの選択は Modal、色の選択や補足説明のように一瞬で済むものは Popover、と分けて考えると迷いません。
どちらも共通しているのは、開閉の状態は自分で持ち、閉じているときはレンダーしないという点です。Modal は onRequestClose、Popover は onClose と onFocusOutside で「閉じてほしい」という通知を受け取り、そこで useState の値を戻して初めて閉じます。コンポーネント側が勝手に閉じてくれるわけではありません。
Modal では title と size、必要に応じて isDismissible や shouldCloseOnClickOutside を押さえておけば、たいていの場面をまかなえます。Popover では anchor を state で持ち、placement と offset で位置を調整するのが基本形です。うまく表示されないときは、条件分岐の書き忘れ、state の戻し忘れ、anchor の渡し方、そして Modal を InspectorControls の中に書いていないかを順に確認してみてください。