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:
parent
d51dcb1ac3
commit
8e5dd7e4c6
5 changed files with 45 additions and 4 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue