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 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,5 @@
1
+ en:
2
+ remote_select:
3
+ placeholder: "Type to search..."
4
+ empty_text: "No results found"
5
+ loading_text: "Loading..."
@@ -0,0 +1,11 @@
1
+ module RemoteSelect
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace RemoteSelect
4
+
5
+ initializer "remote_select.helpers" do
6
+ ActiveSupport.on_load(:action_view) do
7
+ include RemoteSelect::ViewHelpers
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ module RemoteSelect
2
+ VERSION = "0.1.0"
3
+ end
@@ -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
@@ -0,0 +1,7 @@
1
+ require "remote_select/version"
2
+ require "remote_select/view_helpers"
3
+
4
+ module RemoteSelect
5
+ # Engine is only loaded in a Rails context
6
+ require "remote_select/engine" if defined?(Rails)
7
+ 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: []