tam_select 1.0.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3f063f1d79268f900b10c4b86399cba1a34dac0154a90c4c12fd505771a1431
4
- data.tar.gz: 0cf4fe7a1cf1d1dc479065318819db1ced4e71888e81e6c98a25fc6920841852
3
+ metadata.gz: 77477a755bd92b2c88c2f6909b1cd30fc80f4586cc06b047a60a524e95b08b8f
4
+ data.tar.gz: 55381cbe7a64e822f2f2ea6d48afc933d00f353b4f5563d733606b1520bc09df
5
5
  SHA512:
6
- metadata.gz: 8ad40f38121d3e83e9260c825c8bbd7546c62d87073fef14ef63c70fd027a1a08bcbd18d51d60198d3a06410b2e3c3ffd325182f99b9b08c1af7b23e725a70c3
7
- data.tar.gz: 55469f46c8348ed51dff78070efe3ddf8b95ce43cb2ae3d5b299382bf80437022fe1f91066e07c2b4d061abfba0c984d892d958616692a02415c062e4cb622db
6
+ metadata.gz: 749ebc117bccbcb42ada9476629f5897cec512fbd80c669be9bf64150554e2e217907efbd4d4cbb636707bb9650921f630c164ad6fa1cca12141309d1bfcba67
7
+ data.tar.gz: b2b39df96276b3255dce1ab5903ec53ede199a0e741f51b36d243bc129708992a09035150c4f946dfd2da2a065dc43ec9267886d985fcc39a8fff496d95014d4
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
- the # Tam Select
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-0.1.1-blue.svg)](https://github.com/tamiru/tam_select)
4
+ [![Gem Version](https://img.shields.io/badge/version-1.2.0-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
 
@@ -36,6 +36,7 @@ app/javascript/tam_select/tam_select.js
36
36
  app/javascript/controllers/tam_select_controller.js
37
37
  app/inputs/tam_select_input.rb
38
38
  app/controllers/concerns/tam_select_paginatable.rb
39
+ app/controllers/concerns/tam_select_remote.rb
39
40
  app/controllers/tam_select_remote_controller.rb
40
41
  app/helpers/tam_select_helper.rb
41
42
  ```
@@ -72,6 +73,7 @@ Review `git diff` afterward because `--force` overwrites application-specific cu
72
73
  - Local search and user-created tags
73
74
  - Remote JSON search with debouncing and incremental pagination
74
75
  - Loading, empty, and error states
76
+ - Polished search control with clear, search, selected, and open-state affordances
75
77
  - Keyboard navigation: arrows, Enter, Escape, Tab, and Backspace
76
78
  - Combobox/listbox ARIA semantics
77
79
  - Light and dark Tailwind themes
@@ -103,6 +105,18 @@ When the Rails generator is used, scan the generated component source:
103
105
  @source "../../javascript/tam_select/**/*.js";
104
106
  ```
105
107
 
108
+ 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`:
109
+
110
+ ```js
111
+ document.documentElement.classList.toggle("dark")
112
+ ```
113
+
114
+ For Tailwind CSS 4 class-based theming, define the variant in the application stylesheet if it is not already present:
115
+
116
+ ```css
117
+ @custom-variant dark (&:where(.dark, .dark *));
118
+ ```
119
+
106
120
  ## Rails and Stimulus
107
121
 
108
122
  The install generator copies the Stimulus controller into your application, where Stimulus normally discovers it automatically. If controllers are registered manually:
@@ -131,7 +145,9 @@ Stimulus destroys generated markup when Turbo removes the select and recreates i
131
145
 
132
146
  ## Simple Form
133
147
 
134
- The install generator creates `app/inputs/tam_select_input.rb`. Use it like any other Simple Form input:
148
+ The install generator creates `app/inputs/tam_select_input.rb`. Use `as: :tam_select` with any Simple Form collection input.
149
+
150
+ ### Local collection
135
151
 
136
152
  ```erb
137
153
  <%= form.input :region_id,
@@ -139,16 +155,49 @@ The install generator creates `app/inputs/tam_select_input.rb`. Use it like any
139
155
  collection: Region.order(:name),
140
156
  label_method: :name,
141
157
  value_method: :id,
158
+ prompt: "Select region" %>
159
+ ```
160
+
161
+ ### Remote collection
162
+
163
+ Keep the currently selected record in the initial collection so edit forms can display its label before the AJAX request completes:
164
+
165
+ ```erb
166
+ <%= form.input :region_id,
167
+ as: :tam_select,
168
+ collection: [form.object.region].compact,
169
+ label_method: :name,
170
+ value_method: :id,
142
171
  prompt: "Select region",
143
172
  input_html: {
144
173
  tam_options: {
145
- remoteUrl: region_options_path(format: :json),
146
- minQueryLength: 1
174
+ remoteUrl: tam_select_options_regions_path(format: :json),
175
+ minQueryLength: 1,
176
+ debounce: 250
177
+ }
178
+ } %>
179
+ ```
180
+
181
+ The concern and route required by this example are described in [Add remote search to an existing controller](#add-remote-search-to-an-existing-controller).
182
+
183
+ ### Multiple selection
184
+
185
+ ```erb
186
+ <%= form.input :skill_ids,
187
+ as: :tam_select,
188
+ collection: Skill.order(:name),
189
+ label_method: :name,
190
+ value_method: :id,
191
+ input_html: {
192
+ multiple: true,
193
+ tam_options: {
194
+ searchable: true,
195
+ closeAfterSelect: false
147
196
  }
148
197
  } %>
149
198
  ```
150
199
 
151
- For a many-to-many field, add `multiple: true` to `input_html`.
200
+ Options are passed under `input_html[:tam_options]`. Common options include `searchable`, `creatable`, `clearable`, `placeholder`, `searchPlaceholder`, `remoteUrl`, `minQueryLength`, and `debounce`. See [Main options](#main-options) for defaults.
152
201
 
153
202
  ## Remote API contract
154
203
 
@@ -157,8 +206,8 @@ For a many-to-many field, add `multiple: true` to `input_html`.
157
206
  ```json
158
207
  {
159
208
  "items": [
160
- { "value": "1", "label": "Addis Ababa" },
161
- { "value": "2", "label": "Afar" }
209
+ { "value": "1", "label": "Hana Bekele", "detail": "Admission no. UG/1024/26", "meta": "Active", "image": "/avatars/hana.jpg" },
210
+ { "value": "2", "label": "Afar", "detail": "Semera", "meta": "AF" }
162
211
  ],
163
212
  "pagination": {
164
213
  "page": 1,
@@ -172,31 +221,39 @@ The generator installs `TamSelectPaginatable` as a Rails response helper. A comp
172
221
 
173
222
  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.
174
223
 
175
- ### Generic remote controller
224
+ ### Add remote search to an existing controller
176
225
 
177
- The installer generates `TamSelectRemoteController`. Create a small subclass for each allowed remote source:
226
+ Include `TamSelectRemote` and declare the allowed model and searchable fields. This adds the public `tam_select_options` action to the controller:
178
227
 
179
228
  ```ruby
180
- # app/controllers/region_options_controller.rb
181
- class RegionOptionsController < TamSelectRemoteController
182
- tam_select model: Region, label: :name, search_by: %i[name code]
183
-
184
- private
185
-
186
- # Override this method when records require authorization or tenant scoping.
187
- def tam_select_scope(config)
188
- current_account.regions.order(:name)
189
- end
229
+ # app/controllers/regions_controller.rb
230
+ class RegionsController < ApplicationController
231
+ include TamSelectRemote
232
+
233
+ tam_select_remote(
234
+ model: Region,
235
+ label: :name,
236
+ value: :id,
237
+ detail: :description,
238
+ meta: :code,
239
+ search_by: %i[name code],
240
+ scope: -> { Region.order(:name) },
241
+ per_page: 20
242
+ )
190
243
  end
191
244
  ```
192
245
 
193
- Expose the JSON endpoint and pass it to the input:
246
+ The optional `scope` lambda runs in the controller context, so it can use `current_user`, `current_account`, or an authorization policy.
194
247
 
195
248
  ```ruby
196
249
  # config/routes.rb
197
- get "region_options", to: "region_options#index", defaults: { format: :json }
250
+ resources :regions do
251
+ get :tam_select_options, on: :collection, defaults: { format: :json }
252
+ end
198
253
  ```
199
254
 
255
+ Point Simple Form to that collection action:
256
+
200
257
  ```erb
201
258
  <%= form.input :region_id,
202
259
  as: :tam_select,
@@ -205,18 +262,51 @@ get "region_options", to: "region_options#index", defaults: { format: :json }
205
262
  value_method: :id,
206
263
  input_html: {
207
264
  tam_options: {
208
- remoteUrl: region_options_path(format: :json),
265
+ remoteUrl: tam_select_options_regions_path(format: :json),
209
266
  minQueryLength: 1
210
267
  }
211
268
  } %>
212
269
  ```
213
270
 
214
- Do not accept a model name from request parameters. Declaring each source in a controller subclass prevents clients from querying arbitrary application models.
271
+ Typing sends `GET /regions/tam_select_options.json?q=addis&page=1` and receives the standard Tam Select JSON payload.
272
+
273
+ 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
+
275
+ This works well for program and student searches. For example, return the program name as `label` and admission as `detail`:
276
+
277
+ ```ruby
278
+ tam_select_remote(
279
+ model: Estudent::Program,
280
+ label: :name,
281
+ detail: ->(program) { program.admission.to_s },
282
+ search_by: %i[name],
283
+ scope: -> { Estudent::Program.includes(admission: %i[admission_type enrollment_type enrollment_mode]).order(:name) }
284
+ )
285
+ ```
286
+
287
+ For student search, add the admission number and photo URL:
288
+
289
+ ```ruby
290
+ tam_select_remote(
291
+ model: Estudent::Student,
292
+ label: ->(student) { student.full_name },
293
+ detail: ->(student) { "Admission no. #{student.applicant.registration_number}" },
294
+ image: ->(student) { url_for(student.applicant.person.avatar) if student.applicant.person.avatar.attached? },
295
+ search_by: %i[id_number],
296
+ scope: -> { Estudent::Student.includes(applicant: { person: { avatar_attachment: :blob } }) }
297
+ )
298
+ ```
299
+
300
+ For a local native select, provide the same values as option data attributes:
301
+
302
+ ```erb
303
+ <option value="1" data-detail="Admission no. UG/1024/26" data-meta="Active" data-image="/avatars/hana.jpg">Hana Bekele</option>
304
+ ```
215
305
 
216
306
  The browser sends requests such as:
217
307
 
218
308
  ```text
219
- GET /region_options.json?q=addis&page=1
309
+ GET /regions/tam_select_options.json?q=addis&page=1
220
310
  Accept: application/json
221
311
  ```
222
312
 
@@ -224,7 +314,7 @@ Test an endpoint independently with:
224
314
 
225
315
  ```bash
226
316
  curl -H "Accept: application/json" \
227
- "http://localhost:3000/region_options.json?q=addis&page=1"
317
+ "http://localhost:3000/regions/tam_select_options.json?q=addis&page=1"
228
318
  ```
229
319
 
230
320
  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.
@@ -262,6 +352,7 @@ instance.destroy()
262
352
  | `minQueryLength` | `0` | Characters required before requesting |
263
353
  | `valueField` | `value` | Remote item value key |
264
354
  | `labelField` | `label` | Remote item label key |
355
+ | `imageField` | `image` | Remote item image URL key |
265
356
  | `classes` | `{}` | Overrides any Tailwind class group |
266
357
 
267
358
  ## Events
@@ -18,6 +18,7 @@ module TamSelect
18
18
 
19
19
  def copy_controller_concern
20
20
  copy_file "lib/generators/tam_select/templates/tam_select_paginatable.rb", "app/controllers/concerns/tam_select_paginatable.rb"
21
+ copy_file "lib/generators/tam_select/templates/tam_select_remote.rb", "app/controllers/concerns/tam_select_remote.rb"
21
22
  end
22
23
 
23
24
  def copy_remote_controller
@@ -3,7 +3,7 @@ module TamSelectPaginatable
3
3
 
4
4
  private
5
5
 
6
- def tam_select_payload(scope, label:, value: :id, per_page: 20)
6
+ def tam_select_payload(scope, label:, value: :id, detail: nil, meta: nil, image: nil, per_page: 20)
7
7
  page = [params.fetch(:page, 1).to_i, 1].max
8
8
  records = scope.limit(per_page + 1).offset((page - 1) * per_page).to_a
9
9
  has_more = records.length > per_page
@@ -11,9 +11,12 @@ module TamSelectPaginatable
11
11
  {
12
12
  items: records.first(per_page).map do |record|
13
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
- }
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
17
20
  end,
18
21
  pagination: {
19
22
  page: page,
@@ -22,4 +25,8 @@ module TamSelectPaginatable
22
25
  }
23
26
  }
24
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
25
32
  end
@@ -0,0 +1,60 @@
1
+ module TamSelectRemote
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ include TamSelectPaginatable
6
+ class_attribute :tam_select_remote_config, instance_writer: false
7
+ end
8
+
9
+ class_methods do
10
+ def tam_select_remote(model:, label:, value: :id, detail: nil, meta: nil, image: nil, search_by: nil, scope: nil, per_page: 20)
11
+ self.tam_select_remote_config = {
12
+ model: model,
13
+ label: label,
14
+ value: value,
15
+ detail: detail,
16
+ meta: meta,
17
+ image: image,
18
+ search_by: Array(search_by || label),
19
+ scope: scope,
20
+ per_page: per_page
21
+ }.freeze
22
+ end
23
+ end
24
+
25
+ def tam_select_options
26
+ config = tam_select_remote_config!
27
+ records = tam_select_remote_search(tam_select_remote_scope(config), config)
28
+
29
+ render json: tam_select_payload(
30
+ records,
31
+ label: config[:label],
32
+ value: config[:value],
33
+ detail: config[:detail],
34
+ meta: config[:meta],
35
+ image: config[:image],
36
+ per_page: config[:per_page]
37
+ )
38
+ end
39
+
40
+ private
41
+
42
+ def tam_select_remote_config!
43
+ self.class.tam_select_remote_config ||
44
+ raise("Configure #{self.class.name} with tam_select_remote(...)")
45
+ end
46
+
47
+ def tam_select_remote_scope(config)
48
+ config[:scope] ? instance_exec(&config[:scope]) : config[:model].all
49
+ end
50
+
51
+ def tam_select_remote_search(records, config)
52
+ query = params[:q].to_s.strip
53
+ return records if query.blank?
54
+
55
+ model = config[:model]
56
+ pattern = "%#{model.sanitize_sql_like(query)}%"
57
+ predicates = config[:search_by].map { |field| model.arel_table[field].matches(pattern) }
58
+ records.where(predicates.reduce(&:or))
59
+ end
60
+ end
@@ -1,44 +1,6 @@
1
1
  class TamSelectRemoteController < ApplicationController
2
- include TamSelectPaginatable
2
+ include TamSelectRemote
3
3
 
4
- class_attribute :tam_select_config, instance_writer: false
5
-
6
- def self.tam_select(model:, label:, value: :id, search_by: nil, per_page: 20)
7
- self.tam_select_config = {
8
- model: model,
9
- label: label,
10
- value: value,
11
- search_by: Array(search_by || label),
12
- per_page: per_page
13
- }.freeze
14
- end
15
-
16
- def index
17
- config = self.class.tam_select_config
18
- raise "Configure #{self.class.name} with tam_select(...)" unless config
19
-
20
- records = search(tam_select_scope(config), config)
21
- render json: tam_select_payload(
22
- records,
23
- label: config[:label],
24
- value: config[:value],
25
- per_page: config[:per_page]
26
- )
27
- end
28
-
29
- private
30
-
31
- def tam_select_scope(config)
32
- config[:model].all
33
- end
34
-
35
- def search(records, config)
36
- query = params[:q].to_s.strip
37
- return records if query.blank?
38
-
39
- model = config[:model]
40
- pattern = "%#{model.sanitize_sql_like(query)}%"
41
- predicates = config[:search_by].map { |field| model.arel_table[field].matches(pattern) }
42
- records.where(predicates.reduce(&:or))
43
- end
4
+ # Configure this base controller in a subclass, or include TamSelectRemote
5
+ # directly in an existing resource controller. See the Tam Select README.
44
6
  end
@@ -1,3 +1,3 @@
1
1
  module TamSelect
2
- VERSION = "1.0.0"
2
+ VERSION = "1.2.0"
3
3
  end
data/src/tam-select.js CHANGED
@@ -1,18 +1,25 @@
1
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",
2
+ wrapper: "tam-select relative w-full text-zinc-900 [color-scheme:light] dark:text-zinc-100 dark:[color-scheme:dark]",
3
+ control: "flex min-h-11 w-full cursor-text flex-wrap items-center gap-2 rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm transition-colors hover:border-zinc-400 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 dark:shadow-black/20 dark:hover:border-zinc-600 dark:focus-within:border-blue-400 dark:focus-within:ring-blue-400/20",
4
+ controlDisabled: "cursor-not-allowed bg-zinc-100 opacity-60 dark:bg-zinc-800 dark:text-zinc-400",
5
+ input: "min-w-16 flex-1 border-0 bg-transparent p-0 text-left text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-500",
6
+ searchIcon: "size-4 shrink-0 text-zinc-400 dark:text-zinc-500",
6
7
  placeholder: "pointer-events-none text-zinc-400 dark:text-zinc-500",
7
8
  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
9
  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
+ clear: "ml-auto rounded p-1 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:text-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-200 dark:focus:ring-blue-400",
10
11
  chevron: "ml-1 size-4 shrink-0 text-zinc-400 transition-transform",
11
12
  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",
13
+ dropdown: "absolute z-50 mt-1.5 max-h-72 w-full overflow-auto rounded-lg border border-zinc-200 bg-white p-1.5 shadow-xl ring-1 ring-black/5 dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-black/40 dark:ring-white/10",
14
+ option: "flex min-h-11 cursor-pointer items-center justify-between gap-3 rounded-md px-3 py-2 text-sm text-zinc-700 outline-none transition-colors dark:text-zinc-200",
15
+ optionContent: "flex min-w-0 flex-1 items-center gap-3",
16
+ optionText: "flex min-w-0 flex-1 flex-col",
17
+ optionLabel: "font-normal",
18
+ optionDetail: "text-xs font-normal text-zinc-500 dark:text-zinc-400",
19
+ optionImage: "size-9 shrink-0 rounded-full bg-zinc-100 object-cover dark:bg-zinc-800",
20
+ optionMeta: "shrink-0 rounded bg-zinc-100 px-1.5 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300",
21
+ optionActive: "bg-zinc-100 text-zinc-950 dark:bg-zinc-800 dark:text-white",
22
+ optionSelected: "bg-blue-50 text-blue-700 dark:bg-blue-950/60 dark:text-blue-300",
16
23
  optionDisabled: "cursor-not-allowed opacity-50",
17
24
  message: "px-3 py-6 text-center text-sm text-zinc-500 dark:text-zinc-400",
18
25
  spinner: "size-4 animate-spin rounded-full border-2 border-zinc-300 border-t-blue-600",
@@ -20,6 +27,7 @@ const DEFAULT_CLASSES = {
20
27
  }
21
28
 
22
29
  const ICONS = {
30
+ search: '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" d="m14.5 14.5 3 3m-1.25-8.25a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/></svg>',
23
31
  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
32
  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
33
  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>'
@@ -61,6 +69,7 @@ export class TamSelect {
61
69
  minQueryLength: 0,
62
70
  valueField: "value",
63
71
  labelField: "label",
72
+ imageField: "image",
64
73
  itemsPath: "items",
65
74
  paginationPath: "pagination",
66
75
  classes: {},
@@ -107,6 +116,9 @@ export class TamSelect {
107
116
 
108
117
  this.values = document.createElement("div")
109
118
  this.values.className = "contents"
119
+ this.searchIcon = document.createElement("span")
120
+ this.searchIcon.className = this.classes.searchIcon
121
+ this.searchIcon.innerHTML = ICONS.search
110
122
  this.input = document.createElement("input")
111
123
  this.input.type = "text"
112
124
  this.input.className = this.classes.input
@@ -132,7 +144,7 @@ export class TamSelect {
132
144
  this.dropdown.setAttribute("role", "listbox")
133
145
  if (this.multiple) this.dropdown.setAttribute("aria-multiselectable", "true")
134
146
 
135
- this.control.append(this.values, this.input, this.clearButton, this.chevron)
147
+ this.control.append(this.values, this.searchIcon, this.input, this.clearButton, this.chevron)
136
148
  this.wrapper.append(this.control, this.dropdown)
137
149
  this.select.after(this.wrapper)
138
150
  this.applyDisabled()
@@ -171,7 +183,16 @@ export class TamSelect {
171
183
  readNativeOptions() {
172
184
  this.items = Array.from(this.select.options)
173
185
  .filter(option => option.value !== "" || option.selected)
174
- .map(option => ({ value: option.value, label: option.textContent.trim(), disabled: option.disabled, selected: option.selected, option }))
186
+ .map(option => ({
187
+ value: option.value,
188
+ label: option.textContent.trim(),
189
+ detail: option.dataset.detail || null,
190
+ meta: option.dataset.meta || null,
191
+ image: option.dataset.image || null,
192
+ disabled: option.disabled,
193
+ selected: option.selected,
194
+ option
195
+ }))
175
196
  this.filterLocal(false)
176
197
  }
177
198
 
@@ -179,7 +200,7 @@ export class TamSelect {
179
200
 
180
201
  filterLocal(render = true) {
181
202
  const needle = normalize(this.query)
182
- this.filtered = this.items.filter(item => !needle || normalize(item.label).includes(needle))
203
+ this.filtered = this.items.filter(item => !needle || [item.label, item.detail, item.meta].some(value => normalize(value).includes(needle)))
183
204
  this.activeIndex = this.filtered.findIndex(item => !item.disabled)
184
205
  if (render) this.renderDropdown()
185
206
  }
@@ -189,12 +210,10 @@ export class TamSelect {
189
210
  this.values.replaceChildren()
190
211
  if (this.multiple) selected.forEach(item => this.values.append(this.makeTag(item)))
191
212
  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)
213
+ this.values.append(this.makeItemContent(selected[0], true))
196
214
  }
197
215
  const hasValue = selected.length > 0
216
+ this.searchIcon.classList.toggle("hidden", !this.options.searchable || (hasValue && !this.opened))
198
217
  this.input.classList.toggle("hidden", !this.multiple && hasValue && !this.opened)
199
218
  this.input.placeholder = hasValue ? "" : this.options.placeholder
200
219
  this.clearButton.classList.toggle("hidden", !this.options.clearable || !hasValue || this.select.disabled)
@@ -247,10 +266,14 @@ export class TamSelect {
247
266
  option.setAttribute("role", "option")
248
267
  option.setAttribute("aria-selected", String(Boolean(item.selected)))
249
268
  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)
269
+ const content = this.makeItemContent(item)
270
+ option.append(content)
271
+ if (item.meta) {
272
+ const meta = document.createElement("span")
273
+ meta.className = this.classes.optionMeta
274
+ meta.textContent = item.meta
275
+ option.append(meta)
276
+ }
254
277
  if (item.selected) {
255
278
  const check = document.createElement("span")
256
279
  check.innerHTML = ICONS.check
@@ -261,6 +284,34 @@ export class TamSelect {
261
284
  return option
262
285
  }
263
286
 
287
+ makeItemContent(item, selected = false) {
288
+ const content = document.createElement("span")
289
+ content.className = this.classes.optionContent
290
+ if (selected) content.classList.add("min-w-0", "flex-1")
291
+ if (item.image) {
292
+ const image = document.createElement("img")
293
+ image.className = this.classes.optionImage
294
+ image.src = item.image
295
+ image.alt = ""
296
+ image.loading = "lazy"
297
+ content.append(image)
298
+ }
299
+ const text = document.createElement("span")
300
+ text.className = this.classes.optionText
301
+ const label = document.createElement("span")
302
+ label.className = `${this.classes.optionLabel} truncate`
303
+ label.textContent = item.label
304
+ text.append(label)
305
+ if (item.detail) {
306
+ const detail = document.createElement("span")
307
+ detail.className = `${this.classes.optionDetail} truncate`
308
+ detail.textContent = item.detail
309
+ text.append(detail)
310
+ }
311
+ content.append(text)
312
+ return content
313
+ }
314
+
264
315
  makeCreateOption() {
265
316
  const option = document.createElement("div")
266
317
  option.className = this.classes.option
@@ -361,12 +412,29 @@ export class TamSelect {
361
412
  addItem(raw) {
362
413
  const value = String(raw.value ?? raw[this.options.valueField])
363
414
  const existing = this.items.find(item => String(item.value) === value)
364
- if (existing) return existing
415
+ if (existing) {
416
+ const image = raw.image ?? raw[this.options.imageField]
417
+ Object.assign(existing, raw, {
418
+ detail: raw.detail ?? existing.detail,
419
+ meta: raw.meta ?? existing.meta,
420
+ image: image ?? existing.image
421
+ })
422
+ if (existing.option) {
423
+ if (existing.detail != null) existing.option.dataset.detail = String(existing.detail)
424
+ if (existing.meta != null) existing.option.dataset.meta = String(existing.meta)
425
+ if (existing.image != null) existing.option.dataset.image = String(existing.image)
426
+ }
427
+ return existing
428
+ }
365
429
  const label = String(raw.label ?? raw[this.options.labelField] ?? value)
366
430
  const option = new Option(label, value, Boolean(raw.selected), Boolean(raw.selected))
367
431
  option.dataset.tamSelectGenerated = ""
432
+ if (raw.detail != null) option.dataset.detail = String(raw.detail)
433
+ if (raw.meta != null) option.dataset.meta = String(raw.meta)
434
+ const image = raw.image ?? raw[this.options.imageField]
435
+ if (image != null) option.dataset.image = String(image)
368
436
  this.select.add(option)
369
- const item = { ...raw, value, label, option, selected: option.selected, disabled: Boolean(raw.disabled) }
437
+ const item = { ...raw, value, label, image: image == null ? null : String(image), option, selected: option.selected, disabled: Boolean(raw.disabled) }
370
438
  option.disabled = item.disabled
371
439
  this.items.push(item)
372
440
  this.filterLocal(false)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tam_select
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tamiru Hailu
@@ -62,6 +62,7 @@ files:
62
62
  - lib/generators/tam_select/templates/tam_select_helper.rb
63
63
  - lib/generators/tam_select/templates/tam_select_input.rb
64
64
  - lib/generators/tam_select/templates/tam_select_paginatable.rb
65
+ - lib/generators/tam_select/templates/tam_select_remote.rb
65
66
  - lib/generators/tam_select/templates/tam_select_remote_controller.rb
66
67
  - lib/tam_select.rb
67
68
  - lib/tam_select/engine.rb