feat(member): make overview columns sortable through accessible sort headers

This commit is contained in:
Simon 2026-07-06 10:48:28 +02:00
parent dfc616257d
commit 0c375234c8
9 changed files with 350 additions and 51 deletions

View file

@ -957,3 +957,16 @@
margin-bottom: 0; margin-bottom: 0;
} }
/*
* Sort-header tooltips must render on top of the pinned checkbox column's
* horizontal-scroll fade (::after on .sticky-first-col-th). The hovered/focused
* header th is already lifted above the checkbox th (hover/focus-within:z-50),
* so the whole tooltip wins the stacking; this rule additionally keeps the
* daisyUI tooltip bubble's own pseudo-elements above the fade overlay. Scoped
* to the member overview header so no unrelated tooltip is affected.
*/
#members-keyboard thead .tooltip::before,
#members-keyboard thead .tooltip::after {
z-index: 60;
}

View file

@ -336,6 +336,72 @@ Hooks.SortableList = {
} }
} }
// SortTooltip hook: shows the sort-header hint in the browser TOP LAYER via the
// native Popover API, so it escapes the members table's overflow clipping
// (overflow-x:auto forces overflow-y:auto, which clips a CSS tooltip below the
// header) and paints above the sticky header, neighbor cells, and the pinned
// checkbox column's fade — no z-index fight. The popover is shown on hover AND
// keyboard focus and positioned next to the header with getBoundingClientRect
// (portable; CSS anchor-positioning is not yet supported in Safari/Firefox).
Hooks.SortTooltip = {
mounted() {
this.tip = document.getElementById(this.el.dataset.tooltipId)
// Feature-detect: without Popover API support fall back to no visual tooltip
// (the button's aria-label still conveys the hint to assistive tech).
if (!this.tip || typeof this.tip.showPopover !== "function") return
this.position = () => {
const btn = this.el.getBoundingClientRect()
const tip = this.tip.getBoundingClientRect()
const gap = 6
// Prefer just below the header; flip above when there is no room below.
let top = btn.bottom + gap
if (top + tip.height > window.innerHeight && btn.top - gap - tip.height >= 0) {
top = btn.top - gap - tip.height
}
// Left-align to the header, but keep the whole bubble on-screen.
let left = btn.left
const maxLeft = window.innerWidth - tip.width - gap
if (left > maxLeft) left = maxLeft
if (left < gap) left = gap
this.tip.style.top = `${Math.round(top)}px`
this.tip.style.left = `${Math.round(left)}px`
}
this.show = () => {
try {
if (!this.tip.matches(":popover-open")) this.tip.showPopover()
// showPopover() makes it measurable; positioning in the same tick avoids
// a visible flash (JS runs before the browser paints).
this.position()
} catch (_e) {}
}
this.hide = () => {
try {
if (this.tip.matches(":popover-open")) this.tip.hidePopover()
} catch (_e) {}
}
this.onKey = (e) => { if (e.key === "Escape") this.hide() }
this.el.addEventListener("mouseenter", this.show)
this.el.addEventListener("focus", this.show)
this.el.addEventListener("mouseleave", this.hide)
this.el.addEventListener("blur", this.hide)
this.el.addEventListener("keydown", this.onKey)
},
destroyed() {
if (this.hide) this.hide()
this.el.removeEventListener("mouseenter", this.show)
this.el.removeEventListener("focus", this.show)
this.el.removeEventListener("mouseleave", this.hide)
this.el.removeEventListener("blur", this.hide)
this.el.removeEventListener("keydown", this.onKey)
}
}
// SidebarState hook: Manages sidebar expanded/collapsed state // SidebarState hook: Manages sidebar expanded/collapsed state
Hooks.SidebarState = { Hooks.SidebarState = {
mounted() { mounted() {

View file

@ -1061,7 +1061,17 @@ defmodule MvWeb.CoreComponents do
> >
{col[:label]} {col[:label]}
</th> </th>
<th :for={dyn_col <- @dynamic_cols} class={table_th_sticky_class(@sticky_header)}> <th
:for={dyn_col <- @dynamic_cols}
class={table_th_sticky_class(@sticky_header)}
aria-sort={
table_th_aria_sort(
%{sort_field: "custom_field_#{dyn_col[:custom_field].id}"},
@sort_field,
@sort_order
)
}
>
<.live_component <.live_component
module={MvWeb.Components.SortHeaderComponent} module={MvWeb.Components.SortHeaderComponent}
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
@ -1240,8 +1250,8 @@ defmodule MvWeb.CoreComponents do
end end
# Combines column class with optional sticky header classes (desktop only; theme-friendly bg). # Combines column class with optional sticky header classes (desktop only; theme-friendly bg).
# hover:z-20/focus-within:z-20 lift the active header cell above sibling sticky cells (shared z-10) # bg-base-100 + z-10 keep the sticky header opaque and above body rows on vertical scroll.
# so its hover tooltip bubble is not clipped by an adjacent opaque bg-base-100 header background. # Sort tooltips use the Popover API (top layer) and need no header z-lift.
defp table_th_class(col, sticky_header) do defp table_th_class(col, sticky_header) do
base = Map.get(col, :class) base = Map.get(col, :class)
sticky = if sticky_header, do: sticky_th_classes(), else: nil sticky = if sticky_header, do: sticky_th_classes(), else: nil
@ -1253,7 +1263,7 @@ defmodule MvWeb.CoreComponents do
defp table_th_sticky_class(_), do: nil defp table_th_sticky_class(_), do: nil
defp sticky_th_classes, defp sticky_th_classes,
do: "lg:sticky lg:top-0 bg-base-100 hover:bg-base-100 z-10 hover:z-20 focus-within:z-20" do: "lg:sticky lg:top-0 bg-base-100 z-10"
@doc """ @doc """
Renders a reorderable table (sortable list) with drag handle and keyboard support. Renders a reorderable table (sortable list) with drag handle and keyboard support.

View file

@ -6,44 +6,57 @@ defmodule MvWeb.Components.SortHeaderComponent do
- label: string() # Column Heading (can be an heex template) - label: string() # Column Heading (can be an heex template)
- sort_field: atom() | nil # current sort field from parent liveview - sort_field: atom() | nil # current sort field from parent liveview
- sort_order: :asc | :desc | nil # current sorting order - sort_order: :asc | :desc | nil # current sorting order
- sort_hint: string() | nil # optional; overrides the generic "click
# to sort" tooltip/aria-label for columns
# whose sort key is non-obvious (e.g. the
# Name column sorts by last name)
""" """
use MvWeb, :live_component use MvWeb, :live_component
@impl true @impl true
def update(assigns, socket) do def update(assigns, socket) do
{:ok, assign(socket, assigns)} {:ok, socket |> assign(assigns) |> assign_new(:sort_hint, fn -> nil end)}
end end
# Check if we can add the aria-sort label directly to the daisyUI header # Check if we can add the aria-sort label directly to the daisyUI header
# aria-sort={aria_sort(@field, @sort_field, @sort_order)} # aria-sort={aria_sort(@field, @sort_field, @sort_order)}
@impl true @impl true
def render(assigns) do def render(assigns) do
assigns =
assigns
|> assign(:tooltip_id, "sort-tooltip-#{assigns.field}")
|> assign(:hint, assigns.sort_hint || gettext("Click to sort"))
~H""" ~H"""
<div> <div>
<.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom"> <button
<.button id={"sort-btn-#{@field}"}
type="button" type="button"
variant="ghost" aria-label={aria_sort(@field, @sort_field, @sort_order, @hint)}
aria-label={aria_sort(@field, @sort_field, @sort_order)} aria-describedby={@tooltip_id}
class="select-none" class="link link-hover no-underline w-full flex items-center gap-1 text-left text-current select-none"
phx-click="sort" phx-click="sort"
phx-value-field={@field} phx-value-field={@field}
data-testid={@field} phx-hook="SortTooltip"
> data-tooltip-id={@tooltip_id}
{@label} data-testid={@field}
<%= if @sort_field == @field do %> >
<.icon {@label}
name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} <%= if @sort_field == @field do %>
class="sort-icon" <.icon
/> name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
<% else %> class="sort-icon"
<.icon />
name="hero-chevron-up-down" <% else %>
class="sort-icon opacity-40" <.icon
/> name="hero-chevron-up-down"
<% end %> class="sort-icon opacity-40"
</.button> />
</.tooltip> <% end %>
</button>
<div id={@tooltip_id} popover="manual" role="tooltip" class="sort-tooltip">
{aria_sort(@field, @sort_field, @sort_order, @hint)}
</div>
</div> </div>
""" """
end end
@ -51,13 +64,13 @@ defmodule MvWeb.Components.SortHeaderComponent do
# ------------------------------------------------- # -------------------------------------------------
# Hilfsfunktionen für ARIA Attribute & Icon SVG # Hilfsfunktionen für ARIA Attribute & Icon SVG
# ------------------------------------------------- # -------------------------------------------------
defp aria_sort(field, sort_field, dir) when field == sort_field do defp aria_sort(field, sort_field, dir, hint) when field == sort_field do
case dir do case dir do
:asc -> gettext("ascending") :asc -> gettext("ascending")
:desc -> gettext("descending") :desc -> gettext("descending")
nil -> gettext("Click to sort") nil -> hint
end end
end end
defp aria_sort(_, _, _), do: gettext("Click to sort") defp aria_sort(_, _, _, hint), do: hint
end end

View file

@ -1450,7 +1450,9 @@ defmodule MvWeb.MemberLive.Index do
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
non_sortable_fields = [:notes] non_sortable_fields = [:notes]
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field) or field in [:groups, :membership_fee_type]
field in valid_fields or custom_field_sort?(field) or
field in [:groups, :membership_fee_type, :name, :address]
end end
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
@ -1458,6 +1460,8 @@ defmodule MvWeb.MemberLive.Index do
cond do cond do
field == "groups" -> :groups field == "groups" -> :groups
field == "membership_fee_type" -> :membership_fee_type field == "membership_fee_type" -> :membership_fee_type
field == "name" -> :name
field == "address" -> :address
true -> safe_member_field_atom_only(field) true -> safe_member_field_atom_only(field)
end end
@ -1508,10 +1512,16 @@ defmodule MvWeb.MemberLive.Index do
defp determine_field(default, ""), do: default defp determine_field(default, ""), do: default
defp determine_field(default, nil), do: default defp determine_field(default, nil), do: default
# Computed/pseudo fields that are nonetheless sortable (they resolve to DB
# sort keys in OverviewQuery): groups aggregate and the composite Name/Address
# columns. Other computed fields (e.g. membership_fee_status) are not sortable.
@sortable_computed_fields [:groups, :name, :address]
defp determine_field(default, sf) when is_binary(sf) do defp determine_field(default, sf) when is_binary(sf) do
# Handle "groups" specially - it's in computed_member_fields() but can be sorted sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1)
if sf == "groups" do
:groups if sf in sortable_strings do
String.to_existing_atom(sf)
else else
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
@ -1522,9 +1532,8 @@ defmodule MvWeb.MemberLive.Index do
end end
defp determine_field(default, sf) when is_atom(sf) do defp determine_field(default, sf) when is_atom(sf) do
# Handle :groups specially - it's in computed_member_fields() but can be sorted if sf in @sortable_computed_fields do
if sf == :groups do sf
:groups
else else
if sf in FieldVisibility.computed_member_fields(), if sf in FieldVisibility.computed_member_fields(),
do: default, do: default,

View file

@ -5,6 +5,7 @@
<.live_component <.live_component
module={MvWeb.Components.BulkActionsDropdown} module={MvWeb.Components.BulkActionsDropdown}
id="bulk-actions-dropdown" id="bulk-actions-dropdown"
open={@bulk_actions_open}
export_payload_json={@export_payload_json} export_payload_json={@export_payload_json}
selected_count={@selected_count} selected_count={@selected_count}
scope={@scope} scope={@scope}
@ -52,7 +53,9 @@
<.button <.button
type="button" type="button"
variant="secondary" variant="secondary"
class={["gap-2", @show_current_cycle && "btn-active"]} class="gap-2"
active={@show_current_cycle}
aria-pressed={to_string(@show_current_cycle)}
phx-click="toggle_cycle_view" phx-click="toggle_cycle_view"
data-testid="toggle-cycle-view" data-testid="toggle-cycle-view"
aria-label={ aria-label={
@ -135,8 +138,6 @@
sort_order={@sort_order} sort_order={@sort_order}
size_class={if @density == :compact, do: "table-xs", else: "table-md"} size_class={if @density == :compact, do: "table-xs", else: "table-md"}
> >
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
<:col <:col
:let={member} :let={member}
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1} col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
@ -285,10 +286,12 @@
[member.postal_code, member.city] [member.postal_code, member.city]
|> Enum.reject(&(&1 in [nil, ""])) |> Enum.reject(&(&1 in [nil, ""]))
|> Enum.join(" ") %> |> Enum.join(" ") %>
<div data-testid="member-address"> <.maybe_value value={line1 <> line2} empty_sr_text={gettext("No address")}>
<div class="truncate" title={line1}>{line1}</div> <div data-testid="member-address">
<div class="opacity-70 truncate" title={line2}>{line2}</div> <div class="truncate" title={line1}>{line1}</div>
</div> <div class="opacity-70 truncate" title={line2}>{line2}</div>
</div>
</.maybe_value>
</:col> </:col>
<:col <:col
:let={member} :let={member}
@ -307,7 +310,9 @@
""" """
} }
> >
{member.street} <.maybe_value value={member.street} empty_sr_text={gettext("Not specified")}>
{member.street}
</.maybe_value>
</:col> </:col>
<:col <:col
:let={member} :let={member}
@ -326,7 +331,9 @@
""" """
} }
> >
{member.house_number} <.maybe_value value={member.house_number} empty_sr_text={gettext("Not specified")}>
{member.house_number}
</.maybe_value>
</:col> </:col>
<:col <:col
:let={member} :let={member}
@ -345,7 +352,9 @@
""" """
} }
> >
{member.postal_code} <.maybe_value value={member.postal_code} empty_sr_text={gettext("Not specified")}>
{member.postal_code}
</.maybe_value>
</:col> </:col>
<:col <:col
:let={member} :let={member}
@ -364,7 +373,9 @@
""" """
} }
> >
{member.city} <.maybe_value value={member.city} empty_sr_text={gettext("Not specified")}>
{member.city}
</.maybe_value>
</:col> </:col>
<:col <:col
:let={member} :let={member}
@ -551,7 +562,7 @@
class="sticky left-0 flex items-center justify-center gap-2 text-base-content/70" class="sticky left-0 flex items-center justify-center gap-2 text-base-content/70"
data-testid="members-loading-row" data-testid="members-loading-row"
> >
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> <.spinner size="sm" aria-hidden="true" />
{gettext("Loading more members …")} {gettext("Loading more members …")}
</div> </div>
</:footer> </:footer>

