Senren UI
Components / Command

Command

Stable Stimulus: senren--command

command palettes

Open docs Jump to the introduction Add component Install a Senren component

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/.../command_example.html.erb
<div class="w-full max-w-md">
  <%= render Senren::CommandComponent.new(
    placeholder: "Search commands...",
    items: [
      { label: "Open docs", description: "Jump to the introduction", href: "/docs", keywords: "documentation intro" },
      { label: "Add component", description: "Install a Senren component", href: "/docs/installation", keywords: "install generate" },
      { label: "Toggle theme", description: "Switch light and dark mode", keywords: "dark light appearance" }
    ]
  ) %>
</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 command --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 command --client

Dependencies are resolved by senren:add: dialog, input.

At a glance #

Category Rich
Class name Senren::CommandComponent
Stimulus senren--command
Variants default
Depends on dialog, input
Pairs with shortcut_key, dropdown_menu

Source #

app/components/senren/command_component.html.erb
<%= tag.div(**root_attrs("overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] shadow-sm", data: { controller: "senren--command" })) do %>
  <% if content? && items.empty? %>
    <%= content %>
  <% else %>
    <label for="<%= dom_id %>-input" class="sr-only"><%= label %></label>
    <div class="border-b border-[hsl(var(--senren-border))] p-2">
      <input id="<%= dom_id %>-input" type="search" autocomplete="off" placeholder="<%= placeholder %>" aria-label="<%= label %>" aria-controls="<%= dom_id %>-list" aria-activedescendant="" data-senren--command-target="input" data-action="input->senren--command#filter keydown->senren--command#onKey" class="h-10 w-full rounded-(--senren-radius) bg-[hsl(var(--senren-background))] px-3 text-sm text-[hsl(var(--senren-foreground))] outline-none placeholder:text-[hsl(var(--senren-muted-foreground))] focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
    </div>
    <div id="<%= dom_id %>-list" role="listbox" aria-label="<%= label %>" data-senren--command-target="list" class="max-h-72 overflow-y-auto p-1.5">
      <% items.each_with_index do |item, index| %>
        <% tag_name = item[:href].present? ? :a : :button %>
        <%= tag.public_send(tag_name, href: item[:href], type: (tag_name == :button ? "button" : nil), id: item[:id], role: "option", data: { "senren--command-target": "option", action: "click->senren--command#choose", label: item[:keywords] }, class: "block w-full cursor-pointer rounded-(--senren-radius) px-3 py-2 text-left text-sm transition-colors hover:bg-[hsl(var(--senren-accent))] aria-selected:bg-[hsl(var(--senren-accent))] aria-selected:text-[hsl(var(--senren-accent-foreground))]", "aria-selected": (index.zero? ? "true" : "false")) do %>
          <span class="block font-medium"><%= item[:label] %></span>
          <% if item[:description].present? %>
            <span class="mt-0.5 block text-xs text-[hsl(var(--senren-muted-foreground))]"><%= item[:description] %></span>
          <% end %>
        <% end %>
      <% end %>
      <div hidden data-senren--command-target="empty" class="px-3 py-8 text-center text-sm text-[hsl(var(--senren-muted-foreground))]"><%= empty_text %></div>
    </div>
  <% end %>
<% end %>
app/components/senren/command_component.rb
# frozen_string_literal: true

module Senren
  class CommandComponent < BaseComponent
    VARIANTS = { default: '' }.freeze
    SIZES = { md: '' }.freeze

    def initialize(items: [], placeholder: 'Type a command...', label: 'Command menu', empty_text: 'No results found.',
                   id: nil, class_name: nil, **html)
      super(variant: :default, size: :md, class_name: class_name, **html)
      @placeholder = placeholder
      @label = label
      @empty_text = empty_text
      @dom_id = id || "senren-command-#{SecureRandom.hex(3)}"
      @items = normalize_items(items)
    end

    attr_reader :items, :placeholder, :label, :empty_text, :dom_id

    private

    def normalize_items(items)
      Array(items).map.with_index do |item, index|
        data = item.is_a?(Hash) ? item : { label: item.to_s }
        label = data[:label] || data['label']
        description = data[:description] || data['description']
        keywords = data[:keywords] || data['keywords']
        {
          id: data[:id] || data['id'] || "#{dom_id}-option-#{index}",
          label: label,
          description: description,
          href: data[:href] || data['href'],
          keywords: [label, description, keywords].flatten.compact.join(' ')
        }
      end
    end
  end
end

AI agent rules #

Use for

  • +command palettes
  • +quick search

Avoid

  • -persistent navigation

Accessibility #

  • aria-activedescendant for current option; type-ahead filtering.