Last active
October 2, 2024 13:54
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 Thanks for the reviews!
Thank you for sharing this great code. It tried it as a Live Component and it works like a charm.
thank you for sharing
That is an awesome piece of functionality and great example of code, thanks for sharing!
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
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 ❤️