feat: adds keyboard accessibility to tabs

This commit is contained in:
carla 2026-02-26 10:37:57 +01:00
parent 615b4b866b
commit 2922a4d1ee
11 changed files with 680 additions and 613 deletions

View file

@ -2775,6 +2775,10 @@ Building accessible applications ensures that all users, including those with di
<div phx-click="action">Click me</div> <div phx-click="action">Click me</div>
``` ```
**Tables (Core Component `<.table>` with `row_click`):**
- When `row_click` is set, the first column that does not use `col_click` gets `tabindex="0"` and `role="button"` so each row is reachable via Tab. The `TableRowKeydown` hook triggers the row action on Enter and Space (WCAG 2.1.1). Use `row_id` and `row_tooltip` for all clickable tables (e.g. Groups, Users, Roles, Members, Custom Fields, Member Fields) so the table is fully keyboard accessible.
**Tab Order:** **Tab Order:**
- Ensure logical tab order matches visual order - Ensure logical tab order matches visual order

View file

@ -73,6 +73,27 @@ Hooks.ComboBox = {
} }
} }
// TableRowKeydown hook: WCAG 2.1.1 — when a table row cell has data-row-clickable,
// Enter and Space trigger a click so row_click tables are keyboard activatable
Hooks.TableRowKeydown = {
mounted() {
this.handleKeydown = (e) => {
if (
e.target.getAttribute("data-row-clickable") === "true" &&
(e.key === "Enter" || e.key === " ")
) {
e.preventDefault()
e.target.click()
}
}
this.el.addEventListener("keydown", this.handleKeydown)
},
destroyed() {
this.el.removeEventListener("keydown", this.handleKeydown)
}
}
// SidebarState hook: Manages sidebar expanded/collapsed state // SidebarState hook: Manages sidebar expanded/collapsed state
Hooks.SidebarState = { Hooks.SidebarState = {
mounted() { mounted() {

View file

@ -765,6 +765,8 @@ defmodule MvWeb.CoreComponents do
When `row_click` is set, clicking a row (or a data cell) triggers the handler. 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). Rows with `row_click` get a subtle hover and focus-within outline (theme-friendly ring).
For keyboard accessibility (WCAG 2.1.1), the first column without `col_click` gets
`tabindex="0"` and `role="button"`; the TableRowKeydown hook triggers the row action on Enter/Space.
When `selected_row_id` is set and matches a row's id (via `row_value_id` or `row_item.(row).id`), 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). that row gets a stronger selected outline (ring-primary) for accessibility (not color-only).
@ -847,8 +849,22 @@ defmodule MvWeb.CoreComponents do
assigns = assign(assigns, :row_value_id_fn, row_value_id_fn) assigns = assign(assigns, :row_value_id_fn, row_value_id_fn)
# WCAG 2.1.1: when row_click is set, first column without col_click gets tabindex="0"
# so rows are reachable via Tab; TableRowKeydown hook triggers click on Enter/Space
first_row_click_col_idx =
if assigns[:row_click] do
Enum.find_index(assigns[:col] || [], fn c -> !c[:col_click] end)
end
assigns =
assign(assigns, :first_row_click_col_idx, first_row_click_col_idx)
~H""" ~H"""
<div class="overflow-auto"> <div
id={@row_click && "#{@id}-keyboard"}
class="overflow-auto"
phx-hook={@row_click && "TableRowKeydown"}
>
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<tr> <tr>
@ -884,6 +900,11 @@ defmodule MvWeb.CoreComponents do
> >
<td <td
:for={{col, col_idx} <- Enum.with_index(@col)} :for={{col, col_idx} <- Enum.with_index(@col)}
tabindex={if @row_click && @first_row_click_col_idx == col_idx, do: 0, else: nil}
role={if @row_click && @first_row_click_col_idx == col_idx, do: "button", else: nil}
data-row-clickable={
if @row_click && @first_row_click_col_idx == col_idx, do: "true", else: nil
}
phx-click={ phx-click={
(col[:col_click] && col[:col_click].(@row_item.(row))) || (col[:col_click] && col[:col_click].(@row_item.(row))) ||
(@row_click && @row_click.(row)) (@row_click && @row_click.(row))
@ -907,6 +928,19 @@ defmodule MvWeb.CoreComponents do
classes classes
end end
# WCAG: no focus ring on the cell itself; row shows focus via focus-within
classes =
if @row_click && @first_row_click_col_idx == col_idx do
[
"focus:outline-none",
"focus-visible:outline-none",
"focus:ring-0",
"focus-visible:ring-0" | classes
]
else
classes
end
classes = classes =
if col_class do if col_class do
[col_class | classes] [col_class | classes]

View file

@ -150,7 +150,11 @@ defmodule MvWeb.GroupLive.Show do
<div class="relative"> <div class="relative">
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2"> <div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
<%= for member <- @selected_members do %> <%= for member <- @selected_members do %>
<.badge variant="primary" style="outline" class="flex items-center gap-1"> <.badge
variant="primary"
style="outline"
class="flex items-center gap-1"
>
{MvWeb.Helpers.MemberHelpers.display_name(member)} {MvWeb.Helpers.MemberHelpers.display_name(member)}
<.tooltip content={gettext("Remove")} position="top"> <.tooltip content={gettext("Remove")} position="top">
<.button <.button

View file

@ -52,6 +52,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
:if={!@show_form} :if={!@show_form}
id="member_fields" id="member_fields"
rows={@member_fields} rows={@member_fields}
row_id={fn {field_name, _field_data} -> "member_field-#{field_name}" end}
row_click={ row_click={
fn {field_name, _field_data} -> fn {field_name, _field_data} ->
JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself) JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself)

View file

@ -280,7 +280,8 @@ defmodule MvWeb.MemberLive.Form do
phx-click="delete" phx-click="delete"
phx-value-id={@member.id} phx-value-id={@member.id}
data-confirm={ data-confirm={
gettext("Are you sure you want to delete %{name}? This action cannot be undone.", gettext(
"Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member) name: MvWeb.Helpers.MemberHelpers.display_name(@member)
) )
} }

View file

@ -254,7 +254,9 @@ defmodule MvWeb.MemberLive.Show do
/> />
<.data_field label={gettext("Last Cycle")} class="min-w-32"> <.data_field label={gettext("Last Cycle")} class="min-w-32">
<%= if @member.last_cycle_status do %> <%= if @member.last_cycle_status do %>
<.badge variant={MembershipFeeHelpers.status_variant(@member.last_cycle_status)}> <.badge variant={
MembershipFeeHelpers.status_variant(@member.last_cycle_status)
}>
{format_status_label(@member.last_cycle_status)} {format_status_label(@member.last_cycle_status)}
</.badge> </.badge>
<% else %> <% else %>

View file

@ -18,8 +18,7 @@ defmodule MvWeb.RoleLive.Index do
require Ash.Query require Ash.Query
import MvWeb.RoleLive.Helpers, import MvWeb.RoleLive.Helpers, only: [permission_set_badge_variant: 1]
only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3]
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do

View file

@ -16,6 +16,7 @@
<.table <.table
id="roles" id="roles"
rows={@roles} rows={@roles}
row_id={fn role -> "role-#{role.id}" end}
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end} row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
row_tooltip={gettext("Click for role details")} row_tooltip={gettext("Click for role details")}
> >