codeforms 0.2.0__tar.gz → 0.2.2__tar.gz

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 (36) hide show
  1. {codeforms-0.2.0 → codeforms-0.2.2}/.gitignore +3 -0
  2. codeforms-0.2.2/PKG-INFO +675 -0
  3. codeforms-0.2.2/README.md +662 -0
  4. {codeforms-0.2.0 → codeforms-0.2.2}/examples/basic_usage.py +7 -5
  5. {codeforms-0.2.0 → codeforms-0.2.2}/examples/conditional_visibility.py +4 -2
  6. {codeforms-0.2.0 → codeforms-0.2.2}/examples/custom_fields.py +7 -4
  7. {codeforms-0.2.0 → codeforms-0.2.2}/examples/dependent_options.py +2 -1
  8. {codeforms-0.2.0 → codeforms-0.2.2}/examples/i18n_usage.py +25 -17
  9. {codeforms-0.2.0 → codeforms-0.2.2}/examples/wizard_form.py +31 -23
  10. codeforms-0.2.2/pyproject.toml +49 -0
  11. {codeforms-0.2.0 → codeforms-0.2.2}/src/codeforms/__init__.py +25 -22
  12. codeforms-0.2.2/src/codeforms/export.py +653 -0
  13. {codeforms-0.2.0 → codeforms-0.2.2}/src/codeforms/fields.py +86 -38
  14. {codeforms-0.2.0 → codeforms-0.2.2}/src/codeforms/forms.py +343 -318
  15. {codeforms-0.2.0 → codeforms-0.2.2}/src/codeforms/i18n.py +1 -30
  16. {codeforms-0.2.0 → codeforms-0.2.2}/src/codeforms/registry.py +43 -18
  17. {codeforms-0.2.0 → codeforms-0.2.2}/tests/conftest.py +0 -1
  18. {codeforms-0.2.0 → codeforms-0.2.2}/tests/test_basic_usage.py +6 -2
  19. {codeforms-0.2.0 → codeforms-0.2.2}/tests/test_dependent_options.py +11 -9
  20. {codeforms-0.2.0 → codeforms-0.2.2}/tests/test_dynamic_visibility.py +39 -20
  21. {codeforms-0.2.0 → codeforms-0.2.2}/tests/test_i18n.py +18 -13
  22. codeforms-0.2.2/tests/test_json_schema_export.py +640 -0
  23. codeforms-0.2.2/tests/test_object_list_field.py +166 -0
  24. {codeforms-0.2.0 → codeforms-0.2.2}/tests/test_phase2_compat.py +9 -13
  25. {codeforms-0.2.0 → codeforms-0.2.2}/tests/test_registry.py +54 -29
  26. {codeforms-0.2.0 → codeforms-0.2.2}/tests/test_wizard_steps.py +25 -18
  27. codeforms-0.2.0/PKG-INFO +0 -325
  28. codeforms-0.2.0/README.md +0 -313
  29. codeforms-0.2.0/pyproject.toml +0 -25
  30. codeforms-0.2.0/src/codeforms/export.py +0 -400
  31. codeforms-0.2.0/uv.lock +0 -416
  32. {codeforms-0.2.0 → codeforms-0.2.2}/.github/workflows/python-publish.yml +0 -0
  33. {codeforms-0.2.0 → codeforms-0.2.2}/.github/workflows/tests.yml +0 -0
  34. {codeforms-0.2.0 → codeforms-0.2.2}/.python-version +0 -0
  35. {codeforms-0.2.0 → codeforms-0.2.2}/LICENSE +0 -0
  36. {codeforms-0.2.0 → codeforms-0.2.2}/tests/__init__.py +0 -0
@@ -7,6 +7,7 @@ wheels/
7
7
 
8
8
  # Virtual environments
9
9
  .venv
10
+ uv.lock
10
11
 
11
12
  # Private
12
13
  todo
@@ -15,6 +16,8 @@ info
15
16
  # Agents
16
17
  CLAUDE.md
17
18
  .claude
19
+ .agents
20
+ knowledge.md
18
21
 
19
22
  # Caches and temps
20
23
  .uv-cache
