Skip to content

Instantly share code, notes, and snippets.

@caspg
Last active October 2, 2024 13:54
Show Gist options
  • Save caspg/d01eaa9189b5207473419262b44ba8af to your computer and use it in GitHub Desktop.
Save caspg/d01eaa9189b5207473419262b44ba8af to your computer and use it in GitHub Desktop.
Example of real-time search bar implementation in Phoenix LiveView and Tailwind. Working example on https://travelermap.net/parks/usa
defmodule TravelerWeb.SearchbarLive do
use TravelerWeb, :live_view
alias Phoenix.LiveView.JS
alias Traveler.Places
def mount(_params, _session, socket) do
socket = assign(socket, places: [])
{:ok, socket, layout: false}
end
def handle_event("change", %{"search" => %{"query" => ""}}, socket) do
socket = assign(socket, :places, [])
{:noreply, socket}
end
def handle_event("change", %{"search" => %{"query" => search_query}}, socket) do
places = Places.search(search_query)
socket = assign(socket, :places, places)
{:noreply, socket}
end
def open_modal(js \\ %JS{}) do
js
|> JS.show(
to: "#searchbox_container",
transition:
{"transition ease-out duration-200", "opacity-0 scale-95", "opacity-100 scale-100"}
)
|> JS.show(
to: "#searchbar-dialog",
transition: {"transition ease-in duration-100", "opacity-0", "opacity-100"}
)
|> JS.focus(to: "#search-input")
end
def hide_modal(js \\ %JS{}) do
js
|> JS.hide(
to: "#searchbar-searchbox_container",
transition:
{"transition ease-in duration-100", "opacity-100 scale-100", "opacity-0 scale-95"}
)
|> JS.hide(
to: "#searchbar-dialog",
transition: {"transition ease-in duration-100", "opacity-100", "opacity-0"}
)
end
end
<div class="block max-w-xs flex-auto">
<button
type="button"
class="hidden text-gray-500 bg-white hover:ring-gray-500 ring-gray-300 h-8 w-full items-center gap-2 rounded-md pl-2 pr-3 text-sm 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-5 w-5 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>
Find something...
</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 sm:max-w-xl opacity-100 scale-100"
phx-hook="SearchBar"
>
<div
role="combobox"
aria-haspopup="listbox"
phx-click-away={hide_modal()}
aria-expanded={@places != []}
>
<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 sm:text-sm [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden [&::-webkit-search-results-button]:hidden [&::-webkit-search-results-decoration]:hidden pr-4"
style={
@places != [] &&
"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="Find something..."
type="search"
value=""
tabindex="0"
/>
</div>
<ul
:if={@places != []}
class="divide-y divide-slate-200 overflow-y-auto rounded-b-lg border-t border-slate-200 text-sm leading-6"
id="searchbox__results_list"
role="listbox"
>
<%= for place <- @places do %>
<li id={"#{place.id}"}>
<.link
navigate={~p"/places/#{place.slug}"}
class="block p-4 hover:bg-slate-100 focus:outline-none focus:bg-slate-100 focus:text-sky-800"
>
<%= place.name %>
</.link>
</li>
<% end %>
</ul>
</form>
</div>
</div>
</div>
</div>
defmodule Traveler.Places do
import Ecto.Query, warn: false
alias Traveler.Repo
alias Traveler.Places.Place
def search(search_query) do
search_query = "%#{search_query}%"
Place
|> order_by(asc: :name)
|> where([p], ilike(p.name, ^search_query))
|> limit(5)
|> Repo.all()
end
end
// 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()
}
})
},
}
@mrcampbell
Copy link

This is incredible! Exactly what I was looking for! And to see it in action was so cool to see. Incredibly fast.

I went on and left two reviews, and everything is so snappy! Thanks for sharing ❤️

@caspg
Copy link
Author

caspg commented Jun 8, 2023

@mrcampbell Thanks for the reviews!

@karim-semmoud
Copy link

Thank you for sharing this great code. It tried it as a Live Component and it works like a charm.

@tatakishiev
Copy link

thank you for sharing

@lostbean
Copy link

That is an awesome piece of functionality and great example of code, thanks for sharing!

@manuel-rubio
Copy link

Great job! I've only one suggestion regarding this:

            <%= for place <- @places do %>
              <li id={"#{place.id}"}>

It could be like this:

            <li :for={place <- @places} id={place.id}>

And you can remove the <% end %> ;-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment