カスタムフォーム要素

Nette は幅広い組み込みのフォームの要素をそろえています。しかしその中にない要求に出くわしたときも、何かを迂回したり継ぎはぎしたりする必要はありません。自分の要素を書けばよいのです。それは組み込みのものにできることをすべてこなせます。検証も、自分の翻訳も、描画もです。そして使い方もまったく同じです。

実用的な例でお見せしましょう。日、月、年の 3 つの項目で日付を入力する要素です。その道すがら、要素を書くのに必要なことをすべて学べます。

独自の要素を書くべきときと、書かなくてよいとき

独自の要素はフォームが差し出すもっとも強力な道具です。そして強力な道具の常として、それは最初ではなく最後の選択肢であるべきです。多くの場面はもっと簡単な手段で片付きます。

  • 値を変えるのは addFilter()の仕事です。郵便番号の空白やコードの小文字を大目に見たいですか。フィルタなら数行です。
  • 繰り返す設定は独自の追加のメソッドで包みます。同じ検証の付いた郵便番号の項目を 10 か所で足していますか。名前の付いた近道を作りましょう。最後にお見せします
  • 関連する項目のまとまりにはコンテナがあります。通り、市、郵便番号から成る住所に独自の要素は要りません。テキストの項目 3 つのコンテナで十分です。
  • 見た目を変えるには setHtmlType()と HTML の属性、あるいはプロトタイプを使います。

独自の要素が意味を持つのは、独自の値が必要になった瞬間です。外から見るとひとつの値を持つひとつの項目として振る舞うのに、内側では複数の入力から成っていたり、表示とは違う形で値を持っていたりする要素です。3 つの項目から成る日付。地図をクリックして選ぶ座標。補完の付くタグの入力。

要素の解剖

どの独自の要素も、抽象クラス Nette\Forms\Controls\BaseControlを継承します。そこから、できあいの機能を山ほど受け継ぎます。値の保持、検証の規則と条件、エラーのメッセージ、翻訳、HTML の属性、ラベル、そして描画とのつながりです。あなたが書くのは、その要素を違うものにしている部分だけです。

動く最小限の要素は驚くほど短いものです。

use Nette\Forms\Form;
use Nette\Forms\Helpers;
use Nette\Utils\Html;

class SimpleInput extends Nette\Forms\Controls\BaseControl
{
	public function loadHttpData(): void
	{
		$this->setValue($this->getHttpData(Form::DataLine));
	}

	public function getControl(): Html
	{
		return Html::el('input', [
			'type' => 'text',
			'name' => $this->getHtmlName(),
			'id' => $this->getHtmlId(),
			'value' => $this->getValue(),
			'data-nette-rules' => Helpers::exportRules($this->getRules()) ?: null,
		]);
	}
}

メソッドは 2 つ。ひとつは送信されたデータから値をどう取り出すかを、もうひとつは要素をどう描くかを伝えます。どちらもこのあと詳しく見ます。そのほかのすべて、setRequired()addRule()setDefaultValue()、翻訳は、もう自分で働きます。

要素は addComponent() メソッドで、あるいはもっと短く角かっこでフォームに足します。

$form['nickname'] = new SimpleInput('ニックネーム:');

要素のライフサイクル

もっと面白い要素に進む前に、要素に何がいつ起きるかを知っておくとよいでしょう。フォームとその要素は木を作るコンポーネントです。これにはうれしい結果がひとつあります。要素は自分で何かを調べる必要がなく、大事なことはすべてフレームワークが適切な瞬間に片付けてくれるのです。

  1. 要素を送信されたフォームに取り付けた瞬間、フォーム自身がその要素の loadHttpData() を呼びます。その中で要素は、このあとお見せするように、送信された自分の値を読みます。$_POST を直接扱うことは決してなく、自分がコンテナに入れ子になっているかどうかもまったく気にする必要がありません。
  2. フォームが送信されると検証が行われます。addRule() で足した規則が評価され、getValue() の値が使われます。
  3. そのあと $form->getValues() や要素の getValue() を呼ぶ人は、きれいで型の付いた値を受け取ります。フォームからの 3 つの文字列ではなく、たとえば DateTimeImmutable オブジェクトです。

そして描画のときには getControl() が、ラベルには getLabel() が呼ばれます。

送信された値を読む

loadHttpData() メソッドの中で、要素は getHttpData() メソッドを使って送信された自分の値を求めます。そのパラメータは、値をどう清めるかを決める種類です。

種類 意味
Form::DataLine 1 行のテキスト: 改行を空白に置き換え、前後の空白を取り除きます
Form::DataText 複数行のテキスト: 改行を \n にそろえます
Form::DataFile アップロード。Nette\Http\FileUpload のインスタンス

攻撃者がどれだけ頑張っても、結果はいつも制御文字のない正しい UTF-8 の文字列(あるいはアップロードのオブジェクトか null)です。値を $_POST から直接読まないのは、まさにこのためです。読んでしまえばこれらの保証をすべて失います。

私たちの日付のように複数の入力から成る要素は、HTML の名前の一部を第 2 パラメータとして渡し、こうして個々の部分の値を読みます。それらは string 型の自分のプロパティ $day$month$year に持ちます。

public function loadHttpData(): void
{
	$this->day = $this->getHttpData(Form::DataLine, '[day]') ?? '';
	$this->month = $this->getHttpData(Form::DataLine, '[month]') ?? '';
	$this->year = $this->getHttpData(Form::DataLine, '[year]') ?? '';
}

HTML の名前が [] で終わる場合は、値の配列が返されます。Form::DataKeys の種類と組み合わせると(つまり Form::DataLine | Form::DataKeys)、そのキーも保てます。

$tags = $this->getHttpData(Form::DataLine, '[tags][]');

値がなければ null です(配列なら空の配列)。リクエストにその要素のデータがまったく含まれていないこともありますし、攻撃者が好きなものを送るのを妨げるものもありません。例で ?? '' を足しているのはそのためで、あなたもいつもこの場合を考えに入れるべきです。

要素の値

要素は自分の値を持ち、3 つのメソッドを通してそれを見せます。その約束事は守る値のあるものです。

setValue() メソッドはプログラマーから値を受け取ります。setDefaultValue()$form->setDefaults() もこの道を通ります。このメソッドは筋の通るものはすべて受け取り、値を内部の形に変え、意味をなさない入力には例外を投げるべきです。そうすればエラーはすぐに現れ、フォームの不可解な振る舞いとして現れることがありません。私たちの日付は DateTimeInterface、文字列、タイムスタンプ、null を受け取り、3 つの項目に分けます。

public function setValue(mixed $value): static
{
	if ($value === null) {
		$this->day = $this->month = $this->year = '';
	} else {
		$date = Nette\Utils\DateTime::from($value); // 意味をなさないものは例外を投げます
		$this->day = $date->format('j');
		$this->month = $date->format('n');
		$this->year = $date->format('Y');
	}
	return $this;
}

一方 getValue() メソッドは、きれいで型の付いた値を組み立てます。あなたの要素を使う人が目にするのはこれだけです。値が妥当でなければ null を返します。静的メソッド validateDate() は、3 つの項目が実在する日付になるかを確かめるだけです。

public function getValue(): ?DateTimeImmutable
{
	return self::validateDate($this)
		? (new DateTimeImmutable)->setDate((int) $this->year, (int) $this->month, (int) $this->day)->setTime(0, 0)
		: null;
}

そして isFilled() メソッドは、利用者がその要素を埋めたかどうかを伝えます。これは setRequired() の規則が使います。既定の実装(空でない値)で足りることが多いのですが、組み合わせの要素ではその論理に合わせて上書きしてください。

public function isFilled(): bool
{
	return $this->day !== '' || $this->year !== '';
}

描画

getControl() メソッドは要素の HTML の形を返します。ふつうは Htmlオブジェクトとしてですが、ただの文字列でも構いません。そこは問いません。Html オブジェクトに手を伸ばすのは主にコードを組み立てるときで、できあがるマークアップを安全に、しかも心地よい API で作れるからです。いくつかの助っ人が使えます。

  • getHtmlName() は、コンテナへの入れ子も含めた HTML の name 属性を返します(たとえば invoice[date])。組み合わせの要素では、そこに個々の入力の名前の部分を足します。$name . '[day]' のようにです。
  • getHtmlId() はラベルと結び付けられた id 属性を返します。
  • Helpers::exportRules($this->getRules())data-nette-rules 属性のために検証の規則を書き出します。おかげであなたの要素でも JavaScript の検証が働きます。この属性は要素の最初の入力に付けます。
  • Helpers::createSelectBox($items, $optionAttrs, $selected) は項目の配列から <select> 要素を組み立てて(入れ子の配列は <optgroup> として描かれます)Html として返します。私たちの日付の月の項目に便利です。
  • Helpers::createInputList($items, $inputAttrs, $labelAttrs)<label> で包まれた <input> 要素の一覧(ラジオボタンやチェックボックス)を生成して文字列として返します。

