Enhances accessibiity closes #421 #450
8 changed files with 142 additions and 59 deletions
|
|
@ -521,4 +521,26 @@
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
WCAG 1.4.3: Primary button contrast (AA)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Override DaisyUI theme --color-primary-content so text on btn-primary (brand)
|
||||||
|
meets 4.5:1. In DevTools: inspect .btn-primary, check computed --color-primary
|
||||||
|
and --color-primary-content; verify contrast at https://webaim.org/resources/contrastchecker/ */
|
||||||
|
|
||||||
|
/* Light theme: primary is orange (brand); primary-content must be dark. */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--color-primary-content: oklch(0.18 0.02 47);
|
||||||
|
--color-error: oklch(55% 0.253 17.585);
|
||||||
|
--color-error-content: oklch(98% 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme: primary is purple; ensure content is light and meets 4.5:1. */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--color-primary-content: oklch(0.97 0.02 277);
|
||||||
|
--color-error: oklch(55% 0.253 17.585);
|
||||||
|
--color-error-content: oklch(98% 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
/* This file is for your main application CSS */
|
/* This file is for your main application CSS */
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,21 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
alias Phoenix.LiveView.JS
|
alias Phoenix.LiveView.JS
|
||||||
|
|
||||||
|
# WCAG 2.4.7 / 2.4.11: Shared focus ring for buttons and dropdown (trigger + items)
|
||||||
|
@button_focus_classes [
|
||||||
|
"focus-visible:outline-none",
|
||||||
|
"focus-visible:ring-2",
|
||||||
|
"focus-visible:ring-offset-2",
|
||||||
|
"focus-visible:ring-offset-base-100",
|
||||||
|
"focus-visible:ring-base-content/60"
|
||||||
|
]
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the shared focus ring class list for buttons and dropdown items (WCAG 2.4.7).
|
||||||
|
Use when building custom dropdown item buttons so they match <.button> and dropdown trigger.
|
||||||
|
"""
|
||||||
|
def button_focus_classes, do: @button_focus_classes
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders flash notices.
|
Renders flash notices.
|
||||||
|
|
||||||
|
|
@ -147,13 +162,16 @@ defmodule MvWeb.CoreComponents do
|
||||||
size_class = size_classes[size]
|
size_class = size_classes[size]
|
||||||
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
|
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
|
||||||
|
|
||||||
assigns = assign(assigns, :btn_class, btn_class)
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:btn_class, btn_class)
|
||||||
|
|> assign(:button_focus_classes, @button_focus_classes)
|
||||||
|
|
||||||
if rest[:href] || rest[:navigate] || rest[:patch] do
|
if rest[:href] || rest[:navigate] || rest[:patch] do
|
||||||
link_class =
|
link_class =
|
||||||
if assigns[:disabled],
|
if assigns[:disabled],
|
||||||
do: ["btn", btn_class, "btn-disabled"],
|
do: ["btn", btn_class, "btn-disabled"] ++ @button_focus_classes,
|
||||||
else: ["btn", btn_class]
|
else: ["btn", btn_class] ++ @button_focus_classes
|
||||||
|
|
||||||
link_attrs =
|
link_attrs =
|
||||||
if assigns[:disabled] do
|
if assigns[:disabled] do
|
||||||
|
|
@ -176,7 +194,11 @@ defmodule MvWeb.CoreComponents do
|
||||||
"""
|
"""
|
||||||
else
|
else
|
||||||
~H"""
|
~H"""
|
||||||
<button class={["btn", @btn_class]} disabled={@disabled} {@rest}>
|
<button
|
||||||
|
class={["btn", @btn_class] ++ @button_focus_classes}
|
||||||
|
disabled={@disabled}
|
||||||
|
{@rest}
|
||||||
|
>
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
</button>
|
</button>
|
||||||
"""
|
"""
|
||||||
|
|
@ -360,7 +382,11 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
def dropdown_menu(assigns) do
|
def dropdown_menu(assigns) do
|
||||||
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
|
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
|
||||||
assigns = assign(assigns, :menu_testid, menu_testid)
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:menu_testid, menu_testid)
|
||||||
|
|> assign(:button_focus_classes, @button_focus_classes)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div
|
<div
|
||||||
|
|
@ -379,14 +405,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
aria-expanded={@open}
|
aria-expanded={@open}
|
||||||
aria-controls={@id}
|
aria-controls={@id}
|
||||||
aria-label={@button_label}
|
aria-label={@button_label}
|
||||||
class={[
|
class={["btn"] ++ @button_focus_classes ++ [@button_class]}
|
||||||
"btn",
|
|
||||||
"focus:outline-none",
|
|
||||||
"focus-visible:ring-2",
|
|
||||||
"focus-visible:ring-offset-2",
|
|
||||||
"focus-visible:ring-base-content/20",
|
|
||||||
@button_class
|
|
||||||
]}
|
|
||||||
phx-click="toggle_dropdown"
|
phx-click="toggle_dropdown"
|
||||||
phx-target={@phx_target}
|
phx-target={@phx_target}
|
||||||
data-testid={@button_testid}
|
data-testid={@button_testid}
|
||||||
|
|
@ -454,7 +473,12 @@ defmodule MvWeb.CoreComponents do
|
||||||
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
|
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
|
||||||
}
|
}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
class={
|
||||||
|
[
|
||||||
|
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left",
|
||||||
|
"focus-visible:ring-inset"
|
||||||
|
] ++ @button_focus_classes
|
||||||
|
}
|
||||||
phx-click="select_item"
|
phx-click="select_item"
|
||||||
phx-keydown="select_item"
|
phx-keydown="select_item"
|
||||||
phx-key="Enter"
|
phx-key="Enter"
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,16 @@ defmodule MvWeb.Components.ExportDropdown do
|
||||||
use MvWeb, :live_component
|
use MvWeb, :live_component
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
# Same focus ring as CoreComponents button/dropdown (WCAG 2.4.7)
|
||||||
|
defp dropdown_item_class do
|
||||||
|
focus =
|
||||||
|
MvWeb.CoreComponents.button_focus_classes()
|
||||||
|
|> Kernel.++(["focus-visible:ring-inset"])
|
||||||
|
|> Enum.join(" ")
|
||||||
|
|
||||||
|
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left #{focus}"
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(socket) do
|
def mount(socket) do
|
||||||
{:ok, assign(socket, :open, false)}
|
{:ok, assign(socket, :open, false)}
|
||||||
|
|
@ -59,7 +69,7 @@ defmodule MvWeb.Components.ExportDropdown do
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
class={dropdown_item_class()}
|
||||||
aria-label={gettext("Export members to CSV")}
|
aria-label={gettext("Export members to CSV")}
|
||||||
data-testid="export-csv-link"
|
data-testid="export-csv-link"
|
||||||
>
|
>
|
||||||
|
|
@ -75,7 +85,7 @@ defmodule MvWeb.Components.ExportDropdown do
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
class={dropdown_item_class()}
|
||||||
aria-label={gettext("Export members to PDF")}
|
aria-label={gettext("Export members to PDF")}
|
||||||
data-testid="export-pdf-link"
|
data-testid="export-pdf-link"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -294,9 +294,16 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|
|
||||||
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||||
<%= if @member && assigns[:show_delete_modal] do %>
|
<%= if @member && assigns[:show_delete_modal] do %>
|
||||||
<dialog id="delete-member-form-modal" class="modal modal-open" role="dialog" aria-labelledby="delete-member-form-modal-title">
|
<dialog
|
||||||
|
id="delete-member-form-modal"
|
||||||
|
class="modal modal-open"
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="delete-member-form-modal-title"
|
||||||
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 id="delete-member-form-modal-title" class="text-lg font-bold">{gettext("Delete Member")}</h3>
|
<h3 id="delete-member-form-modal-title" class="text-lg font-bold">
|
||||||
|
{gettext("Delete Member")}
|
||||||
|
</h3>
|
||||||
<p class="py-4">
|
<p class="py-4">
|
||||||
{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)
|
||||||
|
|
@ -496,6 +503,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:error, format_destroy_error(error))
|
|> put_flash(:error, format_destroy_error(error))
|
||||||
|
|
|
||||||
|
|
@ -333,9 +333,16 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
|
|
||||||
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||||
<%= if assigns[:show_delete_modal] do %>
|
<%= if assigns[:show_delete_modal] do %>
|
||||||
<dialog id="delete-member-modal" class="modal modal-open" role="dialog" aria-labelledby="delete-member-modal-title">
|
<dialog
|
||||||
|
id="delete-member-modal"
|
||||||
|
class="modal modal-open"
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="delete-member-modal-title"
|
||||||
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 id="delete-member-modal-title" class="text-lg font-bold">{gettext("Delete Member")}</h3>
|
<h3 id="delete-member-modal-title" class="text-lg font-bold">
|
||||||
|
{gettext("Delete Member")}
|
||||||
|
</h3>
|
||||||
<p class="py-4">
|
<p class="py-4">
|
||||||
{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)
|
||||||
|
|
@ -467,6 +474,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
require Logger
|
require Logger
|
||||||
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:error, format_error(error))
|
|> put_flash(:error, format_error(error))
|
||||||
|
|
|
||||||
|
|
@ -324,39 +324,47 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||||
<%= if @user && assigns[:show_delete_modal] do %>
|
<%= if @user && assigns[:show_delete_modal] do %>
|
||||||
<dialog id="delete-user-form-modal" class="modal modal-open" role="dialog" aria-labelledby="delete-user-form-modal-title">
|
<dialog
|
||||||
<div class="modal-box">
|
id="delete-user-form-modal"
|
||||||
<h3 id="delete-user-form-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
|
class="modal modal-open"
|
||||||
<p class="py-4">
|
role="dialog"
|
||||||
{gettext("Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
aria-labelledby="delete-user-form-modal-title"
|
||||||
email: @user.email
|
>
|
||||||
)}
|
<div class="modal-box">
|
||||||
</p>
|
<h3 id="delete-user-form-modal-title" class="text-lg font-bold">
|
||||||
<div class="modal-action">
|
{gettext("Delete User")}
|
||||||
<.button
|
</h3>
|
||||||
type="button"
|
<p class="py-4">
|
||||||
variant="neutral"
|
{gettext(
|
||||||
phx-click="cancel_delete_modal"
|
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||||
phx-mounted={JS.focus()}
|
email: @user.email
|
||||||
id="delete-user-form-modal-cancel"
|
)}
|
||||||
aria-label={gettext("Cancel")}
|
</p>
|
||||||
>
|
<div class="modal-action">
|
||||||
{gettext("Cancel")}
|
<.button
|
||||||
</.button>
|
type="button"
|
||||||
<.button
|
variant="neutral"
|
||||||
type="button"
|
phx-click="cancel_delete_modal"
|
||||||
variant="danger"
|
phx-mounted={JS.focus()}
|
||||||
phx-click={JS.push("delete", value: %{id: @user.id})}
|
id="delete-user-form-modal-cancel"
|
||||||
aria-label={gettext("Delete user")}
|
aria-label={gettext("Cancel")}
|
||||||
>
|
>
|
||||||
{gettext("Delete")}
|
{gettext("Cancel")}
|
||||||
</.button>
|
</.button>
|
||||||
</div>
|
<.button
|
||||||
</div>
|
type="button"
|
||||||
</dialog>
|
variant="danger"
|
||||||
<% end %>
|
phx-click={JS.push("delete", value: %{id: @user.id})}
|
||||||
|
aria-label={gettext("Delete user")}
|
||||||
|
>
|
||||||
|
{gettext("Delete")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<.button navigate={return_path(@return_to, @user)} variant="neutral">
|
<.button navigate={return_path(@return_to, @user)} variant="neutral">
|
||||||
|
|
@ -482,7 +490,6 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("open_delete_modal", _params, socket) do
|
def handle_event("open_delete_modal", _params, socket) do
|
||||||
{:noreply, assign(socket, :show_delete_modal, true)}
|
{:noreply, assign(socket, :show_delete_modal, true)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Listing Users")}
|
{gettext("Users")}
|
||||||
<:actions>
|
<:actions>
|
||||||
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
|
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
|
||||||
<.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
|
<.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,6 @@ defmodule MvWeb.UserLive.Show do
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
{gettext("User")} {@user.email}
|
{gettext("User")} {@user.email}
|
||||||
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
|
|
||||||
|
|
||||||
<:actions>
|
<:actions>
|
||||||
<%= if can?(@current_user, :update, @user) do %>
|
<%= if can?(@current_user, :update, @user) do %>
|
||||||
<.button
|
<.button
|
||||||
|
|
@ -115,11 +113,17 @@ defmodule MvWeb.UserLive.Show do
|
||||||
|
|
||||||
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||||
<%= if assigns[:show_delete_modal] do %>
|
<%= if assigns[:show_delete_modal] do %>
|
||||||
<dialog id="delete-user-modal" class="modal modal-open" role="dialog" aria-labelledby="delete-user-modal-title">
|
<dialog
|
||||||
|
id="delete-user-modal"
|
||||||
|
class="modal modal-open"
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="delete-user-modal-title"
|
||||||
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 id="delete-user-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
|
<h3 id="delete-user-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
|
||||||
<p class="py-4">
|
<p class="py-4">
|
||||||
{gettext("Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
{gettext(
|
||||||
|
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||||
email: @user.email
|
email: @user.email
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue