Skip to content

Instantly share code, notes, and snippets.

@leastbad
Created April 16, 2020 08:08
Show Gist options
  • Save leastbad/e6773a0c800e96ba7c76e342e4e40ef7 to your computer and use it in GitHub Desktop.
Save leastbad/e6773a0c800e96ba7c76e342e4e40ef7 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>
@meceo
Copy link

meceo commented Feb 28, 2023

It works great with stimulus, thank you.

I have introduced 2 minor changes:

  1. changed the setup not to activate EventListeners if searchPath is not defined.
  setup () {
    this.choices = new Choices(this.selectTarget, this.options())
    this.input = this.element.querySelector('input')
    if (this.searchPath) {
      // All that is only relevant if searchPath is set - I for instance don't need AJAX request in many cases.
      this.refresh()
      this.input.addEventListener('input', this.search)
      this.selectTarget.addEventListener('change', this.refresh)
      this.selectTarget.addEventListener('addItem', this.add)
      this.selectTarget.addEventListener('removeItem', this.remove)
    }
  }
  1. added allowHTML in options function to allow me the configuration to fix "Deprecation warning: allowHTML will default to false in a future release. To render HTML in Choices, you will need to set it to true. Setting allowHTML will suppress this message."

@meceo
Copy link

meceo commented Feb 28, 2023

just noticed that the same is required in disconnect:

  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)
    }
   ...
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment