Command
Fast, composable command menu for Phlex.
Usage
Inline
Command works on the page too. Typing filters the items, the first match is active, the arrow keys, Home and End move through them and Enter selects. CommandSeparator hides while searching.
div(class: "w-full max-w-md rounded-xl ring-1 ring-foreground/10") do
Command do
CommandInput(placeholder: "Type a command or search...")
CommandList do
CommandEmpty { "No results found." }
CommandGroup(title: "Suggestions") do
CommandItem(value: "Calendar") do
svg(xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", aria_hidden: "true") do |s|
s.path(d: "M8 2v4"); s.path(d: "M16 2v4"); s.rect(width: "18", height: "18", x: "3", y: "4", rx: "2"); s.path(d: "M3 10h18")
end
span { "Calendar" }
end
CommandItem(value: "Search Emoji") do
svg(xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", aria_hidden: "true") do |s|
s.circle(cx: "12", cy: "12", r: "10"); s.path(d: "M8 14s1.5 2 4 2 4-2 4-2"); s.line(x1: "9", x2: "9.01", y1: "9", y2: "9"); s.line(x1: "15", x2: "15.01", y1: "9", y2: "9")
end
span { "Search Emoji" }
end
CommandItem(value: "Calculator", disabled: true) do
svg(xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", aria_hidden: "true") do |s|
s.rect(width: "16", height: "20", x: "4", y: "2", rx: "2"); s.line(x1: "8", x2: "16", y1: "6", y2: "6"); s.path(d: "M16 14v4"); s.path(d: "M8 10h.01"); s.path(d: "M12 10h.01"); s.path(d: "M16 10h.01"); s.path(d: "M8 14h.01"); s.path(d: "M12 14h.01"); s.path(d: "M8 18h.01"); s.path(d: "M12 18h.01")
end
span { "Calculator" }
end
end
CommandSeparator()
CommandGroup(title: "Settings") do
CommandItem(value: "Profile") do
span { "Profile" }
CommandShortcut { "⌘P" }
end
CommandItem(value: "Billing") do
span { "Billing" }
CommandShortcut { "⌘B" }
end
CommandItem(value: "Settings") do
span { "Settings" }
CommandShortcut { "⌘S" }
end
end
end
end
end
Dialog
CommandDialog opens the Command in a modal dialog from its trigger, and with ⌘K / Ctrl+K by default. This site's search already takes ⌘K, so the example turns the shortcut off with keybindings: []. title: and description: name the dialog for screen readers and are visually hidden. show_close_button: true adds the Dialog close button; it is off by default, as in shadcn, because it sits over the search field.
CommandDialog(title: "Command menu", description: "Choose an action") do
CommandDialogTrigger(keybindings: []) do
Button(variant: :outline) { "Open Menu" }
end
CommandDialogContent do
Command do
CommandInput(placeholder: "Type a command or search...")
CommandList do
CommandEmpty { "No results found." }
CommandGroup(title: "Suggestions") do
CommandItem { "Calendar" }
CommandItem { "Search Emoji" }
CommandItem { "Calculator" }
end
end
end
end
end
With shortcuts
CommandShortcut shows a keyboard hint at the end of an item.
CommandDialog do
CommandDialogTrigger(keybindings: ["keydown.ctrl+j@window", "keydown.meta+j@window"]) do
Button(variant: :outline) { "Open Menu (⌘J)" }
end
CommandDialogContent do
Command do
CommandInput(placeholder: "Type a command or search...")
CommandList do
CommandEmpty { "No results found." }
CommandGroup(title: "Settings") do
CommandItem(value: "Profile") do
span { "Profile" }
CommandShortcut { "⌘P" }
end
CommandItem(value: "Billing") do
span { "Billing" }
CommandShortcut { "⌘B" }
end
CommandItem(value: "Settings") do
span { "Settings" }
CommandShortcut { "⌘S" }
end
end
end
end
end
end
Handling selection
Selecting an item dispatches a cancelable ruby-ui--command:select event with the item's value and href in its detail. Listen to it from your own Stimulus controller; call preventDefault() to skip the href visit.
div(class: "w-full max-w-md rounded-xl ring-1 ring-foreground/10") do
Command(loop: true, data: {action: "ruby-ui--command:select->my-palette#run"}) do
CommandInput(placeholder: "Run an action...")
CommandList do
CommandEmpty { "No results found." }
CommandGroup(title: "Actions") do
CommandItem(value: "new-file") { "New file" }
CommandItem(value: "new-folder") { "New folder" }
CommandItem(value: "rename") { "Rename" }
end
end
end
end
Scrollable
CommandList scrolls past 18rem; the active item stays in view.
CommandDialog do
CommandDialogTrigger(keybindings: []) do
Button(variant: :outline) { "Open Menu" }
end
CommandDialogContent do
Command do
CommandInput(placeholder: "Type a command or search...")
CommandList do
CommandEmpty { "No results found." }
CommandGroup(title: "Navigation") do
%w[Home Inbox Documents Downloads Desktop Pictures Music Videos].each { |name| CommandItem { name } }
end
CommandSeparator()
CommandGroup(title: "Tools") do
%w[Calculator Calendar Clock Notes Terminal Weather].each { |name| CommandItem { name } }
end
CommandSeparator()
CommandGroup(title: "Settings") do
%w[Profile Billing Notifications Privacy Appearance Language].each { |name| CommandItem { name } }
end
end
end
end
end
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component Command
Manual installation
1
Add RubyUI::Command to app/components/ruby_ui/command/command.rb
# frozen_string_literal: true
module RubyUI
# Root of a command menu (shadcn's Command). Works inline on the page or
# inside CommandDialogContent. Typing in CommandInput filters the items; the
# first visible item is active, arrow keys, Home and End move the active item
# and Enter selects it. loop: true lets the arrow keys wrap around.
class Command < Base
def initialize(loop: false, **attrs)
@loop = loop
super(**attrs)
end
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
class: "flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
data: {
slot: "command",
controller: "ruby-ui--command",
ruby_ui__command_loop_value: @loop.to_s
}
}
end
end
end
2
Add RubyUI::CommandDialog to app/components/ruby_ui/command/command_dialog.rb
# frozen_string_literal: true
module RubyUI
# A Command in a modal Dialog (shadcn's CommandDialog). title: and
# description: name the dialog for assistive technology (rendered sr-only);
# show_close_button: adds the Dialog's close button. Wrap a
# CommandDialogTrigger and a CommandDialogContent holding the Command.
class CommandDialog < Base
def initialize(title: "Command Palette", description: "Search for a command to run...", show_close_button: false, **attrs)
@title = title
@description = description
@show_close_button = show_close_button
super(**attrs)
end
def view_template(&)
previous = context[:ruby_ui_command_dialog]
context[:ruby_ui_command_dialog] = {title: @title, description: @description, show_close_button: @show_close_button}
render Dialog.new(**attrs, &)
ensure
context[:ruby_ui_command_dialog] = previous
end
end
end
3
Add RubyUI::CommandDialogContent to app/components/ruby_ui/command/command_dialog_content.rb
# frozen_string_literal: true
module RubyUI
# The modal panel of a CommandDialog: a Dialog content pinned at a third of
# the viewport, with the dialog's sr-only title and description.
class CommandDialogContent < Base
def view_template(&block)
settings = context[:ruby_ui_command_dialog] || {}
render DialogContent.new(show_close_button: settings.fetch(:show_close_button, false), **attrs) do
div(class: "sr-only") do
render DialogTitle.new { settings.fetch(:title, "Command Palette") }
render DialogDescription.new { settings.fetch(:description, "Search for a command to run...") }
end
yield if block
end
end
private
def default_attrs
{class: "top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0"}
end
end
end
4
Add RubyUI::CommandDialogTrigger to app/components/ruby_ui/command/command_dialog_trigger.rb
# frozen_string_literal: true
module RubyUI
# Opens the CommandDialog on click and on its keybindings (⌘K / Ctrl+K by
# default; Stimulus key filters, e.g. "keydown.meta+j@window").
class CommandDialogTrigger < Base
DEFAULT_KEYBINDINGS = [
"keydown.ctrl+k@window",
"keydown.meta+k@window"
].freeze
def initialize(keybindings: DEFAULT_KEYBINDINGS, **attrs)
@keybindings = keybindings.map { |kb| "#{kb}->ruby-ui--dialog#open" }
super(**attrs)
end
def view_template(&)
render DialogTrigger.new(**attrs, &)
end
private
def default_attrs
{data: {action: @keybindings.join(" ")}}
end
end
end
5
Add RubyUI::CommandDocs to app/components/ruby_ui/command/command_docs.rb
# frozen_string_literal: true
class Views::Docs::Command < Views::Base
def view_template
component = "Command"
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
render Docs::Header.new(title: "Command", description: "Fast, composable command menu for Phlex.")
Heading(level: 2) { "Usage" }
render Docs::VisualCodeExample.new(title: "Inline", description: "Command works on the page too. Typing filters the items, the first match is active, the arrow keys, Home and End move through them and Enter selects. CommandSeparator hides while searching.", context: self) do
<<~RUBY
div(class: "w-full max-w-md rounded-xl ring-1 ring-foreground/10") do
Command do
CommandInput(placeholder: "Type a command or search...")
CommandList do
CommandEmpty { "No results found." }
CommandGroup(title: "Suggestions") do
CommandItem(value: "Calendar") do
svg(xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", aria_hidden: "true") do |s|
s.path(d: "M8 2v4"); s.path(d: "M16 2v4"); s.rect(width: "18", height: "18", x: "3", y: "4", rx: "2"); s.path(d: "M3 10h18")
end
span { "Calendar" }
end
CommandItem(value: "Search Emoji") do
svg(xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", aria_hidden: "true") do |s|
s.circle(cx: "12", cy: "12", r: "10"); s.path(d: "M8 14s1.5 2 4 2 4-2 4-2"); s.line(x1: "9", x2: "9.01", y1: "9", y2: "9"); s.line(x1: "15", x2: "15.01", y1: "9", y2: "9")
end
span { "Search Emoji" }
end
CommandItem(value: "Calculator", disabled: true) do
svg(xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", aria_hidden: "true") do |s|
s.rect(width: "16", height: "20", x: "4", y: "2", rx: "2"); s.line(x1: "8", x2: "16", y1: "6", y2: "6"); s.path(d: "M16 14v4"); s.path(d: "M8 10h.01"); s.path(d: "M12 10h.01"); s.path(d: "M16 10h.01"); s.path(d: "M8 14h.01"); s.path(d: "M12 14h.01"); s.path(d: "M8 18h.01"); s.path(d: "M12 18h.01")
end
span { "Calculator" }
end
end
CommandSeparator()
CommandGroup(title: "Settings") do
CommandItem(value: "Profile") do
span { "Profile" }
CommandShortcut { "⌘P" }
end
CommandItem(value: "Billing") do
span { "Billing" }
CommandShortcut { "⌘B" }
end
CommandItem(value: "Settings") do
span { "Settings" }
CommandShortcut { "⌘S" }
end
end
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Dialog", description: "CommandDialog opens the Command in a modal dialog from its trigger, and with ⌘K / Ctrl+K by default. This site's search already takes ⌘K, so the example turns the shortcut off with keybindings: []. title: and description: name the dialog for screen readers and are visually hidden. show_close_button: true adds the Dialog close button; it is off by default, as in shadcn, because it sits over the search field.", context: self) do
<<~RUBY
CommandDialog(title: "Command menu", description: "Choose an action") do
CommandDialogTrigger(keybindings: []) do
Button(variant: :outline) { "Open Menu" }
end
CommandDialogContent do
Command do
CommandInput(placeholder: "Type a command or search...")
CommandList do
CommandEmpty { "No results found." }
CommandGroup(title: "Suggestions") do
CommandItem { "Calendar" }
CommandItem { "Search Emoji" }
CommandItem { "Calculator" }
end
end
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "With shortcuts", description: "CommandShortcut shows a keyboard hint at the end of an item.", context: self) do
<<~RUBY
CommandDialog do
CommandDialogTrigger(keybindings: ["keydown.ctrl+j@window", "keydown.meta+j@window"]) do
Button(variant: :outline) { "Open Menu (⌘J)" }
end
CommandDialogContent do
Command do
CommandInput(placeholder: "Type a command or search...")
CommandList do
CommandEmpty { "No results found." }
CommandGroup(title: "Settings") do
CommandItem(value: "Profile") do
span { "Profile" }
CommandShortcut { "⌘P" }
end
CommandItem(value: "Billing") do
span { "Billing" }
CommandShortcut { "⌘B" }
end
CommandItem(value: "Settings") do
span { "Settings" }
CommandShortcut { "⌘S" }
end
end
end
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Navigation", description: "An item with href: visits that page when selected (Turbo.visit when Turbo is loaded; ⌘/Ctrl opens a new tab). keywords: adds search terms beyond the value.", context: self) do
<<~RUBY
CommandDialog(title: "Search documentation", description: "Find a page in the docs") do
CommandDialogTrigger(keybindings: []) do
Button(variant: :outline) { "Search docs" }
end
CommandDialogContent do
Command do
CommandInput(placeholder: "Search documentation...")
CommandList do
CommandEmpty { "No results found." }
CommandGroup(title: "Components") do
CommandItem(value: "Accordion", href: docs_accordion_path) { "Accordion" }
CommandItem(value: "Alert", href: docs_alert_path) { "Alert" }
CommandItem(value: "Alert Dialog", href: docs_alert_dialog_path, keywords: ["confirm", "modal"]) { "Alert Dialog" }
CommandItem(value: "Badge", href: docs_badge_path) { "Badge" }
end
end
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Handling selection", description: "Selecting an item dispatches a cancelable ruby-ui--command:select event with the item's value and href in its detail. Listen to it from your own Stimulus controller; call preventDefault() to skip the href visit.", context: self) do
<<~RUBY
div(class: "w-full max-w-md rounded-xl ring-1 ring-foreground/10") do
Command(loop: true, data: {action: "ruby-ui--command:select->my-palette#run"}) do
CommandInput(placeholder: "Run an action...")
CommandList do
CommandEmpty { "No results found." }
CommandGroup(title: "Actions") do
CommandItem(value: "new-file") { "New file" }
CommandItem(value: "new-folder") { "New folder" }
CommandItem(value: "rename") { "Rename" }
end
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Scrollable", description: "CommandList scrolls past 18rem; the active item stays in view.", context: self) do
<<~RUBY
CommandDialog do
CommandDialogTrigger(keybindings: []) do
Button(variant: :outline) { "Open Menu" }
end
CommandDialogContent do
Command do
CommandInput(placeholder: "Type a command or search...")
CommandList do
CommandEmpty { "No results found." }
CommandGroup(title: "Navigation") do
%w[Home Inbox Documents Downloads Desktop Pictures Music Videos].each { |name| CommandItem { name } }
end
CommandSeparator()
CommandGroup(title: "Tools") do
%w[Calculator Calendar Clock Notes Terminal Weather].each { |name| CommandItem { name } }
end
CommandSeparator()
CommandGroup(title: "Settings") do
%w[Profile Billing Notifications Privacy Appearance Language].each { |name| CommandItem { name } }
end
end
end
end
end
RUBY
end
render Components::ComponentSetup::Tabs.new(component_name: component)
render Docs::ComponentsTable.new(component_files(component))
end
end
end
6
Add RubyUI::CommandEmpty to app/components/ruby_ui/command/command_empty.rb
# frozen_string_literal: true
module RubyUI
# Shown only while a search matches no item. Put it inside CommandList.
class CommandEmpty < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
role: "presentation",
hidden: true,
class: "py-6 text-center text-sm",
data: {slot: "command-empty", ruby_ui__command_target: "empty"}
}
end
end
end
7
Add RubyUI::CommandGroup to app/components/ruby_ui/command/command_group.rb
# frozen_string_literal: true
module RubyUI
# A titled group of CommandItems. The items sit in a role=group named after
# the heading; the group hides while a search matches none of its items.
class CommandGroup < Base
def initialize(title: nil, **attrs)
@title = title
super(**attrs)
end
def view_template(&)
div(**attrs) do
if @title
div(
aria_hidden: "true",
class: "px-2 py-1.5 text-xs font-medium text-muted-foreground",
data: {slot: "command-group-heading", ruby_ui__command_target: "heading"}
) { @title }
end
div(role: "group", data: {slot: "command-group-items"}, &)
end
end
private
def default_attrs
{
role: "presentation",
class: "overflow-hidden p-1 text-foreground",
data: {slot: "command-group", ruby_ui__command_target: "group"}
}
end
end
end
8
Add RubyUI::CommandInput to app/components/ruby_ui/command/command_input.rb
# frozen_string_literal: true
module RubyUI
# The search field of a Command: an input group with a search icon. The
# input is the combobox that controls CommandList; extra attributes go to the
# <input>.
class CommandInput < Base
# shadcn's InputGroup focus ring targets data-slot=input-group-control,
# which cmdk's input doesn't carry, so the command input shows no ring.
INPUT_GROUP = "group/input-group relative flex w-full min-w-0 items-center outline-none " \
"h-8! rounded-lg! border border-input/30 bg-input/30 shadow-none!"
ADDON = "order-first flex h-auto cursor-text items-center justify-center gap-2 py-1.5 pl-2! " \
"text-sm font-medium text-muted-foreground select-none"
def initialize(placeholder: "Type a command or search...", **attrs)
@placeholder = placeholder
super(**attrs)
end
def view_template
div(class: "p-1 pb-0", data: {slot: "command-input-wrapper"}) do
div(class: INPUT_GROUP, role: "group", data: {slot: "input-group"}) do
input(**attrs)
div(class: ADDON, role: "group", data: {slot: "input-group-addon", align: "inline-start", action: "click->ruby-ui--command#focusInput"}) do
search_icon
end
end
end
end
private
def search_icon
svg(
xmlns: "http://www.w3.org/2000/svg",
viewbox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
aria_hidden: "true",
class: "size-4 shrink-0 opacity-50"
) do |s|
s.path(d: "m21 21-4.34-4.34")
s.circle(cx: "11", cy: "11", r: "8")
end
end
def default_attrs
{
type: "text",
role: "combobox",
placeholder: @placeholder,
autocomplete: "off",
autocorrect: "off",
autocapitalize: "none",
spellcheck: "false",
autofocus: true,
aria: {autocomplete: "list", expanded: "true"},
class: "h-full w-full min-w-0 flex-1 rounded-none border-0 bg-transparent py-1 pr-2 pl-1.5 text-sm shadow-none outline-hidden ring-0 " \
"focus:ring-0 focus-visible:ring-0 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
data: {
slot: "command-input",
ruby_ui__command_target: "input",
action: "input->ruby-ui--command#filter keydown->ruby-ui--command#inputKeydown"
}
}
end
end
end
9
Add RubyUI::CommandItem to app/components/ruby_ui/command/command_item.rb
# frozen_string_literal: true
module RubyUI
# An option of a Command. value: is what the search matches (the item's text
# when omitted), keywords: adds more terms. Selecting it (click or Enter on
# the active item) dispatches a cancelable `ruby-ui--command:select` event
# and, unless prevented, visits href: when given (Turbo.visit when Turbo is
# loaded). disabled: items can't be made active or selected.
class CommandItem < Base
def initialize(value: nil, keywords: [], href: nil, disabled: false, **attrs)
@value = value
@keywords = Array(keywords)
@href = href
@disabled = disabled
super(**attrs)
end
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
role: "option",
aria: {selected: "false", disabled: (@disabled ? "true" : nil)},
class: [
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
"in-[dialog]:rounded-lg! data-selected:bg-muted data-selected:text-foreground data-selected:**:[svg]:text-foreground",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
],
data: {
slot: "command-item",
value: @value,
keywords: (@keywords.join(" ") if @keywords.any?),
href: @href,
disabled: (@disabled ? "true" : nil),
ruby_ui__command_target: "item",
action: "click->ruby-ui--command#itemClick pointermove->ruby-ui--command#itemPointermove"
}
}
end
end
end
10
Add RubyUI::CommandList to app/components/ruby_ui/command/command_list.rb
# frozen_string_literal: true
module RubyUI
# The listbox of a Command. It scrolls past 18rem (max-h-72).
class CommandList < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
role: "listbox",
tabindex: "-1",
class: "max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
data: {slot: "command-list", ruby_ui__command_target: "list"}
}
end
end
end
11
Add RubyUI::CommandSeparator to app/components/ruby_ui/command/command_separator.rb
# frozen_string_literal: true
module RubyUI
# A rule between CommandGroups, hidden while a search is typed.
class CommandSeparator < Base
def view_template
div(**attrs)
end
private
def default_attrs
{
role: "separator",
class: "-mx-1 h-px w-auto bg-border",
data: {slot: "command-separator", ruby_ui__command_target: "separator"}
}
end
end
end
12
Add RubyUI::CommandShortcut to app/components/ruby_ui/command/command_shortcut.rb
# frozen_string_literal: true
module RubyUI
# A keyboard hint at the end of a CommandItem (e.g. "⌘P").
class CommandShortcut < Base
def view_template(&)
span(**attrs, &)
end
private
def default_attrs
{
class: "ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
data: {slot: "command-shortcut"}
}
end
end
end
13
Add command_controller.js to app/javascript/controllers/ruby_ui/command_controller.js
import { Controller } from "@hotwired/stimulus";
import Fuse from "fuse.js";
// Prefix for generated ids, unique per page (ruby-ui-command-1-list, …).
let commandCount = 0;
// Connects to data-controller="ruby-ui--command"
//
// Follows the APG combobox pattern with a listbox popup: focus stays in the
// input, the active option is announced through aria-activedescendant and
// marked aria-selected="true" + data-selected. The first visible option is
// active on connect and after every search; ArrowUp/ArrowDown move it (wrapping
// only with loop: true), Home/End jump to the ends, the pointer moves it too,
// and Enter or a click selects it.
export default class extends Controller {
static targets = ["input", "list", "item", "group", "heading", "separator", "empty"];
static values = { loop: { type: Boolean, default: false } };
connect() {
this.active = null;
this.wireAria();
this.dialog = this.element.closest("dialog");
this.dialog?.addEventListener("close", this.reset);
// Reconnected (Turbo cache, morph): drop the active marks left in the DOM
// and filter by what the input still holds.
this.itemTargets.forEach((item) => this.markActive(item, false));
this.applyQuery(this.currentQuery(), { scroll: false });
this.ready = true;
}
disconnect() {
this.ready = false;
this.dialog?.removeEventListener("close", this.reset);
this.dialog = null;
this.active = null;
}
// Items added or removed later (e.g. a Turbo Stream) get an id and go through
// the current search, once per batch of changes.
itemTargetConnected(item) {
this.ensureId(item, this.nextOptionId());
this.scheduleRefresh();
}
itemTargetDisconnected(item) {
if (item === this.active) this.active = null;
this.scheduleRefresh();
}
scheduleRefresh() {
if (!this.ready || this.refreshQueued) return;
this.refreshQueued = true;
queueMicrotask(() => {
this.refreshQueued = false;
if (this.ready) this.applyQuery(this.currentQuery(), { scroll: false, keepActive: true });
});
}
// A closed dialog opens again with an empty search and the first item active.
reset = () => {
if (!this.hasInputTarget) return;
this.inputTarget.value = "";
this.applyQuery("", { scroll: false });
if (this.hasListTarget) this.listTarget.scrollTop = 0;
};
currentQuery() {
return this.hasInputTarget ? this.inputTarget.value.trim() : "";
}
focusInput() {
if (this.hasInputTarget) this.inputTarget.focus();
}
filter() {
this.applyQuery(this.currentQuery());
}
// Shows the items matching query and makes the first selectable one active
// (or keeps the active one when keepActive and it is still shown). Scrolls
// the list to it only for user actions: scrolling on connect could move the
// whole page to a Command below the fold.
applyQuery(query, { scroll = true, keepActive = false } = {}) {
if (query.length === 0) {
this.itemTargets.forEach((item) => (item.hidden = false));
} else {
const matches = new Set(this.index().search(query).map((result) => result.item.element));
this.itemTargets.forEach((item) => (item.hidden = !matches.has(item)));
}
this.groupTargets.forEach((group) => {
group.hidden = !this.itemTargets.some((item) => !item.hidden && group.contains(item));
});
// Separators only make sense between full groups.
this.separatorTargets.forEach((separator) => (separator.hidden = query.length > 0));
const visible = this.visibleItems();
this.emptyTargets.forEach((empty) => (empty.hidden = visible.length > 0));
const selectable = visible.filter((item) => !this.isDisabled(item));
const next = keepActive && selectable.includes(this.active) ? this.active : selectable[0] || null;
this.activate(next, { scroll });
}
// Built on every search: a morph or the app can change an item's text,
// data-value or data-keywords without reconnecting it, and command menus
// are small enough that rebuilding costs nothing noticeable.
index() {
const entries = this.itemTargets.map((element) => ({
value: this.valueOf(element),
keywords: element.dataset.keywords || "",
element,
}));
return new Fuse(entries, { keys: ["value", "keywords"], threshold: 0.2, ignoreLocation: true });
}
inputKeydown(event) {
if (event.isComposing) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
this.move(1);
break;
case "ArrowUp":
event.preventDefault();
this.move(-1);
break;
case "Home":
case "End": {
const items = this.selectableItems();
// Nothing to jump to: leave Home/End to the text caret.
if (items.length === 0) break;
event.preventDefault();
this.activate(event.key === "Home" ? items[0] : items[items.length - 1]);
break;
}
case "Enter":
if (!this.active) return;
event.preventDefault();
this.select(this.active, event);
break;
}
}
move(step) {
const items = this.selectableItems();
if (items.length === 0) return;
const index = items.indexOf(this.active);
let next = index === -1 ? (step > 0 ? 0 : items.length - 1) : index + step;
if (next < 0 || next >= items.length) {
if (!this.loopValue) return;
next = (next + items.length) % items.length;
}
this.activate(items[next]);
}
itemPointermove(event) {
const item = event.currentTarget;
if (item !== this.active && !this.isDisabled(item)) this.activate(item, { scroll: false });
}
itemClick(event) {
const item = event.currentTarget;
if (this.isDisabled(item)) return;
// The click moved focus to the list (tabindex=-1); the keyboard handlers
// live on the input, so bring it back.
this.focusInput();
this.activate(item, { scroll: false });
this.select(item, event);
}
// Dispatches ruby-ui--command:select (cancelable) and, unless prevented,
// follows the item's href. ⌘/Ctrl opens the href in a new tab.
select(item, event) {
// The item may have been disabled after it became active.
if (this.isDisabled(item)) return;
const value = this.valueOf(item);
const href = item.dataset.href;
const selectEvent = this.dispatch("select", { detail: { value, href, item }, cancelable: true });
if (selectEvent.defaultPrevented || !href) return;
if (event?.metaKey || event?.ctrlKey) {
window.open(href, "_blank", "noopener");
} else if (window.Turbo?.visit) {
window.Turbo.visit(href);
} else {
window.location.assign(href);
}
}
activate(item, { scroll = true } = {}) {
if (this.active && this.active !== item) this.markActive(this.active, false);
this.active = item;
if (!item) {
if (this.hasInputTarget) this.inputTarget.removeAttribute("aria-activedescendant");
return;
}
this.markActive(item, true);
if (this.hasInputTarget) this.inputTarget.setAttribute("aria-activedescendant", item.id);
if (scroll) item.scrollIntoView({ block: "nearest" });
}
markActive(item, active) {
item.setAttribute("aria-selected", String(active));
if (active) item.setAttribute("data-selected", "true");
else item.removeAttribute("data-selected");
}
visibleItems() {
return this.itemTargets.filter((item) => !item.hidden);
}
selectableItems() {
return this.visibleItems().filter((item) => !this.isDisabled(item));
}
isDisabled(item) {
return item.dataset.disabled === "true";
}
valueOf(item) {
return item.dataset.value || item.textContent.trim();
}
// Ties the input to the listbox, names each group after its heading and
// gives every option an id for aria-activedescendant. Never overwrites ids or
// ARIA the author already set.
wireAria() {
const prefix = this.idPrefix();
if (this.hasListTarget) {
const listId = this.ensureId(this.listTarget, `${prefix}-list`);
if (this.hasInputTarget && !this.inputTarget.hasAttribute("aria-controls")) {
this.inputTarget.setAttribute("aria-controls", listId);
}
if (!this.listTarget.hasAttribute("aria-label") && !this.listTarget.hasAttribute("aria-labelledby")) {
this.listTarget.setAttribute("aria-label", "Suggestions");
}
}
this.headingTargets.forEach((heading, index) => {
const headingId = this.ensureId(heading, `${prefix}-group-${index}-heading`);
const items = heading.parentElement?.querySelector(":scope > [role=group]");
if (items && !items.hasAttribute("aria-labelledby")) items.setAttribute("aria-labelledby", headingId);
});
this.itemTargets.forEach((item) => this.ensureId(item, this.nextOptionId()));
}
idPrefix() {
this.prefix ||= `ruby-ui-command-${++commandCount}`;
return this.prefix;
}
nextOptionId() {
this.optionCount = (this.optionCount || 0) + 1;
return `${this.idPrefix()}-option-${this.optionCount - 1}`;
}
ensureId(element, id) {
if (!element.id) element.id = id;
return element.id;
}
}
14
Update the Stimulus controllers manifest file
rake stimulus:manifest:update15
Install fuse.js Javascript dependency
// with yarn
yarn add fuse.js
// with npm
npm install fuse.js
// with importmaps
bin/importmap pin fuse.js
16
Install required components
Component Command relies on the following RubyUI components. Refer to their individual installation guides for setup instructions:
Components
| Component | Built using | Source |
|---|---|---|
Command | Phlex | |
CommandDialog | Phlex | |
CommandDialogContent | Phlex | |
CommandDialogTrigger | Phlex | |
CommandDocs | Phlex | |
CommandEmpty | Phlex | |
CommandGroup | Phlex | |
CommandInput | Phlex | |
CommandItem | Phlex | |
CommandList | Phlex | |
CommandSeparator | Phlex | |
CommandShortcut | Phlex | |
CommandController | Stimulus JS |