Enhances accessibiity closes #421 #450

Merged
carla merged 15 commits from feat/421_accessibility into main 2026-02-26 21:03:02 +01:00
45 changed files with 2517 additions and 1214 deletions

View file

@ -2775,6 +2775,14 @@ Building accessible applications ensures that all users, including those with di
<div phx-click="action">Click me</div>
```
**Tables (Core Component `<.table>` with `row_click`):**
- When `row_click` is set, the first column that does not use `col_click` gets `tabindex="0"` and `role="button"` so each row is reachable via Tab. The `TableRowKeydown` hook triggers the row action on Enter and Space (WCAG 2.1.1). Use `row_id` and `row_tooltip` for all clickable tables (e.g. Groups, Users, Roles, Members, Custom Fields, Member Fields) so the table is fully keyboard accessible.
**Empty table cells (missing values):**
- Do not use dashes ("-", "—", "") or "n/a" as placeholders. Use CoreComponents `<.empty_cell sr_text="…">` for a cell with no value, or `<.maybe_value value={…} empty_sr_text="…">` when content is conditional. The cell is visually empty; screen readers get the `sr_text` (e.g. "No cycle", "No group assignment", "Not specified"). See Design Guidelines §8.6.
**Tab Order:**
- Ensure logical tab order matches visual order
@ -2784,7 +2792,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)
@ -3003,24 +3015,56 @@ 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)
- [ ] ARIA state attributes use string values `"true"` / `"false"` (not boolean), e.g. `aria-selected`, `aria-pressed`, `aria-expanded`.
- [ ] Tabs: when using `role="tablist"` / `role="tab"`, use roving tabindex (only active tab `tabindex="0"`) and ArrowLeft/ArrowRight to switch tabs.
### 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 `<dialog>` so focus and semantics are correct (WCAG 2.4.3, 2.1.2).
**Structure and semantics:**
- Use `<dialog>` with DaisyUI classes `modal modal-open` when the modal is visible.
- Add `role="dialog"` and `aria-labelledby` pointing to the modal titles `id` so screen readers announce the dialog and its purpose.
- Give the title (e.g. `<h3>`) 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"`).
- **MUST** support Escape to close (WCAG / WAI-ARIA dialog pattern): add `phx-keydown="dialog_keydown"` on the `<dialog>` and handle `dialog_keydown` with `key: "Escape"` to close (same effect as Cancel).
- **MUST** return focus to the trigger element when the modal closes (WCAG 2.4.3): give the trigger button a stable `id`, use the `FocusRestore` hook on a parent element, and on close (Cancel or Escape) call `push_event(socket, "focus_restore", %{id: "trigger-id"})` so keyboard users land where they started (e.g. "Delete member" button).
**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
<!-- Modal accessibility -->
<dialog id="my-modal" class="modal" aria-labelledby="modal-title">
<!-- Modal: use dialog + aria-labelledby + focus on first focusable (see §8.11) -->
<dialog id="my-modal" class="modal modal-open" role="dialog" aria-labelledby="my-modal-title">
<div class="modal-box">
<h2 id="modal-title"><%= gettext("Confirm Deletion") %></h2>
<h3 id="my-modal-title" class="text-lg font-bold"><%= gettext("Confirm Deletion") %></h3>
<p><%= gettext("Are you sure?") %></p>
<div class="modal-action">
<button class="btn" onclick="document.getElementById('my-modal').close()">
<.button variant="neutral" phx-click="cancel" phx-mounted={JS.focus()}>
<%= gettext("Cancel") %>
</button>
<button class="btn btn-error" phx-click="confirm-delete">
<%= gettext("Delete") %>
</button>
</.button>
<.button variant="danger" phx-click="confirm_delete"><%= gettext("Delete") %></.button>
</div>
</div>
</dialog>

View file

@ -293,6 +293,12 @@ Notes:
- On **mobile**, sticky headers are not used; the layout uses normal flow (header and table scroll with the page) to preserve vertical space.
- When the table is inside such a scroll container, use the CoreComponents tables `sticky_header={true}` so the tables `<thead>` stays sticky within the scroll area on desktop (`lg:sticky lg:top-0`, opaque background `bg-base-100`, z-index). Sticky areas must not overlap content at 200% zoom; focus order must remain header → filters → table.
### 8.6 Empty table cells (missing values)
- **MUST:** Missing values in tables are shown as **visually empty cells** (no dash, no "n/a").
- **MUST NOT:** Use dashes ("-", "—", "") or "n/a" as placeholders for empty cells.
- **MUST:** For accessibility, render a screen-reader-only label so the cell is not announced as "blank". Use the CoreComponents `<.empty_cell sr_text="…">` for a cell that has no value, or `<.maybe_value value={…} empty_sr_text="…">` when the cell content is conditional (value present vs. absent).
- **SHOULD:** Use context-specific `sr_text` where it helps (e.g. "No cycle", "No group assignment", "Not specified"). Default for "no value" is "Not specified".
---
## 9) Flash / Toast messages (mandatory UX)
@ -331,14 +337,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**: `<dialog>` 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)

View file

@ -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
============================================ */
@ -389,4 +521,31 @@
display: none !important;
}
/* ============================================
WCAG 1.4.3: Primary button contrast (AA)
============================================ */
/* Override DaisyUI theme --color-primary-content so text on btn-primary (brand)
meets 4.5:1. In DevTools: inspect .btn-primary, check computed --color-primary
and --color-primary-content; verify contrast at https://webaim.org/resources/contrastchecker/ */
/* Light theme: primary is orange (brand); primary-content must be dark. */
[data-theme="light"] {
--color-primary-content: oklch(0.18 0.02 47);
--color-error: oklch(55% 0.253 17.585);
--color-error-content: oklch(98% 0 0);
}
/* Dark theme: primary is purple; ensure content is light and meets 4.5:1. */
[data-theme="dark"] {
--color-error: oklch(55% 0.253 17.585);
--color-error-content: oklch(98% 0 0);
--color-primary: oklch(72% 0.17 45);
--color-primary-content: oklch(0.18 0.02 47);
--color-secondary: oklch(48% 0.233 277.117);
--color-secondary-content: oklch(98% 0 0);
}
/* This file is for your main application CSS */

View file

@ -73,6 +73,53 @@ Hooks.ComboBox = {
}
}
// TableRowKeydown hook: WCAG 2.1.1 — when a table row cell has data-row-clickable,
// Enter and Space trigger a click so row_click tables are keyboard activatable
Hooks.TableRowKeydown = {
mounted() {
this.handleKeydown = (e) => {
if (
e.target.getAttribute("data-row-clickable") === "true" &&
(e.key === "Enter" || e.key === " ")
) {
e.preventDefault()
e.target.click()
}
}
this.el.addEventListener("keydown", this.handleKeydown)
},
destroyed() {
this.el.removeEventListener("keydown", this.handleKeydown)
}
}
// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button)
Hooks.FocusRestore = {
mounted() {
this.handleEvent("focus_restore", ({id}) => {
const el = document.getElementById(id)
if (el) el.focus()
})
}
}
// TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex)
Hooks.TabListKeydown = {
mounted() {
this.handleKeydown = (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault()
}
}
this.el.addEventListener('keydown', this.handleKeydown)
},
destroyed() {
this.el.removeEventListener('keydown', this.handleKeydown)
}
}
// SidebarState hook: Manages sidebar expanded/collapsed state
Hooks.SidebarState = {
mounted() {

View file

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

View file

@ -31,6 +31,21 @@ defmodule MvWeb.CoreComponents do
alias Phoenix.LiveView.JS
# WCAG 2.4.7 / 2.4.11: Shared focus ring for buttons and dropdown (trigger + items)
@button_focus_classes [
"focus-visible:outline-none",
"focus-visible:ring-2",
"focus-visible:ring-offset-2",
"focus-visible:ring-offset-base-100",
"focus-visible:ring-base-content/60"
]
@doc """
Returns the shared focus ring class list for buttons and dropdown items (WCAG 2.4.7).
Use when building custom dropdown item buttons so they match <.button> and dropdown trigger.
"""
def button_focus_classes, do: @button_focus_classes
@doc """
Renders flash notices.
@ -147,13 +162,16 @@ defmodule MvWeb.CoreComponents do
size_class = size_classes[size]
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
assigns = assign(assigns, :btn_class, btn_class)
assigns =
assigns
|> assign(:btn_class, btn_class)
|> assign(:button_focus_classes, @button_focus_classes)
if rest[:href] || rest[:navigate] || rest[:patch] do
link_class =
if assigns[:disabled],
do: ["btn", btn_class, "btn-disabled"],
else: ["btn", btn_class]
do: ["btn", btn_class, "btn-disabled"] ++ @button_focus_classes,
else: ["btn", btn_class] ++ @button_focus_classes
link_attrs =
if assigns[:disabled] do
@ -176,13 +194,187 @@ defmodule MvWeb.CoreComponents do
"""
else
~H"""
<button class={["btn", @btn_class]} disabled={@disabled} {@rest}>
<button
class={["btn", @btn_class] ++ @button_focus_classes}
disabled={@disabled}
{@rest}
>
{render_slot(@inner_block)}
</button>
"""
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 visually empty table cell with screen-reader-only text (WCAG).
Use when a table cell has no value so that:
- The cell appears empty (no dash, no "n/a").
- Screen readers still get a meaningful label (e.g. "No cycle", "No group assignment").
See CODE_GUIDELINES §8 (Empty table cells) and Design Guidelines §8.6.
## Examples
<.empty_cell sr_text={gettext("No cycle")} />
<.empty_cell sr_text={gettext("No group assignment")} />
<.empty_cell sr_text={gettext("Not specified")} />
"""
attr :sr_text, :string,
required: true,
doc: "Text read by screen readers when the cell is visually empty"
def empty_cell(assigns) do
~H"""
<span class="sr-only">{@sr_text}</span>
"""
end
@doc """
Renders content when value is present, otherwise an accessible empty cell.
Use in table cells for optional fields: when `value` is blank, only the
screen-reader text is shown (visually empty). Otherwise the inner block is rendered.
Blank check: `nil`, `false`, `[]`, `""`, whitespace-only string, or `%Ash.NotLoaded{}` count as empty.
See CODE_GUIDELINES §8 (Empty table cells) and Design Guidelines §8.6.
## Examples
<.maybe_value value={member.membership_fee_type} empty_sr_text={gettext("No fee type")}>
{member.membership_fee_type.name}
</.maybe_value>
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
<%= for g <- member.groups do %>
<.badge variant="primary" style="outline">{g.name}</.badge>
<% end %>
</.maybe_value>
"""
attr :value, :any, doc: "Value to check; if blank, empty_cell is rendered"
attr :empty_sr_text, :string,
default: nil,
doc: "Screen-reader text when value is blank (default: gettext \"Not specified\")"
slot :inner_block, required: true
def maybe_value(assigns) do
empty_sr = assigns.empty_sr_text || gettext("Not specified")
assigns = assign(assigns, :empty_sr_text, empty_sr)
assigns = assign(assigns, :blank?, value_blank?(assigns.value))
~H"""
<%= if @blank? do %>
<.empty_cell sr_text={@empty_sr_text} />
<% else %>
{render_slot(@inner_block)}
<% end %>
"""
end
defp value_blank?(nil), do: true
defp value_blank?(false), do: true
defp value_blank?([]), do: true
defp value_blank?(%Ash.NotLoaded{}), do: true
defp value_blank?(v) when is_binary(v), do: String.trim(v) == ""
defp value_blank?(_), do: false
@doc """
Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content,
or status badges that need explanation (Design Guidelines §8.2).
@ -265,7 +457,11 @@ defmodule MvWeb.CoreComponents do
def dropdown_menu(assigns) do
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
assigns = assign(assigns, :menu_testid, menu_testid)
assigns =
assigns
|> assign(:menu_testid, menu_testid)
|> assign(:button_focus_classes, @button_focus_classes)
~H"""
<div
@ -281,17 +477,10 @@ defmodule MvWeb.CoreComponents do
tabindex="0"
role="button"
aria-haspopup="menu"
aria-expanded={@open}
aria-expanded={if @open, do: "true", else: "false"}
aria-controls={@id}
aria-label={@button_label}
class={[
"btn",
"focus:outline-none",
"focus-visible:ring-2",
"focus-visible:ring-offset-2",
"focus-visible:ring-base-content/20",
@button_class
]}
class={["btn"] ++ @button_focus_classes ++ [@button_class]}
phx-click="toggle_dropdown"
phx-target={@phx_target}
data-testid={@button_testid}
@ -359,7 +548,12 @@ defmodule MvWeb.CoreComponents do
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
}
tabindex="0"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
class={
[
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left",
"focus-visible:ring-inset"
] ++ @button_focus_classes
}
phx-click="select_item"
phx-keydown="select_item"
phx-key="Enter"
@ -670,6 +864,8 @@ defmodule MvWeb.CoreComponents do
When `row_click` is set, clicking a row (or a data cell) triggers the handler.
Rows with `row_click` get a subtle hover and focus-within outline (theme-friendly ring).
For keyboard accessibility (WCAG 2.1.1), the first column without `col_click` gets
`tabindex="0"` and `role="button"`; the TableRowKeydown hook triggers the row action on Enter/Space.
When `selected_row_id` is set and matches a row's id (via `row_value_id` or `row_item.(row).id`),
that row gets a stronger selected outline (ring-primary) for accessibility (not color-only).
@ -752,8 +948,22 @@ defmodule MvWeb.CoreComponents do
assigns = assign(assigns, :row_value_id_fn, row_value_id_fn)
# WCAG 2.1.1: when row_click is set, first column without col_click gets tabindex="0"
# so rows are reachable via Tab; TableRowKeydown hook triggers click on Enter/Space
first_row_click_col_idx =
if assigns[:row_click] do
Enum.find_index(assigns[:col] || [], fn c -> !c[:col_click] end)
end
assigns =
assign(assigns, :first_row_click_col_idx, first_row_click_col_idx)
~H"""
<div class="overflow-auto">
<div
id={@row_click && "#{@id}-keyboard"}
class="overflow-auto"
phx-hook={@row_click && "TableRowKeydown"}
>
<table class="table table-zebra">
<thead>
<tr>
@ -789,6 +999,11 @@ defmodule MvWeb.CoreComponents do
>
<td
:for={{col, col_idx} <- Enum.with_index(@col)}
tabindex={if @row_click && @first_row_click_col_idx == col_idx, do: 0, else: nil}
role={if @row_click && @first_row_click_col_idx == col_idx, do: "button", else: nil}
data-row-clickable={
if @row_click && @first_row_click_col_idx == col_idx, do: "true", else: nil
}
phx-click={
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
(@row_click && @row_click.(row))
@ -812,6 +1027,19 @@ defmodule MvWeb.CoreComponents do
classes
end
# WCAG: no focus ring on the cell itself; row shows focus via focus-within
classes =
if @row_click && @first_row_click_col_idx == col_idx do
[
"focus:outline-none",
"focus-visible:outline-none",
"focus:ring-0",
"focus-visible:ring-0" | classes
]
else
classes
end
classes =
if col_class do
[col_class | classes]

View file

@ -8,6 +8,16 @@ defmodule MvWeb.Components.ExportDropdown do
use MvWeb, :live_component
use Gettext, backend: MvWeb.Gettext
# Same focus ring as CoreComponents button/dropdown (WCAG 2.4.7)
defp dropdown_item_class do
focus =
MvWeb.CoreComponents.button_focus_classes()
|> Kernel.++(["focus-visible:ring-inset"])
|> Enum.join(" ")
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left #{focus}"
end
@impl true
def mount(socket) do
{:ok, assign(socket, :open, false)}
@ -59,7 +69,7 @@ defmodule MvWeb.Components.ExportDropdown do
<button
type="submit"
role="menuitem"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
class={dropdown_item_class()}
aria-label={gettext("Export members to CSV")}
data-testid="export-csv-link"
>
@ -75,7 +85,7 @@ defmodule MvWeb.Components.ExportDropdown do
<button
type="submit"
role="menuitem"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
class={dropdown_item_class()}
aria-label={gettext("Export members to PDF")}
data-testid="export-pdf-link"
>

View file

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

View file

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

View file

@ -111,6 +111,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
)}
</p>
<.button
id="delete-custom-field-trigger"
type="button"
variant="danger"
phx-click="request_delete"

View file

@ -19,8 +19,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
~H"""
<div id={@id} class="mt-8">
<div class="flex">
<div id={@id}>
<div :if={!@show_form} class="flex">
<p class="text-sm text-base-content/70">
{gettext("These will appear in addition to other data when adding new members.")}
</p>
@ -54,6 +54,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
<.table
id="custom_fields_table"
rows={@streams.custom_fields}
row_id={fn {_stream_key, cf} -> "custom_fields-#{cf.id}" end}
row_click={
fn {_id, custom_field} ->
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
@ -89,20 +90,29 @@ 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>
</.table>
</div>
<%!-- Delete Confirmation Modal --%>
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
<%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
<dialog
:if={@show_delete_modal}
id="delete-custom-field-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-custom-field-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Delete Data Field")}</h3>
<h3 id="delete-custom-field-modal-title" class="text-lg font-bold">
{gettext("Delete Data Field")}
</h3>
<div class="py-4 space-y-4">
<div class="alert alert-warning">
@ -110,15 +120,15 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
<div>
<p class="font-semibold">
{ngettext(
"%{count} member has a value assigned for this custom field.",
"%{count} members have values assigned for this custom field.",
"%{count} member has a value assigned for this datafield.",
"%{count} members have values assigned for this datafield.",
@custom_field_to_delete.assigned_members_count,
count: @custom_field_to_delete.assigned_members_count
)}
</p>
<p class="mt-2 text-sm">
{gettext(
"All custom field values will be permanently deleted when you delete this custom field."
"All datafield values will be permanently deleted when you delete this datafield."
)}
</p>
</div>
@ -184,8 +194,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true
def update(assigns, socket) do
# Track previous show_form state to detect when form is closed
previous_show_form = Map.get(socket.assigns, :show_form, false)
# Use socket state so send_update(open_delete_for_id: ...) does not trigger false "form closed"
previous_show_form = socket.assigns[:show_form] || false
# If show_form is explicitly provided in assigns, reset editing state
socket =
@ -197,13 +207,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
socket
end
# Detect when form is closed (show_form changes from true to false)
new_show_form = Map.get(assigns, :show_form, false)
if previous_show_form and not new_show_form do
send(self(), {:editing_section_changed, nil})
end
# Get actor from assigns or fall back to socket assigns
actor = Map.get(assigns, :actor, socket.assigns[:actor])
@ -225,6 +228,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
socket
id ->
send(self(), {:custom_field_delete_modal_open, true})
custom_field =
Ash.get!(Mv.Membership.CustomField, id,
load: [:assigned_members_count],
@ -238,6 +243,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|> assign(:open_delete_for_id, nil)
end
# Detect form closed only from final socket state (not from assigns alone)
current_show_form = socket.assigns[:show_form] || false
if previous_show_form and not current_show_form do
send(self(), {:editing_section_changed, nil})
end
{:ok, socket}
end
@ -282,6 +294,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
actor: actor
)
send(self(), {:custom_field_delete_modal_open, true})
{:noreply,
socket
|> assign(:custom_field_to_delete, custom_field)
@ -302,6 +316,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
if socket.assigns.slug_confirmation == custom_field.slug do
case Ash.destroy(custom_field, actor: actor) do
:ok ->
send(self(), {:custom_field_delete_modal_open, false})
send(self(), {:custom_field_deleted, custom_field})
{:noreply,
@ -312,6 +327,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|> stream_delete(:custom_fields, custom_field)}
{:error, error} ->
send(self(), {:custom_field_delete_modal_open, false})
send(self(), {:custom_field_delete_error, error})
{:noreply,
@ -321,6 +337,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|> assign(:slug_confirmation, "")}
end
else
send(self(), {:custom_field_delete_modal_open, false})
send(self(), :custom_field_slug_mismatch)
{:noreply,
@ -333,10 +350,22 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true
def handle_event("cancel_delete", _params, socket) do
{:noreply,
send(self(), {:custom_field_delete_modal_open, false})
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
send(self(), {:custom_field_delete_modal_open, false})
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")}
|> assign(:slug_confirmation, "")
|> push_event("focus_restore", %{id: "delete-custom-field-trigger"})
end
end

View file

@ -19,9 +19,32 @@ defmodule MvWeb.DatafieldsLive do
socket
|> assign(:page_title, gettext("Datafields"))
|> assign(:settings, settings)
|> assign(:active_editing_section, nil)}
|> assign(:active_editing_section, nil)
|> assign(:custom_field_delete_modal_open, false)}
end
@impl true
def handle_event("window_keydown", %{"key" => key}, socket)
when key in ["Escape", "Esc"] do
if socket.assigns[:custom_field_delete_modal_open] do
send_update(MvWeb.CustomFieldLive.IndexComponent,
id: "custom-fields-component",
show_delete_modal: false,
custom_field_to_delete: nil,
slug_confirmation: ""
)
{:noreply,
socket
|> assign(:custom_field_delete_modal_open, false)
|> push_event("focus_restore", %{id: "delete-custom-field-trigger"})}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
@impl true
def render(assigns) do
~H"""
@ -29,31 +52,68 @@ defmodule MvWeb.DatafieldsLive do
<.header>
{gettext("Datafields")}
<:subtitle>
{gettext("Configure member fields and custom data fields.")}
{gettext(
"Configure which data you want to save for your members. Define individual datafields."
)}
</:subtitle>
</.header>
<.form_section title={gettext("Member fields")}>
<%!-- Overview: both sections with form_section wrappers; FocusRestore for custom field delete modal --%>
<div
:if={@active_editing_section == nil}
id="datafields-focus-root"
class="mt-6 space-y-6"
phx-hook="FocusRestore"
phx-window-keydown={if @custom_field_delete_modal_open, do: "window_keydown", else: nil}
>
<.form_section title={gettext("Personal Data")}>
<.live_component
:if={@active_editing_section != :custom_fields}
module={MvWeb.MemberFieldLive.IndexComponent}
id="member-fields-component"
settings={@settings}
/>
</.form_section>
<.form_section title={gettext("Custom fields")}>
<.form_section title={gettext("Individual Datafields")}>
<.live_component
:if={@active_editing_section != :member_fields}
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
actor={@current_user}
/>
</.form_section>
</div>
<%!-- Edit mode: only the active section, no section title/card wrapper --%>
<div :if={@active_editing_section == :member_fields} class="mt-6">
<.live_component
module={MvWeb.MemberFieldLive.IndexComponent}
id="member-fields-component"
settings={@settings}
/>
</div>
<div
:if={@active_editing_section == :custom_fields}
id="datafields-focus-root"
class="mt-6"
phx-hook="FocusRestore"
phx-window-keydown={if @custom_field_delete_modal_open, do: "window_keydown", else: nil}
>
<.live_component
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
actor={@current_user}
/>
</div>
</Layouts.app>
"""
end
@impl true
def handle_info({:custom_field_delete_modal_open, open}, socket) do
{:noreply, assign(socket, :custom_field_delete_modal_open, open)}
end
@impl true
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent,

View file

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

View file

@ -68,11 +68,9 @@ defmodule MvWeb.GroupLive.Index do
{group.name}
</:col>
<:col :let={group} label={gettext("Description")}>
<%= if group.description do %>
<.maybe_value value={group.description} empty_sr_text={gettext("Not specified")}>
{group.description}
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</.maybe_value>
</:col>
<:col :let={group} label={gettext("Members")} class="text-right">
{group.member_count || 0}

View file

@ -116,7 +116,12 @@ defmodule MvWeb.GroupLive.Show do
</:actions>
</.header>
<div class="mt-6 space-y-6">
<div
id="group-show-focus-root"
class="mt-6 space-y-6"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<%!-- Group Information --%>
<div class="max-w-2xl space-y-6 mb-6">
<div>
@ -150,7 +155,11 @@ 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)}
<.tooltip content={gettext("Remove")} position="top">
<.button
@ -169,7 +178,7 @@ defmodule MvWeb.GroupLive.Show do
<.icon name="hero-x-mark" class="size-3" />
</.button>
</.tooltip>
</span>
</.badge>
<% end %>
<input
type="text"
@ -300,16 +309,14 @@ defmodule MvWeb.GroupLive.Show do
</.link>
</td>
<td>
<%= if member.email do %>
<.maybe_value value={member.email} empty_sr_text={gettext("No email")}>
<a
href={"mailto:#{member.email}"}
class="link link-primary"
>
{member.email}
</a>
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</.maybe_value>
</td>
<%= if can?(@current_user, :update, @group) do %>
<td>
@ -351,6 +358,7 @@ defmodule MvWeb.GroupLive.Show do
)}
</p>
<.button
id="delete-group-trigger"
variant="danger"
type="button"
phx-click="open_delete_modal"
@ -364,11 +372,19 @@ defmodule MvWeb.GroupLive.Show do
</section>
<% end %>
<%!-- Delete Confirmation Modal --%>
<%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
<%= if assigns[:show_delete_modal] do %>
<dialog id="delete-group-modal" class="modal modal-open" role="dialog">
<dialog
id="delete-group-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-group-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3>
<h3 id="delete-group-modal-title" class="text-lg font-bold mb-4">
{gettext("Delete Group")}
</h3>
<p class="mb-4">
{gettext("Are you sure you want to delete this group? This action cannot be undone.")}
</p>
@ -403,6 +419,7 @@ defmodule MvWeb.GroupLive.Show do
placeholder={gettext("Enter the group name to confirm")}
autocomplete="off"
phx-debounce="200"
phx-mounted={JS.focus()}
class="w-full input input-bordered"
/>
</form>
@ -443,12 +460,25 @@ defmodule MvWeb.GroupLive.Show do
@impl true
def handle_event("cancel_delete", _params, socket) do
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> assign(:name_confirmation, "")}
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
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}
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}
@impl true
def handle_event("update_name_confirmation", %{"name" => name}, socket) do
{:noreply, assign(socket, :name_confirmation, name)}
@ -929,6 +959,13 @@ defmodule MvWeb.GroupLive.Show do
end
end
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> assign(:name_confirmation, "")
|> push_event("focus_restore", %{id: "delete-group-trigger"})
end
defp perform_group_deletion(socket, group, actor) do
case Membership.destroy_group(group, actor: actor) do
:ok ->

View file

@ -25,7 +25,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
~H"""
<div id={@id}>
<p class="text-sm text-base-content/70 mb-4">
<p :if={!@show_form} class="text-sm text-base-content/70 mb-4">
{gettext(
"These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
)}
@ -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)
@ -85,12 +86,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>
</.table>
</div>
@ -99,8 +100,8 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
@impl true
def update(assigns, socket) do
# Track previous show_form state to detect when form is closed
previous_show_form = Map.get(socket.assigns, :show_form, false)
# Use socket state so send_update(show_form: false) is the only trigger for "form closed"
previous_show_form = socket.assigns[:show_form] || false
# If show_form is explicitly provided in assigns, reset editing state
socket =
@ -112,20 +113,22 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
socket
end
# Detect when form is closed (show_form changes from true to false)
new_show_form = Map.get(assigns, :show_form, false)
if previous_show_form and not new_show_form do
send(self(), {:editing_section_changed, nil})
end
{:ok,
socket =
socket
|> assign(assigns)
|> assign_new(:settings, fn -> get_settings() end)
|> assign_new(:show_form, fn -> false end)
|> assign_new(:form_id, fn -> "member-field-form-new" end)
|> assign_new(:editing_member_field, fn -> nil end)}
|> assign_new(:editing_member_field, fn -> nil end)
# Detect form closed only from final socket state (not from assigns alone)
current_show_form = socket.assigns[:show_form] || false
if previous_show_form and not current_show_form do
send(self(), {:editing_section_changed, nil})
end
{:ok, socket}
end
@impl true

View file

@ -38,6 +38,11 @@ defmodule MvWeb.MemberLive.Form do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
<div
id="member-form-focus-root"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<.header>
<:leading>
<.button navigate={return_path(@return_to, @member)} variant="neutral">
@ -58,14 +63,31 @@ defmodule MvWeb.MemberLive.Form do
</.header>
<div class="mt-6 space-y-6">
<%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
<div role="tablist" class="tabs tabs-bordered">
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
<.icon name="hero-identification" class="size-4 mr-2" />
<%!-- Tab navigation: same styling as member show; only Contact Data tab (no Membership Fees on edit) --%>
<div
role="tablist"
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
>
<button
id="member-tab-contact"
role="tab"
type="button"
tabindex="0"
aria-selected="true"
aria-controls="member-tabpanel-contact"
class="tab tab-active flex items-center gap-2"
>
<.icon name="hero-identification" class="size-4 shrink-0" />
{gettext("Contact Data")}
</button>
</div>
<%!-- Contact Data Tab Content (same structure as member show) --%>
<div
id="member-tabpanel-contact"
role="tabpanel"
aria-labelledby="member-tab-contact"
>
<%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%>
@ -164,7 +186,9 @@ defmodule MvWeb.MemberLive.Form do
<%= 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 %>
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
<div class={
if cf.value_type == :boolean, do: "flex items-end", else: ""
}>
<.inputs_for :let={value_form} field={f_cfv[:value]}>
<.input
field={value_form[:value]}
@ -258,15 +282,10 @@ defmodule MvWeb.MemberLive.Form do
)}
</p>
<.button
id="delete-member-form-trigger"
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)
)
}
phx-click="open_delete_modal"
data-testid="member-delete"
aria-label={
gettext("Delete member %{name}",
@ -280,6 +299,51 @@ defmodule MvWeb.MemberLive.Form do
</div>
</section>
<% end %>
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<%= if @member && assigns[:show_delete_modal] do %>
<dialog
id="delete-member-form-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-member-form-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-member-form-modal-title" class="text-lg font-bold">
{gettext("Delete Member")}
</h3>
<p class="py-4">
{gettext(
"Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-member-form-modal-cancel"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click={JS.push("delete", value: %{id: @member.id})}
aria-label={gettext("Delete member")}
>
{gettext("Delete")}
</.button>
</div>
</div>
</dialog>
<% end %>
</div>
</div>
</div>
</.form>
</Layouts.app>
@ -329,6 +393,7 @@ defmodule MvWeb.MemberLive.Form do
|> assign(:available_fee_types, available_fee_types)
|> assign(:interval_warning, nil)
|> assign(:member_field_required_map, member_field_required_map)
|> assign_new(:show_delete_modal, fn -> false end)
|> assign_form()}
end
@ -400,6 +465,32 @@ defmodule MvWeb.MemberLive.Form do
end
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, 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
@ -407,10 +498,16 @@ defmodule MvWeb.MemberLive.Form do
cond do
is_nil(member) ->
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
{:noreply,
socket
|> put_flash(:error, gettext("Member not found"))
|> assign(:show_delete_modal, false)}
to_string(id) != to_string(member.id) ->
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
{:noreply,
socket
|> put_flash(:error, gettext("Member not found"))
|> assign(:show_delete_modal, false)}
true ->
handle_member_delete_destroy(socket, member, actor)
@ -427,14 +524,26 @@ defmodule MvWeb.MemberLive.Form do
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to delete this member"))}
socket
|> put_flash(:error, gettext("You do not have permission to delete this member"))
|> assign(:show_delete_modal, false)}
{:error, error} ->
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
{:noreply, put_flash(socket, :error, format_destroy_error(error))}
{:noreply,
socket
|> put_flash(:error, format_destroy_error(error))
|> assign(:show_delete_modal, false)}
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})

