feat(retirement): PR4c DS::SelectableCard + bucket restyle

DS::SelectableCard — a checkbox rendered as a selectable card (whole card
toggles; brand-accent border + bg-surface when selected via peer-checked
on the sibling). Submits like a normal checkbox, so the bucket's
replace-all form is unchanged. Lookbook preview + component test.

Retirement bucket now renders each account as a DS::SelectableCard
(name · type · balance) instead of a bare checkbox row. Money stays
privacy-sensitive.
This commit is contained in:
Guillem Arias
2026-05-29 12:36:53 +02:00
parent ec6fc1d685
commit ec023cfe71
5 changed files with 76 additions and 6 deletions

View File

@@ -0,0 +1,14 @@
<label class="block cursor-pointer">
<%= check_box_tag name, value, checked, class: "peer sr-only", **opts %>
<div class="flex items-center justify-between gap-3 rounded-xl border-2 border-secondary bg-surface-inset p-4 transition-colors peer-checked:border-primary peer-checked:bg-surface peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-gray-400">
<div class="min-w-0">
<p class="text-primary text-sm font-medium truncate"><%= title %></p>
<% if subtitle %>
<p class="text-secondary text-xs truncate"><%= subtitle %></p>
<% end %>
</div>
<% if amount %>
<span class="text-primary text-sm tabular-nums shrink-0 privacy-sensitive"><%= amount %></span>
<% end %>
</div>
</label>

View File

@@ -0,0 +1,16 @@
# A checkbox rendered as a selectable card: the whole card toggles, with a
# brand-accent border + check glyph when selected. Used for the retirement
# bucket account picker. Submits like a normal checkbox (name[]/value).
class DS::SelectableCard < DesignSystemComponent
attr_reader :name, :value, :title, :subtitle, :amount, :checked, :opts
def initialize(name:, value:, title:, subtitle: nil, amount: nil, checked: false, **opts)
@name = name
@value = value
@title = title
@subtitle = subtitle
@amount = amount
@checked = checked
@opts = opts
end
end

View File

@@ -187,12 +187,14 @@
<%= form_with url: retirement_bucket_path, method: :patch, class: "space-y-3" do |form| %>
<div class="space-y-2">
<% @bucket_candidates.each do |account| %>
<label class="flex items-center gap-2 text-sm text-primary">
<%= check_box_tag "bucket[account_ids][]", account.id,
@bucket_account_ids.include?(account.id) %>
<span><%= account.name %></span>
<span class="text-secondary text-xs privacy-sensitive"><%= account.balance_money&.format %></span>
</label>
<%= render DS::SelectableCard.new(
name: "bucket[account_ids][]",
value: account.id,
title: account.name,
subtitle: account.accountable_type&.underscore&.humanize,
amount: account.balance_money&.format,
checked: @bucket_account_ids.include?(account.id)
) %>
<% end %>
</div>
<%= form.submit t("retirement.show.save_bucket"), class: "text-sm font-medium text-primary underline cursor-pointer" %>

View File

@@ -0,0 +1,25 @@
require "test_helper"
class DS::SelectableCardTest < ViewComponent::TestCase
test "renders a checkbox with title, subtitle, amount" do
render_inline(DS::SelectableCard.new(
name: "bucket[account_ids][]", value: "a1",
title: "Brokerage", subtitle: "ETF", amount: "$100,000"
))
assert_selector "input[type=checkbox][name='bucket[account_ids][]'][value='a1']", visible: false
assert_text "Brokerage"
assert_text "ETF"
assert_text "$100,000"
end
test "checked renders the checkbox checked" do
render_inline(DS::SelectableCard.new(name: "n", value: "v", title: "T", checked: true))
assert_selector "input[type=checkbox][checked]", visible: false
end
test "unchecked omits the checked attribute" do
render_inline(DS::SelectableCard.new(name: "n", value: "v", title: "T", checked: false))
assert_no_selector "input[type=checkbox][checked]", visible: false
end
end

View File

@@ -0,0 +1,13 @@
class DS::SelectableCardPreview < ViewComponent::Preview
# @param checked toggle
def default(checked: true)
render DS::SelectableCard.new(
name: "bucket[account_ids][]",
value: "abc",
title: "Vanguard FTSE All-World (VWCE)",
subtitle: "ETF",
amount: "$115,000",
checked: checked
)
end
end