diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index d4769f3..b3f1c3f 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -2775,6 +2775,14 @@ Building accessible applications ensures that all users, including those with di
Click me
``` +**Tables (Core Component `<.table>` with `row_click`):** + +- When `row_click` is set, the first column that does not use `col_click` gets `tabindex="0"` and `role="button"` so each row is reachable via Tab. The `TableRowKeydown` hook triggers the row action on Enter and Space (WCAG 2.1.1). Use `row_id` and `row_tooltip` for all clickable tables (e.g. Groups, Users, Roles, Members, Custom Fields, Member Fields) so the table is fully keyboard accessible. + +**Empty table cells (missing values):** + +- Do not use dashes ("-", "—", "–") or "n/a" as placeholders. Use CoreComponents `<.empty_cell sr_text="…">` for a cell with no value, or `<.maybe_value value={…} empty_sr_text="…">` when content is conditional. The cell is visually empty; screen readers get the `sr_text` (e.g. "No cycle", "No group assignment", "Not specified"). See Design Guidelines §8.6. + **Tab Order:** - Ensure logical tab order matches visual order @@ -2784,7 +2792,11 @@ Building accessible applications ensures that all users, including those with di ### 8.4 Color and Contrast -**Ensure Sufficient Contrast:** +**Ensure Sufficient Contrast (WCAG 2.2 AA: 4.5:1 for normal text):** + +- Use the Core Component `<.badge>` for all badges; theme and `app.css` overrides ensure badge text meets 4.5:1 in light and dark theme (solid, soft, and outline styles). Cycle status "suspended" uses variant `:warning` (yellow) to match the edit cycle-status button. +- For other UI, prefer theme tokens (`text-*-content` on `bg-*`) or the `.text-success-aa` / `.text-error-aa` utility classes where theme contrast is insufficient. +- Member filter join buttons (All / Paid / Unpaid, etc.) use `.member-filter-dropdown`; `app.css` overrides ensure WCAG 4.5:1 for inactive and active states. ```elixir # Tailwind classes with sufficient contrast (4.5:1 minimum) @@ -3003,24 +3015,56 @@ end - [ ] Skip links are available - [ ] Tables have proper structure (th, scope, caption) - [ ] ARIA labels used for icon-only buttons +- [ ] Modals/dialogs: focus moves into modal, aria-labelledby, keyboard dismiss (Escape) +- [ ] ARIA state attributes use string values `"true"` / `"false"` (not boolean), e.g. `aria-selected`, `aria-pressed`, `aria-expanded`. +- [ ] Tabs: when using `role="tablist"` / `role="tab"`, use roving tabindex (only active tab `tabindex="0"`) and ArrowLeft/ArrowRight to switch tabs. -### 8.11 DaisyUI Accessibility +### 8.11 Modals and Dialogs -DaisyUI components are designed with accessibility in mind, but ensure: +Use a consistent, keyboard-accessible pattern for all confirmation and form modals (e.g. delete role, delete group, delete data field, edit cycle). Do not rely on `data-confirm` (browser `confirm()`) for destructive actions; use a LiveView-controlled `` so focus and semantics are correct (WCAG 2.4.3, 2.1.2). + +**Structure and semantics:** + +- Use `` with DaisyUI classes `modal modal-open` when the modal is visible. +- Add `role="dialog"` and `aria-labelledby` pointing to the modal title’s `id` so screen readers announce the dialog and its purpose. +- Give the title (e.g. `

