diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4497a15..e21f4a8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/DESIGN_GUIDELINES.md b/DESIGN_GUIDELINES.md
index 0ad562e..34c71b8 100644
--- a/DESIGN_GUIDELINES.md
+++ b/DESIGN_GUIDELINES.md
@@ -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 **`
`** 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):
diff --git a/assets/css/app.css b/assets/css/app.css
index d7f873c..611e9ad 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -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;
+}
diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex
index 2eb3051..4e55cf7 100644
--- a/lib/mv_web/components/core_components.ex
+++ b/lib/mv_web/components/core_components.ex
@@ -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
|
{col[:label]}
@@ -1011,7 +1020,14 @@ defmodule MvWeb.CoreComponents do
|
@@ -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
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex
index 92f19b8..efc1eb7 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -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")}
diff --git a/test/mv_web/components/core_components_table_test.exs b/test/mv_web/components/core_components_table_test.exs
index 45e8c6d..03f1f71 100644
--- a/test/mv_web/components/core_components_table_test.exs
+++ b/test/mv_web/components/core_components_table_test.exs
@@ -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
diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs
index 95c71ed..85c3385 100644
--- a/test/mv_web/member_live/index_test.exs
+++ b/test/mv_web/member_live/index_test.exs
@@ -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
|