From cdf58893cb0e09c09056f29c41776b9dc121947f Mon Sep 17 00:00:00 2001 From: mihaim-eastwolf <100922623+Mihai-Munteanu-east-wolf@users.noreply.github.com> Date: Tue, 2 Dec 2025 19:55:39 +0200 Subject: [PATCH] add badge as a ui component --- Gemfile.lock | 1 + app/assets/svgs/avo/paperclip.svg | 9 + .../avo/u_i/badge_component.html.erb | 9 + app/components/avo/u_i/badge_component.rb | 110 +++++++ lib/avo/u_i/colors.rb | 57 ++++ .../avo/u_i/badge_component_spec.rb | 310 ++++++++++++++++++ .../previews/badge_component_preview.rb | 67 ++++ .../semantic_colors.html.erb | 14 + .../solid_style.html.erb | 25 ++ .../subtle_style.html.erb | 35 ++ 10 files changed, 637 insertions(+) create mode 100644 app/assets/svgs/avo/paperclip.svg create mode 100644 app/components/avo/u_i/badge_component.html.erb create mode 100644 app/components/avo/u_i/badge_component.rb create mode 100644 lib/avo/u_i/colors.rb create mode 100644 spec/components/avo/u_i/badge_component_spec.rb create mode 100644 spec/dummy/test/components/previews/badge_component_preview.rb create mode 100644 spec/dummy/test/components/previews/badge_component_preview/semantic_colors.html.erb create mode 100644 spec/dummy/test/components/previews/badge_component_preview/solid_style.html.erb create mode 100644 spec/dummy/test/components/previews/badge_component_preview/subtle_style.html.erb diff --git a/Gemfile.lock b/Gemfile.lock index 502b815d85..52768a8cf6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -728,6 +728,7 @@ GEM zeitwerk (2.7.3) PLATFORMS + arm64-darwin-23 arm64-darwin-24 x86_64-linux diff --git a/app/assets/svgs/avo/paperclip.svg b/app/assets/svgs/avo/paperclip.svg new file mode 100644 index 0000000000..29d848be5a --- /dev/null +++ b/app/assets/svgs/avo/paperclip.svg @@ -0,0 +1,9 @@ + + diff --git a/app/components/avo/u_i/badge_component.html.erb b/app/components/avo/u_i/badge_component.html.erb new file mode 100644 index 0000000000..328d8e870e --- /dev/null +++ b/app/components/avo/u_i/badge_component.html.erb @@ -0,0 +1,9 @@ +> + <%= render_icon_content if icon.present? && icon_position == "left" %> + + <% if label.present? %> + <%= label %> + <% end %> + + <%= render_icon_content_right if icon.present? && icon_position == "right" %> + diff --git a/app/components/avo/u_i/badge_component.rb b/app/components/avo/u_i/badge_component.rb new file mode 100644 index 0000000000..15aa09141a --- /dev/null +++ b/app/components/avo/u_i/badge_component.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +class Avo::UI::BadgeComponent < Avo::BaseComponent + STYLES = %w[solid subtle].freeze + + # Use centralized color definitions + COLOR_DEFINITIONS = Avo::UI::Colors::DEFINITIONS + COLOR_ALIASES = Avo::UI::Colors::ALIASES + VALID_COLORS = Avo::UI::Colors::ALL + + def initialize( + label: '', + color: 'secondary', + style: 'subtle', + icon: nil, + icon_position: 'left', + rounded: true, + **options + ) + @label = label.to_s + @color = normalize_color(color.to_s) + @style = style.to_s + @icon = icon&.to_s + @icon_position = icon_position.to_s + @rounded = rounded + @options = options + + validate_params! + + super() + end + + private + + attr_reader :label, :color, :style, :icon, :icon_position, :rounded, :options + + def normalize_color(value) + # Map aliases to their canonical color names + normalized = COLOR_ALIASES[value] || value + + # Fallback to 'secondary' if color is invalid + VALID_COLORS.include?(normalized) ? normalized : 'secondary' + end + + def color_definition + # For solid style, use -secondary variant if it exists (dark bg, light text) + if @style == 'solid' + secondary_color = "#{color}-secondary" + return COLOR_DEFINITIONS[secondary_color] if COLOR_DEFINITIONS.key?(secondary_color) + end + + # Default: use the base color (always valid due to normalize_color) + COLOR_DEFINITIONS[color] + end + + def text_color + color_definition[:text] + end + + def bg_color + color_definition[:bg] + end + + def validate_params! + raise ArgumentError, "Invalid style: #{style}. Must be one of #{STYLES.join(', ')}" unless STYLES.include?(style) + + return if %w[left right].include?(icon_position) + + raise ArgumentError, "Invalid icon_position: #{icon_position}. Must be 'left' or 'right'" + end + + def badge_classes + classes = [ + 'inline-flex items-center justify-center transition-colors', + 'focus:outline-none focus:ring-2 focus:ring-offset-2', + 'px-2 py-0.5 text-xs gap-0.5 text-center' # x:8px, y:2px, gap:2px, height:24px + ] + + classes << 'rounded-md' if rounded + + classes.compact.join(' ') + end + + def badge_style + # Figma specs: height:24px, font-size:12px, font-weight:500, line-height:16px + "height: 24px; font-size: 12px; font-weight: 500; line-height: 16px; background-color: #{bg_color}; color: #{text_color};" + end + + def icon_classes + 'shrink-0 w-3 h-3' + end + + def icon_style + "color: #{text_color};" + end + + def render_icon_content + return unless icon.present? + + return unless icon_position == 'left' + + helpers.svg(icon, class: icon_classes, style: icon_style) + end + + def render_icon_content_right + return unless icon.present? && icon_position == 'right' + + helpers.svg(icon, class: icon_classes, style: icon_style) + end +end diff --git a/lib/avo/u_i/colors.rb b/lib/avo/u_i/colors.rb new file mode 100644 index 0000000000..d2a2b85793 --- /dev/null +++ b/lib/avo/u_i/colors.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Avo + module UI + # Centralized color definitions for Avo UI components + # Colors are based on Figma design system specifications + module Colors + # Badge and UI component color definitions with exact hex codes + DEFINITIONS = { + 'secondary' => { text: '#171717', bg: '#F6F6F6' }, + 'success' => { text: '#0D8E54', bg: '#D3F8E0' }, + 'informative' => { text: '#068AFF', bg: '#D6F1FF' }, + 'warning' => { text: '#DB6704', bg: '#FFEFC6' }, + 'error' => { text: '#DE3024', bg: '#FEE4E2' }, + + 'orange' => { text: '#C2410C', bg: '#FFEDD5' }, + 'orange-secondary' => { text: '#F6F6F6', bg: '#FB923C' }, + 'yellow' => { text: '#FB923C', bg: '#FEF9C3' }, + 'yellow-secondary' => { text: '#F6F6F6', bg: '#FACC15' }, + 'green' => { text: '#15803D', bg: '#DCFCE7' }, + 'green-secondary' => { text: '#F6F6F6', bg: '#22C55E' }, + 'teal' => { text: '#0F766E', bg: '#CCFBF1' }, + 'teal-secondary' => { text: '#F6F6F6', bg: '#2DD4BF' }, + 'blue' => { text: '#1D4ED8', bg: '#DBEAFE' }, + 'blue-secondary' => { text: '#F6F6F6', bg: '#3B82F6' }, + 'purple' => { text: '#7E22CE', bg: '#F3E8FF' }, + 'purple-secondary' => { text: '#F6F6F6', bg: '#A855F7' } + }.freeze + + # Color name aliases for backward compatibility + ALIASES = { + 'info' => 'informative', + 'danger' => 'error' + }.freeze + + # All valid color names (including aliases) + ALL = (DEFINITIONS.keys + ALIASES.keys).freeze + + # Normalize a color name (resolve aliases) + def self.normalize(color_name) + ALIASES[color_name.to_s] || color_name.to_s + end + + # Get color definition for a given color name + def self.get(color_name, fallback: 'secondary') + normalized = normalize(color_name) + DEFINITIONS[normalized] || DEFINITIONS[fallback] + end + + # Check if a color is valid + def self.valid?(color_name) + ALL.include?(color_name.to_s) + end + end + end +end + diff --git a/spec/components/avo/u_i/badge_component_spec.rb b/spec/components/avo/u_i/badge_component_spec.rb new file mode 100644 index 0000000000..7c9d6fb184 --- /dev/null +++ b/spec/components/avo/u_i/badge_component_spec.rb @@ -0,0 +1,310 @@ +require "rails_helper" + +RSpec.describe Avo::UI::BadgeComponent, type: :component do + # Use centralized color definitions for consistent testing + let(:colors) { Avo::UI::Colors::DEFINITIONS } + let(:color_aliases) { Avo::UI::Colors::ALIASES } + let(:valid_colors) { Avo::UI::Colors::ALL } + describe "rendering" do + it "renders default badge with secondary color" do + render_inline(described_class.new(label: "Test Badge")) + + expect(page).to have_css(".inline-flex") + expect(page).to have_css(".rounded-md") + expect(page).to have_text("Test Badge") + expect(page.find(".inline-flex")["style"]).to include("background-color: #F6F6F6") + expect(page.find(".inline-flex")["style"]).to include("color: #171717") + end + + it "renders badge without label" do + render_inline(described_class.new(label: "")) + + expect(page).to have_css(".inline-flex") + expect(page).not_to have_css("span.truncate") + end + + it "renders badge with icon on the left" do + render_inline(described_class.new( + label: "With Icon", + icon: "avo/paperclip" + )) + + expect(page).to have_css("svg.w-3.h-3") + expect(page).to have_text("With Icon") + end + + it "renders badge with icon on the right" do + render_inline(described_class.new( + label: "Icon Right", + icon: "avo/paperclip", + icon_position: "right" + )) + + expect(page).to have_css("svg") + # Icon should come after the text in the DOM + expect(page.text).to include("Icon Right") + end + end + + describe "color logic" do + context "with subtle style (default)" do + it "uses base color for semantic colors" do + render_inline(described_class.new( + label: "Success", + color: "success", + style: "subtle" + )) + + success_color = colors['success'] + expect(page.find(".inline-flex")["style"]).to include("background-color: #{success_color[:bg]}") + expect(page.find(".inline-flex")["style"]).to include("color: #{success_color[:text]}") + end + + it "uses base color for full colors" do + render_inline(described_class.new( + label: "Purple", + color: "purple", + style: "subtle" + )) + + purple_color = colors['purple'] + expect(page.find(".inline-flex")["style"]).to include("background-color: #{purple_color[:bg]}") + expect(page.find(".inline-flex")["style"]).to include("color: #{purple_color[:text]}") + end + end + + context "with solid style" do + it "uses -secondary variant for colors that support it" do + render_inline(described_class.new( + label: "Purple", + color: "purple", + style: "solid" + )) + + # Should use purple-secondary (dark bg, light text) from centralized colors + purple_secondary = colors['purple-secondary'] + expect(page.find(".inline-flex")["style"]).to include("background-color: #{purple_secondary[:bg]}") + expect(page.find(".inline-flex")["style"]).to include("color: #{purple_secondary[:text]}") + end + + it "uses base color for semantic colors (no -secondary variant)" do + render_inline(described_class.new( + label: "Success", + color: "success", + style: "solid" + )) + + # Success doesn't have -secondary, so use base color from centralized colors + success_color = colors['success'] + expect(page.find(".inline-flex")["style"]).to include("background-color: #{success_color[:bg]}") + expect(page.find(".inline-flex")["style"]).to include("color: #{success_color[:text]}") + end + + Avo::UI::Colors::DEFINITIONS.keys.select { |k| k.include?("-secondary") }.each do |color_key| + base_color = color_key.gsub("-secondary", "") + + it "uses -secondary variant for #{base_color}" do + render_inline(described_class.new( + label: "Test", + color: base_color, + style: "solid" + )) + + secondary_def = Avo::UI::Colors::DEFINITIONS[color_key] + expect(page.find(".inline-flex")["style"]).to include("background-color: #{secondary_def[:bg]}") + expect(page.find(".inline-flex")["style"]).to include("color: #{secondary_def[:text]}") + end + end + end + + context "with color aliases" do + it "maps 'info' to 'informative'" do + render_inline(described_class.new( + label: "Info", + color: "info" + )) + + informative_color = colors['informative'] + expect(page.find(".inline-flex")["style"]).to include("background-color: #{informative_color[:bg]}") + expect(page.find(".inline-flex")["style"]).to include("color: #{informative_color[:text]}") + end + + it "maps 'danger' to 'error'" do + render_inline(described_class.new( + label: "Danger", + color: "danger" + )) + + error_color = colors['error'] + expect(page.find(".inline-flex")["style"]).to include("background-color: #{error_color[:bg]}") + expect(page.find(".inline-flex")["style"]).to include("color: #{error_color[:text]}") + end + end + + context "with invalid color" do + it "falls back to 'secondary'" do + render_inline(described_class.new( + label: "Invalid", + color: "rainbow" + )) + + # Should use secondary color from centralized definitions + secondary_color = colors['secondary'] + expect(page.find(".inline-flex")["style"]).to include("background-color: #{secondary_color[:bg]}") + expect(page.find(".inline-flex")["style"]).to include("color: #{secondary_color[:text]}") + end + + it "does not raise an error" do + expect { + render_inline(described_class.new( + label: "Test", + color: "nonexistent_color" + )) + }.not_to raise_error + end + end + end + + describe "parameter validation" do + it "raises error for invalid style" do + expect { + described_class.new(label: "Test", style: "invalid") + }.to raise_error(ArgumentError, /Invalid style/) + end + + it "raises error for invalid icon position" do + expect { + described_class.new(label: "Test", icon_position: "center") + }.to raise_error(ArgumentError, /Invalid icon_position/) + end + + it "accepts all valid styles" do + described_class::STYLES.each do |style| + expect { + render_inline(described_class.new(label: "Test", style: style)) + }.not_to raise_error + end + end + + it "accepts all valid colors" do + colors.keys.each do |color| + expect { + render_inline(described_class.new(label: "Test", color: color)) + }.not_to raise_error + end + end + + it "accepts all color aliases" do + color_aliases.keys.each do |alias_name| + expect { + render_inline(described_class.new(label: "Test", color: alias_name)) + }.not_to raise_error + end + end + end + + describe "all color variants" do + context "semantic colors" do + %w[secondary success error warning informative].each do |color| + it "renders #{color} badge correctly" do + render_inline(described_class.new( + label: color.capitalize, + color: color + )) + + color_def = colors[color] + expect(page.find(".inline-flex")["style"]).to include("background-color: #{color_def[:bg]}") + expect(page.find(".inline-flex")["style"]).to include("color: #{color_def[:text]}") + end + end + end + + context "full colors with variants" do + %w[orange yellow green teal blue purple].each do |color| + it "renders #{color} subtle style correctly" do + render_inline(described_class.new( + label: color.capitalize, + color: color, + style: "subtle" + )) + + color_def = colors[color] + expect(page.find(".inline-flex")["style"]).to include("background-color: #{color_def[:bg]}") + expect(page.find(".inline-flex")["style"]).to include("color: #{color_def[:text]}") + end + + it "renders #{color} solid style correctly" do + render_inline(described_class.new( + label: color.capitalize, + color: color, + style: "solid" + )) + + color_def = colors["#{color}-secondary"] + expect(page.find(".inline-flex")["style"]).to include("background-color: #{color_def[:bg]}") + expect(page.find(".inline-flex")["style"]).to include("color: #{color_def[:text]}") + end + end + end + end + + describe "icon rendering" do + it "does not render icon when not provided" do + render_inline(described_class.new(label: "No Icon")) + + expect(page).not_to have_css("svg") + end + + it "renders icon with correct size classes" do + render_inline(described_class.new( + label: "Test", + icon: "avo/paperclip" + )) + + expect(page).to have_css("svg.w-3.h-3") + end + + it "applies shrink-0 to prevent icon squishing" do + render_inline(described_class.new( + label: "Test", + icon: "avo/paperclip" + )) + + expect(page).to have_css("svg.shrink-0") + end + end + + describe "edge cases" do + it "handles nil label gracefully" do + expect { + render_inline(described_class.new(label: nil)) + }.not_to raise_error + end + + it "handles empty string label" do + render_inline(described_class.new(label: "")) + + expect(page).to have_css(".inline-flex") + expect(page).not_to have_css("span.truncate") + end + + it "handles very long label text" do + long_label = "A" * 100 + render_inline(described_class.new(label: long_label)) + + expect(page).to have_css("span.truncate") + expect(page).to have_text(long_label) + end + + it "handles icon without label" do + render_inline(described_class.new( + label: "", + icon: "avo/paperclip" + )) + + expect(page).to have_css("svg") + expect(page).not_to have_css("span.truncate") + end + end +end + diff --git a/spec/dummy/test/components/previews/badge_component_preview.rb b/spec/dummy/test/components/previews/badge_component_preview.rb new file mode 100644 index 0000000000..6a06cef5a4 --- /dev/null +++ b/spec/dummy/test/components/previews/badge_component_preview.rb @@ -0,0 +1,67 @@ +class BadgeComponentPreview < ViewComponent::Preview + # @!group Types + + # Default badge + def default + render Avo::UI::BadgeComponent.new(label: 'Badge') + end + + # Badge with icon + def with_icon + render Avo::UI::BadgeComponent.new( + label: 'Badge', + icon: 'avo/paperclip', + color: 'secondary' + ) + end + + # @!endgroup + + # @!group Interactive Playground + + # Interactive badge with customizable options + # @param label text "Badge text" + # @param color select { choices: [secondary, success, error, warning, informative, orange, yellow, green, teal, blue, purple] } "Badge color" + # @param style select { choices: [subtle, solid] } "Badge style (solid = dark background for orange, yellow, green, teal, blue, purple)" + # @param icon text "Icon name (e.g., avo/paperclip)" + # @param icon_position select { choices: [left, right] } "Icon position" + # @param rounded toggle "Rounded corners" + def playground( + label: 'Badge', + color: 'purple', + style: 'solid', + icon: 'avo/paperclip', + icon_position: 'left', + rounded: true + ) + render Avo::UI::BadgeComponent.new( + label: label, + color: color, + style: style, + icon: icon.present? ? icon : nil, + icon_position: icon_position, + rounded: rounded + ) + end + + # @!endgroup + + # @!group Examples + + # Semantic color badges (secondary, success, error, warning, informative) + def semantic_colors + render_with_template(template: 'badge_component_preview/semantic_colors') + end + + # Solid style badges (dark backgrounds with light text) + def solid_style + render_with_template(template: 'badge_component_preview/solid_style') + end + + # Subtle style badges (light backgrounds with dark text) + def subtle_style + render_with_template(template: 'badge_component_preview/subtle_style') + end + + # @!endgroup +end diff --git a/spec/dummy/test/components/previews/badge_component_preview/semantic_colors.html.erb b/spec/dummy/test/components/previews/badge_component_preview/semantic_colors.html.erb new file mode 100644 index 0000000000..7268b26041 --- /dev/null +++ b/spec/dummy/test/components/previews/badge_component_preview/semantic_colors.html.erb @@ -0,0 +1,14 @@ +
+ Note: Semantic colors only support subtle style. For solid style (dark backgrounds), use: orange, yellow, green, teal, blue, purple. +
++ Note: Solid style (dark backgrounds) only available for orange, yellow, green, teal, blue, and purple. +
++ Note: Subtle style (default) available for all colors. +
+