tam_select 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6cf23ba08878f41cb17355d5ce629542fc3bab8e61756960403223d2abde898c
4
+ data.tar.gz: 9257189f40b467cfdf80106c5c2c30ce3430f10395c8bb122b7c6ae09ee6a3c6
5
+ SHA512:
6
+ metadata.gz: 8c2ac3a785840329c378213fa5fdf9499756c44adc64788c6170ac5fc8f8a2de2c18eb7bd8949dbf6cd9792894ef7b5ca75e5b1135066d31052de62598e67510
7
+ data.tar.gz: 326c67bbe68fc22799b6a24993bd378ebb06650772d829ffdba775fe2f119997df09a66bb63c99b1661cc85e21b3e29a3f3662b2b71fa57789fb997fc126e368
data/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tam-select contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
data/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # Tam Select
2
+
3
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
+
5
+ **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.
6
+
7
+ ## Requirements
8
+
9
+ - Ruby 3.2 or newer
10
+ - Rails 8
11
+ - Stimulus 3.2 or newer
12
+ - Tailwind CSS 4
13
+ - Simple Form when using the `TamSelectInput` integration
14
+
15
+ ## Rails installation
16
+
17
+ Add the gem directly from GitHub:
18
+
19
+ ```ruby
20
+ # Gemfile
21
+ gem "tam_select", github: "tamiru/tam_select"
22
+ ```
23
+
24
+ Install the dependency and generate the Rails integration files:
25
+
26
+ ```bash
27
+ bundle install
28
+ bin/rails generate tam_select:install
29
+ ```
30
+
31
+ The generator installs:
32
+
33
+ ```text
34
+ app/javascript/tam_select/tam_select.js
35
+ app/javascript/controllers/tam_select_controller.js
36
+ app/inputs/tam_select_input.rb
37
+ app/controllers/concerns/tam_select_paginatable.rb
38
+ app/helpers/tam_select_helper.rb
39
+ ```
40
+
41
+ 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.
42
+
43
+ Add the generated JavaScript to Tailwind's source detection in `app/assets/tailwind/application.css`:
44
+
45
+ ```css
46
+ @import "tailwindcss";
47
+ @source "../../javascript/tam_select/**/*.js";
48
+ ```
49
+
50
+ ## Features
51
+
52
+ - Single and multiple selection
53
+ - Local search and user-created tags
54
+ - Remote JSON search with debouncing and incremental pagination
55
+ - Loading, empty, and error states
56
+ - Keyboard navigation: arrows, Enter, Escape, Tab, and Backspace
57
+ - Combobox/listbox ARIA semantics
58
+ - Light and dark Tailwind themes
59
+ - Rails 8, Turbo, Turbo Frames, Stimulus, and Simple Form integration
60
+ - Public API and bubbling custom events
61
+ - No jQuery, Tom Select, Select2, Preline, or Floating UI dependency
62
+
63
+ ## JavaScript installation
64
+
65
+ For a non-Rails application, install directly from GitHub:
66
+
67
+ ```bash
68
+ npm install github:tamiru/tam_select
69
+ ```
70
+
71
+ ## Tailwind CSS 4
72
+
73
+ Add the package source to `app/assets/tailwind/application.css` so Tailwind generates every class used by the JavaScript templates:
74
+
75
+ ```css
76
+ @import "tailwindcss";
77
+ @source "../../../node_modules/tam-select/src/**/*.js";
78
+ @source "../../../node_modules/tam-select/rails/**/*.js";
79
+ ```
80
+
81
+ When the Rails generator is used, scan the generated component source:
82
+
83
+ ```css
84
+ @source "../../javascript/tam_select/**/*.js";
85
+ ```
86
+
87
+ ## Rails and Stimulus
88
+
89
+ The install generator copies the Stimulus controller into your application, where Stimulus normally discovers it automatically. If controllers are registered manually:
90
+
91
+ ```js
92
+ import TamSelectController from "./tam_select_controller"
93
+ application.register("tam-select", TamSelectController)
94
+ ```
95
+
96
+ Use a normal Rails select:
97
+
98
+ ```erb
99
+ <%= form.select :region_id,
100
+ options_from_collection_for_select(Region.order(:name), :id, :name, form.object.region_id),
101
+ { prompt: "Select region" },
102
+ data: {
103
+ controller: "tam-select",
104
+ tam_select_options_value: {
105
+ searchable: true,
106
+ placeholder: "Select region…"
107
+ }.to_json
108
+ } %>
109
+ ```
110
+
111
+ Stimulus destroys generated markup when Turbo removes the select and recreates it on reconnection.
112
+
113
+ ## Simple Form
114
+
115
+ The install generator creates `app/inputs/tam_select_input.rb`. Use it like any other Simple Form input:
116
+
117
+ ```erb
118
+ <%= form.input :region_id,
119
+ as: :tam_select,
120
+ collection: Region.order(:name),
121
+ label_method: :name,
122
+ value_method: :id,
123
+ prompt: "Select region",
124
+ input_html: {
125
+ tam_options: {
126
+ remoteUrl: regions_path,
127
+ minQueryLength: 1
128
+ }
129
+ } %>
130
+ ```
131
+
132
+ For a many-to-many field, add `multiple: true` to `input_html`.
133
+
134
+ ## Remote API contract
135
+
136
+ `tam-select` sends `q` and `page` query parameters and expects:
137
+
138
+ ```json
139
+ {
140
+ "items": [
141
+ { "value": "1", "label": "Addis Ababa" },
142
+ { "value": "2", "label": "Afar" }
143
+ ],
144
+ "pagination": {
145
+ "page": 1,
146
+ "next_page": 2,
147
+ "has_more": true
148
+ }
149
+ }
150
+ ```
151
+
152
+ The generator installs `TamSelectPaginatable` as a Rails response helper. A complete Region controller is available in [`examples/regions_controller.rb`](examples/regions_controller.rb).
153
+
154
+ 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.
155
+
156
+ ## Core JavaScript
157
+
158
+ ```js
159
+ import TamSelect from "tam-select"
160
+
161
+ const instance = new TamSelect(document.querySelector("#student_region_id"), {
162
+ searchable: true,
163
+ creatable: false,
164
+ remoteUrl: "/regions",
165
+ minQueryLength: 1
166
+ })
167
+
168
+ instance.setValue("2")
169
+ instance.clear()
170
+ instance.refresh()
171
+ instance.destroy()
172
+ ```
173
+
174
+ ## Main options
175
+
176
+ | Option | Default | Purpose |
177
+ |---|---:|---|
178
+ | `searchable` | `true` | Enables text filtering |
179
+ | `creatable` | `false` | Allows typed values to become options |
180
+ | `clearable` | `true` | Displays the clear control |
181
+ | `closeAfterSelect` | Single only | Keeps multiple dropdowns open |
182
+ | `remoteUrl` | `null` | JSON search endpoint |
183
+ | `queryParam` | `q` | Remote search parameter |
184
+ | `pageParam` | `page` | Remote page parameter |
185
+ | `debounce` | `250` | Remote request delay in milliseconds |
186
+ | `minQueryLength` | `0` | Characters required before requesting |
187
+ | `valueField` | `value` | Remote item value key |
188
+ | `labelField` | `label` | Remote item label key |
189
+ | `classes` | `{}` | Overrides any Tailwind class group |
190
+
191
+ ## Events
192
+
193
+ Listen on the original select. Every event bubbles:
194
+
195
+ ```js
196
+ select.addEventListener("tam-select:change", ({ detail }) => console.log(detail.value))
197
+ select.addEventListener("tam-select:load", ({ detail }) => console.log(detail.items))
198
+ select.addEventListener("tam-select:create", ({ detail }) => console.log(detail.item))
199
+ select.addEventListener("tam-select:error", ({ detail }) => console.error(detail.error))
200
+ ```
201
+
202
+ Standard native `change` events are also dispatched for Rails and other controllers.
203
+
204
+ ## Development
205
+
206
+ ```bash
207
+ bundle install
208
+ bundle exec rake test
209
+ npm test
210
+ npm run check
211
+ ```
212
+
213
+ ## License
214
+
215
+ Tam Select is available under the [MIT License](LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ begin
4
+ require "rake/testtask"
5
+ Rake::TestTask.new
6
+ rescue LoadError
7
+ # The gem has no Ruby runtime tests yet.
8
+ end
@@ -0,0 +1,30 @@
1
+ <%= simple_form_for @student do |form| %>
2
+ <%= form.input :region_id,
3
+ as: :tam_select,
4
+ collection: Region.order(:name),
5
+ label_method: :name,
6
+ value_method: :id,
7
+ prompt: "Select region",
8
+ input_html: {
9
+ tam_options: {
10
+ remoteUrl: regions_path,
11
+ minQueryLength: 1,
12
+ placeholder: "Select region…",
13
+ searchPlaceholder: "Search regions…"
14
+ }
15
+ } %>
16
+
17
+ <%= form.input :skill_ids,
18
+ as: :tam_select,
19
+ collection: Skill.order(:name),
20
+ label_method: :name,
21
+ value_method: :id,
22
+ input_html: {
23
+ multiple: true,
24
+ tam_options: {
25
+ creatable: true,
26
+ closeAfterSelect: false,
27
+ placeholder: "Add skills…"
28
+ }
29
+ } %>
30
+ <% end %>
@@ -0,0 +1,10 @@
1
+ class RegionsController < ApplicationController
2
+ include TamSelectPaginatable
3
+
4
+ def index
5
+ query = params[:q].to_s.strip
6
+ regions = Region.order(:name)
7
+ regions = regions.where("name LIKE ?", "%#{Region.sanitize_sql_like(query)}%") if query.present?
8
+ render json: tam_select_payload(regions, label: :name)
9
+ end
10
+ end
@@ -0,0 +1,35 @@
1
+ require "rails/generators"
2
+
3
+ module TamSelect
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("../../..", __dir__)
7
+
8
+ desc "Install tam_select JavaScript, Stimulus, Simple Form and Rails integration"
9
+
10
+ def copy_javascript
11
+ copy_file "src/tam-select.js", "app/javascript/tam_select/tam_select.js"
12
+ copy_file "lib/generators/tam_select/templates/tam_select_controller.js", "app/javascript/controllers/tam_select_controller.js"
13
+ end
14
+
15
+ def copy_simple_form_input
16
+ copy_file "lib/generators/tam_select/templates/tam_select_input.rb", "app/inputs/tam_select_input.rb"
17
+ end
18
+
19
+ def copy_controller_concern
20
+ copy_file "lib/generators/tam_select/templates/tam_select_paginatable.rb", "app/controllers/concerns/tam_select_paginatable.rb"
21
+ end
22
+
23
+ def copy_helper
24
+ copy_file "lib/generators/tam_select/templates/tam_select_helper.rb", "app/helpers/tam_select_helper.rb"
25
+ end
26
+
27
+ def show_tailwind_instruction
28
+ say "\ntam_select installed.", :green
29
+ say "Tailwind CSS 4 scans app/javascript automatically in most Rails setups."
30
+ say "If your setup uses explicit sources, add:"
31
+ say '@source "../../javascript/tam_select/**/*.js";'
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,26 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import TamSelect from "../tam_select/tam_select"
3
+
4
+ export default class extends Controller {
5
+ static values = {
6
+ options: { type: Object, default: {} }
7
+ }
8
+
9
+ connect() {
10
+ this.instance = TamSelect.getInstance(this.element) || new TamSelect(this.element, this.optionsValue)
11
+ }
12
+
13
+ disconnect() {
14
+ this.instance?.destroy()
15
+ this.instance = null
16
+ }
17
+
18
+ optionsValueChanged() {
19
+ if (!this.instance) return
20
+ this.instance.destroy()
21
+ this.instance = new TamSelect(this.element, this.optionsValue)
22
+ }
23
+
24
+ refresh() { this.instance?.refresh() }
25
+ clear() { this.instance?.clear() }
26
+ }
@@ -0,0 +1,17 @@
1
+ module TamSelectHelper
2
+ def tam_select_tag(name, option_tags = nil, options: {}, html_options: {}, &block)
3
+ data = (html_options[:data] ||= {})
4
+ data[:controller] = [data[:controller], "tam-select"].compact.join(" ")
5
+ data[:tam_select_options_value] = options.to_json
6
+ select_tag(name, option_tags, html_options, &block)
7
+ end
8
+
9
+ def tam_select_options(**overrides)
10
+ {
11
+ searchable: true,
12
+ clearable: true,
13
+ creatable: false,
14
+ placeholder: "Select…"
15
+ }.merge(overrides)
16
+ end
17
+ end
@@ -0,0 +1,30 @@
1
+ class TamSelectInput < SimpleForm::Inputs::CollectionSelectInput
2
+ def input(wrapper_options = nil)
3
+ attributes = merge_wrapper_options(input_html_options, wrapper_options)
4
+ label_method, value_method = detect_collection_methods
5
+ tam_options = attributes.delete(:tam_options) || {}
6
+ data = attributes[:data] ||= {}
7
+ data[:controller] = [data[:controller], "tam-select"].compact.join(" ")
8
+ data[:tam_select_options_value] = defaults.merge(tam_options).to_json
9
+
10
+ @builder.collection_select(
11
+ attribute_name,
12
+ collection,
13
+ value_method,
14
+ label_method,
15
+ input_options,
16
+ attributes
17
+ )
18
+ end
19
+
20
+ private
21
+
22
+ def defaults
23
+ {
24
+ searchable: options.fetch(:searchable, true),
25
+ creatable: options.fetch(:creatable, false),
26
+ clearable: options.fetch(:clearable, true),
27
+ placeholder: options[:placeholder] || options[:prompt] || "Select…"
28
+ }
29
+ end
30
+ end
@@ -0,0 +1,25 @@
1
+ module TamSelectPaginatable
2
+ extend ActiveSupport::Concern
3
+
4
+ private
5
+
6
+ def tam_select_payload(scope, label:, value: :id, per_page: 20)
7
+ page = [params.fetch(:page, 1).to_i, 1].max
8
+ records = scope.limit(per_page + 1).offset((page - 1) * per_page).to_a
9
+ has_more = records.length > per_page
10
+
11
+ {
12
+ items: records.first(per_page).map do |record|
13
+ {
14
+ value: record.public_send(value).to_s,
15
+ label: label.respond_to?(:call) ? label.call(record) : record.public_send(label).to_s
16
+ }
17
+ end,
18
+ pagination: {
19
+ page: page,
20
+ next_page: has_more ? page + 1 : nil,
21
+ has_more: has_more
22
+ }
23
+ }
24
+ end
25
+ end
@@ -0,0 +1,6 @@
1
+ require "rails/engine"
2
+
3
+ module TamSelect
4
+ class Engine < ::Rails::Engine
5
+ end
6
+ end
@@ -0,0 +1,3 @@
1
+ module TamSelect
2
+ VERSION = "0.1.0"
3
+ end
data/lib/tam_select.rb ADDED
@@ -0,0 +1,6 @@
1
+ require "tam_select/version"
2
+ require "tam_select/engine"
3
+
4
+ module TamSelect
5
+ class Error < StandardError; end
6
+ end
data/src/tam-select.js ADDED
@@ -0,0 +1,482 @@
1
+ const DEFAULT_CLASSES = {
2
+ wrapper: "tam-select relative w-full",
3
+ control: "flex min-h-10 w-full cursor-text flex-wrap items-center gap-1.5 rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm transition focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-500/20 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100",
4
+ controlDisabled: "cursor-not-allowed bg-zinc-100 opacity-60 dark:bg-zinc-800",
5
+ input: "min-w-16 flex-1 border-0 bg-transparent p-0 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-500",
6
+ placeholder: "pointer-events-none text-zinc-400 dark:text-zinc-500",
7
+ tag: "inline-flex max-w-full items-center gap-1 rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-200 dark:bg-blue-950/50 dark:text-blue-300 dark:ring-blue-800",
8
+ tagRemove: "rounded p-0.5 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:hover:bg-blue-900",
9
+ clear: "ml-auto rounded p-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-200",
10
+ chevron: "ml-1 size-4 shrink-0 text-zinc-400 transition-transform",
11
+ chevronOpen: "rotate-180",
12
+ dropdown: "absolute z-50 mt-1 max-h-72 w-full overflow-auto rounded-lg border border-zinc-200 bg-white p-1 shadow-xl ring-1 ring-black/5 dark:border-zinc-700 dark:bg-zinc-900",
13
+ option: "flex cursor-pointer items-center justify-between gap-3 rounded-md px-3 py-2 text-sm text-zinc-700 outline-none dark:text-zinc-200",
14
+ optionActive: "bg-zinc-100 dark:bg-zinc-800",
15
+ optionSelected: "bg-blue-50 font-medium text-blue-700 dark:bg-blue-950/50 dark:text-blue-300",
16
+ optionDisabled: "cursor-not-allowed opacity-50",
17
+ message: "px-3 py-6 text-center text-sm text-zinc-500 dark:text-zinc-400",
18
+ spinner: "size-4 animate-spin rounded-full border-2 border-zinc-300 border-t-blue-600",
19
+ error: "px-3 py-3 text-sm text-red-600 dark:text-red-400"
20
+ }
21
+
22
+ const ICONS = {
23
+ chevron: '<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M5.22 7.22a.75.75 0 011.06 0L10 10.94l3.72-3.72a.75.75 0 111.06 1.06l-4.25 4.25a.75.75 0 01-1.06 0L5.22 8.28a.75.75 0 010-1.06z" clip-rule="evenodd"/></svg>',
24
+ close: '<svg viewBox="0 0 20 20" fill="currentColor" class="size-3" aria-hidden="true"><path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/></svg>',
25
+ check: '<svg viewBox="0 0 20 20" fill="currentColor" class="size-4" aria-hidden="true"><path fill-rule="evenodd" d="M16.7 5.3a1 1 0 010 1.4l-8 8a1 1 0 01-1.4 0l-4-4a1 1 0 011.4-1.4L8 12.6l7.3-7.3a1 1 0 011.4 0z" clip-rule="evenodd"/></svg>'
26
+ }
27
+
28
+ const uid = () => `tam-select-${Math.random().toString(36).slice(2, 10)}`
29
+ const normalize = value => String(value ?? "").normalize("NFKD").toLocaleLowerCase()
30
+ const toggleClasses = (element, classNames, force) => {
31
+ String(classNames).split(/\s+/).filter(Boolean).forEach(className => element.classList.toggle(className, force))
32
+ }
33
+
34
+ export class TamSelect {
35
+ static instances = new WeakMap()
36
+
37
+ static getInstance(element) {
38
+ return this.instances.get(element) || null
39
+ }
40
+
41
+ constructor(select, options = {}) {
42
+ if (!(select instanceof HTMLSelectElement)) throw new TypeError("TamSelect requires a <select> element")
43
+ if (TamSelect.instances.has(select)) return TamSelect.instances.get(select)
44
+
45
+ this.select = select
46
+ this.options = {
47
+ searchable: true,
48
+ creatable: false,
49
+ clearable: true,
50
+ closeAfterSelect: !select.multiple,
51
+ placeholder: select.dataset.placeholder || select.querySelector('option[value=""]')?.textContent || "Select…",
52
+ searchPlaceholder: "Search…",
53
+ noResultsText: "No results found",
54
+ createText: query => `Create “${query}”`,
55
+ loadingText: "Loading…",
56
+ loadMoreText: "Load more",
57
+ remoteUrl: null,
58
+ queryParam: "q",
59
+ pageParam: "page",
60
+ debounce: 250,
61
+ minQueryLength: 0,
62
+ valueField: "value",
63
+ labelField: "label",
64
+ itemsPath: "items",
65
+ paginationPath: "pagination",
66
+ classes: {},
67
+ headers: {},
68
+ ...options
69
+ }
70
+ this.classes = { ...DEFAULT_CLASSES, ...this.options.classes }
71
+ this.multiple = select.multiple
72
+ this.items = []
73
+ this.filtered = []
74
+ this.activeIndex = -1
75
+ this.opened = false
76
+ this.loading = false
77
+ this.error = null
78
+ this.query = ""
79
+ this.page = 1
80
+ this.hasMore = false
81
+ this.abortController = null
82
+ this.originalTabIndex = select.getAttribute("tabindex")
83
+ this.build()
84
+ this.bind()
85
+ this.readNativeOptions()
86
+ this.renderSelection()
87
+ TamSelect.instances.set(select, this)
88
+ }
89
+
90
+ build() {
91
+ this.id = this.select.id || uid()
92
+ if (!this.select.id) this.select.id = this.id
93
+ this.listboxId = `${this.id}-listbox`
94
+ this.select.classList.add("sr-only")
95
+ this.select.tabIndex = -1
96
+
97
+ this.wrapper = document.createElement("div")
98
+ this.wrapper.className = this.classes.wrapper
99
+ this.wrapper.dataset.tamSelectRoot = ""
100
+ this.control = document.createElement("div")
101
+ this.control.className = this.classes.control
102
+ this.control.setAttribute("role", "combobox")
103
+ this.control.setAttribute("aria-haspopup", "listbox")
104
+ this.control.setAttribute("aria-expanded", "false")
105
+ this.control.setAttribute("aria-controls", this.listboxId)
106
+ this.control.tabIndex = this.select.disabled ? -1 : 0
107
+
108
+ this.values = document.createElement("div")
109
+ this.values.className = "contents"
110
+ this.input = document.createElement("input")
111
+ this.input.type = "text"
112
+ this.input.className = this.classes.input
113
+ this.input.placeholder = this.options.searchable ? this.options.searchPlaceholder : ""
114
+ this.input.autocomplete = "off"
115
+ this.input.spellcheck = false
116
+ this.input.setAttribute("aria-autocomplete", "list")
117
+ this.input.setAttribute("aria-controls", this.listboxId)
118
+ if (!this.options.searchable) this.input.readOnly = true
119
+
120
+ this.clearButton = document.createElement("button")
121
+ this.clearButton.type = "button"
122
+ this.clearButton.className = this.classes.clear
123
+ this.clearButton.setAttribute("aria-label", "Clear selection")
124
+ this.clearButton.innerHTML = ICONS.close
125
+
126
+ this.chevron = document.createElement("span")
127
+ this.chevron.className = this.classes.chevron
128
+ this.chevron.innerHTML = ICONS.chevron
129
+ this.dropdown = document.createElement("div")
130
+ this.dropdown.id = this.listboxId
131
+ this.dropdown.className = `${this.classes.dropdown} hidden`
132
+ this.dropdown.setAttribute("role", "listbox")
133
+ if (this.multiple) this.dropdown.setAttribute("aria-multiselectable", "true")
134
+
135
+ this.control.append(this.values, this.input, this.clearButton, this.chevron)
136
+ this.wrapper.append(this.control, this.dropdown)
137
+ this.select.after(this.wrapper)
138
+ this.applyDisabled()
139
+ }
140
+
141
+ bind() {
142
+ this.onControlClick = event => {
143
+ if (this.select.disabled || event.target.closest("button")) return
144
+ this.open()
145
+ this.input.focus()
146
+ }
147
+ this.onInput = () => {
148
+ this.query = this.input.value
149
+ this.page = 1
150
+ this.open()
151
+ if (this.options.remoteUrl) this.scheduleRemote()
152
+ else this.filterLocal()
153
+ }
154
+ this.onKeydown = event => this.handleKeydown(event)
155
+ this.onClear = event => { event.stopPropagation(); this.clear() }
156
+ this.onOutside = event => { if (!this.wrapper.contains(event.target)) this.close() }
157
+ this.onNativeChange = () => { this.readNativeOptions(); this.renderSelection(); this.renderDropdown() }
158
+ this.onScroll = () => {
159
+ if (!this.options.remoteUrl || !this.hasMore || this.loading) return
160
+ if (this.dropdown.scrollTop + this.dropdown.clientHeight >= this.dropdown.scrollHeight - 32) this.loadRemote(this.page + 1, true)
161
+ }
162
+ this.control.addEventListener("click", this.onControlClick)
163
+ this.input.addEventListener("input", this.onInput)
164
+ this.control.addEventListener("keydown", this.onKeydown)
165
+ this.clearButton.addEventListener("click", this.onClear)
166
+ this.dropdown.addEventListener("scroll", this.onScroll)
167
+ this.select.addEventListener("change", this.onNativeChange)
168
+ document.addEventListener("pointerdown", this.onOutside)
169
+ }
170
+
171
+ readNativeOptions() {
172
+ this.items = Array.from(this.select.options)
173
+ .filter(option => option.value !== "" || option.selected)
174
+ .map(option => ({ value: option.value, label: option.textContent.trim(), disabled: option.disabled, selected: option.selected, option }))
175
+ this.filterLocal(false)
176
+ }
177
+
178
+ selectedItems() { return this.items.filter(item => item.option?.selected || item.selected) }
179
+
180
+ filterLocal(render = true) {
181
+ const needle = normalize(this.query)
182
+ this.filtered = this.items.filter(item => !needle || normalize(item.label).includes(needle))
183
+ this.activeIndex = this.filtered.findIndex(item => !item.disabled)
184
+ if (render) this.renderDropdown()
185
+ }
186
+
187
+ renderSelection() {
188
+ const selected = this.selectedItems()
189
+ this.values.replaceChildren()
190
+ if (this.multiple) selected.forEach(item => this.values.append(this.makeTag(item)))
191
+ else if (selected.length) {
192
+ const label = document.createElement("span")
193
+ label.className = "min-w-0 flex-1 truncate"
194
+ label.textContent = selected[0].label
195
+ this.values.append(label)
196
+ }
197
+ const hasValue = selected.length > 0
198
+ this.input.classList.toggle("hidden", !this.multiple && hasValue && !this.opened)
199
+ this.input.placeholder = hasValue ? "" : this.options.placeholder
200
+ this.clearButton.classList.toggle("hidden", !this.options.clearable || !hasValue || this.select.disabled)
201
+ }
202
+
203
+ makeTag(item) {
204
+ const tag = document.createElement("span")
205
+ tag.className = this.classes.tag
206
+ const label = document.createElement("span")
207
+ label.className = "max-w-48 truncate"
208
+ label.textContent = item.label
209
+ const remove = document.createElement("button")
210
+ remove.type = "button"
211
+ remove.className = this.classes.tagRemove
212
+ remove.setAttribute("aria-label", `Remove ${item.label}`)
213
+ remove.innerHTML = ICONS.close
214
+ remove.addEventListener("click", event => { event.stopPropagation(); this.deselect(item.value) })
215
+ tag.append(label, remove)
216
+ return tag
217
+ }
218
+
219
+ renderDropdown() {
220
+ if (!this.opened) return
221
+ this.dropdown.replaceChildren()
222
+ if (this.error) {
223
+ const error = document.createElement("div")
224
+ error.className = this.classes.error
225
+ error.setAttribute("role", "alert")
226
+ error.textContent = this.error
227
+ this.dropdown.append(error)
228
+ return
229
+ }
230
+ if (this.loading && this.page === 1) return this.renderMessage(this.options.loadingText, true)
231
+
232
+ const visible = this.multiple ? this.filtered : this.filtered.filter(item => !item.selected)
233
+ visible.forEach((item, index) => this.dropdown.append(this.makeOption(item, index)))
234
+ if (this.options.creatable && this.query.trim() && !this.items.some(item => normalize(item.label) === normalize(this.query))) {
235
+ this.dropdown.append(this.makeCreateOption())
236
+ }
237
+ if (!this.dropdown.children.length) this.renderMessage(this.options.noResultsText)
238
+ if (this.loading && this.page > 1) this.renderMessage(this.options.loadingText, true)
239
+ else if (this.hasMore) this.renderMessage(this.options.loadMoreText)
240
+ }
241
+
242
+ makeOption(item, index) {
243
+ const option = document.createElement("div")
244
+ option.id = `${this.listboxId}-option-${index}`
245
+ option.dataset.value = item.value
246
+ option.className = [this.classes.option, index === this.activeIndex && this.classes.optionActive, item.selected && this.classes.optionSelected, item.disabled && this.classes.optionDisabled].filter(Boolean).join(" ")
247
+ option.setAttribute("role", "option")
248
+ option.setAttribute("aria-selected", String(Boolean(item.selected)))
249
+ option.setAttribute("aria-disabled", String(Boolean(item.disabled)))
250
+ const label = document.createElement("span")
251
+ label.className = "min-w-0 flex-1 truncate"
252
+ label.textContent = item.label
253
+ option.append(label)
254
+ if (item.selected) {
255
+ const check = document.createElement("span")
256
+ check.innerHTML = ICONS.check
257
+ option.append(check)
258
+ }
259
+ option.addEventListener("pointermove", () => { this.activeIndex = index; this.updateActiveOption() })
260
+ option.addEventListener("click", () => { if (!item.disabled) this.toggleItem(item) })
261
+ return option
262
+ }
263
+
264
+ makeCreateOption() {
265
+ const option = document.createElement("div")
266
+ option.className = this.classes.option
267
+ option.setAttribute("role", "option")
268
+ option.textContent = typeof this.options.createText === "function" ? this.options.createText(this.query.trim()) : this.options.createText
269
+ option.addEventListener("click", () => this.createItem(this.query.trim()))
270
+ return option
271
+ }
272
+
273
+ renderMessage(text, spinner = false) {
274
+ const message = document.createElement("div")
275
+ message.className = this.classes.message
276
+ if (spinner) {
277
+ const row = document.createElement("span")
278
+ row.className = "inline-flex items-center gap-2"
279
+ const icon = document.createElement("span")
280
+ icon.className = this.classes.spinner
281
+ row.append(icon, document.createTextNode(text))
282
+ message.append(row)
283
+ } else message.textContent = text
284
+ this.dropdown.append(message)
285
+ }
286
+
287
+ handleKeydown(event) {
288
+ if (this.select.disabled) return
289
+ if (["ArrowDown", "ArrowUp"].includes(event.key)) {
290
+ event.preventDefault()
291
+ this.open()
292
+ const direction = event.key === "ArrowDown" ? 1 : -1
293
+ this.moveActive(direction)
294
+ } else if (event.key === "Enter") {
295
+ if (!this.opened) return this.open()
296
+ event.preventDefault()
297
+ const item = this.filtered[this.activeIndex]
298
+ if (item && !item.disabled) this.toggleItem(item)
299
+ else if (this.options.creatable && this.query.trim()) this.createItem(this.query.trim())
300
+ } else if (event.key === "Escape") {
301
+ event.preventDefault(); this.close(); this.control.focus()
302
+ } else if (event.key === "Backspace" && this.multiple && !this.input.value) {
303
+ const last = this.selectedItems().at(-1)
304
+ if (last) this.deselect(last.value)
305
+ } else if (event.key === "Tab") this.close()
306
+ }
307
+
308
+ moveActive(direction) {
309
+ if (!this.filtered.length) return
310
+ let next = this.activeIndex
311
+ for (let count = 0; count < this.filtered.length; count += 1) {
312
+ next = (next + direction + this.filtered.length) % this.filtered.length
313
+ if (!this.filtered[next].disabled) break
314
+ }
315
+ this.activeIndex = next
316
+ this.updateActiveOption()
317
+ }
318
+
319
+ updateActiveOption() {
320
+ this.dropdown.querySelectorAll('[role="option"]').forEach((option, index) => toggleClasses(option, this.classes.optionActive, index === this.activeIndex))
321
+ const active = this.dropdown.querySelector(`#${CSS.escape(`${this.listboxId}-option-${this.activeIndex}`)}`)
322
+ if (active) { this.input.setAttribute("aria-activedescendant", active.id); active.scrollIntoView({ block: "nearest" }) }
323
+ }
324
+
325
+ toggleItem(item) {
326
+ if (this.multiple && item.selected) this.deselect(item.value)
327
+ else this.selectValue(item.value)
328
+ }
329
+
330
+ selectValue(value) {
331
+ const item = this.ensureNativeOption(value)
332
+ if (!this.multiple) Array.from(this.select.options).forEach(option => { option.selected = false })
333
+ item.option.selected = true
334
+ item.selected = true
335
+ this.commit()
336
+ if (this.options.closeAfterSelect) this.close()
337
+ else { this.input.value = ""; this.query = ""; this.filterLocal() }
338
+ }
339
+
340
+ deselect(value) {
341
+ const item = this.items.find(entry => String(entry.value) === String(value))
342
+ if (!item) return
343
+ item.selected = false
344
+ if (item.option) item.option.selected = false
345
+ this.commit()
346
+ }
347
+
348
+ clear() {
349
+ Array.from(this.select.options).forEach(option => { option.selected = false })
350
+ this.items.forEach(item => { item.selected = false })
351
+ this.commit()
352
+ }
353
+
354
+ createItem(label) {
355
+ const value = typeof this.options.createValue === "function" ? this.options.createValue(label) : label
356
+ const item = this.addItem({ value, label, selected: false, created: true })
357
+ this.selectValue(item.value)
358
+ this.emit("tam-select:create", { item })
359
+ }
360
+
361
+ addItem(raw) {
362
+ const value = String(raw.value ?? raw[this.options.valueField])
363
+ const existing = this.items.find(item => String(item.value) === value)
364
+ if (existing) return existing
365
+ const label = String(raw.label ?? raw[this.options.labelField] ?? value)
366
+ const option = new Option(label, value, Boolean(raw.selected), Boolean(raw.selected))
367
+ option.dataset.tamSelectGenerated = ""
368
+ this.select.add(option)
369
+ const item = { ...raw, value, label, option, selected: option.selected, disabled: Boolean(raw.disabled) }
370
+ option.disabled = item.disabled
371
+ this.items.push(item)
372
+ this.filterLocal(false)
373
+ return item
374
+ }
375
+
376
+ ensureNativeOption(value) {
377
+ return this.items.find(item => String(item.value) === String(value)) || this.addItem({ value, label: value })
378
+ }
379
+
380
+ commit() {
381
+ this.renderSelection()
382
+ this.renderDropdown()
383
+ this.select.dispatchEvent(new Event("change", { bubbles: true }))
384
+ this.emit("tam-select:change", { value: this.value, items: this.selectedItems() })
385
+ }
386
+
387
+ get value() { return this.multiple ? this.selectedItems().map(item => item.value) : this.selectedItems()[0]?.value ?? "" }
388
+
389
+ setValue(value) {
390
+ const values = new Set((Array.isArray(value) ? value : [value]).map(String))
391
+ this.items.forEach(item => { item.selected = values.has(String(item.value)); if (item.option) item.option.selected = item.selected })
392
+ this.commit()
393
+ }
394
+
395
+ open() {
396
+ if (this.opened || this.select.disabled) return
397
+ this.opened = true
398
+ this.dropdown.classList.remove("hidden")
399
+ this.control.setAttribute("aria-expanded", "true")
400
+ this.chevron.classList.add(...this.classes.chevronOpen.split(" "))
401
+ this.renderSelection()
402
+ if (this.options.remoteUrl && !this.filtered.length) this.loadRemote(1)
403
+ else this.renderDropdown()
404
+ this.emit("tam-select:open")
405
+ }
406
+
407
+ close() {
408
+ if (!this.opened) return
409
+ this.opened = false
410
+ this.dropdown.classList.add("hidden")
411
+ this.control.setAttribute("aria-expanded", "false")
412
+ this.input.removeAttribute("aria-activedescendant")
413
+ this.chevron.classList.remove(...this.classes.chevronOpen.split(" "))
414
+ this.input.value = ""
415
+ this.query = ""
416
+ this.filterLocal(false)
417
+ this.renderSelection()
418
+ this.emit("tam-select:close")
419
+ }
420
+
421
+ scheduleRemote() {
422
+ clearTimeout(this.remoteTimer)
423
+ if (this.query.length < this.options.minQueryLength) { this.filtered = []; return this.renderDropdown() }
424
+ this.remoteTimer = setTimeout(() => this.loadRemote(1), this.options.debounce)
425
+ }
426
+
427
+ async loadRemote(page = 1, append = false) {
428
+ if (!append) this.abortController?.abort()
429
+ this.abortController = new AbortController()
430
+ this.loading = true
431
+ this.error = null
432
+ this.page = page
433
+ this.renderDropdown()
434
+ try {
435
+ const url = new URL(this.options.remoteUrl, window.location.origin)
436
+ url.searchParams.set(this.options.queryParam, this.query)
437
+ url.searchParams.set(this.options.pageParam, page)
438
+ const response = await fetch(url, { headers: { Accept: "application/json", ...this.options.headers }, signal: this.abortController.signal })
439
+ if (!response.ok) throw new Error(`Request failed (${response.status})`)
440
+ const data = await response.json()
441
+ const rawItems = this.getPath(data, this.options.itemsPath) || []
442
+ const remoteItems = rawItems.map(raw => this.addItem({ ...raw, value: String(raw[this.options.valueField]), label: String(raw[this.options.labelField]) }))
443
+ const values = new Set(remoteItems.map(item => item.value))
444
+ this.filtered = append ? [...this.filtered, ...remoteItems.filter(item => !this.filtered.some(old => old.value === item.value))] : this.items.filter(item => values.has(String(item.value)) || item.selected)
445
+ const pagination = this.getPath(data, this.options.paginationPath) || {}
446
+ this.hasMore = Boolean(pagination.next_page || pagination.has_more || (pagination.page && pagination.total_pages && pagination.page < pagination.total_pages))
447
+ this.activeIndex = this.filtered.findIndex(item => !item.disabled)
448
+ this.emit("tam-select:load", { items: remoteItems, pagination })
449
+ } catch (error) {
450
+ if (error.name !== "AbortError") { this.error = error.message || "Unable to load options"; this.emit("tam-select:error", { error }) }
451
+ } finally {
452
+ this.loading = false
453
+ this.renderDropdown()
454
+ }
455
+ }
456
+
457
+ getPath(object, path) { return String(path).split(".").reduce((value, key) => value?.[key], object) }
458
+
459
+ applyDisabled() {
460
+ toggleClasses(this.control, this.classes.controlDisabled, this.select.disabled)
461
+ this.control.tabIndex = this.select.disabled ? -1 : 0
462
+ this.input.disabled = this.select.disabled
463
+ }
464
+
465
+ refresh() { this.readNativeOptions(); this.applyDisabled(); this.renderSelection(); this.renderDropdown() }
466
+
467
+ emit(name, detail = {}) { this.select.dispatchEvent(new CustomEvent(name, { bubbles: true, detail: { tamSelect: this, ...detail } })) }
468
+
469
+ destroy() {
470
+ clearTimeout(this.remoteTimer)
471
+ this.abortController?.abort()
472
+ document.removeEventListener("pointerdown", this.onOutside)
473
+ this.select.removeEventListener("change", this.onNativeChange)
474
+ this.wrapper.remove()
475
+ this.select.classList.remove("sr-only")
476
+ if (this.originalTabIndex === null) this.select.removeAttribute("tabindex")
477
+ else this.select.setAttribute("tabindex", this.originalTabIndex)
478
+ TamSelect.instances.delete(this.select)
479
+ }
480
+ }
481
+
482
+ export default TamSelect
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tam_select
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tamiru Hailu
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '8.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: stimulus-rails
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1.3'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '1.3'
46
+ description: Tam Select provides searchable, multi-select, remote-data, and user-created
47
+ option support for Ruby on Rails applications using Simple Form, Stimulus, Turbo,
48
+ and Tailwind CSS.
49
+ email:
50
+ - tamiruhailu@gmail.com
51
+ executables: []
52
+ extensions: []
53
+ extra_rdoc_files: []
54
+ files:
55
+ - LICENSE
56
+ - README.md
57
+ - Rakefile
58
+ - examples/form.html.erb
59
+ - examples/regions_controller.rb
60
+ - lib/generators/tam_select/install_generator.rb
61
+ - lib/generators/tam_select/templates/tam_select_controller.js
62
+ - lib/generators/tam_select/templates/tam_select_helper.rb
63
+ - lib/generators/tam_select/templates/tam_select_input.rb
64
+ - lib/generators/tam_select/templates/tam_select_paginatable.rb
65
+ - lib/tam_select.rb
66
+ - lib/tam_select/engine.rb
67
+ - lib/tam_select/version.rb
68
+ - src/tam-select.js
69
+ homepage: https://github.com/tamiru/tam_select
70
+ licenses:
71
+ - MIT
72
+ metadata:
73
+ source_code_uri: https://github.com/tamiru/tam_select
74
+ changelog_uri: https://github.com/tamiru/tam_select/releases
75
+ rubygems_mfa_required: 'true'
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: 3.2.0
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 4.0.9
91
+ specification_version: 4
92
+ summary: An accessible select component for Ruby on Rails
93
+ test_files: []