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 +7 -0
- data/LICENSE +17 -0
- data/README.md +215 -0
- data/Rakefile +8 -0
- data/examples/form.html.erb +30 -0
- data/examples/regions_controller.rb +10 -0
- data/lib/generators/tam_select/install_generator.rb +35 -0
- data/lib/generators/tam_select/templates/tam_select_controller.js +26 -0
- data/lib/generators/tam_select/templates/tam_select_helper.rb +17 -0
- data/lib/generators/tam_select/templates/tam_select_input.rb +30 -0
- data/lib/generators/tam_select/templates/tam_select_paginatable.rb +25 -0
- data/lib/tam_select/engine.rb +6 -0
- data/lib/tam_select/version.rb +3 -0
- data/lib/tam_select.rb +6 -0
- data/src/tam-select.js +482 -0
- metadata +93 -0
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
|
+
[](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,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
|
data/lib/tam_select.rb
ADDED
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: []
|