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 +4 -4
- data/README.md +48 -3
- data/app/javascript/advanced_select/advanced_select_controller.js +132 -5
- data/app/views/advanced_select/_options.html.erb +2 -2
- data/app/views/advanced_select/_select.html.erb +5 -1
- data/config/locales/en.yml +1 -0
- data/config/locales/tr.yml +1 -0
- data/lib/advanced_select/helper.rb +4 -2
- data/lib/advanced_select/version.rb +1 -1
- data/lib/generators/advanced_select/install/templates/advanced_select_controller.js +153 -8
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9be8d05d537d5e36a565546c0f821c68a6a62cd5e3978cbbe23d3ef7451c1f2a
|
|
4
|
+
data.tar.gz: c6c4c0667ef096fd55e57024621eb9a21d2338ecbef2c2305e2bf2edab0fad4d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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",
|
data/config/locales/en.yml
CHANGED
data/config/locales/tr.yml
CHANGED
|
@@ -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
|
|
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
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|