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).
**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):
- **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.
- **SHOULD:** Place the primary action (e.g. “Save”) in `<:actions>` on the right.
- **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.
- **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. One primary action (Save) per form, in the footer, avoids double submits and matches the reference (member edit form).
**Template for form pages:**
```heex
@ -66,15 +66,20 @@ For LiveViews that render an edit or new form (e.g. member, group, role, user, c
</:leading>
Page title (e.g. “Edit Member” or “New User”)
<:subtitle>Short explanation.</:subtitle>
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.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)

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) {
color: oklch(0.35 0.02 285);
@ -586,6 +588,13 @@
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
============================================ */

View file

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

View file

@ -19,7 +19,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
@impl true
def render(assigns) do
~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="flex items-center gap-4 mb-4">
<.button
@ -98,6 +98,20 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
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 %>
<%!-- Danger zone: canonical pattern (same as member form) --%>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
@ -125,15 +139,6 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
</div>
</section>
<% 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>
</div>
</div>

View file

@ -77,7 +77,7 @@ defmodule MvWeb.GroupLive.Form do
def render(assigns) do
~H"""
<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>
<:leading>
<.button navigate={return_path(@return_to, @group)} variant="neutral">
@ -86,11 +86,6 @@ defmodule MvWeb.GroupLive.Form do
</.button>
</:leading>
{@page_title}
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
<div class="mt-6 space-y-6">
@ -104,6 +99,20 @@ defmodule MvWeb.GroupLive.Form do
/>
</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) --%>
<%= if @group && can?(@current_user, :destroy, @group) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">

View file

@ -118,12 +118,13 @@ defmodule MvWeb.GroupLive.Show do
<div
id="group-show-focus-root"
class="mt-6 space-y-6"
class="mt-6"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<div class="max-w-2xl space-y-6">
<%!-- Group Information --%>
<div class="max-w-2xl space-y-6 mb-6">
<div class="space-y-6 mb-6">
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Description")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
@ -345,7 +346,7 @@ defmodule MvWeb.GroupLive.Show do
</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 %>
<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">
@ -371,6 +372,7 @@ defmodule MvWeb.GroupLive.Show do
</div>
</section>
<% end %>
</div>
<%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
<%= if assigns[:show_delete_modal] do %>

View file

@ -166,12 +166,17 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
/>
</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}>
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Field")}
<.button
type="submit"
phx-disable-with={gettext("Saving...")}
variant="primary"
>
{gettext("Save datafield")}
</.button>
</div>
</.form>

View file

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

View file

@ -44,6 +44,13 @@
query={@query}
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
module={MvWeb.Components.MemberFilterComponent}
id="member-filter"
@ -86,13 +93,6 @@
</span>
</.button>
</.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>
<%!-- 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>
{gettext("Configure fee types for membership fees.")}
</: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>
<%!-- One card: default setting + fee types table --%>
@ -220,13 +215,6 @@ defmodule MvWeb.MembershipFeeSettingsLive do
<% end %>
<% end %>
</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>
<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>
</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>
<div class="divider"></div>
<%!-- Fee types table: row click opens edit --%>
<%!-- Fee types section: heading and "New" button on same line --%>
<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
id="membership_fee_types"
rows={@membership_fee_types}

View file

@ -34,16 +34,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
</.button>
</:leading>
{@page_title}
<:actions>
<.button
form="membership-fee-type-form"
phx-disable-with={gettext("Saving...")}
variant="primary"
type="submit"
>
{gettext("Save")}
</.button>
</:actions>
</.header>
<.form
@ -139,13 +129,22 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
rows="3"
/>
<div class="mt-4">
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Membership Fee Type")}
</.button>
<.button navigate={return_path(@return_to, @membership_fee_type)} type="button">
<%!-- 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, @membership_fee_type)}
variant="neutral"
type="button"
>
{gettext("Cancel")}
</.button>
<.button
type="submit"
phx-disable-with={gettext("Saving...")}
variant="primary"
>
{gettext("Save membership fee type")}
</.button>
</div>
</.form>

View file

@ -30,11 +30,6 @@ defmodule MvWeb.RoleLive.Form do
</.button>
</:leading>
{@page_title}
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
<div class="mt-6 space-y-6">
@ -85,6 +80,20 @@ defmodule MvWeb.RoleLive.Form do
<% end %>
</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>
</Layouts.app>
"""

View file

@ -223,6 +223,7 @@ defmodule MvWeb.RoleLive.Show do
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<div class="max-w-xl mt-6 space-y-6">
<.list>
<:item title={gettext("Name")}>{@role.name}</:item>
<:item title={gettext("Description")}>
@ -272,6 +273,7 @@ defmodule MvWeb.RoleLive.Show do
</div>
</section>
<% end %>
</div>
<%!-- Delete Role Confirmation Modal (WCAG: focus moves into modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %>

View file

@ -60,16 +60,6 @@ defmodule MvWeb.UserLive.Form do
</.button>
</:leading>
{@page_title}
<:actions>
<.button
form="user-form"
phx-disable-with={gettext("Saving...")}
variant="primary"
type="submit"
>
{gettext("Save User")}
</.button>
</:actions>
</.header>
<div
@ -309,6 +299,20 @@ defmodule MvWeb.UserLive.Form do
</div>
<% 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) --%>
<%= if @user && can?(@current_user, :destroy, @user) && !SystemActor.system_user?(@user) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
@ -378,15 +382,6 @@ defmodule MvWeb.UserLive.Form do
</div>
</dialog>
<% 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>
</div>
</Layouts.app>

View file

@ -67,6 +67,7 @@ defmodule MvWeb.UserLive.Show do
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<div class="max-w-xl mt-6 space-y-6">
<.list>
<:item title={gettext("Email")}>{@user.email}</:item>
<:item title={gettext("Role")}>{@user.role.name}</:item>
@ -120,6 +121,7 @@ defmodule MvWeb.UserLive.Show do
</div>
</section>
<% end %>
</div>
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %>