superform 0.5.1 → 0.6.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.
@@ -10,6 +10,7 @@ module Superform
10
10
  @object = object
11
11
  @value = value
12
12
  @dom = Superform::DOM.new(field: self)
13
+ yield self if block_given?
13
14
  end
14
15
 
15
16
  def value
@@ -35,5 +36,86 @@ module Superform
35
36
  def collection(&)
36
37
  @collection ||= FieldCollection.new(field: self, &)
37
38
  end
39
+
40
+ # Make the name more obvious for extending or writing docs.
41
+ def field
42
+ self
43
+ end
44
+
45
+ # High-performance Kit proxy that wraps field methods with form.render calls.
46
+ # Uses Ruby class hooks to define methods at the class level for maximum speed:
47
+ # - Methods are defined once per Field class, not per Kit instance
48
+ # - True Ruby methods with full VM optimization (no method_missing overhead)
49
+ # - ~125x faster Kit instantiation compared to instance-level dynamic methods
50
+ # - Each Field subclass gets its own isolated Kit class with true isolation:
51
+ # * Methods are copied at subclass creation time, not inherited dynamically
52
+ # * Adding methods to a parent Field class won't affect existing subclass Kits
53
+ # * Perfect for library design where you don't want parent changes affecting subclasses
54
+ class Kit
55
+ def initialize(field:, form:)
56
+ @field = field
57
+ @form = form
58
+ end
59
+ end
60
+
61
+ def self.inherited(subclass)
62
+ super
63
+ # Create a new Kit class for each Field subclass with true isolation
64
+ # Copy methods from parent Field classes at creation time, not through inheritance
65
+ subclass.const_set(:Kit, Class.new(Field::Kit))
66
+
67
+ # Copy all existing methods from the inheritance chain
68
+ field_class = self
69
+ while field_class != Field
70
+ copy_field_methods_to_kit(field_class, subclass::Kit)
71
+ field_class = field_class.superclass
72
+ end
73
+ end
74
+
75
+ def self.method_added(method_name)
76
+ super
77
+ # Skip if this is the base Field class or if we don't have a Kit class yet
78
+ return if self == Field
79
+ return unless const_defined?(:Kit, false)
80
+
81
+ # Only add method to THIS class's Kit, not subclasses (isolation)
82
+ add_method_to_kit(method_name, self::Kit)
83
+ end
84
+
85
+ def kit(form)
86
+ self.class::Kit.new(field: self, form: form)
87
+ end
88
+
89
+ private
90
+
91
+ def self.copy_field_methods_to_kit(field_class, kit_class)
92
+ base_methods = (Object.instance_methods + Node.instance_methods +
93
+ [:dom, :value, :serialize, :assign, :collection, :field, :kit]).to_set
94
+
95
+ field_class.instance_methods(false).each do |method_name|
96
+ next if method_name.to_s.end_with?('=')
97
+ next if base_methods.include?(method_name)
98
+ next if kit_class.method_defined?(method_name)
99
+
100
+ kit_class.define_method(method_name) do |*args, **kwargs, &block|
101
+ result = @field.send(method_name, *args, **kwargs, &block)
102
+ @form.render result
103
+ end
104
+ end
105
+ end
106
+
107
+ def self.add_method_to_kit(method_name, kit_class)
108
+ return if method_name.to_s.end_with?('=')
109
+
110
+ base_methods = (Object.instance_methods + Node.instance_methods +
111
+ [:dom, :value, :serialize, :assign, :collection, :field, :kit]).to_set
112
+ return if base_methods.include?(method_name)
113
+ return if kit_class.method_defined?(method_name)
114
+
115
+ kit_class.define_method(method_name) do |*args, **kwargs, &block|
116
+ result = @field.send(method_name, *args, **kwargs, &block)
117
+ @form.render result
118
+ end
119
+ end
38
120
  end
