style: highlight selected table and add tooltip
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
02af136fd9
commit
49fd2181a7
19 changed files with 687 additions and 151 deletions
|
|
@ -660,6 +660,10 @@ defmodule MvWeb.CoreComponents do
|
|||
Renders a table with generic styling.
|
||||
|
||||
When `row_click` is set, clicking a row (or a data cell) triggers the handler.
|
||||
Rows with `row_click` get a subtle hover and focus-within outline (theme-friendly ring).
|
||||
When `selected_row_id` is set and matches a row's id (via `row_value_id` or `row_item.(row).id`),
|
||||
that row gets a stronger selected outline (ring-primary) for accessibility (not color-only).
|
||||
|
||||
The action column has no phx-click on its `<td>`, so action buttons do not trigger row navigation.
|
||||
For interactive elements inside other columns (e.g. checkboxes, buttons), use
|
||||
`Phoenix.LiveView.JS.stop_propagation()` in the element's phx-click so the row click is not fired.
|
||||
|
|
@ -670,12 +674,36 @@ defmodule MvWeb.CoreComponents do
|
|||
<:col :let={user} label="id">{user.id}</:col>
|
||||
<:col :let={user} label="username">{user.username}</:col>
|
||||
</.table>
|
||||
|
||||
<.table id="members" rows={@members} row_click={fn m -> JS.navigate(~p"/members/#{m}") end} selected_row_id={@selected_member_id}>
|
||||
<:col :let={m} label="Name">{m.name}</:col>
|
||||
</.table>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :rows, :list, required: true
|
||||
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
|
||||
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
|
||||
|
||||
attr :selected_row_id, :any,
|
||||
default: nil,
|
||||
doc:
|
||||
"when set, the row whose id equals this value gets selected styling (single row, e.g. from URL)"
|
||||
|
||||
attr :row_selected?, :any,
|
||||
default: nil,
|
||||
doc:
|
||||
"optional; function (row_item) -> boolean to mark multiple rows as selected (e.g. checkbox selection); overrides selected_row_id when set"
|
||||
|
||||
attr :row_tooltip, :string,
|
||||
default: nil,
|
||||
doc:
|
||||
"optional; when row_click is set, tooltip text for the row (e.g. gettext(\"Click to view\")). Shown as title on hover and as sr-only for screen readers."
|
||||
|
||||
attr :row_value_id, :any,
|
||||
default: nil,
|
||||
doc:
|
||||
"optional; function (row) -> id for comparing with selected_row_id; defaults to row_item.(row).id"
|
||||
|
||||
attr :row_item, :any,
|
||||
default: &Function.identity/1,
|
||||
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||
|
|
@ -704,6 +732,12 @@ defmodule MvWeb.CoreComponents do
|
|||
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
||||
end
|
||||
|
||||
# Function to get the row's value id for selected_row_id comparison (no extra DB reads)
|
||||
row_value_id_fn =
|
||||
assigns[:row_value_id] || fn row -> assigns.row_item.(row).id end
|
||||
|
||||
assigns = assign(assigns, :row_value_id_fn, row_value_id_fn)
|
||||
|
||||
~H"""
|
||||
<div class="overflow-auto">
|
||||
<table class="table table-zebra">
|
||||
|
|
@ -732,9 +766,15 @@ defmodule MvWeb.CoreComponents do
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||
<tr
|
||||
:for={row <- @rows}
|
||||
id={@row_id && @row_id.(row)}
|
||||
class={table_row_tr_class(@row_click, table_row_selected?(assigns, row))}
|
||||
data-selected={table_row_selected?(assigns, row) && "true"}
|
||||
title={@row_click && @row_tooltip}
|
||||
>
|
||||
<td
|
||||
:for={col <- @col}
|
||||
:for={{col, col_idx} <- Enum.with_index(@col)}
|
||||
phx-click={
|
||||
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
|
||||
(@row_click && @row_click.(row))
|
||||
|
|
@ -768,6 +808,9 @@ defmodule MvWeb.CoreComponents do
|
|||
Enum.join(classes, " ")
|
||||
}
|
||||
>
|
||||
<%= if col_idx == 0 && @row_click && @row_tooltip do %>
|
||||
<span class="sr-only">{@row_tooltip}</span>
|
||||
<% end %>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
<td
|
||||
|
|
@ -801,6 +844,43 @@ defmodule MvWeb.CoreComponents do
|
|||
"""
|
||||
end
|
||||
|
||||
# Returns true if the row is selected (via row_selected?/1 or selected_row_id match).
|
||||
defp table_row_selected?(assigns, row) do
|
||||
item = assigns.row_item.(row)
|
||||
|
||||
if assigns[:row_selected?] do
|
||||
assigns.row_selected?.(item)
|
||||
else
|
||||
assigns[:selected_row_id] != nil and
|
||||
assigns.row_value_id_fn.(row) == assigns.selected_row_id
|
||||
end
|
||||
end
|
||||
|
||||
# Returns CSS classes for table row: hover/focus-within outline when row_click is set,
|
||||
# and stronger selected outline when selected (WCAG: not color-only).
|
||||
# Hover/focus-within are omitted for the selected row so the selected ring stays visible.
|
||||
defp table_row_tr_class(row_click, selected?) do
|
||||
has_row_click? = not is_nil(row_click)
|
||||
base = []
|
||||
|
||||
base =
|
||||
if has_row_click? and not selected?,
|
||||
do:
|
||||
base ++
|
||||
[
|
||||
"hover:ring-2",
|
||||
"hover:ring-inset",
|
||||
"hover:ring-base-content/10",
|
||||
"focus-within:ring-2",
|
||||
"focus-within:ring-inset",
|
||||
"focus-within:ring-base-content/10"
|
||||
],
|
||||
else: base
|
||||
|
||||
base = if selected?, do: base ++ ["ring-2", "ring-inset", "ring-primary"], else: base
|
||||
Enum.join(base, " ")
|
||||
end
|
||||
|
||||
defp table_th_aria_sort(col, sort_field, sort_order) do
|
||||
col_sort = Map.get(col, :sort_field)
|
||||
|
||||
|
|
|
|||
|
|
@ -460,7 +460,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
boolean_filter_label(boolean_custom_fields, boolean_filters)
|
||||
|
||||
true ->
|
||||
gettext("All")
|
||||
gettext("Apply filters")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -487,7 +487,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
# Get boolean filter label (comma-separated list of active filter names)
|
||||
defp boolean_filter_label(_boolean_custom_fields, boolean_filters)
|
||||
when map_size(boolean_filters) == 0 do
|
||||
gettext("Apply filters")
|
||||
gettext("All")
|
||||
end
|
||||
|
||||
defp boolean_filter_label(boolean_custom_fields, boolean_filters) do
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||
end
|
||||
}
|
||||
row_tooltip={gettext("Click for dataield details")}
|
||||
>
|
||||
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ defmodule MvWeb.GroupLive.Index do
|
|||
rows={@groups}
|
||||
row_id={fn group -> "group-#{group.id}" end}
|
||||
row_click={fn group -> JS.navigate(~p"/groups/#{group.slug}") end}
|
||||
row_tooltip={gettext("Click for group details")}
|
||||
>
|
||||
<:col :let={group} label={gettext("Name")}>
|
||||
{group.name}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself)
|
||||
end
|
||||
}
|
||||
row_tooltip={gettext("Click for datafield details")}
|
||||
>
|
||||
<:col :let={{_field_name, field_data}} label={gettext("Name")}>
|
||||
{MemberFields.label(field_data.field)}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign(:groups, groups)
|
||||
|> assign(:boolean_custom_field_filters, %{})
|
||||
|> assign(:selected_members, MapSet.new())
|
||||
|> assign(:selected_member_id, nil)
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||
|> assign(:all_custom_fields, all_custom_fields)
|
||||
|
|
@ -160,6 +161,12 @@ defmodule MvWeb.MemberLive.Index do
|
|||
- `"select_all"` - Toggles selection of all visible members
|
||||
- `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
||||
"""
|
||||
@impl true
|
||||
def handle_event("select_row_and_navigate", %{"id" => id}, socket) do
|
||||
# Navigate to member show. Back button on show page uses ?highlight=id so returning to index shows row as selected.
|
||||
{:noreply, push_navigate(socket, to: ~p"/members/#{id}")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("select_member", %{"id" => id}, socket) do
|
||||
selected =
|
||||
|
|
@ -599,6 +606,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign(:member_fields_visible_db, visible_member_fields_db)
|
||||
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|
||||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||
|> assign(:selected_member_id, parse_highlight_param(params["highlight"]))
|
||||
|
||||
next_sig = build_signature(socket)
|
||||
|
||||
|
|
@ -798,6 +806,18 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
# Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid.
|
||||
defp parse_highlight_param(nil), do: nil
|
||||
defp parse_highlight_param(""), do: nil
|
||||
|
||||
defp parse_highlight_param(id) when is_binary(id) do
|
||||
if String.length(id) <= @max_uuid_length and match?({:ok, _}, Ecto.UUID.cast(id)),
|
||||
do: id,
|
||||
else: nil
|
||||
end
|
||||
|
||||
defp parse_highlight_param(_), do: nil
|
||||
|
||||
defp merge_fields_param_from_uri(params, nil), do: params
|
||||
|
||||
defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
variant="secondary"
|
||||
class={["gap-2", @show_current_cycle && "btn-active"]}
|
||||
phx-click="toggle_cycle_view"
|
||||
data-testid="toggle-cycle-view"
|
||||
aria-label={
|
||||
if(@show_current_cycle,
|
||||
do: gettext("Current Cycle Payment Status"),
|
||||
|
|
@ -93,7 +94,9 @@
|
|||
id="members"
|
||||
rows={@members}
|
||||
row_id={fn member -> "row-#{member.id}" end}
|
||||
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
|
||||
row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end}
|
||||
row_tooltip={gettext("Click for member details")}
|
||||
row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end}
|
||||
dynamic_cols={@dynamic_cols}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||
<:actions>
|
||||
<.button
|
||||
navigate={~p"/members"}
|
||||
navigate={~p"/members?highlight=#{@member.id}"}
|
||||
variant="neutral"
|
||||
aria-label={gettext("Back to members list")}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
id="roles"
|
||||
rows={@roles}
|
||||
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
|
||||
row_tooltip={gettext("Click for role details")}
|
||||
>
|
||||
<:col :let={role} label={gettext("Name")}>
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -174,8 +174,8 @@ defmodule MvWeb.RoleLive.Show do
|
|||
{gettext("Back")}
|
||||
</.button>
|
||||
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
|
||||
<.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"}>
|
||||
<.icon name="hero-pencil-square" /> {gettext("Rolle bearbeiten")}
|
||||
<.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"} data-testid=role-edit">
|
||||
{gettext("Edit role")}
|
||||
</.button>
|
||||
<% end %>
|
||||
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
rows={@users}
|
||||
row_id={fn user -> "row-#{user.id}" end}
|
||||
row_click={fn user -> JS.navigate(~p"/users/#{user}") end}
|
||||
row_tooltip={gettext("Click for user details")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue