hakumi_components 0.1.16.pre → 0.1.17.pre

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +169 -23
  3. data/app/assets/javascripts/hakumi_components.js +12 -12
  4. data/app/assets/stylesheets/hakumi_components.css +1 -1
  5. data/app/components/hakumi/alert/component.html.erb +12 -8
  6. data/app/components/hakumi/alert/component.rb +18 -62
  7. data/app/components/hakumi/base_component.rb +13 -0
  8. data/app/components/hakumi/card/component.html.erb +14 -22
  9. data/app/components/hakumi/card/component.rb +38 -31
  10. data/app/components/hakumi/checkbox/component.html.erb +39 -21
  11. data/app/components/hakumi/checkbox/component.rb +12 -2
  12. data/app/components/hakumi/collapse/component.html.erb +2 -2
  13. data/app/components/hakumi/collapse/component.rb +1 -1
  14. data/app/components/hakumi/collapse/panel/component.rb +9 -0
  15. data/app/components/hakumi/color_picker/component.rb +0 -4
  16. data/app/components/hakumi/drawer/component.html.erb +7 -7
  17. data/app/components/hakumi/drawer/component.rb +12 -19
  18. data/app/components/hakumi/input/component.rb +0 -2
  19. data/app/components/hakumi/input/text_area/component.rb +0 -2
  20. data/app/components/hakumi/input_number/component.rb +3 -4
  21. data/app/components/hakumi/mentions/component.rb +0 -1
  22. data/app/components/hakumi/modal/component.html.erb +40 -0
  23. data/app/components/hakumi/modal/component.rb +24 -102
  24. data/app/components/hakumi/modal/confirm/component.html.erb +23 -0
  25. data/app/components/hakumi/modal/confirm/component.rb +23 -41
  26. data/app/components/hakumi/modal/error/component.rb +12 -11
  27. data/app/components/hakumi/modal/info/component.rb +12 -11
  28. data/app/components/hakumi/modal/success/component.rb +12 -11
  29. data/app/components/hakumi/modal/warning/component.rb +15 -10
  30. data/app/components/hakumi/popconfirm/component.html.erb +25 -25
  31. data/app/components/hakumi/popconfirm/component.rb +11 -27
  32. data/app/components/hakumi/rate/component.rb +0 -1
  33. data/app/components/hakumi/segmented/component.rb +0 -4
  34. data/app/components/hakumi/slider/component.rb +2 -6
  35. data/app/components/hakumi/statistic/component.rb +0 -4
  36. data/app/components/hakumi/switch/component.html.erb +4 -0
  37. data/app/components/hakumi/switch/component.rb +1 -2
  38. data/app/components/hakumi/table/component.rb +3 -229
  39. data/app/components/hakumi/table/concerns/columns.rb +1 -1
  40. data/app/components/hakumi/table/concerns/editable.rb +121 -0
  41. data/app/components/hakumi/table/concerns/ellipsis.rb +63 -0
  42. data/app/components/hakumi/table/concerns/fixed_columns.rb +87 -0
  43. data/app/components/hakumi/transfer/component.rb +0 -4
  44. data/app/controllers/{hakumi_components → hakumi}/components_controller.rb +2 -2
  45. data/app/form_builders/hakumi/form_builder.rb +217 -175
  46. data/app/helpers/hakumi/form_helper.rb +39 -0
  47. data/app/javascript/hakumi_components/controllers/base/registry_controller.js +83 -3
  48. data/app/javascript/hakumi_components/controllers/hakumi/affix_controller.js +0 -23
  49. data/app/javascript/hakumi_components/controllers/hakumi/alert_controller.js +2 -1
  50. data/app/javascript/hakumi_components/controllers/hakumi/button_controller.js +0 -7
  51. data/app/javascript/hakumi_components/controllers/hakumi/calendar_controller.js +0 -2
  52. data/app/javascript/hakumi_components/controllers/hakumi/color_picker_controller.js +1 -6
  53. data/app/javascript/hakumi_components/controllers/hakumi/date_picker_controller.js +28 -34
  54. data/app/javascript/hakumi_components/controllers/hakumi/drawer_controller.js +2 -1
  55. data/app/javascript/hakumi_components/controllers/hakumi/form_item_controller.js +9 -63
  56. data/app/javascript/hakumi_components/controllers/hakumi/mentions_controller.js +4 -11
  57. data/app/javascript/hakumi_components/controllers/hakumi/message_controller.js +1 -1
  58. data/app/javascript/hakumi_components/controllers/hakumi/modal_controller.js +4 -20
  59. data/app/javascript/hakumi_components/controllers/hakumi/notification_controller.js +1 -1
  60. data/app/javascript/hakumi_components/controllers/hakumi/popconfirm_controller.js +33 -27
  61. data/app/javascript/hakumi_components/controllers/hakumi/popover_controller.js +2 -23
  62. data/app/javascript/hakumi_components/controllers/hakumi/qr_code_controller.js +0 -20
  63. data/app/javascript/hakumi_components/controllers/hakumi/segmented_controller.js +0 -2
  64. data/app/javascript/hakumi_components/controllers/hakumi/spin_controller.js +1 -19
  65. data/app/javascript/hakumi_components/controllers/hakumi/statistic_controller.js +0 -2
  66. data/app/javascript/hakumi_components/controllers/hakumi/table_controller.js +48 -74
  67. data/app/javascript/hakumi_components/controllers/hakumi/tag_controller.js +15 -14
  68. data/app/javascript/hakumi_components/controllers/hakumi/tag_group_controller.js +14 -13
  69. data/app/javascript/hakumi_components/controllers/hakumi/theme_controller.js +24 -1
  70. data/app/javascript/hakumi_components/controllers/hakumi/time_picker_controller.js +3 -7
  71. data/app/javascript/hakumi_components/controllers/hakumi/timeline_controller.js +0 -16
  72. data/app/javascript/hakumi_components/controllers/hakumi/transfer_controller.js +2 -2
  73. data/app/javascript/hakumi_components/controllers/hakumi/tree_controller.js +0 -2
  74. data/app/javascript/hakumi_components/controllers/hakumi/tree_select_controller.js +3 -3
  75. data/app/javascript/hakumi_components/controllers/hakumi/upload_controller.js +12 -26
  76. data/app/javascript/hakumi_components/core/persistence.js +3 -3
  77. data/app/javascript/hakumi_components/core/render_component.js +3 -1
  78. data/app/javascript/lib/validation_manager.js +101 -0
  79. data/app/javascript/stylesheets/_theme-tokens.scss +2 -1
  80. data/app/javascript/stylesheets/components/_modal.scss +13 -0
  81. data/app/services/{hakumi_components → hakumi}/component_handler.rb +1 -1
  82. data/app/services/hakumi/icon/loader.rb +2 -2
  83. data/app/services/hakumi/illustrations/loader.rb +3 -3
  84. data/app/views/hakumi/_drawer.html.erb +21 -0
  85. data/app/views/hakumi/_modal.html.erb +18 -0
  86. data/lib/hakumi_components/documentation.rb +127 -0
  87. data/lib/hakumi_components/engine.rb +13 -4
  88. data/lib/hakumi_components/rails/attribute_introspection.rb +1 -1
  89. data/lib/hakumi_components/rails/validation_introspection.rb +5 -5
  90. data/lib/hakumi_components/rails/validation_mapper.rb +484 -0
  91. data/lib/hakumi_components/rails.rb +2 -1
  92. data/lib/hakumi_components/version.rb +2 -2
  93. data/lib/hakumi_components.rb +3 -1
  94. data/lib/tasks/coverage.rake +37 -0
  95. data/sig/hakumi/base_component.rbs +5 -0
  96. data/sig/hakumi/checkbox/component.rbs +10 -0
  97. data/sig/hakumi/color_picker/component.rbs +0 -1
  98. data/sig/hakumi/form_builder.rbs +9 -1
  99. data/sig/{hakumi_components → hakumi}/rails/attribute_introspection.rbs +1 -1
  100. data/sig/{hakumi_components → hakumi}/rails/validation_introspection.rbs +1 -1
  101. data/sig/hakumi/rails/validation_mapper.rbs +53 -0
  102. data/sig/{hakumi_components → hakumi}/rails.rbs +1 -1
  103. data/sig/hakumi/segmented/component.rbs +0 -1
  104. data/sig/hakumi/slider/component.rbs +0 -1
  105. data/sig/hakumi/statistic/component.rbs +0 -2
  106. data/sig/hakumi/table/component.rbs +3 -4
  107. data/sig/hakumi/table/concerns/columns.rbs +2 -1
  108. data/sig/hakumi/table/concerns/editable.rbs +40 -0
  109. data/sig/hakumi/table/concerns/ellipsis.rbs +27 -0
  110. data/sig/hakumi/table/concerns/fixed_columns.rbs +33 -0
  111. data/sig/hakumi/transfer/component.rbs +0 -1
  112. data/sig/{hakumi_components.rbs → hakumi.rbs} +20 -3
  113. data/sig/rails/active_model/validations/comparison_validator.rbs +6 -0
  114. metadata +44 -29
  115. data/app/views/hakumi_components/_drawer.html.erb +0 -3
  116. data/app/views/hakumi_components/_modal.html.erb +0 -3
  117. /data/app/views/{hakumi_components → hakumi}/_admin_panel.html.erb +0 -0
  118. /data/app/views/{hakumi_components → hakumi}/_affix.html.erb +0 -0
  119. /data/app/views/{hakumi_components → hakumi}/_alert.html.erb +0 -0
  120. /data/app/views/{hakumi_components → hakumi}/_confirm.html.erb +0 -0
  121. /data/app/views/{hakumi_components → hakumi}/_message.html.erb +0 -0
  122. /data/app/views/{hakumi_components → hakumi}/_notification.html.erb +0 -0
  123. /data/app/views/{hakumi_components → hakumi}/_popconfirm.html.erb +0 -0
  124. /data/app/views/{hakumi_components → hakumi}/_popover.html.erb +0 -0
  125. /data/app/views/{hakumi_components → hakumi}/_qr_code.html.erb +0 -0
  126. /data/app/views/{hakumi_components → hakumi}/_result.html.erb +0 -0
  127. /data/app/views/{hakumi_components → hakumi}/_segmented.html.erb +0 -0
  128. /data/app/views/{hakumi_components → hakumi}/_skeleton.html.erb +0 -0
  129. /data/app/views/{hakumi_components → hakumi}/_spin.html.erb +0 -0
  130. /data/app/views/{hakumi_components → hakumi}/_statistic.html.erb +0 -0
  131. /data/app/views/{hakumi_components → hakumi}/_table.html.erb +0 -0
  132. /data/app/views/{hakumi_components → hakumi}/_tag.html.erb +0 -0
  133. /data/app/views/{hakumi_components → hakumi}/_timeline.html.erb +0 -0
  134. /data/app/views/{hakumi_components → hakumi}/_tree.html.erb +0 -0
@@ -49,18 +49,8 @@ module Hakumi
49
49
  # @param options [Hash] Options for the input
50
50
  # @return [String] Rendered HTML
51
51
  def number_field(method, **options)
52
- options[:type] = :number
53
52
  enhance_options_with_introspection!(method, options)
54
-
55
- # Apply numericality constraints
56
- if @object
57
- constraints = HakumiComponents::Rails::ValidationIntrospection.numericality_constraints(@object, method)
58
- options[:min] ||= constraints[:min] if constraints[:min]
59
- options[:max] ||= constraints[:max] if constraints[:max]
60
- options[:step] ||= constraints[:step] if constraints[:step]
61
- end
62
-
63
- render_form_field(Hakumi::Input::Component, method, **options)
53
+ render_form_field(Hakumi::InputNumber::Component, method, **options)
64
54
  end
65
55
 
66
56
  # Renders a Hakumi select field
@@ -73,20 +63,11 @@ module Hakumi
73
63
  choices ||= [] # steep:ignore UnannotatedEmptyCollection
74
64
  enhance_options_with_introspection!(method, options)
75
65
 
76
- # Get field configuration
77
- field_name = @object_name ? "#{@object_name}[#{method}]" : method.to_s
78
- field_id = @object_name ? "#{@object_name}_#{method}" : method.to_s
79
- value = options.delete(:value) || object_value(method)
80
- errors = object_errors(method)
81
-
82
- @template.render(Hakumi::Select::Component.new(
83
- name: field_name,
84
- options: choices,
85
- value: value,
86
- errors: errors,
87
- standalone: false, # Always render with form-item wrapper in FormBuilder
88
- **options.merge(id: field_id)
89
- ))
66
+ render_form_field(
67
+ Hakumi::Select::Component,
68
+ method,
69
+ **options.merge(options: choices)
70
+ )
90
71
  end
91
72
 
92
73
  # Renders a Hakumi tree select field
@@ -99,20 +80,11 @@ module Hakumi
99
80
  choices ||= [] # steep:ignore UnannotatedEmptyCollection
100
81
  enhance_options_with_introspection!(method, options)
101
82
 
102
- # Get field configuration
103
- field_name = @object_name ? "#{@object_name}[#{method}]" : method.to_s
104
- field_id = @object_name ? "#{@object_name}_#{method}" : method.to_s
105
- value = options.delete(:value) || object_value(method)
106
- errors = object_errors(method)
107
-
108
- @template.render(Hakumi::TreeSelect::Component.new(
109
- name: field_name,
110
- options: choices,
111
- value: value,
112
- errors: errors,
113
- standalone: false, # Always render with form-item wrapper in FormBuilder
114
- **options.merge(id: field_id)
115
- ))
83
+ render_form_field(
84
+ Hakumi::TreeSelect::Component,
85
+ method,
86
+ **options.merge(options: choices)
87
+ )
116
88
  end
117
89
 
118
90
  # Renders a Hakumi radio button
@@ -124,25 +96,24 @@ module Hakumi
124
96
  def radio_button(method, tag_value, **options)
125
97
  enhance_options_with_introspection!(method, options)
126
98
 
127
- # Get field configuration
128
- field_name = @object_name ? "#{@object_name}[#{method}]" : method.to_s
129
- field_id = options[:id] || "#{@object_name}_#{method}_#{tag_value}".parameterize.underscore
130
-
131
- # Determine if checked
132
- current_value = object_value(method)
133
- checked = current_value.to_s == tag_value.to_s
99
+ # Use tag_value as ID suffix unless explicitly provided
100
+ id_suffix = options[:id] ? nil : tag_value.to_s
101
+ config = field_configuration(method, options, id_suffix: id_suffix)
134
102
 
135
- # Get validation errors
136
- errors = object_errors(method)
103
+ # Override with explicit ID if provided
104
+ config[:id] = options.delete(:id) if options[:id]
137
105
 
138
- @template.render(Hakumi::Radio::Component.new(
139
- name: field_name,
140
- value: tag_value,
141
- checked: checked,
142
- errors: errors,
143
- standalone: false, # Always render with form-item wrapper in FormBuilder
144
- **options.merge(id: field_id)
145
- ))
106
+ # Determine if checked
107
+ checked = config[:value].to_s == tag_value.to_s
108
+ config[:value] = tag_value
109
+ config[:checked] = checked
110
+
111
+ render_form_field(
112
+ Hakumi::Radio::Component,
113
+ method,
114
+ config_overrides: config,
115
+ **options
116
+ )
146
117
  end
147
118
 
148
119
  # Renders a Hakumi radio group from a collection
@@ -156,27 +127,19 @@ module Hakumi
156
127
  def collection_radio_buttons(method, collection, value_method, text_method, **options)
157
128
  enhance_options_with_introspection!(method, options)
158
129
 
159
- # Convert collection to radio options format
130
+ # Convert collection to radio options format (Hash with :label and :value)
160
131
  radio_options = collection.map do |item|
161
- value = item.send(value_method)
162
- label = item.send(text_method)
163
- [ label, value ]
132
+ {
133
+ label: item.send(text_method),
134
+ value: item.send(value_method)
135
+ }
164
136
  end
165
137
 
166
- # Get field configuration
167
- field_name = @object_name ? "#{@object_name}[#{method}]" : method.to_s
168
- field_id = @object_name ? "#{@object_name}_#{method}" : method.to_s
169
- value = options.delete(:value) || object_value(method)
170
- errors = object_errors(method)
171
-
172
- @template.render(Hakumi::Radio::Group::Component.new(
173
- name: field_name,
174
- options: radio_options,
175
- value: value,
176
- errors: errors,
177
- standalone: false, # Always render with form-item wrapper in FormBuilder
178
- **options.merge(id: field_id)
179
- ))
138
+ render_form_field(
139
+ Hakumi::Radio::Group::Component,
140
+ method,
141
+ **options.merge(options: radio_options)
142
+ )
180
143
  end
181
144
 
182
145
  # Renders a Hakumi switch (checkbox) field
@@ -187,22 +150,16 @@ module Hakumi
187
150
  def check_box(method, **options)
188
151
  enhance_options_with_introspection!(method, options)
189
152
 
190
- # Get field configuration
191
- field_name = @object_name ? "#{@object_name}[#{method}]" : method.to_s
192
- field_id = @object_name ? "#{@object_name}_#{method}" : method.to_s
153
+ # Convert truthy/falsy value to boolean for checked attribute
193
154
  value = object_value(method)
194
- errors = object_errors(method)
195
-
196
- # Convert truthy/falsy values to boolean
197
155
  checked = value ? true : false
198
156
 
199
- @template.render(Hakumi::Switch::Component.new(
200
- name: field_name,
201
- checked: checked,
202
- errors: errors,
203
- standalone: false, # Always render with form-item wrapper in FormBuilder
204
- **options.merge(id: field_id)
205
- ))
157
+ render_form_field(
158
+ Hakumi::Switch::Component,
159
+ method,
160
+ config_overrides: { checked: checked },
161
+ **options
162
+ )
206
163
  end
207
164
 
208
165
  # Renders a Hakumi rate (rating) field
@@ -212,20 +169,7 @@ module Hakumi
212
169
  # @return [String] Rendered HTML
213
170
  def rate_field(method, **options)
214
171
  enhance_options_with_introspection!(method, options)
215
-
216
- # Get field configuration
217
- field_name = @object_name ? "#{@object_name}[#{method}]" : method.to_s
218
- field_id = @object_name ? "#{@object_name}_#{method}" : method.to_s
219
- value = object_value(method)
220
- errors = object_errors(method)
221
-
222
- @template.render(Hakumi::Rate::Component.new(
223
- name: field_name,
224
- value: value,
225
- errors: errors,
226
- standalone: false, # Always render with form-item wrapper in FormBuilder
227
- **options.merge(id: field_id)
228
- ))
172
+ render_form_field(Hakumi::Rate::Component, method, **options)
229
173
  end
230
174
 
231
175
  # Renders a Hakumi slider field
@@ -236,19 +180,14 @@ module Hakumi
236
180
  def slider_field(method, **options)
237
181
  enhance_options_with_introspection!(method, options)
238
182
 
239
- # Get field configuration
240
- field_name = @object_name ? "#{@object_name}[#{method}]" : method.to_s
241
- field_id = @object_name ? "#{@object_name}_#{method}" : method.to_s
242
- value = object_value(method)
243
- errors = object_errors(method)
183
+ config = field_configuration(method, options)
244
184
 
245
- @template.render(Hakumi::Slider::Component.new(
246
- name: field_name,
247
- value: value,
248
- errors: errors,
249
- standalone: false, # Always render with form-item wrapper in FormBuilder
250
- **options.merge(wrapper_id: field_id)
251
- ))
185
+ render_form_field(
186
+ Hakumi::Slider::Component,
187
+ method,
188
+ config_overrides: config,
189
+ **options
190
+ )
252
191
  end
253
192
 
254
193
  # Renders a Hakumi date picker field
@@ -258,20 +197,7 @@ module Hakumi
258
197
  # @return [String] Rendered HTML
259
198
  def date_picker(method, **options)
260
199
  enhance_options_with_introspection!(method, options)
261
-
262
- # Get field configuration
263
- field_name = @object_name ? "#{@object_name}[#{method}]" : method.to_s
264
- field_id = @object_name ? "#{@object_name}_#{method}" : method.to_s
265
- value = options.delete(:value) || object_value(method)
266
- errors = object_errors(method)
267
-
268
- @template.render(Hakumi::DatePicker::Component.new(
269
- name: field_name,
270
- value: value,
271
- errors: errors,
272
- standalone: false, # Always render with form-item wrapper in FormBuilder
273
- **options.merge(id: field_id)
274
- ))
200
+ render_form_field(Hakumi::DatePicker::Component, method, **options)
275
201
  end
276
202
 
277
203
  # Renders a Hakumi mentions field (textarea with @ mention suggestions)
@@ -286,23 +212,120 @@ module Hakumi
286
212
  def mentions_field(method, **options)
287
213
  enhance_options_with_introspection!(method, options)
288
214
 
289
- # Extract mentions-specific options
215
+ # Extract mentions-specific options before field configuration
290
216
  mention_options = options.delete(:options) || []
291
217
 
292
- # Get field configuration
293
- field_name = @object_name ? "#{@object_name}[#{method}]" : method.to_s
294
- field_id = @object_name ? "#{@object_name}_#{method}" : method.to_s
295
- value = options.delete(:value) || object_value(method)
296
- errors = object_errors(method)
218
+ render_form_field(
219
+ Hakumi::Mentions::Component,
220
+ method,
221
+ **options.merge(options: mention_options)
222
+ )
223
+ end
297
224
 
298
- @template.render(Hakumi::Mentions::Component.new(
299
- name: field_name,
300
- options: mention_options,
301
- value: value,
302
- errors: errors,
303
- standalone: false, # Always render with form-item wrapper in FormBuilder
304
- **options.merge(id: field_id)
305
- ))
225
+ # Renders a Hakumi autocomplete field (input with dropdown suggestions)
226
+ #
227
+ # @param method [Symbol] Name of the attribute
228
+ # @param choices [Array] Array of autocomplete options
229
+ # @param options [Hash] Options for the autocomplete component
230
+ # @return [String] Rendered HTML
231
+ def autocomplete_field(method, choices = nil, **options)
232
+ choices ||= [] # steep:ignore UnannotatedEmptyCollection
233
+ enhance_options_with_introspection!(method, options)
234
+
235
+ render_form_field(
236
+ Hakumi::Autocomplete::Component,
237
+ method,
238
+ **options.merge(options: choices)
239
+ )
240
+ end
241
+
242
+ # Renders a Hakumi cascader field (cascading dropdown selector)
243
+ #
244
+ # @param method [Symbol] Name of the attribute
245
+ # @param choices [Array] Array of cascader options with children
246
+ # @param options [Hash] Options for the cascader component
247
+ # @return [String] Rendered HTML
248
+ def cascader_field(method, choices = nil, **options)
249
+ choices ||= [] # steep:ignore UnannotatedEmptyCollection
250
+ enhance_options_with_introspection!(method, options)
251
+
252
+ render_form_field(
253
+ Hakumi::Cascader::Component,
254
+ method,
255
+ **options.merge(options: choices)
256
+ )
257
+ end
258
+
259
+ # Renders a Hakumi checkbox field (traditional checkbox, different from switch)
260
+ #
261
+ # @param method [Symbol] Name of the attribute
262
+ # @param options [Hash] Options for the checkbox
263
+ # @return [String] Rendered HTML
264
+ def checkbox_field(method, **options)
265
+ enhance_options_with_introspection!(method, options)
266
+
267
+ # Convert truthy/falsy value to boolean for checked attribute
268
+ value = object_value(method)
269
+ checked = value ? true : false
270
+
271
+ render_form_field(
272
+ Hakumi::Checkbox::Component,
273
+ method,
274
+ config_overrides: { checked: checked },
275
+ **options
276
+ )
277
+ end
278
+
279
+ # Renders a Hakumi color picker field
280
+ #
281
+ # @param method [Symbol] Name of the attribute
282
+ # @param options [Hash] Options for the color picker component
283
+ # @return [String] Rendered HTML
284
+ def color_picker_field(method, **options)
285
+ enhance_options_with_introspection!(method, options)
286
+ render_form_field(Hakumi::ColorPicker::Component, method, **options)
287
+ end
288
+
289
+ # Renders a Hakumi time picker field
290
+ #
291
+ # @param method [Symbol] Name of the attribute
292
+ # @param options [Hash] Options for the time picker component
293
+ # @return [String] Rendered HTML
294
+ def time_picker_field(method, **options)
295
+ enhance_options_with_introspection!(method, options)
296
+ render_form_field(Hakumi::TimePicker::Component, method, **options)
297
+ end
298
+
299
+ # Renders a Hakumi transfer field (dual list box)
300
+ #
301
+ # @param method [Symbol] Name of the attribute
302
+ # @param data_source [Array] Array of items to transfer
303
+ # @param options [Hash] Options for the transfer component
304
+ # @return [String] Rendered HTML
305
+ def transfer_field(method, data_source = nil, **options)
306
+ data_source ||= [] # steep:ignore UnannotatedEmptyCollection
307
+ enhance_options_with_introspection!(method, options)
308
+
309
+ render_form_field(
310
+ Hakumi::Transfer::Component,
311
+ method,
312
+ **options.merge(data_source: data_source)
313
+ )
314
+ end
315
+
316
+ # Renders a Hakumi upload field (file upload)
317
+ #
318
+ # @param method [Symbol] Name of the attribute
319
+ # @param options [Hash] Options for the upload component
320
+ # @option options [String] :action Upload URL (required)
321
+ # @return [String] Rendered HTML
322
+ def upload_field(method, **options)
323
+ enhance_options_with_introspection!(method, options)
324
+
325
+ # Upload requires action parameter
326
+ raise ArgumentError, "action parameter is required for upload_field" unless options[:action]
327
+
328
+ render_form_field(Hakumi::Upload::Component, method, **options)
306
329
  end
