芽萌丸プログラミング部 @programming
@programming
2020/3/27 12:33 更新✏

入力候補表示用カスタム要素 ajax-suggest

入力候補を表示してくれるシンプルな入力タグが欲しくて ajax-suggest というカスタム要素を自作しました。以下はそのカスタム要素のコード本体と使い方の紹介です。

目次

ajax-suggest カスタム要素のコード

ajax-suggest.js (Custom Element)

/*
 * Ajax available Suggest box.
 * @version 0.3.0 (updated at 2020/03/27)
 * @author 芽萌丸プログラミング部  {@link https://memo.appri.me/programming/}
 * @description See {@link https://memo.appri.me/programming/ajax-suggest-element}.
 */
class AjaxSuggest extends HTMLElement {
  // Specify observed attributes so that
  // attributeChangedCallback will work
  static get observedAttributes() {
    return ["data-list"];
  }

  constructor() {
    super();
    const self = this;
    this.util = this.getUtil();
    const shadow = this.attachShadow({ mode: "open" });
    this.shadow = shadow;
    this.styleEl = document.createElement("style");
    this.styleEl.textContent = `
      ul {
        margin: 0px;
        padding: 0px;
        background-color: white;
        position: absolute;
        top: 10px;
        border: 1px solid #ddd;
        box-shadow: 0 .125rem .25rem rgba(0,0,0,.075)!important;
        z-index: 9999;
      }
      li {
        padding: 6px 16px;
        list-style-type: none;
        cursor: pointer;    
        background-color: white;   
      }
      li.active {
        background-color: #eee;
      }`;
    this.candidateEl = document.createElement("ul");

    // append child:
    shadow.appendChild(this.styleEl);
    shadow.appendChild(this.candidateEl);

    // current selected candidate"s index:
    this.currentCandidateIdx = 0;

    // random list items (source):
    this.list = [];
    this.candidates = [];
  }


  connectedCallback() {
    const self = this;
    if (self.debug) console.log(`${self.constructor.name} element added to page.`);
    self.init();
  }

  disconnectedCallback() {
    const self = this;
    if (self.debug) console.log(`${self.constructor.name} element removed from page.`);
  }

  adoptedCallback() {
    const self = this;
    if (self.debug) console.log(`${self.constructor.name} element moved to new page.`);
  }

  attributeChangedCallback(name, oldValue, newValue) {
    const self = this;
    if (self.debug) console.log(`${self.constructor.name} element attributes changed. name:`, name);
    if (name === "data-list") {
      this.list = JSON.parse(newValue);
    }
  }

  //
  // my functions
  //

  init() {
    const self = this;
    const dataset = self.shadow.host.dataset;
    this.debug = dataset["debug"] === "true" ? true : false;
    if (self.debug) console.log("initializing...");
    // refetch function set:
    this.refetchFnName = dataset["refetch"];
    this.refetchTimeId = null;
    this.refetchKeywait = dataset["refetchKeywait"] ? parseInt(dataset["refetchKeywait"]) : 1000;
    // add events:
    this.addEvents();
  }

  getRefetchFn() {
    const self = this;
    if (this.refetch) return this.refetch;
    const refetchFnName = self.refetchFnName;
    if (!refetchFnName) throw Error("data-refetch must be specified");
    const refetchFn = window[refetchFnName];
    if (typeof refetchFn === "function") {
      this.refetch = refetchFn;
    } else {
      throw Error(`refetch function "${refetchFn}" not found`);
    }
    return this.refetch;
  }

  addEvents() {
    const self = this;
    const el = self.getInputEl();
    if (!el) {
      if (self.debug) console.warn("addEvents failed");
      return;
    }
    //** add event handler to target input box:
    el.addEventListener("keyup", function(e) {
      const keyCode = e.code;
      if (self.debug) console.log("keyCode:", keyCode);
      // カーソル移動
      if (keyCode === "ArrowUp" ||
        keyCode === "ArrowDown") {
        self.changeCurrentCandidate(keyCode);
        self.showCandidates(true, false);
        return;
      }
      // Escapeキー
      if (keyCode === "Escape") {
        self.showCandidates(false);
        return;
      }
      // Enterキー
      if (keyCode === "Enter") {
        self.showCandidates(false);
        return;
      }
      self.filter(self.getInput());
      self.updateCandidates();
      if (keyCode !== "Backspace" && self.getCandidateLength() <= 0) {
        // fetch!
        self.fetch((err, res) => {
          if (!err) {
            self.filter(self.getInput());
            self.updateCandidates();
            self.showCandidates(true);
          }
        });
      } else {
        self.showCandidates(true);
      }
    }, false);
  }


  // 現在選択されている候補カーソルを移動
  changeCurrentCandidate(keyCode) {
    const self = this;
    const currentSelected = this.getCurrentCandidate();
    let nextEl;
    let offset = 0;
    if (!currentSelected) {
      if (keyCode === "ArrowUp") {
        nextEl = this.getLastCandidate();
        this.currentCandidateIdx = this.getCandidateLength() - 1;
      } else if (keyCode === "ArrowDown") {
        nextEl = this.getFirstCandidate();
        this.currentCandidateIdx = 0;
      }
    } else {
      if (keyCode === "ArrowUp")
        offset = -1;
      else if (keyCode === "ArrowDown")
        offset = 1;
      nextEl = this.getNextPrevCandidate(offset);
      if (nextEl)
        this.currentCandidateIdx = this.getCurrentCandidateIdx() + (offset);
    }

    if (currentSelected) {
      // change style:
      this.util.removeClass(currentSelected, "active");
    }
    if (nextEl) {
      // change style:
      this.util.addClass(nextEl, "active");
      // change content:
      this.setInput(nextEl.textContent);
    }
  }

  getInputEl() {
    const self = this;
    if (self.inputEl) return self.inputEl;
    self.targetSelector = self.shadow.host.dataset["targetSelector"];
    if (!self.targetSelector) throw Error("data-target-selector must be specified");
    self.inputEl = document.querySelector(self.targetSelector);
    return self.inputEl;
  }

  getInput() {
    const self = this;
    const el = self.getInputEl();
    if (el) {
      let val = el.value ? el.value.trim() : null;
      return val;
    }
    return null;
  }

  setInput(txt) {
    const self = this;
    const el = this.getInputEl();
    el.value = txt ? txt.trim() : "";
    // fire the onchange event:
    if (self.debug) console.log("firing onchange event ...");
    if ("createEvent" in document) {
      let evt = document.createEvent("HTMLEvents");
      evt.initEvent("change", false, true);
      el.dispatchEvent(evt);
    } else
      el.fireEvent("onchange");
  }

  getCandidateLength() {
    const self = this;
    return this.candidateEl.querySelectorAll("li").length;
  }

  getCurrentCandidateIdx() {
    const self = this;
    const els = this.candidateEl.querySelectorAll("li");
    for (let len = els.length, i = 0; i < len; i++) {
      if (this.util.hasClass(els[i], "active")) return i;
    }
    return -1;
  }

  getCurrentCandidate() {
    const self = this;
    const el = this.candidateEl.querySelector(".active");
    if (self.debug) console.log("got a current candidate:", el);
    return el;
  }
  getFirstCandidate() {
    const self = this;
    const el = this.candidateEl.querySelector(":first-child");
    if (self.debug) console.log("got the first candidate:", el);
    return el;
  }
  getLastCandidate() {
    const self = this;
    const el = this.candidateEl.querySelector(":last-child");
    if (self.debug) console.log("got the last candidate:", el);
    return el;
  }
  getNextPrevCandidate(offset = 1) {
    const self = this;
    const el = this.candidateEl.querySelectorAll("li")[this.currentCandidateIdx + offset];
    if (self.debug) console.log("got a next or prev candidate:", el);
    return el;
  }

  filter(prefix) {
    const self = this;
    if (!prefix) return;
    // NOTE: prefixも比較対象も小文字に統一
    prefix = prefix.toLowerCase();
    if (self.debug) console.log("filtering with prefix:", prefix);
    this.candidates = [];
    for (let len = this.list.length, i = 0; i < len; i++) {
      const src = this.list[i];
      // 小文字に統一して比較:
      if (src.toLowerCase().startsWith(prefix)) {
        this.candidates.push(src);
      }
    }
  }

  updateCandidates() {
    const self = this;
    if (self.debug) console.log("updating candidates...");
    const ul = this.candidateEl;
    ul.innerHTML = "";
    for (let len = this.candidates.length, i = 0; i < len; i++) {
      const item = this.candidates[i];
      const li = document.createElement("li");
      li.textContent = item;
      li.addEventListener("click", function(e) {
        self.setInput(e.currentTarget.textContent);
        self.showCandidates(false);
        // fire "clickselect" event:
        self.fireEvent("clickselect");
      }, false);
      ul.appendChild(li);
    }
  }

  /**
   * @param bool {Boolean} whether show or hide. defaults to true (show).
   * @param updatePosition {Boolean} - updates the candidate element"s position. defaults to true (update).
   */
  showCandidates(bool = true, updatePosition = true) {
    const self = this;
    if (self.debug) console.log("showing candidates...");
    this.candidateEl.style.display = (bool) ? "block" : "none";
    // 候補リストの表示位置調整
    if (bool && updatePosition) {
      const iptTop = this.getInputEl().offsetTop;
      const iptRect = this.getInputEl().getBoundingClientRect();
      // this.candidateEl.style.left = iptRect.left + "px"
      // this.candidateEl.style.left = "14px"
      // ** inputの位置が半分より上の場合:
      this.candidateEl.style.top = iptTop + iptRect.height + "px"
      // ** inputの位置が半分より下の場合:
      if (iptRect.top >= (window.innerHeight / 2)) {
        this.candidateEl.style.top = iptTop - this.candidateEl.clientHeight + "px"
      }
    }
  }

  /**
   * Fire an event to the input element.
   * @param  {String} event
   */
  fireEvent(event) {
    const self = this;
    if (self.debug) console.log(`firing on${event} event ...`);
    const el = self.getInputEl();
    if ("createEvent" in document) {
      let evt = document.createEvent("HTMLEvents");
      evt.initEvent(event, false, true);
      el.dispatchEvent(evt);
    } else {
      el.fireEvent("on" + event);
    }
  }

  fetch(cb) {
    const self = this;
    if (this.refetchTimeId) {
      if (self.debug) console.log("previous fetching cancelled.");
      clearTimeout(this.refetchTimeId);
      this.refetchTimeId = null;
    }
    if (self.debug) console.log(`trying to fetch after ${self.refetchKeywait} ms...`);
    this.refetchTimeId = setTimeout(() => {
      if (self.debug) console.log(`fetching start...`);
      const fn = self.getRefetchFn();
      fn((err, res) => {
        if (self.debug) console.log(`fetching end:`, err, res);
        if (!err)
          self.shadow.host.dataset["list"] = JSON.stringify(res);
        self.refetchTimeId = null;
        return cb && cb(err, res);
      });
    }, self.refetchKeywait);
  }

  getUtil() {
    const self = this;
    return class util {
      static hasClass(el, className) {
        if (el.classList)
          return el.classList.contains(className);
        return !!el.className.match(new RegExp("(\\s|^)" + className + "(\\s|$)"));
      }

      static addClass(el, className) {
        if (el.classList)
          el.classList.add(className)
        else if (!this.hasClass(el, className))
          el.className += " " + className;
      }

      static removeClass(el, className) {
        if (el.classList)
          el.classList.remove(className)
        else if (this.hasClass(el, className)) {
          const reg = new RegExp("(\\s|^)" + className + "(\\s|$)");
          el.className = el.className.replace(reg, " ");
        }
      }
    };
  }
}

// module export:
if (typeof module !== "undefined" && typeof module.exports !== "undefined")
  module.exports = AjaxSuggest;
else {
  window.AjaxSuggest = AjaxSuggest;
}
// define as a custom element:
if (typeof customElements !== "undefined")
  customElements.define("ajax-suggest", AjaxSuggest);

NOTE: このコードは browserify にも対応しています👌

使い方

導入手順

  1. ajax-suggest.jsをインポート
    • <script>タグ版:
      • 例) <script type="text/javascript" src="path/to/ajax-suggest.js"></script>
    • CommonJS版:
      • 例) global.AjaxSuggest = require('./path/to/ajax-suggest.js');
  2. <ajax-suggest>タグなどを貼る
    • 属性に候補取得処理関数などの動作を設定する 例: バニラjs版
        <input
          class="form-control"
          type="text"
          id="ipt"
          onchange="document.querySelector('#result').value=event.currentTarget.value">
        <ajax-suggest
          id="suggest"
          data-target-selector="#ipt"
          data-refetch="fetchDummySuggests"
          data-refetch-keywait="300"
          data-debug="true"></ajax-suggest>
  3. 候補取得関数を実装する 例:
     function fetchDummySuggests(cb){
       ...
       return cb && cb(err, arr); // 第二引数は候補文字列の配列
     }

サンプルコード

バニラjs版

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <title>AjaxSuggest Example [Vanilla]</title>
</head>

