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)
(() => {