307
330
 
308
331
  # Renders a Hakumi submit button
@@ -333,6 +356,29 @@ module Hakumi
333
356
 
334
357
  private
335
358
 
359
+ # Gets standard field configuration (name, id, value, errors)
360
+ #
361
+ # @param method [Symbol] Attribute name
362
+ # @param options [Hash] Options hash (modifies in place by deleting :value)
363
+ # @param id_suffix [String, nil] Optional suffix for field_id (for radio buttons)
364
+ # @return [Hash] Field configuration with :name, :id, :value, :errors
365
+ def field_configuration(method, options, id_suffix: nil)
366
+ field_name = @object_name ? "#{@object_name}[#{method}]" : method.to_s
367
+
368
+ base_id = @object_name ? "#{@object_name}_#{method}" : method.to_s
369
+ field_id = id_suffix ? "#{base_id}_#{id_suffix}".parameterize.underscore : base_id
370
+
371
+ value = options.delete(:value) || object_value(method)
372
+ errors = object_errors(method)
373
+
374
+ {
375
+ name: field_name,
376
+ id: field_id,
377
+ value: value,
378
+ errors: errors
379
+ }
380
+ end
381
+
336
382
  # Enhances options hash with automatic detection from Rails validations and I18n
337
383
  #
338
384
  # @param method [Symbol] Attribute name
@@ -343,42 +389,50 @@ module Hakumi
343
389
 
344
390
  # Auto-detect label from I18n (if not provided)
345
391
  if options[:label].nil?
346
- detected_label = HakumiComponents::Rails::AttributeIntrospection.human_attribute_name(@object, method)
392
+ detected_label = Hakumi::Rails::AttributeIntrospection.human_attribute_name(@object, method)
347
393
  options[:label] = detected_label if detected_label
348
394
  end
349
395
 
350
- # Auto-detect required from presence validation (if not provided)
396
+ # Auto-detect required for UI purposes (asterisk display) - NOT for HTML5 validation
351
397
  if options[:required].nil?
352
- options[:required] = HakumiComponents::Rails::ValidationIntrospection.required?(@object, method)
353
- end
354
-
355
- # Auto-detect maxlength from length validation (if not provided)
356
- if options[:maxlength].nil?
357
- detected_maxlength = HakumiComponents::Rails::ValidationIntrospection.maxlength(@object, method)
358
- options[:maxlength] = detected_maxlength if detected_maxlength
398
+ options[:required] = Hakumi::Rails::ValidationIntrospection.required?(@object, method)
359
399
  end
360
400
 
361
401
  # Auto-detect placeholder from I18n (if not provided)
362
402
  if options[:placeholder].nil?
363
- detected_placeholder = HakumiComponents::Rails::AttributeIntrospection.placeholder(@object, method)
403
+ detected_placeholder = Hakumi::Rails::AttributeIntrospection.placeholder(@object, method)
364
404
  options[:placeholder] = detected_placeholder if detected_placeholder
365
405
  end
366
406
 
367
407
  # Auto-detect caption (hint) from I18n (if not provided)
368
408
  if options[:caption].nil?
369
- detected_hint = HakumiComponents::Rails::AttributeIntrospection.hint(@object, method)
409
+ detected_hint = Hakumi::Rails::AttributeIntrospection.hint(@object, method)
370
410
  options[:caption] = detected_hint if detected_hint
371
411
  end
372
412
 
413
+ # Auto-generate frontend validation rules from Rails validations
414
+ # If manual rules are provided, merge them with auto-detected rules
415
+ # Manual rules take priority over auto-detected ones for the same validation type
416
+ detected_rules = Hakumi::Rails::ValidationMapper.to_frontend_rules(@object, method)
417
+ manual_rules = options[:rules]
418
+
419
+ if manual_rules.nil?
420
+ options[:rules] = detected_rules unless detected_rules.empty?
421
+ elsif !detected_rules.empty?
422
+ # Merge manual and auto-detected rules, with manual rules taking priority
423
+ options[:rules] = Hakumi::Rails::ValidationMapper.merge_rules(manual_rules, detected_rules)
424
+ end
425
+ # If manual_rules is present but detected_rules is empty, keep manual_rules as-is
426
+
373
427
  # Auto-detect input type based on attribute name and validations (if not provided)
374
428
  if options[:type].nil?
375
- if HakumiComponents::Rails::ValidationIntrospection.email_field?(@object, method)
429
+ if Hakumi::Rails::ValidationIntrospection.email_field?(@object, method)
376
430
  options[:type] = :email
377
- elsif HakumiComponents::Rails::ValidationIntrospection.url_field?(@object, method)
431
+ elsif Hakumi::Rails::ValidationIntrospection.url_field?(@object, method)
378
432
  options[:type] = :url
379
433
  else
380
434
  # Fallback to column type
381
- detected_type = HakumiComponents::Rails::AttributeIntrospection.input_type_from_column(@object, method)
435
+ detected_type = Hakumi::Rails::AttributeIntrospection.input_type_from_column(@object, method)
382
436
  options[:type] = detected_type if detected_type && detected_type != :text
383
437
  end
384
438
  end
@@ -391,29 +445,17 @@ module Hakumi
391
445
  # @param component_class [Class] Component class to render
392
446
  # @param method [Symbol] Name of the attribute
393
447
  # @param options [Hash] Options for the component
448
+ # @param config_overrides [Hash] Override field configuration (for special cases)
394
449
  # @return [String] Rendered HTML
395
- def render_form_field(component_class, method, **options)
396
- # Get field configuration
397
- field_name = @object_name ? "#{@object_name}[#{method}]" : method.to_s
398
- field_id = @object_name ? "#{@object_name}_#{method}" : method.to_s
399
-
400
- # Get current value
401
- value = options.delete(:value) || object_value(method)
402
-
403
- # Get validation errors (use full_messages_for for better error messages)
404
- errors = object_errors(method)
450
+ def render_form_field(component_class, method, config_overrides: {}, **options)
451
+ config = field_configuration(method, options)
452
+ config.merge!(config_overrides)
405
453
 
406
- # Merge configuration
407
- html_options = options.merge(
408
- id: field_id,
409
- name: field_name,
410
- value: value,
411
- errors: errors,
412
- standalone: false # Always render with form-item wrapper in FormBuilder
413
- )
454
+ # Merge all options with standalone: false
455
+ component_options = config.merge(options).merge(standalone: false)
414
456
 
415
- # Render the component
416
- @template.render(component_class.new(**html_options))
457
+ # Render the component with merged configuration
458
+ @template.render(component_class.new(**component_options))
417
459
  end
418
460
 
419
461
  # Gets the current value of an attribute from the object
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hakumi
4
+ module FormHelper
5
+ # Creates a form that uses Hakumi components.
6
+ # This is a wrapper around Rails' form_with that automatically sets the Hakumi::FormBuilder.
7
+ #
8
+ # @example Basic usage
9
+ # <%= hakumi_form_with(model: @user) do |f| %>
10
+ # <%= f.text_field :name, label: "Name" %>
11
+ # <%= f.submit "Save" %>
12
+ # <% end %>
13
+ #
14
+ # @example With URL
15
+ # <%= hakumi_form_with(url: "/users", method: :post) do |f| %>
16
+ # <%= f.text_field :email, label: "Email" %>
17
+ # <% end %>
18
+ #
19
+ # @param options [Hash] Options to pass to form_with
20
+ # @option options [Object] :model Model object for the form
21
+ # @option options [String] :url URL to submit the form to
22
+ # @option options [Symbol] :method HTTP method (:get, :post, :patch, :delete)
23
+ # @option options [Class] :builder Custom form builder (defaults to Hakumi::FormBuilder)
24
+ # @option options [Symbol] :layout Form layout (:horizontal, :vertical, :inline)
25
+ # @return [String] HTML form
26
+ def hakumi_form_with(**options, &block)
27
+ options[:builder] ||= Hakumi::FormBuilder
28
+
29
+ layout = options.delete(:layout)
30
+ if layout
31
+ html_options = options[:html] || {}
32
+ html_options[:class] = class_names(html_options[:class], "hakumi-form", "hakumi-form-#{layout}")
33
+ options[:html] = html_options
34
+ end
35
+
36
+ form_with(**options, &block)
37
+ end
38
+ end
39
+ end
@@ -3,6 +3,14 @@ import { ensureId } from "../../core/dom.js"
3
3
  import { register, unregister } from "../../core/registry.js"
