From e30ccd94afe51f96a7b223f247e142bff1770cf8 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Wed, 20 May 2026 18:17:51 +0200 Subject: [PATCH] =?UTF-8?q?fix(design-system):=20DS::Tooltip=20a11y=20?= =?UTF-8?q?=E2=80=94=20focusable=20trigger,=20keyboard=20parity,=20Esc=20d?= =?UTF-8?q?ismiss=20(#1845)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(design-system): DS::Tooltip a11y — focusable trigger, keyboard parity, Esc dismiss Closes #1747. Five fixes on the tooltip primitive. 1. **Tooltip anchor not in a11y tree.** The trigger was a bare Lucide icon, which Lucide renders with `aria-hidden="true"`. The tooltip target had `role="tooltip"` but nothing referenced it, so AT users had no way to discover the description. Wrap the icon in a focusable ` + <% else %> + <%# `as: :span` — used when the tooltip is rendered inside an + already-focusable interactive ancestor (e.g. ``), + where the HTML spec forbids nested interactive content. + Keyboard reveal is wired in the controller, which (in addition + to listening on the outer span for the standalone case) also + binds `focusin/focusout/keydown` on the closest `` + ancestor — because `focusin` only bubbles UP, a listener on + this descendant span would never fire when the ancestor + disclosure receives focus. %> + + <%= helpers.icon icon_name, size: size, color: color %> + + <% end %> - <% elsif ibkr_item.sync_error.present? %>
- <%= render DS::Tooltip.new(text: ibkr_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %> + <%= render DS::Tooltip.new(text: ibkr_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive", as: :span) %> <%= tag.span t(".error"), class: "text-destructive" %>
<% else %> diff --git a/app/views/indexa_capital_items/_indexa_capital_item.html.erb b/app/views/indexa_capital_items/_indexa_capital_item.html.erb index e6f608e33..7f90af7bd 100644 --- a/app/views/indexa_capital_items/_indexa_capital_item.html.erb +++ b/app/views/indexa_capital_items/_indexa_capital_item.html.erb @@ -34,7 +34,7 @@ <% elsif indexa_capital_item.sync_error.present? %>
- <%= render DS::Tooltip.new(text: indexa_capital_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %> + <%= render DS::Tooltip.new(text: indexa_capital_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive", as: :span) %> <%= tag.span t(".error"), class: "text-destructive" %>
<% else %> diff --git a/app/views/lunchflow_items/_lunchflow_item.html.erb b/app/views/lunchflow_items/_lunchflow_item.html.erb index a1aec313b..0b0fecc76 100644 --- a/app/views/lunchflow_items/_lunchflow_item.html.erb +++ b/app/views/lunchflow_items/_lunchflow_item.html.erb @@ -35,7 +35,7 @@ <% elsif lunchflow_item.sync_error.present? %>
- <%= render DS::Tooltip.new(text: lunchflow_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %> + <%= render DS::Tooltip.new(text: lunchflow_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive", as: :span) %> <%= tag.span t(".error"), class: "text-destructive" %>
<% else %> diff --git a/app/views/mercury_items/_mercury_item.html.erb b/app/views/mercury_items/_mercury_item.html.erb index 0159551e8..6485da548 100644 --- a/app/views/mercury_items/_mercury_item.html.erb +++ b/app/views/mercury_items/_mercury_item.html.erb @@ -35,7 +35,7 @@ <% elsif mercury_item.sync_error.present? %>
- <%= render DS::Tooltip.new(text: mercury_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %> + <%= render DS::Tooltip.new(text: mercury_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive", as: :span) %> <%= tag.span t(".error"), class: "text-destructive" %>
<% else %> diff --git a/app/views/simplefin_items/_simplefin_item.html.erb b/app/views/simplefin_items/_simplefin_item.html.erb index c6e90e810..d09d9b202 100644 --- a/app/views/simplefin_items/_simplefin_item.html.erb +++ b/app/views/simplefin_items/_simplefin_item.html.erb @@ -52,7 +52,7 @@ <% if has_warnings %>
<% if stats["accounts_skipped"].to_i > 0 %> - <%= render DS::Tooltip.new(text: t(".accounts_skipped_tooltip"), icon: "alert-triangle", size: "sm", color: "warning") %> + <%= render DS::Tooltip.new(text: t(".accounts_skipped_tooltip"), icon: "alert-triangle", size: "sm", color: "warning", as: :span) %> <%= t(".accounts_skipped_label", count: stats["accounts_skipped"].to_i) %> <% end %> @@ -63,14 +63,15 @@ text: (ago ? t(".rate_limited_ago", time: ago) : t(".rate_limited_recently")), icon: "clock", size: "sm", - color: "warning" + color: "warning", + as: :span ) %> <% end %> <% if stats["total_errors"].to_i > 0 || (stats["errors"].is_a?(Array) && stats["errors"].any?) %> <% tooltip_text = simplefin_error_tooltip(stats) %> <% if tooltip_text.present? %> - <%= render DS::Tooltip.new(text: tooltip_text, icon: "alert-octagon", size: "sm", color: "warning") %> + <%= render DS::Tooltip.new(text: tooltip_text, icon: "alert-octagon", size: "sm", color: "warning", as: :span) %> <% end %> <% end %>
@@ -119,7 +120,7 @@ <% elsif simplefin_item.sync_error.present? && !duplicate_only_errors %>
- <%= render DS::Tooltip.new(text: simplefin_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %> + <%= render DS::Tooltip.new(text: simplefin_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive", as: :span) %> <%= tag.span t(".error"), class: "text-destructive" %>
<% elsif duplicate_only_errors %> diff --git a/app/views/snaptrade_items/_snaptrade_item.html.erb b/app/views/snaptrade_items/_snaptrade_item.html.erb index f36712ec2..636d09dbe 100644 --- a/app/views/snaptrade_items/_snaptrade_item.html.erb +++ b/app/views/snaptrade_items/_snaptrade_item.html.erb @@ -42,7 +42,7 @@ <% elsif snaptrade_item.sync_error.present? %>
- <%= render DS::Tooltip.new(text: snaptrade_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %> + <%= render DS::Tooltip.new(text: snaptrade_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive", as: :span) %> <%= tag.span t(".error"), class: "text-destructive" %>
<% else %> diff --git a/app/views/sophtron_items/_sophtron_item.html.erb b/app/views/sophtron_items/_sophtron_item.html.erb index a8c71d900..d91450a86 100644 --- a/app/views/sophtron_items/_sophtron_item.html.erb +++ b/app/views/sophtron_items/_sophtron_item.html.erb @@ -41,7 +41,7 @@ <% elsif sophtron_item.sync_error.present? %>
- <%= render DS::Tooltip.new(text: sophtron_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %> + <%= render DS::Tooltip.new(text: sophtron_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive", as: :span) %> <%= tag.span t(".error"), class: "text-destructive" %>
<% else %> diff --git a/config/locales/views/components/en.yml b/config/locales/views/components/en.yml index 8c49e0461..11d896222 100644 --- a/config/locales/views/components/en.yml +++ b/config/locales/views/components/en.yml @@ -89,6 +89,8 @@ en: default_label: Preview dialog: close: Close + tooltip: + trigger_label: More info link: opens_in_new_tab: (opens in new tab) provider_sync_summary: diff --git a/test/components/previews/tooltip_component_preview.rb b/test/components/previews/tooltip_component_preview.rb index 68bd6c320..a6345ef0b 100644 --- a/test/components/previews/tooltip_component_preview.rb +++ b/test/components/previews/tooltip_component_preview.rb @@ -6,7 +6,8 @@ class TooltipComponentPreview < ViewComponent::Preview # @param icon text # @param size select [xs, sm, md, lg, xl, 2xl] # @param color select [default, white, success, warning, destructive, current] - def default(text: "This is helpful information", placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default") + # @param as select [button, span] + def default(text: "This is helpful information", placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default", as: "button") render DS::Tooltip.new( text: text, placement: placement, @@ -14,7 +15,8 @@ class TooltipComponentPreview < ViewComponent::Preview cross_axis: cross_axis, icon: icon, size: size, - color: color + color: color, + as: as.to_sym ) end