daisy_components 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/rules/how-to-write-tests.mdc +67 -0
  3. data/.cursor/rules/preview-file-stucture.mdc +95 -0
  4. data/.cursorrules +64 -0
  5. data/.github/dependabot.yml +12 -0
  6. data/.github/workflows/ci.yml +65 -0
  7. data/.gitignore +13 -0
  8. data/.vscode/launch.json +55 -0
  9. data/.vscode/settings.json +44 -0
  10. data/CHANGELOG.md +39 -0
  11. data/Gemfile +32 -0
  12. data/Gemfile.lock +335 -0
  13. data/MIT-LICENSE +20 -0
  14. data/README.md +64 -0
  15. data/Rakefile +7 -0
  16. data/app/assets/images/daisy_components/.keep +0 -0
  17. data/app/assets/stylesheets/daisy_ui/application.css +15 -0
  18. data/app/components/daisy_ui/actions/button.rb +262 -0
  19. data/app/components/daisy_ui/actions/dropdown.rb +125 -0
  20. data/app/components/daisy_ui/actions/swap.rb +126 -0
  21. data/app/components/daisy_ui/base_component.rb +29 -0
  22. data/app/components/daisy_ui/data_display/accordion.rb +128 -0
  23. data/app/components/daisy_ui/data_display/accordion_item.rb +121 -0
  24. data/app/components/daisy_ui/data_display/avatar.rb +131 -0
  25. data/app/components/daisy_ui/data_display/avatar_group.rb +102 -0
  26. data/app/components/daisy_ui/data_display/badge.rb +126 -0
  27. data/app/components/daisy_ui/data_display/card/actions.rb +94 -0
  28. data/app/components/daisy_ui/data_display/card/body.rb +113 -0
  29. data/app/components/daisy_ui/data_display/card/figure.rb +44 -0
  30. data/app/components/daisy_ui/data_display/card/title.rb +56 -0
  31. data/app/components/daisy_ui/data_display/card.rb +157 -0
  32. data/app/components/daisy_ui/data_display/chat.rb +92 -0
  33. data/app/components/daisy_ui/data_display/chat_bubble/metadata.rb +71 -0
  34. data/app/components/daisy_ui/data_display/chat_bubble.rb +166 -0
  35. data/app/components/daisy_ui/divider.rb +9 -0
  36. data/app/components/daisy_ui/item.rb +20 -0
  37. data/app/components/daisy_ui/title.rb +9 -0
  38. data/app/controllers/concerns/.keep +0 -0
  39. data/app/controllers/daisy_ui/application_controller.rb +6 -0
  40. data/app/helpers/daisy_ui/application_helper.rb +6 -0
  41. data/app/helpers/daisy_ui/icons_helper.rb +296 -0
  42. data/app/views/layouts/daisyui/application.html.erb +17 -0
  43. data/bin/parse_coverage.rb +59 -0
  44. data/bin/rails +57 -0
  45. data/bin/rubocop +10 -0
  46. data/bin/scrape_component +86 -0
  47. data/daisy_components.gemspec +33 -0
  48. data/docs/assets/2025-01_screeshot_1.png +0 -0
  49. data/docs/assets/2025-01_screeshot_2.png +0 -0
  50. data/docs/assets/2025-01_screeshot_3.png +0 -0
  51. data/lib/daisy_components.rb +5 -0
  52. data/lib/daisy_ui/engine.rb +51 -0
  53. data/lib/daisy_ui/version.rb +5 -0
  54. data/lib/daisy_ui.rb +13 -0
  55. data/lib/tasks/daisy_ui_tasks.rake +6 -0
  56. metadata +112 -0
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ # Avatar group component that displays a collection of avatars with customizable styling
5
+ #
6
+ # @example Basic usage
7
+ # <%= render(AvatarGroupComponent.new) do |group| %>
8
+ # <% group.with_avatar(img_src: "path/to/image.jpg", img_alt: "User 1") %>
9
+ # <% group.with_avatar(img_src: "path/to/image.jpg", img_alt: "User 2") %>
10
+ # <% end %>
11
+ #
12
+ # @example With max display and counter
13
+ # <%= render(AvatarGroupComponent.new(max_display: 2)) do |group| %>
14
+ # <% group.with_avatar(img_src: "path/to/image.jpg", img_alt: "User 1") %>
15
+ # <% group.with_avatar(img_src: "path/to/image.jpg", img_alt: "User 2") %>
16
+ # <% group.with_avatar(img_src: "path/to/image.jpg", img_alt: "User 3") %>
17
+ # <% end %>
18
+ class AvatarGroup < DaisyUI::BaseComponent
19
+ SIZES = Avatar::SIZES
20
+ SHAPES = Avatar::SHAPES
21
+ DEFAULT_SPACING = 1.5
22
+
23
+ renders_many :avatars, lambda { |**system_arguments|
24
+ Avatar.new(
25
+ size: @size,
26
+ shape: @shape&.to_sym,
27
+ inner_class: system_arguments.delete(:inner_class),
28
+ **system_arguments
29
+ )
30
+ }
31
+
32
+ # Initializes a new Avatar Group component
33
+ #
34
+ # @param size [Symbol, nil] Size of the avatars. Must be one of: w8, w12, w16, w20, w24, w32
35
+ # @param shape [Symbol, nil] Shape of the avatars. Must be one of: circle, squircle, hexagon, triangle
36
+ # @param spacing [Float] Space between avatars in rem units (default: 1.5)
37
+ # @param max_display [Integer, nil] Maximum number of avatars to display before showing a counter
38
+ # @param system_arguments [Hash] Additional HTML attributes to be applied to the wrapper element
39
+ def initialize(size: nil, shape: nil, spacing: DEFAULT_SPACING, max_display: nil, **system_arguments)
40
+ @size = size
41
+ @shape = shape
42
+ @spacing = spacing.to_f
43
+ @max_display = max_display&.to_i
44
+
45
+ super(**system_arguments)
46
+ end
47
+
48
+ # @return [String] Rendered HTML for the avatar group
49
+ def call
50
+ tag.div(**html_attributes) do
51
+ content = []
52
+ content.concat(displayed_avatars)
53
+ content << render_counter if remaining_count
54
+ safe_join(content)
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def default_classes
61
+ class_names(
62
+ 'avatar-group',
63
+ spacing_class,
64
+ system_arguments[:class]
65
+ )
66
+ end
67
+
68
+ def html_attributes
69
+ attrs = system_arguments.except(:class)
70
+ attrs[:class] = default_classes
71
+ attrs
72
+ end
73
+
74
+ def displayed_avatars
75
+ return avatars if avatars.empty? || !@max_display
76
+
77
+ avatars.first(@max_display)
78
+ end
79
+
80
+ def remaining_count
81
+ return unless @max_display && avatars.size > @max_display
82
+
83
+ avatars.size - @max_display
84
+ end
85
+
86
+ def render_counter
87
+ Avatar.new(
88
+ size: @size,
89
+ shape: @shape,
90
+ placeholder_text: "+#{remaining_count}",
91
+ inner_class: 'bg-neutral text-neutral-content'
92
+ ).call
93
+ end
94
+
95
+ def spacing_class
96
+ # Convert rem to tailwind spacing units (1rem = 4 units)
97
+ # Clamp between 0 and 16 (4rem) to ensure valid Tailwind classes
98
+ rem = [(@spacing * 4).round, 0].max.clamp(0, 16)
99
+ "-space-x-#{rem}"
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ # Badge component implementing DaisyUI's badge styles
5
+ #
6
+ # @example Basic usage
7
+ # <%= render(BadgeComponent.new(text: "Badge")) %>
8
+ #
9
+ # @example Primary variant
10
+ # <%= render(BadgeComponent.new(text: "Primary", color: :primary)) %>
11
+ #
12
+ # @example Outline style
13
+ # <%= render(BadgeComponent.new(text: "Outline", variant: :outline)) %>
14
+ #
15
+ # @example Small size with icon
16
+ # <%= render(BadgeComponent.new(
17
+ # text: "Icon",
18
+ # size: :sm,
19
+ # icon: helpers.check_icon
20
+ # )) %>
21
+ #
22
+ # @example Soft style with color
23
+ # <%= render(BadgeComponent.new(
24
+ # text: "Soft",
25
+ # variant: :soft,
26
+ # color: :primary
27
+ # )) %>
28
+ #
29
+ # @example With block content
30
+ # <%= render(BadgeComponent.new) do %>
31
+ # Complex <strong>content</strong>
32
+ # <% end %>
33
+ #
34
+ # @example With custom tag type
35
+ # <%= render(BadgeComponent.new(text: "Badge", tag_type: :span)) %>
36
+ class Badge < DaisyUI::BaseComponent
37
+ # Available badge colors from DaisyUI
38
+ COLORS = {
39
+ primary: 'badge-primary',
40
+ secondary: 'badge-secondary',
41
+ accent: 'badge-accent',
42
+ neutral: 'badge-neutral',
43
+ ghost: 'badge-ghost',
44
+ info: 'badge-info',
45
+ success: 'badge-success',
46
+ warning: 'badge-warning',
47
+ error: 'badge-error'
48
+ }.freeze
49
+
50
+ # Available badge sizes from DaisyUI
51
+ SIZES = {
52
+ xl: 'badge-xl',
53
+ lg: 'badge-lg',
54
+ md: 'badge-md',
55
+ sm: 'badge-sm',
56
+ xs: 'badge-xs'
57
+ }.freeze
58
+
59
+ # Available badge variants from DaisyUI
60
+ VARIANTS = {
61
+ outline: 'badge-outline',
62
+ soft: 'badge-soft',
63
+ dash: 'badge-dash',
64
+ ghost: 'badge-ghost'
65
+ }.freeze
66
+
67
+ # @param text [String] The text content to display inside the badge
68
+ # @param color [String] Visual style of the badge
69
+ # (neutral/primary/secondary/accent/info/success/warning/error/ghost)
70
+ # @param size [String] Size of the badge (xl/lg/md/sm/xs)
71
+ # @param variant [String] Variant of the badge (outline/soft/dash/ghost)
72
+ # @param icon [String] SVG icon to display before the text
73
+ # @param tag_type [Symbol] HTML tag to use for the badge (default: :div)
74
+ # @param system_arguments [Hash] Additional HTML attributes to be applied to the badge
75
+ def initialize(
76
+ text = nil,
77
+ color: nil,
78
+ size: nil,
79
+ variant: nil,
80
+ icon: nil,
81
+ tag_type: :div,
82
+ **system_arguments
83
+ )
84
+ @text = text
85
+ @color = build_argument(color, COLORS, 'color')
86
+ @size = build_argument(size, SIZES, 'size')
87
+ @variant = build_argument(variant, VARIANTS, 'variant')
88
+ @icon = icon
89
+ @tag_type = tag_type
90
+
91
+ super(**system_arguments)
92
+ end
93
+
94
+ def call
95
+ tag.public_send(@tag_type, **html_attributes) do
96
+ safe_join([render_icon, content || @text].compact)
97
+ end
98
+ end
99
+
100
+ delegate :to_s, to: :call
101
+
102
+ private
103
+
104
+ def render_icon
105
+ return unless @icon
106
+
107
+ helpers.sanitize(@icon, tags: %w[svg path g circle],
108
+ attributes: %w[class viewBox fill stroke d])
109
+ end
110
+
111
+ def computed_classes
112
+ modifiers = ['badge']
113
+ modifiers << @variant
114
+ modifiers << @color
115
+ modifiers << @size
116
+
117
+ class_names(modifiers, system_arguments[:class])
118
+ end
119
+
120
+ def html_attributes
121
+ attrs = system_arguments.except(:class)
122
+ attrs[:class] = computed_classes
123
+ attrs
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ class Card
5
+ # Actions component for the card, handling action buttons layout
6
+ #
7
+ # @example Basic usage
8
+ # <%= render(CardComponent.new) do |component| %>
9
+ # <% component.with_body do |body| %>
10
+ # <% body.with_actions do %>
11
+ # <%= render(ButtonComponent.new(text: "Action")) %>
12
+ # <% end %>
13
+ # <% end %>
14
+ # <% end %>
15
+ #
16
+ # @example With multiple buttons
17
+ # <%= render(CardComponent.new) do |component| %>
18
+ # <% component.with_body do |body| %>
19
+ # <% body.with_actions(justify: :between) do |actions| %>
20
+ # <% actions.with_button(text: "Cancel", variant: :ghost) %>
21
+ # <% actions.with_button(text: "Submit", color: :primary) %>
22
+ # <% end %>
23
+ # <% end %>
24
+ # <% end %>
25
+ #
26
+ # @example With custom buttons
27
+ # <%= render(CardComponent.new) do |component| %>
28
+ # <% component.with_body do |body| %>
29
+ # <% body.with_actions(justify: :end) do %>
30
+ # <%= render(ButtonComponent.new(text: "Delete", color: :error)) %>
31
+ # <%= render(ButtonComponent.new(text: "Save", color: :primary)) %>
32
+ # <% end %>
33
+ # <% end %>
34
+ # <% end %>
35
+ class Actions < BaseComponent
36
+ # Available justification options for actions layout
37
+ JUSTIFY_OPTIONS = {
38
+ start: 'justify-start',
39
+ end: 'justify-end',
40
+ center: 'justify-center',
41
+ between: 'justify-between',
42
+ around: 'justify-around',
43
+ evenly: 'justify-evenly'
44
+ }.freeze
45
+
46
+ # Renders multiple actions (buttons or badges)
47
+ renders_many :actions, types: {
48
+ button: { renders: DaisyUI::Button, as: :button },
49
+ badge: { renders: DaisyUI::Badge, as: :badge }
50
+ }
51
+
52
+ # @param justify [Symbol] Justification for action buttons layout
53
+ # @param system_arguments [Hash] Additional HTML attributes
54
+ def initialize(justify: nil, **system_arguments)
55
+ @justify = build_justify(justify)
56
+ super(**system_arguments)
57
+ end
58
+
59
+ def call
60
+ tag.div(**html_attributes) do
61
+ if actions.any?
62
+ safe_join(actions)
63
+ else
64
+ content
65
+ end
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def build_justify(key)
72
+ return nil unless key
73
+
74
+ class_name = JUSTIFY_OPTIONS[key.to_sym]
75
+ return class_name if class_name
76
+
77
+ raise ArgumentError, "Invalid justify: #{key}. Must be one of: #{JUSTIFY_OPTIONS.keys.join(', ')}"
78
+ end
79
+
80
+ def computed_classes
81
+ modifiers = ['card-actions']
82
+ modifiers << @justify if @justify
83
+
84
+ class_names(modifiers, system_arguments[:class])
85
+ end
86
+
87
+ def html_attributes
88
+ system_arguments.merge(
89
+ class: computed_classes
90
+ )
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ class Card
5
+ # Body component for the card, containing title, description and actions
6
+ #
7
+ # @example Basic usage
8
+ # <%= render(CardComponent.new) do |component| %>
9
+ # <% component.with_body do |body| %>
10
+ # <% body.with_title { "Title" } %>
11
+ # <% body.with_description { "Description" } %>
12
+ # <% end %>
13
+ # <% end %>
14
+ #
15
+ # @example With simple button
16
+ # <%= render(CardComponent.new) do |component| %>
17
+ # <% component.with_body(
18
+ # title: "Title",
19
+ # description: "Description",
20
+ # button: { text: "Action", color: :primary, justify: :end }
21
+ # ) %>
22
+ # <% end %>
23
+ #
24
+ # @example With multiple buttons
25
+ # <%= render(CardComponent.new) do |component| %>
26
+ # <% component.with_body do |body| %>
27
+ # <% body.with_title { "Title" } %>
28
+ # <% body.with_actions(justify: :between) do |actions| %>
29
+ # <% actions.with_button(text: "Cancel", variant: :ghost) %>
30
+ # <% actions.with_bage(text: "Submit", color: :primary) %>
31
+ # <% end %>
32
+ # <% end %>
33
+ # <% end %>
34
+ class Body < BaseComponent
35
+ # Title is a component slot that renders TitleComponent
36
+ renders_one :title, DaisyUI::Card::Title
37
+
38
+ # Actions is a component slot that renders ActionsComponent
39
+ renders_one :actions, DaisyUI::Card::Actions
40
+
41
+ # Description is a content slot that can contain any content
42
+ renders_one :description
43
+
44
+ # @param variant [Symbol] Card variant affecting body layout
45
+ # @param description [String] Card description text
46
+ # @param title [String] Card title text
47
+ # @param button [Hash] Simple button configuration
48
+ # @option button [String] :text Button text
49
+ # @option button [Symbol] :color Button color
50
+ # @option button [Symbol] :variant Button variant
51
+ # @option button [Symbol] :size Button size
52
+ # @option button [Symbol] :justify Button justification
53
+ # @param system_arguments [Hash] Additional HTML attributes
54
+ def initialize(variant: nil,
55
+ description: nil,
56
+ title: nil,
57
+ button: nil,
58
+ **system_arguments)
59
+ @variant = variant
60
+ @description = description
61
+ @title = title
62
+ @button = button
63
+ super(**system_arguments)
64
+ end
65
+
66
+ def call
67
+ tag.div(**html_attributes) do
68
+ safe_join([
69
+ title,
70
+ render_description,
71
+ actions
72
+ ].compact)
73
+ end
74
+ end
75
+
76
+ def before_render
77
+ with_title(@title) if @title && !title?
78
+ with_description { tag.p(@description) } if @description && !description?
79
+ setup_button if @button && !actions?
80
+ end
81
+
82
+ private
83
+
84
+ def render_description
85
+ return description if description?
86
+
87
+ tag.p(@description) if @description
88
+ end
89
+
90
+ def setup_button
91
+ button_config = @button.symbolize_keys
92
+ justify = button_config.delete(:justify)
93
+
94
+ with_actions(justify: justify) do |actions|
95
+ actions.with_button(**button_config)
96
+ end
97
+ end
98
+
99
+ def computed_classes
100
+ modifiers = %w[card-body]
101
+ modifiers << 'flex flex-col justify-between' if @variant == :side
102
+
103
+ class_names(modifiers, system_arguments[:class])
104
+ end
105
+
106
+ def html_attributes
107
+ system_arguments.merge(
108
+ class: computed_classes
109
+ )
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ class Card
5
+ # Figure component for the card, handling image display
6
+ #
7
+ # @example Basic usage with URL
8
+ # <%= render(CardComponent.new) do |component| %>
9
+ # <% component.with_figure(img_url: "https://picsum.photos/400/200") %>
10
+ # <% end %>
11
+ #
12
+ # @example Custom image tag
13
+ # <%= render(CardComponent.new) do |component| %>
14
+ # <% component.with_figure do |figure| %>
15
+ # <% figure.with_image do %>
16
+ # <%= image_tag "custom.jpg", class: "rounded-xl" %>
17
+ # <% end %>
18
+ # <% end %>
19
+ # <% end %>
20
+ class Figure < BaseComponent
21
+ # Image is a content slot that can contain any image content
22
+ renders_one :image
23
+
24
+ # @param img_url [String] URL for the card image
25
+ # @param alt [String] Alt text for the image
26
+ # @param system_arguments [Hash] Additional HTML attributes
27
+ def initialize(img_url: nil, img_alt: nil, **system_arguments)
28
+ @img_url = img_url
29
+ @img_alt = img_alt
30
+ super(**system_arguments)
31
+ end
32
+
33
+ def before_render
34
+ with_image { tag.img(src: @img_url, alt: @img_alt) } if @img_url && !image?
35
+ end
36
+
37
+ def call
38
+ tag.figure(**system_arguments) do
39
+ safe_join([image, content].compact)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ class Card
5
+ # Title component for the card, handling the title display
6
+ #
7
+ # @example Basic usage
8
+ # <%= render(CardComponent.new) do |component| %>
9
+ # <% component.with_body do |body| %>
10
+ # <% body.with_title { "Card Title" } %>
11
+ # <% end %>
12
+ # <% end %>
13
+ #
14
+ # @example With custom tag
15
+ # <%= render(CardComponent.new) do |component| %>
16
+ # <% component.with_body do |body| %>
17
+ # <% body.with_title(tag_name: :h1) { "Large Title" } %>
18
+ # <% end %>
19
+ # <% end %>
20
+ #
21
+ # @example With text parameter
22
+ # <%= render(CardComponent.new) do |component| %>
23
+ # <% component.with_body do |body| %>
24
+ # <% body.with_title("Simple Title") %>
25
+ # <% end %>
26
+ # <% end %>
27
+ class Title < BaseComponent
28
+ # @param text [String] The title text (optional if block given)
29
+ # @param tag_name [Symbol] HTML tag to use for the title
30
+ # @param system_arguments [Hash] Additional HTML attributes
31
+ def initialize(text = nil, tag_name: :h2, **system_arguments)
32
+ @text = text
33
+ @tag_name = tag_name
34
+ super(**system_arguments)
35
+ end
36
+
37
+ def call
38
+ content_tag(@tag_name, **html_attributes) do
39
+ content || @text
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def computed_classes
46
+ class_names('card-title', system_arguments[:class])
47
+ end
48
+
49
+ def html_attributes
50
+ system_arguments.merge(
51
+ class: computed_classes
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ # Card component for displaying content in a contained card format
5
+ #
6
+ # @example Basic usage with simple button
7
+ # <%= render(CardComponent.new(
8
+ # title: "Card Title",
9
+ # description: "Card description here",
10
+ # button: { text: "Action", color: :primary, justify: :end }
11
+ # )) %>
12
+ #
13
+ # @example With figure/image
14
+ # <%= render(CardComponent.new(
15
+ # img_url: "https://picsum.photos/400/200"
16
+ # )) %>
17
+ #
18
+ # @example Bordered variant
19
+ # <%= render(CardComponent.new(variant: :bordered)) do |component| %>
20
+ # <% component.with_body(title: "Bordered Card") %>
21
+ # <% end %>
22
+ #
23
+ # @example Side image variant
24
+ # <%= render(CardComponent.new(
25
+ # variant: :side,
26
+ # img_url: "https://picsum.photos/200/400"
27
+ # )) do |component| %>
28
+ # <% component.with_body do |body| %>
29
+ # <% body.with_title { "Side Image" } %>
30
+ # <% end %>
31
+ # <% end %>
32
+ #
33
+ # @example With actions
34
+ # <%= render(CardComponent.new) do |component| %>
35
+ # <% component.with_body do |body| %>
36
+ # <% body.with_title { "Card with Actions" } %>
37
+ # <% body.with_actions(justify: :between) do %>
38
+ # <%= render(ButtonComponent.new(text: "Action")) %>
39
+ # <% end %>
40
+ # <% end %>
41
+ # <% end %>
42
+ class Card < BaseComponent
43
+ # Available card variants
44
+ VARIANTS = {
45
+ compact: 'card-compact',
46
+ side: 'card-side',
47
+ side_responsive: 'lg:card-side',
48
+ bordered: 'card-border',
49
+ dash: 'card-dash'
50
+ }.freeze
51
+
52
+ COLORS = {
53
+ primary: 'bg-primary text-primary-content',
54
+ secondary: 'bg-secondary text-secondary-content',
55
+ accent: 'bg-accent text-accent-content',
56
+ neutral: 'bg-neutral text-neutral-content',
57
+ base: 'bg-base-100'
58
+ }.freeze
59
+
60
+ SIZES = {
61
+ xs: 'card-xs',
62
+ sm: 'card-sm',
63
+ md: 'card-md',
64
+ lg: 'card-lg',
65
+ xl: 'card-xl'
66
+ }.freeze
67
+
68
+ renders_one :figure, lambda { |**system_arguments|
69
+ Figure.new(img_url: @img_url, img_alt: @img_alt, **system_arguments)
70
+ }
71
+
72
+ renders_one :body, lambda { |**system_arguments|
73
+ Body.new(
74
+ variant: @variant,
75
+ title: @title,
76
+ description: @description,
77
+ button: @button,
78
+ **system_arguments
79
+ )
80
+ }
81
+
82
+ # @param variant [Symbol] Card layout variant (:compact, :side, :bordered)
83
+ # @param image_full [Boolean] Makes the image cover the full card
84
+ # @param title [String] Card title (optional)
85
+ # @param description [String] Card description (optional)
86
+ # @param button [Hash] Simple button configuration (optional)
87
+ # @option button [String] :text Button text
88
+ # @option button [Symbol] :color Button color (primary, secondary, etc)
89
+ # @option button [Symbol] :variant Button variant (outline, soft, etc)
90
+ # @option button [Symbol] :size Button size (xs, sm, md, lg, xl)
91
+ # @option button [Symbol] :justify Button justification (start, end, center, between, around, evenly)
92
+ # @param img_url [String] URL for the card image (optional)
93
+ # @param system_arguments [Hash] Additional HTML attributes
94
+ def initialize(variant: nil,
95
+ image_full: false,
96
+ title: nil,
97
+ description: nil,
98
+ button: nil,
99
+ img_url: nil,
100
+ img_alt: nil,
101
+ bottom_image: false,
102
+ shadow: false,
103
+ size: nil,
104
+ color: :base,
105
+ **system_arguments)
106
+ @variant = build_argument(variant, VARIANTS, 'variant')
107
+ @color = build_argument(color, COLORS, 'color')
108
+ @size = build_argument(size, SIZES, 'size')
109
+ @image_full = image_full
110
+ @title = title
111
+ @description = description
112
+ @button = button
113
+ @img_url = img_url
114
+ @img_alt = img_alt
115
+ @shadow = shadow
116
+ @bottom_image = bottom_image
117
+ super(**system_arguments)
118
+ end
119
+
120
+ def before_render
121
+ with_figure if @img_url && !figure?
122
+ with_body unless body?
123
+ end
124
+
125
+ def call
126
+ tag.div(**html_attributes) do
127
+ safe_join(render_in_oder)
128
+ end
129
+ end
130
+
131
+ def render_in_oder
132
+ if @bottom_image
133
+ [body, figure].compact
134
+ else
135
+ [figure, body].compact
136
+ end
137
+ end
138
+
139
+ private
140
+
141
+ def computed_classes
142
+ modifiers = %w[card]
143
+ modifiers << @color if @color.present?
144
+ modifiers << @variant if @variant.present?
145
+ modifiers << 'image-full' if @image_full
146
+ modifiers << 'shadow-sm' if @shadow
147
+ modifiers << 'w-96' unless @variant.in?([VARIANTS[:side], VARIANTS[:side_responsive]])
148
+ modifiers << @size if @size.present?
149
+ class_names(modifiers, system_arguments[:class])
150
+ end
151
+
152
+ def html_attributes
153
+ safe_args = system_arguments.except(:bottom_image)
154
+ safe_args.merge(class: computed_classes)
155
+ end
156
+ end
157
+ end