feat: adds keyboard accessibility to tabs
This commit is contained in:
parent
615b4b866b
commit
2922a4d1ee
11 changed files with 680 additions and 613 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 %>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue