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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 35 additions & 0 deletions app/javascript/advanced_select/advanced_select_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/views/advanced_select/_select.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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) %>">
<div data-advanced-select-target="hiddenFields">
<% if multiple %>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions test/dummy/app/views/advanced_select_examples/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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
) %>
85 changes: 85 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,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

Expand Down Expand Up @@ -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)
(() => {
Expand Down
Loading