the # Tam Select

MIT License Gem Version

Tam Select is an accessible, searchable select component for Ruby on Rails, built for Simple Form, Stimulus, Turbo, and Tailwind CSS. It keeps the native <select> as the source of truth, so Rails form submission, validation, selected values, and browser autofill continue to work.

Requirements

  • Ruby 3.2 or newer
  • Rails 8
  • Stimulus 3.2 or newer
  • Tailwind CSS 4
  • Simple Form when using the TamSelectInput integration

Rails installation

Add the gem directly from GitHub:

# Gemfile
gem "tam_select", github: "tamiru/tam_select"

Install the dependency and generate the Rails integration files:

bundle install
bin/rails generate tam_select:install

The generator installs:

app/javascript/tam_select/tam_select.js
app/javascript/controllers/tam_select_controller.js
app/inputs/tam_select_input.rb
app/controllers/concerns/tam_select_paginatable.rb
app/controllers/concerns/tam_select_remote.rb
app/controllers/tam_select_remote_controller.rb
app/helpers/tam_select_helper.rb

Commit these generated files with the Rails application. This makes customization straightforward and allows Tailwind CSS 4 to scan the component without resolving a Ruby gem directory at build time.

Add the generated JavaScript to Tailwind's source detection in app/assets/tailwind/application.css:

@import "tailwindcss";
@source "../../javascript/tam_select/**/*.js";

Updating

The Rails integration files are copied into your application, so updating the gem does not update them automatically. Commit local customizations first, then run:

bundle update tam_select
bin/rails generate tam_select:install

Rails prompts before replacing changed files. To replace every generated file without prompting, use:

bin/rails generate tam_select:install --force

Review git diff afterward because --force overwrites application-specific customizations.

Features

  • Single and multiple selection
  • Local search and user-created tags
  • Remote JSON search with debouncing and incremental pagination
  • Loading, empty, and error states
  • Keyboard navigation: arrows, Enter, Escape, Tab, and Backspace
  • Combobox/listbox ARIA semantics
  • Light and dark Tailwind themes
  • Rails 8, Turbo, Turbo Frames, Stimulus, and Simple Form integration
  • Public API and bubbling custom events
  • No jQuery, Tom Select, Select2, Preline, or Floating UI dependency

JavaScript installation

For a non-Rails application, install directly from GitHub:

npm install github:tamiru/tam_select

Tailwind CSS 4

Add the package source to app/assets/tailwind/application.css so Tailwind generates every class used by the JavaScript templates:

@import "tailwindcss";
@source "../../../node_modules/tam-select/src/**/*.js";
@source "../../../node_modules/tam-select/rails/**/*.js";

When the Rails generator is used, scan the generated component source:

@source "../../javascript/tam_select/**/*.js";

Rails and Stimulus

The install generator copies the Stimulus controller into your application, where Stimulus normally discovers it automatically. If controllers are registered manually:

import TamSelectController from "./tam_select_controller"
application.register("tam-select", TamSelectController)

Use a normal Rails select:

<%= form.select :region_id,
      options_from_collection_for_select(Region.order(:name), :id, :name, form.object.region_id),
      { prompt: "Select region" },
      data: {
        controller: "tam-select",
        tam_select_options_value: {
          searchable: true,
          placeholder: "Select region…"
        }.to_json
      } %>

Stimulus destroys generated markup when Turbo removes the select and recreates it on reconnection.

Simple Form

The install generator creates app/inputs/tam_select_input.rb. Use as: :tam_select with any Simple Form collection input.

Local collection

<%= form.input :region_id,
      as: :tam_select,
      collection: Region.order(:name),
      label_method: :name,
      value_method: :id,
      prompt: "Select region" %>

Remote collection

Keep the currently selected record in the initial collection so edit forms can display its label before the AJAX request completes:

<%= form.input :region_id,
      as: :tam_select,
      collection: [form.object.region].compact,
      label_method: :name,
      value_method: :id,
      prompt: "Select region",
      input_html: {
        tam_options: {
          remoteUrl: tam_select_options_regions_path(format: :json),
          minQueryLength: 1,
          debounce: 250
        }
      } %>

The concern and route required by this example are described in Add remote search to an existing controller.

Multiple selection

<%= form.input :skill_ids,
      as: :tam_select,
      collection: Skill.order(:name),
      label_method: :name,
      value_method: :id,
      input_html: {
        multiple: true,
        tam_options: {
          searchable: true,
          closeAfterSelect: false
        }
      } %>

Options are passed under input_html[:tam_options]. Common options include searchable, creatable, clearable, placeholder, searchPlaceholder, remoteUrl, minQueryLength, and debounce. See Main options for defaults.

Remote API contract

tam-select sends q and page query parameters and expects:

{
  "items": [
    { "value": "1", "label": "Addis Ababa" },
    { "value": "2", "label": "Afar" }
  ],
  "pagination": {
    "page": 1,
    "next_page": 2,
    "has_more": true
  }
}

The generator installs TamSelectPaginatable as a Rails response helper. A complete Region controller is available in examples/regions_controller.rb.

Secure remote endpoints exactly like other Rails JSON endpoints. Scope records by the current user's permissions and never trust a submitted value merely because it appeared in the dropdown.

Add remote search to an existing controller

Include TamSelectRemote and declare the allowed model and searchable fields. This adds the public tam_select_options action to the controller:

# app/controllers/regions_controller.rb
class RegionsController < ApplicationController
  include TamSelectRemote

  tam_select_remote(
    model: Region,
    label: :name,
    value: :id,
    search_by: %i[name code],
    scope: -> { Region.order(:name) },
    per_page: 20
  )
end

The optional scope lambda runs in the controller context, so it can use current_user, current_account, or an authorization policy.

# config/routes.rb
resources :regions do
  get :tam_select_options, on: :collection, defaults: { format: :json }
end

Point Simple Form to that collection action:

<%= form.input :region_id,
      as: :tam_select,
      collection: [form.object.region].compact,
      label_method: :name,
      value_method: :id,
      input_html: {
        tam_options: {
          remoteUrl: tam_select_options_regions_path(format: :json),
          minQueryLength: 1
        }
      } %>

Typing sends GET /regions/tam_select_options.json?q=addis&page=1 and receives the standard Tam Select JSON payload.

Generic remote controller

The installer generates TamSelectRemoteController. Create a small subclass for each allowed remote source:

# app/controllers/region_options_controller.rb
class RegionOptionsController < TamSelectRemoteController
  tam_select model: Region, label: :name, search_by: %i[name code]

  private

  # Override this method when records require authorization or tenant scoping.
  def tam_select_scope(config)
    .regions.order(:name)
  end
end

Expose the JSON endpoint and pass it to the input:

# config/routes.rb
get "region_options", to: "region_options#index", defaults: { format: :json }
<%= form.input :region_id,
      as: :tam_select,
      collection: [form.object.region].compact,
      label_method: :name,
      value_method: :id,
      input_html: {
        tam_options: {
          remoteUrl: region_options_path(format: :json),
          minQueryLength: 1
        }
      } %>

Do not accept a model name from request parameters. Declaring each source in a controller subclass prevents clients from querying arbitrary application models.

The browser sends requests such as:

GET /region_options.json?q=addis&page=1
Accept: application/json

Test an endpoint independently with:

curl -H "Accept: application/json" \
  "http://localhost:3000/region_options.json?q=addis&page=1"

A 406 Not Acceptable response means the route or controller rejected JSON. Keep defaults: { format: :json } on the route, use a .json URL, and ensure the controller does not restrict responses to HTML only.

Core JavaScript

import TamSelect from "tam-select"

const instance = new TamSelect(document.querySelector("#student_region_id"), {
  searchable: true,
  creatable: false,
  remoteUrl: "/regions",
  minQueryLength: 1
})

instance.setValue("2")
instance.clear()
instance.refresh()
instance.destroy()

Main options

Option Default Purpose
searchable true Enables text filtering
creatable false Allows typed values to become options
clearable true Displays the clear control
closeAfterSelect Single only Keeps multiple dropdowns open
remoteUrl null JSON search endpoint
queryParam q Remote search parameter
pageParam page Remote page parameter
debounce 250 Remote request delay in milliseconds
minQueryLength 0 Characters required before requesting
valueField value Remote item value key
labelField label Remote item label key
classes {} Overrides any Tailwind class group

Events

Listen on the original select. Every event bubbles:

select.addEventListener("tam-select:change", ({ detail }) => console.log(detail.value))
select.addEventListener("tam-select:load", ({ detail }) => console.log(detail.items))
select.addEventListener("tam-select:create", ({ detail }) => console.log(detail.item))
select.addEventListener("tam-select:error", ({ detail }) => console.error(detail.error))

Standard native change events are also dispatched for Rails and other controllers.

Development

bundle install
bundle exec rake test
npm test
npm run check

License

Tam Select is available under the MIT License.