@@ -0,0 +1,675 @@
1
+ Metadata-Version: 2.4
2
+ Name: codeforms
3
+ Version: 0.2.2
4
+ Summary: Python library for creating, validating, and rendering web forms using Pydantic
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.9
8
+ Requires-Dist: pydantic[email]>=2.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8.0; extra == 'dev'
11
+ Requires-Dist: ruff>=0.8; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # codeforms
15
+
16
+ A Python library for dynamically creating, validating, and rendering web forms using [Pydantic](https://docs.pydantic.dev/).
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install codeforms
22
+ ```
23
+
24
+ Or with [uv](https://docs.astral.sh/uv/):
25
+
26
+ ```bash
27
+ uv add codeforms
28
+ ```
29
+
30
+ Requires Python 3.9+.
31
+
32
+ ## Quick Start
33
+
34
+ ### Creating a Form
35
+
36
+ Everything starts with the `Form` class. A form is defined with a name and a list of fields.
37
+
38
+ ```python
39
+ from codeforms import Form, TextField, EmailField, NumberField
40
+
41
+ form = Form(
42
+ name="UserRegistration",
43
+ fields=[
44
+ TextField(name="full_name", label="Full Name", required=True),
45
+ EmailField(name="email", label="Email", required=True),
46
+ NumberField(name="age", label="Age"),
47
+ ]
48
+ )
49
+ ```
50
+
51
+ ### The `Form` Class
52
+
53
+ The `Form` class is the main container for your form structure.
54
+
55
+ - `id` — Auto-generated UUID.
56
+ - `name` — Form name (used in HTML export and validation).
57
+ - `fields` — A list of field objects (e.g. `TextField`, `EmailField`).
58
+ - `css_classes` — Optional CSS classes for the `<form>` tag.
59
+ - `version` — Form version number.
60
+ - `attributes` — Dictionary of additional HTML attributes for the `<form>` tag.
61
+
62
+ ## Field Types
63
+
64
+ All fields inherit from `FormFieldBase` and share these common attributes:
65
+
66
+ - `name` — Field name (maps to `name` in HTML).
67
+ - `label` — User-visible label.
68
+ - `field_type` — Field type (`FieldType` enum).
69
+ - `required` — Whether the field is mandatory.
70
+ - `placeholder` — Placeholder text inside the field.
71
+ - `default_value` — Default value.
72
+ - `help_text` — Help text displayed below the field.
73
+ - `css_classes` — CSS classes for the field element.
74
+ - `readonly` — Whether the field is read-only.
75
+ - `attributes` — Additional HTML attributes for the `<input>` tag.
76
+
77
+ ### Available Fields
78
+
79
+ - **`TextField`** — Generic text input (`<input type="text">`).
80
+ - `minlength`, `maxlength`: Min/max text length.
81
+ - `pattern`: Regex pattern for validation.
82
+ - **`EmailField`** — Email address (`<input type="email">`).
83
+ - **`NumberField`** — Numeric value (`<input type="number">`).
84
+ - `min_value`, `max_value`: Allowed value range.
85
+ - `step`: Increment step.
86
+ - **`DateField`** — Date picker (`<input type="date">`).
87
+ - `min_date`, `max_date`: Allowed date range.
88
+ - **`SelectField`** — Dropdown select (`<select>`).
89
+ - `options`: List of `SelectOption(value="...", label="...")`.
90
+ - `multiple`: Enables multi-select.
91
+ - `min_selected`, `max_selected`: Selection count limits (multi-select only).
92
+ - **`RadioField`** — Radio buttons (`<input type="radio">`).
93
+ - `options`: List of `SelectOption`.
94
+ - `inline`: Display options inline.
95
+ - **`CheckboxField`** — Single checkbox (`<input type="checkbox">`).
96
+ - **`CheckboxGroupField`** — Group of checkboxes.
97
+ - `options`: List of `SelectOption`.
98
+ - `inline`: Display options inline.
99
+ - **`FileField`** — File upload (`<input type="file">`).
100
+ - `accept`: Accepted file types (e.g. `"image/*,.pdf"`).
101
+ - `multiple`: Allow multiple file uploads.
102
+ - **`HiddenField`** — Hidden field (`<input type="hidden">`).
103
+ - **`ListField`** — Array of primitive values.
104
+ - `item_type`: Primitive type for each item (`text`, `number`, `email`, `url`, `date`).
105
+ - `min_items`, `max_items`: List size limits.
106
+ - **`ObjectListField`** — Array of homogeneous objects validated against nested subfields.
107
+ - `fields`: List of subfields that define each object shape.
108
+ - `min_items`, `max_items`: List size limits.
109
+
110
+ ### `ObjectListField`
111
+
112
+ Use `ObjectListField` when a form needs a repeatable list of structured rows, such as parallel approvers, attendees with roles, or line items.
113
+
114
+ ```python
115
+ from codeforms import Form, ObjectListField, TextField, CheckboxField
116
+
117
+ form = Form(
118
+ name="parallel_approvers",
119
+ fields=[
120
+ ObjectListField(
121
+ name="parallel_approvals",
122
+ label="Aprobadores",
123
+ required=True,
124
+ min_items=1,
125
+ max_items=5,
126
+ fields=[
127
+ TextField(name="approver_email", label="Email", required=True),
128
+ TextField(name="label", label="Etiqueta", required=True),
129
+ CheckboxField(name="required", label="Obligatorio"),
130
+ ],
131
+ )
132
+ ],
133
+ )
134
+ ```
135
+
136
+ Expected submitted value:
137
+
138
+ ```json
139
+ {
140
+ "parallel_approvals": [
141
+ {
142
+ "approver_email": "ana@empresa.com",
143
+ "label": "Compras",
144
+ "required": true
145
+ },
146
+ {
147
+ "approver_email": "luis@empresa.com",
148
+ "label": "Finanzas"
149
+ }
150
+ ]
151
+ }
152
+ ```
153
+
154
+ Validation behavior:
155
+
156
+ - The top-level field must be a JSON array.
157
+ - Each item must be a JSON object.
158
+ - Unknown keys inside items are rejected.
159
+ - Required nested subfields are enforced.
160
+ - Validation errors include nested paths like `parallel_approvals[0].label`.
161
+
162
+ Current limitation:
163
+
164
+ - `ObjectListField` is fully supported in backend validation and JSON Schema export.
165
+ - Rich repeatable HTML UI generation is not implemented yet. If you need an interactive editor, prefer consuming the exported `json_schema` from your frontend.
166
+
167
+ ## Data Validation
168
+
169
+ codeforms offers multiple ways to validate user-submitted data, leveraging Pydantic's validation engine.
170
+
171
+ ### Recommended: `FormDataValidator`
172
+
173
+ The most robust approach is `FormDataValidator.create_model`, which dynamically generates a Pydantic model from your form definition. This gives you powerful validations and detailed error messages automatically.
174
+
175
+ ```python
176
+ from codeforms import Form, FormDataValidator, TextField, SelectField, SelectOption
177
+ from pydantic import ValidationError
178
+
179
+ # 1. Define your form
180
+ form = Form(
181
+ name="MyForm",
182
+ fields=[
183
+ TextField(name="name", label="Name", required=True),
184
+ SelectField(
185
+ name="country",
186
+ label="Country",
187
+ options=[
188
+ SelectOption(value="us", label="United States"),
189
+ SelectOption(value="uk", label="United Kingdom"),
190
+ ]
191
+ )
192
+ ]
193
+ )
194
+
195
+ # 2. Create the validation model
196
+ ValidationModel = FormDataValidator.create_model(form)
197
+
198
+ # 3. Validate incoming data
199
+ user_data = {"name": "John", "country": "us"}
200
+
201
+ try:
202
+ validated = ValidationModel.model_validate(user_data)
203
+ print("Valid!", validated)
204
+ except ValidationError as e:
205
+ print("Validation errors:", e.errors())
206
+ ```
207
+
208
+ This approach integrates seamlessly with API backends like FastAPI or Flask, since it produces standard Pydantic models.
209
+
210
+ ### Other Validation Methods
211
+
212
+ Two simpler alternatives exist, though `FormDataValidator` is preferred:
213
+
214
+ 1. `form.validate_data(data)` — Built-in method on the `Form` class. Less flexible; doesn't produce Pydantic models.
215
+ 2. `validate_form_data(form, data)` — Standalone function with basic validation logic.
216
+
217
+ ## Exporting Forms
218
+
219
+ Once your form is defined, you can export it to different formats.
220
+
221
+ ```python
222
+ # Export to plain HTML
223
+ html_output = form.export('html', submit=True)
224
+ print(html_output['output'])
225
+
226
+ # Export to HTML with Bootstrap 5 classes
227
+ bootstrap_output = form.export('html_bootstrap5', submit=True)
228
+ print(bootstrap_output['output'])
229
+
230
+ # Export to JSON
231
+ json_output = form.to_json()
232
+ print(json_output)
233
+
234
+ # Export to a Python dictionary
235
+ dict_output = form.to_dict()
236
+ print(dict_output)
237
+ ```
238
+
239
+ ### Supported Formats
240
+
241
+ | Format | Description |
242
+ |---|---|
243
+ | `html` | Semantic HTML |
244
+ | `html_bootstrap4` | HTML with Bootstrap 4 classes |
245
+ | `html_bootstrap5` | HTML with Bootstrap 5 classes |
246
+ | `json_schema` | [JSON Schema](http://json-schema.org/draft-07/schema#) (draft-07) |
247
+ | `json` | JSON representation of the form |
248
+ | `dict` | Python dictionary representation |
249
+
250
+ HTML export can also generate a `<script>` block for basic client-side validation.
251
+
252
+ ### JSON Schema Export
253
+
254
+ Generate a standard [JSON Schema (draft-07)](http://json-schema.org/draft-07/schema#) from any form. The resulting schema is compatible with tools like [React JSON Schema Form](https://github.com/rjsf-team/react-jsonschema-form), [Angular Formly](https://formly.dev/), and any JSON Schema validator.
255
+
256
+ ```python
257
+ import json
258
+ from codeforms import (
259
+ Form, TextField, EmailField, NumberField, SelectField, SelectOption,
260
+ CheckboxField, form_to_json_schema,
261
+ )
262
+
263
+ form = Form(
264
+ name="registration",
265
+ fields=[
266
+ TextField(name="name", label="Full Name", required=True, minlength=2, maxlength=100),
267
+ EmailField(name="email", label="Email", required=True),
268
+ NumberField(name="age", label="Age", min_value=18, max_value=120),
269
+ SelectField(
270
+ name="country",
271
+ label="Country",
272
+ required=True,
273
+ options=[
274
+ SelectOption(value="us", label="United States"),
275
+ SelectOption(value="uk", label="United Kingdom"),
276
+ ],
277
+ ),
278
+ CheckboxField(name="terms", label="Accept Terms", required=True),
279
+ ],
280
+ )
281
+
282
+ # Option 1: Direct function call
283
+ schema = form_to_json_schema(form)
284
+ print(json.dumps(schema, indent=2))
285
+
286
+ # Option 2: Via form.export()
287
+ result = form.export("json_schema")
288
+ schema = result["output"]
289
+ ```
290
+
291
+ Output:
292
+
293
+ ```json
294
+ {
295
+ "$schema": "http://json-schema.org/draft-07/schema#",
296
+ "type": "object",
297
+ "title": "registration",
298
+ "properties": {
299
+ "name": {
300
+ "type": "string",
301
+ "minLength": 2,
302
+ "maxLength": 100,
303
+ "title": "Full Name"
304
+ },
305
+ "email": {
306
+ "type": "string",
307
+ "format": "email",
308
+ "title": "Email"
309
+ },
310
+ "age": {
311
+ "type": "number",
312
+ "minimum": 18,
313
+ "maximum": 120,
314
+ "title": "Age"
315
+ },
316
+ "country": {
317
+ "type": "string",
318
+ "enum": ["us", "uk"],
319
+ "title": "Country"
320
+ },
321
+ "terms": {
322
+ "type": "boolean",
323
+ "title": "Accept Terms"
324
+ }
325
+ },
326
+ "required": ["name", "email", "country", "terms"],
327
+ "additionalProperties": false
328
+ }
329
+ ```
330
+
331
+ #### Field Type Mapping
332
+
333
+ | codeforms Field | JSON Schema Type | Extra Keywords |
334
+ |---|---|---|
335
+ | `TextField` | `string` | `minLength`, `maxLength`, `pattern` |
336
+ | `EmailField` | `string` (`format: "email"`) | — |
337
+ | `NumberField` | `number` | `minimum`, `maximum`, `multipleOf` |
338
+ | `DateField` | `string` (`format: "date"`) | — |
339
+ | `SelectField` | `string` + `enum` | — |
340
+ | `SelectField` (`multiple=True`) | `array` of `enum` strings | `minItems`, `maxItems`, `uniqueItems` |
341
+ | `RadioField` | `string` + `enum` | — |
342
+ | `CheckboxField` | `boolean` | — |
343
+ | `CheckboxGroupField` | `array` of `enum` strings | `uniqueItems` |
344
+ | `FileField` | `string` (`contentEncoding: "base64"`) | — |
345
+ | `FileField` (`multiple=True`) | `array` of base64 strings | — |
346
+ | `HiddenField` | `string` | — |
347
+ | `UrlField` | `string` (`format: "uri"`) | `minLength`, `maxLength` |
348
+ | `TextareaField` | `string` | `minLength`, `maxLength` |
349
+ | `ListField` | `array` | `minItems`, `maxItems` |
350
+ | `ObjectListField` | `array` of `object` | nested `properties`, nested `required`, `minItems`, `maxItems` |
351
+
352
+ Field annotations like `label`, `help_text`, `default_value`, and `readonly` map to the JSON Schema keywords `title`, `description`, `default`, and `readOnly` respectively.
353
+
354
+ Example `ObjectListField` schema:
355
+
356
+ ```json
357
+ {
358
+ "type": "array",
359
+ "minItems": 1,
360
+ "items": {
361
+ "type": "object",
362
+ "properties": {
363
+ "approver_email": {
364
+ "type": "string",
365
+ "title": "Email"
366
+ },
367
+ "label": {
368
+ "type": "string",
369
+ "title": "Etiqueta"
370
+ },
371
+ "required": {
372
+ "type": "boolean",
373
+ "title": "Obligatorio"
374
+ }
375
+ },
376
+ "required": ["approver_email", "label"],
377
+ "additionalProperties": false
378
+ }
379
+ }
380
+ ```
381
+
382
+ Fields inside `FieldGroup` and `FormStep` containers are flattened into the top-level `properties` automatically.
383
+
384
+ ## Internationalization (i18n)
385
+
386
+ All validation and export messages are locale-aware. **English** (`en`) and **Spanish** (`es`) are included out of the box, and you can register any additional language at runtime via `register_locale()`.
387
+
388
+ ### Switching Locales
389
+
390
+ ```python
391
+ from codeforms import set_locale, get_locale, get_available_locales
392
+
393
+ print(get_locale()) # "en"
394
+ print(get_available_locales()) # ["en", "es"]
395
+
396
+ set_locale("es")
397
+ # All validation messages will now be in Spanish
398
+ ```
399
+
400
+ ### Registering a Custom Locale
401
+
402
+ You can add any locale at runtime. Missing keys automatically fall back to English.
403
+
404
+ ```python
405
+ from codeforms import register_locale, set_locale
406
+
407
+ register_locale("pt", {
408
+ "field.required": "Este campo é obrigatório",
409
+ "field.required_named": "O campo {name} é obrigatório",
410
+ "email.invalid": "E-mail inválido",
411
+ "number.min_value": "O valor deve ser maior ou igual a {min}",
412
+ "form.validation_success": "Dados validados com sucesso",
413
+ "form.data_validation_error": "Erro na validação dos dados",
414
+ })
415
+
416
+ set_locale("pt")
417
+ ```
418
+
419
+ ### Using the Translation Function
420
+
421
+ The `t()` function translates a message key, with optional interpolation:
422
+
423
+ ```python
424
+ from codeforms import t, set_locale
425
+
426
+ set_locale("en")
427
+ print(t("field.required")) # "This field is required"
428
+ print(t("field.required_named", name="email")) # "The field email is required"
429
+
430
+ set_locale("es")
431
+ print(t("field.required")) # "Este campo es requerido"
432
+ print(t("text.minlength", min=3)) # "La longitud mínima es 3"
433
+ ```
434
+
435
+ ### Locale-Aware Validation
436
+
437
+ All validation functions respect the active locale:
438
+
439
+ ```python
440
+ from codeforms import Form, TextField, validate_form_data, set_locale
441
+
442
+ form = Form(
443
+ name="example",
444
+ fields=[TextField(name="name", label="Name", required=True)]
445
+ )
446
+
447
+ set_locale("en")
448
+ result = validate_form_data(form, {})
449
+ print(result["errors"][0]["message"]) # "The field name is required"
450
+
451
+ set_locale("es")
452
+ result = validate_form_data(form, {})
453
+ print(result["errors"][0]["message"]) # "El campo name es requerido"
454
+ ```
455
+
456
+ See [`examples/i18n_usage.py`](examples/i18n_usage.py) for a full working example.
457
+
458
+ ## Dynamic Forms
459
+
460
+ ### Conditional Visibility
461
+
462
+ Fields can be shown or hidden based on the value of other fields using `visible_when`. This is metadata that your frontend can use for dynamic UI, and the backend can respect during validation.
463
+
464
+ ```python
465
+ from codeforms import Form, TextField, SelectField, SelectOption, VisibilityRule
466
+
467
+ form = Form(
468
+ name="address",
469
+ fields=[
470
+ SelectField(
471
+ name="country",
472
+ label="Country",
473
+ required=True,
474
+ options=[
475
+ SelectOption(value="US", label="United States"),
476
+ SelectOption(value="AR", label="Argentina"),
477
+ ],
478
+ ),
479
+ TextField(
480
+ name="state",
481
+ label="State",
482
+ required=True,
483
+ visible_when=[
484
+ VisibilityRule(field="country", operator="equals", value="US"),
485
+ ],
486
+ ),
487
+ TextField(
488
+ name="province",
489
+ label="Province",
490
+ required=True,
491
+ visible_when=[
492
+ VisibilityRule(field="country", operator="equals", value="AR"),
493
+ ],
494
+ ),
495
+ ],
496
+ )
497
+ ```
498
+
499
+ Supported operators: `equals`, `not_equals`, `in`, `not_in`, `gt`, `lt`, `is_empty`, `is_not_empty`.
500
+
501
+ #### Dynamic Validation
502
+
503
+ Use `validate_form_data_dynamic()` to validate only the fields that are currently visible:
504
+
505
+ ```python
506
+ from codeforms import validate_form_data_dynamic
507
+
508
+ result = validate_form_data_dynamic(
509
+ form,
510
+ {"country": "US", "state": "California"},
511
+ respect_visibility=True,
512
+ )
513
+ print(result["success"]) # True — "province" is hidden, so not required
514
+ ```
515
+
516
+ The legacy `validate_form_data()` function is unchanged and always validates all fields regardless of visibility.
517
+
518
+ #### Checking Visible Fields
519
+
520
+ ```python
521
+ visible = form.get_visible_fields({"country": "US"})
522
+ print([f.name for f in visible]) # ["country", "state"]
523
+ ```
524
+
525
+ See [`examples/conditional_visibility.py`](examples/conditional_visibility.py) for a full working example.
526
+
527
+ ### Dependent Options
528
+
529
+ Use `DependentOptionsConfig` to define option sets that change based on another field's value:
530
+
531
+ ```python
532
+ from codeforms import SelectField, SelectOption, DependentOptionsConfig
533
+
534
+ city_field = SelectField(
535
+ name="city",
536
+ label="City",
537
+ options=[ # all possible options (for static HTML rendering)
538
+ SelectOption(value="nyc", label="New York City"),
539
+ SelectOption(value="bsas", label="Buenos Aires"),
540
+ ],
541
+ dependent_options=DependentOptionsConfig(
542
+ depends_on="country",
543
+ options_map={
544
+ "US": [SelectOption(value="nyc", label="New York City")],
545
+ "AR": [SelectOption(value="bsas", label="Buenos Aires")],
546
+ },
547
+ ),
548
+ )
549
+ ```
550
+
551
+ The `dependent_options` metadata serializes to JSON for your frontend to consume. See [`examples/dependent_options.py`](examples/dependent_options.py).
552
+
553
+ ## Multi-Step Wizard Forms
554
+
555
+ Use `FormStep` to split a form into multiple steps. Each step contains its own fields and can be validated independently.
556
+
557
+ ```python
558
+ from codeforms import Form, FormStep, TextField, EmailField, CheckboxField
559
+
560
+ form = Form(
561
+ name="registration",
562
+ content=[
563
+ FormStep(
564
+ title="Personal Information",
565
+ description="Tell us about yourself",
566
+ content=[
567
+ TextField(name="name", label="Name", required=True),
568
+ EmailField(name="email", label="Email", required=True),
569
+ ],
570
+ ),
571
+ FormStep(
572
+ title="Confirmation",
573
+ content=[
574
+ CheckboxField(name="terms", label="I accept the terms", required=True),
575
+ ],
576
+ validation_mode="on_submit",
577
+ ),
578
+ ],
579
+ )
580
+ ```
581
+
582
+ ### Step Validation
583
+
584
+ ```python
585
+ # Validate a single step
586
+ result = form.validate_step(0, {"name": "John", "email": "john@example.com"})
587
+ print(result["success"]) # True
588
+
589
+ # Validate all steps at once
590
+ result = form.validate_all_steps({
591
+ "name": "John",
592
+ "email": "john@example.com",
593
+ "terms": True,
594
+ })
595
+ print(result["success"]) # True
596
+ ```
597
+
598
+ ### Wizard Helpers
599
+
600
+ ```python
601
+ steps = form.get_steps() # List[FormStep]
602
+ fields = form.fields # Flat list of all fields across all steps
603
+ ```
604
+
605
+ ### HTML Export
606
+
607
+ Wizard forms export with `data-wizard="true"` on the `<form>` tag. Each step renders as a `<section data-step="true">` (not `<fieldset>`), so you can wire up your own step navigation in JavaScript.
608
+
609
+ See [`examples/wizard_form.py`](examples/wizard_form.py) for a full working example.
610
+
611
+ ## Custom Field Types
612
+
613
+ You can create your own field types by subclassing `FormFieldBase` and registering them with `register_field_type()`. Custom fields integrate seamlessly with forms, JSON serialization, validation, and HTML export.
614
+
615
+ ### Defining a Custom Field
616
+
617
+ ```python
618
+ from codeforms import FormFieldBase, register_field_type
619
+
620
+ class PhoneField(FormFieldBase):
621
+ field_type: str = "phone" # unique string identifier
622
+ country_code: str = "+1"
623
+
624
+ class RatingField(FormFieldBase):
625
+ field_type: str = "rating"
626
+ min_rating: int = 1
627
+ max_rating: int = 5
628
+
629
+ register_field_type(PhoneField)
630
+ register_field_type(RatingField)
631
+ ```
632
+
633
+ ### Using Custom Fields in Forms
634
+
635
+ ```python
636
+ from codeforms import Form, TextField
637
+
638
+ form = Form(
639
+ name="feedback",
640
+ fields=[
641
+ TextField(name="name", label="Name", required=True),
642
+ PhoneField(name="phone", label="Phone", country_code="+54"),
643
+ RatingField(name="score", label="Score", max_rating=10),
644
+ ],
645
+ )
646
+ ```
647
+
648
+ ### JSON Roundtrip
649
+
650
+ Custom fields serialize and deserialize automatically (as long as the field type is registered before deserialization):
651
+
652
+ ```python
653
+ import json
654
+
655
+ json_str = form.to_json()
656
+ restored = Form.loads(json_str)
657
+
658
+ assert isinstance(restored.fields[1], PhoneField)
659
+ assert restored.fields[1].country_code == "+54"
660
+ ```
661
+
662
+ ### Listing Registered Types
663
+
664
+ ```python
665
+ from codeforms import get_registered_field_types
666
+
667
+ for name, classes in sorted(get_registered_field_types().items()):
668
+ print(f"{name}: {[c.__name__ for c in classes]}")
669
+ ```
670
+
671
+ See [`examples/custom_fields.py`](examples/custom_fields.py) for a full working example.
672
+
673
+ ## License
674
+
675
+ MIT