style: consistent badges with sufficient color contrast

This commit is contained in:
carla 2026-02-26 08:33:52 +01:00
parent d614ad2219
commit d0b8cb672a
22 changed files with 534 additions and 77 deletions

View file

@ -145,6 +145,101 @@ defmodule MvWeb.CoreComponents do
end
end
@doc """
Renders a non-interactive badge with WCAG-compliant contrast.
Use for status labels, counts, or tags. For clickable elements (e.g. filter chips),
use a button or link component instead, not this badge.
## Variants and styles
- **variant:** `:neutral`, `:primary`, `:info`, `:success`, `:warning`, `:error`
- **style:** `:soft` (default, tinted background), `:solid`, `:outline`
- **size:** `:sm`, `:md` (default)
Outline and soft styles always use a visible background so the badge remains
readable on base-200/base-300 surfaces (WCAG 2.2 AA). Ghost style is not exposed
by default to avoid low-contrast on gray backgrounds.
## Examples
<.badge variant="success">Paid</.badge>
<.badge variant="error" style="solid">Unpaid</.badge>
<.badge variant="neutral" size="sm">Custom</.badge>
<.badge variant="primary" style="outline">Label</.badge>
<.badge variant="success" sr_label="Paid">
<.icon name="hero-check-circle" class="size-4" />
</.badge>
"""
attr :variant, :any,
default: "neutral",
doc: "Color variant: neutral | primary | info | success | warning | error (string or atom)"
attr :style, :any,
default: "soft",
doc: "Visual style: soft | solid | outline; :outline gets bg-base-100 for contrast"
attr :size, :any,
default: "md",
doc: "Badge size: sm | md"
attr :sr_label, :string,
default: nil,
doc: "Optional screen-reader label for icon-only content"
attr :rest, :global, doc: "Arbitrary HTML attributes (e.g. id, class, data-testid)"
slot :inner_block, required: true, doc: "Badge text (and optional icon)"
slot :icon, doc: "Optional leading icon slot"
def badge(assigns) do
# Normalize so both HEEx strings (variant="neutral") and helper atoms (variant={:neutral}) work
variant = to_string(assigns.variant || "neutral")
style = to_string(assigns.style || "soft")
size = to_string(assigns.size || "md")
variant_class = "badge-#{variant}"
style_class = badge_style_class(style)
size_class = "badge-#{size}"
# Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300
outline_bg = if style == "outline", do: "bg-base-100", else: nil
rest = assigns.rest || []
rest = if is_list(rest), do: rest, else: Map.to_list(rest)
extra_class = Keyword.get(rest, :class)
rest = Keyword.drop(rest, [:class])
rest = if assigns.sr_label, do: Keyword.put(rest, :"aria-label", assigns.sr_label), else: rest
class =
["badge", variant_class, style_class, size_class, outline_bg, extra_class]
|> List.flatten()
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")
assigns =
assigns
|> assign(:class, class)
|> assign(:rest, rest)
|> assign(:has_icon, assigns.icon != [])
~H"""
<span class={@class} {@rest}>
<%= if @has_icon do %>
{render_slot(@icon)}
<% end %>
{render_slot(@inner_block)}
<%= if @sr_label do %>
<span class="sr-only">{@sr_label}</span>
<% end %>
</span>
"""
end
defp badge_style_class("soft"), do: "badge-soft"
defp badge_style_class("solid"), do: nil
defp badge_style_class("outline"), do: "badge-outline"
defp badge_style_class(_), do: nil
@doc """
Renders a dropdown menu.

View file

@ -219,6 +219,17 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
def status_color(:unpaid), do: "badge-error"
def status_color(:suspended), do: "badge-ghost"
@doc """
Returns the Core Components badge variant for a cycle status (WCAG-compliant).
Use with <.badge variant={MembershipFeeHelpers.status_variant(status)}>.
Suspended uses :warning (yellow) to match the edit cycle-status button.
"""
@spec status_variant(:paid | :unpaid | :suspended) :: :success | :error | :warning
def status_variant(:paid), do: :success
def status_variant(:unpaid), do: :error
def status_variant(:suspended), do: :warning
@doc """
Gets the icon name for a status.

View file

