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

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

目次

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

ajax-suggest.js (Custom Element)

/*
 * Ajax available Suggest box.
 * @version 0.2.1 (updated at 2019/10/25)
 * @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);
    // `data-list`属性が変更された時の処理:
    if (name === "data-list") {
      self.list = JSON.parse(newValue);
      self.filter(self.getInput() ? self.getInput() : "");
      self.updateCandidates();
      self.showCandidates(true);
    }
  }

  //
  // 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...");
    // add events:
    this.addEvents();
  }

  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;
      const isComposing = e.isComposing;
      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();
      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 "change" event:
    self.fireEvent("change");
  }

  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);
        // 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();
      // ** 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);
    }
  }

  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>タグを貼る
    • data-target-selector属性に入力BOXの要素IDをセット
    • data-list属性には空配列のJSON文字列[]をセット
  3. サジェスト表示処理を実装
    • 何らかのユーザの入力イベントをトリガーにAPI等からサジェスト一覧(配列)をフェッチ取得し、data-list属性にJSON文字列としてセットする。
      • 例) document.querySelector("#suggest").dataset["list"] = JSON.stringify(["候補1","候補2","候補3"]);

サンプルコード

<!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 [ピュアJS]</title>
</head>

<body>
  <div id="app">
    <button onclick="fetchDummySuggests(event)">サジェスト呼び出し</button>
    <input class="form-control" type="text" id="ipt">
    <ajax-suggest id="suggest" data-target-selector="#ipt" data-list="[]" data-debug="true"></ajax-suggest>
  </div>
  <script type="text/javascript">
  /**
   * ダミーのサジェスト一覧取得関数
   * @param  {Event} e
   */
  function fetchDummySuggests(e) {
    const self = this;
    const suggestEl = document.querySelector("#suggest");
    setTimeout(() => {
      if (self.debug) console.log("refetched!");
      const list = getDummySourceList() ? getDummySourceList() : [];
      // サジェスト一覧が更新され、選択BOXがポップアップされます:
      suggestEl.dataset["list"] = JSON.stringify(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;
      }
    }
  }
  /** clickselectイベントハンドラ */
  document.querySelector("#ipt").addEventListener("clickselect", function(){
    console.log("候補ポップアップから直接クリック選択されました!");
  }, false);
  </script>
  <script type="text/javascript" src="element/ajax-suggest.js"></script>
</body>

</html>

TIPS

Vue.jsと連携

Vue.jsと連携する場合は、入力BOX要素に@change="ipt=$event.currentTarget.value"のような値をセットすると良いでしょう。(iptはVueモデル変数名です) これにより、サジェスト一覧でのカーソル移動で選択された文字列が即座にVueモデルに繁栄されます。

例)

<input type="text"
  class="form-control"
  id="ipt"
  v-model="ipt"
  @change="ipt=$event.currentTarget.value">`

clickselectイベント発火

候補ポップアップから直接クリック選択した時には、inputボックスにclickselectイベントが発火します。候補ポップアップから直接検索結果表示ページへ飛びたい場面などで利用できます。

ピュアJS

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

Vue.js

<input type="text"
  class="form-control"
  id="ipt"
  v-model="ipt"
  @change="ipt=$event.currentTarget.value"
  @clickselect="console.log('直接クリック選択されました!')">`

親要素のスタイルに注意

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>

更新履歴

  • 2019/10/25: 候補ポップアップを直接クリックして選択した時はclickselectイベントを発火させるようにしました。
  • 2019/09/30: ajax-suggest.js内での自動サジェストフェッチ処理を除去。プログラマが外部からサジェスト結果配列をセットできるようにしました。

以上

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