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

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

目次

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

ajax-suggest.js (Custom Element)

/*
 * Ajax available Suggest box.
 * @version 0.1.1
 * @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;
      }
      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 (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.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);
      }, 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();
      // ** 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"
      }
    }
  }

  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)) {
          var 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 にも対応しています👌

使い方

Vanilla

  1. HTMLから参照できる場所に候補一覧取得APIを叩く関数を各自作成。Node style callback 関数で返り値には Array<list> な候補一覧を返してください。(重要)
    例:

     // "abc"からランダムな6文字を返します
     function callMyAPI(cb) {
       const self = this;
       console.log("refetching...")
       setTimeout(() => {
         console.log("refetched!");
         const list = getDummySourceList() ? getDummySourceList() : [];
         return cb && cb(null, list);
       }, 1000);
    
       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 = "abc";
           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;
         }
       }
     }
  2. HTMLにタグを記述:

     <div id="app">
       <input type="text" id="ipt">
       <ajax-suggest
         data-target-selector="#ipt"
         data-refetch="callMyAPI"
         data-refetch-keywait="1000"
         data-debug="true"></ajax-suggest>
     </div>
     <script>
     // 先ほど定義した候補取得用関数
     function callMyAPI(cb) {
       ...
     }
     </script>
     <script type="text/javascript" src="./ajax-suggest.js"></script>

    ajax-suggestタグのdata属性の解説

    • data-target-selector: 入力input要素のセレクタ
    • data-refetch: 候補一覧を取得するための関数
    • data-refetch-keywait: 利用者がキー入力後もし候補が見つからなかった場合、何ミリ秒後にajax取得しに行くか。デフォルト値は 1000 ミリ秒。
    • data-debug: trueならコンソールにdebugログを出力。デフォルト値は false

これだけです。簡単ですね。

Vue.js

ajax-suggest は Vue.js と一緒に使うこともできます。ただし、少しだけハックが必要です:

<div id="app">
  <input type="text" id="ipt" v-model="ipt" @change="ipt=$event.currentTarget.value">
  <ajax-suggest
    data-target-selector="#ipt"
    data-refetch="callMyAPI"
    data-refetch-keywait="1000"
    data-debug="true"></ajax-suggest>
</div>
<script>
const vm = new Vue({
  el: "#app",
  data: {
    ipt: null,
  },
  methods: {
    // 先ほど定義した候補取得用関数
    callMyAPI(cb) {
      // (省略)
    },
  },
});
// globalな場所に取得用関数の参照を渡しておく:
window.callMyAPI = vm.callMyAPI;
</script>
<script type="text/javascript" src="./ajax-suggest.js"></script>

vanillaなコードとの違いは:

  • ajax-suggestタグに v-model を設定
  • ajax-suggestタグに @change="ipt=$event.currentTarget.value" を追加
  • window.callMyAPI = vm.callMyAPI; を追加

の部分です。

動作確認

こんな感じで動作します→ajax-suggestの動作確認動画(mp4)

TIPS

親要素のスタイルに注意

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>

以上

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