ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

Command

Cmd+K command palette as Dialog + Combobox composition.

Installation

bin/rails g wabi:add command

Command depends on the dialog and combobox components — the generator will install both. The @zag-js/dialog, @zag-js/combobox, and @zag-js/vanilla pins come from those dependencies; no additional importmap pins are needed.

Example

Source

app/components/ui/command.rb

# frozen_string_literal: true

require "date"         # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
require "securerandom" # UUID generation for unique command-id

module Components
  module UI
    class Command < Wabi::Base
      def initialize(id: nil, **attrs)
        @id    = id || "cmd-#{SecureRandom.uuid}"
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          **@attrs,
          id: @id,
          data: {
            controller: "wabi--command wabi--dialog",
            "wabi--dialog-modal-value":  "true",
            "wabi--dialog-portal-value": "true",
            "wabi--dialog-open-value":   "false",
            "wabi--command-id": @id,
          },
          class: user_class,
          &block
        )
      end
    end
  end
end

app/components/ui/command_trigger.rb

# frozen_string_literal: true

require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes

module Components
  module UI
    class CommandTrigger < Wabi::Base
      TRIGGER_CLASS = "inline-flex items-center justify-between h-10 px-4 rounded-md " \
                      "border border-input bg-background text-sm text-muted-foreground " \
                      "hover:bg-accent hover:text-accent-foreground transition-colors " \
                      "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"

      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        button(
          **@attrs,
          type: "button",
          data: { "wabi--dialog-target": "trigger" },
          class: merge_class(TRIGGER_CLASS, user_class)
        ) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/command_dialog.rb

# frozen_string_literal: true

require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes

module Components
  module UI
    class CommandDialog < Wabi::Base
      DIALOG_CLASS = "fixed left-1/2 top-1/4 z-50 grid w-full max-w-2xl -translate-x-1/2 " \
                     "gap-0 border border-input bg-background shadow-lg sm:rounded-lg " \
                     "overflow-hidden " \
                     "transition-opacity duration-200 ease-out motion-reduce:transition-none motion-reduce:duration-0 " \
                     "data-[state=open]:opacity-100 data-[state=open]:pointer-events-auto " \
                     "data-[state=closed]:opacity-0 data-[state=closed]:pointer-events-none"

      BACKDROP_CLASS = "fixed inset-0 z-40 bg-black/80 " \
                       "transition-opacity duration-200 ease-out motion-reduce:transition-none motion-reduce:duration-0 " \
                       "data-[state=open]:opacity-100 data-[state=open]:pointer-events-auto " \
                       "data-[state=closed]:opacity-0 data-[state=closed]:pointer-events-none"

      POSITIONER_CLASS = "fixed inset-0 z-50 pointer-events-none"

      def initialize(label: "Command palette", **attrs)
        @label = label
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        # No outer portal-target wrapper: wabi--dialog#attachToBody moves the
        # positioner (and backdrop) directly to <body>. The wrapper was a
        # vestigial placeholder and now that "portal" is gone from the
        # controller's static targets it would only trigger a Stimulus
        # "Missing target" warning.
        div(
          data: { "wabi--dialog-target": "backdrop" },
          "data-state": "closed",
          class: BACKDROP_CLASS
        )
        div(
          data: { "wabi--dialog-target": "positioner" },
          class: POSITIONER_CLASS
        ) do
          # Dialog content owns role/aria-modal/data-state. The combobox
          # controller must NOT mount on this element directly — its
          # spreadProps(getRootProps()) would strip data-state and break
          # the data-[state=closed]:opacity-0 fade. Mount it on an inner
          # wrapper instead so both controllers manage their own elements.
          div(
            **@attrs,
            role: "dialog",
            "aria-modal": "true",
            "data-state": "closed",
            inert: true,
            data: { "wabi--dialog-target": "content" },
            class: merge_class(DIALOG_CLASS, user_class)
          ) do
            # Visually-hidden title gives the role=dialog an accessible name:
            # wabi--dialog spreads getTitleProps() (with an id) onto this target
            # and Zag injects aria-labelledby on the content at runtime.
            span(
              data: { "wabi--dialog-target": "title" },
              class: "sr-only"
            ) { @label }
            div(
              data: {
                controller: "wabi--combobox",
                "wabi--combobox-portal-value": "false",
                "wabi--combobox-items-value": "[]",
              }
            ) do
              # Visually-hidden label gives the combobox input an accessible name.
              # The combobox controller spreads getLabelProps() (for/id wiring)
              # onto this element when hasLabelTarget is true, which in turn
              # sets aria-labelledby on the input. Callers can override the text
              # via the `label:` param on CommandDialog.
              label(
                data: { "wabi--combobox-target": "label" },
                class: "sr-only"
              ) { @label }
              yield if block_given?
            end
          end
        end
      end
    end
  end