`) a unique `id` (e.g. `id="delete-role-modal-title"`). + +**Focus management (WCAG 2.4.3):** + +- When the modal opens, move focus into the dialog. Use `phx-mounted={JS.focus()}` on the first focusable element: + - If the modal has an input (e.g. confirmation text), put `phx-mounted={JS.focus()}` on that input (e.g. delete data field, delete group). + - If the modal has only buttons (e.g. confirm/cancel), put `phx-mounted={JS.focus()}` on the Cancel (or first) button so the user can Tab to the primary action and confirm with the keyboard. +- This ensures that after choosing "Delete role" (or similar) with the keyboard, focus is inside the modal and the user can confirm or cancel without using the mouse. + +**Layout and consistency:** + +- Use `modal-box` for the content container and `modal-action` for the button row (Cancel + primary action). +- Place Cancel (or neutral) first, primary/danger action second. +- For destructive actions that require typing a confirmation string, use the same pattern as the delete data field modal: label, value to type, single input, then modal-action buttons. + +**Closing:** + +- Cancel button closes the modal (e.g. `phx-click="cancel_delete_modal"`). +- **MUST** support Escape to close (WCAG / WAI-ARIA dialog pattern): add `phx-keydown="dialog_keydown"` on the `` 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 - - + + diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md index fc3acac..92f7a90 100644 --- a/DESIGN_DUIDELINES.md +++ b/DESIGN_DUIDELINES.md @@ -293,6 +293,12 @@ Notes: - On **mobile**, sticky headers are not used; the layout uses normal flow (header and table scroll with the page) to preserve vertical space. - When the table is inside such a scroll container, use the CoreComponents table’s `sticky_header={true}` so the table’s `` stays sticky within the scroll area on desktop (`lg:sticky lg:top-0`, opaque background `bg-base-100`, z-index). Sticky areas must not overlap content at 200% zoom; focus order must remain header → filters → table. +### 8.6 Empty table cells (missing values) +- **MUST:** Missing values in tables are shown as **visually empty cells** (no dash, no "n/a"). +- **MUST NOT:** Use dashes ("-", "—", "–") or "n/a" as placeholders for empty cells. +- **MUST:** For accessibility, render a screen-reader-only label so the cell is not announced as "blank". Use the CoreComponents `<.empty_cell sr_text="…">` for a cell that has no value, or `<.maybe_value value={…} empty_sr_text="…">` when the cell content is conditional (value present vs. absent). +- **SHOULD:** Use context-specific `sr_text` where it helps (e.g. "No cycle", "No group assignment", "Not specified"). Default for "no value" is "Not specified". + --- ## 9) Flash / Toast messages (mandatory UX) @@ -331,14 +337,17 @@ No “silent success”. ### 10.2 Destructive actions: one standard confirmation pattern - **MUST:** All destructive actions use the same confirm style and wording conventions. -- Choose one approach and standardize: - - `JS.confirm("…")` everywhere (simple, consistent) - - or a modal component everywhere (more flexible, more work) +- **MUST:** Use the standard modal (see §10.3); do not use `data-confirm` / browser `confirm()` for destructive actions, so that focus and keyboard behaviour are consistent and accessible. **Recommended copy style:** - Title/confirm text is clear and specific (what will be deleted, consequences). - Buttons: `Cancel` (neutral) + `Delete` (danger). +### 10.3 Dialogs and modals (mandatory) +- **MUST:** For every dialog (confirmations, form overlays, delete role/group/data field, edit cycle, etc.) use the **same modal pattern**: `` with DaisyUI `modal modal-open`, `role="dialog"`, `aria-labelledby` on the title, and focus moved into the modal when it opens (first focusable element). +- **MUST NOT:** Use browser `confirm()` / `data-confirm` for destructive or important choices; use the LiveView-controlled modal so that keyboard users get focus inside the dialog and can confirm or cancel without the mouse. +- **Reference:** Full structure, focus management, and accessibility rules are in **`CODE_GUIDELINES.md` §8.11 (Modals and Dialogs)**. Follow that section for implementation (e.g. `phx-mounted={JS.focus()}` on the first focusable, consistent `modal-box` / `modal-action` layout). + --- ## 11) Detail pages (consistent structure) diff --git a/assets/css/app.css b/assets/css/app.css index bbe7424..6f00298 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -118,6 +118,138 @@ color: oklch(0.45 0.2 25); } +/* WCAG 2.2 AA: Badge contrast. DaisyUI .badge-outline uses transparent bg; we use + Core Component <.badge style="outline"> which adds .bg-base-100. This rule ensures + outline badges always have a visible background in both themes. */ +[data-theme="light"] .badge.badge-outline, +[data-theme="dark"] .badge.badge-outline { + background-color: var(--color-base-100); +} + +/* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background. + Theme tokens *-content are often too light on * backgrounds in light theme, and + badge-soft uses variant as text on a light tint (low contrast). We override + --badge-fg (and for soft, color) so badge text meets 4.5:1 in both themes. */ + +/* Light theme: use dark text on all colored badges (solid, soft, outline). */ +[data-theme="light"] .badge.badge-primary { + --badge-fg: oklch(0.25 0.08 47); +} +[data-theme="light"] .badge.badge-primary.badge-soft { + color: oklch(0.38 0.14 47); +} +[data-theme="light"] .badge.badge-success { + --badge-fg: oklch(0.26 0.06 165); +} +[data-theme="light"] .badge.badge-success.badge-soft { + color: oklch(0.35 0.10 165); +} +[data-theme="light"] .badge.badge-error { + --badge-fg: oklch(0.22 0.08 25); +} +[data-theme="light"] .badge.badge-error.badge-soft { + color: oklch(0.38 0.14 25); +} +[data-theme="light"] .badge.badge-warning { + --badge-fg: oklch(0.28 0.06 75); +} +[data-theme="light"] .badge.badge-warning.badge-soft { + color: oklch(0.42 0.12 75); +} +[data-theme="light"] .badge.badge-info { + --badge-fg: oklch(0.26 0.08 250); +} +[data-theme="light"] .badge.badge-info.badge-soft { + color: oklch(0.38 0.12 250); +} +[data-theme="light"] .badge.badge-neutral { + --badge-fg: oklch(0.22 0.01 285); +} +[data-theme="light"] .badge.badge-neutral.badge-soft { + color: oklch(0.32 0.02 285); +} +[data-theme="light"] .badge.badge-outline.badge-primary, +[data-theme="light"] .badge.badge-outline.badge-success, +[data-theme="light"] .badge.badge-outline.badge-error, +[data-theme="light"] .badge.badge-outline.badge-warning, +[data-theme="light"] .badge.badge-outline.badge-info, +[data-theme="light"] .badge.badge-outline.badge-neutral { + --badge-fg: oklch(0.25 0.02 285); +} + +/* Dark theme: ensure badge backgrounds are dark enough for light content (4.5:1). + Slightly darken solid variant backgrounds so theme *-content (light) passes. */ +[data-theme="dark"] .badge.badge-primary:not(.badge-soft):not(.badge-outline) { + --badge-bg: oklch(0.42 0.20 277); + --badge-fg: oklch(0.97 0.02 277); +} +[data-theme="dark"] .badge.badge-success:not(.badge-soft):not(.badge-outline) { + --badge-bg: oklch(0.42 0.10 185); + --badge-fg: oklch(0.97 0.01 185); +} +[data-theme="dark"] .badge.badge-error:not(.badge-soft):not(.badge-outline) { + --badge-bg: oklch(0.42 0.18 18); + --badge-fg: oklch(0.97 0.02 18); +} +[data-theme="dark"] .badge.badge-warning:not(.badge-soft):not(.badge-outline) { + --badge-bg: oklch(0.48 0.14 58); + --badge-fg: oklch(0.22 0.02 58); +} +[data-theme="dark"] .badge.badge-info:not(.badge-soft):not(.badge-outline) { + --badge-bg: oklch(0.45 0.14 242); + --badge-fg: oklch(0.97 0.02 242); +} +[data-theme="dark"] .badge.badge-neutral:not(.badge-soft):not(.badge-outline) { + --badge-bg: oklch(0.32 0.02 257); + --badge-fg: oklch(0.96 0.01 257); +} +[data-theme="dark"] .badge.badge-soft.badge-primary { color: oklch(0.85 0.12 277); } +[data-theme="dark"] .badge.badge-soft.badge-success { color: oklch(0.82 0.08 165); } +[data-theme="dark"] .badge.badge-soft.badge-error { color: oklch(0.82 0.14 25); } +[data-theme="dark"] .badge.badge-soft.badge-warning { color: oklch(0.88 0.10 75); } +[data-theme="dark"] .badge.badge-soft.badge-info { color: oklch(0.85 0.10 250); } +[data-theme="dark"] .badge.badge-soft.badge-neutral { color: oklch(0.90 0.01 257); } +[data-theme="dark"] .badge.badge-outline.badge-primary, +[data-theme="dark"] .badge.badge-outline.badge-success, +[data-theme="dark"] .badge.badge-outline.badge-error, +[data-theme="dark"] .badge.badge-outline.badge-warning, +[data-theme="dark"] .badge.badge-outline.badge-info, +[data-theme="dark"] .badge.badge-outline.badge-neutral { + --badge-fg: oklch(0.92 0.02 257); +} + +/* WCAG 2.2 AA: Member filter join buttons (All / Paid / Unpaid, group, boolean). + Inactive state uses base-content on a light/dark surface; active state ensures + *-content on * background meets 4.5:1. */ +.member-filter-dropdown .join .btn { + /* Inactive: ensure readable text (theme base-content may be low contrast on btn default) */ + border-color: var(--color-base-300); +} +[data-theme="light"] .member-filter-dropdown .join .btn:not(.btn-active) { + color: oklch(0.25 0.02 285); + background-color: var(--color-base-100); +} +[data-theme="light"] .member-filter-dropdown .join .btn.btn-success.btn-active { + background-color: oklch(0.42 0.12 165); + color: oklch(0.98 0.01 165); +} +[data-theme="light"] .member-filter-dropdown .join .btn.btn-error.btn-active { + background-color: oklch(0.42 0.18 18); + color: oklch(0.98 0.02 18); +} +[data-theme="dark"] .member-filter-dropdown .join .btn:not(.btn-active) { + color: oklch(0.92 0.02 257); + background-color: var(--color-base-200); +} +[data-theme="dark"] .member-filter-dropdown .join .btn.btn-success.btn-active { + background-color: oklch(0.42 0.10 165); + color: oklch(0.97 0.01 165); +} +[data-theme="dark"] .member-filter-dropdown .join .btn.btn-error.btn-active { + background-color: oklch(0.42 0.18 18); + color: oklch(0.97 0.02 18); +} + /* ============================================ Sidebar Base Styles ============================================ */ @@ -389,4 +521,31 @@ display: none !important; } +/* ============================================ + WCAG 1.4.3: Primary button contrast (AA) + ============================================ */ + +/* Override DaisyUI theme --color-primary-content so text on btn-primary (brand) + meets 4.5:1. In DevTools: inspect .btn-primary, check computed --color-primary + and --color-primary-content; verify contrast at https://webaim.org/resources/contrastchecker/ */ + +/* Light theme: primary is orange (brand); primary-content must be dark. */ +[data-theme="light"] { + --color-primary-content: oklch(0.18 0.02 47); + --color-error: oklch(55% 0.253 17.585); + --color-error-content: oklch(98% 0 0); +} + +/* Dark theme: primary is purple; ensure content is light and meets 4.5:1. */ +[data-theme="dark"] { + --color-error: oklch(55% 0.253 17.585); + --color-error-content: oklch(98% 0 0); + + --color-primary: oklch(72% 0.17 45); + --color-primary-content: oklch(0.18 0.02 47); + + --color-secondary: oklch(48% 0.233 277.117); + --color-secondary-content: oklch(98% 0 0); +} + /* This file is for your main application CSS */ diff --git a/assets/js/app.js b/assets/js/app.js index de3f154..b7d1a45 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -73,6 +73,53 @@ Hooks.ComboBox = { } } +// TableRowKeydown hook: WCAG 2.1.1 — when a table row cell has data-row-clickable, +// Enter and Space trigger a click so row_click tables are keyboard activatable +Hooks.TableRowKeydown = { + mounted() { + this.handleKeydown = (e) => { + if ( + e.target.getAttribute("data-row-clickable") === "true" && + (e.key === "Enter" || e.key === " ") + ) { + e.preventDefault() + e.target.click() + } + } + this.el.addEventListener("keydown", this.handleKeydown) + }, + + destroyed() { + this.el.removeEventListener("keydown", this.handleKeydown) + } +} + +// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button) +Hooks.FocusRestore = { + mounted() { + this.handleEvent("focus_restore", ({id}) => { + const el = document.getElementById(id) + if (el) el.focus() + }) + } +} + +// TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex) +Hooks.TabListKeydown = { + mounted() { + this.handleKeydown = (e) => { + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.preventDefault() + } + } + this.el.addEventListener('keydown', this.handleKeydown) + }, + + destroyed() { + this.el.removeEventListener('keydown', this.handleKeydown) + } +} + // SidebarState hook: Manages sidebar expanded/collapsed state Hooks.SidebarState = { mounted() { diff --git a/docs/badge-wcag-phase1-analysis.md b/docs/badge-wcag-phase1-analysis.md new file mode 100644 index 0000000..5b6a834 --- /dev/null +++ b/docs/badge-wcag-phase1-analysis.md @@ -0,0 +1,88 @@ +# Phase 1 — Badge WCAG Analysis & Migration + +## 1) Repo-Analyse (Stand vor Änderungen) + +### Badge-Verwendungen (alle Fundstellen) + +| Datei | Kontext | Markup | +|-------|---------|--------| +| `lib/mv_web/live/member_field_live/index_component.ex` | Tabelle (show_in_overview) | `` / `` | +| `lib/mv_web/live/components/member_filter_component.ex` | Filter-Chips (Anzahl) | `` (2×) | +| `lib/mv_web/live/role_live/index.html.heex` | Tabelle (System Role, Permission Set, Custom) | `badge-warning`, `permission_set_badge_class()`, `badge-ghost` (User Count) | +| `lib/mv_web/helpers/membership_fee_helpers.ex` | Helper | `status_color/1` → "badge-success" \| "badge-error" \| "badge-ghost" | +| `lib/mv_web/live/member_live/show.ex` | Mitgliedsdetail (Beiträge) | ``, `badge-ghost` (No cycles) | +| `lib/mv_web/live/membership_fee_settings_live.ex` | Settings (Fee Types) | `badge-outline`, `badge-ghost` (member count) | +| `lib/mv_web/live/membership_fee_type_live/index.ex` | Index (Fee Types) | `badge-outline`, `badge-ghost` (member count) | +| `lib/mv_web/live/role_live/index.ex` | (Helper-Import) | `permission_set_badge_class/1` | +| `lib/mv_web/live/member_live/show/membership_fees_component.ex` | Mitgliedsbeiträge | `badge-outline`, `["badge", status_color]` | +| `lib/mv_web/live/custom_field_live/index_component.ex` | Tabelle (show_in_overview) | `badge-success`, `badge-ghost` | +| `lib/mv_web/member_live/index/membership_fee_status.ex` | Helper | `format_cycle_status_badge/1` → map mit `color`, `icon`, `label` | +| `lib/mv_web/live/global_settings_live.ex` | Form (label-text-alt) | `badge badge-ghost` "(set)" (2×) | +| `lib/mv_web/live/member_live/index.html.heex` | Tabelle (Status) | `format_cycle_status_badge` + ``, `badge-ghost` (No cycle), `badge-outline badge-primary` (Filter-Chip) | +| `lib/mv_web/live/role_live/helpers.ex` | Helper | `permission_set_badge_class/1` → "badge badge-* badge-sm" | +| `lib/mv_web/live/group_live/show.ex` | Card | `badge badge-outline badge` | +| `lib/mv_web/live/role_live/show.ex` | Detail | `permission_set_badge_class`, `badge-warning` (System), `badge-ghost` (No) | + +### DaisyUI/Tailwind Config + +- **Tailwind:** `assets/tailwind.config.js` — erweitert nur `theme.extend.colors.brand`; kein DaisyUI hier. +- **DaisyUI:** wird in `assets/css/app.css` per `@plugin "../vendor/daisyui"` mit `themes: false` geladen. +- **Themes:** Zwei Custom-Themes in `app.css`: + - `@plugin "../vendor/daisyui-theme"` mit `name: "dark"` (default: false) + - `@plugin "../vendor/daisyui-theme"` mit `name: "light"` (default: true) +- **Theme-Umschaltung:** `lib/mv_web/components/layouts/root.html.heex` — Inline-Script setzt `document.documentElement.setAttribute("data-theme", "light"|"dark")` aus `localStorage["phx:theme"]` oder `prefers-color-scheme`. Sidebar enthält Theme-Toggle (`<.theme_toggle />`). + +### Core Components + +- **Modul:** `lib/mv_web/components/core_components.ex` (MvWeb.CoreComponents). +- **Vorhanden:** flash, button, dropdown_menu, form_section, input, header, table, icon, link, etc. +- **Badge:** Bisher keine zentrale `<.badge>`-Komponente. + +### DaisyUI Badge (Vendor) + +- **Default:** `--badge-bg: var(--badge-color, var(--color-base-100))`, `--badge-fg: var(--color-base-content)`. +- **badge-outline:** `--badge-bg: "#0000"` (transparent) → Kontrastproblem auf base-200/base-300. +- **badge-ghost:** `background-color: var(--color-base-200)`, `color: var(--color-base-content)` → auf base-200-Flächen kaum sichtbar. +- **badge-soft:** color-mix 8% Variante mit base-100 → sichtbar; Text ist Variantenfarbe (Kontrast prüfen). + +--- + +## 2) Core Component <.badge> API (geplant) + +- **attr :variant** — `:neutral | :primary | :info | :success | :warning | :error` +- **attr :style** — `:soft | :solid | :outline` (Default: `:soft`) +- **attr :size** — `:sm | :md` (Default: `:md`) +- **slot :inner_block** — Badge-Text +- **attr :sr_label** — optional, für Icon-only (Screen Reader) +- **slot :icon** — optional + +Regeln: + +- `:soft` und `:solid` nutzen sichtbaren Hintergrund (kein transparenter Ghost als Default). +- `:outline` setzt immer einen Hintergrund (z. B. `bg-base-100`), damit der Rand auf grauen Flächen sichtbar bleibt. +- Ghost nur als explizites Opt-in; dann mit `bg-base-100` für Sichtbarkeit. + +--- + +## 3) Theme-Overrides (WCAG) + +- In `app.css` sind bereits Custom-Themes für `light` und `dark` mit eigenen Tokens. +- **Badge-Kontrast (WCAG 2.2 AA 4.5:1):** Zusätzliche Overrides in `app.css`: + - **Light theme:** Dunkle `--badge-fg` für alle Varianten (primary, success, error, warning, info, neutral); für `badge-soft` dunklere Textfarbe (`color`) auf getöntem Hintergrund; für `badge-outline` einheitlich dunkle Schrift auf base-100. + - **Dark theme:** Leicht abgedunkelte Badge-Hintergründe für Solid-Badges, damit die hellen *-content-Farben 4.5:1 erreichen; für `badge-soft` hellere, gut lesbare Variantentöne; für `badge-outline` heller Text (`--badge-fg`) auf base-100. + +--- + +## 4) Migration (erledigt) + +- Alle `` durch `<.badge variant="..." style="...">...` ersetzt. +- Klickbare Chips (z. B. Group Show „Remove“) bleiben als <.badge> mit Button im inner_block (Badge ist nur Container). +- **Neue Helper:** `MembershipFeeHelpers.status_variant/1` (→ :success | :error | :warning; suspended = :warning wie Edit-Button), `RoleLive.Helpers.permission_set_badge_variant/1` (→ :neutral | :info | :success | :error). +- **Angepasst:** `MembershipFeeStatus.format_cycle_status_badge/1` liefert zusätzlich `:variant` für <.badge>. +- **Migrierte Stellen:** member_field_live, member_filter_component, role_live (index + show), member_live (show, index, membership_fees_component), membership_fee_settings_live, membership_fee_type_live, custom_field_live, global_settings_live, group_live/show. + +## 5) Weitere Anpassungen (nach Phase 1) + +- **Filter Join-Buttons (WCAG):** In `app.css` Kontrast-Overrides für `.member-filter-dropdown .join .btn` (inaktiv: base-100/base-200 + dunkle/helle Schrift; aktiv: success/error mit 4.5:1). +- **Badge „Pausiert“ (suspended):** `status_variant(:suspended)` → `:warning` (gelb), damit Badge dieselbe Farbe wie der Edit-Button (btn-warning) hat. +- **Filter-Dropdown schließen:** `phx-click-away` vom inneren Panel auf den äußeren Wrapper (`member-filter-dropdown`) verschoben; Klick auf den Filter-Button schließt das Dropdown (konsistent mit Spalten/Ausblenden). diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 85c26c7..78b8bfb 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -31,6 +31,21 @@ defmodule MvWeb.CoreComponents do alias Phoenix.LiveView.JS + # WCAG 2.4.7 / 2.4.11: Shared focus ring for buttons and dropdown (trigger + items) + @button_focus_classes [ + "focus-visible:outline-none", + "focus-visible:ring-2", + "focus-visible:ring-offset-2", + "focus-visible:ring-offset-base-100", + "focus-visible:ring-base-content/60" + ] + + @doc """ + Returns the shared focus ring class list for buttons and dropdown items (WCAG 2.4.7). + Use when building custom dropdown item buttons so they match <.button> and dropdown trigger. + """ + def button_focus_classes, do: @button_focus_classes + @doc """ Renders flash notices. @@ -147,13 +162,16 @@ defmodule MvWeb.CoreComponents do size_class = size_classes[size] btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ") - assigns = assign(assigns, :btn_class, btn_class) + assigns = + assigns + |> assign(:btn_class, btn_class) + |> assign(:button_focus_classes, @button_focus_classes) if rest[:href] || rest[:navigate] || rest[:patch] do link_class = if assigns[:disabled], - do: ["btn", btn_class, "btn-disabled"], - else: ["btn", btn_class] + do: ["btn", btn_class, "btn-disabled"] ++ @button_focus_classes, + else: ["btn", btn_class] ++ @button_focus_classes link_attrs = if assigns[:disabled] do @@ -176,13 +194,187 @@ defmodule MvWeb.CoreComponents do """ else ~H""" - """ end end + @doc """ + Renders a non-interactive badge with WCAG-compliant contrast. + + Use for status labels, counts, or tags. For clickable elements (e.g. filter chips), + use a button or link component instead, not this badge. + + ## Variants and styles + + - **variant:** `:neutral`, `:primary`, `:info`, `:success`, `:warning`, `:error` + - **style:** `:soft` (default, tinted background), `:solid`, `:outline` + - **size:** `:sm`, `:md` (default) + + Outline and soft styles always use a visible background so the badge remains + readable on base-200/base-300 surfaces (WCAG 2.2 AA). Ghost style is not exposed + by default to avoid low-contrast on gray backgrounds. + + ## Examples + + <.badge variant="success">Paid + <.badge variant="error" style="solid">Unpaid + <.badge variant="neutral" size="sm">Custom + <.badge variant="primary" style="outline">Label + <.badge variant="success" sr_label="Paid"> + <.icon name="hero-check-circle" class="size-4" /> + + """ + attr :variant, :any, + default: "neutral", + doc: "Color variant: neutral | primary | info | success | warning | error (string or atom)" + + attr :style, :any, + default: "soft", + doc: "Visual style: soft | solid | outline; :outline gets bg-base-100 for contrast" + + attr :size, :any, + default: "md", + doc: "Badge size: sm | md" + + attr :sr_label, :string, + default: nil, + doc: "Optional screen-reader label for icon-only content" + + attr :rest, :global, doc: "Arbitrary HTML attributes (e.g. id, class, data-testid)" + + slot :inner_block, required: true, doc: "Badge text (and optional icon)" + slot :icon, doc: "Optional leading icon slot" + + def badge(assigns) do + # Normalize so both HEEx strings (variant="neutral") and helper atoms (variant={:neutral}) work + variant = to_string(assigns.variant || "neutral") + style = to_string(assigns.style || "soft") + size = to_string(assigns.size || "md") + + variant_class = "badge-#{variant}" + style_class = badge_style_class(style) + size_class = "badge-#{size}" + # Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300 + outline_bg = if style == "outline", do: "bg-base-100", else: nil + + rest = assigns.rest || [] + rest = if is_list(rest), do: rest, else: Map.to_list(rest) + extra_class = Keyword.get(rest, :class) + rest = Keyword.drop(rest, [:class]) + rest = if assigns.sr_label, do: Keyword.put(rest, :"aria-label", assigns.sr_label), else: rest + + class = + ["badge", variant_class, style_class, size_class, outline_bg, extra_class] + |> List.flatten() + |> Enum.reject(&is_nil/1) + |> Enum.join(" ") + + assigns = + assigns + |> assign(:class, class) + |> assign(:rest, rest) + |> assign(:has_icon, assigns.icon != []) + + ~H""" + + <%= if @has_icon do %> + {render_slot(@icon)} + <% end %> + {render_slot(@inner_block)} + <%= if @sr_label do %> + {@sr_label} + <% end %> + + """ + end + + defp badge_style_class("soft"), do: "badge-soft" + defp badge_style_class("solid"), do: nil + defp badge_style_class("outline"), do: "badge-outline" + defp badge_style_class(_), do: nil + + @doc """ + Renders a 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""" + {@sr_text} + """ + 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 value={member.groups} empty_sr_text={gettext("No group assignment")}> + <%= for g <- member.groups do %> + <.badge variant="primary" style="outline">{g.name} + <% end %> + + """ + attr :value, :any, doc: "Value to check; if blank, empty_cell is rendered" + + attr :empty_sr_text, :string, + default: nil, + doc: "Screen-reader text when value is blank (default: gettext \"Not specified\")" + + slot :inner_block, required: true + + def maybe_value(assigns) do + empty_sr = assigns.empty_sr_text || gettext("Not specified") + assigns = assign(assigns, :empty_sr_text, empty_sr) + assigns = assign(assigns, :blank?, value_blank?(assigns.value)) + + ~H""" + <%= if @blank? do %> + <.empty_cell sr_text={@empty_sr_text} /> + <% else %> + {render_slot(@inner_block)} + <% end %> + """ + end + + defp value_blank?(nil), do: true + defp value_blank?(false), do: true + defp value_blank?([]), do: true + defp value_blank?(%Ash.NotLoaded{}), do: true + defp value_blank?(v) when is_binary(v), do: String.trim(v) == "" + defp value_blank?(_), do: false + @doc """ Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content, or status badges that need explanation (Design Guidelines §8.2). @@ -265,7 +457,11 @@ defmodule MvWeb.CoreComponents do def dropdown_menu(assigns) do menu_testid = assigns.menu_testid || "#{assigns.testid}-menu" - assigns = assign(assigns, :menu_testid, menu_testid) + + assigns = + assigns + |> assign(:menu_testid, menu_testid) + |> assign(:button_focus_classes, @button_focus_classes) ~H"""
!c[:col_click] end) + end + + assigns = + assign(assigns, :first_row_click_col_idx, first_row_click_col_idx) + ~H""" -
+
@@ -789,6 +999,11 @@ defmodule MvWeb.CoreComponents do >
Kernel.++(["focus-visible:ring-inset"]) + |> Enum.join(" ") + + "flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left #{focus}" + end + @impl true def mount(socket) do {:ok, assign(socket, :open, false)} @@ -59,7 +69,7 @@ defmodule MvWeb.Components.ExportDropdown do