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

@ -2781,7 +2781,11 @@ Building accessible applications ensures that all users, including those with di
### 8.4 Color and Contrast ### 8.4 Color and Contrast
**Ensure Sufficient Contrast:** **Ensure Sufficient Contrast (WCAG 2.2 AA: 4.5:1 for normal text):**
- Use the Core Component `<.badge>` for all badges; theme and `app.css` overrides ensure badge text meets 4.5:1 in light and dark theme (solid, soft, and outline styles). Cycle status "suspended" uses variant `:warning` (yellow) to match the edit cycle-status button.
- For other UI, prefer theme tokens (`text-*-content` on `bg-*`) or the `.text-success-aa` / `.text-error-aa` utility classes where theme contrast is insufficient.
- Member filter join buttons (All / Paid / Unpaid, etc.) use `.member-filter-dropdown`; `app.css` overrides ensure WCAG 4.5:1 for inactive and active states.
```elixir ```elixir
# Tailwind classes with sufficient contrast (4.5:1 minimum) # Tailwind classes with sufficient contrast (4.5:1 minimum)

View file

@ -118,6 +118,138 @@
color: oklch(0.45 0.2 25); color: oklch(0.45 0.2 25);
} }
/* WCAG 2.2 AA: Badge contrast. DaisyUI .badge-outline uses transparent bg; we use
Core Component <.badge style="outline"> which adds .bg-base-100. This rule ensures
outline badges always have a visible background in both themes. */
[data-theme="light"] .badge.badge-outline,
[data-theme="dark"] .badge.badge-outline {
background-color: var(--color-base-100);
}
/* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background.
Theme tokens *-content are often too light on * backgrounds in light theme, and
badge-soft uses variant as text on a light tint (low contrast). We override
--badge-fg (and for soft, color) so badge text meets 4.5:1 in both themes. */
/* Light theme: use dark text on all colored badges (solid, soft, outline). */
[data-theme="light"] .badge.badge-primary {
--badge-fg: oklch(0.25 0.08 47);
}
[data-theme="light"] .badge.badge-primary.badge-soft {
color: oklch(0.38 0.14 47);
}
[data-theme="light"] .badge.badge-success {
--badge-fg: oklch(0.26 0.06 165);
}
[data-theme="light"] .badge.badge-success.badge-soft {
color: oklch(0.35 0.10 165);
}
[data-theme="light"] .badge.badge-error {
--badge-fg: oklch(0.22 0.08 25);
}
[data-theme="light"] .badge.badge-error.badge-soft {
color: oklch(0.38 0.14 25);
}
[data-theme="light"] .badge.badge-warning {
--badge-fg: oklch(0.28 0.06 75);
}
[data-theme="light"] .badge.badge-warning.badge-soft {
color: oklch(0.42 0.12 75);
}
[data-theme="light"] .badge.badge-info {
--badge-fg: oklch(0.26 0.08 250);
}
[data-theme="light"] .badge.badge-info.badge-soft {
color: oklch(0.38 0.12 250);
}
[data-theme="light"] .badge.badge-neutral {
--badge-fg: oklch(0.22 0.01 285);
}
[data-theme="light"] .badge.badge-neutral.badge-soft {
color: oklch(0.32 0.02 285);
}
[data-theme="light"] .badge.badge-outline.badge-primary,
[data-theme="light"] .badge.badge-outline.badge-success,
[data-theme="light"] .badge.badge-outline.badge-error,
[data-theme="light"] .badge.badge-outline.badge-warning,
[data-theme="light"] .badge.badge-outline.badge-info,
[data-theme="light"] .badge.badge-outline.badge-neutral {
--badge-fg: oklch(0.25 0.02 285);
}
/* Dark theme: ensure badge backgrounds are dark enough for light content (4.5:1).
Slightly darken solid variant backgrounds so theme *-content (light) passes. */
[data-theme="dark"] .badge.badge-primary:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.42 0.20 277);
--badge-fg: oklch(0.97 0.02 277);
}
[data-theme="dark"] .badge.badge-success:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.42 0.10 185);
--badge-fg: oklch(0.97 0.01 185);
}
[data-theme="dark"] .badge.badge-error:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.42 0.18 18);
--badge-fg: oklch(0.97 0.02 18);
}
[data-theme="dark"] .badge.badge-warning:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.48 0.14 58);
--badge-fg: oklch(0.22 0.02 58);
}
[data-theme="dark"] .badge.badge-info:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.45 0.14 242);
--badge-fg: oklch(0.97 0.02 242);
}
[data-theme="dark"] .badge.badge-neutral:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.32 0.02 257);
--badge-fg: oklch(0.96 0.01 257);
}
[data-theme="dark"] .badge.badge-soft.badge-primary { color: oklch(0.85 0.12 277); }
[data-theme="dark"] .badge.badge-soft.badge-success { color: oklch(0.82 0.08 165); }
[data-theme="dark"] .badge.badge-soft.badge-error { color: oklch(0.82 0.14 25); }
[data-theme="dark"] .badge.badge-soft.badge-warning { color: oklch(0.88 0.10 75); }
[data-theme="dark"] .badge.badge-soft.badge-info { color: oklch(0.85 0.10 250); }
[data-theme="dark"] .badge.badge-soft.badge-neutral { color: oklch(0.90 0.01 257); }
[data-theme="dark"] .badge.badge-outline.badge-primary,
[data-theme="dark"] .badge.badge-outline.badge-success,
[data-theme="dark"] .badge.badge-outline.badge-error,
[data-theme="dark"] .badge.badge-outline.badge-warning,
[data-theme="dark"] .badge.badge-outline.badge-info,
[data-theme="dark"] .badge.badge-outline.badge-neutral {
--badge-fg: oklch(0.92 0.02 257);
}
/* WCAG 2.2 AA: Member filter join buttons (All / Paid / Unpaid, group, boolean).
Inactive state uses base-content on a light/dark surface; active state ensures
*-content on * background meets 4.5:1. */
.member-filter-dropdown .join .btn {
/* Inactive: ensure readable text (theme base-content may be low contrast on btn default) */
border-color: var(--color-base-300);
}
[data-theme="light"] .member-filter-dropdown .join .btn:not(.btn-active) {
color: oklch(0.25 0.02 285);
background-color: var(--color-base-100);
}
[data-theme="light"] .member-filter-dropdown .join .btn.btn-success.btn-active {
background-color: oklch(0.42 0.12 165);
color: oklch(0.98 0.01 165);
}
[data-theme="light"] .member-filter-dropdown .join .btn.btn-error.btn-active {
background-color: oklch(0.42 0.18 18);
color: oklch(0.98 0.02 18);
}
[data-theme="dark"] .member-filter-dropdown .join .btn:not(.btn-active) {
color: oklch(0.92 0.02 257);
background-color: var(--color-base-200);
}
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-success.btn-active {
background-color: oklch(0.42 0.10 165);
color: oklch(0.97 0.01 165);
}
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-error.btn-active {
background-color: oklch(0.42 0.18 18);
color: oklch(0.97 0.02 18);
}
/* ============================================ /* ============================================
Sidebar Base Styles Sidebar Base Styles
============================================ */ ============================================ */

