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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f77cb8280bf5c4483c3d38f73628f8e17f7492f73684d4ced437f903198189b
4
- data.tar.gz: 1baff77c13609cfc3c98770dbc7a8a6a2d67a2166ff04beca10e64aa7405e68c
3
+ metadata.gz: be334c14eaabfa7e2d3685deb4f1404b7187cb51c94d8a8dcb17b17e70bc5435
4
+ data.tar.gz: b9a676e2e4a64345631612285e8a362034b4efaaf60650cd201eadb2fa41b5a0
5
5
  SHA512:
6
- metadata.gz: f184de3a7114166e60dd758b6f489191d9e9b469998c2deacb3446ad79992ddbc661f92dd492366acf718e77072b386b7da39c9d11022fac75b31ebfb9257f1e
7
- data.tar.gz: c82e8749636458398cf8d9204cca3986bbff17785504833e18b28d87367209b1e67e99e98d2da07471a07429a8648d0237359d002dc480809190b945f6ea0a01
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.currentValue // => ["3", "7"]
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 response.text()
322
+ return this.readOptionsResponse(response)
230
323
  })
231
- .then((html) => {
324
+ .then((payload) => {
232
325
  if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
233
326
  return
234
327
  }
235
328
 
236
- Turbo.renderStreamMessage(html)
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
- this.dispatchValueChange()
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">&#8964;</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">&times;</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">&times;</span>
59
66
  </button>
60
67
 
61
68
  <% if tooltip_enabled %>
@@ -2,6 +2,7 @@ module AdvancedSelect
2
2
  class ClassMap
3
3
  DEFAULTS = {
4
4
  root: "ui-advanced-select",
5
+ disabled: "ui-advanced-select-disabled",
5
6
  trigger: "ui-advanced-select-trigger",
6
7
  summary: "ui-advanced-select-summary",
7
8
  placeholder: "ui-advanced-select-placeholder",
@@ -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,
@@ -1,3 +1,3 @@
1
1
  module AdvancedSelect
2
- VERSION = "0.1.9"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -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 response.text()
322
+ return this.readOptionsResponse(response)
230
323
  })
231
- .then((html) => {
324
+ .then((payload) => {
232
325
  if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
233
326
  return
234
327
  }
235
328
 
236
- Turbo.renderStreamMessage(html)
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
- this.dispatchValueChange()
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
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.9
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mehmet Celik