Senren UI
Components / Combobox

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.