Project

tam_select

0.0
The project is in a healthy, maintained state
Tam Select provides searchable, multi-select, remote-data, and user-created option support for Ruby on Rails applications using Simple Form, Stimulus, Turbo, and Tailwind CSS.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 8.0, < 9.0
 Project Readme

tam-select

npm version MIT License CI

Accessible, searchable select component. Keeps the native <select> as the source of truth — form submission, validation, and autofill continue to work. Zero dependencies.

Live Playground · Rails Guide · API Reference


Install

npm install tam-select
# or
bun add tam-select

Quick Start

<select id="my-select">
  <option value="">Choose a fruit…</option>
  <option value="apple">Apple</option>
  <option value="banana">Banana</option>
  <option value="cherry">Cherry</option>
</select>

<script type="module">
  import TamSelect from "tam-select"

  new TamSelect(document.getElementById("my-select"), {
    searchable: true,
    clearable: true,
    placeholder: "Choose a fruit…"
  })
</script>

Features

  • Single and multi-select with inline tags
  • Filter-aware bulk selection, live selection counts, and optional hidden selections
  • Ranked fuzzy search with highlighted matches
  • Remote API search with debouncing and pagination
  • Option groups with section headers
  • Smooth dropdown animations (11 built-in presets)
  • Virtual scrolling for 1000+ items
  • RTL support (Arabic, Hebrew, mixed direction)
  • Custom transports for GraphQL and authenticated APIs
  • Keyboard navigation: arrows, Enter, Escape, Tab, Home, End, Ctrl+A
  • Combobox ARIA semantics
  • Light and dark themes
  • Customizable via Tailwind classes
  • No jQuery, Tom Select, Select2, or Floating UI dependency

Theming

Tam Select ships with two built-in themes:

Theme Style
"default" Zinc palette, blue focus, subtle hover
"select2" Gray palette, blue hover fill, bordered tags
new TamSelect(select, { theme: "select2" })

Custom themes

Override any class group via the classes option:

new TamSelect(select, {
  classes: {
    control: "h-10 rounded-lg border-2 border-indigo-500 bg-white",
    option: "px-4 py-3 hover:bg-indigo-50"
  }
})

Dark mode

Tam Select follows the dark class on the document:

document.documentElement.classList.toggle("dark")

Remote Search

new TamSelect(select, {
  remoteUrl: "/api/users",
  queryParam: "q",
  minQueryLength: 2,
  debounce: 300,
  processResults: (data) => ({
    items: data.users.map(u => ({
      value: u.id,
      label: u.name,
      detail: u.email,
      meta: u.role
    })),
    pagination: { has_more: data.total > data.page * 20 }
  })
})

Custom transport

new TamSelect(select, {
  transport: async ({ url, query, signal }) => {
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${token}` },
      signal
    })
    return res.json()
  },
  processResults: (data) => ({
    items: data.nodes.map(n => ({ value: n.id, label: n.name })),
    pagination: { has_more: data.pageInfo.hasNextPage }
  })
})

Virtual Scroll

For large datasets, enable virtual scrolling to render only visible options:

new TamSelect(select, {
  virtualScroll: true,
  virtualItemHeight: 32
})

Auto-enables when visible items exceed virtualScrollThreshold (default: 100).

Advanced Multi-select

new TamSelect(select, {
  bulkActions: true,
  showSelectionCount: true,
  hideSelected: true,
  maximumSelectionLength: 10
})

The dropdown displays count-aware Select all and Clear actions with icons, a Ctrl/⌘-A shortcut hint, and a live selected-count badge (for example, 3 / 10 selected). Actions apply to the current search, preserve its text, skip disabled options and groups, and respect cancelable selecting / unselecting events. Selections outside the search stay unchanged. With hideSelected, Clear still includes matching selected items that are hidden from the results. Empty group headings disappear automatically.

Remote actions affect only loaded results; they never fetch or select unseen pages. Virtual scrolling selects all loaded matches, including rows outside the rendered viewport. Each bulk action emits one native change, one tam-select:change, per-item completion events, and a tam-select:bulk-change event with { action, items, value }. No-op actions emit nothing.

Tab moves from the search field to the action buttons; Shift+Tab returns to search and Escape closes the dropdown. Search remains available when the selection limit is reached. Composition-based keyboards can finish entering text before filtering or creating tags.

Localize the toolbar through language.selectAll, language.deselectAll, and language.selectionCount (a string or (count, limit) => string). Override its appearance with classes.selectionToolbar, classes.selectionAction, and classes.selectionCount. These multi-select UI features are opt-in and do not change single-select controls.

Animations

Choose a preset:

new TamSelect(select, { animationPreset: "spring" })
Preset Duration Feel
"default" 150ms Standard
"material" 250ms Material Design
"spring" 400ms Bouncy overshoot
"bounce" 500ms Overshoot bounce
"elastic" 600ms Wide spring
"snappy" 100ms Fast, responsive
"smooth" 300ms Gentle ease
"fade" 200ms Simple opacity
"pop" 200ms Quick pop-in
"slide" 250ms Smooth deceleration
"none" 0ms Instant

Options

Option Default Description
searchable true Enable text filtering
creatable false Allow typed values to become options
clearable true Show clear button (single selects need an empty value="" option)
closeAfterSelect true (single) Close dropdown after selection
placeholder "Select…" Placeholder text
remoteUrl null Remote search endpoint
minQueryLength 0 Characters before remote search
debounce 250 Remote request delay (ms)
maximumSelectionLength 0 Max selectable items (0 = unlimited)
bulkActions false Show count-aware Select all / Clear actions on multi-selects
showSelectionCount false Show live selected count and configured limit
hideSelected false Hide selected results from the option list (single or multiple)
theme "auto" "default", "select2", or "auto"
classes {} Override any Tailwind class group
animationPreset null Built-in animation preset
virtualScroll false Enable virtual scrolling
virtualItemHeight 32 Row height for virtual scroll
wrapOptionText automatic Wrap ordinary result labels/details; set true to wrap even large lists (disables fixed-height virtualization), or false to truncate
optionTooltip true Show full option text in native hover tooltips
fuzzySearch true Tolerate typing mistakes
highlightMatches true Highlight search matches
sortByRelevance true Rank strong matches first
dropdownParent null Portal target used to escape clipped containers
width "resolve" "resolve" fills the container (or uses the native select's inline style="width: …"); "auto" sizes the control to its widest option like a native select; or any CSS width
dropdownWidth "auto" "auto" is at least the control's width and grows to fit the widest option, like a native select; "trigger" always matches the control ("content" aliases "auto")
maxDropdownWidth 480 Widest an "auto" menu grows (px) before long options wrap; never narrower than the control, and always kept inside the viewport
maxDropdownHeight 360 Maximum menu height before results scroll
viewportPadding 8 Space kept between a portaled menu and viewport edges

API

const instance = new TamSelect(select, options)

instance.open()              // Open dropdown
instance.close()             // Close dropdown
instance.focus()             // Focus search input
instance.blur()              // Remove focus
instance.setValue("apple")   // Set selected value
instance.clear()             // Clear all selections
instance.selectAll()         // Select loaded matches within the selection limit
instance.deselectAll()       // Deselect loaded matches, preserving other selections
instance.refresh()           // Re-read native options
instance.addData({ value: "new", label: "New" })  // Add option
instance.removeData("old")   // Remove option
instance.clearRemoteCache()  // Clear cached responses
instance.destroy()           // Remove all generated markup

// Read current value
instance.value  // "apple" or ["apple", "banana"]

// Get existing instance
TamSelect.getInstance(select)

Events

All events bubble on the original <select>:

select.addEventListener("tam-select:change", (e) => console.log(e.detail.value))
select.addEventListener("tam-select:open", () => console.log("opened"))
select.addEventListener("tam-select:close", () => console.log("closed"))
select.addEventListener("tam-select:select", (e) => console.log(e.detail.item))
select.addEventListener("tam-select:create", (e) => console.log(e.detail.item))
select.addEventListener("tam-select:error", (e) => console.error(e.detail.error))
select.addEventListener("tam-select:bulk-change", (e) => console.log(e.detail.action, e.detail.items))

Cancelable: opening, closing, selecting, unselecting, clearing, creating.


Rails Integration

Installation

# Gemfile
gem "tam_select", github: "tamiru/tam_select"
bundle install
bin/rails generate tam_select:install

With importmap, the gem serves everything itself: the JavaScript, the tam-select Stimulus controller, TamSelectHelper, the Simple Form input (when Simple Form is installed), and the TamSelectPaginatable and TamSelectRemote concerns. The generator adds one line to app/assets/tailwind/application.css so Tailwind (tailwindcss-rails 4.4+) finds the classes Tam Select renders:

@import "tailwindcss";
@import "../builds/tailwind/tam_select";

With jsbundling (esbuild and similar), the generator adds the tam-select npm package to package.json and a one-line controller that re-exports it. Run your package manager's install, then bin/rails stimulus:manifest:update if you use a controllers manifest. For Tailwind, add an @source for node_modules/tam-select/src to your CSS entry point.

Every select as a Tam Select

The generated config/initializers/tam_select.rb enables:

config.tam_select.default_select = true

With it, every select is a Tam Select without changing views: Simple Form's f.input :region, collection: ... and as: :select, and Rails' select_tag, f.select, collection_select and grouped_collection_select. Single selects get a blank option (so they can be cleared and show their placeholder) unless you pass include_blank or prompt, or the choices already contain one. Keep a field native with data: { tam_select: false } (for Simple Form: input_html: { data: { tam_select: false } }).

Updating

bundle update tam_select                            # importmap
bundle update tam_select && npm update tam-select   # jsbundling

Nothing is copied into your app, so there is nothing else to update. To customize a piece, add a file with the same path to your app (for example app/helpers/tam_select_helper.rb or app/javascript/controllers/tam_select_controller.js); the app's file wins over the gem's.

Upgrading from 1.3 or earlier

Earlier versions copied every file into the app. Run this once:

bundle update tam_select
bin/rails generate tam_select:update

It removes each copied file you never changed, and the old tam_select pin or esbuild alias, so the gem's versions load instead. Files you changed are kept and listed; they keep overriding the gem's version until you delete them.

Usage

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

Simple Form

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

Remote search

class RegionsController < ApplicationController
  include TamSelectRemote

  tam_select_remote(
    model: Region,
    label: :name,
    detail: :description,
    search_by: %i[name code],
    scope: -> { Region.order(:name) }
  )
end
<%= form.input :region_id,
      as: :tam_select,
      collection: [form.object.region].compact,
      input_html: {
        tam_options: {
          remoteUrl: tam_select_options_regions_path(format: :json),
          minQueryLength: 1
        }
      } %>

CI/CD

GitHub Actions

The CI workflow validates Ruby, JavaScript, TypeScript declarations, and npm package contents on every push.

Publishing

A matching v*.*.* tag publishes both the gem and npm package.

Required secrets in the release environment:

Secret Description
RUBYGEMS_API_KEY Push-only key scoped to tam_select
NPM_TOKEN Automation token with publish access to tam-select
# Release
git tag v1.3.1
git push origin v1.3.1

Local development

bundle install
bundle exec rake test    # Ruby tests
npm test                  # JavaScript tests
npm run check             # Syntax check
npm run typecheck         # TypeScript declarations

License

MIT

Compact sizing and long option text

Controls use a 36px minimum height; simple dropdown options use 32px rows with 14px regular text and 6px vertical padding. Descriptions use 12px text. Long labels and descriptions wrap by default, and rich options grow to fit their content. For large virtualized lists, set wrapOptionText: true to show every line:

new TamSelect(select, { wrapOptionText: true })

Wrapping applies to dropdown labels and details in both themes, including long words. Selected values stay on one line. Fixed-height virtual scrolling is disabled when wrapping is enabled. Set wrapOptionText: false to keep every result on one line.