tam_select 1.2.0 → 1.2.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 77477a755bd92b2c88c2f6909b1cd30fc80f4586cc06b047a60a524e95b08b8f
4
- data.tar.gz: 55381cbe7a64e822f2f2ea6d48afc933d00f353b4f5563d733606b1520bc09df
3
+ metadata.gz: d83036c60f51e481343b204bc7e6333c92cc36d6c2d1190656cd956b0e3c4301
4
+ data.tar.gz: 0576a3df23baad11bc818b4a2d9c05ccc035e7c7ae369b67606ef1c03c398aea
5
5
  SHA512:
6
- metadata.gz: 749ebc117bccbcb42ada9476629f5897cec512fbd80c669be9bf64150554e2e217907efbd4d4cbb636707bb9650921f630c164ad6fa1cca12141309d1bfcba67
7
- data.tar.gz: b2b39df96276b3255dce1ab5903ec53ede199a0e741f51b36d243bc129708992a09035150c4f946dfd2da2a065dc43ec9267886d985fcc39a8fff496d95014d4
6
+ metadata.gz: 0bd5a3e7eb8d90cb9d18b801a55943abc9da582a33a0616ceadbf8e1a27fcff5ea5df795bae552b4e64d97c3c6e7cb298069285afddae4499c64af1b1cd0579e
7
+ data.tar.gz: 8aed36fcd88674f2cb3d91794ca224f75f909f79d805ba6d67cc613dd9bcf624ee7e2358a39ddcefaf5ff2cd24b27e9583e21ca1bd63e3eaa4b1289c04b87eac
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Tam Select
2
2
 
