mirror of
https://github.com/we-promise/sure.git
synced 2026-05-25 05:24:57 +00:00
* fix(design-system): DS::FilledIcon decorative-vs-meaningful API Closes #1742. `DS::FilledIcon` is mostly used as a decorative visual indicator next to a textual label (transaction merchant avatar, recurring-transaction icon, payment-method tile, etc.). The wrapper was rendering without any aria scaffolding, so screen readers had to traverse the inner `<svg>` or single-letter `<span>` with no context. Two new kwargs: - `description:` (nil) — when set, the wrapper emits `role="img" aria-label="<description>"`. Use this when the surrounding DOM does not carry the label (e.g. icon-only badges in a grid). - `aria_hidden:` (auto) — defaults to `true` when `description:` is blank (= decorative), `false` when description is present. Pass explicitly to override for the rare case where you want the visual exposed but the name already lives in adjacent text. API stays backwards-compatible: existing 33 callsites get `aria-hidden="true"` by default, which is correct — the visible text next to the icon already carries the name. While here: doc the `:text` variant gotcha — only `text.first` is rendered, so AT users hearing "A" can't infer "Apple". Callers should pass the full `description:` when relying on this variant. Out of scope (filed elsewhere if needed): - Touch-target audit (decorative wrapper, WCAG 2.5.5 doesn't apply). - `hex_color:` palette soft-validation (would require a token-name registry; deferred until #1736 / #1653 land). - `color-mix(in oklab, ...)` browser-support note for the transparent variant — tier-2 concern. * fix(review): gate role/aria-label when hidden, normalize blank description CodeRabbit feedback on #1842: - Avoid emitting role="img" and aria-label alongside aria-hidden="true" (dead markup; AT ignores semantics on hidden subtrees). - Normalize blank description strings to nil via .presence so the default aria_hidden fallback treats "" the same as nil. * fix(review): use description.presence so aria-label drops blank strings Codex follow-up review 4319747515 caught that the prior fix still emitted `aria-label=""` when description was a blank string. `.presence` returns nil for blank — Rails `tag.div` drops the attribute entirely when the value is nil.
113 lines
2.9 KiB
Ruby
113 lines
2.9 KiB
Ruby
class DS::FilledIcon < DesignSystemComponent
|
|
attr_reader :icon, :text, :hex_color, :size, :rounded, :variant, :description, :aria_hidden
|
|
|
|
VARIANTS = %i[default text surface container inverse].freeze
|
|
|
|
SIZES = {
|
|
sm: {
|
|
container_size: "w-6 h-6",
|
|
container_radius: "rounded-md",
|
|
icon_size: "sm",
|
|
text_size: "text-xs"
|
|
},
|
|
md: {
|
|
container_size: "w-8 h-8",
|
|
container_radius: "rounded-lg",
|
|
icon_size: "md",
|
|
text_size: "text-xs"
|
|
},
|
|
lg: {
|
|
container_size: "w-9 h-9",
|
|
container_radius: "rounded-xl",
|
|
icon_size: "lg",
|
|
text_size: "text-sm"
|
|
}
|
|
}.freeze
|
|
|
|
# `description:` makes the icon meaningful — emits `role="img"` +
|
|
# `aria-label=description` so AT users hear it. Without `description:`,
|
|
# the wrapper defaults to `aria-hidden="true"` (decorative) on the
|
|
# assumption that adjacent DOM carries the accessible name. Pass
|
|
# `aria_hidden: false` if you want the visual exposed but the name
|
|
# already lives in surrounding text (rare).
|
|
#
|
|
# NOTE on the `:text` variant: only `text.first` is rendered (e.g.
|
|
# "Apple" → "A"). The single letter is decorative — relying on AT
|
|
# users to infer "Apple" from "A" is broken. Use `description:` to
|
|
# surface the full label, or ensure the adjacent text node carries it.
|
|
def initialize(variant: :default, icon: nil, text: nil, hex_color: nil, size: "md", rounded: false, description: nil, aria_hidden: nil)
|
|
@variant = variant.to_sym
|
|
@icon = icon
|
|
@text = text
|
|
@hex_color = hex_color
|
|
@size = size.to_sym
|
|
@rounded = rounded
|
|
@description = description.presence
|
|
@aria_hidden = aria_hidden.nil? ? @description.blank? : aria_hidden
|
|
end
|
|
|
|
def container_classes
|
|
class_names(
|
|
"flex justify-center items-center shrink-0",
|
|
size_classes,
|
|
radius_classes,
|
|
transparent? ? "border" : solid_bg_class
|
|
)
|
|
end
|
|
|
|
def icon_size
|
|
SIZES[size][:icon_size]
|
|
end
|
|
|
|
def text_classes
|
|
class_names(
|
|
"text-center font-medium uppercase",
|
|
SIZES[size][:text_size]
|
|
)
|
|
end
|
|
|
|
def container_styles
|
|
<<~STYLE.strip
|
|
background-color: #{transparent_bg_color};
|
|
border-color: #{transparent_border_color};
|
|
color: #{custom_fg_color};
|
|
STYLE
|
|
end
|
|
|
|
def transparent?
|
|
variant.in?(%i[default text])
|
|
end
|
|
|
|
private
|
|
def solid_bg_class
|
|
case variant
|
|
when :surface
|
|
"bg-surface-inset"
|
|
when :container
|
|
"bg-container-inset"
|
|
when :inverse
|
|
"bg-container"
|
|
end
|
|
end
|
|
|
|
def size_classes
|
|
SIZES[size][:container_size]
|
|
end
|
|
|
|
def radius_classes
|
|
rounded ? "rounded-full" : SIZES[size][:container_radius]
|
|
end
|
|
|
|
def custom_fg_color
|
|
hex_color || "var(--color-gray-500)"
|
|
end
|
|
|
|
def transparent_bg_color
|
|
"color-mix(in oklab, #{custom_fg_color} 10%, transparent)"
|
|
end
|
|
|
|
def transparent_border_color
|
|
"color-mix(in oklab, #{custom_fg_color} 10%, transparent)"
|
|
end
|
|
end
|