Skip to content

Instantly share code, notes, and snippets.

@ryanckulp
Forked from leastbad/README.md
Created November 12, 2023 22:26
Show Gist options
  • Save ryanckulp/a395c504df7083c30c963a16a33374e0 to your computer and use it in GitHub Desktop.
Save ryanckulp/a395c504df7083c30c963a16a33374e0 to your computer and use it in GitHub Desktop.
Choices.js Stimulus wrapper preview

Choices.js Stimulus wrapper

https://joshuajohnson.co.uk/Choices/

Soon, this will be published as an NPM package, but there's an absence of documentation right now. It supports almost all functions from the original library; soon it will support 100% of them.

This wrapper adds Ajax pre-fetch search. Happens if controller has a data-search-path attribute.

Stimulus controller targets use new v2 syntax. Controller attaches a reference to itself on the element so that you can access the internal state from external scripts.

Verified to support single and multi-select drop-downs.

You can pre-populate options into the datalist.

Full compatibility with Turbolinks 5/6 including page caching.

import { Controller } from 'stimulus'
import * as Choices from 'choices.js'
export default class extends Controller {
static targets = ['select', 'options']
initialize () {
this.element['choices'] = this
this.refresh = this.refresh.bind(this)
this.add = this.add.bind(this)
this.remove = this.remove.bind(this)
this.search = this.search.bind(this)
this.update = this.update.bind(this)
this.filter = this.filter.bind(this)
this.options = this.options.bind(this)
this.optionsReducer = this.optionsReducer.bind(this)
this.searchPath = this.element.dataset.searchPath
this.forceOption = this.element.dataset.forceOption || true
}
connect () {
setTimeout(this.setup.bind(this), 5)
}
setup () {
this.choices = new Choices(this.selectTarget, this.options())
this.input = this.element.querySelector('input')
this.refresh()
if (this.searchPath) this.input.addEventListener('input', this.search)
this.selectTarget.addEventListener('change', this.refresh)
this.selectTarget.addEventListener('addItem', this.add)
this.selectTarget.addEventListener('removeItem', this.remove)
}
disconnect () {
if (this.searchPath) this.input.removeEventListener('input', this.search)
this.selectTarget.removeEventListener('change', this.refresh)
this.selectTarget.removeEventListener('addItem', this.add)
this.selectTarget.removeEventListener('removeItem', this.remove)
try {
this.choices.destroy()
} catch {}
this.choices = undefined
}
refresh () {
this.choices.setChoices([], 'value', 'label', true)
if (this.hasOptionsTarget) {
;[...this.optionsTarget.children].forEach(this.append.bind(this))
}
}
append (option) {
if (
![...this.selectTarget.options].some(o => {
return o.label === option.label
})
)
this.choices.setChoices([option], 'value', 'label', false)
}
add (event) {
if (this.hasOptionsTarget) {
const option = [...this.optionsTarget.children].find(option => {
return option.label === event.detail.label
})
if (option) {
option.setAttribute('selected', '')
} else {
const newOption = document.createElement('option')
newOption.setAttribute('label', event.detail.label)
newOption.setAttribute('value', event.detail.value)
newOption.setAttribute('selected', '')
this.optionsTarget.appendChild(newOption)
}
}
}
remove (event) {
if (this.hasOptionsTarget) {
const option = [...this.optionsTarget.children].find(item => {
return item.label === event.detail.label
})
if (option)
this.searchPath ? option.remove() : option.removeAttribute('selected')
}
if (this.forceOption && !this.selectTarget.options.length)
this.selectTarget.add(document.createElement('option'))
}
search (event) {
if (event.target.value) {
fetch(this.searchPath + event.target.value, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(response => response.json())
.then(this.update)
} else {
this.refresh()
}
}
update (data) {
this.choices.setChoices(data.filter(this.filter), 'value', 'label', true)
}
filter (item) {
return ![...this.selectTarget.options].some(option => {
return option.label === item.label
})
}
options () {
return 'silent renderChoiceLimit maxItemCount addItems removeItems removeItemButton editItems duplicateItemsAllowed delimiter paste searchEnabled searchChoices searchFloor searchResultLimit position resetScrollPosition addItemFilter shouldSort shouldSortItems placeholder placeholderValue prependValue appendValue renderSelectedChoices loadingText noResultsText noChoicesText itemSelectText addItemText maxItemText'
.split(' ')
.reduce(this.optionsReducer, {})
}
optionsReducer (accumulator, currentValue) {
if (this.element.dataset[currentValue])
accumulator[currentValue] = this.element.dataset[currentValue]
return accumulator
}
}
class Event < ApplicationRecord
# gem "pg_search", "~> 2.3" # https://github.com/Casecommons/pg_search
include PgSearch::Model
pg_search_scope :stemmed, against: :name, using: {tsearch: {prefix: true}, trigram: {}}
def self.typeahead_search(term)
Event
.stemmed(term)
.map { |event| {value: event.id, label: event.name} }
end
end
class EventsController < ApplicationController
skip_before_action :verify_authenticity_token, only: [:search]
def search
render json: Event.typeahead_search(params[:name])
end
end
<div data-controller="choices" data-search-path="/events/search?name=" data-remove-item-button="true" data-duplicate-items-allowed="false" data-search-result-limit="100" data-no-choices-text="Start typing to search...">
<datalist data-choices-target="options"></datalist>
<select data-choices-target="select"></select>
</div>
Rails.application.routes.draw do
resources :events do
collection do
get "search", constraints: lambda { |request| request.xhr? }
end
end
end
<div data-controller="choices" data-search-path="/events/search?name=" data-remove-item-button="true" data-search-result-limit="100" data-no-choices-text="Start typing to search...">
<datalist data-choices-target="options"></datalist>
<%= f.collection_select :event_id, @events, :id, :name, {include_blank: true}, {data: {"choices-target": "select"}} %>
</div>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment