There are no graded activities, including participation on Fridays and you should instead use the class time to:
- Grade the previous week's assignments (mandatory)
- Complete this week's assignments
- Progress on your final project
- Clean up your project (removing warnings, fixing bugs, adding documentation and tests)
Dropdown Component
Here is my dropdown component. I am giving you first the component and then we'll look into specific parts:
defmodule AppWeb.Components.UI.Dropdown do
use Phoenix.Component
alias Phoenix.LiveView.JS
@doc """
Renders a dropdown menu.
## Examples
<.dropdown id="user-dropdown" wrapper_classes="">
<:button class=""></:button>
<:item patch={~p"/settings"}><%= gettext("Settings") %></:item>
<:item method="DELETE" href={~p"/logout"}>
<%= gettext("Sign out") %>
</:item>
</.dropdown>
"""
attr :align, :string, default: "right", values: ~w(left right)
attr :id, :string, required: true
attr :wrapper_classes, :any, default: nil, doc: "Affect the div in which button and menu are."
slot :button, required: true do
attr :class, :string
end
slot :header
slot :item do
# live attrs
attr :navigate, :string, doc: "Set to a valid path if this is a liveview patch"
attr :patch, :string, doc: "Set to a valid path if this is a liveview patch"
attr :push, :string, doc: "Set to a valid patch event name if this is a liveview push"
# href attrs
attr :href, :string, doc: "Set to a valid URL if this is a href"
attr :method, :string, values: ~w(GET POST PUT PATCH DELETE), doc: "can be provided with href"
attr :target, :string
end
def dropdown(assigns) do
~H"""
<div class={["relative", @wrapper_classes]}>
<button
aria-expanded="false"
aria-haspopup="true"
id={"#{@id}-button"}
class={@button |> List.first() |> Map.get(:class)}
phx-click={show_dropdown(@id)}
phx-keyup="open-dropdown"
phx-value-id={@id}
>
{render_slot(@button)}
</button>
<.focus_wrap
id={"#{@id}-menu"}
class={
[
# Anchor positioning
"absolute z-10 mt-2 ",
# positioning
@align == "left" && "left-0 origin-top-left",
@align == "right" && "right-0 origin-top-right"
]
}
data-open={show_dropdown(@id)}
role="menu"
style="display:none;"
phx-click-away={hide_dropdown(@id)}
phx-key="escape"
phx-window-keydown={hide_dropdown(@id)}
>
<div class="bg-white rounded-lg shadow w-44 dark:bg-gray-700 divide-y divide-gray-100 dark:divide-gray-600">
{render_slot(@header)}
<ul
class="py-2 text-sm text-gray-700 dark:text-gray-200"
aria-orientation="vertical"
aria-labelledby={"#{@id}-button"}
>
<%= for item <- @item do %>
<li>
<.link
:if={Map.get(item, :href)}
class="block font-normal text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
href={Map.get(item, :href)}
method={Map.get(item, :method)}
phx-click={hide_dropdown(@id)}
role="menuitem"
target={Map.get(item, :target, "_self")}
>
{render_slot(item)}
</.link>
<button
:if={Map.get(item, :push) || Map.get(item, :patch) || Map.get(item, :navigate)}
type="button"
class="block font-normal text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem"
phx-click={button_action(@id, item)}
>
{render_slot(item)}
</button>
</li>
<% end %>
</ul>
</div>
</.focus_wrap>
</div>
"""
end
defp button_action(id, item) do
{push, patch, navigate} =
{Map.get(item, :push), Map.get(item, :patch), Map.get(item, :navigate)}
cond do
push -> hide_dropdown(id) |> JS.push(push)
patch -> hide_dropdown(id) |> JS.patch(patch)
navigate -> hide_dropdown(id) |> JS.navigate(navigate)
end
end
@doc """
Call this to open a dropdown menu.
## Examples
<.button phx-click={show_dropdown("user-dropdown")}></.button>
"""
def show_dropdown(id) when is_binary(id) do
%JS{}
|> JS.set_attribute({"area-expanded", "true"}, to: "##{id}-button")
|> JS.show(
to: "##{id}-menu",
transition: {
"transition ease-out duration-100",
"transform opacity-0 scale-95",
"transform opacity-100 scale-100"
}
)
end
@doc """
Call this to close a dropdown menu.
## Examples
<.button phx-click={hide_dropdown("user-dropdown") |> JS.patch(~p"/")}></.button>
"""
def hide_dropdown(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.set_attribute({"area-expanded", "false"}, to: "##{id}-button")
|> JS.hide(
to: "##{id}-menu",
transition: {
"transition ease-in duration-75",
"transform opacity-100 scale-100",
"transform opacity-0 scale-95"
}
)
end
end
Slots with attributes
Notice that slots can have slot attributes:
slot :button, required: true do
attr :class, :string
end
This would allow you to
<.dropdown>
<:button class="CustomClass"></:button>
</.dropdown>
Most optional settings that we have available with regular attributes are not available with slut attributes. This includes the default value :default
.
We can still set a default attribute (when none is given) when using the slot attribute:
<button class={@button |> List.first() |> Map.get(:class, "use-this-as-default")}>
{render_slot(@button)}
</button>
The List.first/1
is necessary because all slots come in lists because we could technically have more than one:
<.dropdown>
<:button>Button 1</:button>
<:button>Button 2</:button>
</.dropdown>
Chaining Events
You may have observed the weired interaction between push
and hide_dropdown
:
defp button_action(id, item) do
{push, patch, navigate} =
{Map.get(item, :push), Map.get(item, :patch), Map.get(item, :navigate)}
cond do
push -> hide_dropdown(id) |> JS.push(push)
patch -> hide_dropdown(id) |> JS.patch(patch)
navigate -> hide_dropdown(id) |> JS.navigate(navigate)
end
end
The reason is if we were to directly patch/navigate/push to another path than nothing instructs the dropdown to actually close and it would remain open. Thus if one of those actions is to be performed on clicking an item in the dropdown, we will first close the dropdown and then perform the action.
Animations
We can also provide animations when showing/hiding elements:
|> JS.show(
to: "##{id}-menu",
transition: {
"transition ease-out duration-100",
"transform opacity-0 scale-95",
"transform opacity-100 scale-100"
}
)
We will have a full lecture on animations soon but for now the short explaination for the code above is:
While we are transitioning from not-shown to shown apply the classes transition ease-out and duration-100
. This will ensure the animation takes 100 milliseconds and is going in a curve that starts slow and picks up pace near the end (ease out
).
We also transform from invisible (opacity-0
) to fully visible (opacity-100
).
Further we start our element at 95% of the size (scale-95
) and by the time the transition is done, it should be 100% of its regular size (scale-100
).
All of this functionality is build into CSS, we simply use tailwind classes to apply them. Unseen to us javascript is used here to add (before transition) and remove classes (after transition), as this is not something we can do with HTML/CSS alone (yet). Some simple transitions don't require javascript such as this toggle element:
defmodule AppWeb.Components.UI.Toggle do
use Phoenix.Component
attr :checked, :boolean, default: false
attr :class, :string, default: nil
attr :label, :string, required: true
attr :rest, :global
def toggle(assigns) do
~H"""
<label class={["inline-flex items-center cursor-pointer", @class]} {@rest}>
<input type="checkbox" value="" class="sr-only peer" checked={@checked} />
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600">
</div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">{@label}</span>
</label>
"""
end
end
Tests
To complete this bonus lecture I am also attaching my tests for those two components:
defmodule DsaWeb.Components.UI.DropdownTest do
use AppWeb.ComponentCase
import AppWeb.Components.UI.Dropdown
test "default dropdown" do
assigns = %{}
html =
rendered_to_string(
~H"""
<.dropdown id="dID" wrapper_classes="wrapperClass">
<:button class="sampleClass">Button Text</:button>
<:item patch={"patch-path"}>PatchContent</:item>
<:item navigate={"navigate-path"}>NavigateContent</:item>
<:item href={"url"} method="DELETE" target="_blank">HrefContent</:item>
<:item push={"event"}>PushEventContent</:item>
</.dropdown>
"""
)
assert html =~ "sampleClass"
assert html =~ "wrapperClass"
assert html =~ "aria-expanded=\"false"
assert html =~ "id=\"dID"
assert html =~ "phx-keyup=\"open-dropdown"
assert html =~ "id=\"dID-menu"
assert html =~ "right-0 origin-top-right"
assert html =~ "PatchContent"
assert html =~ "NavigateContent"
assert html =~ "HrefContent"
assert html =~ "PushEventContent"
end
test "dropdown with header and left align" do
assigns = %{}
html =
rendered_to_string(
~H"""
<.dropdown id="dID" align="left">
<:button>Button Text</:button>
<:header>Header</:header>
<:item patch={"patch-path"}>PatchContent</:item>
<:item navigate={"navigate-path"}>NavigateContent</:item>
<:item href={"url"} method="DELETE" target="_blank">HrefContent</:item>
<:item push={"event"}>PushEventContent</:item>
</.dropdown>
"""
)
assert html =~ "left-0 origin-top-left"
assert html =~ "Header"
end
end
defmodule DsaWeb.Components.UI.ToggleTest do
use AppWeb.ComponentCase
import AppWeb.Components.UI.Toggle
test "default unchecked toggle" do
assigns = %{}
html =
rendered_to_string(
~H"""
<.toggle label="Click Me" />
"""
)
assert html =~ "Click Me"
refute html =~ " checked"
end
test "default checked toggle" do
assigns = %{}
html =
rendered_to_string(
~H"""
<.toggle checked={true} label="Click Me" />
"""
)
assert html =~ "Click Me"
assert html =~ " checked"
end
end