Real-time Search with Phoenix LiveView and Tailwind

Hi everyone,

I recently implemented a real-time search feature in a Phoenix application using LiveView and Tailwind, and I wanted to share the code with the community.

The code includes an example of how to use LiveView to dynamically update the search results based on user input, as well as some optional TypeScript code to allow users to navigate the search results using the keyboard (up and down arrow keys).

You can check out a demo of the feature in action at travelermap.net (search bar is in top right corner), and I have to say, the results are super cool :sweat_smile:! The real-time updates are seamless and make the search experience much more intuitive.

:point_right: Here’s a link to the code on GitHub:

Let me know if you have any questions or feedback!

33 Likes

This is really awesome, and love that you made it for parks! Loved flying in to look at the different parks and read about them. The search is great too :wink: Thanks for sharing! :orange_heart:

2 Likes

I’m glad you found it interesting :slight_smile: The flying on the map is handled purely by MapLibre lib. I only had to make it play nicely with LiveView :fire:

1 Like

Thanks for sharing, always love checking out other liveview projects.

Random side question, how did you do your modals? I see you aren’t using the URL style like in the generators. Are you using a send_update or something?

Edited to add: i see the search modal is just JS, but to open like the wikipedia modal, is this similar?

Wikipedia modal is pure JS. I created this website as a static, non-live phoenix app. Now, I’m in the process of migrating to live views. But still, I think modals should be opened with JS commands to avoid round trips to the server.

4 Likes

Very nice! What mapping component are you using?

In a new Phoenix 1.7 app you get core_components.ex with a modal example it it. I’ve modified it into a TailwindUI modal:


    <!-- Logout confirmation modal -->
    <.modal
      id="confirm_logout"
      on_confirm={JS.navigate(~p"/logout")}
      on_cancel={JS.hide(to: "#confirm_logout")}
    >
      <:title>Logout</:title>
      Are you sure you?
      <:confirm>Logout</:confirm>
      <:cancel>Cancel</:cancel>
    </.modal>

No server side things at all. Check out 1.7 :slight_smile:

@ghenry I’m using 1.7 :slight_smile: I was just referencing the previous comment asking if I’m using send_update.

@maz I’m just using MapLibre - MapLibre GL JS. The map is rendered as separate LiveView with sticky: true option so it’s not re-rendered between navigations.

3 Likes

Awesome chunk of code. Thanks.

1 Like

Thanks for sharing it! I was looking for exactly this functionality. THANKS A LOT!

1 Like

Really neat ! Thank you …
I have been trying to figure out a fuzzier search than ilike … try adding this … helps with spelling mistakes etc.

    |> where(fragment("SIMILARITY(p.name, ?) > .30", ^search_query))
    |> order_by(fragment("LEVENSHTEIN(p.name, ?)", ^search_query))
2 Likes

Nice one! I recently made a similar feature. A couple of things I noticed.

You can have multiple items highlighted if your mouse is in the results section and if you use the arrow keys. Prob not really an issue but the way I approached this was to set the attribute aria-selected on the item using a combination of keydown and mouseenter/mouseleave events.

If you have a long list of results and you use the arrow keys it doesn’t auto scroll. Look into scrollIntoView.

You should probably also use <.focus_wrap /> around your modal for accessibility.

How did did you approach changing the keyboard shortcut icon e.g. the command icon to a windows icon based on the users OS?

1 Like

@milangupta Nice suggestion, thanks. I will add this soon.

@addstar Thanks for all the pointers. I will need to work on improving that key selects. I didn’t approach changing the keyboard shortcut at all yet.

Maybe I will have to use some JS, like this one How to find the operating system details using JavaScript? - Stack Overflow

I just stumbled upon this post :slight_smile: Pass User Agent info to your LiveView · Fly

@caspg , Thanks for sharing this!

I’m very new to Phoenix and Elixir, and I’m struggling to figure out how to add the search form to my app.html.heex file. (and I’m not actually sure I’ve put the files in the correct place.)

