From d0b8cb672ad00f3862b023ed4e99928202bc9165 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 26 Feb 2026 08:33:52 +0100 Subject: [PATCH 01/14] style: consistent badges with sufficient color contrast --- CODE_GUIDELINES.md | 6 +- assets/css/app.css | 132 ++++++++++++++++++ docs/badge-wcag-phase1-analysis.md | 88 ++++++++++++ lib/mv_web/components/core_components.ex | 95 +++++++++++++ lib/mv_web/helpers/membership_fee_helpers.ex | 11 ++ .../components/member_filter_component.ex | 19 +-- .../live/custom_field_live/index_component.ex | 8 +- lib/mv_web/live/global_settings_live.ex | 8 +- lib/mv_web/live/group_live/show.ex | 4 +- .../live/member_field_live/index_component.ex | 8 +- lib/mv_web/live/member_live/index.html.heex | 17 +-- lib/mv_web/live/member_live/show.ex | 20 +-- .../show/membership_fees_component.ex | 12 +- .../live/membership_fee_settings_live.ex | 8 +- .../live/membership_fee_type_live/index.ex | 6 +- lib/mv_web/live/role_live/helpers.ex | 14 ++ lib/mv_web/live/role_live/index.ex | 2 +- lib/mv_web/live/role_live/index.html.heex | 23 +-- lib/mv_web/live/role_live/show.ex | 17 +-- .../index/membership_fee_status.ex | 14 +- .../components/core_components_badge_test.exs | 91 ++++++++++++ .../helpers/membership_fee_helpers_test.exs | 8 ++ 22 files changed, 534 insertions(+), 77 deletions(-) create mode 100644 docs/badge-wcag-phase1-analysis.md create mode 100644 test/mv_web/components/core_components_badge_test.exs 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} - +
0} class="mb-4">
@@ -249,7 +249,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
- +
0} class="mb-2">
@@ -316,7 +316,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
- +
<.button diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index 7e802b8..4ecc6f3 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -130,149 +130,153 @@ defmodule MvWeb.GroupLive.Show do
-
-

{gettext("Members")}

-
-

- {ngettext( - "Total: %{count} member", - "Total: %{count} members", - @group.member_count || 0, - count: @group.member_count || 0 - )} -

+
+

{gettext("Members")}

+
+

+ {ngettext( + "Total: %{count} member", + "Total: %{count} members", + @group.member_count || 0, + count: @group.member_count || 0 + )} +

- <%= if can?(@current_user, :update, @group) do %> -
- <%= if assigns[:show_add_member_input] do %> -
-
-
-
+ <%= if can?(@current_user, :update, @group) do %> +
+ <%= if assigns[:show_add_member_input] do %> +
+ +
+
<%= for member <- @selected_members do %> - <.badge variant="primary" style="outline" class="flex items-center gap-1"> - {MvWeb.Helpers.MemberHelpers.display_name(member)} - <.tooltip content={gettext("Remove")} position="top"> - <.button - type="button" - variant="icon" - size="sm" - phx-click="remove_selected_member" - phx-value-member_id={member.id} - aria-label={ - gettext("Remove %{name}", - name: MvWeb.Helpers.MemberHelpers.display_name(member) - ) - } - class="p-0 h-4 w-4 min-h-0" - > - <.icon name="hero-x-mark" class="size-3" /> - - - - <% end %> - -
- - <%= if length(@available_members) > 0 do %> -
- <%= for {member, index} <- Enum.with_index(@available_members) do %> -
-

- {MvWeb.Helpers.MemberHelpers.display_name(member)} -

-

- {member.email || gettext("No email")} -

-
+ {MvWeb.Helpers.MemberHelpers.display_name(member)} + <.tooltip content={gettext("Remove")} position="top"> + <.button + type="button" + variant="icon" + size="sm" + phx-click="remove_selected_member" + phx-value-member_id={member.id} + aria-label={ + gettext("Remove %{name}", + name: MvWeb.Helpers.MemberHelpers.display_name(member) + ) + } + class="p-0 h-4 w-4 min-h-0" + > + <.icon name="hero-x-mark" class="size-3" /> + + + <% end %> +
- <% end %> -
- - <.button - type="button" - variant="primary" - phx-click="add_selected_members" - data-testid="group-show-add-selected-members-btn" - disabled={Enum.empty?(@selected_member_ids)} - aria-label={gettext("Add members")} - class="join-item" - > - <.icon name="hero-plus" class="size-5" /> - - <.button - type="button" - variant="neutral" - phx-click="hide_add_member_input" - aria-label={gettext("Cancel")} - class="join-item" - > - {gettext("Cancel")} - -
- <% else %> - <.button - variant="primary" - phx-click="show_add_member_input" - aria-label={gettext("Add Member")} - > - {gettext("Add Member")} - - <% end %> -
- <% end %> - <%= if Enum.empty?(@group.members || []) do %> + <%= if length(@available_members) > 0 do %> +
+ <%= for {member, index} <- Enum.with_index(@available_members) do %> +
+

+ {MvWeb.Helpers.MemberHelpers.display_name(member)} +

+

+ {member.email || gettext("No email")} +

+
+ <% end %> +
+ <% end %> +
+ + <.button + type="button" + variant="primary" + phx-click="add_selected_members" + data-testid="group-show-add-selected-members-btn" + disabled={Enum.empty?(@selected_member_ids)} + aria-label={gettext("Add members")} + class="join-item" + > + <.icon name="hero-plus" class="size-5" /> + + <.button + type="button" + variant="neutral" + phx-click="hide_add_member_input" + aria-label={gettext("Cancel")} + class="join-item" + > + {gettext("Cancel")} + +
+ <% else %> + <.button + variant="primary" + phx-click="show_add_member_input" + aria-label={gettext("Add Member")} + > + {gettext("Add Member")} + + <% end %> +
+ <% end %> + + <%= if Enum.empty?(@group.members || []) do %>

{gettext("No members in this group")}

diff --git a/lib/mv_web/live/member_field_live/index_component.ex b/lib/mv_web/live/member_field_live/index_component.ex index d6f87b1..d8b2616 100644 --- a/lib/mv_web/live/member_field_live/index_component.ex +++ b/lib/mv_web/live/member_field_live/index_component.ex @@ -52,6 +52,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do :if={!@show_form} id="member_fields" rows={@member_fields} + row_id={fn {field_name, _field_data} -> "member_field-#{field_name}" end} row_click={ fn {field_name, _field_data} -> JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself) diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 45da418..e4a627b 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -85,218 +85,219 @@ defmodule MvWeb.MemberLive.Form do > <%!-- Personal Data and Custom Fields Row --%>
- <%!-- Personal Data Section --%> -
- <.form_section title={gettext("Personal Data")}> -
- <%!-- Name Row --%> -
-
- <.input - field={@form[:first_name]} - label={gettext("First Name")} - required={@member_field_required_map[:first_name]} - /> + <%!-- Personal Data Section --%> +
+ <.form_section title={gettext("Personal Data")}> +
+ <%!-- Name Row --%> +
+
+ <.input + field={@form[:first_name]} + label={gettext("First Name")} + required={@member_field_required_map[:first_name]} + /> +
+
+ <.input + field={@form[:last_name]} + label={gettext("Last Name")} + required={@member_field_required_map[:last_name]} + /> +
-
- <.input - field={@form[:last_name]} - label={gettext("Last Name")} - required={@member_field_required_map[:last_name]} - /> -
-
- <%!-- Address: Country, Postal Code, City in one row --%> -
-
- <.input field={@form[:country]} label={gettext("Country")} /> + <%!-- Address: Country, Postal Code, City in one row --%> +
+
+ <.input field={@form[:country]} label={gettext("Country")} /> +
+
+ <.input + field={@form[:postal_code]} + label={gettext("Postal Code")} + required={@member_field_required_map[:postal_code]} + /> +
+
+ <.input field={@form[:city]} label={gettext("City")} /> +
-
- <.input - field={@form[:postal_code]} - label={gettext("Postal Code")} - required={@member_field_required_map[:postal_code]} - /> -
-
- <.input field={@form[:city]} label={gettext("City")} /> -
-
- <%!-- Street and Nr. below --%> -
+ <%!-- Street and Nr. below --%> +
+
+ <.input field={@form[:street]} label={gettext("Street")} /> +
+
+ <.input field={@form[:house_number]} label={gettext("Nr.")} /> +
+
+ + <%!-- Email --%>
- <.input field={@form[:street]} label={gettext("Street")} /> + <.input field={@form[:email]} label={gettext("Email")} required type="email" />
-
- <.input field={@form[:house_number]} label={gettext("Nr.")} /> -
-
- <%!-- Email --%> -
- <.input field={@form[:email]} label={gettext("Email")} required type="email" /> -
- - <%!-- Membership Dates Row --%> -
-
- <.input - field={@form[:join_date]} - label={gettext("Join Date")} - type="date" - required={@member_field_required_map[:join_date]} - /> + <%!-- Membership Dates Row --%> +
+
+ <.input + field={@form[:join_date]} + label={gettext("Join Date")} + type="date" + required={@member_field_required_map[:join_date]} + /> +
+
+ <.input + field={@form[:exit_date]} + label={gettext("Exit Date")} + type="date" + required={@member_field_required_map[:exit_date]} + /> +
-
+ + <%!-- Notes --%> +
<.input - field={@form[:exit_date]} - label={gettext("Exit Date")} - type="date" - required={@member_field_required_map[:exit_date]} + field={@form[:notes]} + label={gettext("Notes")} + type="textarea" + required={@member_field_required_map[:notes]} />
+ +
- <%!-- Notes --%> + <%!-- Custom Fields Section --%> + <%= if Enum.any?(@custom_fields) do %> +
+ <.form_section title={gettext("Custom Fields")}> +
+ <%!-- Render in sorted order by finding the form for each sorted custom field --%> + <%= for cf <- @sorted_custom_fields do %> + <.inputs_for :let={f_cfv} field={@form[:custom_field_values]}> + <%= if f_cfv[:custom_field_id].value == cf.id do %> +
+ <.inputs_for :let={value_form} field={f_cfv[:value]}> + <.input + field={value_form[:value]} + label={cf.name} + type={custom_field_input_type(cf.value_type)} + required={cf.required} + /> + + +
+ <% end %> + + <% end %> +
+ +
+ <% end %> +
+ + <%!-- Membership Fee Section --%> +
+ <.form_section title={gettext("Membership Fee")}> +
- <.input - field={@form[:notes]} - label={gettext("Notes")} - type="textarea" - required={@member_field_required_map[:notes]} - /> + + + <%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %> + <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> +

{msg}

+ <% end %> + <%= if @interval_warning do %> +
+ <.icon name="hero-exclamation-triangle" class="size-5" /> + {@interval_warning} +
+ <% end %> +

+ {gettext( + "Select a membership fee type for this member. Members can only switch between types with the same interval." + )} +

- <%!-- Custom Fields Section --%> - <%= if Enum.any?(@custom_fields) do %> -
- <.form_section title={gettext("Custom Fields")}> -
- <%!-- Render in sorted order by finding the form for each sorted custom field --%> - <%= for cf <- @sorted_custom_fields do %> - <.inputs_for :let={f_cfv} field={@form[:custom_field_values]}> - <%= if f_cfv[:custom_field_id].value == cf.id do %> -
- <.inputs_for :let={value_form} field={f_cfv[:value]}> - <.input - field={value_form[:value]} - label={cf.name} - type={custom_field_input_type(cf.value_type)} - required={cf.required} - /> - - -
- <% end %> - - <% end %> -
- -
- <% end %> -
+ <%!-- Bottom Action Buttons --%> +
+ <.button navigate={return_path(@return_to, @member)} variant="neutral" type="button"> + {gettext("Cancel")} + + <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> + {gettext("Save Member")} + +
- <%!-- Membership Fee Section --%> -
- <.form_section title={gettext("Membership Fee")}> -
-
- - - <%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %> - <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> -

{msg}

- <% end %> - <%= if @interval_warning do %> -
- <.icon name="hero-exclamation-triangle" class="size-5" /> - {@interval_warning} -
- <% end %> -

+ <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%> + <%= if @member && can?(@current_user, :destroy, @member) do %> +

+

+ {gettext("Danger zone")} +

+
+

{gettext( - "Select a membership fee type for this member. Members can only switch between types with the same interval." + "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." )}

+ <.button + variant="danger" + type="button" + phx-click="delete" + phx-value-id={@member.id} + data-confirm={ + gettext( + "Are you sure you want to delete %{name}? This action cannot be undone.", + name: MvWeb.Helpers.MemberHelpers.display_name(@member) + ) + } + data-testid="member-delete" + aria-label={ + gettext("Delete member %{name}", + name: MvWeb.Helpers.MemberHelpers.display_name(@member) + ) + } + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete member")} +
-
- -
- - <%!-- Bottom Action Buttons --%> -
- <.button navigate={return_path(@return_to, @member)} variant="neutral" type="button"> - {gettext("Cancel")} - - <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> - {gettext("Save Member")} - -
- - <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%> - <%= if @member && can?(@current_user, :destroy, @member) do %> -
-

- {gettext("Danger zone")} -

-
-

- {gettext( - "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." - )} -

