advanced_select 0.1.2 → 0.1.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8acafe10e284da26e61bec53737160926b3b6f43e51aa4d2a690e9a9b5a47367
4
- data.tar.gz: c2465e1a792dc86ece9747b18fe2dcb3e077d706aed3e178515fe1376308067b
3
+ metadata.gz: 9be8d05d537d5e36a565546c0f821c68a6a62cd5e3978cbbe23d3ef7451c1f2a
4
+ data.tar.gz: c6c4c0667ef096fd55e57024621eb9a21d2338ecbef2c2305e2bf2edab0fad4d
5
5
  SHA512:
6
- metadata.gz: d9a0cefc246959a5f8f5910e91e648f5654c55eee8bbb63ae78232c5abd9b7ab0b307eb04744f70016d5e23ab54cc3b7c9a6973c18b5d2ea3b885255a54e8c5a
7
- data.tar.gz: 4e4143bb1da3fb3e50f5aa6fda9cce40f3631c6f7d0ba5b2185a5f06ca4cb9f0b16938932aca365206cf0391ac5b8151335ae1e32e1e5c9abd4a26447e383617
6
+ metadata.gz: 492cee1cf22c71e9c6e328052bb3ff7c49d519816c06ebe8610f2c0d07d2cbcfd9ebe09383ac3a69e696497295a34015aa467f004d46e40194ae4cc7d7538473
7
+ data.tar.gz: 65b3749af47dc024f475284c0998657190f5c68c27da5578d93dfd35669a4eb9c319d3b1b6df1a07fb16157eb0f8897ed4154ab7b54f5e49d8ad7dda3d1bec84
data/README.md CHANGED
@@ -75,7 +75,7 @@ AdvancedSelect does not provide query objects, model concerns, authorization log
75
75
  Add the gem to the host Rails app:
76
76
 
77
77
  ```ruby
78
- gem "advanced_select", "~> 0.1.0"
78
+ gem "advanced_select", "~> 0.1.4"
79
79
  ```
80
80
 
81
81
  Run the installer:
@@ -316,11 +316,12 @@ Use local options when the complete option list is already available while rende
316
316
  id: "record_item_id",
317
317
  selected: selected_option,
318
318
  options: options,
319
- placeholder: t(".item_placeholder"),
320
- searchable: false
319
+ placeholder: t(".item_placeholder")
321
320
  ) %>
322
321
  ```
323
322
 
323
+ Local selects are searchable by default. Set `searchable: false` when the option list should open without a search field.
324
+
324
325
  Options are hashes:
325
326
 
326
327
  ```ruby
@@ -501,6 +502,48 @@ Use `dependent_fields` when a remote option endpoint depends on another field va
501
502
 
502
503
  The remote request will include `parent_id=<current field value>`.
503
504
 
505
+ By default the field is **eager**: it listens for `change` on each dependent field and reloads its options immediately, without waiting for the dropdown to open. When a parent changes, the field clears its current value, fetches fresh options for the new parent, and (combined with auto select, below) can settle on a value while staying closed. It also loads once on initial render so a parent's default value is reflected right away. An existing server-rendered selection is left untouched.
506
+
507
+ Because the field re-broadcasts a `change` event whenever its own value changes, you can chain advanced selects: a child can depend on another advanced select via its hidden input id (`dependent_fields: { parent_id: "#record_parent_id" }`).
508
+
509
+ Pass `eager: false` for the lazy behaviour instead — options are only fetched when the dropdown opens, and the field does not react to parent changes until then:
510
+
511
+ ```erb
512
+ <%= advanced_select_tag(
513
+ "record[item_id]",
514
+ id: "record_item_id",
515
+ selected: selected_option,
516
+ options: [],
517
+ placeholder: t(".item_placeholder"),
518
+ options_url: item_options_path,
519
+ dependent_fields: { parent_id: "#record_parent_id" },
520
+ eager: false
521
+ ) %>
522
+ ```
523
+
524
+ ### Auto Select Single Option
525
+
526
+ When a remote option list is populated and resolves to exactly one selectable option, the field selects that option automatically. This is enabled by default and is meant for dependent fields where a parent value narrows the children down to a single valid choice.
527
+
528
+ Auto selection only runs when the dropdown is **populated through a remote request** (the cascade case): it fires after the options finish loading, either when the dropdown opens or — with the default eager dependent fields above — as soon as a parent value loads them, so a single valid option is selected without the user opening the field. It does **not** run for statically rendered local options on page load, and it never triggers while the user is actively typing a search.
529
+
530
+ It never overrides an existing selection and is skipped for `multiple` fields.
531
+
532
+ Pass `auto_select_single: false` to opt out:
533
+
534
+ ```erb
535
+ <%= advanced_select_tag(
536
+ "record[item_id]",
537
+ id: "record_item_id",
538
+ selected: selected_option,
539
+ options: [],
540
+ placeholder: t(".item_placeholder"),
541
+ options_url: item_options_path,
542
+ dependent_fields: { parent_id: "#record_parent_id" },
543
+ auto_select_single: false
544
+ ) %>
545
+ ```
546
+
504
547
  ### Custom Option Content
