Let's create a people
table:
mix ecto.gen.migration add_people_table
Add the following fields:
defmodule App.Repo.Migrations.AddPeopleTable do
use Ecto.Migration
def change do
create table(:people) do
add :name, :string
add :picture, :string
add :selected, :boolean, default: false
timestamps()
end
end
end
We have the name
, picture
and selected
fields.
We'll update the selected value when the person's name is clicked in the
dropdown.
Let's now create a Person
schema to manage the data lib/app/tasks/person.ex
:
defmodule App.Tasks.Person do
use Ecto.Schema
import Ecto.Changeset
schema "people" do
field :name, :string
field :picture, :string
field :selected, :boolean
timestamps()
end
@doc false
def changeset(person, attrs) do
person
|> cast(attrs, [:name, :picture, :selected])
|> validate_required([:name, :picture])
end
end
Then in our Tasks
context created in the drag-and-drop
example, add the three
following functions, lib/app/tasks.ex
:
def get_person!(id), do: Repo.get!(Person, id)
def update_person(%Person{} = person, attrs) do
person
|> Person.changeset(attrs)
|> Repo.update()
end
def list_people do
Repo.all(from p in Person, order_by: [desc: p.selected, asc: p.name] )
end
Finally add people via the priv/repo/seeds.exs
file:
alias App.Tasks.Person
people = [
%Person{
name: "Person1",
picture: "https://avatars.githubusercontent.com/...",
selected: false
}, # Add more people, see the seeds file in this repository
]
|> Enum.each(fn p -> App.Repo.insert!(p) end)
To make sure the seeds are inserted, run:
mix ecto.reset
If you check the mix.exs
file you can see the reset
is an alias for:
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
In lib/app_web/router.ex
add a new live endpoint:
scope "/", AppWeb do
pipe_through :browser
# other routes
live "/select-input", SelectInputLive.Index, :index
end
Create the lib/app_web/live/select_input_live/index.ex
controller:
defmodule AppWeb.SelectInputLive.Index do
use AppWeb, :live_view
alias App.Tasks
@impl true
def mount(_params, _session, socket) do
people = Tasks.list_people()
{:ok, assign(socket, :people, people)}
end
end
And finally the LiveView
template lib/app_web/live/select_input_live/index.html.heex
:
<h1>Select Input</h1>
Run the server mix phx.server
and you should be able to see the /select-input
page.
We're going to start by creating the html structure required for our input and dropdown:
<div class="w-72">
<input type="text" class="w-full"/>
<div>
<ul class="border bg-white">
<%= for person <- @people do %>
<li><%= person.name %></li>
<% end %>
</ul>
</div>
</div>
We have a main
div containing the input text and another div which displays
the ul
and li
s html tags.
The "main" div is using a fixed defined by w-72
and we apply the w-full
to the input
to make sure the width matches the main div width.
We add border bg-white
on the ul
tag to create a border around the list
of items.
You should now have something similar to:
We can now use Alpine.js to hide/show the list of items:
<div class="w-72"
x-data="{open: false}"
x-on:click.away="open = false"
x-on:keydown.escape="open = false"
>
<input type="text"
class="w-full"
x-on:input="open = true"
x-on:focus="open = true"
/>
<div>
<ul class="border bg-white" x-show="open">
<%= for person <- @people do %>
<li><%= person.name %></li>
<% end %>
</ul>
</div>
</div>
We have added x-data="{open: false}"
to create the initial value to hide the
dropdown by default. We then use x-show
on the ul
tag. This will track
the open
boolean value and display accordingly the items.
To change the open
value we are using input, focus, click.away
and keywdown.escape
events on the main div and the input.
The dropdown now should toggle when you start typing in the input. However if there is some content under the input-select, this content will be pushed down when the dropdown is opened. You can test it by adding a new paragraph:
<p class="w-72">More content, more content, more content,
more content, more content,more content, more content,
more content, more content,more content, more content,
more content, more content,more content, more content,
more content, more content,more content, more content,
more content, more content,more content, more content,</p>
To fix this we can use the absolute
and relative
positions on the list
<div class="relative z-10">
<ul class="absolute border bg-white" x-show="open">
<%= for person <- @people do %>
<li><%= person.name %></li>
<% end %>
</ul>
</div>
With absolute
position the list of items is removed from the flow of the page
and placed relatively to its parent div containing the relative
position.
To make sure that no other element on the page could be hiding the the dropdown
we have also added the z-10
class to define a z-index value.
Before focusing on the styling of the items we can add the drop-shadow-lg
class
to have an "hover" effect
and make sure there is a max height and we can scroll down the list by using the
max-h-64
and overflow-auto
classes:
<div class="relative z-11 drop-shadow-lg">
<ul class="absolute border bg-white max-h-64 overflow-auto w-full" x-show="open">
<%= for person <- @people do %>
<li><%= person.name %></li>
<% end %>
</ul>
</div>
For the items' style we can start with the following classes to add a border, padding a cursor style and a background color on hover:
<li class="border-b p-2 cursor-pointer hover:bg-slate-200">
<%= person.name %>
</li>
We now want to display three elements for our items, the profile picture, the name
and a check image to represent the selected
value for the person. Let's start
with the image and the name:
<li class="border-b p-3 cursor-pointer hover:bg-slate-200">
<div class="h-10">
<div class="inline-flex items-center">
<img src={person.picture} class="w-10 rounded-full mx-2"/>
<%= person.name %>
</div>
</div>
</li>
We have created a new div
inside the li
with the class h-10
to give a bit
more height to the items. Then another div
is used specifically for containing
the image and name. We are using the flex
classes to be able to center the elements
vertically. Finally we have applied the w-10 rounded-full mx-2
to the image
to make it round and to define its size and horizontal margin:
Finally to display the check icon, we are using a svg image from Heroicons:
<div class="relative h-10">
<div class="inline-flex items-center">
<img src={person.picture} class="w-10 rounded-full mx-2" />
<%= person.name %>
</div>
<%= if !person.selected do %>
<svg
class="absolute font-bold w-4 top-0 bottom-0 m-auto right-0 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M19.916 4.626a.75.75 0 01.208 1.04l-9 13.5a.75.75 0 01-1.154.114l-6-6a.75.75 0 011.06-1.06l5.353 5.353 8.493-12.739a.75.75 0 011.04-.208z"
clip-rule="evenodd"
/>
</svg>
<% end %>
</div>
First we have added a relative
class in our item div, then in the svg we have the
following classes:
class="absolute font-bold w-4 top-0 bottom-0 m-auto right-0 text-green-500"
The absolute
class with right-0
places the check on the right side of the item.
the top-0 bottom-0 m-auto
make the check center vertically:
To finish the css styling, the last issue we want to fix is the overflow of the person name:
<div class="inline-flex items-center w-52">
<img src={person.picture} class="w-10 rounded-full mx-2" />
<span class="overflow-hidden text-ellipsis whitespace-nowrap">
<%= person.name %>
</span>
</div>
We have added a fixed width the div w-52
.
Then a span defines how to display the name when the text overflows. In our case
we add ellipsis.
To filter the list of people when we start to search add the following Phoenix bindings to the input:
<input
type="text"
class="w-full"
x-on:input="open = true"
x-on:focus="open = true"
phx-keyup="filter-items"
phx-debounce="250"
/>
We are sending the filter-items
directly to the Phoenix server. We are also
using phx-debounce="250"
to only send the event every 250ms.
In lib/app_web/live/select_input_live/index.ex
handle the event with:
@impl true
def handle_event("filter-items", %{"key" => _key, "value" => value}, socket) do
people =
Tasks.list_people()
|> Enum.filter(fn p -> String.contains?(String.downcase(p.name), String.downcase(value)) end)
{:noreply, assign(socket, :people, people)}
end
We filter the list of people by checking if the input search matches the person's name.
The other event we want to add is the toggle for the selected
value.
We add an id and a phx-hook, then we create a new event using Alpine.js dispatch
:
<div id="list-items" class="relative z-10 drop-shadow-lg" phx-hook="SelectInput">
<ul class="absolute border bg-white max-h-64 overflow-auto w-full" x-show="open">
<%= for person <- @people do %>
<li
class="border-b p-2 cursor-pointer hover:bg-slate-200"
x-on:click={"$dispatch('toggle-item', {id: #{person.id}})"}
>
...
In lib/assets/js/app.js
create the Hook:
// Hook for selecte input example
Hooks.SelectInput = {
mounted() {
this.el.addEventListener("toggle-item", e => {
this.pushEventTo("#list-items", "toggle", {id: e.detail.id})
})
}
}
And finally handle the toggle
event in LiveView endpoint:
@impl true
def handle_event("toggle", %{"id" => person_id}, socket) do
person = Tasks.get_person!(person_id)
Tasks.update_person(person, %{selected: !person.selected})
people = Tasks.list_people()
{:noreply, assign(socket, :people, people)}
end
You should now have a functional search input with dropdown!
If you think there is a better use of css/html to build this example, don't hesitate to open an issue/PR, thanks!