From b89e745a00d659d6d531630a345997ebba4b53d3 Mon Sep 17 00:00:00 2001 From: "ulsyemr@gmail.com" Date: Mon, 17 Aug 2026 16:01:58 +0300 Subject: [PATCH] Build option elements in the browser with the host class map --- README.md | 10 +++ .../advanced_select_controller.js | 35 ++++++++ app/views/advanced_select/_select.html.erb | 4 + .../templates/advanced_select_controller.js | 35 ++++++++ .../advanced_select_examples/show.html.erb | 12 +++ .../advanced_select_interaction_test.rb | 85 +++++++++++++++++++ 6 files changed, 181 insertions(+) diff --git a/README.md b/README.md index a038590..c39cc56 100644 --- a/README.md +++ b/README.md @@ -1038,6 +1038,16 @@ Tailwind content scanning can usually see class strings when they are written li 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. +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: + +```js +const select = application.getControllerForElementAndIdentifier(element, "advanced-select") + +select.currentOptionsTarget.appendChild(select.optionElement({ id: "7", label: "Item B" })) +``` + +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. + ### CSS Overrides Importmap/Sprockets host applications can put app-specific styling in a host-owned file such as: diff --git a/app/javascript/advanced_select/advanced_select_controller.js b/app/javascript/advanced_select/advanced_select_controller.js index 2e8a94c..a2fc3af 100644 --- a/app/javascript/advanced_select/advanced_select_controller.js +++ b/app/javascript/advanced_select/advanced_select_controller.js @@ -41,6 +41,10 @@ export default class extends Controller { this.optionActiveClasses = this.classList(this.element.dataset.advancedSelectOptionActiveClass || "ui-advanced-select-option-active") this.addOptionActiveClasses = this.classList(this.element.dataset.advancedSelectAddOptionActiveClass || "") this.optionSelectedClasses = this.classList(this.element.dataset.advancedSelectOptionSelectedClass || "") + this.optionClass = this.element.dataset.advancedSelectOptionClass || "ui-advanced-select-option" + this.optionCheckClass = this.element.dataset.advancedSelectOptionCheckClass || "ui-advanced-select-option-check" + this.optionContentClass = this.element.dataset.advancedSelectOptionContentClass || "ui-advanced-select-option-content" + this.optionDescriptionClass = this.element.dataset.advancedSelectOptionDescriptionClass || "ui-advanced-select-option-description" this.selectedValue = this.selectedValue.map((option) => this.normalizeSelectedOption(option)) this.close = this.close.bind(this) this.renderOptionsState() @@ -376,6 +380,37 @@ export default class extends Controller { } } + optionElement(option) { + const normalized = this.normalizeSelectedOption(option) + const button = document.createElement("button") + + button.type = "button" + button.className = this.optionClass + button.setAttribute("role", "option") + button.setAttribute("aria-selected", "false") + button.dataset.advancedSelectOption = "" + button.dataset.action = "mouseenter->advanced-select#activateOption mousedown->advanced-select#choose" + button.dataset.advancedSelectValueParam = normalized.id + button.dataset.advancedSelectSubmitValueParam = normalized.value + button.dataset.advancedSelectLabelParam = normalized.label + button.dataset.advancedSelectDisplayLabelParam = normalized.displayLabel + button.dataset.advancedSelectOptionParam = JSON.stringify(normalized) + + const check = this.textElement("span", this.optionCheckClass, "") + check.dataset.advancedSelectOptionCheck = "" + + const content = this.textElement("span", this.optionContentClass, "") + content.appendChild(this.textElement("span", "", normalized.label)) + + if (normalized.description) { + content.appendChild(this.textElement("span", this.optionDescriptionClass, normalized.description)) + } + + button.append(check, content) + + return button + } + optionData(element) { const data = this.parseOptionData(element.dataset.advancedSelectOptionParam) const value = element.dataset.advancedSelectValueParam diff --git a/app/views/advanced_select/_select.html.erb b/app/views/advanced_select/_select.html.erb index 648f8be..afdf20b 100644 --- a/app/views/advanced_select/_select.html.erb +++ b/app/views/advanced_select/_select.html.erb @@ -29,6 +29,10 @@ data-advanced-select-option-active-class="<%= advanced_select_state_class(class_map, :option_active) %>" data-advanced-select-add-option-active-class="<%= advanced_select_state_class(class_map, :add_option_active) %>" data-advanced-select-option-selected-class="<%= advanced_select_state_class(class_map, :option_selected) %>" + data-advanced-select-option-class="<%= advanced_select_class(class_map, :option) %>" + data-advanced-select-option-check-class="<%= advanced_select_class(class_map, :option_check) %>" + data-advanced-select-option-content-class="<%= advanced_select_class(class_map, :option_content) %>" + data-advanced-select-option-description-class="<%= advanced_select_class(class_map, :option_description) %>" data-advanced-select-selected-value="<%= advanced_select_selected_value(selected_options) %>">
<% if multiple %> 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 2e8a94c..a2fc3af 100644 --- a/lib/generators/advanced_select/install/templates/advanced_select_controller.js +++ b/lib/generators/advanced_select/install/templates/advanced_select_controller.js @@ -41,6 +41,10 @@ export default class extends Controller { this.optionActiveClasses = this.classList(this.element.dataset.advancedSelectOptionActiveClass || "ui-advanced-select-option-active") this.addOptionActiveClasses = this.classList(this.element.dataset.advancedSelectAddOptionActiveClass || "") this.optionSelectedClasses = this.classList(this.element.dataset.advancedSelectOptionSelectedClass || "") + this.optionClass = this.element.dataset.advancedSelectOptionClass || "ui-advanced-select-option" + this.optionCheckClass = this.element.dataset.advancedSelectOptionCheckClass || "ui-advanced-select-option-check" + this.optionContentClass = this.element.dataset.advancedSelectOptionContentClass || "ui-advanced-select-option-content" + this.optionDescriptionClass = this.element.dataset.advancedSelectOptionDescriptionClass || "ui-advanced-select-option-description" this.selectedValue = this.selectedValue.map((option) => this.normalizeSelectedOption(option)) this.close = this.close.bind(this) this.renderOptionsState() @@ -376,6 +380,37 @@ export default class extends Controller { } } + optionElement(option) { + const normalized = this.normalizeSelectedOption(option) + const button = document.createElement("button") + + button.type = "button" + button.className = this.optionClass + button.setAttribute("role", "option") + button.setAttribute("aria-selected", "false") + button.dataset.advancedSelectOption = "" + button.dataset.action = "mouseenter->advanced-select#activateOption mousedown->advanced-select#choose" + button.dataset.advancedSelectValueParam = normalized.id + button.dataset.advancedSelectSubmitValueParam = normalized.value + button.dataset.advancedSelectLabelParam = normalized.label + button.dataset.advancedSelectDisplayLabelParam = normalized.displayLabel + button.dataset.advancedSelectOptionParam = JSON.stringify(normalized) + + const check = this.textElement("span", this.optionCheckClass, "") + check.dataset.advancedSelectOptionCheck = "" + + const content = this.textElement("span", this.optionContentClass, "") + content.appendChild(this.textElement("span", "", normalized.label)) + + if (normalized.description) { + content.appendChild(this.textElement("span", this.optionDescriptionClass, normalized.description)) + } + + button.append(check, content) + + return button + } + optionData(element) { const data = this.parseOptionData(element.dataset.advancedSelectOptionParam) const value = element.dataset.advancedSelectValueParam diff --git a/test/dummy/app/views/advanced_select_examples/show.html.erb b/test/dummy/app/views/advanced_select_examples/show.html.erb index 9f66a49..5a7ddd7 100644 --- a/test/dummy/app/views/advanced_select_examples/show.html.erb +++ b/test/dummy/app/views/advanced_select_examples/show.html.erb @@ -283,3 +283,15 @@ disabled: true, searchable: false ) %> + +<%= advanced_select_tag( + "example[described_id]", + id: "example_described_id", + selected: nil, + options: [ + { id: "described-1", label: "Described One", description: "First description" }, + { id: "described-2", label: "Described Two", description: "Second description" } + ], + placeholder: "Choose described item", + searchable: false +) %> diff --git a/test/system/advanced_select_interaction_test.rb b/test/system/advanced_select_interaction_test.rb index 85fb2ef..d50dd72 100644 --- a/test/system/advanced_select_interaction_test.rb +++ b/test/system/advanced_select_interaction_test.rb @@ -395,6 +395,51 @@ class AdvancedSelectInteractionTest < ApplicationSystemTestCase assert_equal [[]], advanced_select_events.map { |event| event["value"] } end + test "builds an option element with the same shape as a server-rendered one" do + visit root_path + + server = option_shape("document.querySelector(\"#example_item_id_options [data-advanced-select-value-param='local-1']\")") + client = option_shape(build_option_script("example_item_id", "{ id: 'local-1', label: 'Local One' }")) + + assert_equal server, client + end + + test "builds option elements with the host class map" do + visit root_path + + client = option_shape(build_option_script("example_styled_id", "{ id: 'styled-3', label: 'Styled Three' }")) + server = option_shape("document.querySelector(\"#example_styled_id_options [data-advanced-select-value-param='styled-2']\")") + + assert_equal "test-option-class", client.fetch("className") + assert_equal server.fetch("className"), client.fetch("className") + assert_equal server.fetch("checkClassName"), client.fetch("checkClassName") + assert_equal server.fetch("contentClassName"), client.fetch("contentClassName") + end + + test "renders a description on a client-built option like the server does" do + visit root_path + + server = option_shape("document.querySelector(\"#example_described_id_options [data-advanced-select-value-param='described-1']\")") + client = option_shape( + build_option_script("example_described_id", "{ id: 'described-1', label: 'Described One', description: 'First description' }") + ) + + assert_equal "Described OneFirst description", server.fetch("text") + assert_equal server, client + end + + test "selects a client-built option that was added to the list" do + visit root_path + append_built_option("example_item_id", "{ id: 'local-9', label: 'Local Nine' }") + + find("#example_item_id_trigger").click + find("#example_item_id_options button", text: "Local Nine").click + + assert_selector "#example_item_id_summary", text: "Local Nine" + assert_selector "input[name='example[item_id]'][value='local-9']", visible: false + assert_equal "local-9", select_call("example_item_id", "getValue()") + end + test "reads the submit value through getValue" do visit root_path @@ -607,6 +652,46 @@ def advanced_select_events page.evaluate_script("window.__advancedSelectEvents") end + def append_built_option(select_id, option_json) + page.execute_script(<<~JS) + ((select) => select.currentOptionsTarget.appendChild(select.optionElement(#{option_json})))( + window.Stimulus.getControllerForElementAndIdentifier( + document.getElementById("#{select_id}_trigger").closest("[data-controller~='advanced-select']"), + "advanced-select" + ) + ) + JS + end + + def build_option_script(select_id, option_json) + <<~JS.strip + window.Stimulus.getControllerForElementAndIdentifier( + document.getElementById("#{select_id}_trigger").closest("[data-controller~='advanced-select']"), + "advanced-select" + ).optionElement(#{option_json}) + JS + end + + def option_shape(element_script) + page.evaluate_script(<<~JS) + ((element) => ({ + tag: element.tagName, + className: element.className, + role: element.getAttribute("role"), + ariaSelected: element.getAttribute("aria-selected"), + action: element.dataset.action, + value: element.dataset.advancedSelectValueParam, + submitValue: element.dataset.advancedSelectSubmitValueParam, + label: element.dataset.advancedSelectLabelParam, + displayLabel: element.dataset.advancedSelectDisplayLabelParam, + checkClassName: element.querySelector("[data-advanced-select-option-check]").className, + contentClassName: element.children[1].className, + childTags: Array.from(element.children[1].children).map((child) => child.tagName + ":" + child.className), + text: element.textContent.replace(/\\s+/g, " ").trim() + }))(#{element_script}) + JS + end + def select_call(select_id, expression) page.evaluate_script(<<~JS) (() => {