phlex-forms 0.1.0 → 0.2.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c3cb45e86fb22d3240e3d9b0441476376004c2996c2b08e682c00e86ad51fd7
4
- data.tar.gz: d5abd208265060aade20c4b70a834c3404124dec41534892e243babae60f19b0
3
+ metadata.gz: 07e978bd7062d93d342a6048d51a54559abecf20098ae39eda81c10497856578
4
+ data.tar.gz: 30c5714c27d7a892da6eb591a4c3e98cae860d0d971d47932e7eed2f127fe1da
5
5
  SHA512:
6
- metadata.gz: 42908177da2fdddef940576259b03ede3a07b7e561704d4eaa4d30dd5b3768d163b02cb03a0ec2c98fb9912923043f55fe41aaae64fc34709318329703c2961e
7
- data.tar.gz: db8f2f000cfe38ceb447f016b39aac2f3fc9a48c8749aab309f48e56019060b8516707fd43dfd726338a683ffc441e65c2b926e09bf9c797acca1feb959f843f
6
+ metadata.gz: 5ab143d433e88c413810036f0b53cc3f70c3eb74c75b0d5fb4f319b94c44ba9b62b003156274a95d67aa048623edeeafab45fc6e0bcc17875817c980f956126d
7
+ data.tar.gz: 502f58b8543f1b31ba0700a24da4588875945b38f54bf26ac411fa13b88c304f9d2250d822357e327a689ec58863b2d3651d16bb09ed20f7c486c24193259888
data/CHANGELOG.md CHANGED
@@ -9,6 +9,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Added
11
11
 