<body>
  <div id="app">
    <div>
      <h5>バニラjs版ajax-suggest</h5>
      <p>テキストボックスにA,B,C,a,b,cのどれかを入力してみましょう。</p>
    </div>
    <div>
      <input
        class="form-control"
        type="text"
        id="ipt"
        onchange="setResult(event.currentTarget.value)">
      <ajax-suggest
        id="suggest"
        data-target-selector="#ipt"
        data-refetch="fetchDummySuggests"
        data-refetch-keywait="300"
        data-debug="true"></ajax-suggest>
    </div>
    <div>
      入力されたデータ: <input type="text" id="result" readonly>
    </div>
  </div>
  <script type="text/javascript">
  /**
   * ダミーのサジェスト一覧取得関数
   * @param  {Function} callback (第一引数にエラー、第二引数に候補一覧のString配列)
   */
  function fetchDummySuggests(cb) {
    const self = this;
    return cb && cb(null, getDummySourceList());

    function getDummySourceList() {
      const arr = [];
      for (let len = 20, i = 0; i < len; i++) {
        arr.push(_getRandomKeyword());
      }
      return arr;

      function _getRandomKeyword() {
        const length = 6
        const possible = "ABCabc";
        let text = "";
        const possibleLen = possible.length;
        for (let len = length, i = 0; i < length; i++) {
          text += possible.charAt(Math.floor(Math.random() * possibleLen));
        }
        return text;
      }
    }
  }

  function setResult(val) {
    document.querySelector("#result").value = val;
  }

  /** clickselectイベントハンドラ */
  document.querySelector("#ipt").addEventListener("clickselect", function() {
    console.log("候補ポップアップから直接クリック選択されました!");
  }, false);
  </script>
  <script type="text/javascript" src="element/ajax-suggest.js"></script>
</body>

</html>

Vue.js版

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <title>AjaxSuggest Example [Vue]</title>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.min.js" integrity="sha256-FtWfRI+thWlNz2sB3SJbwKx5PgMyKIVgwHCTwa3biXc=" crossorigin="anonymous"></script>
</head>

<body>
  <div id="app">
    <div>
      <h5>Vue.js版ajax-suggest</h5>
      <p>テキストボックスにA,B,C,a,b,cのどれかを入力してみましょう。</p>
    </div>
    <div>
      <input
        class="form-control"
        type="text"
        id="ipt"
        v-model="ipt"
        @change="ipt=$event.currentTarget.value">
      <ajax-suggest
        id="suggest"
        data-target-selector="#ipt"
        data-refetch="fetchDummySuggests"
        data-refetch-keywait="300"
        data-debug="true"></ajax-suggest>
    </div>
    <div>
      入力されたデータ: <span v-text="ipt"></span>
    </div>
  </div>
  <script type="text/javascript">
  const vm = new Vue({
    el: "#app",
    data: {
      ipt: null,
    },
    methods: {
      /**
       * ダミーのサジェスト一覧取得関数
       * @param  {Function} callback (第一引数にエラー、第二引数に候補一覧のString配列)
       */
      fetchDummySuggests(cb) {
        const self = this;
        return cb && cb(null, getDummySourceList());

        function getDummySourceList() {
          const arr = [];
          for (let len = 20, i = 0; i < len; i++) {
            arr.push(_getRandomKeyword());
          }
          return arr;

          function _getRandomKeyword() {
            const length = 6
            const possible = "ABCabc";
            let text = "";
            const possibleLen = possible.length;
            for (let len = length, i = 0; i < length; i++) {
              text += possible.charAt(Math.floor(Math.random() * possibleLen));
            }
            return text;
          }
        }
      }
    },
  });

  // IMPORTANT: fetchDummySuggests関数をグローバルへエクスポート
  // <ajax-suggest>からfetchDummySuggests関数を参照するためにグローバルへ参照をセットします。
  window.fetchDummySuggests = vm.fetchDummySuggests;
  </script>
  <script type="text/javascript" src="element/ajax-suggest.js"></script>
</body>

</html>

NOTE: window.fetchDummySuggests = vm.fetchDummySuggests;のように候補取得関数はhtmlから参照できるグローバルな場所に置きます。

TIPS

clickselectイベント

候補ポップアップをクリックして選択するとclickselectイベントが発火します。こ のイベントは例えば以下のように受信できます:

/** clickselectイベントハンドラ */
document.querySelector("#ipt").addEventListener("clickselect", function() {
  console.log("候補ポップアップから直接クリック選択されました!");
}, false);

親要素のスタイルに注意

ajax-suggest 要素は親要素のスタイルに影響を受けます。例えば、 ajax-suggest の親要素に Bootstrap の form-inline クラスを設定してしまうと、候補ポップアップがinputボックスの下に表示されなくなってしまいます。

これはダメな例:

<div class="form-inline"> <!-- ← form-inline のせいで候補ポップアップが右端に表示されてしまう! -->
  <input type="text" id="ipt">
  <ajax-suggest
    data-target-selector="#ipt"
    data-refetch="callMyAPI"
    data-refetch-keywait="1000"
    data-debug="true"></ajax-suggest>
</div>

更新履歴

以上

Custom Elements

芽萌丸プログラミング部 @programming
芽萌丸プログラミング部@programming
プログラミング関連アカウント。Web標準技術を中心に書いていきます。フロントエンドからサーバサイドまで JavaScript だけで済ませたい人たちの集いです。記事は主に @TanakaSoftwareLab が担当。
オススメ:Zattoyomiで時事ネタチェックの時間節約!