Select
Displays a list of options for the user to pick from—triggered by a button.
Usage
Select (deconstructed)
Fruits
div(class: 'w-56 flex items-center justify-center') do Select do SelectInput(value: "apple", id: "select-a-fruit") SelectTrigger do SelectValue(placeholder: 'Select a fruit', id: "select-a-fruit") { "Apple" } end SelectContent(outlet_id: "select-a-fruit") do SelectGroup do SelectLabel { "Fruits" } SelectItem(value: "apple") { "Apple" } SelectItem(value: "orange") { "Orange" } SelectItem(value: "banana") { "Banana" } SelectItem(value: "watermelon") { "Watermelon" } end end end end
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component Select
Manual installation
1
Add RubyUI::Select
to app/components/ruby_ui/select.rb
# frozen_string_literal: true module RubyUI class Select < Base def view_template(&) div(**attrs, &) end private def default_attrs { data: { controller: "ruby-ui--select", ruby_ui__select_open_value: "false", action: "click@window->ruby-ui--select#clickOutside", ruby_ui__select_ruby_ui__select_item_outlet: ".item" }, class: "group/select w-full relative" } end end end
2
Add RubyUI::SelectContent
to app/components/ruby_ui/select/select_content.rb
# frozen_string_literal: true module RubyUI class SelectContent < Base def initialize(**attrs) @id = "content#{SecureRandom.hex(4)}" super end def view_template(&block) div(**attrs) do div( class: "max-h-96 min-w-max overflow-auto rounded-md border bg-background p-1 text-foreground shadow-md animate-out group-data-[ruby-ui--select-open-value=true]/select:animate-in fade-out-0 group-data-[ruby-ui--select-open-value=true]/select:fade-in-0 zoom-out-95 group-data-[ruby-ui--select-open-value=true]/select:zoom-in-95 slide-in-from-top-2", &block ) end end private def default_attrs { id: @id, role: "listbox", tabindex: "-1", data: { ruby_ui__select_target: "content" }, class: "hidden w-full absolute top-0 left-0 z-50" } end end end
3
Add RubyUI::SelectGroup
to app/components/ruby_ui/select/select_group.rb
# frozen_string_literal: true module RubyUI class SelectGroup < Base def view_template(&) div(**attrs, &) end private def default_attrs {} end end end
4
Add RubyUI::SelectInput
to app/components/ruby_ui/select/select_input.rb
# frozen_string_literal: true module RubyUI class SelectInput < Base def view_template input(**attrs) end private def default_attrs { class: "hidden", data: { ruby_ui__select_target: "input", ruby_ui__form_field_target: "input", action: "change->ruby-ui--form-field#onChange invalid->ruby-ui--form-field#onInvalid" } } end end end
5
Add RubyUI::SelectItem
to app/components/ruby_ui/select/select_item.rb
# frozen_string_literal: true module RubyUI class SelectItem < Base def initialize(value: nil, **attrs) @value = value super(**attrs) end def view_template(&block) div(**attrs) do selected_icon block&.call end end private def selected_icon svg( xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", class: "invisible group-aria-selected:visible mr-2 h-4 w-4 flex-none", 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: "item group relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", data: { controller: "ruby-ui--select-item", action: "click->ruby-ui--select#selectItem keydown.enter->ruby-ui--select#selectItem keydown.down->ruby-ui--select#handleKeyDown keydown.up->ruby-ui--select#handleKeyUp keydown.esc->ruby-ui--select#handleEsc", ruby_ui__select_target: "item" }, data_value: @value, data_orientation: "vertical", aria_selected: "false" } end end end
6
Add RubyUI::SelectLabel
to app/components/ruby_ui/select/select_label.rb
# frozen_string_literal: true module RubyUI class SelectLabel < Base def view_template(&) h3(**attrs, &) end private def default_attrs { class: "px-2 py-1.5 text-sm font-semibold" } end end end
7
Add RubyUI::SelectTrigger
to app/components/ruby_ui/select/select_trigger.rb
# frozen_string_literal: true module RubyUI class SelectTrigger < 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--select#onClick", ruby_ui__select_target: "trigger" }, type: "button", role: "combobox", aria: { controls: "radix-:r0:", expanded: "false", autocomplete: "none", haspopup: "listbox", activedescendant: true }, class: "truncate w-full flex h-9 items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50" } end end end
8
Add RubyUI::SelectValue
to app/components/ruby_ui/select/select_value.rb
# frozen_string_literal: true module RubyUI class SelectValue < 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__select_target: "value" }, class: "pointer-events-none" } end end end
9
Add select_controller.js
to app/javascript/controllers/ruby_ui/select_controller.js
import { Controller } from "@hotwired/stimulus"; import { computePosition, autoUpdate, offset } from "@floating-ui/dom"; export default class extends Controller { static targets = ["trigger", "content", "input", "value", "item"]; static values = { open: Boolean }; static outlets = ["ruby-ui--select-item"]; constructor(...args) { super(...args); this.cleanup; } connect() { this.setFloatingElement(); this.generateItemsIds(); } disconnect() { this.cleanup(); } selectItem(event) { event.preventDefault(); this.rubyUiSelectItemOutlets.forEach((item) => item.handleSelectItem(event), ); const oldValue = this.inputTarget.value; const newValue = event.target.dataset.value; this.inputTarget.value = newValue; this.valueTarget.innerText = event.target.innerText; this.dispatchOnChange(oldValue, newValue); this.closeContent(); } onClick() { this.toogleContent(); if (this.openValue) { this.setFocusAndCurrent(); } else { this.resetCurrent(); } } handleKeyDown(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"); this.setAriaCurrentAndActiveDescendant(currentIndex + 1); } } handleKeyUp(event) { event.preventDefault(); const currentIndex = this.itemTargets.findIndex( (item) => item.getAttribute("aria-current") === "true", ); if (currentIndex > 0) { this.itemTargets[currentIndex].removeAttribute("aria-current"); this.setAriaCurrentAndActiveDescendant(currentIndex - 1); } } handleEsc(event) { event.preventDefault(); this.closeContent(); } setFocusAndCurrent() { const selectedItem = this.itemTargets.find( (item) => item.getAttribute("aria-selected") === "true", ); if (selectedItem) { selectedItem.focus({ preventScroll: true }); selectedItem.setAttribute("aria-current", "true"); this.triggerTarget.setAttribute( "aria-activedescendant", selectedItem.getAttribute("id"), ); } else { this.itemTarget.focus({ preventScroll: true }); this.itemTarget.setAttribute("aria-current", "true"); this.triggerTarget.setAttribute( "aria-activedescendant", this.itemTarget.getAttribute("id"), ); } } resetCurrent() { this.itemTargets.forEach((item) => item.removeAttribute("aria-current")); } clickOutside(event) { if (!this.openValue) return; if (this.element.contains(event.target)) return; event.preventDefault(); this.toogleContent(); } toogleContent() { this.openValue = !this.openValue; this.contentTarget.classList.toggle("hidden"); this.triggerTarget.setAttribute("aria-expanded", this.openValue); } 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`, }); }); }); } generateItemsIds() { const contentId = this.contentTarget.getAttribute("id"); this.triggerTarget.setAttribute("aria-controls", contentId); this.itemTargets.forEach((item, index) => { item.id = `${contentId}-${index}`; }); } setAriaCurrentAndActiveDescendant(currentIndex) { const currentItem = this.itemTargets[currentIndex]; currentItem.focus({ preventScroll: true }); currentItem.setAttribute("aria-current", "true"); this.triggerTarget.setAttribute( "aria-activedescendant", currentItem.getAttribute("id"), ); } closeContent() { this.toogleContent(); this.resetCurrent(); this.triggerTarget.setAttribute("aria-activedescendant", true); this.triggerTarget.focus({ preventScroll: true }); } dispatchOnChange(oldValue, newValue) { if (oldValue === newValue) return; const event = new InputEvent("change", { bubbles: true, cancelable: true, }); this.inputTarget.dispatchEvent(event); } }
10
Add select_item_controller.js
to app/javascript/controllers/ruby_ui/select_item_controller.js
import { Controller } from "@hotwired/stimulus"; export default class extends Controller { handleSelectItem({ target }) { if (this.element.dataset.value == target.dataset.value) { this.element.setAttribute("aria-selected", true); } else { this.element.removeAttribute("aria-selected"); } } }
11
Update the Stimulus controllers manifest file
Importmap!
rake stimulus:manifest:update
12
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 |
---|---|---|
Select | Phlex | |
SelectContent | Phlex | |
SelectGroup | Phlex | |
SelectInput | Phlex | |
SelectItem | Phlex | |
SelectLabel | Phlex | |
SelectTrigger | Phlex | |
SelectValue | Phlex | |
SelectController | Stimulus JS | |
SelectItemController | Stimulus JS |