- <.button - variant="danger" - type="button" - phx-click="delete" - phx-value-id={@member.id} - data-confirm={ - gettext("Are you sure you want to delete %{name}? This action cannot be undone.", - name: MvWeb.Helpers.MemberHelpers.display_name(@member) - ) - } - data-testid="member-delete" - aria-label={ - gettext("Delete member %{name}", - name: MvWeb.Helpers.MemberHelpers.display_name(@member) - ) - } - > - <.icon name="hero-trash" class="size-4" /> - {gettext("Delete member")} - -
-
- <% end %> + + <% end %>
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 412a5c4..c49e343 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -109,7 +109,7 @@ sort_field={@sort_field} sort_order={@sort_order} > - + <:col :let={member} @@ -134,286 +134,286 @@ aria-label={gettext("Select member")} role="checkbox" /> - - <:col - :let={member} - :if={:first_name in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_first_name} - field={:first_name} - label={gettext("First name")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.first_name} - - <:col - :let={member} - :if={:last_name in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_last_name} - field={:last_name} - label={gettext("Last name")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.last_name} - - <:col - :let={member} - :if={:email in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_email} - field={:email} - label={gettext("Email")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.email} - - <:col - :let={member} - :if={:join_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_join_date} - field={:join_date} - label={gettext("Join Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.join_date)} - - <:col - :let={member} - :if={:exit_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_exit_date} - field={:exit_date} - label={gettext("Exit Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.exit_date)} - - <:col - :let={member} - :if={:notes in @member_fields_visible} - label={gettext("Notes")} - > - {member.notes} - - <:col - :let={member} - :if={:country in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_country} - field={:country} - label={gettext("Country")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.country} - - <:col - :let={member} - :if={:city in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_city} - field={:city} - label={gettext("City")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.city} - - <:col - :let={member} - :if={:street in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_street} - field={:street} - label={gettext("Street")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.street} - - <:col - :let={member} - :if={:house_number in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_house_number} - field={:house_number} - label={gettext("House Number")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.house_number} - - <:col - :let={member} - :if={:postal_code in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_postal_code} - field={:postal_code} - label={gettext("Postal Code")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.postal_code} - - <:col - :let={member} - :if={:membership_fee_start_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_membership_fee_start_date} - field={:membership_fee_start_date} - label={gettext("Membership Fee Start Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} - - <:col - :let={member} - :if={:membership_fee_type in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_membership_fee_type} - field={:membership_fee_type} - label={gettext("Fee Type")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <%= if member.membership_fee_type do %> - {member.membership_fee_type.name} - <% else %> - - <% end %> - - <:col - :let={member} - :if={:membership_fee_status in @member_fields_visible} - label={gettext("Membership Fee Status")} - > - <%= if badge = MembershipFeeStatus.format_cycle_status_badge( + + <:col + :let={member} + :if={:first_name in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_first_name} + field={:first_name} + label={gettext("First name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.first_name} + + <:col + :let={member} + :if={:last_name in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_last_name} + field={:last_name} + label={gettext("Last name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.last_name} + + <:col + :let={member} + :if={:email in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_email} + field={:email} + label={gettext("Email")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.email} + + <:col + :let={member} + :if={:join_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_join_date} + field={:join_date} + label={gettext("Join Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.join_date)} + + <:col + :let={member} + :if={:exit_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_exit_date} + field={:exit_date} + label={gettext("Exit Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.exit_date)} + + <:col + :let={member} + :if={:notes in @member_fields_visible} + label={gettext("Notes")} + > + {member.notes} + + <:col + :let={member} + :if={:country in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_country} + field={:country} + label={gettext("Country")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.country} + + <:col + :let={member} + :if={:city in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_city} + field={:city} + label={gettext("City")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.city} + + <:col + :let={member} + :if={:street in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_street} + field={:street} + label={gettext("Street")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.street} + + <:col + :let={member} + :if={:house_number in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_house_number} + field={:house_number} + label={gettext("House Number")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.house_number} + + <:col + :let={member} + :if={:postal_code in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_postal_code} + field={:postal_code} + label={gettext("Postal Code")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.postal_code} + + <:col + :let={member} + :if={:membership_fee_start_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_membership_fee_start_date} + field={:membership_fee_start_date} + label={gettext("Membership Fee Start Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} + + <:col + :let={member} + :if={:membership_fee_type in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_membership_fee_type} + field={:membership_fee_type} + label={gettext("Fee Type")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + <%= if member.membership_fee_type do %> + {member.membership_fee_type.name} + <% else %> + + <% end %> + + <:col + :let={member} + :if={:membership_fee_status in @member_fields_visible} + label={gettext("Membership Fee Status")} + > + <%= if badge = MembershipFeeStatus.format_cycle_status_badge( MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle) ) do %> - <.badge variant={badge.variant}> - <.icon name={badge.icon} class="size-4" /> - {badge.label} - - <% else %> - <.badge variant="neutral">{gettext("No cycle")} - <% end %> - - <:col - :let={member} - :if={:groups in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_groups} - field={:groups} - label={gettext("Groups")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <%= for group <- (member.groups || []) do %> - <.badge - variant="primary" - style="outline" - aria-label={gettext("Member of group %{name}", name: group.name)} - > - {group.name} - - <% end %> - <%= if (member.groups || []) == [] do %> - - <% end %> - - <:action :let={member}> -
- <.link navigate={~p"/members/#{member}"} data-testid="member-show-link"> - {gettext("Show")} - -
- - + <.badge variant={badge.variant}> + <.icon name={badge.icon} class="size-4" /> + {badge.label} + + <% else %> + <.badge variant="neutral">{gettext("No cycle")} + <% end %> + + <:col + :let={member} + :if={:groups in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_groups} + field={:groups} + label={gettext("Groups")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + <%= for group <- (member.groups || []) do %> + <.badge + variant="primary" + style="outline" + aria-label={gettext("Member of group %{name}", name: group.name)} + > + {group.name} + + <% end %> + <%= if (member.groups || []) == [] do %> + + <% end %> + + <:action :let={member}> +
+ <.link navigate={~p"/members/#{member}"} data-testid="member-show-link"> + {gettext("Show")} + +
+ +
diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index c63ced5..a957b61 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -254,7 +254,9 @@ defmodule MvWeb.MemberLive.Show do /> <.data_field label={gettext("Last Cycle")} class="min-w-32"> <%= if @member.last_cycle_status do %> - <.badge variant={MembershipFeeHelpers.status_variant(@member.last_cycle_status)}> + <.badge variant={ + MembershipFeeHelpers.status_variant(@member.last_cycle_status) + }> {format_status_label(@member.last_cycle_status)} <% else %> diff --git a/lib/mv_web/live/role_live/index.ex b/lib/mv_web/live/role_live/index.ex index 0bdc226..58f98d4 100644 --- a/lib/mv_web/live/role_live/index.ex +++ b/lib/mv_web/live/role_live/index.ex @@ -18,8 +18,7 @@ defmodule MvWeb.RoleLive.Index do require Ash.Query - import MvWeb.RoleLive.Helpers, - only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3] + import MvWeb.RoleLive.Helpers, only: [permission_set_badge_variant: 1] @impl true def mount(_params, _session, socket) do diff --git a/lib/mv_web/live/role_live/index.html.heex b/lib/mv_web/live/role_live/index.html.heex index 1dc41c8..94d1fc6 100644 --- a/lib/mv_web/live/role_live/index.html.heex +++ b/lib/mv_web/live/role_live/index.html.heex @@ -16,6 +16,7 @@ <.table id="roles" rows={@roles} + row_id={fn role -> "role-#{role.id}" end} row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end} row_tooltip={gettext("Click for role details")} > -- 2.47.2 From e422e5f4ef4c53cdf3846ab3ff4d39955529602d Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 26 Feb 2026 11:17:21 +0100 Subject: [PATCH 05/14] feat: consistent and accessible modal on delete --- CODE_GUIDELINES.md | 49 ++++++++--- DESIGN_DUIDELINES.md | 9 +- .../live/custom_field_live/index_component.ex | 14 +++- lib/mv_web/live/group_live/show.ex | 14 +++- lib/mv_web/live/member_live/form.ex | 73 +++++++++++++--- lib/mv_web/live/member_live/show.ex | 72 +++++++++++++--- .../show/membership_fees_component.ex | 58 ++++++++++--- lib/mv_web/live/role_live/show.ex | 83 ++++++++++++++----- lib/mv_web/live/user_live/form.ex | 79 +++++++++++++++--- lib/mv_web/live/user_live/show.ex | 75 ++++++++++++++--- 10 files changed, 424 insertions(+), 102 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index d68d0b5..bbc5ee4 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -3011,24 +3011,53 @@ end - [ ] Skip links are available - [ ] Tables have proper structure (th, scope, caption) - [ ] ARIA labels used for icon-only buttons +- [ ] Modals/dialogs: focus moves into modal, aria-labelledby, keyboard dismiss (Escape) -### 8.11 DaisyUI Accessibility +### 8.11 Modals and Dialogs -DaisyUI components are designed with accessibility in mind, but ensure: +Use a consistent, keyboard-accessible pattern for all confirmation and form modals (e.g. delete role, delete group, delete data field, edit cycle). Do not rely on `data-confirm` (browser `confirm()`) for destructive actions; use a LiveView-controlled `` so focus and semantics are correct (WCAG 2.4.3, 2.1.2). + +**Structure and semantics:** + +- Use `` with DaisyUI classes `modal modal-open` when the modal is visible. +- Add `role="dialog"` and `aria-labelledby` pointing to the modal title’s `id` so screen readers announce the dialog and its purpose. +- Give the title (e.g. `

`) a unique `id` (e.g. `id="delete-role-modal-title"`). + +**Focus management (WCAG 2.4.3):** + +- When the modal opens, move focus into the dialog. Use `phx-mounted={JS.focus()}` on the first focusable element: + - If the modal has an input (e.g. confirmation text), put `phx-mounted={JS.focus()}` on that input (e.g. delete data field, delete group). + - If the modal has only buttons (e.g. confirm/cancel), put `phx-mounted={JS.focus()}` on the Cancel (or first) button so the user can Tab to the primary action and confirm with the keyboard. +- This ensures that after choosing "Delete role" (or similar) with the keyboard, focus is inside the modal and the user can confirm or cancel without using the mouse. + +**Layout and consistency:** + +- Use `modal-box` for the content container and `modal-action` for the button row (Cancel + primary action). +- Place Cancel (or neutral) first, primary/danger action second. +- For destructive actions that require typing a confirmation string, use the same pattern as the delete data field modal: label, value to type, single input, then modal-action buttons. + +**Closing:** + +- Cancel button closes the modal (e.g. `phx-click="cancel_delete_modal"`). +- Optionally support Escape to close via `phx-window-keydown` on the LiveView/LiveComponent. + +**Reference implementation:** Delete data field modal in `CustomFieldLive.IndexComponent` (input + `phx-mounted={JS.focus()}` on input; `aria-labelledby` on dialog). Delete role modal in `RoleLive.Show` (no input; `phx-mounted={JS.focus()}` on Cancel button). + +### 8.12 DaisyUI Accessibility + +DaisyUI components are designed with accessibility in mind. For modals and dialogs, follow §8.11 (Modals and Dialogs). Example structure: ```heex - - + + diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md index fc3acac..b497254 100644 --- a/DESIGN_DUIDELINES.md +++ b/DESIGN_DUIDELINES.md @@ -331,14 +331,17 @@ No “silent success”. ### 10.2 Destructive actions: one standard confirmation pattern - **MUST:** All destructive actions use the same confirm style and wording conventions. -- Choose one approach and standardize: - - `JS.confirm("…")` everywhere (simple, consistent) - - or a modal component everywhere (more flexible, more work) +- **MUST:** Use the standard modal (see §10.3); do not use `data-confirm` / browser `confirm()` for destructive actions, so that focus and keyboard behaviour are consistent and accessible. **Recommended copy style:** - Title/confirm text is clear and specific (what will be deleted, consequences). - Buttons: `Cancel` (neutral) + `Delete` (danger). +### 10.3 Dialogs and modals (mandatory) +- **MUST:** For every dialog (confirmations, form overlays, delete role/group/data field, edit cycle, etc.) use the **same modal pattern**: `` with DaisyUI `modal modal-open`, `role="dialog"`, `aria-labelledby` on the title, and focus moved into the modal when it opens (first focusable element). +- **MUST NOT:** Use browser `confirm()` / `data-confirm` for destructive or important choices; use the LiveView-controlled modal so that keyboard users get focus inside the dialog and can confirm or cancel without the mouse. +- **Reference:** Full structure, focus management, and accessibility rules are in **`CODE_GUIDELINES.md` §8.11 (Modals and Dialogs)**. Follow that section for implementation (e.g. `phx-mounted={JS.focus()}` on the first focusable, consistent `modal-box` / `modal-action` layout). + --- ## 11) Detail pages (consistent structure) diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex index a9f921d..b15e49d 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -99,10 +99,18 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do

- <%!-- Delete Confirmation Modal --%> - + <%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%> + <% end %> + + <%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%> + <%= if assigns[:show_delete_modal] do %> + + + + <% end %>
""" @@ -346,7 +374,8 @@ defmodule MvWeb.MemberLive.Show do {:ok, socket |> assign(:active_tab, :contact) - |> assign(:vereinfacht_receipts, nil)} + |> assign(:vereinfacht_receipts, nil) + |> assign_new(:show_delete_modal, fn -> false end)} end @impl true @@ -398,13 +427,26 @@ defmodule MvWeb.MemberLive.Show do {:noreply, assign(socket, :active_tab, :membership_fees)} end + @impl true + def handle_event("open_delete_modal", _params, socket) do + {:noreply, assign(socket, :show_delete_modal, true)} + end + + @impl true + def handle_event("cancel_delete_modal", _params, socket) do + {:noreply, assign(socket, :show_delete_modal, false)} + end + @impl true def handle_event("delete", %{"id" => id}, socket) do member = socket.assigns.member actor = current_actor(socket) if to_string(id) != to_string(member.id) do - {:noreply, put_flash(socket, :error, gettext("Member not found"))} + {:noreply, + socket + |> put_flash(:error, gettext("Member not found")) + |> assign(:show_delete_modal, false)} else case Ash.destroy(member, actor: actor) do :ok -> @@ -415,16 +457,20 @@ defmodule MvWeb.MemberLive.Show do {:error, %Ash.Error.Forbidden{}} -> {:noreply, - put_flash( - socket, + socket + |> put_flash( :error, gettext("You do not have permission to delete this member") - )} + ) + |> assign(:show_delete_modal, false)} {:error, error} -> require Logger Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}") - {:noreply, put_flash(socket, :error, format_error(error))} + {:noreply, + socket + |> put_flash(:error, format_error(error)) + |> assign(:show_delete_modal, false)} end end end diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index 09a9ee1..1db11e3 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -288,11 +288,18 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <% end %> - <%!-- Edit Cycle Amount Modal --%> + <%!-- Edit Cycle Amount Modal (WCAG: focus in modal, aria-labelledby) --%> <%= if @editing_cycle do %> - + <% end %> - <%!-- Delete Cycle Confirmation Modal --%> + <%!-- Delete Cycle Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%> <%= if @deleting_cycle do %> - + <% end %> - <%!-- Delete All Cycles Confirmation Modal --%> + <%!-- Delete All Cycles Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%> <%= if @deleting_all_cycles do %> - + <% end %> - <%!-- Create Cycle Modal --%> + <%!-- Create Cycle Modal (WCAG: focus in modal, aria-labelledby) --%> <%= if @creating_cycle do %> - + - <%!-- Contact Data Tab Content (same structure as member show) --%> -
- <%!-- Personal Data and Custom Fields Row --%> -
- <%!-- Personal Data Section --%> -
- <.form_section title={gettext("Personal Data")}> -
- <%!-- Name Row --%> -
-
- <.input - field={@form[:first_name]} - label={gettext("First Name")} - required={@member_field_required_map[:first_name]} - /> + <%!-- Contact Data Tab Content (same structure as member show) --%> +
+ <%!-- Personal Data and Custom Fields Row --%> +
+ <%!-- Personal Data Section --%> +
+ <.form_section title={gettext("Personal Data")}> +
+ <%!-- Name Row --%> +
+
+ <.input + field={@form[:first_name]} + label={gettext("First Name")} + required={@member_field_required_map[:first_name]} + /> +
+
+ <.input + field={@form[:last_name]} + label={gettext("Last Name")} + required={@member_field_required_map[:last_name]} + /> +
-
- <.input - field={@form[:last_name]} - label={gettext("Last Name")} - required={@member_field_required_map[:last_name]} - /> -
-
- <%!-- Address: Country, Postal Code, City in one row --%> -
-
- <.input field={@form[:country]} label={gettext("Country")} /> + <%!-- Address: Country, Postal Code, City in one row --%> +
+
+ <.input field={@form[:country]} label={gettext("Country")} /> +
+
+ <.input + field={@form[:postal_code]} + label={gettext("Postal Code")} + required={@member_field_required_map[:postal_code]} + /> +
+
+ <.input field={@form[:city]} label={gettext("City")} /> +
-
- <.input - field={@form[:postal_code]} - label={gettext("Postal Code")} - required={@member_field_required_map[:postal_code]} - /> -
-
- <.input field={@form[:city]} label={gettext("City")} /> -
-
- <%!-- Street and Nr. below --%> -
+ <%!-- Street and Nr. below --%> +
+
+ <.input field={@form[:street]} label={gettext("Street")} /> +
+
+ <.input field={@form[:house_number]} label={gettext("Nr.")} /> +
+
+ + <%!-- Email --%>
- <.input field={@form[:street]} label={gettext("Street")} /> + <.input field={@form[:email]} label={gettext("Email")} required type="email" />
-
- <.input field={@form[:house_number]} label={gettext("Nr.")} /> -
-
- <%!-- Email --%> -
- <.input field={@form[:email]} label={gettext("Email")} required type="email" /> -
- - <%!-- Membership Dates Row --%> -
-
- <.input - field={@form[:join_date]} - label={gettext("Join Date")} - type="date" - required={@member_field_required_map[:join_date]} - /> + <%!-- Membership Dates Row --%> +
+
+ <.input + field={@form[:join_date]} + label={gettext("Join Date")} + type="date" + required={@member_field_required_map[:join_date]} + /> +
+
+ <.input + field={@form[:exit_date]} + label={gettext("Exit Date")} + type="date" + required={@member_field_required_map[:exit_date]} + /> +
-
+ + <%!-- Notes --%> +
<.input - field={@form[:exit_date]} - label={gettext("Exit Date")} - type="date" - required={@member_field_required_map[:exit_date]} + field={@form[:notes]} + label={gettext("Notes")} + type="textarea" + required={@member_field_required_map[:notes]} />
+ +
- <%!-- Notes --%> + <%!-- Custom Fields Section --%> + <%= if Enum.any?(@custom_fields) do %> +
+ <.form_section title={gettext("Custom Fields")}> +
+ <%!-- Render in sorted order by finding the form for each sorted custom field --%> + <%= for cf <- @sorted_custom_fields do %> + <.inputs_for :let={f_cfv} field={@form[:custom_field_values]}> + <%= if f_cfv[:custom_field_id].value == cf.id do %> +
+ <.inputs_for :let={value_form} field={f_cfv[:value]}> + <.input + field={value_form[:value]} + label={cf.name} + type={custom_field_input_type(cf.value_type)} + required={cf.required} + /> + + +
+ <% end %> + + <% end %> +
+ +
+ <% end %> +
+ + <%!-- Membership Fee Section --%> +
+ <.form_section title={gettext("Membership Fee")}> +
- <.input - field={@form[:notes]} - label={gettext("Notes")} - type="textarea" - required={@member_field_required_map[:notes]} - /> + + + <%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %> + <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> +

{msg}

+ <% end %> + <%= if @interval_warning do %> +
+ <.icon name="hero-exclamation-triangle" class="size-5" /> + {@interval_warning} +
+ <% end %> +

+ {gettext( + "Select a membership fee type for this member. Members can only switch between types with the same interval." + )} +

- <%!-- Custom Fields Section --%> - <%= if Enum.any?(@custom_fields) do %> -
- <.form_section title={gettext("Custom Fields")}> -
- <%!-- Render in sorted order by finding the form for each sorted custom field --%> - <%= for cf <- @sorted_custom_fields do %> - <.inputs_for :let={f_cfv} field={@form[:custom_field_values]}> - <%= if f_cfv[:custom_field_id].value == cf.id do %> -
- <.inputs_for :let={value_form} field={f_cfv[:value]}> - <.input - field={value_form[:value]} - label={cf.name} - type={custom_field_input_type(cf.value_type)} - required={cf.required} - /> - - -
- <% end %> - - <% end %> -
- -
- <% end %> -
+ <%!-- Bottom Action Buttons --%> +
+ <.button navigate={return_path(@return_to, @member)} variant="neutral" type="button"> + {gettext("Cancel")} + + <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> + {gettext("Save Member")} + +
- <%!-- Membership Fee Section --%> -
- <.form_section title={gettext("Membership Fee")}> -
-
- - - <%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %> - <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> -

{msg}

- <% end %> - <%= if @interval_warning do %> -
- <.icon name="hero-exclamation-triangle" class="size-5" /> - {@interval_warning} -
- <% end %> -

+ <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%> + <%= if @member && can?(@current_user, :destroy, @member) do %> +

+

+ {gettext("Danger zone")} +

+
+

{gettext( - "Select a membership fee type for this member. Members can only switch between types with the same interval." + "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." )}

-
-
- -
- - <%!-- Bottom Action Buttons --%> -
- <.button navigate={return_path(@return_to, @member)} variant="neutral" type="button"> - {gettext("Cancel")} - - <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> - {gettext("Save Member")} - -
- - <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%> - <%= if @member && can?(@current_user, :destroy, @member) do %> -
-

- {gettext("Danger zone")} -

-
-

- {gettext( - "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." - )} -

- <.button - variant="danger" - type="button" - phx-click="open_delete_modal" - data-testid="member-delete" - aria-label={ - gettext("Delete member %{name}", - name: MvWeb.Helpers.MemberHelpers.display_name(@member) - ) - } - > - <.icon name="hero-trash" class="size-4" /> - {gettext("Delete member")} - -
-
- <% end %> - - <%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%> - <%= if @member && assigns[:show_delete_modal] do %> - - - - <% end %> + + <% end %> + + <%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%> + <%= if @member && assigns[:show_delete_modal] do %> + + + + <% end %> +
@@ -461,9 +472,25 @@ defmodule MvWeb.MemberLive.Form do @impl true def handle_event("cancel_delete_modal", _params, socket) do - {:noreply, assign(socket, :show_delete_modal, false)} + {:noreply, close_delete_modal_and_restore_focus(socket)} end + def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do + {:noreply, close_delete_modal_and_restore_focus(socket)} + end + + def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket} + + def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do + if socket.assigns[:show_delete_modal] do + {:noreply, close_delete_modal_and_restore_focus(socket)} + else + {:noreply, socket} + end + end + + def handle_event("window_keydown", _params, socket), do: {:noreply, socket} + @impl true def handle_event("delete", %{"id" => id}, socket) do member = socket.assigns.member @@ -511,6 +538,12 @@ defmodule MvWeb.MemberLive.Form do end end + defp close_delete_modal_and_restore_focus(socket) do + socket + |> assign(:show_delete_modal, false) + |> push_event("focus_restore", %{id: "delete-member-form-trigger"}) + end + defp handle_save_success(socket, member) do notify_parent({:saved, member}) diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index deb6cf0..cc929cd 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -55,18 +55,26 @@ defmodule MvWeb.MemberLive.Show do -
- <%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%> +
+ <%!-- Tab Navigation: roving tabindex (only active tab tabindex="0"), ArrowLeft/ArrowRight (WCAG tab pattern) --%>