---
title: Cmd+K Global Search (search across many tables at once)
slug: global-search-command-palette
category: Navigation
summary: A Cmd+K / click-the-magnifying-glass command palette that opens a modal, queries several tables at once, and streams grouped, clickable results back via Turbo.
tags: [stimulus, turbo, search, command-palette, cmd-k, navigation, ux-default]
status: stable
visibility: public
source_project: llamapress.ai admin
layers: [view, stimulus_js, controller]
---

# Cmd+K Global Search (search across many tables at once)

> ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below
> is an **example snippet**, **not part of the llamapress.ai codebase**, and **not
> running on this server**. This is a reference recipe for a **Leo instance (an AI coding
> agent) to implement in its own app** — read it to understand the pattern, then recreate
> it there.

Once an app has more than a couple of tables, "where do I find X?" becomes the main
friction. A **global search** kills it: the user hits **Cmd+K** (or **Ctrl+K**) from
anywhere — or clicks the magnifying-glass icon in the header — a modal pops open with the
cursor already in the search box, they type a couple characters, and results from
**multiple tables** (users, orders, invoices, whatever) appear **grouped by type** and
**clickable straight to the record**. No page reload, no dedicated search page.

The whole thing is one Stimulus controller (keyboard + modal + fetch), one skinny
controller action that runs a few `ILIKE` queries, and a Turbo Stream that swaps grouped
results into the modal. ~90 lines total.

> **When to use:** any admin/dashboard app with several browseable tables — the moment a
> nav menu can't hold every "find a ___" path. **When not to:** a single-table app (just
> put a filter box on the index), or when you need real full-text relevance ranking across
> huge tables (reach for Postgres `tsvector` / pg_search / Elasticsearch instead — this
> recipe is deliberately a simple substring match).

---

## The 80/20 in one breath

1. A `<dialog>` modal lives once in your layout (in the header, next to the search icon).
2. A `global-search` **Stimulus controller** listens for **Cmd/Ctrl+K** globally to open
   it, **Escape** to close, and focuses the input on open.
3. Typing submits the form; the controller **`fetch`es** the search endpoint asking for a
   **Turbo Stream** (not a full page).
4. A tiny `SearchController#index` runs **one `ILIKE` query per table** (users, instances,
   …), each `.limit(10)`.
5. The Turbo Stream **replaces the results div** with a partial that renders each table's
   hits as its own labeled group of links.

That's it — steps 1–2 are the palette, 3–5 are the multi-table search.

---

## Layer 1 — The route

```ruby
# config/routes.rb
# One GET endpoint. Namespace/authorize it however your app does (admin-only here).
namespace :admin do
  get "search", to: "search#index"    # -> admin_search_path
end
```

---

## Layer 2 — The controller (this is where "many tables" lives)

Each table you want searchable = one more `ILIKE` query. Keep every result set small
(`.limit`) so the modal stays fast and the payload stays tiny.

```ruby
# app/controllers/admin/search_controller.rb
class Admin::SearchController < ApplicationController
  before_action :authenticate_user!
  before_action :ensure_operator_or_admin   # your own authz — don't leak all rows

  def index
    @query = params[:q]

    if @query.present? && @query.length >= 2
      # ALWAYS sanitize before interpolating into an ILIKE pattern.
      sanitized = ActiveRecord::Base.sanitize_sql_like(@query)
      pattern   = "%#{sanitized}%"

      # --- one block per table you want in the palette ---
      @users = User
        .where("email ILIKE :q OR first_name ILIKE :q OR last_name ILIKE :q", q: pattern)
        .order(:email)
        .limit(10)

      @user_instances = UserInstance.includes(:user)   # includes(:user) avoids N+1 in the view
        .where("dns_name ILIKE :q", q: pattern)
        .order(:dns_name)
        .limit(10)

      # @orders   = Order.where("number ILIKE :q", q: pattern).limit(10)
      # @invoices = Invoice.where("reference ILIKE :q", q: pattern).limit(10)
    else
      @users = []
      @user_instances = []
    end

    respond_to do |format|
      format.html          # standalone /admin/search page (graceful fallback, no JS)
      format.turbo_stream  # what the palette fetches
    end
  end
end
```

---

## Layer 3 — The Stimulus controller (keyboard + modal + fetch)

```javascript
// app/javascript/controllers/global_search_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["modal", "input", "results", "form"]

  connect() {
    // Bind once so add/removeEventListener reference the SAME function.
    this.boundKeydown = this.keydown.bind(this)
    window.addEventListener("keydown", this.boundKeydown)
  }

  disconnect() {
    window.removeEventListener("keydown", this.boundKeydown)  // clean up on Turbo nav
  }

  keydown(event) {
    if ((event.metaKey || event.ctrlKey) && event.key === "k") {
      event.preventDefault()   // stop the browser's own Cmd+K (focus address bar)
      this.open()
    }
    if (event.key === "Escape") {
      this.close()
    }
  }

  open() {
    this.modalTarget.showModal()             // native <dialog> — focus trap + backdrop free
    setTimeout(() => this.inputTarget.focus(), 100)  // wait for the dialog to paint
  }

  close() {
    this.modalTarget.close()
  }

  search(event) {
    event.preventDefault()
    const query = this.inputTarget.value.trim()
    if (query.length < 2) return             // mirror the controller's 2-char floor

    fetch(`${this.formTarget.action}?q=${encodeURIComponent(query)}`, {
      headers: { "Accept": "text/vnd.turbo-stream.html" }   // ask for a stream, not HTML
    })
      .then(response => response.text())
      .then(html => Turbo.renderStreamMessage(html))        // Turbo swaps the results div
  }
}
```

---

## Layer 4 — The modal markup (icon + Cmd+K trigger, lives once in the layout)

Uses a native `<dialog>` (free focus-trap, Escape, and backdrop) plus DaisyUI/Tailwind
classes. The whole block is wrapped in one `data-controller="global-search"`.

```erb
<%# app/views/layouts/application.html.erb — put this in the header/navbar %>
<div data-controller="global-search">
  <%# The magnifying-glass button — click to open %>
  <button data-action="click->global-search#open"
          class="btn btn-ghost btn-circle btn-sm tooltip tooltip-bottom"
          data-tip="Search (Cmd+K)">
    <i class="fas fa-search text-sm"></i>
  </button>

  <dialog data-global-search-target="modal" class="modal">
    <div class="modal-box max-w-2xl bg-white p-0 overflow-hidden">
      <%= form_with url: admin_search_path, method: :get,
            data: { action: "submit->global-search#search", global_search_target: "form" },
            class: "relative" do |f| %>
        <div class="flex items-center border-b border-slate-100 px-4 py-3">
          <i class="fas fa-search text-slate-400 mr-3"></i>
          <%= f.text_field :q,
                placeholder: "Search users or DNS names...",
                class: "input input-ghost w-full focus:outline-none px-0 text-lg",
                data: { global_search_target: "input" },
                autocomplete: "off" %>
          <kbd class="kbd kbd-sm">ESC</kbd>
        </div>
      <% end %>

      <%# Turbo replaces the INNER content of this id; the wrapper id must be stable. %>
      <div id="global_search_results" data-global-search-target="results"
           class="max-h-[60vh] overflow-y-auto">
        <div class="p-8 text-center text-slate-400">
          <i class="fas fa-search text-4xl mb-3 opacity-20"></i>
          <p>Start typing to search...</p>
        </div>
      </div>
    </div>

    <%# Native <dialog> backdrop — click outside to close %>
    <form method="dialog" class="modal-backdrop"><button>close</button></form>
  </dialog>
</div>
```

---

## Layer 5 — The Turbo Stream + grouped results partial

The stream targets the **same `id`** the input listens against, and re-renders one group
per table. Each row is a `link_to` straight to the record — that's what makes it a
*navigation* tool, not just a lookup.

```erb
<%# app/views/admin/search/index.turbo_stream.erb %>
<%= turbo_stream.update "global_search_results" do %>
  <%= render partial: "admin/search/results",
        locals: { users: @users, user_instances: @user_instances, query: @query } %>
<% end %>
```

```erb
<%# app/views/admin/search/_results.html.erb %>
<div class="py-2">
  <% if users.any? || user_instances.any? %>

    <%# --- Group 1: Users --- %>
    <% if users.any? %>
      <div class="px-4 py-2 bg-slate-50 text-[10px] font-bold uppercase tracking-widest text-slate-500 border-y flex items-center gap-2">
        <i class="fas fa-users"></i> Users
      </div>
      <div class="divide-y divide-slate-50">
        <% users.each do |user| %>
          <%= link_to admin_user_path(user), class: "flex items-center px-4 py-3 hover:bg-indigo-50 group" do %>
            <div class="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center font-bold text-xs mr-3">
              <%= user.email[0].upcase %>
            </div>
            <div class="flex-1 min-w-0">
              <div class="text-sm font-semibold text-slate-800 truncate"><%= user.email %></div>
              <div class="text-xs text-slate-500 truncate"><%= user.full_name %></div>
            </div>
            <i class="fas fa-chevron-right text-[10px] text-slate-300"></i>
          <% end %>
        <% end %>
      </div>
    <% end %>

    <%# --- Group 2: another table (copy this block per table) --- %>
    <% if user_instances.any? %>
      <div class="px-4 py-2 bg-slate-50 text-[10px] font-bold uppercase tracking-widest text-slate-500 border-y mt-2 flex items-center gap-2">
        <i class="fas fa-server"></i> Instances
      </div>
      <div class="divide-y divide-slate-50">
        <% user_instances.each do |instance| %>
          <%= link_to admin_user_instance_path(instance), class: "flex items-center px-4 py-3 hover:bg-indigo-50 group" do %>
            <div class="flex-1 min-w-0">
              <div class="text-sm font-semibold text-slate-800 truncate"><%= instance.dns_name %></div>
              <div class="text-xs text-slate-500 truncate">Owner: <%= instance.user.email %></div>
            </div>
          <% end %>
        <% end %>
      </div>
    <% end %>

  <%# --- Empty vs. prompt states --- %>
  <% elsif query.present? && query.length >= 2 %>
    <div class="p-12 text-center text-slate-400">
      <i class="fas fa-search-minus text-4xl mb-3 opacity-20"></i>
      <p>No results for "<span class="font-semibold text-slate-600"><%= query %></span>"</p>
    </div>
  <% else %>
    <div class="p-12 text-center text-slate-400">
      <p>Type at least 2 characters to search...</p>
    </div>
  <% end %>
</div>
```