@ -58,8 +58,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
def render(assigns) do
~H"""
<div
class="relative"
class="relative member-filter-dropdown"
id={@id}
phx-click-away={if @open, do: "close_dropdown", else: nil}
phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape"
phx-target={@myself}
@ -89,21 +90,23 @@ defmodule MvWeb.Components.MemberFilterComponent do
@boolean_filters
)}
</span>
<span
<.badge
:if={active_boolean_filters_count(@boolean_filters) > 0}
class="badge badge-primary badge-sm"
variant="primary"
size="sm"
>
{active_boolean_filters_count(@boolean_filters)}
</span>
<span
</.badge>
<.badge
:if={
(@cycle_status_filter || map_size(@group_filters) > 0) &&
active_boolean_filters_count(@boolean_filters) == 0
}
class="badge badge-primary badge-sm"
variant="primary"
size="sm"
>
{@member_count}
</span>
</.badge>
</button>
<!--
@ -118,8 +121,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
:if={@open}
tabindex="0"
class="absolute left-0 mt-2 w-[28rem] rounded-box border border-base-300 bg-base-100 p-4 shadow-xl z-[100]"
phx-click-away="close_dropdown"
phx-target={@myself}
role="dialog"
aria-label={gettext("Member filter")}
>

View file

@ -88,12 +88,12 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
label={gettext("Show in overview")}
class="max-w-[9.375rem] text-center"
>
<span :if={custom_field.show_in_overview} class="badge badge-success">
<.badge :if={custom_field.show_in_overview} variant="success">
{gettext("Yes")}
</span>
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
</.badge>
<.badge :if={!custom_field.show_in_overview} variant="neutral">
{gettext("No")}
</span>
</.badge>
</:col>
<:action :let={{_id, custom_field}}>

View file

@ -124,7 +124,9 @@ defmodule MvWeb.GlobalSettingsLive do
<label class="label" for={@form[:vereinfacht_api_key].id}>
<span class="label-text">{gettext("API Key")}</span>
<%= if @vereinfacht_api_key_set do %>
<span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
<span class="label-text-alt">
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
</span>
<% end %>
</label>
<.input
@ -251,7 +253,9 @@ defmodule MvWeb.GlobalSettingsLive do
<label class="label" for={@form[:oidc_client_secret].id}>
<span class="label-text">{gettext("Client Secret")}</span>
<%= if @oidc_client_secret_set do %>
<span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
<span class="label-text-alt">
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
</span>
<% end %>
</label>
<.input

View file

@ -151,7 +151,7 @@ defmodule MvWeb.GroupLive.Show do
<div class="relative">
<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 %>
<span class="badge badge-outline badge flex items-center gap-1">
<.badge variant="primary" style="outline" class="flex items-center gap-1">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
<button
type="button"
@ -166,7 +166,7 @@ defmodule MvWeb.GroupLive.Show do
>
<.icon name="hero-x-mark" class="size-3" />
</button>
</span>
</.badge>
<% end %>
<input
type="text"

View file

@ -79,12 +79,12 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
label={gettext("Show in overview")}
class="max-w-[9.375rem] text-center"
>
<span :if={field_data.show_in_overview} class="badge badge-success">
<.badge :if={field_data.show_in_overview} variant="success">
{gettext("Yes")}
</span>
<span :if={!field_data.show_in_overview} class="badge badge-ghost">
</.badge>
<.badge :if={!field_data.show_in_overview} variant="neutral">
{gettext("No")}
</span>
</.badge>
</:col>
<:action :let={{_field_name, field_data}}>

View file

@ -358,15 +358,15 @@
:if={:membership_fee_status in @member_fields_visible}
label={gettext("Membership Fee Status")}
>
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
<%= if badge = MembershipFeeStatus.format_cycle_status_badge(
MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
) do %>
<span class={["badge", badge.color]}>
<.badge variant={badge.variant}>
<.icon name={badge.icon} class="size-4" />
{badge.label}
</span>
</.badge>
<% else %>
<span class="badge badge-ghost">{gettext("No cycle")}</span>
<.badge variant="neutral">{gettext("No cycle")}</.badge>
<% end %>
</:col>
<:col
@ -386,12 +386,13 @@
}
>
<%= for group <- (member.groups || []) do %>
<span
class="badge badge-outline badge-primary"
<.badge
variant="primary"
style="outline"
aria-label={gettext("Member of group %{name}", name: group.name)}
>
{group.name}
</span>
</.badge>
<% end %>
<%= if (member.groups || []) == [] do %>
<span class="text-base-content/50">—</span>

View file

@ -221,22 +221,22 @@ defmodule MvWeb.MemberLive.Show do
/>
<.data_field label={gettext("Last Cycle")} class="min-w-32">
<%= if @member.last_cycle_status do %>
<% status = @member.last_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<.badge variant={MembershipFeeHelpers.status_variant(@member.last_cycle_status)}>
{format_status_label(@member.last_cycle_status)}
</.badge>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<.badge variant="neutral">{gettext("No cycles")}</.badge>
<% end %>
</.data_field>
<.data_field label={gettext("Current Cycle")} class="min-w-36">
<%= if @member.current_cycle_status do %>
<% status = @member.current_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<.badge variant={
MembershipFeeHelpers.status_variant(@member.current_cycle_status)
}>
{format_status_label(@member.current_cycle_status)}
</.badge>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<.badge variant="neutral">{gettext("No cycles")}</.badge>
<% end %>
</.data_field>
</div>

View file

@ -183,9 +183,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</:col>
<:col :let={cycle} label={gettext("Interval")}>
<span class="badge badge-outline">
<.badge variant="neutral" style="outline">
{MembershipFeeHelpers.format_interval(cycle.membership_fee_type.interval)}
</span>
</.badge>
</:col>
<:col :let={cycle} label={gettext("Amount")}>
@ -205,12 +205,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</:col>
<:col :let={cycle} label={gettext("Status")}>
<% badge = MembershipFeeHelpers.status_color(cycle.status) %>
<% icon = MembershipFeeHelpers.status_icon(cycle.status) %>
<span class={["badge", badge]}>
<.icon name={icon} class="size-4" />
<.badge variant={MembershipFeeHelpers.status_variant(cycle.status)}>
<.icon name={MembershipFeeHelpers.status_icon(cycle.status)} class="size-4" />
{format_status_label(cycle.status)}
</span>
</.badge>
</:col>
<:action :let={cycle}>

View file

@ -177,7 +177,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
id="default_membership_fee_type_id"
name="settings[default_membership_fee_type_id]"
class={[
"select select-bordered w-full",
"select select-bordered",
if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "")
]}
phx-debounce="blur"
@ -323,13 +323,13 @@ defmodule MvWeb.MembershipFeeSettingsLive do
</:col>
<:col :let={mft} label={gettext("Interval")}>
<span class="badge badge-outline">
<.badge variant="neutral" style="outline">
{MembershipFeeHelpers.format_interval(mft.interval)}
</span>
</.badge>
</:col>
<:col :let={mft} label={gettext("Members")}>
<span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
<.badge variant="neutral">{get_member_count(mft, @member_counts)}</.badge>
</:col>
<:action :let={mft}>

View file

@ -68,13 +68,13 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
</:col>
<:col :let={mft} label={gettext("Interval")}>
<span class="badge badge-outline">
<.badge variant="neutral" style="outline">
{MembershipFeeHelpers.format_interval(mft.interval)}
</span>
</.badge>
</:col>
<:col :let={mft} label={gettext("Members")}>
<span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
<.badge variant="neutral">{get_member_count(mft, @member_counts)}</.badge>
</:col>
<:action :let={mft}>

View file

@ -18,6 +18,8 @@ defmodule MvWeb.RoleLive.Helpers do
@doc """
Returns the CSS badge class for a permission set name.
Deprecated for new code: prefer `permission_set_badge_variant/1` with <.badge>.
"""
@spec permission_set_badge_class(String.t()) :: String.t()
def permission_set_badge_class("own_data"), do: "badge badge-neutral badge-sm"
@ -26,6 +28,18 @@ defmodule MvWeb.RoleLive.Helpers do
def permission_set_badge_class("admin"), do: "badge badge-error badge-sm"
def permission_set_badge_class(_), do: "badge badge-ghost badge-sm"
@doc """
Returns the Core Components badge variant for a permission set name (WCAG-compliant).
Use with <.badge variant={permission_set_badge_variant(permission_set_name)} size="sm">.
"""
@spec permission_set_badge_variant(String.t()) :: :neutral | :info | :success | :error
def permission_set_badge_variant("own_data"), do: :neutral
def permission_set_badge_variant("read_only"), do: :info
def permission_set_badge_variant("normal_user"), do: :success
def permission_set_badge_variant("admin"), do: :error
def permission_set_badge_variant(_), do: :neutral
@doc """
Builds Ash options with actor and domain, ensuring actor is never nil in real paths.
"""

View file

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

View file

@ -21,9 +21,9 @@
<:col :let={role} label={gettext("Name")}>
<div class="flex items-center gap-2">
<span class="font-medium">{role.name}</span>
<%= if role.is_system_role do %>
<span class="badge badge-warning badge-sm">{gettext("System Role")}</span>
<% end %>
<.badge :if={role.is_system_role} variant="warning" size="sm">
{gettext("System Role")}
</.badge>
</div>
</:col>
@ -36,21 +36,22 @@
</:col>
<:col :let={role} label={gettext("Permission Set")}>
<span class={permission_set_badge_class(role.permission_set_name)}>
<.badge variant={permission_set_badge_variant(role.permission_set_name)} size="sm">
{role.permission_set_name}
</span>
</.badge>
</:col>
<:col :let={role} label={gettext("Type")}>
<%= if role.is_system_role do %>
<span class="badge badge-warning badge-sm">{gettext("System")}</span>
<% else %>
<span class="badge badge-ghost badge-sm">{gettext("Custom")}</span>
<% end %>
<.badge :if={role.is_system_role} variant="warning" size="sm">
{gettext("System")}
</.badge>
<.badge :if={!role.is_system_role} variant="neutral" size="sm">
{gettext("Custom")}
</.badge>
</:col>
<:col :let={role} label={gettext("Users")}>
<span class="badge badge-ghost">{get_user_count(role, @user_counts)}</span>
<.badge variant="neutral">{get_user_count(role, @user_counts)}</.badge>
</:col>
<:action :let={role}>

View file

@ -17,7 +17,7 @@ defmodule MvWeb.RoleLive.Show do
require Ash.Query
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3]
@impl true
def mount(%{"id" => id}, _session, socket) do
@ -196,16 +196,17 @@ defmodule MvWeb.RoleLive.Show do
<% end %>
</:item>
<:item title={gettext("Permission Set")}>
<span class={permission_set_badge_class(@role.permission_set_name)}>
<.badge variant={permission_set_badge_variant(@role.permission_set_name)}>
{@role.permission_set_name}
</span>
</.badge>
</:item>
<:item title={gettext("System Role")}>
<%= if @role.is_system_role do %>
<span class="badge badge-warning">{gettext("Yes")}</span>
<% else %>
<span class="badge badge-ghost">{gettext("No")}</span>
<% end %>
<.badge :if={@role.is_system_role} variant="warning">
{gettext("Yes")}
</.badge>
<.badge :if={!@role.is_system_role} variant="neutral">
{gettext("No")}
</.badge>
</:item>
</.list>
</Layouts.app>

View file

@ -93,22 +93,30 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
## Returns
Map with `:color`, `:icon`, and `:label` keys, or `nil` if status is nil
Map with `:variant`, `:icon`, and `:label` keys (and legacy `:color`), or `nil` if status is nil.
Use `:variant` with <.badge variant={badge.variant}> for WCAG-compliant rendering.
## Examples
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(:paid)
%{color: "badge-success", icon: "hero-check-circle", label: "Paid"}
%{variant: :success, color: "badge-success", icon: "hero-check-circle", label: "Paid"}
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(nil)
nil
"""
@spec format_cycle_status_badge(:paid | :unpaid | :suspended | nil) ::
%{color: String.t(), icon: String.t(), label: String.t()} | nil
%{
variant: :success | :error | :warning,
color: String.t(),
icon: String.t(),
label: String.t()
}
| nil
def format_cycle_status_badge(nil), do: nil
def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do
%{
variant: MembershipFeeHelpers.status_variant(status),
color: MembershipFeeHelpers.status_color(status),
icon: MembershipFeeHelpers.status_icon(status),
label: format_status_label(status)