入力候補表示用カスタム要素 ajax-suggest
この記事は最終更新日から1年以上が経過しています。
芽萌丸プログラミング部@programming
投稿日 2019/6/5
更新日 2020/3/27 ✏

入力候補表示用カスタム要素 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 にも対応しています:ok_hand:

使い方

導入手順

  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>

更新履歴

  • 2020/03/27: 大文字小文字を区別せずに候補一覧に表示するように修正しました。
  • 2019/10/25: 候補ポップアップを直接クリックして選択した時はclickselectイベントを発火させるようにしました。

以上


芽萌丸プログラミング部
芽萌丸プログラミング部@programming
プログラミング関連アカウント。Web標準技術を中心に書いていきます。フロントエンドからサーバサイドまで JavaScript だけで済ませたい人たちの集いです。記事は主に @TanakaSoftwareLab が担当。