diff --git a/README.md b/README.md
index 9d11321..e6c7964 100644
--- a/README.md
+++ b/README.md
@@ -293,6 +293,54 @@ def table_rows(items):
])
```
+### Form Builder
+
+Generate HTML5 forms with validation using the `Field` class:
+
+```python
+from nitro_ui import *
+
+form = Form(
+ Field.email("email", label="Email", required=True),
+ Field.password("password", label="Password", min_length=8),
+ Field.select("country", ["USA", "Canada", "Mexico"], label="Country"),
+ Field.checkbox("terms", label="I agree to the Terms", required=True),
+ Button("Sign Up", type="submit"),
+ action="/register"
+)
+```
+
+Field types: `text`, `email`, `password`, `url`, `tel`, `search`, `textarea`, `number`, `range`, `date`, `time`, `datetime_local`, `select`, `checkbox`, `radio`, `file`, `hidden`, `color`. See [SKILL.md](SKILL.md) for full API.
+
+### HTMX Integration
+
+Build interactive UIs without JavaScript. NitroUI converts `hx_*` kwargs to `hx-*` attributes automatically:
+
+```python
+from nitro_ui import *
+
+# Live search
+Input(
+ type="text",
+ hx_get="/search",
+ hx_trigger="keyup changed delay:300ms",
+ hx_target="#results"
+)
+
+# Delete with confirmation
+Button(
+ "Delete",
+ hx_delete="/items/1",
+ hx_confirm="Are you sure?",
+ hx_swap="outerHTML"
+)
+
+# Load more
+Button("Load More", hx_get="/items?page=2", hx_target="#list", hx_swap="beforeend")
+```
+
+All HTMX attributes are supported: `hx_get`, `hx_post`, `hx_put`, `hx_delete`, `hx_target`, `hx_swap`, `hx_trigger`, `hx_confirm`, `hx_indicator`, `hx_boost`, and more. See [SKILL.md](SKILL.md) for the complete reference.
+
### Raw HTML Partials
Embed raw HTML for trusted content like analytics tags:
diff --git a/SKILL.md b/SKILL.md
index c8b973a..dae2639 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -805,6 +805,195 @@ def home(request):
---
+## Form Builder
+
+Generate HTML5 form fields with validation attributes using the `Field` class.
+
+```python
+from nitro_ui import Form, Button, Field
+
+form = Form(
+ Field.email("email", label="Email", required=True, placeholder="you@example.com"),
+ Field.password("password", label="Password", min_length=8),
+ Field.checkbox("remember", label="Remember me"),
+ Button("Log In", type="submit"),
+ action="/login",
+ method="post"
+)
+```
+
+### Text Fields
+
+```python
+Field.text(name, label=None, required=False, min_length=None, max_length=None, pattern=None, placeholder=None, value=None, wrapper=None, **attrs)
+Field.email(name, label=None, required=False, placeholder=None, value=None, wrapper=None, **attrs)
+Field.password(name, label=None, required=False, min_length=None, max_length=None, placeholder=None, wrapper=None, **attrs)
+Field.url(name, label=None, required=False, placeholder=None, value=None, wrapper=None, **attrs)
+Field.tel(name, label=None, required=False, pattern=None, placeholder=None, value=None, wrapper=None, **attrs)
+Field.search(name, label=None, required=False, placeholder=None, value=None, wrapper=None, **attrs)
+Field.textarea(name, label=None, required=False, rows=None, cols=None, min_length=None, max_length=None, placeholder=None, value=None, wrapper=None, **attrs)
+```
+
+### Numeric & Date Fields
+
+```python
+Field.number(name, label=None, required=False, min=None, max=None, step=None, value=None, wrapper=None, **attrs)
+Field.range(name, label=None, min=0, max=100, step=None, value=None, wrapper=None, **attrs)
+Field.date(name, label=None, required=False, min=None, max=None, value=None, wrapper=None, **attrs)
+Field.time(name, label=None, required=False, min=None, max=None, value=None, wrapper=None, **attrs)
+Field.datetime_local(name, label=None, required=False, min=None, max=None, value=None, wrapper=None, **attrs)
+```
+
+### Selection Fields
+
+```python
+# Select with different option formats
+Field.select("country", ["USA", "Canada", "Mexico"]) # strings
+Field.select("status", [("active", "Active"), ("inactive", "Inactive")]) # tuples
+Field.select("priority", [{"value": "1", "label": "Low", "disabled": True}]) # dicts
+
+# Pre-selected value
+Field.select("country", ["USA", "Canada"], value="Canada", label="Country")
+
+# Checkbox (label wraps input)
+Field.checkbox("terms", label="I agree to the Terms", required=True)
+
+# Radio buttons (wrapped in fieldset)
+Field.radio("plan", [("free", "Free"), ("pro", "Pro")], label="Select Plan", value="free")
+```
+
+### Other Fields
+
+```python
+Field.file(name, label=None, required=False, accept=None, multiple=False, wrapper=None, **attrs)
+Field.hidden(name, value, **attrs)
+Field.color(name, label=None, value=None, wrapper=None, **attrs)
+```
+
+### Labels and Wrappers
+
+```python
+# No label - just input
+Field.text("username")
+
+# With label
+Field.text("username", label="Username")
+# →
+
+# With wrapper div
+Field.text("username", label="Username", wrapper="form-field")
+# →
+
+# Wrapper with attributes
+Field.text("username", label="Username", wrapper={"cls": "form-group", "id": "field-1"})
+```
+
+### With HTMX
+
+```python
+Field.text("search",
+ placeholder="Search...",
+ hx_get="/search",
+ hx_trigger="keyup changed delay:300ms",
+ hx_target="#results"
+)
+```
+
+---
+
+## HTMX Integration
+
+NitroUI works seamlessly with [HTMX](https://htmx.org/). Use `hx_*` kwargs — underscores convert to hyphens automatically.
+
+### Basic Usage
+
+```python
+from nitro_ui import Button, Div, Input, Script
+
+# Include HTMX
+Script(src="https://unpkg.com/htmx.org@2.0.4")
+
+# hx_get → hx-get, hx_target → hx-target, etc.
+Button("Load More", hx_get="/items", hx_target="#list", hx_swap="beforeend")
+```
+
+### Common Patterns
+
+```python
+# Click to load
+Button("Load", hx_get="/content", hx_target="#result")
+
+# Delete with confirmation
+Button("Delete", hx_delete="/items/1", hx_confirm="Are you sure?", hx_swap="outerHTML")
+
+# Live search
+Input(
+ type="text",
+ name="q",
+ hx_get="/search",
+ hx_trigger="keyup changed delay:300ms",
+ hx_target="#results"
+)
+
+# Form submission
+Form(
+ Input(type="text", name="email"),
+ Button("Subscribe"),
+ hx_post="/subscribe",
+ hx_swap="outerHTML"
+)
+
+# Infinite scroll
+Div(
+ hx_get="/items?page=2",
+ hx_trigger="revealed",
+ hx_swap="afterend"
+)
+
+# Polling
+Div(id="notifications", hx_get="/notifications", hx_trigger="every 30s")
+```
+
+### All HTMX Attributes
+
+| Python kwarg | HTML attribute | Description |
+|--------------|----------------|-------------|
+| `hx_get` | `hx-get` | GET request |
+| `hx_post` | `hx-post` | POST request |
+| `hx_put` | `hx-put` | PUT request |
+| `hx_patch` | `hx-patch` | PATCH request |
+| `hx_delete` | `hx-delete` | DELETE request |
+| `hx_target` | `hx-target` | Target element selector |
+| `hx_swap` | `hx-swap` | How to swap content |
+| `hx_trigger` | `hx-trigger` | Event that triggers request |
+| `hx_confirm` | `hx-confirm` | Confirmation dialog |
+| `hx_indicator` | `hx-indicator` | Loading indicator |
+| `hx_push_url` | `hx-push-url` | Push URL to history |
+| `hx_select` | `hx-select` | Select content from response |
+| `hx_select_oob` | `hx-select-oob` | Out-of-band select |
+| `hx_swap_oob` | `hx-swap-oob` | Out-of-band swap |
+| `hx_vals` | `hx-vals` | Additional values (JSON) |
+| `hx_boost` | `hx-boost` | Boost all links/forms |
+| `hx_include` | `hx-include` | Include additional inputs |
+| `hx_params` | `hx-params` | Filter parameters |
+| `hx_preserve` | `hx-preserve` | Preserve element |
+| `hx_ext` | `hx-ext` | Extensions |
+
+### HTMX Extensions
+
+```python
+# Server-Sent Events
+Div(hx_ext="sse", sse_connect="/events", sse_swap="message")
+
+# WebSockets
+Div(hx_ext="ws", ws_connect="/ws")
+
+# JSON encoding
+Form(hx_ext="json-enc", hx_post="/api/submit")
+```
+
+---
+
## Security
- **Automatic HTML escaping**: All text content and attribute values are escaped
@@ -841,3 +1030,4 @@ When generating NitroUI code:
- [ ] Use `Fragment` when you need multiple elements without a wrapper
- [ ] Use `Partial` for raw HTML (analytics, embeds) - bypasses escaping
- [ ] Use `Component` + `Slot` for reusable components with declarative templates
+- [ ] Use `Field.xyz()` for form fields with HTML5 validation attributes
diff --git a/docs/plans/2026-01-30-form-builder-design.md b/docs/plans/2026-01-30-form-builder-design.md
new file mode 100644
index 0000000..a7748d2
--- /dev/null
+++ b/docs/plans/2026-01-30-form-builder-design.md
@@ -0,0 +1,176 @@
+# Form Builder Design
+
+## Overview
+
+A declarative form field API that generates HTML5 form elements with validation attributes. Field functions return standard NitroUI elements, making them fully composable with the rest of the library.
+
+**Goals:**
+- Simple, NitroUI-like API using static methods on a `Field` class
+- HTML5 validation only (no server-side validation in v1)
+- Optional labels and wrappers for flexible layouts
+- Full HTML5 input type coverage
+
+## Basic API
+
+```python
+from nitro_ui import Form, Button
+from nitro_ui.forms import Field
+
+form = Form(
+ Field.email("email", label="Email", required=True, placeholder="you@example.com"),
+ Field.password("password", label="Password", min_length=8),
+ Button("Log In", type="submit"),
+ action="/login",
+ method="post"
+)
+```
+
+**Renders:**
+
+```html
+
+```
+
+## Field Types
+
+### Text Inputs
+
+```python
+Field.text(name, label=None, required=False, min_length=None, max_length=None, pattern=None, placeholder=None, value=None, **attrs)
+Field.email(name, label=None, required=False, placeholder=None, value=None, **attrs)
+Field.password(name, label=None, required=False, min_length=None, max_length=None, placeholder=None, **attrs)
+Field.url(name, label=None, required=False, placeholder=None, value=None, **attrs)
+Field.tel(name, label=None, required=False, pattern=None, placeholder=None, value=None, **attrs)
+Field.search(name, label=None, required=False, placeholder=None, value=None, **attrs)
+Field.textarea(name, label=None, required=False, rows=None, cols=None, min_length=None, max_length=None, placeholder=None, value=None, **attrs)
+```
+
+### Numeric
+
+```python
+Field.number(name, label=None, required=False, min=None, max=None, step=None, value=None, **attrs)
+Field.range(name, label=None, min=0, max=100, step=None, value=None, **attrs)
+```
+
+### Date/Time
+
+```python
+Field.date(name, label=None, required=False, min=None, max=None, value=None, **attrs)
+Field.time(name, label=None, required=False, min=None, max=None, value=None, **attrs)
+Field.datetime_local(name, label=None, required=False, min=None, max=None, value=None, **attrs)
+```
+
+### Selection
+
+```python
+Field.select(name, options, label=None, required=False, value=None, **attrs)
+Field.checkbox(name, label=None, checked=False, value="on", **attrs)
+Field.radio(name, options, label=None, required=False, value=None, **attrs)
+```
+
+### Other
+
+```python
+Field.file(name, label=None, required=False, accept=None, multiple=False, **attrs)
+Field.hidden(name, value, **attrs)
+Field.color(name, label=None, value=None, **attrs)
+```
+
+## Label Behavior
+
+**Without label:**
+```python
+Field.email("email", required=True)
+#
+```
+
+**With label:**
+```python
+Field.email("email", label="Email Address", required=True)
+#
+#
+```
+
+**With wrapper:**
+```python
+Field.email("email", label="Email", required=True, wrapper="field")
+#
+#
+#
+#
+
+Field.email("email", label="Email", wrapper={"cls": "form-group", "id": "email-field"})
+# ...
+```
+
+**Custom ID:**
+```python
+Field.email("user_email", id="email-input", label="Email")
+#
+#
+```
+
+## Select Options
+
+Three formats supported:
+
+```python
+# Simple list
+Field.select("country", ["USA", "Canada", "Mexico"])
+
+# Tuples (value, label)
+Field.select("status", [("active", "Active"), ("inactive", "Inactive")])
+
+# Dicts (full control)
+Field.select("priority", [
+ {"value": "1", "label": "Low"},
+ {"value": "2", "label": "Medium", "disabled": True}
+])
+```
+
+## Radio Buttons
+
+```python
+Field.radio("plan", [
+ ("free", "Free"),
+ ("pro", "Pro - $10/mo")
+], label="Plan", value="free")
+```
+
+**Renders:**
+```html
+
+```
+
+## Checkbox
+
+```python
+Field.checkbox("subscribe", label="Subscribe to newsletter", checked=True)
+#
+```
+
+## File Structure
+
+```
+src/nitro_ui/
+├── forms/
+│ ├── __init__.py # exports Field
+│ └── field.py # Field class with static methods
+```
+
+## Export
+
+```python
+from nitro_ui import Field
+from nitro_ui.forms import Field # alternative
+```
diff --git a/src/nitro_ui/__init__.py b/src/nitro_ui/__init__.py
index 1df8d02..2d283e8 100644
--- a/src/nitro_ui/__init__.py
+++ b/src/nitro_ui/__init__.py
@@ -4,6 +4,7 @@
from .core.parser import from_html
from .core.slot import Slot
from .core.component import Component
+from .forms import Field
from .tags.form import (
Textarea,
Select,
@@ -132,6 +133,7 @@
"from_html",
"Slot",
"Component",
+ "Field",
# styles
"CSSStyle",
"StyleSheet",
diff --git a/src/nitro_ui/forms/__init__.py b/src/nitro_ui/forms/__init__.py
new file mode 100644
index 0000000..45bb248
--- /dev/null
+++ b/src/nitro_ui/forms/__init__.py
@@ -0,0 +1,5 @@
+"""Form field helpers for NitroUI."""
+
+from nitro_ui.forms.field import Field
+
+__all__ = ["Field"]
diff --git a/src/nitro_ui/forms/field.py b/src/nitro_ui/forms/field.py
new file mode 100644
index 0000000..ae31fb4
--- /dev/null
+++ b/src/nitro_ui/forms/field.py
@@ -0,0 +1,710 @@
+"""Form field helpers that generate HTML5 form elements with validation attributes."""
+
+from typing import Any, Dict, List, Optional, Union
+
+from nitro_ui.core.element import HTMLElement
+from nitro_ui.core.fragment import Fragment
+from nitro_ui.tags.layout import Div
+from nitro_ui.tags.form import Input, Label, Select, Option, Textarea, Fieldset, Legend
+
+
+def _build_field(
+ input_element: HTMLElement,
+ name: str,
+ label: Optional[str] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+) -> HTMLElement:
+ """Build a field with optional label and wrapper.
+
+ Args:
+ input_element: The input element to wrap
+ name: Field name (used for label's for attribute if no id)
+ label: Optional label text
+ wrapper: Optional wrapper - string for class name, dict for attributes
+ id: Optional custom id (defaults to name)
+
+ Returns:
+ HTMLElement - the input alone, with label, or wrapped in div
+ """
+ field_id = id or name
+
+ # Build elements list
+ elements = []
+
+ if label:
+ elements.append(Label(label, for_element=field_id))
+
+ elements.append(input_element)
+
+ # No label and no wrapper - just return the input
+ if len(elements) == 1 and not wrapper:
+ return input_element
+
+ # Has label but no wrapper - return fragment
+ if not wrapper:
+ return Fragment(*elements)
+
+ # Has wrapper - wrap in div
+ if isinstance(wrapper, str):
+ return Div(*elements, cls=wrapper)
+ elif isinstance(wrapper, dict):
+ return Div(*elements, **wrapper)
+ else:
+ return Div(*elements)
+
+
+def _filter_none(**kwargs) -> Dict[str, Any]:
+ """Filter out None values from kwargs."""
+ return {k: v for k, v in kwargs.items() if v is not None}
+
+
+class Field:
+ """Static methods for generating HTML5 form fields with validation attributes.
+
+ All methods return standard NitroUI elements that can be composed with
+ other elements. Use the `label` parameter to add a label, and `wrapper`
+ to wrap the field in a div for styling.
+
+ Example:
+ from nitro_ui import Form, Button
+ from nitro_ui.forms import Field
+
+ form = Form(
+ Field.email("email", label="Email", required=True),
+ Field.password("password", label="Password", min_length=8),
+ Button("Log In", type="submit")
+ )
+ """
+
+ # =========================================================================
+ # Text Inputs
+ # =========================================================================
+
+ @staticmethod
+ def text(
+ name: str,
+ label: Optional[str] = None,
+ required: bool = False,
+ min_length: Optional[int] = None,
+ max_length: Optional[int] = None,
+ pattern: Optional[str] = None,
+ placeholder: Optional[str] = None,
+ value: Optional[str] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create a text input field.
+
+ Args:
+ name: Field name and default id
+ label: Optional label text
+ required: Whether the field is required
+ min_length: Minimum character length
+ max_length: Maximum character length
+ pattern: Regex pattern for validation
+ placeholder: Placeholder text
+ value: Default value
+ wrapper: Wrapper div class name or attributes dict
+ id: Custom id (defaults to name)
+ **attrs: Additional HTML attributes
+ """
+ field_id = id or name
+ input_attrs = _filter_none(
+ type="text",
+ id=field_id,
+ name=name,
+ required=required if required else None,
+ minlength=min_length,
+ maxlength=max_length,
+ pattern=pattern,
+ placeholder=placeholder,
+ value=value,
+ **attrs
+ )
+ inp = Input(**input_attrs)
+ return _build_field(inp, name, label, wrapper, id)
+
+ @staticmethod
+ def email(
+ name: str,
+ label: Optional[str] = None,
+ required: bool = False,
+ placeholder: Optional[str] = None,
+ value: Optional[str] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create an email input field."""
+ field_id = id or name
+ input_attrs = _filter_none(
+ type="email",
+ id=field_id,
+ name=name,
+ required=required if required else None,
+ placeholder=placeholder,
+ value=value,
+ **attrs
+ )
+ inp = Input(**input_attrs)
+ return _build_field(inp, name, label, wrapper, id)
+
+ @staticmethod
+ def password(
+ name: str,
+ label: Optional[str] = None,
+ required: bool = False,
+ min_length: Optional[int] = None,
+ max_length: Optional[int] = None,
+ placeholder: Optional[str] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create a password input field."""
+ field_id = id or name
+ input_attrs = _filter_none(
+ type="password",
+ id=field_id,
+ name=name,
+ required=required if required else None,
+ minlength=min_length,
+ maxlength=max_length,
+ placeholder=placeholder,
+ **attrs
+ )
+ inp = Input(**input_attrs)
+ return _build_field(inp, name, label, wrapper, id)
+
+ @staticmethod
+ def url(
+ name: str,
+ label: Optional[str] = None,
+ required: bool = False,
+ placeholder: Optional[str] = None,
+ value: Optional[str] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create a URL input field."""
+ field_id = id or name
+ input_attrs = _filter_none(
+ type="url",
+ id=field_id,
+ name=name,
+ required=required if required else None,
+ placeholder=placeholder,
+ value=value,
+ **attrs
+ )
+ inp = Input(**input_attrs)
+ return _build_field(inp, name, label, wrapper, id)
+
+ @staticmethod
+ def tel(
+ name: str,
+ label: Optional[str] = None,
+ required: bool = False,
+ pattern: Optional[str] = None,
+ placeholder: Optional[str] = None,
+ value: Optional[str] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create a telephone input field."""
+ field_id = id or name
+ input_attrs = _filter_none(
+ type="tel",
+ id=field_id,
+ name=name,
+ required=required if required else None,
+ pattern=pattern,
+ placeholder=placeholder,
+ value=value,
+ **attrs
+ )
+ inp = Input(**input_attrs)
+ return _build_field(inp, name, label, wrapper, id)
+
+ @staticmethod
+ def search(
+ name: str,
+ label: Optional[str] = None,
+ required: bool = False,
+ placeholder: Optional[str] = None,
+ value: Optional[str] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create a search input field."""
+ field_id = id or name
+ input_attrs = _filter_none(
+ type="search",
+ id=field_id,
+ name=name,
+ required=required if required else None,
+ placeholder=placeholder,
+ value=value,
+ **attrs
+ )
+ inp = Input(**input_attrs)
+ return _build_field(inp, name, label, wrapper, id)
+
+ @staticmethod
+ def textarea(
+ name: str,
+ label: Optional[str] = None,
+ required: bool = False,
+ rows: Optional[int] = None,
+ cols: Optional[int] = None,
+ min_length: Optional[int] = None,
+ max_length: Optional[int] = None,
+ placeholder: Optional[str] = None,
+ value: Optional[str] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create a textarea field."""
+ field_id = id or name
+ textarea_attrs = _filter_none(
+ id=field_id,
+ name=name,
+ required=required if required else None,
+ rows=rows,
+ cols=cols,
+ minlength=min_length,
+ maxlength=max_length,
+ placeholder=placeholder,
+ **attrs
+ )
+ # Textarea takes content as children, not value attribute
+ if value:
+ ta = Textarea(value, **textarea_attrs)
+ else:
+ ta = Textarea(**textarea_attrs)
+ return _build_field(ta, name, label, wrapper, id)
+
+ # =========================================================================
+ # Numeric Inputs
+ # =========================================================================
+
+ @staticmethod
+ def number(
+ name: str,
+ label: Optional[str] = None,
+ required: bool = False,
+ min: Optional[Union[int, float]] = None,
+ max: Optional[Union[int, float]] = None,
+ step: Optional[Union[int, float, str]] = None,
+ value: Optional[Union[int, float]] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create a number input field."""
+ field_id = id or name
+ input_attrs = _filter_none(
+ type="number",
+ id=field_id,
+ name=name,
+ required=required if required else None,
+ min=min,
+ max=max,
+ step=step,
+ value=value,
+ **attrs
+ )
+ inp = Input(**input_attrs)
+ return _build_field(inp, name, label, wrapper, id)
+
+ @staticmethod
+ def range(
+ name: str,
+ label: Optional[str] = None,
+ min: Union[int, float] = 0,
+ max: Union[int, float] = 100,
+ step: Optional[Union[int, float, str]] = None,
+ value: Optional[Union[int, float]] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create a range slider input field."""
+ field_id = id or name
+ input_attrs = _filter_none(
+ type="range",
+ id=field_id,
+ name=name,
+ min=min,
+ max=max,
+ step=step,
+ value=value,
+ **attrs
+ )
+ inp = Input(**input_attrs)
+ return _build_field(inp, name, label, wrapper, id)
+
+ # =========================================================================
+ # Date/Time Inputs
+ # =========================================================================
+
+ @staticmethod
+ def date(
+ name: str,
+ label: Optional[str] = None,
+ required: bool = False,
+ min: Optional[str] = None,
+ max: Optional[str] = None,
+ value: Optional[str] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create a date input field.
+
+ Args:
+ min/max/value: Date strings in YYYY-MM-DD format
+ """
+ field_id = id or name
+ input_attrs = _filter_none(
+ type="date",
+ id=field_id,
+ name=name,
+ required=required if required else None,
+ min=min,
+ max=max,
+ value=value,
+ **attrs
+ )
+ inp = Input(**input_attrs)
+ return _build_field(inp, name, label, wrapper, id)
+
+ @staticmethod
+ def time(
+ name: str,
+ label: Optional[str] = None,
+ required: bool = False,
+ min: Optional[str] = None,
+ max: Optional[str] = None,
+ value: Optional[str] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create a time input field.
+
+ Args:
+ min/max/value: Time strings in HH:MM format
+ """
+ field_id = id or name
+ input_attrs = _filter_none(
+ type="time",
+ id=field_id,
+ name=name,
+ required=required if required else None,
+ min=min,
+ max=max,
+ value=value,
+ **attrs
+ )
+ inp = Input(**input_attrs)
+ return _build_field(inp, name, label, wrapper, id)
+
+ @staticmethod
+ def datetime_local(
+ name: str,
+ label: Optional[str] = None,
+ required: bool = False,
+ min: Optional[str] = None,
+ max: Optional[str] = None,
+ value: Optional[str] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create a datetime-local input field.
+
+ Args:
+ min/max/value: Datetime strings in YYYY-MM-DDTHH:MM format
+ """
+ field_id = id or name
+ input_attrs = _filter_none(
+ type="datetime-local",
+ id=field_id,
+ name=name,
+ required=required if required else None,
+ min=min,
+ max=max,
+ value=value,
+ **attrs
+ )
+ inp = Input(**input_attrs)
+ return _build_field(inp, name, label, wrapper, id)
+
+ # =========================================================================
+ # Selection Inputs
+ # =========================================================================
+
+ @staticmethod
+ def select(
+ name: str,
+ options: List[Union[str, tuple, Dict[str, Any]]],
+ label: Optional[str] = None,
+ required: bool = False,
+ value: Optional[str] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create a select dropdown field.
+
+ Args:
+ name: Field name
+ options: List of options - can be:
+ - strings: ["USA", "Canada"] - value and label are the same
+ - tuples: [("us", "United States")] - (value, label)
+ - dicts: [{"value": "us", "label": "United States", "disabled": True}]
+ label: Optional label text
+ required: Whether selection is required
+ value: Pre-selected value
+ wrapper: Wrapper div class name or attributes dict
+ id: Custom id (defaults to name)
+ """
+ field_id = id or name
+
+ # Build option elements
+ option_elements = []
+ for opt in options:
+ if isinstance(opt, str):
+ opt_value = opt
+ opt_label = opt
+ opt_attrs = {}
+ elif isinstance(opt, tuple):
+ opt_value, opt_label = opt
+ opt_attrs = {}
+ elif isinstance(opt, dict):
+ opt_value = opt.get("value", "")
+ opt_label = opt.get("label", opt_value)
+ opt_attrs = {k: v for k, v in opt.items() if k not in ("value", "label")}
+ else:
+ continue
+
+ # Check if this option should be selected
+ if value is not None and str(opt_value) == str(value):
+ opt_attrs["selected"] = True
+
+ option_elements.append(Option(opt_label, value=opt_value, **opt_attrs))
+
+ select_attrs = _filter_none(
+ id=field_id,
+ name=name,
+ required=required if required else None,
+ **attrs
+ )
+ sel = Select(*option_elements, **select_attrs)
+ return _build_field(sel, name, label, wrapper, id)
+
+ @staticmethod
+ def checkbox(
+ name: str,
+ label: Optional[str] = None,
+ checked: bool = False,
+ value: str = "on",
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ required: bool = False,
+ **attrs
+ ) -> HTMLElement:
+ """Create a checkbox field.
+
+ For checkboxes, the label wraps the input for better UX.
+
+ Args:
+ name: Field name
+ label: Label text (wraps the checkbox)
+ checked: Whether the checkbox is pre-checked
+ value: Value sent when checked (default "on")
+ wrapper: Wrapper div class name or attributes dict
+ id: Custom id (defaults to name)
+ required: Whether the checkbox must be checked
+ """
+ field_id = id or name
+ input_attrs = _filter_none(
+ type="checkbox",
+ id=field_id,
+ name=name,
+ value=value,
+ checked=checked if checked else None,
+ required=required if required else None,
+ **attrs
+ )
+ inp = Input(**input_attrs)
+
+ # For checkbox, label wraps the input (input first, then text)
+ if label:
+ # Use a fragment inside label to control order: input, space, text
+ from nitro_ui.tags.text import Span
+ labeled = Label(inp, Span(" " + label))
+ if wrapper:
+ if isinstance(wrapper, str):
+ return Div(labeled, cls=wrapper)
+ elif isinstance(wrapper, dict):
+ return Div(labeled, **wrapper)
+ return labeled
+
+ if wrapper:
+ if isinstance(wrapper, str):
+ return Div(inp, cls=wrapper)
+ elif isinstance(wrapper, dict):
+ return Div(inp, **wrapper)
+ return inp
+
+ @staticmethod
+ def radio(
+ name: str,
+ options: List[Union[tuple, Dict[str, Any]]],
+ label: Optional[str] = None,
+ required: bool = False,
+ value: Optional[str] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create a radio button group.
+
+ Radio buttons are wrapped in a fieldset with legend for accessibility.
+
+ Args:
+ name: Field name (shared by all radio buttons)
+ options: List of options - tuples or dicts like select()
+ label: Legend text for the fieldset
+ required: Whether selection is required
+ value: Pre-selected value
+ wrapper: Wrapper div class name or attributes dict
+ """
+ # Build radio button elements
+ radio_elements = []
+ for i, opt in enumerate(options):
+ if isinstance(opt, tuple):
+ opt_value, opt_label = opt
+ opt_attrs = {}
+ elif isinstance(opt, dict):
+ opt_value = opt.get("value", "")
+ opt_label = opt.get("label", opt_value)
+ opt_attrs = {k: v for k, v in opt.items() if k not in ("value", "label")}
+ else:
+ continue
+
+ radio_id = f"{name}_{i}"
+ input_attrs = _filter_none(
+ type="radio",
+ id=radio_id,
+ name=name,
+ value=opt_value,
+ required=required if required and i == 0 else None, # Only first needs required
+ checked=True if value is not None and str(opt_value) == str(value) else None,
+ **opt_attrs,
+ **attrs
+ )
+ inp = Input(**input_attrs)
+ radio_elements.append(Label(inp, " ", opt_label))
+
+ # Wrap in fieldset with legend
+ if label:
+ fieldset = Fieldset(Legend(label), *radio_elements)
+ else:
+ fieldset = Fieldset(*radio_elements)
+
+ if wrapper:
+ if isinstance(wrapper, str):
+ return Div(fieldset, cls=wrapper)
+ elif isinstance(wrapper, dict):
+ return Div(fieldset, **wrapper)
+ return fieldset
+
+ # =========================================================================
+ # Other Inputs
+ # =========================================================================
+
+ @staticmethod
+ def file(
+ name: str,
+ label: Optional[str] = None,
+ required: bool = False,
+ accept: Optional[str] = None,
+ multiple: bool = False,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create a file upload field.
+
+ Args:
+ name: Field name
+ label: Optional label text
+ required: Whether file selection is required
+ accept: Accepted file types (e.g., "image/*", ".pdf,.doc")
+ multiple: Allow multiple file selection
+ wrapper: Wrapper div class name or attributes dict
+ id: Custom id (defaults to name)
+ """
+ field_id = id or name
+ input_attrs = _filter_none(
+ type="file",
+ id=field_id,
+ name=name,
+ required=required if required else None,
+ accept=accept,
+ multiple=multiple if multiple else None,
+ **attrs
+ )
+ inp = Input(**input_attrs)
+ return _build_field(inp, name, label, wrapper, id)
+
+ @staticmethod
+ def hidden(
+ name: str,
+ value: str,
+ **attrs
+ ) -> HTMLElement:
+ """Create a hidden input field.
+
+ Args:
+ name: Field name
+ value: Hidden value
+ """
+ return Input(type="hidden", name=name, value=value, **attrs)
+
+ @staticmethod
+ def color(
+ name: str,
+ label: Optional[str] = None,
+ value: Optional[str] = None,
+ wrapper: Optional[Union[str, Dict[str, Any]]] = None,
+ id: Optional[str] = None,
+ **attrs
+ ) -> HTMLElement:
+ """Create a color picker field.
+
+ Args:
+ name: Field name
+ label: Optional label text
+ value: Default color in #RRGGBB format
+ wrapper: Wrapper div class name or attributes dict
+ id: Custom id (defaults to name)
+ """
+ field_id = id or name
+ input_attrs = _filter_none(
+ type="color",
+ id=field_id,
+ name=name,
+ value=value,
+ **attrs
+ )
+ inp = Input(**input_attrs)
+ return _build_field(inp, name, label, wrapper, id)
diff --git a/tests/test_forms.py b/tests/test_forms.py
new file mode 100644
index 0000000..d21d836
--- /dev/null
+++ b/tests/test_forms.py
@@ -0,0 +1,444 @@
+import unittest
+
+from nitro_ui import Field, Form, Button
+from nitro_ui.core.fragment import Fragment
+from nitro_ui.tags.layout import Div
+
+
+class TestFieldText(unittest.TestCase):
+ """Test text input fields."""
+
+ def test_text_basic(self):
+ """Test basic text field."""
+ field = Field.text("username")
+ rendered = str(field)
+ self.assertIn('type="text"', rendered)
+ self.assertIn('id="username"', rendered)
+ self.assertIn('name="username"', rendered)
+
+ def test_text_with_label(self):
+ """Test text field with label."""
+ field = Field.text("username", label="Username")
+ rendered = str(field)
+ self.assertIn('', rendered)
+ self.assertIn('Email', rendered)
+ self.assertIn('type="email"', rendered)
+ self.assertIn('required', rendered)
+ self.assertIn('placeholder="you@example.com"', rendered)
+
+
+class TestFieldPassword(unittest.TestCase):
+ """Test password input fields."""
+
+ def test_password_basic(self):
+ """Test basic password field."""
+ field = Field.password("password")
+ self.assertIn('type="password"', str(field))
+
+ def test_password_min_length(self):
+ """Test password field with min length."""
+ field = Field.password("password", min_length=8)
+ self.assertIn('minlength="8"', str(field))
+
+
+class TestFieldUrl(unittest.TestCase):
+ """Test URL input fields."""
+
+ def test_url_basic(self):
+ """Test basic URL field."""
+ field = Field.url("website")
+ self.assertIn('type="url"', str(field))
+
+
+class TestFieldTel(unittest.TestCase):
+ """Test telephone input fields."""
+
+ def test_tel_basic(self):
+ """Test basic tel field."""
+ field = Field.tel("phone")
+ self.assertIn('type="tel"', str(field))
+
+ def test_tel_pattern(self):
+ """Test tel field with pattern."""
+ field = Field.tel("phone", pattern="[0-9]{3}-[0-9]{4}")
+ self.assertIn('pattern="[0-9]{3}-[0-9]{4}"', str(field))
+
+
+class TestFieldSearch(unittest.TestCase):
+ """Test search input fields."""
+
+ def test_search_basic(self):
+ """Test basic search field."""
+ field = Field.search("q")
+ self.assertIn('type="search"', str(field))
+
+
+class TestFieldTextarea(unittest.TestCase):
+ """Test textarea fields."""
+
+ def test_textarea_basic(self):
+ """Test basic textarea."""
+ field = Field.textarea("message")
+ rendered = str(field)
+ self.assertIn('