A bit of background: I started learning Phoenix and Elixir just about 5 weeks ago, to rescue my site from an old version of Ruby, and get the site back on its feet. I managed to get that done, and I relaunched it on Heroku ( https://www.shakespeare-monologues.org .)

I relaunched it without a search field, and am hoping to use your example to restore search. (I’m not actually a developer. A friend wrote the Ruby version of the site, but wasn’t available to help restore it when the Ruby version got too old for Heroku, so I rebuilt the site in Phoenix / Elixir. Very happy with the difference so far.)

I’ve installed Tailwind alongside bootstrap, with the intent of using Tailwind just to implement your search solution (at least for now.)

Being new to Phoenix, then just having jumped from 1.6 to 1.7.1 last night (in my local dev environment), I’m feeling quite unsure about where to put each of the files from your example.

For now, I’ve put my edited versions of your example files in lib/mono_phoenix_v01_web/controllers, Will that work? (No compile errors or warnings, but I haven’t actually tested the search yet, because…)

My primary question is: I’m in need of an example of what to put in lib/mono_phoenix_v01_web/templates/layout/app.html.heex to add the search field and modal. And what should the route look like in router.ex?

I’ll include my edited versions of your example files, in case they’re relevant:

lib/mono_phoenix_v01_web/controllers/searchbar_live.ex:

defmodule MonoPhoenixV01Web.SearchbarLive do
  use MonoPhoenixV01Web, :live_view

  alias Phoenix.LiveView.JS
  alias MonoPhoenixV01.Monologues

  def mount(_params, _session, socket) do
    socket = assign(socket, monologues: [])
    {:ok, socket, layout: false}
  end

  def handle_event("change", %{"search" => %{"query" => ""}}, socket) do
    socket = assign(socket, :monologues, [])
    {:noreply, socket}
  end

  def handle_event("change", %{"search" => %{"query" => search_query}}, socket) do
    monologues = Monologues.search(search_query)
    socket = assign(socket, :monologues, monologues)

    {:noreply, socket}
  end

  def open_modal(js \\ %JS{}) do
    js
    |> JS.show(
      to: "#searchbox_container",
      transition:
        {"tw-transition tw-ease-out tw-duration-200", "tw-opacity-0 tw-scale-95",
         "tw-opacity-100 tw-scale-100"}
    )
    |> JS.show(
      to: "#searchbar-dialog",
      transition: {"tw-transition tw-ease-in tw-duration-100", "tw-opacity-0", "tw-opacity-100"}
    )
    |> JS.focus(to: "#search-input")
  end

  def hide_modal(js \\ %JS{}) do
    js
    |> JS.hide(
      to: "#searchbar-searchbox_container",
      transition:
        {"tw-transition tw-ease-in tw-duration-100", "tw-opacity-100 tw-scale-100",
         "tw-opacity-0 tw-scale-95"}
    )
    |> JS.hide(
      to: "#searchbar-dialog",
      transition: {"tw-transition tw-ease-in tw-duration-100", "tw-opacity-100", "tw-opacity-0"}
    )
  end
end

lib/mono_phoenix_v01_web/controllers/searchbar_live.html.heex:

<div class="tw-block tw-max-w-xs tw-flex-auto">
  <button
    type="button"
    class="tw-hidden tw-text-gray-500 tw-bg-white hover:tw-ring-gray-500 tw-ring-gray-300 tw-h-8 tw-w-full items-center tw-gap-2 tw-rounded-md tw-pl-2 tw-pr-3 tw-text-sm tw-ring-1 tw-transition lg:tw-flex focus:tw-[&:not(:focus-visible)]:outline-none"
    phx-click={open_modal()}
  >
    <svg
      viewBox="0 0 20 20"
      fill="none"
      aria-hidden="true"
      class="tw-h-5 tw-w-5 tw-stroke-current"
    >
      <path
        stroke-linecap="round"
        stroke-linejoin="round"
        d="M12.01 12a4.25 4.25 0 1 0-6.02-6 4.25 4.25 0 0 0 6.02 6Zm0 0 3.24 3.25"
      >
      </path>
    </svg>
    Search for monologues...
  </button>
</div>

<div
  id="searchbar-dialog"
  class="tw-hidden tw-fixed tw-inset-0 z-50"
  role="dialog"
  aria-modal="true"
  phx-window-keydown={hide_modal()}
  phx-key="escape"
>
  <div class="tw-fixed tw-inset-0 tw-bg-zinc-400/25 tw-backdrop-blur-sm tw-opacity-100"></div>
  <div class="tw-fixed tw-inset-0 tw-overflow-y-auto tw-px-4 tw-py-4 sm:tw-py-20 sm:tw-px-6 md:tw-py-32 lg:tw-px-8 lg:tw-py-[15vh]">
    <div
      id="searchbox_container"
      class="tw-mx-auto tw-overflow-hidden tw-rounded-lg tw-bg-zinc-50 tw-shadow-xl tw-ring-zinc-900/7.5 sm:tw-max-w-xl tw-opacity-100 tw-scale-100"
      phx-hook="SearchBar"
    >
      <div
        role="combobox"
        aria-haspopup="listbox"
        phx-click-away={hide_modal()}
        aria-expanded={@monologues != []}
      >
        <form action="" novalidate="" role="search" phx-change="change">
          <div class="tw-group tw-relative tw-flex tw-h-12">
            <svg
              viewBox="0 0 20 20"
              fill="none"
              aria-hidden="true"
              class="tw-pointer-events-none tw-absolute tw-left-3 tw-top-0 tw-h-full tw-w-5 tw-stroke-zinc-500"
            >
              <path
                stroke-linecap="round"
                stroke-linejoin="round"
                d="M12.01 12a4.25 4.25 0 1 0-6.02-6 4.25 4.25 0 0 0 6.02 6Zm0 0 3.24 3.25"
              >
              </path>
            </svg>

            <input
              id="search-input"
              name="search[query]"
              class="tw-flex-auto tw-rounded-lg tw-appearance-none tw-bg-transparent tw-pl-10 tw-text-zinc-900 tw-outline-none focus:tw-outline-none tw-border-slate-200 focus:tw-border-slate-200 focus:tw-ring-0 focus:tw-shadow-none placeholder:tw-text-zinc-500 focus:tw-full focus:tw-flex-none sm:tw-text-sm [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden [&::-webkit-search-results-button]:hidden [&::-webkit-search-results-decoration]:hidden pr-4"
              style={
                @monologues != [] &&
                  "tw-border-bottom-left-radius: 0; tw-border-bottom-right-radius: 0; tw-border-bottom: none"
              }
              aria-autocomplete="both"
              aria-controls="searchbox__results_list"
              autocomplete="off"
              autocorrect="off"
              autocapitalize="off"
              enterkeyhint="search"
              spellcheck="false"
              placeholder="Search for monologues"
              type="search"
              value=""
              tabindex="0"
            />
          </div>

          <ul
            :if={@monologues != []}
            class="tw-divide-y tw-divide-slate-200 tw-overflow-y-auto tw-rounded-b-lg tw-border-t tw-border-slate-200 tw-text-sm tw-leading-6"
            id="searchbox__results_list"
            role="listbox"
          >
            <%= for monologue <- @monologues do %>
              <li id={"#{monologue.id}"}>
                <.link
                  navigate={~p"/monologues/#{monologue.slug}"}
                  class="tw-block tw-p-4 hover:tw-bg-slate-100 focus:tw-outline-none focus:tw-bg-slate-100 focus:tw-text-sky-800"
                >
                  <%= monologue.body %>
                </.link>
              </li>
            <% end %>
          </ul>
        </form>
      </div>
    </div>
  </div>
</div>

lib/mono_phoenix_v01_web/controllers/monologues.ex

defmodule MonoPhoenixV01.Monologues do
  @moduledoc """
  The search query thingy
  """
  import Ecto.Query, warn: false
  alias MonoPhoenixV01.Repo
  alias MonoPhoenixV01.Monologues.Monologue

  def search(search_query) do
    search_query = "%#{search_query}%"

    Monologue
    |> order_by(asc: :body)
    |> where([p], ilike(p.body, ^search_query))
    |> limit(15)
    |> Repo.all()
  end
end

Thanks in advance to anyone who is willing to give this n00b some hints on how to implement this cool search bar.

2 Likes

You just need to render SearchbarLive somewhere in your app.html.heex. In my case, I’m using live_render in my navbar:

<%= live_render(
  @conn,
  TravelerWeb.SearchbarLive,
  id: "searchbar",
  container: {:div, class: "flex items-center lg:w-full"}
) %>

You don’t need to add anything to your router. You only need to install and setup LiveView but it’s probably already done by phx generator.

Where to put files? It doesn’t really matter where you put files but there is specific phoenix convention (and Phoenix 1.7 introduced a new concepts).

My search bar “live” files are in lib/traveler_web/live/ and my module responsible for DB search is in lib/traveler/places/ (not in the traveler_web folder).

lib/traveler/places.ex
lib/traveler_web/live/searchbar_live.ex

In your case it should be:

lib/mono_phoenix_v01/monologues.ex

lib/mono_phoenix_v01_web/live/searchbar_live.ex
lib/mono_phoenix_v01_web/live/searchbar_live.html.heex

Hope that helps. You can read more about directory structure here.

2 Likes

Thank you so much @caspg!

I’m now unblocked and moving forward with integrating your cool solution into my site, thanks to this incantation:

<%= live_render(
  @conn,
  MonoPhoenixV01Web.SearchbarLive,
  id: "searchbar",
  container: {:div, class: "tw-flex tw-items-center lg:tw-w-full"}
) %>

Have a great weekend!

1 Like

(edit 2: the error I thought I’d resolve was not resolved after all, so reverted to initial post, then added some more error output.)

Well, I got myself blocked again. (n00bs, man. sheesh.)

After several hours of googling, reading docs, tutorials, blog posts, slackexchange threads, searching these forums and reading threads, and trying different things, the error remains. So I thought I’d better “pull off of the highway to ask someone for directions” again (that’s a reference to a time before GPS. lol)

I get the feeling I’m missing something obvious that an actual developer would have already thought of, so please feel free to mention things that should be obvious to people who actually know what they’re doing. :slight_smile:

How I got here: I’m able to get the search bar to appear where I want it, but clicking on it has no effect, other than moving the border from left and top, to bottom and right when I’m holding down the left mouse button, then it goes back to left and to when I release the left-mouse button. But there are no additional errors generated in the logs in the phx server terminal window, and no errors in Chrome’s dev Console, when I click in the search bar. (this behavior remains even after the renamings described below.)

So, I did some renaming after realizing the naming shown in my previous post may conflict with a def monologues(conn, params) do I already had in lib/mono_phoenix_v01_web/controllers/monologues_page_controller.ex (it includes an ecto query, with a join, and one of the tables is named monologues)

After the renamings, I’m getting:

warning: no route path for MonoPhoenixV01Web.Router matches "/monofinds/#{monofind.slug}"
  lib/mono_phoenix_v01_web/live/searchbar_live.html.heex:87: MonoPhoenixV01Web.SearchbarLive.render/1

(As you can see there, I have included support for verified routes when upgrading from Phoenix 1.6.15 to 1.7.1.)

Here’s that snippet from searchbar_live.html.heex:

          <ul
            :if={@monofinds != []}
            class="divide-y divide-slate-200 overflow-y-auto rounded-b-lg border-t border-slate-200 text-md leading-6"
            id="searchbox__results_list"
            role="listbox"
          >
            <%= for monofind <- @monofinds do %>
              <li id={"#{monofind.id}"}>
                <.link
                  navigate={~p"/monofinds/#{monofind.slug}"}
                  class="block p-4 hover:bg-slate-100 focus:outline-none focus:bg-slate-100 focus:text-sky-800"
                >
                  <%= monofind.body %>
                </.link>
              </li>
            <% end %>
          </ul>

Then I noticed in my root.html.heex I’d commented out /assets/app.js when I was troubleshooting something, and forgot to restore it, so I uncommented it.

I also uncommented liveSocket.enableDebug(). I got some new info. This in the phx server console logs:

[info] Sent 200 in 4ms
[info] CONNECTED TO Phoenix.LiveView.Socket in 75µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"_csrf_token" => "JjUcMwBFPQ1MOUcSZQ1GeDkEaGscNGBDNzZdWrhG4j-h48qLua12Hn5q", "_live_referer" => "undefined", "_mounts" => "0", "_track_static" => %{"0" => "http://localhost:4000/assets/css/normalize.css", "1" => "http://localhost:4000/assets/css/tailwind.css", "2" => "http://localhost:4000/assets/app.css", "3" => "http://localhost:4000/assets/css/application.css"}, "vsn" => "2.0.0"}

[debug] MOUNT MonoPhoenixV01Web.SearchbarLive
  Parameters: :not_mounted_at_router
  Session: %{"_csrf_token" => "hOFWW7UJxSjzQ574LeYYTZU2"}
[debug] Replied in 126µs

Is that :not_mounted_at_router related to the trouble?

I am not using any slugs in my app yet (or, at least, grepping finds no other instances of “slug”), could I be lacking something that slugs depend on?

Here are my deps:

    [
      {:phoenix, "~> 1.7.1", override: true},
      {:phoenix_ecto, "~> 4.4"},
      {:ecto_sql, "~> 3.6"},
      {:postgrex, ">= 0.0.0"},
      {:phoenix_html, "~> 3.0"},
      {:phoenix_view, "~> 2.0"},
      {:phoenix_live_reload, "~> 1.2", only: :dev},
      {:phoenix_live_view, "~> 0.18.15"},
      {:phoenix_live_dashboard, "~> 0.7.2"},
      {:esbuild, "~> 0.4", runtime: Mix.env() == :dev},
      {:tailwind, "~> 0.1.9", runtime: Mix.env() == :dev},
      {:swoosh, "~> 1.3"},
      {:telemetry_metrics, "~> 0.6"},
      {:telemetry_poller, "~> 1.0"},
      {:gettext, "~> 0.18"},
      {:jason, "~> 1.2"},
      {:plug_cowboy, "~> 2.5"},
      {:redirect, "~> 0.4.0"},
      {:html_assertion, "0.1.5", only: :test},
      {:floki, ">= 0.30.0", only: :test},
      {:credo, "~> 1.6", only: [:dev, :test], runtime: false}
    ]

Then again, I didn’t see the error until I changed instance of “Monologues” to “Monofinds”, “Monologue” to “Monofind”, “monologues” to “monofinds”, and “monologue” to “monofind”. So, I’m a baffled n00b.

In case it’s useful in helping me troubleshoot, below are my current versions of the files from @caspg 's ‘TravelWeb’ example. (Please let me know if there are any other files I should paste the contents of. I didn’t include my router.ex, because you’d mentioned there’s no need to add any new routes.)

I’m rendering it in lib/mono_phoenix_v01_web/templates/plays_page/plays.html.heex, thusly:

  <div class="absolute top-24 w-5/6">
    <%= live_render(
      @conn,
      MonoPhoenixV01Web.SearchbarLive,
      id: "searchbar",
      container:
        {:div,
         class: "flex flex-wrap justify-between items-start text-left items-center lg:w-full"}
    ) %>
  </div>

lib/mono_phoenix_v01/monofinds/monofinds.ex:

defmodule MonoPhoenixV01.Monofinds do
  @moduledoc """
  The search query thingy
  """
  import Ecto.Query, warn: false
  alias MonoPhoenixV01.Repo
  alias MonoPhoenixV01.Monofinds.Monofind

  def search(search_query) do
    search_query = "%#{search_query}%"

    Monofind
    |> order_by(asc: :body)
    |> where([p], ilike(p.body, ^search_query))
    |> limit(15)
    |> Repo.all()
  end
end

lib/mono_phoenix_v01_web/live/searchbar_live.ex:

defmodule MonoPhoenixV01Web.SearchbarLive do
  use MonoPhoenixV01Web, :live_view

  alias Phoenix.LiveView.JS
  alias MonoPhoenixV01.Monofinds

  def mount(_params, _session, socket) do
    socket = assign(socket, monofinds: [])
    {:ok, socket, layout: false}
  end

  def handle_event("change", %{"search" => %{"query" => ""}}, socket) do
    socket = assign(socket, :monofinds, [])
    {:noreply, socket}
  end

  def handle_event("change", %{"search" => %{"query" => search_query}}, socket) do
    monofinds = Monofinds.search(search_query)
    socket = assign(socket, :monofinds, monofinds)

    {:noreply, socket}
  end

  def open_modal(js \\ %JS{}) do
    js
    |> JS.show(
      to: "#searchbox_container",
      transition:
        {"tw-transition tw-ease-out tw-duration-200", "tw-opacity-0 tw-scale-95",
         "tw-opacity-100 tw-scale-100"}
    )
    |> JS.show(
      to: "#searchbar-dialog",
      transition: {"tw-transition tw-ease-in tw-duration-100", "tw-opacity-0", "tw-opacity-100"}
    )
    |> JS.focus(to: "#search-input")
  end

  def hide_modal(js \\ %JS{}) do
    js
    |> JS.hide(
      to: "#searchbar-searchbox_container",
      transition:
        {"tw-transition tw-ease-in tw-duration-100", "tw-opacity-100 tw-scale-100",
         "tw-opacity-0 tw-scale-95"}
    )
    |> JS.hide(
      to: "#searchbar-dialog",
      transition: {"tw-transition tw-ease-in tw-duration-100", "tw-opacity-100", "tw-opacity-0"}
    )
  end
