0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

実践!:user-invalid準拠のフォームバリデーションシステム - Stimulusで構築するバリデーター群

Posted at

前回の記事「フォーム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の価値を最大限に活かしつつ、実用的なフォームバリデーションを実現できます。

※ 間違い等ありましたらお手数ですがご指摘いただけたら幸いです

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?