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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module HakumiComponents
3
+ module Hakumi
4
4
  module Rails
5
5
  # Introspects ActiveModel/ActiveRecord validations to automatically
6
6
  # configure form field HTML attributes and client-side validation
@@ -77,8 +77,8 @@ module HakumiComponents
77
77
  return nil unless regex.is_a?(Regexp)
78
78
 
79
79
  # Convert Ruby regex to JavaScript-compatible pattern
80
- # Remove anchors ^$ as HTML pattern attribute adds them implicitly
81
- regex.source.gsub(/\A\^|\$\z/, "")
80
+ # Remove anchors (both Ruby \A\z and PCRE ^$) as HTML pattern adds them implicitly
81
+ regex.source.gsub(/\A(\\A|\^)|(\$|\\z)\z/, "")
82
82
  end
83
83
 
84
84
  # Get min/max values from numericality validation
@@ -96,7 +96,7 @@ module HakumiComponents
96
96
  return {} unless numericality_validator
97
97
 
98
98
  # @type var constraints: Hash[Symbol, Object]
99
- constraints = {}
99
+ constraints = Hash.new
100
100
  opts = validator_options(numericality_validator)
101
101
 
102
102
  min_inclusive = opts[:greater_than_or_equal_to]
@@ -182,7 +182,7 @@ module HakumiComponents
182
182
 
183
183
  def validator_options(validator)
184
184
  # @type var opts: Hash[Symbol, Object]
185
- opts = validator.respond_to?(:options) ? validator.public_send(:options) : {}
185
+ opts = validator.respond_to?(:options) ? validator.public_send(:options) : Hash.new
186
186
  opts = opts.to_h if opts.respond_to?(:to_h)
187
187
  opts.is_a?(Hash) ? opts : {}
188
188
  end