View file

@ -1644,11 +1644,13 @@ defmodule MvWeb.MemberLive.Index do
selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id))
any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id))
# RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +)
mailto_bcc =
if any_selected? do
format_selected_member_emails(members, selected_members)
|> Enum.join(", ")
|> URI.encode_www_form()
|> String.replace("+", "%20")
else
""
end

View file

@ -356,26 +356,24 @@
"""
}
>
<%= if member.membership_fee_type do %>
<.maybe_value value={member.membership_fee_type} empty_sr_text={gettext("Not specified")}>
{member.membership_fee_type.name}
<% else %>
<span class="text-base-content/50">—</span>
<% end %>
</.maybe_value>
</:col>
<:col
:let={member}
: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>
<.empty_cell sr_text={gettext("No cycle")} />
<% end %>
</:col>
<:col
@ -394,17 +392,17 @@
"""
}
>
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
<%= 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>
<% end %>
<%= if (member.groups || []) == [] do %>
<span class="text-base-content/50">—</span>
</.badge>
<% end %>
</.maybe_value>
</:col>
<:action :let={member}>
<div class="sr-only">

View file

@ -55,18 +55,26 @@ defmodule MvWeb.MemberLive.Show do
</:actions>
</.header>
<div class="mt-6 space-y-6">
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
<div
id="member-show-focus-root"
class="mt-6 space-y-6"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<%!-- Tab Navigation: roving tabindex (only active tab tabindex="0"), ArrowLeft/ArrowRight (WCAG tab pattern) --%>
<div
id="member-tablist"
role="tablist"
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
phx-hook="TabListKeydown"
phx-keydown="tab_keydown"
>
<button
id="member-tab-contact"
role="tab"
type="button"
tabindex="0"
aria-selected={@active_tab == :contact}
tabindex={if @active_tab == :contact, do: "0", else: "-1"}
aria-selected={if @active_tab == :contact, do: "true", else: "false"}
aria-controls="member-tabpanel-contact"
class={[
"tab flex items-center gap-2",
@ -82,8 +90,8 @@ defmodule MvWeb.MemberLive.Show do
id="member-tab-membership_fees"
role="tab"
type="button"
tabindex="0"
aria-selected={@active_tab == :membership_fees}
tabindex={if @active_tab == :membership_fees, do: "0", else: "-1"}
aria-selected={if @active_tab == :membership_fees, do: "true", else: "false"}
aria-controls="member-tabpanel-membership_fees"
class={[
"tab flex items-center gap-2",
@ -254,22 +262,24 @@ 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>
@ -313,14 +323,9 @@ defmodule MvWeb.MemberLive.Show do
)}
</p>
<.button
id="delete-member-trigger"
variant="danger"
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)
)
}
phx-click="open_delete_modal"
data-testid="member-delete"
aria-label={
gettext("Delete member %{name}",
@ -334,6 +339,48 @@ defmodule MvWeb.MemberLive.Show do
</div>
</section>
<% end %>
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %>
<dialog
id="delete-member-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-member-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-member-modal-title" class="text-lg font-bold">
{gettext("Delete Member")}
</h3>
<p class="py-4">
{gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-member-modal-cancel"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click={JS.push("delete", value: %{id: @member.id})}
aria-label={gettext("Delete member")}
>
{gettext("Delete")}
</.button>
</div>
</div>
</dialog>
<% end %>
</div>
</Layouts.app>
"""
@ -344,7 +391,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
@ -396,13 +444,58 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)}
end
@impl true
def handle_event("tab_keydown", %{"key" => key}, socket)
when key in ["ArrowLeft", "ArrowRight"] do
new_tab =
case {key, socket.assigns.active_tab} do
{"ArrowRight", :contact} -> :membership_fees
{"ArrowLeft", :membership_fees} -> :contact
_ -> socket.assigns.active_tab
end
{:noreply, assign(socket, :active_tab, new_tab)}
end
def handle_event("tab_keydown", _params, socket), do: {:noreply, socket}
@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, close_delete_modal_and_restore_focus(socket)}
end
# Escape closes modal (WCAG). phx-window-keydown ensures Escape is captured regardless of focus.
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}
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}
@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 ->
@ -413,16 +506,21 @@ 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
@ -437,6 +535,13 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :vereinfacht_receipts, response)}
end
# WCAG 2.4.3: when modal closes, return focus to the trigger (Delete member button)
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-member-trigger"})
end
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
@impl true
def handle_info({:put_flash, type, message}, socket) do
@ -503,7 +608,11 @@ defmodule MvWeb.MemberLive.Show do
<%= if @inner_block != [] do %>
{render_slot(@inner_block)}
<% else %>
{display_value(@value)}
<%= if value_blank?(@value) do %>
<.empty_cell sr_text={gettext("Not set")} />
<% else %>
{@value}
<% end %>
<% end %>
</dd>
</dl>
@ -537,9 +646,9 @@ defmodule MvWeb.MemberLive.Show do
# Helper Functions
# -----------------------------------------------------------------
defp display_value(nil), do: render_empty_value()
defp display_value(""), do: render_empty_value()
defp display_value(value), do: value
defp value_blank?(nil), do: true
defp value_blank?(v) when is_binary(v), do: String.trim(v) == ""
defp value_blank?(_), do: false
defp format_status_label(:paid), do: gettext("Paid")
defp format_status_label(:unpaid), do: gettext("Unpaid")
@ -628,10 +737,10 @@ defmodule MvWeb.MemberLive.Show do
if String.trim(value) == "" do
render_empty_value()
else
assigns = %{email: value}
assigns = %{email: value, display: value}
~H"""
<.mailto_link email={@email} display={@email} />
<.mailto_link email={@email} display={@display} />
"""
end
end
@ -646,17 +755,10 @@ defmodule MvWeb.MemberLive.Show do
defp format_custom_field_value(value, _type), do: to_string(value)
# Renders accessible placeholder for empty values
# Uses translated text for screen readers while maintaining visual consistency
# The visual "—" is hidden from screen readers, while the translated text is only visible to screen readers
# Renders accessible empty value: visually empty, screen-reader text only (see Design Guidelines §8.6).
# Returns safe HTML so it can be used from helpers without LiveView assigns.
defp render_empty_value do
assigns = %{text: gettext("Not set")}
~H"""
<span class="text-base-content/50 italic">
<span aria-hidden="true"></span>
<span class="sr-only">{@text}</span>
</span>
"""
text = gettext("Not set")
{:safe, ["<span class=\"sr-only\">", Phoenix.HTML.Engine.html_escape(text), "</span>"]}
end
end

View file

@ -101,7 +101,13 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<%= for r <- receipts do %>
<tr>
<%= for {col_key, _header_key} <- cols do %>
<td>{format_receipt_cell(col_key, r[col_key])}</td>
<td>
<%= if (cell_content = format_receipt_cell(col_key, r[col_key])) != nil do %>
{cell_content}
<% else %>
<.empty_cell sr_text={receipt_empty_sr_text(col_key)} />
<% end %>
</td>
<% end %>
</tr>
<% end %>
@ -186,9 +192,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")}>
@ -208,12 +214,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}>
@ -227,7 +231,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-status="paid"
phx-target={@myself}
class={cycle_status_btn_class(cycle.status, :paid)}
aria-pressed={cycle.status == :paid}
aria-pressed={if cycle.status == :paid, do: "true", else: "false"}
title={gettext("Mark as paid")}
>
<.icon name="hero-check-circle" class="size-4" />
@ -240,7 +244,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-status="suspended"
phx-target={@myself}
class={cycle_status_btn_class(cycle.status, :suspended)}
aria-pressed={cycle.status == :suspended}
aria-pressed={if cycle.status == :suspended, do: "true", else: "false"}
title={gettext("Mark as suspended")}
>
<.icon name="hero-pause-circle" class="size-4" />
@ -253,7 +257,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-status="unpaid"
phx-target={@myself}
class={cycle_status_btn_class(cycle.status, :unpaid)}
aria-pressed={cycle.status == :unpaid}
aria-pressed={if cycle.status == :unpaid, do: "true", else: "false"}
title={gettext("Mark as unpaid")}
>
<.icon name="hero-x-circle" class="size-4" />
@ -290,11 +294,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %>
</.section_box>
<%!-- Edit Cycle Amount Modal --%>
<%!-- Edit Cycle Amount Modal (WCAG: focus in modal, aria-labelledby) --%>
<%= if @editing_cycle do %>
<dialog id="edit-cycle-amount-modal" class="modal modal-open">
<dialog
id="edit-cycle-amount-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="edit-cycle-amount-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Edit Cycle Amount")}</h3>
<h3 id="edit-cycle-amount-modal-title" class="text-lg font-bold">
{gettext("Edit Cycle Amount")}
</h3>
<form phx-submit="save_cycle_amount" phx-target={@myself}>
<input type="hidden" name="cycle_id" value={@editing_cycle.id} />
<div class="form-control w-full mt-4">
@ -310,6 +322,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
value={Decimal.to_string(@editing_cycle.amount) |> String.replace(".", ",")}
class="input input-bordered w-full"
required
phx-mounted={JS.focus()}
/>
</div>
<div class="modal-action">
@ -328,11 +341,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</dialog>
<% end %>
<%!-- Delete Cycle Confirmation Modal --%>
<%!-- Delete Cycle Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
<%= if @deleting_cycle do %>
<dialog id="delete-cycle-modal" class="modal modal-open">
<dialog
id="delete-cycle-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-cycle-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Delete Cycle")}</h3>
<h3 id="delete-cycle-modal-title" class="text-lg font-bold">{gettext("Delete Cycle")}</h3>
<p class="py-4">
{gettext("Are you sure you want to delete this cycle?")}
</p>
@ -343,7 +362,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
</p>
<div class="modal-action">
<.button variant="neutral" phx-click="cancel_delete_cycle" phx-target={@myself}>
<.button
variant="neutral"
phx-click="cancel_delete_cycle"
phx-target={@myself}
phx-mounted={JS.focus()}
>
{gettext("Cancel")}
</.button>
<.button
@ -359,11 +383,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</dialog>
<% end %>
<%!-- Delete All Cycles Confirmation Modal --%>
<%!-- Delete All Cycles Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
<%= if @deleting_all_cycles do %>
<dialog id="delete-all-cycles-modal" class="modal modal-open">
<dialog
id="delete-all-cycles-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-all-cycles-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 class="text-lg font-bold text-error">{gettext("Delete All Cycles")}</h3>
<h3 id="delete-all-cycles-modal-title" class="text-lg font-bold text-error">
{gettext("Delete All Cycles")}
</h3>
<div class="alert alert-warning mt-4">
<.icon name="hero-exclamation-triangle" class="size-5" />
<div>
@ -391,6 +423,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
value={@delete_all_confirmation || ""}
class="input input-bordered w-full"
placeholder={gettext("Yes")}
phx-mounted={JS.focus()}
/>
</div>
<div class="modal-action">
@ -413,11 +446,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</dialog>
<% end %>
<%!-- Create Cycle Modal --%>
<%!-- Create Cycle Modal (WCAG: focus in modal, aria-labelledby) --%>
<%= if @creating_cycle do %>
<dialog id="create-cycle-modal" class="modal modal-open">
<dialog
id="create-cycle-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="create-cycle-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Create Cycle")}</h3>
<h3 id="create-cycle-modal-title" class="text-lg font-bold">{gettext("Create Cycle")}</h3>
<form phx-submit="create_cycle" phx-target={@myself}>
<div class="form-control w-full mt-4">
<label class="label" for="create-cycle-date">
@ -433,6 +472,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
class="input input-bordered w-full"
required
aria-label={gettext("Date")}
phx-mounted={JS.focus()}
/>
<label class="label">
<span class="label-text-alt">
@ -881,6 +921,35 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:create_cycle_error, nil)}
end
def handle_event("dialog_keydown", %{"key" => "Escape"}, socket) do
socket =
cond do
socket.assigns[:editing_cycle] ->
assign(socket, :editing_cycle, nil)
socket.assigns[:deleting_cycle] ->
assign(socket, :deleting_cycle, nil)
socket.assigns[:deleting_all_cycles] ->
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
socket.assigns[:creating_cycle] ->
socket
|> assign(:creating_cycle, false)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)
true ->
socket
end
{:noreply, socket}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
def handle_event("update_create_cycle_date", %{"date" => date_str}, socket) do
date =
case Date.from_iso8601(date_str) do
@ -1127,7 +1196,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end)
end
defp format_receipt_cell(:amount, nil), do: ""
# Screen-reader text for empty receipt table cells (visually empty, A11y)
defp receipt_empty_sr_text(:status), do: gettext("Not set")
defp receipt_empty_sr_text(_), do: gettext("Not specified")
defp format_receipt_cell(:amount, nil), do: nil
defp format_receipt_cell(:amount, val) when is_number(val) do
case Decimal.cast(val) do
@ -1145,7 +1218,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_receipt_cell(:amount, val), do: to_string(val)
defp format_receipt_cell(:status, nil), do: ""
defp format_receipt_cell(:status, nil), do: nil
defp format_receipt_cell(:status, val) when is_binary(val) do
translate_receipt_status(val)
@ -1153,7 +1226,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val))
defp format_receipt_cell(:receiptType, nil), do: ""
defp format_receipt_cell(:receiptType, nil), do: nil
defp format_receipt_cell(:receiptType, val) when is_binary(val) do
translate_receipt_type(val)
@ -1162,7 +1235,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val))
defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt],
do: ""
do: nil
defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do
format_receipt_date(val)
@ -1223,7 +1296,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp translate_receipt_status("draft"), do: gettext("Draft")
defp translate_receipt_status("incompleted"), do: gettext("Incompleted")
defp translate_receipt_status("completed"), do: gettext("Completed")
defp translate_receipt_status("empty"), do: ""
defp translate_receipt_status("empty"), do: nil
defp translate_receipt_status(other), do: other
# Translate API receipt type values (extend as API returns more values)

View file

@ -142,7 +142,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
<.header>
{gettext("Membership Fee Settings")}
<:subtitle>
{gettext("Configure global settings and fee types for membership fees.")}
{gettext("Configure fee types for membership fees.")}
</:subtitle>
<:actions>
<.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}>
@ -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>
<span class="text-sm">{get_member_count(mft, @member_counts)}</span>
</:col>
<:action :let={mft}>

View file

@ -34,9 +34,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
</.button>
</:leading>
{@page_title}
<:subtitle>
{gettext("Use this form to manage membership fee types in your database.")}
</:subtitle>
<:actions>
<.button
form="membership-fee-type-form"

View file

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

View file

@ -30,7 +30,6 @@ defmodule MvWeb.RoleLive.Form do
</.button>
</:leading>
{@page_title}
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}

View file

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

View file

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

View file

@ -16,15 +16,16 @@
<.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")}
>
<: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>
@ -37,21 +38,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>
<span class="text-sm">{get_user_count(role, @user_counts)}</span>
</:col>
</.table>
</Layouts.app>

View file

@ -17,7 +17,7 @@ defmodule MvWeb.RoleLive.Show do
require Ash.Query
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3]
@impl true
def mount(%{"id" => id}, _session, socket) do
@ -35,7 +35,8 @@ defmodule MvWeb.RoleLive.Show do
socket
|> assign(:page_title, gettext("Show Role"))
|> assign(:role, role)
|> assign(:user_count, user_count)}
|> assign(:user_count, user_count)
|> assign(:show_delete_modal, false)}
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
{:ok,
@ -84,35 +85,61 @@ defmodule MvWeb.RoleLive.Show do
error_message = format_error(error)
{:noreply,
put_flash(
socket,
socket
|> put_flash(
:error,
gettext("Failed to delete role: %{error}", error: error_message)
)}
)
|> assign(:show_delete_modal, false)}
end
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, close_delete_modal_and_restore_focus(socket)}
end
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}
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}
defp handle_delete_role(role, socket) do
if role.is_system_role do
{:noreply,
put_flash(
socket,
:error,
gettext("System roles cannot be deleted.")
)}
socket
|> put_flash(:error, gettext("System roles cannot be deleted."))
|> assign(:show_delete_modal, false)}
else
user_count = recalculate_user_count(role, socket.assigns.current_user)
if user_count > 0 do
{:noreply,
put_flash(
socket,
socket
|> put_flash(
:error,
gettext(
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
count: user_count
)
)}
)
|> assign(:show_delete_modal, false)}
else
perform_role_deletion(role, socket)
end
@ -156,6 +183,12 @@ defmodule MvWeb.RoleLive.Show do
recalculate_user_count(role, actor)
end
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-role-trigger"})
end
@impl true
def render(assigns) do
~H"""
@ -187,6 +220,11 @@ defmodule MvWeb.RoleLive.Show do
</:actions>
</.header>
<div
id="role-show-focus-root"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<.list>
<:item title={gettext("Name")}>{@role.name}</:item>
<:item title={gettext("Description")}>
@ -197,16 +235,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>
@ -223,14 +262,9 @@ defmodule MvWeb.RoleLive.Show do
)}
</p>
<.button
id="delete-role-trigger"
variant="danger"
phx-click={JS.push("delete", value: %{id: @role.id})}
data-confirm={
gettext(
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
name: @role.name
)
}
phx-click="open_delete_modal"
data-testid="role-delete"
aria-label={gettext("Delete role %{name}", name: @role.name)}
>
@ -240,6 +274,49 @@ defmodule MvWeb.RoleLive.Show do
</div>
</section>
<% end %>
<%!-- Delete Role Confirmation Modal (WCAG: focus moves into modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %>
<dialog
id="delete-role-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-role-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-role-modal-title" class="text-lg font-bold">{gettext("Delete Role")}</h3>
<p class="py-4">
{gettext(
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
name: @role.name
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-role-modal-cancel"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click={JS.push("delete", value: %{id: @role.id})}
data-testid="role-delete-confirm"
aria-label={gettext("Delete role")}
>
{gettext("Delete")}
</.button>
</div>
</div>
</dialog>
<% end %>
</div>
</Layouts.app>
"""
end

View file

@ -59,7 +59,6 @@ defmodule MvWeb.StatisticsLive do
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Statistics")}
<:subtitle>{gettext("Overview from first membership to today")}</:subtitle>
</.header>
<section class="mb-8" aria-labelledby="members-heading">

View file

@ -53,7 +53,6 @@ defmodule MvWeb.UserLive.Form do
</.button>
</:leading>
{@page_title}
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
<:actions>
<.button
form="user-form"
@ -66,6 +65,11 @@ defmodule MvWeb.UserLive.Form do
</:actions>
</.header>
<div
id="user-form-focus-root"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
@ -311,16 +315,10 @@ defmodule MvWeb.UserLive.Form do
)}
</p>
<.button
id="delete-user-form-trigger"
type="button"
variant="danger"
phx-click="delete"
phx-value-id={@user.id}
data-confirm={
gettext(
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email
)
}
phx-click="open_delete_modal"
data-testid="user-delete"
aria-label={gettext("Delete user %{email}", email: @user.email)}
>
@ -331,6 +329,49 @@ defmodule MvWeb.UserLive.Form do
</section>
<% end %>
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<%= if @user && assigns[:show_delete_modal] do %>
<dialog
id="delete-user-form-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-user-form-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-user-form-modal-title" class="text-lg font-bold">
{gettext("Delete User")}
</h3>
<p class="py-4">
{gettext(
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-user-form-modal-cancel"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click={JS.push("delete", value: %{id: @user.id})}
aria-label={gettext("Delete user")}
>
{gettext("Delete")}
</.button>
</div>
</div>
</dialog>
<% end %>
<div class="mt-4">
<.button navigate={return_path(@return_to, @user)} variant="neutral">
{gettext("Cancel")}
@ -340,6 +381,7 @@ defmodule MvWeb.UserLive.Form do
</.button>
</div>
</.form>
</div>
</Layouts.app>
"""
end
@ -399,6 +441,7 @@ defmodule MvWeb.UserLive.Form do
|> assign(:selected_member_name, nil)
|> assign(:unlink_member, false)
|> assign(:focused_member_index, nil)
|> assign_new(:show_delete_modal, fn -> false end)
|> load_initial_members()
|> assign_form()}
end
@ -454,6 +497,32 @@ defmodule MvWeb.UserLive.Form do
end
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, 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
user = socket.assigns.user
@ -461,13 +530,22 @@ defmodule MvWeb.UserLive.Form do
cond do
is_nil(user) ->
{:noreply, put_flash(socket, :error, gettext("User not found"))}
{:noreply,
socket
|> put_flash(:error, gettext("User not found"))
|> assign(:show_delete_modal, false)}
to_string(id) != to_string(user.id) ->
{:noreply, put_flash(socket, :error, gettext("User not found"))}
{:noreply,
socket
|> put_flash(:error, gettext("User not found"))
|> assign(:show_delete_modal, false)}
Mv.Helpers.SystemActor.system_user?(user) ->
{:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
{:noreply,
socket
|> put_flash(:error, gettext("System user cannot be deleted."))
|> assign(:show_delete_modal, false)}
true ->
handle_user_delete_destroy(socket, user, actor)
@ -594,13 +672,24 @@ defmodule MvWeb.UserLive.Form do
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
socket
|> put_flash(:error, gettext("You do not have permission to delete this user"))
|> assign(:show_delete_modal, false)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_ash_error(error))}
{:noreply,
socket
|> put_flash(:error, format_ash_error(error))
|> assign(:show_delete_modal, false)}
end
end
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-user-form-trigger"})
end
defp handle_member_linking(socket, user, actor) do
result = perform_member_link_action(socket, user, actor)

View file

@ -1,6 +1,7 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Listing Users")}
{gettext("Users")}
<:subtitle>{gettext("Manage users and their permissions.")}</:subtitle>
<:actions>
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
<.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
@ -37,25 +38,25 @@
{user.role.name}
</:col>
<:col :let={user} label={gettext("Linked Member")}>
<%= if user.member do %>
<.maybe_value value={user.member} empty_sr_text={gettext("No member linked")}>
{MvWeb.Helpers.MemberHelpers.display_name(user.member)}
<% else %>
<span class="text-base-content/70">{gettext("No member linked")}</span>
<% end %>
</.maybe_value>
</:col>
<:col :let={user} label={gettext("Password")}>
<%= if MvWeb.Helpers.UserHelpers.has_password?(user) do %>
<.maybe_value
value={MvWeb.Helpers.UserHelpers.has_password?(user)}
empty_sr_text={gettext("Not set")}
>
<span>{gettext("Enabled")}</span>
<% else %>
<span class="text-base-content/70">—</span>
<% end %>
</.maybe_value>
</:col>
<:col :let={user} label={gettext("OIDC")}>
<%= if MvWeb.Helpers.UserHelpers.has_oidc?(user) do %>
<.maybe_value
value={MvWeb.Helpers.UserHelpers.has_oidc?(user)}
empty_sr_text={gettext("Not set")}
>
<span>{gettext("Linked")}</span>
<% else %>
<span class="text-base-content/70">—</span>
<% end %>
</.maybe_value>
</:col>
</.table>
</Layouts.app>

View file

@ -45,8 +45,6 @@ defmodule MvWeb.UserLive.Show do
</.button>
</:leading>
{gettext("User")} {@user.email}
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
<:actions>
<%= if can?(@current_user, :update, @user) do %>
<.button
@ -60,6 +58,11 @@ defmodule MvWeb.UserLive.Show do
</:actions>
</.header>
<div
id="user-show-focus-root"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<.list>
<:item title={gettext("Email")}>{@user.email}</:item>
<:item title={gettext("Role")}>{@user.role.name}</:item>
@ -101,15 +104,9 @@ defmodule MvWeb.UserLive.Show do
)}
</p>
<.button
id="delete-user-trigger"
variant="danger"
phx-click="delete"
phx-value-id={@user.id}
data-confirm={
gettext(
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email
)
}
phx-click="open_delete_modal"
data-testid="user-delete"
aria-label={gettext("Delete user %{email}", email: @user.email)}
>
@ -119,6 +116,48 @@ defmodule MvWeb.UserLive.Show do
</div>
</section>
<% end %>
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %>
<dialog
id="delete-user-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-user-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-user-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
<p class="py-4">
{gettext(
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-user-modal-cancel"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click={JS.push("delete", value: %{id: @user.id})}
aria-label={gettext("Delete user")}
>
{gettext("Delete")}
</.button>
</div>
</div>
</dialog>
<% end %>
</div>
</Layouts.app>
"""
end
@ -139,10 +178,37 @@ defmodule MvWeb.UserLive.Show do
{:ok,
socket
|> assign(:page_title, gettext("Show User"))
|> assign(:user, user)}
|> assign(:user, user)
|> assign(:show_delete_modal, false)}
end
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, close_delete_modal_and_restore_focus(socket)}
end
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}
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}
@impl true
def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.user
@ -150,10 +216,16 @@ defmodule MvWeb.UserLive.Show do
cond do
to_string(id) != to_string(user.id) ->
{:noreply, put_flash(socket, :error, gettext("User not found"))}
{:noreply,
socket
|> put_flash(:error, gettext("User not found"))
|> assign(:show_delete_modal, false)}
Mv.Helpers.SystemActor.system_user?(user) ->
{:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
{:noreply,
socket
|> put_flash(:error, gettext("System user cannot be deleted."))
|> assign(:show_delete_modal, false)}
true ->
handle_user_delete_destroy(socket, user, actor)
@ -170,10 +242,21 @@ defmodule MvWeb.UserLive.Show do
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
socket
|> put_flash(:error, gettext("You do not have permission to delete this user"))
|> assign(:show_delete_modal, false)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_ash_error(error))}
{:noreply,
socket
|> put_flash(:error, format_ash_error(error))
|> assign(:show_delete_modal, false)}
end
end
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-user-trigger"})
end
end

View file

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

View file

@ -36,7 +36,12 @@ msgid "City"
msgstr "Stadt"
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr "Löschen"
@ -257,9 +262,12 @@ msgstr "Dein Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr "Abbrechen"
@ -294,7 +302,6 @@ msgid "Logout"
msgstr "Abmelden"
#: lib/mv_web/live/user_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Listing Users"
msgstr "Benutzer*innen auflisten"
@ -381,16 +388,6 @@ msgstr "Benutzer*in speichern"
msgid "Show User"
msgstr "Benutzer*in anzeigen"
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "This is a user record from your database."
msgstr "Dies ist ein Benutzer*innen-Datensatz aus Ihrer Datenbank."
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage user records in your database."
msgstr "Verwende dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
@ -533,6 +530,7 @@ msgstr "Suchen..."
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Users"
msgstr "Benutzer*innen"
@ -593,18 +591,6 @@ msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft
msgid "Custom Fields"
msgstr "Benutzerdefinierte Felder"
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this custom field."
msgid_plural "%{count} members have values assigned for this custom field."
msgstr[0] "%{count} Mitglied hat einen Wert für dieses benutzerdefinierte Feld zugewiesen."
msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld zugewiesen."
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Enter the text above to confirm"
@ -790,6 +776,7 @@ msgstr "Beitragsdaten"
msgid "Payments"
msgstr "Zahlungen"
#: lib/mv_web/live/datafields_live.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -1388,6 +1375,8 @@ msgid "None (no default)"
msgstr "Keine (kein Standard)"
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Not set"
msgstr "Nicht gesetzt"
@ -1473,11 +1462,6 @@ msgstr "Art"
msgid "Type '%{confirmation}' to confirm"
msgstr "Gib '%{confirmation}' ein, um zu bestätigen"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage membership fee types in your database."
msgstr "Verwende dieses Formular, um Mitgliedsbeitragsarten in deiner Datenbank zu verwalten."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Warning"
@ -1704,11 +1688,6 @@ msgstr "System-Rollen können nicht gelöscht werden."
msgid "Toggle sidebar"
msgstr "Sidebar umschalten"
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage roles in your database."
msgstr "Verwende dieses Formular, um Rollen in deiner Datenbank zu verwalten."
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "User menu"
@ -2428,11 +2407,6 @@ msgstr "Alle Jahre zusammengefasst (Kreis)"
msgid "Contributions by year"
msgstr "Beiträge nach Jahr"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Overview from first membership to today"
msgstr "Übersicht vom ersten Eintritt bis heute"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Contributions by year as table with stacked bars"
@ -2910,11 +2884,6 @@ msgstr "CSV Datei auswählen"
msgid "Import Members"
msgstr "Mitglieder importieren (CSV)"
#~ #: lib/mv_web/live/import_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Datei auswählen"
#~ msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Admin group name"
@ -2940,21 +2909,6 @@ msgstr "Client-ID"
msgid "Client Secret"
msgstr "Client-Geheimnis"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Configure global settings and fee types for membership fees."
msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Configure member fields and custom data fields."
msgstr "Mitgliedsfelder und benutzerdefinierte Datenfelder konfigurieren."
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom fields"
msgstr "Benutzerdefinierte Felder"
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
@ -2996,11 +2950,6 @@ msgstr "Aus OIDC_REDIRECT_URI"
msgid "Groups claim"
msgstr "Gruppenclaim"
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Member fields"
msgstr "Mitgliedsfelder"
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee settings"
@ -3225,88 +3174,64 @@ msgstr "Das Löschen dieses Datenfeldes kann nicht rückgängig gemacht werden.
msgid "Individual datafields"
msgstr "Individuelle Datenfelder"
#~ #: lib/mv_web/live/member_field_live/form_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Back to Settings"
#~ msgstr "Zurück zu den Einstellungen"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Member"
msgstr "Mitglied löschen"
#~ #: lib/mv_web/live/role_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Cannot delete system role"
#~ msgstr "System-Rolle kann nicht gelöscht werden"
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Role"
msgstr "Rolle löschen"
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Click for custom field details"
#~ msgstr "Klicke für Datenfeld-Details"
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete User"
msgstr "Benutzer*in löschen"
#~ #: lib/mv_web/live/member_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Click for datafield details"
#~ msgstr "Klicke für Datenfeld-Details"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure fee types for membership fees."
msgstr "Verwalte Beitragsarten und Mitgliedsbeiträge."
#~ #: lib/mv_web/live/member_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Coming soon"
#~ msgstr "Demnächst verfügbar"
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Configure which data you want to save for your members. Define individual datafields."
msgstr "Verwalte welche Daten du für eure Mitglieder speichern möchtest. Lege individuelle datenfelder an."
#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Custom Field %{id}"
#~ msgstr "Benutzerdefiniertes Feld %{id}"
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Manage users and their permissions."
msgstr "Verwalte Benutzer*innen und ihre Berechtigungen."
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #: lib/mv_web/live/member_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Edit datafield"
#~ msgstr "Datenfeld bearbeiten"
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "%{count} member has a value assigned for this datafield."
msgid_plural "%{count} members have values assigned for this datafield."
msgstr[0] "%{count} Mitglied hat einen Wert für dieses benutzerdefinierte Feld zugewiesen."
msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld zugewiesen."
#~ #: lib/mv_web/live/user_live/index.html.heex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Edit user"
#~ msgstr "Benutzer*in bearbeiten"
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Individual Datafields"
msgstr "Individuelle Datenfelder"
#~ #: lib/mv_web/live/components/member_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Reset"
#~ msgstr "Zurücksetzen"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "No group assignment"
msgstr "Keine Gruppenzuordnung"
#~ #: lib/mv_web/live/role_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Rolle bearbeiten"
#~ msgstr "Rolle bearbeiten"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Not specified"
msgstr "Nicht angegeben"
#~ #: lib/mv_web/live/role_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Save Role"
#~ msgstr "Rolle speichern"
#~ #: lib/mv_web/live/user_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Select all users"
#~ msgstr "Alle Benutzer*innen auswählen"
#~ #: lib/mv_web/live/user_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Select user"
#~ msgstr "Benutzer*in auswählen"
#~ #: lib/mv_web/live/role_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "System roles cannot be deleted"
#~ msgstr "System-Rollen können nicht gelöscht werden"
#~ #: lib/mv_web/live/group_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "View"
#~ msgstr "Anzeigen"
#~ #: lib/mv_web/live/member_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "You do not have permission to access this member"
#~ msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen"
#~ #: lib/mv_web/live/user_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "You do not have permission to access this user"
#~ msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "All datafield values will be permanently deleted when you delete this datafield."
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."

View file

@ -37,7 +37,12 @@ msgid "City"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
@ -258,9 +263,12 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
@ -295,7 +303,6 @@ msgid "Logout"
msgstr ""
#: lib/mv_web/live/user_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Listing Users"
msgstr ""
@ -382,16 +389,6 @@ msgstr ""
msgid "Show User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "This is a user record from your database."
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage user records in your database."
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
@ -534,6 +531,7 @@ msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Users"
msgstr ""
@ -594,18 +592,6 @@ msgstr ""
msgid "Custom Fields"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this custom field."
msgid_plural "%{count} members have values assigned for this custom field."
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Enter the text above to confirm"
@ -791,6 +777,7 @@ msgstr ""
msgid "Payments"
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -1389,6 +1376,8 @@ msgid "None (no default)"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Not set"
msgstr ""
@ -1474,11 +1463,6 @@ msgstr ""
msgid "Type '%{confirmation}' to confirm"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage membership fee types in your database."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Warning"
@ -1705,11 +1689,6 @@ msgstr ""
msgid "Toggle sidebar"
msgstr ""
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage roles in your database."
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "User menu"
@ -2429,11 +2408,6 @@ msgstr ""
msgid "Contributions by year"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Overview from first membership to today"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Contributions by year as table with stacked bars"
@ -2935,21 +2909,6 @@ msgstr ""
msgid "Client Secret"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure global settings and fee types for membership fees."
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Configure member fields and custom data fields."
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Custom fields"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
@ -2991,11 +2950,6 @@ msgstr ""
msgid "Groups claim"
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Member fields"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Membership fee settings"
@ -3219,3 +3173,65 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Individual datafields"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Delete Member"
msgstr ""
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Delete Role"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Delete User"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure fee types for membership fees."
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Configure which data you want to save for your members. Define individual datafields."
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Manage users and their permissions."
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this datafield."
msgid_plural "%{count} members have values assigned for this datafield."
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Individual Datafields"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "No group assignment"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Not specified"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "All datafield values will be permanently deleted when you delete this datafield."
msgstr ""

View file

@ -37,7 +37,12 @@ msgid "City"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
@ -258,9 +263,12 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
@ -295,7 +303,6 @@ msgid "Logout"
msgstr ""
#: lib/mv_web/live/user_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Listing Users"
msgstr ""
@ -382,16 +389,6 @@ msgstr ""
msgid "Show User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "This is a user record from your database."
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage user records in your database."
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
@ -534,6 +531,7 @@ msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Users"
msgstr ""
@ -594,18 +592,6 @@ msgstr ""
msgid "Custom Fields"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this custom field."
msgid_plural "%{count} members have values assigned for this custom field."
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Enter the text above to confirm"
@ -791,6 +777,7 @@ msgstr ""
msgid "Payments"
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -1389,6 +1376,8 @@ msgid "None (no default)"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Not set"
msgstr ""
@ -1474,11 +1463,6 @@ msgstr ""
msgid "Type '%{confirmation}' to confirm"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage membership fee types in your database."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Warning"
@ -1705,11 +1689,6 @@ msgstr ""
msgid "Toggle sidebar"
msgstr ""
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage roles in your database."
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "User menu"
@ -2429,11 +2408,6 @@ msgstr ""
msgid "Contributions by year"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Overview from first membership to today"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contributions by year as table with stacked bars"
@ -2935,21 +2909,6 @@ msgstr ""
msgid "Client Secret"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Configure global settings and fee types for membership fees."
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Configure member fields and custom data fields."
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom fields"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
@ -2991,11 +2950,6 @@ msgstr ""
msgid "Groups claim"
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Member fields"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee settings"
@ -3220,88 +3174,64 @@ msgstr ""
msgid "Individual datafields"
msgstr ""
#~ #: lib/mv_web/live/member_field_live/form_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Back to Settings"
#~ msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Member"
msgstr ""
#~ #: lib/mv_web/live/role_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Cannot delete system role"
#~ msgstr ""
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Role"
msgstr ""
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Click for custom field details"
#~ msgstr ""
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete User"
msgstr ""
#~ #: lib/mv_web/live/member_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Click for datafield details"
#~ msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure fee types for membership fees."
msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Coming soon"
#~ msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Configure which data you want to save for your members. Define individual datafields."
msgstr ""
#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Custom Field %{id}"
#~ msgstr ""
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Manage users and their permissions."
msgstr ""
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #: lib/mv_web/live/member_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Edit datafield"
#~ msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "%{count} member has a value assigned for this datafield."
msgid_plural "%{count} members have values assigned for this datafield."
msgstr[0] ""
msgstr[1] ""
#~ #: lib/mv_web/live/user_live/index.html.heex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Edit user"
#~ msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Individual Datafields"
msgstr ""
#~ #: lib/mv_web/live/components/member_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Reset"
#~ msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "No group assignment"
msgstr ""
#~ #: lib/mv_web/live/role_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Rolle bearbeiten"
#~ msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Not specified"
msgstr ""
#~ #: lib/mv_web/live/role_live/form.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Save Role"
#~ msgstr ""
#~ #: lib/mv_web/live/user_live/index.html.heex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Select all users"
#~ msgstr ""
#~ #: lib/mv_web/live/user_live/index.html.heex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Select user"
#~ msgstr ""
#~ #: lib/mv_web/live/role_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "System roles cannot be deleted"
#~ msgstr ""
#~ #: lib/mv_web/live/group_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "View"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "You do not have permission to access this member"
#~ msgstr ""
#~ #: lib/mv_web/live/user_live/index.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "You do not have permission to access this user"
#~ msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "All datafield values will be permanently deleted when you delete this datafield."
msgstr ""

View file

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

View file

@ -254,6 +254,14 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
end
end
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"

View file

@ -46,15 +46,19 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
%{conn: conn, user: user_with_role}
end
# Delete is in the edit form (FormComponent); open form by clicking the name cell (unique td with phx-click)
# Delete is in the edit form (FormComponent). First row click opens form (overview) or switches
# to edit-mode (new component shows table). If delete button is visible, click it; else click row
# again to open the form, then click delete.
defp open_delete_modal(view, custom_field) do
view
|> element("tr#custom_fields-#{custom_field.id} td", custom_field.name)
|> render_click()
row_selector = "tr#custom_fields-#{custom_field.id} td"
view |> element(row_selector, custom_field.name) |> render_click()
view
|> element("[data-testid=custom-field-delete]")
|> render_click()
if has_element?(view, "[data-testid=custom-field-delete]") do
view |> element("[data-testid=custom-field-delete]") |> render_click()
else
view |> element(row_selector, custom_field.name) |> render_click()
view |> element("[data-testid=custom-field-delete]") |> render_click()
end
end
describe "delete button and modal" do
@ -71,8 +75,12 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
# Modal should be visible
assert has_element?(view, "#delete-custom-field-modal")
# Edit mode: section titles must not reappear when modal opens (regression)
refute has_element?(view, "h2", "Member fields")
refute has_element?(view, "h2", "Custom fields")
# Should show correct member count (1 member)
assert render(view) =~ "1 member has a value assigned for this custom field"
assert render(view) =~ "1 member has a value assigned for this datafield"
# Should show the slug
assert render(view) =~ custom_field.slug
@ -91,7 +99,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
open_delete_modal(view, custom_field)
# Should show plural form
assert render(view) =~ "2 members have values assigned for this custom field"
assert render(view) =~ "2 members have values assigned for this datafield"
end
test "shows 0 members for custom field without values", %{conn: conn} do
@ -101,7 +109,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
open_delete_modal(view, custom_field)
# Should show 0 members
assert render(view) =~ "0 members have values assigned for this custom field"
assert render(view) =~ "0 members have values assigned for this datafield"
end
end

View file

@ -83,6 +83,21 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do
end
end
describe "edit mode visibility" do
test "clicking member field row shows only form, no section titles", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
# Row click is on the first td (no col_click); click that cell to open edit form
view
|> element("tr#member_field-first_name td:first-child")
|> render_click()
assert has_element?(view, "#member-field-form-first_name")
refute has_element?(view, "h2", "Custom fields")
refute has_element?(view, "h2", "Member fields")
end
end
describe "required fields" do
setup do
{:ok, settings} = Membership.get_settings()

View file

@ -386,11 +386,16 @@ defmodule MvWeb.RoleLiveTest do
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}")
# Delete from Danger zone on show page
# Open delete modal from Danger zone
view
|> element("[data-testid=role-delete]")
|> render_click()
# Confirm deletion in modal
view
|> element("[data-testid=role-delete-confirm]")
|> render_click()
assert_redirect(view, "/admin/roles")
# Verify deletion by checking database

View file

@ -29,9 +29,8 @@ defmodule MvWeb.StatisticsLiveTest do
test "page shows overview of all relevant years without year selector", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/statistics")
# No year dropdown: single select for year should not be present as main control
assert html =~ "Overview" or html =~ "overview"
# table header or legend
# Page shows multi-year data (member numbers by year) and year column; no single-year selector as main control
assert html =~ "Member numbers by year"
assert html =~ "Year"
end

View file

@ -95,6 +95,20 @@ defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do
assert html =~ member3.first_name
end
test "empty group cell is visually empty with sr-only text (no dash)", %{
conn: conn,
member3: member3
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ member3.first_name
# Screen reader gets a meaningful label for the empty cell
assert html =~ "sr-only"
assert html =~ "No group assignment"
# No visible dash as placeholder (Design Guidelines §8.6)
refute html =~ ~r/<span[^>]*class="[^"]*text-base-content\/50[^"]*"[^>]*>—<\/span>/
end
test "displays group name correctly in badge", %{conn: conn, group1: group1} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")

View file

@ -123,13 +123,17 @@ defmodule MvWeb.UserLive.IndexTest do
{:ok, index_view, _html} = live(conn, "/users")
assert render(index_view) =~ "delete-me@example.com"
# Navigate to user show and trigger delete from Danger zone
# Navigate to user show, open delete modal, then confirm in modal (WCAG modal pattern)
{:ok, show_view, _html} = live(conn, "/users/#{user.id}")
show_view
|> element("[data-testid=user-delete]")
|> render_click()
show_view
|> element("#delete-user-modal button", "Delete")
|> render_click()
# Should redirect to index
assert_redirect(show_view, "/users")
@ -206,7 +210,9 @@ defmodule MvWeb.UserLive.IndexTest do
end
describe "Password column display" do
test "user without password shows em dash in Password column", %{conn: conn} do
test "user without password shows empty cell with sr-only text in Password column", %{
conn: conn
} do
# User created with hashed_password: nil (no password) - must not get default password
user_no_pw =
create_test_user(%{
@ -219,9 +225,13 @@ defmodule MvWeb.UserLive.IndexTest do
assert html =~ "no-password@example.com"
# Password column must show "—" (em dash) for user without password, not "Enabled"
# Password column: visually empty, screen-reader gets "Not set" (Design Guidelines §8.6)
row = view |> element("tr#row-#{user_no_pw.id}") |> render()
assert row =~ "", "Password column should show em dash for user without password"
assert row =~ "sr-only", "Password column should have sr-only text for accessibility"
assert row =~ "Not set", "Screen reader should get 'Not set' for empty password"
refute row =~ "",
"Password column must not show dash (use empty cell + sr-only per CODE_GUIDELINES §8)"
refute row =~ "Enabled",
"Password column must not show Enabled when user has no password"