View file

@ -254,6 +254,22 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
defp apply_sort(query, nil, _order, _custom_fields), do: Ash.Query.sort(query, id: :asc) defp apply_sort(query, nil, _order, _custom_fields), do: Ash.Query.sort(query, id: :asc)
defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc) defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc)
# Composite "Member" column: sort by last name then first name. The email that
# shares the cell is display-only and does not affect the sort key (§1.21).
defp apply_sort(query, field, order, _custom_fields) when field in [:name, "name"] do
Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}])
end
# Composite "Address" column: sort by city, then postal code, then street.
defp apply_sort(query, field, order, _custom_fields) when field in [:address, "address"] do
Ash.Query.sort(query, [
{:city, order},
{:postal_code, order},
{:street, order},
{:id, :asc}
])
end
defp apply_sort(query, field, order, custom_fields) do defp apply_sort(query, field, order, custom_fields) do
case sort_key(field) do case sort_key(field) do
nil -> apply_custom_field_sort(query, field, order, custom_fields) nil -> apply_custom_field_sort(query, field, order, custom_fields)

View file

@ -0,0 +1,55 @@
defmodule MvWeb.MemberLive.IndexSortA11yTest do
@moduledoc """
§1.11 When a column is sorted, exactly one `th` carries `aria-sort` with the
correct direction, a non-color sort glyph is present, and the sort is reflected
in the URL.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
@moduletag :ui
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Sortable", last_name: "Member", email: "sortable@example.com"},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
defp aria_sort_count(html) do
~r/aria-sort="/ |> Regex.scan(html) |> length()
end
test "exactly one th carries aria-sort ascending with a non-color glyph", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/members?sort_field=join_date&sort_order=asc")
assert aria_sort_count(html) == 1
assert has_element?(view, "th[aria-sort='ascending']")
# Non-color shape glyph for the active ascending sort.
assert html =~ "hero-chevron-up"
end
test "aria-sort reflects descending and stays unique", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/members?sort_field=join_date&sort_order=desc")
assert aria_sort_count(html) == 1
assert has_element?(view, "th[aria-sort='descending']")
assert html =~ "hero-chevron-down"
end
test "clicking a header reflects the sort in the URL", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("[data-testid='join_date']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=join_date"
assert path =~ "sort_order=asc"
end
end

View file

@ -0,0 +1,106 @@
defmodule MvWeb.MemberLive.IndexSortNameAddressTest do
@moduledoc """
§1.21 the composite Member (Name) and Address columns are sortable DB-side:
Name by last name first name (email in the cell is display-only), Address by
city postal code street. Ascending/descending, reflected in the URL and in
`aria-sort` (§1.11).
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
alias MvWeb.MemberLive.Index.OverviewQuery
setup %{conn: conn} do
actor = SystemActor.get_system_actor()
# Same last name, different first name — proves the first-name tie-breaker.
{:ok, _} =
Mv.Membership.create_member(
%{
first_name: "Bruno",
last_name: "Meyer",
email: "z-bruno@example.com",
city: "Aachen",
postal_code: "52062",
street: "Zebra"
},
actor: actor
)
{:ok, _} =
Mv.Membership.create_member(
%{
first_name: "Anna",
last_name: "Meyer",
email: "a-anna@example.com",
city: "Zwickau",
postal_code: "08056",
street: "Alpha"
},
actor: actor
)
%{conn: conn_with_oidc_user(conn), actor: actor}
end
defp read_names(query, actor) do
query
|> Ash.read!(actor: actor)
|> Enum.map(&{&1.last_name, &1.first_name})
end
test "name sort orders by last name then first name, ignoring email", %{actor: actor} do
asc = OverviewQuery.build(%{sort_field: :name, sort_order: :asc}) |> read_names(actor)
# Both share last name "Meyer"; the first-name tie-breaker orders Anna before
# Bruno, even though Bruno's email sorts first alphabetically.
assert asc == [{"Meyer", "Anna"}, {"Meyer", "Bruno"}]
desc = OverviewQuery.build(%{sort_field: :name, sort_order: :desc}) |> read_names(actor)
assert desc == [{"Meyer", "Bruno"}, {"Meyer", "Anna"}]
end
test "address sort orders by city, then postal code, then street", %{actor: actor} do
asc =
OverviewQuery.build(%{sort_field: :address, sort_order: :asc})
|> Ash.read!(actor: actor)
|> Enum.map(& &1.city)
assert asc == ["Aachen", "Zwickau"]
desc =
OverviewQuery.build(%{sort_field: :address, sort_order: :desc})
|> Ash.read!(actor: actor)
|> Enum.map(& &1.city)
assert desc == ["Zwickau", "Aachen"]
end
test "clicking the Name header sorts and reflects state in URL and aria-sort", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("button[phx-click='sort'][data-testid='name']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=name"
# Exactly one column carries aria-sort (§1.11).
assert has_element?(view, "th[aria-sort='ascending']")
assert render(view) |> aria_sort_count() == 1
end
test "clicking the Address header sorts and reflects state in URL", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("button[phx-click='sort'][data-testid='address']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=address"
assert has_element?(view, "th[aria-sort='ascending']")
end
defp aria_sort_count(html) do
html |> String.split("aria-sort=") |> length() |> Kernel.-(1)
end
end