Select
Displays a list of options for the user to pick from—triggered by a button.
Usage
Basic
Select do
SelectInput(name: "fruit")
SelectTrigger(class: "w-56") do
SelectValue(placeholder: "Select a fruit")
end
SelectContent do
SelectGroup do
SelectLabel { "Fruits" }
SelectItem(value: "apple") { "Apple" }
SelectItem(value: "banana") { "Banana" }
SelectItem(value: "blueberry") { "Blueberry" }
SelectItem(value: "grapes") { "Grapes" }
SelectItem(value: "pineapple") { "Pineapple" }
end
end
end
Pre-selected item
Select do
SelectInput(name: "preselected_fruit", value: "banana")
SelectTrigger(class: "w-56") do
SelectValue(placeholder: "Select a fruit") { "Banana" }
end
SelectContent do
SelectGroup do
SelectLabel { "Fruits" }
SelectItem(value: "apple") { "Apple" }
SelectItem(value: "banana") { "Banana" }
SelectItem(value: "blueberry") { "Blueberry" }
end
end
end
Sizes
div(class: "flex flex-wrap items-center gap-2") do
[:default, :sm].each do |size|
Select do
SelectInput()
SelectTrigger(size: size, class: "w-40") do
SelectValue(placeholder: size == :sm ? "Small" : "Default")
end
SelectContent do
SelectGroup do
SelectItem(value: "apple") { "Apple" }
SelectItem(value: "banana") { "Banana" }
end
end
end
end
end
Placement
Select do
SelectInput(name: "placement_fruit")
SelectTrigger(class: "w-56") do
SelectValue(placeholder: "Opens above, aligned to the start")
end
SelectContent(side: :top, align: :start, side_offset: 8) do
SelectGroup do
SelectItem(value: "apple") { "Apple" }
SelectItem(value: "banana") { "Banana" }
SelectItem(value: "blueberry") { "Blueberry" }
end
end
end
Disabled
Select do
SelectInput(name: "disabled_fruit", value: "apple")
SelectTrigger(disabled: true, class: "w-56") do
SelectValue(placeholder: "Select a fruit") { "Apple" }
end
SelectContent do
SelectGroup do
SelectItem(value: "apple") { "Apple" }
end
end
end
Disabled items
Select do
SelectInput(name: "disabled_items_fruit")
SelectTrigger(class: "w-56") do
SelectValue(placeholder: "Select a fruit")
end
SelectContent do
SelectGroup do
SelectLabel { "Fruits" }
SelectItem(value: "apple", disabled: true) { "Apple" }
SelectItem(value: "banana") { "Banana" }
SelectItem(value: "blueberry") { "Blueberry" }
SelectItem(value: "grapes", disabled: true) { "Grapes" }
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/select.rb
# frozen_string_literal: true
module RubyUI
# Root of a select. Like Base UI's Select.Root it has no look of its own: it
# holds the Stimulus controller, and SelectInput inside it carries the value.
class Select < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {
slot: "select",
controller: "ruby-ui--select"
}
}
end
end
end
2
Add RubyUI::SelectContent to app/components/ruby_ui/select/select_content.rb
# frozen_string_literal: true
require "securerandom"
module RubyUI
# The list. A native popover (top layer) with role=listbox, positioned next
# to the trigger by Floating UI; side:/align: pick the placement and
# side_offset:/align_offset: the gap, as in shadcn's SelectContent. Like
# shadcn's default (alignItemWithTrigger), it doesn't animate.
class SelectContent < Base
SIDES = %i[top right bottom left].freeze
ALIGNS = %i[start center end].freeze
# outlet_id: is accepted for backwards compatibility and ignored.
def initialize(side: :bottom, side_offset: 4, align: :center, align_offset: 0, outlet_id: nil, **)
@side = SIDES.include?(side.to_sym) ? side.to_sym : :bottom
@side_offset = side_offset
@align = ALIGNS.include?(align.to_sym) ? align.to_sym : :center
@align_offset = align_offset
super(**)
end
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
id: "select-content-#{SecureRandom.hex(4)}",
role: "listbox",
tabindex: "-1",
popover: "manual",
data: {
slot: "select-content",
ruby_ui__select_target: "content",
action: "keydown->ruby-ui--select#listKeydown",
closed: "",
side: @side,
align: @align,
preferred_side: @side,
side_offset: @side_offset,
align_offset: @align_offset
},
class: [
"fixed inset-auto m-0 border-0 p-0 outline-none",
"isolate z-50 min-w-36 w-(--anchor-width) max-h-(--available-height) origin-(--transform-origin)",
"overflow-x-hidden overflow-y-auto rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10"
]
}
end
end
end
3
Add RubyUI::SelectDocs to app/components/ruby_ui/select/select_docs.rb
# frozen_string_literal: true
class Views::Docs::Select < Views::Base
def view_template
component = "Select"
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
render Docs::Header.new(title: "Select", description: "Displays a list of options for the user to pick from—triggered by a button.")
Heading(level: 2) { "Usage" }
render Docs::VisualCodeExample.new(title: "Basic", context: self) do
<<~RUBY
Select do
SelectInput(name: "fruit")
SelectTrigger(class: "w-56") do
SelectValue(placeholder: "Select a fruit")
end
SelectContent do
SelectGroup do
SelectLabel { "Fruits" }
SelectItem(value: "apple") { "Apple" }
SelectItem(value: "banana") { "Banana" }
SelectItem(value: "blueberry") { "Blueberry" }
SelectItem(value: "grapes") { "Grapes" }
SelectItem(value: "pineapple") { "Pineapple" }
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Pre-selected Item", context: self) do
<<~RUBY
Select do
SelectInput(name: "preselected_fruit", value: "banana")
SelectTrigger(class: "w-56") do
SelectValue(placeholder: "Select a fruit") { "Banana" }
end
SelectContent do
SelectGroup do
SelectLabel { "Fruits" }
SelectItem(value: "apple") { "Apple" }
SelectItem(value: "banana") { "Banana" }
SelectItem(value: "blueberry") { "Blueberry" }
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Sizes", context: self) do
<<~RUBY
div(class: "flex flex-wrap items-center gap-2") do
[:default, :sm].each do |size|
Select do
SelectInput()
SelectTrigger(size: size, class: "w-40") do
SelectValue(placeholder: size == :sm ? "Small" : "Default")
end
SelectContent do
SelectGroup do
SelectItem(value: "apple") { "Apple" }
SelectItem(value: "banana") { "Banana" }
end
end
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Placement", context: self) do
<<~RUBY
Select do
SelectInput(name: "placement_fruit")
SelectTrigger(class: "w-56") do
SelectValue(placeholder: "Opens above, aligned to the start")
end
SelectContent(side: :top, align: :start, side_offset: 8) do
SelectGroup do
SelectItem(value: "apple") { "Apple" }
SelectItem(value: "banana") { "Banana" }
SelectItem(value: "blueberry") { "Blueberry" }
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Disabled", context: self) do
<<~RUBY
Select do
SelectInput(name: "disabled_fruit", value: "apple")
SelectTrigger(disabled: true, class: "w-56") do
SelectValue(placeholder: "Select a fruit") { "Apple" }
end
SelectContent do
SelectGroup do
SelectItem(value: "apple") { "Apple" }
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Disabled Items", context: self) do
<<~RUBY
Select do
SelectInput(name: "disabled_items_fruit")
SelectTrigger(class: "w-56") do
SelectValue(placeholder: "Select a fruit")
end
SelectContent do
SelectGroup do
SelectLabel { "Fruits" }
SelectItem(value: "apple", disabled: true) { "Apple" }
SelectItem(value: "banana") { "Banana" }
SelectItem(value: "blueberry") { "Blueberry" }
SelectItem(value: "grapes", disabled: true) { "Grapes" }
end
end
end
RUBY
end
render Components::ComponentSetup::Tabs.new(component_name: component)
render Docs::ComponentsTable.new(component_files(component))
end
end
end
4
Add RubyUI::SelectGroup to app/components/ruby_ui/select/select_group.rb
# frozen_string_literal: true
require "securerandom"
module RubyUI
# A group of items, named by the SelectLabel inside it (role=group with
# aria-labelledby, as Base UI's Select.Group).
class SelectGroup < Base
def initialize(id: nil, **)
@id = id || "select-group-#{SecureRandom.hex(4)}"
super(id: @id, **)
end
# The children are captured first, so aria-labelledby only points at a
# SelectLabel that actually rendered.
def view_template(&block)
previous = context[:ruby_ui_select_group]
names = context[:ruby_ui_select_group] = {label_id: "#{@id}-label"}
body = block ? capture(&block) : ""
group_attrs = names[:labelledby] ? mix({aria: {labelledby: names[:labelledby]}}, attrs) : attrs
div(**group_attrs) { raw(safe(body)) }
ensure
context[:ruby_ui_select_group] = previous
end
private
def default_attrs
{
role: "group",
data: {slot: "select-group"},
class: "scroll-my-1 p-1"
}
end
end
end
5
Add RubyUI::SelectInput to app/components/ruby_ui/select/select_input.rb
# frozen_string_literal: true
module RubyUI
# The value carrier, like Base UI's hidden input: a real input (so name:,
# value: and required: work with forms and FormField) kept out of sight, the
# tab order and the accessibility tree.
class SelectInput < Base
def view_template
input(**attrs)
end
private
def default_attrs
{
tabindex: "-1",
autocomplete: "off",
aria: {hidden: "true"},
class: "sr-only",
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 invalid->ruby-ui--select#focusTrigger"
}
}
end
end
end
6
Add RubyUI::SelectItem to app/components/ruby_ui/select/select_item.rb
# frozen_string_literal: true
module RubyUI
# An option. Focus moves onto it (roving tabindex), as in Base UI's
# Select.Item; the check on the right marks the selected one.
class SelectItem < Base
def initialize(value: nil, disabled: false, **)
@value = value
@disabled = disabled
super(**)
end
def view_template(&)
div(**attrs) do
span(class: "flex flex-1 shrink-0 gap-2 whitespace-nowrap", &)
indicator
end
end
private
def indicator
span(class: "pointer-events-none invisible absolute right-2 size-4 items-center justify-center group-aria-selected/select-item:visible", aria: {hidden: "true"}) do
svg(
xmlns: "http://www.w3.org/2000/svg",
viewbox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
aria_hidden: "true",
class: "pointer-events-none size-4"
) { |s| s.path(d: "M20 6 9 17l-5-5") }
end
end
def default_attrs
{
role: "option",
tabindex: "-1",
aria: {selected: "false", disabled: (@disabled ? "true" : nil)},
data: {
slot: "select-item",
value: @value,
disabled: (@disabled ? "" : nil),
ruby_ui__select_target: "item",
action: "click->ruby-ui--select#select pointermove->ruby-ui--select#hover"
},
class: [
"group/select-item relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none",
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground",
"data-disabled:pointer-events-none data-disabled:opacity-50",
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"*:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2"
]
}
end
end
end
7
Add RubyUI::SelectLabel to app/components/ruby_ui/select/select_label.rb
# frozen_string_literal: true
module RubyUI
class SelectLabel < Base
# Inside a SelectGroup, the first label names the group.
def view_template(&)
names = context[:ruby_ui_select_group]
return div(**attrs, &) if names.nil? || (!attrs[:id] && names[:labelledby])
id = attrs[:id] || names[:label_id]
names[:labelledby] ||= id
# The group is named through aria-labelledby, so this text is hidden to
# avoid reading it twice. Other labels stay in the accessibility tree.
div(**mix({aria: {hidden: "true"}}, attrs.merge(id: id)), &)
end
private
def default_attrs
{
data: {slot: "select-label"},
class: "px-2 py-1.5 text-xs text-muted-foreground"
}
end
end
end
8
Add RubyUI::SelectTrigger to app/components/ruby_ui/select/select_trigger.rb
# frozen_string_literal: true
module RubyUI
# The button that opens the list (Base UI's Select.Trigger with role=combobox).
# size: :default (h-9) or :sm (h-8), as in shadcn's SelectTrigger.
class SelectTrigger < Base
SIZES = %i[default sm].freeze
def initialize(size: :default, **)
@size = SIZES.include?(size.to_s.to_sym) ? size.to_s.to_sym : :default
super(**)
end
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",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
aria_hidden: "true",
class: "pointer-events-none size-4 text-muted-foreground"
) { |s| s.path(d: "m6 9 6 6 6-6") }
end
def default_attrs
{
type: "button",
role: "combobox",
# aria-controls is set by the controller while the list is open.
aria: {haspopup: "listbox", expanded: "false"},
data: {
slot: "select-trigger",
size: @size,
ruby_ui__select_target: "trigger",
action: "click->ruby-ui--select#toggle keydown->ruby-ui--select#triggerKeydown"
},
class: [
"flex w-fit items-center justify-between gap-1.5 whitespace-nowrap rounded-md border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm shadow-xs outline-none",
"transition-[color,box-shadow] data-placeholder:text-muted-foreground",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20",
"dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"disabled:cursor-not-allowed disabled:opacity-50",
"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none",
# A bare class, so a caller's h-* replaces it through TailwindMerge.
(@size == :sm) ? "h-8" : "h-9",
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
]
}
end
end
end
9
Add RubyUI::SelectValue to app/components/ruby_ui/select/select_value.rb
# frozen_string_literal: true
module RubyUI
# Shows the selected item's text inside SelectTrigger, or placeholder: while
# nothing is selected. The controller keeps it in sync with SelectInput.
class SelectValue < Base
def initialize(placeholder: nil, **)
@placeholder = placeholder
super(**)
end
def view_template(&block)
span(**attrs) do
value = block&.call
value || @placeholder
end
end
private
def default_attrs
{
data: {
slot: "select-value",
ruby_ui__select_target: "value",
placeholder_text: @placeholder
},
class: "flex flex-1 text-left"
}
end
end
end
10
Add select_controller.js to app/javascript/controllers/ruby_ui/select_controller.js
import { Controller } from "@hotwired/stimulus";
import { computePosition, autoUpdate, offset, flip, shift, size } from "@floating-ui/dom";
// Connects to data-controller="ruby-ui--select"
//
// Follows Base UI's Select: the trigger is a role=combobox button, the list a
// role=listbox native popover, and DOM focus moves onto the options (roving
// tabindex). SelectInput holds the value for forms and FormField.
export default class extends Controller {
static targets = ["trigger", "value", "content", "input", "item"];
connect() {
this.cleanup = null;
this.focusReturn = null;
this.typeahead = "";
// FormField marks the hidden input aria-invalid; mirror it on the trigger,
// so the error is announced and styled where people look.
this.invalidObserver = new MutationObserver(() => this.syncInvalid());
if (this.hasInputTarget) {
this.invalidObserver.observe(this.inputTarget, { attributes: true, attributeFilter: ["aria-invalid", "aria-describedby"] });
}
// The form's reset restores the input without firing change.
this.form = this.element.closest("form");
this.form?.addEventListener("reset", this.handleFormReset);
// A reconnect (Turbo cache, morph) can bring back an open state the
// popover itself no longer has: settle on closed.
if (this.isOpen || this.triggerTarget.getAttribute("aria-expanded") === "true") this.markClosed();
this.assignOptionIds();
this.syncSelection();
this.syncInvalid();
}
disconnect() {
document.removeEventListener("pointerdown", this.handleOutsidePointerDown, true);
this.removeFocusReturn();
this.invalidObserver?.disconnect();
this.form?.removeEventListener("reset", this.handleFormReset);
clearTimeout(this.resetTimeout);
clearTimeout(this.typeaheadTimeout);
this.stopAutoUpdate();
this.markClosed();
}
// Options added later (a morph, a lazy list) pick up the current value.
itemTargetConnected() {
this.assignOptionIds();
if (this.hasTriggerTarget) this.syncSelection();
}
// ...and removed ones stop being shown as the selection.
itemTargetDisconnected() {
if (this.hasTriggerTarget) this.syncSelection();
}
handleFormReset = () => {
clearTimeout(this.resetTimeout);
this.resetTimeout = setTimeout(() => this.syncSelection());
};
// --- Opening and closing ----------------------------------------------------
get isOpen() {
return this.hasContentTarget && this.contentTarget.hasAttribute("data-open");
}
toggle(event) {
event?.preventDefault();
this.isOpen ? this.hide({ restoreFocus: true }) : this.show();
}
// Enter and Space reach toggle through the button's click.
triggerKeydown(event) {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
if (!this.isOpen) this.show();
}
show() {
if (!this.hasContentTarget || this.isOpen || this.triggerTarget.disabled) return;
if (this.triggerTarget.getAttribute("aria-disabled") === "true") return;
const content = this.contentTarget;
content.removeAttribute("data-closed");
content.setAttribute("data-open", "");
if (!content.matches(":popover-open")) content.showPopover();
this.setExpanded(true);
this.position();
document.addEventListener("pointerdown", this.handleOutsidePointerDown, true);
// Like Base UI: the selected option takes focus; with nothing selected the
// list itself does, and no option is highlighted until a key or hover.
const selected = this.selectedItem;
if (selected && !this.isDisabled(selected)) this.focusItem(selected);
else content.focus({ preventScroll: true });
}
hide({ restoreFocus = false } = {}) {
document.removeEventListener("pointerdown", this.handleOutsidePointerDown, true);
this.stopAutoUpdate();
this.resetTypeahead();
this.markClosed();
if (restoreFocus) this.triggerTarget.focus();
}
markClosed() {
this.setExpanded(false);
if (!this.hasContentTarget) return;
const content = this.contentTarget;
content.removeAttribute("data-open");
content.setAttribute("data-closed", "");
if (content.matches(":popover-open")) content.hidePopover();
}
setExpanded(open) {
this.triggerTarget.setAttribute("aria-expanded", open ? "true" : "false");
// Like Base UI, the trigger points at the list only while it is open.
if (open && this.hasContentTarget) this.triggerTarget.setAttribute("aria-controls", this.contentTarget.id);
else this.triggerTarget.removeAttribute("aria-controls");
}
handleOutsidePointerDown = (event) => {
if (this.element.contains(event.target)) return;
const focusWasInside = this.element.contains(document.activeElement);
this.hide();
// Like Base UI, focus goes back to the trigger unless the click lands on
// something that takes focus itself.
this.removeFocusReturn();
if (focusWasInside) {
const trigger = this.triggerTarget;
this.focusReturn = () => {
this.focusReturn = null;
if (document.activeElement === document.body || !document.activeElement) trigger.focus();
};
document.addEventListener("click", this.focusReturn, { once: true, capture: true });
}
};
// A pointerdown with no click (a touch scroll) would otherwise leave it armed.
removeFocusReturn() {
if (!this.focusReturn) return;
document.removeEventListener("click", this.focusReturn, { capture: true });
this.focusReturn = null;
}
// --- Keyboard -------------------------------------------------------------
listKeydown(event) {
if (!this.isOpen) return;
const options = this.enabledOptions;
const current = options.indexOf(document.activeElement);
switch (event.key) {
case "ArrowDown":
event.preventDefault();
// No wrap at the ends, as Base UI.
this.focusItem(current === -1 ? options[0] : options[Math.min(current + 1, options.length - 1)]);
break;
case "ArrowUp":
event.preventDefault();
this.focusItem(current === -1 ? options[options.length - 1] : options[Math.max(current - 1, 0)]);
break;
case "Home":
event.preventDefault();
this.focusItem(options[0]);
break;
case "End":
event.preventDefault();
this.focusItem(options[options.length - 1]);
break;
case "Enter":
event.preventDefault();
if (current !== -1) this.choose(options[current]);
break;
case " ":
event.preventDefault();
// Space is part of a typeahead query while one is being typed.
if (this.typeahead) this.typeaheadTo(" ");
else if (current !== -1) this.choose(options[current]);
break;
case "Escape":
event.preventDefault();
event.stopPropagation();
this.hide({ restoreFocus: true });
break;
case "Tab":
// Like Base UI: Shift+Tab closes onto the trigger; Tab returns focus
// to the trigger first, so the browser moves on from there.
if (event.shiftKey) event.preventDefault();
this.hide({ restoreFocus: true });
break;
default:
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault();
this.typeaheadTo(event.key);
}
}
}
// Typing jumps to the next option whose text starts with what was typed;
// the same letter repeated cycles through the options that start with it.
typeaheadTo(key) {
clearTimeout(this.typeaheadTimeout);
this.typeahead += key.toLowerCase();
this.typeaheadTimeout = setTimeout(() => this.resetTypeahead(), 500);
const options = this.enabledOptions;
const repeated = this.typeahead.split("").every((char) => char === this.typeahead[0]);
const query = repeated ? this.typeahead[0] : this.typeahead;
const start = options.indexOf(document.activeElement);
const ordered = [...options.slice(start + 1), ...options.slice(0, start + 1)];
const pool = repeated || start === -1 ? ordered : [options[start], ...ordered];
const match = pool.find((option) => this.textOf(option).toLowerCase().startsWith(query));
if (match) this.focusItem(match);
}
resetTypeahead() {
clearTimeout(this.typeaheadTimeout);
this.typeahead = "";
}
hover(event) {
const option = event.currentTarget;
if (option !== document.activeElement && !this.isDisabled(option)) this.focusItem(option, { scroll: false });
}
focusItem(option, { scroll = true } = {}) {
if (!option) return;
option.focus({ preventScroll: true });
if (scroll) option.scrollIntoView({ block: "nearest" });
}
// The browser focuses the first invalid control after the invalid event;
// the hidden input can't be seen, so focus moves on to the trigger, as Base UI.
focusTrigger() {
setTimeout(() => {
if (document.activeElement === this.inputTarget) this.triggerTarget.focus();
});
}
// --- Selection --------------------------------------------------------------
select(event) {
this.choose(event.currentTarget);
}
choose(option) {
if (!option || this.isDisabled(option)) return;
const input = this.hasInputTarget ? this.inputTarget : null;
const changed = input && input.value !== option.dataset.value;
if (input) input.value = option.dataset.value;
this.syncSelection();
if (changed) {
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}
this.hide({ restoreFocus: true });
}
syncSelection() {
const selected = this.selectedItem;
this.itemTargets.forEach((item) => item.setAttribute("aria-selected", item === selected ? "true" : "false"));
// A value with no matching option (e.g. a disabled Select rendered
// without items) keeps the text the server rendered in SelectValue.
const current = this.hasInputTarget ? this.inputTarget.value : "";
const placeholder = !selected && current === "";
this.triggerTarget.toggleAttribute("data-placeholder", placeholder);
if (!this.hasValueTarget) return;
const value = this.valueTarget;
value.toggleAttribute("data-placeholder", placeholder);
if (selected) value.textContent = this.textOf(selected);
else if (placeholder) value.textContent = value.dataset.placeholderText ?? "";
}
// What the controller mirrored lives on the trigger (data-mirrored-*), so a
// reconnect still knows which attributes are its own to undo.
syncInvalid() {
if (!this.hasInputTarget) return;
const trigger = this.triggerTarget;
// aria-describedby: the input's ids (FormField hint/error) are merged
// with the author's own, which stay.
const ids = (value) => (value || "").split(/\s+/).filter(Boolean);
const mirrored = ids(trigger.dataset.mirroredDescribedby);
const own = ids(trigger.getAttribute("aria-describedby")).filter((id) => !mirrored.includes(id));
const fromInput = ids(this.inputTarget.getAttribute("aria-describedby")).filter((id) => !own.includes(id));
const merged = [...own, ...fromInput];
if (merged.length) trigger.setAttribute("aria-describedby", merged.join(" "));
else trigger.removeAttribute("aria-describedby");
if (fromInput.length) trigger.dataset.mirroredDescribedby = fromInput.join(" ");
else delete trigger.dataset.mirroredDescribedby;
if (this.inputTarget.getAttribute("aria-invalid") === "true") {
if (trigger.getAttribute("aria-invalid") === "true") return;
trigger.setAttribute("aria-invalid", "true");
trigger.dataset.mirroredInvalid = "";
} else if (trigger.hasAttribute("data-mirrored-invalid")) {
// Only undo what we set: an author's own aria-invalid stays.
trigger.removeAttribute("aria-invalid");
delete trigger.dataset.mirroredInvalid;
}
}
// --- Helpers --------------------------------------------------------------
get selectedItem() {
if (!this.hasInputTarget) return null;
// An option may carry the empty value (e.g. "None"); it is selected when
// the input is empty, as in a native select.
return this.itemTargets.find((item) => item.dataset.value === this.inputTarget.value) || null;
}
get enabledOptions() {
return this.itemTargets.filter((item) => !this.isDisabled(item));
}
textOf(item) {
return item.textContent.trim();
}
isDisabled(option) {
return option.hasAttribute("data-disabled") || option.getAttribute("aria-disabled") === "true";
}
assignOptionIds() {
const prefix = (this.hasContentTarget && this.contentTarget.id) || "select";
let next = 0;
this.itemTargets.forEach((item) => {
if (item.id) return;
while (document.getElementById(`${prefix}-option-${next}`)) next++;
item.id = `${prefix}-option-${next}`;
});
}
// --- Positioning ----------------------------------------------------------
position() {
this.stopAutoUpdate();
const anchor = this.triggerTarget;
const content = this.contentTarget;
const side = content.dataset.preferredSide || "bottom";
const align = content.dataset.align || "center";
const placement = align === "center" ? side : `${side}-${align}`;
const sideOffset = Number(content.dataset.sideOffset ?? 4);
const alignOffset = Number(content.dataset.alignOffset ?? 0);
this.cleanup = autoUpdate(anchor, content, () => {
if (!anchor.isConnected || !content.isConnected) return;
computePosition(anchor, content, {
strategy: "fixed",
placement,
middleware: [
offset({ mainAxis: sideOffset, crossAxis: alignOffset }),
flip(),
shift({ padding: 5 }),
size({
padding: 5,
apply({ availableHeight, rects }) {
content.style.setProperty("--anchor-width", `${rects.reference.width}px`);
content.style.setProperty("--available-height", `${availableHeight}px`);
},
}),
],
}).then(({ x, y, placement: resolved }) => {
if (!content.isConnected) return;
const [resolvedSide, resolvedAlign] = resolved.split("-");
Object.assign(content.style, { left: `${x}px`, top: `${y}px` });
content.dataset.side = resolvedSide;
content.style.setProperty("--transform-origin", this.transformOrigin(resolvedSide, resolvedAlign));
});
});
}
transformOrigin(side, align) {
const opposite = { top: "bottom", bottom: "top", left: "right", right: "left" }[side];
const vertical = side === "top" || side === "bottom";
const cross = { start: vertical ? "left" : "top", end: vertical ? "right" : "bottom" }[align] || "center";
return vertical ? `${cross} ${opposite}` : `${opposite} ${cross}`;
}
stopAutoUpdate() {
this.cleanup?.();
this.cleanup = null;
}
}
11
Update the Stimulus controllers manifest file
rake stimulus:manifest:update12
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 | |
SelectDocs | Phlex | |
SelectGroup | Phlex | |
SelectInput | Phlex | |
SelectItem | Phlex | |
SelectLabel | Phlex | |
SelectTrigger | Phlex | |
SelectValue | Phlex | |
SelectController | Stimulus JS |