Command
Fast, composable, unstyled command menu for Phlex.
Usage
Example
CommandDialog do CommandDialogTrigger do Button(variant: "outline", class: 'w-56 pr-2 pl-3 justify-between') do div(class: "flex items-center space-x-1") do search_icon span(class: "text-muted-foreground font-normal") do plain "Search" end end ShortcutKey do span(class: "text-xs") { "⌘" } plain "K" end end end CommandDialogContent do Command do CommandInput(placeholder: "Type a command or search...") CommandEmpty { "No results found." } CommandList do CommandGroup(title: "Components") do components_list.each do |component| CommandItem(value: component[:name], href: component[:path]) do default_icon plain component[:name] end end end CommandGroup(title: "Settings") do settings_list.each do |setting| CommandItem(value: setting[:name], href: setting[:path]) do default_icon plain setting[:name] end end end end end end end
With keybinding
Press⌘J
CommandDialog do CommandDialogTrigger(keybindings: ['keydown.ctrl+j@window', 'keydown.meta+j@window']) do p(class: "text-sm text-muted-foreground") do span(class: 'mr-1') { "Press" } ShortcutKey do span(class: "text-xs") { "⌘" } plain "J" end end end CommandDialogContent do Command do CommandInput(placeholder: "Type a command or search...") CommandEmpty { "No results found." } CommandList do CommandGroup(title: "Components") do components_list.each do |component| CommandItem(value: component[:name], href: component[:path]) do default_icon plain component[:name] end end end CommandGroup(title: "Settings") do settings_list.each do |setting| CommandItem(value: setting[:name], href: setting[:path]) do default_icon plain setting[:name] end end 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.rb
# frozen_string_literal: true module RubyUI class Command < Base def view_template(&) div(**attrs, &) end end end
2
Add RubyUI::CommandDialog
to app/components/ruby_ui/command/command_dialog.rb
# frozen_string_literal: true module RubyUI class CommandDialog < Base def view_template(&) div(**attrs, &) end private def default_attrs { data: {controller: "ruby-ui--command"} } end end end
3
Add RubyUI::CommandDialogContent
to app/components/ruby_ui/command/command_dialog_content.rb
# frozen_string_literal: true module RubyUI class CommandDialogContent < Base SIZES = { xs: "max-w-sm", sm: "max-w-md", md: "max-w-lg", lg: "max-w-2xl", xl: "max-w-4xl", full: "max-w-full" } def initialize(size: :md, **attrs) @size = size super(**attrs) end def view_template(&block) template(data: {ruby_ui__command_target: "content"}) do div(data: {controller: "ruby-ui--command"}) do backdrop div(**attrs, &block) end end end private def default_attrs { data_state: "open", class: [ "fixed pointer-events-auto left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full", SIZES[@size] ] } end def backdrop div( data_state: "open", data_action: "click->ruby-ui--command#dismiss esc->ruby-ui--command#dismiss", class: "fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" ) end end end
4
Add RubyUI::CommandDialogTrigger
to app/components/ruby_ui/command/command_dialog_trigger.rb
# frozen_string_literal: true module RubyUI 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--command#open" } super(**attrs) end def view_template(&) div(**attrs, &) end private def default_attrs { data: { action: ["click->ruby-ui--command#open", @keybindings.join(" ")] } } end end end
5
Add RubyUI::CommandEmpty
to app/components/ruby_ui/command/command_empty.rb
# frozen_string_literal: true module RubyUI class CommandEmpty < Base def view_template(&) div(**attrs, &) end private def default_attrs { class: "py-6 text-center text-sm", role: "presentation", data: {ruby_ui__command_target: "empty"} } end end end
6
Add RubyUI::CommandGroup
to app/components/ruby_ui/command/command_group.rb
# frozen_string_literal: true module RubyUI class CommandGroup < Base def initialize(title: nil, **attrs) @title = title super(**attrs) end def view_template(&block) div(**attrs) do render_header if @title render_items(&block) end end private def render_header div(group_heading: @title) do @title end end def render_items(&) div(group_items: "", role: "group", &) end def default_attrs { class: "overflow-hidden p-1 text-foreground [&_[group-heading]]:px-2 [&_[group-heading]]:py-1.5 [&_[group-heading]]:text-xs [&_[group-heading]]:font-medium [&_[group-heading]]:text-muted-foreground", role: "presentation", data: { value: @title, ruby_ui__command_target: "group" } } end end end
7
Add RubyUI::CommandInput
to app/components/ruby_ui/command/command_input.rb
# frozen_string_literal: true module RubyUI class CommandInput < Base def initialize(placeholder: "Type a command or search...", **attrs) @placeholder = placeholder super(**attrs) end def view_template input_container do search_icon input(**attrs) end end private def search_icon svg( xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 20 20", fill: "currentColor", class: "w-4 h-4 mr-1.5" ) do |s| s.path( fill_rule: "evenodd", d: "M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z", clip_rule: "evenodd" ) end end def input_container(&) div(class: "flex items-center border-b px-3", &) end def default_attrs { class: "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", placeholder: @placeholder, data_action: "input->ruby-ui--command#filter keydown.down->ruby-ui--command#handleKeydown keydown.up->ruby-ui--command#handleKeydown keydown.enter->ruby-ui--command#handleKeydown keydown.esc->ruby-ui--command#dismiss", data_ruby_ui__command_target: "input", autocomplete: "off", autocorrect: "off", spellcheck: false, autofocus: true, aria_autocomplete: "list", role: "combobox", aria_expanded: true, value: "" } end end end
8
Add RubyUI::CommandItem
to app/components/ruby_ui/command/command_item.rb
# frozen_string_literal: true module RubyUI class CommandItem < Base def initialize(value:, text: "", href: "#", **attrs) @value = value @text = text @href = href super(**attrs) end def view_template(&) a(**attrs, &) end private def default_attrs { class: "relative flex cursor-pointer select-none items-center gap-x-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", href: @href, role: "option", data: { ruby_ui__command_target: "item", value: @value, text: @text } # aria_selected: "true", # Toggles aria-selected="true" on keydown } end end end
9
Add RubyUI::CommandList
to app/components/ruby_ui/command/command_list.rb
# frozen_string_literal: true module RubyUI class CommandList < Base def view_template(&) div(**attrs, &) end private def default_attrs { class: "divide-y divide-border" } end end end
10
Add command_controller.js
to app/javascript/controllers/ruby_ui/command_controller.js
import { Controller } from "@hotwired/stimulus"; import Fuse from "fuse.js"; // Connects to data-controller="ruby-ui--command" export default class extends Controller { static targets = ["input", "group", "item", "empty", "content"]; static values = { open: { type: Boolean, default: false, }, }; connect() { this.inputTarget.focus(); this.searchIndex = this.buildSearchIndex(); this.toggleVisibility(this.emptyTargets, false); this.selectedIndex = -1; if (this.openValue) { this.open(); } } open(e) { e.preventDefault(); document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML); // prevent scroll on body document.body.classList.add("overflow-hidden"); } dismiss() { // allow scroll on body document.body.classList.remove("overflow-hidden"); // remove the element console.log("this.element", this.element); this.element.remove(); } filter(e) { // Deselect any previously selected item this.deselectAll(); const query = e.target.value.toLowerCase(); if (query.length === 0) { this.resetVisibility(); return; } this.toggleVisibility(this.itemTargets, false); const results = this.searchIndex.search(query); results.forEach((result) => this.toggleVisibility([result.item.element], true), ); this.toggleVisibility(this.emptyTargets, results.length === 0); this.updateGroupVisibility(); } toggleVisibility(elements, isVisible) { elements.forEach((el) => el.classList.toggle("hidden", !isVisible)); } updateGroupVisibility() { this.groupTargets.forEach((group) => { const hasVisibleItems = group.querySelectorAll( "[data-ruby-ui--command-target='item']:not(.hidden)", ).length > 0; this.toggleVisibility([group], hasVisibleItems); }); } resetVisibility() { this.toggleVisibility(this.itemTargets, true); this.toggleVisibility(this.groupTargets, true); this.toggleVisibility(this.emptyTargets, false); } buildSearchIndex() { const options = { keys: ["value"], threshold: 0.2, includeMatches: true, }; const items = this.itemTargets.map((el) => ({ value: el.dataset.value, element: el, })); return new Fuse(items, options); } handleKeydown(e) { const visibleItems = this.itemTargets.filter( (item) => !item.classList.contains("hidden"), ); if (e.key === "ArrowDown") { e.preventDefault(); this.updateSelectedItem(visibleItems, 1); } else if (e.key === "ArrowUp") { e.preventDefault(); this.updateSelectedItem(visibleItems, -1); } else if (e.key === "Enter" && this.selectedIndex !== -1) { e.preventDefault(); visibleItems[this.selectedIndex].click(); } } updateSelectedItem(visibleItems, direction) { if (this.selectedIndex >= 0) { this.toggleAriaSelected(visibleItems[this.selectedIndex], false); } this.selectedIndex += direction; // Ensure the selected index is within the bounds of the visible items if (this.selectedIndex < 0) { this.selectedIndex = visibleItems.length - 1; } else if (this.selectedIndex >= visibleItems.length) { this.selectedIndex = 0; } this.toggleAriaSelected(visibleItems[this.selectedIndex], true); } toggleAriaSelected(element, isSelected) { element.setAttribute("aria-selected", isSelected.toString()); } deselectAll() { this.itemTargets.forEach((item) => this.toggleAriaSelected(item, false)); this.selectedIndex = -1; } }
11
Update the Stimulus controllers manifest file
Importmap!
rake stimulus:manifest:update
12
Install fuse.js
Javascript dependency
// with yarn yarn add fuse.js // with npm npm install fuse.js // with importmaps bin/importmap pin fuse.js
Components
Component | Built using | Source |
---|---|---|
Command | Phlex | |
CommandDialog | Phlex | |
CommandDialogContent | Phlex | |
CommandDialogTrigger | Phlex | |
CommandEmpty | Phlex | |
CommandGroup | Phlex | |
CommandInput | Phlex | |
CommandItem | Phlex | |
CommandList | Phlex | |
CommandController | Stimulus JS |