From 8e3150fad74c1f4fa6a3a3816b17a697ee02d308 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Tue, 19 May 2026 12:32:13 +0200 Subject: [PATCH] fix(design-system): DS::Button a11y audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../tailwind/sure-design-system/base.css | 6 +++++- app/components/DS/button.rb | 21 +++++++++++++++++++ app/components/DS/buttonish.rb | 6 +++--- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/app/assets/tailwind/sure-design-system/base.css b/app/assets/tailwind/sure-design-system/base.css index 991cfc4ef..10a6bbd33 100644 --- a/app/assets/tailwind/sure-design-system/base.css +++ b/app/assets/tailwind/sure-design-system/base.css @@ -1,6 +1,10 @@ @layer base { button { - @apply cursor-pointer focus-visible:outline-gray-900; + @apply cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900; + + @variant theme-dark { + @apply focus-visible:outline-white; + } } hr { diff --git a/app/components/DS/button.rb b/app/components/DS/button.rb index a253c04c7..667b85e3d 100644 --- a/app/components/DS/button.rb +++ b/app/components/DS/button.rb @@ -33,6 +33,27 @@ class DS::Button < DS::Buttonish data = data.merge(turbo_frame: frame) end + # `content_tag(:button, ...)` defaults to `type="submit"` per the HTML + # spec — meaning a DS::Button rendered inside a form will steal Enter-key + # submission from the first text input. Default to `type="button"` so + # callers must opt into submit behavior explicitly. `button_to` (href + # branch) wraps the button in its own form, so submit there is correct. + if href.blank? + merged_opts[:type] ||= "button" + end + + # Icon-only buttons have no visible text node, so screen readers fall + # back to announcing "button" with no name. Derive a humanized fallback + # from the icon key so AT users hear *something* meaningful; explicit + # `aria: { label: }` on the caller still wins. + if icon_only? && icon.present? + aria = (merged_opts[:aria] || {}).symbolize_keys + if aria[:label].blank? && merged_opts[:"aria-label"].blank? + aria[:label] = icon.to_s.tr("-_", " ").capitalize + merged_opts[:aria] = aria + end + end + merged_opts.merge( class: class_names(container_classes, extra_classes), data: data diff --git a/app/components/DS/buttonish.rb b/app/components/DS/buttonish.rb index bd8894be6..f1e864511 100644 --- a/app/components/DS/buttonish.rb +++ b/app/components/DS/buttonish.rb @@ -10,7 +10,7 @@ class DS::Buttonish < DesignSystemComponent }, destructive: { container_classes: "text-inverse bg-red-500 theme-dark:bg-red-400 hover:bg-red-600 theme-dark:hover:bg-red-500 disabled:bg-red-200 theme-dark:disabled:bg-red-600", - icon_classes: "fg-white" + icon_classes: "text-inverse" }, outline: { container_classes: "text-primary border border-secondary bg-transparent hover:bg-surface-hover", @@ -43,13 +43,13 @@ class DS::Buttonish < DesignSystemComponent }, md: { container_classes: "px-3 py-2", - icon_container_classes: "inline-flex items-center justify-center w-9 h-9", + icon_container_classes: "inline-flex items-center justify-center w-11 h-11", radius_classes: "rounded-lg", text_classes: "text-sm" }, lg: { container_classes: "px-4 py-3", - icon_container_classes: "inline-flex items-center justify-center w-10 h-10", + icon_container_classes: "inline-flex items-center justify-center w-12 h-12", radius_classes: "rounded-xl", text_classes: "text-base" }