カスタムブロックに「リンク先を指定する」機能を付けたいとき、TextControl で URL を手入力させる作りにしていませんか。それでも動きはしますが、コアのボタンブロックのように「サイト内の投稿を名前で検索する」「新しいタブで開くを切り替える」といった操作はできません。@wordpress/block-editor の LinkControl を使えば、コアとまったく同じリンク挿入 UI を自分のブロックに置けます。この記事では value と onChange の形から、ツールバーのリンクボタンと組み合わせる方法、save 側で <a> を正しく出力するところまでを順に解説します。
目次
LinkControl はコアのリンク UI そのもの
LinkControl は、ブロックエディターでリンクを作るときに出てくるあの小さなパネルを、そのまま部品として提供するコンポーネントです。URL の直接入力だけでなく、入力したキーワードに一致する投稿・固定ページの候補表示、mailto: や tel:、#anchor のような入力の受け付け、「詳細設定」を開いたときに出るトグル(既定では「新しいタブで開く」)まで、全部まとめて面倒を見てくれます。
似たコンポーネントに URLInput がありますが、こちらは「URL を選ばせる入力欄」だけを担当します。LinkControl は内部で URLInput を使いつつ、リンクとしての設定(新しいタブで開くなど)や、その場で固定ページを新規作成する機能を上に載せたものです。特別な理由がなければ LinkControl のほうを選んでください。
// components ではなく block-editor パッケージにある
import { LinkControl } from '@wordpress/block-editor';
読み込み先を間違えやすいので注意してください。PanelBody や ToggleControl は @wordpress/components ですが、LinkControl は @wordpress/block-editor にあります。投稿の検索候補を取得する仕組みがブロックエディターの設定に紐づいているためです。
なお、古い記事やコードでは __experimentalLinkControl という名前で読み込んでいるものがあります。これは WordPress 6.8 で非推奨になり、実験的でない LinkControl として正式に公開されました。いま __experimentalLinkControl を使うと動きはしますが、ブラウザーのコンソールに非推奨の警告が出ます。新しく書くコードでは LinkControl を使ってください。
基本の使い方
必要なのは実質 value と onChange の2つだけです。現在のリンク情報をオブジェクトで渡し、変更があったら新しいオブジェクトを受け取ってどこかに保存する、という形になります。
import { LinkControl } from '@wordpress/block-editor';
import { useState } from '@wordpress/element';
function Example() {
// { url: 'https://example.com', opensInNewTab: true } のような形
const [ link, setLink ] = useState( {} );
return (
<LinkControl
value={ link }
onChange={ ( newValue ) => setLink( newValue ) }
/>
);
}
これだけで、URL 入力欄・検索候補のリスト・「詳細設定」の中の「新しいタブで開く」トグルが表示されます。リンクが確定済みのときは入力欄ではなくリンクのプレビュー表示に切り替わる、という挙動もコンポーネント側が持っています。
value に入る値
value は「既定のリンク情報」と「settings で定義したトグルの値」を合わせたオブジェクトです。既定で用意されているキーは次のとおりです。
| キー | 型 | 説明 |
|---|---|---|
url | string | リンク先の URL |
title | string(任意) | リンクのラベル。hasTextControl を true にすると UI から編集できる |
opensInNewTab | boolean(任意) | 新しいタブで開くか。settings を自分で指定していないときだけ既定のキーとして入る |
これに加えて、検索候補から投稿や固定ページを選んだ場合は id(投稿 ID)・type(page などの投稿タイプ)・kind(post-type や taxonomy)といったキーも onChange に渡ってきます。ナビゲーションブロックのように「リンク先の投稿そのもの」を覚えておきたい場合はこれらも attribute に保存しますが、ただのリンクとして扱うなら url だけ拾えば十分です。
onChange は差分ではなく新しい値の全体を渡してくる
ここが最初につまずきやすいところです。onChange の引数は「変わったキーだけ」ではなく、変更を反映した後の value 全体です。トグルを1つ切り替えただけでも url を含むオブジェクトがまるごと渡ってきます。
// 分割代入で必要なキーだけ取り出すのが分かりやすい
const onChangeLink = ( { url: newURL = '', opensInNewTab } ) => {
setAttributes( {
url: newURL,
linkTarget: opensInNewTab ? '_blank' : undefined,
} );
};
逆に言うと、受け取ったオブジェクトをそのまま setAttributes() に流し込むと、block.json に宣言していない id や kind まで渡そうとしてしまいます。必要なキーだけを取り出して、自分の attribute の形に詰め替えるのが安全です。
主な props
| prop | 型(既定値) | 説明 |
|---|---|---|
value | object | 現在のリンク値。後述のとおり、毎回同じ参照を渡すこと |
onChange | function | リンクや設定が更新されたときに、新しい value 全体を受け取る |
onRemove | function | 渡すと「リンクを解除」ボタンが表示され、押されたときに呼ばれる |
onCancel | function | URL の編集をキャンセルしたときに呼ばれる |
settings | array([ { id: 'opensInNewTab', title: 'Open in new tab' } ]) | 「詳細設定」に並べるトグルの定義。空配列を渡すと詳細設定自体が消える |
forceIsEditingLink | boolean | true で URL 入力欄を強制的に表示、false で強制的にプレビュー表示にする |
searchInputPlaceholder | string | 入力欄のプレースホルダー文言 |
showSuggestions | boolean(true) | 入力中に検索候補を表示するか |
showInitialSuggestions | boolean | まだ何も入力していない時点から候補を表示するか |
suggestionsQuery | object({}) | 候補の絞り込み条件。例: { type: 'post', subtype: 'page' } |
noDirectEntry | boolean(false) | true にすると、URL らしき入力をそのままリンクにすることを禁じ、候補からしか選べなくなる |
noURLSuggestion | boolean(false) | 検索語を URL とみなす予備の候補を出さない |
createSuggestion | function | 「新規作成」が選ばれたときに呼ばれる。{ id, type, title, url } を返す(Promise も可) |
withCreateSuggestion | boolean | 「新規作成」の候補を出すか。createSuggestion を渡すと自動で true になる |
createSuggestionButtonText | string / function | 「新規作成」候補に表示する文言 |
hasTextControl | boolean(false) | value.title を編集するテキスト入力欄を UI に追加する |
onInputChange | function | 検索入力欄の文字が変わるたびに呼ばれる。監視用で、値の制御はできない |
renderControlBottom | function | パネル下部に独自の UI を差し込むレンダープロップ |
LinkControl は完全な制御コンポーネントではありません。入力中の文字列はコンポーネントが内部で持っていて、こちらから上書きすることはできません。onInputChange はあくまで「いま何が入力されているか」を観測するためのもので、そこで返した値が反映されるわけではない点に注意してください。
settings で設定項目を増やす
settings は「詳細設定」の中に並ぶトグルの一覧です。id と title を持つオブジェクトの配列で、渡した id がそのまま value のキーになります。つまり { id: 'nofollow' } を追加すると、onChange の引数に nofollow: true が入ってくるようになります。
既定値は「新しいタブで開く」1つだけですが、これは LinkControl.DEFAULT_LINK_SETTINGS として公開されているので、消さずに足すことができます。
import { __ } from '@wordpress/i18n';
import { LinkControl } from '@wordpress/block-editor';
// 既定の「新しいタブで開く」を残したまま項目を追加する
const LINK_SETTINGS = [
...LinkControl.DEFAULT_LINK_SETTINGS,
{ id: 'nofollow', title: __( 'Mark as nofollow', 'my-blocks' ) },
];
<LinkControl
value={ linkValue }
settings={ LINK_SETTINGS }
onChange={ onChangeLink }
/>
逆に settings={ [] } と空配列を渡すと、「詳細設定」の開閉ボタンごと消えて、URL を選ぶだけのシンプルな UI になります。注意したいのは、自分で settings を指定した時点で opensInNewTab は自動では付かなくなることです。新しいタブの設定も残したいなら、上の例のように DEFAULT_LINK_SETTINGS を展開して含めてください。
settings の値は attribute に直接保存するのではなく、最終的な HTML の形に変換してから保存すると扱いやすくなります。コアのボタンブロックも、opensInNewTab と nofollow を受け取ってから target と rel の文字列を組み立てる、という作りになっています。
実践:ツールバーのリンクボタンから開くリンクカード
ここからは実際のブロックに組み込みます。テキストを入れられるカードを作り、ブロックツールバーのリンクボタンを押すと LinkControl がポップオーバーで開く、という形にします。コアのボタンブロックと同じ操作感です。
block.json:href / target / rel を HTML から読み取る
リンクの情報は最終的に <a> の属性として出力するので、attribute も source を使って HTML から読み取る形にしておきます。こうしておくと保存済みの投稿を開き直したときに、HTML から値が復元されます。
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "my-blocks/link-card",
"title": "Link Card",
"category": "design",
"textdomain": "my-blocks",
"attributes": {
"label": {
"type": "string",
"source": "html",
"selector": "a,span"
},
"url": {
"type": "string",
"source": "attribute",
"selector": "a",
"attribute": "href"
},
"linkTarget": {
"type": "string",
"source": "attribute",
"selector": "a",
"attribute": "target"
},
"rel": {
"type": "string",
"source": "attribute",
"selector": "a",
"attribute": "rel"
}
},
"editorScript": "file:./index.js",
"style": "file:./style-index.css"
}
opensInNewTab という attribute は作りません。LinkControl が扱う opensInNewTab(真偽値)と、HTML に出力する target="_blank" は表現が違うだけの同じ情報なので、保存するのは HTML 寄りの linkTarget のほうだけにして、edit の中で相互に変換します。二重に持つと必ず食い違います。
edit.js:ToolbarButton と Popover を組み合わせる
BlockControls の中に ToolbarButton を置き、リンクが未設定なら link アイコン、設定済みなら linkOff アイコンを出します。アイコンは @wordpress/icons から読み込みます。ポップオーバーの表示位置はブロック要素そのものに合わせたいので、useBlockProps の ref で DOM 要素を受け取り、それを Popover の anchor に渡します。
import { __ } from '@wordpress/i18n';
import { useState, useMemo, useEffect } from '@wordpress/element';
import {
useBlockProps,
RichText,
BlockControls,
LinkControl,
} from '@wordpress/block-editor';
import { ToolbarButton, Popover } from '@wordpress/components';
import { link, linkOff } from '@wordpress/icons';
const NEW_TAB_TARGET = '_blank';
const NEW_TAB_REL = 'noopener';
export default function Edit( { attributes, setAttributes, isSelected } ) {
const { label, url, linkTarget } = attributes;
// ポップオーバーの基準にするブロックの DOM 要素。
// ref ではなく state に入れるのは、要素が決まった時点で再描画させるため
const [ popoverAnchor, setPopoverAnchor ] = useState( null );
const blockProps = useBlockProps( { ref: setPopoverAnchor } );
// URL 入力欄を開いているかどうか
const [ isEditingURL, setIsEditingURL ] = useState( false );
const isURLSet = !! url;
// target="_blank" ⇔ opensInNewTab の変換
const opensInNewTab = linkTarget === NEW_TAB_TARGET;
// value は毎回同じ参照を渡す(理由は後述)
const linkValue = useMemo(
() => ( { url, opensInNewTab } ),
[ url, opensInNewTab ]
);
// 他のブロックを選んだら編集モードを閉じる
useEffect( () => {
if ( ! isSelected ) {
setIsEditingURL( false );
}
}, [ isSelected ] );
const onChangeLink = ( { url: newURL = '', opensInNewTab: newTab } ) => {
setAttributes( {
url: newURL,
// false のときは undefined にして属性ごと消す
linkTarget: newTab ? NEW_TAB_TARGET : undefined,
rel: newTab ? NEW_TAB_REL : undefined,
} );
};
const unlink = () => {
setAttributes( {
url: undefined,
linkTarget: undefined,
rel: undefined,
} );
setIsEditingURL( false );
};
return (
<>
<BlockControls group="block">
<ToolbarButton
name="link"
icon={ isURLSet ? linkOff : link }
title={
isURLSet
? __( 'Unlink', 'my-blocks' )
: __( 'Link', 'my-blocks' )
}
isActive={ isURLSet }
onClick={
isURLSet ? unlink : () => setIsEditingURL( true )
}
/>
</BlockControls>
<div { ...blockProps }>
<RichText
tagName="span"
value={ label }
onChange={ ( value ) =>
setAttributes( { label: value } )
}
placeholder={ __( 'Link text…', 'my-blocks' ) }
/>
</div>
{ isSelected && ( isEditingURL || isURLSet ) && (
<Popover
placement="bottom"
anchor={ popoverAnchor }
onClose={ () => setIsEditingURL( false ) }
focusOnMount={ isEditingURL ? 'firstElement' : false }
shift
>
<LinkControl
value={ linkValue }
onChange={ onChangeLink }
onRemove={ unlink }
forceIsEditingLink={ isEditingURL }
/>
</Popover>
) }
</>
);
}
ポップオーバーを出す条件を isEditingURL || isURLSet にしているのがポイントです。こうすると、リンクボタンを押した直後(まだ URL は空)でも、すでにリンクが設定されているブロックを選び直したときでもパネルが開きます。そして forceIsEditingLink={ isEditingURL } によって、前者では URL 入力欄が、後者ではリンクのプレビューが表示されます。
onRemove に unlink を渡すと、パネル内に「リンクを解除」のボタンが現れます。onRemove を渡さなければこのボタンは出ません。ツールバーの linkOff ボタンと同じ関数を共有しておけば、どちらから解除しても同じ状態になります。
ブロック自身の ref を別の用途でも使いたい場合は、@wordpress/compose の useMergeRefs で [ setPopoverAnchor, myRef ] のようにまとめて渡します。コアのボタンブロックもこの書き方をしています。
save.js:a 要素として出力する
保存側は、url があれば <a>、なければ <span> を出すだけです。block.json の selector を "a,span" にしておいたのは、どちらの形でもラベルを読み取れるようにするためです。
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save( { attributes } ) {
const { label, url, linkTarget, rel } = attributes;
return (
<div { ...useBlockProps.save() }>
{ url ? (
<RichText.Content
tagName="a"
href={ url }
// undefined を渡すと属性自体が出力されない
target={ linkTarget }
rel={ rel }
value={ label }
/>
) : (
<RichText.Content tagName="span" value={ label } />
) }
</div>
);
}
React は undefined が渡された属性を出力しないので、target と rel は undefined のままにしておけば target="" のような空属性が残りません。空文字列を入れてしまうと属性が出力され、保存済み HTML と save の出力がずれてブロック検証エラーの原因になります。結果として、保存される HTML は次のようになります。
| 新しいタブで開く | linkTarget | rel | 出力される HTML |
|---|---|---|---|
| オフ | undefined | undefined | <a href="https://example.com"> |
| オン | "_blank" | "noopener" | <a href="https://example.com" target="_blank" rel="noopener"> |
rel に複数の値を持たせる
rel は空白区切りで複数の値を持てるので、settings に項目を足した場合は文字列を組み立てる処理が必要になります。noopener は「新しいタブで開く」が有効なときに、nofollow は独自のトグルが有効なときに、それぞれ付ける形です。
const linkValue = useMemo(
() => ( {
url,
opensInNewTab: linkTarget === NEW_TAB_TARGET,
// rel の文字列から真偽値に戻す
nofollow: !! rel?.includes( 'nofollow' ),
} ),
[ url, linkTarget, rel ]
);
const onChangeLink = ( { url: newURL = '', opensInNewTab, nofollow } ) => {
// トグルの状態から rel を組み立て直す
const relParts = [];
if ( opensInNewTab ) {
relParts.push( NEW_TAB_REL );
}
if ( nofollow ) {
relParts.push( 'nofollow' );
}
setAttributes( {
url: newURL,
linkTarget: opensInNewTab ? NEW_TAB_TARGET : undefined,
// 空配列のときは undefined にして rel 属性を消す
rel: relParts.length ? relParts.join( ' ' ) : undefined,
} );
};
target="_blank" のリンクに rel="noopener" を付けるのは、リンク先のページから window.opener 経由で元のページを操作されるのを防ぐためです。最近のブラウザーは target="_blank" に対して自動的に同じ制限をかけますが、コアのボタンブロックも明示的に noopener を付けており、古い環境まで含めて確実にするなら書いておくのが無難です。
リンク UI が思うように動かないとき
入力した URL がすぐ消える・候補を選んでも反映されない
圧倒的に多いのが、value をその場のオブジェクトリテラルで渡しているケースです。value={ { url, opensInNewTab } } と書くと、親が再描画されるたびに中身が同じでも新しいオブジェクトが生まれます。LinkControl は value が変わったことを検知して内部の状態を外側の値に同期し直すため、入力中の文字が毎回リセットされてしまいます。
対処は前述のとおり useMemo で包んで、中身が変わったときだけ新しい参照になるようにすることです。依存配列には url や opensInNewTab のようなプリミティブな値を並べてください。オブジェクトを依存に入れると同じ問題が再発します。
別のブロックのリンクが表示されてしまう
1つのエディター内に LinkControl を複数置いていると、ブロックを移動したときに前のブロックの値が残って見えることがあります。React は同じ位置に同じ型のコンポーネントが返ってくると、要素を作り直さずに使い回すためです。
公式ドキュメントでも推奨されている対処が、key を付けてインスタンスを明示的に分けることです。ブロックの clientId を使えば確実に一意になります。
// edit の引数から clientId を受け取っておく
<LinkControl
key={ clientId }
value={ linkValue }
onChange={ onChangeLink }
/>
投稿名で検索しても候補が出ない
検索候補は LinkControl が自前で取ってくるわけではなく、ブロックエディターの設定として渡されている取得関数(core/block-editor の __experimentalFetchLinkSuggestions)を呼び出して得ています。通常の投稿編集画面ではこれが設定済みなので何もしなくても動きますが、独自に BlockEditorProvider を組んだ画面では自分で用意しない限り候補は空のままです。
投稿編集画面で試しているのに出ない場合は、suggestionsQuery で絞り込みすぎていないかを確認してください。{ type: 'post', subtype: 'page' } のように指定すると固定ページしか候補に出ません。また showSuggestions={ false } を渡していると候補の表示自体が止まります。
リンクを解除してもパネルが開いたままになる
ポップオーバーの表示条件を isEditingURL || isURLSet にしている場合、onRemove の中で url を undefined にするだけでは足りません。isEditingURL が true のままだと条件が成立し続けるので、解除の処理で setIsEditingURL( false ) も一緒に呼んでください。上の unlink() が両方を行っているのはこのためです。
ブロックの選択が外れたときにも閉じるよう、isSelected を監視する useEffect を入れておくと確実です。これを忘れると、他のブロックを触っているのにリンクパネルだけ画面に残る、という状態になります。
ポップオーバーが画面の隅に出る
Popover に anchor を渡していないと、基準となる要素が決まらず意図しない位置に表示されます。useBlockProps( { ref: setPopoverAnchor } ) のように、ref を state に入れてから anchor に渡すのがコアと同じやり方です。useRef に入れると、要素が決まったときに再描画が起きないため anchor が null のままになります。
それでもブロックツールバーに隠れてしまう場合は、shift を付けて画面内に収まるよう調整させます。コアのボタンブロックはさらに __unstableSlotName="__unstable-block-tools-after" を指定して、ブロックツールバーより後ろのレイヤーに描画させています。名前のとおり不安定な API なので、使うなら将来変わる可能性を承知のうえで採用してください。
まとめ
カスタムブロックにリンク設定を付けるなら、URL 入力欄を自作せず @wordpress/block-editor の LinkControl を使うのが近道です。value にリンク情報のオブジェクトを渡し、onChange で新しい値を受け取る。必要なら settings でトグルを足し、onRemove で解除ボタンを出す。基本はこれだけで、投稿検索も mailto: の扱いもコンポーネント側が引き受けてくれます。
実装で押さえるべき点は3つです。value は useMemo で同じ参照を渡すこと。onChange には差分ではなく新しい値の全体が渡るので、必要なキーだけ取り出して attribute の形に詰め替えること。そして target と rel は使わないとき undefined にして、属性ごと出力しないこと。ツールバーから開く形にしたいときは、BlockControls の ToolbarButton(link / linkOff アイコン)と Popover の anchor、forceIsEditingLink を組み合わせれば、コアのボタンブロックと同じ操作感になります。