mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
Merge origin/main into feat/goals-v2-architecture
Pulls in #1857 (DS::Disclosure :card_inset), #1858 (:inline variant), #1902 (DS::Pill marker:false + semantic tones + :red palette), #1903 (settings/debugs token fix), plus #1878 (entry.date guard) and other minor fixes that landed. Resolves one conflict in app/components/DS/pill.rb: takes main's new extended API (marker: flag, SEMANTIC_TONE_ALIASES, :red tone, updated docstring) and preserves the goals-branch color-mix(...30% black) text treatment that was added for light-mode contrast. Applies the same color-mix to the new :red tone for consistency.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<details class="<%= details_classes %>" <%= "open" if open %>>
|
||||
<%= tag.details class: details_classes, open: open, **details_opts do %>
|
||||
<%= tag.summary class: summary_classes do %>
|
||||
<% if summary_content? %>
|
||||
<%# `<summary>` is `display: list-item`, so a flex inner div would
|
||||
@@ -28,4 +28,4 @@
|
||||
<div class="mt-2">
|
||||
<%= content %>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
|
||||
@@ -1,46 +1,77 @@
|
||||
class DS::Disclosure < DesignSystemComponent
|
||||
renders_one :summary_content
|
||||
|
||||
VARIANTS = %i[default card].freeze
|
||||
VARIANTS = %i[default card card_inset inline].freeze
|
||||
|
||||
attr_reader :title, :align, :open, :variant, :opts
|
||||
|
||||
# `:default` — bg-surface summary, no chrome on the `<details>`. Use
|
||||
# for inline expanders inside a parent card.
|
||||
# for inline expanders that sit inside a parent card (the summary
|
||||
# itself reads as the surface).
|
||||
#
|
||||
# `:card` — `<details>` itself becomes a `bg-container shadow-border-xs
|
||||
# rounded-xl` card; the summary inherits the container (no own bg).
|
||||
# Use for provider-item rows (binance, lunchflow, plaid, etc.) where
|
||||
# each card is the surface and the summary is custom rich content.
|
||||
# Callers in `:card` mode should pass their own `summary_content`
|
||||
# slot; the built-in title rendering assumes the `:default` shape.
|
||||
#
|
||||
# `:card_inset` — `<details>` is `bg-surface-inset rounded-xl` (no
|
||||
# shadow). Use for inset sub-panels inside a parent card surface
|
||||
# (e.g. the IBKR flex-query "report details" panel embedded inside
|
||||
# the IBKR settings flow). Same summary contract as `:card`.
|
||||
#
|
||||
# `:inline` — no surface, no padding, no shadow. The disclosure reads
|
||||
# as a plain text-link-style toggle (e.g. "Alternative auth" inside
|
||||
# a form, or a "Manage connections" lazy-load opener). Caller provides
|
||||
# the summary text (and optional chevron) via the `summary_content`
|
||||
# slot.
|
||||
#
|
||||
# In card / inline variants, callers should pass their own
|
||||
# `summary_content` slot; the built-in title rendering assumes the
|
||||
# `:default` shape.
|
||||
def initialize(title: nil, align: "right", open: false, variant: :default, **opts)
|
||||
@title = title
|
||||
@align = align.to_sym
|
||||
@open = open
|
||||
@variant = variant.to_sym
|
||||
@variant = variant&.to_sym
|
||||
@opts = opts
|
||||
|
||||
raise ArgumentError, "Invalid variant: #{@variant}. Must be one of #{VARIANTS.inspect}" unless VARIANTS.include?(@variant)
|
||||
raise ArgumentError, "Invalid variant: #{@variant.inspect}. Must be one of #{VARIANTS.inspect}" unless VARIANTS.include?(@variant)
|
||||
end
|
||||
|
||||
def details_classes
|
||||
case variant
|
||||
base = case variant
|
||||
when :card
|
||||
"group bg-container p-4 shadow-border-xs rounded-xl"
|
||||
when :card_inset
|
||||
"group bg-surface-inset rounded-xl p-4"
|
||||
else
|
||||
"group"
|
||||
end
|
||||
|
||||
class_names(base, opts[:class])
|
||||
end
|
||||
|
||||
# `opts` minus the `:class` key, since `details_classes` merges that
|
||||
# separately to avoid duplicate-keyword collisions when forwarding to
|
||||
# `tag.details`.
|
||||
def details_opts
|
||||
opts.except(:class)
|
||||
end
|
||||
|
||||
def summary_classes
|
||||
case variant
|
||||
when :card
|
||||
# Card variant: no bg on summary — the parent details *is* the
|
||||
when :card, :card_inset
|
||||
# Card variants: no bg on summary — the parent details *is* the
|
||||
# surface. Keep cursor + focus-visible ring + flex baseline.
|
||||
# Ring token matches `settings/provider_card.html.erb` (the
|
||||
# established focus pattern on container cards).
|
||||
"list-none cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300 rounded-xl"
|
||||
when :inline
|
||||
# Inline variant: no surface, no padding — the summary reads as
|
||||
# plain text-link copy. Caller markup (text + optional chevron)
|
||||
# provides the visual. Keep cursor + focus-visible ring + matching
|
||||
# alpha-black-300 token used by the card variants for consistency.
|
||||
"list-none cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300 rounded-sm"
|
||||
else
|
||||
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300"
|
||||
end
|
||||
|
||||
@@ -1,31 +1,60 @@
|
||||
class DS::Pill < DesignSystemComponent
|
||||
TONES = %i[violet indigo fuchsia amber green gray].freeze
|
||||
TONES = %i[violet indigo fuchsia amber green gray red].freeze
|
||||
STYLES = %i[soft filled outline].freeze
|
||||
SIZES = %i[sm md].freeze
|
||||
|
||||
attr_reader :label, :tone, :style, :size, :show_dot, :dot_only, :title, :icon
|
||||
# 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
|
||||
|
||||
# Generic inline pill primitive. Used for Beta / Canary markers and goal
|
||||
# status badges, but designed so any future tag (NEW, PRO, EXPERIMENTAL,
|
||||
# etc.) reuses the same shape without forking.
|
||||
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.
|
||||
# - Sure has full violet / indigo / fuchsia / amber / green / gray 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)
|
||||
# - 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?(tone.to_sym) ? tone.to_sym : :violet
|
||||
@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
|
||||
@@ -39,7 +68,8 @@ class DS::Pill < DesignSystemComponent
|
||||
fuchsia: { bg: "var(--color-fuchsia-50)", bg_dark: "var(--color-fuchsia-tint-10)", text: "color-mix(in oklab, var(--color-fuchsia-700), black 30%)", 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: "color-mix(in oklab, var(--color-yellow-700), black 30%)", 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: "color-mix(in oklab, var(--color-green-700), black 30%)", 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: "color-mix(in oklab, var(--color-gray-700), black 30%)", text_dark: "var(--color-gray-200)", border: "var(--color-gray-200)", dot: "var(--color-gray-500)", fill: "var(--color-gray-500)" }
|
||||
gray: { bg: "var(--color-gray-100)", bg_dark: "var(--color-gray-tint-10)", text: "color-mix(in oklab, var(--color-gray-700), black 30%)", 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: "color-mix(in oklab, var(--color-red-700), black 30%)", text_dark: "var(--color-red-200)", border: "var(--color-red-200)", dot: "var(--color-red-500)", fill: "var(--color-red-500)" }
|
||||
}[tone]
|
||||
end
|
||||
|
||||
@@ -77,15 +107,27 @@ class DS::Pill < DesignSystemComponent
|
||||
|
||||
def container_classes
|
||||
base = [
|
||||
"inline-flex items-center align-middle font-medium uppercase whitespace-nowrap shrink-0",
|
||||
"inline-flex items-center align-middle font-medium whitespace-nowrap shrink-0",
|
||||
"border rounded-md",
|
||||
"leading-none"
|
||||
]
|
||||
# text-[10/11px] stays as arbitrary values: the pill is intentionally
|
||||
# sub-12px (Sure's smallest scale token is text-xs / 12px) to read as
|
||||
# a marker, not a label. Padding / gap / tracking snap to Tailwind's
|
||||
# scale to satisfy the design-system "no arbitrary values" rule.
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<% if enable_banking_item.requires_update? %>
|
||||
<%= button_to reauthorize_enable_banking_item_path(enable_banking_item),
|
||||
method: :post,
|
||||
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg text-white bg-warning hover:opacity-90 transition-colors",
|
||||
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg text-inverse bg-warning hover:opacity-90 transition-colors",
|
||||
data: { turbo: false } do %>
|
||||
<%= icon "refresh-cw", size: "sm" %>
|
||||
<%= t(".update") %>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %>
|
||||
|
||||
<div class="flex items-center justify-center h-8 w-8 bg-gray-tint-10 rounded-full">
|
||||
<div class="flex items-center justify-center h-8 w-8 bg-container-inset rounded-full">
|
||||
<div class="flex items-center justify-center">
|
||||
<%= tag.p indexa_capital_item.name.first.upcase, class: "text-primary text-xs font-medium" %>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<div class="flex-1 text-sm">
|
||||
<div class="font-medium text-primary"><%= entry.name %></div>
|
||||
<div class="text-xs text-secondary">
|
||||
<%= I18n.l(entry.date, format: :short) %> •
|
||||
<%= entry.date ? I18n.l(entry.date, format: :short) : "—" %> •
|
||||
<%= number_to_currency(entry.amount.abs, unit: Money::Currency.new(entry.currency).symbol) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<%= tag.p plaid_item.name, class: "font-medium text-primary" %>
|
||||
<% if plaid_item.scheduled_for_deletion? %>
|
||||
<p class="text-destructive text-sm animate-pulse">(deletion in progress...)</p>
|
||||
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if plaid_item.syncing? %>
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, actions: nil, badge: nil) %>
|
||||
<% if collapsible %>
|
||||
<details <%= "open" if open %>
|
||||
class="group bg-container shadow-border-xs rounded-xl p-4"
|
||||
<%= "data-controller=\"auto-open\" data-auto-open-param-value=\"#{h(auto_open_param)}\"".html_safe if auto_open_param.present? %>>
|
||||
<summary class="flex items-center justify-between gap-2 cursor-pointer rounded-lg list-none [&::-webkit-details-marker]:hidden">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h2 class="text-lg font-medium text-primary"><%= title %></h2>
|
||||
<%= badge if badge.present? %>
|
||||
<%= render DS::Disclosure.new(
|
||||
variant: :card,
|
||||
open: open,
|
||||
data: auto_open_param.present? ? { controller: "auto-open", auto_open_param_value: auto_open_param } : nil
|
||||
) do |disclosure| %>
|
||||
<% disclosure.with_summary_content do %>
|
||||
<div class="flex items-center justify-between gap-2 w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "chevron-right", class: "text-secondary group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h2 class="text-lg font-medium text-primary"><%= title %></h2>
|
||||
<%= badge if badge.present? %>
|
||||
</div>
|
||||
<% if subtitle.present? %>
|
||||
<p class="text-secondary text-sm"><%= subtitle %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if subtitle.present? %>
|
||||
<p class="text-secondary text-sm"><%= subtitle %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if status.present? %>
|
||||
<div class="flex items-center gap-2 shrink-0 group-open:hidden">
|
||||
<% if meta.present? %>
|
||||
<span class="text-xs text-subdued"><%= meta %></span>
|
||||
<% end %>
|
||||
<%= status %>
|
||||
<%= actions if actions.present? %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if status.present? %>
|
||||
<div class="flex items-center gap-2 shrink-0 group-open:hidden">
|
||||
<% if meta.present? %>
|
||||
<span class="text-xs text-subdued"><%= meta %></span>
|
||||
<% end %>
|
||||
<%= status %>
|
||||
<%= actions if actions.present? %>
|
||||
</div>
|
||||
<% end %>
|
||||
</summary>
|
||||
<% end %>
|
||||
<div class="space-y-4 mt-4">
|
||||
<%= content %>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<section class="bg-container shadow-border-xs rounded-xl p-4 space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<% if @debug_log_entries.any? %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-surface-default border-b border-primary">
|
||||
<thead class="bg-surface border-b border-primary">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-secondary uppercase"><%= t(".table.time") %></th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-secondary uppercase"><%= t(".table.level") %></th>
|
||||
@@ -70,7 +70,7 @@
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-secondary uppercase"><%= t(".table.metadata") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tbody class="divide-y divide-alpha-black-200 theme-dark:divide-alpha-white-200">
|
||||
<% @debug_log_entries.each do |entry| %>
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm text-primary whitespace-nowrap"><%= l(entry.created_at, format: :long) %></td>
|
||||
|
||||
@@ -13,17 +13,17 @@
|
||||
t(".steps.step_5")
|
||||
] %>
|
||||
|
||||
<details class="group bg-surface-inset rounded-xl p-4">
|
||||
<summary class="list-none cursor-pointer [&::-webkit-details-marker]:hidden">
|
||||
<%= render DS::Disclosure.new(variant: :card_inset) do |disclosure| %>
|
||||
<% disclosure.with_summary_content do %>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-subdued tracking-wider mb-2"><%= t(".flex_query_details.eyebrow") %></p>
|
||||
<p class="text-sm font-medium text-primary"><%= t(".flex_query_details.title") %></p>
|
||||
<p class="text-xs text-secondary mt-1"><%= t(".flex_query_details.summary") %></p>
|
||||
</div>
|
||||
<%= icon "chevron-down", class: "mt-0.5 text-secondary transition-transform group-open:rotate-180" %>
|
||||
<%= icon "chevron-down", class: "mt-0.5 text-secondary group-open:rotate-180 motion-safe:transition-transform motion-safe:duration-150" %>
|
||||
</div>
|
||||
</summary>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-4 space-y-4 text-sm text-secondary">
|
||||
<div class="space-y-2">
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
<p class="text-xs text-secondary"><%= t(".report_window_note") %></p>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
|
||||
<%
|
||||
ibkr_item = Current.family.ibkr_items.first_or_initialize(name: "Interactive Brokers")
|
||||
|
||||
@@ -29,10 +29,12 @@
|
||||
type: :password %>
|
||||
<p class="text-xs text-secondary px-1 -mt-1"><%= t("indexa_capital_items.panel.fields.api_token.description") %></p>
|
||||
|
||||
<details class="group">
|
||||
<summary class="text-sm text-secondary cursor-pointer hover:text-primary transition-colors">
|
||||
<%= t("indexa_capital_items.panel.alternative_auth") %>
|
||||
</summary>
|
||||
<%= render DS::Disclosure.new(variant: :inline) do |disclosure| %>
|
||||
<% disclosure.with_summary_content do %>
|
||||
<span class="text-sm text-secondary hover:text-primary transition-colors">
|
||||
<%= t("indexa_capital_items.panel.alternative_auth") %>
|
||||
</span>
|
||||
<% end %>
|
||||
<div class="mt-3 space-y-3 pt-3 border-t border-primary">
|
||||
<%= form.text_field :username,
|
||||
label: t("indexa_capital_items.panel.fields.username.label"),
|
||||
@@ -49,7 +51,7 @@
|
||||
placeholder: is_new_record ? t("indexa_capital_items.panel.fields.password.placeholder_new") : t("indexa_capital_items.panel.fields.password.placeholder_update"),
|
||||
type: :password %>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<%= form.submit is_new_record ? t("indexa_capital_items.panel.save_button") : t("indexa_capital_items.panel.update_button"),
|
||||
|
||||
@@ -57,25 +57,31 @@
|
||||
<% if items&.any? && items.first.user_registered? %>
|
||||
<% item = items.first %>
|
||||
<div class="border-t border-primary pt-4 mt-4">
|
||||
<details class="group"
|
||||
data-controller="lazy-load"
|
||||
data-action="toggle->lazy-load#toggled"
|
||||
data-lazy-load-url-value="<%= connections_snaptrade_item_path(item) %>"
|
||||
data-lazy-load-auto-open-param-value="manage">
|
||||
<summary class="flex items-center justify-between cursor-pointer list-none [&::-webkit-details-marker]:hidden">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %>
|
||||
<% if item.unlinked_accounts_count > 0 %>
|
||||
<span class="text-warning">(<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>)</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<%= render DS::Disclosure.new(
|
||||
variant: :inline,
|
||||
data: {
|
||||
controller: "lazy-load",
|
||||
action: "toggle->lazy-load#toggled",
|
||||
lazy_load_url_value: connections_snaptrade_item_path(item),
|
||||
lazy_load_auto_open_param_value: "manage"
|
||||
}
|
||||
) do |disclosure| %>
|
||||
<% disclosure.with_summary_content do %>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %>
|
||||
<% if item.unlinked_accounts_count > 0 %>
|
||||
<span class="text-warning">(<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>)</span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<span class="flex items-center gap-1 text-sm text-secondary hover:text-primary">
|
||||
<%= t("providers.snaptrade.manage_connections") %>
|
||||
<%= icon "chevron-right", class: "w-3 h-3 group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %>
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex items-center gap-1 text-sm text-secondary hover:text-primary">
|
||||
<%= t("providers.snaptrade.manage_connections") %>
|
||||
<%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %>
|
||||
</span>
|
||||
</summary>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-3 space-y-3" data-lazy-load-target="content">
|
||||
<div data-lazy-load-target="loading" class="flex items-center gap-2 text-sm text-secondary py-2">
|
||||
@@ -86,7 +92,7 @@
|
||||
<div data-lazy-load-target="frame">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
|
||||
<span class="text-sm text-secondary">
|
||||
<%= I18n.l(entry.date, format: :long) %>
|
||||
<%= entry.date ? I18n.l(entry.date, format: :long) : "—" %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-secondary">
|
||||
<%= I18n.l(entry.date, format: :long) %>
|
||||
<%= entry.date ? I18n.l(entry.date, format: :long) : "—" %>
|
||||
</span>
|
||||
<% if entry.transaction.pending? %>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-secondary text-secondary" title="<%= t("transactions.transaction.pending_tooltip") %>">
|
||||
|
||||
@@ -19,6 +19,6 @@
|
||||
</div>
|
||||
|
||||
<span class="text-sm text-secondary">
|
||||
<%= I18n.l(entry.date, format: :long) %>
|
||||
<%= entry.date ? I18n.l(entry.date, format: :long) : "—" %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
@@ -15,6 +15,7 @@ en:
|
||||
connection_lost_description: This connection is no longer valid. You'll need
|
||||
to delete this connection and add it again to continue syncing data.
|
||||
delete: Delete
|
||||
deletion_in_progress: (deletion in progress...)
|
||||
error: Error occurred while syncing data
|
||||
no_accounts_description: We could not load any accounts from this financial
|
||||
institution.
|
||||
|
||||
59
test/components/DS/pill_test.rb
Normal file
59
test/components/DS/pill_test.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
require "test_helper"
|
||||
|
||||
class DS::PillTest < ViewComponent::TestCase
|
||||
test "marker mode (default) renders uppercase sub-12px chrome" do
|
||||
render_inline(DS::Pill.new(label: "Beta", tone: :violet))
|
||||
|
||||
pill = page.find("span", text: "Beta")
|
||||
assert_includes pill[:class], "uppercase"
|
||||
# Marker keeps sub-12px text via arbitrary value (intentional — see component docs).
|
||||
assert_match(/text-\[1[01]px\]/, pill[:class])
|
||||
end
|
||||
|
||||
test "marker: false renders normal-case DS-scale chrome" do
|
||||
render_inline(DS::Pill.new(label: "Active", tone: :success, marker: false))
|
||||
|
||||
pill = page.find("span", text: "Active")
|
||||
refute_includes pill[:class], "uppercase"
|
||||
# Badge mode snaps to text-xs / text-sm — no sub-12px arbitrary values.
|
||||
assert_match(/text-(xs|sm)/, pill[:class])
|
||||
refute_match(/text-\[1[01]px\]/, pill[:class])
|
||||
end
|
||||
|
||||
test "semantic tone aliases resolve to visual palette tones" do
|
||||
{
|
||||
success: :green,
|
||||
warning: :amber,
|
||||
error: :red,
|
||||
destructive: :red,
|
||||
info: :indigo,
|
||||
neutral: :gray
|
||||
}.each do |alias_name, expected_visual|
|
||||
pill = DS::Pill.new(label: "x", tone: alias_name)
|
||||
assert_equal expected_visual, pill.tone, "Expected #{alias_name} → #{expected_visual}, got #{pill.tone}"
|
||||
end
|
||||
end
|
||||
|
||||
test "unknown tone falls back to violet" do
|
||||
pill = DS::Pill.new(label: "x", tone: :nonexistent)
|
||||
assert_equal :violet, pill.tone
|
||||
end
|
||||
|
||||
test "red tone palette resolves to red-* tokens" do
|
||||
pill = DS::Pill.new(label: "Failed", tone: :error)
|
||||
assert_includes pill.palette[:dot], "color-red-500"
|
||||
assert_includes pill.palette[:bg], "color-red-50"
|
||||
end
|
||||
|
||||
test "icon option renders glyph in place of dot" do
|
||||
render_inline(DS::Pill.new(label: "Syncing", tone: :info, marker: false, icon: "loader"))
|
||||
|
||||
# Lucide icon helper renders the inline SVG; verifying we see at least one <svg>
|
||||
# is enough — the icon helper is covered by its own tests.
|
||||
assert_selector "svg"
|
||||
# And the dot is suppressed when an icon takes its place. `refute_selector
|
||||
# ..., count: N` only fails when there are exactly N matches, so use
|
||||
# `assert_no_selector` to strictly assert zero dots.
|
||||
assert_no_selector "span.rounded-full[style*='background-color']"
|
||||
end
|
||||
end
|
||||
@@ -1,26 +1,68 @@
|
||||
class PillComponentPreview < ViewComponent::Preview
|
||||
# @param tone select ["violet", "indigo", "fuchsia", "amber", "gray"]
|
||||
# @param tone select ["violet", "indigo", "fuchsia", "amber", "green", "gray", "red", "success", "warning", "error", "info", "neutral"]
|
||||
# @param style select ["soft", "filled", "outline"]
|
||||
# @param size select ["sm", "md"]
|
||||
# @param label text
|
||||
# @param show_dot toggle
|
||||
# @param dot_only toggle
|
||||
def default(tone: "violet", style: "soft", size: "sm", label: "Preview", show_dot: true, dot_only: false)
|
||||
# @param marker toggle
|
||||
# @param icon text
|
||||
def default(tone: "violet", style: "soft", size: "sm", label: "Preview", show_dot: true, dot_only: false, marker: true, icon: nil)
|
||||
render DS::Pill.new(
|
||||
label: label,
|
||||
tone: tone.to_sym,
|
||||
style: style.to_sym,
|
||||
size: size.to_sym,
|
||||
show_dot: show_dot,
|
||||
dot_only: dot_only
|
||||
dot_only: dot_only,
|
||||
marker: marker,
|
||||
icon: icon.presence
|
||||
)
|
||||
end
|
||||
|
||||
# @!group Stage markers (marker: true — original #1829 shape)
|
||||
def canary
|
||||
render DS::Pill.new(label: "Canary", tone: :fuchsia)
|
||||
end
|
||||
|
||||
def beta
|
||||
render DS::Pill.new(label: "Beta", tone: :violet)
|
||||
end
|
||||
|
||||
def new_marker
|
||||
render DS::Pill.new(label: "New", tone: :indigo)
|
||||
end
|
||||
|
||||
def dot_only_collapsed_sidebar
|
||||
render DS::Pill.new(dot_only: true, tone: :violet)
|
||||
end
|
||||
# @!endgroup
|
||||
|
||||
# @!group Status badges (marker: false, semantic tones)
|
||||
def status_active
|
||||
render DS::Pill.new(label: "Active", tone: :success, marker: false)
|
||||
end
|
||||
|
||||
def status_pending
|
||||
render DS::Pill.new(label: "Pending", tone: :warning, marker: false)
|
||||
end
|
||||
|
||||
def status_failed
|
||||
render DS::Pill.new(label: "Failed", tone: :error, marker: false, icon: "circle-alert")
|
||||
end
|
||||
|
||||
def status_archived
|
||||
render DS::Pill.new(label: "Archived", tone: :neutral, marker: false)
|
||||
end
|
||||
|
||||
def status_info
|
||||
render DS::Pill.new(label: "Syncing", tone: :info, marker: false, icon: "loader")
|
||||
end
|
||||
# @!endgroup
|
||||
|
||||
# @!group Sizes (md)
|
||||
def status_md
|
||||
render DS::Pill.new(label: "Past due", tone: :error, marker: false, size: :md)
|
||||
end
|
||||
# @!endgroup
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user