style: consistent save buttons and active tab

This commit is contained in:
carla 2026-03-12 15:59:53 +01:00
parent 82962a2f2a
commit ba08434604
15 changed files with 486 additions and 438 deletions

View file

@ -46,14 +46,14 @@ Every authenticated page should follow the same structure:
**MUST:** Use `<.header>` on every page (except login/public pages). **MUST:** Use `<.header>` on every page (except login/public pages).
**SHOULD:** Put short explanations into `<:subtitle>` rather than sprinkling random text blocks. **SHOULD:** Put short explanations into `<:subtitle>` rather than sprinkling random text blocks.
### 2.2 Edit/New form header: Back button left (mandatory) ### 2.2 Edit/New form header and footer buttons (mandatory)
For LiveViews that render an edit or new form (e.g. member, group, role, user, custom field, membership fee type): For LiveViews that render an edit or new form (e.g. member, group, role, user, custom field, membership fee type):
- **MUST:** Provide a **Back** button on the **left** side of the header using the `<:leading>` slot (same as data fields: Back left, title next, primary action on the right). - **MUST:** Provide a **Back** button on the **left** side of the header using the `<:leading>` slot (same as data fields: Back left, title next).
- **MUST:** Use the same pattern everywhere: Back button with `variant="neutral"`, arrow-left icon, and label “Back”. It navigates to the previous context (e.g. detail page or index) via a `return_path`-style helper. - **MUST:** Use the same pattern everywhere: Back button with `variant="neutral"`, arrow-left icon, and label “Back”. It navigates to the previous context (e.g. detail page or index) via a `return_path`-style helper.
- **SHOULD:** Place the primary action (e.g. “Save”) in `<:actions>` on the right. - **MUST:** Place **exactly one** form button bar **below all form fields**, inside the `<.form>`, with: **Abbrechen** (Cancel) left, **Speichern** (Save) right. Use `gettext("Cancel")`, `gettext("Save <ressourcename>")`, `phx-disable-with={gettext("Saving...")}` on the submit button. No submit button in the header; no duplicate submit buttons.
- **Rationale:** Users expect a consistent way to leave the form without submitting; Back left matches the data fields edit view and keeps primary actions on the right. - **Rationale:** Users expect a consistent way to leave the form without submitting; Back left. One primary action (Save) per form, in the footer, avoids double submits and matches the reference (member edit form).
**Template for form pages:** **Template for form pages:**
```heex ```heex
@ -66,15 +66,20 @@ For LiveViews that render an edit or new form (e.g. member, group, role, user, c
</:leading> </:leading>
Page title (e.g. “Edit Member” or “New User”) Page title (e.g. “Edit Member” or “New User”)
<:subtitle>Short explanation.</:subtitle> <:subtitle>Short explanation.</:subtitle>
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header> </.header>
```
If the `<.header>` is outside the `<.form>`, the submit button must reference the form via the `form` attribute (e.g. `form="user-form"`). <.form for={@form} id="..." phx-change="validate" phx-submit="save">
<%!-- form sections and fields --%>
<div class="mt-6 flex items-center justify-end gap-4">
<.button navigate={return_path(@return_to, @resource)} variant="neutral" type="button">
{gettext("Abbrechen")}
</.button>
<.button type="submit" phx-disable-with={gettext("Speichern...")} variant="primary">
{gettext("Speichern")}
</.button>
</div>
</.form>
```
## 3) Typography (system) ## 3) Typography (system)

View file

@ -577,7 +577,9 @@
} }
/* ============================================ /* ============================================
WCAG 2.2 AA: Tab list inactive tab text contrast (4.5:1) Member detail tabs (show + edit): inactive vs active contrast
WCAG 2.2 AA: inactive tab text contrast (4.5:1)
Active tab: visible border (DaisyUI tabs-bordered) and weight so which tab is selected is clear.
============================================ */ ============================================ */
#member-tablist .tab:not(.tab-active) { #member-tablist .tab:not(.tab-active) {
color: oklch(0.35 0.02 285); color: oklch(0.35 0.02 285);
@ -586,6 +588,13 @@
color: oklch(0.72 0.02 257); color: oklch(0.72 0.02 257);
} }
/* Active tab: stronger underline (DaisyUI --tab-border-color) and font weight */
#member-tablist .tab.tab-active,
#member-tablist .tab[aria-selected="true"] {
--tab-border-color: var(--color-base-content);
font-weight: 600;
}
/* ============================================ /* ============================================
WCAG 2.2 AA: Link contrast - primary and accent WCAG 2.2 AA: Link contrast - primary and accent
============================================ */ ============================================ */

View file

@ -70,6 +70,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
id="field-visibility-menu" id="field-visibility-menu"
icon="hero-adjustments-horizontal" icon="hero-adjustments-horizontal"
button_label={gettext("Show/Hide Columns")} button_label={gettext("Show/Hide Columns")}
button_class="btn-secondary"
items={@all_items} items={@all_items}
checkboxes={true} checkboxes={true}
selected={@selected_fields} selected={@selected_fields}

View file

@ -19,7 +19,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div id={@id} class="mb-8 border shadow-xl card border-base-300"> <div id={@id} class="mb-8 max-w-2xl border shadow-xl card border-base-300">
<div class="card-body"> <div class="card-body">
<div class="flex items-center gap-4 mb-4"> <div class="flex items-center gap-4 mb-4">
<.button <.button
@ -98,6 +98,20 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
label={gettext("Show in overview")} label={gettext("Show in overview")}
/> />
<%!-- Buttons: below all form fields, above Danger zone --%>
<div class="mt-6 flex items-center justify-end gap-4">
<.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")}
</.button>
<.button
type="submit"
phx-disable-with={gettext("Saving...")}
variant="primary"
>
{gettext("Save Data Field")}
</.button>
</div>
<%= if @custom_field do %> <%= if @custom_field do %>
<%!-- Danger zone: canonical pattern (same as member form) --%> <%!-- Danger zone: canonical pattern (same as member form) --%>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading"> <section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
@ -125,15 +139,6 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
</div> </div>
</section> </section>
<% end %> <% end %>
<div class="justify-end mt-4 card-actions">
<.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Data Field")}
</.button>
</div>
</.form> </.form>
</div> </div>
</div> </div>

View file

@ -77,7 +77,7 @@ defmodule MvWeb.GroupLive.Form do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.form for={@form} id="group-form" phx-change="validate" phx-submit="save"> <.form class="max-w-2xl" for={@form} id="group-form" phx-change="validate" phx-submit="save">
<.header> <.header>
<:leading> <:leading>
<.button navigate={return_path(@return_to, @group)} variant="neutral"> <.button navigate={return_path(@return_to, @group)} variant="neutral">
@ -86,11 +86,6 @@ defmodule MvWeb.GroupLive.Form do
</.button> </.button>
</:leading> </:leading>
{@page_title} {@page_title}
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header> </.header>
<div class="mt-6 space-y-6"> <div class="mt-6 space-y-6">
@ -104,6 +99,20 @@ defmodule MvWeb.GroupLive.Form do
/> />
</div> </div>
<%!-- Buttons: below all form fields, Abbrechen left (secondary), Speichern right (primary) --%>
<div class="mt-6 flex items-center justify-end gap-4">
<.button navigate={return_path(@return_to, @group)} variant="neutral" type="button">
{gettext("Cancel")}
</.button>
<.button
type="submit"
phx-disable-with={gettext("Saving...")}
variant="primary"
>
{gettext("Save group")}
</.button>
</div>
<%!-- Danger zone: canonical pattern (same as member form) --%> <%!-- Danger zone: canonical pattern (same as member form) --%>
<%= if @group && can?(@current_user, :destroy, @group) do %> <%= if @group && can?(@current_user, :destroy, @group) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading"> <section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">

View file

@ -118,259 +118,261 @@ defmodule MvWeb.GroupLive.Show do
<div <div
id="group-show-focus-root" id="group-show-focus-root"
class="mt-6 space-y-6" class="mt-6"
phx-hook="FocusRestore" phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil} phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
> >
<%!-- Group Information --%> <div class="max-w-2xl space-y-6">
<div class="max-w-2xl space-y-6 mb-6"> <%!-- Group Information --%>
<div> <div class="space-y-6 mb-6">
<h2 class="text-lg font-semibold mb-2">{gettext("Description")}</h2> <div>
<div class="border border-base-300 rounded-lg p-4 bg-base-100"> <h2 class="text-lg font-semibold mb-2">{gettext("Description")}</h2>
<%= if @group.description && String.trim(@group.description) != "" do %> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="whitespace-pre-wrap">{@group.description}</p> <%= if @group.description && String.trim(@group.description) != "" do %>
<% else %> <p class="whitespace-pre-wrap">{@group.description}</p>
<p class="text-base-content/50 italic">{gettext("No description")}</p> <% else %>
<% end %> <p class="text-base-content/50 italic">{gettext("No description")}</p>
<% end %>
</div>
</div> </div>
</div>
<div> <div>
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2> <h2 class="text-lg font-semibold mb-2">{gettext("Members")}</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="mb-4" data-testid="group-show-member-count"> <p class="mb-4" data-testid="group-show-member-count">
{ngettext( {ngettext(
"Total: %{count} member", "Total: %{count} member",
"Total: %{count} members", "Total: %{count} members",
@group.member_count || 0, @group.member_count || 0,
count: @group.member_count || 0 count: @group.member_count || 0
)} )}
</p>
<%= if can?(@current_user, :update, @group) do %>
<div class="mb-4">
<%= if assigns[:show_add_member_input] do %>
<div class="join w-full">
<form phx-change="search_members" class="flex-1">
<div class="relative">
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
<%= for member <- @selected_members do %>
<.badge
variant="primary"
style="outline"
class="flex items-center gap-1"
>
{MvWeb.Helpers.MemberHelpers.display_name(member)}
<.tooltip content={gettext("Remove")} position="top">
<.button
type="button"
variant="icon"
size="sm"
phx-click="remove_selected_member"
phx-value-member_id={member.id}
aria-label={
gettext("Remove %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(member)
)
}
class="p-0 h-4 w-4 min-h-0"
>
<.icon name="hero-x-mark" class="size-3" />
</.button>
</.tooltip>
</.badge>
<% end %>
<input
type="text"
id="member-search-input"
data-testid="group-show-member-search-input"
role="combobox"
phx-hook="ComboBox"
phx-focus="show_member_dropdown"
phx-debounce="300"
phx-keydown="member_dropdown_keydown"
phx-mounted={JS.focus()}
value={@member_search_query}
placeholder={
if Enum.empty?(@selected_members),
do: gettext("Search for a member..."),
else: ""
}
class="flex-1 min-w-[120px] border-0 focus:outline-none bg-transparent"
name="member_search"
aria-label={gettext("Search for a member")}
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"
/>
</div>
<%= 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 top-full #{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 || gettext("No email")}
</p>
</div>
<% end %>
</div>
<% end %>
</div>
</form>
<.button
type="button"
variant="primary"
phx-click="add_selected_members"
data-testid="group-show-add-selected-members-btn"
disabled={Enum.empty?(@selected_member_ids)}
aria-label={gettext("Add members")}
class="join-item"
>
<.icon name="hero-plus" class="size-5" />
</.button>
<.button
type="button"
variant="neutral"
phx-click="hide_add_member_input"
aria-label={gettext("Cancel")}
class="join-item"
>
{gettext("Cancel")}
</.button>
</div>
<% else %>
<.button
variant="primary"
phx-click="show_add_member_input"
aria-label={gettext("Add Member")}
>
{gettext("Add Member")}
</.button>
<% end %>
</div>
<% end %>
<%= if Enum.empty?(@group.members || []) do %>
<p class="text-base-content/50 italic" data-testid="group-show-no-members">
{gettext("No members in this group")}
</p> </p>
<% else %>
<div class="overflow-x-auto" data-testid="group-show-members-table"> <%= if can?(@current_user, :update, @group) do %>
<table class="table table-zebra"> <div class="mb-4">
<thead> <%= if assigns[:show_add_member_input] do %>
<tr> <div class="join w-full">
<th>{gettext("Name")}</th> <form phx-change="search_members" class="flex-1">
<th>{gettext("Email")}</th> <div class="relative">
<%= if can?(@current_user, :update, @group) do %> <div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
<th class="w-0">{gettext("Actions")}</th> <%= for member <- @selected_members do %>
<% end %> <.badge
</tr> variant="primary"
</thead> style="outline"
<tbody> class="flex items-center gap-1"
<%= for member <- @group.members do %>
<tr>
<td>
<.link
navigate={~p"/members/#{member.id}"}
class="link link-primary"
>
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</.link>
</td>
<td>
<.maybe_value value={member.email} empty_sr_text={gettext("No email")}>
<a
href={"mailto:#{member.email}"}
class="link link-primary"
>
{member.email}
</a>
</.maybe_value>
</td>
<%= if can?(@current_user, :update, @group) do %>
<td>
<.tooltip content={gettext("Remove")} position="left">
<.button
type="button"
variant="danger"
size="sm"
phx-click="remove_member"
phx-value-member_id={member.id}
data-testid="group-show-remove-member"
aria-label={gettext("Remove member from group")}
> >
<.icon name="hero-trash" class="size-4" /> {MvWeb.Helpers.MemberHelpers.display_name(member)}
</.button> <.tooltip content={gettext("Remove")} position="top">
</.tooltip> <.button
</td> type="button"
variant="icon"
size="sm"
phx-click="remove_selected_member"
phx-value-member_id={member.id}
aria-label={
gettext("Remove %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(member)
)
}
class="p-0 h-4 w-4 min-h-0"
>
<.icon name="hero-x-mark" class="size-3" />
</.button>
</.tooltip>
</.badge>
<% end %>
<input
type="text"
id="member-search-input"
data-testid="group-show-member-search-input"
role="combobox"
phx-hook="ComboBox"
phx-focus="show_member_dropdown"
phx-debounce="300"
phx-keydown="member_dropdown_keydown"
phx-mounted={JS.focus()}
value={@member_search_query}
placeholder={
if Enum.empty?(@selected_members),
do: gettext("Search for a member..."),
else: ""
}
class="flex-1 min-w-[120px] border-0 focus:outline-none bg-transparent"
name="member_search"
aria-label={gettext("Search for a member")}
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"
/>
</div>
<%= 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 top-full #{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 || gettext("No email")}
</p>
</div>
<% end %>
</div>
<% end %>
</div>
</form>
<.button
type="button"
variant="primary"
phx-click="add_selected_members"
data-testid="group-show-add-selected-members-btn"
disabled={Enum.empty?(@selected_member_ids)}
aria-label={gettext("Add members")}
class="join-item"
>
<.icon name="hero-plus" class="size-5" />
</.button>
<.button
type="button"
variant="neutral"
phx-click="hide_add_member_input"
aria-label={gettext("Cancel")}
class="join-item"
>
{gettext("Cancel")}
</.button>
</div>
<% else %>
<.button
variant="primary"
phx-click="show_add_member_input"
aria-label={gettext("Add Member")}
>
{gettext("Add Member")}
</.button>
<% end %>
</div>
<% end %>
<%= if Enum.empty?(@group.members || []) do %>
<p class="text-base-content/50 italic" data-testid="group-show-no-members">
{gettext("No members in this group")}
</p>
<% else %>
<div class="overflow-x-auto" data-testid="group-show-members-table">
<table class="table table-zebra">
<thead>
<tr>
<th>{gettext("Name")}</th>
<th>{gettext("Email")}</th>
<%= if can?(@current_user, :update, @group) do %>
<th class="w-0">{gettext("Actions")}</th>
<% end %> <% end %>
</tr> </tr>
<% end %> </thead>
</tbody> <tbody>
</table> <%= for member <- @group.members do %>
</div> <tr>
<% end %> <td>
<.link
navigate={~p"/members/#{member.id}"}
class="link link-primary"
>
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</.link>
</td>
<td>
<.maybe_value value={member.email} empty_sr_text={gettext("No email")}>
<a
href={"mailto:#{member.email}"}
class="link link-primary"
>
{member.email}
</a>
</.maybe_value>
</td>
<%= if can?(@current_user, :update, @group) do %>
<td>
<.tooltip content={gettext("Remove")} position="left">
<.button
type="button"
variant="danger"
size="sm"
phx-click="remove_member"
phx-value-member_id={member.id}
data-testid="group-show-remove-member"
aria-label={gettext("Remove member from group")}
>
<.icon name="hero-trash" class="size-4" />
</.button>
</.tooltip>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</div> </div>
</div> </div>
</div>
<%!-- Danger zone: canonical pattern (same as member show) --%> <%!-- Danger zone: same width as form (max-w-2xl) --%>
<%= if can?(@current_user, :destroy, @group) do %> <%= if can?(@current_user, :destroy, @group) 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 group cannot be undone. All member-group associations will be permanently removed." "Deleting this group cannot be undone. All member-group associations will be permanently removed."
)} )}
</p> </p>
<.button <.button
id="delete-group-trigger" id="delete-group-trigger"
variant="danger" variant="danger"
type="button" type="button"
phx-click="open_delete_modal" phx-click="open_delete_modal"
data-testid="group-show-delete-btn" data-testid="group-show-delete-btn"
aria-label={gettext("Delete group %{name}", name: @group.name)} aria-label={gettext("Delete group %{name}", name: @group.name)}
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
{gettext("Delete group")} {gettext("Delete group")}
</.button> </.button>
</div> </div>
</section> </section>
<% end %> <% end %>
</div>
<%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%> <%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
<%= if assigns[:show_delete_modal] do %> <%= if assigns[:show_delete_modal] do %>

