mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
* feat(design-system): extend DS::Pill with badge mode + semantic tones (#1751) Adds two extensions to the existing `DS::Pill` (originally landed as a stage marker primitive in #1829) so it can also serve as the shared status / category badge across the app — the use case tracked by #1751. **Badge mode (`marker: false`)** The original `DS::Pill` was intentionally sub-12px (text-[10px] / text-[11px]) + uppercase + tracking-wide so it reads as a marker (`Beta`, `Canary`, `NEW`), not a label. That shape is wrong for status badges where the surrounding context is regular UI copy and the pill needs to feel like a chip (`Pending`, `Active`, `Past due`, `Failed`). The new `marker: false` flag drops the uppercase + arbitrary sub-12px text and snaps the chrome to the DS text scale: - `marker: false, size: :sm` → `text-xs` (12px), normal case - `marker: false, size: :md` → `text-sm` (14px), normal case - `marker: true` (default) → existing #1829 behavior, unchanged **Semantic tone aliases** Status badges read more naturally with semantic tone names than with the underlying palette colors: | Alias | Resolves to | |---|---| | `:success` | `:green` | | `:warning` | `:amber` | | `:error` / `:destructive` | `:red` (new tone, added here) | | `:info` | `:indigo` | | `:neutral` | `:gray` | Visual-name tones (`:violet`, `:indigo`, `:fuchsia`, `:amber`, `:green`, `:gray`, `:red`) still work as before — semantic aliases resolve through `SEMANTIC_TONE_ALIASES` at component init time, so the callsite can pick whichever name reads better. Unknown tones still fall back to `:violet` (existing behavior). **Red palette** Adds the `:red` tone (palette already present in `design/tokens/sure.tokens.json` — `red-50/100/200/500/700/tint-10`). Needed for `:error` / `:destructive` status badges. **Icon slot** Adds an `icon:` option (already documented in the component's doc-comment as planned). When set, the Lucide glyph replaces the colored dot inside the pill — useful for status badges that read better with a glyph (`circle-check`, `triangle-alert`, `loader`, etc.) than the generic dot. **Scope** API + tests + Lookbook preview only. No callsite migrations in this PR — that's the next slice of #1751, done as separate per-bucket PRs (transaction badges, provider badges, misc) to keep diffs small. DS::Pill currently has no in-app callsites (#1829 shipped the primitive ahead of consumers), so this is a pure-additive change. Existing API is fully backwards-compatible — `marker:` defaults to `true`, so without that flag the pill renders exactly as it does today. * fix(test): use assert_no_selector for dot-suppression assertion `refute_selector ..., count: 1` only fails when there are exactly 1 matches — it would silently pass for 0 OR 2+. The intent is "no dots should render when an icon is set"; `assert_no_selector` strictly asserts zero matches. Flagged by coderabbit on #1902.
130 lines
6.4 KiB
Ruby
130 lines
6.4 KiB
Ruby
class DS::Pill < DesignSystemComponent
|
|
TONES = %i[violet indigo fuchsia amber green gray red].freeze
|
|
STYLES = %i[soft filled outline].freeze
|
|
SIZES = %i[sm md].freeze
|
|
|
|
# Semantic-name → visual-tone aliases. Lets callers say
|
|
# `tone: :success` instead of binding to the underlying palette name.
|
|
# The aliases live here (not on the caller) so the visual palette can
|
|
# be retuned without touching every callsite.
|
|
SEMANTIC_TONE_ALIASES = {
|
|
success: :green,
|
|
warning: :amber,
|
|
error: :red,
|
|
destructive: :red,
|
|
info: :indigo,
|
|
neutral: :gray
|
|
}.freeze
|
|
|
|
attr_reader :label, :tone, :style, :size, :show_dot, :dot_only, :title, :icon, :marker
|
|
|
|
# Generic inline pill primitive. Two modes:
|
|
#
|
|
# - `marker: true` (default) — the original shape from #1829: uppercase
|
|
# 10/11px text, tracking-wide. Reads as a stage marker (Beta, Canary,
|
|
# NEW, PRO, EXPERIMENTAL, …).
|
|
#
|
|
# - `marker: false` — normal case, snaps to the DS text scale
|
|
# (`text-xs` / `text-sm`). Reads as a status / category badge.
|
|
# Pair with semantic tones (`:success`, `:warning`, `:error`,
|
|
# `:info`, `:neutral`) for status badges; pair with visual tones
|
|
# (`:violet`, `:indigo`, etc.) for category tags.
|
|
#
|
|
# Other options:
|
|
#
|
|
# - `dot_only: true` renders only the colored dot (no label, no border).
|
|
# Use on the collapsed sidebar nav, where there's no room for the label.
|
|
# - `icon:` overrides the dot with a Lucide icon (sized xs, current color).
|
|
# Useful for status pills that benefit from a glyph (circle-check,
|
|
# triangle-alert, pause, etc.) rather than the generic dot.
|
|
# - Tones accept both visual names (`:violet`, `:amber`, …) and
|
|
# semantic aliases (`:success`, `:warning`, `:error`,
|
|
# `:destructive`, `:neutral`, `:info`). Aliases resolve via
|
|
# `SEMANTIC_TONE_ALIASES`.
|
|
# - Sure has full violet / indigo / fuchsia / amber / green / gray /
|
|
# red ramps in the design system; this component picks named tokens
|
|
# at render time. No raw hex.
|
|
def initialize(label: nil, tone: :violet, style: :soft, size: :sm, show_dot: true, dot_only: false, title: nil, icon: nil, marker: true)
|
|
resolved_tone = SEMANTIC_TONE_ALIASES.fetch(tone.to_sym, tone.to_sym)
|
|
@label = label || I18n.t("ds.pill.default_label", default: "Beta")
|
|
@tone = TONES.include?(resolved_tone) ? resolved_tone : :violet
|
|
@style = STYLES.include?(style.to_sym) ? style.to_sym : :soft
|
|
@size = SIZES.include?(size.to_sym) ? size.to_sym : :sm
|
|
@show_dot = show_dot
|
|
@dot_only = dot_only
|
|
@title = title
|
|
@icon = icon
|
|
@marker = marker
|
|
end
|
|
|
|
def palette
|
|
{
|
|
violet: { bg: "var(--color-violet-50)", bg_dark: "var(--color-violet-tint-10)", text: "var(--color-violet-700)", text_dark: "var(--color-violet-200)", border: "var(--color-violet-200)", dot: "var(--color-violet-500)", fill: "var(--color-violet-500)" },
|
|
indigo: { bg: "var(--color-indigo-50)", bg_dark: "var(--color-indigo-tint-10)", text: "var(--color-indigo-700)", text_dark: "var(--color-indigo-200)", border: "var(--color-indigo-200)", dot: "var(--color-indigo-500)", fill: "var(--color-indigo-500)" },
|
|
fuchsia: { bg: "var(--color-fuchsia-50)", bg_dark: "var(--color-fuchsia-tint-10)", text: "var(--color-fuchsia-700)", text_dark: "var(--color-fuchsia-200)", border: "var(--color-fuchsia-200)", dot: "var(--color-fuchsia-500)", fill: "var(--color-fuchsia-500)" },
|
|
amber: { bg: "var(--color-yellow-50)", bg_dark: "var(--color-yellow-tint-10)", text: "var(--color-yellow-700)", text_dark: "var(--color-yellow-200)", border: "var(--color-yellow-200)", dot: "var(--color-yellow-500)", fill: "var(--color-yellow-500)" },
|
|
green: { bg: "var(--color-green-50)", bg_dark: "var(--color-green-tint-10)", text: "var(--color-green-700)", text_dark: "var(--color-green-200)", border: "var(--color-green-200)", dot: "var(--color-green-500)", fill: "var(--color-green-500)" },
|
|
gray: { bg: "var(--color-gray-100)", bg_dark: "var(--color-gray-tint-10)", text: "var(--color-gray-700)", text_dark: "var(--color-gray-200)", border: "var(--color-gray-200)", dot: "var(--color-gray-500)", fill: "var(--color-gray-500)" },
|
|
red: { bg: "var(--color-red-50)", bg_dark: "var(--color-red-tint-10)", text: "var(--color-red-700)", text_dark: "var(--color-red-200)", border: "var(--color-red-200)", dot: "var(--color-red-500)", fill: "var(--color-red-500)" }
|
|
}[tone]
|
|
end
|
|
|
|
def dot_size_px
|
|
size == :md ? 6 : 5
|
|
end
|
|
|
|
def container_styles
|
|
p = palette
|
|
case style
|
|
when :filled
|
|
<<~CSS.strip.gsub(/\s+/, " ")
|
|
background-color: #{p[:fill]};
|
|
color: var(--color-white);
|
|
border-color: transparent;
|
|
CSS
|
|
when :outline
|
|
<<~CSS.strip.gsub(/\s+/, " ")
|
|
background-color: transparent;
|
|
color: light-dark(#{p[:text]}, #{p[:text_dark]});
|
|
border-color: light-dark(#{p[:border]}, color-mix(in oklab, #{p[:dot]} 40%, transparent));
|
|
CSS
|
|
else # :soft
|
|
<<~CSS.strip.gsub(/\s+/, " ")
|
|
background-color: light-dark(#{p[:bg]}, #{p[:bg_dark]});
|
|
color: light-dark(#{p[:text]}, #{p[:text_dark]});
|
|
border-color: light-dark(#{p[:border]}, color-mix(in oklab, #{p[:dot]} 20%, transparent));
|
|
CSS
|
|
end
|
|
end
|
|
|
|
def dot_color
|
|
style == :filled ? "rgba(255,255,255,0.85)" : palette[:dot]
|
|
end
|
|
|
|
def container_classes
|
|
base = [
|
|
"inline-flex items-center align-middle font-medium whitespace-nowrap shrink-0",
|
|
"border rounded-md",
|
|
"leading-none"
|
|
]
|
|
|
|
if marker
|
|
# Marker mode (Beta / Canary / NEW): uppercase, sub-12px text,
|
|
# wider tracking. text-[10/11px] stays as arbitrary values — the
|
|
# pill is intentionally sub-12px (Sure's smallest scale token is
|
|
# text-xs / 12px) so it reads as a marker, not a label. Padding /
|
|
# gap / tracking snap to Tailwind's scale to satisfy the
|
|
# design-system "no arbitrary values" rule.
|
|
base << "uppercase"
|
|
base << (size == :md ? "px-2 py-0.5 text-[11px] tracking-wide gap-1" : "px-1.5 py-0.5 text-[10px] tracking-wider gap-1")
|
|
else
|
|
# Badge mode (Pending / Active / Past due / category tag):
|
|
# normal case, snaps to the design-system text scale
|
|
# (`text-xs` / `text-sm`). Padding bumps slightly so the badge
|
|
# reads as a status chip rather than a sub-12px marker.
|
|
base << (size == :md ? "px-2 py-0.5 text-sm gap-1.5" : "px-1.5 py-0.5 text-xs gap-1")
|
|
end
|
|
class_names(*base)
|
|
end
|
|
end
|