diff --git a/README.md b/README.md index 62d2139..a038590 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 — diff --git a/app/javascript/advanced_select/advanced_select_controller.js b/app/javascript/advanced_select/advanced_select_controller.js index 2646ff5..2e8a94c 100644 --- a/app/javascript/advanced_select/advanced_select_controller.js +++ b/app/javascript/advanced_select/advanced_select_controller.js @@ -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" @@ -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() @@ -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() { diff --git a/lib/generators/advanced_select/install/templates/advanced_select_controller.js b/lib/generators/advanced_select/install/templates/advanced_select_controller.js index 2646ff5..2e8a94c 100644 --- a/lib/generators/advanced_select/install/templates/advanced_select_controller.js +++ b/lib/generators/advanced_select/install/templates/advanced_select_controller.js @@ -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" @@ -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() @@ -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() { diff --git a/test/system/advanced_select_interaction_test.rb b/test/system/advanced_select_interaction_test.rb index 9301eac..85fb2ef 100644 --- a/test/system/advanced_select_interaction_test.rb +++ b/test/system/advanced_select_interaction_test.rb @@ -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 @@ -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