🔍

【JavaScript】Popover APIを使ったオートコンプリート機能の作り方 (前編)

2024/12/05に公開

先日、業務でライブラリを使わずにオートコンプリート機能(自動補完)を実装する機会がありました。

オートコンプリート機能を自作することは、カスタマイズ性の向上やライブラリ依存性を排除することができる等のメリットがあります。

自作でのオートコンプリート機能の実装を考えている方に少しでも参考になれば幸いです。

※設計自体は別の方が行い、実装を自分が担当したので、本記事は実装部分がメインの内容になります。

完成形

実装の前に、完成形を紹介します。
データは次の4つを設定していて、3文字以上合致すると候補が表示されます。
['text1', 'text2', 'text3', 'text4']

キー操作による選択・決定にも対応しています。(上下キー・Tab/Shift+Tab・Escape・Enterキー)

Popover API

今回の実装では、PopoverAPIを利用します。

Popover APIは、HTML要素にポップオーバー(ツールチップやモーダルのようなインタラクティブな要素)を簡単に追加できるようにする、ブラウザの組み込みAPIです。
PopoverAPIの最大のメリットは、HTML要素の最前面に表示する機能をブラウザがネイティブにサポートしている点です。

オートコンプリート機能によって表示される候補リストは、ほとんどの場合においてHTML要素の最前面に表示したいと思います。このサポートによって、従来の手動でのz-index管理や複雑なスタイル設定をすることなく、簡単にポップアップのような要素をユーザーの目につきやすい場所に表示できます。

実装

これから作成するファイルは次の7つになります。
オートコンプリート機能はautocompleteフォルダに作成していきます。

  • index.html
  • style.css
  • main.js
  • autocomplete/index.js (オートコンプリート機能の本体クラス)
  • autocomplete/autocompleteModel.js (モデルの役割を担うクラス)
  • autocomplete/itemContainer.js (候補のHTML要素を作るクラス)
  • autocomplete/createResultElement.js (itemContainerクラスで仕様する関数)

今回作成するオートコンプリート機能は次の形で初期化します。

Autocomplete.new(inputElement, onSearch, onSelect, minLength)

引数についての説明です。

  • inputElementは対象のinput要素です。
  • onSearchは検索周りの処理を行うコールバック関数です。次の引数を持ちます。
    • term 入力中の文字列(検索に使用)
    • onResult 検索結果を渡すコールバック関数
      • onResultコールバック関数は受け取った結果を利用してレンダリングを行う
  • onSelectは候補を選択したときに処理を行うコールバック関数です。
  • minLengthは候補の表示を開始する最小文字数です。
    • 例えば3と設定すれば、3文字以上の入力からオートコンプリート機能が動作します。
    • デフォルト値を設定するので、省略可能です。

下準備

オートコンプリート機能を実装する入力フィールドと、検索に使用するデータセットを定義します。

index.html
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Autocomplete</title>
</head>
<body>
  <input>

  <script type="module" src="main.js"></script>
</body>
</html>
main.js
const items = ['text1', 'text2', 'text3', 'text4']

Autocompleteクラスの基盤を作成

最初に、Autocompleteクラスの基盤を作成します。
autocompleteフォルダを作成し、index.jsに記述していきます。

autocomplete/index.js
export default class Autocomplete {
  #onSelect

  constructor(inputElement, onSearch, onSelect, minLength = 3) {
    this.#onSelect = onSelect
  }
}

minLengthのデフォルト値は3として設定しておきます。

minLength = 3

候補のHTML要素を作るクラスを作成

次に、候補のHTML要素を作るItemContainerクラスを作成します。

autocomplete/itemContainer.js
import createResultElement from './createResultElement.js'

export default class ItemContainer {
  #inputElement
  #container

  constructor(inputElement) {
    this.#inputElement = inputElement
    this.#container = document.createElement('ul')
    this.#container.setAttribute('popover', 'auto')
    this.#container.style.margin = 0 // Clear popover default style.
    inputElement.parentElement.appendChild(this.#container)

    window.addEventListener('resize', this.#moveUnderInputElement.bind(this))
  }

  get element() {
    return this.#container
  }

  set items(items) {
    if (items.length > 0) {
      this.#container.innerHTML = ''
      const elements = items.map(createResultElement)
      this.#container.append(...elements)
      this.#moveUnderInputElement()
      this.#container.showPopover()
    } else {
      this.#container.hidePopover()
    }
  }

  #moveUnderInputElement() {
    const rect = this.#inputElement.getBoundingClientRect()

    Object.assign(this.#container.style, {
      position: 'absolute',
      top: `${rect.bottom + window.scrollY}px`,
      left: `${rect.left + window.scrollX}px`
    })
  }
}

importしている関数です。

autocomplete/createResultElement.js
export default function createResultElement(item, i) {
  const resultElement = document.createElement('li')
  resultElement.textContent = item
  return resultElement
}

