location.hash と webapl を連動させるライブラリ

Ajax を利用してアプリを作るとき、location.hash の値と連動して動く。
onHashChange を使用せずに済む。


とある条件でページをスクリプトで生成するプログラムがあるとする。
その条件が変化するごとに location.hash に書き出し、コールバック(生成プログラム)に条件を渡して呼び出す
外部からある条件下でページを生成したい場合 location.hash に条件を書いて呼び出す
インスタンス化した時点の条件を標準とし、それと違う用件だけを location.hash に記述する

2022-07-09 更新

/*----------------------------------------------
  onHashChange を使用せず
  現在の状態を location.hash に反映させる(逆も可)
  対象となるFORM の要素群には id もしくは name 属性が必要である
----------------------------------------------*/

class ApplicationNavigator {
  
  #defaultState = null;//初期設定時の状態を基本とする

  constructor (form = document.querySelector ('form'), cbFunc = null, cbObj = null, ...cbArgs) {
    this.form = form;
    this.cbFunc = cbFunc;
    this.cbObj = cbObj;
    this.cbArgs = cbArgs;
    this.elements = [...form.querySelectorAll ('input[id], input[name], select[id], select[name], textarea[id], textarea[name]')];
    this.#defaultState = this.constructor.getValues (this.elements);
    this.hash = location.hash;

    if (cbFunc)
      form.addEventListener ('change', this, false);
  }


  execute (e) {
    if ('function' === typeof this.cbFunc) {
      this.cbFunc.apply (this.cbObj || this, [this.status, ...this.cbArgs]);
      location.hash = this.hash; 
    }
    return this;
  }


  reset () {
    this.status = this.#defaultState;
    return this;
  }


  handleEvent (event) {
    let e = event.target;
    if (this.elements.includes (e))
      this.execute (e);
  }


  get status () {//全ての状態を返す
    return this.constructor.getValues (this.elements);
  }

  set status (obj = { }) {//statusには実態がない(その都度要素から収集)
    this.constructor.setValues (this.elements, Object.assign ({ }, obj));
    this.execute ();//コールバック関数を呼び出す
  }


  get hash () {
    let a = this.#defaultState;
    return '#' +
      Object.entries (this.status)
        .filter (([k,v])=> a[k] != v)
        .map (b=> b.join`=`)
        .join('&');
  }
  set hash (hash) {
     this.status = this.constructor.parseParms (hash);
  }


  //______________________


  static parseParms (str = '') {
    let
      reg = /(?:^#)?(\D\w*)(\[\])?=(.*?)(?:\&|\;|$)/g,
      pieces = new Map,
      piece;

    while (piece = reg.exec (str)) {
      let [, key, isAry, val] = piece;
      key = decodeURIComponent (key);
      val = decodeURIComponent (val);

      if (isAry)
        val = [val];

      if (pieces.has (key))
        pieces.set (key, [].concat (pieces.get (key), val));
      else
        pieces.set (key, val);
    }

    return Object.fromEntries (pieces);
  }


  //要素の値を取得してオブジェクトにして返す
  //name属性が複数な場合配列として記憶する
  static getValues ([...es]) {
    const
      excludeType = ['fieldset', 'reset', 'submit', 'image', 'file','button'],//除外する要素
      obj = new Map;

    for (let e of es) {
      let type = e.type, key, value;

      if (excludeType.includes (type))
        continue;
      if (['checkbox', 'radio'].includes (type) && !e.checked)
        continue;//checked属性がfalsse は除外する

      if (key = e.id || e.name) {//keyがあるものだけ有効
        //最初から配列とする
        if ('select-multiple' === type)
          value = [...e.options].reduce ((a, b)=> (b.selected && a.push (b.value), a), [ ]);
        else if ('checkbox' === type)
          value = [value];
        else
          value = e.value;

        if (obj.has (key))//すでに登録されたものは配列
          value = [].concat (obj.get (key), value);

        obj.set (key, value);
      }
    }
    return Object.fromEntries (obj);//オブジェクト化
  }


  //指定要素群に値を代入する
  static setValues ([...es], obj = { }) {
    const
      priority = ['radio', 'checkbox', 'select-one', 'select-multiple'],//優先順位
      prioritize = (a, b)=> priority.indexOf (a.type) - priority.indexOf (b.type);

    let hs = es.reduce ((a,e)=> (a[e.id||e.name] = (a[e.id||e.name] || []).concat(e), a), { });//名前毎にhashにする

    //名前毎の中で優先順に並び替え
    Object.values (hs).forEach (v=> v.sort (prioritize));
    //select要素のmultipleのすべてをリセットする
    es.filter (e=> e.type === 'select-multiple').flatMap (e=> [...e.options]).forEach (e=> e.selected = false);
    //es.filter (e=> e.hasAttribute ('defaulValue')).forEach (e=> e.value = e.defaultValue);
      
    for (let [name, vals] of Object.entries (obj)) {//※valsはコピーしたもの
      let es = hs[name] || [ ];
      vals = [].concat (vals);

      while (vals.length) {
        let val = vals.shift ();
        for (let e of es) {
          switch (e.type) {
            //想定のtype順で設定する
            case 'select-multiple':
              for (let op of e.options) {
                if (op.value == val) {
                  op.selected = true;
                  break;
                }
              }
              break;
            case 'select-one' :  e.value = val; break;
            case 'checkbox' : case 'radio' : e.checked = e.value === val; break;
            default : e.value = val; break;
          }
        }
      }
    }
  }
}