Improve member view table behavior+style, fix config settings #493

Merged
simon merged 5 commits from bugfix/480-fix-more-minor-bugs into main 2026-05-08 15:04:55 +02:00
7 changed files with 234 additions and 35 deletions
Showing only changes of commit 93e1ec7414 - Show all commits

View file

@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **Clickable table row highlights** The new hover/focus-visible row highlight behavior is now the CoreComponents default across clickable tables. Sticky-first-column tables keep zebra striping and show selection through the sticky-column accent stripe (checkboxes keep their default style).
- **Members overview scrolling** The members table scrollbar now scrolls inside the table container instead of moving with the full page.
- **Join request display and settings workflow** Improved join request rendering and related settings behavior in one cohesive update:
- Join request fields now respect their configured field types in the details view.
- Custom field labels in join request views were standardized.

View file

@ -247,11 +247,13 @@ If these cannot be met, use `secondary`/`outline` instead of `ghost`.
### 8.1 Default behavior: row click opens details
- **DEFAULT:** Clicking a row navigates to the details page.
- **EXCEPTIONS:** Highly interactive rows may disable row-click (document why).
- **Row outline (CoreComponents):** When `row_click` is set, rows get a subtle hover and focus-within ring (theme-friendly). Use `selected_row_id` to show a stronger selected outline (e.g. from URL `?highlight=id` or last selection); the Back link from detail can use `?highlight=id` so the row is visually selected when returning to the index.
- **Row highlight (CoreComponents):** When `row_click` is set, rows use a neutral background highlight on `hover` and `tr:has(:focus-visible)` (see `assets/css/app.css`), so keyboard focus is visible while mouse-only focus does not appear "stuck". For non-sticky tables, `selected_row_id` can still add a stronger selected ring. For sticky-first-column tables, selection emphasis is handled by the sticky-column accent stripe.
**IMPORTANT (correctness with our `<.table>` CoreComponent):**
Our table implementation attaches the `phx-click` to the **`<td>`** when `row_click` is set. That means click events bubble from inner elements up to the cell unless we stop propagation.
**LiveStream rows:** Do not enumerate `@rows` with `Enum.with_index` in the table template; streams must be consumed only through `:for`. Sticky-first-column zebra striping for those tables is handled in CSS (`nth-child` under `data-sticky-first-col-rows`), not by assigning odd/even classes from an index.
So, for interactive elements inside a clickable row, you must **stop propagation using `Phoenix.LiveView.JS.stop_propagation/1`**, not a custom attribute.
✅ Correct pattern (one click handler that both stops propagation and triggers an event):

View file

@ -708,3 +708,68 @@
background-color: transparent !important;
color: inherit;
}
/*
* Default interactive table rows: neutral hover/focus-visible fill for clickable rows.
* Uses :has(:focus-visible) so keyboard navigation highlights the row without sticky mouse-focus artifacts.
*/
.table.table-zebra tbody tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible)) > td {
background-color: var(--color-base-300);
}
/*
* Sticky first column in zebra tables: opaque backgrounds per row.
* Use nth-child (not HEEx row index) so LiveStream rows stay iterable only via :for (Phoenix LV requirement).
*/
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr:nth-child(odd) > td.sticky-first-col-cell {
background-color: var(--color-base-100);
}
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr:nth-child(even) > td.sticky-first-col-cell {
background-color: var(--color-base-200);
}
/*
* Checkbox-selected rows: keep zebra backgrounds; only accent the sticky checkbox column.
*/
[data-sticky-first-col-rows="true"]
.table.table-zebra
tbody
tr[data-selected="true"]
> td.sticky-first-col-cell {
box-shadow: inset 2px 0 0 var(--color-primary);
}
[data-sticky-first-col-rows="true"]
.table.table-zebra
tbody
tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible))
> td.sticky-first-col-cell {
background-color: var(--color-base-300);
/* Left accent only; keep the familiar orange primary accent. */
box-shadow: inset 2px 0 0 var(--color-primary);
}
/*
* Sticky member selection table: drop mouse-only focus outlines that read like a thin frame around the row;
* keyboard :focus-visible keeps DaisyUI control outlines (checkbox / tabindex cell).
*/
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr {
outline: none;
}
[data-sticky-first-col-rows="true"]
.table.table-zebra
tbody
tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible)):not(:last-child) {
/* DaisyUI draws a bottom border on each row; hiding it while highlighted avoids a boxy “frame”. */
border-bottom-color: transparent;
}
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr td:focus:not(:focus-visible) {
outline: none;
}
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr input.checkbox:focus:not(:focus-visible) {
outline: none;
}

View file

@ -943,6 +943,11 @@ defmodule MvWeb.CoreComponents do
doc:
"overflow class for the table wrapper; set to overflow-visible when outer container owns scrolling"
attr :sticky_first_col, :boolean,
default: false,
doc:
"when true, first header/body column gets sticky left positioning to keep selection controls visible"
slot :col, required: true do
attr :label, :string
attr :class, :string
@ -980,14 +985,18 @@ defmodule MvWeb.CoreComponents do
<div
id={@row_click && "#{@id}-keyboard"}
class={@wrapper_overflow_class}
data-sticky-first-col-rows={@sticky_first_col && "true"}
phx-hook={@row_click && "TableRowKeydown"}
>
<table class="table table-zebra">
<thead>
<tr>
<th
:for={col <- @col}
class={table_th_class(col, @sticky_header)}
:for={{col, col_idx} <- Enum.with_index(@col)}
class={[
table_th_class(col, @sticky_header),
@sticky_first_col && col_idx == 0 && "sticky left-0 z-30 bg-base-100"
]}
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
>
{col[:label]}
@ -1011,7 +1020,14 @@ defmodule MvWeb.CoreComponents do
<tr
:for={row <- @rows}
id={@row_id && @row_id.(row)}
class={table_row_tr_class(@row_click, table_row_selected?(assigns, row))}
class={[
table_row_tr_class(
@row_click,
table_row_selected?(assigns, row),
@sticky_first_col
)
]}
data-row-interactive={@row_click && "true"}
data-selected={table_row_selected?(assigns, row) && "true"}
title={@row_click && @row_tooltip}
>
@ -1031,6 +1047,13 @@ defmodule MvWeb.CoreComponents do
has_click = col[:col_click] || @row_click
classes = ["max-w-xs"]
classes =
if @sticky_first_col && col_idx == 0 do
["sticky-first-col-cell sticky left-0 z-20" | classes]
else
classes
end
classes =
if col_class == nil || (col_class && !String.contains?(col_class, "text-center")) do
["truncate" | classes]
@ -1045,7 +1068,7 @@ defmodule MvWeb.CoreComponents do
classes
end
# WCAG: no focus ring on the cell itself; row shows focus via focus-within
# WCAG: no focus ring on the cell itself; sticky zebra rows show keyboard focus via CSS :has(:focus-visible)
classes =
if @row_click && @first_row_click_col_idx == col_idx do
[
@ -1116,28 +1139,18 @@ defmodule MvWeb.CoreComponents do
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)
# Returns CSS classes for table row selection styles.
# Hover/focus row highlighting is CSS-driven via [data-row-interactive] selectors in app.css.
# Sticky-first-column zebra tables use CSS accents and omit selected row ring classes.
defp table_row_tr_class(_row_click, selected?, sticky_first_col) do
base = []
# Sticky-first-col tables: selection/hover accents are CSS-only (orange bar + fills).
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"
],
if selected? and not sticky_first_col,
do: base ++ ["ring-2", "ring-inset", "ring-primary"],
else: base
base = if selected?, do: base ++ ["ring-2", "ring-inset", "ring-primary"], else: base
Enum.join(base, " ")
end

View file

@ -107,6 +107,7 @@
rows={@members}
wrapper_overflow_class="overflow-visible"
sticky_header={true}
sticky_first_col={true}
row_id={fn member -> "row-#{member.id}" end}
row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end}
row_tooltip={gettext("Click for member details")}

View file

