feat(member-live): sort the composite Member column by first or last name
This commit is contained in:
parent
ca2aaa069c
commit
f7005f395f
11 changed files with 443 additions and 34 deletions
142
lib/mv_web/live/components/member_name_sort_header.ex
Normal file
142
lib/mv_web/live/components/member_name_sort_header.ex
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue