defmodule Shadix.Website.CommandPalette do @moduledoc """ A ⌘K / Ctrl+K command palette for the Shadix docs site. A LiveComponent that renders a search trigger (in the top nav) plus a modal dialog built on `Shadix.Components.Dialog`, hosting a `Shadix.Components.Command` whose items are the site's docs pages and documented components. Typing filters server-side (per keystroke); results are ranked by relevance and shown in two labelled sections — Pages and Components — the higher-scoring section first. Selecting an item live-navigates to its page and closes the palette. The ranking/sectioning helpers (`entries/0`, `score/2`, `search/1`) are pure so they can be unit-tested without a live process. """ use Phoenix.LiveComponent import Shadix.Components.Dialog, only: [dialog: 1, hide_dialog: 1, dialog_title: 1, dialog_description: 1] import Shadix.Components.Command alias Phoenix.LiveView.JS alias Shadix.Website.Components.Catalog # The non-component docs pages. Today: just Introduction. Add entries here to # populate the Pages section further (each needs a route + a doc module). @pages [ %{type: :page, label: "Introduction", path: "/", sub: "Getting started", slug: nil} ] @doc """ The flat list of searchable entries: pages first, then every documented component from `Catalog.all()` mapped to a labelled entry. """ def entries do @pages ++ component_entries() end defp component_entries do Enum.map(Catalog.all(), fn entry -> %{ type: :component, label: Catalog.humanize(entry.slug), path: "/components/#{entry.slug}", sub: entry.category, slug: entry.slug } end) end @doc """ Scores an entry against a query. Higher is better; `nil` means no match. Tiers (best first): exact label/slug equality (4), prefix match (3), word-boundary match (2), plain substring (1), no match (`nil`). Matching is case-insensitive across the entry's label and (for components) its slug. The best applicable tier wins. """ def score(entry, query) do q = query |> String.trim() |> String.downcase() label = String.downcase(entry.label) slug = entry[:slug] && String.downcase(entry.slug) cond do q == "" -> 4 q == label or q == slug -> 4 String.starts_with?(label, q) or (slug && String.starts_with?(slug, q)) -> 3 word_boundary?(label, q) or (slug && word_boundary?(slug, q)) -> 2 String.contains?(label, q) or (slug && String.contains?(slug, q)) -> 1 true -> nil end end # True if `q` is a prefix of any token of `haystack` (tokens split on `_` # and space). Used for the word-boundary tier. defp word_boundary?(haystack, q) do haystack |> String.split(~r/[_\s]+/, trim: true) |> Enum.any?(&String.starts_with?(&1, q)) end @doc """ Filters and sections the entries for `query`. Returns `%{pages: [...], components: [...], sections: [...]}` where each list is ranked (highest score first, alphabetical by label as tiebreak) and `sections` lists the non-empty sections in render order: the section whose top hit scores higher comes first, pages winning ties. """ def search(query) do scored = entries() |> Enum.map(fn entry -> {score(entry, query), entry} end) |> Enum.reject(fn {s, _} -> is_nil(s) end) |> Enum.sort_by(fn {score, entry} -> {-score, entry.label} end) pages = Enum.map(Enum.filter(scored, fn {_, e} -> e.type == :page end), &elem(&1, 1)) components = Enum.map(Enum.filter(scored, fn {_, e} -> e.type == :component end), &elem(&1, 1)) sections = case {pages, components} do {[], []} -> [] {[], _} -> [:components] {_, []} -> [:pages] {[page_top | _], [comp_top | _]} -> page_score = score(page_top, query) comp_score = score(comp_top, query) if comp_score > page_score, do: [:components, :pages], else: [:pages, :components] end %{pages: pages, components: components, sections: sections} end @impl true def mount(socket) do {:ok, assign(socket, q: "", results: search(""))} end @impl true def update(assigns, socket) do {:ok, assign(socket, assigns)} end @impl true def handle_event("search", %{"q" => q}, socket) do {:noreply, assign(socket, q: q, results: search(q))} end # Selecting an item live-navigates to its page. The dialog is closed # client-side via hide_dialog composed into the item's phx-click (see # palette_item). After navigation the LiveView unmounts and the component # remounts fresh with q: "" on the next page, so no explicit reset is needed. def handle_event("navigate", %{"path" => path}, socket) do {:noreply, push_navigate(socket, to: path)} end @impl true def render(assigns) do ~H"""
<.dialog id="command-palette" trigger_id="command-palette-trigger" class="p-0"> <:trigger> <.dialog_title id="command-palette" class="sr-only">Search docs <.dialog_description id="command-palette" class="sr-only"> Search pages and components. <.command id="command-palette" class="h-[400px] max-w-xl"> <.command_list id="command-palette"> <.command_empty>No results. <.command_group :for={section <- @results.sections} heading={if section == :pages, do: "Pages", else: "Components"} > <.palette_item :for={entry <- if(section == :pages, do: @results.pages, else: @results.components)} entry={entry} myself={@myself} />
""" end defp palette_item(assigns) do ~H""" <.command_item value={item_value(@entry)} phx-value-path={@entry.path} phx-click={hide_dialog("command-palette") |> JS.push("navigate", value: %{path: @entry.path}, target: @myself)} > {@entry.label} {@entry.sub} """ end defp item_value(%{slug: nil} = entry), do: entry.label defp item_value(entry), do: "#{entry.label} #{entry.slug}" end