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" } 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
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.rb
# frozen_string_literal: true module RubyUI class Combobox < Base def initialize(term: "items", **) @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.to_s } } end end end
2
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 h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background accent-primary", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-50" ], data: { ruby_ui__combobox_target: "input", action: "ruby-ui--combobox#inputChanged" } } end end end
3
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
4
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, &) end private def default_attrs { class: [ "flex flex-row w-full text-wrap truncate gap-2 items-center rounded-sm px-2 py-1.5 text-sm outline-none cursor-pointer", "select-none has-[:checked]:bg-accent hover:bg-accent p-2", "[&>svg]:pointer-events-none [&>svg]:size-4 [&>svg]:shrink-0 aria-[current=true]:bg-accent aria-[current=true]:ring aria-[current=true]:ring-offset-2" ], role: "option", data: { ruby_ui__combobox_target: "item" } } end end end
5
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
6
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 border-b", LABEL_CLASSES], role: "group" } end end end
7
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", autofocus: true, popover: true, data: { ruby_ui__combobox_target: "popover", action: %w[ keydown.down->ruby-ui--combobox#keyDownPressed keydown.up->ruby-ui--combobox#keyUpPressed keydown.enter->ruby-ui--combobox#keyEnterPressed keydown.esc->ruby-ui--combobox#closeDialog:prevent resize@window->ruby-ui--combobox#updatePopoverWidth ] } } end end end
8
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: "aspect-square h-4 w-4 rounded-full border border-primary accent-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", 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
9
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", class: "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none border-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", role: "searchbox", placeholder: @placeholder, data: { ruby_ui__combobox_target: "searchInput", action: "keyup->ruby-ui--combobox#filterItems search->ruby-ui--combobox#filterItems" }, autocomplete: "off", autocorrect: "off", spellcheck: "false" } 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
10
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", 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 justify-between", data: { placeholder: @placeholder, ruby_ui__combobox_target: "trigger", action: "ruby-ui--combobox#openPopover" }, aria: { haspopup: "listbox", expanded: "false" } } end def icon svg( xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", class: "ml-2 h-4 w-4 shrink-0 opacity-50", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round" ) do |s| s.path( d: "m7 15 5 5 5-5" ) s.path( d: "m7 9 5-5 5 5" ) end end end end
11
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", "popover", "item", "emptyState", "searchInput", "trigger", "triggerContent" ] selectedItemIndex = null connect() { this.updateTriggerContent() } disconnect() { this.cleanup(); } inputChanged(e) { this.updateTriggerContent() if (e.target.type == "radio") { this.closePopover() } } inputContent(input) { return input.dataset.text || input.parentElement.innerText } updateTriggerContent() { const checkedInputs = this.inputTargets.filter(input => input.checked) if (checkedInputs.length == 0) { this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder } else if (checkedInputs.length === 1) { this.triggerContentTarget.innerText = this.inputContent(checkedInputs[0]) } else { this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}` } } openPopover(event) { event.preventDefault() this.updatePopoverPosition() this.updatePopoverWidth() this.triggerTarget.ariaExpanded = "true" this.selectedItemIndex = null this.itemTargets.forEach(item => item.ariaCurrent = "false") this.popoverTarget.showPopover() } closePopover() { this.triggerTarget.ariaExpanded = "false" this.popoverTarget.hidePopover() } filterItems(e) { if (["ArrowDown", "ArrowUp", "Tab", "Enter"].includes(e.key)) { return } const filterTerm = this.searchInputTarget.value.toLowerCase() 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) } keyDownPressed() { if (this.selectedItemIndex !== null) { this.selectedItemIndex++ } else { this.selectedItemIndex = 0 } this.focusSelectedInput() } keyUpPressed() { if (this.selectedItemIndex !== null) { this.selectedItemIndex-- } else { this.selectedItemIndex = -1 } this.focusSelectedInput() } 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" } }) } keyEnterPressed(event) { event.preventDefault() const option = this.itemTargets.find(item => item.ariaCurrent === "true") if (option) { option.click() } } wrapSelectedInputIndex(length) { this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length } 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` } }
12
Update the Stimulus controllers manifest file
Importmap!
rake stimulus:manifest:update
13
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 | |
ComboboxCheckbox | Phlex | |
ComboboxEmptyState | Phlex | |
ComboboxItem | Phlex | |
ComboboxList | Phlex | |
ComboboxListGroup | Phlex | |
ComboboxPopover | Phlex | |
ComboboxRadio | Phlex | |
ComboboxSearchInput | Phlex | |
ComboboxTrigger | Phlex | |
ComboboxController | Stimulus JS |