remote_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/CHANGELOG.md +16 -0
- data/LICENSE +21 -0
- data/README.md +216 -0
- data/app/assets/stylesheets/remote_select.css +238 -0
- data/app/javascript/remote_select.js +456 -0
- data/config/locales/en.yml +5 -0
- data/lib/remote_select/engine.rb +11 -0
- data/lib/remote_select/version.rb +3 -0
- data/lib/remote_select/view_helpers.rb +54 -0
- data/lib/remote_select.rb +7 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4cce3aae6bcdda3d4094e25da7d93613b40d1971a6e62284dd0f8416569e4fa4
|
|
4
|
+
data.tar.gz: 3f4d2f7cc3ce1f84e87b4da8b5b1c9b9e33611bfae03a03e354ea2d4d6db005d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2d0f92bff38d7f97943075af39052ff425cd3a1e1a6314bafaa3ddb0073383c2d716bc01dc00831657858187fa4e47c55567bc2e98f62a647fb61b3b23961d62
|
|
7
|
+
data.tar.gz: 95699a31cdf43ba2ff131e4bd9a017380da506f21d6f1946af94d9b5712da2e51383414e718903afb0fc5288aed498ed4f440f2d6fb111010e5c9fad7b022c98
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2026-03-20
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Initial release
|
|
9
|
+
- `remote_select` form helper
|
|
10
|
+
- Vanilla JS widget (`RemoteSelect`) with remote data fetching
|
|
11
|
+
- Keyboard navigation and full ARIA support
|
|
12
|
+
- Pagination (infinite scroll)
|
|
13
|
+
- Dependent selects with auto-clearing
|
|
14
|
+
- Turbo (full-page + Frames) compatibility
|
|
15
|
+
- i18n support via Rails `I18n.t`
|
|
16
|
+
- CSS custom properties for zero-fork theming
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ghennadii Mir
|
|
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. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# remote_select
|
|
2
|
+
|
|
3
|
+
A lightweight, zero-dependency Rails form helper that renders a searchable select whose options are fetched from a JSON endpoint.
|
|
4
|
+
|
|
5
|
+
Replaces Select2 / Tom Select for the common case of "type to search a remote list" — with no npm packages, no jQuery, and full Turbo support.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Remote data fetching** with debouncing and request cancellation (AbortController)
|
|
10
|
+
- **Keyboard navigation** — ↑↓ arrows, Enter to select, Escape to close
|
|
11
|
+
- **Full ARIA** — `role="combobox"`, `aria-expanded`, `aria-activedescendant`, `role="listbox"`
|
|
12
|
+
- **Pagination** — infinite scroll loading of additional results
|
|
13
|
+
- **Dependent selects** — pass values from other fields as query parameters automatically
|
|
14
|
+
- **Auto-clearing** — clear selection when a dependency changes
|
|
15
|
+
- **Minimum character threshold** — configurable before search fires
|
|
16
|
+
- **Preselected values** — display preselected options on page load
|
|
17
|
+
- **i18n** — default strings resolved via `I18n.t` with English fallbacks
|
|
18
|
+
- **Turbo compatible** — full-page navigation and Turbo Frames, no Stimulus required
|
|
19
|
+
- **No dependencies** — pure vanilla JavaScript
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Add to your `Gemfile`:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
gem "remote_select"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Run:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bundle install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### JavaScript
|
|
36
|
+
|
|
37
|
+
**Importmap** (Rails 8 default):
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# config/importmap.rb
|
|
41
|
+
pin "remote_select", to: "remote_select.js"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
// app/javascript/application.js
|
|
46
|
+
import "remote_select"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**esbuild / rollup / webpack**:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import "remote_select"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Sprockets** (`application.js`):
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
//= require remote_select
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Stylesheet
|
|
62
|
+
|
|
63
|
+
**Sass / SCSS**:
|
|
64
|
+
|
|
65
|
+
```scss
|
|
66
|
+
@import 'remote_select';
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Sprockets** (`application.css`):
|
|
70
|
+
|
|
71
|
+
```css
|
|
72
|
+
/*= require remote_select */
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
### Basic
|
|
78
|
+
|
|
79
|
+
```erb
|
|
80
|
+
<%= form_with model: @article do |form| %>
|
|
81
|
+
<%= form.label :company_id %>
|
|
82
|
+
<%= remote_select(form, :company_id, search_companies_path,
|
|
83
|
+
placeholder: "Type to search companies...",
|
|
84
|
+
min_chars: 2) %>
|
|
85
|
+
<% end %>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### With preselected value
|
|
89
|
+
|
|
90
|
+
```erb
|
|
91
|
+
<%= remote_select(form, :company_id, search_companies_path,
|
|
92
|
+
selected_value: @article.company_id,
|
|
93
|
+
selected_text: @article.company&.name) %>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Dependent select (clears when parent changes)
|
|
97
|
+
|
|
98
|
+
```erb
|
|
99
|
+
<%# Parent — standard Rails select %>
|
|
100
|
+
<%= form.select :source, sources_options, {}, { id: "source-selector" } %>
|
|
101
|
+
|
|
102
|
+
<%# Child — clears and re-fetches when source changes %>
|
|
103
|
+
<%= remote_select(form, :company_id, search_companies_path,
|
|
104
|
+
depends_on: "#source-selector",
|
|
105
|
+
clear_on_dependency_change: true) %>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Multiple dependencies
|
|
109
|
+
|
|
110
|
+
```erb
|
|
111
|
+
<%= remote_select(form, :city_id, search_cities_path,
|
|
112
|
+
depends_on: "#state-selector, #country-selector") %>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Helper options
|
|
116
|
+
|
|
117
|
+
| Option | Type | Default | Description |
|
|
118
|
+
|--------|------|---------|-------------|
|
|
119
|
+
| `endpoint` | String | required | JSON endpoint URL |
|
|
120
|
+
| `selected_value` | String/Integer | `nil` | Pre-selected value ID |
|
|
121
|
+
| `selected_text` | String | `nil` | Pre-selected display text |
|
|
122
|
+
| `min_chars` | Integer | `2` | Chars needed before search fires |
|
|
123
|
+
| `debounce_delay` | Integer | `250` | Debounce in milliseconds |
|
|
124
|
+
| `placeholder` | String | i18n | Placeholder text |
|
|
125
|
+
| `per_page` | Integer | `20` | Results per page |
|
|
126
|
+
| `depends_on` | String | `nil` | CSS selector(s) of dependency field(s) |
|
|
127
|
+
| `clear_on_dependency_change` | Boolean | `true` | Clear when dependency changes |
|
|
128
|
+
| `empty_text` | String | i18n | Text shown when no results |
|
|
129
|
+
| `loading_text` | String | i18n | Text shown while loading |
|
|
130
|
+
| `html` | Hash | `{}` | Extra HTML attrs on the hidden input |
|
|
131
|
+
|
|
132
|
+
## i18n
|
|
133
|
+
|
|
134
|
+
Override any key in your locale files:
|
|
135
|
+
|
|
136
|
+
```yaml
|
|
137
|
+
# config/locales/en.yml
|
|
138
|
+
en:
|
|
139
|
+
remote_select:
|
|
140
|
+
placeholder: "Type to search..."
|
|
141
|
+
empty_text: "No results found"
|
|
142
|
+
loading_text: "Loading..."
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Backend endpoint
|
|
146
|
+
|
|
147
|
+
Your action must return JSON:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
def search_companies
|
|
151
|
+
query = params[:q].to_s.strip
|
|
152
|
+
page = (params[:page] || 1).to_i
|
|
153
|
+
per_page = (params[:per_page] || 20).to_i
|
|
154
|
+
|
|
155
|
+
companies = Company.order(:name)
|
|
156
|
+
companies = companies.where("name ILIKE ?", "%#{query}%") if query.present?
|
|
157
|
+
|
|
158
|
+
total = companies.count
|
|
159
|
+
results = companies.limit(per_page).offset((page - 1) * per_page)
|
|
160
|
+
|
|
161
|
+
render json: {
|
|
162
|
+
results: results.map { |c| { id: c.id, text: c.name } },
|
|
163
|
+
has_more: (page * per_page) < total,
|
|
164
|
+
total: total
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Required response shape
|
|
170
|
+
|
|
171
|
+
```json
|
|
172
|
+
{
|
|
173
|
+
"results": [{ "id": 1, "text": "Acme Corp" }],
|
|
174
|
+
"has_more": true,
|
|
175
|
+
"total": 150
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Theming
|
|
180
|
+
|
|
181
|
+
All visual properties are CSS custom properties on `.remote-select-container`. Override without touching the stylesheet:
|
|
182
|
+
|
|
183
|
+
```css
|
|
184
|
+
.my-form .remote-select-container {
|
|
185
|
+
--rs-focus-border-color: #198754;
|
|
186
|
+
--rs-focus-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.25);
|
|
187
|
+
--rs-item-hover-bg: #d1e7dd;
|
|
188
|
+
--rs-border-radius: 0.25rem;
|
|
189
|
+
--rs-dropdown-max-height: 400px;
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## JavaScript API
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
// Manual init
|
|
197
|
+
const rs = new RemoteSelect(document.querySelector('#my-input'), {
|
|
198
|
+
endpoint: '/api/search',
|
|
199
|
+
minChars: 3
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
rs.setParam('category', 'books'); // add extra query param
|
|
203
|
+
rs.clearParams(); // remove all extra params
|
|
204
|
+
rs.clearSelection();
|
|
205
|
+
rs.openDropdown();
|
|
206
|
+
rs.closeDropdown();
|
|
207
|
+
rs.destroy(); // remove all listeners + DOM
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Browser support
|
|
211
|
+
|
|
212
|
+
Modern browsers (Chrome, Firefox, Safari, Edge). Requires ES2020 (`??`, `async/await`, `AbortController`). No IE11.
|
|
213
|
+
|
|
214
|
+
## License
|
|
215
|
+
|
|
216
|
+
MIT
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/* RemoteSelect component styles */
|
|
2
|
+
|
|
3
|
+
/* ── Theming variables ────────────────────────────────────────────────────── */
|
|
4
|
+
/* All colors and key measurements are CSS custom properties. */
|
|
5
|
+
/* Override any of these on .remote-select-container (or :root) to retheme */
|
|
6
|
+
/* without touching this stylesheet. */
|
|
7
|
+
.remote-select-container {
|
|
8
|
+
--rs-text-color: #212529;
|
|
9
|
+
--rs-muted-color: #6c757d;
|
|
10
|
+
--rs-bg-color: #fff;
|
|
11
|
+
--rs-border-color: #dee2e6;
|
|
12
|
+
--rs-border-hover-color: #adb5bd;
|
|
13
|
+
--rs-border-radius: 0.375rem;
|
|
14
|
+
--rs-focus-border-color: #86b7fe;
|
|
15
|
+
--rs-focus-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
|
16
|
+
--rs-item-hover-bg: #e9ecef;
|
|
17
|
+
--rs-item-hover-color: #1e2125;
|
|
18
|
+
--rs-item-active-bg: #dee2e6;
|
|
19
|
+
--rs-item-active-color: #000;
|
|
20
|
+
--rs-error-color: #dc3545;
|
|
21
|
+
--rs-error-bg: #f8d7da;
|
|
22
|
+
--rs-shimmer-color: #f8f9fa;
|
|
23
|
+
--rs-zindex: 1050;
|
|
24
|
+
--rs-dropdown-max-height: 300px;
|
|
25
|
+
--rs-results-max-height: 250px;
|
|
26
|
+
|
|
27
|
+
position: relative;
|
|
28
|
+
width: 100%;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.remote-select-trigger {
|
|
32
|
+
display: flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
justify-content: space-between;
|
|
35
|
+
padding: 0.375rem 2.25rem 0.375rem 0.75rem;
|
|
36
|
+
font-size: 1rem;
|
|
37
|
+
font-weight: 400;
|
|
38
|
+
line-height: 1.5;
|
|
39
|
+
color: var(--rs-text-color);
|
|
40
|
+
background-color: var(--rs-bg-color);
|
|
41
|
+
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
|
|
42
|
+
background-repeat: no-repeat;
|
|
43
|
+
background-position: right 0.75rem center;
|
|
44
|
+
background-size: 16px 12px;
|
|
45
|
+
border: 1px solid var(--rs-border-color);
|
|
46
|
+
border-radius: var(--rs-border-radius);
|
|
47
|
+
appearance: none;
|
|
48
|
+
cursor: pointer;
|
|
49
|
+
user-select: none;
|
|
50
|
+
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.remote-select-trigger:hover {
|
|
54
|
+
border-color: var(--rs-border-hover-color);
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.remote-select-trigger:focus,
|
|
59
|
+
.remote-select-trigger:focus-visible {
|
|
60
|
+
color: var(--rs-text-color);
|
|
61
|
+
background-color: var(--rs-bg-color);
|
|
62
|
+
border-color: var(--rs-focus-border-color);
|
|
63
|
+
outline: 0;
|
|
64
|
+
box-shadow: var(--rs-focus-shadow);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.remote-select-trigger:disabled {
|
|
68
|
+
color: var(--rs-muted-color);
|
|
69
|
+
background-color: var(--rs-item-hover-bg);
|
|
70
|
+
border-color: var(--rs-border-color);
|
|
71
|
+
cursor: not-allowed;
|
|
72
|
+
opacity: 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.remote-select-value {
|
|
76
|
+
flex: 1;
|
|
77
|
+
overflow: hidden;
|
|
78
|
+
text-overflow: ellipsis;
|
|
79
|
+
white-space: nowrap;
|
|
80
|
+
color: var(--rs-muted-color);
|
|
81
|
+
cursor: pointer;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.remote-select-trigger.has-value .remote-select-value {
|
|
85
|
+
color: var(--rs-text-color);
|
|
86
|
+
font-weight: 400;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.remote-select-arrow {
|
|
90
|
+
display: none;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* Dropdown: hidden by default, shown when container has .is-open */
|
|
94
|
+
.remote-select-dropdown {
|
|
95
|
+
display: none;
|
|
96
|
+
position: absolute;
|
|
97
|
+
top: calc(100% + 0.125rem);
|
|
98
|
+
left: 0;
|
|
99
|
+
right: 0;
|
|
100
|
+
z-index: var(--rs-zindex);
|
|
101
|
+
background-color: var(--rs-bg-color);
|
|
102
|
+
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
103
|
+
border-radius: var(--rs-border-radius);
|
|
104
|
+
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
|
105
|
+
max-height: var(--rs-dropdown-max-height);
|
|
106
|
+
flex-direction: column;
|
|
107
|
+
overflow: hidden;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.remote-select-container.is-open .remote-select-dropdown {
|
|
111
|
+
display: flex;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.remote-select-search {
|
|
115
|
+
width: 100%;
|
|
116
|
+
padding: 0.5rem 0.75rem;
|
|
117
|
+
border: none;
|
|
118
|
+
border-bottom: 1px solid var(--rs-border-color);
|
|
119
|
+
font-size: 0.875rem;
|
|
120
|
+
line-height: 1.5;
|
|
121
|
+
color: var(--rs-text-color);
|
|
122
|
+
background-color: var(--rs-bg-color);
|
|
123
|
+
outline: none;
|
|
124
|
+
cursor: text;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.remote-select-search::placeholder {
|
|
128
|
+
color: var(--rs-muted-color);
|
|
129
|
+
opacity: 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.remote-select-search:focus {
|
|
133
|
+
color: var(--rs-text-color);
|
|
134
|
+
background-color: var(--rs-bg-color);
|
|
135
|
+
border-bottom-color: var(--rs-focus-border-color);
|
|
136
|
+
outline: 0;
|
|
137
|
+
box-shadow: inset 0 -1px 0 0 rgba(13, 110, 253, 0.25);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.remote-select-results {
|
|
141
|
+
flex: 1;
|
|
142
|
+
overflow-y: auto;
|
|
143
|
+
max-height: var(--rs-results-max-height);
|
|
144
|
+
padding: 0.25rem 0;
|
|
145
|
+
scrollbar-width: thin;
|
|
146
|
+
scrollbar-color: var(--rs-border-color) transparent;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.remote-select-item {
|
|
150
|
+
padding: 0.375rem 0.75rem;
|
|
151
|
+
font-size: 1rem;
|
|
152
|
+
color: var(--rs-text-color);
|
|
153
|
+
cursor: pointer;
|
|
154
|
+
transition: background-color 0.15s ease, color 0.15s ease;
|
|
155
|
+
user-select: none;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.remote-select-item:hover,
|
|
159
|
+
.remote-select-item.is-focused {
|
|
160
|
+
background-color: var(--rs-item-hover-bg);
|
|
161
|
+
color: var(--rs-item-hover-color);
|
|
162
|
+
outline: none;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.remote-select-item:active {
|
|
166
|
+
background-color: var(--rs-item-active-bg);
|
|
167
|
+
color: var(--rs-item-active-color);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.remote-select-item:first-child {
|
|
171
|
+
margin-top: 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.remote-select-item:last-child {
|
|
175
|
+
margin-bottom: 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.remote-select-message {
|
|
179
|
+
padding: 0.75rem;
|
|
180
|
+
text-align: center;
|
|
181
|
+
color: var(--rs-muted-color);
|
|
182
|
+
font-size: 0.875rem;
|
|
183
|
+
font-style: italic;
|
|
184
|
+
user-select: none;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.remote-select-message.remote-select-error {
|
|
188
|
+
color: var(--rs-error-color);
|
|
189
|
+
background-color: var(--rs-error-bg);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.remote-select-message.remote-select-loader {
|
|
193
|
+
font-style: normal;
|
|
194
|
+
background: linear-gradient(90deg, transparent, var(--rs-shimmer-color), transparent);
|
|
195
|
+
background-size: 200px 100%;
|
|
196
|
+
animation: rs-loading-shimmer 1.5s infinite;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
@keyframes rs-loading-shimmer {
|
|
200
|
+
0% { background-position: -200px 0; }
|
|
201
|
+
100% { background-position: calc(200px + 100%) 0; }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* Scrollbar styling for results (webkit browsers) */
|
|
205
|
+
.remote-select-results::-webkit-scrollbar {
|
|
206
|
+
width: 6px;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.remote-select-results::-webkit-scrollbar-track {
|
|
210
|
+
background: transparent;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.remote-select-results::-webkit-scrollbar-thumb {
|
|
214
|
+
background: var(--rs-border-color);
|
|
215
|
+
border-radius: 3px;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.remote-select-results::-webkit-scrollbar-thumb:hover {
|
|
219
|
+
background: var(--rs-border-hover-color);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* Loading state for trigger */
|
|
223
|
+
.remote-select-trigger.loading {
|
|
224
|
+
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='%23343a40' d='M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zM7 4v4.07L9.5 9.5l.707-.707L8 6.586V4H7z'/%3e%3c/svg%3e");
|
|
225
|
+
pointer-events: none;
|
|
226
|
+
opacity: 0.7;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* Responsive adjustments */
|
|
230
|
+
@media (max-width: 576px) {
|
|
231
|
+
.remote-select-dropdown {
|
|
232
|
+
max-height: 60vh;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.remote-select-results {
|
|
236
|
+
max-height: calc(60vh - 60px);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RemoteSelect - Vanilla JS remote data select component
|
|
3
|
+
* A lightweight replacement for select2/tom-select with remote data source
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Remote data fetching with debouncing
|
|
7
|
+
* - Pagination support
|
|
8
|
+
* - Dynamic parameters (e.g., dependent selects)
|
|
9
|
+
* - Minimum character threshold
|
|
10
|
+
* - Preselected values
|
|
11
|
+
* - No jQuery dependency
|
|
12
|
+
*/
|
|
13
|
+
class RemoteSelect {
|
|
14
|
+
constructor(element, options = {}) {
|
|
15
|
+
this.element = element;
|
|
16
|
+
const ds = element.dataset;
|
|
17
|
+
|
|
18
|
+
// Options: JS options take precedence over data attributes, falling back to defaults.
|
|
19
|
+
// The object is built explicitly — no spread of raw `options` after parsed values,
|
|
20
|
+
// which would silently re-override the carefully resolved types below.
|
|
21
|
+
this.options = {
|
|
22
|
+
endpoint: options.endpoint ?? ds.endpoint,
|
|
23
|
+
minChars: parseInt(options.minChars ?? ds.minChars ?? 2, 10),
|
|
24
|
+
debounceDelay: parseInt(options.debounceDelay ?? ds.debounceDelay ?? 250, 10),
|
|
25
|
+
placeholder: options.placeholder ?? ds.placeholder ?? 'Type to search...',
|
|
26
|
+
perPage: parseInt(options.perPage ?? ds.perPage ?? 20, 10),
|
|
27
|
+
dependsOn: options.dependsOn ?? ds.dependsOn ?? null,
|
|
28
|
+
clearOnDependencyChange: _parseBool(options.clearOnDependencyChange ?? ds.clearOnDependencyChange, true),
|
|
29
|
+
emptyText: options.emptyText ?? ds.emptyText ?? 'No results found',
|
|
30
|
+
loadingText: options.loadingText ?? ds.loadingText ?? 'Loading...',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
this.selectedValue = ds.selectedValue || '';
|
|
34
|
+
this.selectedText = ds.selectedText || '';
|
|
35
|
+
this.currentQuery = '';
|
|
36
|
+
this.currentPage = 1;
|
|
37
|
+
this.hasMore = false;
|
|
38
|
+
this.loading = false;
|
|
39
|
+
this.debounceTimer = null;
|
|
40
|
+
this.results = [];
|
|
41
|
+
this.additionalParams = {};
|
|
42
|
+
this.focusedIndex = -1;
|
|
43
|
+
this._abortController = null;
|
|
44
|
+
this._dependencyHandlers = [];
|
|
45
|
+
// Unique ID prefix for ARIA id references
|
|
46
|
+
this._uid = `rs-${Math.random().toString(36).slice(2, 9)}`;
|
|
47
|
+
|
|
48
|
+
this._init();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_init() {
|
|
52
|
+
this.element.style.display = 'none';
|
|
53
|
+
this._createUI();
|
|
54
|
+
this._setupEventListeners();
|
|
55
|
+
if (this.options.dependsOn) this._setupDependency();
|
|
56
|
+
if (this.selectedValue && this.selectedText) this._updateTriggerDisplay();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_createUI() {
|
|
60
|
+
this.container = document.createElement('div');
|
|
61
|
+
this.container.className = 'remote-select-container';
|
|
62
|
+
|
|
63
|
+
// Trigger — acts as the visible combobox control
|
|
64
|
+
this.trigger = document.createElement('div');
|
|
65
|
+
this.trigger.className = 'remote-select-trigger';
|
|
66
|
+
this.trigger.setAttribute('tabindex', '0');
|
|
67
|
+
this.trigger.setAttribute('role', 'combobox');
|
|
68
|
+
this.trigger.setAttribute('aria-expanded', 'false');
|
|
69
|
+
this.trigger.setAttribute('aria-haspopup', 'listbox');
|
|
70
|
+
this.trigger.setAttribute('aria-controls', `${this._uid}-listbox`);
|
|
71
|
+
|
|
72
|
+
this.valueSpan = document.createElement('span');
|
|
73
|
+
this.valueSpan.className = 'remote-select-value';
|
|
74
|
+
this.valueSpan.textContent = this.selectedText || this.options.placeholder;
|
|
75
|
+
this.trigger.appendChild(this.valueSpan);
|
|
76
|
+
|
|
77
|
+
// Dropdown
|
|
78
|
+
this.dropdown = document.createElement('div');
|
|
79
|
+
this.dropdown.className = 'remote-select-dropdown';
|
|
80
|
+
this.dropdown.id = `${this._uid}-listbox`;
|
|
81
|
+
this.dropdown.setAttribute('role', 'listbox');
|
|
82
|
+
|
|
83
|
+
// Search input inside dropdown
|
|
84
|
+
this.searchInput = document.createElement('input');
|
|
85
|
+
this.searchInput.type = 'text';
|
|
86
|
+
this.searchInput.className = 'remote-select-search';
|
|
87
|
+
this.searchInput.placeholder = this.options.placeholder;
|
|
88
|
+
this.searchInput.setAttribute('autocomplete', 'off');
|
|
89
|
+
this.searchInput.setAttribute('aria-autocomplete', 'list');
|
|
90
|
+
this.searchInput.setAttribute('aria-controls', `${this._uid}-listbox`);
|
|
91
|
+
|
|
92
|
+
// Results container
|
|
93
|
+
this.resultsContainer = document.createElement('div');
|
|
94
|
+
this.resultsContainer.className = 'remote-select-results';
|
|
95
|
+
|
|
96
|
+
this.dropdown.appendChild(this.searchInput);
|
|
97
|
+
this.dropdown.appendChild(this.resultsContainer);
|
|
98
|
+
this.container.appendChild(this.trigger);
|
|
99
|
+
this.container.appendChild(this.dropdown);
|
|
100
|
+
|
|
101
|
+
this.element.parentNode.insertBefore(this.container, this.element.nextSibling);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_setupEventListeners() {
|
|
105
|
+
// Open/close via click on trigger
|
|
106
|
+
this.trigger.addEventListener('click', (e) => {
|
|
107
|
+
e.stopPropagation();
|
|
108
|
+
this.toggleDropdown();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Keyboard activation of trigger (Space/Enter opens; ArrowDown opens and moves focus)
|
|
112
|
+
this.trigger.addEventListener('keydown', (e) => {
|
|
113
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
this.openDropdown();
|
|
116
|
+
} else if (e.key === 'ArrowDown') {
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
this.openDropdown();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Search input typing
|
|
123
|
+
this.searchInput.addEventListener('input', (e) => {
|
|
124
|
+
this._handleSearchInput(e.target.value);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Keyboard navigation within dropdown
|
|
128
|
+
this.searchInput.addEventListener('keydown', (e) => {
|
|
129
|
+
this._handleKeyboardNavigation(e);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Infinite scroll pagination
|
|
133
|
+
this.resultsContainer.addEventListener('scroll', () => {
|
|
134
|
+
if (this._isScrolledToBottom() && this.hasMore && !this.loading) {
|
|
135
|
+
this.currentPage++;
|
|
136
|
+
this._fetchResults(this.currentQuery, true);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Close on outside click — stored as named ref for cleanup in destroy()
|
|
141
|
+
this._outsideClickHandler = (e) => {
|
|
142
|
+
if (!this.container.contains(e.target)) this.closeDropdown();
|
|
143
|
+
};
|
|
144
|
+
document.addEventListener('click', this._outsideClickHandler);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_handleKeyboardNavigation(e) {
|
|
148
|
+
const items = Array.from(this.resultsContainer.querySelectorAll('.remote-select-item'));
|
|
149
|
+
if (!items.length) {
|
|
150
|
+
if (e.key === 'Escape') { this.closeDropdown(); this.trigger.focus(); }
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
switch (e.key) {
|
|
155
|
+
case 'ArrowDown':
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
this._setFocusedIndex(Math.min(this.focusedIndex + 1, items.length - 1), items);
|
|
158
|
+
break;
|
|
159
|
+
case 'ArrowUp':
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
if (this.focusedIndex <= 0) {
|
|
162
|
+
this._setFocusedIndex(-1, items);
|
|
163
|
+
} else {
|
|
164
|
+
this._setFocusedIndex(this.focusedIndex - 1, items);
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
case 'Enter':
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
if (this.focusedIndex >= 0 && items[this.focusedIndex]) {
|
|
170
|
+
items[this.focusedIndex].click();
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
case 'Escape':
|
|
174
|
+
this.closeDropdown();
|
|
175
|
+
this.trigger.focus();
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_setFocusedIndex(newIndex, items) {
|
|
181
|
+
// Remove highlight from previously focused item
|
|
182
|
+
if (this.focusedIndex >= 0 && items[this.focusedIndex]) {
|
|
183
|
+
items[this.focusedIndex].classList.remove('is-focused');
|
|
184
|
+
items[this.focusedIndex].setAttribute('aria-selected', 'false');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.focusedIndex = newIndex;
|
|
188
|
+
|
|
189
|
+
if (newIndex >= 0 && items[newIndex]) {
|
|
190
|
+
items[newIndex].classList.add('is-focused');
|
|
191
|
+
items[newIndex].setAttribute('aria-selected', 'true');
|
|
192
|
+
items[newIndex].scrollIntoView({ block: 'nearest' });
|
|
193
|
+
this.trigger.setAttribute('aria-activedescendant', items[newIndex].id);
|
|
194
|
+
this.searchInput.setAttribute('aria-activedescendant', items[newIndex].id);
|
|
195
|
+
} else {
|
|
196
|
+
this.trigger.removeAttribute('aria-activedescendant');
|
|
197
|
+
this.searchInput.removeAttribute('aria-activedescendant');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
_setupDependency() {
|
|
202
|
+
this.options.dependsOn.split(',').map(s => s.trim()).forEach(selector => {
|
|
203
|
+
const depEl = document.querySelector(selector);
|
|
204
|
+
if (!depEl) return;
|
|
205
|
+
|
|
206
|
+
const handler = (e) => {
|
|
207
|
+
const key = depEl.name || depEl.id;
|
|
208
|
+
this.additionalParams[key] = e.target.value;
|
|
209
|
+
if (this.options.clearOnDependencyChange) this.clearSelection();
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
depEl.addEventListener('change', handler);
|
|
213
|
+
this._dependencyHandlers.push({ element: depEl, handler });
|
|
214
|
+
|
|
215
|
+
// Set initial param value
|
|
216
|
+
const key = depEl.name || depEl.id;
|
|
217
|
+
this.additionalParams[key] = depEl.value;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
_handleSearchInput(query) {
|
|
222
|
+
clearTimeout(this.debounceTimer);
|
|
223
|
+
this.currentQuery = query;
|
|
224
|
+
this.currentPage = 1;
|
|
225
|
+
this.focusedIndex = -1;
|
|
226
|
+
|
|
227
|
+
if (query.length < this.options.minChars) {
|
|
228
|
+
this._setMessage(this.options.placeholder);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
this.debounceTimer = setTimeout(() => {
|
|
233
|
+
this._fetchResults(query, false);
|
|
234
|
+
}, this.options.debounceDelay);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async _fetchResults(query, append = false) {
|
|
238
|
+
if (this.loading) return;
|
|
239
|
+
|
|
240
|
+
// Cancel any in-flight request so stale results cannot overwrite the current query
|
|
241
|
+
if (this._abortController) this._abortController.abort();
|
|
242
|
+
this._abortController = new AbortController();
|
|
243
|
+
|
|
244
|
+
this.loading = true;
|
|
245
|
+
this._showLoading(append);
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const url = new URL(this.options.endpoint, window.location.origin);
|
|
249
|
+
url.searchParams.set('q', query);
|
|
250
|
+
url.searchParams.set('page', this.currentPage);
|
|
251
|
+
url.searchParams.set('per_page', this.options.perPage);
|
|
252
|
+
|
|
253
|
+
Object.entries(this.additionalParams).forEach(([key, val]) => {
|
|
254
|
+
if (val !== null && val !== undefined && val !== '') url.searchParams.set(key, val);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const response = await fetch(url, {
|
|
258
|
+
signal: this._abortController.signal,
|
|
259
|
+
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
263
|
+
|
|
264
|
+
const data = await response.json();
|
|
265
|
+
this.results = append ? [...this.results, ...data.results] : data.results;
|
|
266
|
+
this.hasMore = data.has_more || false;
|
|
267
|
+
this._renderResults(append);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
if (err.name === 'AbortError') return; // request was intentionally cancelled
|
|
270
|
+
console.error('RemoteSelect fetch error:', err);
|
|
271
|
+
this._showError();
|
|
272
|
+
} finally {
|
|
273
|
+
this.loading = false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
_showLoading(append) {
|
|
278
|
+
if (!append) {
|
|
279
|
+
this._setMessage(this.options.loadingText, 'remote-select-loader');
|
|
280
|
+
} else {
|
|
281
|
+
const loader = document.createElement('div');
|
|
282
|
+
loader.className = 'remote-select-message remote-select-loader';
|
|
283
|
+
loader.textContent = this.options.loadingText;
|
|
284
|
+
this.resultsContainer.appendChild(loader);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
_showError() {
|
|
289
|
+
this._setMessage('Error loading results', 'remote-select-error');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Clear the results area and render a single status message (safe — uses textContent)
|
|
293
|
+
_setMessage(text, extraClass = '') {
|
|
294
|
+
this.resultsContainer.innerHTML = '';
|
|
295
|
+
const msg = document.createElement('div');
|
|
296
|
+
msg.className = ['remote-select-message', extraClass].filter(Boolean).join(' ');
|
|
297
|
+
msg.textContent = text;
|
|
298
|
+
this.resultsContainer.appendChild(msg);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
_renderResults(append) {
|
|
302
|
+
if (!append) {
|
|
303
|
+
this.resultsContainer.innerHTML = '';
|
|
304
|
+
this.focusedIndex = -1;
|
|
305
|
+
} else {
|
|
306
|
+
const loader = this.resultsContainer.querySelector('.remote-select-loader');
|
|
307
|
+
if (loader) loader.remove();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (this.results.length === 0 && !append) {
|
|
311
|
+
this._setMessage(this.options.emptyText);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const toRender = append ? this.results.slice(-this.options.perPage) : this.results;
|
|
316
|
+
|
|
317
|
+
toRender.forEach(result => {
|
|
318
|
+
const escapedId = CSS.escape(String(result.id));
|
|
319
|
+
if (append && this.resultsContainer.querySelector(`[data-value="${escapedId}"]`)) return;
|
|
320
|
+
|
|
321
|
+
const item = document.createElement('div');
|
|
322
|
+
item.className = 'remote-select-item';
|
|
323
|
+
item.id = `${this._uid}-option-${result.id}`;
|
|
324
|
+
item.dataset.value = result.id;
|
|
325
|
+
item.textContent = result.text; // textContent — never innerHTML — prevents XSS
|
|
326
|
+
item.setAttribute('role', 'option');
|
|
327
|
+
item.setAttribute('aria-selected', 'false');
|
|
328
|
+
|
|
329
|
+
item.addEventListener('click', (e) => {
|
|
330
|
+
e.stopPropagation();
|
|
331
|
+
this.selectItem(result.id, result.text);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
this.resultsContainer.appendChild(item);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ─── Public API ───────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
selectItem(value, text) {
|
|
341
|
+
this.selectedValue = value;
|
|
342
|
+
this.selectedText = text;
|
|
343
|
+
this.element.value = value;
|
|
344
|
+
this.element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
345
|
+
this._updateTriggerDisplay();
|
|
346
|
+
this.closeDropdown();
|
|
347
|
+
this.trigger.focus();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
clearSelection() {
|
|
351
|
+
this.selectedValue = '';
|
|
352
|
+
this.selectedText = '';
|
|
353
|
+
this.element.value = '';
|
|
354
|
+
this._updateTriggerDisplay();
|
|
355
|
+
this.element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
toggleDropdown() {
|
|
359
|
+
this.container.classList.contains('is-open') ? this.closeDropdown() : this.openDropdown();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
openDropdown() {
|
|
363
|
+
if (this.container.classList.contains('is-open')) return;
|
|
364
|
+
this.container.classList.add('is-open');
|
|
365
|
+
this.trigger.setAttribute('aria-expanded', 'true');
|
|
366
|
+
this.searchInput.value = '';
|
|
367
|
+
this.currentQuery = '';
|
|
368
|
+
this.currentPage = 1;
|
|
369
|
+
this.focusedIndex = -1;
|
|
370
|
+
this._setMessage(this.options.placeholder);
|
|
371
|
+
this.searchInput.focus();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
closeDropdown() {
|
|
375
|
+
if (!this.container.classList.contains('is-open')) return;
|
|
376
|
+
this.container.classList.remove('is-open');
|
|
377
|
+
this.trigger.setAttribute('aria-expanded', 'false');
|
|
378
|
+
this.trigger.removeAttribute('aria-activedescendant');
|
|
379
|
+
this.searchInput.removeAttribute('aria-activedescendant');
|
|
380
|
+
this.searchInput.value = '';
|
|
381
|
+
this.focusedIndex = -1;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
setParam(key, value) { this.additionalParams[key] = value; }
|
|
385
|
+
clearParams() { this.additionalParams = {}; }
|
|
386
|
+
|
|
387
|
+
destroy() {
|
|
388
|
+
if (this._abortController) this._abortController.abort();
|
|
389
|
+
document.removeEventListener('click', this._outsideClickHandler);
|
|
390
|
+
this._dependencyHandlers.forEach(({ element, handler }) => {
|
|
391
|
+
element.removeEventListener('change', handler);
|
|
392
|
+
});
|
|
393
|
+
this._dependencyHandlers = [];
|
|
394
|
+
this.container.remove();
|
|
395
|
+
this.element.style.display = '';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ─── Private helpers ─────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
_updateTriggerDisplay() {
|
|
401
|
+
this.valueSpan.textContent = this.selectedText || this.options.placeholder;
|
|
402
|
+
this.trigger.classList.toggle('has-value', Boolean(this.selectedValue));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
_isScrolledToBottom() {
|
|
406
|
+
const { scrollHeight, scrollTop, clientHeight } = this.resultsContainer;
|
|
407
|
+
return scrollHeight - scrollTop - clientHeight < 50;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ─── Module-level helpers ─────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
function _parseBool(value, defaultValue) {
|
|
414
|
+
if (value === undefined || value === null) return defaultValue;
|
|
415
|
+
if (typeof value === 'boolean') return value;
|
|
416
|
+
return value !== 'false' && value !== '0';
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ─── Auto-init (Turbo-compatible, no Stimulus required) ──────────────────────
|
|
420
|
+
|
|
421
|
+
const _rsInstances = new WeakMap();
|
|
422
|
+
|
|
423
|
+
function _rsInit(root = document) {
|
|
424
|
+
root.querySelectorAll('[data-remote-select]').forEach(el => {
|
|
425
|
+
if (!_rsInstances.has(el)) _rsInstances.set(el, new RemoteSelect(el));
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function _rsDestroy(root = document) {
|
|
430
|
+
root.querySelectorAll('[data-remote-select]').forEach(el => {
|
|
431
|
+
if (_rsInstances.has(el)) {
|
|
432
|
+
_rsInstances.get(el).destroy();
|
|
433
|
+
_rsInstances.delete(el);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Works without Turbo — fires on plain page load
|
|
439
|
+
document.addEventListener('DOMContentLoaded', () => _rsInit());
|
|
440
|
+
|
|
441
|
+
// Turbo full-page navigation (no-op if Turbo is not present)
|
|
442
|
+
document.addEventListener('turbo:load', () => _rsInit());
|
|
443
|
+
|
|
444
|
+
// Turbo Frames — scope init to just the updated frame element
|
|
445
|
+
document.addEventListener('turbo:frame-load', (e) => _rsInit(e.target));
|
|
446
|
+
|
|
447
|
+
// Cleanup before Turbo replaces the DOM — removes event listeners before elements are gone
|
|
448
|
+
document.addEventListener('turbo:before-render', (e) => _rsDestroy(e.detail.newBody));
|
|
449
|
+
document.addEventListener('turbo:before-frame-render', (e) => _rsDestroy(e.target));
|
|
450
|
+
|
|
451
|
+
// Global export for <script> tag / CDN usage
|
|
452
|
+
window.RemoteSelect = RemoteSelect;
|
|
453
|
+
|
|
454
|
+
// ESM export for importmaps (Rails 8 default) and bundlers (esbuild, rollup, etc.)
|
|
455
|
+
export { RemoteSelect };
|
|
456
|
+
export default RemoteSelect;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module RemoteSelect
|
|
2
|
+
module ViewHelpers
|
|
3
|
+
# Renders a remote-data searchable select for a Rails form.
|
|
4
|
+
#
|
|
5
|
+
# @param form [ActionView::Helpers::FormBuilder] Rails form builder
|
|
6
|
+
# @param attribute [Symbol] model attribute (becomes the hidden field name)
|
|
7
|
+
# @param endpoint [String] URL that returns JSON { results: [...], has_more: bool }
|
|
8
|
+
# @param options [Hash] see below
|
|
9
|
+
#
|
|
10
|
+
# Options:
|
|
11
|
+
# :selected_value [String/Integer] pre-selected value id
|
|
12
|
+
# :selected_text [String] pre-selected display text
|
|
13
|
+
# :min_chars [Integer] chars needed before search fires (default: 2)
|
|
14
|
+
# :debounce_delay [Integer] debounce in ms (default: 250)
|
|
15
|
+
# :placeholder [String] i18n: remote_select.placeholder
|
|
16
|
+
# :per_page [Integer] results per page (default: 20)
|
|
17
|
+
# :depends_on [String] CSS selector(s) of dependency field(s)
|
|
18
|
+
# :clear_on_dependency_change [Boolean] clear on dependency change (default: true)
|
|
19
|
+
# :empty_text [String] i18n: remote_select.empty_text
|
|
20
|
+
# :loading_text [String] i18n: remote_select.loading_text
|
|
21
|
+
# :html [Hash] extra HTML attrs for the hidden input
|
|
22
|
+
#
|
|
23
|
+
def remote_select(form, attribute, endpoint, options = {})
|
|
24
|
+
selected_value = options.delete(:selected_value) || form.object.try(attribute)
|
|
25
|
+
selected_text = options.delete(:selected_text)
|
|
26
|
+
html_options = options.delete(:html) || {}
|
|
27
|
+
|
|
28
|
+
data_attrs = {
|
|
29
|
+
remote_select: true,
|
|
30
|
+
endpoint: endpoint,
|
|
31
|
+
selected_value: selected_value,
|
|
32
|
+
selected_text: selected_text
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
data_attrs[:placeholder] = options[:placeholder] || I18n.t("remote_select.placeholder", default: "Type to search...")
|
|
36
|
+
data_attrs[:empty_text] = options[:empty_text] || I18n.t("remote_select.empty_text", default: "No results found")
|
|
37
|
+
data_attrs[:loading_text] = options[:loading_text] || I18n.t("remote_select.loading_text", default: "Loading...")
|
|
38
|
+
|
|
39
|
+
%i[min_chars debounce_delay per_page depends_on].each do |key|
|
|
40
|
+
data_attrs[key] = options[key] if options[key].present?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
unless options[:clear_on_dependency_change].nil?
|
|
44
|
+
data_attrs[:clear_on_dependency_change] = options[:clear_on_dependency_change]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
html_options[:data] ||= {}
|
|
48
|
+
html_options[:data].merge!(data_attrs)
|
|
49
|
+
html_options[:class] = [html_options[:class], "remote-select-input"].compact.join(" ")
|
|
50
|
+
|
|
51
|
+
form.hidden_field(attribute, html_options)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: remote_select
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ghennadii Mir
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-20 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: railties
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '7.0'
|
|
27
|
+
description: |
|
|
28
|
+
A zero-dependency Rails form helper that renders a searchable dropdown
|
|
29
|
+
whose options are fetched from a JSON endpoint. Supports pagination,
|
|
30
|
+
dependent selects, keyboard navigation, full ARIA, i18n, and Turbo.
|
|
31
|
+
email:
|
|
32
|
+
- ''
|
|
33
|
+
executables: []
|
|
34
|
+
extensions: []
|
|
35
|
+
extra_rdoc_files: []
|
|
36
|
+
files:
|
|
37
|
+
- CHANGELOG.md
|
|
38
|
+
- LICENSE
|
|
39
|
+
- README.md
|
|
40
|
+
- app/assets/stylesheets/remote_select.css
|
|
41
|
+
- app/javascript/remote_select.js
|
|
42
|
+
- config/locales/en.yml
|
|
43
|
+
- lib/remote_select.rb
|
|
44
|
+
- lib/remote_select/engine.rb
|
|
45
|
+
- lib/remote_select/version.rb
|
|
46
|
+
- lib/remote_select/view_helpers.rb
|
|
47
|
+
homepage: https://github.com/GhennadiiMir/remote_select
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata:
|
|
51
|
+
homepage_uri: https://github.com/GhennadiiMir/remote_select
|
|
52
|
+
source_code_uri: https://github.com/GhennadiiMir/remote_select
|
|
53
|
+
changelog_uri: https://github.com/GhennadiiMir/remote_select/blob/main/CHANGELOG.md
|
|
54
|
+
post_install_message:
|
|
55
|
+
rdoc_options: []
|
|
56
|
+
require_paths:
|
|
57
|
+
- lib
|
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '3.1'
|
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
requirements: []
|
|
69
|
+
rubygems_version: 3.5.22
|
|
70
|
+
signing_key:
|
|
71
|
+
specification_version: 4
|
|
72
|
+
summary: Lightweight vanilla-JS remote-data select for Rails forms
|
|
73
|
+
test_files: []
|