505
548
 
506
549
  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:
@@ -630,6 +673,8 @@ advanced_select_tag(
630
673
  add_mode: false,
631
674
  dependent_fields: {},
632
675
  include_hidden: true,
676
+ auto_select_single: true,
677
+ eager: true,
633
678
  option_content_partial: nil,
634
679
  classes: {},
635
680
  append_classes: {}
@@ -5,8 +5,11 @@ export default class extends Controller {
5
5
  static targets = ["hiddenFields", "trigger", "summary", "dropdown", "search", "options", "caret", "clear"]
6
6
  static values = {
7
7
  addMode: Boolean,
8
+ autoSelectSingle: { type: Boolean, default: true },
8
9
  delay: { type: Number, default: 200 },
9
10
  dependentFields: Object,
11
+ eager: { type: Boolean, default: true },
12
+ emptyText: String,
10
13
  errorText: String,
11
14
  includeHidden: { type: Boolean, default: true },
12
15
  inputId: String,
@@ -29,6 +32,7 @@ export default class extends Controller {
29
32
  this.tokenClass = this.element.dataset.advancedSelectTokenClass || "ui-advanced-select-token"
30
33
  this.loadingClass = this.element.dataset.advancedSelectLoadingClass || "ui-advanced-select-loading"
31
34
  this.errorClass = this.element.dataset.advancedSelectErrorClass || "ui-advanced-select-error"
35
+ this.emptyClass = this.element.dataset.advancedSelectEmptyClass || "ui-advanced-select-empty"
32
36
  this.optionActiveClasses = this.classList(this.element.dataset.advancedSelectOptionActiveClass || "ui-advanced-select-option-active")
33
37
  this.addOptionActiveClasses = this.classList(this.element.dataset.advancedSelectAddOptionActiveClass || "")
34
38
  this.optionSelectedClasses = this.classList(this.element.dataset.advancedSelectOptionSelectedClass || "")
@@ -39,11 +43,16 @@ export default class extends Controller {
39
43
  }))
40
44
  this.close = this.close.bind(this)
41
45
  this.renderOptionsState()
46
+ this.setupEagerDependentFields()
42
47
  }
43
48
 
44
49
  disconnect() {
45
50
  window.clearTimeout(this.timer)
46
51
  document.removeEventListener("click", this.close)
52
+
53
+ if (this.dependentChangeListener) {
54
+ document.removeEventListener("change", this.dependentChangeListener)
55
+ }
47
56
  }
48
57
 
49
58
  toggle(event) {
@@ -61,7 +70,7 @@ export default class extends Controller {
61
70
  this.triggerTarget.setAttribute("aria-expanded", "true")
62
71
  document.addEventListener("click", this.close)
63
72
  this.activate(-1)
64
- this.fetchOptions({ selected: true })
73
+ this.fetchOptions({ selected: true, autoSelect: true })
65
74
 
66
75
  if (this.searchableValue) {
67
76
  requestAnimationFrame(() => this.searchTarget.focus())
@@ -81,6 +90,12 @@ export default class extends Controller {
81
90
  }
82
91
 
83
92
  search() {
93
+ if (!this.urlValue) {
94
+ this.filterOptions()
95
+ this.activate(-1)
96
+ return
97
+ }
98
+
84
99
  window.clearTimeout(this.timer)
85
100
  this.renderLoading()
86
101
  this.activate(-1)
@@ -127,10 +142,58 @@ export default class extends Controller {
127
142
  clearSearch() {
128
143
  if (this.searchableValue) {
129
144
  this.searchTarget.value = ""
145
+
146
+ if (!this.urlValue) {
147
+ this.filterOptions()
148
+ }
149
+ }
150
+ }
151
+
152
+ filterOptions() {
153
+ const query = this.normalize(this.searchTarget.value)
154
+ const container = this.currentOptionsTarget
155
+ let visibleCount = 0
156
+ let currentGroup = null
157
+ let groupHasMatch = false
158
+
159
+ Array.from(container.children).forEach((child) => {
160
+ if (child.hasAttribute("data-advanced-select-group-label")) {
161
+ if (currentGroup) currentGroup.classList.toggle("hidden", !groupHasMatch)
162
+ currentGroup = child
163
+ groupHasMatch = false
164
+ } else if (child.hasAttribute("data-advanced-select-option")) {
165
+ const match = this.normalize(child.dataset.advancedSelectLabelParam).includes(query)
166
+ child.classList.toggle("hidden", !match)
167
+ if (match) {
168
+ visibleCount += 1
169
+ groupHasMatch = true
170
+ }
171
+ }
172
+ })
173
+
174
+ if (currentGroup) currentGroup.classList.toggle("hidden", !groupHasMatch)
175
+
176
+ this.toggleEmptyState(visibleCount === 0)
177
+ }
178
+
179
+ toggleEmptyState(empty) {
180
+ const container = this.currentOptionsTarget
181
+ let emptyState = container.querySelector("[data-advanced-select-empty-state]")
182
+
183
+ if (empty && !emptyState) {
184
+ emptyState = this.textElement("div", this.emptyClass, this.emptyTextValue)
185
+ emptyState.setAttribute("data-advanced-select-empty-state", "")
186
+ container.appendChild(emptyState)
187
+ } else if (!empty && emptyState) {
188
+ emptyState.remove()
130
189
  }
131
190
  }
132
191
 
133
- fetchOptions({ selected = false } = {}) {
192
+ normalize(text) {
193
+ return (text || "").trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "")
194
+ }
195
+
196
+ fetchOptions({ selected = false, autoSelect = false, eager = false } = {}) {
134
197
  if (!this.urlValue) {
135
198
  return
136
199
  }
@@ -165,23 +228,27 @@ export default class extends Controller {
165
228
  return response.text()
166
229
  })
167
230
  .then((html) => {
168
- if (!this.expanded || requestSequence !== this.requestSequence) {
231
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
169
232
  return
170
233
  }
171
234
 
172
235
  Turbo.renderStreamMessage(html)
173
236
  requestAnimationFrame(() => {
174
- if (!this.expanded || requestSequence !== this.requestSequence) {
237
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
175
238
  return
176
239
  }
177
240
 
178
241
  this.currentOptionsTarget.setAttribute("aria-busy", "false")
179
242
  this.renderOptionsState()
180
243
  this.activate(-1)
244
+
245
+ if (autoSelect) {
246
+ this.autoSelectSingle()
247
+ }
181
248
  })
182
249
  })
183
250
  .catch(() => {
184
- if (!this.expanded || requestSequence !== this.requestSequence) {
251
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
185
252
  return
186
253
  }
187
254
 
@@ -238,6 +305,14 @@ export default class extends Controller {
238
305
  this.renderOptionsState()
239
306
  this.caretTarget.classList.toggle("hidden", this.selectedValue.length > 0)
240
307
  this.clearTarget.classList.toggle("hidden", this.selectedValue.length === 0)
308
+ this.dispatchValueChange()
309
+ }
310
+
311
+ dispatchValueChange() {
312
+ const input = this.hiddenFieldsTarget.querySelector("input")
313
+ if (input) {
314
+ input.dispatchEvent(new Event("change", { bubbles: true }))
315
+ }
241
316
  }
242
317
 
243
318
  renderOptionsState() {
@@ -263,6 +338,54 @@ export default class extends Controller {
263
338
  }
264
339
  }
265
340
 
341
+ setupEagerDependentFields() {
342
+ if (!this.eagerValue || !this.urlValue) {
343
+ return
344
+ }
345
+
346
+ this.dependentSelectors = [...new Set(Object.values(this.dependentFieldsValue))]
347
+ if (this.dependentSelectors.length === 0) {
348
+ return
349
+ }
350
+
351
+ this.dependentChangeListener = (event) => {
352
+ if (event.target instanceof Element && this.dependentSelectors.some((selector) => event.target.matches(selector))) {
353
+ this.reloadDependentOptions()
354
+ }
355
+ }
356
+ document.addEventListener("change", this.dependentChangeListener)
357
+
358
+ if (this.selectedValue.length === 0) {
359
+ this.fetchOptions({ eager: true, autoSelect: true })
360
+ }
361
+ }
362
+
363
+ reloadDependentOptions() {
364
+ this.selectedValue = []
365
+ this.renderSelection()
366
+ this.clearSearch()
367
+ this.fetchOptions({ eager: true, autoSelect: true })
368
+ }
369
+
370
+ autoSelectSingle() {
371
+ if (!this.autoSelectSingleValue || this.multipleValue || this.selectedValue.length > 0) {
372
+ return
373
+ }
374
+
375
+ const options = this.selectableOptionElements
376
+ if (options.length !== 1) {
377
+ return
378
+ }
379
+
380
+ const option = options[0]
381
+ this.selectOption(
382
+ option.dataset.advancedSelectValueParam,
383
+ option.dataset.advancedSelectLabelParam,
384
+ option.dataset.advancedSelectSubmitValueParam || option.dataset.advancedSelectValueParam,
385
+ { displayLabel: option.dataset.advancedSelectDisplayLabelParam, refreshOptions: false }
386
+ )
387
+ }
388
+
266
389
  chooseActiveOption() {
267
390
  if (this.activeOption.hasAttribute("data-advanced-select-add-option")) {
268
391
  this.addOption(
@@ -308,6 +431,10 @@ export default class extends Controller {
308
431
  return this.optionElements[this.activeIndex]
309
432
  }
310
433
 
434
+ get selectableOptionElements() {
435
+ return Array.from(this.currentOptionsTarget.querySelectorAll("[data-advanced-select-option]")).filter((option) => !option.classList.contains("hidden"))
436
+ }
437
+
311
438
  get currentOptionsTarget() {
312
439
  return document.getElementById(this.targetIdValue) || this.optionsTarget
313
440
  }
@@ -8,7 +8,7 @@
8
8
 
9
9
  <% advanced_select_option_groups(options).each do |group| %>
10
10
  <% if group[:label].present? %>
11
- <div class="<%= advanced_select_class(class_map, :group_label) %>"><%= group.fetch(:label) %></div>
11
+ <div class="<%= advanced_select_class(class_map, :group_label) %>" data-advanced-select-group-label><%= group.fetch(:label) %></div>
12
12
  <% end %>
13
13
 
14
14
  <% group.fetch(:options).each do |option| %>
@@ -28,6 +28,6 @@
28
28
  <%= t("shared.advanced_select.add_option", query: query) %>
29
29
  </button>
30
30
  <% elsif advanced_select_options_empty?(options) %>
31
- <div class="<%= advanced_select_class(class_map, :empty) %>"><%= t("shared.advanced_select.empty") %></div>
31
+ <div class="<%= advanced_select_class(class_map, :empty) %>" data-advanced-select-empty-state><%= t("shared.advanced_select.empty") %></div>
32
32
  <% end %>
33
33
  </div>
@@ -5,6 +5,7 @@
5
5
  data-advanced-select-name-value="<%= name %>"
6
6
  data-advanced-select-input-id-value="<%= id %>"
7
7
  data-advanced-select-placeholder-value="<%= placeholder %>"
8
+ data-advanced-select-empty-text-value="<%= t("shared.advanced_select.empty") %>"
8
9
  data-advanced-select-loading-text-value="<%= t("shared.advanced_select.loading") %>"
9
10
  data-advanced-select-error-text-value="<%= t("shared.advanced_select.error") %>"
10
11
  data-advanced-select-dependent-fields-value="<%= dependent_fields.to_json %>"
@@ -12,9 +13,12 @@
12
13
  data-advanced-select-searchable-value="<%= searchable %>"
13
14
  data-advanced-select-add-mode-value="<%= add_mode %>"
14
15
  data-advanced-select-include-hidden-value="<%= include_hidden %>"
16
+ data-advanced-select-auto-select-single-value="<%= auto_select_single %>"
17
+ data-advanced-select-eager-value="<%= eager %>"
15
18
  data-advanced-select-placeholder-class="<%= advanced_select_class(class_map, :placeholder) %>"
16
19
  data-advanced-select-value-class="<%= advanced_select_class(class_map, :value) %>"
17
20
  data-advanced-select-token-class="<%= advanced_select_class(class_map, :token) %>"
21
+ data-advanced-select-empty-class="<%= advanced_select_class(class_map, :empty) %>"
18
22
  data-advanced-select-loading-class="<%= advanced_select_class(class_map, :loading) %>"
19
23
  data-advanced-select-error-class="<%= advanced_select_class(class_map, :error) %>"
20
24
  data-advanced-select-option-active-class="<%= advanced_select_state_class(class_map, :option_active) %>"
@@ -56,7 +60,7 @@
56
60
  <%= tag.input type: "search",
57
61
  id: "#{id}_search",
58
62
  autocomplete: "off",
59
- placeholder: placeholder,
63
+ placeholder: t("shared.advanced_select.search_placeholder"),
60
64
  class: advanced_select_class(class_map, :search),
61
65
  data: {
62
66
  advanced_select_target: "search",
@@ -5,3 +5,4 @@ en:
5
5
  empty: "No options found"
6
6
  error: "Options could not be loaded"
7
7
  loading: "Loading..."
8
+ search_placeholder: "Search..."
@@ -5,3 +5,4 @@ tr:
5
5
  empty: "Seçenek bulunamadı"
6
6
  error: "Seçenekler yüklenemedi"
7
7
  loading: "Yükleniyor..."
8
+ search_placeholder: "Ara..."
@@ -1,6 +1,6 @@
1
1
  module AdvancedSelect
2
2
  module Helper
3
- def advanced_select_tag(name, id:, selected:, options:, placeholder:, options_url: nil, multiple: false, searchable: true, add_mode: false, dependent_fields: {}, include_hidden: true, option_content_partial: nil, classes: {}, append_classes: {})
3
+ def advanced_select_tag(name, id:, selected:, options:, placeholder:, options_url: nil, multiple: false, searchable: true, add_mode: false, dependent_fields: {}, include_hidden: true, auto_select_single: true, eager: true, option_content_partial: nil, classes: {}, append_classes: {})
4
4
  selected_options = advanced_select_selected_options(selected)
5
5
  class_map = advanced_select_class_map(classes, append_classes)
6
6
 
@@ -12,10 +12,12 @@ module AdvancedSelect
12
12
  options: options,
13
13
  placeholder: placeholder,
14
14
  multiple: multiple,
15
- searchable: searchable && options_url.present?,
15
+ searchable: searchable,
16
16
  add_mode: add_mode,
17
17
  dependent_fields: dependent_fields,
18
18
  include_hidden: include_hidden,
19
+ auto_select_single: auto_select_single,
20
+ eager: eager,
19
21
  target_id: "#{id}_options",
20
22
  option_content_partial: option_content_partial,
21
23
  class_map: class_map
@@ -1,3 +1,3 @@
1
1
  module AdvancedSelect
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.4"
3
3
  end
@@ -5,9 +5,13 @@ export default class extends Controller {
5
5
  static targets = ["hiddenFields", "trigger", "summary", "dropdown", "search", "options", "caret", "clear"]
6
6
  static values = {
7
7
  addMode: Boolean,
8
+ autoSelectSingle: { type: Boolean, default: true },
8
9
  delay: { type: Number, default: 200 },
9
10
  dependentFields: Object,
11
+ eager: { type: Boolean, default: true },
12
+ emptyText: String,
10
13
  errorText: String,
14
+ includeHidden: { type: Boolean, default: true },
11
15
  inputId: String,
12
16
  loadingText: String,
13
17
  multiple: Boolean,
@@ -28,6 +32,7 @@ export default class extends Controller {
28
32
  this.tokenClass = this.element.dataset.advancedSelectTokenClass || "ui-advanced-select-token"
29
33
  this.loadingClass = this.element.dataset.advancedSelectLoadingClass || "ui-advanced-select-loading"
30
34
  this.errorClass = this.element.dataset.advancedSelectErrorClass || "ui-advanced-select-error"
35
+ this.emptyClass = this.element.dataset.advancedSelectEmptyClass || "ui-advanced-select-empty"
31
36
  this.optionActiveClasses = this.classList(this.element.dataset.advancedSelectOptionActiveClass || "ui-advanced-select-option-active")
32
37
  this.addOptionActiveClasses = this.classList(this.element.dataset.advancedSelectAddOptionActiveClass || "")
33
38
  this.optionSelectedClasses = this.classList(this.element.dataset.advancedSelectOptionSelectedClass || "")
@@ -37,11 +42,17 @@ export default class extends Controller {
37
42
  displayLabel: option.displayLabel || option.label
38
43
  }))
39
44
  this.close = this.close.bind(this)
45
+ this.renderOptionsState()
46
+ this.setupEagerDependentFields()
40
47
  }
41
48
 
42
49
  disconnect() {
43
50
  window.clearTimeout(this.timer)
44
51
  document.removeEventListener("click", this.close)
52
+
53
+ if (this.dependentChangeListener) {
54
+ document.removeEventListener("change", this.dependentChangeListener)
55
+ }
45
56
  }
46
57
 
47
58
  toggle(event) {
@@ -59,7 +70,7 @@ export default class extends Controller {
59
70
  this.triggerTarget.setAttribute("aria-expanded", "true")
60
71
  document.addEventListener("click", this.close)
61
72
  this.activate(-1)
62
- this.fetchOptions({ selected: true })
73
+ this.fetchOptions({ selected: true, autoSelect: true })
63
74
 
64
75
  if (this.searchableValue) {
65
76
  requestAnimationFrame(() => this.searchTarget.focus())
@@ -79,6 +90,12 @@ export default class extends Controller {
79
90
  }
80
91
 
81
92
  search() {
93
+ if (!this.urlValue) {
94
+ this.filterOptions()
95
+ this.activate(-1)
96
+ return
97
+ }
98
+
82
99
  window.clearTimeout(this.timer)
83
100
  this.renderLoading()
84
101
  this.activate(-1)
@@ -125,10 +142,58 @@ export default class extends Controller {
125
142
  clearSearch() {
126
143
  if (this.searchableValue) {
127
144
  this.searchTarget.value = ""
145
+
146
+ if (!this.urlValue) {
147
+ this.filterOptions()
148
+ }
128
149
  }
129
150
  }
130
151
 
131
- fetchOptions({ selected = false } = {}) {
152
+ filterOptions() {
153
+ const query = this.normalize(this.searchTarget.value)
154
+ const container = this.currentOptionsTarget
155
+ let visibleCount = 0
156
+ let currentGroup = null
157
+ let groupHasMatch = false
158
+
159
+ Array.from(container.children).forEach((child) => {
160
+ if (child.hasAttribute("data-advanced-select-group-label")) {
161
+ if (currentGroup) currentGroup.classList.toggle("hidden", !groupHasMatch)
162
+ currentGroup = child
163
+ groupHasMatch = false
164
+ } else if (child.hasAttribute("data-advanced-select-option")) {
165
+ const match = this.normalize(child.dataset.advancedSelectLabelParam).includes(query)
166
+ child.classList.toggle("hidden", !match)
167
+ if (match) {
168
+ visibleCount += 1
169
+ groupHasMatch = true
170
+ }
171
+ }
172
+ })
173
+
174
+ if (currentGroup) currentGroup.classList.toggle("hidden", !groupHasMatch)
175
+
176
+ this.toggleEmptyState(visibleCount === 0)
177
+ }
178
+
179
+ toggleEmptyState(empty) {
180
+ const container = this.currentOptionsTarget
181
+ let emptyState = container.querySelector("[data-advanced-select-empty-state]")
182
+
183
+ if (empty && !emptyState) {
184
+ emptyState = this.textElement("div", this.emptyClass, this.emptyTextValue)
185
+ emptyState.setAttribute("data-advanced-select-empty-state", "")
186
+ container.appendChild(emptyState)
187
+ } else if (!empty && emptyState) {
188
+ emptyState.remove()
189
+ }
190
+ }
191
+
192
+ normalize(text) {
193
+ return (text || "").trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "")
194
+ }
195
+
196
+ fetchOptions({ selected = false, autoSelect = false, eager = false } = {}) {
132
197
  if (!this.urlValue) {
133
198
  return
134
199
  }
@@ -163,23 +228,27 @@ export default class extends Controller {
163
228
  return response.text()
164
229
  })
165
230
  .then((html) => {
166
- if (!this.expanded || requestSequence !== this.requestSequence) {
231
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
167
232
  return
168
233
  }
169
234
 
170
235
  Turbo.renderStreamMessage(html)
171
236
  requestAnimationFrame(() => {
172
- if (!this.expanded || requestSequence !== this.requestSequence) {
237
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
173
238
  return
174
239
  }
175
240
 
176
241
  this.currentOptionsTarget.setAttribute("aria-busy", "false")
177
242
  this.renderOptionsState()
178
243
  this.activate(-1)
244
+
245
+ if (autoSelect) {
246
+ this.autoSelectSingle()
247
+ }
179
248
  })
180
249
  })
181
250
  .catch(() => {
182
- if (!this.expanded || requestSequence !== this.requestSequence) {
251
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
183
252
  return
184
253
  }
185
254
 
@@ -214,7 +283,7 @@ export default class extends Controller {
214
283
  if (this.selectedValue.some((option) => option.id === value)) {
215
284
  this.selectedValue = this.selectedValue.filter((option) => option.id !== value)
216
285
  } else {
217
- this.selectedValue = this.selectedValue.concat({ id: value, value: submitValue, label, displayLabel })
286
+ this.selectedValue = [{ id: value, value: submitValue, label, displayLabel }, ...this.selectedValue]
218
287
  }
219
288
  } else {
220
289
  this.selectedValue = [{ id: value, value: submitValue, label, displayLabel }]
@@ -236,10 +305,19 @@ export default class extends Controller {
236
305
  this.renderOptionsState()
237
306
  this.caretTarget.classList.toggle("hidden", this.selectedValue.length > 0)
238
307
  this.clearTarget.classList.toggle("hidden", this.selectedValue.length === 0)
308
+ this.dispatchValueChange()
309
+ }
310
+
311
+ dispatchValueChange() {
312
+ const input = this.hiddenFieldsTarget.querySelector("input")
313
+ if (input) {
314
+ input.dispatchEvent(new Event("change", { bubbles: true }))
315
+ }
239
316
  }
240
317
 
241
318
  renderOptionsState() {
242
319
  const selectedIds = new Set(this.selectedValue.map((option) => option.id))
320
+ const container = this.currentOptionsTarget
243
321
 
244
322
  this.optionElements.forEach((option) => {
245
323
  const selected = selectedIds.has(option.dataset.advancedSelectValueParam)
@@ -251,6 +329,63 @@ export default class extends Controller {
251
329
  check.textContent = selected ? "\u2713" : ""
252
330
  }
253
331
  })
332
+
333
+ for (let i = this.selectedValue.length - 1; i >= 0; i--) {
334
+ const option = container.querySelector(
335
+ `[data-advanced-select-option][data-advanced-select-value-param="${this.selectedValue[i].id}"]`
336
+ )
337
+ if (option) container.prepend(option)
338
+ }
339
+ }
340
+
341
+ setupEagerDependentFields() {
342
+ if (!this.eagerValue || !this.urlValue) {
343
+ return
344
+ }
345
+
346
+ this.dependentSelectors = [...new Set(Object.values(this.dependentFieldsValue))]
347
+ if (this.dependentSelectors.length === 0) {
348
+ return
349
+ }
350
+
351
+ // Delegated on document so it keeps working after a parent advanced select
352
+ // rewrites its hidden input on each selection.
353
+ this.dependentChangeListener = (event) => {
354
+ if (event.target instanceof Element && this.dependentSelectors.some((selector) => event.target.matches(selector))) {
355
+ this.reloadDependentOptions()
356
+ }
357
+ }
358
+ document.addEventListener("change", this.dependentChangeListener)
359
+
360
+ if (this.selectedValue.length === 0) {
361
+ this.fetchOptions({ eager: true, autoSelect: true })
362
+ }
363
+ }
364
+
365
+ reloadDependentOptions() {
366
+ this.selectedValue = []
367
+ this.renderSelection()
368
+ this.clearSearch()
369
+ this.fetchOptions({ eager: true, autoSelect: true })
370
+ }
371
+
372
+ autoSelectSingle() {
373
+ if (!this.autoSelectSingleValue || this.multipleValue || this.selectedValue.length > 0) {
374
+ return
375
+ }
376
+
377
+ const options = this.selectableOptionElements
378
+ if (options.length !== 1) {
379
+ return
380
+ }
381
+
382
+ const option = options[0]
383
+ this.selectOption(
384
+ option.dataset.advancedSelectValueParam,
385
+ option.dataset.advancedSelectLabelParam,
386
+ option.dataset.advancedSelectSubmitValueParam || option.dataset.advancedSelectValueParam,
387
+ { displayLabel: option.dataset.advancedSelectDisplayLabelParam, refreshOptions: false }
388
+ )
254
389
  }
255
390
 
256
391
  chooseActiveOption() {
@@ -298,12 +433,22 @@ export default class extends Controller {
298
433
  return this.optionElements[this.activeIndex]
299
434
  }
300
435
 
436
+ get selectableOptionElements() {
437
+ return Array.from(this.currentOptionsTarget.querySelectorAll("[data-advanced-select-option]")).filter((option) => !option.classList.contains("hidden"))
438
+ }
439
+
301
440
  get currentOptionsTarget() {
302
441
  return document.getElementById(this.targetIdValue) || this.optionsTarget
303
442
  }
304
443
 
305
444
  get hiddenFieldElements() {
306
- const options = this.multipleValue ? this.selectedValue : [this.selectedValue[0]]
445
+ let options
446
+
447
+ if (this.multipleValue) {
448
+ options = this.includeHiddenValue ? [null, ...this.selectedValue] : this.selectedValue
449
+ } else {
450
+ options = [this.selectedValue[0]]
451
+ }
307
452
 
308
453
  return options.map((option) => {
309
454
  const input = document.createElement("input")
@@ -376,4 +521,4 @@ export default class extends Controller {
376
521
  get expanded() {
377
522
  return this.triggerTarget.getAttribute("aria-expanded") === "true"
378
523
  }
379
- }
524
+ }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: advanced_select
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mehmet Celik