fluxbit_view_components 0.3.0 → 0.4.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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -0
  3. data/app/assets/javascripts/fluxbit_view_components/assigner_controller.js +49 -0
  4. data/app/assets/javascripts/fluxbit_view_components/auto_submit_controller.js +39 -0
  5. data/app/assets/javascripts/fluxbit_view_components/drawer_controller.js +135 -0
  6. data/app/assets/javascripts/fluxbit_view_components/index.js +56 -0
  7. data/app/assets/javascripts/fluxbit_view_components/method_link_controller.js +143 -0
  8. data/app/assets/javascripts/fluxbit_view_components/modal_controller.js +118 -0
  9. data/app/assets/javascripts/fluxbit_view_components/password_controller.js +170 -0
  10. data/app/assets/javascripts/fluxbit_view_components/progress_controller.js +374 -0
  11. data/app/assets/javascripts/fluxbit_view_components/row_click_controller.js +32 -0
  12. data/app/assets/javascripts/fluxbit_view_components/select_all_controller.js +122 -0
  13. data/app/assets/javascripts/fluxbit_view_components/spinner_percent_controller.js +174 -0
  14. data/app/assets/javascripts/fluxbit_view_components/theme_button_controller.js +90 -0
  15. data/app/assets/javascripts/fluxbit_view_components.js +1175 -0
  16. data/app/components/fluxbit/accordion_component.rb +125 -0
  17. data/app/components/fluxbit/alert_component.rb +8 -8
  18. data/app/components/fluxbit/avatar_component.rb +11 -12
  19. data/app/components/fluxbit/avatar_group_component.rb +1 -1
  20. data/app/components/fluxbit/badge_component.rb +8 -7
  21. data/app/components/fluxbit/banner_component.rb +139 -0
  22. data/app/components/fluxbit/bottom_navigation_component.rb +437 -0
  23. data/app/components/fluxbit/breadcrumb_component.rb +66 -0
  24. data/app/components/fluxbit/button_component.rb +39 -11
  25. data/app/components/fluxbit/button_group_component.rb +1 -1
  26. data/app/components/fluxbit/card_component.rb +26 -23
  27. data/app/components/fluxbit/carousel_component.rb +154 -0
  28. data/app/components/fluxbit/component.rb +24 -3
  29. data/app/components/fluxbit/drawer_component.html.erb +30 -0
  30. data/app/components/fluxbit/drawer_component.rb +125 -0
  31. data/app/components/fluxbit/dropdown_component.rb +41 -0
  32. data/app/components/fluxbit/dropdown_item_component.rb +68 -0
  33. data/app/components/fluxbit/flex_component.rb +1 -1
  34. data/app/components/fluxbit/form/component.rb +15 -8
  35. data/app/components/fluxbit/form/dropzone_component.rb +3 -3
  36. data/app/components/fluxbit/form/field_component.rb +4 -2
  37. data/app/components/fluxbit/form/help_text_component.rb +1 -1
  38. data/app/components/fluxbit/form/label_component.rb +10 -3
  39. data/app/components/fluxbit/form/password_component.rb +247 -0
  40. data/app/components/fluxbit/form/radio_group_button_component.rb +126 -0
  41. data/app/components/fluxbit/form/select_component.rb +108 -11
  42. data/app/components/fluxbit/form/text_field_component.rb +40 -23
  43. data/app/components/fluxbit/form/toggle_component.rb +2 -2
  44. data/app/components/fluxbit/form/upload_image_component.html.erb +3 -3
  45. data/app/components/fluxbit/form/upload_image_component.rb +12 -1
  46. data/app/components/fluxbit/gravatar_component.rb +7 -0
  47. data/app/components/fluxbit/icon_helpers.rb +167 -0
  48. data/app/components/fluxbit/link_component.rb +42 -0
  49. data/app/components/fluxbit/modal_component.rb +28 -31
  50. data/app/components/fluxbit/pagination_component.rb +206 -0
  51. data/app/components/fluxbit/popover_component.rb +14 -14
  52. data/app/components/fluxbit/progress_component.rb +196 -0
  53. data/app/components/fluxbit/skeleton_component.rb +237 -0
  54. data/app/components/fluxbit/speed_dial_action_component.html.erb +30 -0
  55. data/app/components/fluxbit/speed_dial_action_component.rb +59 -0
  56. data/app/components/fluxbit/speed_dial_component.html.erb +33 -0
  57. data/app/components/fluxbit/speed_dial_component.rb +73 -0
  58. data/app/components/fluxbit/spinner_component.rb +71 -0
  59. data/app/components/fluxbit/spinner_percent_component.rb +174 -0
  60. data/app/components/fluxbit/stepper_component.rb +223 -0
  61. data/app/components/fluxbit/tab_component.rb +44 -25
  62. data/app/components/fluxbit/table_component.rb +186 -0
  63. data/app/components/fluxbit/table_group_component.rb +28 -0
  64. data/app/components/fluxbit/theme_button_component.rb +64 -0
  65. data/app/components/fluxbit/timeline_component.rb +63 -0
  66. data/app/components/fluxbit/timeline_item_component.html.erb +64 -0
  67. data/app/components/fluxbit/timeline_item_component.rb +78 -0
  68. data/app/components/fluxbit/tooltip_component.rb +2 -2
  69. data/app/helpers/fluxbit/components_helper.rb +74 -4
  70. data/app/helpers/fluxbit/form_builder.rb +64 -15
  71. data/app/helpers/fluxbit/view_helper.rb +71 -0
  72. data/config/locales/en.yml +37 -4
  73. data/config/locales/pt-BR.yml +36 -0
  74. data/lib/fluxbit/config/accordion_component.rb +73 -0
  75. data/lib/fluxbit/config/avatar_component.rb +11 -11
  76. data/lib/fluxbit/config/badge_component.rb +14 -11
  77. data/lib/fluxbit/config/banner_component.rb +60 -0
  78. data/lib/fluxbit/config/bottom_navigation_component.rb +74 -0
  79. data/lib/fluxbit/config/breadcrumb_component.rb +24 -0
  80. data/lib/fluxbit/config/button_component.rb +6 -4
  81. data/lib/fluxbit/config/card_component.rb +23 -12
  82. data/lib/fluxbit/config/carousel_component.rb +33 -0
  83. data/lib/fluxbit/config/drawer_component.rb +48 -0
  84. data/lib/fluxbit/config/dropdown_component.rb +29 -0
  85. data/lib/fluxbit/config/form/check_box_component.rb +1 -1
  86. data/lib/fluxbit/config/form/dropzone_component.rb +1 -1
  87. data/lib/fluxbit/config/form/help_text_component.rb +1 -1
  88. data/lib/fluxbit/config/form/label_component.rb +3 -2
  89. data/lib/fluxbit/config/form/password_component.rb +19 -0
  90. data/lib/fluxbit/config/form/radio_group_button_component.rb +24 -0
  91. data/lib/fluxbit/config/form/text_field_component.rb +11 -11
  92. data/lib/fluxbit/config/form/toggle_component.rb +5 -5
  93. data/lib/fluxbit/config/link_component.rb +24 -0
  94. data/lib/fluxbit/config/modal_component.rb +1 -1
  95. data/lib/fluxbit/config/pagination_component.rb +31 -0
  96. data/lib/fluxbit/config/popover_component.rb +1 -1
  97. data/lib/fluxbit/config/progress_component.rb +63 -0
  98. data/lib/fluxbit/config/skeleton_component.rb +82 -0
  99. data/lib/fluxbit/config/speed_dial_component.rb +50 -0
  100. data/lib/fluxbit/config/spinner_component.rb +30 -0
  101. data/lib/fluxbit/config/spinner_percent_component.rb +61 -0
  102. data/lib/fluxbit/config/stepper_component.rb +299 -0
  103. data/lib/fluxbit/config/tab_component.rb +6 -0
  104. data/lib/fluxbit/config/table_component.rb +75 -0
  105. data/lib/fluxbit/config/theme_button_component.rb +19 -0
  106. data/lib/fluxbit/config/timeline_component.rb +77 -0
  107. data/lib/fluxbit/view_components/engine.rb +11 -3
  108. data/lib/fluxbit/view_components/version.rb +1 -1
  109. data/lib/fluxbit/view_components.rb +20 -0
  110. data/lib/generators/fluxbit/devise_views_generator.rb +116 -0
  111. data/lib/generators/fluxbit/pagy_generator.rb +39 -0
  112. data/lib/generators/fluxbit/scaffold_generator.rb +165 -0
  113. data/lib/generators/fluxbit/templates/_alert.html.erb.tt +1 -0
  114. data/lib/generators/fluxbit/templates/_flash.html.erb.tt +15 -0
  115. data/lib/generators/fluxbit/templates/_form.html.erb.tt +38 -0
  116. data/lib/generators/fluxbit/templates/_metadata.html.erb.tt +44 -0
  117. data/lib/generators/fluxbit/templates/controller.rb.tt +406 -0
  118. data/lib/generators/fluxbit/templates/create.turbo_stream.erb.tt +7 -0
  119. data/lib/generators/fluxbit/templates/destroy.turbo_stream.erb.tt +3 -0
  120. data/lib/generators/fluxbit/templates/destroy_all.turbo_stream.erb.tt +9 -0
  121. data/lib/generators/fluxbit/templates/devise_views/confirmations/new.html.erb +11 -0
  122. data/lib/generators/fluxbit/templates/devise_views/layouts/devise.html.erb +64 -0
  123. data/lib/generators/fluxbit/templates/devise_views/mailer/confirmation_instructions.html.erb +5 -0
  124. data/lib/generators/fluxbit/templates/devise_views/mailer/email_changed.html.erb +7 -0
  125. data/lib/generators/fluxbit/templates/devise_views/mailer/password_changed.html.erb +3 -0
  126. data/lib/generators/fluxbit/templates/devise_views/mailer/reset_password_instructions.html.erb +8 -0
  127. data/lib/generators/fluxbit/templates/devise_views/mailer/unlock_instructions.html.erb +7 -0
  128. data/lib/generators/fluxbit/templates/devise_views/passwords/edit.html.erb +29 -0
  129. data/lib/generators/fluxbit/templates/devise_views/passwords/new.html.erb +11 -0
  130. data/lib/generators/fluxbit/templates/devise_views/registrations/edit.html.erb +43 -0
  131. data/lib/generators/fluxbit/templates/devise_views/registrations/new.html.erb +34 -0
  132. data/lib/generators/fluxbit/templates/devise_views/sessions/new.html.erb +15 -0
  133. data/lib/generators/fluxbit/templates/devise_views/shared/_error_messages.html.erb +14 -0
  134. data/lib/generators/fluxbit/templates/devise_views/shared/_links.html.erb +25 -0
  135. data/lib/generators/fluxbit/templates/devise_views/unlocks/new.html.erb +11 -0
  136. data/lib/generators/fluxbit/templates/edit.html.erb.tt +47 -0
  137. data/lib/generators/fluxbit/templates/fluxbit_pagy.css +27 -0
  138. data/lib/generators/fluxbit/templates/i18n.en.yml.tt +121 -0
  139. data/lib/generators/fluxbit/templates/i18n.pt-BR.yml.tt +121 -0
  140. data/lib/generators/fluxbit/templates/index.html.erb.tt +254 -0
  141. data/lib/generators/fluxbit/templates/index.json.jbuilder.tt +33 -0
  142. data/lib/generators/fluxbit/templates/new.html.erb.tt +47 -0
  143. data/lib/generators/fluxbit/templates/partial.html.erb.tt +61 -0
  144. data/lib/generators/fluxbit/templates/policy.rb.tt +36 -0
  145. data/lib/generators/fluxbit/templates/send_alert_via_drawer.erb.tt +10 -0
  146. data/lib/generators/fluxbit/templates/show.html.erb.tt +44 -0
  147. data/lib/generators/fluxbit/templates/show.json.jbuilder.tt +6 -0
  148. data/lib/generators/fluxbit/templates/update.turbo_stream.erb.tt +10 -0
  149. data/lib/generators/fluxbit/templates/update_all.turbo_stream.erb.tt +20 -0
  150. data/lib/install/install.rb +58 -0
  151. metadata +107 -18
  152. data/app/helpers/fluxbit/classes_helper.rb +0 -9
@@ -9,6 +9,7 @@ class Fluxbit::Form::FieldComponent < Fluxbit::Form::Component
9
9
  @name = @props.delete(:name) || (@attribute if @form.present?)
10
10
  @value = @props.delete(:value)
11
11
  @id = @props.delete(:id)
12
+ @required = @props.delete(:required)
12
13
 
13
14
  @object = @form&.object
14
15
  @help_text = define_help_text(props.delete(:help_text), @object, @attribute)
@@ -16,11 +17,12 @@ class Fluxbit::Form::FieldComponent < Fluxbit::Form::Component
16
17
  @helper_popover_placement = props.delete(:helper_popover_placement) || "right"
17
18
  @label = label_value(props.delete(:label), @object, @attribute, @id)
18
19
  @wrapper_html = props.delete(:wrapper_html) || {}
20
+ @wrapper_html = { class: @wrapper_html } if @wrapper_html.is_a?(String)
19
21
  define_wrapper_options
20
22
  end
21
23
 
22
24
  def define_wrapper_options
23
- add(to: @wrapper_html, class: "required") if @props[:required].present?
24
- add(to: @wrapper_html, class: @name) if @name.present?
25
+ add(to: @wrapper_html, class: "required") if @required.present?
26
+ add(to: @wrapper_html, class: @name.to_s) if @name.present?
25
27
  end
26
28
  end
@@ -20,7 +20,7 @@ class Fluxbit::Form::HelpTextComponent < Fluxbit::Form::Component
20
20
  def initialize(color: nil, **props)
21
21
  super
22
22
  @props = props
23
- color = @@color unless color.in? %i[info default success failure warning]
23
+ color = @@color unless color.in? styles[:colors].keys
24
24
  add class: style(color), to: @props, first_element: true
25
25
  end
26
26
 
@@ -18,7 +18,7 @@ class Fluxbit::Form::LabelComponent < Fluxbit::Form::Component
18
18
  # @param helper_popover [String] Popover content shown on icon hover
19
19
  # @param helper_popover_placement [String] Placement of the popover (default: "right")
20
20
  # @param sizing [Integer] Size index for label text (default: config default)
21
- # @param color [Symbol] Label color (:default, :success, :failure, :info, :warning)
21
+ # @param color [Symbol] Label color (:default, :success, :danger, :info, :warning)
22
22
  # @param class [String] Additional CSS classes for the label element
23
23
  # @param ... any other HTML attribute supported by the <label> tag
24
24
  def initialize(**props)
@@ -32,6 +32,7 @@ class Fluxbit::Form::LabelComponent < Fluxbit::Form::Component
32
32
  @sizing = @props[:sizing].to_i || @@sizing
33
33
  @sizing = (styles[:sizes].count - 1) if @sizing > (styles[:sizes].count - 1)
34
34
  @color = options(@props.delete(:color), collection: styles[:colors], default: @@color)
35
+ @required = @props.delete(:required) || false
35
36
 
36
37
  add class: styles[:colors][@color], to: @props, first_element: true
37
38
  add class: styles[:base], to: @props, first_element: true
@@ -42,7 +43,7 @@ class Fluxbit::Form::LabelComponent < Fluxbit::Form::Component
42
43
  return "" if @helper_popover.nil?
43
44
 
44
45
  content_tag :span,
45
- anyicon(icon: @@helper_popover_icon, class: @@helper_popover_icon_class),
46
+ anyicon(@@helper_popover_icon, class: @@helper_popover_icon_class),
46
47
  {
47
48
  "data-popover-placement": @helper_popover_placement,
48
49
  "data-popover-target": target,
@@ -59,10 +60,16 @@ class Fluxbit::Form::LabelComponent < Fluxbit::Form::Component
59
60
  def call
60
61
  safe_join(
61
62
  [
62
- content_tag(:label, safe_join([ content || @with_content, span_helper_popover ]), @props),
63
+ content_tag(:label, safe_join([ content || @with_content, span_helper_popover, required ]), @props),
63
64
  help_text,
64
65
  render_popover
65
66
  ]
66
67
  )
67
68
  end
69
+
70
+ def required
71
+ return "" unless @required
72
+
73
+ content_tag(:span, "*", class: styles[:required])
74
+ end
68
75
  end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The `Fluxbit::Form::PasswordComponent` is a password input component that extends `Fluxbit::Form::TextFieldComponent`.
4
+ # It provides a password field with a toggleable visibility icon (eye/eye-slash) and optional password strength indicators.
5
+ # The component can display validation checks for password requirements such as length, letters, capital letters, numbers, and special characters.
6
+ #
7
+ # @example Basic usage
8
+ # = render Fluxbit::Form::PasswordComponent.new(name: :password)
9
+ #
10
+ # @example With password strength checks
11
+ # = render Fluxbit::Form::PasswordComponent.new(name: :password, show_strength: true)
12
+ #
13
+ # @example Custom requirements
14
+ # = render Fluxbit::Form::PasswordComponent.new(
15
+ # name: :password,
16
+ # show_strength: true,
17
+ # min_length: 12,
18
+ # require_uppercase: true,
19
+ # require_lowercase: true,
20
+ # require_numbers: true,
21
+ # require_special: true
22
+ # )
23
+ #
24
+ # @see docs/03_Forms/Password.md For detailed documentation and examples.
25
+ class Fluxbit::Form::PasswordComponent < Fluxbit::Form::TextFieldComponent
26
+ # Initializes the password field component with the given properties.
27
+ #
28
+ # @param form [ActionView::Helpers::FormBuilder] The form builder (optional, for Rails forms)
29
+ # @param attribute [Symbol] The model attribute to be used in the form (required if using form builder)
30
+ # @param show_strength [Boolean] Whether to display password strength indicators (default: false)
31
+ # @param min_length [Integer] Minimum password length requirement (default: 8)
32
+ # @param require_uppercase [Boolean] Require at least one uppercase letter (default: true)
33
+ # @param require_lowercase [Boolean] Require at least one lowercase letter (default: true)
34
+ # @param require_numbers [Boolean] Require at least one number (default: true)
35
+ # @param require_special [Boolean] Require at least one special character (default: false)
36
+ # @param strength_labels [Hash] Custom labels for strength checks
37
+ # @param ... any other options supported by TextFieldComponent
38
+ def initialize(**props)
39
+ @show_strength = props.delete(:show_strength) || false
40
+ @min_length = props.delete(:min_length) || 8
41
+
42
+ # Get boolean values, defaulting to true for uppercase/lowercase/numbers, false for special
43
+ uppercase_val = props.delete(:require_uppercase)
44
+ @require_uppercase = uppercase_val.nil? ? true : uppercase_val
45
+
46
+ lowercase_val = props.delete(:require_lowercase)
47
+ @require_lowercase = lowercase_val.nil? ? true : lowercase_val
48
+
49
+ numbers_val = props.delete(:require_numbers)
50
+ @require_numbers = numbers_val.nil? ? true : numbers_val
51
+
52
+ @require_special = props.delete(:require_special) || false
53
+ @strength_labels = props.delete(:strength_labels) || default_strength_labels
54
+
55
+ # Force type to password and add eye icon for visibility toggle
56
+ props[:type] = :password
57
+ props[:right_icon] ||= :"heroicons_outline:eye"
58
+
59
+ # Add data attributes for Stimulus controller
60
+ props[:data] ||= {}
61
+ # props[:data][:controller] = add_controller(props[:data][:controller], "fx-password")
62
+ props[:data][:action] = add_action(props[:data][:action], "input->fx-password#validate")
63
+
64
+ # Store right_icon_html for later use in create_right_icon
65
+ @password_right_icon_html = props.delete(:right_icon_html) || {}
66
+
67
+ # Set up wrapper_html with controller before calling super
68
+ props[:wrapper_html] ||= {}
69
+ props[:wrapper_html][:data] ||= {}
70
+ props[:wrapper_html][:data][:controller] = "fx-password"
71
+ props[:wrapper_html][:data].merge!(
72
+ {
73
+ "fx-password-target": "inputWrapper",
74
+ "fx-password-min-length-value": @min_length,
75
+ "fx-password-require-uppercase-value": @require_uppercase,
76
+ "fx-password-require-lowercase-value": @require_lowercase,
77
+ "fx-password-require-numbers-value": @require_numbers,
78
+ "fx-password-require-special-value": @require_special
79
+ }
80
+ )
81
+
82
+
83
+ super(**props)
84
+ end
85
+
86
+ def call
87
+ content_tag :div, **@wrapper_html do
88
+ safe_join [
89
+ label,
90
+ icon_container_wrapper,
91
+ help_text,
92
+ (@show_strength ? strength_indicator : nil)
93
+ ].compact
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def password_styles
100
+ @password_styles ||= {
101
+ strength_wrapper: "mt-2 space-y-2",
102
+ strength_bar_wrapper: "space-y-1",
103
+ strength_bar_label: "text-sm font-medium text-slate-700 dark:text-slate-300",
104
+ strength_bar_container: "w-full bg-slate-200 rounded-full h-2 dark:bg-slate-700",
105
+ strength_bar: "h-2 rounded-full transition-all duration-300 bg-slate-300 dark:bg-slate-600",
106
+ checks_list: "space-y-1",
107
+ check_item: "flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400",
108
+ check_icon: "flex-shrink-0 text-red-500 dark:text-red-400",
109
+ check_label: ""
110
+ }
111
+ end
112
+
113
+ def icon_container_wrapper
114
+ content_tag :div,
115
+ data: {
116
+ "fx-password-target": "inputWrapper",
117
+ "fx-password-min-length-value": @min_length,
118
+ "fx-password-require-uppercase-value": @require_uppercase,
119
+ "fx-password-require-lowercase-value": @require_lowercase,
120
+ "fx-password-require-numbers-value": @require_numbers,
121
+ "fx-password-require-special-value": @require_special
122
+ } do
123
+ icon_container
124
+ end
125
+ end
126
+
127
+ def default_strength_labels
128
+ {
129
+ length: I18n.t("fluxbit.form.password.checks.length", default: "At least %{count} characters", count: @min_length),
130
+ uppercase: I18n.t("fluxbit.form.password.checks.uppercase", default: "Contains uppercase letter"),
131
+ lowercase: I18n.t("fluxbit.form.password.checks.lowercase", default: "Contains lowercase letter"),
132
+ numbers: I18n.t("fluxbit.form.password.checks.numbers", default: "Contains number"),
133
+ special: I18n.t("fluxbit.form.password.checks.special", default: "Contains special character"),
134
+ strength: I18n.t("fluxbit.form.password.strength", default: "Password strength")
135
+ }
136
+ end
137
+
138
+ def strength_indicator
139
+ content_tag :div, class: password_styles[:strength_wrapper], data: { "fx-password-target": "strengthIndicator" } do
140
+ safe_join [
141
+ strength_bar,
142
+ strength_checks
143
+ ]
144
+ end
145
+ end
146
+
147
+ def strength_bar
148
+ content_tag :div, class: password_styles[:strength_bar_wrapper] do
149
+ safe_join([
150
+ content_tag(:div, class: password_styles[:strength_bar_label]) do
151
+ content_tag :span, @strength_labels[:strength]
152
+ end,
153
+ content_tag(:div, class: password_styles[:strength_bar_container]) do
154
+ content_tag :div,
155
+ "",
156
+ class: password_styles[:strength_bar],
157
+ data: { "fx-password-target": "strengthBar" }
158
+ end
159
+ ])
160
+ end
161
+ end
162
+
163
+ def strength_checks
164
+ content_tag :ul, class: password_styles[:checks_list] do
165
+ safe_join [
166
+ strength_check(:length, @strength_labels[:length]),
167
+ (@require_lowercase ? strength_check(:lowercase, @strength_labels[:lowercase]) : nil),
168
+ (@require_uppercase ? strength_check(:uppercase, @strength_labels[:uppercase]) : nil),
169
+ (@require_numbers ? strength_check(:numbers, @strength_labels[:numbers]) : nil),
170
+ (@require_special ? strength_check(:special, @strength_labels[:special]) : nil)
171
+ ].compact
172
+ end
173
+ end
174
+
175
+ def strength_check(type, label)
176
+ content_tag :li,
177
+ class: password_styles[:check_item],
178
+ data: { "fx-password-target": "check#{type.to_s.capitalize}" } do
179
+ safe_join([
180
+ # Icon container with both check and X icons
181
+ content_tag(:span, class: password_styles[:check_icon]) do
182
+ safe_join([
183
+ # X icon (visible by default - password requirement not met)
184
+ content_tag(:span,
185
+ data: { "fx-password-target": "check#{type.to_s.capitalize}Fail" }
186
+ ) do
187
+ anyicon(:"heroicons_solid:x-circle", class: "size-4")
188
+ end,
189
+ # Check icon (hidden by default - password requirement met)
190
+ content_tag(:span,
191
+ class: "hidden",
192
+ data: { "fx-password-target": "check#{type.to_s.capitalize}Pass" }
193
+ ) do
194
+ anyicon(:"heroicons_solid:check-circle", class: "size-4")
195
+ end
196
+ ])
197
+ end,
198
+ content_tag(:span, label, class: password_styles[:check_label])
199
+ ])
200
+ end
201
+ end
202
+
203
+ def add_controller(existing, new_controller)
204
+ existing ? "#{existing} #{new_controller}" : new_controller
205
+ end
206
+
207
+ def add_action(existing, new_action)
208
+ existing ? "#{existing} #{new_action}" : new_action
209
+ end
210
+
211
+ def add_class(existing, new_class)
212
+ existing ? "#{existing} #{new_class}" : new_class
213
+ end
214
+
215
+ # Override to render both eye icons with toggle visibility
216
+ def create_right_icon
217
+ return "" if @right_icon.nil?
218
+
219
+ # Build wrapper attributes
220
+ wrapper_attrs = {
221
+ class: add_class(@password_right_icon_html[:class], "absolute inset-y-0 right-0 flex items-center pr-3 cursor-pointer"),
222
+ onclick: "", # Prevent pointer-events-none
223
+ data: {
224
+ action: "click->fx-password#toggleVisibility"
225
+ }
226
+ }
227
+
228
+ content_tag :div, **wrapper_attrs do
229
+ safe_join([
230
+ # Eye icon (visible by default)
231
+ content_tag(:div,
232
+ class: "#{styles[:additional_icons][:class][@color]}",
233
+ data: { "fx-password-target": "eyeIcon" }
234
+ ) do
235
+ eye_icon
236
+ end,
237
+ # Eye-slash icon (hidden by default)
238
+ content_tag(:div,
239
+ class: "#{styles[:additional_icons][:class][@color]} hidden",
240
+ data: { "fx-password-target": "eyeSlashIcon" }
241
+ ) do
242
+ eye_slash_icon
243
+ end
244
+ ])
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # The `Fluxbit::Form::RadioGroupButtonComponent` is a component for rendering radio buttons
5
+ # styled as a button group. It provides the visual appearance of grouped buttons while
6
+ # maintaining radio button behavior (only one option can be selected at a time).
7
+ #
8
+ # This component is useful for creating segmented controls, view toggles, or any interface
9
+ # where users need to select one option from a group with a button-like appearance.
10
+ class Fluxbit::Form::RadioGroupButtonComponent < Fluxbit::Form::FieldComponent
11
+ include Fluxbit::Config::Form::RadioGroupButtonComponent
12
+
13
+ renders_many :radio_options, lambda { |**props, &block|
14
+ @options_group << ComponentObj.new(props, view_context.capture(&block))
15
+ }
16
+
17
+ ##
18
+ # Initializes the radio group button component with the given properties.
19
+ #
20
+ # @param [Hash] props The properties to customize the radio group button.
21
+ # @option props [String] :name (nil) The name attribute for the radio button group (required for proper radio functionality).
22
+ # @option props [Symbol, String] :color (:default) The color style of the buttons.
23
+ # @option props [Symbol, String] :size (1) The size of the buttons (e.g., `0` to `4`).
24
+ # @option props [Boolean] :pill (false) Determines if the buttons have pill-shaped edges.
25
+ # @option props [Hash] **props Remaining options declared as HTML attributes, applied to the group container.
26
+ #
27
+ # @return [Fluxbit::Form::RadioGroupButtonComponent]
28
+ def initialize(**props)
29
+ super
30
+ @props = props
31
+ @name = @props.delete(:name) || "radio_group_#{fx_id}"
32
+ @color = options (@props.delete(:color) || "").to_sym, collection: button_styles[:colors].keys, default: @@color
33
+ @size = @props.delete(:size) || @@size
34
+ @pill = options @props.delete(:pill), default: false
35
+ @outline = @color.to_s.end_with?("_outline")
36
+ @options_group = []
37
+
38
+ add class: Fluxbit::Config::Form::RadioGroupButtonComponent.styles[:group], to: @props, first_element: true
39
+ end
40
+
41
+ def call
42
+ radio_options
43
+ tag.div(**@props) do
44
+ @options_group.each_with_index do |option, index|
45
+ concat render_radio_button(option, index)
46
+ end
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def render_radio_button(option, index)
53
+ option_props = option.props || {}
54
+ option_content = option.content
55
+ option_value = option_props.delete(:value) || index
56
+ option_checked = option_props.delete(:checked) || false
57
+ option_disabled = option_props.delete(:disabled) || false
58
+
59
+ radio_id = "#{@name}_#{option_value}_#{fx_id}"
60
+
61
+ # Input element
62
+ input_html = {
63
+ type: "radio",
64
+ id: radio_id,
65
+ name: @name,
66
+ value: option_value,
67
+ class: "#{Fluxbit::Config::Form::RadioGroupButtonComponent.styles[:input]} peer"
68
+ }
69
+ input_html[:checked] = true if option_checked
70
+ input_html[:disabled] = true if option_disabled
71
+
72
+ # Label element (styled as button)
73
+ label_html = option_props.dup
74
+ label_html[:for] = radio_id
75
+
76
+ add class: Fluxbit::Config::Form::RadioGroupButtonComponent.styles[:label][:base], to: label_html, first_element: true
77
+ add class: button_color_classes, to: label_html, first_element: true
78
+ add class: button_size_classes, to: label_html, first_element: true
79
+ add class: button_pill_classes, to: label_html, first_element: true
80
+ add class: button_outline_classes, to: label_html, first_element: true
81
+
82
+ # Position classes
83
+ add class: Fluxbit::Config::Form::RadioGroupButtonComponent.styles[:label][:position][:start], to: label_html if index == 0
84
+ add class: Fluxbit::Config::Form::RadioGroupButtonComponent.styles[:label][:position][:end], to: label_html if index == @options_group.size - 1
85
+ add class: Fluxbit::Config::Form::RadioGroupButtonComponent.styles[:label][:position][:middle], to: label_html if index > 0 && index < @options_group.size - 1
86
+
87
+ # Selected state - use peer-checked for CSS-only interaction
88
+ # The peer modifier allows the label to change when the radio input is checked
89
+ add class: "peer-checked:brightness-90 dark:peer-checked:brightness-75", to: label_html
90
+
91
+ # Disabled state
92
+ if option_disabled
93
+ add class: Fluxbit::Config::ButtonComponent.styles[:disabled], to: label_html, first_element: true
94
+ end
95
+
96
+ tag.div(class: "relative") do
97
+ concat tag.input(**input_html)
98
+ concat tag.label(option_content, **label_html)
99
+ end
100
+ end
101
+
102
+ def button_styles
103
+ Fluxbit::Config::ButtonComponent.styles
104
+ end
105
+
106
+ def button_color_classes
107
+ @color.in?(button_styles[:colors].keys) ? button_styles[:colors][@color] : button_styles[:colors][:default]
108
+ end
109
+
110
+ def button_size_classes
111
+ button_styles[:size][@size.to_i]
112
+ end
113
+
114
+ def button_pill_classes
115
+ return "" if @outline
116
+ button_styles[:pill][@pill ? :on : :off]
117
+ end
118
+
119
+ def button_outline_classes
120
+ if @outline
121
+ "#{button_styles[:outline][:on]} #{button_styles[:outline][:pill][@pill ? :on : :off]}"
122
+ else
123
+ button_styles[:outline][:off]
124
+ end
125
+ end
126
+ end
@@ -12,14 +12,22 @@ class Fluxbit::Form::SelectComponent < Fluxbit::Form::TextFieldComponent
12
12
  # Initializes the select component with the given properties.
13
13
  #
14
14
  # @param name [String] Name of the field (required unless using form builder)
15
- # @param label [String] Label for the input (optional)
15
+ # @param label [String, false] Label text for the select field (optional, auto-generated from attribute if using form builder)
16
+ # @param help_text [String, Array, false] Help text displayed below the field (optional, supports i18n)
17
+ # @param helper_popover [String, false] Content for an info popover next to the label (optional, supports i18n)
18
+ # @param helper_popover_placement [String] Placement of the popover: "top", "right", "bottom", "left" (default: "right")
16
19
  # @param value [String] Value for the field (optional)
17
20
  # @param grouped [Boolean] Enables grouped select options (default: false)
18
21
  # @param time_zone [Boolean] Uses Rails time zone select options (default: false)
19
22
  # @param select_options [Hash] Options for select tag (prompt, selected, disabled, etc)
20
23
  # @param choices [Array] List of choices for options (alternative to options)
21
- # @param options [Array, Hash] List or hash of options (or groups if grouped)
22
- # @param help_text [String] Helper or error text below the field
24
+ # @param options [Array, Hash, String] List or hash of options, or pre-formatted HTML from options_for_select
25
+ # @param prompt [String, Boolean, false] Prompt text, true to use default, or false to disable (optional, defaults to I18n if object/attribute present)
26
+ # @param include_blank [String, Boolean] Include blank option (optional)
27
+ # @param selected [Object] Pre-selected value (optional)
28
+ # @param disabled [Array, Object] Disabled options (optional)
29
+ # @param color [Symbol] Color state: :default, :success, :danger, :warning, :info (optional)
30
+ # @param sizing [Integer] Field size (0 to 2, default: 1)
23
31
  # @param class [String] Additional CSS classes for the select element
24
32
  # @param ... any other HTML attribute supported by <select>
25
33
  def initialize(**props)
@@ -29,18 +37,38 @@ class Fluxbit::Form::SelectComponent < Fluxbit::Form::TextFieldComponent
29
37
  @select_options = @props.delete(:select_options) || {}
30
38
  @choices = @props.delete(:choices) || nil
31
39
  @options = @props.delete(:options) || {}
40
+
41
+ # Extract select-specific options
42
+ prompt_value = @select_options.delete(:prompt) || @props.delete(:prompt)
43
+ @include_blank = @select_options.delete(:include_blank) || @props.delete(:include_blank)
44
+ @selected = @select_options.delete(:selected) || @props.delete(:selected)
45
+ @disabled_options = @select_options.delete(:disabled) || @props.delete(:disabled)
46
+
32
47
  @options = ::ActiveSupport::TimeZone.all if @time_zone
48
+
49
+ # Define prompt with I18n support
50
+ define_prompt(prompt_value)
33
51
  end
34
52
 
35
53
  def input
36
54
  if @form.present? && @attribute.present?
37
- @form.select(
38
- @attribute,
39
- build_options_for_select,
40
- @select_options,
41
- @props
42
- )
55
+ # form.select(attribute, choices, options = {}, html_options = {})
56
+ # For form builder with time zones, use the specialized helper
57
+ if @time_zone
58
+ @form.time_zone_select(@attribute, nil, build_select_options_hash, @props)
59
+ else
60
+ # form.select can accept raw choices OR pre-formatted option tags
61
+ # We use pre-formatted tags for consistency with grouped selects
62
+ @form.select(
63
+ @attribute,
64
+ build_options_for_select,
65
+ build_select_options_hash,
66
+ @props
67
+ )
68
+ end
43
69
  else
70
+ # select_tag(name, option_tags = nil, options = {})
71
+ # option_tags should be pre-formatted HTML
44
72
  select_tag(
45
73
  @name,
46
74
  build_options_for_select,
@@ -49,13 +77,38 @@ class Fluxbit::Form::SelectComponent < Fluxbit::Form::TextFieldComponent
49
77
  end
50
78
  end
51
79
 
80
+ # Build options hash for form.select (prompt, include_blank, etc.)
81
+ # Note: Don't include selected/disabled if options are pre-formatted HTML
82
+ def build_select_options_hash
83
+ options_hash = @select_options.dup
84
+ options_hash[:prompt] = @prompt if @prompt
85
+ options_hash[:include_blank] = @include_blank if @include_blank
86
+
87
+ # Only add selected/disabled if we're building options from raw data
88
+ unless options_are_preformatted?
89
+ options_hash[:selected] = @selected if @selected
90
+ options_hash[:disabled] = @disabled_options if @disabled_options
91
+ end
92
+
93
+ options_hash
94
+ end
95
+
96
+ # Build pre-formatted option tags for select_tag and form.select
52
97
  def build_options_for_select
53
- if @grouped
98
+ # If options are already HTML (from options_for_select/grouped_options_for_select), use as-is
99
+ if options_are_preformatted?
100
+ html = @options.dup
101
+ # Add prompt if needed and not already in the HTML (only for select_tag)
102
+ html = add_prompt_to_html(html) if @prompt && !html.include?(@prompt.to_s) && !using_form_builder?
103
+ return html
104
+ end
105
+
106
+ # Otherwise, build the HTML from raw data
107
+ html = if @grouped
54
108
  grouped_options_for_select(
55
109
  @options,
56
110
  @selected,
57
111
  disabled: @disabled_options,
58
- prompt: @prompt,
59
112
  divider: @divider
60
113
  )
61
114
  elsif @time_zone
@@ -63,10 +116,54 @@ class Fluxbit::Form::SelectComponent < Fluxbit::Form::TextFieldComponent
63
116
  else
64
117
  options_for_select(@options, selected: @selected, disabled: @disabled_options)
65
118
  end
119
+
120
+ # Add prompt option at the beginning if specified
121
+ # Only add it manually for select_tag (form.select handles it via options hash)
122
+ html = add_prompt_to_html(html) if @prompt && !using_form_builder?
123
+ html
66
124
  end
67
125
 
68
126
  private
69
127
 
128
+ # Check if we're using a form builder
129
+ def using_form_builder?
130
+ @form.present? && @attribute.present?
131
+ end
132
+
133
+ def define_prompt(prompt)
134
+ # If prompt is explicitly false, don't set it
135
+ return if prompt.is_a?(FalseClass)
136
+
137
+ # If prompt is explicitly provided (string or true), use it
138
+ if prompt.present?
139
+ @prompt = prompt
140
+ return
141
+ end
142
+
143
+ # If no prompt provided and we have an object and attribute, try I18n lookup
144
+ if prompt.nil? && @object.present? && @attribute.present?
145
+ i18n_prompt = I18n.t(
146
+ @attribute,
147
+ scope: [ @object.class.name.pluralize.underscore.to_sym, :prompts ],
148
+ default: nil
149
+ )
150
+ @prompt = i18n_prompt if i18n_prompt.present?
151
+ end
152
+ end
153
+
154
+ # Check if options are already pre-formatted HTML
155
+ # Pre-formatted options are strings containing HTML tags
156
+ def options_are_preformatted?
157
+ @options.is_a?(String) && @options.include?("<option")
158
+ end
159
+
160
+ # Add prompt option to the beginning of the HTML options
161
+ def add_prompt_to_html(html)
162
+ prompt_text = @prompt.is_a?(String) ? @prompt : "Please select"
163
+ prompt_option = "<option value=\"\">#{ERB::Util.html_escape(prompt_text)}</option>".html_safe
164
+ prompt_option + html
165
+ end
166
+
70
167
  def grouped_selected_option
71
168
  @options.each do |group|
72
169
  group_to_traverse = @divider ? group[1] : group