@@ -0,0 +1,484 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hakumi
4
+ module Rails
5
+ # Maps Rails ActiveModel validations to frontend validation rules
6
+ # compatible with Hakumi's ValidationManager (JavaScript)
7
+ #
8
+ # @example
9
+ # class User < ApplicationRecord
10
+ # validates :email, presence: true, length: { maximum: 255 }
11
+ # end
12
+ #
13
+ # ValidationMapper.to_frontend_rules(user, :email)
14
+ # # => [
15
+ # # { required: true, message: "Email can't be blank" },
16
+ # # { maxLength: { value: 255 }, message: "Email is too long (maximum is 255 characters)" }
17
+ # # ]
18
+ class ValidationMapper
19
+ class << self
20
+ # Convert Rails validations to frontend validation rules
21
+ #
22
+ # @param object [ActiveModel::Model, ActiveRecord::Base]
23
+ # @param attribute [Symbol] Attribute name
24
+ # @return [Array<Hash>] Array of validation rules for frontend
25
+ def to_frontend_rules(object, attribute)
26
+ return [] unless object
27
+ return [] unless object.class.respond_to?(:validators_on)
28
+
29
+ validators = object.class.validators_on(attribute)
30
+ return [] if validators.empty?
31
+
32
+ rules = Array.new
33
+
34
+ validators.each do |validator|
35
+ mapped_rules = map_validator(validator, object, attribute)
36
+ rules.concat(mapped_rules) if mapped_rules
37
+ end
38
+
39
+ rules.compact
40
+ end
41
+
42
+ # Merge manual rules with auto-detected rules
43
+ # Manual rules take priority over auto-detected ones for the same validation type
44
+ #
45
+ # @param manual_rules [Array<Hash>, nil] User-provided rules
46
+ # @param auto_rules [Array<Hash>] Auto-detected rules from validators
47
+ # @return [Array<Hash>] Merged rules with manual rules taking priority
48
+ def merge_rules(manual_rules, auto_rules)
49
+ return auto_rules if manual_rules.nil? || manual_rules.empty?
50
+ return manual_rules if auto_rules.nil? || auto_rules.empty?
51
+
52
+ # Get rule types from manual rules
53
+ manual_types = manual_rules.map { |rule| extract_rule_type(rule) }.compact
54
+
55
+ # Filter out auto-detected rules that have the same type as manual rules
56
+ merged = manual_rules.dup
57
+ auto_rules.each do |auto_rule|
58
+ type = extract_rule_type(auto_rule)
59
+ merged << auto_rule unless type && manual_types.include?(type)
60
+ end
61
+
62
+ merged
63
+ end
64
+
65
+ private
66
+
67
+ # Extract the validation type from a rule
68
+ # Rules have format: { required: true, message: "..." }
69
+ # or { minLength: { value: 3 }, message: "..." }
70
+ def extract_rule_type(rule)
71
+ # The first key that isn't :message is the rule type
72
+ (rule.keys - [ :message ]).first
73
+ end
74
+
75
+ # Map a single validator to frontend rule(s)
76
+ def map_validator(validator, object, attribute)
77
+ case validator
78
+ when ActiveModel::Validations::PresenceValidator
79
+ map_presence(validator, object, attribute)
80
+ when ActiveModel::Validations::LengthValidator
81
+ map_length(validator, object, attribute)
82
+ when ActiveModel::Validations::NumericalityValidator
83
+ map_numericality(validator, object, attribute)
84
+ when ActiveModel::Validations::FormatValidator
85
+ map_format(validator, object, attribute)
86
+ when ActiveModel::Validations::ConfirmationValidator
87
+ map_confirmation(validator, object, attribute)
88
+ when ActiveModel::Validations::ComparisonValidator
89
+ map_comparison(validator, object, attribute)
90
+ when ActiveModel::Validations::InclusionValidator
91
+ map_inclusion(validator, object, attribute)
92
+ when ActiveModel::Validations::ExclusionValidator
93
+ map_exclusion(validator, object, attribute)
94
+ when ActiveModel::Validations::AcceptanceValidator
95
+ map_acceptance(validator, object, attribute)
96
+ when ActiveModel::Validations::AbsenceValidator
97
+ map_absence(validator, object, attribute)
98
+ when defined?(ActiveRecord) && ActiveRecord::Validations::PresenceValidator
99
+ map_presence(validator, object, attribute)
100
+ else
101
+ # Unknown validator type - skip for now
102
+ # Can be extended by adding more cases
103
+ nil
104
+ end
105
+ end
106
+
107
+ # Map presence validation to required rule
108
+ def map_presence(validator, object, attribute)
109
+ [ {
110
+ required: true,
111
+ message: validation_message(object, attribute, :blank, {}, validator.options)
112
+ } ]
113
+ end
114
+
115
+ # Map length validation to minLength/maxLength rules
116
+ def map_length(validator, object, attribute)
117
+ rules = Array.new
118
+ options = validator.options
119
+
120
+ # Exact length
121
+ exact = options[:is]
122
+ if exact
123
+ rules << {
124
+ minLength: { value: exact },
125
+ maxLength: { value: exact },
126
+ message: validation_message(object, attribute, :wrong_length, { count: exact }, options)
127
+ }
128
+
129
+ return rules
130
+ end
131
+
132
+ # Minimum length
133
+ min = options[:minimum] || (options[:in]&.min if options[:in].respond_to?(:min))
134
+ if min
135
+ rules << {
136
+ minLength: { value: min },
137
+ message: validation_message(object, attribute, :too_short, { count: min }, options)
138
+ }
139
+ end
140
+
141
+ # Maximum length
142
+ max = options[:maximum] || options[:is] || (options[:in]&.max if options[:in].respond_to?(:max))
143
+ if max
144
+ rules << {
145
+ maxLength: { value: max },
146
+ message: validation_message(object, attribute, :too_long, { count: max }, options)
147
+ }
148
+ end
149
+
150
+ rules
151
+ end
152
+
153
+ # Map numericality validation to min/max rules
154
+ def map_numericality(validator, object, attribute)
155
+ rules = Array.new
156
+ options = validator.options
157
+
158
+ # Minimum value (inclusive)
159
+ if options[:greater_than_or_equal_to]
160
+ rules << {
161
+ min: { value: options[:greater_than_or_equal_to] },
162
+ message: validation_message(
163
+ object,
164
+ attribute,
165
+ :greater_than_or_equal_to,
166
+ { count: options[:greater_than_or_equal_to] },
167
+ options
168
+ )
169
+ }
170
+ end
171
+
172
+ # Minimum value (exclusive) - add 1 for frontend
173
+ if options[:greater_than]
174
+ min_value = options[:greater_than] + 1
175
+ rules << {
176
+ min: { value: min_value },
177
+ message: validation_message(
178
+ object,
179
+ attribute,
180
+ :greater_than,
181
+ { count: options[:greater_than] },
182
+ options
183
+ )
184
+ }
185
+ end
186
+
187
+ # Maximum value (inclusive)
188
+ if options[:less_than_or_equal_to]
189
+ rules << {
190
+ max: { value: options[:less_than_or_equal_to] },
191
+ message: validation_message(
192
+ object,
193
+ attribute,
194
+ :less_than_or_equal_to,
195
+ { count: options[:less_than_or_equal_to] },
196
+ options
197
+ )
198
+ }
199
+ end
200
+
201
+ # Maximum value (exclusive) - subtract 1 for frontend
202
+ if options[:less_than]
203
+ max_value = options[:less_than] - 1
204
+ rules << {
205
+ max: { value: max_value },
206
+ message: validation_message(
207
+ object,
208
+ attribute,
209
+ :less_than,
210
+ { count: options[:less_than] },
211
+ options
212
+ )
213
+ }
214
+ end
215
+
216
+ rules
217
+ end
218
+
219
+ # Map format validation to pattern/email/url rule
220
+ def map_format(validator, object, attribute)
221
+ options = validator.options
222
+ regex = options[:with]
223
+ return [] unless regex.is_a?(Regexp)
224
+
225
+ regex_source = regex.source
226
+
227
+ # Detect email pattern
228
+ if email_regex?(regex_source) || attribute.to_s.match?(/email/i)
229
+ return [ {
230
+ email: true,
231
+ message: validation_message(object, attribute, :invalid, {}, options)
232
+ } ]
233
+ end
234
+
235
+ # Detect URL pattern
236
+ if url_regex?(regex_source) || attribute.to_s.match?(/url|website|link/i)
237
+ return [ {
238
+ url: true,
239
+ message: validation_message(object, attribute, :invalid, {}, options)
240
+ } ]
241
+ end
242
+
243
+ # Generic pattern rule
244
+ # Remove anchors (\A, \z, ^, $) as HTML pattern adds them implicitly
245
+ pattern = regex_source.gsub(/\A(\\A|\^)|(\$|\\z)\z/, "")
246
+
247
+ [ {
248
+ pattern: { value: pattern },
249
+ message: validation_message(object, attribute, :invalid, {}, options)
250
+ } ]
251
+ end
252
+
253
+ # Map confirmation validation to match rule
254
+ def map_confirmation(validator, object, attribute)
255
+ [ {
256
+ match: { field: "#{attribute}_confirmation" },
257
+ message: validation_message(object, attribute, :confirmation, {}, validator.options)
258
+ } ]
259
+ end
260
+
261
+ # Map comparison validation to comparison rule
262
+ def map_comparison(validator, object, attribute)
263
+ options = validator.options
264
+ rules = Array.new
265
+
266
+ comparison_keys = %i[
267
+ greater_than
268
+ greater_than_or_equal_to
269
+ equal_to
270
+ less_than
271
+ less_than_or_equal_to
272
+ other_than
273
+ ]
274
+
275
+ comparison_keys.each do |key|
276
+ next unless options.key?(key)
277
+
278
+ target = options[key]
279
+ comparison = build_comparison_target(target, object)
280
+ next unless comparison
281
+
282
+ message_options = comparison_message_options(target, object, comparison)
283
+ rules << {
284
+ comparison: comparison.merge(operator: key),
285
+ message: validation_message(object, attribute, key, message_options, options)
286
+ }
287
+ end
288
+
289
+ rules
290
+ end
291
+
292
+ # Map inclusion validation to enum rule
293
+ def map_inclusion(validator, object, attribute)
294
+ options = validator.options
295
+ values = options[:in] || options[:within]
296
+
297
+ return [] unless values
298
+
299
+ [ {
300
+ enum: { values: values.to_a },
301
+ message: validation_message(object, attribute, :inclusion, {}, options)
302
+ } ]
303
+ end
304
+
305
+ # Map exclusion validation to forbidden values rule
306
+ def map_exclusion(validator, object, attribute)
307
+ options = validator.options
308
+ values = options[:in] || options[:within]
309
+
310
+ return [] unless values
311
+
312
+ [ {
313
+ exclusion: { values: values.to_a },
314
+ message: validation_message(object, attribute, :exclusion, {}, options)
315
+ } ]
316
+ end
317
+
318
+ # Map acceptance validation to required rule
319
+ def map_acceptance(validator, object, attribute)
320
+ [ {
321
+ required: true,
322
+ message: validation_message(object, attribute, :accepted, {}, validator.options)
323
+ } ]
324
+ end
325
+
326
+ # Map absence validation to absent rule (field must be blank)
327
+ def map_absence(validator, object, attribute)
328
+ [ {
329
+ absent: true,
330
+ message: validation_message(object, attribute, :present, {}, validator.options)
331
+ } ]
332
+ end
333
+
334
+ # Check if regex matches email pattern
335
+ def email_regex?(regex_source)
336
+ # Common email regex patterns
337
+ regex_source.match?(/@.*\./) || regex_source.include?("EMAIL")
338
+ end
339
+
340
+ # Check if regex matches URL pattern
341
+ def url_regex?(regex_source)
342
+ # Common URL regex patterns
343
+ regex_source.match?(/https?|:\/\//) || regex_source.include?("URL")
344
+ end
345
+
346
+ # Humanize attribute name
347
+ def humanize(attribute)
348
+ attribute.to_s.humanize
349
+ end
350
+
351
+ # Use Rails' error message generation to honor I18n and custom messages
352
+ def validation_message(object, attribute, type, options, validator_options)
353
+ return fallback_message(attribute, type, options, validator_options) unless object&.respond_to?(:errors)
354
+ return fallback_message(attribute, type, options, validator_options) if anonymous_class?(object)
355
+
356
+ value = object.respond_to?(attribute) ? object.public_send(attribute) : nil
357
+ message = object.errors.generate_message(
358
+ attribute,
359
+ type,
360
+ options.merge(value: value, message: validator_options[:message])
361
+ )
362
+
363
+ if object.errors.respond_to?(:full_message) && object.class.name && !object.class.name.empty?
364
+ object.errors.full_message(attribute, message)
365
+ else
366
+ message
367
+ end
368
+ end
369
+
370
+ def anonymous_class?(object)
371
+ class_name = object.class.name
372
+ class_name.nil? || class_name.empty?
373
+ end
374
+
375
+ # Build comparison target from validator option value
376
+ #
377
+ # @param target [Symbol, String, Proc, Object] The comparison target value
378
+ # @param _object [Object] The model object (unused, Procs are not evaluated)
379
+ # @return [Hash, nil] Hash with :field or :value key, or nil if cannot be used client-side
380
+ #
381
+ # @note Procs are always ignored (return nil) because they cannot be reliably
382
+ # evaluated on the client-side. For field comparisons, use Symbol references:
383
+ # `comparison: { greater_than: :start_date }` instead of Procs.
384
+ def build_comparison_target(target, _object)
385
+ case target
386
+ when Symbol, String
387
+ { field: target.to_s }
388
+ when Proc
389
+ # Procs cannot be evaluated reliably on client-side
390
+ # Skip this validation rule for frontend
391
+ nil
392
+ else
393
+ { value: target }
394
+ end
395
+ end
396
+
397
+ def comparison_message_options(target, object, comparison)
398
+ if comparison[:field]
399
+ {
400
+ count: humanize(comparison[:field])
401
+ }
402
+ else
403
+ resolved = resolve_comparison_value(target, object)
404
+ {
405
+ count: resolved
406
+ }
407
+ end
408
+ end
409
+
410
+ def resolve_comparison_value(target, object)
411
+ return object.public_send(target) if target.is_a?(Symbol) && object&.respond_to?(target)
412
+ return target.call(object) if target.respond_to?(:call)
413
+
414
+ target
415
+ rescue StandardError
416
+ nil
417
+ end
418
+
419
+ # Fallback for standalone objects or anonymous classes where Rails can't build model-based messages.
420
+ # This keeps validations usable without requiring ActiveRecord models.
421
+ def fallback_message(attribute, type, options, validator_options)
422
+ message = validator_options[:message]
423
+ return message if message.is_a?(String)
424
+
425
+ local_options = options.dup
426
+ if type == :confirmation && !local_options.key?(:attribute)
427
+ local_options[:attribute] = humanize(:"#{attribute}_confirmation")
428
+ end
429
+
430
+ default_text = fallback_message_text(type, local_options)
431
+ return "#{humanize(attribute)} #{default_text}" unless defined?(I18n) && I18n.respond_to?(:t)
432
+
433
+ translated = I18n.t(
434
+ :"errors.messages.#{type}",
435
+ **local_options,
436
+ default: default_text
437
+ )
438
+
439
+ "#{humanize(attribute)} #{translated}"
440
+ end
441
+
442
+ def fallback_message_text(type, options)
443
+ count = options[:count]
444
+ case type
445
+ when :blank
446
+ "can't be blank"
447
+ when :too_short
448
+ "is too short (minimum is #{count} characters)"
449
+ when :too_long
450
+ "is too long (maximum is #{count} characters)"
451
+ when :wrong_length
452
+ "is the wrong length (should be #{count} characters)"
453
+ when :greater_than_or_equal_to
454
+ "must be greater than or equal to #{count}"
455
+ when :greater_than
456
+ "must be greater than #{count}"
457
+ when :less_than_or_equal_to
458
+ "must be less than or equal to #{count}"
459
+ when :less_than
460
+ "must be less than #{count}"
461
+ when :equal_to
462
+ "must be equal to #{count}"
463
+ when :other_than
464
+ "must be other than #{count}"
465
+ when :confirmation
466
+ "doesn't match #{options[:attribute]}"
467
+ when :inclusion
468
+ "is not included in the list"
469
+ when :exclusion
470
+ "is reserved"
471
+ when :accepted
472
+ "must be accepted"
473
+ when :present
474
+ "must be blank"
475
+ when :invalid
476
+ "is invalid"
477
+ else
478
+ type.to_s.tr("_", " ")
479
+ end
480
+ end
481
+ end
482
+ end
483
+ end
484
+ end
@@ -2,8 +2,9 @@
2
2
 
3
3
  require_relative "rails/validation_introspection"
4
4
  require_relative "rails/attribute_introspection"
5
+ require_relative "rails/validation_mapper"
5
6
 
6
- module HakumiComponents
7
+ module Hakumi
7
8
  module Rails
8
9
  end
9
10
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module HakumiComponents
4
- VERSION = "0.1.16.pre"
3
+ module Hakumi
4
+ VERSION = "0.1.17.pre"
5
5
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "view_component"
3
4
  require "hakumi_components/version"
4
5
  require "hakumi_components/engine"
5
6
  require "hakumi_components/rails"
7
+ require "hakumi_components/documentation"
6
8
 
7
- module HakumiComponents
9
+ module Hakumi
8
10
  class << self
9
11
  def configure
10
12
  yield configuration
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :coverage do
4
+ desc "Run tests with coverage report (no parallel)"
5
+ task :report do
6
+ puts "🔍 Running tests with coverage (no parallelization)..."
7
+ puts ""
8
+
9
+ # Set environment variables
10
+ ENV["COVERAGE"] = "1"
11
+ ENV["PARALLEL"] = "0"
12
+
13
+ # Clean previous coverage
14
+ sh "rm -rf coverage" rescue nil
15
+
16
+ # Run tests
17
+ Rake::Task["test"].invoke
18
+
19
+ puts ""
20
+ puts "✅ Coverage report generated at: coverage/index.html"
21
+ puts ""
22
+ puts "To view the report:"
23
+ puts " - macOS: open coverage/index.html"
24
+ puts " - Linux: xdg-open coverage/index.html"
25
+ puts ""
26
+ end
27
+
28
+ desc "Run tests with coverage and console output"
29
+ task :console do
30
+ ENV["COVERAGE_CONSOLE"] = "1"
31
+ Rake::Task["coverage:report"].invoke
32
+ end
33
+ end
34
+
35
+ # Alias for convenience
36
+ desc "Run tests with coverage (alias for coverage:report)"
37
+ task coverage: "coverage:report"
@@ -65,6 +65,11 @@ module Hakumi
65
65
  # @return [String, nil] CSS dimension string
66
66
  def dimension_to_css: ((Numeric | String | nil) value) -> String?
67
67
 
68
+ # Cast a value to boolean using ActiveModel::Type::Boolean
69
+ # @param value [untyped] Value to cast
70
+ # @return [Boolean] Casted boolean value
71
+ def cast_boolean: (untyped value) -> bool
72
+
68
73
  # Build inline style string from array or hash
69
74
  # @param styles [Array<String>, Hash] Styles as array of CSS strings or hash of property => value
70
75
  # @return [String, nil] Combined style string
@@ -3,6 +3,8 @@
3
3
  module Hakumi
4
4
  module Checkbox
5
5
  class Component < Hakumi::BaseComponent
6
+ include Hakumi::Concerns::FormField
7
+
6
8
  @checked: bool
7
9
  @disabled: bool
8
10
  @indeterminate: bool
@@ -11,6 +13,10 @@ module Hakumi
11
13
  @id: String
12
14
  @auto_focus: bool
13
15
  @label: String?
16
+ @caption: String?
17
+ @standalone: bool
18
+ @required: bool
19
+ @errors: Array[String]
14
20
  @wrapper_id: String
15
21
  @html_options: hakumi_html_options
16
22
 
@@ -23,6 +29,10 @@ module Hakumi
23
29
  ?id: String?,
24
30
  ?auto_focus: bool,
25
31
  ?label: String?,
32
+ ?caption: String?,
33
+ ?standalone: bool,
34
+ ?required: bool,
35
+ ?errors: Array[String],
26
36
  **hakumi_html_options html_options
27
37
  ) -> void
