Advanced Phoenix Components: Dropdown

DANA 325 E W G A I F X Q H C V B L K D M O T S U R P N

Objectives

  • Nothing due this day.

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
Copyright © 2025 Alexander Fuchsberger, Bucknell University. All rights reserved.