12
+ - **`Forms::Base` declarative form classes**: subclass, declare fields in
13
+ `#fields` where `self` IS the form (bare `field :email`, no `f.` prefix),
14
+ render with `render UserForm.new(model: @user)`. Class-level `form_options`
15
+ defaults are inherited and merged down the subclass chain; a render-time
16
+ block appends after the declared fields.
17
+ - **`live` server-truth validation** (`Forms::Base` only, phlex-reactive soft
18
+ dependency): `live model: User` runs the real ActiveModel validators
19
+ server-side on blur/debounced input — uniqueness, `:if`/`:unless`,
20
+ confirmation, `:on` context — and morphs errors back in with focus
21
+ preserved. Touched-field tracking rides the signed reactive state; nothing
22
+ is persisted. Inline `Form(live: true)` raises with guidance.
23
+ - **Model-driven type inference** in `field`: boolean column → toggle, AR enum
24
+ → humanized select, non-polymorphic `belongs_to` → association select (name
25
+ rewritten to the foreign key, errors still surface from the association
26
+ name), text column → textarea, date/datetime/time/numeric columns → matching
27
+ inputs (+`step`), `has_rich_text` → rich textarea, attachments → file
28
+ (`multiple` + `name[]` for `has_many_attached`), length/numericality
29
+ validators → `maxlength`/`min`/`max`. Explicit `as:`/`choices:`/positional
30
+ type modifiers always win; POROs and untyped models behave exactly as
31
+ before. Kill-switch: `PhlexForms.config.infer_from_model = false`.
32
+ - `choices:` without `as: :select` now implies a select (previously silently
33
+ dropped).
34
+ - **Themes**: component roles resolve through `PhlexForms::Theme`. The daisy
35
+ theme (default when the daisyui gem is loaded) is unchanged; the new Plain
36
+ theme renders bare semantic HTML — same binding contract, variants accepted
37
+ and ignored, `aria-invalid` + `role="alert"` + `data-field-error/hint`
38
+ hooks, zero styling classes — so the same form class renders in non-daisyui
39
+ projects. Select per form (`theme:`), per class (`form_options theme:`), or
40
+ globally; override single roles with `Theme#with`.
41
+ - **Form-level variant defaults**: `field_variants: [:primary, :sm]` on a
42
+ form / `form_options` / `PhlexForms.config.field_variants` prepends daisyui
43
+ variants to every `field`'s inner input (call-site modifiers stack last).
44
+ - Layout helpers: `f.row(columns:)` (responsive grid) and `f.group(legend:)`
45
+ (fieldset), available on inline forms and `fields_for` builders.
46
+ - Adoption enablers: `Form(scope: false)` emits bare field names (reactive row
47
+ editors, `<template>` rows); `fields_for(..., nested_attributes: false)`
48
+ skips the `_attributes` suffix (JSONB columns); public `Form#field_value`
49
+ joins `field_name`/`field_id` for external widgets.
50
+
51
+ ### Changed
52
+
53
+ - **Minimum Ruby is now 3.4** (was 3.2). The optional `live` validation runs on
54
+ phlex-reactive, which has required Ruby >= 3.4 since its 0.9 line; aligning the
55
+ floor keeps the whole gem installable wherever the live integration is.
56
+ - **`daisyui` is now a soft dependency** (removed from the gemspec). With it
57
+ loaded nothing changes; without it the Plain theme is the default and the
58
+ gem renders unstyled semantic HTML.
59
+ - **Live validation is compatible with phlex-reactive >= 0.11's default-ON
60
+ `verify_authorized` (#168).** The `:validate` action declares
61
+ `skip_verify_authorized` — it's a no-persist, read-only validation pass, so it
62
+ needs no authorization call and won't raise `AuthorizationNotVerified`. No host
63
+ action required.
64
+ - Model-driven inference changes rendered output for ActiveRecord-backed
65
+ forms that previously fell through to a text input. Before/after:
66
+
67
+ | field on an AR model | before | after |
68
+ | --- | --- | --- |
69
+ | boolean column | `<input type="text">` | toggle (checkbox + hidden pair) |
70
+ | text column | `<input type="text">` | `<textarea>` |
71
+ | AR enum | text input | `<select>` with humanized options |
72
+ | `belongs_to` (`:country`/`:country_id`) | text input | `<select>` over the association, `name="…[country_id]"` |
73
+ | date/datetime/time column | text input (unless name-mapped) | matching input type |
74
+ | integer/decimal/float column | text input | `<input type="number" step=…>` |
75
+ | attachment / rich text | text input | file input / rich textarea |
76
+
77
+ Pass `as:` explicitly or set `PhlexForms.config.infer_from_model = false`
78
+ to keep the old rendering.
79
+
80
+ ### Initial extraction
81
+
12
82
  - Initial extraction of the `Forms::` Phlex form builder from the Cosmos apps.
13
83
  - Control-first builder API: `f.field :email, label:, hint:, as:, choices:`
14
84
  renders label + input + error/hint in one call, inferring input type and the
data/README.md CHANGED
@@ -1,20 +1,38 @@
1
1
  # phlex-forms
2
2
 
3
- A model-bound, DaisyUI-styled form builder for [Phlex](https://www.phlex.fun).
3
+ A model-bound form builder for [Phlex](https://www.phlex.fun) — forms as
4
+ first-class Phlex classes, types inferred from your model, DaisyUI styling by
5
+ default (and a plain-HTML theme when you want none), and optional
6
+ **server-truth live validation** over
7
+ [phlex-reactive](https://github.com/mhenrixon/phlex-reactive).
4
8
 
5
- `Form(model:) { |f| f.field :email }` renders a label, an input, and an
6
- error/hint in **one call** — inferring the input type from the attribute name and
7
- the `required` flag from the model's validations. It's built on the
8
- [`daisyui`](https://github.com/mhenrixon/daisyui) gem, so every field is a thin
9
- binding layer over a real DaisyUI component: positional variants stack, blocks
10
- pass through, and the markup stays DaisyUI v5-correct.
9
+ ```ruby
10
+ class UserForm < Forms::Base
11
+ live model: User # real validators, live, focus preserved
12
+
13
+ def fields # self IS the form no f. prefix
14
+ field :email # name type=email, required inferred
15
+ field :role # AR enum → select, humanized
16
+ field :country # belongs_to → select over the association
17
+ field :notify # boolean column → toggle
18
+ field :bio # text column → textarea
19
+ row do
20
+ field :first_name
21
+ field :last_name
22
+ end
23
+ submit :primary
24
+ end
25
+ end
26
+
27
+ render UserForm.new(model: @user)
28
+ ```
29
+
30
+ For one-offs, the inline builder does the same with a yielded form:
11
31
 
12
32
  ```ruby
13
- Form(:spaced, model: @user) do |f|
14
- f.field :email, hint: t("users.email_hint") # type=email, required inferred
15
- f.field :role, as: :select, choices: roles
16
- f.field :bio, as: :textarea, rows: 6
17
- f.field :notify, as: :toggle
33
+ Form(model: @user) do |f|
34
+ f.field :email, hint: t("users.email_hint")
35
+ f.field :bio
18
36
  f.submit :primary
19
37
  end
20
38
  ```
@@ -24,6 +42,8 @@ end
24
42
  ```ruby
25
43
  # Gemfile
26
44
  gem "phlex-forms"
45
+ gem "daisyui" # optional — the daisy theme; omit for plain semantic HTML
46
+ gem "phlex-reactive" # optional — the `live` server-truth validation
27
47
  ```
28
48
 
29
49
  phlex-forms exposes its components under the `Forms::` namespace as a
@@ -36,36 +56,109 @@ class ApplicationComponent < Phlex::HTML
36
56
  end
37
57
  ```
38
58
 
39
- Now `Form(...)`, `Submit(...)`, and every other `Forms::*` component are available
40
- as bare kit helpers.
59
+ ## Form classes (`Forms::Base`)
60
+
61
+ Subclass `Forms::Base`, declare the fields in `#fields`, render. The whole
62
+ builder surface (`field`, `row`, `group`, `Input`, `submit`, `fields_for`, …)
63
+ is available as bare calls — the form is an object you can reuse, subclass,
64
+ and test in isolation.
65
+
66
+ ```ruby
67
+ class ApplicationForm < Forms::Base
68
+ form_options :spaced, field_variants: [:primary] # inherited defaults
69
+ end
70
+
71
+ class UserForm < ApplicationForm
72
+ form_options url: "/signup" # merged over the parent's
73
+
74
+ def fields
75
+ field :email
76
+ submit :primary
77
+ end
78
+ end
79
+
80
+ render UserForm.new(model: @user) # instance args win
81
+ render UserForm.new(model: @user) { |f| f.Hidden(:token) } # appends after fields
82
+ ```
41
83
 
42
84
  ## The `field` API
43
85
 
44
- `f.field(name, *modifiers, **options)` is the primary verb. It renders a
45
- `form-control` wrapping a label, the input, and an error (or hint):
86
+ `f.field(name, *modifiers, **options)` is the primary verb: one call renders a
87
+ control wrapping the label, the input, and an error (or hint).
46
88
 
47
89
  | Option | Effect |
48
90
  | --- | --- |
49
91
  | `label:` | Label text. Defaults to the model's humanized attribute name. `label: false` omits it. |
50
92
  | `hint:` | Help text shown when there is no error. |
51
- | `as:` | Override the control: `:select`, `:textarea`, `:toggle`, `:checkbox`, `:file`, `:radio`, `:hidden`, or any text-like type. |
93
+ | `as:` | Override the control: `:select`, `:textarea`, `:toggle`, `:checkbox`, `:file`, `:radio`, `:hidden`, `:rich_textarea`, or any text-like type. |
52
94
  | `required:` | Force the required flag. Otherwise inferred from the model's presence validators. |
53
- | `choices:` | Choices for `as: :select`. |
95
+ | `choices:` | Choices for a select (implies `as: :select`). |
54
96
  | positional modifiers | daisyui variants — `:primary`, `:lg`, `:ghost`, … — stacked onto the input. |
55
97
 
56
- Type is inferred from the attribute name (`email` → `type=email`, `password` →
57
- `type=password`, `phone` → `type=tel`, …).
98
+ ### Model-driven inference
99
+
100
+ `field :x` interrogates the bound model, so `as:` is an override, not a
101
+ requirement. Precedence (first hit wins):
102
+
103
+ 1. Explicit `as:`
104
+ 2. A positional type modifier (`field :price, :number`)
105
+ 3. `choices:` → select
106
+ 4. Model structure — `has_rich_text` → rich textarea; ActiveStorage attachment
107
+ → file input (`multiple` for `has_many_attached`); ActiveRecord enum →
108
+ select over humanized keys; non-polymorphic `belongs_to` (as `:country` or
109
+ `:country_id`) → select over the association (`name="user[country_id]"`,
110
+ label and required flag from the association, errors surface from
111
+ `:country`)
112
+ 5. Non-string column type — boolean → toggle, text → textarea,
113
+ date/datetime/time → matching inputs, integer/decimal/float → number with
114
+ a sensible `step`
115
+ 6. The attribute-name map (`email` → `type=email`, `password` →
116
+ `type=password`, `phone` → `type=tel`, …)
117
+ 7. `type=text`
118
+
119
+ Length and numericality validators also emit `maxlength`/`min`/`max`
120
+ (conditional validators are skipped; caller options always win). Everything is
121
+ duck-typed behind `respond_to?` guards — plain objects, Structs, and form
122
+ objects fall through to the name map, exactly as before. Opt out entirely
123
+ with:
124
+
125
+ ```ruby
126
+ PhlexForms.configure { |c| c.infer_from_model = false }
127
+ ```
128
+
129
+ Association selects load `klass.all` per render — pass `choices:` to scope,
130
+ order, or cache.
58
131
 
59
- ### Escape hatches
132
+ ### Variants
60
133
 
61
- When you need full control (custom Stimulus wiring, bespoke layout), the
62
- lower-level component methods are always available with stable signatures:
134
+ daisyui variants stack positionally, per field or as defaults:
63
135
 
64
136
  ```ruby
65
- f.Control(:email, label: "Email") do
66
- f.Input(:email, :primary, data: { controller: "autocomplete" })
137
+ f.field :email, :primary, :sm # this field
138
+ Form(model: @user, field_variants: [:primary]) # every field in this form
139
+ form_options field_variants: [:primary] # every field in this class
140
+ PhlexForms.configure { |c| c.field_variants = [:sm] } # everywhere
141
+ ```
142
+
143
+ Defaults stack first, call-site modifiers last — the most local wins.
144
+
145
+ ### Layout
146
+
147
+ ```ruby
148
+ row { field :first_name; field :last_name } # responsive grid, columns: 2|3|4
149
+ group(legend: "Address") do # <fieldset> + legend
150
+ field :street
151
+ field :city
67
152
  end
153
+ ```
154
+
155
+ Both work on inline forms (`f.row { … }`) and inside `fields_for` builders.
156
+
157
+ ## Escape hatches & custom widgets
68
158
 
159
+ The lower-level component methods are always available with stable signatures:
160
+
161
+ ```ruby
69
162
  f.Input(:name, :primary, :lg) # bare input, variants stacked
70
163
  f.Select(:role, choices: roles) # native <select>
71
164
  f.Textarea(:bio, :ghost)
@@ -73,51 +166,109 @@ f.Checkbox(:terms) ; f.Toggle(:notify) ; f.FileInput(:avatar)
73
166
  f.Label(:email) ; f.Hidden(:token) ; f.submit("Save", :primary)
74
167
  ```
75
168
 
76
- ### Icons inside a field (DaisyUI v5)
169
+ For a bespoke widget (date picker, tag field, remote select), wrap it in
170
+ `f.Control` and bind through the public helpers — this is the supported path,
171
+ not a fork reason:
172
+
173
+ ```ruby
174
+ f.Control(:starts_at, label: "Starts") do
175
+ render MyDatePicker.new(
176
+ name: f.field_name(:starts_at),
177
+ id: f.field_id(:starts_at),
178
+ value: f.field_value(:starts_at)
179
+ )
180
+ end
181
+ ```
77
182
 
78
- `WrappedInput` renders the `<label class="input">{icon}{input}` pattern:
183
+ Icons inside a field (daisyui v5 `<label class="input">` pattern):
79
184
 
80
185
  ```ruby
81
186
  f.field(:search).wrapped_input(:primary) do
82
- LucideIcon("search", class: "opacity-50") # leading content
187
+ LucideIcon("search", class: "opacity-50")
83
188
  end
84
189
  ```
85
190
 
86
- ## Nested attributes & collections
191
+ ## Themes using phlex-forms without daisyui
192
+
193
+ Every component resolves through a theme (a role → component-class map). With
194
+ the daisyui gem loaded, the daisy theme is the default. Without it — or on
195
+ demand — the **Plain theme** renders bare semantic HTML: the same binding
196
+ (names, ids, values, required, errors), variants accepted and ignored, no
197
+ styling classes, and stable hooks (`aria-invalid`, `role="alert"`,
198
+ `data-field-error`, `data-field-hint`, `data-form-row`) for your own CSS.
87
199
 
88
200
  ```ruby
89
- f.fields_for(:line_items) do |item| # single assoc or has_many
90
- item.field :description
91
- end
201
+ render UserForm.new(model: @user, theme: :plain) # per render
202
+ form_options theme: :plain # per class
203
+ PhlexForms.configure { |c| c.theme = :plain } # global
92
204
 
93
- f.collection_check_boxes(:role_ids, Role.all, :id, :name) do |b|
94
- render b.check_box
95
- render b.label
205
+ # override single roles:
206
+ PhlexForms.configure do |c|
207
+ c.theme = PhlexForms::Theme.resolve(:plain).with(input: MyInput)
96
208
  end
209
+ ```
97
210
 
98
- f.collection_select(:country_id, Country.all, :id, :name, prompt: "Select…")
211
+ The same `UserForm` class renders under either theme — write the form once,
212
+ style it per project. (Plain degradations: `searchable:` selects fall back to
213
+ the native select; `rich_textarea` falls back to a plain textarea.)
214
+
215
+ ## Live validation (server-truth, via phlex-reactive)
216
+
217
+ The `live` macro turns the whole form into one reactive component. Blurring a
218
+ field (or typing, debounced) POSTs **all** form fields to a single signed
219
+ `:validate` action; the server assigns a whitelisted slice to the model, runs
220
+ the **real** ActiveModel validators, and morphs the errors back in — the
221
+ focused input and caret survive.
222
+
223
+ ```ruby
224
+ class UserForm < Forms::Base
225
+ live model: User, debounce: 300
226
+
227
+ def fields
228
+ field :email # uniqueness validates against the real DB
229
+ field :password
230
+ field :password_confirmation # cross-field confirmation just works
231
+ submit :primary
232
+ end
233
+ end
99
234
  ```
100
235
 
101
- ## Client-side validation
236
+ What you get over any client-side mirror:
102
237
 
103
- phlex-forms ships a Stimulus validation framework that mirrors your ActiveModel
104
- validators no ugly, browser-inconsistent native validation bubbles.
238
+ - **One source of truth** uniqueness, `:if`/`:unless`, `:on` contexts,
239
+ cross-field and custom validators all run, because it *is* your model.
240
+ - **i18n is plain Rails i18n** — no duplicated message catalogs.
241
+ - **No premature errors** — a field's error first appears on blur (`touched`
242
+ tracking rides the signed token; zero client-side bookkeeping), while fixing
243
+ a field live-updates errors of fields you already touched.
244
+ - **Progressive enhancement** — nothing is ever persisted by `:validate`;
245
+ native submit and your controller stay authoritative. A failed-submit 422
246
+ re-render shows all errors as usual.
247
+
248
+ Constraints: `live` needs a `Forms::Base` subclass (the endpoint rebuilds the
249
+ form from its class — an inline block cannot be serialized; `Form(live: true)`
250
+ raises and says so). Collection controls (`collection_check_boxes`,
251
+ multi-selects) are excluded from live assignment in v1. Use
252
+ `live_permit`/`live_deny` to adjust the assignable attributes; setters run on
253
+ an in-memory model only.
254
+
255
+ ### Client-side fallback (`validate: true`)
256
+
257
+ Without phlex-reactive, the bundled Stimulus framework mirrors your validators
258
+ client-side:
105
259
 
106
260
  ```ruby
107
261
  Form(model: @partner, validate: true) do |f|
108
262
  f.field :title # every validator on :title
109
- f.field :slug, validate: false # opt this field out
263
+ f.field :slug, validate: false # opt this field out
110
264
  f.field :note, validate: { length: { maximum: 30 } } # explicit rules
111
265
  end
112
266
  ```
113
267
 
114
- When `validate: true`, the form gets `novalidate` and a submit coordinator; each
115
- field emits `data-forms--validations--*` bindings introspected from the model.
116
268
  Supported: presence, length (with a live counter), format, numericality,
117
- inclusion, exclusion, confirmation, acceptance. Validators with `:if` / `:unless`
118
- / `:on` are skipped (they need server context); the server stays authoritative.
119
-
120
- Register the controllers in your Stimulus setup:
269
+ inclusion, exclusion, confirmation, acceptance. Validators with
270
+ `:if`/`:unless`/`:on` are skipped; uniqueness can't be checked client-side
271
+ the server stays authoritative. Register the controllers:
121
272
 
122
273
  ```js
123
274
  // app/javascript/controllers/index.js
@@ -125,13 +276,36 @@ import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
125
276
  lazyLoadControllersFrom("phlex_forms/controllers", application)
126
277
  ```
127
278
 
128
- Validation messages ship for `en` / `fr` / `af`; override any string via
129
- `window.PhlexForms.messages`.
279
+ Messages ship for `en` / `fr` / `af`; override via `window.PhlexForms.messages`.
280
+
281
+ ## Nested attributes, collections & escape valves
282
+
283
+ ```ruby
284
+ f.fields_for(:line_items) do |item| # single assoc or has_many
285
+ item.field :description
286
+ end
287
+
288
+ f.fields_for(:settings, nested_attributes: false) do |s|
289
+ s.field :locale # user[settings][locale] — JSONB/hash columns
290
+ end
291
+
292
+ f.collection_check_boxes(:role_ids, Role.all, :id, :name) do |b|
293
+ render b.check_box
294
+ render b.label
295
+ end
296
+
297
+ f.collection_select(:country_id, Country.all, :id, :name, prompt: "Select…")
298
+ ```
299
+
300
+ `Form(model: @item, scope: false)` emits **bare** field names
301
+ (`name="quantity"`) — the shape phlex-reactive row editors and
302
+ `<template>`-cloned rows need. External widgets bind through the public
303
+ `f.field_name` / `f.field_id` / `f.field_value` helpers.
130
304
 
131
305
  ## Icons
132
306
 
133
307
  Icons default to a bundled inline SVG so the gem is self-contained. To use
134
- [`glyphs`](https://rubygems.org/gems/glyphs) (rails-icons under the hood):
308
+ [`glyphs`](https://rubygems.org/gems/glyphs):
135
309
 
136
310
  ```ruby
137
311
  PhlexForms.configure do |c|
@@ -155,10 +329,13 @@ inherit_gem:
155
329
  - `PhlexForms/LegacyFormMethod` — use `form.field(...)` / the PascalCase methods
156
330
  over Rails-style `text_field` / `select` / etc.
157
331
 
158
- ## JavaScript peer dependencies
332
+ ## Dependencies
159
333
 
160
- - `@hotwired/stimulus` required.
161
- - `choices.js` — only if you use searchable/multi selects (`searchable: true`).
334
+ - Hard: `phlex` (~> 2.0), `activesupport`, `zeitwerk`, `glyphs`.
335
+ - Soft: `daisyui` (the daisy theme without it the Plain theme is the
336
+ default), `phlex-reactive` (the `live` macro).
337
+ - JS peers: `@hotwired/stimulus` (client validation fallback), `choices.js`
338
+ (only for `searchable: true` selects).
162
339
 
163
340
  ## Companion
164
341
 
data/lib/forms/base.rb ADDED
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # Declarative form classes: subclass, declare the fields in #fields, render.
5
+ # Inside #fields, `self` IS the form — the whole builder surface (field, row,
6
+ # group, Input, submit, fields_for, ...) is available as bare calls.
7
+ #
8
+ # class UserForm < Forms::Base
9
+ # form_options :spaced
10
+ #
11
+ # def fields
12
+ # field :email
13
+ # row { field :first_name; field :last_name }
14
+ # submit :primary
15
+ # end
16
+ # end
17
+ #
18
+ # render UserForm.new(model: @user)
19
+ #
20
+ # A render-time block appends after the declared fields:
21
+ #
22
+ # render UserForm.new(model: @user) { |f| f.Hidden(:token) }
23
+ class Base < Form
24
+ class << self
25
+ # Class-level defaults, inherited and merged down the subclass chain
26
+ # (instance args beat class defaults; subclass defaults beat parents'):
27
+ #
28
+ # form_options :spaced, url: "/signup", validate: true
29
+ def form_options(*modifiers, **defaults)
30
+ @form_modifiers = modifiers
31
+ @form_defaults = defaults
32
+ end
33
+
34
+ def form_modifiers
35
+ inherited = superclass.respond_to?(:form_modifiers) ? superclass.form_modifiers : []
36
+ (inherited + (@form_modifiers || [])).uniq
37
+ end
38
+
39
+ def form_defaults
40
+ inherited = superclass.respond_to?(:form_defaults) ? superclass.form_defaults : {}
41
+ inherited.merge(@form_defaults || {})
42
+ end
43
+
44
+ # Server-truth live validation via phlex-reactive (a soft dependency):
45
+ #
46
+ # class UserForm < Forms::Base
47
+ # live model: User
48
+ # def fields = field(:email)
49
+ # end
50
+ #
51
+ # Blur/debounced input POST every field to a :validate action that runs
52
+ # the REAL model validators and morphs the errors back in, focus intact.
53
+ # Only Forms::Base subclasses can be live — the endpoint rebuilds the
54
+ # form from its class; an inline block cannot be serialized.
55
+ def live(model:, scope: nil, debounce: 300)
56
+ unless reactive_available?
57
+ raise PhlexForms::FeatureUnavailable,
58
+ "#{name || 'this form'} declares `live` but the phlex-reactive gem is not " \
59
+ "installed. Add `gem \"phlex-reactive\"` to your Gemfile, or use " \
60
+ "`validate: true` for the client-side Stimulus mirror instead."
61
+ end
62
+
63
+ include Forms::Live
64
+
65
+ setup_live(model_class: model, scope: scope || derive_live_scope(model), debounce:)
66
+ end
67
+
68
+ # Extracted so specs can exercise the FeatureUnavailable guard.
69
+ def reactive_available?
70
+ defined?(Phlex::Reactive::Component) ? true : false
71
+ end
72
+
73
+ private
74
+
75
+ def derive_live_scope(model)
76
+ model.respond_to?(:model_name) ? model.model_name.param_key : model.name.underscore.tr("/", "_")
77
+ end
78
+ end
79
+
80
+ def initialize(*modifiers, **options)
81
+ super(*(self.class.form_modifiers + modifiers).uniq, **self.class.form_defaults.merge(options))
82
+ end
83
+
84
+ # Renders Form's chrome (action/method/CSRF/enctype), then the declared
85
+ # fields. `yield`/`block_given?` inside the inner block bind to
86
+ # view_template's own block — the optional render-time block.
87
+ def view_template
88
+ super do
89
+ fields
90
+ yield self if block_given?
91
+ end
92
+ end
93
+
94
+ # The hook name `fields` is verified free against Phlex::HTML (phlex 2.4.x)
95
+ # and this gem (Form defines fields_for/field_object, never fields).
96
+ def fields
97
+ raise NotImplementedError, "#{self.class} must define #fields"
98
+ end
99
+ end
100
+ end