diff --git a/CHANGELOG.md b/CHANGELOG.md index 3946e73..db08d7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +* Badge component. * Sidebar component. ### Changed diff --git a/app/components/flowbite/badge.rb b/app/components/flowbite/badge.rb new file mode 100644 index 0000000..3c5ea4b --- /dev/null +++ b/app/components/flowbite/badge.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Flowbite + # Renders a badge component for displaying labels, counts, or status + # indicators. + # + # @example Basic usage + # <%= render(Flowbite::Badge.new) { "Default" } %> + # + # @example With border + # <%= render(Flowbite::Badge.new(bordered: true, style: :success)) { "Success" } %> + # + # @see https://flowbite.com/docs/components/badge/ + # @lookbook_embed BadgePreview + class Badge < ViewComponent::Base + SIZES = { + default: ["text-xs", "font-medium", "px-1.5", "py-0.5"], + lg: ["text-sm", "font-medium", "px-2", "py-1"] + }.freeze + + BORDER_CLASSES = { + alternative: ["border", "border-default"], + brand: ["border", "border-brand-subtle"], + danger: ["border", "border-danger-subtle"], + gray: ["border", "border-default-medium"], + success: ["border", "border-success-subtle"], + warning: ["border", "border-warning-subtle"] + }.freeze + + class << self + def classes(size: :default, state: :default, style: :brand) + styles.fetch(style).fetch(state) + sizes.fetch(size) + end + + def sizes + SIZES + end + + # rubocop:disable Layout/LineLength + def styles + Flowbite::Styles.from_hash({ + alternative: { + default: ["bg-neutral-primary-soft", "hover:bg-neutral-secondary-medium", "rounded", "text-heading"] + }, + brand: { + default: ["bg-brand-softer", "hover:bg-brand-soft", "rounded", "text-fg-brand-strong"] + }, + danger: { + default: ["bg-danger-soft", "hover:bg-danger-medium", "rounded", "text-fg-danger-strong"] + }, + gray: { + default: ["bg-neutral-secondary-medium", "hover:bg-neutral-tertiary-medium", "rounded", "text-heading"] + }, + success: { + default: ["bg-success-soft", "hover:bg-success-medium", "rounded", "text-fg-success-strong"] + }, + warning: { + default: ["bg-warning-soft", "hover:bg-warning-medium", "rounded", "text-fg-warning"] + } + }.freeze) + end + # rubocop:enable Layout/LineLength + end + + attr_reader :options + + # @param bordered [Boolean] Whether to add a border to the badge. + # @param class [String, Array] Additional CSS classes. + # @param dot [Boolean] Whether to show a dot indicator. + # @param href [String] If provided, renders the badge as a link. + # @param size [Symbol] The size of the badge (:default or :lg). + # @param style [Symbol] The color style (:alternative, :brand, :danger, + # :gray, :success, :warning). + def initialize(bordered: false, class: nil, dot: false, href: nil, + size: :default, style: :brand, **options) + @bordered = bordered + @class = Array.wrap(binding.local_variable_get(:class)) + @dot = dot + @href = href + @size = size + @style = style + @options = options + end + + def bordered? + !!@bordered + end + + def dot? + !!@dot + end + + def link? + @href.present? + end + + private + + def classes + result = self.class.classes(size: @size, state: :default, style: @style) + result += BORDER_CLASSES.fetch(@style) if bordered? + result += ["inline-flex", "items-center"] if dot? + result + @class + end + + def tag_name + link? ? :a : :span + end + + def tag_options + opts = {class: classes} + opts[:href] = @href if link? + opts.merge(options) + end + end +end diff --git a/app/components/flowbite/badge/badge.html.erb b/app/components/flowbite/badge/badge.html.erb new file mode 100644 index 0000000..0ad88b2 --- /dev/null +++ b/app/components/flowbite/badge/badge.html.erb @@ -0,0 +1,4 @@ +<%= content_tag(tag_name, **tag_options) do %> + <% if dot? %><%= render(Flowbite::Badge::Dot.new(style: @style)) %><% end %> + <%= content %> +<% end %> diff --git a/app/components/flowbite/badge/dot.rb b/app/components/flowbite/badge/dot.rb new file mode 100644 index 0000000..d8fc567 --- /dev/null +++ b/app/components/flowbite/badge/dot.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Flowbite + class Badge + # Renders a colored dot indicator for use inside a badge. + # + # @param style [Symbol] The color style of the dot (:alternative, :brand, + # :danger, :gray, :success, :warning). + class Dot < ViewComponent::Base + CLASSES = { + alternative: ["bg-heading", "me-1", "rounded-full"], + brand: ["bg-fg-brand-strong", "me-1", "rounded-full"], + danger: ["bg-fg-danger-strong", "me-1", "rounded-full"], + gray: ["bg-heading", "me-1", "rounded-full"], + success: ["bg-fg-success-strong", "me-1", "rounded-full"], + warning: ["bg-fg-warning", "me-1", "rounded-full"] + }.freeze + + SIZES = { + default: ["h-1.5", "w-1.5"] + }.freeze + + class << self + def classes(size: :default, style: :brand) + CLASSES.fetch(style) + sizes.fetch(size) + end + + def sizes + SIZES + end + end + + attr_reader :size, :style + + def initialize(size: :default, style: :brand) + @size = size + @style = style + end + + def call + content_tag(:span, nil, class: self.class.classes(size: size, style: style)) + end + end + end +end diff --git a/app/components/flowbite/badge/pill.rb b/app/components/flowbite/badge/pill.rb new file mode 100644 index 0000000..ed0f845 --- /dev/null +++ b/app/components/flowbite/badge/pill.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Flowbite + class Badge + # Renders a pill-shaped badge with fully rounded corners. + # + # @example Basic usage + # <%= render(Flowbite::Badge::Pill.new) { "Default" } %> + # + # @see https://flowbite.com/docs/components/badge/ + class Pill < Flowbite::Badge + class << self + # rubocop:disable Layout/LineLength + def styles + Flowbite::Styles.from_hash({ + alternative: { + default: ["bg-neutral-primary-soft", "hover:bg-neutral-secondary-medium", "rounded-full", "text-heading"] + }, + brand: { + default: ["bg-brand-softer", "hover:bg-brand-soft", "rounded-full", "text-fg-brand-strong"] + }, + danger: { + default: ["bg-danger-soft", "hover:bg-danger-medium", "rounded-full", "text-fg-danger-strong"] + }, + gray: { + default: ["bg-neutral-secondary-medium", "hover:bg-neutral-tertiary-medium", "rounded-full", "text-heading"] + }, + success: { + default: ["bg-success-soft", "hover:bg-success-medium", "rounded-full", "text-fg-success-strong"] + }, + warning: { + default: ["bg-warning-soft", "hover:bg-warning-medium", "rounded-full", "text-fg-warning"] + } + }.freeze) + end + # rubocop:enable Layout/LineLength + end + end + end +end diff --git a/demo/.yardoc/checksums b/demo/.yardoc/checksums index a5792e2..24d3072 100644 --- a/demo/.yardoc/checksums +++ b/demo/.yardoc/checksums @@ -1,5 +1,6 @@ app/components/flowbite/card.rb 9fe54b52bc9d177c2ec1d9e68e0a397b8a327744 app/components/flowbite/link.rb 1516522405f7cf2021913a4ebbb792f4ae386c16 +app/components/flowbite/badge.rb e695813e46a6244924ca0707e6f788a1f59c4cee app/components/flowbite/input.rb df2ae5f59a7d33a635599632386053f999f65919 app/components/flowbite/style.rb ef063360cc99cd7a6b8e67a7693326bb5dfb0e42 app/components/flowbite/toast.rb 6b822405dd55d87d56979e6cfba55e8f73965047 @@ -7,6 +8,7 @@ app/components/flowbite/button.rb 6ae7681d3b842d73aa99cddfa5a9b107ede7fea4 app/components/flowbite/styles.rb 929c42e428ba5a8e16efacaae0f35380e2f5f95c app/components/flowbite/sidebar.rb 85033b602a098f3334b9b3e180239ef20a1b6f90 app/components/flowbite/input/url.rb f1046824f9b06c8df8e0f567979321b82baac6fa +app/components/flowbite/badge/pill.rb cf713c5935e9300648f5501c90dced0aca488472 app/components/flowbite/breadcrumb.rb c69ffb465b6e7f2489d4ac9a928e08bdf252fe99 app/components/flowbite/card/title.rb 8067aa1e027c725896b063b67364aecfbf2f7d4e app/components/flowbite/input/date.rb 3b47f26b5622267e772c0d42d37655336ddf0169 diff --git a/demo/.yardoc/object_types b/demo/.yardoc/object_types index a5bb2e9..0c3b187 100644 Binary files a/demo/.yardoc/object_types and b/demo/.yardoc/object_types differ diff --git a/demo/.yardoc/objects/root.dat b/demo/.yardoc/objects/root.dat index a52ce9c..0220623 100644 Binary files a/demo/.yardoc/objects/root.dat and b/demo/.yardoc/objects/root.dat differ diff --git a/demo/test/components/previews/badge_preview.rb b/demo/test/components/previews/badge_preview.rb new file mode 100644 index 0000000..7c8977f --- /dev/null +++ b/demo/test/components/previews/badge_preview.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +class BadgePreview < Lookbook::Preview + def example + render(Flowbite::Badge.new) { "Default" } + end + + # @!group Styles + # + # Use these badge styles with multiple colors to indicate status or categories. + # + # @display classes flex flex-wrap gap-2 + + def alternative + render(Flowbite::Badge.new(style: :alternative)) { "Alternative" } + end + + def brand + render(Flowbite::Badge.new(style: :brand)) { "Brand" } + end + + def danger + render(Flowbite::Badge.new(style: :danger)) { "Danger" } + end + + def gray + render(Flowbite::Badge.new(style: :gray)) { "Gray" } + end + + def success + render(Flowbite::Badge.new(style: :success)) { "Success" } + end + + def warning + render(Flowbite::Badge.new(style: :warning)) { "Warning" } + end + + # @!endgroup + + # @!group Bordered + # + # Add a border accent in a matching color scheme. + # + # @display classes flex flex-wrap gap-2 + + def bordered_alternative + render(Flowbite::Badge.new(bordered: true, style: :alternative)) { "Alternative" } + end + + def bordered_brand + render(Flowbite::Badge.new(bordered: true, style: :brand)) { "Brand" } + end + + def bordered_danger + render(Flowbite::Badge.new(bordered: true, style: :danger)) { "Danger" } + end + + def bordered_gray + render(Flowbite::Badge.new(bordered: true, style: :gray)) { "Gray" } + end + + def bordered_success + render(Flowbite::Badge.new(bordered: true, style: :success)) { "Success" } + end + + def bordered_warning + render(Flowbite::Badge.new(bordered: true, style: :warning)) { "Warning" } + end + + # @!endgroup + + # @!group Large + # + # Increase the paddings to create a larger badge variant. + # + # @display classes flex flex-wrap gap-2 + + def large_brand + render(Flowbite::Badge.new(size: :lg, style: :brand)) { "Brand" } + end + + def large_bordered + render(Flowbite::Badge.new(bordered: true, size: :lg, style: :brand)) { "Brand" } + end + + # @!endgroup + + # @!group Pill + # + # Make the corners even more rounded like pills. + # + # @display classes flex flex-wrap gap-2 + + def pill_alternative + render(Flowbite::Badge::Pill.new(style: :alternative)) { "Alternative" } + end + + def pill_brand + render(Flowbite::Badge::Pill.new(style: :brand)) { "Brand" } + end + + def pill_danger + render(Flowbite::Badge::Pill.new(style: :danger)) { "Danger" } + end + + def pill_gray + render(Flowbite::Badge::Pill.new(style: :gray)) { "Gray" } + end + + def pill_success + render(Flowbite::Badge::Pill.new(style: :success)) { "Success" } + end + + def pill_warning + render(Flowbite::Badge::Pill.new(style: :warning)) { "Warning" } + end + + # @!endgroup + + # @!group Link + # + # Use badges as anchor elements to link to another page. + # + # @display classes flex flex-wrap gap-2 + + def link_badge + render(Flowbite::Badge.new(bordered: true, href: "#", style: :brand)) { "Brand" } + end + + def link_pill + render(Flowbite::Badge::Pill.new(bordered: true, href: "#", style: :brand)) { "Brand" } + end + + # @!endgroup + + # @!group Dot + # + # Add a colored dot indicator before the badge text. + # + # @display classes flex flex-wrap gap-2 + + def dot_alternative + render(Flowbite::Badge.new(bordered: true, dot: true, style: :alternative)) { "Alternative" } + end + + def dot_brand + render(Flowbite::Badge.new(bordered: true, dot: true, style: :brand)) { "Brand" } + end + + def dot_danger + render(Flowbite::Badge.new(bordered: true, dot: true, style: :danger)) { "Danger" } + end + + def dot_gray + render(Flowbite::Badge.new(bordered: true, dot: true, style: :gray)) { "Gray" } + end + + def dot_success + render(Flowbite::Badge.new(bordered: true, dot: true, style: :success)) { "Success" } + end + + def dot_warning + render(Flowbite::Badge.new(bordered: true, dot: true, style: :warning)) { "Warning" } + end + + # @!endgroup +end diff --git a/test/components/flowbite/badge_test.rb b/test/components/flowbite/badge_test.rb new file mode 100644 index 0000000..3322729 --- /dev/null +++ b/test/components/flowbite/badge_test.rb @@ -0,0 +1,193 @@ +require "test_helper" + +class Flowbite::BadgeTest < Minitest::Test + include ViewComponent::TestHelpers + + def test_render_component + render_inline(Flowbite::Badge.new) { "Default" } + + assert_component_rendered + assert_selector("span", text: "Default") + end + + def test_renders_with_default_classes + render_inline(Flowbite::Badge.new) { "Badge" } + + assert_selector("span.bg-brand-softer.text-fg-brand-strong.text-xs.font-medium.rounded") + end + + # Styles + + def test_renders_brand_style + render_inline(Flowbite::Badge.new(style: :brand)) { "Brand" } + + assert_selector("span.bg-brand-softer.text-fg-brand-strong") + end + + def test_renders_danger_style + render_inline(Flowbite::Badge.new(style: :danger)) { "Danger" } + + assert_selector("span.bg-danger-soft.text-fg-danger-strong") + end + + def test_renders_alternative_style + render_inline(Flowbite::Badge.new(style: :alternative)) { "Alternative" } + + assert_selector("span.bg-neutral-primary-soft.text-heading") + end + + def test_renders_gray_style + render_inline(Flowbite::Badge.new(style: :gray)) { "Gray" } + + assert_selector("span.bg-neutral-secondary-medium.text-heading") + end + + def test_renders_success_style + render_inline(Flowbite::Badge.new(style: :success)) { "Success" } + + assert_selector("span.bg-success-soft.text-fg-success-strong") + end + + def test_renders_warning_style + render_inline(Flowbite::Badge.new(style: :warning)) { "Warning" } + + assert_selector("span.bg-warning-soft.text-fg-warning") + end + + # Sizes + + def test_renders_default_size + render_inline(Flowbite::Badge.new(size: :default)) { "Badge" } + + assert_selector("span.text-xs.px-1\\.5.py-0\\.5") + end + + def test_renders_large_size + render_inline(Flowbite::Badge.new(size: :lg)) { "Badge" } + + assert_selector("span.text-sm.px-2.py-1") + end + + # Bordered + + def test_renders_with_border + render_inline(Flowbite::Badge.new(bordered: true)) { "Badge" } + + assert_selector("span.border.border-brand-subtle") + end + + def test_renders_bordered_with_matching_style + render_inline(Flowbite::Badge.new(bordered: true, style: :danger)) { "Badge" } + + assert_selector("span.border.border-danger-subtle") + end + + # Rounding + + def test_renders_default_rounding + render_inline(Flowbite::Badge.new) { "Badge" } + + assert_selector("span.rounded") + end + + # Link + + def test_renders_as_link_when_href_provided + render_inline(Flowbite::Badge.new(href: "/path")) { "Badge" } + + assert_selector("a[href='/path']", text: "Badge") + end + + def test_link_badge_has_hover_style + render_inline(Flowbite::Badge.new(href: "/path")) { "Badge" } + + assert_selector("a.hover\\:bg-brand-soft") + end + + def test_renders_as_span_without_href + render_inline(Flowbite::Badge.new) { "Badge" } + + assert_selector("span") + assert_no_selector("a") + end + + # Dot + + def test_renders_with_dot_indicator + render_inline(Flowbite::Badge.new(dot: true)) { "Badge" } + + assert_selector("span.inline-flex.items-center") + assert_selector("span span.rounded-full.bg-fg-brand-strong") + end + + def test_dot_has_correct_size_classes + render_inline(Flowbite::Badge.new(dot: true)) { "Badge" } + + assert_selector("span span.h-1\\.5.w-1\\.5.me-1") + end + + def test_dot_matches_style_color + render_inline(Flowbite::Badge.new(dot: true, style: :success)) { "Badge" } + + assert_selector("span span.bg-fg-success-strong") + end + + # Custom classes + + def test_adds_classes_to_the_default_ones + render_inline(Flowbite::Badge.new(class: "custom-class")) { "Badge" } + + assert_selector("span.bg-brand-softer.custom-class") + end + + # Custom options + + def test_passes_additional_options_as_attributes + render_inline(Flowbite::Badge.new(id: "my-badge", data: {controller: "badge"})) { "Badge" } + + assert_selector("span[id='my-badge'][data-controller='badge']") + end + + # Combinations + + def test_renders_large_bordered_badge + render_inline(Flowbite::Badge.new(bordered: true, size: :lg)) { "Badge" } + + assert_selector("span.text-sm.px-2.py-1.border") + end +end + +class Flowbite::Badge::PillTest < Minitest::Test + include ViewComponent::TestHelpers + + def test_render_component + render_inline(Flowbite::Badge::Pill.new) { "Pill" } + + assert_component_rendered + assert_selector("span", text: "Pill") + end + + def test_renders_with_rounded_full + render_inline(Flowbite::Badge::Pill.new) { "Pill" } + + assert_selector("span.rounded-full") + end + + def test_does_not_have_regular_rounded + render_inline(Flowbite::Badge::Pill.new) { "Pill" } + + assert_no_selector("span.rounded:not(.rounded-full)") + end + + def test_inherits_style_classes + render_inline(Flowbite::Badge::Pill.new(style: :success)) { "Pill" } + + assert_selector("span.bg-success-soft.text-fg-success-strong.rounded-full") + end + + def test_renders_bordered_pill + render_inline(Flowbite::Badge::Pill.new(bordered: true)) { "Pill" } + + assert_selector("span.rounded-full.border") + end +end