4
4
 
5
5
  export default class RegistryController extends Controller {
6
+ /**
7
+ * Declarative actions supported by this component.
8
+ * Each component must explicitly declare which actions it supports.
9
+ * Actions map to methods: 'close' -> close(), 'confirm' -> handleConfirm()
10
+ * @type {string[]}
11
+ */
12
+ static declarativeActions = []
13
+
6
14
  connect() {
7
15
  const isComponentController = this.isHakumiController()
8
16
 
@@ -21,6 +29,8 @@ export default class RegistryController extends Controller {
21
29
 
22
30
  if (typeof this.setup === "function") this.setup()
23
31
 
32
+ this.setupDeclarativeActions()
33
+
24
34
  this.validateContract()
25
35
  register(this.element.id, this.element.hakumiComponent.api)
26
36
 
@@ -28,12 +38,17 @@ export default class RegistryController extends Controller {
28
38
  if (this.element.hakumiComponent.singleton && typeof window !== "undefined" && window.HakumiComponents) {
29
39
  window.HakumiComponents[this.element.hakumiComponent.name] = this.element.hakumiComponent.api
30
40
  }
41
+
42
+ // Mark component as ready for testing
43
+ this.element.dataset.hakumiReady = "true"
31
44
  }
32
45
 
33
46
 
34
47
  disconnect() {
35
48
  if (typeof this.teardown === "function") this.teardown()
36
49
 
50
+ this.teardownDeclarativeActions()
51
+
37
52
  const c = this.element?.hakumiComponent
38
53
  if (c?.singleton && typeof window !== "undefined" && window.HakumiComponents) {
39
54
  if (window.HakumiComponents[c.name] === c.api) {
@@ -84,18 +99,83 @@ export default class RegistryController extends Controller {
84
99
  return String(this.identifier || "").startsWith("hakumi--")
85
100
  }
86
101
 
87
-
102
+
88
103
  setup() {
89
104
 
90
105
  }
91
106
 
92
-
107
+
93
108
  teardown() {
94
109
 
95
110
  }
96
111
 
97
-
112
+
98
113
  registerApi() {
99
114
 
100
115
  }
116
+
117
+ /**
118
+ * Sets up declarative action handling for data-hakumi-action attributes.
119
+ * Listens for clicks on elements with data-hakumi-action and routes to
120
+ * the appropriate handler method (close, open, toggle, confirm, cancel).
121
+ */
122
+ setupDeclarativeActions() {
123
+ this.boundHandleDeclarativeAction = this.handleDeclarativeAction.bind(this)
124
+ this.element.addEventListener("click", this.boundHandleDeclarativeAction)
125
+ }
126
+
127
+ /**
128
+ * Cleans up declarative action event listener.
129
+ */
130
+ teardownDeclarativeActions() {
131
+ if (this.boundHandleDeclarativeAction) {
132
+ this.element.removeEventListener("click", this.boundHandleDeclarativeAction)
133
+ this.boundHandleDeclarativeAction = null
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Handles clicks on elements with data-hakumi-action attribute.
139
+ * Routes to the appropriate method based on the action value.
140
+ * Supported actions: close, open, toggle, confirm, cancel
141
+ * @param {Event} event - The click event
142
+ */
143
+ handleDeclarativeAction(event) {
144
+ const actionElement = event.target.closest("[data-hakumi-action]")
145
+ if (!actionElement || !this.element.contains(actionElement)) return
146
+
147
+ const action = actionElement.dataset.hakumiAction
148
+ const actionMethod = this.getActionMethod(action)
149
+
150
+ if (typeof actionMethod === "function") {
151
+ event.preventDefault()
152
+ actionMethod.call(this, event, actionElement)
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Returns the method to call for a given action name.
158
+ * Only returns methods for actions declared in static declarativeActions.
159
+ * @param {string} action - The action name
160
+ * @returns {Function|undefined} The method to call
161
+ */
162
+ getActionMethod(action) {
163
+ const allowedActions = this.constructor.declarativeActions || []
164
+ if (!allowedActions.includes(action)) {
165
+ return undefined
166
+ }
167
+
168
+ // Try direct method name: close, open, toggle
169
+ if (typeof this[action] === "function") {
170
+ return this[action]
171
+ }
172
+
173
+ // Try handleX pattern: confirm -> handleConfirm, cancel -> handleCancel
174
+ const handlerName = `handle${action.charAt(0).toUpperCase()}${action.slice(1)}`
175
+ if (typeof this[handlerName] === "function") {
176
+ return this[handlerName]
177
+ }
178
+
179
+ return undefined
180
+ }
101
181
  }