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> <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:** **Tab Order:**
- Ensure logical tab order matches visual 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 ### 8.4 Color and Contrast
**Ensure Sufficient Contrast:** **Ensure Sufficient Contrast (WCAG 2.2 AA: 4.5:1 for normal text):**
- Use the Core Component `<.badge>` for all badges; theme and `app.css` overrides ensure badge text meets 4.5:1 in light and dark theme (solid, soft, and outline styles). Cycle status "suspended" uses variant `:warning` (yellow) to match the edit cycle-status button.
- For other UI, prefer theme tokens (`text-*-content` on `bg-*`) or the `.text-success-aa` / `.text-error-aa` utility classes where theme contrast is insufficient.
- Member filter join buttons (All / Paid / Unpaid, etc.) use `.member-filter-dropdown`; `app.css` overrides ensure WCAG 4.5:1 for inactive and active states.
```elixir ```elixir
# Tailwind classes with sufficient contrast (4.5:1 minimum) # Tailwind classes with sufficient contrast (4.5:1 minimum)
@ -3003,24 +3015,56 @@ end
- [ ] Skip links are available - [ ] Skip links are available
- [ ] Tables have proper structure (th, scope, caption) - [ ] Tables have proper structure (th, scope, caption)
- [ ] ARIA labels used for icon-only buttons - [ ] 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 ```heex
<!-- Modal accessibility --> <!-- Modal: use dialog + aria-labelledby + focus on first focusable (see §8.11) -->
<dialog id="my-modal" class="modal" aria-labelledby="modal-title"> <dialog id="my-modal" class="modal modal-open" role="dialog" aria-labelledby="my-modal-title">
<div class="modal-box"> <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> <p><%= gettext("Are you sure?") %></p>
<div class="modal-action"> <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") %> <%= gettext("Cancel") %>
</button> </.button>
<button class="btn btn-error" phx-click="confirm-delete"> <.button variant="danger" phx-click="confirm_delete"><%= gettext("Delete") %></.button>
<%= gettext("Delete") %>
</button>
</div> </div>
</div> </div>
</dialog> </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. - 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. - 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) ## 9) Flash / Toast messages (mandatory UX)
@ -331,14 +337,17 @@ No “silent success”.
### 10.2 Destructive actions: one standard confirmation pattern ### 10.2 Destructive actions: one standard confirmation pattern
- **MUST:** All destructive actions use the same confirm style and wording conventions. - **MUST:** All destructive actions use the same confirm style and wording conventions.
- Choose one approach and standardize: - **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.
- `JS.confirm("…")` everywhere (simple, consistent)
- or a modal component everywhere (more flexible, more work)
**Recommended copy style:** **Recommended copy style:**
- Title/confirm text is clear and specific (what will be deleted, consequences). - Title/confirm text is clear and specific (what will be deleted, consequences).
- Buttons: `Cancel` (neutral) + `Delete` (danger). - 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) ## 11) Detail pages (consistent structure)

View file

@ -118,6 +118,138 @@
color: oklch(0.45 0.2 25); color: oklch(0.45 0.2 25);
} }
/* WCAG 2.2 AA: Badge contrast. DaisyUI .badge-outline uses transparent bg; we use
Core Component <.badge style="outline"> which adds .bg-base-100. This rule ensures
outline badges always have a visible background in both themes. */
[data-theme="light"] .badge.badge-outline,
[data-theme="dark"] .badge.badge-outline {
background-color: var(--color-base-100);
}
/* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background.
Theme tokens *-content are often too light on * backgrounds in light theme, and
badge-soft uses variant as text on a light tint (low contrast). We override
--badge-fg (and for soft, color) so badge text meets 4.5:1 in both themes. */
/* Light theme: use dark text on all colored badges (solid, soft, outline). */
[data-theme="light"] .badge.badge-primary {
--badge-fg: oklch(0.25 0.08 47);
}
[data-theme="light"] .badge.badge-primary.badge-soft {
color: oklch(0.38 0.14 47);
}
[data-theme="light"] .badge.badge-success {
--badge-fg: oklch(0.26 0.06 165);
}
[data-theme="light"] .badge.badge-success.badge-soft {
color: oklch(0.35 0.10 165);
}
[data-theme="light"] .badge.badge-error {
--badge-fg: oklch(0.22 0.08 25);
}
[data-theme="light"] .badge.badge-error.badge-soft {
color: oklch(0.38 0.14 25);
}
[data-theme="light"] .badge.badge-warning {
--badge-fg: oklch(0.28 0.06 75);
}
[data-theme="light"] .badge.badge-warning.badge-soft {
color: oklch(0.42 0.12 75);
}
[data-theme="light"] .badge.badge-info {
--badge-fg: oklch(0.26 0.08 250);
}
[data-theme="light"] .badge.badge-info.badge-soft {
color: oklch(0.38 0.12 250);
}
[data-theme="light"] .badge.badge-neutral {
--badge-fg: oklch(0.22 0.01 285);
}
[data-theme="light"] .badge.badge-neutral.badge-soft {
color: oklch(0.32 0.02 285);
}
[data-theme="light"] .badge.badge-outline.badge-primary,
[data-theme="light"] .badge.badge-outline.badge-success,
[data-theme="light"] .badge.badge-outline.badge-error,
[data-theme="light"] .badge.badge-outline.badge-warning,
[data-theme="light"] .badge.badge-outline.badge-info,
[data-theme="light"] .badge.badge-outline.badge-neutral {
--badge-fg: oklch(0.25 0.02 285);
}
/* Dark theme: ensure badge backgrounds are dark enough for light content (4.5:1).
Slightly darken solid variant backgrounds so theme *-content (light) passes. */
[data-theme="dark"] .badge.badge-primary:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.42 0.20 277);
--badge-fg: oklch(0.97 0.02 277);
}
[data-theme="dark"] .badge.badge-success:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.42 0.10 185);
--badge-fg: oklch(0.97 0.01 185);
}
[data-theme="dark"] .badge.badge-error:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.42 0.18 18);
--badge-fg: oklch(0.97 0.02 18);
}
[data-theme="dark"] .badge.badge-warning:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.48 0.14 58);
--badge-fg: oklch(0.22 0.02 58);
}
[data-theme="dark"] .badge.badge-info:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.45 0.14 242);
--badge-fg: oklch(0.97 0.02 242);
}
[data-theme="dark"] .badge.badge-neutral:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.32 0.02 257);
--badge-fg: oklch(0.96 0.01 257);
}
[data-theme="dark"] .badge.badge-soft.badge-primary { color: oklch(0.85 0.12 277); }
[data-theme="dark"] .badge.badge-soft.badge-success { color: oklch(0.82 0.08 165); }
[data-theme="dark"] .badge.badge-soft.badge-error { color: oklch(0.82 0.14 25); }
[data-theme="dark"] .badge.badge-soft.badge-warning { color: oklch(0.88 0.10 75); }
[data-theme="dark"] .badge.badge-soft.badge-info { color: oklch(0.85 0.10 250); }
[data-theme="dark"] .badge.badge-soft.badge-neutral { color: oklch(0.90 0.01 257); }
[data-theme="dark"] .badge.badge-outline.badge-primary,
[data-theme="dark"] .badge.badge-outline.badge-success,
[data-theme="dark"] .badge.badge-outline.badge-error,
[data-theme="dark"] .badge.badge-outline.badge-warning,
[data-theme="dark"] .badge.badge-outline.badge-info,
[data-theme="dark"] .badge.badge-outline.badge-neutral {
--badge-fg: oklch(0.92 0.02 257);
}
/* WCAG 2.2 AA: Member filter join buttons (All / Paid / Unpaid, group, boolean).
Inactive state uses base-content on a light/dark surface; active state ensures
*-content on * background meets 4.5:1. */
.member-filter-dropdown .join .btn {
/* Inactive: ensure readable text (theme base-content may be low contrast on btn default) */
border-color: var(--color-base-300);
}
[data-theme="light"] .member-filter-dropdown .join .btn:not(.btn-active) {
color: oklch(0.25 0.02 285);
background-color: var(--color-base-100);
}
[data-theme="light"] .member-filter-dropdown .join .btn.btn-success.btn-active {
background-color: oklch(0.42 0.12 165);
color: oklch(0.98 0.01 165);
}
[data-theme="light"] .member-filter-dropdown .join .btn.btn-error.btn-active {
background-color: oklch(0.42 0.18 18);
color: oklch(0.98 0.02 18);
}
[data-theme="dark"] .member-filter-dropdown .join .btn:not(.btn-active) {
color: oklch(0.92 0.02 257);
background-color: var(--color-base-200);
}
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-success.btn-active {
background-color: oklch(0.42 0.10 165);
color: oklch(0.97 0.01 165);
}
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-error.btn-active {
background-color: oklch(0.42 0.18 18);
color: oklch(0.97 0.02 18);
}
/* ============================================ /* ============================================
Sidebar Base Styles Sidebar Base Styles
============================================ */ ============================================ */
@ -389,4 +521,31 @@
display: none !important; 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 */ /* 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 // SidebarState hook: Manages sidebar expanded/collapsed state
Hooks.SidebarState = { Hooks.SidebarState = {
mounted() { 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 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 """ @doc """
Renders flash notices. Renders flash notices.
@ -147,13 +162,16 @@ defmodule MvWeb.CoreComponents do
size_class = size_classes[size] size_class = size_classes[size]
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ") 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 if rest[:href] || rest[:navigate] || rest[:patch] do
link_class = link_class =
if assigns[:disabled], if assigns[:disabled],
do: ["btn", btn_class, "btn-disabled"], do: ["btn", btn_class, "btn-disabled"] ++ @button_focus_classes,
else: ["btn", btn_class] else: ["btn", btn_class] ++ @button_focus_classes
link_attrs = link_attrs =
if assigns[:disabled] do if assigns[:disabled] do
@ -176,13 +194,187 @@ defmodule MvWeb.CoreComponents do
""" """
else else
~H""" ~H"""
<button class={["btn", @btn_class]} disabled={@disabled} {@rest}> <button
class={["btn", @btn_class] ++ @button_focus_classes}
disabled={@disabled}
{@rest}
>
{render_slot(@inner_block)} {render_slot(@inner_block)}
</button> </button>
""" """
end end
end end
@doc """
Renders a non-interactive badge with WCAG-compliant contrast.
Use for status labels, counts, or tags. For clickable elements (e.g. filter chips),
use a button or link component instead, not this badge.
## Variants and styles
- **variant:** `:neutral`, `:primary`, `:info`, `:success`, `:warning`, `:error`
- **style:** `:soft` (default, tinted background), `:solid`, `:outline`
- **size:** `:sm`, `:md` (default)
Outline and soft styles always use a visible background so the badge remains
readable on base-200/base-300 surfaces (WCAG 2.2 AA). Ghost style is not exposed
by default to avoid low-contrast on gray backgrounds.
## Examples
<.badge variant="success">Paid</.badge>
<.badge variant="error" style="solid">Unpaid</.badge>
<.badge variant="neutral" size="sm">Custom</.badge>
<.badge variant="primary" style="outline">Label</.badge>
<.badge variant="success" sr_label="Paid">
<.icon name="hero-check-circle" class="size-4" />
</.badge>
"""
attr :variant, :any,
default: "neutral",
doc: "Color variant: neutral | primary | info | success | warning | error (string or atom)"
attr :style, :any,
default: "soft",
doc: "Visual style: soft | solid | outline; :outline gets bg-base-100 for contrast"
attr :size, :any,
default: "md",
doc: "Badge size: sm | md"
attr :sr_label, :string,
default: nil,
doc: "Optional screen-reader label for icon-only content"
attr :rest, :global, doc: "Arbitrary HTML attributes (e.g. id, class, data-testid)"
slot :inner_block, required: true, doc: "Badge text (and optional icon)"
slot :icon, doc: "Optional leading icon slot"
def badge(assigns) do
# Normalize so both HEEx strings (variant="neutral") and helper atoms (variant={:neutral}) work
variant = to_string(assigns.variant || "neutral")
style = to_string(assigns.style || "soft")
size = to_string(assigns.size || "md")
variant_class = "badge-#{variant}"
style_class = badge_style_class(style)
size_class = "badge-#{size}"
# Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300
outline_bg = if style == "outline", do: "bg-base-100", else: nil
rest = assigns.rest || []
rest = if is_list(rest), do: rest, else: Map.to_list(rest)
extra_class = Keyword.get(rest, :class)
rest = Keyword.drop(rest, [:class])
rest = if assigns.sr_label, do: Keyword.put(rest, :"aria-label", assigns.sr_label), else: rest
class =
["badge", variant_class, style_class, size_class, outline_bg, extra_class]
|> List.flatten()
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")
assigns =
assigns
|> assign(:class, class)
|> assign(:rest, rest)
|> assign(:has_icon, assigns.icon != [])
~H"""
<span class={@class} {@rest}>
<%= if @has_icon do %>
{render_slot(@icon)}
<% end %>
{render_slot(@inner_block)}
<%= if @sr_label do %>
<span class="sr-only">{@sr_label}</span>
<% end %>
</span>
"""
end
defp badge_style_class("soft"), do: "badge-soft"
defp badge_style_class("solid"), do: nil
defp badge_style_class("outline"), do: "badge-outline"
defp badge_style_class(_), do: nil
@doc """
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 """ @doc """
Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content, Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content,
or status badges that need explanation (Design Guidelines §8.2). or status badges that need explanation (Design Guidelines §8.2).
@ -265,7 +457,11 @@ defmodule MvWeb.CoreComponents do
def dropdown_menu(assigns) do def dropdown_menu(assigns) do
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu" 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""" ~H"""
<div <div
@ -281,17 +477,10 @@ defmodule MvWeb.CoreComponents do
tabindex="0" tabindex="0"
role="button" role="button"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={@open} aria-expanded={if @open, do: "true", else: "false"}
aria-controls={@id} aria-controls={@id}
aria-label={@button_label} aria-label={@button_label}
class={[ class={["btn"] ++ @button_focus_classes ++ [@button_class]}
"btn",
"focus:outline-none",
"focus-visible:ring-2",
"focus-visible:ring-offset-2",
"focus-visible:ring-base-content/20",
@button_class
]}
phx-click="toggle_dropdown" phx-click="toggle_dropdown"
phx-target={@phx_target} phx-target={@phx_target}
data-testid={@button_testid} 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 if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
} }
tabindex="0" 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-click="select_item"
phx-keydown="select_item" phx-keydown="select_item"
phx-key="Enter" 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. 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). 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`), 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). 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) 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""" ~H"""
<div class="overflow-auto"> <div
id={@row_click && "#{@id}-keyboard"}
class="overflow-auto"
phx-hook={@row_click && "TableRowKeydown"}
>
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<tr> <tr>
@ -789,6 +999,11 @@ defmodule MvWeb.CoreComponents do
> >
<td <td
:for={{col, col_idx} <- Enum.with_index(@col)} :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={ phx-click={
(col[:col_click] && col[:col_click].(@row_item.(row))) || (col[:col_click] && col[:col_click].(@row_item.(row))) ||
(@row_click && @row_click.(row)) (@row_click && @row_click.(row))
@ -812,6 +1027,19 @@ defmodule MvWeb.CoreComponents do
classes classes
end 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 = classes =
if col_class do if col_class do
[col_class | classes] [col_class | classes]

View file

@ -8,6 +8,16 @@ defmodule MvWeb.Components.ExportDropdown do
use MvWeb, :live_component use MvWeb, :live_component
use Gettext, backend: MvWeb.Gettext 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 @impl true
def mount(socket) do def mount(socket) do
{:ok, assign(socket, :open, false)} {:ok, assign(socket, :open, false)}
@ -59,7 +69,7 @@ defmodule MvWeb.Components.ExportDropdown do
<button <button
type="submit" type="submit"
role="menuitem" 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")} aria-label={gettext("Export members to CSV")}
data-testid="export-csv-link" data-testid="export-csv-link"
> >
@ -75,7 +85,7 @@ defmodule MvWeb.Components.ExportDropdown do
<button <button
type="submit" type="submit"
role="menuitem" 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")} aria-label={gettext("Export members to PDF")}
data-testid="export-pdf-link" 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(:unpaid), do: "badge-error"
def status_color(:suspended), do: "badge-ghost" def status_color(:suspended), do: "badge-ghost"
@doc """
Returns the Core Components badge variant for a cycle status (WCAG-compliant).
Use with <.badge variant={MembershipFeeHelpers.status_variant(status)}>.
Suspended uses :warning (yellow) to match the edit cycle-status button.
"""
@spec status_variant(:paid | :unpaid | :suspended) :: :success | :error | :warning
def status_variant(:paid), do: :success
def status_variant(:unpaid), do: :error
def status_variant(:suspended), do: :warning
@doc """ @doc """
Gets the icon name for a status. Gets the icon name for a status.

View file

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

View file

@ -111,6 +111,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
)} )}
</p> </p>
<.button <.button
id="delete-custom-field-trigger"
type="button" type="button"
variant="danger" variant="danger"
phx-click="request_delete" 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) assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
~H""" ~H"""
<div id={@id} class="mt-8"> <div id={@id}>
<div class="flex"> <div :if={!@show_form} class="flex">
<p class="text-sm text-base-content/70"> <p class="text-sm text-base-content/70">
{gettext("These will appear in addition to other data when adding new members.")} {gettext("These will appear in addition to other data when adding new members.")}
</p> </p>
@ -54,6 +54,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
<.table <.table
id="custom_fields_table" id="custom_fields_table"
rows={@streams.custom_fields} rows={@streams.custom_fields}
row_id={fn {_stream_key, cf} -> "custom_fields-#{cf.id}" end}
row_click={ row_click={
fn {_id, custom_field} -> fn {_id, custom_field} ->
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself) 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")} label={gettext("Show in overview")}
class="max-w-[9.375rem] text-center" class="max-w-[9.375rem] text-center"
> >
<span :if={custom_field.show_in_overview} class="badge badge-success"> <.badge :if={custom_field.show_in_overview} variant="success">
{gettext("Yes")} {gettext("Yes")}
</span> </.badge>
<span :if={!custom_field.show_in_overview} class="badge badge-ghost"> <.badge :if={!custom_field.show_in_overview} variant="neutral">
{gettext("No")} {gettext("No")}
</span> </.badge>
</:col> </:col>
</.table> </.table>
</div> </div>
<%!-- Delete Confirmation Modal --%> <%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open"> <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"> <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="py-4 space-y-4">
<div class="alert alert-warning"> <div class="alert alert-warning">
@ -110,15 +120,15 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
<div> <div>
<p class="font-semibold"> <p class="font-semibold">
{ngettext( {ngettext(
"%{count} member has a value assigned for this custom field.", "%{count} member has a value assigned for this datafield.",
"%{count} members have values assigned for this custom field.", "%{count} members have values assigned for this datafield.",
@custom_field_to_delete.assigned_members_count, @custom_field_to_delete.assigned_members_count,
count: @custom_field_to_delete.assigned_members_count count: @custom_field_to_delete.assigned_members_count
)} )}
</p> </p>
<p class="mt-2 text-sm"> <p class="mt-2 text-sm">
{gettext( {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> </p>
</div> </div>
@ -184,8 +194,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true @impl true
def update(assigns, socket) do def update(assigns, socket) do
# Track previous show_form state to detect when form is closed # Use socket state so send_update(open_delete_for_id: ...) does not trigger false "form closed"
previous_show_form = Map.get(socket.assigns, :show_form, false) previous_show_form = socket.assigns[:show_form] || false
# If show_form is explicitly provided in assigns, reset editing state # If show_form is explicitly provided in assigns, reset editing state
socket = socket =
@ -197,13 +207,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
socket socket
end 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 # Get actor from assigns or fall back to socket assigns
actor = Map.get(assigns, :actor, socket.assigns[:actor]) actor = Map.get(assigns, :actor, socket.assigns[:actor])
@ -225,6 +228,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
socket socket
id -> id ->
send(self(), {:custom_field_delete_modal_open, true})
custom_field = custom_field =
Ash.get!(Mv.Membership.CustomField, id, Ash.get!(Mv.Membership.CustomField, id,
load: [:assigned_members_count], load: [:assigned_members_count],
@ -238,6 +243,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|> assign(:open_delete_for_id, nil) |> assign(:open_delete_for_id, nil)
end 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} {:ok, socket}
end end
@ -282,6 +294,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
actor: actor actor: actor
) )
send(self(), {:custom_field_delete_modal_open, true})
{:noreply, {:noreply,
socket socket
|> assign(:custom_field_to_delete, custom_field) |> 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 if socket.assigns.slug_confirmation == custom_field.slug do
case Ash.destroy(custom_field, actor: actor) do case Ash.destroy(custom_field, actor: actor) do
:ok -> :ok ->
send(self(), {:custom_field_delete_modal_open, false})
send(self(), {:custom_field_deleted, custom_field}) send(self(), {:custom_field_deleted, custom_field})
{:noreply, {:noreply,
@ -312,6 +327,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|> stream_delete(:custom_fields, custom_field)} |> stream_delete(:custom_fields, custom_field)}
{:error, error} -> {:error, error} ->
send(self(), {:custom_field_delete_modal_open, false})
send(self(), {:custom_field_delete_error, error}) send(self(), {:custom_field_delete_error, error})
{:noreply, {:noreply,
@ -321,6 +337,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|> assign(:slug_confirmation, "")} |> assign(:slug_confirmation, "")}
end end
else else
send(self(), {:custom_field_delete_modal_open, false})
send(self(), :custom_field_slug_mismatch) send(self(), :custom_field_slug_mismatch)
{:noreply, {:noreply,
@ -333,10 +350,22 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true @impl true
def handle_event("cancel_delete", _params, socket) do def handle_event("cancel_delete", _params, socket) do
{:noreply, send(self(), {:custom_field_delete_modal_open, false})
socket {:noreply, close_delete_modal_and_restore_focus(socket)}
|> assign(:show_delete_modal, false) end
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")} 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, "")
|> push_event("focus_restore", %{id: "delete-custom-field-trigger"})
end end
end end

View file

@ -19,9 +19,32 @@ defmodule MvWeb.DatafieldsLive do
socket socket
|> assign(:page_title, gettext("Datafields")) |> assign(:page_title, gettext("Datafields"))
|> assign(:settings, settings) |> assign(:settings, settings)
|> assign(:active_editing_section, nil)} |> assign(:active_editing_section, nil)
|> assign(:custom_field_delete_modal_open, false)}
end 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 @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -29,31 +52,68 @@ defmodule MvWeb.DatafieldsLive do
<.header> <.header>
{gettext("Datafields")} {gettext("Datafields")}
<:subtitle> <:subtitle>
{gettext("Configure member fields and custom data fields.")} {gettext(
"Configure which data you want to save for your members. Define individual datafields."
)}
</:subtitle> </:subtitle>
</.header> </.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
module={MvWeb.MemberFieldLive.IndexComponent}
id="member-fields-component"
settings={@settings}
/>
</.form_section>
<.form_section title={gettext("Individual Datafields")}>
<.live_component
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 <.live_component
:if={@active_editing_section != :custom_fields}
module={MvWeb.MemberFieldLive.IndexComponent} module={MvWeb.MemberFieldLive.IndexComponent}
id="member-fields-component" id="member-fields-component"
settings={@settings} settings={@settings}
/> />
</.form_section> </div>
<.form_section title={gettext("Custom fields")}> <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 <.live_component
:if={@active_editing_section != :member_fields}
module={MvWeb.CustomFieldLive.IndexComponent} module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component" id="custom-fields-component"
actor={@current_user} actor={@current_user}
/> />
</.form_section> </div>
</Layouts.app> </Layouts.app>
""" """
end 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 @impl true
def handle_info({:custom_field_saved, _custom_field, action}, socket) do def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent, send_update(MvWeb.CustomFieldLive.IndexComponent,

View file

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

View file

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

View file

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

View file

@ -25,7 +25,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
~H""" ~H"""
<div id={@id}> <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( {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." "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} :if={!@show_form}
id="member_fields" id="member_fields"
rows={@member_fields} rows={@member_fields}
row_id={fn {field_name, _field_data} -> "member_field-#{field_name}" end}
row_click={ row_click={
fn {field_name, _field_data} -> fn {field_name, _field_data} ->
JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself) 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")} label={gettext("Show in overview")}
class="max-w-[9.375rem] text-center" class="max-w-[9.375rem] text-center"
> >
<span :if={field_data.show_in_overview} class="badge badge-success"> <.badge :if={field_data.show_in_overview} variant="success">
{gettext("Yes")} {gettext("Yes")}
</span> </.badge>
<span :if={!field_data.show_in_overview} class="badge badge-ghost"> <.badge :if={!field_data.show_in_overview} variant="neutral">
{gettext("No")} {gettext("No")}
</span> </.badge>
</:col> </:col>
</.table> </.table>
</div> </div>
@ -99,8 +100,8 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
@impl true @impl true
def update(assigns, socket) do def update(assigns, socket) do
# Track previous show_form state to detect when form is closed # Use socket state so send_update(show_form: false) is the only trigger for "form closed"
previous_show_form = Map.get(socket.assigns, :show_form, false) previous_show_form = socket.assigns[:show_form] || false
# If show_form is explicitly provided in assigns, reset editing state # If show_form is explicitly provided in assigns, reset editing state
socket = socket =
@ -112,20 +113,22 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
socket socket
end end
# Detect when form is closed (show_form changes from true to false) socket =
new_show_form = Map.get(assigns, :show_form, false) 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)
if previous_show_form and not new_show_form do # 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}) send(self(), {:editing_section_changed, nil})
end end
{:ok, {: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)}
end end
@impl true @impl true

View file

@ -38,248 +38,312 @@ defmodule MvWeb.MemberLive.Form do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save"> <.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
<.header> <div
<:leading> id="member-form-focus-root"
<.button navigate={return_path(@return_to, @member)} variant="neutral"> phx-hook="FocusRestore"
<.icon name="hero-arrow-left" class="size-4" /> phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
{gettext("Back")} >
</.button> <.header>
</:leading> <:leading>
<%= if @member do %> <.button navigate={return_path(@return_to, @member)} variant="neutral">
{MvWeb.Helpers.MemberHelpers.display_name(@member)} <.icon name="hero-arrow-left" class="size-4" />
<% else %> {gettext("Back")}
{gettext("New Member")} </.button>
<% end %> </:leading>
<:actions> <%= if @member do %>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> {MvWeb.Helpers.MemberHelpers.display_name(@member)}
{gettext("Save")} <% else %>
</.button> {gettext("New Member")}
</:actions> <% end %>
</.header> <:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
<div class="mt-6 space-y-6"> <div class="mt-6 space-y-6">
<%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%> <%!-- Tab navigation: same styling as member show; only Contact Data tab (no Membership Fees on edit) --%>
<div role="tablist" class="tabs tabs-bordered"> <div
<button type="button" role="tab" class="tab tab-active" aria-selected="true"> role="tablist"
<.icon name="hero-identification" class="size-4 mr-2" /> class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
{gettext("Contact Data")} >
</button> <button
</div> id="member-tab-contact"
role="tab"
<%!-- Personal Data and Custom Fields Row --%> type="button"
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> tabindex="0"
<%!-- Personal Data Section --%> aria-selected="true"
<div> aria-controls="member-tabpanel-contact"
<.form_section title={gettext("Personal Data")}> class="tab tab-active flex items-center gap-2"
<div class="space-y-4"> >
<%!-- Name Row --%> <.icon name="hero-identification" class="size-4 shrink-0" />
<div class="flex gap-4"> {gettext("Contact Data")}
<div class="w-48"> </button>
<.input
field={@form[:first_name]}
label={gettext("First Name")}
required={@member_field_required_map[:first_name]}
/>
</div>
<div class="w-48">
<.input
field={@form[:last_name]}
label={gettext("Last Name")}
required={@member_field_required_map[:last_name]}
/>
</div>
</div>
<%!-- Address: Country, Postal Code, City in one row --%>
<div class="flex gap-4">
<div class="w-48">
<.input field={@form[:country]} label={gettext("Country")} />
</div>
<div class="w-24">
<.input
field={@form[:postal_code]}
label={gettext("Postal Code")}
required={@member_field_required_map[:postal_code]}
/>
</div>
<div class="w-48">
<.input field={@form[:city]} label={gettext("City")} />
</div>
</div>
<%!-- Street and Nr. below --%>
<div class="flex gap-4">
<div class="w-64">
<.input field={@form[:street]} label={gettext("Street")} />
</div>
<div class="w-24">
<.input field={@form[:house_number]} label={gettext("Nr.")} />
</div>
</div>
<%!-- Email --%>
<div class="w-64">
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-4">
<div class="w-36">
<.input
field={@form[:join_date]}
label={gettext("Join Date")}
type="date"
required={@member_field_required_map[:join_date]}
/>
</div>
<div class="w-36">
<.input
field={@form[:exit_date]}
label={gettext("Exit Date")}
type="date"
required={@member_field_required_map[:exit_date]}
/>
</div>
</div>
<%!-- Notes --%>
<div>
<.input
field={@form[:notes]}
label={gettext("Notes")}
type="textarea"
required={@member_field_required_map[:notes]}
/>
</div>
</div>
</.form_section>
</div> </div>
<%!-- Custom Fields Section --%> <%!-- Contact Data Tab Content (same structure as member show) --%>
<%= if Enum.any?(@custom_fields) do %> <div
<div> id="member-tabpanel-contact"
<.form_section title={gettext("Custom Fields")}> role="tabpanel"
<div class="grid grid-cols-2 gap-4"> aria-labelledby="member-tab-contact"
<%!-- Render in sorted order by finding the form for each sorted custom field --%> >
<%= for cf <- @sorted_custom_fields do %> <%!-- Personal Data and Custom Fields Row --%>
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%= if f_cfv[:custom_field_id].value == cf.id do %> <%!-- Personal Data Section --%>
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}> <div>
<.inputs_for :let={value_form} field={f_cfv[:value]}> <.form_section title={gettext("Personal Data")}>
<.input <div class="space-y-4">
field={value_form[:value]} <%!-- Name Row --%>
label={cf.name} <div class="flex gap-4">
type={custom_field_input_type(cf.value_type)} <div class="w-48">
required={cf.required} <.input
/> field={@form[:first_name]}
</.inputs_for> label={gettext("First Name")}
<input required={@member_field_required_map[:first_name]}
type="hidden" />
name={f_cfv[:custom_field_id].name} </div>
value={f_cfv[:custom_field_id].value} <div class="w-48">
/> <.input
</div> field={@form[:last_name]}
label={gettext("Last Name")}
required={@member_field_required_map[:last_name]}
/>
</div>
</div>
<%!-- Address: Country, Postal Code, City in one row --%>
<div class="flex gap-4">
<div class="w-48">
<.input field={@form[:country]} label={gettext("Country")} />
</div>
<div class="w-24">
<.input
field={@form[:postal_code]}
label={gettext("Postal Code")}
required={@member_field_required_map[:postal_code]}
/>
</div>
<div class="w-48">
<.input field={@form[:city]} label={gettext("City")} />
</div>
</div>
<%!-- Street and Nr. below --%>
<div class="flex gap-4">
<div class="w-64">
<.input field={@form[:street]} label={gettext("Street")} />
</div>
<div class="w-24">
<.input field={@form[:house_number]} label={gettext("Nr.")} />
</div>
</div>
<%!-- Email --%>
<div class="w-64">
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-4">
<div class="w-36">
<.input
field={@form[:join_date]}
label={gettext("Join Date")}
type="date"
required={@member_field_required_map[:join_date]}
/>
</div>
<div class="w-36">
<.input
field={@form[:exit_date]}
label={gettext("Exit Date")}
type="date"
required={@member_field_required_map[:exit_date]}
/>
</div>
</div>
<%!-- Notes --%>
<div>
<.input
field={@form[:notes]}
label={gettext("Notes")}
type="textarea"
required={@member_field_required_map[:notes]}
/>
</div>
</div>
</.form_section>
</div>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.form_section title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
<%= for cf <- @sorted_custom_fields do %>
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
<%= if f_cfv[:custom_field_id].value == cf.id do %>
<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]}
label={cf.name}
type={custom_field_input_type(cf.value_type)}
required={cf.required}
/>
</.inputs_for>
<input
type="hidden"
name={f_cfv[:custom_field_id].name}
value={f_cfv[:custom_field_id].value}
/>
</div>
<% end %>
</.inputs_for>
<% end %> <% end %>
</.inputs_for> </div>
<% end %> </.form_section>
</div>
<% end %>
</div>
<%!-- Membership Fee Section --%>
<div class="max-w-xl">
<.form_section title={gettext("Membership Fee")}>
<div class="space-y-4">
<div>
<label class="label">
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
</label>
<select
class="select select-bordered w-full"
name={@form[:membership_fee_type_id].name}
phx-change="validate"
value={@form[:membership_fee_type_id].value || ""}
>
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
<option value="">{gettext("Select a membership fee type")}</option>
<%= for fee_type <- @available_fee_types do %>
<option
value={fee_type.id}
selected={fee_type.id == @form[:membership_fee_type_id].value}
>
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
fee_type.interval
)})
</option>
<% end %>
</select>
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="text-error text-sm mt-1">{msg}</p>
<% end %>
<%= if @interval_warning do %>
<div class="alert alert-warning mt-2">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>{@interval_warning}</span>
</div>
<% end %>
<p class="text-sm text-base-content/60 mt-2">
{gettext(
"Select a membership fee type for this member. Members can only switch between types with the same interval."
)}
</p>
</div>
</div> </div>
</.form_section> </.form_section>
</div> </div>
<% end %>
</div>
<%!-- Membership Fee Section --%> <%!-- Bottom Action Buttons --%>
<div class="max-w-xl"> <div class="flex justify-end gap-4 mt-6">
<.form_section title={gettext("Membership Fee")}> <.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
<div class="space-y-4"> {gettext("Cancel")}
<div> </.button>
<label class="label"> <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span> {gettext("Save Member")}
</label>
<select
class="select select-bordered w-full"
name={@form[:membership_fee_type_id].name}
phx-change="validate"
value={@form[:membership_fee_type_id].value || ""}
>
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
<option value="">{gettext("Select a membership fee type")}</option>
<%= for fee_type <- @available_fee_types do %>
<option
value={fee_type.id}
selected={fee_type.id == @form[:membership_fee_type_id].value}
>
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
fee_type.interval
)})
</option>
<% end %>
</select>
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="text-error text-sm mt-1">{msg}</p>
<% end %>
<%= if @interval_warning do %>
<div class="alert alert-warning mt-2">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>{@interval_warning}</span>
</div>
<% end %>
<p class="text-sm text-base-content/60 mt-2">
{gettext(
"Select a membership fee type for this member. Members can only switch between types with the same interval."
)}
</p>
</div>
</div>
</.form_section>
</div>
<%!-- Bottom Action Buttons --%>
<div class="flex justify-end gap-4 mt-6">
<.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Member")}
</.button>
</div>
<%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
<%= if @member && can?(@current_user, :destroy, @member) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
)}
</p>
<.button
variant="danger"
type="button"
phx-click="delete"
phx-value-id={@member.id}
data-confirm={
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)
}
data-testid="member-delete"
aria-label={
gettext("Delete member %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)
}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete member")}
</.button> </.button>
</div> </div>
</section>
<% end %> <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
<%= if @member && can?(@current_user, :destroy, @member) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
)}
</p>
<.button
id="delete-member-form-trigger"
variant="danger"
type="button"
phx-click="open_delete_modal"
data-testid="member-delete"
aria-label={
gettext("Delete member %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)
}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete member")}
</.button>
</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> </div>
</.form> </.form>
</Layouts.app> </Layouts.app>
@ -329,6 +393,7 @@ defmodule MvWeb.MemberLive.Form do
|> assign(:available_fee_types, available_fee_types) |> assign(:available_fee_types, available_fee_types)
|> assign(:interval_warning, nil) |> assign(:interval_warning, nil)
|> assign(:member_field_required_map, member_field_required_map) |> assign(:member_field_required_map, member_field_required_map)
|> assign_new(:show_delete_modal, fn -> false end)
|> assign_form()} |> assign_form()}
end end
@ -400,6 +465,32 @@ defmodule MvWeb.MemberLive.Form do
end end
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 @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
member = socket.assigns.member member = socket.assigns.member
@ -407,10 +498,16 @@ defmodule MvWeb.MemberLive.Form do
cond do cond do
is_nil(member) -> 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) -> 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 -> true ->
handle_member_delete_destroy(socket, member, actor) handle_member_delete_destroy(socket, member, actor)
@ -427,14 +524,26 @@ defmodule MvWeb.MemberLive.Form do
{:error, %Ash.Error.Forbidden{}} -> {:error, %Ash.Error.Forbidden{}} ->
{:noreply, {: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} -> {:error, error} ->
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(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
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 defp handle_save_success(socket, member) do
notify_parent({:saved, member}) 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)) selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id))
any_selected? = Enum.any?(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 = mailto_bcc =
if any_selected? do if any_selected? do
format_selected_member_emails(members, selected_members) format_selected_member_emails(members, selected_members)
|> Enum.join(", ") |> Enum.join(", ")
|> URI.encode_www_form() |> URI.encode_www_form()
|> String.replace("+", "%20")
else else
"" ""
end 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} {member.membership_fee_type.name}
<% else %> </.maybe_value>
<span class="text-base-content/50">—</span>
<% end %>
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:membership_fee_status in @member_fields_visible} :if={:membership_fee_status in @member_fields_visible}
label={gettext("Membership Fee Status")} label={gettext("Membership Fee Status")}
> >
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge( <%= if badge = MembershipFeeStatus.format_cycle_status_badge(
MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle) MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
) do %> ) do %>
<span class={["badge", badge.color]}> <.badge variant={badge.variant}>
<.icon name={badge.icon} class="size-4" /> <.icon name={badge.icon} class="size-4" />
{badge.label} {badge.label}
</span> </.badge>
<% else %> <% else %>
<span class="badge badge-ghost">{gettext("No cycle")}</span> <.empty_cell sr_text={gettext("No cycle")} />
<% end %> <% end %>
</:col> </:col>
<:col <:col
@ -394,17 +392,17 @@
""" """
} }
> >
<%= for group <- (member.groups || []) do %> <.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
<span <%= for group <- (member.groups || []) do %>
class="badge badge-outline badge-primary" <.badge
aria-label={gettext("Member of group %{name}", name: group.name)} variant="primary"
> style="outline"
{group.name} aria-label={gettext("Member of group %{name}", name: group.name)}
</span> >
<% end %> {group.name}
<%= if (member.groups || []) == [] do %> </.badge>
<span class="text-base-content/50">—</span> <% end %>
<% end %> </.maybe_value>
</:col> </:col>
<:action :let={member}> <:action :let={member}>
<div class="sr-only"> <div class="sr-only">

View file

@ -55,18 +55,26 @@ defmodule MvWeb.MemberLive.Show do
</:actions> </:actions>
</.header> </.header>
<div class="mt-6 space-y-6"> <div
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%> 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 <div
id="member-tablist"
role="tablist" role="tablist"
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit" class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
phx-hook="TabListKeydown"
phx-keydown="tab_keydown"
> >
<button <button
id="member-tab-contact" id="member-tab-contact"
role="tab" role="tab"
type="button" type="button"
tabindex="0" tabindex={if @active_tab == :contact, do: "0", else: "-1"}
aria-selected={@active_tab == :contact} aria-selected={if @active_tab == :contact, do: "true", else: "false"}
aria-controls="member-tabpanel-contact" aria-controls="member-tabpanel-contact"
class={[ class={[
"tab flex items-center gap-2", "tab flex items-center gap-2",
@ -82,8 +90,8 @@ defmodule MvWeb.MemberLive.Show do
id="member-tab-membership_fees" id="member-tab-membership_fees"
role="tab" role="tab"
type="button" type="button"
tabindex="0" tabindex={if @active_tab == :membership_fees, do: "0", else: "-1"}
aria-selected={@active_tab == :membership_fees} aria-selected={if @active_tab == :membership_fees, do: "true", else: "false"}
aria-controls="member-tabpanel-membership_fees" aria-controls="member-tabpanel-membership_fees"
class={[ class={[
"tab flex items-center gap-2", "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"> <.data_field label={gettext("Last Cycle")} class="min-w-32">
<%= if @member.last_cycle_status do %> <%= if @member.last_cycle_status do %>
<% status = @member.last_cycle_status %> <.badge variant={
<span class={["badge", MembershipFeeHelpers.status_color(status)]}> MembershipFeeHelpers.status_variant(@member.last_cycle_status)
{format_status_label(status)} }>
</span> {format_status_label(@member.last_cycle_status)}
</.badge>
<% else %> <% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span> <.badge variant="neutral">{gettext("No cycles")}</.badge>
<% end %> <% end %>
</.data_field> </.data_field>
<.data_field label={gettext("Current Cycle")} class="min-w-36"> <.data_field label={gettext("Current Cycle")} class="min-w-36">
<%= if @member.current_cycle_status do %> <%= if @member.current_cycle_status do %>
<% status = @member.current_cycle_status %> <.badge variant={
<span class={["badge", MembershipFeeHelpers.status_color(status)]}> MembershipFeeHelpers.status_variant(@member.current_cycle_status)
{format_status_label(status)} }>
</span> {format_status_label(@member.current_cycle_status)}
</.badge>
<% else %> <% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span> <.badge variant="neutral">{gettext("No cycles")}</.badge>
<% end %> <% end %>
</.data_field> </.data_field>
</div> </div>
@ -313,14 +323,9 @@ defmodule MvWeb.MemberLive.Show do
)} )}
</p> </p>
<.button <.button
id="delete-member-trigger"
variant="danger" variant="danger"
phx-click="delete" phx-click="open_delete_modal"
phx-value-id={@member.id}
data-confirm={
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)
}
data-testid="member-delete" data-testid="member-delete"
aria-label={ aria-label={
gettext("Delete member %{name}", gettext("Delete member %{name}",
@ -334,6 +339,48 @@ defmodule MvWeb.MemberLive.Show do
</div> </div>
</section> </section>
<% end %> <% 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> </div>
</Layouts.app> </Layouts.app>
""" """
@ -344,7 +391,8 @@ defmodule MvWeb.MemberLive.Show do
{:ok, {:ok,
socket socket
|> assign(:active_tab, :contact) |> assign(:active_tab, :contact)
|> assign(:vereinfacht_receipts, nil)} |> assign(:vereinfacht_receipts, nil)
|> assign_new(:show_delete_modal, fn -> false end)}
end end
@impl true @impl true
@ -396,13 +444,58 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)} {:noreply, assign(socket, :active_tab, :membership_fees)}
end 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 @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
member = socket.assigns.member member = socket.assigns.member
actor = current_actor(socket) actor = current_actor(socket)
if to_string(id) != to_string(member.id) do 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 else
case Ash.destroy(member, actor: actor) do case Ash.destroy(member, actor: actor) do
:ok -> :ok ->
@ -413,16 +506,21 @@ defmodule MvWeb.MemberLive.Show do
{:error, %Ash.Error.Forbidden{}} -> {:error, %Ash.Error.Forbidden{}} ->
{:noreply, {:noreply,
put_flash( socket
socket, |> put_flash(
:error, :error,
gettext("You do not have permission to delete this member") gettext("You do not have permission to delete this member")
)} )
|> assign(:show_delete_modal, false)}
{:error, error} -> {:error, error} ->
require Logger require Logger
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}") 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 end
end end
@ -437,6 +535,13 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :vereinfacht_receipts, response)} {:noreply, assign(socket, :vereinfacht_receipts, response)}
end 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 # Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
@impl true @impl true
def handle_info({:put_flash, type, message}, socket) do def handle_info({:put_flash, type, message}, socket) do
@ -503,7 +608,11 @@ defmodule MvWeb.MemberLive.Show do
<%= if @inner_block != [] do %> <%= if @inner_block != [] do %>
{render_slot(@inner_block)} {render_slot(@inner_block)}
<% else %> <% else %>
{display_value(@value)} <%= if value_blank?(@value) do %>
<.empty_cell sr_text={gettext("Not set")} />
<% else %>
{@value}
<% end %>
<% end %> <% end %>
</dd> </dd>
</dl> </dl>
@ -537,9 +646,9 @@ defmodule MvWeb.MemberLive.Show do
# Helper Functions # Helper Functions
# ----------------------------------------------------------------- # -----------------------------------------------------------------
defp display_value(nil), do: render_empty_value() defp value_blank?(nil), do: true
defp display_value(""), do: render_empty_value() defp value_blank?(v) when is_binary(v), do: String.trim(v) == ""
defp display_value(value), do: value defp value_blank?(_), do: false
defp format_status_label(:paid), do: gettext("Paid") defp format_status_label(:paid), do: gettext("Paid")
defp format_status_label(:unpaid), do: gettext("Unpaid") defp format_status_label(:unpaid), do: gettext("Unpaid")
@ -628,10 +737,10 @@ defmodule MvWeb.MemberLive.Show do
if String.trim(value) == "" do if String.trim(value) == "" do
render_empty_value() render_empty_value()
else else
assigns = %{email: value} assigns = %{email: value, display: value}
~H""" ~H"""
<.mailto_link email={@email} display={@email} /> <.mailto_link email={@email} display={@display} />
""" """
end end
end end
@ -646,17 +755,10 @@ defmodule MvWeb.MemberLive.Show do
defp format_custom_field_value(value, _type), do: to_string(value) defp format_custom_field_value(value, _type), do: to_string(value)
# Renders accessible placeholder for empty values # Renders accessible empty value: visually empty, screen-reader text only (see Design Guidelines §8.6).
# Uses translated text for screen readers while maintaining visual consistency # Returns safe HTML so it can be used from helpers without LiveView assigns.
# The visual "—" is hidden from screen readers, while the translated text is only visible to screen readers
defp render_empty_value do defp render_empty_value do
assigns = %{text: gettext("Not set")} text = gettext("Not set")
{:safe, ["<span class=\"sr-only\">", Phoenix.HTML.Engine.html_escape(text), "</span>"]}
~H"""
<span class="text-base-content/50 italic">
<span aria-hidden="true"></span>
<span class="sr-only">{@text}</span>
</span>
"""
end end
end end

View file

@ -101,7 +101,13 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<%= for r <- receipts do %> <%= for r <- receipts do %>
<tr> <tr>
<%= for {col_key, _header_key} <- cols do %> <%= 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 %> <% end %>
</tr> </tr>
<% end %> <% end %>
@ -186,9 +192,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</:col> </:col>
<:col :let={cycle} label={gettext("Interval")}> <:col :let={cycle} label={gettext("Interval")}>
<span class="badge badge-outline"> <.badge variant="neutral" style="outline">
{MembershipFeeHelpers.format_interval(cycle.membership_fee_type.interval)} {MembershipFeeHelpers.format_interval(cycle.membership_fee_type.interval)}
</span> </.badge>
</:col> </:col>
<:col :let={cycle} label={gettext("Amount")}> <:col :let={cycle} label={gettext("Amount")}>
@ -208,12 +214,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</:col> </:col>
<:col :let={cycle} label={gettext("Status")}> <:col :let={cycle} label={gettext("Status")}>
<% badge = MembershipFeeHelpers.status_color(cycle.status) %> <.badge variant={MembershipFeeHelpers.status_variant(cycle.status)}>
<% icon = MembershipFeeHelpers.status_icon(cycle.status) %> <.icon name={MembershipFeeHelpers.status_icon(cycle.status)} class="size-4" />
<span class={["badge", badge]}>
<.icon name={icon} class="size-4" />
{format_status_label(cycle.status)} {format_status_label(cycle.status)}
</span> </.badge>
</:col> </:col>
<:action :let={cycle}> <:action :let={cycle}>
@ -227,7 +231,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-status="paid" phx-value-status="paid"
phx-target={@myself} phx-target={@myself}
class={cycle_status_btn_class(cycle.status, :paid)} 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")} title={gettext("Mark as paid")}
> >
<.icon name="hero-check-circle" class="size-4" /> <.icon name="hero-check-circle" class="size-4" />
@ -240,7 +244,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-status="suspended" phx-value-status="suspended"
phx-target={@myself} phx-target={@myself}
class={cycle_status_btn_class(cycle.status, :suspended)} 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")} title={gettext("Mark as suspended")}
> >
<.icon name="hero-pause-circle" class="size-4" /> <.icon name="hero-pause-circle" class="size-4" />
@ -253,7 +257,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-status="unpaid" phx-value-status="unpaid"
phx-target={@myself} phx-target={@myself}
class={cycle_status_btn_class(cycle.status, :unpaid)} 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")} title={gettext("Mark as unpaid")}
> >
<.icon name="hero-x-circle" class="size-4" /> <.icon name="hero-x-circle" class="size-4" />
@ -290,11 +294,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %> <% end %>
</.section_box> </.section_box>
<%!-- Edit Cycle Amount Modal --%> <%!-- Edit Cycle Amount Modal (WCAG: focus in modal, aria-labelledby) --%>
<%= if @editing_cycle do %> <%= 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"> <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}> <form phx-submit="save_cycle_amount" phx-target={@myself}>
<input type="hidden" name="cycle_id" value={@editing_cycle.id} /> <input type="hidden" name="cycle_id" value={@editing_cycle.id} />
<div class="form-control w-full mt-4"> <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(".", ",")} value={Decimal.to_string(@editing_cycle.amount) |> String.replace(".", ",")}
class="input input-bordered w-full" class="input input-bordered w-full"
required required
phx-mounted={JS.focus()}
/> />
</div> </div>
<div class="modal-action"> <div class="modal-action">
@ -328,11 +341,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</dialog> </dialog>
<% end %> <% end %>
<%!-- Delete Cycle Confirmation Modal --%> <%!-- Delete Cycle Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
<%= if @deleting_cycle do %> <%= 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"> <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"> <p class="py-4">
{gettext("Are you sure you want to delete this cycle?")} {gettext("Are you sure you want to delete this cycle?")}
</p> </p>
@ -343,7 +362,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)} )} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
</p> </p>
<div class="modal-action"> <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")} {gettext("Cancel")}
</.button> </.button>
<.button <.button
@ -359,11 +383,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</dialog> </dialog>
<% end %> <% end %>
<%!-- Delete All Cycles Confirmation Modal --%> <%!-- Delete All Cycles Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
<%= if @deleting_all_cycles do %> <%= 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"> <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"> <div class="alert alert-warning mt-4">
<.icon name="hero-exclamation-triangle" class="size-5" /> <.icon name="hero-exclamation-triangle" class="size-5" />
<div> <div>
@ -391,6 +423,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
value={@delete_all_confirmation || ""} value={@delete_all_confirmation || ""}
class="input input-bordered w-full" class="input input-bordered w-full"
placeholder={gettext("Yes")} placeholder={gettext("Yes")}
phx-mounted={JS.focus()}
/> />
</div> </div>
<div class="modal-action"> <div class="modal-action">
@ -413,11 +446,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</dialog> </dialog>
<% end %> <% end %>
<%!-- Create Cycle Modal --%> <%!-- Create Cycle Modal (WCAG: focus in modal, aria-labelledby) --%>
<%= if @creating_cycle do %> <%= 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"> <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}> <form phx-submit="create_cycle" phx-target={@myself}>
<div class="form-control w-full mt-4"> <div class="form-control w-full mt-4">
<label class="label" for="create-cycle-date"> <label class="label" for="create-cycle-date">
@ -433,6 +472,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
class="input input-bordered w-full" class="input input-bordered w-full"
required required
aria-label={gettext("Date")} aria-label={gettext("Date")}
phx-mounted={JS.focus()}
/> />
<label class="label"> <label class="label">
<span class="label-text-alt"> <span class="label-text-alt">
@ -881,6 +921,35 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:create_cycle_error, nil)} |> assign(:create_cycle_error, nil)}
end 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 def handle_event("update_create_cycle_date", %{"date" => date_str}, socket) do
date = date =
case Date.from_iso8601(date_str) do 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) |> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end)
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 defp format_receipt_cell(:amount, val) when is_number(val) do
case Decimal.cast(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(: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 defp format_receipt_cell(:status, val) when is_binary(val) do
translate_receipt_status(val) 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(: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 defp format_receipt_cell(:receiptType, val) when is_binary(val) do
translate_receipt_type(val) 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(:receiptType, val), do: translate_receipt_type(to_string(val))
defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt], 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 defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do
format_receipt_date(val) 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("draft"), do: gettext("Draft")
defp translate_receipt_status("incompleted"), do: gettext("Incompleted") defp translate_receipt_status("incompleted"), do: gettext("Incompleted")
defp translate_receipt_status("completed"), do: gettext("Completed") 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 defp translate_receipt_status(other), do: other
# Translate API receipt type values (extend as API returns more values) # Translate API receipt type values (extend as API returns more values)

View file

@ -142,7 +142,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
<.header> <.header>
{gettext("Membership Fee Settings")} {gettext("Membership Fee Settings")}
<:subtitle> <:subtitle>
{gettext("Configure global settings and fee types for membership fees.")} {gettext("Configure fee types for membership fees.")}
</:subtitle> </:subtitle>
<:actions> <:actions>
<.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}> <.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" id="default_membership_fee_type_id"
name="settings[default_membership_fee_type_id]" name="settings[default_membership_fee_type_id]"
class={[ class={[
"select select-bordered w-full", "select select-bordered",
if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "") if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "")
]} ]}
phx-debounce="blur" phx-debounce="blur"
@ -323,13 +323,13 @@ defmodule MvWeb.MembershipFeeSettingsLive do
</:col> </:col>
<:col :let={mft} label={gettext("Interval")}> <:col :let={mft} label={gettext("Interval")}>
<span class="badge badge-outline"> <.badge variant="neutral" style="outline">
{MembershipFeeHelpers.format_interval(mft.interval)} {MembershipFeeHelpers.format_interval(mft.interval)}
</span> </.badge>
</:col> </:col>
<:col :let={mft} label={gettext("Members")}> <:col :let={mft} label={gettext("Members")}>
<span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span> <span class="text-sm">{get_member_count(mft, @member_counts)}</span>
</:col> </:col>
<:action :let={mft}> <:action :let={mft}>

