style: consistent badges with sufficient color contrast
This commit is contained in:
parent
d614ad2219
commit
d0b8cb672a
22 changed files with 534 additions and 77 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
============================================ */
|
||||
|
|
|
|||
88
docs/badge-wcag-phase1-analysis.md
Normal file
88
docs/badge-wcag-phase1-analysis.md
Normal 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).
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
91
test/mv_web/components/core_components_badge_test.exs
Normal file
91
test/mv_web/components/core_components_badge_test.exs
Normal 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
|
||||
|
|
@ -254,6 +254,14 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
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
|
||||
test "returns correct color classes for statuses" do
|
||||
assert MembershipFeeHelpers.status_color(:paid) == "badge-success"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue