前回の記事「フォームUXの新常識 - :user-invalid疑似クラスが解決する『邪魔しない』バリデーション設計」では、:user-invalid
疑似クラスの価値とUX改善効果について解説しました。
今回は実装編として、:user-invalid
の動作を再現し、さらに拡張した実用的なフォームバリデーションシステムを構築していきます。
システム全体像
今回構築するのは、以下の4つのStimulusコントローラーで構成される包括的なバリデーションシステムです:
form_controller.js (統合管理)
├── field-validator_controller.js (単一フィールド)
├── checkbox-group-validator_controller.js (チェックボックスグループ)
└── any-required-validator_controller.js (いずれか必須)
設計思想
-
:user-invalid
準拠: ブラウザネイティブの動作を再現 - カスタムメッセージ: 日本語エラーメッセージの対応
- 疎結合設計: 各コントローラーが独立して動作
- 段階的拡張: 必要に応じて機能を追加可能
1. field-validator_controller.js - 単一フィールドバリデーター
まずは、システムの中核となる単一フィールド用のバリデーターです。
// field-validator_controller.js
import { Controller } from '@hotwired/stimulus';
/**
* @class FieldValidatorController
* @classdesc フィールドバリデーションとカスタムエラーメッセージを管理する
*/
export default class extends Controller {
static values = {
// 標準エラータイプのエラーメッセージ
patternMismatch: String,
rangeOverflow: String,
rangeUnderflow: String,
stepMismatch: String,
tooShort: String,
tooLong: String,
typeMismatch: String,
valueMissing: String,
};
/**
* コントローラー接続時の初期化処理
* @returns {void}
*/
connect() {
this.lastSetMessage = '';
this.hasUserInteracted = false;
this.hasDetectedInput = false;
this.blurHandler = this.#handleBlur.bind(this);
this.inputHandler = this.#handleInput.bind(this);
this.invalidHandler = this.validateBySubmit.bind(this);
this.#setupNativeValidation();
}
/**
* コントローラー切断時のクリーンアップ
* @returns {void}
*/
disconnect() {
this.element.removeEventListener('input', this.inputHandler);
this.element.removeEventListener('blur', this.blurHandler);
this.element.removeEventListener('invalid', this.invalidHandler);
}
// ===========================================
// Public API Methods
// ===========================================
/**
* ユーザーが相互作用した場合のみバリデーションを実行
* @returns {void}
*/
validate() {
this.hasUserInteracted = true;
this.#setCustomErrorMessage();
}
/**
* submit時のバリデーション処理(form_controllerからoutlet経由で呼び出し)
* @returns {void}
*/
validateBySubmit() {
this.validate();
}
// ===========================================
// Private Event Handlers
// ===========================================
/**
* input イベントハンドラー
* @returns {void}
*/
#handleInput() {
if (this.element.value) {
this.hasDetectedInput = true;
}
if (this.hasUserInteracted) {
this.#setCustomErrorMessage();
}
}
/**
* blur イベントハンドラー(初回のみ)
* @returns {void}
*/
#handleBlur() {
if (!this.hasDetectedInput) return;
this.hasUserInteracted = true;
this.#setCustomErrorMessage();
this.element.removeEventListener('blur', this.blurHandler);
}
// ===========================================
// Private Helper Methods
// ===========================================
/**
* ネイティブタイミングでのバリデーション設定
* @returns {void}
*/
#setupNativeValidation() {
this.element.addEventListener('input', this.inputHandler);
this.element.addEventListener('blur', this.blurHandler);
this.element.addEventListener('invalid', this.invalidHandler);
}
/**
* カスタムエラーメッセージを要素に設定
* @returns {void}
*/
#setCustomErrorMessage() {
if (this.#hasCustomError()) return;
const message = this.errorMessage;
this.element.setCustomValidity(message);
this.element.setAttribute('aria-invalid', this.element.validity.valid ? 'false' : 'true');
this.lastSetMessage = message;
}
/**
* 既にカスタムエラーが設定されているかチェック
* @returns {boolean}
*/
#hasCustomError() {
const validity = this.element.validity;
return (
validity.customError && this.element.validationMessage && this.element.validationMessage !== this.lastSetMessage
);
}
/**
* 現在の要素の状態に基づいてカスタムエラーメッセージを取得
* @returns {string} エラーメッセージ(エラーがない場合は空文字)
*/
get errorMessage() {
const validity = this.element.validity;
// 標準バリデーションを優先
const standardErrors = {
valueMissing: this.valueMissingValue,
patternMismatch: this.patternMismatchValue,
typeMismatch: this.typeMismatchValue,
tooShort: this.tooShortValue,
tooLong: this.tooLongValue,
rangeUnderflow: this.rangeUnderflowValue,
rangeOverflow: this.rangeOverflowValue,
stepMismatch: this.stepMismatchValue,
};
for (const [error, message] of Object.entries(standardErrors)) {
if (validity[error]) return message;
}
return '';
}
}
2. checkbox-group-validator_controller.js - チェックボックスグループバリデーター
複数のチェックボックスから最小選択数を検証するバリデーターです。
// checkbox-group-validator_controller.js
import { Controller } from '@hotwired/stimulus';
/**
* @class CheckboxGroupValidatorController
* @classdesc チェックボックスグループの最小選択数バリデーションを管理する
*/
export default class extends Controller {
static targets = ['checkbox'];
static values = {
valueMissing: { type: String, default: '1つ以上選択してください' },
minCount: { type: Number, default: 1 },
};
// ===========================================
// Public API Methods
// ===========================================
/**
* チェックボックスグループのバリデーションを実行
* @returns {void}
*/
validate() {
this.#runValidation();
}
/**
* submit時のバリデーション処理
* @returns {void}
*/
validateBySubmit() {
this.validate();
}
// ===========================================
// Private Helper Methods
// ===========================================
/**
* バリデーションを実行
* @returns {void}
*/
#runValidation() {
const checkedCount = this.#getCheckedCount();
const isValid = checkedCount >= this.minCountValue;
this.checkboxTargets.forEach((checkbox) => {
this.#setValidationState(checkbox, isValid);
});
}
/**
* チェックされたチェックボックスの数を取得
* @returns {number} チェック数
*/
#getCheckedCount() {
return this.checkboxTargets.filter((checkbox) => checkbox.checked).length;
}
/**
* チェックボックスのバリデーション状態を設定
* @param {HTMLInputElement} checkbox - チェックボックス要素
* @param {boolean} isValid - バリデーション結果
* @returns {void}
*/
#setValidationState(checkbox, isValid) {
const shouldShowError = !isValid && !checkbox.checked;
const message = shouldShowError ? this.valueMissingValue : '';
checkbox.setCustomValidity(message);
checkbox.setAttribute('aria-invalid', shouldShowError ? 'true' : 'false');
}
}
3. any-required-validator_controller.js - いずれか必須バリデーター
複数のフィールドのうち、いずれか1つが入力されていることを検証するバリデーターです。
// any-required-validator_controller.js
import { Controller } from '@hotwired/stimulus';
/**
* @class AnyRequiredValidatorController
* @classdesc 複数の入力要素のうちいずれか1つが必須のバリデーションを管理する
*/
export default class extends Controller {
static targets = ['input'];
static values = {
valueMissing: { type: String, default: 'いずれか1つは入力してください' },
};
/**
* コントローラー接続時の初期化処理
* @returns {void}
*/
connect() {
this.hasUserInteracted = false;
}
// ===========================================
// Public API Methods
// ===========================================
/**
* いずれか1つが必須のバリデーションを実行
* @returns {void}
*/
validate() {
this.hasUserInteracted = true;
this.#runValidation();
}
/**
* submit時のバリデーション処理
* @returns {void}
*/
validateBySubmit() {
this.validate();
}
// ===========================================
// Private Helper Methods
// ===========================================
/**
* バリデーションを実行
* @returns {void}
*/
#runValidation() {
const hasValue = this.#hasAnyValue();
this.inputTargets.forEach((input) => {
this.#setValidationState(input, hasValue);
});
}
/**
* いずれかの入力要素に値があるかチェック
* @returns {boolean} 値の有無
*/
#hasAnyValue() {
return this.inputTargets.some((input) => input.value.trim() !== '');
}
/**
* 入力要素のバリデーション状態を設定
* @param {HTMLInputElement} input - 入力要素
* @param {boolean} hasValue - 値の有無
* @returns {void}
*/
#setValidationState(input, hasValue) {
if (hasValue) {
// このバリデーション由来のエラーメッセージの時は削除
if (input.validationMessage === this.valueMissingValue) {
input.setCustomValidity('');
}
// エラーメッセージがない時のみaria-invalidを更新
if (!input.validationMessage) {
input.setAttribute('aria-invalid', 'false');
}
} else {
input.setCustomValidity(this.valueMissingValue);
input.setAttribute('aria-invalid', 'true');
}
}
}
4. form_controller.js - 統合管理コントローラー
全体のバリデーションを管理し、フォーム送信を制御するコントローラーです。
// form_controller.js
import { Controller } from '@hotwired/stimulus';
/**
* @class FormController
* @classdesc フォームバリデーションを統合管理する
*/
export default class extends Controller {
static outlets = ['any-required-validator', 'checkbox-group-validator', 'field-validator'];
static values = {
usePopup: { type: Boolean, default: true },
};
/**
* コントローラー接続時の初期化処理
* @returns {void}
*/
connect() {
if (!this.usePopupValue) {
this.element.addEventListener('invalid', this.#handleInvalid.bind(this), true);
}
}
/**
* コントローラー切断時のクリーンアップ
* @returns {void}
*/
disconnect() {
if (!this.usePopupValue) {
this.element.removeEventListener('invalid', this.#handleInvalid.bind(this), true);
}
}
// ===========================================
// Public API Methods
// ===========================================
/**
* 全バリデーターを実行してフォームを送信
* @param {Event} event - イベントオブジェクト
* @returns {void}
*/
validateAll(event) {
event.preventDefault();
this.#runAllValidators();
this.#submitForm();
}
// ===========================================
// Private Helper Methods
// ===========================================
/**
* invalidイベントハンドラー(ポップアップ阻止)
* @param {Event} event - invalidイベント
* @returns {void}
*/
#handleInvalid(event) {
event.preventDefault();
}
/**
* 全バリデーターを実行
* @returns {void}
*/
#runAllValidators() {
// カスタムバリデーターを先に実行
this.anyRequiredValidatorOutlets?.forEach((outlet) => outlet.validateBySubmit());
this.checkboxGroupValidatorOutlets?.forEach((outlet) => outlet.validateBySubmit());
// フィールドバリデーターを後に実行
this.fieldValidatorOutlets?.forEach((outlet) => outlet.validateBySubmit());
}
/**
* フォームを送信(:user-invalid適用のため)
* @returns {void}
*/
#submitForm() {
this.element.requestSubmit();
}
}
実際のフォーム例
会員登録フォーム
<form data-controller="form" data-action="submit->form#validateAll">
<!-- 基本情報 -->
<fieldset>
<legend>基本情報</legend>
<div>
<label for="username">ユーザー名</label>
<input
id="username"
name="username"
type="text"
required
minlength="3"
maxlength="20"
data-controller="field-validator"
data-field-validator-value-missing-value="ユーザー名を入力してください"
class="border border-gray-300 user-invalid:border-red-500 aria-invalid:border-red-500"
/>
</div>
<div>
<label for="email">メールアドレス</label>
<input
id="email"
name="email"
type="email"
required
data-controller="field-validator"
data-field-validator-value-missing-value="メールアドレスを入力してください"
data-field-validator-type-mismatch-value="正しいメールアドレスを入力してください"
class="border border-gray-300 user-invalid:border-red-500 aria-invalid:border-red-500"
/>
</div>
</fieldset>
<!-- 連絡先(いずれか必須) -->
<fieldset
data-controller="any-required-validator"
data-any-required-validator-value-missing-value="電話番号またはLINE IDのいずれかを入力してください"
>
<legend>連絡先(いずれか必須)</legend>
<div>
<label for="phone">電話番号</label>
<input
id="phone"
name="phone"
type="tel"
data-any-required-validator-target="input"
class="border border-gray-300 user-invalid:border-red-500 aria-invalid:border-red-500"
/>
</div>
<div>
<label for="line_id">LINE ID</label>
<input
id="line_id"
name="line_id"
type="text"
data-any-required-validator-target="input"
class="border border-gray-300 user-invalid:border-red-500 aria-invalid:border-red-500"
/>
</div>
</fieldset>
<!-- 興味のある分野(最低2つ選択) -->
<fieldset
data-controller="checkbox-group-validator"
data-checkbox-group-validator-min-value="2"
data-checkbox-group-validator-value-missing-value="興味のある分野を2つ以上選択してください"
>
<legend>興味のある分野</legend>
<label>
<input
type="checkbox"
name="interests[]"
value="tech"
data-checkbox-group-validator-target="checkbox"
class="user-invalid:border-red-500 aria-invalid:border-red-500"
/>
テクノロジー
</label>
<label>
<input
type="checkbox"
name="interests[]"
value="design"
data-checkbox-group-validator-target="checkbox"
class="user-invalid:border-red-500 aria-invalid:border-red-500"
/>
デザイン
</label>
<label>
<input
type="checkbox"
name="interests[]"
value="business"
data-checkbox-group-validator-target="checkbox"
class="user-invalid:border-red-500 aria-invalid:border-red-500"
/>
ビジネス
</label>
</fieldset>
<button type="submit" class="rounded bg-blue-500 px-4 py-2 text-white">登録する</button>
</form>
CSS設定
/* :user-invalid対応 */
input:user-invalid,
input[aria-invalid='true'],
select:user-invalid,
select[aria-invalid='true'],
textarea:user-invalid,
textarea[aria-invalid='true'] {
border-color: #ef4444;
background-color: #fef2f2;
}
/* 親要素への状態反映(:has()対応ブラウザ) */
fieldset:has(input:user-invalid),
fieldset:has(input[aria-invalid='true']) {
background-color: #fef2f2;
border-left: 4px solid #ef4444;
padding-left: 1rem;
}
カスタマイズ
今回構築したバリデーションシステムは、統一されたインターフェースによりカスタマイズが可能です。
- 既存のコントローラーと同じパターンで、独自のバリデーションロジックを持つコントローラーを作成可能
- (バリデーション結果をdispatchするなど拡張すれば)独自の方法でエラーを表示するコントローラーも作成可能
- form_controllerを拡張して、より高度な制御も実現可能
拡張時のポイント
同じインターフェース(validate()
、validateBySubmit()
メソッド)を実装することで、form_controllerから統一的に制御できます。また、setCustomValidity()
とaria-invalid
属性の適切な管理により、:user-invalid
の恩恵を受けながら独自のバリデーションロジックを組み込めます。
まとめ
今回構築したフォームバリデーションシステムの特徴
技術的価値
-
:user-invalid
準拠: ブラウザネイティブの理想的なUXを実現 - 疎結合設計: 各コントローラーが独立して動作
- 拡張性: 新しいバリデーションルールを簡単に追加可能
保守性
- 統一されたインターフェース: 全コントローラーで一貫したAPI
- 明確な責任分離: 各コントローラーの役割が明確
- テスタブル: 各機能を独立してテスト可能
このシステムにより、:user-invalid
の価値を最大限に活かしつつ、実用的なフォームバリデーションを実現できます。
※ 間違い等ありましたらお手数ですがご指摘いただけたら幸いです