View file

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

View file

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

View file

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

View file

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

View file

@ -18,7 +18,7 @@ defmodule MvWeb.RoleLive.Index do
require Ash.Query 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 @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do

View file

@ -16,15 +16,16 @@
<.table <.table
id="roles" id="roles"
rows={@roles} rows={@roles}
row_id={fn role -> "role-#{role.id}" end}
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end} row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
row_tooltip={gettext("Click for role details")} row_tooltip={gettext("Click for role details")}
> >
<:col :let={role} label={gettext("Name")}> <:col :let={role} label={gettext("Name")}>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium">{role.name}</span> <span class="font-medium">{role.name}</span>
<%= if role.is_system_role do %> <.badge :if={role.is_system_role} variant="warning" size="sm">
<span class="badge badge-warning badge-sm">{gettext("System Role")}</span> {gettext("System Role")}
<% end %> </.badge>
</div> </div>
</:col> </:col>
@ -37,21 +38,22 @@
</:col> </:col>
<:col :let={role} label={gettext("Permission Set")}> <:col :let={role} label={gettext("Permission Set")}>
<span class={permission_set_badge_class(role.permission_set_name)}> <.badge variant={permission_set_badge_variant(role.permission_set_name)} size="sm">
{role.permission_set_name} {role.permission_set_name}
</span> </.badge>
</:col> </:col>
<:col :let={role} label={gettext("Type")}> <:col :let={role} label={gettext("Type")}>
<%= if role.is_system_role do %> <.badge :if={role.is_system_role} variant="warning" size="sm">
<span class="badge badge-warning badge-sm">{gettext("System")}</span> {gettext("System")}
<% else %> </.badge>
<span class="badge badge-ghost badge-sm">{gettext("Custom")}</span> <.badge :if={!role.is_system_role} variant="neutral" size="sm">
<% end %> {gettext("Custom")}
</.badge>
</:col> </:col>
<:col :let={role} label={gettext("Users")}> <:col :let={role} label={gettext("Users")}>
<span class="badge badge-ghost">{get_user_count(role, @user_counts)}</span> <span class="text-sm">{get_user_count(role, @user_counts)}</span>
</:col> </:col>
</.table> </.table>
</Layouts.app> </Layouts.app>

