feat(member): bring the overview table up to WCAG 2.2 AA
This commit is contained in:
parent
0c375234c8
commit
c2cb3edab8
6 changed files with 91 additions and 39 deletions
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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", %{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue