better_ui 0.7.2 → 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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +86 -29
  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/drawer/sidebar_component.rb +1 -0
  25. data/app/components/better_ui/dropdown/divider_component/divider_component.html.erb +1 -0
  26. data/app/components/better_ui/dropdown/divider_component.rb +20 -0
  27. data/app/components/better_ui/dropdown/dropdown_component/dropdown_component.html.erb +19 -0
  28. data/app/components/better_ui/dropdown/dropdown_component.rb +108 -0
  29. data/app/components/better_ui/dropdown/header_component/header_component.html.erb +3 -0
  30. data/app/components/better_ui/dropdown/header_component.rb +25 -0
  31. data/app/components/better_ui/dropdown/item_component/item_component.html.erb +7 -0
  32. data/app/components/better_ui/dropdown/item_component.rb +97 -0
  33. data/app/components/better_ui/fa_icon_component/fa_icon_component.html.erb +1 -0
  34. data/app/components/better_ui/fa_icon_component.rb +165 -0
  35. data/app/components/better_ui/forms/base_component.rb +3 -1
  36. data/app/components/better_ui/forms/select_component/select_component.html.erb +86 -0
  37. data/app/components/better_ui/forms/select_component.rb +347 -0
  38. data/app/components/better_ui/forms/text_input_component.rb +24 -2
  39. data/app/components/better_ui/heading_component/heading_component.html.erb +11 -0
  40. data/app/components/better_ui/heading_component.rb +259 -0
  41. data/app/components/better_ui/link_component/link_component.html.erb +5 -0
  42. data/app/components/better_ui/link_component.rb +169 -0
  43. data/app/components/better_ui/progress_component/progress_component.html.erb +15 -0
  44. data/app/components/better_ui/progress_component.rb +98 -0
  45. data/app/components/better_ui/spinner_component/spinner_component.html.erb +11 -0
  46. data/app/components/better_ui/spinner_component.rb +70 -0
  47. data/app/components/better_ui/table/cell_component/cell_component.html.erb +3 -0
  48. data/app/components/better_ui/table/cell_component.rb +84 -0
  49. data/app/components/better_ui/table/column_component.rb +75 -0
  50. data/app/components/better_ui/table/header_cell_component/header_cell_component.html.erb +18 -0
  51. data/app/components/better_ui/table/header_cell_component.rb +138 -0
  52. data/app/components/better_ui/table/header_component/header_component.html.erb +5 -0
  53. data/app/components/better_ui/table/header_component.rb +37 -0
  54. data/app/components/better_ui/table/row_component/row_component.html.erb +5 -0
  55. data/app/components/better_ui/table/row_component.rb +88 -0
  56. data/app/components/better_ui/table/table_component/table_component.html.erb +90 -0
  57. data/app/components/better_ui/table/table_component.rb +467 -0
  58. data/app/components/better_ui/tabs/container_component/container_component.html.erb +40 -0
  59. data/app/components/better_ui/tabs/container_component.rb +428 -0
  60. data/app/components/better_ui/tabs/panel_component/panel_component.html.erb +3 -0
  61. data/app/components/better_ui/tabs/panel_component.rb +105 -0
  62. data/app/components/better_ui/tabs/tab_component/tab_component.html.erb +9 -0
  63. data/app/components/better_ui/tabs/tab_component.rb +316 -0
  64. data/app/components/better_ui/tag_component/tag_component.html.erb +33 -0
  65. data/app/components/better_ui/tag_component.rb +114 -0
  66. data/app/components/better_ui/tooltip_component/tooltip_component.html.erb +11 -0
  67. data/app/components/better_ui/tooltip_component.rb +154 -0
  68. data/app/form_builders/better_ui/ui_form_builder.rb +90 -0
  69. data/app/helpers/better_ui/application_helper.rb +575 -0
  70. data/lib/better_ui/engine.rb +7 -0
  71. data/lib/better_ui/version.rb +1 -1
  72. metadata +63 -3
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterUi
4
+ # A divider component for visually separating content sections.
5
+ #
6
+ # Renders a horizontal or vertical divider line with optional centered label text.
7
+ # Supports multiple border styles, color variants, sizes, and spacing options.
8
+ #
9
+ # @example Basic horizontal divider
10
+ # <%= render BetterUi::DividerComponent.new %>
11
+ #
12
+ # @example Dashed divider with primary color
13
+ # <%= render BetterUi::DividerComponent.new(style: :dashed, variant: :primary) %>
14
+ #
15
+ # @example Divider with centered label
16
+ # <%= render BetterUi::DividerComponent.new(label: "OR") %>
17
+ #
18
+ # @example Divider with left-aligned label
19
+ # <%= render BetterUi::DividerComponent.new(label: "Section", label_position: :left) %>
20
+ #
21
+ # @example Vertical divider
22
+ # <%= render BetterUi::DividerComponent.new(orientation: :vertical) %>
23
+ #
24
+ # @example Thick divider with large spacing
25
+ # <%= render BetterUi::DividerComponent.new(size: :md, spacing: :xl) %>
26
+ class DividerComponent < ApplicationComponent
27
+ ORIENTATIONS = %i[horizontal vertical].freeze
28
+ STYLES = %i[solid dashed dotted].freeze
29
+ SIZES = %i[xs sm md].freeze
30
+ LABEL_POSITIONS = %i[left center right].freeze
31
+ SPACINGS = %i[xs sm md lg xl].freeze
32
+
33
+ # Initializes a new divider component.
34
+ #
35
+ # @param orientation [Symbol] the divider direction (:horizontal, :vertical)
36
+ # @param style [Symbol] the border style (:solid, :dashed, :dotted)
37
+ # @param variant [Symbol, nil] the color variant (nil for default gray)
38
+ # @param size [Symbol] the border thickness (:xs, :sm, :md)
39
+ # @param label [String, nil] centered text label for horizontal dividers
40
+ # @param label_position [Symbol] label alignment (:left, :center, :right)
41
+ # @param spacing [Symbol] outer margins (:xs, :sm, :md, :lg, :xl)
42
+ # @param container_classes [String, nil] additional CSS classes
43
+ # @param options [Hash] additional HTML attributes
44
+ #
45
+ # @raise [ArgumentError] if any parameter value is invalid
46
+ def initialize(
47
+ orientation: :horizontal,
48
+ style: :solid,
49
+ variant: nil,
50
+ size: :md,
51
+ label: nil,
52
+ label_position: :center,
53
+ spacing: :md,
54
+ container_classes: nil,
55
+ **options
56
+ )
57
+ @orientation = validate_orientation(orientation)
58
+ @style = validate_style(style)
59
+ @variant = validate_variant(variant) if variant
60
+ @size = validate_size(size)
61
+ @label = label
62
+ @label_position = validate_label_position(label_position)
63
+ @spacing = validate_spacing(spacing)
64
+ @container_classes = container_classes
65
+ @options = options
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :orientation, :style, :variant, :size, :label, :label_position,
71
+ :spacing, :container_classes, :options
72
+
73
+ # Returns true if the divider is horizontal.
74
+ #
75
+ # @return [Boolean]
76
+ # @api private
77
+ def horizontal?
78
+ @orientation == :horizontal
79
+ end
80
+
81
+ # Returns true if the divider has a label and is horizontal.
82
+ #
83
+ # @return [Boolean]
84
+ # @api private
85
+ def has_label?
86
+ @label.present? && horizontal?
87
+ end
88
+
89
+ # Returns the complete CSS classes for a horizontal divider without label.
90
+ #
91
+ # @return [String] merged CSS class string
92
+ # @api private
93
+ def component_classes
94
+ if horizontal?
95
+ css_classes([
96
+ border_style_class,
97
+ horizontal_size_class,
98
+ variant_color_class,
99
+ horizontal_spacing_class,
100
+ @container_classes
101
+ ].compact)
102
+ else
103
+ css_classes([
104
+ "inline-block",
105
+ "h-full",
106
+ "min-h-[1em]",
107
+ border_style_class,
108
+ vertical_size_class,
109
+ variant_color_class,
110
+ vertical_spacing_class,
111
+ @container_classes
112
+ ].compact)
113
+ end
114
+ end
115
+
116
+ # Returns the CSS classes for the flex wrapper when label is present.
117
+ #
118
+ # @return [String] merged CSS class string
119
+ # @api private
120
+ def wrapper_classes
121
+ css_classes([
122
+ "flex",
123
+ "items-center",
124
+ horizontal_spacing_class,
125
+ @container_classes
126
+ ].compact)
127
+ end
128
+
129
+ # Returns the CSS classes for the line segments in a labeled divider.
130
+ # Returns different classes based on label position and which line (first/second).
131
+ #
132
+ # @param position [Symbol] :first or :second to identify which line segment
133
+ # @return [String] merged CSS class string
134
+ # @api private
135
+ def line_classes(position = :both)
136
+ grow_class = case @label_position
137
+ when :left
138
+ position == :first ? "flex-grow-0 w-8" : "flex-grow"
139
+ when :right
140
+ position == :first ? "flex-grow" : "flex-grow-0 w-8"
141
+ else
142
+ "flex-grow"
143
+ end
144
+
145
+ css_classes([
146
+ grow_class,
147
+ "border-t",
148
+ border_style_class,
149
+ horizontal_label_size_class,
150
+ variant_color_class
151
+ ].compact)
152
+ end
153
+
154
+ # Returns the CSS classes for the label text.
155
+ #
156
+ # @return [String] merged CSS class string
157
+ # @api private
158
+ def label_text_classes
159
+ "px-3 text-sm text-grayscale-500 whitespace-nowrap"
160
+ end
161
+
162
+ # Returns the border style class.
163
+ #
164
+ # @return [String] border style class
165
+ # @api private
166
+ def border_style_class
167
+ case @style
168
+ when :solid then "border-solid"
169
+ when :dashed then "border-dashed"
170
+ when :dotted then "border-dotted"
171
+ end
172
+ end
173
+
174
+ # Returns the border thickness class for horizontal dividers.
175
+ #
176
+ # @return [String] border size class
177
+ # @api private
178
+ def horizontal_size_class
179
+ case @size
180
+ when :xs then "border-t"
181
+ when :sm then "border-t-2"
182
+ when :md then "border-t-4"
183
+ end
184
+ end
185
+
186
+ # Returns the border thickness class for horizontal labeled dividers.
187
+ # Uses border-t classes since the lines are div elements with border-top.
188
+ #
189
+ # @return [String] border size class
190
+ # @api private
191
+ def horizontal_label_size_class
192
+ case @size
193
+ when :xs then ""
194
+ when :sm then "border-t-2"
195
+ when :md then "border-t-4"
196
+ end
197
+ end
198
+
199
+ # Returns the border thickness class for vertical dividers.
200
+ #
201
+ # @return [String] border size class
202
+ # @api private
203
+ def vertical_size_class
204
+ case @size
205
+ when :xs then "border-l"
206
+ when :sm then "border-l-2"
207
+ when :md then "border-l-4"
208
+ end
209
+ end
210
+
211
+ # Returns the color class for the border based on variant.
212
+ #
213
+ # @return [String] border color class
214
+ # @api private
215
+ def variant_color_class
216
+ case @variant
217
+ when nil then "border-grayscale-300"
218
+ when :primary then "border-primary-300"
219
+ when :secondary then "border-secondary-300"
220
+ when :accent then "border-accent-300"
221
+ when :success then "border-success-300"
222
+ when :danger then "border-danger-300"
223
+ when :warning then "border-warning-300"
224
+ when :info then "border-info-300"
225
+ when :light then "border-grayscale-200"
226
+ when :dark then "border-grayscale-600"
227
+ end
228
+ end
229
+
230
+ # Returns the spacing (margin) class for horizontal dividers.
231
+ #
232
+ # @return [String] margin class
233
+ # @api private
234
+ def horizontal_spacing_class
235
+ case @spacing
236
+ when :xs then "my-1"
237
+ when :sm then "my-2"
238
+ when :md then "my-4"
239
+ when :lg then "my-6"
240
+ when :xl then "my-8"
241
+ end
242
+ end
243
+
244
+ # Returns the spacing (margin) class for vertical dividers.
245
+ #
246
+ # @return [String] margin class
247
+ # @api private
248
+ def vertical_spacing_class
249
+ case @spacing
250
+ when :xs then "mx-1"
251
+ when :sm then "mx-2"
252
+ when :md then "mx-4"
253
+ when :lg then "mx-6"
254
+ when :xl then "mx-8"
255
+ end
256
+ end
257
+
258
+ # Returns HTML attributes for the component element.
259
+ #
260
+ # @return [Hash] HTML attributes
261
+ # @api private
262
+ def html_attributes
263
+ @options
264
+ end
265
+
266
+ # Validates the orientation parameter.
267
+ #
268
+ # @param orientation [Symbol] the orientation to validate
269
+ # @return [Symbol] the validated orientation
270
+ # @raise [ArgumentError] if orientation is invalid
271
+ # @api private
272
+ def validate_orientation(orientation)
273
+ unless ORIENTATIONS.include?(orientation)
274
+ raise ArgumentError, "Invalid orientation: #{orientation}. Must be one of: #{ORIENTATIONS.join(', ')}"
275
+ end
276
+ orientation
277
+ end
278
+
279
+ # Validates the style parameter.
280
+ #
281
+ # @param style [Symbol] the style to validate
282
+ # @return [Symbol] the validated style
283
+ # @raise [ArgumentError] if style is invalid
284
+ # @api private
285
+ def validate_style(style)
286
+ unless STYLES.include?(style)
287
+ raise ArgumentError, "Invalid style: #{style}. Must be one of: #{STYLES.join(', ')}"
288
+ end
289
+ style
290
+ end
291
+
292
+ # Validates the variant parameter.
293
+ #
294
+ # @param variant [Symbol] the variant to validate
295
+ # @return [Symbol] the validated variant
296
+ # @raise [ArgumentError] if variant is invalid
297
+ # @api private
298
+ def validate_variant(variant)
299
+ unless BetterUi::ApplicationComponent::VARIANTS.key?(variant)
300
+ raise ArgumentError, "Invalid variant: #{variant}. Must be one of: #{BetterUi::ApplicationComponent::VARIANTS.keys.join(', ')}"
301
+ end
302
+ variant
303
+ end
304
+
305
+ # Validates the size parameter.
306
+ #
307
+ # @param size [Symbol] the size to validate
308
+ # @return [Symbol] the validated size
309
+ # @raise [ArgumentError] if size is invalid
310
+ # @api private
311
+ def validate_size(size)
312
+ unless SIZES.include?(size)
313
+ raise ArgumentError, "Invalid size: #{size}. Must be one of: #{SIZES.join(', ')}"
314
+ end
315
+ size
316
+ end
317
+
318
+ # Validates the label_position parameter.
319
+ #
320
+ # @param label_position [Symbol] the label position to validate
321
+ # @return [Symbol] the validated label position
322
+ # @raise [ArgumentError] if label_position is invalid
323
+ # @api private
324
+ def validate_label_position(label_position)
325
+ unless LABEL_POSITIONS.include?(label_position)
326
+ raise ArgumentError, "Invalid label_position: #{label_position}. Must be one of: #{LABEL_POSITIONS.join(', ')}"
327
+ end
328
+ label_position
329
+ end
330
+
331
+ # Validates the spacing parameter.
332
+ #
333
+ # @param spacing [Symbol] the spacing to validate
334
+ # @return [Symbol] the validated spacing
335
+ # @raise [ArgumentError] if spacing is invalid
336
+ # @api private
337
+ def validate_spacing(spacing)
338
+ unless SPACINGS.include?(spacing)
339
+ raise ArgumentError, "Invalid spacing: #{spacing}. Must be one of: #{SPACINGS.join(', ')}"
340
+ end
341
+ spacing
342
+ end
343
+ end
344
+ end
@@ -183,6 +183,7 @@ module BetterUi
183
183
  "flex-1",
184
184
  "overflow-y-auto",
185
185
  "p-4",
186
+ "space-y-6",
186
187
  @navigation_classes
187
188
  ].compact)
188
189
  end
@@ -0,0 +1 @@
1
+ <div role="separator" class="<%= component_classes %>"></div>
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterUi
4
+ module Dropdown
5
+ class DividerComponent < ApplicationComponent
6
+ def initialize(container_classes: nil)
7
+ @container_classes = container_classes
8
+ end
9
+
10
+ private
11
+
12
+ def component_classes
13
+ css_classes(
14
+ "border-t border-grayscale-200 my-1",
15
+ @container_classes
16
+ )
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ <%= tag.div(class: root_classes, data: controller_data) do %>
2
+ <% if trigger? %>
3
+ <div aria-haspopup="true"
4
+ aria-expanded="false"
5
+ data-action="click->better-ui--dropdown--dropdown#toggle"
6
+ data-better-ui--dropdown--dropdown-target="trigger">
7
+ <%= trigger %>
8
+ </div>
9
+ <% end %>
10
+
11
+ <div role="menu"
12
+ hidden
13
+ data-better-ui--dropdown--dropdown-target="menu"
14
+ class="<%= menu_classes %>">
15
+ <% items.each do |item| %>
16
+ <%= item %>
17
+ <% end %>
18
+ </div>
19
+ <% end %>
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterUi
4
+ module Dropdown
5
+ class DropdownComponent < ApplicationComponent
6
+ SIZES = {
7
+ sm: "w-40",
8
+ md: "w-56",
9
+ lg: "w-72"
10
+ }.freeze
11
+
12
+ PLACEMENTS = {
13
+ bottom_start: "top-full left-0 mt-1",
14
+ bottom_end: "top-full right-0 mt-1",
15
+ top_start: "bottom-full left-0 mb-1",
16
+ top_end: "bottom-full right-0 mb-1"
17
+ }.freeze
18
+
19
+ renders_one :trigger
20
+ renders_many :items, types: {
21
+ item: {
22
+ renders: lambda { |**args, &block|
23
+ ItemComponent.new(**args, &block)
24
+ },
25
+ as: :item
26
+ },
27
+ divider: {
28
+ renders: lambda { |**args|
29
+ DividerComponent.new(**args)
30
+ },
31
+ as: :divider
32
+ },
33
+ header: {
34
+ renders: lambda { |**args|
35
+ HeaderComponent.new(**args)
36
+ },
37
+ as: :header
38
+ }
39
+ }
40
+
41
+ def initialize(
42
+ variant: :default,
43
+ size: :md,
44
+ placement: :bottom_start,
45
+ shadow: :lg,
46
+ auto_close: true,
47
+ close_on_item_click: true,
48
+ container_classes: nil,
49
+ menu_classes: nil
50
+ )
51
+ @variant = variant
52
+ @size = validate_size(size || :md)
53
+ @placement = validate_placement(placement || :bottom_start)
54
+ @shadow = normalize_shadow(shadow, default: :lg)
55
+ @auto_close = auto_close
56
+ @close_on_item_click = close_on_item_click
57
+ @container_classes = container_classes
58
+ @menu_classes = menu_classes
59
+ end
60
+
61
+ private
62
+
63
+ def controller_data
64
+ {
65
+ controller: "better-ui--dropdown--dropdown",
66
+ "better-ui--dropdown--dropdown-auto-close-value": @auto_close,
67
+ "better-ui--dropdown--dropdown-close-on-item-click-value": @close_on_item_click
68
+ }
69
+ end
70
+
71
+ def root_classes
72
+ css_classes(
73
+ "relative inline-block",
74
+ @container_classes
75
+ )
76
+ end
77
+
78
+ def menu_classes
79
+ css_classes(
80
+ "absolute z-50",
81
+ "bg-white rounded-md ring-1 ring-grayscale-200 ring-opacity-5",
82
+ "py-1 overflow-hidden",
83
+ "focus:outline-none",
84
+ "transition-all duration-150 ease-out",
85
+ "opacity-0 scale-95",
86
+ SIZES[@size],
87
+ PLACEMENTS[@placement],
88
+ SHADOWS[@shadow],
89
+ @menu_classes
90
+ )
91
+ end
92
+
93
+ def validate_size(size)
94
+ unless SIZES.key?(size)
95
+ raise ArgumentError, "Invalid size: #{size}. Must be one of: #{SIZES.keys.join(', ')}"
96
+ end
97
+ size
98
+ end
99
+
100
+ def validate_placement(placement)
101
+ unless PLACEMENTS.key?(placement)
102
+ raise ArgumentError, "Invalid placement: #{placement}. Must be one of: #{PLACEMENTS.keys.join(', ')}"
103
+ end
104
+ placement
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,3 @@
1
+ <div role="presentation" class="<%= component_classes %>">
2
+ <%= display_text %>
3
+ </div>
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterUi
4
+ module Dropdown
5
+ class HeaderComponent < ApplicationComponent
6
+ def initialize(text: nil, container_classes: nil)
7
+ @text = text
8
+ @container_classes = container_classes
9
+ end
10
+
11
+ private
12
+
13
+ def display_text
14
+ content.present? ? content : @text
15
+ end
16
+
17
+ def component_classes
18
+ css_classes(
19
+ "px-3 py-2 text-xs font-semibold uppercase tracking-wider text-grayscale-500",
20
+ @container_classes
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ <% tag_name = link? ? :a : :button %>
2
+ <%= content_tag(tag_name, **component_attributes) do %>
3
+ <% if icon? %>
4
+ <span class="flex-shrink-0"><%= icon %></span>
5
+ <% end %>
6
+ <span class="flex-1"><%= content %></span>
7
+ <% end %>
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterUi
4
+ module Dropdown
5
+ class ItemComponent < ApplicationComponent
6
+ ITEM_VARIANTS = {
7
+ default: "text-grayscale-700",
8
+ danger: "text-danger-600"
9
+ }.freeze
10
+
11
+ renders_one :icon
12
+
13
+ def initialize(
14
+ href: nil,
15
+ disabled: false,
16
+ method: nil,
17
+ active: false,
18
+ variant: :default,
19
+ container_classes: nil
20
+ )
21
+ @href = href
22
+ @disabled = disabled
23
+ @method = method
24
+ @active = active
25
+ @variant = validate_variant(variant)
26
+ @container_classes = container_classes
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :href, :disabled, :method, :active, :variant, :container_classes
32
+
33
+ def link?
34
+ @href.present?
35
+ end
36
+
37
+ def component_classes
38
+ css_classes(
39
+ "flex items-center gap-2 w-full text-left px-3 py-2 text-sm",
40
+ "transition-colors duration-150",
41
+ ITEM_VARIANTS[@variant],
42
+ hover_classes,
43
+ active_classes,
44
+ disabled_classes,
45
+ @container_classes
46
+ )
47
+ end
48
+
49
+ def hover_classes
50
+ return nil if @disabled
51
+
52
+ case @variant
53
+ when :danger then "hover:bg-danger-50 hover:text-danger-700"
54
+ else "hover:bg-grayscale-100 hover:text-grayscale-900"
55
+ end
56
+ end
57
+
58
+ def active_classes
59
+ @active ? "bg-grayscale-100" : nil
60
+ end
61
+
62
+ def disabled_classes
63
+ @disabled ? "opacity-50 cursor-not-allowed pointer-events-none" : "cursor-pointer"
64
+ end
65
+
66
+ def component_attributes
67
+ base = {
68
+ role: "menuitem",
69
+ tabindex: "-1",
70
+ class: component_classes,
71
+ data: {
72
+ "better-ui--dropdown--dropdown-target": "item"
73
+ }
74
+ }
75
+
76
+ if link?
77
+ base[:href] = @href
78
+ base[:data]["turbo-method"] = @method if @method
79
+ base[:"aria-disabled"] = "true" if @disabled
80
+ else
81
+ base[:type] = "button"
82
+ base[:disabled] = true if @disabled
83
+ base[:"aria-disabled"] = "true" if @disabled
84
+ end
85
+
86
+ base
87
+ end
88
+
89
+ def validate_variant(variant)
90
+ unless ITEM_VARIANTS.key?(variant)
91
+ raise ArgumentError, "Invalid variant: #{variant}. Must be one of: #{ITEM_VARIANTS.keys.join(', ')}"
92
+ end
93
+ variant
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1 @@
1
+ <i class="<%= component_classes %>" aria-hidden="true"></i>