defmodule PhiaUi.Components.Command do @moduledoc """ Command palette component powered by the `PhiaCommand` vanilla JavaScript hook. A command palette is a modal search interface — popularised by tools like VS Code, Linear, and Notion — that lets users quickly navigate and trigger actions by typing. It is activated globally via `Ctrl+K` / `Cmd+K` and closed with `Escape`. Search filtering is **server-side** via `phx-change` on `command_input/1`, giving you full access to your LiveView's data and LiveView Streams for efficient DOM updates. ## Architecture The component provides two top-level container options: - `command/1` — inline modal with a simple dark backdrop; good for embedded palettes within a specific page section - `command_dialog/1` — centered modal with `backdrop-blur-sm`; the standard choice for a global application-wide command palette Both use `phx-hook="PhiaCommand"` and the same keyboard behaviour. ## Sub-components | Function | Purpose | |---------------------|-----------------------------------------------------------| | `command/1` | Modal overlay with backdrop (inline variant) | | `command_dialog/1` | Centered modal with blur backdrop (global palette variant)| | `command_input/1` | Search input (`role="combobox"`, drives `phx-change`) | | `command_list/1` | Results container (`role="listbox"`) | | `command_empty/1` | Empty state message shown when results are empty | | `command_group/1` | Labeled category section for related results | | `command_item/1` | Selectable result item (`role="option"`) | | `command_separator/1` | Visual divider between groups | | `command_shortcut/1`| Right-aligned keyboard shortcut badge | ## Hook Setup Copy the hook via `mix phia.add command`, then register it in `app.js`: # assets/js/app.js import PhiaCommand from "./hooks/command" let liveSocket = new LiveSocket("/live", Socket, { hooks: { PhiaCommand } }) ## Full Example — Global Command Palette A common pattern: render the palette once in your app layout, use a LiveView assign for search results, and navigate on item selection. <%# In root.html.heex or app.html.heex, or in a persistent LiveView %> <.command_dialog id="global-cmd" title="Command Palette"> <.command_input id="global-cmd-input" on_change="cmd_search" placeholder="Search pages and actions..." /> <.command_list id="global-cmd-list"> <%= if @cmd_results == [] do %> <.command_empty>No results for "{@cmd_query}". <% else %> <.command_group :if={@cmd_results[:pages] != []} label="Pages"> <.command_item :for={page <- @cmd_results[:pages]} on_click="navigate" value={page.path}> <.icon name="file" class="mr-2 h-4 w-4" /> {page.title} <.command_shortcut :if={page.shortcut}>{page.shortcut} <.command_separator :if={@cmd_results[:pages] != [] and @cmd_results[:actions] != []} /> <.command_group :if={@cmd_results[:actions] != []} label="Actions"> <.command_item :for={action <- @cmd_results[:actions]} on_click={action.event} value={action.value}> <.icon name={action.icon} class="mr-2 h-4 w-4" /> {action.label} <% end %> # LiveView def handle_event("cmd_search", %{"value" => query}, socket) do results = MyApp.Search.command_palette(query) {:noreply, assign(socket, cmd_query: query, cmd_results: results)} end def handle_event("navigate", %{"value" => path}, socket) do {:noreply, push_navigate(socket, to: path)} end ## Example — Scoped Palette (No Dialog Chrome) Use `command/1` when you want the palette to be scoped to a section and triggered by something other than `Ctrl+K`: <.button phx-click={JS.show(to: "#local-cmd")}>Open Command <.command id="local-cmd"> <.command_input id="local-cmd-input" on_change="filter_items" /> <.command_list id="local-cmd-list"> <.command_group label="Recent Files"> <.command_item :for={f <- @filtered_files} on_click="open_file" value={f.id}> {f.name} ## Keyboard Navigation The `PhiaCommand` hook provides full WAI-ARIA keyboard support: - `Ctrl+K` / `Cmd+K` — opens the palette globally (any element focused) - `ArrowDown` / `ArrowUp` — moves focus between `command_item/1` elements - `Enter` — activates the focused item (fires its `phx-click` event) - `Escape` — closes the palette, clears the input, returns focus - `Tab` — wraps focus within the list (does not close) ## Server-Side Filtering Unlike client-side filtering (which is fast but limited to pre-loaded data), PhiaUI's command palette uses `phx-change` to send every keystroke to the LiveView. This enables: - Searching across the full database rather than a pre-loaded list - Permission-filtered results (only show actions the user can perform) - LiveView Streams for efficient DOM patching when results change - Async searches with `Task.async` and `handle_info` for heavy queries ## Accessibility - The palette uses `role="dialog"` and `aria-modal="true"` — screen readers treat it as a modal and restrict virtual cursor navigation to the palette - `command_input/1` has `role="combobox"` and `aria-autocomplete="list"` to declare the search+results relationship - `command_list/1` has `role="listbox"` and items have `role="option"` - Selected items are indicated via `aria-selected` and `data-[selected]` CSS - The `aria-label` on `command_dialog/1` provides a name for the dialog announced when it opens """ use Phoenix.Component import PhiaUi.ClassMerger, only: [cn: 1] # --------------------------------------------------------------------------- # command/1 # --------------------------------------------------------------------------- attr(:id, :string, required: true, doc: "Unique ID for the command modal element — the hook mount point" ) attr(:class, :string, default: nil, doc: "Additional CSS classes") attr(:rest, :global, doc: "Extra HTML attributes forwarded to the root `