superform 0.6.0 → 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 (40) 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 -9
  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 +184 -0
  29. data/lib/superform/rails/form.rb +18 -129
  30. data/lib/superform/version.rb +1 -1
  31. data/server/components/breadcrumb.rb +11 -0
  32. data/server/components/form_card.rb +13 -0
  33. data/server/components/layout.rb +23 -0
  34. data/server/controllers/forms_controller.rb +94 -0
  35. data/server/models/example.rb +31 -0
  36. data/server/public/styles.css +282 -0
  37. data/server/rails.rb +56 -0
  38. data/superform.gemspec +37 -0
  39. metadata +28 -5
  40. data/lib/superform/rails/option_mapper.rb +0 -36
@@ -14,146 +14,35 @@ module Superform
14
14
  include Phlex::Rails::Helpers::FormAuthenticityToken
15
15
  include Phlex::Rails::Helpers::URLFor
16
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:
17
+ # The `Field` class is nested inside the `Form` class so it can be easily extended
18
+ # to customize the form inputs for your application. For example, if you wanted to
19
+ # add some default classes to all your inputs and labels you could do something like:
30
20
  #
31
21
  # ```ruby
32
22
  # 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)
23
+ # class Field < self::Field
24
+ # def input(**attributes)
25
+ # super(class: "input input-bordered", **attributes)
36
26
  # end
37
- # end
38
27
  #
39
- # class Field < Field
40
- # def label(**attributes)
41
- # MyLabel.new(self, **attributes)
28
+ # def label(**attributes, &block)
29
+ # super(class: "label", **attributes, &block)
42
30
  # end
43
31
  # end
44
32
  # end
45
33
  # ```
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
34
+ Field = Superform::Rails::Field
60
35
 
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
36
+ attr_accessor :model
152
37
 
153
- def title
154
- key.to_s.titleize
155
- end
156
- end
38
+ delegate \
39
+ :Field,
40
+ :field,
41
+ :collection,
42
+ :namespace,
43
+ :assign,
44
+ :serialize,
45
+ to: :@namespace
157
46
 
158
47
  def build_field(...)
159
48
  self.class::Field.new(...)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Superform
4
- VERSION = "0.6.0"
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
+ }
data/server/rails.rb ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ require "active_model"
6
+ require "action_controller/railtie"
7
+ require "phlex-rails"
8
+
9
+ # Load superform from this repo
10
+ require_relative "../lib/superform"
11
+ require_relative "../lib/superform/rails"
12
+
13
+ # Directories
14
+ SERVER_DIR = Pathname.new(__dir__)
15
+ EXAMPLES_DIR = SERVER_DIR.join("../examples").expand_path
16
+
17
+ # Minimal Rails app
18
+ class SuperformApp < Rails::Application
19
+ config.root = SERVER_DIR
20
+ config.eager_load = false
21
+ config.consider_all_requests_local = true
22
+ config.secret_key_base = "superform-demo-secret-key-base-for-development-only"
23
+ config.hosts.clear
24
+ config.public_file_server.enabled = true
25
+
26
+ config.autoload_paths << root.join("components")
27
+ config.autoload_paths << root.join("models")
28
+ config.autoload_paths << root.join("controllers")
29
+
30
+ config.autoload_paths << EXAMPLES_DIR
31
+ end
32
+
33
+ # Collect form classes dynamically
34
+ def form_classes
35
+ EXAMPLES_DIR.glob("*.rb").sort.filter_map do |path|
36
+ next if path.basename.to_s == "example_form.rb"
37
+ cpath = Rails.autoloaders.main.cpath_expected_at(path)
38
+ Object.const_get(cpath)
39
+ end
40
+ end
41
+
42
+ SuperformApp.initialize!
43
+
44
+ SuperformApp.routes.draw do
45
+ root to: "forms#index"
46
+ get "/forms/:id" => "forms#show", as: :form
47
+ post "/forms/:id" => "forms#create"
48
+ end
49
+
50
+ def self.start(port: 3000)
51
+ require "rackup"
52
+ puts "Starting Superform Examples on http://localhost:#{port}"
53
+ puts "Press Ctrl+C to stop"
54
+ puts
55
+ Rackup::Handler::WEBrick.run(SuperformApp, Port: port)
56
+ end