View file

@ -166,12 +166,17 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
/> />
</div> </div>
<div class="justify-end mt-4 card-actions"> <%!-- Buttons: below all form fields, Cancel left (secondary), Speichern right (primary) --%>
<div class="mt-6 flex items-center justify-end gap-4">
<.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}> <.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")} {gettext("Cancel")}
</.button> </.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button
{gettext("Save Field")} type="submit"
phx-disable-with={gettext("Saving...")}
variant="primary"
>
{gettext("Save datafield")}
</.button> </.button>
</div> </div>
</.form> </.form>

View file

@ -63,6 +63,7 @@ defmodule MvWeb.MemberLive.Form do
<div class="mt-6 space-y-6"> <div class="mt-6 space-y-6">
<%!-- Tab navigation: same styling as member show; only Contact Data tab (no Membership Fees on edit) --%> <%!-- Tab navigation: same styling as member show; only Contact Data tab (no Membership Fees on edit) --%>
<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"
> >
@ -259,13 +260,17 @@ defmodule MvWeb.MemberLive.Form do
</.form_section> </.form_section>
</div> </div>
<%!-- Bottom Action Buttons --%> <%!-- Buttons: below all form fields, Cancel left (secondary), Speichern right (primary) --%>
<div class="flex justify-end gap-4 mt-6"> <div class="mt-6 flex items-center justify-end gap-4">
<.button navigate={return_path(@return_to, @member)} variant="neutral" type="button"> <.link navigate={return_path(@return_to, @member)} class="btn btn-secondary">
{gettext("Cancel")} {gettext("Cancel")}
</.button> </.link>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> <.button
{gettext("Save Member")} type="submit"
phx-disable-with={gettext("Saving...")}
class="btn btn-primary"
>
{gettext("Save member")}
</.button> </.button>
</div> </div>

View file

@ -44,6 +44,13 @@
query={@query} query={@query}
placeholder={gettext("Search...")} placeholder={gettext("Search...")}
/> />
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown"
all_fields={@all_available_fields}
custom_fields={@all_custom_fields}
selected_fields={@user_field_selection}
/>
<.live_component <.live_component
module={MvWeb.Components.MemberFilterComponent} module={MvWeb.Components.MemberFilterComponent}
id="member-filter" id="member-filter"
@ -86,13 +93,6 @@
</span> </span>
</.button> </.button>
</.tooltip> </.tooltip>
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown"
all_fields={@all_available_fields}
custom_fields={@all_custom_fields}
selected_fields={@user_field_selection}
/>
</div> </div>
<%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%> <%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>

View file

@ -144,11 +144,6 @@ defmodule MvWeb.MembershipFeeSettingsLive do
<:subtitle> <:subtitle>
{gettext("Configure fee types for membership fees.")} {gettext("Configure fee types for membership fees.")}
</:subtitle> </:subtitle>
<:actions>
<.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}>
<.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
</.button>
</:actions>
</.header> </.header>
<%!-- One card: default setting + fee types table --%> <%!-- One card: default setting + fee types table --%>
@ -220,13 +215,6 @@ defmodule MvWeb.MembershipFeeSettingsLive do
<% end %> <% end %>
<% end %> <% end %>
</fieldset> </fieldset>
<div class="flex-shrink-0 ml-auto border-l border-base-300 pl-6">
<.button type="submit" variant="primary">
<.icon name="hero-check" class="size-5" />
{gettext("Save Settings")}
</.button>
</div>
</div> </div>
<ul class="text-sm text-base-content/60 list-disc list-inside space-y-0.5"> <ul class="text-sm text-base-content/60 list-disc list-inside space-y-0.5">
@ -237,12 +225,24 @@ defmodule MvWeb.MembershipFeeSettingsLive do
)} )}
</li> </li>
</ul> </ul>
<%!-- Save button: below default settings form, no icon (consistent with other Save buttons) --%>
<div class="mt-6 flex items-center justify-end">
<.button type="submit" phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save default settings")}
</.button>
</div>
</.form> </.form>
<div class="divider"></div> <div class="divider"></div>
<%!-- Fee types table: row click opens edit --%> <%!-- Fee types section: heading and "New" button on same line --%>
<h2 class="text-lg font-semibold">{gettext("Membership Fee Types")}</h2> <div class="flex items-center justify-between gap-4 flex-wrap">
<h2 class="text-lg font-semibold">{gettext("Membership Fee Types")}</h2>
<.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}>
<.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
</.button>
</div>
<.table <.table
id="membership_fee_types" id="membership_fee_types"
rows={@membership_fee_types} rows={@membership_fee_types}