end

app/components/ui/command_input.rb

# frozen_string_literal: true

require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes

module Components
  module UI
    class CommandInput < Wabi::Base
      INPUT_CLASS = "flex h-12 w-full bg-transparent py-3 px-4 text-sm outline-none " \
                    "placeholder:text-muted-foreground border-b border-input"

      def initialize(placeholder: "Type a command or search...", **attrs)
        @placeholder = placeholder
        @attrs       = attrs
      end

      def view_template
        user_class = @attrs.delete(:class)
        input(
          **@attrs,
          type: "text",
          placeholder: @placeholder,
          data: { "wabi--combobox-target": "input" },
          class: merge_class(INPUT_CLASS, user_class)
        )
      end
    end
  end
end

app/components/ui/command_list.rb

# frozen_string_literal: true

require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes

module Components
  module UI
    class CommandList < Wabi::Base
      LIST_CLASS = "max-h-80 overflow-y-auto overflow-x-hidden p-1 list-none m-0"

      def initialize(items: [], **attrs)
        @items = items
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)

        groups = @items.group_by { |i| i[:group] || "_default" }

        ul(
          **@attrs,
          data: { "wabi--combobox-target": "content" },
          "data-state": "closed",
          class: merge_class(LIST_CLASS, user_class)
        ) do
          groups.each do |group_name, items|
            if group_name != "_default"
              render Components::UI::CommandGroup.new(heading: group_name) do
                items.each { |item| render Components::UI::CommandItem.new(**item) { item[:label] } }
              end
            else
              items.each { |item| render Components::UI::CommandItem.new(**item) { item[:label] } }
            end
          end
          yield if block_given?  # for CommandEmpty
        end
      end
    end
  end
end

app/components/ui/command_group.rb

# frozen_string_literal: true

require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes

module Components
  module UI
    class CommandGroup < Wabi::Base
      HEADING_CLASS = "px-2 py-1.5 text-xs font-medium text-muted-foreground"

      def initialize(heading: nil, **attrs)
        @heading = heading
        @attrs   = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(**@attrs, role: "group", class: merge_class("overflow-hidden p-1", user_class)) do
          h3(class: HEADING_CLASS) { @heading } if @heading
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/command_item.rb

# frozen_string_literal: true

require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes

module Components
  module UI
    class CommandItem < Wabi::Base
      ITEM_CLASS = "relative flex cursor-default select-none items-center rounded-sm py-1.5 px-2 " \
                   "text-sm outline-none gap-2 " \
                   "data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground " \
                   "data-[disabled]:pointer-events-none data-[disabled]:opacity-50"

      def initialize(value:, label: nil, shortcut: nil, disabled: false, **attrs)
        @value    = value
        @label    = label
        @shortcut = shortcut
        @disabled = disabled
        # `:group` is consumed by CommandList; absorbed-and-discarded here.
        attrs.delete(:group)
        @attrs    = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        li(
          **@attrs,
          data: {
            "wabi--combobox-target": "item",
            "wabi-value": @value,
            "wabi-disabled": @disabled.to_s,
          },
          class: merge_class(ITEM_CLASS, user_class)
        ) do
          if block_given?
            yield
          elsif @label
            plain @label
          end
          if @shortcut
            span(class: "ml-auto text-xs text-muted-foreground") { @shortcut }
          end
        end
      end
    end
  end
end

app/components/ui/command_empty.rb

# frozen_string_literal: true

require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes

module Components
  module UI
    class CommandEmpty < Wabi::Base
      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          **@attrs,
          role: "status",
          data: { "wabi-command-empty": "true" },
          class: merge_class("py-6 text-center text-sm text-muted-foreground", user_class)
        ) do
          yield if block_given?
        end
      end
    end
  end
end

Accessibility

  • Command is a modal dialog (role="dialog", aria-modal="true") containing an autocomplete combobox.
  • The visible list items get role="option" + data-highlighted from Zag's combobox machine; arrow keys navigate, Enter selects.
  • ⌘K is NOT bound by default — callers add their own keybinding (e.g. a Stimulus controller that listens for keydown and calls the dialog's open() action).
  • Selecting an item dispatches wabi--combobox:change, which a small wabi--command bridge controller forwards to the dialog's close().
  • When closed, the dialog content is inert + pointer-events-none so it's skipped by tab order and click events.