Combobox
Autocomplete input and command palette with a list of suggestions.
Usage
Example
div(class: "w-96") do Combobox do ComboboxInput() ComboboxTrigger do ComboboxValue(placeholder: "Select event...") end ComboboxContent do ComboboxSearchInput(placeholder: "Search event...") ComboboxList do ComboboxEmpty { "No results found." } ComboboxGroup(heading: "Suggestions") do ComboboxItem(value: "railsworld") do span { "Rails World" } end ComboboxItem(value: "tropicalrb") do span { "Tropical.rb" } end ComboboxItem(value: "friendly.rb") do span { "Friendly.rb" } end end ComboboxSeparator() ComboboxGroup(heading: "Others") do ComboboxItem(value: "railsconf") do span { "RailsConf" } end ComboboxItem(value: "euruko") do span { "Euruko" } end ComboboxItem(value: "rubykaigi") do span { "RubyKaigi" } 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 view_template(&) div(**attrs, &) end private def default_attrs { data: { controller: "ruby-ui--combobox", ruby_ui__combobox_open_value: "false", action: "click@window->ruby-ui--combobox#onClickOutside", ruby_ui__combobox_ruby_ui__combobox_content_outlet: ".combobox-content", ruby_ui__combobox_ruby_ui__combobox_item_outlet: ".combobox-item" }, class: "group/combobox w-full relative" } end end end
2
Add RubyUI::ComboboxContent
to app/components/ruby_ui/combobox/combobox_content.rb
# frozen_string_literal: true module RubyUI class ComboboxContent < Base def initialize(**attrs) @id = "content#{SecureRandom.hex(4)}" super end def view_template(&) div(**attrs) do div(class: "min-w-max max-h-[300px] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-out group-data-[ruby-ui--combobox-open-value=true]/combobox:animate-in fade-out-0 group-data-[ruby-ui--combobox-open-value=true]/combobox:fade-in-0 zoom-out-95 group-data-[ruby-ui--combobox-open-value=true]/combobox:zoom-in-95 slide-in-from-top-2", &) end end private def default_attrs { id: @id, role: "listbox", data: { controller: "ruby-ui--combobox-content", ruby_ui__combobox_target: "content", action: "keydown.enter->ruby-ui--combobox#onKeyEnter keydown.esc->ruby-ui--combobox#onEscKey keydown.down->ruby-ui--combobox#onKeyDown keydown.up->ruby-ui--combobox#onKeyUp" }, class: "combobox-content hidden w-full absolute top-0 left-0 z-50" } end end end
3
Add RubyUI::ComboboxEmpty
to app/components/ruby_ui/combobox/combobox_empty.rb
# frozen_string_literal: true module RubyUI class ComboboxEmpty < 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_content_target: "empty" } } end end end
4
Add RubyUI::ComboboxGroup
to app/components/ruby_ui/combobox/combobox_group.rb
# frozen_string_literal: true module RubyUI class ComboboxGroup < Base def initialize(heading: nil, **attrs) @heading = heading super(**attrs) end def view_template(&block) div(**attrs) do render_header if @heading render_items(&block) end end private def render_header div(group_heading: @heading, class: "px-2 py-1.5 text-xs font-medium text-muted-foreground") { @heading } end def render_items(&) div(group_items: "", role: "group", &) end def default_attrs { class: "overflow-hidden p-1 text-foreground", role: "presentation", data: { value: @heading, ruby_ui__combobox_content_target: "group" } } end end end
5
Add RubyUI::ComboboxInput
to app/components/ruby_ui/combobox/combobox_input.rb
# frozen_string_literal: true module RubyUI class ComboboxInput < Base def view_template input(**attrs) end private def default_attrs { class: "hidden", data: { ruby_ui__combobox_target: "input", ruby_ui__form_field_target: "input", action: "change->ruby-ui--form-field#onChange invalid->ruby-ui--form-field#onInvalid" } } end end end
6
Add RubyUI::ComboboxItem
to app/components/ruby_ui/combobox/combobox_item.rb
# frozen_string_literal: true module RubyUI class ComboboxItem < Base def initialize(value: nil, **attrs) @value = value super(**attrs) end def view_template(&block) div(**attrs) do div(class: "invisible group-aria-selected:visible") { icon } block.call end end private 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", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round" ) do |s| s.path( d: "M20 6 9 17l-5-5" ) end end def default_attrs { role: "option", tabindex: "0", class: "combobox-item group relative flex cursor-pointer select-none items-center gap-x-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-[current]:bg-accent aria-[current]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", data: { value: @value, ruby_ui__combobox_target: "item", ruby_ui__combobox_content_target: "item", controller: "ruby-ui--combobox-item", action: "click->ruby-ui--combobox#onItemSelected" }, aria_selected: "false" } end end end
7
Add RubyUI::ComboboxList
to app/components/ruby_ui/combobox/combobox_list.rb
# frozen_string_literal: true module RubyUI class ComboboxList < Base def initialize(**attrs) @id = "list#{SecureRandom.hex(4)}" super end def view_template(&) div(**attrs, &) end private def default_attrs { id: @id, data: { ruby_ui__combobox_target: "list" }, role: "listbox", tabindex: "-1" } end end end
8
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:, **attrs) @placeholder = placeholder super(**attrs) end def view_template input_container do search_icon input(**attrs) end end private def search_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 def input_container(&) div(class: "flex items-center border-b px-3", &) end def default_attrs { class: "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", placeholder: @placeholder, data: { action: "input->ruby-ui--combobox#onSearchInput", ruby_ui__combobox_target: "search" }, autocomplete: "off", autocorrect: "off", spellcheck: false } end end end
9
Add RubyUI::ComboboxSeparator
to app/components/ruby_ui/combobox/combobox_separator.rb
# frozen_string_literal: true module RubyUI class ComboboxSeparator < Base def view_template(&) div(**attrs, &) end private def default_attrs {class: "-mx-1 h-px bg-border"} 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 view_template(&block) button(**attrs) do block&.call icon end end private 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 def default_attrs { data: { action: "ruby-ui--combobox#onTriggerClick", ruby_ui__combobox_target: "trigger" }, type: "button", role: "combobox", aria: { expanded: "false", haspopup: "listbox", autocomplete: "none", activedescendant: true }, 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 w-[200px] justify-between" } end end end
11
Add RubyUI::ComboboxValue
to app/components/ruby_ui/combobox/combobox_value.rb
# frozen_string_literal: true module RubyUI class ComboboxValue < Base def initialize(placeholder: nil, **attrs) @placeholder = placeholder super(**attrs) end def view_template(&block) span(**attrs) do block ? block.call : @placeholder end end private def default_attrs { data: { ruby_ui__combobox_target: "value" }, class: "pointer-events-none" } end end end
12
Add combobox_content_controller.js
to app/javascript/controllers/ruby_ui/combobox_content_controller.js
import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static targets = ["item", "empty", "group"]; handleSearchInput(value) { const query = this.#sanitizeStr(value); this.#toggleVisibility(this.itemTargets, false); const visibleItems = this.#filterItems(query); this.#toggleVisibility(visibleItems, true); this.#toggleVisibility(this.emptyTargets, visibleItems.length === 0); this.#updateGroupVisibility(); } #updateGroupVisibility() { this.groupTargets.forEach((group) => { const hasVisibleItems = group.querySelectorAll( "[data-ruby-ui--combobox-content-target='item']:not(.hidden)", ).length > 0; this.#toggleVisibility([group], hasVisibleItems); }); } #filterItems(query) { return this.itemTargets.filter((item) => this.#sanitizeStr(item.innerText).includes(query), ); } #toggleVisibility(elements, isVisible) { elements.forEach((el) => el.classList.toggle("hidden", !isVisible)); } #sanitizeStr(str) { return str.toLowerCase().trim(); } }
13
Add combobox_controller.js
to app/javascript/controllers/ruby_ui/combobox_controller.js
import { Controller } from "@hotwired/stimulus"; import { computePosition, autoUpdate, offset } from "@floating-ui/dom"; export const POPOVER_OPENED = "ruby-ui--combobox#popoverOpened"; export default class extends Controller { static targets = [ "input", "trigger", "value", "content", "search", "list", "item", ]; static values = { open: Boolean }; static outlets = ["ruby-ui--combobox-item", "ruby-ui--combobox-content"]; constructor(...args) { super(...args); this.cleanup; } connect() { this.#setFloatingElement(); this.#generateItemsIds(); } disconnect() { this.cleanup(); } onTriggerClick(event) { event.preventDefault(); if (this.openValue) { this.#closeContent(); } else { this.#openContent(); } } onItemSelected(event) { event.preventDefault(); this.#setValueDispatchEventAndCloseContent(event.target); } onKeyEnter(event) { event.preventDefault(); const currentItem = this.itemTargets.find( (item) => item.getAttribute("aria-current") === "true", ); if (!currentItem) this.#closeContent(); this.#setValueDispatchEventAndCloseContent(currentItem); } onSearchInput(event) { this.rubyUiComboboxContentOutlet.handleSearchInput(event.target.value); this.#findAndSetCurrentAndActiveDescendant(); } onClickOutside(event) { if (!this.openValue) return; if (this.element.contains(event.target)) return; event.preventDefault(); this.#closeContent(); } onEscKey(event) { event.preventDefault(); this.#closeContent(); } onKeyDown(event) { event.preventDefault(); const currentIndex = this.itemTargets.findIndex( (item) => item.getAttribute("aria-current") === "true", ); if (currentIndex + 1 < this.itemTargets.length) { this.itemTargets[currentIndex].removeAttribute("aria-current"); const currentItem = this.itemTargets[currentIndex + 1]; this.#setCurrentAndActiveDescendant(currentItem); } } onKeyUp(event) { event.preventDefault(); const currentIndex = this.itemTargets.findIndex( (item) => item.getAttribute("aria-current") === "true", ); if (currentIndex > 0) { this.itemTargets[currentIndex].removeAttribute("aria-current"); const currentItem = this.itemTargets[currentIndex - 1]; this.#setCurrentAndActiveDescendant(currentItem); } } #closeContent() { this.openValue = false; this.contentTarget.classList.add("hidden"); this.triggerTarget.setAttribute("aria-expanded", false); this.triggerTarget.setAttribute("aria-activedescendant", true); this.itemTargets.forEach((item) => item.removeAttribute("aria-current")); this.triggerTarget.focus({ preventScroll: true }); } #openContent() { this.openValue = true; this.contentTarget.classList.remove("hidden"); this.triggerTarget.setAttribute("aria-expanded", true); this.#findAndSetCurrentAndActiveDescendant(); this.searchTarget.focus({ preventScroll: true }); } #findAndSetCurrentAndActiveDescendant() { const selectedItem = this.itemTargets.find( (item) => item.getAttribute("aria-selected") === "true", ); if (selectedItem) { this.#setCurrentAndActiveDescendant(selectedItem); return; } const selectedVisible = this.itemTargets.find( (item) => !item.classList.contains("hidden"), ); this.#setCurrentAndActiveDescendant(selectedVisible); } #setCurrentAndActiveDescendant(item) { if (!item) return; item.setAttribute("aria-current", "true"); this.triggerTarget.setAttribute( "aria-activedescendant", item.getAttribute("id"), ); } #setValueDispatchEventAndCloseContent(item) { const oldValue = this.inputTarget.value; const newValue = item.dataset.value; this.rubyUiComboboxItemOutlets.forEach((item) => item.handleItemSelected(newValue), ); this.inputTarget.value = item.dataset.value; this.valueTarget.innerText = item.innerText; this.#dispatchOnChange(oldValue, newValue); this.#closeContent(); } #dispatchOnChange(oldValue, newValue) { if (oldValue === newValue) return; const event = new InputEvent("change", { bubbles: true, cancelable: true, }); this.inputTarget.dispatchEvent(event); } #generateItemsIds() { const listId = this.listTarget.getAttribute("id"); this.triggerTarget.setAttribute("aria-controls", listId); this.itemTargets.forEach((item, index) => { item.id = `${listId}-${index}`; }); } #setFloatingElement() { this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => { computePosition(this.triggerTarget, this.contentTarget, { middleware: [offset(4)], }).then(({ x, y }) => { Object.assign(this.contentTarget.style, { left: `${x}px`, top: `${y}px`, }); }); }); } }
14
Add combobox_item_controller.js
to app/javascript/controllers/ruby_ui/combobox_item_controller.js
import { Controller } from "@hotwired/stimulus"; export default class extends Controller { handleItemSelected(value) { if (this.element.dataset.value == value) { this.element.setAttribute("aria-selected", true); } else { this.element.removeAttribute("aria-selected"); } } }
15
Update the Stimulus controllers manifest file
Importmap!
rake stimulus:manifest:update
16
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 | |
ComboboxContent | Phlex | |
ComboboxEmpty | Phlex | |
ComboboxGroup | Phlex | |
ComboboxInput | Phlex | |
ComboboxItem | Phlex | |
ComboboxList | Phlex | |
ComboboxSearchInput | Phlex | |
ComboboxSeparator | Phlex | |
ComboboxTrigger | Phlex | |
ComboboxValue | Phlex | |
ComboboxContentController | Stimulus JS | |
ComboboxController | Stimulus JS | |
ComboboxItemController | Stimulus JS |