feat(savings_goals/new): live previewable name avatar + ghost cancel + circular header icon

Replace the big square DS::FilledIcon next to the name input with a
small Savings::GoalAvatarComponent that previews the goal's avatar
(seeded color + first character of the typed name, updates live via
new stepper#nameChanged action).

Switch the modal header's target avatar from FilledIcon(size: lg,
rounded: false) → (size: md, rounded: true) — matches the goal-avatar
shape used elsewhere on the page.

Replace the hand-rolled <button> for Cancel/Back with DS::Button
variant: "ghost". Stepper now drills into the button's inner span to
swap the label, same pattern already used for the Continue/Create
button on the right.

Drop the now-unused footerLeftLabel target.
This commit is contained in:
Guillem Arias
2026-05-11 14:59:26 +02:00
parent c04b306bfd
commit e3dd1c4c1e
3 changed files with 29 additions and 15 deletions

View File

@@ -15,6 +15,9 @@ export default class extends Controller {
"step2Circle",
"stepperLine",
"modalSubtitle",
"nameInput",
"amountInput",
"avatarPreview",
"linkedAccountCheckbox",
"initialContributionAmount",
"initialContributionAccountSelect",
@@ -23,7 +26,6 @@ export default class extends Controller {
"reviewAccounts",
"reviewSuggested",
"footerLeftButton",
"footerLeftLabel",
"footerRightButton",
"submitButton",
];
@@ -91,6 +93,14 @@ export default class extends Controller {
this.updateReview();
}
nameChanged() {
if (!this.hasAvatarPreviewTarget || !this.hasNameInputTarget) return;
const name = this.nameInputTarget.value.trim();
const initial = name ? name.charAt(0).toUpperCase() : "?";
const inner = this.avatarPreviewTarget.querySelector('[data-testid="savings-goal-avatar"]');
if (inner) inner.textContent = initial;
}
validateStep1() {
const requiredInputs = this.step1PanelTarget.querySelectorAll(
'input[name="savings_goal[name]"], input[name="savings_goal[target_amount]"]'
@@ -163,9 +173,12 @@ export default class extends Controller {
}
updateFooter() {
if (this.hasFooterLeftLabelTarget) {
this.footerLeftLabelTarget.textContent =
this.currentStep === 1 ? this.cancelLabelValue : this.backLabelValue;
if (this.hasFooterLeftButtonTarget) {
const labelSpan = this.footerLeftButtonTarget.querySelector("span");
if (labelSpan) {
labelSpan.textContent =
this.currentStep === 1 ? this.cancelLabelValue : this.backLabelValue;
}
}
if (this.hasFooterRightButtonTarget) {
const labelSpan = this.footerRightButtonTarget.querySelector("span");

View File

@@ -27,9 +27,9 @@
<div>
<label class="block text-sm font-medium text-primary mb-2"><%= t("savings_goals.form_stepper.step1.fields.name") %></label>
<div class="flex items-stretch gap-2">
<span class="shrink-0">
<%= render DS::FilledIcon.new(variant: :container, icon: "target", size: "lg", rounded: false) %>
<div class="flex items-center gap-2">
<span class="shrink-0" data-savings-goal-stepper-target="avatarPreview">
<%= render Savings::GoalAvatarComponent.new(name: savings_goal.name, color: savings_goal.color, size: "md") %>
</span>
<%= f.text_field :name,
placeholder: t("savings_goals.form_stepper.step1.fields.name_placeholder"),
@@ -160,13 +160,14 @@
</section>
<div class="flex items-center justify-between pt-2">
<button type="button"
class="text-sm font-medium text-secondary hover:text-primary px-2 py-2"
data-savings-goal-stepper-target="footerLeftButton"
data-action="click->savings-goal-stepper#footerLeft"
data-cancel-action="DS--dialog#close">
<span data-savings-goal-stepper-target="footerLeftLabel"><%= t("savings_goals.form_stepper.cancel") %></span>
</button>
<%= render DS::Button.new(
variant: "ghost",
text: t("savings_goals.form_stepper.cancel"),
data: {
savings_goal_stepper_target: "footerLeftButton",
action: "click->savings-goal-stepper#footerLeft"
}
) %>
<%= render DS::Button.new(
text: t("savings_goals.form_stepper.continue"),
variant: "primary",

View File

@@ -2,7 +2,7 @@
<% dialog.with_header(custom_header: true) do %>
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3">
<%= render DS::FilledIcon.new(variant: :container, icon: "target", size: "lg", rounded: false) %>
<%= render DS::FilledIcon.new(variant: :container, icon: "target", size: "md", rounded: true) %>
<div>
<h2 class="text-base font-medium text-primary"><%= t(".heading") %></h2>
<p class="text-sm text-secondary mt-0.5" data-savings-goal-stepper-modal-subtitle>