From b1db81c18bc6fb27cd38f8d105b4ad028bc4f787 Mon Sep 17 00:00:00 2001 From: Mike Dalton Date: Fri, 26 Jun 2026 22:13:47 -0500 Subject: [PATCH] Add inline percentage sliders to ongoing giving allocation cards Redesign the on-going giving allocation cards on the scenario edit page so a donor can adjust an allocation's percentage with an inline slider that auto-saves on release, with live %/$ and perpetuity figures, edit/delete icons, and a "No additional preferences" subtitle. - New `allocation-slider` Stimulus controller drives the card and modal sliders: live updates while dragging, auto-save on release for the card. - Custom slider styling (colored fill, light track, ringed thumb) lives in a dedicated allocation_slider.css stylesheet imported into application.css. - Wrap the allocations grid in a turbo frame so a slider save updates both the card and the summary in place. - Card and modal sliders share the allocation's summary color. Co-Authored-By: Claude Opus 4.8 --- app/assets/tailwind/allocation_slider.css | 55 +++++++++ app/assets/tailwind/application.css | 1 + .../allocation_slider_controller.js | 32 ++++++ .../controllers/range_controller.js | 10 -- app/views/scenarios/_allocation.html.erb | 105 ++++++++++++++---- .../scenarios/_allocation_modal.html.erb | 12 +- .../scenarios/_allocation_section.html.erb | 2 +- app/views/scenarios/show.html.erb | 4 +- 8 files changed, 181 insertions(+), 40 deletions(-) create mode 100644 app/assets/tailwind/allocation_slider.css create mode 100644 app/javascript/controllers/allocation_slider_controller.js delete mode 100644 app/javascript/controllers/range_controller.js diff --git a/app/assets/tailwind/allocation_slider.css b/app/assets/tailwind/allocation_slider.css new file mode 100644 index 0000000..23278b1 --- /dev/null +++ b/app/assets/tailwind/allocation_slider.css @@ -0,0 +1,55 @@ +@layer components { + .allocation-slider { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 0.5rem; + border-radius: 9999px; + background: transparent; + cursor: pointer; + } + + .allocation-slider::-webkit-slider-runnable-track { + height: 0.5rem; + border-radius: 9999px; + background: linear-gradient( + to right, + var(--slider-color) 0%, + var(--slider-color) var(--slider-value, 0%), + var(--color-line-soft) var(--slider-value, 0%), + var(--color-line-soft) 100% + ); + } + + .allocation-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + height: 1rem; + width: 1rem; + margin-top: -0.25rem; + border-radius: 9999px; + background: var(--slider-color); + border: 2px solid var(--color-surface); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08); + } + + .allocation-slider::-moz-range-track { + height: 0.5rem; + border-radius: 9999px; + background: var(--color-line-soft); + } + + .allocation-slider::-moz-range-progress { + height: 0.5rem; + border-radius: 9999px; + background: var(--slider-color); + } + + .allocation-slider::-moz-range-thumb { + height: 1rem; + width: 1rem; + border: 2px solid var(--color-surface); + border-radius: 9999px; + background: var(--slider-color); + } +} diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index b01976e..73a839d 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "./allocation_slider.css"; @theme { --color-canvas: #F5F2ED; diff --git a/app/javascript/controllers/allocation_slider_controller.js b/app/javascript/controllers/allocation_slider_controller.js new file mode 100644 index 0000000..550c218 --- /dev/null +++ b/app/javascript/controllers/allocation_slider_controller.js @@ -0,0 +1,32 @@ +import { Controller } from "@hotwired/stimulus" + +const currency = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, +}) + +export default class extends Controller { + static targets = ["input", "percent", "dollar", "perpetuity"] + static values = { + ongoingAmount: Number, + payoutRate: Number, + } + + update() { + const percent = Number(this.inputTarget.value) + const dollar = Math.round((percent / 100) * this.ongoingAmountValue) + const perpetuity = Math.round(dollar * this.payoutRateValue) + + this.inputTarget.style.setProperty("--slider-value", `${percent}%`) + this.percentTargets.forEach((el) => (el.textContent = `${percent}%`)) + this.dollarTargets.forEach((el) => (el.textContent = currency.format(dollar))) + if (this.hasPerpetuityTarget) { + this.perpetuityTarget.textContent = currency.format(perpetuity) + } + } + + save() { + this.inputTarget.form?.requestSubmit() + } +} diff --git a/app/javascript/controllers/range_controller.js b/app/javascript/controllers/range_controller.js deleted file mode 100644 index ea40c92..0000000 --- a/app/javascript/controllers/range_controller.js +++ /dev/null @@ -1,10 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Mirrors a range input's value into an output element as it moves. -export default class extends Controller { - static targets = ["input", "output"] - - update() { - this.outputTarget.textContent = this.inputTarget.value - } -} diff --git a/app/views/scenarios/_allocation.html.erb b/app/views/scenarios/_allocation.html.erb index 6670993..048f6ec 100644 --- a/app/views/scenarios/_allocation.html.erb +++ b/app/views/scenarios/_allocation.html.erb @@ -1,25 +1,86 @@ -<%# locals: (allocation:, scenario:) %> -
-
-
-

<%= allocation.display_label %>

- <% if allocation.note.present? %> -

<%= allocation.note %>

- <% end %> -
-
- - <%= allocation.ongoing? ? "#{allocation.percentage}%" : number_to_currency(allocation.amount, precision: 0) %> - - - <%= button_to "✕", scenario_allocation_path(allocation.scenario, allocation), method: :delete, - class: "text-ink-faint hover:text-danger cursor-pointer bg-transparent p-0 leading-none", - data: { turbo_confirm: "Remove this allocation?" } %> +<%# locals: (allocation:, scenario:, allocation_counter: 0) %> +<% if allocation.ongoing? %> + <% color = allocation_chart_color(allocation_counter) %> +
+
+
+
+

<%= allocation.display_label %>

+

+ <%= allocation.note.presence || "No additional preferences" %> +

+
+
+
+ <%= allocation.percentage %>% + <%= number_to_currency(allocation.dollar_amount, precision: 0) %> +
+ + <%= button_to scenario_allocation_path(allocation.scenario, allocation), method: :delete, + class: "text-ink-faint hover:text-danger cursor-pointer bg-transparent p-0 leading-none", + form_class: "leading-none", aria: { label: "Remove allocation" }, + data: { turbo_confirm: "Remove this allocation?" } do %> + + + + <% end %> +
+
+ +
+ <%= form_with url: scenario_allocation_path(scenario, allocation), method: :patch, class: "flex-1 min-w-0 leading-none" do %> + <%= range_field_tag "allocation[percentage]", allocation.percentage, min: 0, max: 100, step: 1, + style: "--slider-color: #{color}; --slider-value: #{allocation.percentage}%", + data: { allocation_slider_target: "input", action: "input->allocation-slider#update change->allocation-slider#save" }, + class: "allocation-slider block" %> + <% end %> +
+ <%= allocation.percentage %>% + <%= number_to_currency(allocation.dollar_amount, precision: 0) %> +
+
+ +

+ + Est. <%= number_to_currency(allocation.perpetuity_annual_amount, precision: 0) %> + annually in perpetuity + (<%= (Allocation::Ongoing::PERPETUITY_PAYOUT_RATE * 100).to_i %>% of principal) +

+ + <%= render "allocation_modal", allocation: allocation, scenario: scenario, color: color %>
+<% else %> +
+
+
+

<%= allocation.display_label %>

+ <% if allocation.note.present? %> +

<%= allocation.note %>

+ <% end %> +
+
+ + <%= number_to_currency(allocation.amount, precision: 0) %> + + + <%= button_to "✕", scenario_allocation_path(allocation.scenario, allocation), method: :delete, + class: "text-ink-faint hover:text-danger cursor-pointer bg-transparent p-0 leading-none", + data: { turbo_confirm: "Remove this allocation?" } %> +
+
- <%= render "allocation_modal", allocation: allocation, scenario: scenario %> -
+ <%= render "allocation_modal", allocation: allocation, scenario: scenario %> +
+<% end %> diff --git a/app/views/scenarios/_allocation_modal.html.erb b/app/views/scenarios/_allocation_modal.html.erb index 0433ebe..6d0736c 100644 --- a/app/views/scenarios/_allocation_modal.html.erb +++ b/app/views/scenarios/_allocation_modal.html.erb @@ -1,8 +1,9 @@ -<%# locals: (allocation:, scenario:) %> +<%# locals: (allocation:, scenario:, color: nil) %> <% klass = allocation.class %> <% editing = allocation.persisted? %> <% suffix = editing ? dom_id(allocation) : klass.model_name.element %> <% percentage = allocation.percentage || 20 %> +<% color ||= ScenariosHelper::CHART_COLORS.first %> <%= form_with url: (editing ? scenario_allocation_path(scenario, allocation) : scenario_allocations_path(scenario)), method: (editing ? :patch : :post), class: "p-8" do |form| %> <%= hidden_field_tag "allocation[type]", klass.name %> @@ -11,15 +12,16 @@ <%= render "scenarios/category_picker", allocation: allocation, scenario: scenario %> <% if klass == Allocation::Ongoing %> -
+
Giving percentage % 100 % reminder
-

<%= percentage %> %

+

<%= percentage %>%

<%= range_field_tag "allocation[percentage]", percentage, min: 0, max: 100, step: 1, - data: { range_target: "input", action: "input->range#update" }, - class: "mt-2 w-full accent-brand" %> + style: "--slider-color: #{color}; --slider-value: #{percentage}%", + data: { allocation_slider_target: "input", action: "input->allocation-slider#update" }, + class: "allocation-slider block mt-2" %>
<% else %>
diff --git a/app/views/scenarios/_allocation_section.html.erb b/app/views/scenarios/_allocation_section.html.erb index 9d579c7..415dca3 100644 --- a/app/views/scenarios/_allocation_section.html.erb +++ b/app/views/scenarios/_allocation_section.html.erb @@ -19,5 +19,5 @@
<% end %> - <%= render "allocation_modal", allocation: klass.new, scenario: scenario %> + <%= render "allocation_modal", allocation: klass.new, scenario: scenario, color: allocation_chart_color(allocations.size) %>
diff --git a/app/views/scenarios/show.html.erb b/app/views/scenarios/show.html.erb index 4c19d39..67176cf 100644 --- a/app/views/scenarios/show.html.erb +++ b/app/views/scenarios/show.html.erb @@ -17,7 +17,7 @@ <%= render "scenarios/total_giving_amounts/total_giving_amount", scenario: @scenario %> -
+ <%= turbo_frame_tag dom_id(@scenario, :allocations), class: "mt-8 grid gap-6 lg:grid-cols-2" do %>

Allocations

@@ -129,5 +129,5 @@
<% end %>
-
+ <% end %>