28
38
 
@@ -75,7 +75,6 @@ module Hakumi
75
75
  def component_classes: () -> String
76
76
  def data_value: (String? value) -> String?
77
77
  def normalize_presets: (preset_input? presets) -> Array[preset_group]
78
- def cast_boolean: (untyped value) -> bool
79
78
  end
80
79
  end
81
80
  end
@@ -16,13 +16,21 @@ module Hakumi
16
16
  def mentions_field: (Symbol method, **untyped options) -> String
17
17
  def slider_field: (Symbol method, **untyped options) -> String
18
18
  def rate_field: (Symbol method, **untyped options) -> String
19
+ def autocomplete_field: (Symbol method, untyped? choices, **untyped options) -> String
20
+ def cascader_field: (Symbol method, untyped? choices, **untyped options) -> String
21
+ def checkbox_field: (Symbol method, **untyped options) -> String
22
+ def color_picker_field: (Symbol method, **untyped options) -> String
23
+ def time_picker_field: (Symbol method, **untyped options) -> String
24
+ def transfer_field: (Symbol method, untyped? data_source, **untyped options) -> String
25
+ def upload_field: (Symbol method, **untyped options) -> String
19
26
  def submit: (String? value, **untyped options) -> String
20
27
 
21
28
  private
22
29
 
23
30
  # Private helper methods
31
+ def field_configuration: (Symbol method, Hash[Symbol, untyped] options, ?id_suffix: String?) -> Hash[Symbol, untyped]
24
32
  def enhance_options_with_introspection!: (Symbol method, Hash[Symbol, untyped] options) -> (Hash[Symbol, untyped] | nil)
25
- def render_form_field: (Class component_class, Symbol method, **untyped options) -> String
33
+ def render_form_field: (Class component_class, Symbol method, ?config_overrides: Hash[Symbol, untyped], **untyped options) -> String
26
34
  def object_value: (Symbol method) -> untyped
27
35
  def object_errors: (Symbol method) -> Array[String]
28
36
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module HakumiComponents
3
+ module Hakumi
4
4
  module Rails
5
5
  class AttributeIntrospection
6
6
  def self.human_attribute_name: (Object object, Symbol attribute) -> String?
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module HakumiComponents
3
+ module Hakumi
4
4
  module Rails
5
5
  class ValidationIntrospection
6
6
  def self.required?: (Object object, Symbol attribute) -> bool