Skip to content

Instantly share code, notes, and snippets.

@leastbad
Created April 16, 2020 08:08
Show Gist options
  • Select an option

  • Save leastbad/e6773a0c800e96ba7c76e342e4e40ef7 to your computer and use it in GitHub Desktop.

Select an option

Save leastbad/e6773a0c800e96ba7c76e342e4e40ef7 to your computer and use it in GitHub Desktop.

Revisions

  1. leastbad created this gist Apr 16, 2020.
    15 changes: 15 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,15 @@
    # 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.
    124 changes: 124 additions & 0 deletions choices_controller.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,124 @@
    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
    }
    }
    10 changes: 10 additions & 0 deletions event.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,10 @@
    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
    7 changes: 7 additions & 0 deletions events_controller.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,7 @@
    class EventsController < ApplicationController
    skip_before_action :verify_authenticity_token, only: [:search]

    def search
    render json: Event.typeahead_search(params[:name])
    end
    end
    4 changes: 4 additions & 0 deletions multi.html.erb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,4 @@
    <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>
    7 changes: 7 additions & 0 deletions routes.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,7 @@
    Rails.application.routes.draw do
    resources :events do
    collection do
    get "search", constraints: lambda { |request| request.xhr? }
    end
    end
    end
    4 changes: 4 additions & 0 deletions single.html.erb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,4 @@
    <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>