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.
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).