Rework member overview #553

Open
simon wants to merge 27 commits from issue/mitgliederverwaltung-547 into main
6 changed files with 91 additions and 39 deletions
Showing only changes of commit c2cb3edab8 - Show all commits

View file

@ -957,16 +957,3 @@
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

@ -36,6 +36,22 @@ function getBrowserTimezone() {
// Hooks for LiveView components // Hooks for LiveView components
let Hooks = {} let Hooks = {}
// IndeterminateCheckbox: the `indeterminate` state of a checkbox is a JS
// property, not an HTML attribute, so it cannot be set from the server render.
// Mirror it from the data-indeterminate attribute on mount and every update
// (e.g. select-all reflecting a partial selection — WCAG select-all semantics).
Hooks.IndeterminateCheckbox = {
updateIndeterminate() {
this.el.indeterminate = this.el.dataset.indeterminate === "true"
},
mounted() {
this.updateIndeterminate()
},
updated() {
this.updateIndeterminate()
}
}
// CopyToClipboard hook: Copies text to clipboard when triggered by server event // CopyToClipboard hook: Copies text to clipboard when triggered by server event
Hooks.CopyToClipboard = { Hooks.CopyToClipboard = {
mounted() { mounted() {

View file

@ -1093,12 +1093,17 @@ defmodule MvWeb.CoreComponents do
<tr <tr
:for={row <- @rows} :for={row <- @rows}
id={@row_id && @row_id.(row)} id={@row_id && @row_id.(row)}
class={[ class={
[
table_row_tr_class( table_row_tr_class(
table_row_selected?(assigns, row), table_row_selected?(assigns, row),
@sticky_first_col @sticky_first_col
) ),
]} # WCAG 2.4.11: keep a keyboard-focused row from being hidden under
# the sticky header by reserving header-height scroll margin.
@sticky_header && "scroll-mt-16"
]
}
data-row-interactive={@row_click && "true"} data-row-interactive={@row_click && "true"}
data-selected={table_row_selected?(assigns, row) && "true"} data-selected={table_row_selected?(assigns, row) && "true"}
title={@row_click && @row_tooltip} title={@row_click && @row_tooltip}
@ -1178,8 +1183,37 @@ defmodule MvWeb.CoreComponents do
<td <td
:for={dyn_col <- @dynamic_cols} :for={dyn_col <- @dynamic_cols}
phx-click={@row_click && @row_click.(row)} phx-click={@row_click && @row_click.(row)}
class={["max-w-xs truncate", @row_click && "hover:cursor-pointer"]} class={[
"max-w-xs",
dyn_col[:custom_field].value_type != :boolean && "truncate",
@row_click && "hover:cursor-pointer"
]}
> >
<%= if dyn_col[:custom_field].value_type == :boolean do %>
<% val = dyn_col[:render] && dyn_col[:render].(@row_item.(row)) %>
<%= cond do %>
<% val == true -> %>
<div class="flex justify-center">
<.icon
name="hero-check-circle"
class="size-4 text-success"
aria-hidden="true"
/>
<span class="sr-only">{gettext("Yes")}</span>
</div>
<% val == false -> %>
<div class="flex justify-center">
<.icon
name="hero-x-circle"
class="size-4 text-error"
aria-hidden="true"
/>
<span class="sr-only">{gettext("No")}</span>
</div>
<% true -> %>
<.empty_cell sr_text={gettext("Not specified")} />
<% end %>
<% else %>
{if dyn_col[:render] do {if dyn_col[:render] do
rendered = dyn_col[:render].(@row_item.(row)) rendered = dyn_col[:render].(@row_item.(row))
@ -1191,6 +1225,7 @@ defmodule MvWeb.CoreComponents do
else else
"" ""
end} end}
<% end %>
</td> </td>
<td :if={@action != []} class="w-0 font-semibold"> <td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4"> <div class="flex gap-4">

View file

@ -767,8 +767,15 @@ defmodule MvWeb.MemberLive.Index do
custom_field: custom_field, custom_field: custom_field,
render: fn member -> render: fn member ->
case get_custom_field_value(member, custom_field) do case get_custom_field_value(member, custom_field) do
nil -> "" nil ->
cfv -> Formatter.format_custom_field_value(cfv.value, custom_field) ""
cfv ->
if custom_field.value_type == :boolean do
extract_boolean_value(cfv.value)
else
Formatter.format_custom_field_value(cfv.value, custom_field)
end
end end
end end
} }

View file

@ -47,13 +47,16 @@ defmodule MvWeb.MemberLive.IndexA11yHardeningTest do
test "interactive row targets meet the >=24px minimum in both densities", %{conn: conn} do test "interactive row targets meet the >=24px minimum in both densities", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members") {:ok, view, _html} = live(conn, ~p"/members")
# Compact (default) and comfortable both keep the >=24px checkbox target. # The row/select-all checkboxes are pinned to 1.5rem (24px) in both densities
assert has_element?(view, "input[type='checkbox'].min-h-6.min-w-6") # via the `#members-table-guard input.checkbox` CSS rule (WCAG 2.5.8), which
# overrides DaisyUI's density-proportional sizing. The faithful proxy in a
# LiveView test is that the guarded checkbox markup exists in both densities.
assert has_element?(view, "#members-table-guard input[type='checkbox'].checkbox")
# Switch to comfortable via the view-settings dropdown. # Switch to comfortable via the view-settings dropdown.
view |> element("[data-testid='view-settings-button']") |> render_click() view |> element("[data-testid='view-settings-button']") |> render_click()
view |> element("[data-testid='view-setting-density']") |> render_click() view |> element("[data-testid='view-setting-density']") |> render_click()
assert has_element?(view, "input[type='checkbox'].min-h-6.min-w-6") assert has_element?(view, "#members-table-guard input[type='checkbox'].checkbox")
end end
test "select-all reflects indeterminate for a partial selection", %{ test "select-all reflects indeterminate for a partial selection", %{

View file

@ -1,8 +1,10 @@
defmodule MvWeb.MemberLive.IndexStickyPinnedTest do defmodule MvWeb.MemberLive.IndexStickyPinnedTest do
@moduledoc """ @moduledoc """
§1.19 When the table scrolls vertically and horizontally, the header stays §1.19 When the table scrolls vertically and horizontally, the header stays
sticky and the identifier (Name) column stays pinned; overflowing cell content sticky. Horizontal pinning is limited to the checkbox column (with a scroll
truncates with ellipsis and exposes a hover/focus tooltip. fade marking the boundary) rather than also pinning the Name column;
overflowing cell content truncates with ellipsis and exposes a hover/focus
tooltip.
""" """
use MvWeb.ConnCase, async: false use MvWeb.ConnCase, async: false
@ -30,16 +32,18 @@ defmodule MvWeb.MemberLive.IndexStickyPinnedTest do
%{conn: conn_with_oidc_user(conn)} %{conn: conn_with_oidc_user(conn)}
end end
test "header is sticky and the identifier column is pinned", %{conn: conn} do test "header is sticky and only the checkbox column is pinned", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/members") {:ok, _view, html} = live(conn, ~p"/members")
# Sticky header (desktop). # Sticky header (desktop).
assert html =~ "lg:sticky" assert html =~ "lg:sticky"
assert html =~ "lg:top-0" assert html =~ "lg:top-0"
# Checkbox column pinned at left-0, identifier (Name) column pinned beside it. # Only the checkbox column is pinned (left-0) now; a scroll fade marks the
# boundary instead of also pinning the Name column at left-12.
assert html =~ "left-0" assert html =~ "left-0"
assert html =~ "left-12" assert html =~ "sticky-first-col-th"
refute html =~ "left-12"
end end
test "composite cells truncate and expose a tooltip via title", %{conn: conn} do test "composite cells truncate and expose a tooltip via title", %{conn: conn} do