superform 0.6.1 → 0.7.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -3
  3. data/CLAUDE.md +46 -0
  4. data/Gemfile.lock +6 -1
  5. data/README.md +208 -75
  6. data/examples/basic_form.rb +21 -0
  7. data/examples/checkbox_form.rb +28 -0
  8. data/examples/date_time_form.rb +18 -0
  9. data/examples/example_form.rb +10 -0
  10. data/examples/select_form.rb +26 -0
  11. data/examples/special_inputs_form.rb +23 -0
  12. data/examples/textarea_form.rb +15 -0
  13. data/lib/generators/superform/install/templates/base.rb +33 -7
  14. data/lib/superform/dom.rb +13 -1
  15. data/lib/superform/field.rb +14 -31
  16. data/lib/superform/rails/choices/choice.rb +39 -0
  17. data/lib/superform/rails/choices/mapper.rb +41 -0
  18. data/lib/superform/rails/choices.rb +6 -0
  19. data/lib/superform/rails/components/base.rb +9 -2
  20. data/lib/superform/rails/components/checkbox.rb +34 -7
  21. data/lib/superform/rails/components/checkboxes.rb +38 -0
  22. data/lib/superform/rails/components/datalist.rb +34 -0
  23. data/lib/superform/rails/components/input.rb +1 -1
  24. data/lib/superform/rails/components/label.rb +1 -1
  25. data/lib/superform/rails/components/radio.rb +21 -0
  26. data/lib/superform/rails/components/radios.rb +38 -0
  27. data/lib/superform/rails/components/select.rb +52 -8
  28. data/lib/superform/rails/field.rb +91 -44
  29. data/lib/superform/version.rb +1 -1
  30. data/server/components/breadcrumb.rb +11 -0
  31. data/server/components/form_card.rb +13 -0
  32. data/server/components/layout.rb +23 -0
  33. data/server/controllers/forms_controller.rb +94 -0
  34. data/server/models/example.rb +31 -0
  35. data/server/public/styles.css +282 -0
  36. data/server/rails.rb +56 -0
  37. data/superform.gemspec +37 -0
  38. metadata +26 -4
  39. data/lib/superform/rails/option_mapper.rb +0 -36
data/lib/superform/dom.rb CHANGED
@@ -3,6 +3,12 @@ module Superform
3
3
  # norms that were established by Rails. These can be used outsidef or Rails in
4
4
  # other Ruby web frameworks since it has now dependencies on Rails.
5
5
  class DOM
6
+ DELIMITER = "_"
7
+
8
+ def self.join(*segments)
9
+ segments.join(DELIMITER)
10
+ end
11
+
6
12
  def initialize(field:)
7
13
  @field = field
8
14
  end
@@ -17,7 +23,7 @@ module Superform
17
23
  # them with a `_` for a DOM ID. One limitation of this approach is if multiple forms
18
24
  # exist on the same page, the ID may be duplicate.
19
25
  def id
20
- lineage.map(&:key).join("_")
26
+ self.class.join(*lineage.map(&:key))
21
27
  end
22
28
 
23
29
  # The `name` attribute of a node, which is influenced by Rails (not sure where Rails got
@@ -29,6 +35,12 @@ module Superform
29
35
  names.map { |name| "[#{name}]" }.unshift(root).join
30
36
  end
31
37
 
38
+ # Returns the name with `[]` appended for array/multiple value fields.
39
+ # Used by multiple selects, checkbox groups, etc.
40
+ def array_name
41
+ "#{name}[]"
42
+ end
43
+
32
44
  # Emit the id, name, and value in an HTML tag-ish that doesnt have an element.
33
45
  def inspect
34
46
  "<id=#{id.inspect} name=#{name.inspect} value=#{value.inspect}/>"
@@ -3,7 +3,12 @@ module Superform
3
3
  # methods for accessing and modifying the field's value. HTML concerns are all
4
4
  # delegated to the DOM object.
5
5
  class Field < Node
6
- attr_reader :dom
6
+ # Helpful for overriding methods in the `input`, `email`, `label`, etc. calls.
7
+ include Phlex::Helpers
8
+
9
+ # Expose these as a reader so they're accessible from the `input`, `label`,
10
+ # etc. methods.
11
+ attr_reader :dom, :object
7
12
 
8
13
  def initialize(key, parent:, object: nil, value: nil)
9
14
  super key, parent: parent
@@ -42,28 +47,6 @@ module Superform
42
47
  self
43
48
  end
44
49
 
45
- # A helper, borrowed from Phlex, that makes it easy to "grab" values
46
- # passed into a method that are reserved keywords. For example, this
47
- # would throw a syntax error because `class` and `end` are reserved:
48
- #
49
- # def foo(end:, class:)
50
- # puts class
51
- # puts end
52
- # end
53
- #
54
- # So you "grab" them like this:
55
- # def foo(end:, class:)
56
- # puts grab(end:)
57
- # puts grab(class:)
58
- # end
59
- private def grab(**bindings)
60
- if bindings.size > 1
61
- bindings.values
62
- else
63
- bindings.values.first
64
- end
65
- end
66
-
67
50
  # High-performance Kit proxy that wraps field methods with form.render calls.
68
51
  # Uses Ruby class hooks to define methods at the class level for maximum speed:
69
52
  # - Methods are defined once per Field class, not per Kit instance
@@ -85,7 +68,7 @@ module Superform
85
68
  # Create a new Kit class for each Field subclass with true isolation
86
69
  # Copy methods from parent Field classes at creation time, not through inheritance
87
70
  subclass.const_set(:Kit, Class.new(Field::Kit))
88
-
71
+
89
72
  # Copy all existing methods from the inheritance chain
90
73
  field_class = self
91
74
  while field_class != Field
@@ -99,7 +82,7 @@ module Superform
99
82
  # Skip if this is the base Field class or if we don't have a Kit class yet
100
83
  return if self == Field
101
84
  return unless const_defined?(:Kit, false)
102
-
85
+
103
86
  # Only add method to THIS class's Kit, not subclasses (isolation)
104
87
  add_method_to_kit(method_name, self::Kit)
105
88
  end
@@ -111,14 +94,14 @@ module Superform
111
94
  private
112
95
 
113
96
  def self.copy_field_methods_to_kit(field_class, kit_class)
114
- base_methods = (Object.instance_methods + Node.instance_methods +
97
+ base_methods = (Object.instance_methods + Node.instance_methods +
115
98
  [:dom, :value, :serialize, :assign, :collection, :field, :kit]).to_set
116
-
99
+
117
100
  field_class.instance_methods(false).each do |method_name|
118
101
  next if method_name.to_s.end_with?('=')
119
102
  next if base_methods.include?(method_name)
120
103
  next if kit_class.method_defined?(method_name)
121
-
104
+
122
105
  kit_class.define_method(method_name) do |*args, **kwargs, &block|
123
106
  result = @field.send(method_name, *args, **kwargs, &block)
124
107
  @form.render result
@@ -128,12 +111,12 @@ module Superform
128
111
 
129
112
  def self.add_method_to_kit(method_name, kit_class)
130
113
  return if method_name.to_s.end_with?('=')
131
-
132
- base_methods = (Object.instance_methods + Node.instance_methods +
114
+
115
+ base_methods = (Object.instance_methods + Node.instance_methods +
133
116
  [:dom, :value, :serialize, :assign, :collection, :field, :kit]).to_set
134
117
  return if base_methods.include?(method_name)
135
118
  return if kit_class.method_defined?(method_name)
136
-
119
+
137
120
  kit_class.define_method(method_name) do |*args, **kwargs, &block|
138
121
  result = @field.send(method_name, *args, **kwargs, &block)
139
122
  @form.render result
@@ -0,0 +1,39 @@
1
+ module Superform
2
+ module Rails
3
+ module Choices
4
+ class Choice
5
+ attr_reader :value, :text, :index
6
+
7
+ def initialize(component:, field:, value:, text:, index:, type:)
8
+ @component = component
9
+ @field = field
10
+ @value = value
11
+ @text = text
12
+ @index = index
13
+ @type = type
14
+ end
15
+
16
+ def input(**attrs)
17
+ @component.render build_input(**attrs)
18
+ end
19
+
20
+ def label(**attrs, &block)
21
+ label_text = @text
22
+ block ||= proc { label_text }
23
+ @component.render Components::Label.new(
24
+ @field, for: DOM.join(@field.dom.id, @index), **attrs, &block
25
+ )
26
+ end
27
+
28
+ def build_input(**attrs)
29
+ case @type
30
+ when :radio
31
+ Components::Radio.new(@field, value: @value, index: @index, **attrs)
32
+ when :checkbox
33
+ Components::Checkbox.new(@field, value: @value, index: @index, **attrs)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,41 @@
1
+ module Superform
2
+ module Rails
3
+ module Choices
4
+ # Maps collections of options into (value, text) pairs for form controls.
5
+ # Accepts arrays, hashes, single values, and ActiveRecord relations.
6
+ class Mapper
7
+ include Enumerable
8
+
9
+ def initialize(collection)
10
+ @collection = collection
11
+ end
12
+
13
+ def each(&options)
14
+ @collection.each do |object|
15
+ case object
16
+ in ActiveRecord::Relation => relation
17
+ active_record_relation_options_enumerable(relation).each(&options)
18
+ in Hash => hash
19
+ hash.each { |id, value| options.call id, value }
20
+ in id, value
21
+ options.call id, value
22
+ in value
23
+ options.call value, value.to_s
24
+ end
25
+ end
26
+ end
27
+
28
+ def active_record_relation_options_enumerable(relation)
29
+ Enumerator.new do |collection|
30
+ relation.each do |object|
31
+ attributes = object.attributes
32
+ id = attributes.delete(relation.primary_key)
33
+ value = attributes.values.join(" ")
34
+ collection << [ id, value ]
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,6 @@
1
+ module Superform
2
+ module Rails
3
+ module Choices
4
+ end
5
+ end
6
+ end
@@ -6,9 +6,16 @@ module Superform
6
6
 
7
7
  delegate :dom, to: :field
8
8
 
9
- def initialize(field, attributes: {})
9
+ def initialize(field, attributes: nil, **attributes_kwargs)
10
10
  @field = field
11
- @attributes = attributes
11
+ if attributes
12
+ warn "[DEPRECATION] Passing `attributes:` keyword to #{self.class.name} is deprecated. " \
13
+ "Pass HTML attributes as keyword arguments directly instead: " \
14
+ "#{self.class.name}.new(field, **attributes)"
15
+ @attributes = attributes.merge(attributes_kwargs)
16
+ else
17
+ @attributes = attributes_kwargs
18
+ end
12
19
  end
13
20
 
14
21
  def field_attributes
@@ -2,18 +2,45 @@ module Superform
2
2
  module Rails
3
3
  module Components
4
4
  class Checkbox < Field
5
+ def initialize(field, index: nil, **attributes)
6
+ super(field, **attributes)
7
+ @index = index
8
+ end
9
+
5
10
  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
+ if boolean?
12
+ # Rails convention: hidden input ensures a value is sent even when unchecked
13
+ input(name: dom.name, type: :hidden, value: "0")
14
+ input(type: :checkbox, value: "1", **attributes)
15
+ elsif collection?
16
+ input(type: :checkbox, value: dom.value, **attributes)
17
+ else
18
+ input(type: :checkbox, **attributes)
19
+ end
11
20
  end
12
21
 
13
22
  def field_attributes
14
- { id: dom.id, name: dom.name, checked: field.value }
23
+ if boolean?
24
+ { id: dom.id, name: dom.name, checked: field.value }
25
+ elsif collection?
26
+ { id: dom.id, name: dom.name, checked: true }
27
+ else
28
+ { id: DOM.join(dom.id, @index || @attributes[:value]), name: dom.array_name, checked: Array(field.value).include?(@attributes[:value]) }
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ # Inside a FieldCollection — the field is a child of another Field
35
+ def collection?
36
+ field.parent.is_a?(Superform::Field)
37
+ end
38
+
39
+ # Scalar field with no explicit value — classic on/off toggle
40
+ def boolean?
41
+ !collection? && !field.value.is_a?(Array)
15
42
  end
16
43
  end
17
44
  end
18
45
  end
19
- end
46
+ end
@@ -0,0 +1,38 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Checkboxes < Base
5
+ def initialize(field, options: [], **attributes)
6
+ super(field, **attributes)
7
+ @options = options
8
+ end
9
+
10
+ def view_template(&block)
11
+ choices.each do |choice|
12
+ if block
13
+ yield choice
14
+ else
15
+ label(for: DOM.join(dom.id, choice.index)) do
16
+ render choice.build_input
17
+ whitespace
18
+ plain choice.text
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def choices
27
+ Choices::Mapper.new(@options).each_with_index.map do |(value, text), index|
28
+ Choices::Choice.new(component: self, field: @field, value:, text:, index:, type: :checkbox)
29
+ end
30
+ end
31
+
32
+ def field_attributes
33
+ {}
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,34 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Datalist < Field
5
+ def initialize(field, options: [], **attributes)
6
+ super(field, **attributes)
7
+ @options = options
8
+ end
9
+
10
+ def view_template(&block)
11
+ datalist_id = DOM.join(dom.id, "datalist")
12
+ input(list: datalist_id, **attributes)
13
+ datalist(id: datalist_id) do
14
+ if block
15
+ yield self
16
+ else
17
+ options(*@options)
18
+ end
19
+ end
20
+ end
21
+
22
+ def options(*collection)
23
+ Choices::Mapper.new(collection).each do |value, text|
24
+ if value == text
25
+ option(value: value)
26
+ else
27
+ option(value: value) { text }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -2,7 +2,7 @@ module Superform
2
2
  module Rails
3
3
  module Components
4
4
  class Input < Field
5
- def view_template(&)
5
+ def view_template
6
6
  input(**attributes)
7
7
  end
8
8
 
@@ -12,7 +12,7 @@ module Superform
12
12
  end
13
13
 
14
14
  def label_text
15
- field.key.to_s.titleize
15
+ field.human_attribute_name
16
16
  end
17
17
  end
18
18
  end
@@ -0,0 +1,21 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Radio < Field
5
+ def initialize(field, value:, index: value, **attributes)
6
+ super(field, **attributes)
7
+ @value = value
8
+ @index = index
9
+ end
10
+
11
+ def view_template(&)
12
+ input(type: :radio, **attributes)
13
+ end
14
+
15
+ def field_attributes
16
+ { id: DOM.join(dom.id, @index), name: dom.name, value: @value, checked: field.value == @value }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,38 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Radios < Base
5
+ def initialize(field, options: [], **attributes)
6
+ super(field, **attributes)
7
+ @options = options
8
+ end
9
+
10
+ def view_template(&block)
11
+ choices.each do |choice|
12
+ if block
13
+ yield choice
14
+ else
15
+ label(for: DOM.join(dom.id, choice.index)) do
16
+ render choice.build_input
17
+ whitespace
18
+ plain choice.text
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def choices
27
+ Choices::Mapper.new(@options).each_with_index.map do |(value, text), index|
28
+ Choices::Choice.new(component: self, field: @field, value:, text:, index:, type: :radio)
29
+ end
30
+ end
31
+
32
+ def field_attributes
33
+ {}
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -2,22 +2,54 @@ module Superform
2
2
  module Rails
3
3
  module Components
4
4
  class Select < Field
5
- def initialize(*, collection: [], **, &)
6
- super(*, **, &)
7
- @collection = collection
5
+ def initialize(
6
+ field,
7
+ options: [],
8
+ collection: nil,
9
+ multiple: false,
10
+ **attributes,
11
+ &
12
+ )
13
+ super(field, **attributes, &)
14
+
15
+ # Handle deprecated collection parameter
16
+ if collection && options.empty?
17
+ warn "[DEPRECATION] Superform::Rails::Components::Select: " \
18
+ "`collection:` keyword is deprecated and will be removed. " \
19
+ "Use positional arguments instead: field.select([1, 'A'], [2, 'B'])"
20
+ options = collection
21
+ end
22
+
23
+ @options = options
24
+ @multiple = multiple
8
25
  end
9
26
 
10
- def view_template(&options)
27
+ def view_template(&block)
28
+ # Hidden input ensures a value is sent even when all options are
29
+ # deselected in a multiple select
30
+ if @multiple
31
+ hidden_name = field.parent.is_a?(Superform::Field) ? dom.name : dom.array_name
32
+ input(type: "hidden", name: hidden_name, value: "")
33
+ end
34
+
11
35
  if block_given?
12
- select(**attributes, &options)
36
+ select(**attributes, &block)
13
37
  else
14
- select(**attributes) { options(*@collection) }
38
+ select(**attributes) do
39
+ options(*@options)
40
+ end
15
41
  end
16
42
  end
17
43
 
18
44
  def options(*collection)
45
+ # Handle both single values and arrays (for multiple selects)
46
+ selected_values = Array(field.value)
19
47
  map_options(collection).each do |key, value|
20
- option(selected: field.value == key, value: key) { value }
48
+ if key.nil?
49
+ blank_option
50
+ else
51
+ option(selected: selected_values.include?(key), value: key) { value }
52
+ end
21
53
  end
22
54
  end
23
55
 
@@ -35,7 +67,19 @@ module Superform
35
67
 
36
68
  protected
37
69
  def map_options(collection)
38
- OptionMapper.new(collection)
70
+ Choices::Mapper.new(collection)
71
+ end
72
+
73
+ def field_attributes
74
+ attrs = super
75
+ if @multiple
76
+ # Only append [] if the field doesn't already have a Field parent
77
+ # (which would mean it's already in a collection and has [] notation)
78
+ name = field.parent.is_a?(Superform::Field) ? attrs[:name] : dom.array_name
79
+ attrs.merge(multiple: true, name: name)
80
+ else
81
+ attrs
82
+ end
39
83
  end
40
84
  end
41
85
  end