View file

@ -0,0 +1,88 @@
# Phase 1 — Badge WCAG Analysis & Migration
## 1) Repo-Analyse (Stand vor Änderungen)
### Badge-Verwendungen (alle Fundstellen)
| Datei | Kontext | Markup |
|-------|---------|--------|
| `lib/mv_web/live/member_field_live/index_component.ex` | Tabelle (show_in_overview) | `<span class="badge badge-success">` / `<span class="badge badge-ghost">` |
| `lib/mv_web/live/components/member_filter_component.ex` | Filter-Chips (Anzahl) | `<span class="badge badge-primary badge-sm">` (2×) |
| `lib/mv_web/live/role_live/index.html.heex` | Tabelle (System Role, Permission Set, Custom) | `badge-warning`, `permission_set_badge_class()`, `badge-ghost` (User Count) |
| `lib/mv_web/helpers/membership_fee_helpers.ex` | Helper | `status_color/1` → "badge-success" \| "badge-error" \| "badge-ghost" |
| `lib/mv_web/live/member_live/show.ex` | Mitgliedsdetail (Beiträge) | `<span class={["badge", status_color(status)]}>`, `badge-ghost` (No cycles) |
| `lib/mv_web/live/membership_fee_settings_live.ex` | Settings (Fee Types) | `badge-outline`, `badge-ghost` (member count) |
| `lib/mv_web/live/membership_fee_type_live/index.ex` | Index (Fee Types) | `badge-outline`, `badge-ghost` (member count) |
| `lib/mv_web/live/role_live/index.ex` | (Helper-Import) | `permission_set_badge_class/1` |
| `lib/mv_web/live/member_live/show/membership_fees_component.ex` | Mitgliedsbeiträge | `badge-outline`, `["badge", status_color]` |
| `lib/mv_web/live/custom_field_live/index_component.ex` | Tabelle (show_in_overview) | `badge-success`, `badge-ghost` |
| `lib/mv_web/member_live/index/membership_fee_status.ex` | Helper | `format_cycle_status_badge/1` → map mit `color`, `icon`, `label` |
| `lib/mv_web/live/global_settings_live.ex` | Form (label-text-alt) | `badge badge-ghost` "(set)" (2×) |
| `lib/mv_web/live/member_live/index.html.heex` | Tabelle (Status) | `format_cycle_status_badge` + `<span class={["badge", badge.color]}>`, `badge-ghost` (No cycle), `badge-outline badge-primary` (Filter-Chip) |
| `lib/mv_web/live/role_live/helpers.ex` | Helper | `permission_set_badge_class/1` → "badge badge-* badge-sm" |
| `lib/mv_web/live/group_live/show.ex` | Card | `badge badge-outline badge` |
| `lib/mv_web/live/role_live/show.ex` | Detail | `permission_set_badge_class`, `badge-warning` (System), `badge-ghost` (No) |
### DaisyUI/Tailwind Config
- **Tailwind:** `assets/tailwind.config.js` — erweitert nur `theme.extend.colors.brand`; kein DaisyUI hier.
- **DaisyUI:** wird in `assets/css/app.css` per `@plugin "../vendor/daisyui"` mit `themes: false` geladen.
- **Themes:** Zwei Custom-Themes in `app.css`:
- `@plugin "../vendor/daisyui-theme"` mit `name: "dark"` (default: false)
- `@plugin "../vendor/daisyui-theme"` mit `name: "light"` (default: true)
- **Theme-Umschaltung:** `lib/mv_web/components/layouts/root.html.heex` — Inline-Script setzt `document.documentElement.setAttribute("data-theme", "light"|"dark")` aus `localStorage["phx:theme"]` oder `prefers-color-scheme`. Sidebar enthält Theme-Toggle (`<.theme_toggle />`).
### Core Components
- **Modul:** `lib/mv_web/components/core_components.ex` (MvWeb.CoreComponents).
- **Vorhanden:** flash, button, dropdown_menu, form_section, input, header, table, icon, link, etc.
- **Badge:** Bisher keine zentrale `<.badge>`-Komponente.
### DaisyUI Badge (Vendor)
- **Default:** `--badge-bg: var(--badge-color, var(--color-base-100))`, `--badge-fg: var(--color-base-content)`.
- **badge-outline:** `--badge-bg: "#0000"` (transparent) → Kontrastproblem auf base-200/base-300.
- **badge-ghost:** `background-color: var(--color-base-200)`, `color: var(--color-base-content)` → auf base-200-Flächen kaum sichtbar.
- **badge-soft:** color-mix 8% Variante mit base-100 → sichtbar; Text ist Variantenfarbe (Kontrast prüfen).
---
## 2) Core Component <.badge> API (geplant)
- **attr :variant**`:neutral | :primary | :info | :success | :warning | :error`
- **attr :style**`:soft | :solid | :outline` (Default: `:soft`)
- **attr :size**`:sm | :md` (Default: `:md`)
- **slot :inner_block** — Badge-Text
- **attr :sr_label** — optional, für Icon-only (Screen Reader)
- **slot :icon** — optional
Regeln:
- `:soft` und `:solid` nutzen sichtbaren Hintergrund (kein transparenter Ghost als Default).
- `:outline` setzt immer einen Hintergrund (z. B. `bg-base-100`), damit der Rand auf grauen Flächen sichtbar bleibt.
- Ghost nur als explizites Opt-in; dann mit `bg-base-100` für Sichtbarkeit.
---
## 3) Theme-Overrides (WCAG)
- In `app.css` sind bereits Custom-Themes für `light` und `dark` mit eigenen Tokens.
- **Badge-Kontrast (WCAG 2.2 AA 4.5:1):** Zusätzliche Overrides in `app.css`:
- **Light theme:** Dunkle `--badge-fg` für alle Varianten (primary, success, error, warning, info, neutral); für `badge-soft` dunklere Textfarbe (`color`) auf getöntem Hintergrund; für `badge-outline` einheitlich dunkle Schrift auf base-100.
- **Dark theme:** Leicht abgedunkelte Badge-Hintergründe für Solid-Badges, damit die hellen *-content-Farben 4.5:1 erreichen; für `badge-soft` hellere, gut lesbare Variantentöne; für `badge-outline` heller Text (`--badge-fg`) auf base-100.
---
## 4) Migration (erledigt)
- Alle `<span class="badge ...">` durch `<.badge variant="..." style="...">...</.badge>` ersetzt.
- Klickbare Chips (z. B. Group Show „Remove“) bleiben als <.badge> mit Button im inner_block (Badge ist nur Container).
- **Neue Helper:** `MembershipFeeHelpers.status_variant/1` (→ :success | :error | :warning; suspended = :warning wie Edit-Button), `RoleLive.Helpers.permission_set_badge_variant/1` (→ :neutral | :info | :success | :error).
- **Angepasst:** `MembershipFeeStatus.format_cycle_status_badge/1` liefert zusätzlich `:variant` für <.badge>.
- **Migrierte Stellen:** member_field_live, member_filter_component, role_live (index + show), member_live (show, index, membership_fees_component), membership_fee_settings_live, membership_fee_type_live, custom_field_live, global_settings_live, group_live/show.
## 5) Weitere Anpassungen (nach Phase 1)
- **Filter Join-Buttons (WCAG):** In `app.css` Kontrast-Overrides für `.member-filter-dropdown .join .btn` (inaktiv: base-100/base-200 + dunkle/helle Schrift; aktiv: success/error mit 4.5:1).
- **Badge „Pausiert“ (suspended):** `status_variant(:suspended)``:warning` (gelb), damit Badge dieselbe Farbe wie der Edit-Button (btn-warning) hat.
- **Filter-Dropdown schließen:** `phx-click-away` vom inneren Panel auf den äußeren Wrapper (`member-filter-dropdown`) verschoben; Klick auf den Filter-Button schließt das Dropdown (konsistent mit Spalten/Ausblenden).

View file

@ -145,6 +145,101 @@ defmodule MvWeb.CoreComponents do
end end
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 """ @doc """
Renders a dropdown menu. 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(:unpaid), do: "badge-error"
def status_color(:suspended), do: "badge-ghost" 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 """ @doc """
Gets the icon name for a status. Gets the icon name for a status.

View file

@ -58,8 +58,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div <div
class="relative" class="relative member-filter-dropdown"
id={@id} id={@id}
phx-click-away={if @open, do: "close_dropdown", else: nil}
phx-window-keydown={@open && "close_dropdown"} phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape" phx-key="Escape"
phx-target={@myself} phx-target={@myself}
@ -89,21 +90,23 @@ defmodule MvWeb.Components.MemberFilterComponent do
@boolean_filters @boolean_filters
)} )}
</span> </span>
<span <.badge
:if={active_boolean_filters_count(@boolean_filters) > 0} :if={active_boolean_filters_count(@boolean_filters) > 0}
class="badge badge-primary badge-sm" variant="primary"
size="sm"
> >
{active_boolean_filters_count(@boolean_filters)} {active_boolean_filters_count(@boolean_filters)}
</span> </.badge>
<span <.badge
:if={ :if={
(@cycle_status_filter || map_size(@group_filters) > 0) && (@cycle_status_filter || map_size(@group_filters) > 0) &&
active_boolean_filters_count(@boolean_filters) == 0 active_boolean_filters_count(@boolean_filters) == 0
} }
class="badge badge-primary badge-sm" variant="primary"
size="sm"
> >
{@member_count} {@member_count}
</span> </.badge>
</button> </button>
<!-- <!--
@ -118,8 +121,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
:if={@open} :if={@open}
tabindex="0" 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]" 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" role="dialog"
aria-label={gettext("Member filter")} aria-label={gettext("Member filter")}
> >

View file

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

View file

@ -124,7 +124,9 @@ defmodule MvWeb.GlobalSettingsLive do
<label class="label" for={@form[:vereinfacht_api_key].id}> <label class="label" for={@form[:vereinfacht_api_key].id}>
<span class="label-text">{gettext("API Key")}</span> <span class="label-text">{gettext("API Key")}</span>
<%= if @vereinfacht_api_key_set do %> <%= 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 %> <% end %>
</label> </label>
<.input <.input
@ -251,7 +253,9 @@ defmodule MvWeb.GlobalSettingsLive do
<label class="label" for={@form[:oidc_client_secret].id}> <label class="label" for={@form[:oidc_client_secret].id}>
<span class="label-text">{gettext("Client Secret")}</span> <span class="label-text">{gettext("Client Secret")}</span>
<%= if @oidc_client_secret_set do %> <%= 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 %> <% end %>
</label> </label>
<.input <.input

View file

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

View file

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

View file

@ -358,15 +358,15 @@
:if={:membership_fee_status in @member_fields_visible} :if={:membership_fee_status in @member_fields_visible}
label={gettext("Membership Fee Status")} label={gettext("Membership Fee Status")}
> >
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge( <%= if badge = MembershipFeeStatus.format_cycle_status_badge(
MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle) MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
) do %> ) do %>
<span class={["badge", badge.color]}> <.badge variant={badge.variant}>
<.icon name={badge.icon} class="size-4" /> <.icon name={badge.icon} class="size-4" />
{badge.label} {badge.label}
</span> </.badge>
<% else %> <% else %>
<span class="badge badge-ghost">{gettext("No cycle")}</span> <.badge variant="neutral">{gettext("No cycle")}</.badge>
<% end %> <% end %>
</:col> </:col>
<:col <:col
@ -386,12 +386,13 @@
} }
> >
<%= for group <- (member.groups || []) do %> <%= for group <- (member.groups || []) do %>
<span <.badge
class="badge badge-outline badge-primary" variant="primary"
style="outline"
aria-label={gettext("Member of group %{name}", name: group.name)} aria-label={gettext("Member of group %{name}", name: group.name)}
> >
{group.name} {group.name}
</span> </.badge>
<% end %> <% end %>
<%= if (member.groups || []) == [] do %> <%= if (member.groups || []) == [] do %>
<span class="text-base-content/50">—</span> <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"> <.data_field label={gettext("Last Cycle")} class="min-w-32">
<%= if @member.last_cycle_status do %> <%= if @member.last_cycle_status do %>
<% status = @member.last_cycle_status %> <.badge variant={MembershipFeeHelpers.status_variant(@member.last_cycle_status)}>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}> {format_status_label(@member.last_cycle_status)}
{format_status_label(status)} </.badge>
</span>
<% else %> <% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span> <.badge variant="neutral">{gettext("No cycles")}</.badge>
<% end %> <% end %>
</.data_field> </.data_field>
<.data_field label={gettext("Current Cycle")} class="min-w-36"> <.data_field label={gettext("Current Cycle")} class="min-w-36">
<%= if @member.current_cycle_status do %> <%= if @member.current_cycle_status do %>
<% status = @member.current_cycle_status %> <.badge variant={
<span class={["badge", MembershipFeeHelpers.status_color(status)]}> MembershipFeeHelpers.status_variant(@member.current_cycle_status)
{format_status_label(status)} }>
</span> {format_status_label(@member.current_cycle_status)}
</.badge>
<% else %> <% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span> <.badge variant="neutral">{gettext("No cycles")}</.badge>
<% end %> <% end %>
</.data_field> </.data_field>
</div> </div>

