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
@@ -12,8 +12,8 @@ module Superform
12
12
  # end
13
13
  #
14
14
  # class Field < Field
15
- # def label(**, &)
16
- # MyLabel.new(self, **, &)
15
+ # def label(**attributes, &)
16
+ # MyLabel.new(self, **attributes, &)
17
17
  # end
18
18
  #
19
19
  # def input(class: nil, **)
@@ -26,27 +26,53 @@ module Superform
26
26
  # Now all calls to `label` will have the `text-bold` class applied to it.
27
27
  class Field < Superform::Field
28
28
  def button(**attributes)
29
- Components::Button.new(field, attributes:)
29
+ Components::Button.new(field, **attributes)
30
30
  end
31
31
 
32
32
  def input(**attributes)
33
- Components::Input.new(field, attributes:)
33
+ Components::Input.new(field, **attributes)
34
34
  end
35
35
 
36
- def checkbox(**attributes)
37
- Components::Checkbox.new(field, attributes:)
36
+ def checkbox(index: nil, **attributes)
37
+ Components::Checkbox.new(field, index:, **attributes)
38
38
  end
39
39
 
40
40
  def label(**attributes, &)
41
- Components::Label.new(field, attributes:, &)
41
+ Components::Label.new(field, **attributes, &)
42
42
  end
43
43
 
44
44
  def textarea(**attributes)
45
- Components::Textarea.new(field, attributes:)
45
+ Components::Textarea.new(field, **attributes)
46
46
  end
47
47
 
48
- def select(*collection, **attributes, &)
49
- Components::Select.new(field, attributes:, collection:, &)
48
+ def select(*options, multiple: false, **attributes, &)
49
+ Components::Select.new(
50
+ field,
51
+ options:,
52
+ multiple:,
53
+ **attributes,
54
+ &
55
+ )
56
+ end
57
+
58
+ def datalist(*options, **attributes, &block)
59
+ Components::Datalist.new(field, options:, **attributes, &block)
60
+ end
61
+
62
+ def errors
63
+ object.errors[key]
64
+ end
65
+
66
+ def invalid?
67
+ errors.any?
68
+ end
69
+
70
+ def valid?
71
+ not invalid?
72
+ end
73
+
74
+ def human_attribute_name
75
+ object.class.human_attribute_name key
50
76
  end
51
77
 
52
78
  # HTML5 input type convenience methods - clean API without _field suffix
@@ -56,73 +82,83 @@ module Superform
56
82
  # field(:birthday).date
57
83
  # field(:secret).hidden(value: "token123")
58
84
  # field(:gender).radio("male", id: "user_gender_male")
59
- def text(*, **, &)
60
- input(*, **, type: :text, &)
85
+ def text(*, **)
86
+ input(*, **, type: :text)
61
87
  end
62
88
 
63
- def hidden(*, **, &)
64
- input(*, **, type: :hidden, &)
89
+ def hidden(*, **)
90
+ input(*, **, type: :hidden)
65
91
  end
66
92
 
67
- def password(*, **, &)
68
- input(*, **, type: :password, &)
93
+ def password(*, **)
94
+ input(*, **, type: :password)
69
95
  end
70
96
 
71
- def email(*, **, &)
72
- input(*, **, type: :email, &)
97
+ def email(*, **)
98
+ input(*, **, type: :email)
73
99
  end
74
100
 
75
- def url(*, **, &)
76
- input(*, **, type: :url, &)
101
+ def url(*, **)
102
+ input(*, **, type: :url)
77
103
  end
78
104
 
79
- def tel(*, **, &)
80
- input(*, **, type: :tel, &)
105
+ def tel(*, **)
106
+ input(*, **, type: :tel)
81
107
  end
82
108
  alias_method :phone, :tel
83
109
 
84
- def number(*, **, &)
85
- input(*, **, type: :number, &)
110
+ def number(*, **)
111
+ input(*, **, type: :number)
112
+ end
113
+
114
+ def range(*, **)
115
+ input(*, **, type: :range)
116
+ end
117
+
118
+ def date(*, **)
119
+ input(*, **, type: :date)
86
120
  end
87
121
 
88
- def range(*, **, &)
89
- input(*, **, type: :range, &)
122
+ def time(*, **)
123
+ input(*, **, type: :time)
90
124
  end
91
125
 
92
- def date(*, **, &)
93
- input(*, **, type: :date, &)
126
+ def datetime(*, **)
127
+ input(*, **, type: :"datetime-local")
94
128
  end
95
129
 
96
- def time(*, **, &)
97
- input(*, **, type: :time, &)
130
+ def month(*, **)
131
+ input(*, **, type: :month)
98
132
  end
99
133
 
100
- def datetime(*, **, &)
101
- input(*, **, type: :"datetime-local", &)
134
+ def week(*, **)
135
+ input(*, **, type: :week)
102
136
  end
103
137
 
104
- def month(*, **, &)
105
- input(*, **, type: :month, &)
138
+ def color(*, **)
139
+ input(*, **, type: :color)
106
140
  end
107
141
 
108
- def week(*, **, &)
109
- input(*, **, type: :week, &)
142
+ def search(*, **)
143
+ input(*, **, type: :search)
110
144
  end
111
145
 
112
- def color(*, **, &)
113
- input(*, **, type: :color, &)
146
+ def file(*, **)
147
+ input(*, **, type: :file)
114
148
  end
115
149
 
116
- def search(*, **, &)
117
- input(*, **, type: :search, &)
150
+ def radio(value, index: value, **attributes)
151
+ Components::Radio.new(field, value:, index:, **attributes)
118
152
  end
119
153
 
120
- def file(*, **, &)
121
- input(*, **, type: :file, &)
154
+ def radios(*options, **attributes, &block)
155
+ options = enum_options if options.empty?
156
+ Components::Radios.new(field, options:, **attributes, &block)
122
157
  end
123
158
 
124
- def radio(value, *, **, &)
125
- input(*, **, type: :radio, value: value, &)
159
+ def checkboxes(*options, **attributes, &block)
160
+ options = enum_options if options.empty?
161
+ Components::Checkboxes.new(field, options:, **attributes, &block)
126
162
  end
127
163
 
128
164
  # Rails compatibility aliases
@@ -132,6 +168,17 @@ module Superform
132
168
  def title
133
169
  key.to_s.titleize
134
170
  end
171
+
172
+ private
173
+
174
+ def enum_options
175
+ return [] unless object
176
+ enums = object.class.try(:defined_enums)
177
+ return [] unless enums
178
+ enum = enums[key.to_s]
179
+ return [] unless enum
180
+ enum.keys.map { |k| [k, k.humanize] }
181
+ end
135
182
  end
136
183
  end