View file

@ -17,7 +17,7 @@ defmodule MvWeb.RoleLive.Show do
require Ash.Query require Ash.Query
import MvWeb.RoleLive.Helpers, import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3] only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3]
@impl true @impl true
def mount(%{"id" => id}, _session, socket) do def mount(%{"id" => id}, _session, socket) do
@ -35,7 +35,8 @@ defmodule MvWeb.RoleLive.Show do
socket socket
|> assign(:page_title, gettext("Show Role")) |> assign(:page_title, gettext("Show Role"))
|> assign(:role, 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{} | _]}} -> {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
{:ok, {:ok,
@ -84,35 +85,61 @@ defmodule MvWeb.RoleLive.Show do
error_message = format_error(error) error_message = format_error(error)
{:noreply, {:noreply,
put_flash( socket
socket, |> put_flash(
:error, :error,
gettext("Failed to delete role: %{error}", error: error_message) gettext("Failed to delete role: %{error}", error: error_message)
)} )
|> assign(:show_delete_modal, false)}
end end
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 defp handle_delete_role(role, socket) do
if role.is_system_role do if role.is_system_role do
{:noreply, {:noreply,
put_flash( socket
socket, |> put_flash(:error, gettext("System roles cannot be deleted."))
:error, |> assign(:show_delete_modal, false)}
gettext("System roles cannot be deleted.")
)}
else else
user_count = recalculate_user_count(role, socket.assigns.current_user) user_count = recalculate_user_count(role, socket.assigns.current_user)
if user_count > 0 do if user_count > 0 do
{:noreply, {:noreply,
put_flash( socket
socket, |> put_flash(
:error, :error,
gettext( gettext(
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.", "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
count: user_count count: user_count
) )
)} )
|> assign(:show_delete_modal, false)}
else else
perform_role_deletion(role, socket) perform_role_deletion(role, socket)
end end
@ -156,6 +183,12 @@ defmodule MvWeb.RoleLive.Show do
recalculate_user_count(role, actor) recalculate_user_count(role, actor)
end 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 @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -187,59 +220,103 @@ defmodule MvWeb.RoleLive.Show do
</:actions> </:actions>
</.header> </.header>
<.list> <div
<:item title={gettext("Name")}>{@role.name}</:item> id="role-show-focus-root"
<:item title={gettext("Description")}> phx-hook="FocusRestore"
<%= if @role.description do %> phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
{@role.description} >
<% else %> <.list>
<span class="text-base-content/70 italic">{gettext("No description")}</span> <:item title={gettext("Name")}>{@role.name}</:item>
<% end %> <:item title={gettext("Description")}>
</:item> <%= if @role.description do %>
<:item title={gettext("Permission Set")}> {@role.description}
<span class={permission_set_badge_class(@role.permission_set_name)}> <% else %>
{@role.permission_set_name} <span class="text-base-content/70 italic">{gettext("No description")}</span>
</span> <% end %>
</:item> </:item>
<:item title={gettext("System Role")}> <:item title={gettext("Permission Set")}>
<%= if @role.is_system_role do %> <.badge variant={permission_set_badge_variant(@role.permission_set_name)}>
<span class="badge badge-warning">{gettext("Yes")}</span> {@role.permission_set_name}
<% else %> </.badge>
<span class="badge badge-ghost">{gettext("No")}</span> </:item>
<% end %> <:item title={gettext("System Role")}>
</:item> <.badge :if={@role.is_system_role} variant="warning">
</.list> {gettext("Yes")}
</.badge>
<.badge :if={!@role.is_system_role} variant="neutral">
{gettext("No")}
</.badge>
</:item>
</.list>
<%!-- Danger zone: canonical pattern (same as member show) --%> <%!-- Danger zone: canonical pattern (same as member show) --%>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %> <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading"> <section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error"> <h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")} {gettext("Danger zone")}
</h2> </h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100"> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4"> <p class="text-base-content/70 mb-4">
{gettext( {gettext(
"Deleting this role cannot be undone. Users assigned to this role must be reassigned first." "Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
)} )}
</p> </p>
<.button <.button
variant="danger" id="delete-role-trigger"
phx-click={JS.push("delete", value: %{id: @role.id})} variant="danger"
data-confirm={ phx-click="open_delete_modal"
gettext( data-testid="role-delete"
aria-label={gettext("Delete role %{name}", name: @role.name)}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete role")}
</.button>
</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.", "Are you sure you want to delete the role %{name}? This action cannot be undone.",
name: @role.name name: @role.name
) )}
} </p>
data-testid="role-delete" <div class="modal-action">
aria-label={gettext("Delete role %{name}", name: @role.name)} <.button
> type="button"
<.icon name="hero-trash" class="size-4" /> variant="neutral"
{gettext("Delete role")} phx-click="cancel_delete_modal"
</.button> phx-mounted={JS.focus()}
</div> id="delete-role-modal-cancel"
</section> aria-label={gettext("Cancel")}
<% end %> >
{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> </Layouts.app>
""" """
end end

View file

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

View file

@ -53,7 +53,6 @@ defmodule MvWeb.UserLive.Form do
</.button> </.button>
</:leading> </:leading>
{@page_title} {@page_title}
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
<:actions> <:actions>
<.button <.button
form="user-form" form="user-form"
@ -66,280 +65,323 @@ defmodule MvWeb.UserLive.Form do
</:actions> </:actions>
</.header> </.header>
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save"> <div
<.input field={@form[:email]} label={gettext("Email")} required type="email" /> 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" />
<%= if @user && @can_assign_role do %> <%= if @user && @can_assign_role do %>
<div class="mt-4"> <div class="mt-4">
<.input
field={@form[:role_id]}
type="select"
label={gettext("Role")}
options={Enum.map(@roles, &{&1.name, &1.id})}
prompt={gettext("Select role...")}
/>
</div>
<% end %>
<!-- Password Section -->
<div class="mt-6">
<label class="flex items-center space-x-2">
<input
type="checkbox"
name="set_password"
phx-click="toggle_password_section"
checked={@show_password_fields}
class="checkbox checkbox-sm"
/>
<span class="text-sm font-medium">
{if @user, do: gettext("Change Password"), else: gettext("Set Password")}
</span>
</label>
<%= if @show_password_fields do %>
<div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
<%= if @user && MvWeb.Helpers.UserHelpers.has_oidc?(@user) do %>
<div class="p-3 mb-4 border border-red-300 rounded-lg bg-red-50" role="alert">
<p class="text-sm font-semibold text-red-800">
{gettext("SSO / OIDC user")}
</p>
<p class="mt-1 text-sm text-red-700">
{gettext(
"This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
)}
</p>
</div>
<% end %>
<.input <.input
field={@form[:password]} field={@form[:role_id]}
label={gettext("Password")} type="select"
type="password" label={gettext("Role")}
required options={Enum.map(@roles, &{&1.name, &1.id})}
autocomplete="new-password" prompt={gettext("Select role...")}
/> />
<!-- Only show password confirmation for new users (register_with_password) -->
<%= if !@user do %>
<.input
field={@form[:password_confirmation]}
label={gettext("Confirm Password")}
type="password"
required
autocomplete="new-password"
/>
<% end %>
<div class="text-sm text-gray-600">
<p><strong>{gettext("Password requirements")}:</strong></p>
<ul class="mt-1 space-y-1 text-xs list-disc list-inside">
<li>{gettext("At least 8 characters")}</li>
<li>{gettext("Include both letters and numbers")}</li>
<li>{gettext("Consider using special characters")}</li>
</ul>
</div>
<%= if @user && @can_manage_member_linking do %>
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
<p class="text-sm text-orange-800">
<strong>{gettext("Admin Note")}:</strong> {gettext(
"As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
)}
</p>
</div>
<% end %>
</div> </div>
<% else %>
<%= if @user do %>
<div class="p-4 mt-4 rounded-lg bg-blue-50">
<p class="text-sm text-blue-800">
<strong>{gettext("Note")}:</strong> {gettext(
"Check 'Change Password' above to set a new password for this user."
)}
</p>
</div>
<% else %>
<div class="p-4 mt-4 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"User will be created without a password. Check 'Set Password' to add one."
)}
</p>
</div>
<% end %>
<% end %> <% end %>
</div>
<!-- Password Section -->
<!-- Member Linking Section (admin only: only admins can link/unlink users to members) -->
<%= if @can_manage_member_linking do %>
<div class="mt-6"> <div class="mt-6">
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2> <label class="flex items-center space-x-2">
<input
type="checkbox"
name="set_password"
phx-click="toggle_password_section"
checked={@show_password_fields}
class="checkbox checkbox-sm"
/>
<span class="text-sm font-medium">
{if @user, do: gettext("Change Password"), else: gettext("Set Password")}
</span>
</label>
<%= if @user && @user.member && !@unlink_member do %> <%= if @show_password_fields do %>
<!-- Show linked member with unlink button --> <div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
<div class="p-4 border border-green-200 rounded-lg bg-green-50"> <%= if @user && MvWeb.Helpers.UserHelpers.has_oidc?(@user) do %>
<div class="flex items-center justify-between"> <div class="p-3 mb-4 border border-red-300 rounded-lg bg-red-50" role="alert">
<div> <p class="text-sm font-semibold text-red-800">
<p class="font-medium text-green-900"> {gettext("SSO / OIDC user")}
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
</p> </p>
<p class="text-sm text-green-700">{@user.member.email}</p> <p class="mt-1 text-sm text-red-700">
</div> {gettext(
<.button "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
type="button"
variant="danger"
size="sm"
phx-click="unlink_member"
>
{gettext("Unlink Member")}
</.button>
</div>
</div>
<% else %>
<%= if @unlink_member do %>
<!-- Show unlink pending message -->
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
"Member will be unlinked when you save. Cannot select new member until saved."
)}
</p>
</div>
<% end %>
<!-- Show member search/selection for unlinked users -->
<div class="space-y-3">
<div class="relative">
<input
type="text"
id="member-search-input"
role="combobox"
phx-hook="ComboBox"
phx-focus="show_member_dropdown"
phx-change="search_members"
phx-debounce="300"
phx-window-keydown="member_dropdown_keydown"
value={@member_search_query}
placeholder={gettext("Search for a member to link...")}
class="w-full input"
name="member_search"
disabled={@unlink_member}
aria-label={gettext("Search for member to link")}
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
aria-autocomplete="list"
aria-controls="member-dropdown"
aria-expanded={to_string(@show_member_dropdown)}
aria-activedescendant={
if @focused_member_index,
do: "member-option-#{@focused_member_index}",
else: nil
}
autocomplete="off"
/>
<%= if length(@available_members) > 0 do %>
<div
id="member-dropdown"
role="listbox"
aria-label={gettext("Available members")}
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
phx-click-away="hide_member_dropdown"
>
<%= for {member, index} <- Enum.with_index(@available_members) do %>
<div
id={"member-option-#{index}"}
role="option"
tabindex="0"
aria-selected={to_string(@focused_member_index == index)}
phx-click="select_member"
phx-value-id={member.id}
data-member-id={member.id}
class={[
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
if(@focused_member_index == index,
do: "bg-base-300",
else: "hover:bg-base-200"
)
]}
>
<p class="font-medium">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</p>
<p class="text-sm text-base-content/70">{member.email}</p>
</div>
<% end %>
</div>
<% end %>
</div>
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
)} )}
</p> </p>
</div> </div>
<% end %> <% end %>
<.input
field={@form[:password]}
label={gettext("Password")}
type="password"
required
autocomplete="new-password"
/>
<!-- Only show password confirmation for new users (register_with_password) -->
<%= if !@user do %>
<.input
field={@form[:password_confirmation]}
label={gettext("Confirm Password")}
type="password"
required
autocomplete="new-password"
/>
<% end %>
<%= if @selected_member_id && @selected_member_name do %> <div class="text-sm text-gray-600">
<div <p><strong>{gettext("Password requirements")}:</strong></p>
id="member-selected" <ul class="mt-1 space-y-1 text-xs list-disc list-inside">
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50" <li>{gettext("At least 8 characters")}</li>
> <li>{gettext("Include both letters and numbers")}</li>
<p class="text-sm text-blue-800"> <li>{gettext("Consider using special characters")}</li>
<strong>{gettext("Selected")}:</strong> {@selected_member_name} </ul>
</p> </div>
<p class="mt-1 text-xs text-blue-600">
{gettext("Save to confirm linking.")} <%= if @user && @can_manage_member_linking do %>
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
<p class="text-sm text-orange-800">
<strong>{gettext("Admin Note")}:</strong> {gettext(
"As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
)}
</p> </p>
</div> </div>
<% end %> <% end %>
</div> </div>
<% else %>
<%= if @user do %>
<div class="p-4 mt-4 rounded-lg bg-blue-50">
<p class="text-sm text-blue-800">
<strong>{gettext("Note")}:</strong> {gettext(
"Check 'Change Password' above to set a new password for this user."
)}
</p>
</div>
<% else %>
<div class="p-4 mt-4 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"User will be created without a password. Check 'Set Password' to add one."
)}
</p>
</div>
<% end %>
<% end %> <% end %>
</div> </div>
<% end %>
<!-- Member Linking Section (admin only: only admins can link/unlink users to members) -->
<%= if @can_manage_member_linking do %>
<div class="mt-6">
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
<%!-- Danger zone: canonical pattern (same as member form) --%> <%= if @user && @user.member && !@unlink_member do %>
<%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %> <!-- Show linked member with unlink button -->
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading"> <div class="p-4 border border-green-200 rounded-lg bg-green-50">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error"> <div class="flex items-center justify-between">
{gettext("Danger zone")} <div>
</h2> <p class="font-medium text-green-900">
<div class="border border-base-300 rounded-lg p-4 bg-base-100"> {MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
<p class="text-base-content/70 mb-4"> </p>
{gettext( <p class="text-sm text-green-700">{@user.member.email}</p>
"Deleting this user cannot be undone. The user account and any linked member association will be affected." </div>
)} <.button
</p> type="button"
<.button variant="danger"
type="button" size="sm"
variant="danger" phx-click="unlink_member"
phx-click="delete" >
phx-value-id={@user.id} {gettext("Unlink Member")}
data-confirm={ </.button>
gettext( </div>
</div>
<% else %>
<%= if @unlink_member do %>
<!-- Show unlink pending message -->
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
"Member will be unlinked when you save. Cannot select new member until saved."
)}
</p>
</div>
<% end %>
<!-- Show member search/selection for unlinked users -->
<div class="space-y-3">
<div class="relative">
<input
type="text"
id="member-search-input"
role="combobox"
phx-hook="ComboBox"
phx-focus="show_member_dropdown"
phx-change="search_members"
phx-debounce="300"
phx-window-keydown="member_dropdown_keydown"
value={@member_search_query}
placeholder={gettext("Search for a member to link...")}
class="w-full input"
name="member_search"
disabled={@unlink_member}
aria-label={gettext("Search for member to link")}
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
aria-autocomplete="list"
aria-controls="member-dropdown"
aria-expanded={to_string(@show_member_dropdown)}
aria-activedescendant={
if @focused_member_index,
do: "member-option-#{@focused_member_index}",
else: nil
}
autocomplete="off"
/>
<%= if length(@available_members) > 0 do %>
<div
id="member-dropdown"
role="listbox"
aria-label={gettext("Available members")}
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
phx-click-away="hide_member_dropdown"
>
<%= for {member, index} <- Enum.with_index(@available_members) do %>
<div
id={"member-option-#{index}"}
role="option"
tabindex="0"
aria-selected={to_string(@focused_member_index == index)}
phx-click="select_member"
phx-value-id={member.id}
data-member-id={member.id}
class={[
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
if(@focused_member_index == index,
do: "bg-base-300",
else: "hover:bg-base-200"
)
]}
>
<p class="font-medium">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</p>
<p class="text-sm text-base-content/70">{member.email}</p>
</div>
<% end %>
</div>
<% end %>
</div>
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
)}
</p>
</div>
<% end %>
<%= if @selected_member_id && @selected_member_name do %>
<div
id="member-selected"
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
>
<p class="text-sm text-blue-800">
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
</p>
<p class="mt-1 text-xs text-blue-600">
{gettext("Save to confirm linking.")}
</p>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
<%!-- Danger zone: canonical pattern (same as member form) --%>
<%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
)}
</p>
<.button
id="delete-user-form-trigger"
type="button"
variant="danger"
phx-click="open_delete_modal"
data-testid="user-delete"
aria-label={gettext("Delete user %{email}", email: @user.email)}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete user")}
</.button>
</div>
</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.", "Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email email: @user.email
) )}
} </p>
data-testid="user-delete" <div class="modal-action">
aria-label={gettext("Delete user %{email}", email: @user.email)} <.button
> type="button"
<.icon name="hero-trash" class="size-4" /> variant="neutral"
{gettext("Delete user")} phx-click="cancel_delete_modal"
</.button> phx-mounted={JS.focus()}
</div> id="delete-user-form-modal-cancel"
</section> aria-label={gettext("Cancel")}
<% end %> >
{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"> <div class="mt-4">
<.button navigate={return_path(@return_to, @user)} variant="neutral"> <.button navigate={return_path(@return_to, @user)} variant="neutral">
{gettext("Cancel")} {gettext("Cancel")}
</.button> </.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save User")} {gettext("Save User")}
</.button> </.button>
</div> </div>
</.form> </.form>
</div>
</Layouts.app> </Layouts.app>
""" """
end end
@ -399,6 +441,7 @@ defmodule MvWeb.UserLive.Form do
|> assign(:selected_member_name, nil) |> assign(:selected_member_name, nil)
|> assign(:unlink_member, false) |> assign(:unlink_member, false)
|> assign(:focused_member_index, nil) |> assign(:focused_member_index, nil)
|> assign_new(:show_delete_modal, fn -> false end)
|> load_initial_members() |> load_initial_members()
|> assign_form()} |> assign_form()}
end end
@ -454,6 +497,32 @@ defmodule MvWeb.UserLive.Form do
end end
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 @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.user user = socket.assigns.user
@ -461,13 +530,22 @@ defmodule MvWeb.UserLive.Form do
cond do cond do
is_nil(user) -> 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) -> 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) -> 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 -> true ->
handle_user_delete_destroy(socket, user, actor) handle_user_delete_destroy(socket, user, actor)
@ -594,13 +672,24 @@ defmodule MvWeb.UserLive.Form do
{:error, %Ash.Error.Forbidden{}} -> {:error, %Ash.Error.Forbidden{}} ->
{:noreply, {: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} -> {: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
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 defp handle_member_linking(socket, user, actor) do
result = perform_member_link_action(socket, user, actor) result = perform_member_link_action(socket, user, actor)

View file

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

View file

@ -45,8 +45,6 @@ defmodule MvWeb.UserLive.Show do
</.button> </.button>
</:leading> </:leading>
{gettext("User")} {@user.email} {gettext("User")} {@user.email}
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
<:actions> <:actions>
<%= if can?(@current_user, :update, @user) do %> <%= if can?(@current_user, :update, @user) do %>
<.button <.button
@ -60,65 +58,106 @@ defmodule MvWeb.UserLive.Show do
</:actions> </:actions>
</.header> </.header>
<.list> <div
<:item title={gettext("Email")}>{@user.email}</:item> id="user-show-focus-root"
<:item title={gettext("Role")}>{@user.role.name}</:item> phx-hook="FocusRestore"
<:item title={gettext("Password Authentication")}> phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
{if MvWeb.Helpers.UserHelpers.has_password?(@user), >
do: gettext("Enabled"), <.list>
else: gettext("Not enabled")} <:item title={gettext("Email")}>{@user.email}</:item>
</:item> <:item title={gettext("Role")}>{@user.role.name}</:item>
<:item title={gettext("OIDC")}> <:item title={gettext("Password Authentication")}>
{if MvWeb.Helpers.UserHelpers.has_oidc?(@user), {if MvWeb.Helpers.UserHelpers.has_password?(@user),
do: gettext("Linked"), do: gettext("Enabled"),
else: gettext("Not linked")} else: gettext("Not enabled")}
</:item> </:item>
<:item title={gettext("Linked Member")}> <:item title={gettext("OIDC")}>
<%= if @user.member do %> {if MvWeb.Helpers.UserHelpers.has_oidc?(@user),
<.link do: gettext("Linked"),
navigate={~p"/members/#{@user.member}"} else: gettext("Not linked")}
class="text-blue-600 underline hover:text-blue-800" </:item>
> <:item title={gettext("Linked Member")}>
<.icon name="hero-users" class="inline w-4 h-4 mr-1" /> <%= if @user.member do %>
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)} <.link
</.link> navigate={~p"/members/#{@user.member}"}
<% else %> class="text-blue-600 underline hover:text-blue-800"
<span class="italic text-gray-500">{gettext("No member linked")}</span> >
<% end %> <.icon name="hero-users" class="inline w-4 h-4 mr-1" />
</:item> {MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
</.list> </.link>
<% else %>
<span class="italic text-gray-500">{gettext("No member linked")}</span>
<% end %>
</:item>
</.list>
<%!-- Danger zone: canonical pattern (same as member show) --%> <%!-- Danger zone: canonical pattern (same as member show) --%>
<%= if can?(@current_user, :destroy, @user) and not Mv.Helpers.SystemActor.system_user?(@user) do %> <%= if can?(@current_user, :destroy, @user) and not Mv.Helpers.SystemActor.system_user?(@user) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading"> <section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error"> <h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")} {gettext("Danger zone")}
</h2> </h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100"> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4"> <p class="text-base-content/70 mb-4">
{gettext( {gettext(
"Deleting this user cannot be undone. The user account and any linked member association will be affected." "Deleting this user cannot be undone. The user account and any linked member association will be affected."
)} )}
</p> </p>
<.button <.button
variant="danger" id="delete-user-trigger"
phx-click="delete" variant="danger"
phx-value-id={@user.id} phx-click="open_delete_modal"
data-confirm={ data-testid="user-delete"
gettext( aria-label={gettext("Delete user %{email}", email: @user.email)}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete user")}
</.button>
</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.", "Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email email: @user.email
) )}
} </p>
data-testid="user-delete" <div class="modal-action">
aria-label={gettext("Delete user %{email}", email: @user.email)} <.button
> type="button"
<.icon name="hero-trash" class="size-4" /> variant="neutral"
{gettext("Delete user")} phx-click="cancel_delete_modal"
</.button> phx-mounted={JS.focus()}
</div> id="delete-user-modal-cancel"
</section> aria-label={gettext("Cancel")}
<% end %> >
{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> </Layouts.app>
""" """
end end
@ -139,10 +178,37 @@ defmodule MvWeb.UserLive.Show do
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Show User")) |> assign(:page_title, gettext("Show User"))
|> assign(:user, user)} |> assign(:user, user)
|> assign(:show_delete_modal, false)}
end end
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 @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.user user = socket.assigns.user
@ -150,10 +216,16 @@ defmodule MvWeb.UserLive.Show do
cond do cond do
to_string(id) != to_string(user.id) -> 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) -> 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 -> true ->
handle_user_delete_destroy(socket, user, actor) handle_user_delete_destroy(socket, user, actor)
@ -170,10 +242,21 @@ defmodule MvWeb.UserLive.Show do
{:error, %Ash.Error.Forbidden{}} -> {:error, %Ash.Error.Forbidden{}} ->
{:noreply, {: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} -> {: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
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 end

View file

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

View file

@ -36,7 +36,12 @@ msgid "City"
msgstr "Stadt" msgstr "Stadt"
#: lib/mv_web/live/group_live/show.ex #: 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/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 #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "Löschen" 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/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.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/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/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.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/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cancel" msgid "Cancel"
msgstr "Abbrechen" msgstr "Abbrechen"
@ -294,7 +302,6 @@ msgid "Logout"
msgstr "Abmelden" msgstr "Abmelden"
#: lib/mv_web/live/user_live/index.ex #: lib/mv_web/live/user_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Listing Users" msgid "Listing Users"
msgstr "Benutzer*innen auflisten" msgstr "Benutzer*innen auflisten"
@ -381,16 +388,6 @@ msgstr "Benutzer*in speichern"
msgid "Show User" msgid "Show User"
msgstr "Benutzer*in anzeigen" 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/form.ex
#: lib/mv_web/live/user_live/show.ex #: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -533,6 +530,7 @@ msgstr "Suchen..."
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Users" msgid "Users"
msgstr "Benutzer*innen" msgstr "Benutzer*innen"
@ -593,18 +591,6 @@ msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft
msgid "Custom Fields" msgid "Custom Fields"
msgstr "Benutzerdefinierte Felder" 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 #: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Enter the text above to confirm" msgid "Enter the text above to confirm"
@ -790,6 +776,7 @@ msgstr "Beitragsdaten"
msgid "Payments" msgid "Payments"
msgstr "Zahlungen" msgstr "Zahlungen"
#: lib/mv_web/live/datafields_live.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -1388,6 +1375,8 @@ msgid "None (no default)"
msgstr "Keine (kein Standard)" msgstr "Keine (kein Standard)"
#: lib/mv_web/live/member_live/show.ex #: 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 #, elixir-autogen, elixir-format
msgid "Not set" msgid "Not set"
msgstr "Nicht gesetzt" msgstr "Nicht gesetzt"
@ -1473,11 +1462,6 @@ msgstr "Art"
msgid "Type '%{confirmation}' to confirm" msgid "Type '%{confirmation}' to confirm"
msgstr "Gib '%{confirmation}' ein, um zu bestätigen" 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 #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Warning" msgid "Warning"
@ -1704,11 +1688,6 @@ msgstr "System-Rollen können nicht gelöscht werden."
msgid "Toggle sidebar" msgid "Toggle sidebar"
msgstr "Sidebar umschalten" 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 #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "User menu" msgid "User menu"
@ -2428,11 +2407,6 @@ msgstr "Alle Jahre zusammengefasst (Kreis)"
msgid "Contributions by year" msgid "Contributions by year"
msgstr "Beiträge nach Jahr" 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 #: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Contributions by year as table with stacked bars" msgid "Contributions by year as table with stacked bars"
@ -2910,11 +2884,6 @@ msgstr "CSV Datei auswählen"
msgid "Import Members" msgid "Import Members"
msgstr "Mitglieder importieren (CSV)" 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 #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Admin group name" msgid "Admin group name"
@ -2940,21 +2909,6 @@ msgstr "Client-ID"
msgid "Client Secret" msgid "Client Secret"
msgstr "Client-Geheimnis" 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/components/layouts/sidebar.ex
#: lib/mv_web/live/datafields_live.ex #: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -2996,11 +2950,6 @@ msgstr "Aus OIDC_REDIRECT_URI"
msgid "Groups claim" msgid "Groups claim"
msgstr "Gruppenclaim" 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 #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee settings" msgid "Membership fee settings"
@ -3225,88 +3174,64 @@ msgstr "Das Löschen dieses Datenfeldes kann nicht rückgängig gemacht werden.
msgid "Individual datafields" msgid "Individual datafields"
msgstr "Individuelle Datenfelder" msgstr "Individuelle Datenfelder"
#~ #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex
#~ #, elixir-autogen, elixir-format #: lib/mv_web/live/member_live/show.ex
#~ msgid "Back to Settings" #, elixir-autogen, elixir-format, fuzzy
#~ msgstr "Zurück zu den Einstellungen" msgid "Delete Member"
msgstr "Mitglied löschen"
#~ #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Cannot delete system role" msgid "Delete Role"
#~ msgstr "System-Rolle kann nicht gelöscht werden" msgstr "Rolle löschen"
#~ #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/user_live/form.ex
#~ #, elixir-autogen, elixir-format #: lib/mv_web/live/user_live/show.ex
#~ msgid "Click for custom field details" #, elixir-autogen, elixir-format, fuzzy
#~ msgstr "Klicke für Datenfeld-Details" msgid "Delete User"
msgstr "Benutzer*in löschen"
#~ #: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Click for datafield details" msgid "Configure fee types for membership fees."
#~ msgstr "Klicke für Datenfeld-Details" msgstr "Verwalte Beitragsarten und Mitgliedsbeiträge."
#~ #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/datafields_live.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Coming soon" msgid "Configure which data you want to save for your members. Define individual datafields."
#~ msgstr "Demnächst verfügbar" 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 #: lib/mv_web/live/user_live/index.html.heex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Custom Field %{id}" msgid "Manage users and their permissions."
#~ msgstr "Benutzerdefiniertes Feld %{id}" msgstr "Verwalte Benutzer*innen und ihre Berechtigungen."
#~ #: lib/mv_web/live/custom_field_live/index_component.ex #: 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
#~ #, elixir-autogen, elixir-format, fuzzy msgid "%{count} member has a value assigned for this datafield."
#~ msgid "Edit datafield" msgid_plural "%{count} members have values assigned for this datafield."
#~ msgstr "Datenfeld bearbeiten" 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 #: lib/mv_web/live/datafields_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Edit user" msgid "Individual Datafields"
#~ msgstr "Benutzer*in bearbeiten" msgstr "Individuelle Datenfelder"
#~ #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Reset" msgid "No group assignment"
#~ msgstr "Zurücksetzen" msgstr "Keine Gruppenzuordnung"
#~ #: lib/mv_web/live/role_live/show.ex #: lib/mv_web/components/core_components.ex
#~ #, elixir-autogen, elixir-format #: lib/mv_web/live/group_live/index.ex
#~ msgid "Rolle bearbeiten" #: lib/mv_web/live/member_live/index.html.heex
#~ msgstr "Rolle bearbeiten" #: 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 #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Save Role" msgid "All datafield values will be permanently deleted when you delete this datafield."
#~ msgstr "Rolle speichern" msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
#~ #: 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"

View file

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

View file

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

View file

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

View file

@ -46,15 +46,19 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
%{conn: conn, user: user_with_role} %{conn: conn, user: user_with_role}
end 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 defp open_delete_modal(view, custom_field) do
view row_selector = "tr#custom_fields-#{custom_field.id} td"
|> element("tr#custom_fields-#{custom_field.id} td", custom_field.name) view |> element(row_selector, custom_field.name) |> render_click()
|> render_click()
view if has_element?(view, "[data-testid=custom-field-delete]") do
|> element("[data-testid=custom-field-delete]") view |> element("[data-testid=custom-field-delete]") |> render_click()
|> render_click() else
view |> element(row_selector, custom_field.name) |> render_click()
view |> element("[data-testid=custom-field-delete]") |> render_click()
end
end end
describe "delete button and modal" do describe "delete button and modal" do
@ -71,8 +75,12 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
# Modal should be visible # Modal should be visible
assert has_element?(view, "#delete-custom-field-modal") 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) # 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 # Should show the slug
assert render(view) =~ custom_field.slug assert render(view) =~ custom_field.slug
@ -91,7 +99,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
open_delete_modal(view, custom_field) open_delete_modal(view, custom_field)
# Should show plural form # 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 end
test "shows 0 members for custom field without values", %{conn: conn} do 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) open_delete_modal(view, custom_field)
# Should show 0 members # 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
end end

