From 72e2eec1a0ac9e5aa8e497c4fb89f4b3c66987bd Mon Sep 17 00:00:00 2001 From: Sean N Date: Fri, 30 Jan 2026 06:48:34 +0200 Subject: [PATCH 1/2] Add HTMX integration documentation and tests - 26 new tests covering all HTMX attributes - Document hx_* attribute usage in SKILL.md (full reference) - Add HTMX examples to README.md - Covers: requests, targeting, swapping, triggers, extensions HTMX attributes already work via underscore-to-hyphen conversion. This commit adds docs and test coverage to ensure it stays working. Co-Authored-By: Claude Opus 4.5 --- README.md | 29 ++++++++ SKILL.md | 93 ++++++++++++++++++++++++ tests/test_htmx.py | 174 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 tests/test_htmx.py diff --git a/README.md b/README.md index 9d11321..acc804d 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,35 @@ def table_rows(items): ]) ``` +### 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..7e8b3c2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -805,6 +805,99 @@ def home(request): --- +## 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 diff --git a/tests/test_htmx.py b/tests/test_htmx.py new file mode 100644 index 0000000..409d9f8 --- /dev/null +++ b/tests/test_htmx.py @@ -0,0 +1,174 @@ +import unittest + +from nitro_ui import Button, Div, Form, Input, Span +from nitro_ui.tags.text import Href + + +class TestHTMXAttributes(unittest.TestCase): + """Test that HTMX attributes work correctly with NitroUI.""" + + def test_hx_get(self): + """Test hx-get attribute.""" + btn = Button("Load", hx_get="/items") + self.assertIn('hx-get="/items"', str(btn)) + + def test_hx_post(self): + """Test hx-post attribute.""" + btn = Button("Submit", hx_post="/submit") + self.assertIn('hx-post="/submit"', str(btn)) + + def test_hx_put(self): + """Test hx-put attribute.""" + btn = Button("Update", hx_put="/items/1") + self.assertIn('hx-put="/items/1"', str(btn)) + + def test_hx_patch(self): + """Test hx-patch attribute.""" + btn = Button("Patch", hx_patch="/items/1") + self.assertIn('hx-patch="/items/1"', str(btn)) + + def test_hx_delete(self): + """Test hx-delete attribute.""" + btn = Button("Delete", hx_delete="/items/1") + self.assertIn('hx-delete="/items/1"', str(btn)) + + def test_hx_target(self): + """Test hx-target attribute.""" + btn = Button("Load", hx_get="/items", hx_target="#list") + self.assertIn('hx-target="#list"', str(btn)) + + def test_hx_swap(self): + """Test hx-swap attribute.""" + btn = Button("Load", hx_get="/items", hx_swap="outerHTML") + self.assertIn('hx-swap="outerHTML"', str(btn)) + + def test_hx_trigger(self): + """Test hx-trigger attribute.""" + inp = Input(type="text", hx_get="/search", hx_trigger="keyup changed delay:500ms") + self.assertIn('hx-trigger="keyup changed delay:500ms"', str(inp)) + + def test_hx_confirm(self): + """Test hx-confirm attribute.""" + btn = Button("Delete", hx_delete="/items/1", hx_confirm="Are you sure?") + self.assertIn('hx-confirm="Are you sure?"', str(btn)) + + def test_hx_indicator(self): + """Test hx-indicator attribute.""" + btn = Button("Load", hx_get="/items", hx_indicator="#spinner") + self.assertIn('hx-indicator="#spinner"', str(btn)) + + def test_hx_push_url(self): + """Test hx-push-url attribute.""" + link = Href("Page 2", href="#", hx_get="/page/2", hx_push_url="true") + self.assertIn('hx-push-url="true"', str(link)) + + def test_hx_select(self): + """Test hx-select attribute.""" + btn = Button("Load", hx_get="/items", hx_select="#content") + self.assertIn('hx-select="#content"', str(btn)) + + def test_hx_select_oob(self): + """Test hx-select-oob attribute.""" + btn = Button("Load", hx_get="/items", hx_select_oob="#sidebar") + self.assertIn('hx-select-oob="#sidebar"', str(btn)) + + def test_hx_swap_oob(self): + """Test hx-swap-oob attribute.""" + div = Div("Updated", id="notification", hx_swap_oob="true") + self.assertIn('hx-swap-oob="true"', str(div)) + + def test_hx_vals(self): + """Test hx-vals attribute with JSON.""" + btn = Button("Submit", hx_post="/submit", hx_vals='{"key": "value"}') + self.assertIn('hx-vals="{"key": "value"}"', str(btn)) + + def test_hx_boost(self): + """Test hx-boost attribute.""" + link = Href("About", href="/about", hx_boost="true") + self.assertIn('hx-boost="true"', str(link)) + + def test_hx_include(self): + """Test hx-include attribute.""" + btn = Button("Submit", hx_post="/submit", hx_include="[name='csrf']") + self.assertIn('hx-include="[name=', str(btn)) + + def test_hx_params(self): + """Test hx-params attribute.""" + btn = Button("Submit", hx_post="/submit", hx_params="*") + self.assertIn('hx-params="*"', str(btn)) + + def test_hx_preserve(self): + """Test hx-preserve attribute.""" + inp = Input(type="text", id="search", hx_preserve="true") + self.assertIn('hx-preserve="true"', str(inp)) + + def test_hx_disable(self): + """Test hx-disable attribute.""" + btn = Button("Submit", hx_post="/submit", hx_disable="true") + self.assertIn('hx-disable="true"', str(btn)) + + def test_multiple_hx_attributes(self): + """Test multiple HTMX attributes together.""" + btn = Button( + "Load More", + hx_get="/items", + hx_target="#list", + hx_swap="beforeend", + hx_indicator="#spinner" + ) + rendered = str(btn) + self.assertIn('hx-get="/items"', rendered) + self.assertIn('hx-target="#list"', rendered) + self.assertIn('hx-swap="beforeend"', rendered) + self.assertIn('hx-indicator="#spinner"', rendered) + + def test_hx_with_other_attributes(self): + """Test HTMX attributes alongside regular HTML attributes.""" + btn = Button( + "Delete", + id="delete-btn", + cls="btn btn-danger", + hx_delete="/items/1", + hx_confirm="Are you sure?", + disabled=True + ) + rendered = str(btn) + self.assertIn('id="delete-btn"', rendered) + self.assertIn('class="btn btn-danger"', rendered) + self.assertIn('hx-delete="/items/1"', rendered) + self.assertIn('hx-confirm="Are you sure?"', rendered) + + def test_form_with_htmx(self): + """Test form with HTMX attributes.""" + form = Form( + Input(type="text", name="query", hx_get="/search", hx_trigger="keyup changed delay:300ms", hx_target="#results"), + Div(id="results"), + hx_boost="true" + ) + rendered = str(form) + self.assertIn('hx-boost="true"', rendered) + self.assertIn('hx-get="/search"', rendered) + self.assertIn('hx-trigger="keyup changed delay:300ms"', rendered) + + +class TestHTMXExtensions(unittest.TestCase): + """Test HTMX extension attributes.""" + + def test_hx_ext(self): + """Test hx-ext attribute for extensions.""" + div = Div(hx_ext="json-enc") + self.assertIn('hx-ext="json-enc"', str(div)) + + def test_sse_connect(self): + """Test SSE extension attributes.""" + div = Div(hx_ext="sse", sse_connect="/events") + self.assertIn('sse-connect="/events"', str(div)) + + def test_ws_connect(self): + """Test WebSocket extension attributes.""" + div = Div(hx_ext="ws", ws_connect="/ws") + self.assertIn('ws-connect="/ws"', str(div)) + + +if __name__ == "__main__": + unittest.main() From e7a5c6daf7d3649d45b8248aca342bac4b36df38 Mon Sep 17 00:00:00 2001 From: Sean N Date: Fri, 30 Jan 2026 08:46:28 +0200 Subject: [PATCH 2/2] Add Form Builder with Field class for HTML5 form generation New Field class with static methods for generating form fields: - Text inputs: text, email, password, url, tel, search, textarea - Numeric: number, range - Date/time: date, time, datetime_local - Selection: select, checkbox, radio - Other: file, hidden, color Features: - HTML5 validation attributes (required, minlength, pattern, etc.) - Optional labels with proper for/id linking - Optional wrapper divs for CSS framework compatibility - Select supports string, tuple, and dict option formats - Works seamlessly with HTMX attributes Usage: Form( Field.email("email", label="Email", required=True), Field.password("password", label="Password", min_length=8), Button("Log In", type="submit") ) 53 new tests (420 total) Co-Authored-By: Claude Opus 4.5 --- README.md | 19 + SKILL.md | 97 +++ docs/plans/2026-01-30-form-builder-design.md | 176 +++++ src/nitro_ui/__init__.py | 2 + src/nitro_ui/forms/__init__.py | 5 + src/nitro_ui/forms/field.py | 710 +++++++++++++++++++ tests/test_forms.py | 444 ++++++++++++ 7 files changed, 1453 insertions(+) create mode 100644 docs/plans/2026-01-30-form-builder-design.md create mode 100644 src/nitro_ui/forms/__init__.py create mode 100644 src/nitro_ui/forms/field.py create mode 100644 tests/test_forms.py diff --git a/README.md b/README.md index acc804d..e6c7964 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,25 @@ 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: diff --git a/SKILL.md b/SKILL.md index 7e8b3c2..dae2639 100644 --- a/SKILL.md +++ b/SKILL.md @@ -805,6 +805,102 @@ 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. @@ -934,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('