feat(web): add chevron affordance and scope-badge slot to dropdown triggers

Dropdown openers were visually indistinguishable from ordinary buttons. A
trailing chevron now marks every dropdown trigger — both the shared
dropdown_menu component and the bespoke member-filter trigger — and an
optional badge slot lets a trigger show a status indicator beside its label.
This commit is contained in:
Simon 2026-06-04 16:40:05 +02:00
parent d51dcb1ac3
commit 8e5dd7e4c6
5 changed files with 45 additions and 4 deletions

View file

@ -464,6 +464,9 @@ defmodule MvWeb.CoreComponents do
slot :inner_block, doc: "Custom content for the dropdown menu (e.g., forms)" slot :inner_block, doc: "Custom content for the dropdown menu (e.g., forms)"
slot :trigger_badge,
doc: "Optional badge rendered in the trigger after the label (e.g. a scope badge)"
def dropdown_menu(assigns) do def dropdown_menu(assigns) do
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu" menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
@ -498,6 +501,8 @@ defmodule MvWeb.CoreComponents do
<.icon name={@icon} /> <.icon name={@icon} />
<% end %> <% end %>
<span>{@button_label}</span> <span>{@button_label}</span>
{render_slot(@trigger_badge)}
<.icon name="hero-chevron-down" class="size-4" />
</button> </button>
<ul <ul

View file

@ -156,6 +156,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
> >
{@member_count} {@member_count}
</.badge> </.badge>
<.icon name="hero-chevron-down" class="size-4" />
</.button> </.button>
<!-- <!--

View file

@ -17,5 +17,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do
assert has_element?(view, "button[phx-click='select_all']") assert has_element?(view, "button[phx-click='select_all']")
assert has_element?(view, "button[phx-click='select_none']") assert has_element?(view, "button[phx-click='select_none']")
end end
test "trigger carries a trailing chevron affordance", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members")
# The shared dropdown trigger signals "opens a menu" with a trailing chevron.
assert html =~ "hero-chevron-down"
end
end end
end end

View file

@ -49,6 +49,20 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
end end
describe "rendering" do describe "rendering" do
test "trigger carries a trailing chevron affordance", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Mirror the shared dropdown affordance: a trailing chevron inside the
# bespoke filter trigger button.
chevron =
html
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s(#member-filter button[aria-haspopup="true"] .hero-chevron-down))
assert Enum.count(chevron) == 1
end
test "renders boolean custom fields when present", %{conn: conn} do test "renders boolean custom fields when present", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field(%{name: "Active Member"}) boolean_field = create_boolean_custom_field(%{name: "Active Member"})

View file

@ -82,8 +82,10 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") {:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
# Count occurrences to ensure only one descending icon # Count occurrences to ensure only one descending sort icon. Dropdown
down_count = html |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1) # triggers carry their own trailing "hero-chevron-down size-4" chevron, so
# the sort-active icon is identified by its bare class (no size-4 suffix).
down_count = active_sort_down_count(html)
# Should be exactly one chevrondown icon # Should be exactly one chevrondown icon
assert down_count == 1 assert down_count == 1
end end
@ -158,7 +160,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
# Count active icons (should be exactly 1 - ascending for default sort field) # Count active icons (should be exactly 1 - ascending for default sort field)
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1) up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1) down_count = active_sort_down_count(html_neutral)
assert up_count == 1, "Expected exactly 1 ascending icon, got #{up_count}" assert up_count == 1, "Expected exactly 1 ascending icon, got #{up_count}"
assert down_count == 0, "Expected 0 descending icons, got #{down_count}" assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
@ -167,13 +169,24 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
{:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc") {:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc")
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1) up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = html_desc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1) down_count = active_sort_down_count(html_desc)
assert up_count == 0, "Expected 0 ascending icons, got #{up_count}" assert up_count == 0, "Expected 0 ascending icons, got #{up_count}"
assert down_count == 1, "Expected exactly 1 descending icon, got #{down_count}" assert down_count == 1, "Expected exactly 1 descending icon, got #{down_count}"
end end
end end
# Counts only the descending chevron icons that belong to a sort header. Both
# the sort-active icon and the dropdown-trigger chevron render as
# "hero-chevron-down size-4", so they are told apart by their containing
# button: sort headers carry phx-click="sort", dropdown triggers do not.
defp active_sort_down_count(html) do
html
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s(button[phx-click="sort"] .hero-chevron-down))
|> Enum.count()
end
describe "accessibility" do describe "accessibility" do
test "sets aria-label correctly for unsorted state", %{conn: conn} do test "sets aria-label correctly for unsorted state", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)