Files
sure/app/components/DS/button.rb
Guillem Arias 8e3150fad7 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.
2026-05-19 12:32:13 +02:00

63 lines
2.0 KiB
Ruby

# frozen_string_literal: true
# An extension to `button_to` helper. All options are passed through to the `button_to` helper with some additional
# options available.
class DS::Button < DS::Buttonish
attr_reader :confirm
def initialize(confirm: nil, **opts)
super(**opts)
@confirm = confirm
end
def container(&block)
if href.present?
button_to(href, **merged_opts, &block)
else
content_tag(:button, **merged_opts, &block)
end
end
private
def merged_opts
merged_opts = opts.dup || {}
extra_classes = merged_opts.delete(:class)
href = merged_opts.delete(:href)
data = merged_opts.delete(:data) || {}
if confirm.present?
data = data.merge(turbo_confirm: confirm.to_data_attribute)
end
if frame.present?
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
)
end
end