---

## Gotchas (the hard-won stuff)

- **`event.preventDefault()` on Cmd+K is mandatory.** Browsers bind Cmd/Ctrl+K to
  "focus the address bar / search." Without the preventDefault your modal fights the
  browser and often loses. Handle **both** `metaKey` (Mac) and `ctrlKey` (Win/Linux).
- **Bind the keydown handler once and remove it on `disconnect`.** `this.keydown.bind(this)`
  returns a *new* function each call, so you must stash it (`this.boundKeydown`) to
  `removeEventListener` the *same* reference. Skip this and every Turbo navigation stacks
  another live listener → Cmd+K fires N times, N modals, leaks.
- **Focus after a `setTimeout`, not synchronously.** A native `<dialog>` isn't focusable
  the instant you call `showModal()`; `inputTarget.focus()` on the same tick silently
  no-ops. The ~100ms delay lets it paint first.
- **Use a native `<dialog>` + `showModal()`.** You get a focus-trap, Escape-to-close, and a
  `::backdrop` for free — no custom overlay z-index wars. The `<form method="dialog">`
  backdrop closes on outside-click with zero JS.
- **The Turbo target id must be stable and match.** The controller renders into
  `#global_search_results`; the layout div carries that id. Rename one and results
  silently vanish (the stream targets a node that isn't there).
- **`Accept: text/vnd.turbo-stream.html` is what makes it a stream.** Drop that header and
  Rails serves the full HTML page, `Turbo.renderStreamMessage` gets a whole document, and
  nothing swaps. It pairs with `format.turbo_stream` in the controller.
- **ALWAYS `ActiveRecord::Base.sanitize_sql_like` the query** before building the `ILIKE`
  pattern. A raw `%` or `_` from the user otherwise becomes a wildcard, and unsanitized
  interpolation is an injection vector. Bind with `:q`, never string-interpolate the value.
- **`.limit(10)` every table and enforce a 2-char floor** — in *both* the JS and the
  controller. A 1-char search matches half your DB; unbounded results make the payload huge
  and the modal janky. The floor lives in two places on purpose (fast client bail + server
  guard).
- **`includes(:user)` (or your association) on any table whose row renders an association**,
  or the results partial fires an N+1 across up to 10 rows per group.
- **Keep the `format.html` fallback.** The same action doubles as a real `/admin/search`
  page for no-JS / deep-link / bookmark cases. It costs one `respond_to` branch.
- **Font Awesome isn't guaranteed on every Leo box.** This recipe uses `fa-search` etc. If
  FA isn't loaded, swap in an inline SVG magnifier — don't ship a blank button.
- **Authorize the endpoint.** It reaches across tables, so it can leak everything if
  unguarded. Gate it (`ensure_operator_or_admin` here) exactly like the records it exposes.

---

## Files this pattern touches

```
config/routes.rb                                      # one GET endpoint
app/controllers/admin/search_controller.rb            # one ILIKE query per table
app/javascript/controllers/global_search_controller.js # keyboard + modal + fetch
app/views/layouts/application.html.erb                # the icon + <dialog> modal (once)
app/views/admin/search/index.turbo_stream.erb         # swaps the results div
app/views/admin/search/_results.html.erb              # grouped, clickable results
app/views/admin/search/index.html.erb                 # (optional) no-JS fallback page
```

## How to adapt to your schema

1. **Pick the tables** users hunt for most (5–7 max — the palette is for "jump to a
   record," not analytics). For each, add one `ILIKE` block in `SearchController#index` and
   set an `@ivar`.
2. **Choose the searchable columns per table** — the human-recognizable ones (name, email,
   number, reference, title). Widen with `OR col ILIKE :q` as needed; keep it to a handful.
3. **Add a group** in `_results.html.erb`: copy an existing `<% if @things.any? %>` block,
   give it a header + icon, and make each row a `link_to <thing>_path(thing)` so it
   navigates on click.
4. **Wire the new ivars** through `index.turbo_stream.erb`'s `locals:`.
5. **Drop the parts you don't need:** the `format.html` fallback page is optional; the
   avatar/badge chrome is cosmetic. The four load-bearing pieces are the Stimulus
   controller, the `<dialog>` markup, the controller queries, and the stream+partial.
6. **Outgrown substring match?** When tables get big or you need relevance ranking, keep
   this exact UI and swap only the controller's queries for Postgres full-text
   (`tsvector` / the `pg_search` gem). The palette, modal, and Turbo plumbing don't change.
