From c622dabd20a4060dd849956fa7603c9816974d03 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 11 May 2026 19:31:29 +0200 Subject: [PATCH] a11y(savings_goals): ARIA semantics + unique IDs + h2 hierarchy - Projection chart SVG: role=img + + <desc> wired through new ariaLabelValue / ariaDescriptionValue Stimulus values. Show.html.erb passes a localized chart label and a strip_tags'd projection summary. - Progress ring container: role=progressbar + aria-valuenow/min/max + aria-label so screen readers announce "Goal 27% complete. $13,250 of $50,000 saved." instead of four disjoint spans. - Funding-account checkboxes (stepper step 1): explicit per-account id ("savings_goal_account_ids_<id>") so each row has a unique DOM id; duplicate-id HTML violation gone. - show.html.erb: <h3> -> <h2> at six section headings (celebration, no-target-date, projection, contributions, funding accounts, notes) so the heading hierarchy is h1 -> h2, not h1 -> h3. - goal_avatar + account_stack components: aria-hidden=true on the decorative wrappers; the textual goal/account name beside them is always read separately so the SR no longer prefixes every entry with the avatar initial. - New locale keys: savings_goals.show.ring.aria_label and savings_goals.show.projection.aria_label. --- .../savings/account_stack_component.html.erb | 2 +- .../savings/goal_avatar_component.html.erb | 1 + .../savings/progress_ring_component.html.erb | 5 +++++ .../savings_goal_projection_chart_controller.js | 8 +++++++- app/views/savings_goals/_form_stepper.html.erb | 1 + app/views/savings_goals/show.html.erb | 16 +++++++++------- config/locales/views/savings_goals/en.yml | 2 ++ 7 files changed, 26 insertions(+), 9 deletions(-) diff --git a/app/components/savings/account_stack_component.html.erb b/app/components/savings/account_stack_component.html.erb index 349c316f3..9c4c933e0 100644 --- a/app/components/savings/account_stack_component.html.erb +++ b/app/components/savings/account_stack_component.html.erb @@ -1,4 +1,4 @@ -<span class="inline-flex items-center"> +<span class="inline-flex items-center" aria-hidden="true"> <% shown.each_with_index do |account, i| %> <span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-inverse text-[9px] font-semibold ring-2 ring-container" style="background-color: <%= Savings::GoalAvatarComponent.color_for(account.name) %>; <%= "margin-left: -6px;" if i > 0 %>" diff --git a/app/components/savings/goal_avatar_component.html.erb b/app/components/savings/goal_avatar_component.html.erb index b415cce5b..ab7c803c9 100644 --- a/app/components/savings/goal_avatar_component.html.erb +++ b/app/components/savings/goal_avatar_component.html.erb @@ -1,5 +1,6 @@ <span class="inline-flex items-center justify-center text-inverse font-semibold <%= box_classes %> <%= text_classes %> <%= radius_classes %>" style="background-color: <%= color %>;" + aria-hidden="true" data-testid="savings-goal-avatar"> <%= initial %> </span> diff --git a/app/components/savings/progress_ring_component.html.erb b/app/components/savings/progress_ring_component.html.erb index 3cd21c1de..bdac3620f 100644 --- a/app/components/savings/progress_ring_component.html.erb +++ b/app/components/savings/progress_ring_component.html.erb @@ -1,6 +1,11 @@ <div data-controller="donut-chart" data-donut-chart-segments-value="<%= goal.to_donut_segments_json.to_json %>" data-donut-chart-segment-height-value="6" + role="progressbar" + aria-valuenow="<%= percent %>" + aria-valuemin="0" + aria-valuemax="100" + aria-label="<%= t("savings_goals.show.ring.aria_label", percent: percent, amount: amount_label, target: target_label) %>" class="relative mx-auto" style="width: <%= size %>px; height: <%= size %>px;"> <div data-donut-chart-target="chartContainer" class="absolute inset-0 pointer-events-none"></div> diff --git a/app/javascript/controllers/savings_goal_projection_chart_controller.js b/app/javascript/controllers/savings_goal_projection_chart_controller.js index 9a1379daa..dde653e7a 100644 --- a/app/javascript/controllers/savings_goal_projection_chart_controller.js +++ b/app/javascript/controllers/savings_goal_projection_chart_controller.js @@ -11,7 +11,7 @@ import * as d3 from "d3"; // Data shape passed via `data-savings-goal-projection-chart-data-value` // matches SavingsGoal#projection_payload. export default class extends Controller { - static values = { data: Object }; + static values = { data: Object, ariaLabel: String, ariaDescription: String }; connect() { this._draw(); @@ -96,6 +96,12 @@ export default class extends Controller { .attr("viewBox", `0 0 ${width} ${height}`) .attr("preserveAspectRatio", "none"); + const titleId = `chart-title-${this._id()}`; + const descId = `chart-desc-${this._id()}`; + svg.attr("role", "img").attr("aria-labelledby", titleId).attr("aria-describedby", descId); + svg.append("title").attr("id", titleId).text(this.ariaLabelValue || "Savings goal projection"); + svg.append("desc").attr("id", descId).text(this.ariaDescriptionValue || ""); + const defs = svg.append("defs"); const gradient = defs .append("linearGradient") diff --git a/app/views/savings_goals/_form_stepper.html.erb b/app/views/savings_goals/_form_stepper.html.erb index 86832e858..97b61dbf1 100644 --- a/app/views/savings_goals/_form_stepper.html.erb +++ b/app/views/savings_goals/_form_stepper.html.erb @@ -64,6 +64,7 @@ <%= check_box_tag "savings_goal[account_ids][]", account.id, false, + id: "savings_goal_account_ids_#{account.id}", class: "checkbox checkbox--light shrink-0", data: { savings_goal_stepper_target: "linkedAccountCheckbox", diff --git a/app/views/savings_goals/show.html.erb b/app/views/savings_goals/show.html.erb index 89e20d5c7..d61b9ea46 100644 --- a/app/views/savings_goals/show.html.erb +++ b/app/views/savings_goals/show.html.erb @@ -145,7 +145,7 @@ <div class="w-16 h-16 rounded-full bg-green-500/10 inline-flex items-center justify-center text-success mb-3"> <%= icon("party-popper", size: "2xl", color: "success") %> </div> - <h3 class="text-lg font-semibold text-primary"><%= t(".celebration.heading") %></h3> + <h2 class="text-lg font-semibold text-primary"><%= t(".celebration.heading") %></h2> <p class="text-sm text-secondary mt-1 max-w-md"><%= t(".celebration.body", amount: @savings_goal.target_amount_money.format) %></p> <% if @savings_goal.may_archive? %> <div class="mt-4"> @@ -162,7 +162,7 @@ <div class="w-16 h-16 rounded-full bg-surface-inset inline-flex items-center justify-center text-secondary mb-3"> <%= icon("calendar-plus", size: "2xl") %> </div> - <h3 class="text-lg font-semibold text-primary"><%= t(".no_target_date.heading") %></h3> + <h2 class="text-lg font-semibold text-primary"><%= t(".no_target_date.heading") %></h2> <p class="text-sm text-secondary mt-1 max-w-md"><%= t(".no_target_date.body") %></p> <div class="mt-4"> <%= render DS::Link.new( @@ -179,7 +179,7 @@ <div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col"> <div class="flex items-start justify-between mb-2 gap-3"> <div class="min-w-0"> - <h3 class="text-sm font-medium text-primary"><%= t(".projection.heading") %></h3> + <h2 class="text-sm font-medium text-primary"><%= t(".projection.heading") %></h2> <p class="text-xs text-secondary mt-0.5"><%= @stats[:projection_summary].html_safe %></p> </div> <% projection_color = @savings_goal.status == :on_track ? "var(--color-green-600)" : "var(--color-yellow-600)" %> @@ -196,7 +196,9 @@ </div> <div class="flex-1 min-h-[200px]" data-controller="savings-goal-projection-chart" - data-savings-goal-projection-chart-data-value="<%= @savings_goal.projection_payload.to_json %>"></div> + data-savings-goal-projection-chart-data-value="<%= @savings_goal.projection_payload.to_json %>" + data-savings-goal-projection-chart-aria-label-value="<%= t("savings_goals.show.projection.aria_label", name: @savings_goal.name) %>" + data-savings-goal-projection-chart-aria-description-value="<%= strip_tags(@stats[:projection_summary]) %>"></div> </div> <% end %> </section> @@ -241,7 +243,7 @@ <section class="grid grid-cols-1 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)] gap-3"> <div class="bg-container rounded-xl shadow-border-xs p-5"> <div class="flex items-center mb-4"> - <h3 class="text-sm font-medium text-primary"><%= t(".contributions_heading") %></h3> + <h2 class="text-sm font-medium text-primary"><%= t(".contributions_heading") %></h2> <span class="ml-2 text-xs text-subdued tabular-nums"><%= @contributions.size %></span> </div> <div class="max-h-[420px] overflow-y-auto overflow-x-hidden"> @@ -250,14 +252,14 @@ </div> <div class="bg-container rounded-xl shadow-border-xs p-5"> - <h3 class="text-sm font-medium text-primary mb-3"><%= t(".funding_accounts_heading") %></h3> + <h2 class="text-sm font-medium text-primary mb-3"><%= t(".funding_accounts_heading") %></h2> <%= render Savings::FundingAccountsBreakdownComponent.new(goal: @savings_goal, rows: @funding_breakdown) %> </div> </section> <% if @savings_goal.notes.present? %> <section class="bg-container rounded-xl shadow-border-xs p-5"> - <h3 class="text-sm font-medium text-primary mb-2"><%= t(".notes") %></h3> + <h2 class="text-sm font-medium text-primary mb-2"><%= t(".notes") %></h2> <p class="text-sm text-secondary whitespace-pre-line"><%= @savings_goal.notes %></p> </section> <% end %> diff --git a/config/locales/views/savings_goals/en.yml b/config/locales/views/savings_goals/en.yml index 333d2bcaf..a2035c4c0 100644 --- a/config/locales/views/savings_goals/en.yml +++ b/config/locales/views/savings_goals/en.yml @@ -109,6 +109,7 @@ en: of: "of %{target}" to_go: "%{amount} to go" of_target: of target + aria_label: "Goal %{percent}% complete. %{amount} of %{target} saved." projection: heading: Projection legend_saved: Saved @@ -118,6 +119,7 @@ en: no_pace: No contributions yet. Add some to start a projection. behind: At %{current}/mo you'll fall short. Bump to <strong class="text-primary">%{required}/mo</strong> to hit it on time. on_track: At your current pace, you'll reach this goal around <strong class="text-primary">%{date}</strong>. + aria_label: "Projection chart for %{name}" catch_up: title: "Save %{amount}/mo to catch up" body_with_date: "Bump your monthly contribution to stay on track for %{date}."