phlex-forms 0.1.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 (61) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +170 -0
  5. data/app/javascript/phlex_forms/controllers/validations/acceptance_controller.js +23 -0
  6. data/app/javascript/phlex_forms/controllers/validations/base_controller.js +166 -0
  7. data/app/javascript/phlex_forms/controllers/validations/confirmation_controller.js +27 -0
  8. data/app/javascript/phlex_forms/controllers/validations/exclusion_controller.js +19 -0
  9. data/app/javascript/phlex_forms/controllers/validations/form_controller.js +43 -0
  10. data/app/javascript/phlex_forms/controllers/validations/format_controller.js +23 -0
  11. data/app/javascript/phlex_forms/controllers/validations/inclusion_controller.js +19 -0
  12. data/app/javascript/phlex_forms/controllers/validations/length_controller.js +95 -0
  13. data/app/javascript/phlex_forms/controllers/validations/numericality_controller.js +59 -0
  14. data/app/javascript/phlex_forms/controllers/validations/presence_controller.js +14 -0
  15. data/app/javascript/phlex_forms/i18n.js +40 -0
  16. data/app/javascript/phlex_forms/messages.js +100 -0
  17. data/config/importmap.rb +21 -0
  18. data/config/locales/de.yml +12 -0
  19. data/config/locales/en.yml +14 -0
  20. data/config/locales/sv.yml +12 -0
  21. data/config/rubocop.yml +20 -0
  22. data/lib/forms/checkbox.rb +39 -0
  23. data/lib/forms/choices_select.rb +132 -0
  24. data/lib/forms/collection_check_box.rb +26 -0
  25. data/lib/forms/collection_check_box_builder.rb +26 -0
  26. data/lib/forms/collection_label.rb +20 -0
  27. data/lib/forms/email_field.rb +11 -0
  28. data/lib/forms/field.rb +222 -0
  29. data/lib/forms/field_error.rb +26 -0
  30. data/lib/forms/field_hint.rb +24 -0
  31. data/lib/forms/fields_for_builder.rb +58 -0
  32. data/lib/forms/file_input.rb +29 -0
  33. data/lib/forms/form.rb +225 -0
  34. data/lib/forms/form_control.rb +42 -0
  35. data/lib/forms/input.rb +35 -0
  36. data/lib/forms/label.rb +32 -0
  37. data/lib/forms/password_field.rb +11 -0
  38. data/lib/forms/radio.rb +29 -0
  39. data/lib/forms/range.rb +33 -0
  40. data/lib/forms/rich_textarea.rb +43 -0
  41. data/lib/forms/select.rb +73 -0
  42. data/lib/forms/submit.rb +62 -0
  43. data/lib/forms/textarea.rb +27 -0
  44. data/lib/forms/time_zone_select.rb +52 -0
  45. data/lib/forms/toggle.rb +39 -0
  46. data/lib/forms/validations/introspector.rb +224 -0
  47. data/lib/forms/validations/manual_rules.rb +77 -0
  48. data/lib/forms/wrapped_input.rb +67 -0
  49. data/lib/phlex-forms.rb +4 -0
  50. data/lib/phlex_forms/builder.rb +153 -0
  51. data/lib/phlex_forms/class_merge.rb +56 -0
  52. data/lib/phlex_forms/configuration.rb +49 -0
  53. data/lib/phlex_forms/delegated_field.rb +51 -0
  54. data/lib/phlex_forms/engine.rb +35 -0
  55. data/lib/phlex_forms/inline_icons.rb +36 -0
  56. data/lib/phlex_forms/rubocop.rb +14 -0
  57. data/lib/phlex_forms/version.rb +5 -0
  58. data/lib/phlex_forms.rb +80 -0
  59. data/lib/rubocop/phlex_forms/cop/legacy_form_method.rb +103 -0
  60. data/lib/rubocop/phlex_forms/cop/raw_form.rb +51 -0
  61. metadata +181 -0
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Per-field context: knows a field's name, its scope, the bound model, and the
4
+ # error set. Builds the concrete leaf components (input/textarea/select/...) with
5
+ # name/id/value/error wired in, and derives the label text and required flag.
6
+ #
7
+ # Not usually built directly — `Form#field(:name)` / the builder return one.
8
+ module Forms
9
+ class Field
10
+ attr_reader :name, :model, :scope, :errors
11
+
12
+ def initialize(name:, model:, scope:, errors:, form:)
13
+ @name = name
14
+ @model = model
15
+ @scope = scope
16
+ @errors = errors
17
+ @form = form
18
+ end
19
+
20
+ # --- leaf component builders (return component instances to render) ---
21
+
22
+ def input(*modifiers, type: :text, **)
23
+ Forms::Input.new(*modifiers, type:, **field_attributes, **)
24
+ end
25
+
26
+ def textarea(*modifiers, **)
27
+ Forms::Textarea.new(*modifiers, **field_attributes, **)
28
+ end
29
+ alias text_area textarea
30
+
31
+ def rich_textarea(*modifiers, **)
32
+ Forms::RichTextarea.new(*modifiers, name: field_name, id: field_id, value: field_value, **)
33
+ end
34
+ alias rich_text_area rich_textarea
35
+
36
+ def hidden(**)
37
+ Forms::Input.new(type: :hidden, **field_attributes, **)
38
+ end
39
+
40
+ # daisyui v5 "icon/text inside the field" wrapper. The block renders the
41
+ # leading content (icon, prefix); the bare input is wired to this field.
42
+ def wrapped_input(*modifiers, type: :text, **, &)
43
+ Forms::WrappedInput.new(*modifiers, type:, **field_attributes.except(:error), error: invalid?, **, &)
44
+ end
45
+
46
+ def file(*modifiers, **)
47
+ Forms::FileInput.new(*modifiers, **field_attributes.except(:value), **)
48
+ end
49
+
50
+ def checkbox(*modifiers, **)
51
+ Forms::Checkbox.new(*modifiers, checked: field_value, **field_attributes.except(:value), **)
52
+ end
53
+ alias check_box checkbox
54
+
55
+ def toggle(*modifiers, **)
56
+ Forms::Toggle.new(*modifiers, checked: field_value, **field_attributes.except(:value), **)
57
+ end
58
+
59
+ def radio(value, *modifiers, **options)
60
+ Forms::Radio.new(
61
+ *modifiers,
62
+ value:,
63
+ checked: field_value == value,
64
+ **field_attributes.merge(options).merge(id: "#{field_id}_#{value}")
65
+ )
66
+ end
67
+ alias radio_button radio
68
+
69
+ def select(choices = nil, **options)
70
+ Forms::Select.new(choices:, selected: field_value, **select_options(options))
71
+ end
72
+
73
+ # Enhanced select: choices.js-backed when searchable, native otherwise.
74
+ def choices_select(choices = nil, *modifiers, **options)
75
+ searchable = options.delete(:searchable) { false }
76
+ opts = select_options(options)
77
+ if searchable
78
+ Forms::ChoicesSelect.new(*modifiers, choices:, selected: field_value, searchable: true, **opts)
79
+ else
80
+ Forms::Select.new(*modifiers, choices:, selected: field_value, **opts)
81
+ end
82
+ end
83
+
84
+ def label(text = nil, *modifiers, **, &block)
85
+ Forms::Label.new(*modifiers, text: text || (block ? nil : field_label), for: field_id, **, &block)
86
+ end
87
+
88
+ def control(label: nil, hint: nil, required: false, **, &)
89
+ Forms::FormControl.new(label:, hint:, error: field_error_message, for: field_id, required:, **, &)
90
+ end
91
+
92
+ # --- derived metadata ---
93
+
94
+ # Humanized label text: the model's human_attribute_name when available.
95
+ def field_label
96
+ if @model.respond_to?(:class) && @model.class.respond_to?(:human_attribute_name)
97
+ @model.class.human_attribute_name(@name)
98
+ else
99
+ @name.to_s.tr("_", " ").capitalize
100
+ end
101
+ end
102
+
103
+ # Inferred from the model's presence validators (ActiveModel). False when the
104
+ # model doesn't expose validators.
105
+ def required?
106
+ return false unless @model.respond_to?(:class) && @model.class.respond_to?(:validators_on)
107
+
108
+ @model.class.validators_on(@name).any? do |v|
109
+ v.is_a?(ActiveModel::Validations::PresenceValidator) && !conditional?(v)
110
+ end
111
+ rescue StandardError
112
+ false
113
+ end
114
+
115
+ def invalid?
116
+ @errors&.include?(@name)
117
+ end
118
+
119
+ def field_name
120
+ @scope ? "#{@scope}[#{@name}]" : @name.to_s
121
+ end
122
+
123
+ def field_id
124
+ if @scope
125
+ "#{@scope.tr('[', '_').delete(']')}_#{@name}"
126
+ else
127
+ @name.to_s
128
+ end
129
+ end
130
+
131
+ # Ransack-safe getter: dispatches through method_missing consumers (e.g.
132
+ # Ransack::Search predicate getters) but only swallows the direct dispatch miss.
133
+ def field_value
134
+ return nil unless @model
135
+
136
+ @model.public_send(@name)
137
+ rescue NoMethodError => e
138
+ raise unless e.receiver.equal?(@model) && e.name == @name
139
+
140
+ nil
141
+ end
142
+
143
+ # --- client-side validation wiring (pairs with Forms::Validations) ---
144
+
145
+ # Merge a per-call `validate:` override into the field's option hash. Strips
146
+ # `:validate` and, when rules apply, merges the Stimulus data into `data:`.
147
+ # Falls back to the form-level introspector when `:validate` is absent.
148
+ #
149
+ # validate: false → opt this field out
150
+ # validate: true → the form-level introspector
151
+ # validate: { length: {…} } → explicit inline rules
152
+ def apply_validations(options)
153
+ return consume_validate(options) if options.key?(:validate)
154
+
155
+ data = form_introspector.data_attributes_for(@name)
156
+ return options if data.empty?
157
+
158
+ options.merge(data: merge_data(options[:data], data))
159
+ end
160
+
161
+ private
162
+
163
+ def consume_validate(options)
164
+ return options unless options.key?(:validate)
165
+
166
+ override = options.delete(:validate)
167
+ data = validation_data_for(override)
168
+ return options if data.empty?
169
+
170
+ options.merge(data: merge_data(options[:data], data))
171
+ end
172
+
173
+ def validation_data_for(override)
174
+ case override
175
+ when true then form_introspector.data_attributes_for(@name)
176
+ when Hash then Forms::Validations::ManualRules.new(override).data_attributes
177
+ else {} # false / nil / anything else → no client-side validation
178
+ end
179
+ end
180
+
181
+ def form_introspector
182
+ if @form.respond_to?(:validations_introspector)
183
+ @form.validations_introspector
184
+ else
185
+ Forms::Validations::Introspector::Null.new
186
+ end
187
+ end
188
+
189
+ def merge_data(existing, additions)
190
+ existing = (existing || {}).dup
191
+ additions.each do |key, value|
192
+ if key == :controller
193
+ existing[:controller] = [existing[:controller], value].compact.reject { |s| s.to_s.empty? }.join(" ")
194
+ else
195
+ existing[key] = value
196
+ end
197
+ end
198
+ existing
199
+ end
200
+
201
+ def conditional?(validator)
202
+ validator.options.key?(:if) || validator.options.key?(:unless) || validator.options.key?(:on)
203
+ end
204
+
205
+ def field_attributes
206
+ { name: field_name, id: field_id, value: field_value, error: invalid? }
207
+ end
208
+
209
+ def field_error_message
210
+ @errors&.full_messages_for(@name)&.first if invalid?
211
+ end
212
+
213
+ def select_options(options)
214
+ include_blank = options.delete(:include_blank)
215
+ prompt = options.delete(:prompt)
216
+ opts = field_attributes.merge(options)
217
+ opts[:include_blank] = include_blank unless include_blank.nil?
218
+ opts[:prompt] = prompt unless prompt.nil?
219
+ opts
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # Inline validation error shown beneath a field. Uses the same visual treatment
5
+ # (`text-error text-sm`) the client-side validation controllers apply, so a
6
+ # server-rendered error and a live client-rendered one look identical.
7
+ class FieldError < Phlex::HTML
8
+ def initialize(message: nil, **options)
9
+ @message = message
10
+ @options = options
11
+ super()
12
+ end
13
+
14
+ def view_template
15
+ return unless @message
16
+
17
+ p(class: classes) { @message }
18
+ end
19
+
20
+ private
21
+
22
+ def classes
23
+ PhlexForms::ClassMerge.merge("text-error text-sm mt-1", @options[:class])
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # Inline hint/help text shown beneath a field.
5
+ class FieldHint < Phlex::HTML
6
+ def initialize(text: nil, **options)
7
+ @text = text
8
+ @options = options
9
+ super()
10
+ end
11
+
12
+ def view_template
13
+ return unless @text
14
+
15
+ p(class: classes) { @text }
16
+ end
17
+
18
+ private
19
+
20
+ def classes
21
+ PhlexForms::ClassMerge.merge("text-base-content/60 text-sm mt-1", @options[:class])
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # Yielded inside Form#fields_for for nested attributes. Reuses the shared
5
+ # Builder API (f.field, f.Input, ...) against a nested scope, rendering through
6
+ # the parent form.
7
+ class FieldsForBuilder
8
+ include PhlexForms::Builder
9
+
10
+ attr_reader :model, :scope, :errors, :parent_form
11
+
12
+ def initialize(model:, scope:, errors:, parent_form:)
13
+ @model = model
14
+ @scope = scope
15
+ @errors = errors
16
+ @parent_form = parent_form
17
+ end
18
+
19
+ # The Builder mixin renders through `render`; delegate to the parent form.
20
+ def render(...)
21
+ @parent_form.render(...)
22
+ end
23
+
24
+ def field_object(name)
25
+ Forms::Field.new(name:, model: @model, scope: @scope, errors: @errors, form: @parent_form)
26
+ end
27
+
28
+ # Nested fields_for (single association or has_many collection).
29
+ def fields_for(association_name, model = nil, &)
30
+ return unless block_given?
31
+
32
+ associated = model || (@model.public_send(association_name) if @model.respond_to?(association_name))
33
+ base_scope = "#{@scope}[#{association_name}_attributes]"
34
+
35
+ if associated.respond_to?(:each_with_index)
36
+ associated.each_with_index do |item, index|
37
+ yield build_nested("#{base_scope}[#{index}]", item)
38
+ end
39
+ else
40
+ yield build_nested(base_scope, associated)
41
+ end
42
+ end
43
+
44
+ def field_name(name) = "#{@scope}[#{name}]"
45
+ def field_id(name) = "#{@scope.tr('[', '_').delete(']')}_#{name}"
46
+
47
+ private
48
+
49
+ def build_nested(scope, item)
50
+ self.class.new(
51
+ model: item,
52
+ scope:,
53
+ errors: (item.errors if item.respond_to?(:errors)),
54
+ parent_form: @parent_form
55
+ )
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A model-bound `<input type="file">`, delegating to DaisyUI::FileInput.
5
+ class FileInput < Phlex::HTML
6
+ include PhlexForms::DelegatedField
7
+
8
+ def initialize(*modifiers, name: nil, id: nil, multiple: false, accept: nil,
9
+ error: false, disabled: false, required: false, full_width: true, **attributes)
10
+ @modifiers = normalize_modifiers(modifiers)
11
+ @name = name
12
+ @id = id
13
+ @multiple = multiple
14
+ @accept = accept
15
+ @error = error
16
+ @disabled = disabled
17
+ @required = required
18
+ @full_width = full_width
19
+ @attributes = attributes
20
+ super()
21
+ end
22
+
23
+ def view_template
24
+ attrs = binding_attributes(accept: @accept)
25
+ attrs[:multiple] = true if @multiple
26
+ render DaisyUI::FileInput.new(*daisy_modifiers, **attrs)
27
+ end
28
+ end
29
+ end
data/lib/forms/form.rb ADDED
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The model-bound form. Yields itself as the builder:
4
+ #
5
+ # Form(model: @user) do |f|
6
+ # f.field :email
7
+ # f.field :role, as: :select, choices: roles
8
+ # f.submit
9
+ # end
10
+ #
11
+ # Derives scope/url/method from the model (including the polymorphic array form
12
+ # `model: [parent, child]`), emits the CSRF + method-override hidden fields, and
13
+ # always sets multipart encoding for non-GET forms so file inputs never silently
14
+ # fail to upload.
15
+ module Forms
16
+ class Form < Phlex::HTML
17
+ include Phlex::Rails::Helpers::FormAuthenticityToken if defined?(Phlex::Rails::Helpers::FormAuthenticityToken)
18
+ include PhlexForms::Builder
19
+
20
+ attr_reader :model, :scope, :url, :method, :errors, :validate
21
+
22
+ def initialize(*modifiers, model: nil, scope: nil, url: nil, method: nil, validate: false, **options)
23
+ super()
24
+ @base_modifiers = modifiers
25
+ @options = options
26
+ @model = record_from(model)
27
+ @scope = scope&.to_s || derive_scope(@model)
28
+ @url = url || derive_url(model)
29
+ @method = method || derive_method(@model)
30
+ @errors = (@model.errors if @model.respond_to?(:errors))
31
+ @validate = validate
32
+ end
33
+
34
+ # Introspector for the bound model when client-side validation is enabled, or
35
+ # a no-op Null otherwise. Callers can always call #data_attributes_for(attr).
36
+ def validations_introspector
37
+ @validations_introspector ||=
38
+ if @validate
39
+ Forms::Validations::Introspector.for(@model)
40
+ else
41
+ Forms::Validations::Introspector::Null.new
42
+ end
43
+ end
44
+
45
+ def view_template(&)
46
+ form(action: @url, accept_charset: "UTF-8", method: form_method, **form_attributes) do
47
+ authenticity_token_field unless @method&.to_sym == :get
48
+ method_field if @method && %i[get post].exclude?(@method.to_sym)
49
+ yield self if block_given?
50
+ end
51
+ end
52
+
53
+ # Return a Forms::Field for a name (used by the Builder mixin).
54
+ def field_object(name)
55
+ Forms::Field.new(name:, model: @model, scope: @scope, errors: @errors, form: self)
56
+ end
57
+
58
+ def submit(*, **, &)
59
+ render Forms::Submit.new(*, model: @model, **, &)
60
+ end
61
+
62
+ def rich_textarea(name, *modifiers, **)
63
+ render field_object(name).rich_textarea(*modifiers, **)
64
+ end
65
+ alias rich_text_area rich_textarea
66
+
67
+ def time_zone_select(name, *modifiers, selected: nil, **)
68
+ render Forms::TimeZoneSelect.new(
69
+ *modifiers,
70
+ name: field_name(name),
71
+ id: field_id(name),
72
+ selected: selected || @model&.public_send(name),
73
+ **
74
+ )
75
+ end
76
+
77
+ # Nested attributes. Yields a FieldsForBuilder per association (single) or per
78
+ # item (has_many), with the correctly-indexed nested scope.
79
+ def fields_for(association_name, model = nil, &)
80
+ return unless block_given?
81
+
82
+ associated = model || (@model.public_send(association_name) if @model.respond_to?(association_name))
83
+ attributes_key = "#{association_name}_attributes"
84
+ base_scope = @scope ? "#{@scope}[#{attributes_key}]" : attributes_key
85
+
86
+ if associated.respond_to?(:each_with_index)
87
+ associated.each_with_index do |item, index|
88
+ yield build_fields_for("#{base_scope}[#{index}]", item)
89
+ end
90
+ else
91
+ yield build_fields_for(base_scope, associated)
92
+ end
93
+ end
94
+
95
+ # Rails-style collection_check_boxes. Emits a hidden field so an empty
96
+ # selection still submits, then yields a builder per collection item.
97
+ def collection_check_boxes(name, collection, value_method, text_method, &)
98
+ return unless block_given?
99
+
100
+ input(type: "hidden", name: "#{field_name(name)}[]", value: "")
101
+ current = Array(@model&.public_send(name))
102
+ current_ids = current.map { |v| v.respond_to?(value_method) ? v.public_send(value_method) : v }
103
+
104
+ collection.each do |item|
105
+ item_value = item.public_send(value_method)
106
+ item_text = text_method.is_a?(Proc) ? text_method.call(item) : item.public_send(text_method)
107
+ yield Forms::CollectionCheckBoxBuilder.new(
108
+ object: item, value: item_value, text: item_text,
109
+ checked: current_ids.include?(item_value),
110
+ name: "#{field_name(name)}[]", id: "#{field_id(name)}_#{item_value}"
111
+ )
112
+ end
113
+ end
114
+
115
+ # Rails-style collection_select over an enumerable of records.
116
+ def collection_select(name, collection, value_method, text_method, options = {}, html_options = {})
117
+ choices = collection.map do |item|
118
+ text = text_method.is_a?(Proc) ? text_method.call(item) : item.public_send(text_method)
119
+ [text, item.public_send(value_method)]
120
+ end
121
+ choices = [[options[:prompt], ""]] + choices if options[:prompt]
122
+ render field_object(name).select(choices, **options.except(:prompt), **html_options)
123
+ end
124
+
125
+ # Public name/id/value helpers for external components mirroring the Rails API.
126
+ def field_name(name) = @scope ? "#{@scope}[#{name}]" : name.to_s
127
+ def field_id(name) = @scope ? "#{@scope}_#{name}" : name.to_s
128
+
129
+ private
130
+
131
+ def build_fields_for(scope, item)
132
+ Forms::FieldsForBuilder.new(
133
+ model: item,
134
+ scope:,
135
+ errors: (item.errors if item.respond_to?(:errors)),
136
+ parent_form: self
137
+ )
138
+ end
139
+
140
+ # For a polymorphic array model, the record is the last element.
141
+ def record_from(model)
142
+ model.is_a?(Array) ? model.last : model
143
+ end
144
+
145
+ def form_method
146
+ %i[get post].include?(@method.to_sym) ? @method : :post
147
+ end
148
+
149
+ def form_attributes
150
+ attrs = @options.except(:class, :local, :multipart, :turbo_frame, :id, :data)
151
+ attrs[:id] = @options[:id] if @options[:id]
152
+ if (classes = form_classes)
153
+ attrs[:class] = classes
154
+ end
155
+ attrs[:data] = (@options[:data] || {}).dup
156
+ attrs[:data][:turbo] = "false" if @options[:local] == true
157
+ attrs[:data][:turbo_frame] = @options[:turbo_frame] if @options[:turbo_frame]
158
+ apply_validation_coordinator(attrs) if @validate
159
+ attrs[:data].transform_values! { |v| v == false ? "false" : v }
160
+ attrs[:enctype] = "multipart/form-data" unless @method&.to_sym == :get
161
+ attrs
162
+ end
163
+
164
+ # When client-side validation is on, attach the form-level coordinator
165
+ # controller (submit interceptor) and turn OFF the native browser validation
166
+ # UI (novalidate) — the Stimulus layer owns error display.
167
+ def apply_validation_coordinator(attrs)
168
+ existing = attrs[:data][:controller].to_s
169
+ coordinator = "forms--validations--form"
170
+ attrs[:data][:controller] = [existing, coordinator].reject(&:empty?).join(" ")
171
+ attrs[:novalidate] = true
172
+ end
173
+
174
+ def form_classes
175
+ classes = []
176
+ classes << "space-y-4" if @base_modifiers.include?(:spaced)
177
+ classes << "space-y-6" if @base_modifiers.include?(:spacious)
178
+ PhlexForms::ClassMerge.merge(classes.join(" "), @options[:class]).presence
179
+ end
180
+
181
+ def authenticity_token_field
182
+ return unless respond_to?(:form_authenticity_token)
183
+
184
+ token = form_authenticity_token
185
+ input(type: "hidden", name: "authenticity_token", value: token) if token
186
+ end
187
+
188
+ def method_field
189
+ input(type: "hidden", name: "_method", value: @method)
190
+ end
191
+
192
+ def derive_scope(record)
193
+ return nil unless record
194
+
195
+ if record.respond_to?(:model_name)
196
+ record.model_name.param_key
197
+ elsif record.is_a?(Symbol) || record.is_a?(String)
198
+ record.to_s
199
+ else
200
+ record.class.name.underscore.tr("/", "_")
201
+ end
202
+ end
203
+
204
+ # For an array model, build the url from the whole array (polymorphic nesting);
205
+ # otherwise from the record.
206
+ def derive_url(model)
207
+ return "/" unless model
208
+
209
+ record = record_from(model)
210
+ persisted = record.respond_to?(:persisted?) && record.persisted?
211
+ prefix = url_prefix(model, record)
212
+ persisted ? "#{prefix}/#{record.to_param}" : prefix
213
+ end
214
+
215
+ def url_prefix(model, record)
216
+ parents = model.is_a?(Array) ? model[0...-1] : []
217
+ segments = parents.map { |p| "/#{p.class.name.underscore.pluralize}/#{p.to_param}" }
218
+ "#{segments.join}/#{derive_scope(record).pluralize}"
219
+ end
220
+
221
+ def derive_method(record)
222
+ record.respond_to?(:persisted?) && record.persisted? ? :patch : :post
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # Wraps a field in the daisyui form-control layout: optional label on top, the
5
+ # field (yielded block), then an error (if present) or a hint. This is the
6
+ # workhorse behind the Control-first `f.field` API and the explicit `f.Control`
7
+ # escape hatch.
8
+ class FormControl < Phlex::HTML
9
+ def initialize(*modifiers, label: nil, hint: nil, error: nil, for: nil, required: false, **options)
10
+ @modifiers = modifiers
11
+ @label = label
12
+ @hint = hint
13
+ @error = error
14
+ @field_id = grab(for:)
15
+ @required = required
16
+ @options = options
17
+ super()
18
+ end
19
+
20
+ def view_template(&)
21
+ div(class: control_classes, **@options.except(:class)) do
22
+ render Forms::Label.new(text: @label, for: @field_id, required: @required) if @label
23
+
24
+ yield if block_given?
25
+
26
+ if @error
27
+ render Forms::FieldError.new(message: @error)
28
+ elsif @hint
29
+ render Forms::FieldHint.new(text: @hint)
30
+ end
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def control_classes
37
+ base = %w[form-control w-full]
38
+ base << "flex flex-row items-center gap-4" if @modifiers.include?(:horizontal)
39
+ PhlexForms::ClassMerge.merge(base.join(" "), @options[:class])
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A model-bound text-like input. Thin form-binding layer over DaisyUI::Input:
5
+ # it wires name/id/value/error/required onto the field, then delegates all
6
+ # markup and variant handling to the daisyui gem's component (which stacks
7
+ # positional color/size/style modifiers and passes a block straight through).
8
+ #
9
+ # Forms::Input.new(:primary, :lg, type: "email", name: "user[email]")
10
+ #
11
+ # `w-full` is added by default. For the daisyui v5 "icon/text inside the field"
12
+ # pattern (a <label class="input"> wrapper), use Forms::WrappedInput.
13
+ class Input < Phlex::HTML
14
+ include PhlexForms::DelegatedField
15
+
16
+ def initialize(*modifiers, type: "text", name: nil, id: nil, value: nil,
17
+ error: false, disabled: false, required: false, full_width: true, **attributes)
18
+ @modifiers = normalize_modifiers(modifiers)
19
+ @type = type
20
+ @name = name
21
+ @id = id
22
+ @value = value
23
+ @error = error
24
+ @disabled = disabled
25
+ @required = required
26
+ @full_width = full_width
27
+ @attributes = attributes
28
+ super()
29
+ end
30
+
31
+ def view_template(&)
32
+ render DaisyUI::Input.new(*daisy_modifiers, type: @type, value: @value.to_s, **binding_attributes, &)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A standalone form label: `<label for><span class="label">text</span></label>`
5
+ # with an optional required marker. A block, when given, is yielded directly
6
+ # inside the `<label>` so checkboxes/toggles can nest as direct children.
7
+ #
8
+ # For the daisyui v5 "text/icon inside the field" wrapper (<label class="input">
9
+ # {span.label}{input}), use Forms::WrappedInput instead.
10
+ class Label < Phlex::HTML
11
+ def initialize(text: nil, for: nil, required: false, **attributes)
12
+ @text = text
13
+ @for = grab(for:)
14
+ @required = required
15
+ @attributes = attributes
16
+ super()
17
+ end
18
+
19
+ def view_template(&block)
20
+ label(for: @for, class: @attributes[:class], **@attributes.except(:class)) do
21
+ if block
22
+ yield
23
+ elsif @text
24
+ span(class: "label") do
25
+ plain @text
26
+ span(class: "text-error ml-1") { "*" } if @required
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end