Skip to content

Instantly share code, notes, and snippets.

@finist
Last active August 31, 2023 11:00
Show Gist options
  • Save finist/1437abb5dee3e2ea28f42ec56a1eda61 to your computer and use it in GitHub Desktop.
Save finist/1437abb5dee3e2ea28f42ec56a1eda61 to your computer and use it in GitHub Desktop.
Rails Stimulus autocomplete
<%# views/application/autocomplete/_field.html.erb %>
<div
data-controller="autocomplete"
data-autocomplete-multiple-value=true
data-autocomplete-search-path-value=<%= local_assigns[:search_path] %>
data-autocomplete-search-param-value=<%= local_assigns[:search_param] %>
data-autocomplete-selected-class="bg-n42-blue text-white"
data-action="autocomplete:click:outside->autocomplete#hide"
class="relative"
>
<%= text_field_tag local_assigns[:name], nil, class: "n42-input", data: { action: "input->autocomplete#search", autocomplete_target: "search" } %>
<div data-autocomplete-target="list" hidden class="absolute bg-white px-4 py-2 w-full mt-1 border border-gray-300"></div>
<div data-autocomplete-target="result">
<% local_assigns[:values].each do |value| %>
<%= render 'application/autocomplete/result', form: form, title: value[:name], value: value[:id] %>
<% end %>
</div>
<template data-autocomplete-target="listItemTemplate">
<div class="hover:bg-n42-blue hover:text-white p-1" data-autocomplete-target="listItem" data-action="click->autocomplete#select"></div>
</template>
<template data-autocomplete-target="resultItemTemplate">
<%= render 'application/autocomplete/result', form: form %>
</template>
</div>
<%# views/application/autocomplete/_result.html.erb %>
<div class="bg-blue-100 inline-flex items-center text-sm rounded mt-2 mr-1 overflow-hidden">
<span class="ml-2 mr-1 leading-relaxed truncate max-w-xs px-1"><%= local_assigns[:title] %></span>
<button class="w-6 h-8 inline-block align-middle text-gray-500 bg-blue-200 focus:outline-none" data-action="click->autocomplete#remove">
<svg class="w-6 h-6 fill-current mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M15.78 14.36a1 1 0 0 1-1.42 1.42l-2.82-2.83-2.83 2.83a1 1 0 1 1-1.42-1.42l2.83-2.82L7.3 8.7a1 1 0 0 1 1.42-1.42l2.83 2.83 2.82-2.83a1 1 0 0 1 1.42 1.42l-2.83 2.83 2.83 2.82z"></path>
</svg>
</button>
<%= form.hidden_field :tag_ids, multiple: true, value: local_assigns[:value] %>
</div>
import { Controller } from "@hotwired/stimulus"
import { useClickOutside } from 'stimulus-use'
export default class extends Controller {
static classes = ['selected']
static targets = [
'search',
'value',
'list',
'listItem',
'listItemTemplate',
'result',
'resultItemTemplate',
'resultItemName'
]
static values = {
searchPath: String,
searchParam: String,
multiple: Boolean,
result: Array
}
connect() {
useClickOutside(this)
this.index = 0
this.previousIndex = 0
this.searchTarget.addEventListener('keydown', event => {
if (this.listTarget.hidden) { return }
if (event.key == 'Enter') {
this.commit()
event.preventDefault()
}
if (event.key == 'Escape') {
this.hide()
event.preventDefault()
}
if (event.key == 'ArrowDown') {
this.index >= this.listItemTargets.length ? this.index = 0 : this.index += 1
this.navigate()
event.preventDefault()
}
if (event.key == 'ArrowUp' && !this.listTarget.hidden) {
this.index == 0 ? this.index = this.listItemTargets.length : this.index -= 1
this.navigate()
event.preventDefault()
}
})
}
search(event) {
if (event.target.value.length > 2) {
let params = new URLSearchParams()
params.append(this.searchParamValue, event.target.value)
fetch(`${this.searchPathValue}?${params}`, {})
.then((response) => response.json())
.then(data => {
this.listTarget.innerHTML = ''
data.forEach(item => {
const listItem = this.listItemTemplateTarget.content.cloneNode(true)
listItem.firstElementChild.dataset.autocompleteTarget = 'listItem'
listItem.firstElementChild.dataset.autocompleteValue = item.id
listItem.firstElementChild.innerHTML = item.title
this.listTarget.appendChild(listItem)
})
if (data.length > 0) {
this.listTarget.hidden = false
} else {
this.listTarget.hidden = true
}
})
} else {
this.listTarget.hidden = true
}
}
navigate() {
this.previousListItem = this.listItemTargets[this.previousIndex - 1]
this.listItem = this.listItemTargets[this.index - 1]
if (this.previousListItem) {
this.previousListItem.classList.remove(...this.selectedClasses)
}
if (this.listItem) {
this.listItem.classList.add(...this.selectedClasses)
}
this.previousIndex = this.index
}
commit() {
if (this.multipleValue) {
this.searchTarget.value = ''
const resultItem = this.resultItemTemplateTarget.content.cloneNode(true)
const resultValue = this.listItem.dataset.autocompleteValue
if (!this.resultTarget.querySelector('[data-autocomplete-id="autocomplete-id-' + resultValue + '"]')) {
resultItem.firstElementChild.firstElementChild.innerHTML = this.listItem.innerHTML
resultItem.firstElementChild.dataset.autocompleteId = 'autocomplete-id-' + this.listItem.dataset.autocompleteValue
resultItem.querySelector('input').value = this.listItem.dataset.autocompleteValue
this.resultTarget.appendChild(resultItem)
}
} else {
this.searchTarget.value = this.listItem.innerHTML
this.valueTarget.value = this.listItem.dataset.autocompleteValue
}
this.hide()
}
select(event) {
this.listItem = event.target
this.commit()
}
remove(event) {
event.currentTarget.parentElement.remove()
event.preventDefault()
}
hide() {
this.index = 0
this.previousIndex = 0
this.listTarget.hidden = true
}
}
<%= render 'application/autocomplete/field',
form: f,
name: :tags,
search_path: tags_path(format: :json),
search_param: 'q[title_cont]',
values: doc.tags.map { |tag| { id: tag.id, name: tag.title } }
%>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment