diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 50c9eca..4d303c3 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -2781,7 +2781,11 @@ Building accessible applications ensures that all users, including those with di ### 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 # Tailwind classes with sufficient contrast (4.5:1 minimum) diff --git a/assets/css/app.css b/assets/css/app.css index bbe7424..04d887f 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -118,6 +118,138 @@ 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 ============================================ */ diff --git a/docs/badge-wcag-phase1-analysis.md b/docs/badge-wcag-phase1-analysis.md new file mode 100644 index 0000000..5b6a834 --- /dev/null +++ b/docs/badge-wcag-phase1-analysis.md @@ -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) | `` / `` | +| `lib/mv_web/live/components/member_filter_component.ex` | Filter-Chips (Anzahl) | `` (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) | ``, `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` + ``, `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 `` durch `<.badge variant="..." style="...">...` 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). diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 21e3546..3ee5ede 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -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 variant="error" style="solid">Unpaid + <.badge variant="neutral" size="sm">Custom + <.badge variant="primary" style="outline">Label + <.badge variant="success" sr_label="Paid"> + <.icon name="hero-check-circle" class="size-4" /> + + """ + 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""" + + <%= if @has_icon do %> + {render_slot(@icon)} + <% end %> + {render_slot(@inner_block)} + <%= if @sr_label do %> + {@sr_label} + <% end %> + + """ + 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. diff --git a/lib/mv_web/helpers/membership_fee_helpers.ex b/lib/mv_web/helpers/membership_fee_helpers.ex index 27c99f5..e8a2ce8 100644 --- a/lib/mv_web/helpers/membership_fee_helpers.ex +++ b/lib/mv_web/helpers/membership_fee_helpers.ex @@ -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. diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index ef6f32e..95a3954 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -58,8 +58,9 @@ defmodule MvWeb.Components.MemberFilterComponent do def render(assigns) do ~H"""
- 0} - class="badge badge-primary badge-sm" + variant="primary" + size="sm" > {active_boolean_filters_count(@boolean_filters)} - - + <.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} - +