feat: improve color contrast

This commit is contained in:
carla 2026-02-26 11:50:45 +01:00
parent e422e5f4ef
commit 2c49018ab7
8 changed files with 142 additions and 59 deletions

View file

@ -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 */

View file

@ -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"

View file

@ -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"
> >

View file

@ -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))

View file

@ -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))

View file

@ -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)}

View file

@ -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">

View file

@ -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>