@ -9,7 +9,7 @@ defmodule MvWeb.Components.CoreComponentsTableTest do
alias MvWeb.CoreComponents
describe "table row_click styling" do
test "when row_click is set, table rows have hover and focus-within ring classes" do
test "when row_click is set, rows are marked interactive and omit ring hover classes" do
rows = [%{id: "1", name: "Alice"}, %{id: "2", name: "Bob"}]
assigns = %{
@ -31,12 +31,12 @@ defmodule MvWeb.Components.CoreComponentsTableTest do
html = render_component(&CoreComponents.table/1, assigns)
assert html =~ "hover:ring-2"
assert html =~ "focus-within:ring-2"
assert html =~ "hover:ring-base-content/10"
assert html =~ ~s(data-row-interactive="true")
refute html =~ "hover:ring-2"
refute html =~ "focus-within:ring-2"
end
test "when row_click is nil, table rows do not have hover ring classes" do
test "when row_click is nil, rows are not marked interactive" do
rows = [%{id: "1", name: "Alice"}]
assigns = %{
@ -58,8 +58,7 @@ defmodule MvWeb.Components.CoreComponentsTableTest do
html = render_component(&CoreComponents.table/1, assigns)
refute html =~ "hover:ring-2"
refute html =~ "focus-within:ring-2"
refute html =~ ~s(data-row-interactive="true")
end
end
@ -233,4 +232,101 @@ defmodule MvWeb.Components.CoreComponentsTableTest do
refute html =~ ~s(class="overflow-x-auto")
end
end
describe "sticky first column contract" do
test "when sticky_first_col is enabled, first header and body cells render sticky-left classes" do
rows = [%{id: "1", selected: true, name: "Alice"}]
assigns = %{
id: "test-table",
rows: rows,
row_id: fn r -> "row-#{r.id}" end,
row_click: nil,
sticky_first_col: true,
row_item: &Function.identity/1,
col: [
%{
__slot__: :col,
label: "Select",
inner_block: fn _socket, item -> [if(item[:selected], do: "x", else: "")] end
},
%{
__slot__: :col,
label: "Name",
inner_block: fn _socket, item -> [item[:name] || ""] end
}
],
dynamic_cols: [],
action: []
}
html = render_component(&CoreComponents.table/1, assigns)
assert html =~ "sticky"
assert html =~ "left-0"
assert html =~ "z-20"
assert html =~ "z-30"
end
test "sticky first column marks wrapper and uses CSS row backgrounds instead of row ring classes" do
rows = [%{id: "1", name: "Alice"}]
assigns = %{
id: "test-table",
rows: rows,
row_id: fn r -> "row-#{r.id}" end,
row_click: fn _ -> nil end,
sticky_first_col: true,
row_item: &Function.identity/1,
col: [
%{
__slot__: :col,
label: "Select",
inner_block: fn _socket, _item -> ["x"] end
},
%{
__slot__: :col,
label: "Name",
inner_block: fn _socket, item -> [item[:name] || ""] end
}
],
dynamic_cols: [],
action: []
}
html = render_component(&CoreComponents.table/1, assigns)
assert html =~ ~s(data-sticky-first-col-rows="true")
assert html =~ "sticky-first-col-cell"
refute html =~ "hover:ring-2"
end
test "sticky first column with selection sets data-selected without ring-primary" do
rows = [%{id: "one", name: "Alice"}, %{id: "two", name: "Bob"}]
assigns = %{
id: "test-table",
rows: rows,
row_id: fn r -> "row-#{r.id}" end,
row_click: fn _ -> nil end,
sticky_first_col: true,
selected_row_id: "two",
row_item: &Function.identity/1,
col: [
%{
__slot__: :col,
label: "Name",
inner_block: fn _socket, item -> [item[:name] || ""] end
}
],
dynamic_cols: [],
action: []
}
html = render_component(&CoreComponents.table/1, assigns)
assert html =~ ~s(data-selected="true")
refute html =~ "ring-primary"
end
end
end

View file

@ -90,6 +90,25 @@ defmodule MvWeb.MemberLive.IndexTest do
refute html =~ ~s(id="members-keyboard" class="overflow-x-auto")
refute html =~ ~s(id="members-keyboard" class="overflow-auto")
end
test "members table keeps checkbox column sticky while horizontally scrolling", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
{:ok, _member} =
Membership.create_member(
%{first_name: "Sticky", last_name: "Column", email: "sticky-column@example.com"},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members")
# Contract: first column (select-all header + row checkbox cells) is sticky on the left
assert html =~ "left-0"
assert html =~ "sticky"
assert html =~ "z-30"
assert html =~ "z-20"
end
end
describe "translations" do
@ -351,10 +370,12 @@ defmodule MvWeb.MemberLive.IndexTest do
assert_redirect(view, ~p"/members/#{member}")
end
describe "table row outline (hover and selected)" do
describe "table row highlight (hover and selected)" do
@describetag :ui
test "clickable rows have hover and focus-within ring classes", %{conn: conn} do
test "clickable rows with sticky first column use hover/focus background highlight", %{
conn: conn
} do
system_actor = SystemActor.get_system_actor()
{:ok, _member} =
@ -366,10 +387,9 @@ defmodule MvWeb.MemberLive.IndexTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# CoreComponents table adds hover and focus-within ring when row_click is set
assert html =~ "hover:ring-2"
assert html =~ "focus-within:ring-2"
assert html =~ "hover:ring-base-content/10"
# Sticky-first-column tables: hover/focus fills live in CSS; wrapper is marked for tests.
assert html =~ ~s(data-sticky-first-col-rows="true")
refute html =~ "hover:ring-2"
end
test "selected outline only from checkbox selection, not from highlight param", %{conn: conn} do