View file

@ -34,16 +34,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
</.button> </.button>
</:leading> </:leading>
{@page_title} {@page_title}
<:actions>
<.button
form="membership-fee-type-form"
phx-disable-with={gettext("Saving...")}
variant="primary"
type="submit"
>
{gettext("Save")}
</.button>
</:actions>
</.header> </.header>
<.form <.form
@ -139,13 +129,22 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
rows="3" rows="3"
/> />
<div class="mt-4"> <%!-- Buttons: below all form fields, Cancel left (secondary), Speichern right (primary) --%>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> <div class="mt-6 flex items-center justify-end gap-4">
{gettext("Save Membership Fee Type")} <.button
</.button> navigate={return_path(@return_to, @membership_fee_type)}
<.button navigate={return_path(@return_to, @membership_fee_type)} type="button"> variant="neutral"
type="button"
>
{gettext("Cancel")} {gettext("Cancel")}
</.button> </.button>
<.button
type="submit"
phx-disable-with={gettext("Saving...")}
variant="primary"
>
{gettext("Save membership fee type")}
</.button>
</div> </div>
</.form> </.form>

View file

@ -30,11 +30,6 @@ defmodule MvWeb.RoleLive.Form do
</.button> </.button>
</:leading> </:leading>
{@page_title} {@page_title}
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header> </.header>
<div class="mt-6 space-y-6"> <div class="mt-6 space-y-6">
@ -85,6 +80,20 @@ defmodule MvWeb.RoleLive.Form do
<% end %> <% end %>
</div> </div>
</div> </div>
<%!-- Buttons: below all form fields, Cancel left (secondary), Speichern right (primary) --%>
<div class="mt-6 flex items-center justify-end gap-4">
<.button navigate={return_path(@return_to, @role)} variant="neutral" type="button">
{gettext("Cancel")}
</.button>
<.button
type="submit"
phx-disable-with={gettext("Saving...")}
variant="primary"
>
{gettext("Save role")}
</.button>
</div>
</.form> </.form>
</Layouts.app> </Layouts.app>
""" """

