better_ui 0.3.0 → 0.6.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/app/components/better_ui/application/main/component.html.erb +1 -1
  3. data/app/components/better_ui/application/sidebar/component.html.erb +25 -3
  4. data/app/components/better_ui/application/sidebar/component.rb +62 -5
  5. data/app/components/better_ui/general/button/component.html.erb +8 -8
  6. data/app/components/better_ui/general/button/component.rb +11 -11
  7. data/app/components/better_ui/general/dropdown/component.html.erb +7 -4
  8. data/app/components/better_ui/general/dropdown/component.rb +23 -1
  9. data/app/components/better_ui/general/field/component.html.erb +3 -3
  10. data/app/components/better_ui/general/field/component.rb +3 -3
  11. data/app/components/better_ui/general/grid/cell_component.html.erb +3 -0
  12. data/app/components/better_ui/general/grid/cell_component.rb +390 -0
  13. data/app/components/better_ui/general/grid/component.html.erb +3 -0
  14. data/app/components/better_ui/general/grid/component.rb +301 -0
  15. data/app/components/better_ui/general/heading/component.html.erb +1 -1
  16. data/app/components/better_ui/general/icon/component.rb +2 -1
  17. data/app/components/better_ui/general/input/checkbox/component.rb +10 -10
  18. data/app/components/better_ui/general/input/pin/component.html.erb +1 -0
  19. data/app/components/better_ui/general/input/pin/component.rb +201 -0
  20. data/app/components/better_ui/general/input/radio/component.rb +10 -10
  21. data/app/components/better_ui/general/input/rating/component.html.erb +4 -0
  22. data/app/components/better_ui/general/input/rating/component.rb +272 -0
  23. data/app/components/better_ui/general/input/select/component.html.erb +76 -14
  24. data/app/components/better_ui/general/input/select/component.rb +166 -101
  25. data/app/components/better_ui/general/input/toggle/component.html.erb +5 -0
  26. data/app/components/better_ui/general/input/toggle/component.rb +242 -0
  27. data/app/components/better_ui/general/link/component.rb +1 -1
  28. data/app/components/better_ui/general/text/component.html.erb +1 -0
  29. data/app/components/better_ui/general/text/component.rb +194 -0
  30. data/app/helpers/better_ui/application_helper.rb +7 -0
  31. data/app/helpers/better_ui/general/components/button/button_helper.rb +6 -6
  32. data/app/helpers/better_ui/general/components/dropdown/dropdown_helper.rb +9 -0
  33. data/app/helpers/better_ui/general/components/dropdown/item_helper.rb +13 -7
  34. data/app/helpers/better_ui/general/components/field/field_helper.rb +4 -4
  35. data/app/helpers/better_ui/general/components/grid/grid_helper.rb +145 -0
  36. data/app/helpers/better_ui/general/components/input/pin/pin_helper.rb +76 -0
  37. data/app/helpers/better_ui/general/components/input/rating/rating_helper.rb +70 -0
  38. data/app/helpers/better_ui/general/components/input/select/select_helper.rb +47 -31
  39. data/app/helpers/better_ui/general/components/input/toggle/toggle_helper.rb +77 -0
  40. data/app/helpers/better_ui/general/components/text/text_helper.rb +83 -0
  41. data/lib/better_ui/version.rb +1 -1
  42. data/lib/better_ui.rb +1 -0
  43. metadata +19 -4
  44. data/app/helpers/better_ui/general/components/accordion.rb +0 -11
  45. data/app/helpers/better_ui/general/components/modal.rb +0 -11
@@ -9,7 +9,8 @@ module BetterUi
9
9
  ICON_SIZE_CLASSES = {
10
10
  small: "bui-icon--small w-4 h-4 text-sm",
11
11
  medium: "bui-icon--medium w-5 h-5 text-base",
12
- large: "bui-icon--large w-6 h-6 text-lg"
12
+ large: "bui-icon--large w-6 h-6 text-lg",
13
+ xlarge: "bui-icon--large w-7 h-7 text-xl"
13
14
  }.freeze
14
15
 
15
16
  # Temi dell'icona con colori coerenti
@@ -7,7 +7,7 @@ module BetterUi
7
7
  class Component < ViewComponent::Base
8
8
  # Costanti con classi Tailwind dirette
9
9
  CHECKBOX_THEME = {
10
- default: 'border-gray-300 text-blue-600 focus:border-blue-500 focus:ring-blue-500 checked:bg-blue-600 checked:border-blue-600',
10
+ default: 'border-gray-300 text-gray-800 focus:border-gray-600 focus:ring-gray-600 checked:bg-gray-800 checked:border-gray-800',
11
11
  white: 'border-gray-300 text-gray-900 focus:border-gray-500 focus:ring-gray-500 checked:bg-white checked:border-gray-900 checked:text-gray-900',
12
12
  red: 'border-gray-300 text-red-600 focus:border-red-500 focus:ring-red-500 checked:bg-red-600 checked:border-red-600',
13
13
  rose: 'border-gray-300 text-rose-600 focus:border-rose-500 focus:ring-rose-500 checked:bg-rose-600 checked:border-rose-600',
@@ -19,9 +19,9 @@ module BetterUi
19
19
  }.freeze
20
20
 
21
21
  CHECKBOX_SIZE = {
22
- small: 'h-4 w-4',
23
- medium: 'h-5 w-5',
24
- large: 'h-6 w-6'
22
+ small: 'h-2.5 w-2.5',
23
+ medium: 'h-3 w-3',
24
+ large: 'h-4 w-4'
25
25
  }.freeze
26
26
 
27
27
  CHECKBOX_ROUNDED = {
@@ -35,15 +35,15 @@ module BetterUi
35
35
  CHECKBOX_BASE_CLASSES = 'appearance-none border-2 focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-200 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50'.freeze
36
36
 
37
37
  CHECKBOX_LABEL_GAP = {
38
- small: 'gap-2',
39
- medium: 'gap-2.5',
40
- large: 'gap-3'
38
+ small: 'gap-1.5',
39
+ medium: 'gap-2',
40
+ large: 'gap-2.5'
41
41
  }.freeze
42
42
 
43
43
  CHECKBOX_LABEL_TEXT = {
44
- small: 'text-sm',
45
- medium: 'text-base',
46
- large: 'text-lg'
44
+ small: 'text-xs',
45
+ medium: 'text-sm',
46
+ large: 'text-base'
47
47
  }.freeze
48
48
 
49
49
  attr_reader :name, :value, :checked, :required, :disabled, :indeterminate,
@@ -0,0 +1 @@
1
+ <%= render_pin_container %>
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterUi
4
+ module General
5
+ module Input
6
+ module Pin
7
+ class Component < ViewComponent::Base
8
+ # Costanti con classi Tailwind dirette
9
+ PIN_THEME = {
10
+ default: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500',
11
+ white: 'border-gray-300 bg-white focus:border-gray-900 focus:ring-gray-900',
12
+ red: 'border-red-300 focus:border-red-500 focus:ring-red-500',
13
+ rose: 'border-rose-300 focus:border-rose-500 focus:ring-rose-500',
14
+ orange: 'border-orange-300 focus:border-orange-500 focus:ring-orange-500',
15
+ green: 'border-green-300 focus:border-green-500 focus:ring-green-500',
16
+ blue: 'border-blue-300 focus:border-blue-500 focus:ring-blue-500',
17
+ yellow: 'border-yellow-300 focus:border-yellow-500 focus:ring-yellow-500',
18
+ violet: 'border-violet-300 focus:border-violet-500 focus:ring-violet-500'
19
+ }.freeze
20
+
21
+ PIN_SIZE = {
22
+ small: 'w-8 h-8 text-sm',
23
+ medium: 'w-12 h-12 text-base',
24
+ large: 'w-16 h-16 text-lg'
25
+ }.freeze
26
+
27
+ PIN_GAP = {
28
+ small: 'gap-2',
29
+ medium: 'gap-3',
30
+ large: 'gap-4'
31
+ }.freeze
32
+
33
+ PIN_BASE_CLASSES = 'rounded-md border text-center font-mono focus:outline-none focus:ring-2 focus:ring-offset-1 transition-colors'.freeze
34
+
35
+ attr_reader :name, :value, :length, :placeholder, :required, :disabled,
36
+ :theme, :size, :form, :classes, :options
37
+
38
+ # @param name [String] Nome del campo pin (obbligatorio)
39
+ # @param value [String] Valore del pin preimpostato
40
+ # @param length [Integer] Numero di campi pin (4-8, default: 6)
41
+ # @param placeholder [String] Placeholder per campi vuoti (default: '•')
42
+ # @param required [Boolean] Se il campo è obbligatorio
43
+ # @param disabled [Boolean] Se il campo è disabilitato
44
+ # @param theme [Symbol] Tema del componente (:default, :white, :red, :rose, :orange, :green, :blue, :yellow, :violet)
45
+ # @param size [Symbol] Dimensione del componente (:small, :medium, :large)
46
+ # @param form [ActionView::Helpers::FormBuilder, nil] Form builder Rails opzionale
47
+ # @param classes [String] Classi CSS aggiuntive
48
+ # @param options [Hash] Opzioni aggiuntive per attributi HTML
49
+ def initialize(name:, value: '', length: 6, placeholder: '•', required: false, disabled: false,
50
+ theme: :default, size: :medium, form: nil, classes: '', **options)
51
+ @name = name
52
+ @value = value.to_s
53
+ @length = length.to_i
54
+ @placeholder = placeholder
55
+ @required = required
56
+ @disabled = disabled
57
+ @theme = theme.to_sym
58
+ @size = size.to_sym
59
+ @form = form
60
+ @classes = classes
61
+ @options = options
62
+
63
+ validate_params
64
+ end
65
+
66
+ private
67
+
68
+ def validate_params
69
+ validate_theme
70
+ validate_size
71
+ validate_length
72
+ validate_name
73
+ end
74
+
75
+ def validate_theme
76
+ return if PIN_THEME.key?(@theme)
77
+
78
+ raise ArgumentError, "Invalid theme: #{@theme}. Valid themes are: #{PIN_THEME.keys.join(', ')}"
79
+ end
80
+
81
+ def validate_size
82
+ return if PIN_SIZE.key?(@size)
83
+
84
+ raise ArgumentError, "Invalid size: #{@size}. Valid sizes are: #{PIN_SIZE.keys.join(', ')}"
85
+ end
86
+
87
+ def validate_length
88
+ return if @length >= 4 && @length <= 8
89
+
90
+ raise ArgumentError, "Length must be between 4 and 8, got: #{@length}"
91
+ end
92
+
93
+ def validate_name
94
+ return if @name.present?
95
+
96
+ raise ArgumentError, "Name is required for pin input components"
97
+ end
98
+
99
+ def container_classes
100
+ base_classes = ['flex', 'items-center']
101
+ base_classes << PIN_GAP[@size]
102
+ base_classes << @classes if @classes.present?
103
+ base_classes.join(' ')
104
+ end
105
+
106
+ def input_classes
107
+ [
108
+ PIN_BASE_CLASSES,
109
+ PIN_SIZE[@size],
110
+ PIN_THEME[@theme],
111
+ (@disabled ? 'opacity-50 cursor-not-allowed' : '')
112
+ ].compact.join(' ')
113
+ end
114
+
115
+ def controller_attributes
116
+ {
117
+ data: {
118
+ controller: 'bui-pin',
119
+ 'bui-pin-length-value': @length,
120
+ 'bui-pin-name-value': @name,
121
+ 'bui-pin-placeholder-value': @placeholder
122
+ }
123
+ }
124
+ end
125
+
126
+ def input_attributes(index)
127
+ attrs = {
128
+ type: 'text',
129
+ class: input_classes,
130
+ maxlength: '1',
131
+ autocomplete: 'off',
132
+ inputmode: 'numeric',
133
+ pattern: '[0-9]*',
134
+ placeholder: @placeholder,
135
+ disabled: @disabled,
136
+ required: @required && index == 0, # Solo il primo campo è required per validazione
137
+ data: {
138
+ 'bui-pin-target': 'input',
139
+ action: [
140
+ 'input->bui-pin#inputChange',
141
+ 'keydown->bui-pin#inputKeydown',
142
+ 'paste->bui-pin#inputPaste'
143
+ ].join(' ')
144
+ }
145
+ }
146
+
147
+ # Imposta valore se presente
148
+ if @value.present? && @value[index]
149
+ attrs[:value] = @value[index]
150
+ end
151
+
152
+ attrs.merge(@options.except(:id))
153
+ end
154
+
155
+ def hidden_input_attributes
156
+ attrs = {
157
+ type: 'hidden',
158
+ name: input_name,
159
+ value: @value,
160
+ data: { 'bui-pin-target': 'hiddenInput' }
161
+ }
162
+
163
+ # Aggiungi ID se specificato
164
+ if @options[:id]
165
+ attrs[:id] = @options[:id]
166
+ end
167
+
168
+ attrs
169
+ end
170
+
171
+ def input_name
172
+ if @form
173
+ @form.field_name(@name)
174
+ else
175
+ @name
176
+ end
177
+ end
178
+
179
+ def render_pin_inputs
180
+ @length.times.map do |index|
181
+ tag(:input, input_attributes(index))
182
+ end
183
+ end
184
+
185
+ def render_hidden_input
186
+ tag(:input, hidden_input_attributes)
187
+ end
188
+
189
+ def render_pin_container
190
+ content_tag(:div, class: container_classes, **controller_attributes) do
191
+ safe_join([
192
+ render_hidden_input,
193
+ *render_pin_inputs
194
+ ])
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -7,7 +7,7 @@ module BetterUi
7
7
  class Component < ViewComponent::Base
8
8
  # Costanti con classi Tailwind dirette
9
9
  RADIO_THEME = {
10
- default: 'border-gray-300 text-blue-600 focus:border-blue-500 focus:ring-blue-500 checked:bg-blue-600 checked:border-blue-600',
10
+ default: 'border-gray-300 text-gray-800 focus:border-gray-600 focus:ring-gray-600 checked:bg-gray-800 checked:border-gray-800',
11
11
  white: 'border-gray-300 text-gray-900 focus:border-gray-500 focus:ring-gray-500 checked:bg-white checked:border-gray-900 checked:text-gray-900',
12
12
  red: 'border-gray-300 text-red-600 focus:border-red-500 focus:ring-red-500 checked:bg-red-600 checked:border-red-600',
13
13
  rose: 'border-gray-300 text-rose-600 focus:border-rose-500 focus:ring-rose-500 checked:bg-rose-600 checked:border-rose-600',
@@ -19,9 +19,9 @@ module BetterUi
19
19
  }.freeze
20
20
 
21
21
  RADIO_SIZE = {
22
- small: 'h-4 w-4',
23
- medium: 'h-5 w-5',
24
- large: 'h-6 w-6'
22
+ small: 'h-2.5 w-2.5',
23
+ medium: 'h-3 w-3',
24
+ large: 'h-4 w-4'
25
25
  }.freeze
26
26
 
27
27
  RADIO_ROUNDED = {
@@ -35,15 +35,15 @@ module BetterUi
35
35
  RADIO_BASE_CLASSES = 'appearance-none border-2 focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-200 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50'.freeze
36
36
 
37
37
  RADIO_LABEL_GAP = {
38
- small: 'gap-2',
39
- medium: 'gap-2.5',
40
- large: 'gap-3'
38
+ small: 'gap-1.5',
39
+ medium: 'gap-2',
40
+ large: 'gap-2.5'
41
41
  }.freeze
42
42
 
43
43
  RADIO_LABEL_TEXT = {
44
- small: 'text-sm',
45
- medium: 'text-base',
46
- large: 'text-lg'
44
+ small: 'text-xs',
45
+ medium: 'text-sm',
46
+ large: 'text-base'
47
47
  }.freeze
48
48
 
49
49
  attr_reader :name, :value, :checked, :required, :disabled,
@@ -0,0 +1,4 @@
1
+ <div class="<%= container_classes %>">
2
+ <%= render_stars_container %>
3
+ <%= render_value_display %>
4
+ </div>
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterUi
4
+ module General
5
+ module Input
6
+ module Rating
7
+ class Component < ViewComponent::Base
8
+ # Costanti con classi Tailwind dirette
9
+ RATING_THEME = {
10
+ default: 'text-yellow-400',
11
+ yellow: 'text-yellow-400',
12
+ orange: 'text-orange-400',
13
+ red: 'text-red-400',
14
+ pink: 'text-pink-400',
15
+ purple: 'text-purple-400',
16
+ blue: 'text-blue-400',
17
+ green: 'text-green-400',
18
+ gray: 'text-gray-400'
19
+ }.freeze
20
+
21
+ RATING_SIZE = {
22
+ small: 'text-sm',
23
+ medium: 'text-xl',
24
+ large: 'text-3xl'
25
+ }.freeze
26
+
27
+ RATING_EMPTY_COLOR = 'text-gray-300'.freeze
28
+ RATING_HALF_COLOR = 'text-yellow-300'.freeze
29
+
30
+ attr_reader :name, :value, :max_stars, :readonly, :half_stars,
31
+ :theme, :size, :show_value, :form, :classes, :options
32
+
33
+ # @param name [String] Nome del campo rating (obbligatorio se non readonly)
34
+ # @param value [Float, Integer] Valore del rating attuale (0.0 - max_stars)
35
+ # @param max_stars [Integer] Numero massimo di stelle (default: 5)
36
+ # @param readonly [Boolean] Se il rating è in sola lettura
37
+ # @param half_stars [Boolean] Se supportare mezze stelle
38
+ # @param theme [Symbol] Tema del componente (:default, :yellow, :orange, :red, :pink, :purple, :blue, :green, :gray)
39
+ # @param size [Symbol] Dimensione del componente (:small, :medium, :large)
40
+ # @param show_value [Boolean] Se mostrare il valore numerico accanto alle stelle
41
+ # @param form [ActionView::Helpers::FormBuilder, nil] Form builder Rails opzionale
42
+ # @param classes [String] Classi CSS aggiuntive
43
+ # @param options [Hash] Opzioni aggiuntive per attributi HTML
44
+ def initialize(name: nil, value: 0, max_stars: 5, readonly: false, half_stars: true,
45
+ theme: :default, size: :medium, show_value: false, form: nil,
46
+ classes: '', **options)
47
+ @name = name
48
+ @value = value.to_f
49
+ @max_stars = max_stars.to_i
50
+ @readonly = readonly
51
+ @half_stars = half_stars
52
+ @theme = theme.to_sym
53
+ @size = size.to_sym
54
+ @show_value = show_value
55
+ @form = form
56
+ @classes = classes
57
+ @options = options
58
+
59
+ validate_params
60
+ end
61
+
62
+ private
63
+
64
+ def validate_params
65
+ validate_theme
66
+ validate_size
67
+ validate_value
68
+ validate_max_stars
69
+ validate_name if interactive?
70
+ end
71
+
72
+ def validate_theme
73
+ return if RATING_THEME.key?(@theme)
74
+
75
+ raise ArgumentError, "Invalid theme: #{@theme}. Valid themes are: #{RATING_THEME.keys.join(', ')}"
76
+ end
77
+
78
+ def validate_size
79
+ return if RATING_SIZE.key?(@size)
80
+
81
+ raise ArgumentError, "Invalid size: #{@size}. Valid sizes are: #{RATING_SIZE.keys.join(', ')}"
82
+ end
83
+
84
+ def validate_value
85
+ return if @value >= 0 && @value <= @max_stars
86
+
87
+ raise ArgumentError, "Value must be between 0 and #{@max_stars}, got: #{@value}"
88
+ end
89
+
90
+ def validate_max_stars
91
+ return if @max_stars > 0
92
+
93
+ raise ArgumentError, "Max stars must be greater than 0, got: #{@max_stars}"
94
+ end
95
+
96
+ def validate_name
97
+ return if @name.present?
98
+
99
+ raise ArgumentError, "Name is required for interactive rating components"
100
+ end
101
+
102
+ def interactive?
103
+ !@readonly
104
+ end
105
+
106
+ def container_classes
107
+ base_classes = ['inline-flex', 'items-center', 'gap-1']
108
+ base_classes << @classes if @classes.present?
109
+ base_classes.join(' ')
110
+ end
111
+
112
+ def rating_container_classes
113
+ base_classes = ['flex', 'items-center']
114
+ base_classes << (interactive? ? 'cursor-pointer' : 'cursor-default')
115
+ base_classes.join(' ')
116
+ end
117
+
118
+ def star_classes(index)
119
+ base_classes = [RATING_SIZE[@size], 'transition-colors', 'duration-150']
120
+ base_classes << (interactive? ? 'hover:scale-110 transform transition-transform' : '')
121
+ base_classes.compact.join(' ')
122
+ end
123
+
124
+ def controller_attributes
125
+ return {} unless interactive?
126
+
127
+ {
128
+ data: {
129
+ controller: 'bui-rating',
130
+ 'bui-rating-rating-value': @value,
131
+ 'bui-rating-max-value': @max_stars,
132
+ 'bui-rating-readonly-value': @readonly,
133
+ 'bui-rating-half-stars-value': @half_stars,
134
+ 'bui-rating-name-value': @name,
135
+ action: 'keydown->bui-rating#keydown'
136
+ },
137
+ tabindex: '0'
138
+ }
139
+ end
140
+
141
+ def star_attributes(index)
142
+ attrs = {
143
+ data: {
144
+ 'bui-rating-target': 'star',
145
+ index: index
146
+ }
147
+ }
148
+
149
+ if interactive?
150
+ attrs[:data][:action] = [
151
+ 'click->bui-rating#starClick',
152
+ 'mouseover->bui-rating#starHover',
153
+ 'mouseleave->bui-rating#starLeave'
154
+ ].join(' ')
155
+ end
156
+
157
+ attrs
158
+ end
159
+
160
+ def hidden_input_attributes
161
+ return {} unless interactive?
162
+
163
+ {
164
+ type: 'hidden',
165
+ name: input_name,
166
+ value: @value,
167
+ data: { 'bui-rating-target': 'hiddenInput' }
168
+ }
169
+ end
170
+
171
+ def input_name
172
+ if @form
173
+ @form.field_name(@name)
174
+ else
175
+ @name
176
+ end
177
+ end
178
+
179
+ def input_id
180
+ @options[:id] || "rating_#{@name}"
181
+ end
182
+
183
+ def display_value
184
+ if @value % 1 == 0
185
+ @value.to_i.to_s
186
+ else
187
+ @value.to_s
188
+ end
189
+ end
190
+
191
+ def star_display_state(index)
192
+ star_number = index + 1
193
+
194
+ if @value >= star_number
195
+ :full
196
+ elsif @half_stars && @value >= star_number - 0.5
197
+ :half
198
+ else
199
+ :empty
200
+ end
201
+ end
202
+
203
+ def star_color(state)
204
+ case state
205
+ when :full
206
+ RATING_THEME[@theme]
207
+ when :half
208
+ RATING_HALF_COLOR
209
+ when :empty
210
+ RATING_EMPTY_COLOR
211
+ end
212
+ end
213
+
214
+ def star_symbol(state)
215
+ case state
216
+ when :full, :half
217
+ '★'
218
+ when :empty
219
+ '☆'
220
+ end
221
+ end
222
+
223
+ def render_star(index)
224
+ state = star_display_state(index)
225
+
226
+ content_tag(:span, star_attributes(index).merge(class: "#{star_classes(index)} #{star_color(state)}")) do
227
+ if state == :half
228
+ render_half_star
229
+ else
230
+ star_symbol(state)
231
+ end
232
+ end
233
+ end
234
+
235
+ def render_half_star
236
+ content_tag(:span, class: 'relative inline-block') do
237
+ safe_join([
238
+ content_tag(:span, '☆', class: RATING_EMPTY_COLOR),
239
+ content_tag(:span, class: 'absolute inset-0 overflow-hidden w-1/2') do
240
+ content_tag(:span, '★', class: RATING_THEME[@theme])
241
+ end
242
+ ])
243
+ end
244
+ end
245
+
246
+ def render_hidden_input
247
+ return unless interactive?
248
+
249
+ tag(:input, hidden_input_attributes)
250
+ end
251
+
252
+ def render_value_display
253
+ return unless @show_value
254
+
255
+ content_tag(:span, class: "ml-2 text-sm text-gray-600", data: { 'bui-rating-target': 'display' }) do
256
+ "(#{display_value})"
257
+ end
258
+ end
259
+
260
+ def render_stars_container
261
+ content_tag(:div, class: rating_container_classes, **controller_attributes) do
262
+ safe_join([
263
+ render_hidden_input,
264
+ *@max_stars.times.map { |i| render_star(i) }
265
+ ].compact)
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
@@ -1,16 +1,78 @@
1
- <select <%= tag.attributes(select_attributes).html_safe %>>
2
- <% if @placeholder.present? && !@multiple %>
3
- <option value="" disabled <%= "selected" if @selected.nil? %>><%= @placeholder %></option>
4
- <% end %>
5
- <% @options.each do |option| %>
6
- <option
7
- value="<%= option[:value] %>"
8
- <%= "selected" if selected?(option) %>
9
- <%= "disabled" if option[:disabled] %>
10
- <%= option[:html]&.map { |k, v| "#{k}=\"#{v}\"" }&.join(' ').to_s.html_safe %>
1
+ <%= tag.div(**container_attributes) do %>
2
+ <!-- Hidden input for form compatibility -->
3
+ <input
4
+ type="hidden"
5
+ name="<%= input_name %>"
6
+ id="<%= input_id %>"
7
+ value="<%= hidden_input_value %>"
8
+ data-select-target="hiddenInput"
9
+ <%= "required" if @required %>
10
+ <%= "disabled" if @disabled %>
11
+ />
12
+
13
+ <!-- Trigger button -->
14
+ <button
15
+ type="button"
16
+ class="<%= trigger_classes %>"
17
+ data-select-target="trigger"
18
+ data-action="click->select#toggle"
19
+ <%= "disabled" if @disabled %>
20
+ >
21
+ <span data-select-text class="<%= trigger_text_classes %>">
22
+ <%= trigger_text %>
23
+ </span>
24
+ <%= chevron_icon %>
25
+ </button>
26
+
27
+ <!-- Badge container for multi-select -->
28
+ <% if @multiple %>
29
+ <div
30
+ class="<%= badge_container_classes %>"
31
+ data-select-target="badgeContainer"
32
+ style="<%= @selected.empty? ? 'display: none;' : '' %>"
11
33
  >
12
- <%= option[:label] %>
13
- </option>
34
+ <!-- Badges will be populated by Stimulus -->
35
+ </div>
14
36
  <% end %>
15
- <%= content %>
16
- </select>
37
+
38
+ <!-- Dropdown panel -->
39
+ <div class="<%= dropdown_classes %>" data-select-target="dropdown">
40
+ <!-- Search input -->
41
+ <% if @searchable %>
42
+ <div class="p-1">
43
+ <input
44
+ type="text"
45
+ placeholder="<%= @search_placeholder %>"
46
+ class="<%= search_input_classes %>"
47
+ data-select-target="search"
48
+ data-action="input->select#search"
49
+ />
50
+ </div>
51
+ <% end %>
52
+
53
+ <!-- Options container -->
54
+ <div class="<%= options_container_classes %>">
55
+ <% @options.each do |option| %>
56
+ <div
57
+ class="<%= option_classes %> <%= 'bg-gray-100' if option_selected?(option) %>"
58
+ data-select-target="option"
59
+ data-value="<%= option[:value] %>"
60
+ data-action="click->select#selectOption"
61
+ <%= "data-disabled" if option[:disabled] %>
62
+ >
63
+ <span><%= option[:label] %></span>
64
+ <% if option_selected?(option) %>
65
+ <span class="checkmark text-gray-600">✓</span>
66
+ <% end %>
67
+ </div>
68
+ <% end %>
69
+ </div>
70
+
71
+ <!-- Empty state -->
72
+ <% if @options.empty? %>
73
+ <div class="px-3 py-6 text-center text-gray-500 text-sm">
74
+ Nessuna opzione disponibile
75
+ </div>
76
+ <% end %>
77
+ </div>
78
+ <% end %>