phlex-forms 0.1.0 → 0.2.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.
data/lib/forms/field.rb CHANGED
@@ -4,13 +4,21 @@
4
4
  # error set. Builds the concrete leaf components (input/textarea/select/...) with
5
5
  # name/id/value/error wired in, and derives the label text and required flag.
6
6
  #
7
- # Not usually built directly — `Form#field(:name)` / the builder return one.
7
+ # Not usually built directly — `Form#field_object(:name)` / the builder return one.
8
8
  module Forms
9
9
  class Field
10
+ # Data keys whose values are Stimulus token lists — merged by joining, not
11
+ # replacing, so validation controllers and live triggers coexist.
12
+ TOKEN_JOINED_DATA_KEYS = %i[controller action].freeze
13
+
10
14
  attr_reader :name, :model, :scope, :errors
11
15
 
12
- def initialize(name:, model:, scope:, errors:, form:)
16
+ # error_name: where errors live when it differs from the input's name — an
17
+ # inferred belongs_to select is named :country_id but Rails attaches its
18
+ # errors to :country.
19
+ def initialize(name:, model:, scope:, errors:, form:, error_name: nil)
13
20
  @name = name
21
+ @error_name = error_name || name
14
22
  @model = model
15
23
  @scope = scope
16
24
  @errors = errors
@@ -18,46 +26,48 @@ module Forms
18
26
  end
19
27
 
20
28
  # --- leaf component builders (return component instances to render) ---
29
+ # Component classes resolve through the form's theme (see
30
+ # PhlexForms::Theme), so the same field renders daisy or plain.
21
31
 
22
32
  def input(*modifiers, type: :text, **)
23
- Forms::Input.new(*modifiers, type:, **field_attributes, **)
33
+ theme[:input].new(*modifiers, type:, **field_attributes, **)
24
34
  end
25
35
 
26
36
  def textarea(*modifiers, **)
27
- Forms::Textarea.new(*modifiers, **field_attributes, **)
37
+ theme[:textarea].new(*modifiers, **field_attributes, **)
28
38
  end
29
39
  alias text_area textarea
30
40
 
31
41
  def rich_textarea(*modifiers, **)
32
- Forms::RichTextarea.new(*modifiers, name: field_name, id: field_id, value: field_value, **)
42
+ theme[:rich_textarea].new(*modifiers, name: field_name, id: field_id, value: field_value, **)
33
43
  end
34
44
  alias rich_text_area rich_textarea
35
45
 
36
46
  def hidden(**)
37
- Forms::Input.new(type: :hidden, **field_attributes, **)
47
+ theme[:input].new(type: :hidden, **field_attributes, **)
38
48
  end
39
49
 
40
50
  # daisyui v5 "icon/text inside the field" wrapper. The block renders the
41
51
  # leading content (icon, prefix); the bare input is wired to this field.
42
52
  def wrapped_input(*modifiers, type: :text, **, &)
43
- Forms::WrappedInput.new(*modifiers, type:, **field_attributes.except(:error), error: invalid?, **, &)
53
+ theme[:wrapped_input].new(*modifiers, type:, **field_attributes.except(:error), error: invalid?, **, &)
44
54
  end
45
55
 
46
56
  def file(*modifiers, **)
47
- Forms::FileInput.new(*modifiers, **field_attributes.except(:value), **)
57
+ theme[:file].new(*modifiers, **field_attributes.except(:value), **)
48
58
  end
49
59
 
50
60
  def checkbox(*modifiers, **)
51
- Forms::Checkbox.new(*modifiers, checked: field_value, **field_attributes.except(:value), **)
61
+ theme[:checkbox].new(*modifiers, checked: field_value, **field_attributes.except(:value), **)
52
62
  end
53
63
  alias check_box checkbox
54
64
 
55
65
  def toggle(*modifiers, **)
56
- Forms::Toggle.new(*modifiers, checked: field_value, **field_attributes.except(:value), **)
66
+ theme[:toggle].new(*modifiers, checked: field_value, **field_attributes.except(:value), **)
57
67
  end
58
68
 
59
69
  def radio(value, *modifiers, **options)
60
- Forms::Radio.new(
70
+ theme[:radio].new(
61
71
  *modifiers,
62
72
  value:,
63
73
  checked: field_value == value,
@@ -67,7 +77,7 @@ module Forms
67
77
  alias radio_button radio
68
78
 
69
79
  def select(choices = nil, **options)
70
- Forms::Select.new(choices:, selected: field_value, **select_options(options))
80
+ theme[:select].new(choices:, selected: field_value, **select_options(options))
71
81
  end
72
82
 
73
83
  # Enhanced select: choices.js-backed when searchable, native otherwise.
@@ -75,18 +85,18 @@ module Forms
75
85
  searchable = options.delete(:searchable) { false }
76
86
  opts = select_options(options)
77
87
  if searchable
78
- Forms::ChoicesSelect.new(*modifiers, choices:, selected: field_value, searchable: true, **opts)
88
+ theme[:choices_select].new(*modifiers, choices:, selected: field_value, searchable: true, **opts)
79
89
  else
80
- Forms::Select.new(*modifiers, choices:, selected: field_value, **opts)
90
+ theme[:select].new(*modifiers, choices:, selected: field_value, **opts)
81
91
  end
82
92
  end
83
93
 
84
94
  def label(text = nil, *modifiers, **, &block)
85
- Forms::Label.new(*modifiers, text: text || (block ? nil : field_label), for: field_id, **, &block)
95
+ theme[:label].new(*modifiers, text: text || (block ? nil : field_label), for: field_id, **, &block)
86
96
  end
87
97
 
88
98
  def control(label: nil, hint: nil, required: false, **, &)
89
- Forms::FormControl.new(label:, hint:, error: field_error_message, for: field_id, required:, **, &)
99
+ theme[:control].new(label:, hint:, error: field_error_message, for: field_id, required:, **, &)
90
100
  end
91
101
 
92
102
  # --- derived metadata ---
@@ -113,7 +123,9 @@ module Forms
113
123
  end
114
124
 
115
125
  def invalid?
116
- @errors&.include?(@name)
126
+ return false unless @errors
127
+
128
+ @errors.include?(@name) || @errors.include?(@error_name)
117
129
  end
118
130
 
119
131
  def field_name
@@ -160,6 +172,10 @@ module Forms
160
172
 
161
173
  private
162
174
 
175
+ def theme
176
+ @theme ||= @form.respond_to?(:theme) ? @form.theme : PhlexForms::Theme.resolve(nil)
177
+ end
178
+
163
179
  def consume_validate(options)
164
180
  return options unless options.key?(:validate)
165
181
 
@@ -189,10 +205,10 @@ module Forms
189
205
  def merge_data(existing, additions)
190
206
  existing = (existing || {}).dup
191
207
  additions.each do |key, value|
192
- if key == :controller
193
- existing[:controller] = [existing[:controller], value].compact.reject { |s| s.to_s.empty? }.join(" ")
208
+ existing[key] = if TOKEN_JOINED_DATA_KEYS.include?(key)
209
+ [existing[key], value].compact.reject { |s| s.to_s.empty? }.join(" ")
194
210
  else
195
- existing[key] = value
211
+ value
196
212
  end
197
213
  end
198
214
  existing
@@ -207,7 +223,9 @@ module Forms
207
223
  end
208
224
 
209
225
  def field_error_message
210
- @errors&.full_messages_for(@name)&.first if invalid?
226
+ return nil unless invalid?
227
+
228
+ @errors.full_messages_for(@name).first || @errors.full_messages_for(@error_name).first
211
229
  end
212
230
 
213
231
  def select_options(options)
@@ -21,16 +21,26 @@ module Forms
21
21
  @parent_form.render(...)
22
22
  end
23
23
 
24
- def field_object(name)
25
- Forms::Field.new(name:, model: @model, scope: @scope, errors: @errors, form: @parent_form)
24
+ def field_object(name, error_name: nil)
25
+ Forms::Field.new(name:, model: @model, scope: @scope, errors: @errors, form: @parent_form, error_name:)
26
+ end
27
+
28
+ def default_field_variants
29
+ @parent_form.default_field_variants
30
+ end
31
+
32
+ def theme
33
+ @parent_form.theme
26
34
  end
27
35
 
28
36
  # Nested fields_for (single association or has_many collection).
29
- def fields_for(association_name, model = nil, &)
37
+ # nested_attributes: false nests under the raw name (JSONB/hash columns).
38
+ def fields_for(association_name, model = nil, nested_attributes: true, &)
30
39
  return unless block_given?
31
40
 
32
41
  associated = model || (@model.public_send(association_name) if @model.respond_to?(association_name))
33
- base_scope = "#{@scope}[#{association_name}_attributes]"
42
+ attributes_key = nested_attributes ? "#{association_name}_attributes" : association_name.to_s
43
+ base_scope = "#{@scope}[#{attributes_key}]"
34
44
 
35
45
  if associated.respond_to?(:each_with_index)
36
46
  associated.each_with_index do |item, index|
@@ -41,8 +51,9 @@ module Forms
41
51
  end
42
52
  end
43
53
 
44
- def field_name(name) = "#{@scope}[#{name}]"
45
- def field_id(name) = "#{@scope.tr('[', '_').delete(']')}_#{name}"
54
+ def field_name(name) = "#{@scope}[#{name}]"
55
+ def field_id(name) = "#{@scope.tr('[', '_').delete(']')}_#{name}"
56
+ def field_value(name) = field_object(name).field_value
46
57
 
47
58
  private
48
59
 
data/lib/forms/form.rb CHANGED
@@ -17,14 +17,26 @@ module Forms
17
17
  include Phlex::Rails::Helpers::FormAuthenticityToken if defined?(Phlex::Rails::Helpers::FormAuthenticityToken)
18
18
  include PhlexForms::Builder
19
19
 
20
- attr_reader :model, :scope, :url, :method, :errors, :validate
20
+ attr_reader :model, :scope, :url, :method, :errors, :validate, :theme
21
+
22
+ def initialize(*modifiers, model: nil, scope: nil, url: nil, method: nil, validate: false,
23
+ field_variants: nil, theme: nil, live: nil, **options)
24
+ if live
25
+ raise ArgumentError,
26
+ "live validation requires a Forms::Base subclass — the endpoint rebuilds the " \
27
+ "form from its class, and an inline block cannot be serialized. Declare " \
28
+ "`live model: #{record_from(model)&.class&.name || 'YourModel'}` on the class instead."
29
+ end
21
30
 
22
- def initialize(*modifiers, model: nil, scope: nil, url: nil, method: nil, validate: false, **options)
23
31
  super()
24
32
  @base_modifiers = modifiers
33
+ @field_variants = Array(field_variants)
34
+ @theme = PhlexForms::Theme.resolve(theme)
25
35
  @options = options
26
36
  @model = record_from(model)
27
- @scope = scope&.to_s || derive_scope(@model)
37
+ # scope: false opts out of scoping entirely — bare field names for
38
+ # reactive row editors / <template>-cloned rows. nil means "derive".
39
+ @scope = scope == false ? nil : (scope&.to_s || derive_scope(@model))
28
40
  @url = url || derive_url(model)
29
41
  @method = method || derive_method(@model)
30
42
  @errors = (@model.errors if @model.respond_to?(:errors))
@@ -51,12 +63,12 @@ module Forms
51
63
  end
52
64
 
53
65
  # 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)
66
+ def field_object(name, error_name: nil)
67
+ Forms::Field.new(name:, model: @model, scope: @scope, errors: @errors, form: self, error_name:)
56
68
  end
57
69
 
58
70
  def submit(*, **, &)
59
- render Forms::Submit.new(*, model: @model, **, &)
71
+ render theme[:submit].new(*, model: @model, **, &)
60
72
  end
61
73
 
62
74
  def rich_textarea(name, *modifiers, **)
@@ -76,11 +88,13 @@ module Forms
76
88
 
77
89
  # Nested attributes. Yields a FieldsForBuilder per association (single) or per
78
90
  # item (has_many), with the correctly-indexed nested scope.
79
- def fields_for(association_name, model = nil, &)
91
+ # nested_attributes: false nests under the raw name (no `_attributes` suffix)
92
+ # for JSONB/hash columns that aren't Rails nested attributes.
93
+ def fields_for(association_name, model = nil, nested_attributes: true, &)
80
94
  return unless block_given?
81
95
 
82
96
  associated = model || (@model.public_send(association_name) if @model.respond_to?(association_name))
83
- attributes_key = "#{association_name}_attributes"
97
+ attributes_key = nested_attributes ? "#{association_name}_attributes" : association_name.to_s
84
98
  base_scope = @scope ? "#{@scope}[#{attributes_key}]" : attributes_key
85
99
 
86
100
  if associated.respond_to?(:each_with_index)
@@ -122,9 +136,16 @@ module Forms
122
136
  render field_object(name).select(choices, **options.except(:prompt), **html_options)
123
137
  end
124
138
 
139
+ # Default variants prepended to every `field`'s inner input: global config
140
+ # first, then this form's field_variants: (call-site modifiers stack last).
141
+ def default_field_variants
142
+ PhlexForms.config.field_variants + @field_variants
143
+ end
144
+
125
145
  # 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
146
+ def field_name(name) = @scope ? "#{@scope}[#{name}]" : name.to_s
147
+ def field_id(name) = @scope ? "#{@scope}_#{name}" : name.to_s
148
+ def field_value(name) = field_object(name).field_value
128
149
 
129
150
  private
130
151
 
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A daisyui fieldset with an optional legend, for sectioning related fields.
5
+ # Exposed as `f.group` (not `section`/`fieldset` — those are Phlex::HTML
6
+ # element methods and defining them would shadow the elements inside forms).
7
+ #
8
+ # f.group(legend: "Address") { f.field :street; f.field :city }
9
+ class Group < Phlex::HTML
10
+ def initialize(legend: nil, **options)
11
+ @legend = legend
12
+ @options = options
13
+ super()
14
+ end
15
+
16
+ def view_template
17
+ render DaisyUI::Fieldset.new(**@options) do |fs|
18
+ fs.legend { @legend } if @legend
19
+ yield if block_given?
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Live
5
+ # A Forms::Field that merges the blur-time validate trigger into every
6
+ # input's data attributes (via apply_validations, the seam every builder
7
+ # already routes options through).
8
+ class Field < Forms::Field
9
+ def initialize(live_trigger: nil, **)
10
+ super(**)
11
+ @live_trigger = live_trigger
12
+ end
13
+
14
+ def apply_validations(options)
15
+ merged = super
16
+ return merged unless @live_trigger
17
+
18
+ merged.merge(data: merge_data(merged[:data], @live_trigger[:data]))
19
+ end
20
+ end
21
+ end
22
+ end
data/lib/forms/live.rb ADDED
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # Server-truth live validation via phlex-reactive: the whole form is one
5
+ # reactive component. Each input's blur (and a debounced form-wide input
6
+ # trigger) POSTs every field to a single :validate action, which assigns a
7
+ # whitelisted slice to the model, runs the REAL ActiveModel validators —
8
+ # uniqueness, :if/:unless, confirmation, :on context all work — and replies
9
+ # with a focus-preserving morph. Nothing is ever persisted.
10
+ #
11
+ # Enabled through the Forms::Base macro (inline forms cannot be live — the
12
+ # endpoint rebuilds the component from its class, and a caller-supplied
13
+ # block cannot be serialized):
14
+ #
15
+ # class UserForm < Forms::Base
16
+ # live model: User, debounce: 300
17
+ # def fields
18
+ # field :email
19
+ # field :password
20
+ # field :password_confirmation
21
+ # end
22
+ # end
23
+ #
24
+ # Identity is STATE-backed (not reactive_record): the token signs the
25
+ # model's GlobalID when persisted (nil for new records — reactive_record
26
+ # cannot round-trip an unsaved draft) plus the `touched` field list, so
27
+ # error display state survives every round trip tamper-proof, with zero
28
+ # client-side bookkeeping.
29
+ module Live
30
+ extend ActiveSupport::Concern
31
+ include Phlex::Reactive::Component
32
+
33
+ # Registers the raw-hash param type the :validate schema is declared with.
34
+ # Called from the engine initializer (the registry freezes after boot);
35
+ # test suites call it from their spec helper.
36
+ def self.register_param_type!
37
+ return if Phlex::Reactive.param_type?(:form_attributes)
38
+
39
+ Phlex::Reactive.param_type(:form_attributes) do |value|
40
+ value.is_a?(Hash) ? value : Phlex::Reactive::ParamSchema::DROP
41
+ end
42
+ end
43
+
44
+ class_methods do
45
+ def setup_live(model_class:, scope:, debounce:)
46
+ @live_model_class = model_class
47
+ @live_scope = scope.to_s
48
+ @live_debounce = debounce
49
+ reactive_state :model_gid, :touched
50
+ action :validate, params: { scope.to_sym => :form_attributes, _touch: :string }
51
+ # phlex-reactive >= 0.11 defaults verify_authorized ON: an action that
52
+ # completes without an authorization call raises AuthorizationNotVerified
53
+ # and rolls back (#168). :validate is a deliberate no-persist, read-only
54
+ # pass — it assigns, validates, and discards; native submit stays
55
+ # authoritative — so it needs no authorization. Guarded so it's a no-op
56
+ # on older phlex-reactive that lacks the macro.
57
+ skip_verify_authorized :validate if respond_to?(:skip_verify_authorized)
58
+ end
59
+
60
+ def live_model_class = @live_model_class || inherited_live(:live_model_class)
61
+ def live_scope = @live_scope || inherited_live(:live_scope)
62
+ def live_debounce = @live_debounce || inherited_live(:live_debounce)
63
+
64
+ # Narrow (live_permit) or trim (live_deny) the attributes the :validate
65
+ # action may assign. Default: the model's column/attribute names plus
66
+ # every validated attribute (and its _confirmation twin).
67
+ def live_permit(*attrs) = @live_permit = attrs.map(&:to_s)
68
+ def live_deny(*attrs) = @live_deny = attrs.map(&:to_s)
69
+
70
+ def live_permitted_attributes(model)
71
+ permitted = @live_permit || derived_live_attributes(model)
72
+ permitted - (@live_deny || [])
73
+ end
74
+
75
+ private
76
+
77
+ def inherited_live(reader)
78
+ superclass.respond_to?(reader) ? superclass.public_send(reader) : nil
79
+ end
80
+
81
+ def derived_live_attributes(model)
82
+ klass = model.class
83
+ names = []
84
+ names.concat(klass.attribute_names.map(&:to_s)) if klass.respond_to?(:attribute_names)
85
+ if klass.respond_to?(:validators)
86
+ klass.validators.each do |validator|
87
+ validator.attributes.each do |attr|
88
+ names << attr.to_s
89
+ names << "#{attr}_confirmation" if validator.is_a?(ActiveModel::Validations::ConfirmationValidator)
90
+ end
91
+ end
92
+ end
93
+ names.uniq
94
+ end
95
+ end
96
+
97
+ # Two construction modes: the app renders `new(model:)`; the endpoint
98
+ # rebuilds `new(model_gid:, touched:)` from the signed state (see
99
+ # Component::DSL.from_identity — only declared state keys round-trip).
100
+ def initialize(*modifiers, model: nil, model_gid: nil, touched: nil, **)
101
+ model ||= locate_live_model(model_gid)
102
+ @touched = Array(touched).map(&:to_s)
103
+ super(*modifiers, model:, **)
104
+ @model_gid = (@model.to_gid.to_s if signed_model_gid?)
105
+ # A form re-rendered after a failed submit (classic 422) arrives with
106
+ # errors already on the model — surface them without requiring touches.
107
+ @touched |= @errors.attribute_names.map(&:to_s) if @errors.respond_to?(:attribute_names)
108
+ end
109
+
110
+ # Streamable's #id contract: the reactive root's stable DOM id.
111
+ def id
112
+ @options[:id] || "#{@model.respond_to?(:persisted?) && @model.persisted? ? 'edit' : 'new'}_#{@scope}"
113
+ end
114
+
115
+ # Assign → validate → morph. The reply preserves the focused input and its
116
+ # caret (Idiomorph); the fresh token re-signs the updated touched set.
117
+ # `_touch` is the wire key (underscored so it can never collide with a
118
+ # model attribute) — the schema splats it back as this keyword.
119
+ def validate(_touch: nil, **posted) # rubocop:disable Lint/UnderscorePrefixedVariableName
120
+ @touched |= [_touch.to_s] if _touch
121
+ assign_live_attributes(posted[self.class.live_scope.to_sym])
122
+ @model.validate
123
+ reply.morph
124
+ end
125
+
126
+ # The <form> element is the reactive root: id + controller + signed token,
127
+ # plus the debounced whole-form input trigger (input events bubble; blur
128
+ # triggers live per-input via Live::Field because Stimulus params are
129
+ # per-element).
130
+ def form_attributes
131
+ attrs = super
132
+ mix(
133
+ attrs,
134
+ reactive_root(id: attrs[:id] || id),
135
+ on(:validate, event: "input", debounce: self.class.live_debounce)
136
+ )
137
+ end
138
+
139
+ # Untouched fields get no error set, so nothing flashes before the user
140
+ # leaves a field; each input carries its blur _touch trigger.
141
+ def field_object(name, error_name: nil)
142
+ show_errors = touched?(name) || touched?(error_name)
143
+ Forms::Live::Field.new(
144
+ name:, model: @model, scope: @scope, form: self, error_name:,
145
+ errors: (show_errors ? @errors : nil),
146
+ live_trigger: on(:validate, event: "blur", _touch: name.to_s)
147
+ )
148
+ end
149
+
150
+ def touched?(name)
151
+ name && @touched.include?(name.to_s)
152
+ end
153
+
154
+ def locate_live_model(model_gid)
155
+ return GlobalID::Locator.locate(model_gid) if model_gid.present?
156
+
157
+ self.class.live_model_class.new
158
+ end
159
+
160
+ def signed_model_gid?
161
+ @model.respond_to?(:to_gid) && @model.respond_to?(:persisted?) && @model.persisted?
162
+ end
163
+
164
+ # Whitelisted assignment through public writers. Values are validated and
165
+ # rendered, never saved; use live_permit/live_deny to adjust the surface.
166
+ def assign_live_attributes(attrs)
167
+ return if attrs.blank?
168
+
169
+ permitted = self.class.live_permitted_attributes(@model)
170
+ attrs.each do |key, value|
171
+ key = key.to_s
172
+ next unless permitted.include?(key)
173
+
174
+ setter = :"#{key}="
175
+ @model.public_send(setter, value) if @model.respond_to?(setter)
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Bare checkbox, keeping the hidden unchecked-value field (that's binding
6
+ # logic, not styling). Also fills the :toggle theme role — a toggle is a
7
+ # styled checkbox.
8
+ class Checkbox < Forms::Checkbox
9
+ def view_template
10
+ input(type: "hidden", name: @name, value: @unchecked_value) if @unchecked_value && @name
11
+ attrs = unstyled_attributes
12
+ attrs[:checked] = true if @checked
13
+ input(type: "checkbox", value: @value, **attrs)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # The label + field + error/hint wrapper as a bare <div>, preserving
6
+ # FormControl's ordering contract.
7
+ class Control < Forms::FormControl
8
+ def view_template
9
+ div(**@options.except(:class), class: @options[:class]) do
10
+ render Label.new(text: @label, for: @field_id, required: @required) if @label
11
+
12
+ yield if block_given?
13
+
14
+ if @error
15
+ render FieldError.new(message: @error)
16
+ elsif @hint
17
+ render FieldHint.new(text: @hint)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Inline validation error: no styling classes, stable hooks only
6
+ # (role="alert" + data-field-error).
7
+ class FieldError < Forms::FieldError
8
+ def view_template
9
+ return unless @message
10
+
11
+ p(role: "alert", data: { field_error: true }, **@options) { @message }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Inline hint text with a data-field-hint hook, no styling classes.
6
+ class FieldHint < Forms::FieldHint
7
+ def view_template
8
+ return unless @text
9
+
10
+ p(data: { field_hint: true }, **@options) { @text }
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Bare <input type="file">.
6
+ class FileInput < Forms::FileInput
7
+ def view_template
8
+ attrs = unstyled_attributes(accept: @accept)
9
+ attrs[:multiple] = true if @multiple
10
+ input(type: "file", **attrs)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Semantic <fieldset>/<legend> without daisyui classes.
6
+ class Group < Forms::Group
7
+ def view_template
8
+ fieldset(**@options.except(:class), class: @options[:class]) do
9
+ legend { @legend } if @legend
10
+ yield if block_given?
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Bare semantic <input>. Inherits the binding contract from Forms::Input;
6
+ # positional variants are accepted and ignored.
7
+ class Input < Forms::Input
8
+ def view_template
9
+ input(type: @type, value: @value.to_s, **unstyled_attributes)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Bare <label>, with the required marker as a semantic <abbr>.
6
+ class Label < Forms::Label
7
+ def view_template(&block)
8
+ label(for: @for, **@attributes) do
9
+ if block
10
+ yield
11
+ elsif @text
12
+ plain @text
13
+ abbr(title: "required") { "*" } if @required
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Bare radio input.
6
+ class Radio < Forms::Radio
7
+ def view_template
8
+ attrs = unstyled_attributes
9
+ attrs[:checked] = true if @checked
10
+ input(type: "radio", value: @value, **attrs)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Row without grid classes: a bare <div> with a data hook so host CSS can
6
+ # lay it out (columns: is accepted and ignored).
7
+ class Row < Forms::Row
8
+ def view_template(&)
9
+ div(data: { form_row: true }, class: @options[:class], **@options.except(:class), &)
10
+ end
11
+ end
12
+ end
13
+ end