feat(member): make overview columns sortable through accessible sort headers
This commit is contained in:
parent
dfc616257d
commit
0c375234c8
9 changed files with 350 additions and 51 deletions
|
|
@ -1061,7 +1061,17 @@ defmodule MvWeb.CoreComponents do
|
|||
>
|
||||
{col[:label]}
|
||||
</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
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
||||
|
|
@ -1240,8 +1250,8 @@ defmodule MvWeb.CoreComponents do
|
|||
end
|
||||
|
||||
# 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)
|
||||
# so its hover tooltip bubble is not clipped by an adjacent opaque bg-base-100 header background.
|
||||
# bg-base-100 + z-10 keep the sticky header opaque and above body rows on vertical scroll.
|
||||
# Sort tooltips use the Popover API (top layer) and need no header z-lift.
|
||||
defp table_th_class(col, sticky_header) do
|
||||
base = Map.get(col, :class)
|
||||
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 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 """
|
||||
Renders a reorderable table (sortable list) with drag handle and keyboard support.
|
||||
|
|
|
|||
|
|
@ -6,44 +6,57 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
|||
- label: string() # Column Heading (can be an heex template)
|
||||
- sort_field: atom() | nil # current sort field from parent liveview
|
||||
- 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
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
{:ok, assign(socket, assigns)}
|
||||
{:ok, socket |> assign(assigns) |> assign_new(:sort_hint, fn -> nil end)}
|
||||
end
|
||||
|
||||
# Check if we can add the aria-sort label directly to the daisyUI header
|
||||
# aria-sort={aria_sort(@field, @sort_field, @sort_order)}
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:tooltip_id, "sort-tooltip-#{assigns.field}")
|
||||
|> assign(:hint, assigns.sort_hint || gettext("Click to sort"))
|
||||
|
||||
~H"""
|
||||
<div>
|
||||
<.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom">
|
||||
<.button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-label={aria_sort(@field, @sort_field, @sort_order)}
|
||||
class="select-none"
|
||||
phx-click="sort"
|
||||
phx-value-field={@field}
|
||||
data-testid={@field}
|
||||
>
|
||||
{@label}
|
||||
<%= if @sort_field == @field do %>
|
||||
<.icon
|
||||
name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
|
||||
class="sort-icon"
|
||||
/>
|
||||
<% else %>
|
||||
<.icon
|
||||
name="hero-chevron-up-down"
|
||||
class="sort-icon opacity-40"
|
||||
/>
|
||||
<% end %>
|
||||
</.button>
|
||||
</.tooltip>
|
||||
<button
|
||||
id={"sort-btn-#{@field}"}
|
||||
type="button"
|
||||
aria-label={aria_sort(@field, @sort_field, @sort_order, @hint)}
|
||||
aria-describedby={@tooltip_id}
|
||||
class="link link-hover no-underline w-full flex items-center gap-1 text-left text-current select-none"
|
||||
phx-click="sort"
|
||||
phx-value-field={@field}
|
||||
phx-hook="SortTooltip"
|
||||
data-tooltip-id={@tooltip_id}
|
||||
data-testid={@field}
|
||||
>
|
||||
{@label}
|
||||
<%= if @sort_field == @field do %>
|
||||
<.icon
|
||||
name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
|
||||
class="sort-icon"
|
||||
/>
|
||||
<% else %>
|
||||
<.icon
|
||||
name="hero-chevron-up-down"
|
||||
class="sort-icon opacity-40"
|
||||
/>
|
||||
<% end %>
|
||||
</button>
|
||||
<div id={@tooltip_id} popover="manual" role="tooltip" class="sort-tooltip">
|
||||
{aria_sort(@field, @sort_field, @sort_order, @hint)}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
|
@ -51,13 +64,13 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
|||
# -------------------------------------------------
|
||||
# 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
|
||||
:asc -> gettext("ascending")
|
||||
:desc -> gettext("descending")
|
||||
nil -> gettext("Click to sort")
|
||||
nil -> hint
|
||||
end
|
||||
end
|
||||
|
||||
defp aria_sort(_, _, _), do: gettext("Click to sort")
|
||||
defp aria_sort(_, _, _, hint), do: hint
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1450,7 +1450,9 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
|
||||
non_sortable_fields = [:notes]
|
||||
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
|
||||
|
||||
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
|
||||
|
|
@ -1458,6 +1460,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
cond do
|
||||
field == "groups" -> :groups
|
||||
field == "membership_fee_type" -> :membership_fee_type
|
||||
field == "name" -> :name
|
||||
field == "address" -> :address
|
||||
true -> safe_member_field_atom_only(field)
|
||||
end
|
||||
|
||||
|
|
@ -1508,10 +1512,16 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp determine_field(default, ""), 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
|
||||
# Handle "groups" specially - it's in computed_member_fields() but can be sorted
|
||||
if sf == "groups" do
|
||||
:groups
|
||||
sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1)
|
||||
|
||||
if sf in sortable_strings do
|
||||
String.to_existing_atom(sf)
|
||||
else
|
||||
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
|
||||
|
||||
|
|
@ -1522,9 +1532,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
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 == :groups do
|
||||
:groups
|
||||
if sf in @sortable_computed_fields do
|
||||
sf
|
||||
else
|
||||
if sf in FieldVisibility.computed_member_fields(),
|
||||
do: default,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<.live_component
|
||||
module={MvWeb.Components.BulkActionsDropdown}
|
||||
id="bulk-actions-dropdown"
|
||||
open={@bulk_actions_open}
|
||||
export_payload_json={@export_payload_json}
|
||||
selected_count={@selected_count}
|
||||
scope={@scope}
|
||||
|
|
@ -52,7 +53,9 @@
|
|||
<.button
|
||||
type="button"
|
||||
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"
|
||||
data-testid="toggle-cycle-view"
|
||||
aria-label={
|
||||
|
|
@ -135,8 +138,6 @@
|
|||
sort_order={@sort_order}
|
||||
size_class={if @density == :compact, do: "table-xs", else: "table-md"}
|
||||
>
|
||||
|
||||
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
||||
<:col
|
||||
:let={member}
|
||||
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
|
||||
|
|
@ -285,10 +286,12 @@
|
|||
[member.postal_code, member.city]
|
||||
|> Enum.reject(&(&1 in [nil, ""]))
|
||||
|> Enum.join(" ") %>
|
||||
<div data-testid="member-address">
|
||||
<div class="truncate" title={line1}>{line1}</div>
|
||||
<div class="opacity-70 truncate" title={line2}>{line2}</div>
|
||||
</div>
|
||||
<.maybe_value value={line1 <> line2} empty_sr_text={gettext("No address")}>
|
||||
<div data-testid="member-address">
|
||||
<div class="truncate" title={line1}>{line1}</div>
|
||||
<div class="opacity-70 truncate" title={line2}>{line2}</div>
|
||||
</div>
|
||||
</.maybe_value>
|
||||
</:col>
|
||||
<:col
|
||||
: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
|
||||
: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
|
||||
: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
|
||||
: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
|
||||
:let={member}
|
||||
|
|
@ -551,7 +562,7 @@
|
|||
class="sticky left-0 flex items-center justify-center gap-2 text-base-content/70"
|
||||
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 …")}
|
||||
</div>
|
||||
</:footer>
|
||||
|
|
|
|||
|
|
@ -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, _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
|
||||
case sort_key(field) do
|
||||
nil -> apply_custom_field_sort(query, field, order, custom_fields)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue