diff --git a/app/components/DS/progress_ring.html.erb b/app/components/DS/progress_ring.html.erb new file mode 100644 index 000000000..d3940cca8 --- /dev/null +++ b/app/components/DS/progress_ring.html.erb @@ -0,0 +1,20 @@ +<%= tag.div class: "relative inline-flex shrink-0", + style: "width: #{size}px; height: #{size}px;", + **wrapper_aria do %> + + <% if show_percent %> + + <% end %> +<% end %> diff --git a/app/components/DS/progress_ring.rb b/app/components/DS/progress_ring.rb new file mode 100644 index 000000000..eb904164c --- /dev/null +++ b/app/components/DS/progress_ring.rb @@ -0,0 +1,80 @@ +# A single-arc circular progress ring, decoupled from any domain model. +# +# Extracted from the goal card's inline (issue #1899) so goals, loans, +# sub-account funding, etc. stop each hand-rolling the same two-circle SVG with +# slightly different chrome / colors / a11y. Pass a percent and a tone; the +# component owns the geometry (radius, circumference, dash offset) and the +# accessible progressbar markup. +# +# Not a segmented donut — that's the `donut-chart` Stimulus controller's job +# (budget/dashboard breakdowns, and the goals/show ring). This is the simple +# "X% of one thing" ring. +class DS::ProgressRing < DesignSystemComponent + TONES = { + success: "var(--color-success)", + warning: "var(--color-warning)", + destructive: "var(--color-destructive)", + neutral: "var(--color-gray-400)" + }.freeze + + # Track (unfilled remainder) color. Reuses the existing token to keep the + # goal card pixel-identical. TODO(#1899 follow-up): rename this to a generic + # --color-progress-track-fill in the token source — that change also touches + # the budget donut surfaces, so it's deferred out of this extraction. + DEFAULT_TRACK = "var(--budget-unused-fill)".freeze + + attr_reader :size, :stroke_width, :label, :show_percent + + def initialize(percent:, size: 64, stroke_width: 6, tone: :neutral, label: nil, show_percent: true, track: DEFAULT_TRACK) + @percent = percent + @size = size + @stroke_width = stroke_width + @tone = tone.to_sym + @label = label + @show_percent = show_percent + @track = track + end + + def clamped_percent + [ [ @percent.to_i, 0 ].max, 100 ].min + end + + def stroke_color + TONES.fetch(@tone, TONES[:neutral]) + end + + def track_color + @track + end + + def center + size / 2.0 + end + + def radius + (size - stroke_width) / 2.0 + end + + def circumference + 2 * Math::PI * radius + end + + # Length of the dash gap that hides the unfilled portion of the arc. + def dash_offset + circumference * (1 - clamped_percent / 100.0) + end + + # Center label scales with the ring so 64px reads ~11px (the goal card's size) + # and a 180px ring reads ~30px without a per-callsite font class. + def percent_font_px + (size * 0.17).round + end + + # role=progressbar + value/label only when a label is supplied; otherwise the + # ring is decorative (aria-hidden via the inner svg) and the caller labels it. + def wrapper_aria + return {} if label.blank? + + { role: "progressbar", aria: { valuenow: clamped_percent, valuemin: 0, valuemax: 100, label: label } } + end +end diff --git a/app/components/goals/card_component.html.erb b/app/components/goals/card_component.html.erb index 7e2174885..c42340d19 100644 --- a/app/components/goals/card_component.html.erb +++ b/app/components/goals/card_component.html.erb @@ -18,29 +18,7 @@

<%= secondary_line %>

-
- - -
+ <%= render DS::ProgressRing.new(percent: progress_percent, tone: ring_tone) %>
diff --git a/app/components/goals/card_component.rb b/app/components/goals/card_component.rb index 0f68d4839..7ebca94dd 100644 --- a/app/components/goals/card_component.rb +++ b/app/components/goals/card_component.rb @@ -1,7 +1,4 @@ class Goals::CardComponent < ApplicationComponent - RING_SIZE = 64 - RING_STROKE = 6 - def initialize(goal:, filterable: true) @goal = goal @filterable = filterable @@ -13,11 +10,13 @@ class Goals::CardComponent < ApplicationComponent goal.progress_percent end - def ring_color + # Maps goal status to a DS::ProgressRing tone (the ring geometry/colors now + # live in that primitive — see #1899). + def ring_tone case goal.status - when :reached, :on_track then "var(--color-success)" - when :behind then "var(--color-warning)" - else "var(--color-gray-400)" + when :reached, :on_track then :success + when :behind then :warning + else :neutral end end @@ -66,19 +65,6 @@ class Goals::CardComponent < ApplicationComponent end end - def ring_circumference - @ring_circumference ||= 2 * Math::PI * ring_radius - end - - def ring_radius - @ring_radius ||= (RING_SIZE - RING_STROKE) / 2.0 - end - - def ring_offset - pct = [ [ progress_percent.to_i, 0 ].max, 100 ].min - ring_circumference * (1 - pct / 100.0) - end - def pace_line return nil if goal.archived? || goal.paused? || goal.completed? || goal.status == :reached diff --git a/test/components/DS/progress_ring_test.rb b/test/components/DS/progress_ring_test.rb new file mode 100644 index 000000000..14ca25f95 --- /dev/null +++ b/test/components/DS/progress_ring_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class DS::ProgressRingTest < ViewComponent::TestCase + test "renders a track circle and a progress arc" do + render_inline(DS::ProgressRing.new(percent: 50, tone: :success)) + assert_selector "svg circle", count: 2 + end + + test "renders the center percent by default and clamps it" do + render_inline(DS::ProgressRing.new(percent: 140)) + assert_text "100%" + + render_inline(DS::ProgressRing.new(percent: -10)) + assert_text "0%" + end + + test "show_percent: false omits the center label" do + render_inline(DS::ProgressRing.new(percent: 40, show_percent: false)) + assert_no_text "40%" + end + + test "exposes a progressbar role and value only when labelled" do + render_inline(DS::ProgressRing.new(percent: 30, label: "Goal progress")) + assert_selector "[role='progressbar'][aria-valuenow='30'][aria-label='Goal progress']" + + render_inline(DS::ProgressRing.new(percent: 30)) + assert_no_selector "[role='progressbar']" + end + + test "tone selects the arc stroke color token" do + assert_equal "var(--color-success)", DS::ProgressRing.new(percent: 1, tone: :success).stroke_color + assert_equal "var(--color-warning)", DS::ProgressRing.new(percent: 1, tone: :warning).stroke_color + assert_equal "var(--color-destructive)", DS::ProgressRing.new(percent: 1, tone: :destructive).stroke_color + # Unknown tone falls back to neutral. + assert_equal "var(--color-gray-400)", DS::ProgressRing.new(percent: 1, tone: :bogus).stroke_color + end + + test "dash offset runs from full circumference at 0% to zero at 100%" do + ring = DS::ProgressRing.new(percent: 0) + assert_in_delta ring.circumference, ring.dash_offset, 0.001 + + full = DS::ProgressRing.new(percent: 100) + assert_in_delta 0.0, full.dash_offset, 0.001 + end +end diff --git a/test/components/previews/progress_ring_component_preview.rb b/test/components/previews/progress_ring_component_preview.rb new file mode 100644 index 000000000..b4476500c --- /dev/null +++ b/test/components/previews/progress_ring_component_preview.rb @@ -0,0 +1,68 @@ +class ProgressRingComponentPreview < ViewComponent::Preview + # @param percent number + # @param size number + # @param stroke_width number + # @param tone select ["success", "warning", "destructive", "neutral"] + # @param show_percent toggle + # @param label text + def default(percent: 65, size: 64, stroke_width: 6, tone: "neutral", show_percent: true, label: nil) + render DS::ProgressRing.new( + percent: percent, + size: size, + stroke_width: stroke_width, + tone: tone.to_sym, + show_percent: show_percent, + label: label.presence + ) + end + + # @!group Tones (50%) + def success + render DS::ProgressRing.new(percent: 50, tone: :success) + end + + def warning + render DS::ProgressRing.new(percent: 50, tone: :warning) + end + + def destructive + render DS::ProgressRing.new(percent: 50, tone: :destructive) + end + + def neutral + render DS::ProgressRing.new(percent: 50, tone: :neutral) + end + # @!endgroup + + # @!group Sizes + def small_48 + render DS::ProgressRing.new(percent: 72, size: 48, stroke_width: 5, tone: :success) + end + + def medium_64 + render DS::ProgressRing.new(percent: 72, size: 64, stroke_width: 6, tone: :success) + end + + def large_180 + render DS::ProgressRing.new(percent: 72, size: 180, stroke_width: 10, tone: :success) + end + # @!endgroup + + # @!group Edges + def empty_0 + render DS::ProgressRing.new(percent: 0, tone: :neutral) + end + + def full_100 + render DS::ProgressRing.new(percent: 100, tone: :success) + end + + def clamps_over_100 + render DS::ProgressRing.new(percent: 140, tone: :success) + end + + def without_center_percent + render DS::ProgressRing.new(percent: 40, tone: :warning, show_percent: false) + end + # @!endgroup +end