View file

@ -223,55 +223,57 @@ defmodule MvWeb.RoleLive.Show do
phx-hook="FocusRestore" phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil} phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
> >
<.list> <div class="max-w-xl mt-6 space-y-6">
<:item title={gettext("Name")}>{@role.name}</:item> <.list>
<:item title={gettext("Description")}> <:item title={gettext("Name")}>{@role.name}</:item>
<%= if @role.description do %> <:item title={gettext("Description")}>
{@role.description} <%= if @role.description do %>
<% else %> {@role.description}
<span class="text-base-content/70 italic">{gettext("No description")}</span> <% else %>
<% end %> <span class="text-base-content/70 italic">{gettext("No description")}</span>
</:item> <% end %>
<:item title={gettext("Permission Set")}> </:item>
<.badge variant={permission_set_badge_variant(@role.permission_set_name)}> <:item title={gettext("Permission Set")}>
{@role.permission_set_name} <.badge variant={permission_set_badge_variant(@role.permission_set_name)}>
</.badge> {@role.permission_set_name}
</:item> </.badge>
<:item title={gettext("System Role")}> </:item>
<.badge :if={@role.is_system_role} variant="warning"> <:item title={gettext("System Role")}>
{gettext("Yes")} <.badge :if={@role.is_system_role} variant="warning">
</.badge> {gettext("Yes")}
<.badge :if={!@role.is_system_role} variant="neutral"> </.badge>
{gettext("No")} <.badge :if={!@role.is_system_role} variant="neutral">
</.badge> {gettext("No")}
</:item> </.badge>
</.list> </: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
id="delete-role-trigger" id="delete-role-trigger"
variant="danger" variant="danger"
phx-click="open_delete_modal" phx-click="open_delete_modal"
data-testid="role-delete" data-testid="role-delete"
aria-label={gettext("Delete role %{name}", name: @role.name)} aria-label={gettext("Delete role %{name}", name: @role.name)}
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
{gettext("Delete role")} {gettext("Delete role")}
</.button> </.button>
</div> </div>
</section> </section>
<% end %> <% end %>
</div>
<%!-- Delete Role Confirmation Modal (WCAG: focus moves into modal, keyboard confirm/cancel) --%> <%!-- Delete Role Confirmation Modal (WCAG: focus moves into modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %> <%= if assigns[:show_delete_modal] do %>

View file

@ -60,16 +60,6 @@ defmodule MvWeb.UserLive.Form do
</.button> </.button>
</:leading> </:leading>
{@page_title} {@page_title}
<:actions>
<.button
form="user-form"
phx-disable-with={gettext("Saving...")}
variant="primary"
type="submit"
>
{gettext("Save User")}
</.button>
</:actions>
</.header> </.header>
<div <div
@ -309,6 +299,20 @@ defmodule MvWeb.UserLive.Form do
</div> </div>
<% end %> <% end %>
<%!-- Buttons: below all form fields, above Danger zone --%>
<div class="mt-6 flex items-center justify-end gap-4">
<.button navigate={return_path(@return_to, @user)} variant="neutral" type="button">
{gettext("Cancel")}
</.button>
<.button
type="submit"
phx-disable-with={gettext("Saving...")}
variant="primary"
>
{gettext("Save user")}
</.button>
</div>
<%!-- Danger zone: canonical pattern (same as member form) --%> <%!-- Danger zone: canonical pattern (same as member form) --%>
<%= if @user && can?(@current_user, :destroy, @user) && !SystemActor.system_user?(@user) do %> <%= if @user && can?(@current_user, :destroy, @user) && !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">
@ -378,15 +382,6 @@ defmodule MvWeb.UserLive.Form do
</div> </div>
</dialog> </dialog>
<% end %> <% end %>
<div class="mt-4">
<.button navigate={return_path(@return_to, @user)} variant="neutral">
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save User")}
</.button>
</div>
</.form> </.form>
</div> </div>
</Layouts.app> </Layouts.app>

View file

@ -67,59 +67,61 @@ defmodule MvWeb.UserLive.Show do
phx-hook="FocusRestore" phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil} phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
> >
<.list> <div class="max-w-xl mt-6 space-y-6">
<:item title={gettext("Email")}>{@user.email}</:item> <.list>
<:item title={gettext("Role")}>{@user.role.name}</:item> <:item title={gettext("Email")}>{@user.email}</:item>
<:item title={gettext("Password Authentication")}> <:item title={gettext("Role")}>{@user.role.name}</:item>
{if MvWeb.Helpers.UserHelpers.has_password?(@user), <:item title={gettext("Password Authentication")}>
do: gettext("Enabled"), {if MvWeb.Helpers.UserHelpers.has_password?(@user),
else: gettext("Not enabled")} do: gettext("Enabled"),
</:item> else: gettext("Not enabled")}
<:item title={gettext("OIDC")}> </:item>
{if MvWeb.Helpers.UserHelpers.has_oidc?(@user), <:item title={gettext("OIDC")}>
do: gettext("Linked"), {if MvWeb.Helpers.UserHelpers.has_oidc?(@user),
else: gettext("Not linked")} do: gettext("Linked"),
</:item> else: gettext("Not linked")}
<:item title={gettext("Linked Member")}> </:item>
<%= if @user.member do %> <:item title={gettext("Linked Member")}>
<.link <%= if @user.member do %>
navigate={~p"/members/#{@user.member}"} <.link
class="text-blue-600 underline hover:text-blue-800" navigate={~p"/members/#{@user.member}"}
> class="text-blue-600 underline hover:text-blue-800"
<.icon name="hero-users" class="inline w-4 h-4 mr-1" /> >
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)} <.icon name="hero-users" class="inline w-4 h-4 mr-1" />
</.link> {MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
<% else %> </.link>
<span class="italic text-base-content/70">{gettext("No member linked")}</span> <% else %>
<% end %> <span class="italic text-base-content/70">{gettext("No member linked")}</span>
</:item> <% end %>
</.list> </: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
id="delete-user-trigger" id="delete-user-trigger"
variant="danger" variant="danger"
phx-click="open_delete_modal" phx-click="open_delete_modal"
data-testid="user-delete" data-testid="user-delete"
aria-label={gettext("Delete user %{email}", email: @user.email)} aria-label={gettext("Delete user %{email}", email: @user.email)}
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
{gettext("Delete user")} {gettext("Delete user")}
</.button> </.button>
</div> </div>
</section> </section>
<% end %> <% end %>
</div>
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%> <%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %> <%= if assigns[:show_delete_modal] do %>