better_ui 0.8.0 → 0.9.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +49 -6
  3. data/app/components/better_ui/application_component.rb +34 -0
  4. data/app/components/better_ui/avatar_component/avatar_component.html.erb +15 -0
  5. data/app/components/better_ui/avatar_component.rb +227 -0
  6. data/app/components/better_ui/badge_component/badge_component.html.erb +16 -0
  7. data/app/components/better_ui/badge_component.rb +114 -0
  8. data/app/components/better_ui/breadcrumb/breadcrumb_component/breadcrumb_component.html.erb +12 -0
  9. data/app/components/better_ui/breadcrumb/breadcrumb_component.rb +148 -0
  10. data/app/components/better_ui/breadcrumb/item_component/item_component.html.erb +11 -0
  11. data/app/components/better_ui/breadcrumb/item_component.rb +78 -0
  12. data/app/components/better_ui/card_component.rb +45 -11
  13. data/app/components/better_ui/concerns/inline_label_styles.rb +137 -0
  14. data/app/components/better_ui/container_component/container_component.html.erb +3 -0
  15. data/app/components/better_ui/container_component.rb +143 -0
  16. data/app/components/better_ui/dialog/alert_component/alert_component.html.erb +61 -0
  17. data/app/components/better_ui/dialog/alert_component.rb +78 -0
  18. data/app/components/better_ui/dialog/confirm_component/confirm_component.html.erb +67 -0
  19. data/app/components/better_ui/dialog/confirm_component.rb +80 -0
  20. data/app/components/better_ui/dialog/dialog_component/dialog_component.html.erb +44 -0
  21. data/app/components/better_ui/dialog/dialog_component.rb +81 -0
  22. data/app/components/better_ui/divider_component/divider_component.html.erb +11 -0
  23. data/app/components/better_ui/divider_component.rb +344 -0
  24. data/app/components/better_ui/dropdown/divider_component/divider_component.html.erb +1 -0
  25. data/app/components/better_ui/dropdown/divider_component.rb +20 -0
  26. data/app/components/better_ui/dropdown/dropdown_component/dropdown_component.html.erb +19 -0
  27. data/app/components/better_ui/dropdown/dropdown_component.rb +108 -0
  28. data/app/components/better_ui/dropdown/header_component/header_component.html.erb +3 -0
  29. data/app/components/better_ui/dropdown/header_component.rb +25 -0
  30. data/app/components/better_ui/dropdown/item_component/item_component.html.erb +7 -0
  31. data/app/components/better_ui/dropdown/item_component.rb +97 -0
  32. data/app/components/better_ui/fa_icon_component/fa_icon_component.html.erb +1 -0
  33. data/app/components/better_ui/fa_icon_component.rb +165 -0
  34. data/app/components/better_ui/forms/base_component.rb +3 -1
  35. data/app/components/better_ui/forms/select_component/select_component.html.erb +86 -0
  36. data/app/components/better_ui/forms/select_component.rb +347 -0
  37. data/app/components/better_ui/forms/text_input_component.rb +24 -2
  38. data/app/components/better_ui/heading_component/heading_component.html.erb +11 -0
  39. data/app/components/better_ui/heading_component.rb +259 -0
  40. data/app/components/better_ui/link_component/link_component.html.erb +5 -0
  41. data/app/components/better_ui/link_component.rb +169 -0
  42. data/app/components/better_ui/progress_component/progress_component.html.erb +15 -0
  43. data/app/components/better_ui/progress_component.rb +98 -0
  44. data/app/components/better_ui/spinner_component/spinner_component.html.erb +11 -0
  45. data/app/components/better_ui/spinner_component.rb +70 -0
  46. data/app/components/better_ui/table/cell_component/cell_component.html.erb +3 -0
  47. data/app/components/better_ui/table/cell_component.rb +84 -0
  48. data/app/components/better_ui/table/column_component.rb +75 -0
  49. data/app/components/better_ui/table/header_cell_component/header_cell_component.html.erb +18 -0
  50. data/app/components/better_ui/table/header_cell_component.rb +138 -0
  51. data/app/components/better_ui/table/header_component/header_component.html.erb +5 -0
  52. data/app/components/better_ui/table/header_component.rb +37 -0
  53. data/app/components/better_ui/table/row_component/row_component.html.erb +5 -0
  54. data/app/components/better_ui/table/row_component.rb +88 -0
  55. data/app/components/better_ui/table/table_component/table_component.html.erb +90 -0
  56. data/app/components/better_ui/table/table_component.rb +467 -0
  57. data/app/components/better_ui/tag_component/tag_component.html.erb +33 -0
  58. data/app/components/better_ui/tag_component.rb +114 -0
  59. data/app/components/better_ui/tooltip_component/tooltip_component.html.erb +11 -0
  60. data/app/components/better_ui/tooltip_component.rb +154 -0
  61. data/app/form_builders/better_ui/ui_form_builder.rb +90 -0
  62. data/app/helpers/better_ui/application_helper.rb +501 -0
  63. data/lib/better_ui/version.rb +1 -1
  64. metadata +57 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b3c7255ce65e6a53ed2ee42174475f656461303920e1a4e6c9912fe3ecd4d4a
