Command
Cmd+K command palette as Dialog + Combobox composition.
Installation
bin/rails g wabi:add commandCommand 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
Command palette
- New File⌘N
- Open File…⌘O
- Save File⌘S
- Settings⌘,
- Logout
File
App
No results found.
render Components::UI::Command.new do
render Components::UI::CommandTrigger.new { "Search commands... ⌘K" }
render Components::UI::CommandDialog.new do
render Components::UI::CommandInput.new(placeholder: "Type a command or search...")
render Components::UI::CommandList.new(
items: [
{ value: "new_file", label: "New File", group: "File", shortcut: "⌘N" },
{ value: "open_file", label: "Open File…", group: "File", shortcut: "⌘O" },
{ value: "save_file", label: "Save File", group: "File", shortcut: "⌘S" },
{ value: "settings", label: "Settings", group: "App", shortcut: "⌘," },
{ value: "logout", label: "Logout", group: "App" },
]
) do
render Components::UI::CommandEmpty.new { "No results found." }
end
end
end
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.