137
184
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Superform
4
- VERSION = "0.6.1"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Breadcrumb < Phlex::HTML
4
+ def view_template(&)
5
+ nav(class: "breadcrumb") { yield self }
6
+ end
7
+
8
+ def crumb(&)
9
+ span(class: "crumb") { yield }
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class FormCard < Phlex::HTML
4
+ def initialize(form_class:, index:)
5
+ @form_class = form_class
6
+ @index = index
7
+ end
8
+
9
+ def view_template
10
+ h2 { a(href: "/forms/#{@index}") { @form_class.name_text } }
11
+ p { @form_class.description.html_safe }
12
+ end
13
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Layout < Phlex::HTML
4
+ def initialize(title: "Superform")
5
+ @title = title
6
+ end
7
+
8
+ def view_template(&)
9
+ doctype
10
+ html do
11
+ head do
12
+ title { @title }
13
+ link rel: "stylesheet", href: "/styles.css"
14
+ end
15
+ body do
16
+ header do
17
+ h1 { a(href: "/") { "Superform" } }
18
+ end
19
+ yield
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ class FormsController < ActionController::Base
4
+ before_action { Rails.autoloaders.main.reload }
5
+ class IndexPage < Phlex::HTML
6
+ def view_template
7
+ render Layout.new do
8
+ render Breadcrumb.new do |b|
9
+ b.crumb { "Examples" }
10
+ end
11
+
12
+ form_classes.each_with_index do |form_class, index|
13
+ render FormCard.new(form_class: form_class, index: index)
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ class ShowPage < Phlex::HTML
20
+ def initialize(form_class:, action:)
21
+ @form_class = form_class
22
+ @form = form_class.new(Example.new, action: action)
23
+ end
24
+
25
+ def view_template
26
+ render Layout.new(title: "#{@form_class.name_text} - Superform") do
27
+ render Breadcrumb.new do |b|
28
+ b.crumb { a(href: "/") { "Examples" } }
29
+ b.crumb { @form_class.name_text }
30
+ end
31
+
32
+ p { @form_class.description.html_safe }
33
+ render @form
34
+
35
+ hr
36
+
37
+ h3 { "Source Code" }
38
+ pre do
39
+ code { source_code }
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def source_code
47
+ file = File.join(EXAMPLES_DIR, "#{underscore(@form_class.name)}.rb")
48
+ File.exist?(file) ? File.read(file) : "# Source not found"
49
+ end
50
+
51
+ def underscore(name)
52
+ name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
53
+ end
54
+ end
55
+
56
+ class SubmitPage < Phlex::HTML
57
+ def initialize(form_class:, params:, form_path:)
58
+ @form_class = form_class
59
+ @params = params
60
+ @form_path = form_path
61
+ end
62
+
63
+ def view_template
64
+ render Layout.new(title: "#{@form_class.name_text} - Submitted") do
65
+ render Breadcrumb.new do |b|
66
+ b.crumb { a(href: "/") { "Examples" } }
67
+ b.crumb { a(href: @form_path) { @form_class.name_text } }
68
+ b.crumb { "Submitted" }
69
+ end
70
+
71
+ h3 { "Params" }
72
+ pre do
73
+ code { JSON.pretty_generate(@params.to_unsafe_h) }
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ def index
80
+ render IndexPage.new, layout: false
81
+ end
82
+
83
+ def show
84
+ index = params[:id].to_i
85
+ form_class = form_classes[index]
86
+ render ShowPage.new(form_class: form_class, action: request.path), layout: false
87
+ end
88
+
89
+ def create
90
+ index = params[:id].to_i
91
+ form_class = form_classes[index]
92
+ render SubmitPage.new(form_class: form_class, params: params, form_path: request.path), layout: false
93
+ end
94
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Example
4
+ include ActiveModel::Model
5
+ include ActiveModel::Attributes
6
+
7
+ attribute :name, :string
8
+ attribute :email, :string
9
+ attribute :password, :string
10
+ attribute :age, :integer
11
+ attribute :title, :string
12
+ attribute :body, :string
13
+ attribute :country, :string
14
+ attribute :priority, :string
15
+ attribute :quantity, :integer
16
+ attribute :terms_accepted, :boolean
17
+ attribute :subscribe, :boolean
18
+ attribute :featured, :boolean
19
+ attribute :birth_date, :date
20
+ attribute :appointment_time, :time
21
+ attribute :event_datetime, :datetime
22
+ attribute :favorite_color, :string
23
+ attribute :volume, :integer
24
+ attribute :search_query, :string
25
+ attribute :avatar, :string
26
+ attribute :address, :string
27
+
28
+ def self.model_name
29
+ ActiveModel::Name.new(self, nil, "Example")
30
+ end
31
+ end
@@ -0,0 +1,282 @@
1
+ /* Superform Examples */
2
+
3
+ :root {
4
+ --bg: #ffffff;
5
+ --bg-secondary: #f5f5f7;
6
+ --fg: #1d1d1f;
7
+ --fg-secondary: #6e6e73;
8
+ --border: #c7c7cc;
9
+ --accent: #007aff;
10
+ --radius: 8px;
11
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", system-ui, sans-serif;
12
+ --font-mono: "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace;
13
+ }
14
+
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --bg: #000000;
18
+ --bg-secondary: #1c1c1e;
19
+ --fg: #f5f5f7;
20
+ --fg-secondary: #86868b;
21
+ --border: #38383a;
22
+ --accent: #0a84ff;
23
+ }
24
+ }
25
+
26
+ * {
27
+ box-sizing: border-box;
28
+ }
29
+
30
+ html {
31
+ font-family: var(--font);
32
+ font-size: 17px;
33
+ line-height: 1.47059;
34
+ -webkit-font-smoothing: antialiased;
35
+ }
36
+
37
+ body {
38
+ margin: 0;
39
+ padding: 48px 24px;
40
+ background: var(--bg);
41
+ color: var(--fg);
42
+ max-width: 600px;
43
+ margin-inline: auto;
44
+ }
45
+
46
+ h1, h2, h3 {
47
+ font-weight: 600;
48
+ letter-spacing: -0.015em;
49
+ margin: 0 0 8px;
50
+ }
51
+
52
+ h1 { font-size: 21px; }
53
+ h2 { font-size: 19px; }
54
+ h3 { font-size: 14px; color: var(--fg-secondary); font-weight: 500; letter-spacing: 0; }
55
+
56
+ p {
57
+ margin: 0 0 16px;
58
+ color: var(--fg-secondary);
59
+ font-size: 15px;
60
+ }
61
+
62
+ a {
63
+ color: var(--accent);
64
+ text-decoration: none;
65
+ }
66
+
67
+ a:hover {
68
+ text-decoration: underline;
69
+ }
70
+
71
+ hr {
72
+ border: none;
73
+ border-top: 1px solid var(--border);
74
+ margin: 32px 0;
75
+ }
76
+
77
+ pre, code {
78
+ font-family: var(--font-mono);
79
+ font-size: 13px;
80
+ }
81
+
82
+ code {
83
+ background: var(--bg-secondary);
84
+ padding: 2px 6px;
85
+ border-radius: 4px;
86
+ }
87
+
88
+ pre {
89
+ background: var(--bg-secondary);
90
+ border-radius: var(--radius);
91
+ padding: 16px;
92
+ overflow-x: auto;
93
+ margin: 0 0 16px;
94
+ }
95
+
96
+ pre code {
97
+ background: none;
98
+ padding: 0;
99
+ }
100
+
101
+ /* Header */
102
+ header {
103
+ margin-bottom: 32px;
104
+ }
105
+
106
+ header h1 {
107
+ margin: 0;
108
+ font-size: 21px;
109
+ font-weight: 600;
110
+ }
111
+
112
+ header h1 a {
113
+ color: var(--fg);
114
+ }
115
+
116
+ header h1 a:hover {
117
+ text-decoration: none;
118
+ color: var(--accent);
119
+ }
120
+
121
+ /* Breadcrumb */
122
+ nav.breadcrumb {
123
+ margin-bottom: 24px;
124
+ font-size: 15px;
125
+ color: var(--fg-secondary);
126
+ }
127
+
128
+ nav.breadcrumb .crumb + .crumb::before {
129
+ content: "›";
130
+ margin: 0 8px;
131
+ color: var(--fg-secondary);
132
+ }
133
+
134
+ /* Forms */
135
+ form {
136
+ display: flex;
137
+ flex-direction: column;
138
+ gap: 16px;
139
+ }
140
+
141
+ fieldset {
142
+ border: 1px solid var(--border);
143
+ border-radius: var(--radius);
144
+ padding: 16px;
145
+ margin: 0;
146
+ }
147
+
148
+ legend {
149
+ padding: 0 8px;
150
+ font-size: 15px;
151
+ font-weight: 500;
152
+ color: var(--fg-secondary);
153
+ }
154
+
155
+ label {
156
+ display: block;
157
+ font-size: 13px;
158
+ font-weight: 500;
159
+ margin-bottom: 4px;
160
+ color: var(--fg-secondary);
161
+ }
162
+
163
+ label + input,
164
+ label + textarea,
165
+ label + select {
166
+ margin-top: 0;
167
+ }
168
+
169
+ input[type="text"],
170
+ input[type="email"],
171
+ input[type="password"],
172
+ input[type="number"],
173
+ input[type="date"],
174
+ input[type="time"],
175
+ input[type="datetime-local"],
176
+ input[type="url"],
177
+ input[type="tel"],
178
+ input[type="search"],
179
+ input[type="color"],
180
+ input[type="file"],
181
+ textarea,
182
+ select {
183
+ display: block;
184
+ width: 100%;
185
+ padding: 7px 10px;
186
+ font-family: inherit;
187
+ font-size: 17px;
188
+ color: var(--fg);
189
+ background: var(--bg);
190
+ border: 1px solid var(--border);
191
+ border-radius: var(--radius);
192
+ }
193
+
194
+ input:focus,
195
+ textarea:focus,
196
+ select:focus {
197
+ outline: none;
198
+ border-color: var(--accent);
199
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
200
+ }
201
+
202
+ input[type="checkbox"],
203
+ input[type="radio"] {
204
+ accent-color: var(--accent);
205
+ }
206
+
207
+ input[type="range"] {
208
+ accent-color: var(--accent);
209
+ }
210
+
211
+ input[type="color"] {
212
+ padding: 4px;
213
+ height: 38px;
214
+ }
215
+
216
+ input[type="file"] {
217
+ font-size: 15px;
218
+ padding: 6px;
219
+ }
220
+
221
+ textarea {
222
+ min-height: 80px;
223
+ resize: vertical;
224
+ }
225
+
226
+ button,
227
+ input[type="submit"] {
228
+ display: inline-flex;
229
+ align-items: center;
230
+ justify-content: center;
231
+ padding: 8px 16px;
232
+ font-family: inherit;
233
+ font-size: 15px;
234
+ font-weight: 500;
235
+ color: #fff;
236
+ background: var(--accent);
237
+ border: none;
238
+ border-radius: var(--radius);
239
+ cursor: pointer;
240
+ }
241
+
242
+ button:hover,
243
+ input[type="submit"]:hover {
244
+ filter: brightness(1.1);
245
+ }
246
+
247
+ /* List */
248
+ article {
249
+ padding: 16px 0;
250
+ border-bottom: 1px solid var(--border);
251
+ }
252
+
253
+ article:first-of-type {
254
+ padding-top: 0;
255
+ }
256
+
257
+ article:last-of-type {
258
+ border-bottom: none;
259
+ }
260
+
261
+ article h2 {
262
+ margin-bottom: 4px;
263
+ font-size: 17px;
264
+ }
265
+
266
+ article h2 a {
267
+ color: var(--fg);
268
+ }
269
+
270
+ article h2 a:hover {
271
+ color: var(--accent);
272
+ text-decoration: none;
273
+ }
274
+
275
+ article p {
276
+ margin: 0;
277
+ font-size: 15px;
278
+ }
279
+
280
+ article p code {
281
+ font-size: 12px;
282
+ }