【JavaScript】Popover APIを使ったオートコンプリート機能の作り方 (後編)
後編
この記事は、Popover APIを使ったオートコンプリート機能の作り方 (前編)の続きです。
前編までで、単純なオートコンプリート機能が動くようになっています。
データは['text1', 'text2', 'text3', 'text4']
をセットしているので、3文字以上データにヒットすると表示されます。
後編では、次の機能を追加します。
- 選択中の候補のハイライト表示
- キーボード操作
実装
ハイライト機能の追加
現状では候補をマウスホバーしてもハイライトされません。
選択状態を分かりやすくするために、選択中の候補をハイライトされるようにします。
まず最初に、cssファイルを作成してハイライト中のcssクラスを用意しておきます。
.autocomplete-item-highlighted {
background-color: #f0f0f0;
}
HTMLでcssファイルを読み込んでおきます。
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Autocomplete</title>
+ <link rel="stylesheet" href="style.css">
次に、候補のそれぞれの要素にdatasetを用いてindex値を付与します。
export default function createResultElement(item, i) {
const resultElement = document.createElement('li')
+ resultElement.dataset.index = i
resultElement.textContent = item
return resultElement
}
続いて、ハイライトを付ける/消すメソッドをItemContainerクラスに追加します。
+ highlight(index) {
+ this.#unhighlight() // Clear previous highlight.
+
+ const target = this.#container.querySelector(`li:nth-child(${index + 1})`)
+
+ if (target) {
+ target.classList.add('autocomplete-item-highlighted')
+ }
+ }
#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`
})
}
+ #unhighlight() {
+ const target = this.#container.querySelector('.autocomplete-item-highlighted')
+
+ if (target) {
+ target.classList.remove('autocomplete-item-highlighted')
+ }
+ }
続いて、AutocompleteModelにハイライト中のindex値を管理するためのコードを追加していきます。
export default class AutocompleteModel {
#onTermChange
#onItemsChange
+ #onHighlightIndexChange
#termMinLength
#term = ''
#items = []
+ #highlightedIndex = -1
- constructor(onTermChange, onItemsChange, minLength) {
+ constructor(onTermChange, onItemsChange, onHighlightIndexChange, minLength) {
this.#onTermChange = onTermChange
this.#onItemsChange = onItemsChange
+ this.#onHighlightIndexChange = onHighlightIndexChange
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.clearHighlight()
this.#onItemsChange(this.#items)
}
+ get highlightedIndex() {
+ return this.#highlightedIndex
+ }
+
+ set highlightedIndex(value) {
+ this.#highlightedIndex = value
+ this.#onHighlightIndexChange(this.#highlightedIndex)
+ }
clearItems() {
this.items = []
}
+ clearHighlight() {
+ this.highlightedIndex = -1
+ }
}
最後に、Autocompleteクラスでモデルのコンストラクタにコールバック関数を渡し、マウスホバーのイベントを追加します。
constructor(inputElement, onSearch, onSelect) {
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),
+ (index) => this.#itemContainer.highlight(index),
minLength
)
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.value)
element.hidePopover()
})
+ this.#delegate(element, 'mouseover', 'li', ({ delegateTarget }) => {
+ this.#model.highlightedIndex = Number(delegateTarget.dataset.index)
+ })
+
+ this.#delegate(element, 'mouseout', 'li', () => {
+ this.#model.clearHighlight()
+ })
}
これでハイライトができるようになりました。
ハイライト機能の仕組み
ハイライト機能の仕組みを説明します。
まず、候補の要素をマウスホバーすると以下のイベントが発火し、モデルのhighlightedIndexプロパティに要素のdataset.index値が代入されます。
this.#delegate(element, 'mouseover', 'li', ({ delegateTarget }) => {
this.#model.highlightedIndex = Number(delegateTarget.dataset.index)
})
highlightedIndexプロパティに値が代入されると、以下のセッターが呼ばれます。
モデルのプロパティに値をセットし、onHighlightIndexChangeを呼び出します。
set highlightedIndex(value) {
this.#highlightedIndex = value
this.#onHighlightIndexChange(this.#highlightedIndex)
}
onHighlightIndexChangeには、次のコールバック関数を代入していました。
これによってItemContainer#highlightが呼ばれます。
(index) => this.#itemContainer.highlight(index)
highlightメソッドでは、受け取った引数とnth-childを用いて対象を探します。
候補をホバーしている場合候補が見つかるので、その候補に対してHTMLクラスを付与することでハイライトしています。
highlight(index) {
this.#unhighlight() // Clear previous highlight.
const target = this.#container.querySelector(`li:nth-child(${index + 1})`)
if (target) {
target.classList.add('autocomplete-item-highlighted')
}
}
ホバーを外した場合は次のイベントが発火します。
this.#delegate(element, 'mouseout', 'li', () => {
this.#model.clearHighlight()
})
ホバー時と同じようにItemContainer#highlightまで処理が流れますが、index値は-1で、nth-childは1から始まるのでtargetが見つからず、unhighlightメソッドのみ実行されてハイライトが切れるようになっています。
highlight(index) {
this.#unhighlight() // Clear previous highlight.
const target = this.#container.querySelector(`li:nth-child(${index + 1})`)
if (target) {
target.classList.add('autocomplete-item-highlighted')
}
}
キーボード操作を追加する
最後にキーボード操作機能を追加します。
実装する操作は次の4つです。
- 上下キーで候補を選択
- Tab/Shift+Tabで候補を選択
- エンターキーで決定
- エスケープキーで候補を閉じる
まず、AutocompleteModelに以下を追加します。
- データの状態を表すプロパティ
- ハイライトを前後に移動させるメソッド
export default class AutocompleteModel {
#onTermChange
#onItemsChange
#onHighlightIndexChange
#termMinLength
#term = ''
#items = []
#highlightedIndex = -1
constructor(onTermChange, onItemsChange, onHighlightIndexChange) {
this.#onTermChange = onTermChange
this.#onItemsChange = onItemsChange
this.#onHighlightIndexChange = onHighlightIndexChange
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()
}
}
+ get itemsCount() {
+ return this.#items.length
+ }
+
+ get hasItems() {
+ return this.#items.length > 0
+ }
+
+ get hasNoItems() {
+ return this.#items.length === 0
+ }
set items(value) {
this.#items = value
this.clearHighlight()
this.#onItemsChange(this.#items)
}
get highlightedIndex() {
return this.#highlightedIndex
}
set highlightedIndex(value) {
this.#highlightedIndex = value
this.#onHighlightIndexChange(this.#highlightedIndex)
}
clearItems() {
this.items = []
}
+ moveHighlightIndexPrevious() {
+ const isItemHighlighted = this.highlightedIndex >= 0
+
+ if (isItemHighlighted) {
+ this.highlightedIndex--
+ } else {
+ this.highlightedIndex = this.itemsCount - 1
+ }
+ }
+
+ moveHighlightIndexNext() {
+ const hasNextItem = this.highlightedIndex < this.itemsCount - 1
+
+ if (hasNextItem) {
+ this.highlightedIndex++
+ } else {
+ this.clearHighlight()
+ }
+ }
}
Autocompleteクラスにキー操作のイベントリスナーとロジックを追加します。
import ItemContainer from "./itemContainer.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),
(index) => this.#itemContainer.highlight(index),
minLength
)
- inputElement.addEventListener('input', (e) => this.#model.term = e.target.value)
+ this.#setEventHandlersToInput(inputElement)
this.#setEventHandlersToItemsContainer(this.#itemContainer.element)
}
+ #setEventHandlersToInput(element) {
+ element.addEventListener('input', ({ target }) => this.#model.term = target.value)
+ element.addEventListener('keydown', (event) => this.#handleKeydown(event))
+ element.addEventListener('keyup', (event) => this.#handleKeyup(event))
+ }
#setEventHandlersToItemsContainer(element) {
this.#delegate(element, 'mousedown', 'li', ({ delegateTarget }) => {
this.#onSelect(delegateTarget.textContent)
element.hidePopover()
})
this.#delegate(element, 'mouseover', 'li', ({ delegateTarget }) => {
this.#model.highlightedIndex = Number(delegateTarget.dataset.index)
})
this.#delegate(element, 'mouseout', 'li', () => {
this.#model.highlightedIndex = -1
})
}
#delegate(element, event, selector, callback) {
element.addEventListener(event, (e) => {
const delegateTarget = e.target.closest(selector)
if (delegateTarget) {
callback({ delegateTarget })
}
})
}
+ #handleKeydown(event) {
+ if (this.#model.hasNoItems) return
+
+ switch (event.key) {
+ case 'ArrowDown':
+ event.preventDefault()
+ this.#model.moveHighlightIndexNext()
+ break
+
+ case 'ArrowUp':
+ event.preventDefault()
+ this.#model.moveHighlightIndexPrevious()
+ break
+
+ case 'Tab':
+ event.preventDefault()
+ if (event.shiftKey) {
+ this.#model.moveHighlightIndexPrevious()
+ } else {
+ this.#model.moveHighlightIndexNext()
+ }
+ break
+
+ case 'Escape':
+ event.preventDefault()
+ this.#model.clearItems()
+ break
+ }
+ }
+
+ #handleKeyup(event) {
+ if (event.key === 'Enter' && this.#model.hasItems) {
+ event.stopPropagation()
+
+ const currentItem = document.querySelector('.autocomplete-item-highlighted')
+
+ if (currentItem) {
+ this.#onSelect(currentItem.textContent)
+ }
+
+ this.#model.clearItems()
+ }
+ }
}
これでキーボード操作ができるようになりました。
キーボード操作の仕組み
キーボード操作について説明します。
特にハイライト移動部分が少し複雑なので、その辺りを説明します。
キー操作が発生すると以下のイベントが発火します。
keyupイベントはEnterキーのみ処理が発生するようになっていて、他のキーはkeydownイベントで発火するようになっています。
#setEventHandlersToInput(element) {
element.addEventListener('input', ({ target }) => this.#model.term = target.value)
element.addEventListener('keydown', (event) => this.#handleKeydown(event))
element.addEventListener('keyup', (event) => this.#handleKeyup(event))
}
上下キーの↓が押されたとします。keydownイベントが発火して、handleKeydownメソッドに処理が移ります。
モデルに候補があれば、AutocompleteModel#moveHighlightIndexNext()を呼び出します。
#handleKeydown(event) {
if (this.#model.hasNoItems) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
this.#model.moveHighlightIndexNext()
break
moveHighlightIndexNextメソッドでは、次の候補がある場合はindex値をインクリメントし、それによってItemContainer#highlightまで処理が流れてハイライト状態を1つ下に移動します。
次の候補がなければ、-1を代入しています。-1を代入するとハイライトが消えるのでした。これによって、最後の候補がハイライトされている状態で↓キーを押すとハイライトが解除されるようになっています。
moveHighlightIndexNext() {
const hasNextItem = this.highlightedIndex < this.itemsCount - 1
if (hasNextItem) {
this.highlightedIndex++
} else {
this.clearHighlight()
}
}
上下キーの↑を押した場合はmoveHighlightIndexPreviousメソッドが呼び出されます。
こちらは先ほどとは逆で、候補がハイライトされている場合はデクリメントすることでハイライト状態を一つ上に移動します。
候補がハイライトされていない場合は、候補数から1引いた値をプロパティに代入します。何もハイライトされていない状態で↑キーを押すと、候補の一番下がハイライトされるようになっています。
moveHighlightIndexPrevious() {
const isItemHighlighted = this.highlightedIndex >= 0
if (isItemHighlighted) {
this.highlightedIndex--
} else {
this.highlightedIndex = this.itemsCount - 1
}
}
見た目を整える
見た目を整える場合は、候補のul要素とli要素にHTML classを設定します。
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.
+ this.#container.classList.add('autocomplete')
inputElement.parentElement.appendChild(this.#container)
window.addEventListener('resize', this.#moveUnderInputElement.bind(this))
}
export default function createResultElement(item, i) {
const resultElement = document.createElement('li')
+ resultElement.classList.add('autocomplete-item')
+
resultElement.dataset.index = i
resultElement.textContent = item
return resultElement
}
スタイルを当ててみます。
.autocomplete-input {
display: block;
margin: auto;
width: 300px;
padding: 10px;
margin-top: 20vh;
font-size: 16px;
font-family: Arial, sans-serif;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
outline: none;
color: #333;
background-color: #f9f9f9;
transition: border-color 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
}
.autocomplete-input:focus {
border-color: #0078d7;
box-shadow: 0 0 6px rgba(0, 120, 215, 0.5);
}
.autocomplete {
position: absolute;
top: 100%;
left: 0;
width: 300px;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-height: 300px;
overflow-y: auto;
padding: 0;
margin: 0;
list-style: none;
}
.autocomplete-item {
padding: 10px 15px;
font-size: 14px;
font-family: Arial, sans-serif;
color: #333;
cursor: pointer;
}
.autocomplete-item-highlighted {
background-color: #f0f0f0;
color: #000;
}
完成
全体のコード
index.html
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Autocomplete</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<input class="autocomplete-input">
<script type="module" src="main.js"></script>
</body>
</html>
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/index.js
import ItemContainer from "./itemContainer.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),
(index) => this.#itemContainer.highlight(index),
minLength
)
this.#setEventHandlersToInput(inputElement)
this.#setEventHandlersToItemsContainer(this.#itemContainer.element)
}
#setEventHandlersToInput(element) {
element.addEventListener('input', ({ target }) => this.#model.term = target.value)
element.addEventListener('keydown', (event) => this.#handleKeydown(event))
element.addEventListener('keyup', (event) => this.#handleKeyup(event))
}
#setEventHandlersToItemsContainer(element) {
this.#delegate(element, 'mousedown', 'li', ({ delegateTarget }) => {
this.#onSelect(delegateTarget.textContent)
element.hidePopover()
})
this.#delegate(element, 'mouseover', 'li', ({ delegateTarget }) => {
this.#model.highlightedIndex = Number(delegateTarget.dataset.index)
})
this.#delegate(element, 'mouseout', 'li', () => {
this.#model.clearHighlight()
})
}
#delegate(element, event, selector, callback) {
element.addEventListener(event, ({ target }) => {
const delegateTarget = target.closest(selector)
if (delegateTarget) {
callback({ delegateTarget })
}
})
}
#handleKeydown(event) {
if (this.#model.hasNoItems) return
switch (event.key) {
case 'ArrowDown':
this.#model.moveHighlightIndexNext()
break
case 'ArrowUp':
this.#model.moveHighlightIndexPrevious()
break
case 'Tab':
event.preventDefault()
if (event.shiftKey) {
this.#model.moveHighlightIndexPrevious()
} else {
this.#model.moveHighlightIndexNext()
}
break
case 'Escape':
event.preventDefault()
this.#model.clearItems()
break
}
}
#handleKeyup(event) {
if (event.key === 'Enter' && this.#model.hasItems) {
event.stopPropagation()
const currentItem = document.querySelector(
'.autocomplete-item-highlighted'
)
if (currentItem) {
this.#onSelect(currentItem.textContent)
}
this.#model.clearItems()
}
}
}
autocomplete/autocompleteModel.js
export default class AutocompleteModel {
#onTermChange
#onItemsChange
#onHighlightIndexChange
#termMinLength
#term = ''
#items = []
#highlightedIndex = -1
constructor(onTermChange, onItemsChange, onHighlightIndexChange, minLength) {
this.#onTermChange = onTermChange
this.#onItemsChange = onItemsChange
this.#onHighlightIndexChange = onHighlightIndexChange
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()
}
}
get itemsCount() {
return this.#items.length
}
get hasItems() {
return this.#items.length > 0
}
get hasNoItems() {
return this.#items.length === 0
}
set items(value) {
this.#items = value
this.clearHighlight()
this.#onItemsChange(this.#items)
}
get highlightedIndex() {
return this.#highlightedIndex
}
set highlightedIndex(value) {
this.#highlightedIndex = value
this.#onHighlightIndexChange(this.#highlightedIndex)
}
clearItems() {
this.items = []
}
clearHighlight() {
this.highlightedIndex = -1
}
moveHighlightIndexPrevious() {
const isItemHighlighted = this.highlightedIndex >= 0
if (isItemHighlighted) {
this.highlightedIndex--
} else {
this.highlightedIndex = this.itemsCount - 1
}
}
moveHighlightIndexNext() {
const hasNextItem = this.highlightedIndex < this.itemsCount - 1
if (hasNextItem) {
this.highlightedIndex++
} else {
this.clearHighlight()
}
}
}
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.classList.add('autocomplete')
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()
}
}
highlight(index) {
console.log(index)
this.#unhighlight() // Clear previous highlight.
const target = this.#container.querySelector(`li:nth-child(${index + 1})`)
if (target) {
target.classList.add('autocomplete-item-highlighted')
}
}
#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`
})
}
#unhighlight() {
const target = this.#container.querySelector('.autocomplete-item-highlighted')
if (target) {
target.classList.remove('autocomplete-item-highlighted')
}
}
}
autocomplete/createResultElement.js
export default function createResultElement(item, i) {
const resultElement = document.createElement('li')
resultElement.classList.add('autocomplete-item')
resultElement.dataset.index = i
resultElement.textContent = item
return resultElement
}
style.css
.autocomplete-input {
display: block;
margin: auto;
width: 300px;
padding: 10px;
margin-top: 20vh;
font-size: 16px;
font-family: Arial, sans-serif;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
outline: none;
color: #333;
background-color: #f9f9f9;
transition: border-color 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
}
.autocomplete-input:focus {
border-color: #0078d7;
box-shadow: 0 0 6px rgba(0, 120, 215, 0.5);
}
.autocomplete {
position: absolute;
top: 100%;
left: 0;
width: 300px;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-height: 300px;
overflow-y: auto;
padding: 0;
margin: 0;
list-style: none;
}
.autocomplete-item {
padding: 10px 15px;
font-size: 14px;
font-family: Arial, sans-serif;
color: #333;
cursor: pointer;
}
.autocomplete-item-highlighted {
background-color: #f0f0f0;
color: #000;
}
最後に
オートコンプリート機能の作り方を一から紹介しました。
コールバック関数を多用する設計になっているので、柔軟にカスタマイズが可能な実装になっていると思います。例えば検索(onSearch)や選択処理(onSelect)などです。
状況に合わせてカスタマイズしていただければと思います。
大変長くなりましたが、少しでも参考になったら幸いです。
株式会社ラグザイア(luxiar.com)の技術広報ブログです。 ラグザイアはRuby on RailsとC#に特化した町田の受託開発企業です。フルリモートでの開発を積極的に推進しており、全国からの参加を可能にしています。柔軟な働き方で最新のソフトウェアソリューションを提供します。
Discussion