View file

@ -83,6 +83,21 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do
end end
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 describe "required fields" do
setup do setup do
{:ok, settings} = Membership.get_settings() {:ok, settings} = Membership.get_settings()

View file

@ -386,11 +386,16 @@ defmodule MvWeb.RoleLiveTest do
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}") {:ok, view, _html} = live(conn, "/admin/roles/#{role.id}")
# Delete from Danger zone on show page # Open delete modal from Danger zone
view view
|> element("[data-testid=role-delete]") |> element("[data-testid=role-delete]")
|> render_click() |> render_click()
# Confirm deletion in modal
view
|> element("[data-testid=role-delete-confirm]")
|> render_click()
assert_redirect(view, "/admin/roles") assert_redirect(view, "/admin/roles")
# Verify deletion by checking database # 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 test "page shows overview of all relevant years without year selector", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/statistics") {:ok, _view, html} = live(conn, ~p"/statistics")
# No year dropdown: single select for year should not be present as main control # Page shows multi-year data (member numbers by year) and year column; no single-year selector as main control
assert html =~ "Overview" or html =~ "overview" assert html =~ "Member numbers by year"
# table header or legend
assert html =~ "Year" assert html =~ "Year"
end end

View file

@ -95,6 +95,20 @@ defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do
assert html =~ member3.first_name assert html =~ member3.first_name
end 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 test "displays group name correctly in badge", %{conn: conn, group1: group1} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members") {:ok, _view, html} = live(conn, "/members")

View file

@ -123,13 +123,17 @@ defmodule MvWeb.UserLive.IndexTest do
{:ok, index_view, _html} = live(conn, "/users") {:ok, index_view, _html} = live(conn, "/users")
assert render(index_view) =~ "delete-me@example.com" 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}") {:ok, show_view, _html} = live(conn, "/users/#{user.id}")
show_view show_view
|> element("[data-testid=user-delete]") |> element("[data-testid=user-delete]")
|> render_click() |> render_click()
show_view
|> element("#delete-user-modal button", "Delete")
|> render_click()
# Should redirect to index # Should redirect to index
assert_redirect(show_view, "/users") assert_redirect(show_view, "/users")
@ -206,7 +210,9 @@ defmodule MvWeb.UserLive.IndexTest do
end end
describe "Password column display" do 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 created with hashed_password: nil (no password) - must not get default password
user_no_pw = user_no_pw =
create_test_user(%{ create_test_user(%{
@ -219,9 +225,13 @@ defmodule MvWeb.UserLive.IndexTest do
assert html =~ "no-password@example.com" 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() 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", refute row =~ "Enabled",
"Password column must not show Enabled when user has no password" "Password column must not show Enabled when user has no password"