コンストラクタ内で、候補のHTML要素であるcontainerプロパティにpopover属性を付与しています。

autocomplete/itemContainer.js
this.#container.setAttribute('popover', 'auto')

また、以下のようにpopover要素のmargin値を0で初期化しています。
また、popover属性を持つ要素にはブラウザのデフォルトスタイルが適応されているので、以下のように初期化する必要があります。この処理を行わないと、後述のgetBoundingClientRectでの位置調整がうまく行かなくなります。

autocomplete/itemContainer.js
this.#container.style.margin = 0

候補の表示自体はitemsセッターが担っています。
items引数の数が1つ以上の場合はcreateResultElement関数によって候補のli要素を生成し、PopoverAPIのshowPopoverメソッドを用いてcontainerプロパティを最前面に表示します。
items引数が0の場合はPopoverAPIのhidePopoverメソッドを用いてcontainerプロパティを非表示にします。

autocomplete/itemContainer.js
  set items(items) {
    if (items.length > 0) {
      this.#container.innerHTML = ''
      const elements = items.map(createResultElement)
      this.#container.append(...elements)
      this.#moveUnderInputElement()
      this.#container.showPopover()
    } else {
      this.#container.hidePopover()
    }
  }

popover属性を持つ要素は最前面に表示されるため、CSSでの相対的な位置調整が難しいです。
候補をinput要素の下に表示するため、今回の実装ではgetBoundingClientRectメソッドを利用して位置調整を行っています。

  #moveUnderInputElement() {
    const rect = this.#inputElement.getBoundingClientRect()

    Object.assign(this.#container.style, {
      position: 'absolute',
      top: `${rect.bottom + window.scrollY}px`,
      left: `${rect.left + window.scrollX}px`
    })
  }

