Skip to content

Widgets

A widget controls how a single form field is rendered as HTML and what HTMX attributes, if any, it carries. Widgets are Python dataclasses that pair a Jinja2 template with optional static assets and HTMX attributes.


Widget Architecture

@dataclass(slots=True)
class HtmxWidget:
    template_path: str                        # Jinja2 template to render
    static_list: tuple[str, ...] = ()         # CSS/JS assets to include
    htmx_attrs: Mapping[str, str] | None = None  # hx-* attributes injected into the element

Every widget template receives a FormField context object:

@dataclass(slots=True)
class FormField:
    name: str                   # HTML name attribute
    model_field: FieldInfo      # Pydantic FieldInfo (metadata, annotation)
    widget: HtmxWidget
    value: Any | None = None    # Current value (for edit forms)
    errors: list[str] | None = None

    @property
    def label(self) -> str: ...      # Human-readable label
    @property
    def required(self) -> bool: ...  # Whether the field is required
    @property
    def help_text(self) -> str | None: ...  # Field description

Standard HTML Structure

All widgets follow the same outer wrapper pattern:

<div class="ha-form-group">
    <label for="{{ field.name }}" class="ha-label">
        {{ field.label }}
        {%- if field.required -%}<span class="ha-required">*</span>{%- endif -%}
    </label>

    <!-- widget-specific input element -->

    {%- if field.help_text -%}
        <p class="ha-help-text">{{ field.help_text }}</p>
    {%- endif -%}
    {%- if field.errors -%}
        <ul class="ha-field-errors" aria-live="polite">
            {%- for e in field.errors -%}<li>{{ e }}</li>{%- endfor -%}
        </ul>
    {%- endif -%}
</div>

Built-in Widgets

Auto-selection Logic

PydanticForm picks a widget automatically based on the field's Python type annotation and name. Override by passing widgets={"field_name": MyWidget()} to PydanticForm.

Condition Widget selected
Field name is description, text, body, or content Textarea
Annotation is bool CheckboxInput
Annotation is int NumberInput
Annotation is float FloatInput
Annotation is datetime DateTimeInput
Annotation is an Enum subclass SelectInput
Anything else TextInput

TextInput

Renders <input type="text">. Suitable for short strings.

<input id="{{ field.name }}" name="{{ field.name }}"
       type="text" value="{{ field.value or '' }}"
       class="ha-input"
       aria-invalid="{{ 'true' if field.errors else 'false' }}" />

NumberInput / FloatInput

Both render <input type="number">. FloatInput adds step="any" to allow decimals.

Textarea

Renders <textarea>. Auto-selected for fields whose name implies long-form text.

CheckboxInput

Renders a checkbox where the label wraps the input (label-as-container pattern for larger click targets):

<label for="{{ field.name }}" class="ha-checkbox-label">
    <input type="checkbox" id="{{ field.name }}" name="{{ field.name }}"
           value="true" class="ha-checkbox"
           {%- if field.value -%}checked{%- endif -%} />
    <span class="ha-checkbox-text">{{ field.label }}</span>
</label>

SelectInput

Renders <select> for Enum fields. Iterates over enum members to produce options:

<select id="{{ field.name }}" name="{{ field.name }}" class="ha-select"
        aria-invalid="{{ 'true' if field.errors else 'false' }}">
    {%- for choice in field.model_field.annotation -%}
        <option value="{{ choice.value }}"
                {%- if choice == field.value -%}selected{%- endif -%}>
            {{ choice.name }}
        </option>
    {%- endfor -%}
</select>

DateTimeInput

Renders <input type="datetime-local">.