Midwest::TurboCommands
Overview
Midwest::TurboCommands is a controller concern that powers a command palette with server-rendered items delivered via <turbo-frame>. Include it in a controller, call the turbo_commands macro, implement a single method that returns items, and the command palette fetches results from the server as the user types.
Items are rendered with CommandItemComponent inside a turbo-frame partial, giving you full control over what appears in the palette — pages, actions, search results, or anything else.
Setup
1. Include the concern and implement items
class DashboardController < ApplicationController include Midwest::TurboCommands turbo_commands private def midwest_command_palette_items(query) items = [ { label: "Dashboard", url: root_path, group: "Pages", icon: :home }, { label: "Settings", url: settings_path, group: "Pages", icon: :settings }, { label: "Users", url: users_path, group: "Pages", icon: :users, description: "Manage team members" }, { label: "Create Project", url: new_project_path, group: "Actions", icon: :plus, shortcut: "⌘+N" }, { label: "Toggle Dark Mode", action: "toggle-dark", group: "Actions" } ] return items if query.blank? items.select { |item| item[:label].downcase.include?(query.downcase) } endendThe midwest_command_palette_items(query) method receives the search string and returns an array of item hashes. You have full flexibility here — query multiple models, merge static actions, call external APIs, whatever fits your app.
Tip: As your item list grows, use
Midwest::CommandPaletteBuilderto declare items with built-in permission gating via apermit:callable.
2. Add the route
Register the provided routing concern once, then apply it to any resource:
concern :midwest_command_palette, Midwest::Routing::COMMAND_PALETTEresources :dashboard, concerns: :midwest_command_paletteThis adds GET /dashboard/midwest_command_palette as a collection route.
3. Wire up the component
Pass the route to turbo_frame_url: on the command palette:
<%= midwest_command_palette( turbo_frame_url: midwest_command_palette_dashboard_index_path) %>When the user opens the palette and types, the Stimulus controller sends GET /dashboard/midwest_command_palette?q=<query> and Turbo replaces the frame with the server-rendered results.
How it works
- User opens the command palette (button click or
Cmd+K) - The Stimulus controller navigates the embedded
<turbo-frame>by setting itssrcattribute - The server receives
GET /controller/midwest_command_palette?q=query - Your
midwest_command_palette_items(query)method returns matching items - The built-in partial renders each item with
CommandItemComponentinside a turbo-frame response - Turbo replaces the frame content — keyboard navigation and selection work immediately
Search requests are debounced at 200ms to avoid excessive server hits while the user types.
Item hash keys
Each hash returned from midwest_command_palette_items supports these keys:
| Key | Required | Description |
|---|---|---|
label: |
yes | Display text for the item |
url: |
no | Navigation URL — selecting the item visits this path via Turbo |
action: |
no | Custom action name — selecting dispatches a midwest-command-palette:action event |
description: |
no | Secondary text shown below the label |
icon: |
no | Icon name symbol (e.g. :home, :settings) |
shortcut: |
no | Keyboard shortcut string (e.g. "⌘+D") — rendered as <kbd> elements |
group: |
no | Group name — items are visually grouped under this heading |
Items with a url: navigate on selection. Items with an action: dispatch a custom event you can listen for:
document.addEventListener('midwest-command-palette:action', (event) => { const { action, item } = event.detail if (action === 'toggle-dark') { document.documentElement.classList.toggle('dark') }})turbo_commands options
| Option | Default | Description |
|---|---|---|
frame_id: |
"command-palette-list" |
Turbo frame ID used by the palette and the server response |
CommandItemComponent
CommandItemComponent renders a single command palette item as an <li> element. It is used automatically by the turbo-frame partial, but you can also use it directly when building a custom action.
<%= midwest_command_item( label: "Dashboard", url: root_path, icon: :home, description: "View your dashboard", shortcut: "G+D", index: 0) %>Options
| Option | Required | Description |
|---|---|---|
label: |
yes | Display text |
url: |
no | Navigation URL |
action: |
no | Custom event action name |
description: |
no | Secondary text |
icon: |
no | Icon name (renders via midwest_icon) |
shortcut: |
no | Keyboard shortcut, +-delimited (e.g. "⌘+K") |
index: |
no | Flat item position for keyboard navigation |
Slots
| Slot | Description |
|---|---|
icon_slot |
Custom icon content — overrides the icon: string |
description_slot |
Custom description content — overrides the description: string |
<%= midwest_command_item(label: "Deploy") do |item| %> <% item.with_icon_slot do %> <span class="text-green-500"><%= midwest_icon(:rocket) %></span> <% end %> <% item.with_description_slot do %> Deploy to <strong>production</strong> <% end %><% end %>Rolling your own action
If the default concern doesn't fit your needs, skip it and write the action yourself. Return a turbo-frame response that contains a <ul> with CommandItemComponent items:
def search_commands query = params[:q].to_s.strip @pages = Page.search(query).limit(10) @actions = Action.where(enabled: true).search(query).limit(5)end<%= turbo_frame_tag request.headers["Turbo-Frame"] do %> <ul class="command-palette-list" role="listbox" data-midwest-command-palette-target="list"> <li class="command-palette-group-label" role="presentation">Pages</li> <% @pages.each_with_index do |page, i| %> <%= midwest_command_item( label: page.title, url: page.path, icon: :file, index: i ) %> <% end %> <li class="command-palette-group-label" role="presentation">Actions</li> <% @actions.each_with_index do |act, i| %> <%= midwest_command_item( label: act.name, action: act.key, icon: act.icon, description: act.description, index: @pages.size + i ) %> <% end %> </ul><% end %>The key requirements for a custom response:
- Wrap in
turbo_frame_tag request.headers["Turbo-Frame"]to match the frame ID - The
<ul>must havedata-midwest-command-palette-target="list"for Stimulus to discover it - Each item needs a flat
index:value across all groups for keyboard navigation to work
Three modes compared
The command palette supports three ways to load items:
Client-side (items:) |
JSON (src:) |
Turbo frame (turbo_frame_url:) |
|
|---|---|---|---|
| Where items live | Serialized in the HTML as a data attribute | JSON API endpoint | Rails controller action |
| Rendering | JS builds DOM client-side | JS builds DOM from JSON | Server renders HTML, Turbo replaces frame |
| Filtering | Client-side string match | Server handles search | Server handles search |
| Custom markup | No (JS template only) | No (JS template only) | Yes — full ERB with any component |
| Best for | Small static lists | Large datasets, simple items | Rich items, model queries, full flexibility |