end

lib/mono_phoenix_v01_web/live/searchbar_live.html.heex:

<div class="block max-w-96 flex-auto">
  <button
    type="button"
    class="tw-hidden flex items-start text-left text-gray-500 bg-white hover:ring-gray-500 ring-gray-300 h-12 w-full items-center gap-1 rounded-md pl-2 pr-3 text-2xl ring-1 transition lg:flex focus:[&:not(:focus-visible)]:outline-none"
    phx-click={open_modal()}
  >
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" class="h-7 w-7 stroke-current">
      <path
        stroke-linecap="round"
        stroke-linejoin="round"
        d="M12.01 12a4.25 4.25 0 1 0-6.02-6 4.25 4.25 0 0 0 6.02 6Zm0 0 3.24 3.25"
      >
      </path>
    </svg>
    Search for monologues...
  </button>
</div>

<div
  id="searchbar-dialog"
  class="hidden fixed inset-0 z-50"
  role="dialog"
  aria-modal="true"
  phx-window-keydown={hide_modal()}
  phx-key="escape"
>
  <div class="fixed inset-0 bg-zinc-400/25 backdrop-blur-sm opacity-100"></div>
  <div class="fixed inset-0 overflow-y-auto px-4 py-4 sm:py-20 sm:px-6 md:py-32 lg:px-8 lg:py-[15vh]">
    <div
      id="searchbox_container"
      class="mx-auto overflow-hidden rounded-lg bg-zinc-50 shadow-xl ring-zinc-900/7.5 md:max-w-xl opacity-100 scale-100"
      phx-hook="SearchBar"
    >
      <div
        role="combobox"
        aria-haspopup="listbox"
        phx-click-away={hide_modal()}
        aria-expanded={@monofinds != []}
      >
        <form action="" novalidate="" role="search" phx-change="change">
          <div class="group relative flex h-12">
            <svg
              viewBox="0 0 20 20"
              fill="none"
              aria-hidden="true"
              class="pointer-events-none absolute left-3 top-0 h-full w-5 stroke-zinc-500"
            >
              <path
                stroke-linecap="round"
                stroke-linejoin="round"
                d="M12.01 12a4.25 4.25 0 1 0-6.02-6 4.25 4.25 0 0 0 6.02 6Zm0 0 3.24 3.25"
              >
              </path>
            </svg>

            <input
              id="search-input"
              name="search[query]"
              class="flex-auto rounded-lg appearance-none bg-transparent pl-10 text-zinc-900 outline-none focus:outline-none border-slate-200 focus:border-slate-200 focus:ring-0 focus:shadow-none placeholder:text-zinc-500 focus:w-full focus:flex-none md:text-md [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden [&::-webkit-search-results-button]:hidden [&::-webkit-search-results-decoration]:hidden pr-4"
              style={
                @monofinds != [] &&
                  "border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-bottom: none"
              }
              aria-autocomplete="both"
              aria-controls="searchbox__results_list"
              autocomplete="off"
              autocorrect="off"
              autocapitalize="off"
              enterkeyhint="search"
              spellcheck="false"
              placeholder="Search for monologues"
              type="search"
              value=""
              tabindex="0"
            />
          </div>

          <ul
            :if={@monofinds != []}
            class="divide-y divide-slate-200 overflow-y-auto rounded-b-lg border-t border-slate-200 text-md leading-6"
            id="searchbox__results_list"
            role="listbox"
          >
            <%= for monofind <- @monofinds do %>
              <li id={"#{monofind.id}"}>
                <.link
                  navigate={~p"/monofinds/#{monofind.slug}"}
                  class="block p-4 hover:bg-slate-100 focus:outline-none focus:bg-slate-100 focus:text-sky-800"
                >
                  <%= monofind.body %>
                </.link>
              </li>
            <% end %>
          </ul>
        </form>
      </div>
    </div>
  </div>
