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 +4 -4
- data/README.md +117 -26
- data/lib/generators/tam_select/install_generator.rb +1 -0
- data/lib/generators/tam_select/templates/tam_select_paginatable.rb +11 -4
- data/lib/generators/tam_select/templates/tam_select_remote.rb +60 -0
- data/lib/generators/tam_select/templates/tam_select_remote_controller.rb +3 -41
- data/lib/tam_select/version.rb +1 -1
- data/src/tam-select.js +90 -22
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 77477a755bd92b2c88c2f6909b1cd30fc80f4586cc06b047a60a524e95b08b8f
|
|
4
|
+
data.tar.gz: 55381cbe7a64e822f2f2ea6d48afc933d00f353b4f5563d733606b1520bc09df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 749ebc117bccbcb42ada9476629f5897cec512fbd80c669be9bf64150554e2e217907efbd4d4cbb636707bb9650921f630c164ad6fa1cca12141309d1bfcba67
|
|
7
|
+
data.tar.gz: b2b39df96276b3255dce1ab5903ec53ede199a0e741f51b36d243bc129708992a09035150c4f946dfd2da2a065dc43ec9267886d985fcc39a8fff496d95014d4
|
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
# Tam Select
|
|
2
2
|
|
|
3
3
|
[](LICENSE)
|
|
4
|
-
[](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
|
|
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:
|
|
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
|
-
|
|
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": "
|
|
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
|
-
###
|
|
224
|
+
### Add remote search to an existing controller
|
|
176
225
|
|
|
177
|
-
|
|
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/
|
|
181
|
-
class
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
265
|
+
remoteUrl: tam_select_options_regions_path(format: :json),
|
|
209
266
|
minQueryLength: 1
|
|
210
267
|
}
|
|
211
268
|
} %>
|
|
212
269
|
```
|
|
213
270
|
|
|
214
|
-
|
|
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 /
|
|
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/
|
|
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
|
|
15
|
-
label:
|
|
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
|
|
2
|
+
include TamSelectRemote
|
|
3
3
|
|
|
4
|
-
|
|
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
|
data/lib/tam_select/version.rb
CHANGED
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-
|
|
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
|
-
|
|
15
|
-
|
|
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 => ({
|
|
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 ||
|
|
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
|
-
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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)
|
|
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.
|
|
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
|