From 04422f36b31106343ea5fb34fc8df445fadfbd50 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 11 May 2026 20:24:26 +0200 Subject: [PATCH] a11y(goals/card): scope link accessible name to title + status summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whole card was wrapped in <%= link_to ... %>, so screen readers concatenated every nested text node into one accessible name (~60 words on a typical card: avatar initial + name + status pill + percent + balance + target + pace + accounts + footer). - Outer wrapper now
carrying the filter-target + goal-name + goal-status data attrs. - Inner wraps only the goal name. aria-label = ", , % of " — concise SR sentence. - `before:absolute before:inset-0` makes the inner link's hit area span the whole card so sighted users keep the existing click affordance. - Ring SVG + percent overlay marked aria-hidden (decorative — same info already in the aria-label). - New locale key goals.goal_card.aria_progress. --- app/components/goals/card_component.html.erb | 25 +++++++++++--------- app/components/goals/card_component.rb | 11 +++++++++ config/locales/views/goals/en.yml | 1 + 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/app/components/goals/card_component.html.erb b/app/components/goals/card_component.html.erb index db0bdce76..ea88de9ea 100644 --- a/app/components/goals/card_component.html.erb +++ b/app/components/goals/card_component.html.erb @@ -1,22 +1,25 @@ -<%= link_to goal_path(goal), - class: "group block bg-container rounded-xl shadow-border-xs hover:bg-surface-hover transition-colors p-6 #{"opacity-75" if goal.paused? || goal.archived?}", - data: { - goals_filter_target: "card", - goal_name: goal.name, - goal_status: goal.display_status - } do %> +
" + data-goals-filter-target="card" + data-goal-name="<%= goal.name %>" + data-goal-status="<%= goal.display_status %>">
<%= render Goals::AvatarComponent.new(goal: goal, size: "lg") %>
-

<%= goal.name %>

+

+ + <%= goal.name %> + +

<%= render Goals::StatusPillComponent.new(goal: goal) %>

<%= secondary_line %>

- + -
+
@@ -57,4 +60,4 @@
<%= footer_line %>
-<% end %> +
diff --git a/app/components/goals/card_component.rb b/app/components/goals/card_component.rb index 6bc735f2b..e7676c517 100644 --- a/app/components/goals/card_component.rb +++ b/app/components/goals/card_component.rb @@ -29,6 +29,17 @@ class Goals::CardComponent < ApplicationComponent I18n.t("goals.goal_card.accounts", count: linked_accounts.size) end + # Single screen-reader sentence for the card's title aria-label. + # Without this, the whole-card link would inherit every nested text node + # as its accessible name (>15 strings on a typical card). + def aria_label + status_text = I18n.t("goals.status.#{goal.display_status}") + progress_text = I18n.t("goals.goal_card.aria_progress", + percent: progress_percent, + target: goal.target_amount_money.format) + [ goal.name, status_text, progress_text ].join(", ") + end + def secondary_line if goal.completed? I18n.t("goals.goal_card.completed") diff --git a/config/locales/views/goals/en.yml b/config/locales/views/goals/en.yml index bb9cf5721..529d56d51 100644 --- a/config/locales/views/goals/en.yml +++ b/config/locales/views/goals/en.yml @@ -194,6 +194,7 @@ en: no_accounts: No linked accounts n_accounts: "%{first} +%{count}" left: left + aria_progress: "%{percent}% of %{target}" accounts: one: 1 account other: "%{count} accounts"