-
-
Save ostinelli/564466292cd135585cc63f87fa068112 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() | |
} | |
}) | |
}, | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment