mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 13:04:56 +00:00
* 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.
74 lines
2.8 KiB
Ruby
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
|