2018.09.23

JavaScriptとLaravelで年月日の入力フォームを実装する


この記事では、以下のような年月日が分かれたタイプの入力欄の実装例を紹介します。

Screen 1

誕生日などでこのような入力フォームを見かけると思いますが……まぁ最近はあまり見なくなったでしょうか?カレンダー形式で選択できるタイプの JavaScript のライブラリがいろいろ出ていますのでそちらを使った方が便利ですし、HTML5 の <input type="date" /> を使ってもいいでしょう。

ただ今でもデザイン上の要請から年月日が分かれたタイプの入力フォームを実装することはあると思います。というか実際あったので、改めて実装方法をまとめます。

フロントエンドは特にフレームワークは使いません。サーバーサイドは Laravel フレームワークのバージョン 5.7 を使用しています。

フロントエンド

まずはフロントエンドです。ここでのポイントは、不正な日付を選択させないようにするということですね。つまり、例えば「2018年2月31日」のような存在しない日付はあらかじめ選択肢に表示させず、選べないようにします。

JavaScript

年と月の値を取得して、その年月に存在する日のみをセットします。うるう年にも対応しています。

public/js/birthday.js
// 誕生日 日付の取得
function setDay() {
  // 年の値を取得
  const yearVal = document.getElementById('year').value;
  // 月の値を取得
  const monthVal = document.getElementById('month').value;
  // 日のセレクトボックスに挿入するHTML
  let html = '<option value="">---</option>';
  // 年月が有効な値の場合のみ日付の選択肢を加える
  if (yearVal !== '' && monthVal !== '') {
    // 特定の年月の最後の日付を取得する
    const lastDay = (new Date(yearVal, monthVal, 0)).getDate();
    // optionを組み立てる
    for (let day = 1; day <= lastDay; day++) {
      html += '<option value="' + day + '">' + day + '</option>';
    }
  }
  document.getElementById('day').innerHTML = html;
};

window.onload = function () {
  setDay();
  document.getElementById('year').addEventListener('change', setDay);
  document.getElementById('month').addEventListener('change', setDay);

  // リダイレクトした場合に元の入力値を復元する
  const dayElem = document.getElementById('day');
  dayElem.value = dayElem.getAttribute('data-old-value');
}

以下のコードで特定の年月の最後の日付を取得することができるという仕組みを利用したスクリプトです。

(new Date(yearVal, monthVal, 0)).getDate();

基本的に Laravel とは独立したスクリプトなので、サーバサイドが他のフレームワークや WordPress などであっても使えるはずです。ただし最後の「リダイレクトした場合に〜」の部分は Laravel が関わる部分です。入力値のバリデーションエラーがあった場合に入力フォーム画面にリダイレクトする機能を前提にしていますので、お使いのサーバーサイドフレームワークに合わせてカスタマイズしてください。仮に消しても全体の挙動に影響はないです。

テンプレート

HTML 側です。Laravel なので、Blade テンプレートのシンタックスで記述します。

入力フォーム画面

resources/views/input.blade.php
<!doctype html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Birthday Form Sample</title>
</head>
<body>

@if ($errors->any())
  <ul>
    @foreach($errors->all() as $error)
      <li>{{ $error }}</li>
    @endforeach
  </ul>
@endif

<form action="{{ route('input') }}" method="post">
  @csrf

  <select id="year" name="year">
    <option value="">---</option>
    <?php $years = array_reverse(range(today()->year - 100, today()->year)); ?>
    @foreach($years as $year)
      <option
          value="{{ $year }}"
          {{ old('year') == $year ? 'selected' : '' }}
      >{{ $year }}</option>
    @endforeach
  </select>
  <label for="year"></label>

  <select id="month" name="month">
    <option value="">---</option>
    @foreach(range(1, 12) as $month)
      <option
          value="{{ $month }}"
          {{ old('month') == $month ? 'selected' : '' }}
      >{{ $month }}</option>
    @endforeach
  </select>
  <label for="month"></label>

  <select
      id="day"
      name="day"
      data-old-value="{{ old('day') }}"
  ></select>
  <label for="day"></label>

  <button type="submit">送信</button>
</form>

<script src="/js/birthday.js"></script>
</body>
</html>

年と月はあらかじめ決められるので、JavaScript ではなく PHP 側で作ってしまいます。シングルページアプリケーションは別として、オールドファッションにマルチページな設計で作っている場合は、なるべくサーバー側で作れる HTML はサーバー側で作った方がフロントに無用な処理負荷をかけないぶん好ましいと思います。

年はこの例では100年ぶん用意していますが、この辺はアプリの仕様次第でしょう。

日の選択肢(<option>)は JavaScript で後から挿入するので空にしておきます。

入力完了画面

resources/views/complete.blade.php
<!doctype html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Birthday Form Sample</title>
</head>
<body>
入力した日付け:{{ $full_date->format('Y年n月j日') }}
</body>
</html>

サーバーサイド

サーバーサイドでのポイントは、入力値のバリデーションですね。ざっと以下の制約が考えられると思います。

  • 年と月と日はそれぞれ数値でなければならない。
  • 年と月と日のいずれかが入力されている場合は、すべてが入力されていなければならない。
  • 年月日がすべて入力された場合は、有効な日付でなくてはならない(99月とかはNG)。
  • 年月日は過去の日付でなければならない。

最後の制約は誕生日としての利用を想定して付け足してみました。

以下にサーバサイド実装を列挙します。

ルーティング

routes/web.php
<?php

Route::get('/input', 'DateSampleController@showForm')->name('input');
Route::post('/input', 'DateSampleController@input');
Route::get('/complete', 'DateSampleController@complete')->name('complete');

コントローラー

実際は入力された値はデータベースに格納すると思いますが、ここではサンプルとしてセッションに入れることにしました。

DateSampleController.php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreDate;
use Carbon\Carbon;

class DateSampleController extends Controller
{
    public function showForm()
    {
        session()->forget('full_date');

        return view('input');
    }

    public function input(StoreDate $request)
    {
        $full_date = Carbon::create(
            $request->year, $request->month, $request->day
        );

        session()->put('full_date', $full_date);

        return redirect()->route('complete');
    }

    public function complete()
    {
        $full_date = session()->get('full_date');

        return view('complete', compact('full_date'));
    }
}

フォームリクエスト

こちらがバリデーションを司るフォームリクエストクラスです。

StoreDate.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreDate extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'year' => 'nullable|present|numeric|required_with:month,day',
            'month' => 'nullable|present|numeric|required_with:year,day',
            'day' => 'nullable|present|numeric|required_with:year,month',
            'full_date' => 'nullable|date|before_or_equal:' . today()->format('Y-m-d'),
        ];
    }

    public function attributes()
    {
        return [
            'year' => '年',
            'month' => '月',
            'day' => '日',
            'full_date' => '年月日',
        ];
    }

    public function messages()
    {
        return [
            'full_date.before_or_equal' => ':attribute には過去の日付を入力してください。',
        ];
    }

    protected function validationData()
    {
        $data = parent::validationData();

        $data['full_date'] = null;

        // 年月日すべて揃っている場合にbirthdayに値を代入する
        if ($data['year'] && $data['month'] && $data['day']) {
            $data['full_date'] = $data['year'] . '-' . $data['month'] . '-' . $data['day'];
        }

        return $data;
    }
}

ポイントは、validationData メソッドをオーバーライドしている箇所です。

yearmonthday はそれぞれ別の入力値としてアプリケーションに届きますが、最初に挙げた制約の3番目(有効な日付け)と4番目(過去日付け)は yearmonthday をまとめてひとつの日付けとして見ないと検証できません。

そこで validationData メソッドをオーバーライドします。validationData メソッドはバリデーションの対象となるデータを取得するメソッドで、デフォルトの実装では入力値を配列で返すだけですが、今回は年月日を2018-9-23 の形式でまとめた full_date というデータを追加しています。

これにより rules メソッドで、他の入力値と同じように制約を定義することができています。

バリデーションルールの詳細などLaravel におけるバリデーションの実装方法については、こちらのマニュアルページも参考にしてください。

エラーメッセージの日本語化

エラーメッセージを日本語化するコードも載せておきます。

まずは設定ファイル app.php でロケールを日本語に設定します。

config/app.php
'locale' => 'ja',

次に、メッセージのファイルを作成します。

  1. resources/lang ディレクトリの下にロケール名と同じ名前のディレクトリを作成します。今回は ja ですね。
  2. 上の手順で作成したディレクトリに validation.php というファイルを作成します。
resources/lang/ja/validation.php
<?php

return [
    'date' => ':attribute には有効な日付けを入力してください。',
    'numeric' => ':attribute には数値を入力してください。',
    'present' => ':attribute を入力してください。',
    'required_with' => ':attribute を入力してください。',
];

resources/lang ディレクトリを見ると、最初は en ディレクトリだけが用意されているでしょう。Laravel は各種のメッセージを取得する際に、resources/lang ディレクトリのさらに下のロケール名のディレクトリの中の特定のファイルを探す仕組みというわけです。

なので、最初に用意された en ディレクトリの中のファイルをコピーしてきて必要な箇所のみ日本語にすればよいということです。ロケール名のディレクトリにメッセージが見つからない場合は、fallback_locale である en を探す仕組みなので必ずしもすべてのメッセージを日本語化する必要はありません。


以上、年月日が分かれたタイプの入力欄の実装例を紹介しました。
参考になれば幸いです。