advanced_select 0.1.3 → 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: 254f883547b3f323b98150becc0db7f8ff76f609a9a7186a5d1eb8903a0afb7f
4
- data.tar.gz: 56b4d1866feaad22d4c4ed00bb2ab573240dbedda3d2f7cc6a1759c7da53b571
3
+ metadata.gz: 9be8d05d537d5e36a565546c0f821c68a6a62cd5e3978cbbe23d3ef7451c1f2a
4
+ data.tar.gz: c6c4c0667ef096fd55e57024621eb9a21d2338ecbef2c2305e2bf2edab0fad4d
5
5
  SHA512:
6
- metadata.gz: f6a74b01aed5465044972b7f89d75148a9192120d8bba05e7a212899a11715198a3a4743092e329e1dc8ae4ccb0c86c2ab21ccafb48b88fa52059212b6d4acc3
7
- data.tar.gz: 9df6c7b80f331cdc57fabaddce592f1f899728fe643d73ac3c98149acdb6b9f4c7a559e2fd87113655338ece19695fcfed7fbb6b570808bf02395f44c568301b
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:
@@ -502,6 +502,48 @@ Use `dependent_fields` when a remote option endpoint depends on another field va
502
502
 
503
503
  The remote request will include `parent_id=<current field value>`.
504
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
+
505
547
  ### Custom Option Content
506
548
 
507
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:
@@ -631,6 +673,8 @@ advanced_select_tag(
631
673
  add_mode: false,
632
674
  dependent_fields: {},
633
675
  include_hidden: true,
676
+ auto_select_single: true,
677
+ eager: true,
634
678
  option_content_partial: nil,
635
679
  classes: {},
636
680
  append_classes: {}
@@ -5,8 +5,10 @@ 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 },
10
12
  emptyText: String,
11
13
  errorText: String,
12
14
  includeHidden: { type: Boolean, default: true },
@@ -41,11 +43,16 @@ export default class extends Controller {
41
43
  }))
42
44
  this.close = this.close.bind(this)
43
45
  this.renderOptionsState()
46
+ this.setupEagerDependentFields()
44
47
  }
45
48
 
46
49
  disconnect() {
47
50
  window.clearTimeout(this.timer)
48
51
  document.removeEventListener("click", this.close)
52
+
53
+ if (this.dependentChangeListener) {
54
+ document.removeEventListener("change", this.dependentChangeListener)
55
+ }
49
56
  }
50
57
 
51
58
  toggle(event) {
@@ -63,7 +70,7 @@ export default class extends Controller {
63
70
  this.triggerTarget.setAttribute("aria-expanded", "true")
64
71
  document.addEventListener("click", this.close)
65
72
  this.activate(-1)
66
- this.fetchOptions({ selected: true })
73
+ this.fetchOptions({ selected: true, autoSelect: true })
67
74
 
68
75
  if (this.searchableValue) {
69
76
  requestAnimationFrame(() => this.searchTarget.focus())
@@ -186,7 +193,7 @@ export default class extends Controller {
186
193
  return (text || "").trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "")
187
194
  }
188
195
 
189
- fetchOptions({ selected = false } = {}) {
196
+ fetchOptions({ selected = false, autoSelect = false, eager = false } = {}) {
190
197
  if (!this.urlValue) {
191
198
  return
192
199
  }
@@ -221,23 +228,27 @@ export default class extends Controller {
221
228
  return response.text()
222
229
  })
223
230
  .then((html) => {
224
- if (!this.expanded || requestSequence !== this.requestSequence) {
231
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
225
232
  return
226
233
  }
227
234
 
228
235
  Turbo.renderStreamMessage(html)
229
236
  requestAnimationFrame(() => {
230
- if (!this.expanded || requestSequence !== this.requestSequence) {
237
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
231
238
  return
232
239
  }
233
240
 
234
241
  this.currentOptionsTarget.setAttribute("aria-busy", "false")
235
242
  this.renderOptionsState()
236
243
  this.activate(-1)
244
+
245
+ if (autoSelect) {
246
+ this.autoSelectSingle()
247
+ }
237
248
  })
238
249
  })
239
250
  .catch(() => {
240
- if (!this.expanded || requestSequence !== this.requestSequence) {
251
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
241
252
  return
242
253
  }
243
254
 
@@ -294,6 +305,14 @@ export default class extends Controller {
294
305
  this.renderOptionsState()
295
306
  this.caretTarget.classList.toggle("hidden", this.selectedValue.length > 0)
296
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
+ }
297
316
  }
298
317
 
299
318
  renderOptionsState() {
@@ -319,6 +338,54 @@ export default class extends Controller {
319
338
  }
320
339
  }
321
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
+
322
389
  chooseActiveOption() {
323
390
  if (this.activeOption.hasAttribute("data-advanced-select-add-option")) {
324
391
  this.addOption(
@@ -364,6 +431,10 @@ export default class extends Controller {
364
431
  return this.optionElements[this.activeIndex]
365
432
  }
366
433
 
434
+ get selectableOptionElements() {
435
+ return Array.from(this.currentOptionsTarget.querySelectorAll("[data-advanced-select-option]")).filter((option) => !option.classList.contains("hidden"))
436
+ }
437
+
367
438
  get currentOptionsTarget() {
368
439
  return document.getElementById(this.targetIdValue) || this.optionsTarget
369
440
  }
@@ -13,6 +13,8 @@
13
13
  data-advanced-select-searchable-value="<%= searchable %>"
14
14
  data-advanced-select-add-mode-value="<%= add_mode %>"
15
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 %>"
16
18
  data-advanced-select-placeholder-class="<%= advanced_select_class(class_map, :placeholder) %>"
17
19
  data-advanced-select-value-class="<%= advanced_select_class(class_map, :value) %>"
18
20
  data-advanced-select-token-class="<%= advanced_select_class(class_map, :token) %>"
@@ -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
 
@@ -16,6 +16,8 @@ module AdvancedSelect
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.3"
2
+ VERSION = "0.1.4"
3
3
  end
@@ -5,8 +5,10 @@ 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 },
10
12
  emptyText: String,
11
13
  errorText: String,
12
14
  includeHidden: { type: Boolean, default: true },
@@ -41,11 +43,16 @@ export default class extends Controller {
41
43
  }))
42
44
  this.close = this.close.bind(this)
43
45
  this.renderOptionsState()
46
+ this.setupEagerDependentFields()
44
47
  }
45
48
 
46
49
  disconnect() {
47
50
  window.clearTimeout(this.timer)
48
51
  document.removeEventListener("click", this.close)
52
+
53
+ if (this.dependentChangeListener) {
54
+ document.removeEventListener("change", this.dependentChangeListener)
55
+ }
49
56
  }
50
57
 
51
58
  toggle(event) {
@@ -63,7 +70,7 @@ export default class extends Controller {
63
70
  this.triggerTarget.setAttribute("aria-expanded", "true")
64
71
  document.addEventListener("click", this.close)
65
72
  this.activate(-1)
66
- this.fetchOptions({ selected: true })
73
+ this.fetchOptions({ selected: true, autoSelect: true })
67
74
 
68
75
  if (this.searchableValue) {
69
76
  requestAnimationFrame(() => this.searchTarget.focus())
@@ -186,7 +193,7 @@ export default class extends Controller {
186
193
  return (text || "").trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "")
187
194
  }
188
195
 
189
- fetchOptions({ selected = false } = {}) {
196
+ fetchOptions({ selected = false, autoSelect = false, eager = false } = {}) {
190
197
  if (!this.urlValue) {
191
198
  return
192
199
  }
@@ -221,23 +228,27 @@ export default class extends Controller {
221
228
  return response.text()
222
229
  })
223
230
  .then((html) => {
224
- if (!this.expanded || requestSequence !== this.requestSequence) {
231
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
225
232
  return
226
233
  }
227
234
 
228
235
  Turbo.renderStreamMessage(html)
229
236
  requestAnimationFrame(() => {
230
- if (!this.expanded || requestSequence !== this.requestSequence) {
237
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
231
238
  return
232
239
  }
233
240
 
234
241
  this.currentOptionsTarget.setAttribute("aria-busy", "false")
235
242
  this.renderOptionsState()
236
243
  this.activate(-1)
244
+
245
+ if (autoSelect) {
246
+ this.autoSelectSingle()
247
+ }
237
248
  })
238
249
  })
239
250
  .catch(() => {
240
- if (!this.expanded || requestSequence !== this.requestSequence) {
251
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
241
252
  return
242
253
  }
243
254
 
@@ -294,6 +305,14 @@ export default class extends Controller {
294
305
  this.renderOptionsState()
295
306
  this.caretTarget.classList.toggle("hidden", this.selectedValue.length > 0)
296
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
+ }
297
316
  }
298
317
 
299
318
  renderOptionsState() {
@@ -319,6 +338,56 @@ export default class extends Controller {
319
338
  }
320
339
  }
321
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
+ )
389
+ }
390
+
322
391
  chooseActiveOption() {
323
392
  if (this.activeOption.hasAttribute("data-advanced-select-add-option")) {
324
393
  this.addOption(
@@ -364,6 +433,10 @@ export default class extends Controller {
364
433
  return this.optionElements[this.activeIndex]
365
434
  }
366
435
 
436
+ get selectableOptionElements() {
437
+ return Array.from(this.currentOptionsTarget.querySelectorAll("[data-advanced-select-option]")).filter((option) => !option.classList.contains("hidden"))
438
+ }
439
+
367
440
  get currentOptionsTarget() {
368
441
  return document.getElementById(this.targetIdValue) || this.optionsTarget
369
442
  }
@@ -448,4 +521,4 @@ export default class extends Controller {
448
521
  get expanded() {
449
522
  return this.triggerTarget.getAttribute("aria-expanded") === "true"
450
523
  }
451
- }
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.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mehmet Celik