Files
sure/app/components/DS/link.rb
Guillem Arias Fauste f2782901d3 fix(design-system): DS::Link a11y — distinguishable default, icon-only label, external-link hardening (#1844)
* fix(design-system): DS::Link a11y — distinguishable default,
icon-only label, external-link hardening

Closes #1739. DS::Link extends Buttonish, so the styled variants
(`:primary`, `:secondary`, `:icon`, `:ghost`, etc.) inherit the
Buttonish styling pipeline. The `default` variant is the bare
inline link, which had multiple a11y gaps:

1. **WCAG 1.4.1 — color is not the only difference.** The default
   variant had `container_classes: ""`, so a link rendered as plain
   text-color text with no underline, no weight change, nothing.
   Color-only differentiation fails WCAG 1.4.1 for low-vision and
   colorblind users. Now: `text-link underline underline-offset-2
   hover:no-underline` — underlined at rest, underline removed on
   hover for a polish hint, plus the `text-link` token (blue-600
   light / blue-500 dark) for color.

2. **Focus ring.** `<a>` doesn't pick up the `button` focus rule
   from base.css (#1738). Add `focus-visible:outline-2
   outline-offset-2 outline-gray-900 theme-dark:outline-white`
   directly on the default variant. The Buttonish-derived variants
   render as buttons visually but as `<a>` in markup — out of scope
   here; covered by their own callsites styling.

3. **Icon-only accessible name.** Mirror the DS::Button fix from
   #1738: derive a humanized `aria-label` from the icon key when
   the caller doesn't provide one, so AT users hear "More
   horizontal" instead of just the URL.

4. **External-link hardening.** `target="_blank"` without
   `rel="noopener"` exposes `window.opener` to the new tab
   (reverse-tabnabbing). Always set `noopener noreferrer` when the
   target is `_blank`. Authors can override by passing `rel:`
   explicitly.

5. **sr-only "(opens in new tab)" hint.** Append an `sr-only` span
   after the link text when `target="_blank"` so AT users hear the
   navigation behavior. Visual indication (e.g. an external-link
   icon) stays at the caller's discretion.

Locale key: `ds.link.opens_in_new_tab` (en only — other locales in
a separate translation pass per repo norm).

API unchanged. No existing callsites use `target="_blank"` or
icon-only links, so no migration needed.

* fix(review): fold new-tab cue into icon-only aria-label

When an icon-only DS::Link also targets `_blank`, the generated
`aria-label` was overriding the descendant accessible name, masking
the sr-only "(opens in new tab)" span. Include the cue directly in
the generated label so AT users hear the warning. Also switch
`capitalize` to `humanize` so multi-word icon keys like
`external-link` read as "External link" rather than "External link"
already worked but `humanize` is the more idiomatic Rails choice and
keeps us aligned with the suggested patch.

Flagged by Codex P2 + CodeRabbit on PR #1844.

* fix(review): swap raw outline palette to alpha-ring tokens

Codex P1 follow-up after the ready-for-review transition: the default
\`DS::Link\` focus ring used raw \`outline-gray-900\` +
\`theme-dark:focus-visible:outline-white\`, which violates the DS-hygiene
rule that bans raw Tailwind palette utilities in component styling.

Swap to the established alpha-ring pattern already used by DS::Toggle
(#1843), DS::Tooltip (#1845), provider_card, and form-field —
\`focus-visible:ring-2 focus-visible:ring-alpha-black-300\` +
\`theme-dark:focus-visible:ring-alpha-white-300\`. Same visual contract
(WCAG 1.4.11), theme tokens centralized.
2026-05-20 18:16:20 +02:00

74 lines
2.8 KiB
Ruby

# An extension to `link_to` helper. All options are passed through to the `link_to` helper with some additional
# options available.
class DS::Link < DS::Buttonish
attr_reader :frame
VARIANTS = VARIANTS.reverse_merge(
default: {
# Underline + `text-link` so the link is distinguishable by more
# than color alone (WCAG 1.4.1). Focus ring uses the established
# alpha-ring DS pattern (also used by DS::Toggle, DS::Tooltip,
# provider_card, form-field) so theming stays centralized.
container_classes: "text-link underline underline-offset-2 hover:no-underline " \
"focus-visible:ring-2 focus-visible:ring-alpha-black-300 " \
"theme-dark:focus-visible:ring-alpha-white-300",
icon_classes: "text-secondary"
}
).freeze
def merged_opts
merged_opts = opts.dup || {}
data = merged_opts.delete(:data) || {}
if frame
data = data.merge(turbo_frame: frame)
end
# External link hardening: `target="_blank"` without `rel="noopener"`
# exposes window.opener to the new tab (reverse-tabnabbing). Always
# set `noopener noreferrer` when we send the user off-tab. Authors
# can override by passing `rel:` explicitly.
if merged_opts[:target].to_s == "_blank"
merged_opts[:rel] ||= "noopener noreferrer"
end
# Icon-only links have no visible text node, so screen readers fall
# back to announcing the href. Derive a humanized fallback from the
# icon key so AT users hear *something* meaningful; explicit
# `aria: { label: }` on the caller still wins. Mirrors DS::Button.
#
# When the link also opens in a new tab, fold the cue into the
# generated `aria-label` itself — `aria-label` overrides the
# descendant accessible name, so the sr-only "(opens in new tab)"
# span in the template would otherwise be masked.
if icon_only? && icon.present?
aria = (merged_opts[:aria] || {}).symbolize_keys
if aria[:label].blank? && merged_opts[:"aria-label"].blank?
label = icon.to_s.tr("-_", " ").humanize
if merged_opts[:target].to_s == "_blank"
label = "#{label} #{I18n.t("ds.link.opens_in_new_tab", default: "(opens in new tab)")}"
end
aria[:label] = label
merged_opts[:aria] = aria
end
end
merged_opts.merge(
class: class_names(container_classes, extra_classes),
data: data
)
end
# Render an sr-only suffix when the link opens in a new tab so AT
# users hear "(opens in new tab)" — visual is a separate concern
# (callers can render a `external-link` icon if they want a glyph).
def opens_in_new_tab?
opts[:target].to_s == "_blank"
end
private
def container_size_classes
super unless variant == :default
end
end