Tam Select
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
TamSelectInputintegration
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 the core module, Stimulus controller, Rails helpers, and remote-search concerns:
app/javascript/tam_select/tam_select.js
app/javascript/controllers/tam_select_controller.js
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
When Simple Form is present, it also installs app/inputs/tam_select_input.rb. Simple Form is optional; applications without it receive a short skip message and continue with standard Rails form helpers.
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.
Rails 8 importmap
The default Rails 8 importmap setup needs no manual JavaScript package installation. The generator adds this pin to config/importmap.rb:
pin "tam_select", to: "tam_select/tam_select.js"
The generated Stimulus controller imports TamSelect from "tam_select". Running the generator again updates an old pin when necessary and never adds a duplicate. Rails' normal pin_all_from "app/javascript/controllers", under: "controllers" line discovers the generated controller.
jsbundling-rails and esbuild
The same generated controller works with esbuild when the bare tam_select import is aliased to the generated core file. Add the alias flag to the existing build script in package.json:
{
"scripts": {
"build": "esbuild app/javascript/*.* --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets --alias:tam_select=./app/javascript/tam_select/tam_select.js"
}
}
This uses the local file installed by the generator. For non-Rails bundler use, install the npm package from GitHub and import its published package name instead:
npm install github:tamiru/tam_select
import TamSelect from "tam-select"
Add the generated JavaScript to Tailwind's source detection in app/assets/tailwind/application.css:
@import "tailwindcss";
@source "../../javascript/tam_select/**/*.js";
Upgrading
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.
Versions before this release generated a relative controller import. After upgrading, confirm the controller uses import TamSelect from "tam_select" and that the importmap pin or esbuild alias above is present.
Features
- Single and multiple selection
- Local search and user-created tags
- Remote JSON search with debouncing and incremental pagination
- Loading, empty, and error states
- Select2-style closed selection and open search states with clear active, selected, disabled, loading, empty, and error feedback
- 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
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";
Tam Select includes adaptive light and dark styles for every state. It follows a dark class on the document or any ancestor and also sets the appropriate native color-scheme:
document.documentElement.classList.toggle("dark")
For Tailwind CSS 4 class-based theming, define the variant in the application stylesheet if it is not already present:
@custom-variant dark (&:where(.dark, .dark *));
Standard Rails forms 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.
For multiple selection, Rails must receive an array field and the native select must include multiple: true:
<%= form.select :skill_ids,
options_from_collection_for_select(Skill.order(:name), :id, :name, form.object.skill_ids),
{},
multiple: true,
data: {
controller: "tam-select",
tam_select_options_value: {
searchable: true,
closeAfterSelect: false,
placeholder: "Add skills…"
}.to_json
} %>
The original <select> remains the source of truth. This preserves form names in nested forms, submitted values, prompts, required, validation metadata, and native change events.
Simple Form
When the simple_form gem is installed, the generator creates app/inputs/tam_select_input.rb. Use as: :tam_select with any Simple Form collection input. The input forwards Simple Form's prompt, selected values, multiple flag, error classes, ARIA attributes, and nested builder context to the native collection select.
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": "Hana Bekele", "detail": "Admission no. UG/1024/26", "meta": "Active", "image": "/avatars/hana.jpg" },
{ "value": "2", "label": "Afar", "detail": "Semera", "meta": "AF" }
],
"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,
detail: :description,
meta: :code,
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.
When pagination.has_more is true, scrolling near the bottom requests next_page. Earlier pages keep their order, duplicate values are collapsed, and a later response can update an existing item's label, metadata, image, or disabled state. A new query aborts the old request and never displays cached results from the previous query.
Remote items may include optional detail, meta, and image fields. Tam Select renders the primary label with the detail on a second line and an optional circular image on the left. The selected value keeps the same image, label, and detail, including an initial value rendered before remote search completes. meta remains a badge on the right.
This works well for program and student searches. For example, return the program name as label and admission as detail:
tam_select_remote(
model: Estudent::Program,
label: :name,
detail: ->(program) { program.admission.to_s },
search_by: %i[name],
scope: -> { Estudent::Program.includes(admission: %i[admission_type enrollment_type enrollment_mode]).order(:name) }
)
For student search, add the admission number and photo URL:
tam_select_remote(
model: Estudent::Student,
label: ->(student) { student.full_name },
detail: ->(student) { "Admission no. #{student.applicant.registration_number}" },
image: ->(student) { url_for(student.applicant.person.avatar) if student.applicant.person.avatar.attached? },
search_by: %i[id_number],
scope: -> { Estudent::Student.includes(applicant: { person: { avatar_attachment: :blob } }) }
)
For a local native select, provide the same values as option data attributes:
<option value="1" data-detail="Admission no. UG/1024/26" data-meta="Active" data-image="/avatars/hana.jpg">Hana Bekele</option>
The browser sends requests such as:
GET /regions/tam_select_options.json?q=addis&page=1
Accept: application/json
Test an endpoint independently with:
curl -H "Accept: application/json" \
"http://localhost:3000/regions/tam_select_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.
Creatable values
Set creatable: true for tag-style input. The Create action is part of the same keyboard list as normal results, so ArrowUp and ArrowDown can highlight it and Enter creates it.
<%= form.select :category_ids,
options_from_collection_for_select(Category.order(:name), :id, :name),
{},
multiple: true,
data: {
controller: "tam-select",
tam_select_options_value: {
creatable: true,
closeAfterSelect: false,
placeholder: "Add categories…"
}.to_json
} %>
Values and labels are compared with normalized, case-insensitive, accent-insensitive text before creation, preventing duplicate entries such as Café and cafe. A successful creation dispatches tam-select:create.
Keyboard and accessibility behavior
- ArrowDown and ArrowUp open the list, move through the rendered entries, and skip disabled options.
- Enter selects the highlighted option or activates the highlighted Create action.
- Escape closes the list and returns focus to the combobox; Tab closes without trapping focus.
- Backspace removes the last tag from a multiple select when the search input is empty.
- Searchable controls use an input combobox; non-searchable controls use a focusable button combobox.
- Rails labels, descriptions, required state, disabled state, and invalid state are mirrored from the native select.
Core JavaScript
For a generated Rails/importmap installation, use the pinned module name:
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.open()
instance.close()
instance.clear()
instance.refresh()
instance.destroy()
When consuming the npm package directly, import from "tam-select" instead.
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 |
placeholder |
Native prompt or Select… |
Closed-control placeholder |
searchPlaceholder |
Search… |
Search-input placeholder |
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 |
imageField |
image |
Remote item image URL key |
matcher |
null |
Optional (item, query) => boolean local matcher |
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:open", () => console.log("opened"))
select.addEventListener("tam-select:close", () => console.log("closed"))
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.
The supported public methods are open(), close(), setValue(value), clear(), refresh(), and destroy(). Read the current native-compatible selection through instance.value, and recover an existing instance with TamSelect.getInstance(select).
Development
bundle install
bundle exec rake test
npm test
npm run check
License
Tam Select is available under the MIT License.