Combobox
Autocomplete input and command palette with a list of suggestions.
Usage
Single option
div class: "w-96" do Combobox do ComboboxTrigger placeholder: "Pick value" ComboboxPopover do ComboboxSearchInput(placeholder: "Pick value or type anything") ComboboxList do ComboboxEmptyState { "No result" } ComboboxListGroup(label: "Fruits") do ComboboxItem do ComboboxRadio(name: "food", value: "apple") span { "Apple" } end ComboboxItem do ComboboxRadio(name: "food", value: "banana") span { "Banana" } end end ComboboxListGroup(label: "Vegetable") do ComboboxItem do ComboboxRadio(name: "food", value: "brocoli") span { "Broccoli" } end ComboboxItem do ComboboxRadio(name: "food", value: "carrot") span { "Carrot" } end end ComboboxListGroup(label: "Others") do ComboboxItem do ComboboxRadio(name: "food", value: "chocolate") span { "Chocolate" } end ComboboxItem do ComboboxRadio(name: "food", value: "milk") span { "Milk" } end end end end end end
Multiple options
div class: "w-96" do Combobox term: "things" do ComboboxTrigger placeholder: "Pick value" ComboboxPopover do ComboboxSearchInput(placeholder: "Pick value or type anything") ComboboxList do ComboboxEmptyState { "No result" } ComboboxItem(class: "mt-3") do ComboboxToggleAllCheckbox(name: "all", value: "all") span { "Select all" } end ComboboxListGroup label: "Fruits" do ComboboxItem do ComboboxCheckbox(name: "food", value: "apple") span { "Apple" } end ComboboxItem do ComboboxCheckbox(name: "food", value: "banana") span { "Banana" } end end ComboboxListGroup label: "Vegetable" do ComboboxItem do ComboboxCheckbox(name: "food", value: "brocoli") span { "Broccoli" } end ComboboxItem do ComboboxCheckbox(name: "food", value: "carrot") span { "Carrot" } end end ComboboxListGroup label: "Others" do ComboboxItem do ComboboxCheckbox(name: "food", value: "chocolate") span { "Chocolate" } end ComboboxItem do ComboboxCheckbox(name: "food", value: "milk") span { "Milk" } end end end end end end
Disabled
div(class: "w-96") do Combobox do ComboboxTrigger(disabled: true, placeholder: "Pick value") end end
Aria disabled
div(class: "w-96") do Combobox do ComboboxTrigger(aria: {disabled: "true"}, placeholder: "Pick value") end end
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component Combobox
Manual installation
1
Add RubyUI::Combobox to app/components/ruby_ui/combobox/combobox.rb
# frozen_string_literal: true module RubyUI class Combobox < Base def initialize(term: nil, **) @term = term super(**) end def view_template(&) div(**attrs, &) end private def default_attrs { role: "combobox", data: { controller: "ruby-ui--combobox", ruby_ui__combobox_term_value: @term, action: %w[ turbo:morph@window->ruby-ui--combobox#updateTriggerContent keydown.down->ruby-ui--combobox#keyDownPressed keydown.up->ruby-ui--combobox#keyUpPressed keydown.enter->ruby-ui--combobox#keyEnterPressed keydown.esc->ruby-ui--combobox#closePopover:prevent ] } } end end end
2
Add RubyUI::ComboboxBadge to app/components/ruby_ui/combobox/combobox_badge.rb
# frozen_string_literal: true module RubyUI class ComboboxBadge < Base def view_template(&) span(**attrs, &) end private def default_attrs { class: "inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground" } end end end
3
Add RubyUI::ComboboxBadgeTrigger to app/components/ruby_ui/combobox/combobox_badge_trigger.rb
# frozen_string_literal: true module RubyUI class ComboboxBadgeTrigger < Base def initialize(placeholder: "", clear_button: false, **) @placeholder = placeholder @clear_button = clear_button super(**) end def view_template(&) div(**attrs) do div(data: {ruby_ui__combobox_target: "badgeContainer"}, class: "hidden") input( type: "text", class: "flex-1 min-w-8 bg-transparent border-0 px-0 outline-none focus:ring-0 placeholder:text-muted-foreground text-sm", autocomplete: "off", autocorrect: "off", spellcheck: "false", placeholder: @placeholder, data: { ruby_ui__combobox_target: "badgeInput", action: "keyup->ruby-ui--combobox#filterItems input->ruby-ui--combobox#filterItems keydown.backspace->ruby-ui--combobox#handleBadgeInputBackspace" } ) render ComboboxClearButton.new if @clear_button end end private # JS-toggled classes (referenced here so Tailwind compiles them): h-auto min-h-9 pt-1.5 def default_attrs { class: "flex h-9 w-full flex-wrap items-center gap-1 rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 cursor-text", data: { ruby_ui__combobox_target: "trigger", action: "click->ruby-ui--combobox#openPopover focusin->ruby-ui--combobox#openPopover" }, aria: { haspopup: "listbox", expanded: "false" } } end end end
4
Add RubyUI::ComboboxCheckbox to app/components/ruby_ui/combobox/combobox_checkbox.rb
# frozen_string_literal: true module RubyUI class ComboboxCheckbox < Base def view_template input(type: "checkbox", **attrs) end private def default_attrs { class: "peer sr-only", data: { ruby_ui__combobox_target: "input", action: "ruby-ui--combobox#inputChanged" } } end end end
5
Add RubyUI::ComboboxClearButton to app/components/ruby_ui/combobox/combobox_clear_button.rb
# frozen_string_literal: true module RubyUI class ComboboxClearButton < Base def view_template button(**attrs) do svg( xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "size-3.5" ) do |s| s.path(d: "M18 6 6 18") s.path(d: "m6 6 12 12") end end end private def default_attrs { type: "button", 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", aria: {label: "Clear selection"}, data: { ruby_ui__combobox_target: "clearButton", # JS implementation in combobox_controller.js action: "ruby-ui--combobox#clearAll" } } end end end
6
Add RubyUI::ComboboxDocs to app/components/ruby_ui/combobox/combobox_docs.rb
# frozen_string_literal: true class Views::Docs::Combobox < Views::Base def view_template component = "Combobox" div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do render Docs::Header.new(title: component, description: "Autocomplete input and command palette with a list of suggestions.") Heading(level: 2) { "Usage" } render Docs::VisualCodeExample.new(title: "Basic", context: self) do <<~RUBY div(class: "w-96") do Combobox do ComboboxInputTrigger(placeholder: "Select framework...") ComboboxPopover do ComboboxList do ComboboxEmptyState { "No results found." } ComboboxItem do ComboboxRadio(name: "framework", value: "rails") span { "Rails" } end ComboboxItem do ComboboxRadio(name: "framework", value: "hanami") span { "Hanami" } end ComboboxItem do ComboboxRadio(name: "framework", value: "nextjs") span { "Next.js" } end ComboboxItem do ComboboxRadio(name: "framework", value: "nuxt") span { "Nuxt" } end end end end end RUBY end render Docs::VisualCodeExample.new(title: "Popup", context: self) do <<~RUBY div(class: "w-96") do Combobox do ComboboxTrigger(placeholder: "Select framework...") ComboboxPopover do ComboboxSearchInput(placeholder: "Search framework...") ComboboxList do ComboboxEmptyState { "No results found." } ComboboxItem do ComboboxRadio(name: "fw2", value: "rails") span { "Rails" } end ComboboxItem do ComboboxRadio(name: "fw2", value: "hanami") span { "Hanami" } end ComboboxItem do ComboboxRadio(name: "fw2", value: "nextjs") span { "Next.js" } end end end end end RUBY end render Docs::VisualCodeExample.new(title: "Multiple", context: self) do <<~RUBY div(class: "w-96") do Combobox do ComboboxBadgeTrigger(clear_button: true) ComboboxPopover do ComboboxList do ComboboxEmptyState { "No results found." } ComboboxItem do ComboboxCheckbox(name: "frameworks[]", value: "rails") span { "Rails" } end ComboboxItem do ComboboxCheckbox(name: "frameworks[]", value: "hanami") span { "Hanami" } end ComboboxItem do ComboboxCheckbox(name: "frameworks[]", value: "sinatra") span { "Sinatra" } end ComboboxItem do ComboboxCheckbox(name: "frameworks[]", value: "nextjs", checked: true) span { "Next.js" } end ComboboxItem do ComboboxCheckbox(name: "frameworks[]", value: "nuxt") span { "Nuxt" } end ComboboxItem do ComboboxCheckbox(name: "frameworks[]", value: "svelte") span { "SvelteKit" } end ComboboxItem do ComboboxCheckbox(name: "frameworks[]", value: "remix") span { "Remix" } end ComboboxItem do ComboboxCheckbox(name: "frameworks[]", value: "astro") span { "Astro" } end end end end end RUBY end render Docs::VisualCodeExample.new(title: "Groups", context: self) do <<~RUBY div(class: "w-96") do Combobox do ComboboxInputTrigger(placeholder: "Select food...") ComboboxPopover do ComboboxList do ComboboxEmptyState { "No results found." } ComboboxListGroup(label: "Fruits") do ComboboxItem do ComboboxRadio(name: "food", value: "apple") span { "Apple" } end ComboboxItem do ComboboxRadio(name: "food", value: "banana") span { "Banana" } end end ComboboxListGroup(label: "Vegetables") do ComboboxItem do ComboboxRadio(name: "food", value: "broccoli") span { "Broccoli" } end ComboboxItem do ComboboxRadio(name: "food", value: "carrot") span { "Carrot" } end end ComboboxListGroup(label: "Grains") do ComboboxItem do ComboboxRadio(name: "food", value: "rice") span { "Rice" } end ComboboxItem do ComboboxRadio(name: "food", value: "wheat") span { "Wheat" } end end end end end end RUBY end render Docs::VisualCodeExample.new(title: "Custom Items", context: self) do <<~RUBY div(class: "w-96") do Combobox do ComboboxInputTrigger(placeholder: "Select status...") ComboboxPopover do ComboboxList do ComboboxEmptyState { "No results found." } ComboboxItem do ComboboxRadio(name: "status", value: "backlog", data: {text: "Backlog"}) svg(xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", class: "text-muted-foreground") { |s| s.circle(cx: "12", cy: "12", r: "10") } span { "Backlog" } end ComboboxItem do ComboboxRadio(name: "status", value: "todo", data: {text: "Todo"}) svg(xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", class: "text-blue-500") { |s| s.circle(cx: "12", cy: "12", r: "10") } span { "Todo" } end ComboboxItem do ComboboxRadio(name: "status", value: "done", data: {text: "Done"}) svg(xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", class: "text-green-500") { |s| s.path(d: "M22 11.08V12a10 10 0 1 1-5.93-9.14"); s.path(d: "m9 11 3 3L22 4") } span { "Done" } end end end end end RUBY end render Docs::VisualCodeExample.new(title: "Invalid", context: self) do <<~RUBY div(class: "w-96") do Combobox do ComboboxInputTrigger(placeholder: "Required field", aria: {invalid: "true"}) ComboboxPopover do ComboboxList do ComboboxEmptyState { "No results found." } ComboboxItem do ComboboxRadio(name: "req", value: "option1") span { "Option 1" } end ComboboxItem do ComboboxRadio(name: "req", value: "option2") span { "Option 2" } end end end end end RUBY end render Docs::VisualCodeExample.new(title: "Disabled", context: self) do <<~RUBY div(class: "w-96 space-y-2") do Combobox do ComboboxTrigger(disabled: true, placeholder: "Disabled trigger") end Combobox do ComboboxInputTrigger(placeholder: "Disabled input", disabled: true) end end RUBY end render Docs::VisualCodeExample.new(title: "Auto Highlight", context: self) do <<~RUBY div(class: "w-96") do Combobox do ComboboxInputTrigger(placeholder: "Type to search...") ComboboxPopover do ComboboxList do ComboboxEmptyState { "No results found." } ComboboxItem do ComboboxRadio(name: "color", value: "red") span { "Red" } end ComboboxItem do ComboboxRadio(name: "color", value: "green") span { "Green" } end ComboboxItem do ComboboxRadio(name: "color", value: "blue") span { "Blue" } end ComboboxItem do ComboboxRadio(name: "color", value: "yellow") span { "Yellow" } end ComboboxItem do ComboboxRadio(name: "color", value: "purple") span { "Purple" } end end end end end RUBY end render Components::ComponentSetup::Tabs.new(component_name: component) render Docs::ComponentsTable.new(component_files(component)) end end end
7
Add RubyUI::ComboboxEmptyState to app/components/ruby_ui/combobox/combobox_empty_state.rb
# frozen_string_literal: true module RubyUI class ComboboxEmptyState < Base def view_template(&) div(**attrs, &) end private def default_attrs { role: "presentation", class: "hidden py-6 text-center text-sm", data: { ruby_ui__combobox_target: "emptyState" } } end end end
8
Add RubyUI::ComboboxInputTrigger to app/components/ruby_ui/combobox/combobox_input_trigger.rb
# frozen_string_literal: true module RubyUI class ComboboxInputTrigger < Base def initialize(placeholder: "", **) @placeholder = placeholder super(**) end def view_template div(**attrs) do input( type: "text", placeholder: @placeholder, autocomplete: "off", autocorrect: "off", spellcheck: "false", class: "flex-1 border-0 px-0 bg-transparent outline-none focus:ring-0 placeholder:text-muted-foreground text-sm disabled:cursor-not-allowed", data: { ruby_ui__combobox_target: "inputTrigger", action: "keyup->ruby-ui--combobox#filterItems input->ruby-ui--combobox#filterItems" } ) chevron_icon end end private def default_attrs { class: "flex h-9 w-full items-center rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 aria-invalid:border-destructive", data: { ruby_ui__combobox_target: "trigger", placeholder: @placeholder, action: "click->ruby-ui--combobox#openPopover focusin->ruby-ui--combobox#openPopover" }, aria: { haspopup: "listbox", expanded: "false" } } end def chevron_icon span(class: "shrink-0 flex items-center justify-center size-6 rounded-sm hover:bg-muted hover:text-foreground") do svg( xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "pointer-events-none size-4 text-muted-foreground" ) do |s| s.path(d: "m6 9 6 6 6-6") end end end end end
9
Add RubyUI::ComboboxItem to app/components/ruby_ui/combobox/combobox_item.rb
# frozen_string_literal: true module RubyUI class ComboboxItem < Base def view_template(&) label(**attrs) do yield if block_given? render ComboboxItemIndicator.new end end private def default_attrs { class: "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-[current=true]:bg-accent has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed", role: "option", data: { ruby_ui__combobox_target: "item" } } end end end
10
Add RubyUI::ComboboxItemIndicator to app/components/ruby_ui/combobox/combobox_item_indicator.rb
# frozen_string_literal: true module RubyUI class ComboboxItemIndicator < Base def view_template svg( xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", **attrs ) do |s| s.path(d: "M20 6 9 17l-5-5") end end private def default_attrs { class: "ml-auto size-4 shrink-0 opacity-0 peer-checked:opacity-100" } end end end
11
Add RubyUI::ComboboxList to app/components/ruby_ui/combobox/combobox_list.rb
# frozen_string_literal: true module RubyUI class ComboboxList < Base def view_template(&) div(**attrs, &) end private def default_attrs { class: "flex flex-col gap-1 p-1 max-h-72 overflow-y-auto text-foreground", role: "listbox" } end end end
12
Add RubyUI::ComboboxListGroup to app/components/ruby_ui/combobox/combobox_list_group.rb
# frozen_string_literal: true module RubyUI class ComboboxListGroup < Base LABEL_CLASSES = "before:content-[attr(label)] before:px-2 before:py-1.5 before:text-xs before:font-medium before:text-muted-foreground before:not-italic" def view_template(&) div(**attrs, &) end private def default_attrs { class: ["hidden has-[label:not(.hidden)]:flex flex-col py-1 gap-1", LABEL_CLASSES], role: "group" } end end end
13
Add RubyUI::ComboboxPopover to app/components/ruby_ui/combobox/combobox_popover.rb
# frozen_string_literal: true module RubyUI class ComboboxPopover < Base def view_template(&) div(**attrs, &) end private def default_attrs { class: "inset-auto m-0 absolute border bg-background shadow-lg rounded-lg", role: "popover", popover: true, data: { ruby_ui__combobox_target: "popover", action: %w[ toggle->ruby-ui--combobox#handlePopoverToggle resize@window->ruby-ui--combobox#updatePopoverWidth ] } } end end end
14
Add RubyUI::ComboboxRadio to app/components/ruby_ui/combobox/combobox_radio.rb
# frozen_string_literal: true module RubyUI class ComboboxRadio < Base def view_template input(type: "radio", **attrs) end private def default_attrs { class: "peer sr-only", data: { ruby_ui__combobox_target: "input", 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 end end
15
Add RubyUI::ComboboxSearchInput to app/components/ruby_ui/combobox/combobox_search_input.rb
# frozen_string_literal: true module RubyUI class ComboboxSearchInput < Base def initialize(placeholder:, **) @placeholder = placeholder super(**) end def view_template div class: "flex text-muted-foreground items-center border-b px-3" do icon input(**attrs) end end private def default_attrs { type: "search", role: "searchbox", autocorrect: "off", autocomplete: "off", spellcheck: "false", placeholder: @placeholder, class: [ "flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none border-none", "focus:ring-0", "placeholder:text-muted-foreground", "disabled:cursor-not-allowed disabled:opacity-50", "aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none" ], data: { ruby_ui__combobox_target: "searchInput", action: "keyup->ruby-ui--combobox#filterItems search->ruby-ui--combobox#filterItems" } } end def icon svg( xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", class: "mr-2 h-4 w-4 shrink-0 opacity-50", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round" ) do |s| s.circle(cx: "11", cy: "11", r: "8") s.path( d: "m21 21-4.3-4.3" ) end end end end
16
Add RubyUI::ComboboxToggleAllCheckbox to app/components/ruby_ui/combobox/combobox_toggle_all_checkbox.rb
# frozen_string_literal: true module RubyUI class ComboboxToggleAllCheckbox < Base def view_template input(type: "checkbox", **attrs) end private def default_attrs { class: "peer sr-only disabled:cursor-not-allowed", data: { ruby_ui__combobox_target: "toggleAll", action: "change->ruby-ui--combobox#toggleAllItems" } } end end end
17
Add RubyUI::ComboboxTrigger to app/components/ruby_ui/combobox/combobox_trigger.rb
# frozen_string_literal: true module RubyUI class ComboboxTrigger < Base def initialize(placeholder: "", **) @placeholder = placeholder super(**) end def view_template button(**attrs) do span(class: "truncate text-muted-foreground", data: {ruby_ui__combobox_target: "triggerContent"}) do @placeholder end icon end end private def default_attrs { type: "button", class: [ "flex h-full w-full items-center whitespace-nowrap rounded-md text-sm ring-offset-background transition-colors border border-input bg-background h-9 px-4 py-2 justify-between", "hover:bg-accent hover:text-accent-foreground", "disabled:pointer-events-none disabled:opacity-50", "aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:cursor-not-allowed", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "aria-invalid:border-destructive" ], data: { placeholder: @placeholder, ruby_ui__combobox_target: "trigger", action: "click->ruby-ui--combobox#togglePopover focus->ruby-ui--combobox#openPopover" }, aria: { haspopup: "listbox", expanded: "false" } } end def icon span(class: "shrink-0 flex items-center justify-center size-6 rounded-sm hover:bg-muted hover:text-foreground") do svg( xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "pointer-events-none size-4 text-muted-foreground" ) do |s| s.path(d: "m6 9 6 6 6-6") end end end end end
18
Add combobox_controller.js to app/javascript/controllers/ruby_ui/combobox_controller.js
import { Controller } from "@hotwired/stimulus"; import { computePosition, autoUpdate, offset, flip } from "@floating-ui/dom"; // Connects to data-controller="ruby-ui--combobox" export default class extends Controller { static values = { term: String } static targets = [ "input", "toggleAll", "popover", "item", "emptyState", "searchInput", "trigger", "triggerContent", "badgeContainer", "clearButton", "badgeInput", "inputTrigger" ] selectedItemIndex = null connect() { this.updateTriggerContent() this.updateBadges() this.updateClearButton() this.updateInputTrigger() // Track mouse state to distinguish click-focus from tab-focus this._mouseDown = false this.element.addEventListener("mousedown", () => { this._mouseDown = true }) this.element.addEventListener("mouseup", () => { setTimeout(() => { this._mouseDown = false }, 0) }) } disconnect() { if (this.cleanup) { this.cleanup() } } // Popover togglePopover(event) { event.preventDefault() if (this.triggerTarget.ariaExpanded === "true") { this.closePopover() } else { this.openPopover(event) } } openPopover(event) { if (event && event.type !== "focusin" && event.type !== "focus") event.preventDefault() // focusin/focus: only open on keyboard focus (tab), not mouse click if (event && (event.type === "focusin" || event.type === "focus")) { if (this._mouseDown || this.triggerTarget.ariaExpanded === "true" || this._closingPopover) return } this.updatePopoverPosition() this.updatePopoverWidth() this.triggerTarget.ariaExpanded = "true" this.selectedItemIndex = null this.itemTargets.forEach(item => item.ariaCurrent = "false") this.popoverTarget.showPopover() // Always show all items on open; filter only on user typing this.applyFilter("") if (this.hasBadgeInputTarget) { this.badgeInputTarget.value = "" } } closePopover() { this._closingPopover = true this.triggerTarget.ariaExpanded = "false" this.popoverTarget.hidePopover() setTimeout(() => this._closingPopover = false, 200) } handlePopoverToggle(event) { // Keep ariaExpanded in sync with the actual popover state this.triggerTarget.ariaExpanded = event.newState === 'open' ? 'true' : 'false' } updatePopoverPosition() { this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => { computePosition(this.triggerTarget, this.popoverTarget, { placement: 'bottom-start', middleware: [offset(4), flip()], }).then(({ x, y }) => { Object.assign(this.popoverTarget.style, { left: `${x}px`, top: `${y}px`, }); }); }); } updatePopoverWidth() { this.popoverTarget.style.width = `${this.triggerTarget.offsetWidth}px` } // Selection inputChanged(e) { this.updateTriggerContent() if (e.target.type == "radio") { this.closePopover() this.updateInputTrigger() } if (this.hasToggleAllTarget && !e.target.checked) { this.toggleAllTarget.checked = false } this.updateBadges() this.updateClearButton() } toggleAllItems() { const isChecked = this.toggleAllTarget.checked this.inputTargets.forEach(input => input.checked = isChecked) this.updateTriggerContent() this.updateBadges() this.updateClearButton() } clearAll(event) { if (event) event.preventDefault() this.inputTargets.forEach(input => input.checked = false) this.updateBadges() this.updateClearButton() this.updateTriggerContent() this.updateInputTrigger() } removeBadge(event) { event.preventDefault() event.stopPropagation() const value = event.currentTarget.closest('[data-value]').dataset.value const input = this.inputTargets.find(input => input.value === value) if (input) { input.checked = false input.dispatchEvent(new Event("change", { bubbles: true })) } } // Display inputContent(input) { return input.dataset.text || input.parentElement.textContent } updateTriggerContent() { if (!this.hasTriggerContentTarget) return const checkedInputs = this.inputTargets.filter(input => input.checked) if (checkedInputs.length === 0) { this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder this.triggerContentTarget.classList.add("text-muted-foreground") } else if (this.termValue && checkedInputs.length > 1) { this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}` this.triggerContentTarget.classList.remove("text-muted-foreground") } else { this.triggerContentTarget.innerText = checkedInputs.map((input) => this.inputContent(input)).join(", ") this.triggerContentTarget.classList.remove("text-muted-foreground") } } updateInputTrigger() { if (!this.hasInputTriggerTarget) return const checked = this.inputTargets.find(i => i.checked) this.inputTriggerTarget.value = checked ? this.inputContent(checked) : "" } // NOTE: badge classes mirror ComboboxBadge Ruby component. Update both if styles change. updateBadges() { if (!this.hasBadgeContainerTarget) return // Remove existing badges this.triggerTarget.querySelectorAll("[data-combobox-badge]").forEach(el => el.remove()) const checkedInputs = this.inputTargets.filter(input => input.checked) // Toggle trigger height: h-9 when empty, h-auto min-h-9 when badges exist if (checkedInputs.length > 0) { this.triggerTarget.classList.remove("h-9") this.triggerTarget.classList.add("h-auto", "min-h-9") } else { this.triggerTarget.classList.remove("h-auto", "min-h-9", "pt-1.5") this.triggerTarget.classList.add("h-9") } checkedInputs.forEach(input => { const badge = document.createElement("span") badge.setAttribute("data-combobox-badge", "") badge.className = "inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground" badge.dataset.value = input.value badge.appendChild(document.createTextNode(this.inputContent(input).trim())) const btn = document.createElement("button") btn.type = "button" btn.setAttribute("aria-label", "Remove") btn.className = "rounded-sm opacity-50 hover:opacity-100 focus-visible:outline-none" btn.addEventListener("click", (e) => { e.preventDefault() e.stopPropagation() e.stopImmediatePropagation() const target = this.inputTargets.find(i => i.value === input.value) if (target) { target.checked = false this.updateBadges() this.updateClearButton() } }) const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg") svg.setAttribute("xmlns", "http://www.w3.org/2000/svg") svg.setAttribute("width", "12") svg.setAttribute("height", "12") svg.setAttribute("viewBox", "0 0 24 24") svg.setAttribute("fill", "none") svg.setAttribute("stroke", "currentColor") svg.setAttribute("stroke-width", "2") svg.setAttribute("stroke-linecap", "round") svg.setAttribute("stroke-linejoin", "round") svg.classList.add("pointer-events-none") const path1 = document.createElementNS("http://www.w3.org/2000/svg", "path") path1.setAttribute("d", "M18 6 6 18") const path2 = document.createElementNS("http://www.w3.org/2000/svg", "path") path2.setAttribute("d", "m6 6 12 12") svg.appendChild(path1) svg.appendChild(path2) btn.appendChild(svg) badge.appendChild(btn) // Insert badge directly in trigger, before the text input this.badgeInputTarget.insertAdjacentElement("beforebegin", badge) }) // Add top padding only when badges wrap to multiple lines // Class "pt-1.5" is referenced in ComboboxBadgeTrigger for Tailwind to compile it const badges = this.triggerTarget.querySelectorAll("[data-combobox-badge]") if (badges.length > 0 && this.badgeInputTarget.offsetTop > badges[0].offsetTop) { this.triggerTarget.classList.add("pt-1.5") } else { this.triggerTarget.classList.remove("pt-1.5") } } updateClearButton() { if (!this.hasClearButtonTarget) return const hasChecked = this.inputTargets.some(input => input.checked) this.clearButtonTarget.classList.toggle("hidden", !hasChecked) } // Filter filterItems(e) { if (["ArrowDown", "ArrowUp", "Tab", "Enter"].includes(e.key)) return const term = this.hasBadgeInputTarget ? this.badgeInputTarget.value : this.hasInputTriggerTarget ? this.inputTriggerTarget.value : this.searchInputTarget.value this.applyFilter(term) } applyFilter(term) { const filterTerm = term.toLowerCase() if (this.hasToggleAllTarget) { if (filterTerm) this.toggleAllTarget.parentElement.classList.add("hidden") else this.toggleAllTarget.parentElement.classList.remove("hidden") } let resultCount = 0 this.selectedItemIndex = null this.inputTargets.forEach((input) => { const text = this.inputContent(input).toLowerCase() if (text.indexOf(filterTerm) > -1) { input.parentElement.classList.remove("hidden") resultCount++ } else { input.parentElement.classList.add("hidden") } }) this.emptyStateTarget.classList.toggle("hidden", resultCount !== 0) // Auto-highlight first visible result (without scrolling to avoid page jump) this.itemTargets.forEach(item => item.ariaCurrent = "false") const firstVisible = this.inputTargets.find(i => !i.parentElement.classList.contains("hidden")) if (firstVisible) { this.selectedItemIndex = 0 firstVisible.parentElement.ariaCurrent = "true" } } // Keyboard keyDownPressed(event) { event.preventDefault() if (this.selectedItemIndex !== null) { this.selectedItemIndex++ } else { this.selectedItemIndex = 0 } this.focusSelectedInput() } keyUpPressed(event) { event.preventDefault() if (this.selectedItemIndex !== null) { this.selectedItemIndex-- } else { this.selectedItemIndex = -1 } this.focusSelectedInput() } keyEnterPressed(event) { event.preventDefault() const option = this.itemTargets.find(item => item.ariaCurrent === "true") if (option) { option.click() } } focusSelectedInput() { const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains("hidden")) this.wrapSelectedInputIndex(visibleInputs.length) visibleInputs.forEach((input, index) => { if (index == this.selectedItemIndex) { input.parentElement.ariaCurrent = "true" input.parentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) } else { input.parentElement.ariaCurrent = "false" } }) } wrapSelectedInputIndex(length) { this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length } handleBadgeInputBackspace(event) { if (this.badgeInputTarget.value !== "") return const checkedInputs = this.inputTargets.filter(input => input.checked) const lastChecked = checkedInputs[checkedInputs.length - 1] if (lastChecked) { lastChecked.checked = false lastChecked.dispatchEvent(new Event("change", { bubbles: true })) } } }
19
Update the Stimulus controllers manifest file
Importmap!
rake stimulus:manifest:update
20
Install @floating-ui/dom Javascript dependency
// with yarn yarn add @floating-ui/dom // with npm npm install @floating-ui/dom // with importmaps bin/importmap pin @floating-ui/dom
Components
| Component | Built using | Source |
|---|---|---|
Combobox | Phlex | |
ComboboxBadge | Phlex | |
ComboboxBadgeTrigger | Phlex | |
ComboboxCheckbox | Phlex | |
ComboboxClearButton | Phlex | |
ComboboxDocs | Phlex | |
ComboboxEmptyState | Phlex | |
ComboboxInputTrigger | Phlex | |
ComboboxItem | Phlex | |
ComboboxItemIndicator | Phlex | |
ComboboxList | Phlex | |
ComboboxListGroup | Phlex | |
ComboboxPopover | Phlex | |
ComboboxRadio | Phlex | |
ComboboxSearchInput | Phlex | |
ComboboxToggleAllCheckbox | Phlex | |
ComboboxTrigger | Phlex | |
ComboboxController | Stimulus JS |