画面サイズの変更に対応するために、次のイベントリスナを設定しています。

 window.addEventListener('resize', this.#moveUnderInputElement.bind(this))

これでItemContainerが作成できたので、Autocompleteクラスで初期化します。

autocomplete/index.js
+import ItemContainer from "./itemsContainer.js"

export default class Autocomplete {
  #onSelect
+ #itemContainer

  constructor(inputElement, onSearch, onSelect, minLength = 3) {
    this.#onSelect = onSelect
+   this.#itemContainer = new ItemContainer(inputElement)
  }
}

モデルクラスを作成

次にAutocompleteModelクラスを作成します。
オートコンプリート機能のモデルの役割を持ちます。

また、今回の実装ではオブザーバーパターンを利用しています。
モデルはterm(入力値)とitems(候補)の状態を監視し、変更があった場合にビューの更新を行います。

autocomplete/autocompleteModel.js
export default class AutocompleteModel {
  #onTermChange
  #onItemsChange
  #termMinLength
  #term = ''
  #items = []

  constructor(onTermChange, onItemsChange, minLength) {
    this.#onTermChange = onTermChange
    this.#onItemsChange = onItemsChange
    this.#termMinLength = minLength
  }

  get term() {
    return this.#term
  }

  set term(value) {
    this.#term = value

    if (this.#term.length >= this.#termMinLength) {
      this.#onTermChange(this.#term)
    } else {
      this.clearItems()
    }
  }

  set items(value) {
    this.#items = value
    this.#onItemsChange(this.#items)
  }

  clearItems() {
    this.items = []
  }
}

AutocompleteModelはonTermChangeonItemsChangeminLength引数を持ちます。
onTermChangeは入力値が更新された時に処理を行うコールバック関数です。
onItemsChangeは候補が更新された時に処理を行うコールバック関数です。
minLengthは候補の表示を開始する最小文字数です。

AutocompleteModelクラスができたので、Autocompleteクラスで初期化します。

autocomplete/index.js
import ItemContainer from "./itemsContainer.js"
+import AutocompleteModel from "./autocompleteModel.js"

export default class Autocomplete {
  #onSelect
  #itemContainer
+ #model

  constructor(inputElement, onSearch, onSelect, minLength = 3) {
    this.#onSelect = onSelect
    this.#itemContainer = new ItemContainer(inputElement)

+   this.#model = new AutocompleteModel(
+     (term) => onSearch(term, (results) => (this.#model.items = results)),
+     (items) => (this.#itemContainer.items = items),
+     minLength
+   )
  }
}

入力イベントとクリックイベントを追加

続いて、Autocompleteクラスに入力イベントと候補のクリックイベントを追加していきます。

autocomplete/index.js
import ItemContainer from "./itemsContainer.js"
import AutocompleteModel from "./autocompleteModel.js"

export default class Autocomplete {
  #onSelect
  #itemContainer
  #model

  constructor(inputElement, onSearch, onSelect, minLength = 3) {
    this.#onSelect = onSelect
    this.#itemContainer = new ItemContainer(inputElement)

    this.#model = new AutocompleteModel(
      (term) => onSearch(term, (results) => (this.#model.items = results)),
      (items) => (this.#itemContainer.items = items)
    )

+   inputElement.addEventListener('input', ({ target }) => this.#model.term = target.value)
+   this.#setEventHandlersToItemsContainer(this.#itemContainer.element)
  }

+ #setEventHandlersToItemsContainer(element) {
+   this.#delegate(element, 'mousedown', 'li', ({ delegateTarget }) => {
+     this.#onSelect(delegateTarget.textContent)
+     element.hidePopover()
+   })
+ }
+
+ #delegate(element, event, selector, callback) {
+   element.addEventListener(event, ({ target }) => {
+     const delegateTarget = target.closest(selector)
+     if (delegateTarget) {
+       callback({ delegateTarget })
+     }
+   })
+ }
}

クリックイベントを候補のそれぞれの要素に適応させるために、delegateメソッドを定義しています。
これはdelegateライブラリでも代用できます。
リンク先のdelegateライブラリを使う場合は引数の順序が違うので注意が必要です。

レンダリングの仕組み

ここまでの実装で、レンダリングの仕組みがほぼできています。
流れを説明します。

まず、inputの値がきっかけになります。
ここで先ほど設定したinputイベントが発火し、モデルのtermプロパティに値を代入します。

autocomplete/index.js
inputElement.addEventListener('input', ({ target }) => this.#model.term = target.value)

モデルのtermプロパティに値を代入するとtermセッターが呼ばれます。
term文字数がtermMinLength以上の場合、onTermChangeコールバック関数を呼び出します。

autocomplete/autocompleteModel.js
set term(value) {
  this.#term = value

  if (this.#term.length >= this.#termMinLength) {
    this.#onTermChange(this.#term)
  } else {
    this.clearItems()
  }
}

AutocompleteModelのコンストラクタの第一引数がonTermChangeでした。onSearchコールバック関数を渡しています。
(onSearchは後ほど設定します)

autocomplete/index.js
(term) => onSearch(term, (results) => (this.#model.items = results)

onSearchはtermを用いた検索と、検索結果を用いて第二引数(onResult)を呼び出します。
(onSearchは後ほど設定します)

onResultによってモデルのitemsプロパティに値が代入されると、以下のセッターが呼び出されます。
itemsプロパティに結果をセットし、onItemsChangeコールバック関数を呼び出します。

autocomplete/autocompleteModel.js
  set items(value) {
    this.#items = value
    this.#onItemsChange(this.#items)
  }

onItemsChangeコールバック関数には、次の処理を渡していました。

autocomplete/index.js
(items) => (this.#itemContainer.items = items)

これによってItemContainerクラスのitemsセッターが呼び出され、候補が表示される仕組みなっています。

autocomplete/itemContainer.js
  set items(items) {
    if (items.length > 0) {
      this.#container.innerHTML = ''
      const elements = items.map(createResultElement)
      this.#container.append(...elements)
      this.#moveUnderInputElement()
      this.#container.showPopover()
    } else {
      this.#container.hidePopover()
    }
  }

Autocompleteクラスの初期化

オートコンプリート機能の大枠は実装できました。

ここで一度、初期化を行って動かしてみます。

main.js
+import Autocomplete from './autocomplete/index.js'
+
const items = ['text1', 'text2', 'text3', 'text4']
+const inputElement = document.querySelector('.autocomplete-input')
+
+new Autocomplete(
+  inputElement,
+  (term, onResult) => {
+    const results = items.filter((item) => item.includes(term))
+    onResult(results)
+  },
+  (value) => inputElement.value = value
+)

Autocompleteクラスの第一引数には、対象のinput要素を渡します。

const inputElement = document.querySelector('.autocomplete-input')

第二引数にはonSearchコールバック関数を渡します。
検索部分はシンプルにfilterincludesメソッドを使って実装しています。
部分一致検索になっていますが、前方一致検索にするなど好きなように変更可能です。

検索結果を用いてonResultコールバック関数を呼び出すようにします。

(term, onResult) => {
  const results = items.filter((item) => item.includes(term))
  onResult(results)
}

Autocompleteクラスの第三引数は、onSelectでした。
候補を選択したときに処理を行うコールバック関数を与えます。これによってinput要素に値が代入されます。

(value) => inputElement.value = value

第四引数としてminLengthを設定可能ですが、今回はデフォルト値を利用することにするので省略します。

以上でひとまず動くようになりました。
以下で試すことができます。
データは['text1', 'text2', 'text3', 'text4]ですので、3文字以上データにヒットすると表示されます。

後編に続く

後編では、以下の実装を行います。

  • ハイライト表示
  • キーボード操作
  • スタイルの調整
ラグザイア

Discussion