</div>

lib/mono_phoenix_v01_web/live/SearchBar.ts

// This is optional phoenix client hook. It allows to use key down and up to select results.

export const SearchBar = {
    mounted() {
      const searchBarContainer = (this as any).el as HTMLDivElement
      document.addEventListener('keydown', (event) => {
        if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
          return
        }
  
        const focusElemnt = document.querySelector(':focus') as HTMLElement
  
        if (!focusElemnt) {
          return
        }
  
        if (!searchBarContainer.contains(focusElemnt)) {
          return
        }
  
        event.preventDefault()
  
        const tabElements = document.querySelectorAll(
          '#search-input, #searchbox__results_list a',
        ) as NodeListOf<HTMLElement>
        const focusIndex = Array.from(tabElements).indexOf(focusElemnt)
        const tabElementsCount = tabElements.length - 1
  
        if (event.key === 'ArrowUp') {
          tabElements[focusIndex > 0 ? focusIndex - 1 : tabElementsCount].focus()
        }
  
        if (event.key === 'ArrowDown') {
          tabElements[focusIndex < tabElementsCount ? focusIndex + 1 : 0].focus()
        }
      })
    },
  }

Sidenote question on the .ts file: Is focusElemnt a typo or purposeful/ correct? (I’m guessing the latter, but thought I’d double-check.)

Thanks in advance to @caspg and/ or anyone else willing to help me get unlost!

focusElemnt is a typo of course.

warning: no route path for MonoPhoenixV01Web.Router matches "/monofinds/#{monofind.slug}"

Tells you that there is no such route defined in your router.ex. Can you show that file?

:not_mounted_at_router

Is not an error. It should tell that this LiveView is not mounted directly at the router with live /some-path, SomeLiveView

Do you have a staging environment? If not, can you deploy your changes to some hidden path just for debugging?

Thanks for the reply, and the help, @caspg!

Ooh, you’d said before that I shouldn’t need to add a route. My liveDashboard works in my dev environment, so that made sense to me.

But that error kept making me think it was a route issue, so since my last post, I added this to my router:

 live("/monofinds/:monofinds", SearchbarLive)

I don’t know if that’s correct, but it cleared the no route path warning.

However, I’m still getting the :not_mounted_at_router in the phx console. I also noticed this in Chrome’s dev console:

unknown hook found for "SearchBar"

Just above that, in Chrome’s dev console, is this:

app.js:1389 phx-F0wPiKxdk45twQbB mount:  -  {0: ' phx-click="[[&quot;show&quot;,{&quot;display&quot…ot;,{&quot;to&quot;:&quot;#search-input&quot;}]]"', 1: ' phx-window-keydown="[[&quot;hide&quot;,{&quot;tim…pacity-100&quot;],[&quot;tw-opacity-0&quot;]]}]]"', 2: ' phx-click-away="[[&quot;hide&quot;,{&quot;time&qu…pacity-100&quot;],[&quot;tw-opacity-0&quot;]]}]]"', 3: '', 4: '', 5: '', s: Array(7)}0: " phx-click=\"[[&quot;show&quot;,{&quot;display&quot;:null,&quot;time&quot;:200 ... etc.

Here’s my current router file:

lib/mono_phoenix_v01_web/router.ex:

