diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index b3f1c3f..d4769f3 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -2775,14 +2775,6 @@ 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 @@ -2792,11 +2784,7 @@ Building accessible applications ensures that all users, including those with di ### 8.4 Color and 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. +**Ensure Sufficient Contrast:** ```elixir # Tailwind classes with sufficient contrast (4.5:1 minimum) @@ -3015,56 +3003,24 @@ 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 Modals and Dialogs +### 8.11 DaisyUI Accessibility -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: +DaisyUI components are designed with accessibility in mind, but ensure: ```heex - - + + diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md index 92f7a90..fc3acac 100644 --- a/DESIGN_DUIDELINES.md +++ b/DESIGN_DUIDELINES.md @@ -293,12 +293,6 @@ 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) @@ -337,17 +331,14 @@ No “silent success”. ### 10.2 Destructive actions: one standard confirmation pattern - **MUST:** All destructive actions use the same confirm style and wording conventions. -- **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. +- Choose one approach and standardize: + - `JS.confirm("…")` everywhere (simple, consistent) + - or a modal component everywhere (more flexible, more work) **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/README.md b/README.md index b35d742..c0072aa 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ Most membership tools for clubs are either: Our philosophy: **software should help people spend less time on administration and more time on their community.** -## 📸 Screenshots +## User Documentation (German) + +You can find our documentation for users here: https://wiki.local-it.org/s/mila-user-dokumentation -![Screenshot placeholder](docs/images/screenshot.png) -*This is how Mila might look in action.* ## 🔑 Features diff --git a/assets/css/app.css b/assets/css/app.css index 6f00298..bbe7424 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -118,138 +118,6 @@ 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 ============================================ */ @@ -521,31 +389,4 @@ 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 b7d1a45..de3f154 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -73,53 +73,6 @@ 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 deleted file mode 100644 index 5b6a834..0000000 --- a/docs/badge-wcag-phase1-analysis.md +++ /dev/null @@ -1,88 +0,0 @@ -# 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/config.ex b/lib/mv/config.ex index 8b8c088..ec69b18 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -360,29 +360,13 @@ defmodule Mv.Config do end @doc """ - Returns the OIDC client secret. - In production, uses the value from config :mv, :oidc (set by runtime.exs from OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE). - Otherwise ENV OIDC_CLIENT_SECRET, then Settings. + Returns the OIDC client secret. ENV first, then Settings. """ @spec oidc_client_secret() :: String.t() | nil def oidc_client_secret do - case Application.get_env(:mv, :oidc) do - oidc when is_list(oidc) -> oidc_client_secret_from_config(Keyword.get(oidc, :client_secret)) - _ -> env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) - end + env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) end - defp oidc_client_secret_from_config(nil), - do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) - - defp oidc_client_secret_from_config(secret) when is_binary(secret) do - s = String.trim(secret) - if s != "", do: s, else: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) - end - - defp oidc_client_secret_from_config(_), - do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) - @doc """ Returns the OIDC admin group name (for role sync). ENV first, then Settings. """ @@ -442,10 +426,7 @@ defmodule Mv.Config do def oidc_client_id_env_set?, do: env_set?("OIDC_CLIENT_ID") def oidc_base_url_env_set?, do: env_set?("OIDC_BASE_URL") def oidc_redirect_uri_env_set?, do: env_set?("OIDC_REDIRECT_URI") - - def oidc_client_secret_env_set?, - do: env_set?("OIDC_CLIENT_SECRET") or env_set?("OIDC_CLIENT_SECRET_FILE") - + def oidc_client_secret_env_set?, do: env_set?("OIDC_CLIENT_SECRET") def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME") def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM") def oidc_only_env_set?, do: env_set?("OIDC_ONLY") diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 78b8bfb..85c26c7 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -31,21 +31,6 @@ 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. @@ -162,16 +147,13 @@ defmodule MvWeb.CoreComponents do size_class = size_classes[size] btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ") - assigns = - assigns - |> assign(:btn_class, btn_class) - |> assign(:button_focus_classes, @button_focus_classes) + assigns = assign(assigns, :btn_class, btn_class) if rest[:href] || rest[:navigate] || rest[:patch] do link_class = if assigns[:disabled], - do: ["btn", btn_class, "btn-disabled"] ++ @button_focus_classes, - else: ["btn", btn_class] ++ @button_focus_classes + do: ["btn", btn_class, "btn-disabled"], + else: ["btn", btn_class] link_attrs = if assigns[:disabled] do @@ -194,187 +176,13 @@ 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). @@ -457,11 +265,7 @@ defmodule MvWeb.CoreComponents do def dropdown_menu(assigns) do menu_testid = assigns.menu_testid || "#{assigns.testid}-menu" - - assigns = - assigns - |> assign(:menu_testid, menu_testid) - |> assign(:button_focus_classes, @button_focus_classes) + assigns = assign(assigns, :menu_testid, menu_testid) ~H"""
!c[:col_click] end) - end - - assigns = - assign(assigns, :first_row_click_col_idx, first_row_click_col_idx) - ~H""" -
+
@@ -999,11 +789,6 @@ 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)} @@ -69,7 +59,7 @@ defmodule MvWeb.Components.ExportDropdown do