Skip to content
Closed
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
12 changes: 12 additions & 0 deletions docs/app/javascript/controllers/ruby_ui/combobox_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export default class extends Controller {

connect() {
this.updateTriggerContent()
this.requiredInputs = this.inputTargets.filter((input) => input.required)
this.syncValidity()
}

disconnect() {
Expand All @@ -35,6 +37,7 @@ export default class extends Controller {

inputChanged(e) {
this.updateTriggerContent()
this.syncValidity()

if (e.target.type == "radio") {
this.closePopover()
Expand All @@ -45,6 +48,14 @@ export default class extends Controller {
}
}

syncValidity() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: After a failed submit, using Select all can leave the required error visible even though the selection is now valid, because this validity update does not notify ruby-ui--form-field#onInput. Triggering the form-field input update after toggleAllItems() would keep FormFieldError synchronized with the native validity state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/app/javascript/controllers/ruby_ui/combobox_controller.js, line 51:

<comment>After a failed submit, using Select all can leave the required error visible even though the selection is now valid, because this validity update does not notify `ruby-ui--form-field#onInput`. Triggering the form-field input update after `toggleAllItems()` would keep `FormFieldError` synchronized with the native validity state.</comment>

<file context>
@@ -45,6 +48,14 @@ export default class extends Controller {
     }
   }
 
+  syncValidity() {
+    if (this.requiredInputs.length === 0) return
+
</file context>

if (this.requiredInputs.length === 0) return

const anyChecked = this.requiredInputs.some((input) => input.checked)

this.requiredInputs.forEach((input) => { input.required = !anyChecked })
}

inputContent(input) {
return input.dataset.text || input.parentElement.textContent
}
Expand All @@ -53,6 +64,7 @@ export default class extends Controller {
const isChecked = this.toggleAllTarget.checked
this.inputTargets.forEach(input => input.checked = isChecked)
this.updateTriggerContent()
this.syncValidity()
}

updateTriggerContent() {
Expand Down
7 changes: 6 additions & 1 deletion gem/lib/ruby_ui/combobox/combobox_checkbox.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ def default_attrs
],
data: {
ruby_ui__combobox_target: "input",
action: "ruby-ui--combobox#inputChanged"
ruby_ui__form_field_target: "input",
action: %w[
ruby-ui--combobox#inputChanged
input->ruby-ui--form-field#onInput
invalid->ruby-ui--form-field#onInvalid
]
}
}
end
Expand Down
12 changes: 12 additions & 0 deletions gem/lib/ruby_ui/combobox/combobox_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export default class extends Controller {

connect() {
this.updateTriggerContent()
this.requiredInputs = this.inputTargets.filter((input) => input.required)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A combobox that gains required options after connect can remain in the wrong native-validation state because requiredInputs is a one-time snapshot. Tracking target connect/disconnect or maintaining a stable marker for originally required inputs would keep dynamically morphed options in the group.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/combobox/combobox_controller.js, line 26:

<comment>A combobox that gains required options after connect can remain in the wrong native-validation state because `requiredInputs` is a one-time snapshot. Tracking target connect/disconnect or maintaining a stable marker for originally required inputs would keep dynamically morphed options in the group.</comment>

<file context>
@@ -23,6 +23,8 @@ export default class extends Controller {
 
   connect() {
     this.updateTriggerContent()
+    this.requiredInputs = this.inputTargets.filter((input) => input.required)
+    this.syncValidity()
   }
</file context>

this.syncValidity()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: After a required error is displayed, selecting or clearing all options leaves the FormField error message stale because bulk changes emit no option input/change event. Reusing the option input event path after the bulk update would refresh both native validity and the form-field error.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/combobox/combobox_controller.js, line 27:

<comment>After a required error is displayed, selecting or clearing all options leaves the `FormField` error message stale because bulk changes emit no option `input`/`change` event. Reusing the option input event path after the bulk update would refresh both native validity and the form-field error.</comment>

<file context>
@@ -23,6 +23,8 @@ export default class extends Controller {
   connect() {
     this.updateTriggerContent()
+    this.requiredInputs = this.inputTargets.filter((input) => input.required)
+    this.syncValidity()
   }
 
</file context>

}

disconnect() {
Expand All @@ -36,6 +38,7 @@ export default class extends Controller {

inputChanged(e) {
this.updateTriggerContent()
this.syncValidity()

if (e.target.type == "radio") {
this.closePopover()
Expand All @@ -46,6 +49,14 @@ export default class extends Controller {
}
}

syncValidity() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: After a user selects an option, syncValidity() removes required from every option; resetting the containing form can then clear the selection without reapplying required, allowing submission with nothing selected. Listening for the form's reset event and resynchronizing after the reset would keep native validation correct.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/combobox/combobox_controller.js, line 52:

<comment>After a user selects an option, `syncValidity()` removes `required` from every option; resetting the containing form can then clear the selection without reapplying `required`, allowing submission with nothing selected. Listening for the form's `reset` event and resynchronizing after the reset would keep native validation correct.</comment>

<file context>
@@ -46,6 +49,14 @@ export default class extends Controller {
     }
   }
 
+  syncValidity() {
+    if (this.requiredInputs.length === 0) return
+
</file context>

if (this.requiredInputs.length === 0) return

const anyChecked = this.requiredInputs.some((input) => input.checked)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a checked option is disabled, it is treated as satisfying the required group even though it contributes no submitted form value, so all enabled unchecked options become non-required. Excluding disabled controls from anyChecked would prevent a form from passing with no usable selection.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/combobox/combobox_controller.js, line 55:

<comment>When a checked option is disabled, it is treated as satisfying the required group even though it contributes no submitted form value, so all enabled unchecked options become non-required. Excluding disabled controls from `anyChecked` would prevent a form from passing with no usable selection.</comment>

<file context>
@@ -46,6 +49,14 @@ export default class extends Controller {
+  syncValidity() {
+    if (this.requiredInputs.length === 0) return
+
+    const anyChecked = this.requiredInputs.some((input) => input.checked)
+
+    this.requiredInputs.forEach((input) => { input.required = !anyChecked })
</file context>
Suggested change
const anyChecked = this.requiredInputs.some((input) => input.checked)
const anyChecked = this.requiredInputs.some((input) => input.checked && !input.matches(":disabled"))


this.requiredInputs.forEach((input) => { input.required = !anyChecked })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new required-group logic substantially duplicates CheckboxGroupController#handleRequired, creating two implementations that can drift as validation edge cases are fixed. Sharing the group-validity helper or centralizing this behavior would keep checkbox and combobox validation consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/combobox/combobox_controller.js, line 57:

<comment>The new required-group logic substantially duplicates `CheckboxGroupController#handleRequired`, creating two implementations that can drift as validation edge cases are fixed. Sharing the group-validity helper or centralizing this behavior would keep checkbox and combobox validation consistent.</comment>

<file context>
@@ -46,6 +49,14 @@ export default class extends Controller {
+
+    const anyChecked = this.requiredInputs.some((input) => input.checked)
+
+    this.requiredInputs.forEach((input) => { input.required = !anyChecked })
+  }
+
</file context>

}

inputContent(input) {
return input.dataset.text || input.parentElement.textContent
}
Expand All @@ -54,6 +65,7 @@ export default class extends Controller {
const isChecked = this.toggleAllTarget.checked
this.inputTargets.forEach(input => input.checked = isChecked)
this.updateTriggerContent()
this.syncValidity()
}

updateTriggerContent() {
Expand Down
7 changes: 7 additions & 0 deletions gem/test/ruby_ui/combobox_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ def test_combobox_checkbox_renders_styled_input
assert_match(/checked:bg-primary/, output)
end

def test_combobox_checkbox_wires_form_field_validation
output = phlex { RubyUI.ComboboxCheckbox(name: "x", value: "1") }
assert_match(/data-ruby-ui--form-field-target="input"/, output)
assert_match(/input->ruby-ui--form-field#onInput/, output)
assert_match(/invalid->ruby-ui--form-field#onInvalid/, output)
end

def test_combobox_item_indicator_renders_check_svg
output = phlex { RubyUI.ComboboxItemIndicator() }
assert_match(/peer-checked:opacity-100/, output)
Expand Down
4 changes: 2 additions & 2 deletions mcp/data/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -935,15 +935,15 @@
},
{
"path": "combobox_checkbox.rb",
"content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxCheckbox < Base\n def view_template\n input(type: \"checkbox\", **attrs)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background accent-primary\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n \"checked:bg-primary checked:text-primary-foreground\",\n \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n ],\n data: {\n ruby_ui__combobox_target: \"input\",\n action: \"ruby-ui--combobox#inputChanged\"\n }\n }\n end\n end\nend\n"
"content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxCheckbox < Base\n def view_template\n input(type: \"checkbox\", **attrs)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background accent-primary\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n \"checked:bg-primary checked:text-primary-foreground\",\n \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n ],\n data: {\n ruby_ui__combobox_target: \"input\",\n ruby_ui__form_field_target: \"input\",\n action: %w[\n ruby-ui--combobox#inputChanged\n input->ruby-ui--form-field#onInput\n invalid->ruby-ui--form-field#onInvalid\n ]\n }\n }\n end\n end\nend\n"
},
{
"path": "combobox_clear_button.rb",
"content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxClearButton < Base\n def view_template\n button(**attrs) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"24\",\n height: \"24\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"size-3.5\"\n ) do |s|\n s.path(d: \"M18 6 6 18\")\n s.path(d: \"m6 6 12 12\")\n end\n end\n end\n\n private\n\n def default_attrs\n {\n type: \"button\",\n class: \"ml-auto shrink-0 rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hidden\",\n aria: {label: \"Clear selection\"},\n data: {\n ruby_ui__combobox_target: \"clearButton\",\n # JS implementation in combobox_controller.js\n action: \"ruby-ui--combobox#clearAll\"\n }\n }\n end\n end\nend\n"
},
{
"path": "combobox_controller.js",
"content": "import { Controller } from \"@hotwired/stimulus\";\nimport { computePosition, autoUpdate, offset, flip } from \"@floating-ui/dom\";\n\n// Connects to data-controller=\"ruby-ui--combobox\"\nexport default class extends Controller {\n static values = {\n term: String,\n minPopoverWidth: { type: Number, default: 240 }\n }\n\n static targets = [\n \"input\",\n \"toggleAll\",\n \"popover\",\n \"item\",\n \"emptyState\",\n \"searchInput\",\n \"trigger\",\n \"triggerContent\"\n ]\n\n selectedItemIndex = null\n\n connect() {\n this.updateTriggerContent()\n }\n\n disconnect() {\n if (this.cleanup) { this.cleanup() }\n }\n\n handlePopoverToggle(event) {\n // Keep ariaExpanded in sync with the actual popover state\n this.triggerTarget.ariaExpanded = event.newState === 'open' ? 'true' : 'false'\n }\n\n inputChanged(e) {\n this.updateTriggerContent()\n\n if (e.target.type == \"radio\") {\n this.closePopover()\n }\n\n if (this.hasToggleAllTarget && !e.target.checked) {\n this.toggleAllTarget.checked = false\n }\n }\n\n inputContent(input) {\n return input.dataset.text || input.parentElement.textContent\n }\n\n toggleAllItems() {\n const isChecked = this.toggleAllTarget.checked\n this.inputTargets.forEach(input => input.checked = isChecked)\n this.updateTriggerContent()\n }\n\n updateTriggerContent() {\n const checkedInputs = this.inputTargets.filter(input => input.checked)\n\n if (checkedInputs.length === 0) {\n this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder\n } else if (this.termValue && checkedInputs.length > 1) {\n this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}`\n } else {\n this.triggerContentTarget.innerText = checkedInputs.map((input) => this.inputContent(input)).join(\", \")\n }\n }\n\n togglePopover(event) {\n event.preventDefault()\n\n if (this.triggerTarget.ariaExpanded === \"true\") {\n this.closePopover()\n } else {\n this.openPopover(event)\n }\n }\n\n openPopover(event) {\n if (event) event.preventDefault()\n\n this.updatePopoverPosition()\n this.updatePopoverWidth()\n this.triggerTarget.ariaExpanded = \"true\"\n this.selectedItemIndex = null\n this.itemTargets.forEach(item => item.ariaCurrent = \"false\")\n this.popoverTarget.showPopover()\n }\n\n closePopover() {\n this.triggerTarget.ariaExpanded = \"false\"\n this.popoverTarget.hidePopover()\n }\n\n filterItems(e) {\n if ([\"ArrowDown\", \"ArrowUp\", \"Tab\", \"Enter\"].includes(e.key)) {\n return\n }\n\n const filterTerm = this.searchInputTarget.value.toLowerCase()\n\n if (this.hasToggleAllTarget) {\n if (filterTerm) this.toggleAllTarget.parentElement.classList.add(\"hidden\")\n else this.toggleAllTarget.parentElement.classList.remove(\"hidden\")\n }\n\n let resultCount = 0\n\n this.selectedItemIndex = null\n\n this.inputTargets.forEach((input) => {\n const text = this.inputContent(input).toLowerCase()\n\n if (text.indexOf(filterTerm) > -1) {\n input.parentElement.classList.remove(\"hidden\")\n resultCount++\n } else {\n input.parentElement.classList.add(\"hidden\")\n }\n })\n\n this.emptyStateTarget.classList.toggle(\"hidden\", resultCount !== 0)\n }\n\n keyDownPressed() {\n if (this.selectedItemIndex !== null) {\n this.selectedItemIndex++\n } else {\n this.selectedItemIndex = 0\n }\n\n this.focusSelectedInput()\n }\n\n keyUpPressed() {\n if (this.selectedItemIndex !== null) {\n this.selectedItemIndex--\n } else {\n this.selectedItemIndex = -1\n }\n\n this.focusSelectedInput()\n }\n\n focusSelectedInput() {\n const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains(\"hidden\"))\n\n this.wrapSelectedInputIndex(visibleInputs.length)\n\n visibleInputs.forEach((input, index) => {\n if (index == this.selectedItemIndex) {\n input.parentElement.ariaCurrent = \"true\"\n input.parentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })\n } else {\n input.parentElement.ariaCurrent = \"false\"\n }\n })\n }\n\n keyEnterPressed(event) {\n event.preventDefault()\n const option = this.itemTargets.find(item => item.ariaCurrent === \"true\")\n\n if (option) {\n option.click()\n }\n }\n\n wrapSelectedInputIndex(length) {\n this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length\n }\n\n updatePopoverPosition() {\n this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => {\n computePosition(this.triggerTarget, this.popoverTarget, {\n placement: 'bottom-start',\n middleware: [offset(4), flip()],\n }).then(({ x, y }) => {\n Object.assign(this.popoverTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n });\n }\n\n updatePopoverWidth() {\n const width = Math.max(this.triggerTarget.offsetWidth, this.minPopoverWidthValue)\n this.popoverTarget.style.width = `${width}px`\n }\n}\n"
"content": "import { Controller } from \"@hotwired/stimulus\";\nimport { computePosition, autoUpdate, offset, flip } from \"@floating-ui/dom\";\n\n// Connects to data-controller=\"ruby-ui--combobox\"\nexport default class extends Controller {\n static values = {\n term: String,\n minPopoverWidth: { type: Number, default: 240 }\n }\n\n static targets = [\n \"input\",\n \"toggleAll\",\n \"popover\",\n \"item\",\n \"emptyState\",\n \"searchInput\",\n \"trigger\",\n \"triggerContent\"\n ]\n\n selectedItemIndex = null\n\n connect() {\n this.updateTriggerContent()\n this.requiredInputs = this.inputTargets.filter((input) => input.required)\n this.syncValidity()\n }\n\n disconnect() {\n if (this.cleanup) { this.cleanup() }\n }\n\n handlePopoverToggle(event) {\n // Keep ariaExpanded in sync with the actual popover state\n this.triggerTarget.ariaExpanded = event.newState === 'open' ? 'true' : 'false'\n }\n\n inputChanged(e) {\n this.updateTriggerContent()\n this.syncValidity()\n\n if (e.target.type == \"radio\") {\n this.closePopover()\n }\n\n if (this.hasToggleAllTarget && !e.target.checked) {\n this.toggleAllTarget.checked = false\n }\n }\n\n syncValidity() {\n if (this.requiredInputs.length === 0) return\n\n const anyChecked = this.requiredInputs.some((input) => input.checked)\n\n this.requiredInputs.forEach((input) => { input.required = !anyChecked })\n }\n\n inputContent(input) {\n return input.dataset.text || input.parentElement.textContent\n }\n\n toggleAllItems() {\n const isChecked = this.toggleAllTarget.checked\n this.inputTargets.forEach(input => input.checked = isChecked)\n this.updateTriggerContent()\n this.syncValidity()\n }\n\n updateTriggerContent() {\n const checkedInputs = this.inputTargets.filter(input => input.checked)\n\n if (checkedInputs.length === 0) {\n this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder\n } else if (this.termValue && checkedInputs.length > 1) {\n this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}`\n } else {\n this.triggerContentTarget.innerText = checkedInputs.map((input) => this.inputContent(input)).join(\", \")\n }\n }\n\n togglePopover(event) {\n event.preventDefault()\n\n if (this.triggerTarget.ariaExpanded === \"true\") {\n this.closePopover()\n } else {\n this.openPopover(event)\n }\n }\n\n openPopover(event) {\n if (event) event.preventDefault()\n\n this.updatePopoverPosition()\n this.updatePopoverWidth()\n this.triggerTarget.ariaExpanded = \"true\"\n this.selectedItemIndex = null\n this.itemTargets.forEach(item => item.ariaCurrent = \"false\")\n this.popoverTarget.showPopover()\n }\n\n closePopover() {\n this.triggerTarget.ariaExpanded = \"false\"\n this.popoverTarget.hidePopover()\n }\n\n filterItems(e) {\n if ([\"ArrowDown\", \"ArrowUp\", \"Tab\", \"Enter\"].includes(e.key)) {\n return\n }\n\n const filterTerm = this.searchInputTarget.value.toLowerCase()\n\n if (this.hasToggleAllTarget) {\n if (filterTerm) this.toggleAllTarget.parentElement.classList.add(\"hidden\")\n else this.toggleAllTarget.parentElement.classList.remove(\"hidden\")\n }\n\n let resultCount = 0\n\n this.selectedItemIndex = null\n\n this.inputTargets.forEach((input) => {\n const text = this.inputContent(input).toLowerCase()\n\n if (text.indexOf(filterTerm) > -1) {\n input.parentElement.classList.remove(\"hidden\")\n resultCount++\n } else {\n input.parentElement.classList.add(\"hidden\")\n }\n })\n\n this.emptyStateTarget.classList.toggle(\"hidden\", resultCount !== 0)\n }\n\n keyDownPressed() {\n if (this.selectedItemIndex !== null) {\n this.selectedItemIndex++\n } else {\n this.selectedItemIndex = 0\n }\n\n this.focusSelectedInput()\n }\n\n keyUpPressed() {\n if (this.selectedItemIndex !== null) {\n this.selectedItemIndex--\n } else {\n this.selectedItemIndex = -1\n }\n\n this.focusSelectedInput()\n }\n\n focusSelectedInput() {\n const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains(\"hidden\"))\n\n this.wrapSelectedInputIndex(visibleInputs.length)\n\n visibleInputs.forEach((input, index) => {\n if (index == this.selectedItemIndex) {\n input.parentElement.ariaCurrent = \"true\"\n input.parentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })\n } else {\n input.parentElement.ariaCurrent = \"false\"\n }\n })\n }\n\n keyEnterPressed(event) {\n event.preventDefault()\n const option = this.itemTargets.find(item => item.ariaCurrent === \"true\")\n\n if (option) {\n option.click()\n }\n }\n\n wrapSelectedInputIndex(length) {\n this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length\n }\n\n updatePopoverPosition() {\n this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => {\n computePosition(this.triggerTarget, this.popoverTarget, {\n placement: 'bottom-start',\n middleware: [offset(4), flip()],\n }).then(({ x, y }) => {\n Object.assign(this.popoverTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n });\n }\n\n updatePopoverWidth() {\n const width = Math.max(this.triggerTarget.offsetWidth, this.minPopoverWidthValue)\n this.popoverTarget.style.width = `${width}px`\n }\n}\n"
},
{
"path": "combobox_empty_state.rb",
Expand Down