ですから私たちの日付の最初の項目は、次のように作ります。

public function getControl(): Html
{
	$name = $this->getHtmlName();
	return Html::el()
		->addHtml(Html::el('input', [
			'name' => $name . '[day]',
			'id' => $this->getHtmlId(),
			'value' => $this->day,
			'type' => 'number',
			'data-nette-rules' => Helpers::exportRules($this->getRules()) ?: null,
		]))
		->addHtml(/* ... 月の select と年の input ... */);
}

ラベルは getLabel() が描き、その既定の実装でたいてい足ります。ただし気をつけてください。組み合わせの要素では、その for 属性が getHtmlId() を指すので、この id は最初の入力に与えてください。例のとおりです。

組み合わせの要素をテンプレートで部分ごとに描けるようにするには(たとえば {input birthdate:day})、getControlPart($key)getLabelPart($key) メソッドを上書きします。これらはその部分の Html 要素を返します。CheckboxListRadioList と同じやり方です。

getControl() を上書きするときは、BaseControl::getControl()setOption('rendered', true) で要素を描画済みと印を付けることも忘れないでください。同じフォームで手動の描画と自動の描画を混ぜるときは、これも呼ぶ(あるいは parent::getControl() を呼ぶ)ようにしてください。そうすれば要素が二度描かれません。(上の DateInput の例では短さのために省いています。)

完全な例: DateInput

ここまで説明した部品をすべて合わせ、月を選ぶセレクトボックスを添えたものが、リポジトリの例の中のできあがった DateInput 要素です。

コンストラクタの中で、この要素が日付として筋が通るかを確かめる検証の規則を自分に足していることに注目してください。2 月 31 日のような意味をなさない入力は、こうしてふつうのフォームの検証のエラーとして現れます。

public function __construct($label = null)
{
	parent::__construct($label);
	$this->addRule(self::validateDate(...), '日付が正しくありません。');
}

そして使い方は。組み込みの要素とまったく同じです。

$form['birthdate'] = (new DateInput('生年月日:'))
	->setDefaultValue(new DateTime('2000-01-01'))
	->setRequired('生まれた日を入力してください');

$date = $form->getValues()->birthdate; // ?DateTimeImmutable

Latte のテンプレートでは、ほかの要素と同じく、いつもの {input birthdate}{label birthdate /} のタグで描きます。

検証

組み込みの検証の規則は独自の要素でもそのまま働きます。getValue() の値を扱うからです。ですから私たちの DateInput では、たとえば許される最も古い日付に Form::Min を使えます。JavaScript 側も含めて自分の規則を書く方法は、独自の規則と条件の章で説明しています。

独自の追加のメソッド

組み込みの要素は $form->addText() などの便利なメソッドで足します。独自の要素にはそうしたメソッドがないので、ただの代入で足します。フォームでもコンテナでも同じように働き、エディタも静的な解析もそれを理解します。

$form['birthdate'] = new DateInput('生年月日:');

補完を保ったまま追加を短くしたいなら、要素そのものに静的なファクトリメソッドを置くと便利です。これは入れ子のコンテナでも働きます。Form クラスの子孫に置いたメソッドではそうはいきません。入れ子のコンテナはそれを知らないからです。

class DateInput extends Nette\Forms\Controls\BaseControl
{
	public static function addTo(
		Nette\Forms\Container $container,
		string $name,
		?string $label = null,
	): self {
		return $container[$name] = new self($label);
	}
}

// フォームでも、どのコンテナでも働きます:
DateInput::addTo($form, 'birthdate', '生年月日:');

同じやり方は、組み込みの要素の繰り返す設定に名前の付いた近道を与えるのにも使えます。

final class ZipInput
{
	public static function addTo(
		Nette\Forms\Container $container,
		string $name,
		?string $label = null,
	): Nette\Forms\Controls\TextInput {
		return $container->addText($name, $label)
			->addRule(Nette\Forms\Form::Pattern, '郵便番号はちょうど 5 桁でなければなりません', '[0-9]{5}');
	}
}

ZipInput::addTo($form, 'zip', '郵便番号:');
バージョン: 4.x