defmodule MonoPhoenixV01Web.Router do
  use MonoPhoenixV01Web, :router

  import Redirect

  pipeline :browser do
    plug(:accepts, ["html"])
    plug(:fetch_session)
    plug(:fetch_live_flash)
    plug(:put_root_layout, {MonoPhoenixV01Web.LayoutView, :root})
    plug(:protect_from_forgery)
    plug(:put_secure_browser_headers)
  end

  pipeline :api do
    plug(:accepts, ["json"])
  end

  scope "/", MonoPhoenixV01Web do
    pipe_through(:browser)

    get("/", StaticPageController, :home)
    get("/plays", PlaysPageController, :plays)
    get("/play/:playid", PlayPageController, :play)
    get("/mens", MensPageController, :mens)
    get("/men/:playid", MenplayPageController, :menplay)
    get("/womens", WomensPageController, :womens)
    get("/women/:playid", WomenplayPageController, :womenplay)
    get("/monologues/:monoid", MonologuesPageController, :monologues)
    get("/aboutus", StaticPageController, :aboutus)
    get("/faq", StaticPageController, :faq)
    get("/home", StaticPageController, :home)
    get("/links", StaticPageController, :links)
    get("/privacy", StaticPageController, :privacy)
    get("/maintenance", StaticPageController, :maintenance)
    get("/hello", PageController, :hello)
    get("/sandbox", PageController, :sandbox)
    live("/monofinds/:monofinds", SearchbarLive)
  end

  ## redirects for deep links from other sites. Will not work inside a scope.
  # redirect from /men/plays/123 to /men/123
  # redirect from /men/plays/123 to /men/123
  redirect("/men", "/mens", :permanent, preserve_query_string: true)
  redirect("/women", "/womens", :permanent, preserve_query_string: true)
  redirect("/men/plays/9", "/men/9", :permanent, preserve_query_string: true)
# [omitted from post: a few dozen more redirects to handle old/ legacy links from external sites]

  # Other scopes may use custom stacks.
  # scope "/api", MonoPhoenixV01Web do
  #   pipe_through :api
  # end

  # Enables LiveDashboard only for development
  #
  # If you want to use the LiveDashboard in production, you should put
  # it behind authentication and allow only admins to access it.
  # If your application does not have an admins-only section yet,
  # you can use Plug.BasicAuth to set up some basic authentication
  # as long as you are also using SSL (which you should anyway).
  
  if Mix.env() in [:dev, :test] do
    import Phoenix.LiveDashboard.Router

    scope "/" do
      pipe_through(:browser)

      live_dashboard("/dashboard", metrics: MonoPhoenixV01Web.Telemetry)
    end
  end

  # Enables the Swoosh mailbox preview in development.
  #
  # Note that preview only shows emails that were sent by the same
  # node running the Phoenix server.
  if Mix.env() == :dev do
    scope "/dev" do
      pipe_through(:browser)

      forward("/mailbox", Plug.Swoosh.MailboxPreview)
    end
  end
end

I don’t have a staging site deployed yet, but I’d planned to deploy one, before I deploy 1.7.1 to prod for the first time (current prod site is 1.6.15.)

I’ll go ahead and get the staging site deployed, and will follow-up with a link when it’s running.

In the meantime, I’m not sure if this is related, but it seems worth mentioning, in case:

I’ve got some static pages in the site as well (landing page, faq, aboutus, etc) so I have this in lib/mono_phoenix_v01_web.ex

  def verified_routes do
    quote do
      use Phoenix.VerifiedRoutes,
        endpoint: MonoPhoenixV01Web.Endpoint,
        router: MonoPhoenixV01Web.Router,
        statics: MonoPhoenixV01Web.static_paths()
    end
  end

Seems like it could be having an impact on tailwind’s use of assets/js/app.js and assets/css/app.css, and how those files relate to files in priv/static/assets?
Related reminder from an earlier post, I have this in config/config.js:

config :tailwind,
  version: "3.2.4",
  default: [
    args: ~w(
    --config=tailwind.config.js
    --input=css/app.css
    --output=../priv/static/assets/css/tailwind.css
  ),
    cd: Path.expand("../assets", __DIR__)
  ]

It does look like priv/static/assets/css/tailwind.css is getting updated, but I’m not sure if there are issues related to assets/js/app.js vs priv/static/assets/js/app.js. So, ignore that if it’s unrelated, I just wanted to mention it in case it is related.

Oh, and I forgot to mention, in an earlier post, that I removed @tailwind base; from assets/css/app.css (to prevent it from stomping on bootstrap), but I do have both of these included: @tailwind components; and @tailwind utilities;

Also, I’ve corrected focusElemnt to focusElement in the .ts file.

Thanks for the help. I’ll go work on deploying a staging environment, in case the details above don’t reveal the source of my troubles.