feat: improve color contrast
This commit is contained in:
parent
e422e5f4ef
commit
2c49018ab7
8 changed files with 142 additions and 59 deletions
|
|
@ -31,6 +31,21 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
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 """
|
||||
Renders flash notices.
|
||||
|
||||
|
|
@ -147,13 +162,16 @@ defmodule MvWeb.CoreComponents do
|
|||
size_class = size_classes[size]
|
||||
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
|
||||
link_class =
|
||||
if assigns[:disabled],
|
||||
do: ["btn", btn_class, "btn-disabled"],
|
||||
else: ["btn", btn_class]
|
||||
do: ["btn", btn_class, "btn-disabled"] ++ @button_focus_classes,
|
||||
else: ["btn", btn_class] ++ @button_focus_classes
|
||||
|
||||
link_attrs =
|
||||
if assigns[:disabled] do
|
||||
|
|
@ -176,7 +194,11 @@ defmodule MvWeb.CoreComponents do
|
|||
"""
|
||||
else
|
||||
~H"""
|
||||
<button class={["btn", @btn_class]} disabled={@disabled} {@rest}>
|
||||
<button
|
||||
class={["btn", @btn_class] ++ @button_focus_classes}
|
||||
disabled={@disabled}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
|
|
@ -360,7 +382,11 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
def dropdown_menu(assigns) do
|
||||
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"""
|
||||
<div
|
||||
|
|
@ -379,14 +405,7 @@ defmodule MvWeb.CoreComponents do
|
|||
aria-expanded={@open}
|
||||
aria-controls={@id}
|
||||
aria-label={@button_label}
|
||||
class={[
|
||||
"btn",
|
||||
"focus:outline-none",
|
||||
"focus-visible:ring-2",
|
||||
"focus-visible:ring-offset-2",
|
||||
"focus-visible:ring-base-content/20",
|
||||
@button_class
|
||||
]}
|
||||
class={["btn"] ++ @button_focus_classes ++ [@button_class]}
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@phx_target}
|
||||
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
|
||||
}
|
||||
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-keydown="select_item"
|
||||
phx-key="Enter"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,16 @@ defmodule MvWeb.Components.ExportDropdown do
|
|||
use MvWeb, :live_component
|
||||
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
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, :open, false)}
|
||||
|
|
@ -59,7 +69,7 @@ defmodule MvWeb.Components.ExportDropdown do
|
|||
<button
|
||||
type="submit"
|
||||
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")}
|
||||
data-testid="export-csv-link"
|
||||
>
|
||||
|
|
@ -75,7 +85,7 @@ defmodule MvWeb.Components.ExportDropdown do
|
|||
<button
|
||||
type="submit"
|
||||
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")}
|
||||
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) --%>
|
||||
<%= 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">
|
||||
<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">
|
||||
{gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
|
|
@ -496,6 +503,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|
||||
{:error, error} ->
|
||||
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> 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) --%>
|
||||
<%= 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">
|
||||
<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">
|
||||
{gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
|
|
@ -467,6 +474,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{:error, error} ->
|
||||
require Logger
|
||||
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, format_error(error))
|
||||
|
|
|
|||
|
|
@ -324,39 +324,47 @@ defmodule MvWeb.UserLive.Form do
|
|||
</section>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||
<%= 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">
|
||||
<div class="modal-box">
|
||||
<h3 id="delete-user-form-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
|
||||
<p class="py-4">
|
||||
{gettext("Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||
email: @user.email
|
||||
)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="cancel_delete_modal"
|
||||
phx-mounted={JS.focus()}
|
||||
id="delete-user-form-modal-cancel"
|
||||
aria-label={gettext("Cancel")}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
variant="danger"
|
||||
phx-click={JS.push("delete", value: %{id: @user.id})}
|
||||
aria-label={gettext("Delete user")}
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||
<%= 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"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 id="delete-user-form-modal-title" class="text-lg font-bold">
|
||||
{gettext("Delete User")}
|
||||
</h3>
|
||||
<p class="py-4">
|
||||
{gettext(
|
||||
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||
email: @user.email
|
||||
)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="cancel_delete_modal"
|
||||
phx-mounted={JS.focus()}
|
||||
id="delete-user-form-modal-cancel"
|
||||
aria-label={gettext("Cancel")}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
variant="danger"
|
||||
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">
|
||||
<.button navigate={return_path(@return_to, @user)} variant="neutral">
|
||||
|
|
@ -482,7 +490,6 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
@impl true
|
||||
def handle_event("open_delete_modal", _params, socket) do
|
||||
{:noreply, assign(socket, :show_delete_modal, true)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Listing Users")}
|
||||
{gettext("Users")}
|
||||
<:actions>
|
||||
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
|
||||
<.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
|
||||
|
|
|
|||
|
|
@ -45,8 +45,6 @@ defmodule MvWeb.UserLive.Show do
|
|||
</.button>
|
||||
</:leading>
|
||||
{gettext("User")} {@user.email}
|
||||
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
|
||||
|
||||
<:actions>
|
||||
<%= if can?(@current_user, :update, @user) do %>
|
||||
<.button
|
||||
|
|
@ -115,11 +113,17 @@ defmodule MvWeb.UserLive.Show do
|
|||
|
||||
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||
<%= 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">
|
||||
<h3 id="delete-user-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
|
||||
<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
|
||||
)}
|
||||
</p>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue