年・月・日の入力を SELECT 要素で連動する(年度指定も可)

年・月・日の入力を SELECT 要素で連動する(年度指定も可)

年月日の入力を簡単にする。
マウスホイールにも対応。
年月日の範囲も指定可能。例:dateObj を [-7, 0, 10] の様な引数にすると、[一週間前, 今日, 10日後] の様な指定も可能。

使い方

chainDateSelector.create ([select element,,], [beginDateObj, selectedDateObj, endDateObj], callBackFunc, option);

オプション

chainDateSelector.option を参照のこと

ちょっとだけ気に入ったコード

function sprintf (fmt, ...ary) {
    let j = 0;
    return fmt.replace (/\%(?:(\d+)\$)?(?:(.)?([1-9][0-9]?))?([ds])/g, (_, i = ++j, spc='', len)=>
       String (ary[i - 1] == null ? '': ary[i - 1]).padStart (len, spc));
}
//exp:
console.log (sprintf ('%3$02d 日 %2$02d月 %1$d 年', year, month, day));

コード

/***********************************************************
 *  ChainDateSelector 
 *  select要素を年、月、日とし、連携して機能する
 *  changeイベントが発生するたびに、コールバック関数を呼び出す
 *  ChainDateSelector.create ([selct,..], [begin_date, selected_date, end_date], cbFunc, option);
 *
 ***********************************************************/

class ChainDateSelector {

  constructor (select, date, cbFunc = null, option = ChainDateSelector.getDefaultOption ()) {
    this.selects  = select; //[ year[, month [, day]]].  3 < length の場合不正な動きをする 
    this.dates    = date; // [start, current, end]
    this.cbFunc   = cbFunc;
    this.option   = option;
    this.disabled = false; //連動の抑止をしない

    //初期化
    initialize: {
      this.replace ();//select要素の中身を生成
      //イベント設定
      for (let e of select) {
        e.addEventListener ('change', this, false);
        if (option.wheel)
          e.addEventListener ('wheel', this, false);
      }
      //最初にコールバック関数を呼び出すか?
      if (option.cbFunc_init_start) this.call ();
    }
  }

  //____________________________________

  //コールバック関数を指定した形を引数として呼び出す
  call (type = this.option.valueType) {
    if (this.cbFunc) {
      let vals = this.getValue (type);
      (Array.isArray (vals))
        ? this.cbFunc (...vals)
        : this.cbFunc (vals);
    }
    return this;
  }

  //____________________________________

  //現在の年月日の値をtypeで指定した形式で返す
  getValue (type = this.option.valueType) {
    const { sprintf, getYMD, date_min, date_max } = ChainDateSelector.tools ();
    let
      OP = this.option,
      nd = OP.nendo,
      [y = null, m = null, d = null, ny = null, nm = null] = this.selects.map (e=> '' == e.value ? null: Number (e.value)),
      Y = y, dt, fmt = [ ], fg, rst = null;//Y,Mは実数値、y,mは年度により変化する
    
    if (! (fg = null == y)) { //y が null つまり fg= ture の時は、date object を生成しない
      switch (this.selects.length) {//select要素の数で処理を分岐
      case 1 :
        m = nd ? 3: 0;
        [y, m, d] = getYMD (date_min (date_max (this.dates[0], new Date (y, m, 1)), this.dates[2]));
        fmt = OP.valueJpFormatY;
        break;
      
      case 2 :
        if (! (fg = null == m)) {
          d = 1; //select要素が year&month 場合はその年月の朔日を返す
          fmt = OP.valueJpFormatYM;
        }
        break;

      case 3 :
        if (! (fg = (null == m || null == d))) {
          fmt = OP.valueJpFormatYMD;
        }
        break;
      }

    ny = y;
    if (! fg) {
        nm = m - 4;
        if (nd) {//年度処理
          if (12 < m) { m -= 12; Y++ ; }
        } else {
          if (m < 4) { nm += 12; ny -= 1; }
        }
      }
    }

    dt = fg ? null: new Date (Y, m - 1, d);
    switch (type) {
      case 1 : case 'DateObject' :    return dt;
      case 2 : case '{y,m,d}' :       return { y, m, d };
      case 3 : case '[y,m,d]' :       return [ y, m, d ];
      case 4 : case 'YMD' :           return fg ? '': sprintf (fmt[0], y, m, d);
      case 5 : case 'Y-M-D':          return fg ? '': sprintf (fmt[1], y, m, d);
      case 6 : case 'Y年M年D日' :     return fg ? '': sprintf (nd ? fmt[2]: fmt[3], y, m, d);
      case 7 : case 'NENDO[Y,M,D]' :  return [ny, nm, d]; //便宜上4月を0とし、3月を11とする
      case 0 : default :              return [y, m, d, dt, {y, m, d}, [ny, nm, d]];
    }
  }

  //____________________________________
  
  //select要素を随時書き換える
  //引数が null の場合は ”年”の select 要素も書き換える。
  //実際には引数に SEL_Day 要素を与えても書き換えしないが、指定することでコールバック関数を呼び出す
  replace (target = null, onCall = false) {
    if (this.disabled) return this;
    const { getDate, sprintf, replaceSelect } = ChainDateSelector.tools ();

    let
      [SEL_Year, SEL_Month, SEL_Day] = this.selects,
      [ya, yb, yc] = this.dates.map (d => d.getFullYear ()),
      [ma, mb, mc] = this.dates.map (d => d.getMonth ()),
      nd = this.option.nendo ? 3: 0,
      opts, a, b, c, y, m;

    //年度処理
    if (nd) {
      if (ma < 3) { ya -= 1; ma += 12; }
      if (mb < 3) { yb -= 1; mb += 12; }
      if (mc < 3) { yc -= 1; mc += 12; }
    }

    switch (target) {

    case null : default : //年の書き換え
      opts = [ ];//optionを構成するための配列、一括で作成登録
      for (let i = ya; i <= yc; i++)//年が小さい順に作成し、必要に応じてソートする
        opts.push ([sprintf (this.option.yearFormat, i), i, i === yb, i === yb]);

      if (this.option.sortYear) opts.reverse (); //大きい順にする
      if (this.option.firstYear) opts.unshift (this.option.firstYear);//最初のoption
      replaceSelect (SEL_Year, opts);
      // ! break; //このまま次に


    case SEL_Year : //月の書き換え
      if (SEL_Month) {
        opts = [ ];
        y = Number (SEL_Year.value);
        a = y === ya ? ma: nd;//最小
        b = y === yb ? mb: -1;//月が defaultValue なのか判別に使用する
        c = y === yc ? mc: 11 + nd;//最大

        for (let i = a; i <= c; i++)
          opts.push ([sprintf (this.option.monthFormat, (i % 12) + 1), i + 1, i === b, i === b]);

        if (this.option.sortMonth) opts.reverse (); //大きい順にする
        if (this.option.firstMonth) opts.unshift (this.option.firstMonth);//最初のoption
        replaceSelect (SEL_Month, opts);
      }
      // ! break; //このまま次に


    case SEL_Month : //日の書き換え
      if (SEL_Day) {
        opts = [ ];
        y = Number (SEL_Year.value);
        m = Number (SEL_Month.value) -1;

        a = y === ya && m === ma ? this.dates[0].getDate () : 1;
        b = y === yb && m === mb ? this.dates[1].getDate (): -1;
        c = y === yc && m === mc ? this.dates[2].getDate (): (new Date (y, m + 1 , 0)).getDate ();//末日;

        for (let i = a; i <= c; i++)
          opts.push ([sprintf (this.option.dayFormat, i), i, i === b, i === b]);

        if (this.option.sortDay) opts.reverse (); //大きい順にする
        if (this.option.firstDay) opts.unshift (this.option.firstDay);//最初のoption
        replaceSelect (SEL_Day, opts);
      }
      // ! break; //このまま次に

    case SEL_Day :
      //SELECT要素の day が無い場合を考慮し、最後の要素として判断し実行する。
      //注意 selects.length が4以上の場合正常に機能しない
      let last = this.selects[this.selects.length -1];
      if (target == last || onCall)
        this.call ();
      break;
    }

    return this;
  }

  //____________________________________

  //年月日のselect 要素にオフセット値を加える
  setSelect (dt = this.getValue ('DateObject'), oy = 0, om = 0, od = 0) {
    if (this.disabled) return this;
    const { getYMD, date_min, date_max } = ChainDateSelector.tools ();
    dt = new Date (dt); //複写したものを使う
    //各オフセット値を設定
    if (oy) dt.setFullYear (oy + dt.getFullYear ());
    if (om) dt.setMonth (om + dt.getMonth ());
    if (od) dt.setDate (od + dt.getDate ());
    //範囲チェック
    dt = date_min (date_max (this.dates[0], dt), this.dates[2]);

    let
      [y, m, d] = getYMD (dt),
      [sy, sm, sd] = this.selects;

    if (this.option.nendo && 4 > m) {
      m = m + 12; y = y - 1;//年度調整
    }
    sy.value = y; this.replace (sy);//y をセットしたうえで、後の月・日を書き換える
    if (sm) {
      sm.value = m;
      this.replace (sm);//m をセットしたうえで、日を書き換える
      if (sd) {
        sd.value = d;
        this.replace (sd);//d をセット(してから実行)
      }
    }
    return this;
  }

  //____________________________________

  //イベント処理
  handleEvent (event) {
    if (this.disabled) return;

    let { target, type } = event;

    switch (type) {
    case 'change' :
      this.replace (target, true);
      break;

    case 'wheel' :
      event.preventDefault ();
      let
        w = event.deltaY < 0 ? 1: -1,
        dt = this.getValue ('DateObject') || this.dates[w + 1],//dtが未定な場合 w により初期値を決定
        idx = this.selects.indexOf (target),
        args = [null, [dt,w], [dt,,w], [dt,,,w]][1 + idx];

      if (args) this.setSelect (...args);
      break;
    }
  }


  //=========================================================

  static tools () {
    return {
      //サーバーの時刻を取得する
      getUTCDateByServer: function () {
        let xhr = new XMLHttpRequest ();
        xhr.open ('GET', '#', false);
        xhr.send (null);
        return new Date (xhr.getResponseHeader ('Date')); 
      },
      //年・月・日の値を配列で返す
      getYMD: function (d = new Date, o = [0, 1, 0]) {
        return ['getFullYear', 'getMonth', 'getDate'].map ((fn,i)=> d[fn]()+o[i]);
      },
      //DateObjectをコピー&オフセット日を加算して返す
      getDate: function (d = new Date, o = 0) {
        d = new Date (d);
        d.setDate (d.getDate () + o);
        return d;
      },
      //php の sprintf を模した関数
      sprintf: function (fmt, ...ary) {
        let j = 0;
        return fmt.replace (/\%(?:(\d+)\$)?(?:(.)?([1-9][0-9]?))?([ds])/g, (_, i = ++j, spc='', len)=>
          String (ary[i - 1] == null ? '': ary[i - 1]).padStart (len, spc));
      },
      //select要素の中身を書き換える
      replaceSelect: function (select, opts = []) {
        select.innerText = null;
        for (let op of opts.reverse ())
        select.insertBefore (new Option (...op), select.firstChild);
      },
      isInt: n=> /^[+\-]?[1-9]?[0-9]+/.test (String (n)),//整数なのか?
      date_min: (a, b)=> a < b ? a: b,//最小の日付
      date_max: (a, b)=> a < b ? b: a,//最大の日付
      isDate: d=> '[object Date]' === Object.prototype.toString.call (d),//DateObject?

    };
  }

  //____________________________________

  //オブジェクトの生成: バリデーションも行う
  static create (_select = [ ], _date = [ ], _cbFunc = null, _option = { }) {
    const { getUTCDateByServer, isInt, isDate, getDate } = ChainDateSelector.tools ();

    let
      obj, select = [ ], date,
      option = Object.assign ({ }, ChainDateSelector.getDefaultOption (), _option);

    //arguments validation
    select: { //select要素数は1~3個まで許容
      for (let e of _select)//要素名をチェック
        if ('select-one' === e.type || 'DATALIST' === e.tagName)
          if (3 === select.push (e)) break;//3個まで
      if (select.length < 1)//要素数をチェック
        throw new Error ('対象となる SELECT(DATALIST) 要素が不足です');
    }
    
    date: {
      if (! Array.isArray (_date))
        throw new Error ('範囲となる日付を見つけられませんでした');
      let
        dt = option.referenceDateServer ? getUTCDateByServer (): new Date,
        [begin = dt, selected = 0, end = 0] = _date;

      //dates が数値ならば基準(dt)からのオフセット日とする
      if (! begin || isInt (begin)) begin = getDate (dt, begin || 0);
      else if (! isDate (begin)) throw new Error ('DateObject以外が指定されました');

      if (! selected || isInt (selected)) selected = getDate (dt, selected || 0);
      else if (! isDate (selected)) throw new Error ('DateObject以外が指定されました');

      if (! end || isInt (end)) end = getDate (dt, end || 0);
      else if (! isDate (end)) throw new Error ('DateObject以外が指定されました');

      date = [begin, selected, end];
    }

    nendo: {//年度の場合は、表示書式を変更する
      if (option.nendo)
        option.yearFormat = '%04d 年度';
    }

    obj = new ChainDateSelector (select, date, _cbFunc, option);
    return obj;
  }

  //____________________________________

  //初期値
  static getDefaultOption (key = null) {
    const
      option = {
        wheel               : true, //マウスのホイールを有効にする
        referenceDateServer : true, //サーバーの時間を取得する, false:端末のパソコンの時間を基準とする 
        cbFunc_init_start   : true, //コールバック関数を最初に実行するか?
        valueType           : 0,    //:y,m,d,... , 'YMD',1, 'Y-M-D',2, 'Y年M月D日',3, '[y,m,d]',4, '{y,m,d}',5
        sortYear            : true, //年賀大きい順にソート
        sortMonth           : false, //月を小さい順から
        sortDay             : false, //日を小さい順から

        yearFormat          : '%04d 年',
        monthFormat         : '%02d 月',
        dayFormat           : '%02d 日',
        valueJpFormatY      : ['%04d', '%04d', '%d年度', '%d年'],//select が「年」の実の場合
        valueJpFormatYM     : ['%04d%02d', '%04d-%02d', '%d年度%02d月', '%d年%02d月'],//select が「年月」の実の場合
        valueJpFormatYMD    : ['%04d%02d%02d', '%04d-%02d-%02d', '%d年度%02d月%02d日', '%d年%02d月%02d日'],

        firstYear           : ['--', '', true, true], //SELECT年の最初のoption要素
        firstMonth          : ['--', '', true, true], //SELECT月の最初のoption要素
        firstDay            : ['--', '', true, true], //SELECT日の最初のoption要素

        nendo               : false,//true: 年度表記にする
      };

    return key ? option[key]: Object.assign ({ }, option);
  }

}