advanced_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/MIT-LICENSE +20 -0
- data/README.md +814 -0
- data/Rakefile +4 -0
- data/app/assets/config/manifest.js +2 -0
- data/app/assets/stylesheets/advanced_select/advanced_select.css +220 -0
- data/app/javascript/advanced_select/advanced_select_controller.js +379 -0
- data/app/javascript/advanced_select/index.js +1 -0
- data/app/views/advanced_select/_default_option_content.html.erb +6 -0
- data/app/views/advanced_select/_option.html.erb +18 -0
- data/app/views/advanced_select/_options.html.erb +31 -0
- data/app/views/advanced_select/_select.html.erb +74 -0
- data/app/views/advanced_select/_summary.html.erb +14 -0
- data/config/locales/en.yml +7 -0
- data/config/locales/tr.yml +7 -0
- data/lib/advanced_select/class_map.rb +55 -0
- data/lib/advanced_select/engine.rb +28 -0
- data/lib/advanced_select/helper.rb +153 -0
- data/lib/advanced_select/version.rb +3 -0
- data/lib/advanced_select.rb +8 -0
- data/lib/generators/advanced_select/install/install_generator.rb +161 -0
- data/lib/generators/advanced_select/install/templates/advanced_select.css +220 -0
- data/lib/generators/advanced_select/install/templates/advanced_select_controller.js +379 -0
- data/lib/generators/advanced_select/option_content/option_content_generator.rb +11 -0
- data/lib/generators/advanced_select/option_content/templates/option_content.html.erb +8 -0
- data/lib/tasks/advanced_select/tasks.rake +9 -0
- metadata +139 -0
data/README.md
ADDED
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
# AdvancedSelect
|
|
2
|
+
|
|
3
|
+
AdvancedSelect is a small Rails engine for rendering an advanced select input with Rails partials, Stimulus behavior, plain CSS, and i18n defaults.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- [Design Principles](#design-principles)
|
|
8
|
+
- [Requirements](#requirements)
|
|
9
|
+
- [Limitations](#limitations)
|
|
10
|
+
- [Usage](#usage)
|
|
11
|
+
- [Supported Rails Setups](#supported-rails-setups)
|
|
12
|
+
- [JavaScript](#javascript)
|
|
13
|
+
- [Stimulus Customization](#stimulus-customization)
|
|
14
|
+
- [jsbundling/Propshaft Example](#jsbundlingpropshaft-example)
|
|
15
|
+
- [CSS And Asset Pipeline](#css-and-asset-pipeline)
|
|
16
|
+
- [Basic Local Select](#basic-local-select)
|
|
17
|
+
- [Remote Search](#remote-search)
|
|
18
|
+
- [Multiple Select](#multiple-select)
|
|
19
|
+
- [Add Mode](#add-mode)
|
|
20
|
+
- [Dependent Fields](#dependent-fields)
|
|
21
|
+
- [Custom Option Content](#custom-option-content)
|
|
22
|
+
- [Option Contract](#option-contract)
|
|
23
|
+
- [API Reference](#api-reference)
|
|
24
|
+
- [Local Development](#local-development)
|
|
25
|
+
- [i18n](#i18n)
|
|
26
|
+
- [Styling](#styling)
|
|
27
|
+
- [Contributing](#contributing)
|
|
28
|
+
- [License](#license)
|
|
29
|
+
|
|
30
|
+
## Design Principles
|
|
31
|
+
|
|
32
|
+
AdvancedSelect is intentionally lightweight. It owns the reusable UI contract, not the host application's data or business rules.
|
|
33
|
+
|
|
34
|
+
The gem owns:
|
|
35
|
+
|
|
36
|
+
- Rails helper and partial rendering.
|
|
37
|
+
- Option HTML structure.
|
|
38
|
+
- Stimulus dropdown behavior.
|
|
39
|
+
- Plain CSS defaults.
|
|
40
|
+
- i18n defaults.
|
|
41
|
+
- Optional generators for installation and custom option content.
|
|
42
|
+
|
|
43
|
+
The host Rails app owns:
|
|
44
|
+
|
|
45
|
+
- Routes.
|
|
46
|
+
- Controllers.
|
|
47
|
+
- Database queries.
|
|
48
|
+
- Authorization.
|
|
49
|
+
- Filtering and sorting.
|
|
50
|
+
- Turbo Stream endpoints.
|
|
51
|
+
- Domain-specific option formatting.
|
|
52
|
+
|
|
53
|
+
Remote options are Rails/Turbo driven. The Stimulus controller sends UI state such as `query`, `selected_id`, `selected_ids[]`, `add_mode`, and dependent field values to the host endpoint. The endpoint returns server-rendered Turbo Stream HTML, and Turbo replaces the option list.
|
|
54
|
+
|
|
55
|
+
Stimulus does not know about models, database tables, authorization, or business workflows. This keeps the gem small, reusable, and easy to integrate into different Rails apps.
|
|
56
|
+
|
|
57
|
+
## Requirements
|
|
58
|
+
|
|
59
|
+
- Ruby `>= 3.1`
|
|
60
|
+
- Rails `>= 7.1`
|
|
61
|
+
- `turbo-rails >= 2.0`
|
|
62
|
+
- `stimulus-rails >= 1.3`
|
|
63
|
+
- A Rails asset setup that can load plain CSS
|
|
64
|
+
|
|
65
|
+
Supported asset setups are listed below.
|
|
66
|
+
|
|
67
|
+
## Limitations
|
|
68
|
+
|
|
69
|
+
AdvancedSelect does not provide backend endpoints. The host app must define routes and controller actions for remote option loading.
|
|
70
|
+
|
|
71
|
+
AdvancedSelect does not provide query objects, model concerns, authorization logic, filtering logic, or database integrations. It only renders the select UI and sends UI state to the host app's Turbo endpoint.
|
|
72
|
+
|
|
73
|
+
## Usage
|
|
74
|
+
|
|
75
|
+
Add the gem to the host Rails app:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
gem "advanced_select", git: "https://github.com/MehmetCelik4/advanced_select.git"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Run the installer:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
bin/rails generate advanced_select:install
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The default setup is `importmap`. Apps that use `jsbundling-rails` should pass the setup explicitly:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
bin/rails generate advanced_select:install --setup=importmap
|
|
91
|
+
bin/rails generate advanced_select:install --setup=jsbundling
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Or use the Rake shortcut:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
bin/rails advanced_select:install
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The Rake shortcut accepts the same setup choice through an environment variable:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
SETUP=jsbundling bin/rails advanced_select:install
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
For the default `importmap` setup, the installer registers the engine Stimulus controller and wires the engine assets:
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
config/importmap.rb
|
|
110
|
+
app/javascript/controllers/index.js
|
|
111
|
+
app/assets/stylesheets/application.css
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The installer currently supports two setup modes:
|
|
115
|
+
|
|
116
|
+
- `--setup=importmap`: pins the engine controller, registers it in `app/javascript/controllers/index.js`, and requires the engine stylesheet from `app/assets/stylesheets/application.css`.
|
|
117
|
+
- `--setup=jsbundling`: copies the files, registers the controller in `app/javascript/controllers/index.js`, and imports the stylesheet from `app/assets/stylesheets/application.postcss.css`.
|
|
118
|
+
|
|
119
|
+
Other asset layouts can still use the copied files manually. Installer support for those layouts can be added later as separate, tested setup modes.
|
|
120
|
+
|
|
121
|
+
## Supported Rails Setups
|
|
122
|
+
|
|
123
|
+
AdvancedSelect expects a Rails app with Turbo and Stimulus available. The gem depends on `railties`, `actionview`, `turbo-rails`, and `stimulus-rails`; it does not depend on the full `rails` gem or on Active Record.
|
|
124
|
+
|
|
125
|
+
### JavaScript
|
|
126
|
+
|
|
127
|
+
Supported:
|
|
128
|
+
|
|
129
|
+
- `importmap-rails` with `stimulus-rails`
|
|
130
|
+
- `jsbundling-rails` or another bundler with manual Stimulus registration
|
|
131
|
+
|
|
132
|
+
The host app must load Turbo and start Stimulus. AdvancedSelect depends on `turbo-rails` and `stimulus-rails`, but the app still owns its JavaScript entrypoint.
|
|
133
|
+
|
|
134
|
+
The installer adds an explicit registration to `app/javascript/controllers/index.js`:
|
|
135
|
+
|
|
136
|
+
```js
|
|
137
|
+
import AdvancedSelectController from "advanced_select/advanced_select_controller"
|
|
138
|
+
application.register("advanced-select", AdvancedSelectController)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The installer also pins the engine controller:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
pin "advanced_select/advanced_select_controller", to: "advanced_select/advanced_select_controller.js"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The engine exposes `advanced_select/advanced_select_controller.js` to the asset pipeline, so host apps should not need to add `AdvancedSelect::Engine.root.join("app/javascript")` to `config.assets.paths` or link the controller from `app/assets/config/manifest.js`.
|
|
148
|
+
|
|
149
|
+
Importmap apps should already have a Stimulus entrypoint with an exported `application`, similar to this:
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
// app/javascript/controllers/index.js
|
|
153
|
+
import { application } from "controllers/application"
|
|
154
|
+
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
|
|
155
|
+
|
|
156
|
+
eagerLoadControllersFrom("controllers", application)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
If the host app does not use the standard `stimulus-rails` entrypoint, register the engine controller manually:
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
import AdvancedSelectController from "advanced_select/advanced_select_controller"
|
|
163
|
+
|
|
164
|
+
application.register("advanced-select", AdvancedSelectController)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
For `jsbundling-rails`, the installer registers the copied controller in the manifest-style Stimulus entrypoint. It expects `app/javascript/controllers/index.js` to follow the shape generated by `stimulus-rails`, for example:
|
|
168
|
+
|
|
169
|
+
```js
|
|
170
|
+
import { application } from "./application"
|
|
171
|
+
|
|
172
|
+
import ExistingController from "./existing_controller"
|
|
173
|
+
application.register("existing", ExistingController)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Other bundlers can use the copied controller manually, but the installer currently only patches the `jsbundling-rails` manifest shape.
|
|
177
|
+
|
|
178
|
+
The installed controller imports `Turbo` from `@hotwired/turbo-rails`, so the host app must have `@hotwired/turbo-rails` resolvable in its importmap or bundler setup.
|
|
179
|
+
|
|
180
|
+
### Stimulus Customization
|
|
181
|
+
|
|
182
|
+
For importmap apps, customize behavior only when the host app needs it. Add a local subclass:
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
// app/javascript/controllers/advanced_select_controller.js
|
|
186
|
+
import AdvancedSelectController from "advanced_select/advanced_select_controller"
|
|
187
|
+
|
|
188
|
+
export default class extends AdvancedSelectController {
|
|
189
|
+
displayLabel(option) {
|
|
190
|
+
return super.displayLabel(option).trim()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Then change the registration in `app/javascript/controllers/index.js` to import the local subclass:
|
|
196
|
+
|
|
197
|
+
```js
|
|
198
|
+
import AdvancedSelectController from "./advanced_select_controller"
|
|
199
|
+
application.register("advanced-select", AdvancedSelectController)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
This keeps local custom behavior small while allowing future gem fixes to flow through the base controller.
|
|
203
|
+
|
|
204
|
+
For `jsbundling-rails` and other bundlers, the installer copies the full controller because bundlers do not resolve Rails engine JavaScript assets automatically. In that setup the copied file is host-owned.
|
|
205
|
+
|
|
206
|
+
### jsbundling/Propshaft Example
|
|
207
|
+
|
|
208
|
+
Apps that use Rails with `jsbundling-rails`, esbuild, Propshaft, and a PostCSS entrypoint can install with the jsbundling setup. In that setup the installer is expected to leave these changes:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
bin/rails generate advanced_select:install --setup=jsbundling
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```js
|
|
215
|
+
// app/javascript/controllers/index.js
|
|
216
|
+
import AdvancedSelectController from "./advanced_select_controller"
|
|
217
|
+
application.register("advanced-select", AdvancedSelectController)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
```css
|
|
221
|
+
/* app/assets/stylesheets/application.postcss.css */
|
|
222
|
+
@import "advanced_select.css";
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Then rebuild the host app's JavaScript and CSS assets. The exact commands are app-specific:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
yarn build
|
|
229
|
+
yarn build:css
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### CSS And Asset Pipeline
|
|
233
|
+
|
|
234
|
+
For importmap apps, the installer uses the engine stylesheet directly. It adds this Sprockets require to `app/assets/stylesheets/application.css`:
|
|
235
|
+
|
|
236
|
+
```css
|
|
237
|
+
/*
|
|
238
|
+
*= require advanced_select/advanced_select
|
|
239
|
+
*= require_tree .
|
|
240
|
+
*= require_self
|
|
241
|
+
*/
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
When `require_tree .` is present, the installer places the engine stylesheet before it. If the host app needs app-specific styling, create a stylesheet such as `app/assets/stylesheets/advanced_select_overrides.css`; `require_tree .` will load it after the gem defaults.
|
|
245
|
+
|
|
246
|
+
If the host app loads a separate Tailwind bundle and keeps component overrides in Tailwind files, keep the gem CSS in `application.css` and load Tailwind after it in the layout:
|
|
247
|
+
|
|
248
|
+
```erb
|
|
249
|
+
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
|
250
|
+
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
With that order, Tailwind component files such as `app/assets/tailwind/components/forms.css` load after the gem defaults and can override them with `@apply`:
|
|
254
|
+
|
|
255
|
+
```css
|
|
256
|
+
.ui-advanced-select-trigger {
|
|
257
|
+
@apply flex min-h-10 w-full items-center justify-between;
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Do not also import or require `advanced_select/advanced_select` from the Tailwind bundle in that setup; load the gem CSS once through `application.css`.
|
|
262
|
+
|
|
263
|
+
For `--setup=jsbundling`, the installer copies plain CSS to:
|
|
264
|
+
|
|
265
|
+
```text
|
|
266
|
+
app/assets/stylesheets/advanced_select.css
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Then it imports the copied file from:
|
|
270
|
+
|
|
271
|
+
```css
|
|
272
|
+
/* app/assets/stylesheets/application.postcss.css */
|
|
273
|
+
@import "advanced_select.css";
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
For `--setup=importmap`, the installer checks `app/assets/stylesheets/application.css`:
|
|
277
|
+
|
|
278
|
+
- If it is a Sprockets-style manifest, the installer normalizes the AdvancedSelect require before `require_tree .` when that directive is present, otherwise before `require_self`.
|
|
279
|
+
- If `advanced_select/advanced_select` already exists, the installer does not add duplicates.
|
|
280
|
+
|
|
281
|
+
```css
|
|
282
|
+
/*
|
|
283
|
+
*= require advanced_select/advanced_select
|
|
284
|
+
*/
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
The installer does not create a host override stylesheet. Create one only when the app needs it:
|
|
288
|
+
|
|
289
|
+
```text
|
|
290
|
+
app/assets/stylesheets/advanced_select_overrides.css
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Use plain CSS in that file. Do not use Tailwind `@apply` there unless your host app explicitly processes Sprockets stylesheets through Tailwind.
|
|
294
|
+
|
|
295
|
+
If the installer cannot safely detect the stylesheet entrypoint, require `advanced_select/advanced_select` through the host app's asset setup.
|
|
296
|
+
|
|
297
|
+
If the host app uses plain Propshaft stylesheet links or another CSS pipeline, wire the engine stylesheet manually for now. Those layouts are intentionally not installer modes yet.
|
|
298
|
+
|
|
299
|
+
Sprockets-style manual example:
|
|
300
|
+
|
|
301
|
+
```css
|
|
302
|
+
/*
|
|
303
|
+
*= require advanced_select/advanced_select
|
|
304
|
+
*= require_tree .
|
|
305
|
+
*= require_self
|
|
306
|
+
*/
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Basic Local Select
|
|
310
|
+
|
|
311
|
+
Use local options when the complete option list is already available while rendering the page:
|
|
312
|
+
|
|
313
|
+
```erb
|
|
314
|
+
<%= advanced_select_tag(
|
|
315
|
+
"record[item_id]",
|
|
316
|
+
id: "record_item_id",
|
|
317
|
+
selected: selected_option,
|
|
318
|
+
options: options,
|
|
319
|
+
placeholder: t(".item_placeholder"),
|
|
320
|
+
searchable: false
|
|
321
|
+
) %>
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Options are hashes:
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
options = [
|
|
328
|
+
{ id: "item-1", label: "Item one" },
|
|
329
|
+
{ id: "item-2", label: "Item two", description: "Optional secondary text" }
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
selected_option = { id: "item-1", label: "Item one" }
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Remote Search
|
|
336
|
+
|
|
337
|
+
Use `options_url` when options should be loaded from a host app endpoint:
|
|
338
|
+
|
|
339
|
+
```erb
|
|
340
|
+
<%= advanced_select_tag(
|
|
341
|
+
"record[item_id]",
|
|
342
|
+
id: "record_item_id",
|
|
343
|
+
selected: selected_option,
|
|
344
|
+
options: [],
|
|
345
|
+
placeholder: t(".item_placeholder"),
|
|
346
|
+
options_url: item_options_path
|
|
347
|
+
) %>
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
The endpoint should return a Turbo Stream that replaces the options target:
|
|
351
|
+
|
|
352
|
+
```erb
|
|
353
|
+
<%= turbo_stream.replace params[:target] do %>
|
|
354
|
+
<%= advanced_select_options_tag(
|
|
355
|
+
target_id: params[:target],
|
|
356
|
+
selected: selected_options,
|
|
357
|
+
options: options,
|
|
358
|
+
query: params[:query]
|
|
359
|
+
) %>
|
|
360
|
+
<% end %>
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
If the endpoint action does not use Rails' normal Turbo Stream negotiation, render the format explicitly:
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
def options
|
|
367
|
+
@target_id = params.fetch(:target)
|
|
368
|
+
@options = load_options
|
|
369
|
+
|
|
370
|
+
render formats: :turbo_stream
|
|
371
|
+
end
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
The Stimulus controller sends these query params when loading remote options:
|
|
375
|
+
|
|
376
|
+
- `target`: DOM id to replace with the returned options HTML.
|
|
377
|
+
- `query`: current search text.
|
|
378
|
+
- `selected_id`: current single selected id when opening a selected remote field.
|
|
379
|
+
- `selected_ids[]`: all selected ids.
|
|
380
|
+
- `add_mode`: `"1"` when add mode is enabled, otherwise `"0"`.
|
|
381
|
+
- each `dependent_fields` entry, using the configured param name.
|
|
382
|
+
|
|
383
|
+
### Multiple Select
|
|
384
|
+
|
|
385
|
+
Set `multiple: true` and use an array-style form field name:
|
|
386
|
+
|
|
387
|
+
```erb
|
|
388
|
+
<%= advanced_select_tag(
|
|
389
|
+
"record[item_ids][]",
|
|
390
|
+
id: "record_item_ids",
|
|
391
|
+
selected: selected_options,
|
|
392
|
+
options: options,
|
|
393
|
+
placeholder: t(".items_placeholder"),
|
|
394
|
+
multiple: true,
|
|
395
|
+
searchable: false
|
|
396
|
+
) %>
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
For remote multiple options, pass `multiple: true` to the options render too:
|
|
400
|
+
|
|
401
|
+
```erb
|
|
402
|
+
<%= advanced_select_options_tag(
|
|
403
|
+
target_id: params[:target],
|
|
404
|
+
selected: selected_options,
|
|
405
|
+
options: options,
|
|
406
|
+
multiple: true,
|
|
407
|
+
query: params[:query]
|
|
408
|
+
) %>
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### Add Mode
|
|
412
|
+
|
|
413
|
+
Set `add_mode: true` when users may submit a new typed value:
|
|
414
|
+
|
|
415
|
+
```erb
|
|
416
|
+
<%= advanced_select_tag(
|
|
417
|
+
"record[tags][]",
|
|
418
|
+
id: "record_tags",
|
|
419
|
+
selected: selected_tags,
|
|
420
|
+
options: [],
|
|
421
|
+
placeholder: t(".tags_placeholder"),
|
|
422
|
+
options_url: tag_options_path,
|
|
423
|
+
multiple: true,
|
|
424
|
+
add_mode: true
|
|
425
|
+
) %>
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
New values submit with the `__new__:` prefix:
|
|
429
|
+
|
|
430
|
+
```text
|
|
431
|
+
__new__:New tag
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
For remote add mode, the host endpoint owns the add-new business rule. AdvancedSelect sends the current `query`, `selected_ids[]`, and `add_mode`; the endpoint should use that state, plus its own records, to decide whether to render an add-new row. If a typed value is already selected or already exists in the host data source, the Turbo Stream response should not render another add-new option for the same value.
|
|
435
|
+
|
|
436
|
+
When a newly typed value should remain visible in the dropdown so users can deselect it later, render that selected value from the host endpoint as part of the returned option list. The gem does not persist or invent remote options; it only submits the `__new__:` value and renders the options returned by the host app.
|
|
437
|
+
|
|
438
|
+
For example, a remote endpoint can turn selected `__new__:` values back into options before rendering the Turbo Stream:
|
|
439
|
+
|
|
440
|
+
```ruby
|
|
441
|
+
new_selected_options = Array(params[:selected_ids]).filter_map do |id|
|
|
442
|
+
next unless id.start_with?("__new__:")
|
|
443
|
+
|
|
444
|
+
label = id.delete_prefix("__new__:")
|
|
445
|
+
{ id: id, value: id, label: label }
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
options = new_selected_options + load_options_for_query(params[:query])
|
|
449
|
+
selected_options = new_selected_options
|
|
450
|
+
|
|
451
|
+
render turbo_stream: turbo_stream.replace(params[:target]) {
|
|
452
|
+
helpers.advanced_select_options_tag(
|
|
453
|
+
target_id: params[:target],
|
|
454
|
+
selected: selected_options,
|
|
455
|
+
options: options,
|
|
456
|
+
multiple: true,
|
|
457
|
+
add_mode: params[:add_mode] == "1",
|
|
458
|
+
query: params[:query]
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
If the endpoint also needs to mark existing records as selected, resolve those ids from `params[:selected_ids]` and include them in `selected_options` too.
|
|
464
|
+
|
|
465
|
+
### Dependent Fields
|
|
466
|
+
|
|
467
|
+
Use `dependent_fields` when a remote option endpoint depends on another field value:
|
|
468
|
+
|
|
469
|
+
```erb
|
|
470
|
+
<%= select_tag "record[parent_id]", options_for_select(parent_options), id: "record_parent_id" %>
|
|
471
|
+
|
|
472
|
+
<%= advanced_select_tag(
|
|
473
|
+
"record[item_id]",
|
|
474
|
+
id: "record_item_id",
|
|
475
|
+
selected: selected_option,
|
|
476
|
+
options: [],
|
|
477
|
+
placeholder: t(".item_placeholder"),
|
|
478
|
+
options_url: item_options_path,
|
|
479
|
+
dependent_fields: { parent_id: "#record_parent_id" }
|
|
480
|
+
) %>
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
The remote request will include `parent_id=<current field value>`.
|
|
484
|
+
|
|
485
|
+
### Custom Option Content
|
|
486
|
+
|
|
487
|
+
Use a custom option content partial when an option needs richer content. The engine still renders the option button, Stimulus data attributes, and ARIA attributes:
|
|
488
|
+
|
|
489
|
+
```bash
|
|
490
|
+
bin/rails generate advanced_select:option_content products
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
This creates:
|
|
494
|
+
|
|
495
|
+
```text
|
|
496
|
+
app/views/advanced_select/option_contents/_products.html.erb
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
The partial receives one local:
|
|
500
|
+
|
|
501
|
+
```erb
|
|
502
|
+
<%# locals: (option:) %>
|
|
503
|
+
|
|
504
|
+
<span class="ui-advanced-select-option-content">
|
|
505
|
+
<span><%= option.fetch(:code) %></span>
|
|
506
|
+
<span><%= option.fetch(:label) %></span>
|
|
507
|
+
<% if option[:description].present? %>
|
|
508
|
+
<span class="ui-advanced-select-option-description"><%= option[:description] %></span>
|
|
509
|
+
<% end %>
|
|
510
|
+
</span>
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
The host app can pass any custom keys inside each option hash:
|
|
514
|
+
|
|
515
|
+
```ruby
|
|
516
|
+
product_options = [
|
|
517
|
+
{
|
|
518
|
+
id: product.id,
|
|
519
|
+
value: product.id,
|
|
520
|
+
label: product.name,
|
|
521
|
+
display_label: product.name,
|
|
522
|
+
description: product.category_name,
|
|
523
|
+
code: product.code
|
|
524
|
+
}
|
|
525
|
+
]
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
Pass the partial path to the select:
|
|
529
|
+
|
|
530
|
+
```erb
|
|
531
|
+
<%= advanced_select_tag(
|
|
532
|
+
"record[product_id]",
|
|
533
|
+
id: "record_product_id",
|
|
534
|
+
selected: selected_product,
|
|
535
|
+
options: product_options,
|
|
536
|
+
placeholder: t(".product_placeholder"),
|
|
537
|
+
options_url: product_options_path,
|
|
538
|
+
option_content_partial: "advanced_select/option_contents/products"
|
|
539
|
+
) %>
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
Use the same partial when rendering remote options:
|
|
543
|
+
|
|
544
|
+
```erb
|
|
545
|
+
<%= turbo_stream.replace params[:target] do %>
|
|
546
|
+
<%= advanced_select_options_tag(
|
|
547
|
+
target_id: params[:target],
|
|
548
|
+
selected: selected_options,
|
|
549
|
+
options: options,
|
|
550
|
+
multiple: false,
|
|
551
|
+
add_mode: params[:add_mode] == "1",
|
|
552
|
+
query: params[:query],
|
|
553
|
+
option_content_partial: "advanced_select/option_contents/products"
|
|
554
|
+
) %>
|
|
555
|
+
<% end %>
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### Option Contract
|
|
559
|
+
|
|
560
|
+
Each option must include `id`. Other keys are optional:
|
|
561
|
+
|
|
562
|
+
```ruby
|
|
563
|
+
{
|
|
564
|
+
id: "row-7",
|
|
565
|
+
value: "submitted-value",
|
|
566
|
+
label: "Parent > Child",
|
|
567
|
+
display_label: "Child",
|
|
568
|
+
description: "Optional secondary text"
|
|
569
|
+
}
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
- `id` is the stable selection identity.
|
|
573
|
+
- `value` is submitted in the hidden input. If omitted, `id` is submitted.
|
|
574
|
+
- `label` is the full option label.
|
|
575
|
+
- `display_label` is used in the selected summary. If omitted, the helper derives it from `label`.
|
|
576
|
+
- `description` is rendered by the default option content partial.
|
|
577
|
+
|
|
578
|
+
Grouped options use this shape:
|
|
579
|
+
|
|
580
|
+
```ruby
|
|
581
|
+
[
|
|
582
|
+
{
|
|
583
|
+
label: "Recent",
|
|
584
|
+
options: [
|
|
585
|
+
{ id: "recent-1", label: "Recent item" }
|
|
586
|
+
]
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
label: "All",
|
|
590
|
+
options: [
|
|
591
|
+
{ id: "all-1", label: "All item" }
|
|
592
|
+
]
|
|
593
|
+
}
|
|
594
|
+
]
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
### API Reference
|
|
598
|
+
|
|
599
|
+
`advanced_select_tag`:
|
|
600
|
+
|
|
601
|
+
```ruby
|
|
602
|
+
advanced_select_tag(
|
|
603
|
+
name,
|
|
604
|
+
id:,
|
|
605
|
+
selected:,
|
|
606
|
+
options:,
|
|
607
|
+
placeholder:,
|
|
608
|
+
options_url: nil,
|
|
609
|
+
multiple: false,
|
|
610
|
+
searchable: true,
|
|
611
|
+
add_mode: false,
|
|
612
|
+
dependent_fields: {},
|
|
613
|
+
option_content_partial: nil,
|
|
614
|
+
classes: {},
|
|
615
|
+
append_classes: {}
|
|
616
|
+
)
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
`advanced_select_options_tag`:
|
|
620
|
+
|
|
621
|
+
```ruby
|
|
622
|
+
advanced_select_options_tag(
|
|
623
|
+
target_id:,
|
|
624
|
+
selected:,
|
|
625
|
+
options:,
|
|
626
|
+
multiple: false,
|
|
627
|
+
add_mode: false,
|
|
628
|
+
query: nil,
|
|
629
|
+
option_content_partial: nil,
|
|
630
|
+
classes: {},
|
|
631
|
+
append_classes: {}
|
|
632
|
+
)
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
For importmap/Sprockets apps, require `advanced_select/advanced_select` from your stylesheet manifest before host app styles. For jsbundling apps, include the copied `app/assets/stylesheets/advanced_select.css` after your base styles.
|
|
636
|
+
|
|
637
|
+
## Local Development
|
|
638
|
+
|
|
639
|
+
This repo includes a committed local Nix flake for isolated development and testing. It pins the shell to the gem's own `Gemfile`, local bundle path, Ruby, Node, esbuild, and Playwright browsers:
|
|
640
|
+
|
|
641
|
+
```bash
|
|
642
|
+
nix develop
|
|
643
|
+
bundle install
|
|
644
|
+
bin/rails test test/advanced_select/test.rb
|
|
645
|
+
bin/rails test test/helpers/advanced_select/helper_test.rb
|
|
646
|
+
bin/rails test test/system/advanced_select_interaction_test.rb
|
|
647
|
+
bin/rails test test/system/jsbundling_advanced_select_interaction_test.rb
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
With direnv enabled, `.envrc` loads the flake and adds `bin/` to `PATH`, so the same commands can be shortened further:
|
|
651
|
+
|
|
652
|
+
```bash
|
|
653
|
+
rails test test/helpers/advanced_select/helper_test.rb
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
The system tests use Capybara with Playwright against two dummy Rails apps:
|
|
657
|
+
|
|
658
|
+
- `test/dummy` covers the importmap setup.
|
|
659
|
+
- `test/dummy_jsbundling` covers a jsbundling/Propshaft setup with an esbuild-built JavaScript and CSS bundle.
|
|
660
|
+
|
|
661
|
+
Both browser tests verify local selection, remote Turbo Stream option replacement, stylesheet loading, and hidden input updates.
|
|
662
|
+
|
|
663
|
+
If you do not use Nix, provide equivalent local Ruby, Bundler, Node, esbuild, and Playwright browser dependencies before running the browser/system tests.
|
|
664
|
+
|
|
665
|
+
### i18n
|
|
666
|
+
|
|
667
|
+
Default locale keys:
|
|
668
|
+
|
|
669
|
+
```yaml
|
|
670
|
+
shared:
|
|
671
|
+
advanced_select:
|
|
672
|
+
add_option: "Add %{query}"
|
|
673
|
+
empty: "No options found"
|
|
674
|
+
error: "Options could not be loaded"
|
|
675
|
+
loading: "Loading..."
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
Override these keys in the host app as needed.
|
|
679
|
+
|
|
680
|
+
## Styling
|
|
681
|
+
|
|
682
|
+
AdvancedSelect ships plain CSS defaults. When no `classes:` map is provided, rendered elements use the public `ui-advanced-select-*` styling classes.
|
|
683
|
+
|
|
684
|
+
### Styling With Tailwind Classes
|
|
685
|
+
|
|
686
|
+
Host apps can pass a `classes:` map to replace the default styling class for each mapped element. This is useful for option rows where the gem's default hover or selected styles should not compete with host Tailwind classes.
|
|
687
|
+
|
|
688
|
+
Use `append_classes:` when the host app wants to keep the gem's structural defaults and add small adjustments to the end of the class list. This is usually better for structural elements such as `trigger`, `dropdown`, `summary`, and `search`.
|
|
689
|
+
|
|
690
|
+
```erb
|
|
691
|
+
<%= advanced_select_tag(
|
|
692
|
+
"cost_allocation[customer_type]",
|
|
693
|
+
id: "cost_allocation_customer_type",
|
|
694
|
+
selected: selected_customer_type,
|
|
695
|
+
options: customer_type_options,
|
|
696
|
+
placeholder: "Customer type",
|
|
697
|
+
classes: {
|
|
698
|
+
option: "flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-gray-700 hover:bg-red-500 hover:text-white",
|
|
699
|
+
option_active: "bg-red-500 text-white",
|
|
700
|
+
option_selected: "bg-indigo-50 text-indigo-700"
|
|
701
|
+
},
|
|
702
|
+
append_classes: {
|
|
703
|
+
trigger: "min-h-10 rounded-md border-gray-300"
|
|
704
|
+
}
|
|
705
|
+
) %>
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
Class map values replace defaults per key; they are not appended to the default styling class. For example, if `classes[:option]` is present, option buttons use only that class string and do not also include `.ui-advanced-select-option`. Keys that are not present still use their default classes. `append_classes:` values append after the resolved class for the same key. For example, `append_classes[:trigger]` renders `.ui-advanced-select-trigger` followed by the host classes.
|
|
709
|
+
|
|
710
|
+
Use `option_active` for hover and keyboard active state. Stimulus adds and removes those classes as the active option changes. Use `add_option_active` when add-mode rows need a different active state. Use `option_selected` for selected state; it is rendered on initially selected options and updated by Stimulus when selection changes. `aria-selected="true"` is still preserved.
|
|
711
|
+
|
|
712
|
+
Supported `classes:` and `append_classes:` keys:
|
|
713
|
+
|
|
714
|
+
```ruby
|
|
715
|
+
classes: {
|
|
716
|
+
root: "...",
|
|
717
|
+
trigger: "...",
|
|
718
|
+
summary: "...",
|
|
719
|
+
placeholder: "...",
|
|
720
|
+
value: "...",
|
|
721
|
+
token: "...",
|
|
722
|
+
caret: "...",
|
|
723
|
+
clear: "...",
|
|
724
|
+
dropdown: "...",
|
|
725
|
+
search: "...",
|
|
726
|
+
options: "...",
|
|
727
|
+
option: "...",
|
|
728
|
+
option_active: "...",
|
|
729
|
+
option_selected: "...",
|
|
730
|
+
option_check: "...",
|
|
731
|
+
option_content: "...",
|
|
732
|
+
option_description: "...",
|
|
733
|
+
group_label: "...",
|
|
734
|
+
add_option: "...",
|
|
735
|
+
add_option_active: "...",
|
|
736
|
+
empty: "...",
|
|
737
|
+
loading: "...",
|
|
738
|
+
error: "..."
|
|
739
|
+
}
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
For remote Turbo Stream option replacement, pass the same class map to `advanced_select_options_tag` in the endpoint template when server-rendered option rows should include the host classes:
|
|
743
|
+
|
|
744
|
+
```erb
|
|
745
|
+
<%= advanced_select_options_tag(
|
|
746
|
+
target_id: @target_id,
|
|
747
|
+
selected: @selected_options,
|
|
748
|
+
options: @options,
|
|
749
|
+
classes: advanced_select_classes,
|
|
750
|
+
append_classes: advanced_select_append_classes
|
|
751
|
+
) %>
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
Tailwind content scanning can usually see class strings when they are written literally in ERB. If the host app builds class names dynamically, add the relevant classes to the app's Tailwind safelist.
|
|
755
|
+
|
|
756
|
+
The host app can still load the gem CSS through `application.css`. `classes:` entries replace the mapped default classes for that helper call; unmapped keys keep the gem defaults. `append_classes:` entries keep the resolved class and append host classes after it.
|
|
757
|
+
|
|
758
|
+
### CSS Overrides
|
|
759
|
+
|
|
760
|
+
Importmap/Sprockets host applications can put app-specific styling in a host-owned file such as:
|
|
761
|
+
|
|
762
|
+
```text
|
|
763
|
+
app/assets/stylesheets/advanced_select_overrides.css
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
The installer does not create this file. Create it only when the host app needs Sprockets-side overrides. With the default Sprockets manifest, `require_tree .` loads it after `advanced_select/advanced_select`, so app-specific styles can override the gem defaults. Keep it as plain CSS so gem updates stay clean.
|
|
767
|
+
|
|
768
|
+
Tailwind apps can keep AdvancedSelect overrides in an existing Tailwind component file such as `app/assets/tailwind/components/forms.css`. In that case, load the host layout's `application` stylesheet before `tailwind` so the Tailwind bundle wins:
|
|
769
|
+
|
|
770
|
+
```erb
|
|
771
|
+
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
|
772
|
+
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
Example Tailwind override:
|
|
776
|
+
|
|
777
|
+
```css
|
|
778
|
+
.ui-advanced-select-trigger {
|
|
779
|
+
@apply flex min-h-10 w-full items-center justify-between rounded-md;
|
|
780
|
+
}
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
For jsbundling apps, override after the copied `app/assets/stylesheets/advanced_select.css`.
|
|
784
|
+
|
|
785
|
+
Common styling hooks:
|
|
786
|
+
|
|
787
|
+
- `.ui-advanced-select-trigger` controls the visible input button, border, radius, height, background, and focus outline.
|
|
788
|
+
- `.ui-advanced-select-dropdown` controls the popup container, border, radius, shadow, width, and `z-index`.
|
|
789
|
+
- `.ui-advanced-select-options` controls the scroll container and default `max-height`.
|
|
790
|
+
- `.ui-advanced-select-option` controls option row spacing, hover state, and font sizing.
|
|
791
|
+
- `.ui-advanced-select-option[aria-selected="true"]` controls selected option colors.
|
|
792
|
+
- `.ui-advanced-select-token` controls multiple-select token styling.
|
|
793
|
+
- `.ui-advanced-select-add-option` controls add-mode row styling.
|
|
794
|
+
- `.ui-advanced-select-empty`, `.ui-advanced-select-loading`, and `.ui-advanced-select-error` control state message styling.
|
|
795
|
+
|
|
796
|
+
Example host override:
|
|
797
|
+
|
|
798
|
+
```css
|
|
799
|
+
.ui-advanced-select-trigger {
|
|
800
|
+
border-color: var(--field-border);
|
|
801
|
+
border-radius: 0.5rem;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
.ui-advanced-select-option[aria-selected="true"] {
|
|
805
|
+
background: var(--selected-bg);
|
|
806
|
+
color: var(--selected-text);
|
|
807
|
+
}
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
## Contributing
|
|
811
|
+
Contribution directions go here.
|
|
812
|
+
|
|
813
|
+
## License
|
|
814
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|