Files
sure/app/components/DS/link.html.erb
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

483 B