3
3
  [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
- [![Gem Version](https://img.shields.io/badge/version-1.2.0-blue.svg)](https://github.com/tamiru/tam_select)
4
+ [![Gem Version](https://img.shields.io/badge/version-1.2.3-blue.svg)](https://github.com/tamiru/tam_select)
5
5
 
6
6
  **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.
7
7
 
@@ -29,20 +29,53 @@ bundle install
29
29
  bin/rails generate tam_select:install
30
30
  ```
31
31
 
32
- The generator installs:
32
+ The generator installs the core module, Stimulus controller, Rails helpers, and remote-search concerns:
33
33
 
34
34
  ```text
35
35
  app/javascript/tam_select/tam_select.js
36
36
  app/javascript/controllers/tam_select_controller.js
37
- app/inputs/tam_select_input.rb
38
37
  app/controllers/concerns/tam_select_paginatable.rb
39
38
  app/controllers/concerns/tam_select_remote.rb
40
39
  app/controllers/tam_select_remote_controller.rb
41
40
  app/helpers/tam_select_helper.rb
42
41
  ```
43
42
 
43
+ 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.
44
+
44
45
  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.
45
46
 
47
+ ### Rails 8 importmap
48
+
49
+ The default Rails 8 importmap setup needs no manual JavaScript package installation. The generator adds this pin to `config/importmap.rb`:
50
+
51
+ ```ruby
52
+ pin "tam_select", to: "tam_select/tam_select.js"
53
+ ```
54
+
55
+ 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.
56
+
57
+ ### jsbundling-rails and esbuild
58
+
59
+ 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`:
60
+
61
+ ```json
62
+ {
63
+ "scripts": {
64
+ "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"
65
+ }
66
+ }
67
+ ```
68
+
69
+ 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:
70
+
71
+ ```bash
72
+ npm install github:tamiru/tam_select
73
+ ```
74
+
75
+ ```js
76
+ import TamSelect from "tam-select"
77
+ ```
78
+
46
79
  Add the generated JavaScript to Tailwind's source detection in `app/assets/tailwind/application.css`:
47
80
 
48
81
  ```css
@@ -50,7 +83,7 @@ Add the generated JavaScript to Tailwind's source detection in `app/assets/tailw
50
83
  @source "../../javascript/tam_select/**/*.js";
51
84
  ```
52
85
 
53
- ### Updating
86
+ ### Upgrading
54
87
 
55
88
  The Rails integration files are copied into your application, so updating the gem does not update them automatically. Commit local customizations first, then run:
56
89
 
@@ -67,13 +100,15 @@ bin/rails generate tam_select:install --force
67
100
 
68
101
  Review `git diff` afterward because `--force` overwrites application-specific customizations.
69
102
 
103
+ 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.
104
+
70
105
  ## Features
71
106
 
72
107
  - Single and multiple selection
73
108
  - Local search and user-created tags
74
109
  - Remote JSON search with debouncing and incremental pagination
75
110
  - Loading, empty, and error states
76
- - Polished search control with clear, search, selected, and open-state affordances
111
+ - Select2-style closed selection and open search states with clear active, selected, disabled, loading, empty, and error feedback
77
112
  - Keyboard navigation: arrows, Enter, Escape, Tab, and Backspace
78
113
  - Combobox/listbox ARIA semantics
79
114
  - Light and dark Tailwind themes
@@ -81,14 +116,6 @@ Review `git diff` afterward because `--force` overwrites application-specific cu
81
116
  - Public API and bubbling custom events
82
117
  - No jQuery, Tom Select, Select2, Preline, or Floating UI dependency
83
118
 
84
- ## JavaScript installation
85
-
86
- For a non-Rails application, install directly from GitHub:
87
-
88
- ```bash
89
- npm install github:tamiru/tam_select
90
- ```
91
-
92
119
  ## Tailwind CSS 4
93
120
 
94
121
  Add the package source to `app/assets/tailwind/application.css` so Tailwind generates every class used by the JavaScript templates:
@@ -117,7 +144,7 @@ For Tailwind CSS 4 class-based theming, define the variant in the application st
117
144
  @custom-variant dark (&:where(.dark, .dark *));
118
145
  ```
119
146
 
120
- ## Rails and Stimulus
147
+ ## Standard Rails forms and Stimulus
121
148
 
122
149
  The install generator copies the Stimulus controller into your application, where Stimulus normally discovers it automatically. If controllers are registered manually:
123
150
 
@@ -143,9 +170,28 @@ Use a normal Rails select:
143
170
 
144
171
  Stimulus destroys generated markup when Turbo removes the select and recreates it on reconnection.
145
172
 
173
+ For multiple selection, Rails must receive an array field and the native select must include `multiple: true`:
174
+
175
+ ```erb
176
+ <%= form.select :skill_ids,
177
+ options_from_collection_for_select(Skill.order(:name), :id, :name, form.object.skill_ids),
178
+ {},
179
+ multiple: true,
180
+ data: {
181
+ controller: "tam-select",
182
+ tam_select_options_value: {
183
+ searchable: true,
184
+ closeAfterSelect: false,
185
+ placeholder: "Add skills…"
186
+ }.to_json
187
+ } %>
188
+ ```
189
+
190
+ 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.
191
+
146
192
  ## Simple Form
147
193
 
148
- The install generator creates `app/inputs/tam_select_input.rb`. Use `as: :tam_select` with any Simple Form collection input.
194
+ 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.
149
195
 
150
196
  ### Local collection
151
197
 
@@ -270,6 +316,8 @@ Point Simple Form to that collection action:
270
316
 
271
317
  Typing sends `GET /regions/tam_select_options.json?q=addis&page=1` and receives the standard Tam Select JSON payload.
272
318
 
319
+ 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.
320
+
273
321
  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.
274
322
 
275
323
  This works well for program and student searches. For example, return the program name as `label` and admission as `detail`:
@@ -319,10 +367,42 @@ curl -H "Accept: application/json" \
319
367
 
320
368
  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.
321
369
 
370
+ ## Creatable values
371
+
372
+ 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.
373
+
374
+ ```erb
375
+ <%= form.select :category_ids,
376
+ options_from_collection_for_select(Category.order(:name), :id, :name),
377
+ {},
378
+ multiple: true,
379
+ data: {
380
+ controller: "tam-select",
381
+ tam_select_options_value: {
382
+ creatable: true,
383
+ closeAfterSelect: false,
384
+ placeholder: "Add categories…"
385
+ }.to_json
386
+ } %>
387
+ ```
388
+
389
+ 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`.
390
+
391
+ ## Keyboard and accessibility behavior
392
+
393
+ - ArrowDown and ArrowUp open the list, move through the rendered entries, and skip disabled options.
394
+ - Enter selects the highlighted option or activates the highlighted Create action.
395
+ - Escape closes the list and returns focus to the combobox; Tab closes without trapping focus.
396
+ - Backspace removes the last tag from a multiple select when the search input is empty.
397
+ - Searchable controls use an input combobox; non-searchable controls use a focusable button combobox.
398
+ - Rails labels, descriptions, required state, disabled state, and invalid state are mirrored from the native select.
399
+
322
400
  ## Core JavaScript
323
401
 
402
+ For a generated Rails/importmap installation, use the pinned module name:
403
+
324
404
  ```js
325
- import TamSelect from "tam-select"
405
+ import TamSelect from "tam_select"
326
406
 
327
407
  const instance = new TamSelect(document.querySelector("#student_region_id"), {
328
408
  searchable: true,
@@ -332,11 +412,15 @@ const instance = new TamSelect(document.querySelector("#student_region_id"), {
332
412
  })
333
413
 
334
414
  instance.setValue("2")
415
+ instance.open()
416
+ instance.close()
335
417
  instance.clear()
336
418
  instance.refresh()
337
419
  instance.destroy()
338
420
  ```
339
421
 
422
+ When consuming the npm package directly, import from `"tam-select"` instead.
423
+
340
424
  ## Main options
341
425
 
342
426
  | Option | Default | Purpose |
@@ -345,6 +429,8 @@ instance.destroy()
345
429
  | `creatable` | `false` | Allows typed values to become options |
346
430
  | `clearable` | `true` | Displays the clear control |
347
431
  | `closeAfterSelect` | Single only | Keeps multiple dropdowns open |
432
+ | `placeholder` | Native prompt or `Select…` | Closed-control placeholder |
433
+ | `searchPlaceholder` | `Search…` | Search-input placeholder |
348
434
  | `remoteUrl` | `null` | JSON search endpoint |
349
435
  | `queryParam` | `q` | Remote search parameter |
350
436
  | `pageParam` | `page` | Remote page parameter |
@@ -353,6 +439,7 @@ instance.destroy()
353
439
  | `valueField` | `value` | Remote item value key |
354
440
  | `labelField` | `label` | Remote item label key |
355
441
  | `imageField` | `image` | Remote item image URL key |
442
+ | `matcher` | `null` | Optional `(item, query) => boolean` local matcher |
356
443
  | `classes` | `{}` | Overrides any Tailwind class group |
357
444
 
358
445
  ## Events
@@ -361,6 +448,8 @@ Listen on the original select. Every event bubbles:
361
448
 
362
449
  ```js
363
450
  select.addEventListener("tam-select:change", ({ detail }) => console.log(detail.value))
451
+ select.addEventListener("tam-select:open", () => console.log("opened"))
452
+ select.addEventListener("tam-select:close", () => console.log("closed"))
364
453
  select.addEventListener("tam-select:load", ({ detail }) => console.log(detail.items))
365
454
  select.addEventListener("tam-select:create", ({ detail }) => console.log(detail.item))
366
455
  select.addEventListener("tam-select:error", ({ detail }) => console.error(detail.error))
@@ -368,6 +457,8 @@ select.addEventListener("tam-select:error", ({ detail }) => console.error(detail
368
457
 
369
458
  Standard native `change` events are also dispatched for Rails and other controllers.
370
459
 
460
+ 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)`.
461
+
371
462
  ## Development
372
463
 
373
464
  ```bash
data/Rakefile CHANGED
@@ -2,7 +2,10 @@ require "bundler/gem_tasks"
2
2
 
3
3
  begin
4
4
  require "rake/testtask"
5
- Rake::TestTask.new
5
+ Rake::TestTask.new do |test|
6
+ test.libs << "test"
7
+ test.pattern = "test/**/*_test.rb"
8
+ end
6
9
  rescue LoadError
7
- # The gem has no Ruby runtime tests yet.
10
+ # Rake's test task is unavailable.
8
11
  end
@@ -12,8 +12,30 @@ module TamSelect
12
12
  copy_file "lib/generators/tam_select/templates/tam_select_controller.js", "app/javascript/controllers/tam_select_controller.js"
13
13
  end
14
14
 
15
+ def configure_importmap
16
+ importmap = "config/importmap.rb"
17
+ unless File.exist?(destination_path(importmap))
18
+ say "Importmap was not detected. For jsbundling/esbuild, alias tam_select to app/javascript/tam_select/tam_select.js.", :yellow
19
+ return
20
+ end
21
+
22
+ pin = 'pin "tam_select", to: "tam_select/tam_select.js"'
23
+ contents = File.read(destination_path(importmap))
24
+ existing_pin = /^\s*pin\s+["']tam_select["'].*$/
25
+
26
+ if contents.match?(existing_pin)
27
+ gsub_file importmap, existing_pin, pin unless contents.match?(/^#{Regexp.escape(pin)}$/)
28
+ else
29
+ append_to_file importmap, "\n#{pin}\n"
30
+ end
31
+ end
32
+
15
33
  def copy_simple_form_input
16
- copy_file "lib/generators/tam_select/templates/tam_select_input.rb", "app/inputs/tam_select_input.rb"
34
+ if simple_form_available?
35
+ copy_file "lib/generators/tam_select/templates/tam_select_input.rb", "app/inputs/tam_select_input.rb"
36
+ else
37
+ say "Simple Form is not installed; skipped app/inputs/tam_select_input.rb. Standard Rails form helpers remain available.", :yellow
38
+ end
17
39
  end
18
40
 
19
41
  def copy_controller_concern
@@ -35,6 +57,16 @@ module TamSelect
35
57
  say "If your setup uses explicit sources, add:"
36
58
  say '@source "../../javascript/tam_select/**/*.js";'
37
59
  end
60
+
61
+ private
62
+
63
+ def destination_path(relative_path)
64
+ File.join(destination_root, relative_path)
65
+ end
66
+
67
+ def simple_form_available?
68
+ Gem.loaded_specs.key?("simple_form")
69
+ end
38
70
  end
39
71
  end
40
72
  end
@@ -1,5 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import TamSelect from "../tam_select/tam_select"
2
+ import TamSelect from "tam_select"
3
3
 
4
4
  export default class extends Controller {
5
5
  static values = {
@@ -1,7 +1,7 @@
1
1
  module TamSelectHelper
2
2
  def tam_select_tag(name, option_tags = nil, options: {}, html_options: {}, &block)
3
3
  data = (html_options[:data] ||= {})
4
- data[:controller] = [data[:controller], "tam-select"].compact.join(" ")
4
+ data[:controller] = (data[:controller].to_s.split + ["tam-select"]).uniq.join(" ")
5
5
  data[:tam_select_options_value] = options.to_json
6
6
  select_tag(name, option_tags, html_options, &block)
7
7
  end
@@ -4,7 +4,7 @@ class TamSelectInput < SimpleForm::Inputs::CollectionSelectInput
4
4
  label_method, value_method = detect_collection_methods
5
5
  tam_options = attributes.delete(:tam_options) || {}
6
6
  data = attributes[:data] ||= {}
7
- data[:controller] = [data[:controller], "tam-select"].compact.join(" ")
7
+ data[:controller] = (data[:controller].to_s.split + ["tam-select"]).uniq.join(" ")
8
8
  data[:tam_select_options_value] = defaults.merge(tam_options).to_json
9
9
 
10
10
  @builder.collection_select(
@@ -1,3 +1,3 @@
1
1
  module TamSelect
2
- VERSION = "1.2.0"
2
+ VERSION = "1.2.4"
3
3
  end
data/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "tam-select",
3
+ "version": "1.2.3",
4
+ "description": "Accessible, searchable select component for Ruby on Rails, Simple Form, Stimulus, Turbo, and Tailwind CSS",
5
+ "homepage": "https://github.com/tamiru/tam_select",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/tamiru/tam_select.git"
9
+ },
10
+ "type": "module",
11
+ "main": "./src/tam-select.js",
12
+ "exports": {
13
+ ".": "./src/tam-select.js",
14
+ "./rails-controller": "./rails/app/javascript/controllers/tam_select_controller.js"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "rails",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "test": "node --test test/*.test.js",
24
+ "check": "node --check src/tam-select.js && node --check rails/app/javascript/controllers/tam_select_controller.js"
25
+ },
26
+ "keywords": [
27
+ "select",
28
+ "tailwindcss",
29
+ "stimulus",
30
+ "turbo",
31
+ "rails",
32
+ "combobox"
33
+ ],
34
+ "license": "MIT",
35
+ "peerDependencies": {
36
+ "@hotwired/stimulus": ">=3.2.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "@hotwired/stimulus": {
40
+ "optional": true
41
+ }
42
+ },
43
+ "devDependencies": {
44
+ "@hotwired/stimulus": "^3.2.2",
45
+ "esbuild": "^0.28.1",
46
+ "jsdom": "^29.1.1"
47
+ }
48
+ }
@@ -0,0 +1,32 @@
1
+ module TamSelectPaginatable
2
+ extend ActiveSupport::Concern
3
+
4
+ private
5
+
6
+ def tam_select_payload(scope, label:, value: :id, detail: nil, meta: nil, image: nil, 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: tam_select_record_value(record, value).to_s,
15
+ label: tam_select_record_value(record, label).to_s,
16
+ detail: detail && tam_select_record_value(record, detail).to_s,
17
+ meta: meta && tam_select_record_value(record, meta).to_s,
18
+ image: image && tam_select_record_value(record, image).to_s
19
+ }.compact
20
+ end,
21
+ pagination: {
22
+ page: page,
23
+ next_page: has_more ? page + 1 : nil,
24
+ has_more: has_more
25
+ }
26
+ }
27
+ end
28
+
29
+ def tam_select_record_value(record, resolver)
30
+ resolver.respond_to?(:call) ? resolver.call(record) : record.public_send(resolver)
31
+ end
32
+ end
@@ -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].to_s.split + ["tam-select"]).uniq.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].to_s.split + ["tam-select"]).uniq.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,31 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import TamSelect from "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() {
25
+ this.instance?.refresh()
26
+ }
27
+
28
+ clear() {
29
+ this.instance?.clear()
30
+ }
31
+ }