4
- data.tar.gz: 4c4c5063560d6edcb534fd1856869928e4b3f0456e7d86dea76f216bb3c4ae2e
3
+ metadata.gz: 2c218661dc93efb93042ba9a4a5277091a7dac3de216acc162d50c8d36077bc8
4
+ data.tar.gz: b06ea500b4d3c547249664dcee953910457263029549d77af2a3314cef45ef58
5
5
  SHA512:
6
- metadata.gz: b06273f6f5fbdd050f7f1fcdc20b1a15663af200f25d076472d31c77bc953e39a68db31633de2f64c888e0a0f0459c1fa8b75cefd4dc919bb4a18f2397da09de
7
- data.tar.gz: 1ecb849cc505cda578e2c93493dcc8464e52ca3e64d55a618cb3d4181b1d6eb55130c0a3173c93d3761539ac0fd402e0c52a4a3a050b8df291f5dece55095739
6
+ metadata.gz: 238ed43c059619f7f8ab17ffcc28aa6e6d461b1de72a7bfe57a9a188da44e4f810381ad2c0aac5118b79013855f985635f4bbc01355558640a7f3cb377695fd9
7
+ data.tar.gz: 5a8971012b178358d34198642446a423da9ccee69f6418f6d2cc3bc77529bb96a4185e5f281d14b0a6d83fd5195cc20255e61664c7fa4899ef2c06758b63b04e
data/README.md CHANGED
@@ -69,7 +69,7 @@ For detailed installation and configuration instructions, see the [Installation
69
69
 
70
70
  - Rails 8.1.1 or higher
71
71
  - Node.js and npm (for Tailwind CSS)
72
- - Tailwind CSS v4 (currently in beta)
72
+ - Tailwind CSS v4
73
73
 
74
74
  ## Component Overview
75
75
 
@@ -230,11 +230,12 @@ BetterUi includes a custom form builder for seamless Rails form integration:
230
230
 
231
231
  ```erb
232
232
  <%= form_with model: @user, builder: BetterUi::UiFormBuilder do |f| %>
233
- <%= f.ui_text_input :name %>
234
- <%= f.ui_text_input :email, hint: "We'll never share your email" %>
235
- <%= f.ui_password_input :password %>
236
- <%= f.ui_textarea :bio, rows: 6 %>
237
- <%= f.ui_number_input :age, min: 0, max: 120 %>
233
+ <%= f.bui_text_input :name %>
234
+ <%= f.bui_text_input :email, hint: "We'll never share your email" %>
235
+ <%= f.bui_password_input :password %>
236
+ <%= f.bui_textarea :bio, rows: 6 %>
237
+ <%= f.bui_number_input :age, min: 0, max: 120 %>
238
+ <%= f.bui_select :role, [["Admin", "admin"], ["Editor", "editor"]] %>
238
239
  <%= f.bui_checkbox :newsletter, label: "Subscribe to newsletter" %>
239
240
  <%= f.bui_checkbox_group :roles, [["Admin", "admin"], ["Editor", "editor"]] %>
240
241
  <% end %>
@@ -242,23 +243,65 @@ BetterUi includes a custom form builder for seamless Rails form integration:
242
243
 
243
244
  ## Available View Helpers
244
245
 
246
+ ### Core Components
247
+
245
248
  | Helper | Component |
246
249
  |--------|-----------|
247
250
  | `bui_button` | ButtonComponent |
251
+ | `bui_link` | LinkComponent |
248
252
  | `bui_card` | CardComponent |
249
253
  | `bui_action_messages` | ActionMessagesComponent |
254
+ | `bui_avatar` | AvatarComponent |
255
+ | `bui_badge` | BadgeComponent |
256
+ | `bui_tag` | TagComponent |
257
+ | `bui_heading` | HeadingComponent |
258
+ | `bui_spinner` | SpinnerComponent |
259
+ | `bui_progress` | ProgressComponent |
260
+ | `bui_divider` | DividerComponent |
261
+ | `bui_tooltip` | TooltipComponent |
262
+ | `bui_container` | ContainerComponent |
263
+ | `bui_fa_icon` | FaIconComponent |
264
+ | `bui_breadcrumb` | Breadcrumb::BreadcrumbComponent |
265
+
266
+ ### Form Components
267
+
268
+ | Helper | Component |
269
+ |--------|-----------|
250
270
  | `bui_text_input` | Forms::TextInputComponent |
271
+ | `bui_email_input` | Forms::TextInputComponent (type: :email) |
272
+ | `bui_tel_input` | Forms::TextInputComponent (type: :tel) |
273
+ | `bui_date_input` | Forms::TextInputComponent (type: :date) |
274
+ | `bui_time_input` | Forms::TextInputComponent (type: :time) |
251
275
  | `bui_number_input` | Forms::NumberInputComponent |
252
276
  | `bui_password_input` | Forms::PasswordInputComponent |
253
277
  | `bui_textarea` | Forms::TextareaComponent |
254
278
  | `bui_checkbox` | Forms::CheckboxComponent |
255
279
  | `bui_checkbox_group` | Forms::CheckboxGroupComponent |
280
+ | `bui_select` | Forms::SelectComponent |
281
+
282
+ ### Layout & Navigation
283
+
284
+ | Helper | Component |
285
+ |--------|-----------|
256
286
  | `bui_drawer_layout` | Drawer::LayoutComponent |
257
287
  | `bui_drawer_sidebar` | Drawer::SidebarComponent |
258
288
  | `bui_drawer_header` | Drawer::HeaderComponent |
259
289
  | `bui_drawer_nav_item` | Drawer::NavItemComponent |
260
290
  | `bui_drawer_nav_group` | Drawer::NavGroupComponent |
261
291
 
292
+ ### Interactive Components
293
+
294
+ | Helper | Component |
295
+ |--------|-----------|
296
+ | `bui_dialog` | Dialog::DialogComponent |
297
+ | `bui_dialog_alert` | Dialog::AlertComponent |
298
+ | `bui_dialog_confirm` | Dialog::ConfirmComponent |
299
+ | `bui_dropdown` | Dropdown::DropdownComponent |
300
+ | `bui_tabs` | Tabs::ContainerComponent |
301
+ | `bui_tab` | Tabs::TabComponent |
302
+ | `bui_tab_panel` | Tabs::PanelComponent |
303
+ | `bui_table` | Table::TableComponent |
304
+
262
305
  > **Note**: You can also use ViewComponent directly with `render BetterUi::*Component.new(...)` if you prefer the explicit rendering syntax.
263
306
 
264
307
  ## Documentation
@@ -49,8 +49,42 @@ module BetterUi
49
49
  dark: 900 # Dark backgrounds and dark text
50
50
  }.freeze
51
51
 
52
+ # Shadow size definitions mapping to Tailwind shadow classes.
53
+ # Used across all components for consistent elevation styling.
54
+ #
55
+ # @example Usage in component
56
+ # SHADOWS[@shadow] # => "shadow-sm"
57
+ SHADOWS = {
58
+ none: nil,
59
+ sm: "shadow-sm",
60
+ md: "shadow-md",
61
+ lg: "shadow-lg",
62
+ xl: "shadow-xl"
63
+ }.freeze
64
+
52
65
  private
53
66
 
67
+ # Normalizes a shadow parameter value.
68
+ # Accepts Symbol sizes (:sm, :md, etc.), booleans for backward compatibility,
69
+ # or false/nil to disable shadows.
70
+ #
71
+ # @param value [Symbol, Boolean] the shadow value to normalize
72
+ # @param default [Symbol] the default shadow size (used when value is true)
73
+ # @return [Symbol] normalized shadow key
74
+ def normalize_shadow(value, default: :sm)
75
+ case value
76
+ when false, nil, :none then :none
77
+ when true then default
78
+ when Symbol
79
+ unless SHADOWS.key?(value)
80
+ raise ArgumentError, "Invalid shadow: #{value}. Must be one of: #{SHADOWS.keys.join(', ')}"
81
+ end
82
+ value
83
+ else
84
+ raise ArgumentError, "Invalid shadow: #{value}. Must be a Symbol or Boolean"
85
+ end
86
+ end
87
+
54
88
  # Helper to merge CSS classes intelligently using TailwindMerge
55
89
  # Resolves conflicting Tailwind utility classes
56
90
  #
@@ -0,0 +1,15 @@
1
+ <div class="relative inline-flex <%= @container_classes %>">
2
+ <% if @src %>
3
+ <img src="<%= @src %>" alt="<%= alt_text %>" class="<%= component_classes %> object-cover" />
4
+ <% else %>
5
+ <div class="<%= component_classes %> flex items-center justify-center font-medium">
6
+ <%= initials %>
7
+ </div>
8
+ <% end %>
9
+ <% if @status %>
10
+ <span class="absolute bottom-0 right-0 block <%= status_classes %> rounded-full ring-2 ring-white"></span>
11
+ <% end %>
12
+ <% if badge? %>
13
+ <span class="absolute -top-1 -right-1"><%= badge %></span>
14
+ <% end %>
15
+ </div>
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterUi
4
+ # An avatar component for displaying user profile images or initials.
5
+ #
6
+ # Supports image display, initials fallback from a name, multiple sizes,
7
+ # shapes, color variants, status indicators, and a badge slot.
8
+ #
9
+ # @example With image
10
+ # <%= render BetterUi::AvatarComponent.new(src: user.avatar_url, alt: user.name) %>
11
+ #
12
+ # @example With initials fallback
13
+ # <%= render BetterUi::AvatarComponent.new(name: "John Doe", variant: :primary) %>
14
+ #
15
+ # @example With status indicator
16
+ # <%= render BetterUi::AvatarComponent.new(
17
+ # src: user.avatar_url,
18
+ # name: user.name,
19
+ # status: :online,
20
+ # size: :lg
21
+ # ) %>
22
+ #
23
+ # @example With badge slot
24
+ # <%= render BetterUi::AvatarComponent.new(src: user.avatar_url) do |avatar| %>
25
+ # <% avatar.with_badge do %>
26
+ # <span class="bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">3</span>
27
+ # <% end %>
28
+ # <% end %>
29
+ class AvatarComponent < ApplicationComponent
30
+ # Size configurations mapping to Tailwind dimension and text classes
31
+ SIZES = {
32
+ xs: "w-6 h-6 text-xs",
33
+ sm: "w-8 h-8 text-sm",
34
+ md: "w-10 h-10 text-base",
35
+ lg: "w-14 h-14 text-lg",
36
+ xl: "w-20 h-20 text-xl"
37
+ }.freeze
38
+
39
+ # Shape configurations mapping to Tailwind border-radius classes
40
+ SHAPES = {
41
+ circle: "rounded-full",
42
+ square: "rounded-none",
43
+ rounded: "rounded-lg"
44
+ }.freeze
45
+
46
+ # Status indicator colors
47
+ STATUSES = %i[online offline busy away].freeze
48
+
49
+ # Status dot size classes scaled by avatar size
50
+ STATUS_DOT_SIZES = {
51
+ xs: "w-2 h-2",
52
+ sm: "w-2 h-2",
53
+ md: "w-2.5 h-2.5",
54
+ lg: "w-3 h-3",
55
+ xl: "w-4 h-4"
56
+ }.freeze
57
+
58
+ # @!method with_badge
59
+ # Slot for rendering an optional badge overlay (e.g., notification count).
60
+ # Positioned at top-right of the avatar.
61
+ # @yieldreturn [String] the HTML content for the badge
62
+ renders_one :badge
63
+
64
+ # Initializes a new avatar component.
65
+ #
66
+ # @param src [String, nil] URL of the avatar image
67
+ # @param alt [String, nil] Alt text for the image (falls back to name)
68
+ # @param name [String, nil] Full name used to generate initials fallback
69
+ # @param variant [Symbol] Color variant for initials background
70
+ # @param size [Symbol] Avatar size (:xs, :sm, :md, :lg, :xl)
71
+ # @param shape [Symbol] Avatar shape (:circle, :square, :rounded)
72
+ # @param status [Symbol, nil] Status indicator (:online, :offline, :busy, :away)
73
+ # @param container_classes [String, nil] Additional CSS classes for the outer wrapper
74
+ # @param options [Hash] Additional HTML attributes
75
+ #
76
+ # @raise [ArgumentError] if variant, size, shape, or status is invalid
77
+ def initialize(
78
+ src: nil,
79
+ alt: nil,
80
+ name: nil,
81
+ variant: :primary,
82
+ size: :md,
83
+ shape: :circle,
84
+ status: nil,
85
+ container_classes: nil,
86
+ **options
87
+ )
88
+ @src = src
89
+ @alt = alt
90
+ @name = name
91
+ @variant = validate_variant(variant)
92
+ @size = validate_size(size)
93
+ @shape = validate_shape(shape)
94
+ @status = validate_status(status) if status
95
+ @container_classes = container_classes
96
+ @options = options
97
+ end
98
+
99
+ private
100
+
101
+ attr_reader :src, :alt, :name, :variant, :size, :shape, :status,
102
+ :container_classes, :options
103
+
104
+ # Returns the combined CSS classes for the avatar element (img or div).
105
+ #
106
+ # @return [String] merged CSS class string
107
+ # @api private
108
+ def component_classes
109
+ css_classes(
110
+ SIZES[@size],
111
+ SHAPES[@shape],
112
+ variant_classes,
113
+ @container_classes
114
+ )
115
+ end
116
+
117
+ # Returns variant-specific background and text color classes for initials mode.
118
+ # When an image is provided, no variant background is needed.
119
+ #
120
+ # @return [String, nil] variant CSS classes or nil
121
+ # @api private
122
+ def variant_classes
123
+ return nil if @src
124
+
125
+ case @variant
126
+ when :primary then "bg-primary-100 text-primary-700"
127
+ when :secondary then "bg-secondary-100 text-secondary-700"
128
+ when :accent then "bg-accent-100 text-accent-700"
129
+ when :success then "bg-success-100 text-success-700"
130
+ when :danger then "bg-danger-100 text-danger-700"
131
+ when :warning then "bg-warning-100 text-warning-700"
132
+ when :info then "bg-info-100 text-info-700"
133
+ when :light then "bg-grayscale-100 text-grayscale-700"
134
+ when :dark then "bg-grayscale-800 text-grayscale-100"
135
+ end
136
+ end
137
+
138
+ # Returns CSS classes for the status indicator dot.
139
+ #
140
+ # @return [String] combined status color and size classes
141
+ # @api private
142
+ def status_classes
143
+ color = case @status
144
+ when :online then "bg-success-500"
145
+ when :offline then "bg-grayscale-400"
146
+ when :busy then "bg-danger-500"
147
+ when :away then "bg-warning-500"
148
+ end
149
+
150
+ "#{color} #{STATUS_DOT_SIZES[@size]}"
151
+ end
152
+
153
+ # Extracts initials from the name.
154
+ # Takes the first letter of the first word and the first letter of the second word (if present).
155
+ #
156
+ # @return [String] uppercase initials (e.g., "JD" for "John Doe", "A" for "Alice")
157
+ # @api private
158
+ def initials
159
+ return "" unless @name
160
+
161
+ parts = @name.strip.split(/\s+/)
162
+ result = parts[0][0].to_s
163
+ result += parts[1][0].to_s if parts.length > 1
164
+ result.upcase
165
+ end
166
+
167
+ # Returns the alt text for the image, falling back to name.
168
+ #
169
+ # @return [String, nil] alt text
170
+ # @api private
171
+ def alt_text
172
+ @alt || @name
173
+ end
174
+
175
+ # Validates the variant parameter.
176
+ #
177
+ # @param variant [Symbol] the variant to validate
178
+ # @return [Symbol] the validated variant
179
+ # @raise [ArgumentError] if variant is invalid
180
+ # @api private
181
+ def validate_variant(variant)
182
+ unless BetterUi::ApplicationComponent::VARIANTS.key?(variant)
183
+ raise ArgumentError, "Invalid variant: #{variant}. Must be one of: #{BetterUi::ApplicationComponent::VARIANTS.keys.join(', ')}"
184
+ end
185
+ variant
186
+ end
187
+
188
+ # Validates the size parameter.
189
+ #
190
+ # @param size [Symbol] the size to validate
191
+ # @return [Symbol] the validated size
192
+ # @raise [ArgumentError] if size is invalid
193
+ # @api private
194
+ def validate_size(size)
195
+ unless SIZES.key?(size)
196
+ raise ArgumentError, "Invalid size: #{size}. Must be one of: #{SIZES.keys.join(', ')}"
197
+ end
198
+ size
199
+ end
200
+
201
+ # Validates the shape parameter.
202
+ #
203
+ # @param shape [Symbol] the shape to validate
204
+ # @return [Symbol] the validated shape
205
+ # @raise [ArgumentError] if shape is invalid
206
+ # @api private
207
+ def validate_shape(shape)
208
+ unless SHAPES.key?(shape)
209
+ raise ArgumentError, "Invalid shape: #{shape}. Must be one of: #{SHAPES.keys.join(', ')}"
210
+ end
211
+ shape
212
+ end
213
+
214
+ # Validates the status parameter.
215
+ #
216
+ # @param status [Symbol] the status to validate
217
+ # @return [Symbol] the validated status
218
+ # @raise [ArgumentError] if status is invalid
219
+ # @api private
220
+ def validate_status(status)
221
+ unless STATUSES.include?(status)
222
+ raise ArgumentError, "Invalid status: #{status}. Must be one of: #{STATUSES.join(', ')}"
223
+ end
224
+ status
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,16 @@
1
+ <span <%= tag.attributes(component_attributes) %> class="<%= component_classes %>">
2
+ <% if dot %>
3
+ <span class="w-2 h-2 rounded-full <%= dot_classes %>"></span>
4
+ <% else %>
5
+ <% if icon_before? %>
6
+ <span class="inline-flex items-center <%= SIZES[size][:icon] %>">
7
+ <%= icon_before %>
8
+ </span>
9
+ <% end %>
10
+ <% if counter %>
11
+ <%= counter %>
12
+ <% else %>
13
+ <%= content %>
14
+ <% end %>
15
+ <% end %>
16
+ </span>
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterUi
4
+ class BadgeComponent < ApplicationComponent
5
+ include BetterUi::Concerns::InlineLabelStyles
6
+
7
+ renders_one :icon_before
8
+
9
+ SIZES = {
10
+ xs: { padding: "px-1.5 py-0.5", text: "text-xs", icon: "w-3 h-3", gap: "gap-1" },
11
+ sm: { padding: "px-2 py-0.5", text: "text-xs", icon: "w-3.5 h-3.5", gap: "gap-1" },
12
+ md: { padding: "px-2.5 py-1", text: "text-sm", icon: "w-4 h-4", gap: "gap-1.5" },
13
+ lg: { padding: "px-3 py-1.5", text: "text-base", icon: "w-5 h-5", gap: "gap-2" }
14
+ }.freeze
15
+
16
+ STYLES = %i[solid outline soft ghost].freeze
17
+
18
+ def initialize(
19
+ variant: :primary,
20
+ style: :solid,
21
+ size: :md,
22
+ pill: true,
23
+ dot: false,
24
+ counter: nil,
25
+ container_classes: nil,
26
+ **options
27
+ )
28
+ @variant = validate_variant(variant)
29
+ @style = validate_style(style)
30
+ @size = validate_size(size)
31
+ @pill = pill
32
+ @dot = dot
33
+ @counter = counter
34
+ @container_classes = container_classes
35
+ @options = options
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :variant, :style, :size, :pill, :dot, :counter, :container_classes, :options
41
+
42
+ def component_classes
43
+ css_classes([
44
+ base_classes,
45
+ style_classes,
46
+ size_classes,
47
+ shape_classes,
48
+ @container_classes
49
+ ].flatten.compact)
50
+ end
51
+
52
+ def component_attributes
53
+ @options
54
+ end
55
+
56
+ def base_classes
57
+ [
58
+ "inline-flex items-center",
59
+ "font-medium",
60
+ "transition-colors duration-200"
61
+ ]
62
+ end
63
+
64
+ def shape_classes
65
+ @pill ? "rounded-full" : "rounded-md"
66
+ end
67
+
68
+ def style_classes
69
+ case @style
70
+ when :solid then solid_classes
71
+ when :outline then outline_classes
72
+ when :soft then soft_classes
73
+ when :ghost then ghost_classes
74
+ end
75
+ end
76
+
77
+ def size_classes
78
+ size_config = SIZES[@size]
79
+ [
80
+ size_config[:padding],
81
+ size_config[:text],
82
+ size_config[:gap]
83
+ ]
84
+ end
85
+
86
+ def dot_classes
87
+ case @variant
88
+ when :primary then "bg-primary-600"
89
+ when :secondary then "bg-secondary-600"
90
+ when :accent then "bg-accent-600"
91
+ when :success then "bg-success-600"
92
+ when :danger then "bg-danger-600"
93
+ when :warning then "bg-warning-600"
94
+ when :info then "bg-info-600"
95
+ when :light then "bg-grayscale-400"
96
+ when :dark then "bg-grayscale-900"
97
+ end
98
+ end
99
+
100
+ def validate_style(style)
101
+ unless STYLES.include?(style)
102
+ raise ArgumentError, "Invalid style: #{style}. Must be one of: #{STYLES.join(", ")}"
103
+ end
104
+ style
105
+ end
106
+
107
+ def validate_size(size)
108
+ unless SIZES.key?(size)
109
+ raise ArgumentError, "Invalid size: #{size}. Must be one of: #{SIZES.keys.join(", ")}"
110
+ end
111
+ size
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,12 @@
1
+ <nav aria-label="Breadcrumb" class="<%= @container_classes %>">
2
+ <ol class="<%= component_classes %>">
3
+ <% items.each_with_index do |item, index| %>
4
+ <li class="inline-flex items-center">
5
+ <% if index > 0 %>
6
+ <span class="<%= separator_classes %>" aria-hidden="true"><%= separator_content %></span>
7
+ <% end %>
8
+ <%= item %>
9
+ </li>
10
+ <% end %>
11
+ </ol>
12
+ </nav>
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterUi
4
+ module Breadcrumb
5
+ # A breadcrumb navigation component that renders an ordered list of items
6
+ # with configurable separators and sizes.
7
+ #
8
+ # This is a compound component that uses ItemComponent for individual breadcrumb items.
9
+ # The last item (without href) is typically rendered as the current page indicator.
10
+ #
11
+ # @example Basic breadcrumb
12
+ # <%= render BetterUi::Breadcrumb::BreadcrumbComponent.new do |breadcrumb| %>
13
+ # <% breadcrumb.with_item(label: "Home", href: "/") %>
14
+ # <% breadcrumb.with_item(label: "Products", href: "/products") %>
15
+ # <% breadcrumb.with_item(label: "Widget") %>
16
+ # <% end %>
17
+ #
18
+ # @example With chevron separator and large size
19
+ # <%= render BetterUi::Breadcrumb::BreadcrumbComponent.new(separator: :chevron, size: :lg) do |breadcrumb| %>
20
+ # <% breadcrumb.with_item(label: "Home", href: "/") %>
21
+ # <% breadcrumb.with_item(label: "Settings") %>
22
+ # <% end %>
23
+ class BreadcrumbComponent < ApplicationComponent
24
+ # @!method with_item
25
+ # Slot for rendering breadcrumb items.
26
+ # @param label [String] the item label text
27
+ # @param href [String, nil] the item link URL
28
+ # @yieldparam [BetterUi::Breadcrumb::ItemComponent] item the item component instance
29
+ # @yieldreturn [String] the HTML content for the item
30
+ renders_many :items, BetterUi::Breadcrumb::ItemComponent
31
+
32
+ # Allowed separator types
33
+ SEPARATORS = %i[slash chevron dot].freeze
34
+
35
+ # Allowed size options
36
+ SIZES = %i[sm md lg].freeze
37
+
38
+ # Initializes a new breadcrumb component.
39
+ #
40
+ # @param separator [Symbol] the separator type (:slash, :chevron, :dot), defaults to :slash
41
+ # @param size [Symbol] the text size (:sm, :md, :lg), defaults to :md
42
+ # @param container_classes [String, nil] additional CSS classes for the nav element
43
+ # @param options [Hash] additional HTML attributes passed to the nav element
44
+ #
45
+ # @raise [ArgumentError] if separator is not one of the allowed values
46
+ # @raise [ArgumentError] if size is not one of the allowed values
47
+ def initialize(
48
+ separator: :slash,
49
+ size: :md,
50
+ container_classes: nil,
51
+ **options
52
+ )
53
+ @separator = validate_separator(separator)
54
+ @size = validate_size(size)
55
+ @container_classes = container_classes
56
+ @options = options
57
+ end
58
+
59
+ private
60
+
61
+ # Returns the complete CSS classes for the ordered list element.
62
+ #
63
+ # @return [String] the merged CSS class string
64
+ # @api private
65
+ def component_classes
66
+ css_classes(
67
+ "flex",
68
+ "items-center",
69
+ "flex-wrap",
70
+ "gap-1",
71
+ size_classes
72
+ )
73
+ end
74
+
75
+ # Returns CSS classes for separator elements.
76
+ #
77
+ # @return [String] the merged CSS class string
78
+ # @api private
79
+ def separator_classes
80
+ css_classes(
81
+ "mx-2",
82
+ "text-grayscale-400"
83
+ )
84
+ end
85
+
86
+ # Returns the separator content (character or SVG) based on the separator type.
87
+ #
88
+ # @return [String] the separator HTML content
89
+ # @api private
90
+ def separator_content
91
+ case @separator
92
+ when :slash
93
+ "/"
94
+ when :chevron
95
+ chevron_svg
96
+ when :dot
97
+ "\u00B7"
98
+ end
99
+ end
100
+
101
+ # Returns the size-specific CSS class.
102
+ #
103
+ # @return [String] the Tailwind text size class
104
+ # @api private
105
+ def size_classes
106
+ case @size
107
+ when :sm then "text-sm"
108
+ when :md then "text-base"
109
+ when :lg then "text-lg"
110
+ end
111
+ end
112
+
113
+ # Returns an SVG chevron-right icon for the chevron separator.
114
+ #
115
+ # @return [String] the SVG HTML markup
116
+ # @api private
117
+ def chevron_svg
118
+ '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /></svg>'.html_safe
119
+ end
120
+
121
+ # Validates the separator parameter.
122
+ #
123
+ # @param separator [Symbol] the separator to validate
124
+ # @return [Symbol] the validated separator
125
+ # @raise [ArgumentError] if separator is invalid
126
+ # @api private
127
+ def validate_separator(separator)
128
+ unless SEPARATORS.include?(separator)
129
+ raise ArgumentError, "Invalid separator: #{separator}. Must be one of: #{SEPARATORS.join(', ')}"
130
+ end
131
+ separator
132
+ end
133
+
134
+ # Validates the size parameter.
135
+ #
136
+ # @param size [Symbol] the size to validate
137
+ # @return [Symbol] the validated size
138
+ # @raise [ArgumentError] if size is invalid
139
+ # @api private
140
+ def validate_size(size)
141
+ unless SIZES.include?(size)
142
+ raise ArgumentError, "Invalid size: #{size}. Must be one of: #{SIZES.join(', ')}"
143
+ end
144
+ size
145
+ end
146
+ end
147
+ end
148
+ end