Files
sure/app/views/settings/profiles/show.html.erb
Guillem Arias Fauste e07d641ead fix(design-system): DS::Button a11y audit — focus ring, touch target, type default, icon-only label (#1840)
* fix(design-system): DS::Button a11y audit

Closes #1738. Four concrete fixes surfaced by the savings-goals
audit + #1737 universal checklist:

1. Focus ring (WCAG 2.4.7). `base.css` had
   `focus-visible:outline-gray-900` which is **1.07:1** against the
   primary button's gray-900 background — invisible. Widen to
   `outline-2 outline-offset-2`, place outline outside the button
   via offset, and add a dark-mode `outline-white` so the ring is
   always visible against the page chrome regardless of the button
   surface.

2. Touch target (WCAG 2.5.5). Icon-only buttons at the default
   `:md` size were `w-9 h-9` = 36×36, below the 44×44 enhanced
   target. Bump `md.icon_container_classes` to `w-11 h-11` and
   `lg.icon_container_classes` to `w-12 h-12` to keep the size
   scale intact. `sm` stays at 32×32 (already passes WCAG 2.5.8
   AA's 24×24 minimum; intentional compact-density variant).

3. Default button type. `content_tag(:button, ...)` inherits the
   HTML default `type="submit"`, so a DS::Button rendered inside a
   form steals Enter-key submission from the first text input
   (reproducible in the form stepper). Default to `type="button"`
   in the non-`href` branch; existing form submitters pass
   `type: "submit"` explicitly and continue to work. The `button_to`
   (href) branch keeps the submit default because button_to wraps
   its own form.

4. Icon-only accessible name. Icon-only buttons render no text
   node, so AT users hear "button" with no name. Derive a
   humanized aria-label from the icon key (e.g. `icon: "more-horizontal"`
   → `aria-label="More horizontal"`); explicit
   `aria: { label: }` on the caller still wins. Soft fallback —
   callers should still pass meaningful labels for richer copy.

Plus: replace the stale `fg-white` icon class on the destructive
variant with `text-inverse` (the `fg-*` namespace was deprecated
in #1626 so `fg-white` resolved to nothing; the icon was using its
helper-default color rather than the white the design intended).

Out of scope:
- Menu avatar trigger (custom 36×36 button bypassing DS::Button) —
  belongs to #1743 DS::Menu audit.
- DS::FilledIcon `lg` size container (decorative, not interactive)
  — belongs to #1742.

* fix(design-system): force type=submit on StyledFormBuilder#submit

The DS::Button default-type-button change in the previous commit
broke every `form.submit "Log in"` callsite because
`StyledFormBuilder#submit` (app/helpers/styled_form_builder.rb)
renders a DS::Button under the hood with no explicit `type:`.

After the default flip, those submit buttons rendered as
`type="button"`, so submitting forms (login, password reset, every
form using `form.submit`) silently no-ops. CI surfaced this via
~30 system tests failing in the `sign_in` helper, which couldn't
get past the login page.

Pin `type: "submit"` on the DS::Button rendered by
`StyledFormBuilder#submit`. The 22 view-level `f.submit` /
`render DS::Button.new(type: :submit, ...)` callers already pass
type explicitly and are unaffected.

* fix(review): href-branch type-button bug + focus-ring tokens + profile Save submit

CodeRabbit P1+P2 review on #1840:

1. button.rb: `merged_opts.delete(:href)` always returned nil because
   Buttonish#initialize strips :href from opts into @href, so the
   `if href.blank?` guard was ALWAYS true. Every DS::Button rendered via
   button_to (the href branch) got `type="button"` on the inner button,
   breaking submission of those button_to-generated forms (e.g.
   imports/_ready.html.erb publish button, imports/_failure.html.erb
   try-again button). Drop the local `href = merged_opts.delete(:href)`
   so the guard now reads the @href reader, leaving the href branch's
   HTML default intact.

2. settings/profiles/show.html.erb: the Save button is rendered with
   `render DS::Button.new(...)` inside `styled_form_with` (not via
   form.submit), so the StyledFormBuilder#submit type-pin from
   624e9794 doesn't cover it. Pass `type: :submit` explicitly so the
   profile form submits again under the default-type-button policy.

3. base.css: replace raw `outline-gray-900` / `outline-white` with the
   established alpha-ring focus pattern
   (focus-visible:ring-alpha-black-300 + theme-dark:ring-alpha-white-300)
   already used by app/components/settings/provider_card.html.erb and
   sure-design-system/components.css. Keeps a11y focus ring while using
   DS tokens.

* fix(review): add type: :submit to DS::Button submitters inside forms

CI test_system on #1840 surfaced 6 failures (confirm-dialog close,
property create/edit, transaction filter apply) caused by the same
gap that db563f3d started addressing: the default-type-button policy
on DS::Button means every \`render DS::Button.new(...)\` inside a
\`<form>\` (or \`styled_form_with\`) that relies on the HTML default to
submit is now an inert \`type="button"\`.

Audited every \`render DS::Button.new(\` callsite repo-wide for the
combination (no \`type:\`, no \`href:\`, inside a form context) and
pinned \`type: :submit\` explicitly on the 12 forms that need it:

- layouts/shared/_confirm_dialog.html.erb: Confirm button inside the
  global \`<form method=\"dialog\">\` — fixes
  test_should_allow_revoking_API_key_with_confirmation.
- properties/{new,edit,balances}.html.erb: Save/Next submitter inside
  \`styled_form_with\` — fixes test_can_create_property_account,
  test_can_persist_property_subtype.
- transactions/searches/_menu.html.erb: Apply inside the filter form —
  fixes test_can_filter_uncategorized_transactions,
  test_all_filters_work_and_empty_state_shows_if_no_match,
  test_can_open_filters_and_apply_one_or_more.
- transactions/bulk_updates/new.html.erb: Save in bulk-edit drawer.
- account_sharings/show.html.erb: Save in account-sharing form.
- category/deletions/new.html.erb, tag/deletions/new.html.erb:
  destructive + safe submit buttons in deletion dialog forms.
- family_merchants/merge.html.erb: Submit in merge form.
- subscriptions/upgrade.html.erb: contribute_and_support_sure submit.
- rules/_category_rule_cta.html.erb: Dismiss inside the
  rule_prompts_disabled form.

Cancel/close DS::Button instances inside these same forms intentionally
keep the \`type=button\` default since they drive JS-only actions
(\`DS--dialog#close\`, \`DS--menu#close\`).

* fix(review): add type: :submit to 4 remaining form-context DS::Button callers

Second sweep for the same default-type-button regression that 24c517eb
fixed for 12 callsites. The latest CI run on this branch narrowed the
failures from 6 to 2 (the property wizard's Address step still failed
because that view was not in the first sweep). Audited via a wider
4000-char form-context window:

- app/views/properties/address.html.erb: Save inside
  styled_form_with — fixes the remaining
  test_can_create_property_account + test_can_persist_property_subtype
  by letting Step 3 of the property wizard complete.
- app/views/onboardings/goals.html.erb: Submit inside form_with so
  the onboarding goals step submits.
- app/views/account_sharings/show.html.erb (owner-side form): Save
  button for the family-share permissions form (the non-owner Save
  was already fixed in 24c517eb).
- app/views/transactions/_attachments.html.erb: Upload inside
  styled_form_with — kept the JS-driven hook (attachment_upload_target)
  but explicit type:submit covers the no-JS fallback.

* fix(review): pin type=submit on the Save currencies button

Codex P1 (third pass) caught one more in-form DS::Button I missed in
the earlier sweeps: \`app/views/settings/preferences/show.html.erb:185\`
renders the Save currencies submit deep inside a long
\`styled_form_with\` block. The form-context scan I used had a finite
look-back window which missed it because the matching
\`styled_form_with\` opener sits ~80 lines / 4k+ characters above the
button. Switched to a whole-file scan to confirm no further callsite
remains.
2026-05-20 18:12:36 +02:00

190 lines
9.0 KiB
Plaintext

<%= content_for :page_title, t(".page_title") %>
<%= settings_section title: t(".profile_title"), subtitle: t(".profile_subtitle", product_name: product_name) do %>
<%= styled_form_with model: @user, url: user_path(@user), class: "space-y-4" do |form| %>
<%= render "settings/user_avatar_field", form: form, user: @user %>
<div>
<%= form.email_field :email, placeholder: t(".email"), label: t(".email") %>
<% if @user.unconfirmed_email.present? %>
<p class="mt-2 text-sm text-secondary">
You have requested to change your email to <%= @user.unconfirmed_email %>. Please go to your email and confirm for the change to take effect. If you haven't received the email, please check your spam folder, or <%= link_to "request a new confirmation email", resend_confirmation_email_user_path(@user), class: "hover:underline text-secondary" %>.
</p>
<% end %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<%= form.text_field :first_name, placeholder: t(".first_name"), label: t(".first_name") %>
<%= form.text_field :last_name, placeholder: t(".last_name"), label: t(".last_name") %>
</div>
<div class="flex justify-end mt-4">
<%= render DS::Button.new(text: t(".save"), type: :submit, class: "md:w-auto w-full justify-center") %>
</div>
</div>
<% end %>
<% end %>
<% unless Current.user.ui_layout_intro? %>
<%= settings_section title: Current.family&.moniker == "Group" ? t(".group_title", default: "Group") : t(".household_title"), subtitle: t(".household_subtitle", moniker_plural: family_moniker_plural_downcase, moniker: family_moniker_downcase) do %>
<div class="space-y-4">
<%= styled_form_with model: Current.user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %>
<%= form.fields_for :family do |family_fields| %>
<% name_label = Current.family&.moniker == "Group" ? t(".group_form_label", default: "Group name") : t(".household_form_label") %>
<% name_placeholder = Current.family&.moniker == "Group" ? t(".group_form_input_placeholder", default: "Enter group name") : t(".household_form_input_placeholder") %>
<%= family_fields.text_field :name,
placeholder: name_placeholder,
label: name_label,
disabled: !Current.user.admin?,
"data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
<div class="bg-container-inset rounded-xl p-1">
<div class="px-4 py-2">
<p class="uppercase text-xs text-secondary font-medium"><%= Current.family.name %> &middot; <%= Current.family.users.size %></p>
</div>
<% @users.each do |user| %>
<div class="flex gap-2 mt-2 items-center bg-container p-4 shadow-border-xs rounded-lg">
<div class="w-9 h-9 shrink-0">
<%= render "settings/user_avatar", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials %>
</div>
<p class="text-primary font-medium text-sm"><%= user.display_name %></p>
<div class="rounded-md bg-surface px-1.5 py-0.5">
<p class="uppercase text-secondary font-medium text-xs"><%= t("users.roles.#{user.role}", default: user.role.humanize) %></p>
</div>
<% if Current.user.admin? && user != Current.user %>
<div class="ml-auto">
<%= render DS::Button.new(
variant: "icon",
icon: "x",
href: settings_profile_path(user_id: user),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(user.display_name, high_severity: true)
) %>
</div>
<% end %>
</div>
<% end %>
<% if @pending_invitations.any? %>
<% @pending_invitations.each do |invitation| %>
<div class="flex gap-2 items-center justify-between bg-container p-4 border border-alpha-black-25 rounded-lg">
<div class="flex gap-2 items-center">
<div class="w-9 h-9 shrink-0">
<div class="text-inverse w-full h-full bg-surface-inset rounded-full flex items-center justify-center text-lg uppercase"><%= invitation.email[0] %></div>
</div>
<div class="flex">
<p class="text-primary font-medium text-sm"><%= invitation.email %></p>
<div class="rounded-md bg-surface px-1.5 py-0.5">
<p class="uppercase text-secondary font-medium text-xs"><%= t(".pending") %></p>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<% if self_hosted? %>
<div class="flex items-center gap-2" data-controller="clipboard">
<p class="text-secondary text-sm"><%= t(".invitation_link") %></p>
<span data-clipboard-target="source" class="hidden"><%= accept_invitation_url(invitation.token) %></span>
<input type="text"
readonly
autocomplete="off"
value="<%= accept_invitation_url(invitation.token) %>"
class="text-sm bg-surface-inset px-2 py-1 rounded border border-secondary w-72">
<button data-action="clipboard#copy" class="text-secondary hover:text-primary">
<span data-clipboard-target="iconDefault">
<%= icon "copy" %>
</span>
<span class="hidden" data-clipboard-target="iconSuccess">
<%= icon "check" %>
</span>
</button>
</div>
<% end %>
<% if Current.user.admin? %>
<%= render DS::Button.new(
variant: "icon",
icon: "x",
href: invitation_path(invitation),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(invitation.email, high_severity: true)
) %>
<% end %>
</div>
</div>
<% end %>
<% end %>
<% if Current.user.admin? %>
<%= link_to new_invitation_path,
class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center",
data: { turbo_frame: :modal } do %>
<%= icon("plus") %>
<%= t(".invite_member") %>
<% end %>
<% end %>
</div>
</div>
<% end %>
<% end %>
<%= settings_section title: t(".danger_zone_title") do %>
<div class="space-y-4">
<% if Current.user.admin? %>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="w-full md:w-2/3">
<h3 class="font-medium text-primary"><%= t(".reset_account") %></h3>
<p class="text-secondary text-sm"><%= t(".reset_account_warning") %></p>
</div>
<%= render DS::Button.new(
text: t(".reset_account"),
variant: "destructive",
href: reset_user_path(@user),
method: :delete,
confirm: CustomConfirm.new(
title: t(".confirm_reset.title"),
body: t(".confirm_reset.body"),
btn_text: t(".reset_account"),
destructive: true,
high_severity: true
)
) %>
</div>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="w-full md:w-2/3">
<h3 class="font-medium text-primary"><%= t(".reset_account_with_sample_data") %></h3>
<p class="text-secondary text-sm"><%= t(".reset_account_with_sample_data_warning") %></p>
</div>
<%= render DS::Button.new(
text: t(".reset_account_with_sample_data"),
variant: "destructive",
href: reset_with_sample_data_user_path(@user),
method: :delete,
confirm: CustomConfirm.new(
title: t(".confirm_reset_with_sample_data.title"),
body: t(".confirm_reset_with_sample_data.body"),
btn_text: t(".reset_account_with_sample_data"),
destructive: true,
high_severity: true
)
) %>
</div>
<% end %>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="w-full md:w-2/3">
<h3 class="font-medium text-primary"><%= t(".delete_account") %></h3>
<p class="text-secondary text-sm"><%= t(".delete_account_warning") %></p>
</div>
<%= render DS::Button.new(
text: t(".delete_account"),
variant: "destructive",
href: user_path(@user),
method: :delete,
confirm: CustomConfirm.for_resource_deletion("your account", high_severity: true)
) %>
</div>
</div>
<% end %>