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,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterUi
4
+ class FaIconComponent < ApplicationComponent
5
+ STYLES = {
6
+ regular: "fa-regular",
7
+ solid: "fa-solid",
8
+ light: "fa-light",
9
+ thin: "fa-thin",
10
+ brands: "fa-brands"
11
+ }.freeze
12
+
13
+ SIZES = {
14
+ xs: "fa-xs",
15
+ sm: "fa-sm",
16
+ md: nil,
17
+ lg: "fa-lg",
18
+ xl: "fa-xl",
19
+ "2xl": "fa-2xl"
20
+ }.freeze
21
+
22
+ FLIPS = {
23
+ horizontal: "fa-flip-horizontal",
24
+ vertical: "fa-flip-vertical",
25
+ both: "fa-flip-both"
26
+ }.freeze
27
+
28
+ ROTATIONS = {
29
+ 90 => "fa-rotate-90",
30
+ 180 => "fa-rotate-180",
31
+ 270 => "fa-rotate-270"
32
+ }.freeze
33
+
34
+ def initialize(
35
+ name:,
36
+ style: :regular,
37
+ variant: nil,
38
+ size: :md,
39
+ spin: false,
40
+ pulse: false,
41
+ flip: nil,
42
+ rotate: nil,
43
+ fixed_width: false,
44
+ container_classes: nil,
45
+ **options
46
+ )
47
+ @name = name
48
+ @style = validate_style(style)
49
+ @variant = validate_variant(variant)
50
+ @size = validate_size(size)
51
+ @spin = spin
52
+ @pulse = pulse
53
+ @flip = validate_flip(flip)
54
+ @rotate = validate_rotate(rotate)
55
+ @fixed_width = fixed_width
56
+ @container_classes = container_classes
57
+ @options = options
58
+ end
59
+
60
+ private
61
+
62
+ attr_reader :name, :style, :variant, :size, :spin, :pulse, :flip, :rotate,
63
+ :fixed_width, :container_classes, :options
64
+
65
+ def component_classes
66
+ css_classes([
67
+ style_class,
68
+ icon_class,
69
+ size_class,
70
+ variant_class,
71
+ animation_classes,
72
+ flip_class,
73
+ rotate_class,
74
+ fixed_width_class,
75
+ @container_classes
76
+ ].flatten.compact)
77
+ end
78
+
79
+ def style_class
80
+ STYLES[@style]
81
+ end
82
+
83
+ def icon_class
84
+ "fa-#{@name}"
85
+ end
86
+
87
+ def size_class
88
+ SIZES[@size]
89
+ end
90
+
91
+ def variant_class
92
+ case @variant
93
+ when nil then nil
94
+ when :primary then "text-primary-600"
95
+ when :secondary then "text-secondary-500"
96
+ when :accent then "text-accent-500"
97
+ when :success then "text-success-600"
98
+ when :danger then "text-danger-600"
99
+ when :warning then "text-warning-500"
100
+ when :info then "text-info-500"
101
+ when :light then "text-grayscale-400"
102
+ when :dark then "text-grayscale-800"
103
+ end
104
+ end
105
+
106
+ def animation_classes
107
+ classes = []
108
+ classes << "fa-spin" if @spin
109
+ classes << "fa-pulse" if @pulse
110
+ classes
111
+ end
112
+
113
+ def flip_class
114
+ return nil unless @flip
115
+ FLIPS[@flip]
116
+ end
117
+
118
+ def rotate_class
119
+ return nil unless @rotate
120
+ ROTATIONS[@rotate]
121
+ end
122
+
123
+ def fixed_width_class
124
+ @fixed_width ? "fa-fw" : nil
125
+ end
126
+
127
+ def validate_style(style)
128
+ unless STYLES.key?(style)
129
+ raise ArgumentError, "Invalid style: #{style}. Must be one of: #{STYLES.keys.join(", ")}"
130
+ end
131
+ style
132
+ end
133
+
134
+ def validate_variant(variant)
135
+ return nil if variant.nil?
136
+ unless BetterUi::ApplicationComponent::VARIANTS.key?(variant)
137
+ raise ArgumentError, "Invalid variant: #{variant}. Must be one of: #{BetterUi::ApplicationComponent::VARIANTS.keys.join(", ")}"
138
+ end
139
+ variant
140
+ end
141
+
142
+ def validate_size(size)
143
+ unless SIZES.key?(size)
144
+ raise ArgumentError, "Invalid size: #{size}. Must be one of: #{SIZES.keys.join(", ")}"
145
+ end
146
+ size
147
+ end
148
+
149
+ def validate_flip(flip)
150
+ return nil if flip.nil?
151
+ unless FLIPS.key?(flip)
152
+ raise ArgumentError, "Invalid flip: #{flip}. Must be one of: #{FLIPS.keys.join(", ")}"
153
+ end
154
+ flip
155
+ end
156
+
157
+ def validate_rotate(rotate)
158
+ return nil if rotate.nil?
159
+ unless ROTATIONS.key?(rotate)
160
+ raise ArgumentError, "Invalid rotate: #{rotate}. Must be one of: #{ROTATIONS.keys.join(", ")}"
161
+ end
162
+ rotate
163
+ end
164
+ end
165
+ end
@@ -92,6 +92,7 @@ module BetterUi
92
92
  readonly: false,
93
93
  required: false,
94
94
  errors: nil,
95
+ shadow: :sm,
95
96
  container_classes: nil,
96
97
  label_classes: nil,
97
98
  input_classes: nil,
@@ -109,6 +110,7 @@ module BetterUi
109
110
  @readonly = readonly
110
111
  @required = required
111
112
  @errors = Array(errors).compact.reject(&:blank?)
113
+ @shadow = normalize_shadow(shadow)
112
114
  @container_classes = container_classes
113
115
  @label_classes = label_classes
114
116
  @input_classes = input_classes
@@ -238,7 +240,7 @@ module BetterUi
238
240
  "w-full",
239
241
  "rounded-md",
240
242
  "border",
241
- "shadow-sm",
243
+ SHADOWS[@shadow],
242
244
  "transition-colors",
243
245
  "duration-200"
244
246
  ]
@@ -0,0 +1,86 @@
1
+ <div class="<%= wrapper_classes %>">
2
+ <% if @label.present? %>
3
+ <label for="<%= input_id %>" class="<%= label_element_classes %>">
4
+ <%= @label %>
5
+ <% if @required %>
6
+ <span class="text-danger-600">*</span>
7
+ <% end %>
8
+ </label>
9
+ <% end %>
10
+
11
+ <div class="relative" data-controller="better-ui--forms--select"
12
+ data-better-ui--forms--select-clearable-value="<%= @clearable %>"
13
+ data-better-ui--forms--select-placeholder-value="<%= placeholder_text %>"
14
+ data-better-ui--forms--select-disabled-value="<%= @disabled %>"
15
+ data-better-ui--forms--select-readonly-value="<%= @readonly %>">
16
+
17
+ <input <%= tag.attributes(hidden_input_attributes) %> />
18
+
19
+ <button <%= tag.attributes(trigger_attributes) %>>
20
+ <% if prefix_icon.present? %>
21
+ <span class="flex items-center mr-2">
22
+ <%= prefix_icon %>
23
+ </span>
24
+ <% end %>
25
+
26
+ <span class="<%= trigger_text_classes %>"
27
+ data-better-ui--forms--select-target="display">
28
+ <%= has_selection? ? selected_label : placeholder_text %>
29
+ </span>
30
+
31
+ <% if @clearable %>
32
+ <span class="<%= has_selection? ? 'flex items-center ml-1' : 'hidden flex items-center ml-1' %>"
33
+ data-better-ui--forms--select-target="clearButton"
34
+ data-action="click->better-ui--forms--select#clear">
35
+ <svg class="<%= caret_icon_size %> text-gray-400 hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
36
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
37
+ </svg>
38
+ </span>
39
+ <% end %>
40
+
41
+ <span class="flex items-center ml-2"
42
+ data-better-ui--forms--select-target="caret">
43
+ <svg class="<%= caret_icon_size %> text-gray-400 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
44
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
45
+ </svg>
46
+ </span>
47
+ </button>
48
+
49
+ <ul id="<%= listbox_id %>"
50
+ role="listbox"
51
+ class="<%= dropdown_element_classes %>"
52
+ data-better-ui--forms--select-target="listbox"
53
+ data-action="keydown->better-ui--forms--select#handleListboxKeydown">
54
+ <% @collection.each_with_index do |item, index| %>
55
+ <% value = item_value(item) %>
56
+ <% label = item_label(item) %>
57
+ <% selected = has_selection? && @value.to_s == value %>
58
+ <li role="option"
59
+ id="<%= input_id %>_option_<%= index %>"
60
+ class="<%= option_element_classes(selected) %>"
61
+ data-value="<%= value %>"
62
+ data-label="<%= label %>"
63
+ aria-selected="<%= selected %>"
64
+ data-better-ui--forms--select-target="option"
65
+ data-action="click->better-ui--forms--select#selectOption"
66
+ tabindex="-1">
67
+ <%= label %>
68
+ </li>
69
+ <% end %>
70
+ </ul>
71
+ </div>
72
+
73
+ <% if @hint.present? %>
74
+ <div class="<%= hint_element_classes %>">
75
+ <%= @hint %>
76
+ </div>
77
+ <% end %>
78
+
79
+ <% if has_errors? %>
80
+ <div class="<%= errors_element_classes %>">
81
+ <% @errors.each do |error| %>
82
+ <div><%= error %></div>
83
+ <% end %>
84
+ </div>
85
+ <% end %>
86
+ </div>
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterUi
4
+ module Forms
5
+ # A custom dropdown select component for choosing a single option from a collection.
6
+ #
7
+ # This component extends {BaseComponent} to provide a custom select dropdown with
8
+ # keyboard navigation, type-ahead search, and full ARIA support. It uses a hidden
9
+ # input for form submission and a Stimulus controller for interactivity.
10
+ #
11
+ # @example Basic select
12
+ # <%= render BetterUi::Forms::SelectComponent.new(
13
+ # name: "user[country]",
14
+ # collection: [["Italy", "it"], ["France", "fr"], ["Germany", "de"]],
15
+ # label: "Country"
16
+ # ) %>
17
+ #
18
+ # @example With prefix icon
19
+ # <%= render BetterUi::Forms::SelectComponent.new(
20
+ # name: "country",
21
+ # collection: [["Italy", "it"], ["France", "fr"]],
22
+ # label: "Country",
23
+ # clearable: true
24
+ # ) do |component| %>
25
+ # <% component.with_prefix_icon do %>
26
+ # <svg class="h-5 w-5 text-gray-400">...</svg>
27
+ # <% end %>
28
+ # <% end %>
29
+ #
30
+ # @example Simple array collection
31
+ # <%= render BetterUi::Forms::SelectComponent.new(
32
+ # name: "color",
33
+ # collection: ["Red", "Blue", "Green"],
34
+ # placeholder: "Pick a color"
35
+ # ) %>
36
+ #
37
+ # @see BaseComponent
38
+ # @see BetterUi::UiFormBuilder#bui_select
39
+ class SelectComponent < BaseComponent
40
+ renders_one :prefix_icon
41
+
42
+ # @param name [String] the name attribute for the hidden input field
43
+ # @param collection [Array] array of values or [label, value] pairs
44
+ # @param clearable [Boolean] whether to show a clear button
45
+ # @param dropdown_classes [String, nil] custom CSS classes for the dropdown
46
+ # @param (see BaseComponent#initialize)
47
+ def initialize(
48
+ name:,
49
+ collection: [],
50
+ clearable: false,
51
+ dropdown_classes: nil,
52
+ value: nil,
53
+ label: nil,
54
+ hint: nil,
55
+ placeholder: nil,
56
+ size: :md,
57
+ disabled: false,
58
+ readonly: false,
59
+ required: false,
60
+ errors: nil,
61
+ shadow: :sm,
62
+ container_classes: nil,
63
+ label_classes: nil,
64
+ input_classes: nil,
65
+ hint_classes: nil,
66
+ error_classes: nil,
67
+ **options
68
+ )
69
+ @collection = collection
70
+ @clearable = clearable
71
+ @dropdown_classes = dropdown_classes
72
+ super(
73
+ name: name,
74
+ value: value,
75
+ label: label,
76
+ hint: hint,
77
+ placeholder: placeholder,
78
+ size: size,
79
+ disabled: disabled,
80
+ readonly: readonly,
81
+ required: required,
82
+ errors: errors,
83
+ shadow: shadow,
84
+ container_classes: container_classes,
85
+ label_classes: label_classes,
86
+ input_classes: input_classes,
87
+ hint_classes: hint_classes,
88
+ error_classes: error_classes,
89
+ **options
90
+ )
91
+ end
92
+
93
+ private
94
+
95
+ # Extracts the label from a collection item.
96
+ #
97
+ # @param item [String, Array] the collection item
98
+ # @return [String] the label text
99
+ def item_label(item)
100
+ item.is_a?(Array) ? item.first.to_s : item.to_s
101
+ end
102
+
103
+ # Extracts the value from a collection item.
104
+ #
105
+ # @param item [String, Array] the collection item
106
+ # @return [String] the value
107
+ def item_value(item)
108
+ item.is_a?(Array) ? item.last.to_s : item.to_s
109
+ end
110
+
111
+ # Finds the label for the currently selected value.
112
+ #
113
+ # @return [String, nil] the label of the selected option, or nil
114
+ def selected_label
115
+ return nil unless has_selection?
116
+
117
+ selected_item = @collection.find { |item| item_value(item) == @value.to_s }
118
+ selected_item ? item_label(selected_item) : @value.to_s
119
+ end
120
+
121
+ # Checks if there is a current selection.
122
+ #
123
+ # @return [Boolean] true if a value is present
124
+ def has_selection?
125
+ @value.present?
126
+ end
127
+
128
+ # Generates a unique ID based on the input name.
129
+ #
130
+ # @return [String] the generated ID
131
+ def input_id
132
+ @name.to_s.gsub(/\[|\]/, "_").gsub(/_+/, "_").chomp("_")
133
+ end
134
+
135
+ # Generates a unique ID for the listbox element.
136
+ #
137
+ # @return [String] the listbox element ID
138
+ def listbox_id
139
+ "#{input_id}_listbox"
140
+ end
141
+
142
+ # Returns CSS classes for the trigger button element.
143
+ #
144
+ # @return [String] merged CSS classes
145
+ def trigger_element_classes
146
+ css_classes([
147
+ trigger_base_classes,
148
+ size_input_classes,
149
+ trigger_state_classes,
150
+ @input_classes
151
+ ].flatten.compact)
152
+ end
153
+
154
+ # Returns the base CSS classes for the trigger element.
155
+ # Uses flex instead of block (unlike text inputs) for icon layout.
156
+ #
157
+ # @return [Array<String>] base trigger CSS classes
158
+ def trigger_base_classes
159
+ [
160
+ "block",
161
+ "w-full",
162
+ "rounded-md",
163
+ "border",
164
+ SHADOWS[@shadow],
165
+ "transition-colors",
166
+ "duration-200",
167
+ "flex",
168
+ "items-center",
169
+ "text-left"
170
+ ]
171
+ end
172
+
173
+ # Returns state-specific trigger classes with correct cursor.
174
+ # Cursor-pointer is only applied in normal and error states.
175
+ #
176
+ # @return [Array<String>] state CSS classes
177
+ def trigger_state_classes
178
+ if @disabled
179
+ disabled_classes
180
+ elsif @readonly
181
+ readonly_classes
182
+ elsif has_errors?
183
+ error_state_classes + [ "cursor-pointer" ]
184
+ else
185
+ normal_state_classes + [ "cursor-pointer" ]
186
+ end
187
+ end
188
+
189
+ # Returns CSS classes for the trigger display text.
190
+ #
191
+ # @return [String] merged CSS classes
192
+ def trigger_text_classes
193
+ if has_selection?
194
+ css_classes([ "flex-1", "truncate", "text-gray-900" ])
195
+ else
196
+ css_classes([ "flex-1", "truncate", "text-gray-400" ])
197
+ end
198
+ end
199
+
200
+ # Returns CSS classes for the dropdown listbox.
201
+ #
202
+ # @return [String] merged CSS classes
203
+ def dropdown_element_classes
204
+ css_classes([
205
+ "absolute",
206
+ "z-50",
207
+ "w-full",
208
+ "mt-1",
209
+ "bg-white",
210
+ "border",
211
+ "border-gray-300",
212
+ "rounded-md",
213
+ "shadow-lg",
214
+ "max-h-60",
215
+ "overflow-auto",
216
+ "py-1",
217
+ "hidden",
218
+ @dropdown_classes
219
+ ].flatten.compact)
220
+ end
221
+
222
+ # Returns CSS classes for an option element.
223
+ #
224
+ # @param selected [Boolean] whether this option is currently selected
225
+ # @return [String] merged CSS classes
226
+ def option_element_classes(selected)
227
+ css_classes([
228
+ "px-3",
229
+ "py-2",
230
+ "cursor-pointer",
231
+ "truncate",
232
+ option_size_classes,
233
+ selected ? "bg-primary-50 text-primary-700 font-medium" : "text-gray-900",
234
+ "hover:bg-gray-100"
235
+ ].flatten.compact)
236
+ end
237
+
238
+ # Returns size-specific classes for option text.
239
+ #
240
+ # @return [String] the text size class
241
+ def option_size_classes
242
+ case @size
243
+ when :xs then "text-xs"
244
+ when :sm then "text-sm"
245
+ when :md then "text-base"
246
+ when :lg then "text-lg"
247
+ when :xl then "text-xl"
248
+ end
249
+ end
250
+
251
+ # Returns SVG size classes for the caret icon.
252
+ #
253
+ # @return [String] the size classes
254
+ def caret_icon_size
255
+ case @size
256
+ when :xs, :sm then "w-4 h-4"
257
+ when :md, :lg then "w-5 h-5"
258
+ when :xl then "w-6 h-6"
259
+ end
260
+ end
261
+
262
+ # Returns the placeholder text with a default fallback.
263
+ #
264
+ # @return [String] the placeholder text
265
+ def placeholder_text
266
+ @placeholder || "Select..."
267
+ end
268
+
269
+ # Overrides disabled_classes to use cursor-not-allowed without focus styles.
270
+ #
271
+ # @return [Array<String>] CSS classes for disabled state
272
+ def disabled_classes
273
+ [
274
+ "border-gray-300",
275
+ "bg-gray-100",
276
+ "text-gray-500",
277
+ "cursor-not-allowed",
278
+ "opacity-60"
279
+ ]
280
+ end
281
+
282
+ # Overrides readonly_classes for the trigger.
283
+ #
284
+ # @return [Array<String>] CSS classes for readonly state
285
+ def readonly_classes
286
+ [
287
+ "border-gray-300",
288
+ "bg-gray-50",
289
+ "text-gray-700",
290
+ "cursor-default"
291
+ ]
292
+ end
293
+
294
+ # Returns the Stimulus controller wrapper data attributes.
295
+ #
296
+ # @return [Hash] data attributes hash
297
+ def controller_wrapper_attributes
298
+ attrs = {
299
+ controller: "better-ui--forms--select",
300
+ "better-ui--forms--select-clearable-value": @clearable,
301
+ "better-ui--forms--select-placeholder-value": placeholder_text,
302
+ "better-ui--forms--select-disabled-value": @disabled,
303
+ "better-ui--forms--select-readonly-value": @readonly
304
+ }
305
+ attrs
306
+ end
307
+
308
+ # Returns the complete set of HTML attributes for the trigger button.
309
+ #
310
+ # @return [Hash] trigger button attributes
311
+ def trigger_attributes
312
+ attrs = {
313
+ type: "button",
314
+ role: "combobox",
315
+ "aria-expanded": "false",
316
+ "aria-haspopup": "listbox",
317
+ "aria-controls": listbox_id,
318
+ class: trigger_element_classes,
319
+ disabled: @disabled || nil,
320
+ "aria-readonly": @readonly ? "true" : nil,
321
+ "data-better-ui--forms--select-target": "trigger",
322
+ "data-action": "click->better-ui--forms--select#toggle keydown->better-ui--forms--select#handleTriggerKeydown"
323
+ }.compact
324
+ attrs
325
+ end
326
+
327
+ # Returns the HTML attributes for the hidden input.
328
+ #
329
+ # @return [Hash] hidden input attributes
330
+ def hidden_input_attributes
331
+ attrs = {
332
+ type: "hidden",
333
+ name: @name,
334
+ value: @value,
335
+ "data-better-ui--forms--select-target": "hiddenInput"
336
+ }
337
+ attrs[:id] = @options[:id] if @options[:id]
338
+ attrs[:required] = true if @required
339
+ @options.each do |key, val|
340
+ next if key == :id
341
+ attrs[key] = val
342
+ end
343
+ attrs.compact
344
+ end
345
+ end
346
+ end
347
+ end
@@ -71,6 +71,13 @@ module BetterUi
71
71
  # @see BaseComponent
72
72
  # @see BetterUi::UiFormBuilder#ui_text_input
73
73
  class TextInputComponent < BaseComponent
74
+ # Available input types for text-based inputs.
75
+ # Password has its own component ({PasswordInputComponent}) with a Stimulus controller.
76
+ # Number has its own component ({NumberInputComponent}).
77
+ #
78
+ # @return [Array<Symbol>] the list of valid type options
79
+ TYPES = %i[text email tel date time].freeze
80
+
74
81
  # @!method with_prefix_icon
75
82
  # Slot for rendering an icon or content before (left of) the input text.
76
83
  # The icon is positioned absolutely and input padding is adjusted automatically.
@@ -95,6 +102,7 @@ module BetterUi
95
102
  # @see BaseComponent#initialize
96
103
  def initialize(
97
104
  name:,
105
+ type: :text,
98
106
  value: nil,
99
107
  label: nil,
100
108
  hint: nil,
@@ -111,6 +119,7 @@ module BetterUi
111
119
  error_classes: nil,
112
120
  **options
113
121
  )
122
+ @type = validate_type(type)
114
123
  super(
115
124
  name: name,
116
125
  value: value,
@@ -133,12 +142,25 @@ module BetterUi
133
142
 
134
143
  private
135
144
 
145
+ # Validates that the provided type is one of the allowed TYPES.
146
+ #
147
+ # @param type [Symbol] the type to validate
148
+ # @return [Symbol] the validated type
149
+ # @raise [ArgumentError] if type is not in TYPES
150
+ # @api private
151
+ def validate_type(type)
152
+ unless TYPES.include?(type)
153
+ raise ArgumentError, "Invalid type: #{type}. Must be one of #{TYPES.join(', ')}"
154
+ end
155
+ type
156
+ end
157
+
136
158
  # Returns the HTML input type attribute.
137
159
  #
138
- # @return [String] the input type ("text")
160
+ # @return [String] the input type
139
161
  # @api private
140
162
  def input_type
141
- "text"
163
+ @type.to_s
142
164
  end
143
165
 
144
166
  # Checks if a prefix icon has been provided via the slot.
@@ -0,0 +1,11 @@
1
+ <div class="<%= wrapper_classes %>">
2
+ <div class="flex items-center justify-between">
3
+ <<%= heading_tag %> class="<%= component_classes %>" <%= tag.attributes(html_attributes) %>><%= content %></<%= heading_tag %>>
4
+ <% if actions? %>
5
+ <div class="flex items-center gap-2"><%= actions %></div>
6
+ <% end %>
7
+ </div>
8
+ <% if show_subtitle? %>
9
+ <p class="<%= subtitle_classes %>"><%= subtitle? ? subtitle : @subtitle_text %></p>
10
+ <% end %>
11
+ </div>