Combobox
Stable Stimulus:senren--combobox
searchable selects
Usage example #
Copy this ERB into a Rails view after installing the component. The snippet below is the same code used by the live preview above.
app/views/.../combobox_example.html.erb
<div class="w-full max-w-sm">
<%= render Senren::ComboboxComponent.new(
name: "assignee",
value: "mai",
placeholder: "Search teammates...",
options: [["mai", "Mai Nguyen"], ["an", "An Tran"], ["linh", "Linh Pham"], ["kai", "Kai Ito"]]
) %>
</div>
Install this component #
Copy the official component into your app
This component requires Stimulus. Keep --client so the controller is copied with the ViewComponent.
Terminal
bin/rails senren:add combobox --client
Create a custom component with the same conventions
Use this when you need an app-specific component that follows Senren's ViewComponent and Stimulus structure. --client is required for this behavior.
Terminal
bin/rails generate senren:component combobox --client
Dependencies are resolved by senren:add:
input, button.
At a glance #
Category
Forms
Class name
Senren::ComboboxComponent
Stimulus
senren--combobox
Variants
default, error
Depends on
input, button
Pairs with
form, label
Source #
app/components/senren/combobox_component.html.erb
<div <%= tag.attributes(**root_attrs("relative w-full", data: { controller: "senren--combobox" })) %>>
<input type="hidden" name="<%= name %>" value="<%= value %>" data-senren--combobox-target="value">
<button type="button" class="group flex h-10 w-full cursor-pointer items-center justify-between rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] px-3 text-left text-sm text-[hsl(var(--senren-foreground))] transition-colors hover:bg-[hsl(var(--senren-muted)/0.35)] <%= self.class::VARIANTS[variant] %>" aria-haspopup="listbox" aria-expanded="false" data-state="closed" data-senren--combobox-target="button" data-action="click->senren--combobox#toggle">
<span data-senren--combobox-target="label"><%= selected_label || placeholder %></span>
<svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 shrink-0 text-[hsl(var(--senren-muted-foreground))] transition-transform duration-150 data-[state=open]:rotate-180" data-state="closed" data-senren--combobox-target="chevron">
<path d="m5 8 5 5 5-5"></path>
</svg>
</button>
<div data-senren--combobox-target="panel" hidden class="absolute z-50 mt-2 w-full rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] p-2 shadow-md">
<input type="text" placeholder="<%= placeholder %>" data-senren--combobox-target="search" data-action="input->senren--combobox#filter keydown->senren--combobox#onKey" class="mb-2 h-9 w-full rounded-md border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-background))] px-2 text-sm outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
<div role="listbox" class="max-h-56 overflow-auto">
<% options.each do |option| %>
<button type="button" role="option" data-senren--combobox-target="option" data-value="<%= option[:value] %>" data-label="<%= option[:label] %>" data-action="click->senren--combobox#choose" class="block w-full cursor-pointer rounded-md px-2 py-2 text-left text-sm text-[hsl(var(--senren-popover-foreground))] hover:bg-[hsl(var(--senren-accent))]">
<%= option[:label] %>
</button>
<% end %>
</div>
</div>
</div>
app/components/senren/combobox_component.rb
# frozen_string_literal: true
module Senren
class ComboboxComponent < BaseComponent
VARIANTS = {
default: 'border-[hsl(var(--senren-border))]',
error: 'border-[hsl(var(--senren-destructive))]'
}.freeze
SIZES = { md: '' }.freeze
def initialize(name:, options:, value: nil, placeholder: 'Search...', variant: :default, class_name: nil, **html)
super(variant: variant, size: :md, class_name: class_name, **html)
@name = name
@options = normalize_options(options)
@value = value
@placeholder = placeholder
end
attr_reader :name, :options, :value, :placeholder
def selected_label
options.find { |option| option[:value].to_s == value.to_s }&.dig(:label)
end
private
def normalize_options(options)
Array(options).map do |option|
if option.is_a?(Hash)
{ value: option[:value] || option['value'], label: option[:label] || option['label'] }
else
value, label = option
{ value: value, label: label || value }
end
end
end
end
end
AI agent rules #
Use for
- +searchable selects
- +large option lists
Avoid
- -short
- -fixed lists - use native_select
Accessibility #
- WAI-ARIA combobox pattern; aria-expanded; aria-activedescendant.