View file

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

View file

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

View file

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

View file

@ -18,6 +18,8 @@ defmodule MvWeb.RoleLive.Helpers do
@doc """ @doc """
Returns the CSS badge class for a permission set name. 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() @spec permission_set_badge_class(String.t()) :: String.t()
def permission_set_badge_class("own_data"), do: "badge badge-neutral badge-sm" 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("admin"), do: "badge badge-error badge-sm"
def permission_set_badge_class(_), do: "badge badge-ghost 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 """ @doc """
Builds Ash options with actor and domain, ensuring actor is never nil in real paths. 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 require Ash.Query
import MvWeb.RoleLive.Helpers, 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 @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do

View file

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

View file

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

View file

@ -93,22 +93,30 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
## Returns ## 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 ## Examples
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(:paid) 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) iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(nil)
nil nil
""" """
@spec format_cycle_status_badge(:paid | :unpaid | :suspended | 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(nil), do: nil
def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do
%{ %{
variant: MembershipFeeHelpers.status_variant(status),
color: MembershipFeeHelpers.status_color(status), color: MembershipFeeHelpers.status_color(status),
icon: MembershipFeeHelpers.status_icon(status), icon: MembershipFeeHelpers.status_icon(status),
label: format_status_label(status) label: format_status_label(status)

View file

@ -0,0 +1,91 @@
defmodule MvWeb.Components.CoreComponentsBadgeTest do
@moduledoc """
Unit tests for the Core Components badge (WCAG-compliant, non-transparent).
"""
use MvWeb.ConnCase, async: true
import Phoenix.Component
import Phoenix.LiveViewTest
import MvWeb.CoreComponents
describe "badge/1" do
test "default variant renders with badge and badge-neutral classes (visible, not ghost)" do
assigns = %{}
html =
rendered_to_string(~H"""
<.badge variant="neutral">Label</.badge>
""")
assert html =~ "badge"
assert html =~ "badge-neutral"
assert html =~ "badge-soft"
refute html =~ "badge-ghost"
assert html =~ "Label"
end
test "success variant renders badge-success and badge-soft" do
assigns = %{}
html =
rendered_to_string(~H"""
<.badge variant="success">Paid</.badge>
""")
assert html =~ "badge-success"
assert html =~ "badge-soft"
assert html =~ "Paid"
end
test "outline style includes bg-base-100 for contrast" do
assigns = %{}
html =
rendered_to_string(~H"""
<.badge variant="primary" style="outline">Outline</.badge>
""")
assert html =~ "badge-outline"
assert html =~ "bg-base-100"
assert html =~ "Outline"
end
test "solid style has no badge-soft or badge-outline" do
assigns = %{}
html =
rendered_to_string(~H"""
<.badge variant="error" style="solid">Error</.badge>
""")
assert html =~ "badge-error"
refute html =~ "badge-soft"
refute html =~ "badge-outline"
assert html =~ "Error"
end
test "size sm adds badge-sm" do
assigns = %{}
html =
rendered_to_string(~H"""
<.badge variant="neutral" size="sm">Small</.badge>
""")
assert html =~ "badge-sm"
assert html =~ "Small"
end
test "renders as span (non-interactive)" do
assigns = %{}
html =
rendered_to_string(~H"""
<.badge variant="info">Info</.badge>
""")
assert html =~ ~r/<span[^>]*class="[^"]*badge[^"]*"/
refute html =~ ~r/<button/
end
end
end

View file

@ -254,6 +254,14 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
end end
end end
describe "status_variant/1" do
test "returns badge variant for <.badge> (suspended uses warning to match edit button)" do
assert MembershipFeeHelpers.status_variant(:paid) == :success
assert MembershipFeeHelpers.status_variant(:unpaid) == :error
assert MembershipFeeHelpers.status_variant(:suspended) == :warning
end
end
describe "status_color/1" do describe "status_color/1" do
test "returns correct color classes for statuses" do test "returns correct color classes for statuses" do
assert MembershipFeeHelpers.status_color(:paid) == "badge-success" assert MembershipFeeHelpers.status_color(:paid) == "badge-success"