39
121
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phlex"
4
+
5
+ module Superform
6
+ # A basic form component that inherits from Phlex::HTML and wraps content in a form tag.
7
+ # This provides a simple foundation for building forms without Rails dependencies.
8
+ #
9
+ # Example usage:
10
+ # class MyForm < Superform::Form
11
+ # def view_template
12
+ # div { "Form content goes here" }
13
+ # end
14
+ # end
15
+ #
16
+ # form = MyForm.new(action: "/users", method: :post)
17
+ # form.call # renders <form action="/users" method="post">...</form>
18
+ class Form < Phlex::HTML
19
+ def initialize(action: nil, method: :post, **attributes)
20
+ @action = action
21
+ @method = method
22
+ @attributes = attributes
23
+ super()
24
+ end
25
+
26
+ def around_template(&block)
27
+ form(action: @action, method: @method, **@attributes, &block)
28
+ end
29
+
30
+ def build_field(key, parent:, object: nil, &block)
31
+ Field.new(key, parent: parent, object: object, &block)
32
+ end
33
+ end
34
+ end
@@ -9,12 +9,12 @@ module Superform
9
9
  class Namespace < Node
10
10
  include Enumerable
11
11
 
12
- attr_reader :object
12
+ attr_reader :object, :form
13
13
 
14
- def initialize(key, parent:, object: nil, field_class: Field)
15
- super(key, parent: parent)
14
+ def initialize(key, parent:, object: nil, form: Superform::Form.new)
15
+ super(key, parent:)
16
16
  @object = object
17
- @field_class = field_class
17
+ @form = form
18
18
  @children = Hash.new
19
19
  yield self if block_given?
20
20
  end
@@ -33,8 +33,8 @@ module Superform
33
33
  # end
34
34
  # end
35
35
  # ```
36
- def namespace(key, &block)
37
- create_child(key, self.class, object: object_for(key: key), &block)
36
+ def namespace(key, &)
37
+ @children[key] ||= build_namespace(key, &)
38
38
  end
39
39
 
40
40
  # Maps the `Object#proprety` and `Object#property=` to a field in a web form that can be
@@ -46,12 +46,13 @@ module Superform
46
46
  # form.field :name
47
47
  # end
48
48
  # ```
49
- def field(key)
50
- create_child(key, @field_class, object: object).tap do |field|
51
- yield field if block_given?
52
- end
49
+ def field(key, &)
50
+ @children[key] ||= build_field(key, &)
53
51
  end
54
52
 
53
+ def Field(...)
54
+ field(...).kit(@form)
55
+ end
55
56
  # Wraps an array of objects in Namespace classes. For example, if `User#addresses` returns
56
57
  # an enumerable or array of `Address` classes:
57
58
  #
@@ -69,7 +70,7 @@ module Superform
69
70
  # The object within the block is a `Namespace` object that maps each object within the enumerable
70
71
  # to another `Namespace` or `Field`.
71
72
  def collection(key, &)
72
- create_child(key, NamespaceCollection, &)
73
+ @children[key] ||= build_collection(key, &)
73
74
  end
74
75
 
75
76
  # Creates a Hash of Hashes and Arrays that represent the fields and collections of the Superform.
@@ -115,10 +116,19 @@ module Superform
115
116
 
116
117
  private
117
118
 
118
- # Checks if the child exists. If it does then it returns that. If it doesn't, it will
119
- # build the child.
120
- def create_child(key, child_class, **kwargs, &block)
121
- @children.fetch(key) { @children[key] = child_class.new(key, parent: self, **kwargs, &block) }
119
+ # Builds a new field child
120
+ def build_field(key, &)
121
+ @form.build_field(key, parent: self, object:, &)
122
+ end
123
+
124
+ # Builds a new namespace child
125
+ def build_namespace(key, &)
126
+ self.class.new(key, parent: self, object: object_for(key:), form:, &)
127
+ end
128
+
129
+ # Builds a new collection child
130
+ def build_collection(key, &)
131
+ NamespaceCollection.new(key, parent: self, form:, &)
122
132
  end
123
133
  end
124
134
  end
@@ -5,9 +5,12 @@ module Superform
5
5
  class NamespaceCollection < Node
6
6
  include Enumerable
7
7
 
8
- def initialize(key, parent:, &template)
9
- super(key, parent: parent)
8
+ attr_reader :form
9
+
10
+ def initialize(key, parent:, form: parent.form, &template)
11
+ super(key, parent:)
10
12
  @template = template
13
+ @form = form
11
14
  @namespaces = enumerate(parent_collection)
12
15
  end
13
16
 
@@ -32,13 +35,13 @@ module Superform
32
35
  def enumerate(enumerator)
33
36
  Enumerator.new do |y|
34
37
  enumerator.each.with_index do |object, key|
35
- y << build_namespace(key, object: object)
38
+ y << build_namespace(key, object:)
36
39
  end
37
40
  end
38
41
  end
39
42
 
40
43
  def build_namespace(index, **)
41
- parent.class.new(index, parent: self, **, &@template)
44
+ parent.class.new(index, parent: self, form:, **, &@template)
42
45
  end
43
46
 
44
47
  def parent_collection
@@ -0,0 +1,31 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Base < Component
5
+ attr_reader :field, :dom
6
+
7
+ delegate :dom, to: :field
8
+
9
+ def initialize(field, attributes: {})
10
+ @field = field
11
+ @attributes = attributes
12
+ end
13
+
14
+ def field_attributes
15
+ {}
16
+ end
17
+
18
+ def focus(value = true)
19
+ @attributes[:autofocus] = value
20
+ self
21
+ end
22
+
23
+ private
24
+
25
+ def attributes
26
+ field_attributes.merge(@attributes)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,20 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Button < Field
5
+ def view_template(&content)
6
+ content ||= Proc.new { button_text }
7
+ button(**attributes, &content)
8
+ end
9
+
10
+ def button_text
11
+ @attributes.fetch(:value, dom.value).titleize
12
+ end
13
+
14
+ def field_attributes
15
+ { id: dom.id, name: dom.name, value: dom.value }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Checkbox < Field
5
+ def view_template(&)
6
+ # Rails has a hidden and checkbox input to deal with sending back a value
7
+ # to the server regardless of if the input is checked or not.
8
+ input(name: dom.name, type: :hidden, value: "0")
9
+ # The hard coded keys need to be in here so the user can't overrite them.
10
+ input(type: :checkbox, value: "1", **attributes)
11
+ end
12
+
13
+ def field_attributes
14
+ { id: dom.id, name: dom.name, checked: field.value }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,11 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Field < Base
5
+ def field_attributes
6
+ { id: dom.id, name: dom.name }
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,59 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Input < Field
5
+ def view_template(&)
6
+ input(**attributes)
7
+ end
8
+
9
+ def field_attributes
10
+ {
11
+ id: dom.id,
12
+ name: dom.name,
13
+ type: type,
14
+ value: value
15
+ }
16
+ end
17
+
18
+ def has_client_provided_value?
19
+ case type.to_s
20
+ when "file", "image"
21
+ true
22
+ else
23
+ false
24
+ end
25
+ end
26
+
27
+ def value
28
+ dom.value unless has_client_provided_value?
29
+ end
30
+
31
+ def type
32
+ @type ||= ActiveSupport::StringInquirer.new(attribute_type || value_type)
33
+ end
34
+
35
+ protected
36
+ def value_type
37
+ case field.value
38
+ when URI
39
+ "url"
40
+ when Integer, Float
41
+ "number"
42
+ when Date, DateTime
43
+ "date"
44
+ when Time
45
+ "time"
46
+ else
47
+ "text"
48
+ end
49
+ end
50
+
51
+ def attribute_type
52
+ if type = @attributes[:type] || @attributes["type"]
53
+ type.to_s
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,20 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Label < Base
5
+ def view_template(&content)
6
+ content ||= Proc.new { label_text }
7
+ label(**attributes, &content)
8
+ end
9
+
10
+ def field_attributes
11
+ { for: dom.id }
12
+ end
13
+
14
+ def label_text
15
+ field.key.to_s.titleize
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,43 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Select < Field
5
+ def initialize(*, collection: [], **, &)
6
+ super(*, **, &)
7
+ @collection = collection
8
+ end
9
+
10
+ def view_template(&options)
11
+ if block_given?
12
+ select(**attributes, &options)
13
+ else
14
+ select(**attributes) { options(*@collection) }
15
+ end
16
+ end
17
+
18
+ def options(*collection)
19
+ map_options(collection).each do |key, value|
20
+ option(selected: field.value == key, value: key) { value }
21
+ end
22
+ end
23
+
24
+ def blank_option(&)
25
+ option(selected: field.value.nil?, &)
26
+ end
27
+
28
+ def true_option(&)
29
+ option(selected: field.value == true, value: true.to_s, &)
30
+ end
31
+
32
+ def false_option(&)
33
+ option(selected: field.value == false, value: false.to_s, &)
34
+ end
35
+
36
+ protected
37
+ def map_options(collection)
38
+ OptionMapper.new(collection)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,12 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Textarea < Field
5
+ def view_template(&content)
6
+ content ||= Proc.new { dom.value }
7
+ textarea(**attributes, &content)
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,240 @@
1
+ module Superform
2
+ module Rails
3
+ # A Phlex::HTML view module that accepts a model and sets a `Superform::Namespace`
4
+ # with the `Object#model_name` as the key and maps the object to form fields
5
+ # and namespaces.
6
+ #
7
+ # The `Form::Field` is a class that's meant to be extended so you can customize the `Form` inputs
8
+ # to your applications needs. Defaults for the `input`, `button`, `label`, and `textarea` tags
9
+ # are provided.
10
+ #
11
+ # The `Form` component also handles Rails authenticity tokens via the `authenticity_toklen_field`
12
+ # method and the HTTP verb via the `_method_field`.
13
+ class Form < Component
14
+ include Phlex::Rails::Helpers::FormAuthenticityToken
15
+ include Phlex::Rails::Helpers::URLFor
16
+
17
+ attr_accessor :model
18
+
19
+ delegate \
20
+ :Field,
21
+ :field,
22
+ :collection,
23
+ :namespace,
24
+ :assign,
25
+ :serialize,
26
+ to: :@namespace
27
+
28
+ # The Field class is designed to be extended to create custom forms. To override,
29
+ # in your subclass you may have something like this:
30
+ #
31
+ # ```ruby
32
+ # class MyForm < Superform::Rails::Form
33
+ # class MyLabel < Superform::Rails::Components::Label
34
+ # def view_template(&content)
35
+ # label(form: @field.dom.name, class: "text-bold", &content)
36
+ # end
37
+ # end
38
+ #
39
+ # class Field < Field
40
+ # def label(**attributes)
41
+ # MyLabel.new(self, **attributes)
42
+ # end
43
+ # end
44
+ # end
45
+ # ```
46
+ #
47
+ # Now all calls to `label` will have the `text-bold` class applied to it.
48
+ class Field < Superform::Field
49
+ def button(**attributes)
50
+ Components::Button.new(self, attributes:)
51
+ end
52
+
53
+ def input(**attributes)
54
+ Components::Input.new(self, attributes:)
55
+ end
56
+
57
+ def text(*, **, &)
58
+ input(*, **, type: :text, &)
59
+ end
60
+
61
+ def checkbox(**attributes)
62
+ Components::Checkbox.new(self, attributes:)
63
+ end
64
+
65
+ def label(**attributes, &)
66
+ Components::Label.new(self, attributes:, &)
67
+ end
68
+
69
+ def textarea(**attributes)
70
+ Components::Textarea.new(self, attributes:)
71
+ end
72
+
73
+ def select(*collection, **attributes, &)
74
+ Components::Select.new(self, attributes:, collection:, &)
75
+ end
76
+
77
+ # HTML5 input type convenience methods - clean API without _field suffix
78
+ # Examples:
79
+ # field(:email).email(class: "form-input")
80
+ # field(:age).number(min: 18, max: 99)
81
+ # field(:birthday).date
82
+ # field(:secret).hidden(value: "token123")
83
+ # field(:gender).radio("male", id: "user_gender_male")
84
+ def hidden(*, **, &)
85
+ input(*, **, type: :hidden, &)
86
+ end
87
+
88
+ def password(*, **, &)
89
+ input(*, **, type: :password, &)
90
+ end
91
+
92
+ def email(*, **, &)
93
+ input(*, **, type: :email, &)
94
+ end
95
+
96
+ def url(*, **, &)
97
+ input(*, **, type: :url, &)
98
+ end
99
+
100
+ def tel(*, **, &)
101
+ input(*, **, type: :tel, &)
102
+ end
103
+ alias_method :phone, :tel
104
+
105
+ def number(*, **, &)
106
+ input(*, **, type: :number, &)
107
+ end
108
+
109
+ def range(*, **, &)
110
+ input(*, **, type: :range, &)
111
+ end
112
+
113
+ def date(*, **, &)
114
+ input(*, **, type: :date, &)
115
+ end
116
+
117
+ def time(*, **, &)
118
+ input(*, **, type: :time, &)
119
+ end
120
+
121
+ def datetime(*, **, &)
122
+ input(*, **, type: :"datetime-local", &)
123
+ end
124
+
125
+ def month(*, **, &)
126
+ input(*, **, type: :month, &)
127
+ end
128
+
129
+ def week(*, **, &)
130
+ input(*, **, type: :week, &)
131
+ end
132
+
133
+ def color(*, **, &)
134
+ input(*, **, type: :color, &)
135
+ end
136
+
137
+ def search(*, **, &)
138
+ input(*, **, type: :search, &)
139
+ end
140
+
141
+ def file(*, **, &)
142
+ input(*, **, type: :file, &)
143
+ end
144
+
145
+ def radio(value, *, **, &)
146
+ input(*, **, type: :radio, value: value, &)
147
+ end
148
+
149
+ # Rails compatibility aliases
150
+ alias_method :check_box, :checkbox
151
+ alias_method :text_area, :textarea
152
+
153
+ def title
154
+ key.to_s.titleize
155
+ end
156
+ end
157
+
158
+ def build_field(...)
159
+ self.class::Field.new(...)
160
+ end
161
+
162
+ def initialize(model, action: nil, method: nil, **attributes)
163
+ @model = model
164
+ @action = action
165
+ @method = method
166
+ @attributes = attributes
167
+ @namespace = Namespace.root(key, object: model, form: self)
168
+ end
169
+
170
+ def around_template(&)
171
+ form_tag do
172
+ authenticity_token_field
173
+ _method_field
174
+ super
175
+ end
176
+ end
177
+
178
+ def form_tag(&)
179
+ form action: form_action, method: form_method, **@attributes, &
180
+ end
181
+
182
+ def view_template(&block)
183
+ yield self if block_given?
184
+ end
185
+
186
+ def submit(value = submit_value, **attributes)
187
+ input **attributes.merge(
188
+ name: "commit",
189
+ type: "submit",
190
+ value: value
191
+ )
192
+ end
193
+
194
+ def key
195
+ @model.model_name.param_key
196
+ end
197
+
198
+ protected
199
+ def authenticity_token_field
200
+ input(
201
+ name: "authenticity_token",
202
+ type: "hidden",
203
+ value: form_authenticity_token
204
+ )
205
+ end
206
+
207
+ def _method_field
208
+ input(
209
+ name: "_method",
210
+ type: "hidden",
211
+ value: _method_field_value
212
+ )
213
+ end
214
+
215
+ def _method_field_value
216
+ @method || resource_method_field_value
217
+ end
218
+
219
+ def resource_method_field_value
220
+ @model.persisted? ? "patch" : "post"
221
+ end
222
+
223
+ def submit_value
224
+ "#{resource_action.to_s.capitalize} #{@model.model_name}"
225
+ end
226
+
227
+ def resource_action
228
+ @model.persisted? ? :update : :create
229
+ end
230
+
231
+ def form_action
232
+ @action ||= url_for(action: resource_action)
233
+ end
234
+
235
+ def form_method
236
+ @method.to_s.downcase == "get" ? "get" : "post"
237
+ end
238
+ end
239
+ end
240
+ end