feat(member-live): sort the composite Member column by first or last name

This commit is contained in:
Simon 2026-07-16 09:39:07 +02:00
parent ca2aaa069c
commit f7005f395f
11 changed files with 443 additions and 34 deletions

View file

@ -0,0 +1,142 @@
defmodule MvWeb.Components.MemberNameSortHeader do
@moduledoc """
Header for the composite "Member" overview column (§1.21 / U3).
The column shows the full name (and, when folded in, the email), so a plain
"sort by X" is ambiguous. This header keeps the familiar one-click sort of the
other columns a click toggles the direction of the currently active sub-field
(last name by default) and adds a small sub-field menu (the "" button) to
switch between sorting by last name and first name. The active sub-field is
shown next to the direction chevron only while this column is the active sort,
so the header stays uncluttered otherwise but is never ambiguous when it does
sort.
The sub-field menu is rendered as a native Popover (top layer) so it escapes
the overview table's overflow clipping; the `SubfieldMenu` JS hook toggles and
positions it. Menu items emit `sort_composite` (always ascending by the chosen
field); the header click emits `sort` (toggles direction of the active field).
"""
use MvWeb, :html
@subfields [:first_name, :last_name]
attr :sort_field, :any, required: true, doc: "current sort field (atom or string)"
attr :sort_order, :atom, required: true, doc: ":asc or :desc"
def composite_name_sort_header(assigns) do
active = active_subfield(assigns.sort_field)
assigns =
assigns
|> assign(:active, active)
|> assign(:subfields, @subfields)
# A click on the header sorts by the active sub-field (toggling direction);
# when this column is not the active sort it starts from last name.
|> assign(:click_field, active || :last_name)
~H"""
<div class="flex items-center gap-1">
<button
id="sort-btn-member"
type="button"
phx-click="sort"
phx-value-field={@click_field}
aria-describedby="sort-tooltip-member"
phx-hook="PopoverTooltip"
data-tooltip-id="sort-tooltip-member"
class="link link-hover no-underline flex items-center gap-1 text-left text-current select-none"
data-testid="name"
>
{gettext("Member")}
<span :if={@active} class="opacity-70 font-normal">
· {subfield_label(@active)}
</span>
<.icon
:if={@active && @sort_order == :asc}
name="hero-chevron-up"
class="sort-icon"
/>
<.icon
:if={@active && @sort_order == :desc}
name="hero-chevron-down"
class="sort-icon"
/>
<.icon :if={!@active} name="hero-chevron-up-down" class="sort-icon opacity-40" />
</button>
<div id="sort-tooltip-member" popover="manual" role="tooltip" class="popover-tooltip">
{tooltip_text(@active)}
</div>
<button
id="member-subfield-btn"
type="button"
style="anchor-name: --member-sort-anchor"
aria-label={gettext("Choose sort field")}
aria-describedby="member-subfield-tooltip"
aria-haspopup="menu"
aria-expanded="false"
phx-hook="SubfieldMenu"
data-menu-id="member-subfield-menu"
data-tooltip-id="member-subfield-tooltip"
class="btn btn-ghost btn-xs btn-square"
data-testid="member-sort-subfield"
>
<.icon name="hero-ellipsis-vertical" class="size-4" />
</button>
<div
id="member-subfield-tooltip"
popover="manual"
role="tooltip"
style="position-anchor: --member-sort-anchor; position-area: bottom span-left; inset: auto"
class="popover-tooltip"
>
{gettext("Choose sort field")}
</div>
<ul
id="member-subfield-menu"
popover="manual"
role="menu"
aria-label={gettext("Sort members by")}
style="position-anchor: --member-sort-anchor"
class="popover-menu w-44 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li role="none" class="mb-1 px-2">
<span class="font-semibold">{gettext("Sort by")}</span>
</li>
<li role="separator" class="divider my-1"></li>
<li :for={field <- @subfields} role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@active == field)}
phx-click="sort_composite"
phx-value-field={field}
class="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1 text-left font-normal hover:bg-base-200"
data-testid={"member-sort-#{field}"}
>
<.icon
name="hero-check"
class={"size-4 text-primary #{if @active != field, do: "invisible"}"}
/>
{subfield_label(field)}
</button>
</li>
</ul>
</div>
"""
end
# Which sub-field this column currently sorts by, or nil when another column is
# the active sort. Accepts atoms or the string form that arrives via the URL.
defp active_subfield(sort_field) do
Enum.find(@subfields, fn f -> to_string(sort_field) == Atom.to_string(f) end)
end
defp subfield_label(:first_name), do: gettext("First name")
defp subfield_label(:last_name), do: gettext("Last name")
# The header already shows the active sub-field and direction, so the tooltip
# only needs to convey the click affordance.
defp tooltip_text(nil), do: gettext("Click to sort by last name")
defp tooltip_text(_active), do: gettext("Click to reverse the sort direction")
end