Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ AdvancedSelect is a small Rails engine for rendering an advanced select input wi
- [Custom Option Content](#custom-option-content)
- [Option Contract](#option-contract)
- [Events](#events)
- [Reading And Writing The Value](#reading-and-writing-the-value)
- [Disabled State](#disabled-state)
- [API Reference](#api-reference)
- [Local Development](#local-development)
Expand Down Expand Up @@ -784,14 +785,38 @@ Prefer it over `change` on multiple selects. `change` is dispatched from the fir
it does not fire at all once the last value is cleared from a field rendered with
`include_hidden: false` — at that point the field has no inputs left.

The current value is also readable from the controller:
The current value is also readable from the controller — see [Reading And Writing The Value](#reading-and-writing-the-value).

### Reading And Writing The Value

The controller exposes the selection as a small programmatic API, so host code can drive a field
without reaching into its markup:

```js
const select = application.getControllerForElementAndIdentifier(element, "advanced-select")

select.currentValue // => ["3", "7"]
select.getValue() // => "7", or ["3", "7"] on a multiple select
select.setValue("7")
select.setValue(["3", "7"]) // multiple selects also accept a single value
select.setValue("7", { silent: true }) // assign without broadcasting a change
select.refresh() // re-render from the current selection
```

`getValue` returns the submit value — the option's `value` when it defines one, otherwise its `id`.
It is a string for single selects, an array for multiple selects, and empty when nothing is
selected.

`setValue` resolves each value against the options currently in the list, matching either the
option's id or its submit value, so `setValue("identity-7")` and `setValue("submit-7")` select the
same row. A value that matches no option is still assigned and submitted, using the value itself as
the label — this keeps a server-assigned value from being silently dropped. Passing `""`, `null`, or
`[]` clears the field. On a single select only the first value of an array is kept.

`silent: true` suppresses the `change` and `advanced-select:change` events for that one assignment.
Use it when the value is being restored rather than chosen — replacing options after a Turbo Stream
update, for example — so dependent fields do not cascade off a change the user did not make. The
suppression lasts for a single render; the next assignment broadcasts normally.

### Disabled State

Pass `disabled: true` to lock a field. It still renders its current selection —
Expand Down
33 changes: 32 additions & 1 deletion app/javascript/advanced_select/advanced_select_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default class extends Controller {
this.timer = null
this.requestSequence = 0
this.activeIndex = -1
this.suppressChange = false
this.placeholderClass = this.element.dataset.advancedSelectPlaceholderClass || "ui-advanced-select-placeholder"
this.valueClass = this.element.dataset.advancedSelectValueClass || "ui-advanced-select-value"
this.tokenClass = this.element.dataset.advancedSelectTokenClass || "ui-advanced-select-token"
Expand Down Expand Up @@ -164,6 +165,31 @@ export default class extends Controller {
this.close()
}

getValue() {
return this.currentValue
}

setValue(value, { silent = false } = {}) {
const requested = (Array.isArray(value) ? value : [value])
.filter((item) => item !== "" && item != null)
.map(String)
const values = this.multipleValue ? requested : requested.slice(0, 1)
const options = this.optionElements.map((element) => this.optionData(element))

this.selectedValue = values.map((item) => {
const option = options.find((candidate) => candidate.id === item || candidate.value === item)

return this.normalizeSelectedOption(option || { id: item, value: item, label: item })
})

this.suppressChange = silent
this.renderSelection()
}

refresh() {
this.renderSelection()
}

keydown(event) {
if (event.key === "ArrowDown") {
event.preventDefault()
Expand Down Expand Up @@ -402,7 +428,12 @@ export default class extends Controller {
this.renderOptionsState()
this.caretTarget.classList.toggle("hidden", this.selectedValue.length > 0)
this.clearTarget.classList.toggle("hidden", this.disabledValue || this.selectedValue.length === 0)
this.dispatchValueChange()

if (this.suppressChange) {
this.suppressChange = false
} else {
this.dispatchValueChange()
}
}

dispatchValueChange() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default class extends Controller {
this.timer = null
this.requestSequence = 0
this.activeIndex = -1
this.suppressChange = false
this.placeholderClass = this.element.dataset.advancedSelectPlaceholderClass || "ui-advanced-select-placeholder"
this.valueClass = this.element.dataset.advancedSelectValueClass || "ui-advanced-select-value"
this.tokenClass = this.element.dataset.advancedSelectTokenClass || "ui-advanced-select-token"
Expand Down Expand Up @@ -164,6 +165,31 @@ export default class extends Controller {
this.close()
}

getValue() {
return this.currentValue
}

setValue(value, { silent = false } = {}) {
const requested = (Array.isArray(value) ? value : [value])
.filter((item) => item !== "" && item != null)
.map(String)
const values = this.multipleValue ? requested : requested.slice(0, 1)
const options = this.optionElements.map((element) => this.optionData(element))

this.selectedValue = values.map((item) => {
const option = options.find((candidate) => candidate.id === item || candidate.value === item)

return this.normalizeSelectedOption(option || { id: item, value: item, label: item })
})

this.suppressChange = silent
this.renderSelection()
}

refresh() {
this.renderSelection()
}

keydown(event) {
if (event.key === "ArrowDown") {
event.preventDefault()
Expand Down Expand Up @@ -402,7 +428,12 @@ export default class extends Controller {
this.renderOptionsState()
this.caretTarget.classList.toggle("hidden", this.selectedValue.length > 0)
this.clearTarget.classList.toggle("hidden", this.disabledValue || this.selectedValue.length === 0)
this.dispatchValueChange()

if (this.suppressChange) {
this.suppressChange = false
} else {
this.dispatchValueChange()
}
}

dispatchValueChange() {
Expand Down
134 changes: 134 additions & 0 deletions test/system/advanced_select_interaction_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,131 @@ class AdvancedSelectInteractionTest < ApplicationSystemTestCase
assert_equal [[]], advanced_select_events.map { |event| event["value"] }
end

test "reads the submit value through getValue" do
visit root_path

assert_equal "", select_call("example_submit_id", "getValue()")

find("#example_submit_id_trigger").click
find("#example_submit_id_options button", text: "Submit Item").click

assert_selector "#example_submit_id_summary", text: "Submit Item"
assert_equal "submit-7", select_call("example_submit_id", "getValue()")
end

test "reads an array through getValue on a multiple select" do
visit root_path

assert_equal [], select_call("example_multiple_ids", "getValue()")

find("#example_multiple_ids_trigger").click
find("#example_multiple_ids_options button", text: "Multi One").click
find("#example_multiple_ids_options button", text: "Multi Two").click

assert_equal %w[multi-2 multi-1], select_call("example_multiple_ids", "getValue()")
end

test "selects a known option through setValue" do
visit root_path
select_call("example_item_id", "setValue('local-2')")

assert_selector "#example_item_id_summary", text: "Local Two"
assert_selector "input[name='example[item_id]'][value='local-2']", visible: false

find("#example_item_id_trigger").click

assert_selected_option_check "example_item_id", "Local Two"
end

test "resolves setValue against the option id as well as its submit value" do
visit root_path
select_call("example_submit_id", "setValue('identity-7')")

assert_selector "#example_submit_id_summary", text: "Submit Item"
assert_selector "input[name='example[submit_id]'][value='submit-7']", visible: false
end

test "keeps an unknown setValue as a raw value" do
visit root_path
select_call("example_item_id", "setValue('not-in-the-list')")

assert_selector "input[name='example[item_id]'][value='not-in-the-list']", visible: false
assert_selector "#example_item_id_summary", text: "not-in-the-list"
end

test "assigns every value of an array through setValue on a multiple select" do
visit root_path
select_call("example_multiple_ids", "setValue(['multi-1', 'multi-2'])")

assert_selector "input[name='example[multiple_ids][]'][value='multi-1']", visible: false
assert_selector "input[name='example[multiple_ids][]'][value='multi-2']", visible: false
assert_equal %w[multi-1 multi-2], select_call("example_multiple_ids", "getValue()")
end

test "keeps only the first value of an array on a single select" do
visit root_path
select_call("example_item_id", "setValue(['local-2', 'local-1'])")

assert_equal "local-2", select_call("example_item_id", "getValue()")
assert_no_selector "input[name='example[item_id]'][value='local-1']", visible: false
end

test "clears the selection when setValue receives a blank value" do
visit root_path
select_call("example_item_id", "setValue('local-2')")

assert_selector "input[name='example[item_id]'][value='local-2']", visible: false

select_call("example_item_id", "setValue('')")

assert_no_selector "input[name='example[item_id]'][value='local-2']", visible: false
assert_equal "", select_call("example_item_id", "getValue()")
end

test "broadcasts a change from setValue unless it is silent" do
visit root_path
record_advanced_select_events("example[item_id]")

select_call("example_item_id", "setValue('local-1')")

assert_selector "#example_item_id_summary", text: "Local One"
assert_equal ["local-1"], advanced_select_events.map { |event| event["value"] }

select_call("example_item_id", "setValue('local-2', { silent: true })")

assert_selector "#example_item_id_summary", text: "Local Two"
assert_equal ["local-1"], advanced_select_events.map { |event| event["value"] }
end

test "broadcasts again after a silent setValue" do
visit root_path
select_call("example_item_id", "setValue('local-1', { silent: true })")

assert_selector "#example_item_id_summary", text: "Local One"

record_advanced_select_events("example[item_id]")
select_call("example_item_id", "setValue('local-2')")

assert_selector "#example_item_id_summary", text: "Local Two"
assert_equal ["local-2"], advanced_select_events.map { |event| event["value"] }
end

test "re-renders the current selection through refresh" do
visit root_path
select_call("example_item_id", "setValue('local-1', { silent: true })")

assert_selector "#example_item_id_summary", text: "Local One"

page.execute_script("document.getElementById('example_item_id_summary').replaceChildren()")

assert_selector "#example_item_id_summary", text: ""

select_call("example_item_id", "refresh()")

assert_selector "#example_item_id_summary", text: "Local One"
assert_equal "local-1", select_call("example_item_id", "getValue()")
end

test "renders its selection but cannot be opened while disabled" do
visit root_path

Expand Down Expand Up @@ -482,6 +607,15 @@ def advanced_select_events
page.evaluate_script("window.__advancedSelectEvents")
end

def select_call(select_id, expression)
page.evaluate_script(<<~JS)
(() => {
const root = document.getElementById("#{select_id}_trigger").closest("[data-controller~='advanced-select']")
return window.Stimulus.getControllerForElementAndIdentifier(root, "advanced-select").#{expression}
})()
JS
end

def dropdown_display(select_id)
page.evaluate_script("getComputedStyle(document.getElementById('#{select_id}_dropdown')).display")
end
Expand Down
Loading