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 +
+ Plan + + +
+``` + +## 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('USA', rendered) + self.assertIn('', rendered) + self.assertIn('', rendered) + + def test_select_tuple_options(self): + """Test select with tuple options.""" + field = Field.select("status", [("active", "Active"), ("inactive", "Inactive")]) + rendered = str(field) + self.assertIn('', rendered) + self.assertIn('', rendered) + + def test_select_dict_options(self): + """Test select with dict options.""" + field = Field.select("priority", [ + {"value": "1", "label": "Low"}, + {"value": "2", "label": "High", "disabled": True} + ]) + rendered = str(field) + self.assertIn('', rendered) + self.assertIn('disabled', rendered) + + def test_select_with_value(self): + """Test select with pre-selected value.""" + field = Field.select("country", ["USA", "Canada"], value="Canada") + self.assertIn('selected', str(field)) + + def test_select_with_label(self): + """Test select with label.""" + field = Field.select("country", ["USA", "Canada"], label="Country") + rendered = str(field) + self.assertIn('', rendered) + + +class TestFieldCheckbox(unittest.TestCase): + """Test checkbox fields.""" + + def test_checkbox_basic(self): + """Test basic checkbox.""" + field = Field.checkbox("subscribe") + rendered = str(field) + self.assertIn('type="checkbox"', rendered) + self.assertIn('name="subscribe"', rendered) + self.assertIn('value="on"', rendered) + + def test_checkbox_checked(self): + """Test checked checkbox.""" + field = Field.checkbox("subscribe", checked=True) + self.assertIn('checked', str(field)) + + def test_checkbox_with_label(self): + """Test checkbox with label wrapping input.""" + field = Field.checkbox("subscribe", label="Subscribe to newsletter") + rendered = str(field) + self.assertIn('