advanced_select 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +157 -2
- data/app/assets/stylesheets/advanced_select/advanced_select.css +10 -0
- data/app/javascript/advanced_select/advanced_select_controller.js +156 -5
- data/app/views/advanced_select/_select.html.erb +12 -5
- data/lib/advanced_select/class_map.rb +1 -0
- data/lib/advanced_select/helper.rb +2 -1
- data/lib/advanced_select/version.rb +1 -1
- data/lib/generators/advanced_select/install/templates/advanced_select.css +10 -0
- data/lib/generators/advanced_select/install/templates/advanced_select_controller.js +156 -5
- 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: be334c14eaabfa7e2d3685deb4f1404b7187cb51c94d8a8dcb17b17e70bc5435
|
|
4
|
+
data.tar.gz: b9a676e2e4a64345631612285e8a362034b4efaaf60650cd201eadb2fa41b5a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: be666dac14558aa5437cdf536c4bac3e8b392c61694904794a9d935de038152d6852c19e1d537a833f80836ae1101671890b707ea17732773905f185a81bc007
|
|
7
|
+
data.tar.gz: 5ac9dd843c6e837480dbc2fd6a00ecb6013e150a4c278e46ad75dafa1d8476973ac3e081125cd5ef678f5ba5fa5c6d2eb54676fc30fa258c870cd72833aa8a0d
|
data/README.md
CHANGED
|
@@ -23,6 +23,9 @@ AdvancedSelect is a small Rails engine for rendering an advanced select input wi
|
|
|
23
23
|
- [Custom Option Content](#custom-option-content)
|
|
24
24
|
- [Option Contract](#option-contract)
|
|
25
25
|
- [Events](#events)
|
|
26
|
+
- [Reading And Writing The Value](#reading-and-writing-the-value)
|
|
27
|
+
- [Driving The Option List](#driving-the-option-list)
|
|
28
|
+
- [Disabled State](#disabled-state)
|
|
26
29
|
- [API Reference](#api-reference)
|
|
27
30
|
- [Local Development](#local-development)
|
|
28
31
|
- [i18n](#i18n)
|
|
@@ -204,6 +207,30 @@ application.register("advanced-select", AdvancedSelectController)
|
|
|
204
207
|
|
|
205
208
|
This keeps local custom behavior small while allowing future gem fixes to flow through the base controller.
|
|
206
209
|
|
|
210
|
+
Two methods exist as extension points for host apps whose options endpoint does not speak Turbo
|
|
211
|
+
Streams. `readOptionsResponse(response)` turns the `fetch` response into a payload and defaults to
|
|
212
|
+
`response.text()`; `renderOptionsResponse(payload)` puts that payload on the page and defaults to
|
|
213
|
+
`Turbo.renderStreamMessage(payload)`. Overriding them keeps the request sequencing, the stale
|
|
214
|
+
response guard, and the post-render bookkeeping in the base controller:
|
|
215
|
+
|
|
216
|
+
```js
|
|
217
|
+
export default class extends AdvancedSelectController {
|
|
218
|
+
readOptionsResponse(response) {
|
|
219
|
+
return response.headers.get("content-type")?.includes("application/json")
|
|
220
|
+
? response.json()
|
|
221
|
+
: super.readOptionsResponse(response)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
renderOptionsResponse(payload) {
|
|
225
|
+
if (Array.isArray(payload)) {
|
|
226
|
+
this.replaceOptions(payload)
|
|
227
|
+
} else {
|
|
228
|
+
super.renderOptionsResponse(payload)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
207
234
|
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.
|
|
208
235
|
|
|
209
236
|
### jsbundling/Propshaft Example
|
|
@@ -755,6 +782,22 @@ Every value change dispatches two events:
|
|
|
755
782
|
| `change` | the first hidden input | native form behaviour and [dependent fields](#dependent-fields) |
|
|
756
783
|
| `advanced-select:change` | the root element | application listeners that need the whole selection |
|
|
757
784
|
|
|
785
|
+
A remote field also announces when its options finish rendering:
|
|
786
|
+
|
|
787
|
+
| Event | Target | Purpose |
|
|
788
|
+
| --- | --- | --- |
|
|
789
|
+
| `advanced-select:options-loaded` | the root element | application listeners that react to the option list itself |
|
|
790
|
+
|
|
791
|
+
It fires after every successful [remote](#remote-search) load — including eager
|
|
792
|
+
[dependent](#dependent-fields) loads — once the options are in the DOM and any single-option
|
|
793
|
+
auto-selection has been applied. Its `detail` carries the field `name`, the resulting `value`, and
|
|
794
|
+
the `count` of selectable options, which is enough to react to an empty or single-option result
|
|
795
|
+
without reading the DOM:
|
|
796
|
+
|
|
797
|
+
```erb
|
|
798
|
+
data-action="advanced-select:options-loaded->my-controller#optionsChanged"
|
|
799
|
+
```
|
|
800
|
+
|
|
758
801
|
Both bubble. `advanced-select:change` carries a `detail` payload:
|
|
759
802
|
|
|
760
803
|
```js
|
|
@@ -783,12 +826,111 @@ Prefer it over `change` on multiple selects. `change` is dispatched from the fir
|
|
|
783
826
|
it does not fire at all once the last value is cleared from a field rendered with
|
|
784
827
|
`include_hidden: false` — at that point the field has no inputs left.
|
|
785
828
|
|
|
786
|
-
The current value is also readable from the controller
|
|
829
|
+
The current value is also readable from the controller — see [Reading And Writing The Value](#reading-and-writing-the-value).
|
|
830
|
+
|
|
831
|
+
### Reading And Writing The Value
|
|
832
|
+
|
|
833
|
+
The controller exposes the selection as a small programmatic API, so host code can drive a field
|
|
834
|
+
without reaching into its markup:
|
|
835
|
+
|
|
836
|
+
```js
|
|
837
|
+
const select = application.getControllerForElementAndIdentifier(element, "advanced-select")
|
|
838
|
+
|
|
839
|
+
select.getValue() // => "7", or ["3", "7"] on a multiple select
|
|
840
|
+
select.setValue("7")
|
|
841
|
+
select.setValue(["3", "7"]) // multiple selects also accept a single value
|
|
842
|
+
select.setValue("7", { silent: true }) // assign without broadcasting a change
|
|
843
|
+
select.refresh() // re-render from the current selection
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
`getValue` returns the submit value — the option's `value` when it defines one, otherwise its `id`.
|
|
847
|
+
It is a string for single selects, an array for multiple selects, and empty when nothing is
|
|
848
|
+
selected.
|
|
849
|
+
|
|
850
|
+
`setValue` resolves each value against the options currently in the list, matching either the
|
|
851
|
+
option's id or its submit value, so `setValue("identity-7")` and `setValue("submit-7")` select the
|
|
852
|
+
same row. A value that matches no option is still assigned and submitted, using the value itself as
|
|
853
|
+
the label — this keeps a server-assigned value from being silently dropped. Passing `""`, `null`, or
|
|
854
|
+
`[]` clears the field. On a single select only the first value of an array is kept.
|
|
855
|
+
|
|
856
|
+
`silent: true` suppresses the `change` and `advanced-select:change` events for that one assignment.
|
|
857
|
+
Use it when the value is being restored rather than chosen — replacing options after a Turbo Stream
|
|
858
|
+
update, for example — so dependent fields do not cascade off a change the user did not make. The
|
|
859
|
+
suppression lasts for a single render; the next assignment broadcasts normally.
|
|
860
|
+
|
|
861
|
+
### Driving The Option List
|
|
862
|
+
|
|
863
|
+
The option list can be driven from the browser as well, for host apps that load options from
|
|
864
|
+
somewhere the Turbo Stream flow does not cover:
|
|
865
|
+
|
|
866
|
+
```js
|
|
867
|
+
select.appendOption({ id: "9", label: "Item C" })
|
|
868
|
+
|
|
869
|
+
select.replaceOptions([
|
|
870
|
+
{ id: "3", label: "Item A" },
|
|
871
|
+
{ id: "7", label: "Item B" }
|
|
872
|
+
])
|
|
873
|
+
|
|
874
|
+
select.replaceOptions(options, { selected: "7" }) // replace the list and set the value
|
|
875
|
+
select.replaceOptions(options, { selected: null }) // replace the list and clear the value
|
|
876
|
+
select.replaceOptions(options, { silent: false }) // broadcast the resulting value
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
Both take options in the [option contract](#option-contract) shape and render them through
|
|
880
|
+
`optionElement`, so they carry the host's class map.
|
|
881
|
+
|
|
882
|
+
`appendOption` adds one option to the end of the list and does nothing if an option with that id is
|
|
883
|
+
already there. It does not select the option — that is `addOption`, which exists for
|
|
884
|
+
[Add Mode](#add-mode), where the user creates a value that was not in the list.
|
|
885
|
+
|
|
886
|
+
`replaceOptions` swaps the whole list. Leaving `selected` out keeps the current selection, even when
|
|
887
|
+
the new list no longer contains it — the common case after a search, where the selected record may
|
|
888
|
+
have fallen outside the page of results. Passing `selected` assigns it with `setValue` semantics.
|
|
889
|
+
|
|
890
|
+
Unlike `setValue`, `replaceOptions` is silent by default: replacing a list is not a value change, so
|
|
891
|
+
it should not cascade to dependent fields on its own. Pass `silent: false` when it should.
|
|
892
|
+
|
|
893
|
+
### Disabled State
|
|
894
|
+
|
|
895
|
+
Pass `disabled: true` to lock a field. It still renders its current selection —
|
|
896
|
+
the point is to show the value, not to hide it — but it cannot be opened,
|
|
897
|
+
searched, or cleared, and its value is **left out of the form**:
|
|
898
|
+
|
|
899
|
+
```erb
|
|
900
|
+
<%= advanced_select_tag(
|
|
901
|
+
"record[item_id]",
|
|
902
|
+
id: "record_item_id",
|
|
903
|
+
selected: selected_option,
|
|
904
|
+
options: options,
|
|
905
|
+
placeholder: "Choose an item",
|
|
906
|
+
disabled: true
|
|
907
|
+
) %>
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
This matches a native `<select disabled>`: the browser omits a disabled field
|
|
911
|
+
from the submitted params, so an update never writes a value the user was not
|
|
912
|
+
allowed to change.
|
|
913
|
+
|
|
914
|
+
The trigger carries the `disabled` attribute, so mouse and keyboard are both
|
|
915
|
+
blocked by the browser rather than by CSS alone. The root element takes the
|
|
916
|
+
`disabled` class from the class map.
|
|
917
|
+
|
|
918
|
+
To toggle the state after render — from a Stimulus controller, or from a Turbo
|
|
919
|
+
Stream that flips the attribute — set the value on the root element:
|
|
920
|
+
|
|
921
|
+
```js
|
|
922
|
+
element.dataset.advancedSelectDisabledValue = "true"
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
The controller reacts on its own: it disables the hidden inputs, applies the
|
|
926
|
+
class, hides the clear control, and closes the dropdown if it happens to be
|
|
927
|
+
open. The same thing is available as a method:
|
|
787
928
|
|
|
788
929
|
```js
|
|
789
930
|
const select = application.getControllerForElementAndIdentifier(element, "advanced-select")
|
|
790
931
|
|
|
791
|
-
select.
|
|
932
|
+
select.disable()
|
|
933
|
+
select.enable()
|
|
792
934
|
```
|
|
793
935
|
|
|
794
936
|
### API Reference
|
|
@@ -806,6 +948,7 @@ advanced_select_tag(
|
|
|
806
948
|
multiple: false,
|
|
807
949
|
searchable: true,
|
|
808
950
|
add_mode: false,
|
|
951
|
+
disabled: false,
|
|
809
952
|
dependent_fields: {},
|
|
810
953
|
include_hidden: true,
|
|
811
954
|
auto_select_single: true,
|
|
@@ -823,6 +966,8 @@ advanced_select_tag(
|
|
|
823
966
|
|
|
824
967
|
`tooltip:` / `tooltip_partial:` enable an optional hover tooltip on the trigger (see [Selection Tooltip](#selection-tooltip)).
|
|
825
968
|
|
|
969
|
+
`disabled:` locks the field (see [Disabled State](#disabled-state)).
|
|
970
|
+
|
|
826
971
|
`advanced_select_options_tag`:
|
|
827
972
|
|
|
828
973
|
```ruby
|
|
@@ -966,6 +1111,16 @@ Tailwind content scanning can usually see class strings when they are written li
|
|
|
966
1111
|
|
|
967
1112
|
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.
|
|
968
1113
|
|
|
1114
|
+
The resolved `option`, `option_check`, `option_content`, and `option_description` classes are also published on the root element as data attributes, so options the controller builds in the browser carry the same classes as the ones the server renders. `optionElement(option)` returns such an element:
|
|
1115
|
+
|
|
1116
|
+
```js
|
|
1117
|
+
const select = application.getControllerForElementAndIdentifier(element, "advanced-select")
|
|
1118
|
+
|
|
1119
|
+
select.currentOptionsTarget.appendChild(select.optionElement({ id: "7", label: "Item B" }))
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
It produces the same markup as `advanced_select_option_tag`: the option contract's `id`, `value`, `label`, `display_label`, and `description` are all honored, and the resulting element is fully interactive — hovering activates it and clicking selects it.
|
|
1123
|
+
|
|
969
1124
|
### CSS Overrides
|
|
970
1125
|
|
|
971
1126
|
Importmap/Sprockets host applications can put app-specific styling in a host-owned file such as:
|
|
@@ -11,6 +11,16 @@
|
|
|
11
11
|
display: none !important;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
.ui-advanced-select-disabled .ui-advanced-select-trigger {
|
|
15
|
+
background: #f3f4f6;
|
|
16
|
+
color: #6b7280;
|
|
17
|
+
cursor: not-allowed;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.ui-advanced-select-disabled .ui-advanced-select-clear {
|
|
21
|
+
display: none;
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
.ui-advanced-select-trigger {
|
|
15
25
|
align-items: center;
|
|
16
26
|
background: #ffffff;
|
|
@@ -8,6 +8,7 @@ export default class extends Controller {
|
|
|
8
8
|
autoSelectSingle: { type: Boolean, default: true },
|
|
9
9
|
delay: { type: Number, default: 200 },
|
|
10
10
|
dependentFields: Object,
|
|
11
|
+
disabled: Boolean,
|
|
11
12
|
eager: { type: Boolean, default: true },
|
|
12
13
|
emptyText: String,
|
|
13
14
|
errorText: String,
|
|
@@ -29,6 +30,7 @@ export default class extends Controller {
|
|
|
29
30
|
this.timer = null
|
|
30
31
|
this.requestSequence = 0
|
|
31
32
|
this.activeIndex = -1
|
|
33
|
+
this.suppressChange = false
|
|
32
34
|
this.placeholderClass = this.element.dataset.advancedSelectPlaceholderClass || "ui-advanced-select-placeholder"
|
|
33
35
|
this.valueClass = this.element.dataset.advancedSelectValueClass || "ui-advanced-select-value"
|
|
34
36
|
this.tokenClass = this.element.dataset.advancedSelectTokenClass || "ui-advanced-select-token"
|
|
@@ -39,6 +41,10 @@ export default class extends Controller {
|
|
|
39
41
|
this.optionActiveClasses = this.classList(this.element.dataset.advancedSelectOptionActiveClass || "ui-advanced-select-option-active")
|
|
40
42
|
this.addOptionActiveClasses = this.classList(this.element.dataset.advancedSelectAddOptionActiveClass || "")
|
|
41
43
|
this.optionSelectedClasses = this.classList(this.element.dataset.advancedSelectOptionSelectedClass || "")
|
|
44
|
+
this.optionClass = this.element.dataset.advancedSelectOptionClass || "ui-advanced-select-option"
|
|
45
|
+
this.optionCheckClass = this.element.dataset.advancedSelectOptionCheckClass || "ui-advanced-select-option-check"
|
|
46
|
+
this.optionContentClass = this.element.dataset.advancedSelectOptionContentClass || "ui-advanced-select-option-content"
|
|
47
|
+
this.optionDescriptionClass = this.element.dataset.advancedSelectOptionDescriptionClass || "ui-advanced-select-option-description"
|
|
42
48
|
this.selectedValue = this.selectedValue.map((option) => this.normalizeSelectedOption(option))
|
|
43
49
|
this.close = this.close.bind(this)
|
|
44
50
|
this.renderOptionsState()
|
|
@@ -65,7 +71,40 @@ export default class extends Controller {
|
|
|
65
71
|
this.expanded ? this.close() : this.open()
|
|
66
72
|
}
|
|
67
73
|
|
|
74
|
+
disable() {
|
|
75
|
+
this.disabledValue = true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
enable() {
|
|
79
|
+
this.disabledValue = false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
disabledValueChanged() {
|
|
83
|
+
if (!this.hasTriggerTarget) {
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const disabled = this.disabledValue
|
|
88
|
+
const classes = this.classList(this.element.dataset.advancedSelectDisabledClass || "ui-advanced-select-disabled")
|
|
89
|
+
|
|
90
|
+
this.triggerTarget.disabled = disabled
|
|
91
|
+
this.hiddenFieldsTarget.querySelectorAll("input").forEach((input) => { input.disabled = disabled })
|
|
92
|
+
this.clearTarget.classList.toggle("hidden", disabled || this.selectedValue.length === 0)
|
|
93
|
+
|
|
94
|
+
if (classes.length) {
|
|
95
|
+
disabled ? this.element.classList.add(...classes) : this.element.classList.remove(...classes)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (disabled && this.expanded) {
|
|
99
|
+
this.close()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
68
103
|
open() {
|
|
104
|
+
if (this.disabledValue) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
69
108
|
this.hideTooltip(true)
|
|
70
109
|
this.dropdownTarget.classList.remove("hidden")
|
|
71
110
|
this.triggerTarget.setAttribute("aria-expanded", "true")
|
|
@@ -120,11 +159,65 @@ export default class extends Controller {
|
|
|
120
159
|
clear(event) {
|
|
121
160
|
event.preventDefault()
|
|
122
161
|
event.stopPropagation()
|
|
162
|
+
|
|
163
|
+
if (this.disabledValue) {
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
123
167
|
this.selectedValue = []
|
|
124
168
|
this.renderSelection()
|
|
125
169
|
this.close()
|
|
126
170
|
}
|
|
127
171
|
|
|
172
|
+
getValue() {
|
|
173
|
+
return this.currentValue
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
setValue(value, { silent = false } = {}) {
|
|
177
|
+
const requested = (Array.isArray(value) ? value : [value])
|
|
178
|
+
.filter((item) => item !== "" && item != null)
|
|
179
|
+
.map(String)
|
|
180
|
+
const values = this.multipleValue ? requested : requested.slice(0, 1)
|
|
181
|
+
const options = this.optionElements.map((element) => this.optionData(element))
|
|
182
|
+
|
|
183
|
+
this.selectedValue = values.map((item) => {
|
|
184
|
+
const option = options.find((candidate) => candidate.id === item || candidate.value === item)
|
|
185
|
+
|
|
186
|
+
return this.normalizeSelectedOption(option || { id: item, value: item, label: item })
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
this.suppressChange = silent
|
|
190
|
+
this.renderSelection()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
appendOption(option) {
|
|
194
|
+
const normalized = this.normalizeSelectedOption(option)
|
|
195
|
+
const present = this.optionElements.some((element) => element.dataset.advancedSelectValueParam === normalized.id)
|
|
196
|
+
|
|
197
|
+
if (present) {
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.currentOptionsTarget.appendChild(this.optionElement(normalized))
|
|
202
|
+
this.renderOptionsState()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
replaceOptions(options, { selected, silent = true } = {}) {
|
|
206
|
+
this.currentOptionsTarget.replaceChildren(...options.map((option) => this.optionElement(option)))
|
|
207
|
+
|
|
208
|
+
if (selected === undefined) {
|
|
209
|
+
this.suppressChange = silent
|
|
210
|
+
this.renderSelection()
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
this.setValue(selected, { silent })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
refresh() {
|
|
218
|
+
this.renderSelection()
|
|
219
|
+
}
|
|
220
|
+
|
|
128
221
|
keydown(event) {
|
|
129
222
|
if (event.key === "ArrowDown") {
|
|
130
223
|
event.preventDefault()
|
|
@@ -226,14 +319,14 @@ export default class extends Controller {
|
|
|
226
319
|
throw new Error("Advanced select options request failed")
|
|
227
320
|
}
|
|
228
321
|
|
|
229
|
-
return
|
|
322
|
+
return this.readOptionsResponse(response)
|
|
230
323
|
})
|
|
231
|
-
.then((
|
|
324
|
+
.then((payload) => {
|
|
232
325
|
if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
|
|
233
326
|
return
|
|
234
327
|
}
|
|
235
328
|
|
|
236
|
-
|
|
329
|
+
this.renderOptionsResponse(payload)
|
|
237
330
|
requestAnimationFrame(() => {
|
|
238
331
|
if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
|
|
239
332
|
return
|
|
@@ -246,6 +339,8 @@ export default class extends Controller {
|
|
|
246
339
|
if (autoSelect) {
|
|
247
340
|
this.autoSelectSingle()
|
|
248
341
|
}
|
|
342
|
+
|
|
343
|
+
this.dispatchOptionsLoaded()
|
|
249
344
|
})
|
|
250
345
|
})
|
|
251
346
|
.catch(() => {
|
|
@@ -311,6 +406,37 @@ export default class extends Controller {
|
|
|
311
406
|
}
|
|
312
407
|
}
|
|
313
408
|
|
|
409
|
+
optionElement(option) {
|
|
410
|
+
const normalized = this.normalizeSelectedOption(option)
|
|
411
|
+
const button = document.createElement("button")
|
|
412
|
+
|
|
413
|
+
button.type = "button"
|
|
414
|
+
button.className = this.optionClass
|
|
415
|
+
button.setAttribute("role", "option")
|
|
416
|
+
button.setAttribute("aria-selected", "false")
|
|
417
|
+
button.dataset.advancedSelectOption = ""
|
|
418
|
+
button.dataset.action = "mouseenter->advanced-select#activateOption mousedown->advanced-select#choose"
|
|
419
|
+
button.dataset.advancedSelectValueParam = normalized.id
|
|
420
|
+
button.dataset.advancedSelectSubmitValueParam = normalized.value
|
|
421
|
+
button.dataset.advancedSelectLabelParam = normalized.label
|
|
422
|
+
button.dataset.advancedSelectDisplayLabelParam = normalized.displayLabel
|
|
423
|
+
button.dataset.advancedSelectOptionParam = JSON.stringify(normalized)
|
|
424
|
+
|
|
425
|
+
const check = this.textElement("span", this.optionCheckClass, "")
|
|
426
|
+
check.dataset.advancedSelectOptionCheck = ""
|
|
427
|
+
|
|
428
|
+
const content = this.textElement("span", this.optionContentClass, "")
|
|
429
|
+
content.appendChild(this.textElement("span", "", normalized.label))
|
|
430
|
+
|
|
431
|
+
if (normalized.description) {
|
|
432
|
+
content.appendChild(this.textElement("span", this.optionDescriptionClass, normalized.description))
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
button.append(check, content)
|
|
436
|
+
|
|
437
|
+
return button
|
|
438
|
+
}
|
|
439
|
+
|
|
314
440
|
optionData(element) {
|
|
315
441
|
const data = this.parseOptionData(element.dataset.advancedSelectOptionParam)
|
|
316
442
|
const value = element.dataset.advancedSelectValueParam
|
|
@@ -362,8 +488,32 @@ export default class extends Controller {
|
|
|
362
488
|
this.renderTooltip()
|
|
363
489
|
this.renderOptionsState()
|
|
364
490
|
this.caretTarget.classList.toggle("hidden", this.selectedValue.length > 0)
|
|
365
|
-
this.clearTarget.classList.toggle("hidden", this.selectedValue.length === 0)
|
|
366
|
-
|
|
491
|
+
this.clearTarget.classList.toggle("hidden", this.disabledValue || this.selectedValue.length === 0)
|
|
492
|
+
|
|
493
|
+
if (this.suppressChange) {
|
|
494
|
+
this.suppressChange = false
|
|
495
|
+
} else {
|
|
496
|
+
this.dispatchValueChange()
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
readOptionsResponse(response) {
|
|
501
|
+
return response.text()
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
renderOptionsResponse(payload) {
|
|
505
|
+
Turbo.renderStreamMessage(payload)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
dispatchOptionsLoaded() {
|
|
509
|
+
this.element.dispatchEvent(new CustomEvent("advanced-select:options-loaded", {
|
|
510
|
+
bubbles: true,
|
|
511
|
+
detail: {
|
|
512
|
+
name: this.nameValue,
|
|
513
|
+
value: this.currentValue,
|
|
514
|
+
count: this.selectableOptionElements.length
|
|
515
|
+
}
|
|
516
|
+
}))
|
|
367
517
|
}
|
|
368
518
|
|
|
369
519
|
dispatchValueChange() {
|
|
@@ -515,6 +665,7 @@ export default class extends Controller {
|
|
|
515
665
|
input.type = "hidden"
|
|
516
666
|
input.name = this.nameValue
|
|
517
667
|
input.value = option ? option.value || option.id : ""
|
|
668
|
+
input.disabled = this.disabledValue
|
|
518
669
|
|
|
519
670
|
if (!this.multipleValue) {
|
|
520
671
|
input.id = this.inputIdValue
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
<div class="<%= advanced_select_class(class_map, :root) %>"
|
|
1
|
+
<div class="<%= advanced_select_class(class_map, :root, (:disabled if disabled)) %>"
|
|
2
2
|
data-controller="advanced-select"
|
|
3
|
+
data-advanced-select-disabled-value="<%= disabled %>"
|
|
4
|
+
data-advanced-select-disabled-class="<%= advanced_select_state_class(class_map, :disabled) %>"
|
|
3
5
|
data-advanced-select-url-value="<%= options_url %>"
|
|
4
6
|
data-advanced-select-target-id-value="<%= target_id %>"
|
|
5
7
|
data-advanced-select-name-value="<%= name %>"
|
|
@@ -27,17 +29,21 @@
|
|
|
27
29
|
data-advanced-select-option-active-class="<%= advanced_select_state_class(class_map, :option_active) %>"
|
|
28
30
|
data-advanced-select-add-option-active-class="<%= advanced_select_state_class(class_map, :add_option_active) %>"
|
|
29
31
|
data-advanced-select-option-selected-class="<%= advanced_select_state_class(class_map, :option_selected) %>"
|
|
32
|
+
data-advanced-select-option-class="<%= advanced_select_class(class_map, :option) %>"
|
|
33
|
+
data-advanced-select-option-check-class="<%= advanced_select_class(class_map, :option_check) %>"
|
|
34
|
+
data-advanced-select-option-content-class="<%= advanced_select_class(class_map, :option_content) %>"
|
|
35
|
+
data-advanced-select-option-description-class="<%= advanced_select_class(class_map, :option_description) %>"
|
|
30
36
|
data-advanced-select-selected-value="<%= advanced_select_selected_value(selected_options) %>">
|
|
31
37
|
<div data-advanced-select-target="hiddenFields">
|
|
32
38
|
<% if multiple %>
|
|
33
39
|
<% if include_hidden %>
|
|
34
|
-
<%= hidden_field_tag name, "", id: nil %>
|
|
40
|
+
<%= hidden_field_tag name, "", id: nil, disabled: disabled %>
|
|
35
41
|
<% end %>
|
|
36
42
|
<% selected_options.each do |option| %>
|
|
37
|
-
<%= hidden_field_tag name, option.fetch(:value, option.fetch(:id)) %>
|
|
43
|
+
<%= hidden_field_tag name, option.fetch(:value, option.fetch(:id)), disabled: disabled %>
|
|
38
44
|
<% end %>
|
|
39
45
|
<% else %>
|
|
40
|
-
<%= hidden_field_tag name, selected_options.first&.fetch(:value, selected_options.first&.fetch(:id)), id: id %>
|
|
46
|
+
<%= hidden_field_tag name, selected_options.first&.fetch(:value, selected_options.first&.fetch(:id)), id: id, disabled: disabled %>
|
|
41
47
|
<% end %>
|
|
42
48
|
</div>
|
|
43
49
|
|
|
@@ -49,13 +55,14 @@
|
|
|
49
55
|
aria-haspopup="listbox"
|
|
50
56
|
aria-expanded="false"
|
|
51
57
|
aria-controls="<%= "#{id}_dropdown" %>"
|
|
58
|
+
<%= "disabled" if disabled %>
|
|
52
59
|
data-advanced-select-target="trigger"
|
|
53
60
|
data-action="advanced-select#toggle keydown->advanced-select#keydown<%= " mouseenter->advanced-select#showTooltip mouseleave->advanced-select#hideTooltip" if tooltip_enabled %>">
|
|
54
61
|
<span id="<%= "#{id}_summary" %>" class="<%= advanced_select_class(class_map, :summary) %>" data-advanced-select-target="summary">
|
|
55
62
|
<%= render partial: "advanced_select/summary", locals: { selected_options: selected_options, multiple: multiple, placeholder: placeholder, summary_mode: summary_mode, class_map: class_map } %>
|
|
56
63
|
</span>
|
|
57
64
|
<span id="<%= "#{id}_caret" %>" class="<%= [advanced_select_class(class_map, :caret), ("hidden" if selected_options.any?)].compact.join(" ") %>" data-advanced-select-target="caret">⌄</span>
|
|
58
|
-
<span id="<%= "#{id}_clear" %>" class="<%= [advanced_select_class(class_map, :clear), ("hidden" if selected_options.empty?)].compact.join(" ") %>" data-advanced-select-target="clear" data-action="click->advanced-select#clear">×</span>
|
|
65
|
+
<span id="<%= "#{id}_clear" %>" class="<%= [advanced_select_class(class_map, :clear), ("hidden" if disabled || selected_options.empty?)].compact.join(" ") %>" data-advanced-select-target="clear" data-action="click->advanced-select#clear">×</span>
|
|
59
66
|
</button>
|
|
60
67
|
|
|
61
68
|
<% if tooltip_enabled %>
|
|
@@ -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, auto_select_single: true, eager: true, summary_mode: :tokens, tooltip: false, tooltip_partial: nil, 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, disabled: false, dependent_fields: {}, include_hidden: true, auto_select_single: true, eager: true, summary_mode: :tokens, tooltip: false, tooltip_partial: nil, 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
|
|
|
@@ -14,6 +14,7 @@ module AdvancedSelect
|
|
|
14
14
|
multiple: multiple,
|
|
15
15
|
searchable: searchable,
|
|
16
16
|
add_mode: add_mode,
|
|
17
|
+
disabled: disabled,
|
|
17
18
|
dependent_fields: dependent_fields,
|
|
18
19
|
include_hidden: include_hidden,
|
|
19
20
|
auto_select_single: auto_select_single,
|
|
@@ -11,6 +11,16 @@
|
|
|
11
11
|
display: none !important;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
.ui-advanced-select-disabled .ui-advanced-select-trigger {
|
|
15
|
+
background: #f3f4f6;
|
|
16
|
+
color: #6b7280;
|
|
17
|
+
cursor: not-allowed;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.ui-advanced-select-disabled .ui-advanced-select-clear {
|
|
21
|
+
display: none;
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
.ui-advanced-select-trigger {
|
|
15
25
|
align-items: center;
|
|
16
26
|
background: #ffffff;
|
|
@@ -8,6 +8,7 @@ export default class extends Controller {
|
|
|
8
8
|
autoSelectSingle: { type: Boolean, default: true },
|
|
9
9
|
delay: { type: Number, default: 200 },
|
|
10
10
|
dependentFields: Object,
|
|
11
|
+
disabled: Boolean,
|
|
11
12
|
eager: { type: Boolean, default: true },
|
|
12
13
|
emptyText: String,
|
|
13
14
|
errorText: String,
|
|
@@ -29,6 +30,7 @@ export default class extends Controller {
|
|
|
29
30
|
this.timer = null
|
|
30
31
|
this.requestSequence = 0
|
|
31
32
|
this.activeIndex = -1
|
|
33
|
+
this.suppressChange = false
|
|
32
34
|
this.placeholderClass = this.element.dataset.advancedSelectPlaceholderClass || "ui-advanced-select-placeholder"
|
|
33
35
|
this.valueClass = this.element.dataset.advancedSelectValueClass || "ui-advanced-select-value"
|
|
34
36
|
this.tokenClass = this.element.dataset.advancedSelectTokenClass || "ui-advanced-select-token"
|
|
@@ -39,6 +41,10 @@ export default class extends Controller {
|
|
|
39
41
|
this.optionActiveClasses = this.classList(this.element.dataset.advancedSelectOptionActiveClass || "ui-advanced-select-option-active")
|
|
40
42
|
this.addOptionActiveClasses = this.classList(this.element.dataset.advancedSelectAddOptionActiveClass || "")
|
|
41
43
|
this.optionSelectedClasses = this.classList(this.element.dataset.advancedSelectOptionSelectedClass || "")
|
|
44
|
+
this.optionClass = this.element.dataset.advancedSelectOptionClass || "ui-advanced-select-option"
|
|
45
|
+
this.optionCheckClass = this.element.dataset.advancedSelectOptionCheckClass || "ui-advanced-select-option-check"
|
|
46
|
+
this.optionContentClass = this.element.dataset.advancedSelectOptionContentClass || "ui-advanced-select-option-content"
|
|
47
|
+
this.optionDescriptionClass = this.element.dataset.advancedSelectOptionDescriptionClass || "ui-advanced-select-option-description"
|
|
42
48
|
this.selectedValue = this.selectedValue.map((option) => this.normalizeSelectedOption(option))
|
|
43
49
|
this.close = this.close.bind(this)
|
|
44
50
|
this.renderOptionsState()
|
|
@@ -65,7 +71,40 @@ export default class extends Controller {
|
|
|
65
71
|
this.expanded ? this.close() : this.open()
|
|
66
72
|
}
|
|
67
73
|
|
|
74
|
+
disable() {
|
|
75
|
+
this.disabledValue = true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
enable() {
|
|
79
|
+
this.disabledValue = false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
disabledValueChanged() {
|
|
83
|
+
if (!this.hasTriggerTarget) {
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const disabled = this.disabledValue
|
|
88
|
+
const classes = this.classList(this.element.dataset.advancedSelectDisabledClass || "ui-advanced-select-disabled")
|
|
89
|
+
|
|
90
|
+
this.triggerTarget.disabled = disabled
|
|
91
|
+
this.hiddenFieldsTarget.querySelectorAll("input").forEach((input) => { input.disabled = disabled })
|
|
92
|
+
this.clearTarget.classList.toggle("hidden", disabled || this.selectedValue.length === 0)
|
|
93
|
+
|
|
94
|
+
if (classes.length) {
|
|
95
|
+
disabled ? this.element.classList.add(...classes) : this.element.classList.remove(...classes)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (disabled && this.expanded) {
|
|
99
|
+
this.close()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
68
103
|
open() {
|
|
104
|
+
if (this.disabledValue) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
69
108
|
this.hideTooltip(true)
|
|
70
109
|
this.dropdownTarget.classList.remove("hidden")
|
|
71
110
|
this.triggerTarget.setAttribute("aria-expanded", "true")
|
|
@@ -120,11 +159,65 @@ export default class extends Controller {
|
|
|
120
159
|
clear(event) {
|
|
121
160
|
event.preventDefault()
|
|
122
161
|
event.stopPropagation()
|
|
162
|
+
|
|
163
|
+
if (this.disabledValue) {
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
123
167
|
this.selectedValue = []
|
|
124
168
|
this.renderSelection()
|
|
125
169
|
this.close()
|
|
126
170
|
}
|
|
127
171
|
|
|
172
|
+
getValue() {
|
|
173
|
+
return this.currentValue
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
setValue(value, { silent = false } = {}) {
|
|
177
|
+
const requested = (Array.isArray(value) ? value : [value])
|
|
178
|
+
.filter((item) => item !== "" && item != null)
|
|
179
|
+
.map(String)
|
|
180
|
+
const values = this.multipleValue ? requested : requested.slice(0, 1)
|
|
181
|
+
const options = this.optionElements.map((element) => this.optionData(element))
|
|
182
|
+
|
|
183
|
+
this.selectedValue = values.map((item) => {
|
|
184
|
+
const option = options.find((candidate) => candidate.id === item || candidate.value === item)
|
|
185
|
+
|
|
186
|
+
return this.normalizeSelectedOption(option || { id: item, value: item, label: item })
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
this.suppressChange = silent
|
|
190
|
+
this.renderSelection()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
appendOption(option) {
|
|
194
|
+
const normalized = this.normalizeSelectedOption(option)
|
|
195
|
+
const present = this.optionElements.some((element) => element.dataset.advancedSelectValueParam === normalized.id)
|
|
196
|
+
|
|
197
|
+
if (present) {
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.currentOptionsTarget.appendChild(this.optionElement(normalized))
|
|
202
|
+
this.renderOptionsState()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
replaceOptions(options, { selected, silent = true } = {}) {
|
|
206
|
+
this.currentOptionsTarget.replaceChildren(...options.map((option) => this.optionElement(option)))
|
|
207
|
+
|
|
208
|
+
if (selected === undefined) {
|
|
209
|
+
this.suppressChange = silent
|
|
210
|
+
this.renderSelection()
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
this.setValue(selected, { silent })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
refresh() {
|
|
218
|
+
this.renderSelection()
|
|
219
|
+
}
|
|
220
|
+
|
|
128
221
|
keydown(event) {
|
|
129
222
|
if (event.key === "ArrowDown") {
|
|
130
223
|
event.preventDefault()
|
|
@@ -226,14 +319,14 @@ export default class extends Controller {
|
|
|
226
319
|
throw new Error("Advanced select options request failed")
|
|
227
320
|
}
|
|
228
321
|
|
|
229
|
-
return
|
|
322
|
+
return this.readOptionsResponse(response)
|
|
230
323
|
})
|
|
231
|
-
.then((
|
|
324
|
+
.then((payload) => {
|
|
232
325
|
if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
|
|
233
326
|
return
|
|
234
327
|
}
|
|
235
328
|
|
|
236
|
-
|
|
329
|
+
this.renderOptionsResponse(payload)
|
|
237
330
|
requestAnimationFrame(() => {
|
|
238
331
|
if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
|
|
239
332
|
return
|
|
@@ -246,6 +339,8 @@ export default class extends Controller {
|
|
|
246
339
|
if (autoSelect) {
|
|
247
340
|
this.autoSelectSingle()
|
|
248
341
|
}
|
|
342
|
+
|
|
343
|
+
this.dispatchOptionsLoaded()
|
|
249
344
|
})
|
|
250
345
|
})
|
|
251
346
|
.catch(() => {
|
|
@@ -311,6 +406,37 @@ export default class extends Controller {
|
|
|
311
406
|
}
|
|
312
407
|
}
|
|
313
408
|
|
|
409
|
+
optionElement(option) {
|
|
410
|
+
const normalized = this.normalizeSelectedOption(option)
|
|
411
|
+
const button = document.createElement("button")
|
|
412
|
+
|
|
413
|
+
button.type = "button"
|
|
414
|
+
button.className = this.optionClass
|
|
415
|
+
button.setAttribute("role", "option")
|
|
416
|
+
button.setAttribute("aria-selected", "false")
|
|
417
|
+
button.dataset.advancedSelectOption = ""
|
|
418
|
+
button.dataset.action = "mouseenter->advanced-select#activateOption mousedown->advanced-select#choose"
|
|
419
|
+
button.dataset.advancedSelectValueParam = normalized.id
|
|
420
|
+
button.dataset.advancedSelectSubmitValueParam = normalized.value
|
|
421
|
+
button.dataset.advancedSelectLabelParam = normalized.label
|
|
422
|
+
button.dataset.advancedSelectDisplayLabelParam = normalized.displayLabel
|
|
423
|
+
button.dataset.advancedSelectOptionParam = JSON.stringify(normalized)
|
|
424
|
+
|
|
425
|
+
const check = this.textElement("span", this.optionCheckClass, "")
|
|
426
|
+
check.dataset.advancedSelectOptionCheck = ""
|
|
427
|
+
|
|
428
|
+
const content = this.textElement("span", this.optionContentClass, "")
|
|
429
|
+
content.appendChild(this.textElement("span", "", normalized.label))
|
|
430
|
+
|
|
431
|
+
if (normalized.description) {
|
|
432
|
+
content.appendChild(this.textElement("span", this.optionDescriptionClass, normalized.description))
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
button.append(check, content)
|
|
436
|
+
|
|
437
|
+
return button
|
|
438
|
+
}
|
|
439
|
+
|
|
314
440
|
optionData(element) {
|
|
315
441
|
const data = this.parseOptionData(element.dataset.advancedSelectOptionParam)
|
|
316
442
|
const value = element.dataset.advancedSelectValueParam
|
|
@@ -362,8 +488,32 @@ export default class extends Controller {
|
|
|
362
488
|
this.renderTooltip()
|
|
363
489
|
this.renderOptionsState()
|
|
364
490
|
this.caretTarget.classList.toggle("hidden", this.selectedValue.length > 0)
|
|
365
|
-
this.clearTarget.classList.toggle("hidden", this.selectedValue.length === 0)
|
|
366
|
-
|
|
491
|
+
this.clearTarget.classList.toggle("hidden", this.disabledValue || this.selectedValue.length === 0)
|
|
492
|
+
|
|
493
|
+
if (this.suppressChange) {
|
|
494
|
+
this.suppressChange = false
|
|
495
|
+
} else {
|
|
496
|
+
this.dispatchValueChange()
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
readOptionsResponse(response) {
|
|
501
|
+
return response.text()
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
renderOptionsResponse(payload) {
|
|
505
|
+
Turbo.renderStreamMessage(payload)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
dispatchOptionsLoaded() {
|
|
509
|
+
this.element.dispatchEvent(new CustomEvent("advanced-select:options-loaded", {
|
|
510
|
+
bubbles: true,
|
|
511
|
+
detail: {
|
|
512
|
+
name: this.nameValue,
|
|
513
|
+
value: this.currentValue,
|
|
514
|
+
count: this.selectableOptionElements.length
|
|
515
|
+
}
|
|
516
|
+
}))
|
|
367
517
|
}
|
|
368
518
|
|
|
369
519
|
dispatchValueChange() {
|
|
@@ -515,6 +665,7 @@ export default class extends Controller {
|
|
|
515
665
|
input.type = "hidden"
|
|
516
666
|
input.name = this.nameValue
|
|
517
667
|
input.value = option ? option.value || option.id : ""
|
|
668
|
+
input.disabled = this.disabledValue
|
|
518
669
|
|
|
519
670
|
if (!this.multipleValue) {
|
|
520
671
|
input.id = this.inputIdValue
|