Files
sure/app/components/DS/pill.rb
Guillem Arias Fauste 20844923e6 refactor(transactions): migrate 5 transaction badges to DS::Pill (#1751 PR B) (#1917)
Migrates the hand-rolled "Pending" / "Review recommended" / "Potential
duplicate" / "Split" badges across the transaction views to the
extended DS::Pill primitive from #1902.

**Visual contract for badge mode**

In #1902 the badge mode (`marker: false`) used `rounded-md` (chip shape)
because the marker mode does. But every existing pill / status badge
in the codebase uses `rounded-full` — see
`settings/providers/_status_pill.html.erb`,
`settings/providers/_maturity_badge.html.erb`, and the inline
transaction badges this PR is migrating. To keep the visual contract
consistent, this PR shifts `DS::Pill`'s badge mode to `rounded-full`
(marker mode stays `rounded-md`, unchanged from #1829). The shape
distinction now reads: markers are tags, badges are pills.

**Callsites migrated** (5):

- `app/views/transactions/_transaction.html.erb` — Pending,
  Review-recommended, Possible-duplicate, Split badges
- `app/views/transactions/_header.html.erb` — Pending badge
- `app/views/transactions/_split_parent_row.html.erb` — Split badge

**Tone mapping**

| Badge | Tone | Notes |
|---|---|---|
| Pending | `:neutral` | unchanged copy/icon, gains subtle DS-controlled bg |
| Review recommended | `:neutral` | matches existing `bg-surface-inset` look |
| Possible duplicate | `:warning` | DS semantic alias for the existing `text-warning` |
| Split | `:neutral` | matches existing `bg-surface-inset` look |

**Deferred to follow-up PRs**

- `app/views/transactions/_transfer_match.html.erb` — uses two
  responsive-visibility variants (`hidden lg:inline-flex` for long
  copy, `inline-flex lg:hidden` for short). DS::Pill currently has no
  `class:` arg for caller-controlled wrapper classes; deferring until
  that lands.
- `app/views/transactions/searches/filters/_badge.html.erb` — has a
  close button alongside the label (`button_to clear_filter_*`) and
  uses `rounded-3xl p-1.5` instead of a true pill. Closer to a
  removable filter chip — better fit for a separate `DS::FilterChip`
  primitive than for `DS::Pill`.

Refs #1751.
2026-05-23 08:46:55 +02:00

132 lines
6.6 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 leading-none"
]
if marker
# Marker mode (Beta / Canary / NEW): rounded-md (slight chip
# shape), 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 << "rounded-md 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):
# rounded-full pill shape (matches the existing convention used
# by `settings/providers/_status_pill`, `_maturity_badge`, and
# the inline transaction badges). Normal case, snaps to the
# design-system text scale (`text-xs` / `text-sm`).
base << "rounded-full"
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