Skip to content

Fields and widgets

What a field is (FieldInfo, built by niceview.Field()), how a model's fields are resolved into a set of them (Fields), how a layout arranges them — and the model-free half, which renders a single widget from a single FieldInfo. See Field types for the type→widget mapping.

Field metadata

Field

Field(**kwargs: Unpack[_FieldInfoInputs]) -> FieldInfo

Create FieldInfo instance with the provided keyword arguments. This is a convenience function to create fields for forms and tables.

Source code in niceview/__init__.py
def Field(**kwargs: Unpack[_FieldInfoInputs]) -> FieldInfo:
    """
    Create FieldInfo instance with the provided keyword arguments.
    This is a convenience function to create fields for forms and tables.
    """
    return FieldInfo(**kwargs)

FieldInfo

Per-field UI metadata, the rendering counterpart of pydantic's Field: not validation or serialization, but how a field looks and behaves in forms and tables.

Source code in niceview/fieldinfo.py
class FieldInfo():
    """
    Per-field UI metadata, the rendering counterpart of pydantic's Field: not validation or
    serialization, but how a field looks and behaves in forms and tables.
    """
    field_type: type = str
    """Python type of the value. Resolved from the model annotation by Fields; set it explicitly
    when building a FieldInfo by hand for render_field()."""

    label: str = ''
    """The field's label text."""
    placeholder: str | None = None
    """Placeholder text shown in an empty text-like widget."""

    required: bool | None = None
    """Whether the field must have a value. None until Fields resolves it from pydantic's
    is_required() — a different concept from pydantic's own required."""
    hidden: bool = False
    """Hide the field entirely."""
    editable: bool = True
    """Whether the widget accepts input; False renders it disabled."""
    hint: str | None = None
    """Help text shown below the widget (Quasar's `hint` prop). Set explicitly; widgets without
    a hint slot (see widgets.HINT_WIDGETS) ignore it."""
    description: str | None = None
    """What the model says the field means, resolved from pydantic's `description`. Carried as
    metadata only — `description_as` decides at render time whether it becomes the hint, the
    tooltip, or nothing at all, and an explicit `hint`/`tooltip` always wins over it."""
    widget_type: WidgetType | None = None
    """Which element renders the field; inferred from the type if omitted."""

    props: str | None = None
    """Quasar props string, merged on top of niceview's own."""
    classes: str | None = None
    """CSS classes for the widget."""
    style: str | None = None
    """Inline CSS style for the widget."""
    tooltip: str | None = None
    """Text shown on hover."""

    aggrid: dict[str, str] | None = None
    """Additional ag-grid column properties, e.g. {'headerName': 'My Column'}, merged on top of
    the computed ones."""

    options: OptionsSource | None = None
    """Choices for select/radio/toggle/checkbox_group widgets. Resolution order per widget:
    options, then literal_options (auto-extracted from Literal[...] types). checkbox_group lays
    out horizontally via props='inline' (same convention as ui.radio)."""

    # additional options when field is rendered in a ui.input widget
    password: bool = False
    """Mask the input (ui.input only)."""
    password_toggle_button: bool = False
    """Show/hide toggle for a password input."""
    autocomplete: list[str] | None = None
    """Autocomplete suggestions for a text input."""
    validation: ValidationFunction | ValidationDict | None = None
    """Extra validation beyond `required`: a NiceGUI ValidationFunction or dict."""

    # additional options when field is rendered as ui.number
    min: float | None = None
    """Minimum value (ui.number)."""
    max: float | None = None
    """Maximum value (ui.number)."""
    precision: int | None = None
    """Decimal places (ui.number)."""
    step: float | None = None
    """Increment step (ui.number)."""
    prefix: str | None = None
    """Text shown before the value (ui.number)."""
    suffix: str | None = None
    """Text shown after the value (ui.number)."""
    number_format: str | None = None
    """Display format of ui.number, e.g. '%.2f'. Named number_format, not format, to keep it
    apart from JSON Schema's `format`, which corresponds to widget_type."""

    clearable: bool = False
    """Offer a clear button. Honoured by the select-like widgets and by every text input,
    ui.color_input included; a widget without a clear affordance (checkbox, switch, radio,
    slider, rating, checkbox_group) ignores it. Clearing writes None into the field."""

    # additional options when field is rendered as ui.select
    with_input: bool = False
    """Allow free-text filtering in ui.select."""
    multiple: bool = False
    """Allow selecting multiple values in ui.select."""
    key_generator: Callable[[Any], Any] | None = None
    """Generates a dict key for a new value typed into ui.select."""

    # additional options when field is rendered as ui.color_input
    color_preview: bool = False
    """Show a color swatch preview next to ui.color_input."""

    # options inferred from Literal type args — set by Fields, not user-settable
    literal_options: list | None = None
    """Choices auto-extracted from a Literal[...] annotation; set by Fields, not user-settable."""

    # additional options when the field is rendered as ui.input_chips
    new_value_mode: Literal['add', 'add-unique', 'toggle'] = 'add-unique'
    """How ui.input_chips treats a typed value not already in the list."""

    # additional options when field is a relationship field
    item_type: type | None = None
    """Item's pydantic type for editgrid/modelselect fields."""

    # options when field is used in a table or grid column
    table_label: str = ''
    """Column header label; defaults to the field's label."""
    table_hidden: bool = False
    """Hide the column in a table/grid (the field may still show in a form)."""
    table_align: Literal['left', 'center', 'right'] | None = None
    """Horizontal text alignment of the cell."""
    table_cell_style: str = ''
    """Extra CSS for the cell, merged with table_align."""
    table_sortable: bool = True
    """Whether the column can be sorted."""
    table_sort: Literal[None, 'asc', 'desc'] | None = None
    """Default sort order for the column."""
    table_filterable: bool = True
    """Show a filter row for the column; filter type inferred from the field type."""
    table_floating_filter: bool = False
    """Show a floating filter row for the column."""
    aggrid_type: str | None = None
    """ag-grid column type, e.g. 'numericColumn', 'rightAligned'."""


    def __init__(self, **kwargs: Unpack[_FieldInfoInputs]):
        # Initialize the field with the provided keyword arguments.
        for key, value in kwargs.items():
            if hasattr(self, key):
                # use default value if not provided (not None)
                if value is not None:
                    setattr(self, key, value)
            else:
                raise TypeError(f"Unexpected keyword argument for FieldInfo: {key}")

    def __repr__(self):
        """Print non-none values"""
        non_default_values = {k: v for k, v in self.__dict__.items() if v is not None}
        formatted_values = ', '.join(
            f"{k}={v!r}"
            for k, v in non_default_values.items()
        )
        if formatted_values:
            return f"{self.__class__.__name__}({formatted_values})"
        return super().__repr__()

field_type class-attribute instance-attribute

field_type: type = str

Python type of the value. Resolved from the model annotation by Fields; set it explicitly when building a FieldInfo by hand for render_field().

label class-attribute instance-attribute

label: str = ''

The field's label text.

placeholder class-attribute instance-attribute

placeholder: str | None = None

Placeholder text shown in an empty text-like widget.

required class-attribute instance-attribute

required: bool | None = None

Whether the field must have a value. None until Fields resolves it from pydantic's is_required() — a different concept from pydantic's own required.

hidden class-attribute instance-attribute

hidden: bool = False

Hide the field entirely.

editable class-attribute instance-attribute

editable: bool = True

Whether the widget accepts input; False renders it disabled.

hint class-attribute instance-attribute

hint: str | None = None

Help text shown below the widget (Quasar's hint prop). Set explicitly; widgets without a hint slot (see widgets.HINT_WIDGETS) ignore it.

description class-attribute instance-attribute

description: str | None = None

What the model says the field means, resolved from pydantic's description. Carried as metadata only — description_as decides at render time whether it becomes the hint, the tooltip, or nothing at all, and an explicit hint/tooltip always wins over it.

widget_type class-attribute instance-attribute

widget_type: WidgetType | None = None

Which element renders the field; inferred from the type if omitted.

props class-attribute instance-attribute

props: str | None = None

Quasar props string, merged on top of niceview's own.

classes class-attribute instance-attribute

classes: str | None = None

CSS classes for the widget.

style class-attribute instance-attribute

style: str | None = None

Inline CSS style for the widget.

tooltip class-attribute instance-attribute

tooltip: str | None = None

Text shown on hover.

aggrid class-attribute instance-attribute

aggrid: dict[str, str] | None = None

Additional ag-grid column properties, e.g. {'headerName': 'My Column'}, merged on top of the computed ones.

options class-attribute instance-attribute

options: OptionsSource | None = None

Choices for select/radio/toggle/checkbox_group widgets. Resolution order per widget: options, then literal_options (auto-extracted from Literal[...] types). checkbox_group lays out horizontally via props='inline' (same convention as ui.radio).

password class-attribute instance-attribute

password: bool = False

Mask the input (ui.input only).

password_toggle_button class-attribute instance-attribute

password_toggle_button: bool = False

Show/hide toggle for a password input.

autocomplete class-attribute instance-attribute

autocomplete: list[str] | None = None

Autocomplete suggestions for a text input.

validation class-attribute instance-attribute

validation: ValidationFunction | ValidationDict | None = (
    None
)

Extra validation beyond required: a NiceGUI ValidationFunction or dict.

min class-attribute instance-attribute

min: float | None = None

Minimum value (ui.number).

max class-attribute instance-attribute

max: float | None = None

Maximum value (ui.number).

precision class-attribute instance-attribute

precision: int | None = None

Decimal places (ui.number).

step class-attribute instance-attribute

step: float | None = None

Increment step (ui.number).

prefix class-attribute instance-attribute

prefix: str | None = None

Text shown before the value (ui.number).

suffix class-attribute instance-attribute

suffix: str | None = None

Text shown after the value (ui.number).

number_format class-attribute instance-attribute

number_format: str | None = None

Display format of ui.number, e.g. '%.2f'. Named number_format, not format, to keep it apart from JSON Schema's format, which corresponds to widget_type.

clearable class-attribute instance-attribute

clearable: bool = False

Offer a clear button. Honoured by the select-like widgets and by every text input, ui.color_input included; a widget without a clear affordance (checkbox, switch, radio, slider, rating, checkbox_group) ignores it. Clearing writes None into the field.

with_input class-attribute instance-attribute

with_input: bool = False

Allow free-text filtering in ui.select.

multiple class-attribute instance-attribute

multiple: bool = False

Allow selecting multiple values in ui.select.

key_generator class-attribute instance-attribute

key_generator: Callable[[Any], Any] | None = None

Generates a dict key for a new value typed into ui.select.

color_preview class-attribute instance-attribute

color_preview: bool = False

Show a color swatch preview next to ui.color_input.

literal_options class-attribute instance-attribute

literal_options: list | None = None

Choices auto-extracted from a Literal[...] annotation; set by Fields, not user-settable.

new_value_mode class-attribute instance-attribute

new_value_mode: Literal["add", "add-unique", "toggle"] = (
    "add-unique"
)

How ui.input_chips treats a typed value not already in the list.

item_type class-attribute instance-attribute

item_type: type | None = None

Item's pydantic type for editgrid/modelselect fields.

table_label class-attribute instance-attribute

table_label: str = ''

Column header label; defaults to the field's label.

table_hidden class-attribute instance-attribute

table_hidden: bool = False

Hide the column in a table/grid (the field may still show in a form).

table_align class-attribute instance-attribute

table_align: Literal["left", "center", "right"] | None = (
    None
)

Horizontal text alignment of the cell.

table_cell_style class-attribute instance-attribute

table_cell_style: str = ''

Extra CSS for the cell, merged with table_align.

table_sortable class-attribute instance-attribute

table_sortable: bool = True

Whether the column can be sorted.

table_sort class-attribute instance-attribute

table_sort: Literal[None, 'asc', 'desc'] | None = None

Default sort order for the column.

table_filterable class-attribute instance-attribute

table_filterable: bool = True

Show a filter row for the column; filter type inferred from the field type.

table_floating_filter class-attribute instance-attribute

table_floating_filter: bool = False

Show a floating filter row for the column.

aggrid_type class-attribute instance-attribute

aggrid_type: str | None = None

ag-grid column type, e.g. 'numericColumn', 'rightAligned'.

Fields

Bases: Mapping[str, FieldInfo]

Fields and field information for datamodel based UI components.

Source code in niceview/fields.py
class Fields(typing.Mapping[str, FieldInfo]):
    """
    Fields and field information for datamodel based UI components.
    """
    _item_type: type[pydantic.BaseModel]
    _include: list[str]
    _exclude: list[str]
    _field_names: list[str]
    _field_infos: dict[str, FieldInfo]
    _layout: LayoutGroup

    def __init__(self, item_type: type[pydantic.BaseModel], include: str | typing.Iterable[str] = '__all__', exclude: str | typing.Iterable[str] = '', field_infos: dict[str, FieldInfo] = {}, profile: str | None = None, layout: typing.Any = None, actions: typing.Container[str] = ()):
        self._item_type = item_type
        meta = getattr(item_type, 'Meta', None)

        if profile is not None:
            profiles: dict = getattr(meta, 'profiles', {}) if meta else {}
            if profile not in profiles:
                available = list(profiles.keys())
                raise ValueError(f"Profile '{profile}' not found in {item_type.__name__}.Meta.profiles. Available: {available}")
            include = profiles[profile]
        elif layout is None and include == '__all__':
            # Only the untouched default falls back to it -- an explicit include=/Meta.include
            # already answered "which fields", same rank as profile=/layout= above. Meta.default_profile
            # is a hint, not a request, though: unlike an explicit profile=, a stale or absent
            # name degrades to no profile rather than raising.
            default_profile = getattr(meta, 'default_profile', None) if meta else None
            if default_profile is not None:
                profiles = getattr(meta, 'profiles', {}) if meta else {}
                if default_profile in profiles:
                    include = profiles[default_profile]
        if layout is not None:
            include = layout  # an explicit layout is an inline profile: it defines the field set

        all_fields = set(item_type.model_fields.keys())

        # An explicit field list is parsed as a layout: a flat list is a layout without rows, so
        # there is one code path, one place that validates names — and one ordering rule, no
        # matter whether the fields were given as a list or as a comma-separated string.
        if isinstance(include, str) and include.strip() not in ('', '__all__'):
            include = [name.strip() for name in include.split(',') if name.strip()]
        parsed_layout: LayoutGroup | None = None
        if isinstance(include, (list, tuple)) and list(include) != ['__all__']:
            parsed_layout = parse_layout(include, all_fields, valid_actions=actions)
            include = layout_field_names(parsed_layout)
            duplicates = sorted({n for n in include if include.count(n) > 1})
            if duplicates:
                raise ValueError(f"Layout for '{item_type.__name__}' names field(s) more than once: {duplicates}")

        self._include = self._parse_field_names(include, all_fields, allow_all=True, model_name=item_type.__name__)
        self._exclude = self._parse_field_names(exclude, all_fields, allow_all=False, model_name=item_type.__name__)

        resolver = _FieldInfoResolver(item_type)
        self._field_names, self._field_infos = self._build_field_infos(resolver, meta, field_infos)
        if parsed_layout is None:
            self._apply_field_order(meta)
            self._layout = LayoutGroup(tuple(LayoutField(name) for name in self._field_names))
        else:
            # The layout defines the order; Meta.field_order does not apply on top of it.
            unavailable = [n for n in layout_field_names(parsed_layout) if n not in self._field_infos]
            if unavailable:
                raise ValueError(
                    f"Layout for '{item_type.__name__}' names field(s) that are not available: {unavailable} "
                    f"(excluded, private, or without usable type information)"
                )
            self._layout = parsed_layout
            self._field_names = layout_field_names(parsed_layout)

    @staticmethod
    def _parse_field_names(field_list: str | typing.Iterable[str], all_fields: set[str], *, allow_all: bool, model_name: str = '') -> list[str]:
        """Parse an include or exclude field list from a string or iterable."""
        if isinstance(field_list, str):
            result = [f.strip() for f in field_list.split(',') if f.strip()]
        elif isinstance(field_list, typing.Iterable):
            result = list(field_list)
        else:
            raise ValueError(f"Invalid field list: '{field_list}' must be a string or an iterable of field names")

        if allow_all and result == ['__all__']:
            return ['__all__']

        invalid = [f for f in result if not isinstance(f, str) or f not in all_fields]
        if invalid:
            raise ValueError(f"Invalid field name(s): {invalid} not found in '{model_name}'")

        return result

    def is_included(self, field_name: str) -> bool:
        """
        Check if the field is included (and not excluded) in the fields.
        Exclude private fields (starting with '_') by default.
        """
        if field_name.startswith('_'):
            return False
        if self._include == ['__all__']:
            return field_name not in self._exclude
        return field_name in self._include and field_name not in self._exclude

    def _build_field_infos(self, resolver: _FieldInfoResolver, meta, field_infos: dict[str, FieldInfo]) -> tuple[list[str], dict[str, FieldInfo]]:
        pydantic_fields = self._item_type.model_fields
        is_sqlmodel = _SQLMODEL_AVAILABLE and issubclass(self._item_type, _SQLModel)
        # 'field_infos' is the documented name; 'field_info' (singular) is accepted for backward compatibility.
        meta_field_info: dict[str, FieldInfo] = {}
        if meta is not None:
            meta_field_info = getattr(meta, 'field_infos', None) or getattr(meta, 'field_info', {})

        names: list[str] = []
        infos: dict[str, FieldInfo] = {}

        # Collect annotations across the MRO so inherited model fields are included
        # (cls.__annotations__ only contains the class's own annotations). Base-class
        # fields come first, matching pydantic's model_fields ordering; an override
        # in a subclass keeps the base-class position (dict.update semantics).
        annotations: dict[str, typing.Any] = {}
        for klass in reversed(self._item_type.__mro__):
            annotations.update(getattr(klass, '__annotations__', {}))

        for field_name, field_type in annotations.items():
            if not self.is_included(field_name):
                continue

            fi: FieldInfo | None
            if field_name in pydantic_fields:
                fi = resolver.from_pydantic(field_name, pydantic_fields[field_name])
            elif is_sqlmodel:
                fi = resolver.from_sqlmodel(field_name, field_type)
            else:
                fi = None

            if fi is None:
                log.debug(f"{self._item_type.__name__}.{field_name} type={field_type} has no additional info")
                continue

            if field_name in meta_field_info:
                meta_fi = meta_field_info[field_name]
                if not isinstance(meta_fi, FieldInfo):
                    raise ValueError(f"Invalid field info in Meta class for field '{field_name}': {meta_fi}")
                fi = _merge_field_infos(fi, meta_fi)

            if field_name in field_infos:
                fi = _merge_field_infos(fi, field_infos[field_name])

            names.append(field_name)
            infos[field_name] = fi
            log.debug(f"{self._item_type.__name__}.{field_name} type={field_type} FieldInfo={fi}")

        return names, infos

    def _apply_field_order(self, meta) -> None:
        field_order: list[str] | None = getattr(meta, 'field_order', None) if meta is not None else None
        if field_order is None:
            return

        unknown = [f for f in field_order if f not in self._field_infos]
        if unknown:
            raise ValueError(f"Meta.field_order contains unknown field(s) for '{self._item_type.__name__}': {unknown}")

        ordered = [f for f in field_order if f in self._field_names]
        remaining = [f for f in self._field_names if f not in set(field_order)]
        self._field_names = ordered + remaining
        log.debug(f"{self._item_type.__name__}: field_order applied -> {self._field_names}")

    @property
    def field_names(self) -> typing.Iterable[str]:
        return self._field_names

    @property
    def layout(self) -> LayoutGroup:
        """
        The form layout: a tree of rows, columns and sections over the fields. Without an explicit
        layout this is a flat group in field order, which renders exactly as before.
        Grids and lists ignore the tree and read the flattened `field_names`.
        """
        return self._layout

    def __getitem__(self, key: str) -> FieldInfo:
        return self._field_infos[key]

    def __iter__(self) -> typing.Iterator[str]:
        return iter(self._field_names)

    def __len__(self) -> int:
        return len(self._field_names)

    def validation_errors(self, model_dict) -> typing.Tuple[typing.Dict[str, str], typing.List[str]]:
        """
        Validate the model with the new value and return a list of validation errors.
        If there are no validation errors, return None.
        """
        field_error_lists: dict[str, list[str]] = {}
        nonfield_errors: list[str] = []
        try:
            self._item_type.model_validate(model_dict)
        except pydantic.ValidationError as e:
            for error in e.errors():
                msg = error['msg']
                attributed = False

                # First pass: find a visible (non-hidden) field in the error location
                for loc in error['loc']:
                    if not isinstance(loc, str) or loc not in self._field_names:
                        continue
                    if not self._field_infos[loc].hidden:
                        field_error_lists.setdefault(loc, []).append(msg)
                        attributed = True
                        break

                if not attributed:
                    # Second pass: find a hidden field, redirect FK errors to the visible relationship field
                    for loc in error['loc']:
                        if not isinstance(loc, str) or loc not in self._field_names:
                            continue
                        fi = self._field_infos[loc]
                        if not fi.hidden:
                            continue
                        # e.g. author_id -> author
                        base = loc.removesuffix('_id') if loc.endswith('_id') else None
                        if base and base in self._field_names and not self._field_infos[base].hidden:
                            field_error_lists.setdefault(base, []).append(msg)
                        else:
                            nonfield_errors.append(f"{fi.label or loc}: {msg}")
                        attributed = True
                        break

                if not attributed:
                    nonfield_errors.append(msg)

        field_errors: dict[str, str] = {k: ', '.join(v) for k, v in field_error_lists.items()}
        return field_errors, nonfield_errors

    def validation_error_list(self, model_dict) -> typing.List[str]:
        """
        Validate the model with the new value and return a list of validation error messages.
        """
        field_errors, nonfield_errors = self.validation_errors(model_dict)
        errors = []
        for k, v in field_errors.items():
            field_label = self._field_infos[k].label or k
            errors.append(f"{field_label}: {v}")
        errors.extend(nonfield_errors)
        return errors

layout property

layout: LayoutGroup

The form layout: a tree of rows, columns and sections over the fields. Without an explicit layout this is a flat group in field order, which renders exactly as before. Grids and lists ignore the tree and read the flattened field_names.

is_included

is_included(field_name: str) -> bool

Check if the field is included (and not excluded) in the fields. Exclude private fields (starting with '_') by default.

Source code in niceview/fields.py
def is_included(self, field_name: str) -> bool:
    """
    Check if the field is included (and not excluded) in the fields.
    Exclude private fields (starting with '_') by default.
    """
    if field_name.startswith('_'):
        return False
    if self._include == ['__all__']:
        return field_name not in self._exclude
    return field_name in self._include and field_name not in self._exclude

validation_errors

validation_errors(
    model_dict,
) -> Tuple[Dict[str, str], List[str]]

Validate the model with the new value and return a list of validation errors. If there are no validation errors, return None.

Source code in niceview/fields.py
def validation_errors(self, model_dict) -> typing.Tuple[typing.Dict[str, str], typing.List[str]]:
    """
    Validate the model with the new value and return a list of validation errors.
    If there are no validation errors, return None.
    """
    field_error_lists: dict[str, list[str]] = {}
    nonfield_errors: list[str] = []
    try:
        self._item_type.model_validate(model_dict)
    except pydantic.ValidationError as e:
        for error in e.errors():
            msg = error['msg']
            attributed = False

            # First pass: find a visible (non-hidden) field in the error location
            for loc in error['loc']:
                if not isinstance(loc, str) or loc not in self._field_names:
                    continue
                if not self._field_infos[loc].hidden:
                    field_error_lists.setdefault(loc, []).append(msg)
                    attributed = True
                    break

            if not attributed:
                # Second pass: find a hidden field, redirect FK errors to the visible relationship field
                for loc in error['loc']:
                    if not isinstance(loc, str) or loc not in self._field_names:
                        continue
                    fi = self._field_infos[loc]
                    if not fi.hidden:
                        continue
                    # e.g. author_id -> author
                    base = loc.removesuffix('_id') if loc.endswith('_id') else None
                    if base and base in self._field_names and not self._field_infos[base].hidden:
                        field_error_lists.setdefault(base, []).append(msg)
                    else:
                        nonfield_errors.append(f"{fi.label or loc}: {msg}")
                    attributed = True
                    break

            if not attributed:
                nonfield_errors.append(msg)

    field_errors: dict[str, str] = {k: ', '.join(v) for k, v in field_error_lists.items()}
    return field_errors, nonfield_errors

validation_error_list

validation_error_list(model_dict) -> List[str]

Validate the model with the new value and return a list of validation error messages.

Source code in niceview/fields.py
def validation_error_list(self, model_dict) -> typing.List[str]:
    """
    Validate the model with the new value and return a list of validation error messages.
    """
    field_errors, nonfield_errors = self.validation_errors(model_dict)
    errors = []
    for k, v in field_errors.items():
        field_label = self._field_infos[k].label or k
        errors.append(f"{field_label}: {v}")
    errors.extend(nonfield_errors)
    return errors

Layout

The tree a layout notation parses into. '@name' becomes a LayoutAction, a field name a LayoutField, a nested list a LayoutGroup — see Components for the notation itself.

LayoutGroup dataclass

A container in a form layout: a row, a column, or — with a title — a section. Nesting alternates row and column; a titled group is always a column, so that a section reads the same wherever it sits.

A section comes in two shapes, told apart by card: '# Title' draws a card around its fields, '## Title' only sets the heading above them. Both headings look the same.

Source code in niceview/fields.py
@dataclasses.dataclass(frozen=True, slots=True)
class LayoutGroup:
    """
    A container in a form layout: a row, a column, or — with a title — a section.
    Nesting alternates row and column; a titled group is always a column, so that a section
    reads the same wherever it sits.

    A section comes in two shapes, told apart by `card`: '# Title' draws a card around its
    fields, '## Title' only sets the heading above them. Both headings look the same.
    """
    children: tuple['LayoutField | LayoutAction | LayoutGroup', ...]
    row: bool = False
    title: str | None = None
    classes: str | None = None
    card: bool = False

LayoutField dataclass

One field in a form layout, with the CSS classes given after its colon (if any).

Source code in niceview/fields.py
@dataclasses.dataclass(frozen=True, slots=True)
class LayoutField:
    """One field in a form layout, with the CSS classes given after its colon (if any)."""
    name: str
    classes: str | None = None

LayoutAction dataclass

One action button in a form layout, written '@name' and looked up in the form's actions. An action is layout, not a field: it has no value, no validation and no place in the model.

Source code in niceview/fields.py
@dataclasses.dataclass(frozen=True, slots=True)
class LayoutAction:
    """
    One action button in a form layout, written '@name' and looked up in the form's `actions`.
    An action is layout, not a field: it has no value, no validation and no place in the model.
    """
    name: str
    classes: str | None = None

parse_layout

parse_layout(
    spec: Any,
    valid_names: Container[str],
    *,
    valid_actions: Container[str] = (),
    row: bool = False,
    path: str = "layout",
) -> LayoutGroup

Parse a nested field layout into LayoutGroups, LayoutFields and LayoutActions.

A list holds field names; a nested list opens a container (rows and columns alternate). Leading strings are metadata for their own group — '# Title' makes it a titled card, '## Title' a section with the same heading but no card around it, ':classes' replaces the container's default CSS classes. A field name may carry classes of its own after a colon: 'street🇸🇲w-2/3' (only the first colon separates, so Tailwind prefixes stay intact).

'@name' is an action button rather than a field, and must be declared in valid_actions — the form's actions table, which holds the callback the name cannot carry.

Raises ValueError with the position of the offending element.

Source code in niceview/fields.py
def parse_layout(spec: typing.Any, valid_names: typing.Container[str], *, valid_actions: typing.Container[str] = (),
                 row: bool = False, path: str = 'layout') -> LayoutGroup:
    """
    Parse a nested field layout into LayoutGroups, LayoutFields and LayoutActions.

    A list holds field names; a nested list opens a container (rows and columns alternate).
    Leading strings are metadata for their own group — '# Title' makes it a titled card,
    '## Title' a section with the same heading but no card around it, ':classes' replaces the
    container's default CSS classes. A field name may carry classes of its own after a colon:
    'street:sm:w-2/3' (only the first colon separates, so Tailwind prefixes stay intact).

    '@name' is an action button rather than a field, and must be declared in `valid_actions` —
    the form's `actions` table, which holds the callback the name cannot carry.

    Raises ValueError with the position of the offending element.
    """
    if not isinstance(spec, (list, tuple)):
        raise ValueError(f"{path}: expected a list of field names, got {type(spec).__name__}")

    title: str | None = None
    card = False
    classes: str | None = None
    first_field = 0
    for index, element in enumerate(spec):
        if not (isinstance(element, str) and element[:1] in ('#', ':')):
            break
        where = f'{path}[{index}]'
        if element.startswith('#'):
            if title is not None:
                raise ValueError(f"{where}: the group already has a title ('{title}')")
            level = len(element) - len(element.lstrip('#'))
            if level > 2:
                raise ValueError(f"{where}: '{'#' * level}' is not a heading level — "
                                 f"'#' draws a card, '##' only the heading")
            card = level == 1
            title = element.lstrip('#').strip()
            if not title:
                raise ValueError(f"{where}: '{'#' * level}' needs a title")
        else:
            if classes is not None:
                raise ValueError(f"{where}: the group already has classes ('{classes}')")
            classes = element[1:].strip()
            if not classes:
                raise ValueError(f"{where}: ':' needs at least one CSS class")
        first_field = index + 1

    row = False if title is not None else row  # a section stacks: its heading sits above it

    children: list[LayoutField | LayoutAction | LayoutGroup] = []
    for index, element in enumerate(spec[first_field:], start=first_field):
        where = f'{path}[{index}]'
        if isinstance(element, str):
            if element[:1] in ('#', ':'):
                raise ValueError(f"{where}: '{element}' is group metadata and must come before the fields")
            name, _, hint = element.partition(':')
            name, hint = name.strip(), hint.strip()
            if name.startswith('@'):
                action = name[1:]
                if not action:
                    raise ValueError(f"{where}: '@' needs an action name")
                if action not in valid_actions:
                    raise ValueError(f"{where}: unknown action '{action}' — every '@name' needs an "
                                     f"entry in the form's actions")
                children.append(LayoutAction(action, hint or None))
            elif name not in valid_names:
                raise ValueError(f"{where}: unknown field '{name}'")
            else:
                children.append(LayoutField(name, hint or None))
        elif isinstance(element, (list, tuple)):
            children.append(parse_layout(element, valid_names, valid_actions=valid_actions, row=not row, path=where))
        else:
            raise ValueError(f"{where}: expected a field name or a nested list, got {type(element).__name__}")

    if not children:
        raise ValueError(f"{path}: a layout group must contain at least one field")
    return LayoutGroup(tuple(children), row=row, title=title, classes=classes, card=card)

layout_field_names

layout_field_names(group: LayoutGroup) -> list[str]

All field names in a layout, in rendering order. Actions are not fields and not listed.

Source code in niceview/fields.py
def layout_field_names(group: LayoutGroup) -> list[str]:
    """All field names in a layout, in rendering order. Actions are not fields and not listed."""
    names: list[str] = []
    for child in group.children:
        if isinstance(child, LayoutField):
            names.append(child.name)
        elif isinstance(child, LayoutGroup):
            names.extend(layout_field_names(child))
    return names

layout_action_names

layout_action_names(group: LayoutGroup) -> list[str]

All action names in a layout, in rendering order.

Source code in niceview/fields.py
def layout_action_names(group: LayoutGroup) -> list[str]:
    """All action names in a layout, in rendering order."""
    names: list[str] = []
    for child in group.children:
        if isinstance(child, LayoutAction):
            names.append(child.name)
        elif isinstance(child, LayoutGroup):
            names.extend(layout_action_names(child))
    return names

Widgets without a model

render_field

render_field(
    field_info: FieldInfo,
    value: Any = None,
    *,
    local_tz: str | None = None,
    required_marker: str
    | None
    | _FromChromeText = FROM_CHROME_TEXT,
    description_as: DescriptionTarget = DESCRIPTION_AS,
) -> Any

Render a single widget from a FieldInfo in the current NiceGUI context, initialised to value, and return it.

The widget-building half of ModelForm without the model: no Fields, no item, no adapter, no autosave, no change events — the caller reads the widget itself, via field_value(widget, field_info) for the same value conversions ModelForm applies.

fi = niceview.Field(label='Name', widget_type='ui.input', props='outlined dense', classes='w-full')
widget = niceview.render_field(fi, 'Alice')
...
name = niceview.field_value(widget, fi)

field_info.widget_type is required — without a model there is nothing to infer it from. label / placeholder / hint / tooltip / props / classes / style / options and the widget-specific attributes (min, max, step, multiple, ...) are applied as in a ModelForm, the application-wide FieldStyle included: the category's props (input_props / control_props) beneath the field's own, and default_classes when the field brings none. Only a ModelForm's per-form layers (base_props, its own default_classes, the layout) do not apply here — there is no form. Validation works the same: required rejects an empty value, then field_info.validation runs as NiceGUI's own validation would. What a ModelForm adds on top — validating the whole item against a Pydantic model — is the only other difference.

required also appends required_marker to the label — ChromeText's by default; pass required_marker=None for none.

field_info.description is help text without a fixed place: description_as decides whether it is rendered as the hint, as the tooltip, or not at all. It is the slot for text that came from a schema rather than from the person laying out the form — an explicit hint or tooltip on the FieldInfo always wins over it.

Raises ValueError if widget_type is missing, unknown, or one of 'editgrid' / 'modelselect' (both need a model type and a repository — use ModelForm for those).

Source code in niceview/widgets.py
def render_field(field_info: FieldInfo, value: Any = None, *, local_tz: str | None = None,
                 required_marker: 'str | None | _FromChromeText' = FROM_CHROME_TEXT,
                 description_as: DescriptionTarget = DESCRIPTION_AS) -> Any:
    """
    Render a single widget from a FieldInfo in the current NiceGUI context, initialised to
    `value`, and return it.

    The widget-building half of ModelForm without the model: no Fields, no item, no adapter,
    no autosave, no change events — the caller reads the widget itself, via
    `field_value(widget, field_info)` for the same value conversions ModelForm applies.

    ```python
    fi = niceview.Field(label='Name', widget_type='ui.input', props='outlined dense', classes='w-full')
    widget = niceview.render_field(fi, 'Alice')
    ...
    name = niceview.field_value(widget, fi)
    ```

    `field_info.widget_type` is required — without a model there is nothing to infer it from.
    label / placeholder / hint / tooltip / props / classes / style / options and the
    widget-specific attributes (min, max, step, multiple, ...) are applied as in a ModelForm,
    the application-wide FieldStyle included: the category's props (input_props / control_props)
    beneath the field's own, and default_classes when the field brings none. Only a ModelForm's
    per-form layers (base_props, its own default_classes, the layout) do not apply here — there
    is no form. Validation works the same: `required` rejects an empty value, then
    `field_info.validation` runs as NiceGUI's own validation would. What a ModelForm adds on
    top — validating the whole item against a Pydantic model — is the only other difference.

    `required` also appends `required_marker` to the label — ChromeText's by default; pass
    `required_marker=None` for none.

    `field_info.description` is help text without a fixed place: `description_as` decides
    whether it is rendered as the hint, as the tooltip, or not at all. It is the slot for text
    that came from a schema rather than from the person laying out the form — an explicit
    `hint` or `tooltip` on the FieldInfo always wins over it.

    Raises ValueError if widget_type is missing, unknown, or one of 'editgrid' / 'modelselect'
    (both need a model type and a repository — use ModelForm for those).
    """
    if not isinstance(field_info, FieldInfo):
        raise TypeError(f"field_info must be a FieldInfo, got {type(field_info)}")
    widget_type = field_info.widget_type
    if not widget_type:
        raise ValueError("render_field() requires field_info.widget_type to be set: without a model there is no type to infer it from")
    if widget_type in MODEL_ONLY_WIDGETS:
        raise ValueError(f"render_field() does not support widget_type '{widget_type}': it needs a model type and a repository (use ModelForm)")

    def push_value(widget: Any) -> None:
        widget.value = to_widget_value(field_info, value, local_tz=local_tz)

    field_info = _apply_field_style(field_info)
    widget = create_widget(field_info, field_info.label or widget_type, push_value, required_marker, description_as)
    if field_info.required and hasattr(widget, 'validation'):
        # Chain the required check in front of the caller's own validation, same order as
        # ModelForm's — apply_field_info() has already set field_info.validation.
        own = field_info.validation

        def validate(value: Any, own: Any = own) -> Any:
            return required_error(field_info, value) or run_validation(own, value)

        widget.validation = validate
        widget.validate(return_result=False)
    return widget

field_value

field_value(
    widget: Any,
    field_info: FieldInfo,
    *,
    local_tz: str | None = None,
) -> Any

Read a widget rendered by render_field() and convert its value to the field's Python type.

The inverse of the value handling in render_field(): ISO strings become date / time / datetime / timedelta objects, numbers become int or float according to field_info.field_type, comma-separated chips are split.

field_info.field_type (default str) drives the conversions that depend on the target type — set it to int to get ints out of a 'ui.number', to list[str] for a comma-separated 'ui.input', or to T | None to have an empty selection read back as None.

Raises ValueError for 'editgrid' and 'modelselect', which need a model and a repository.

Source code in niceview/widgets.py
def field_value(widget: Any, field_info: FieldInfo, *, local_tz: str | None = None) -> Any:
    """
    Read a widget rendered by render_field() and convert its value to the field's Python type.

    The inverse of the value handling in render_field(): ISO strings become date / time /
    datetime / timedelta objects, numbers become int or float according to
    `field_info.field_type`, comma-separated chips are split.

    `field_info.field_type` (default `str`) drives the conversions that depend on the target
    type — set it to `int` to get ints out of a 'ui.number', to `list[str]` for a
    comma-separated 'ui.input', or to `T | None` to have an empty selection read back as None.

    Raises ValueError for 'editgrid' and 'modelselect', which need a model and a repository.
    """
    widget_type = field_info.widget_type
    if not widget_type:
        raise ValueError("field_value() requires field_info.widget_type to be set")
    if widget_type in MODEL_ONLY_WIDGETS:
        raise ValueError(f"field_value() does not support widget_type '{widget_type}': it needs a model repository (use ModelForm)")

    field_type = field_info.field_type
    value = widget.value

    if widget_type == 'ui.select' and field_info.multiple:
        # Map an empty multi-select back to None when the field is Optional,
        # so Optional[list[...]] round-trips without special-casing elsewhere.
        if not value and _field_allows_none(field_type):
            value = None

    elif widget_type == 'checkbox_group':
        # Same None <-> [] interchangeability as the ui.select multi-select case.
        if not value and _field_allows_none(field_type):
            value = None

    elif widget_type == 'ui.input' and typing.get_origin(field_type) == list:
        value = [item.strip() for item in value.split(',')]
        item_type = field_info.item_type
        if item_type in (int, float, bool, str):
            value = [item_type(item) for item in value]  # type: ignore[misc]
        else:
            raise ValueError("Field is a list but no allowed item type is specified")

    elif widget_type == 'ui.number':
        # A cleared number field yields None (or ''); map it back to None so
        # Optional fields round-trip and required fields fail validation cleanly,
        # instead of int(None)/float(None) raising and leaving a stale value.
        if value is None or value == '':
            value = None
        elif _unwrap_optional(field_type) == int:
            value = int(value)
        else:
            value = float(value)

    elif widget_type in ('ui.slider', 'ui.rating'):
        if _unwrap_optional(field_type) == int:
            value = int(value) if value is not None else 0
        else:
            value = float(value) if value is not None else 0.0

    elif widget_type == 'ui.input_chips':
        expanded: list[Any] = []
        for v in value:
            if isinstance(v, str) and ',' in v:
                expanded.extend(item.strip() for item in v.split(','))
            else:
                expanded.append(v)
        value = expanded

    elif widget_type == 'datetime':
        if value:
            dt = datetime.datetime.fromisoformat(value)
            tz = ZoneInfo(local_tz) if local_tz else None
            value = dt.replace(tzinfo=tz).astimezone(datetime.timezone.utc)
        else:
            value = None

    elif widget_type == 'date':
        value = datetime.date.fromisoformat(value) if value else None

    elif widget_type == 'time':
        value = datetime.time.fromisoformat(value) if value else None

    elif widget_type == 'timedelta':
        value = parse_timedelta(value) if isinstance(value, str) else value

    if _unwrap_optional(field_type) is SecretStr and value is not None:
        value = SecretStr(value)

    return value

to_widget_value

to_widget_value(
    field_info: FieldInfo,
    value: Any,
    *,
    local_tz: str | None = None,
) -> Any

Convert a Python value to the representation its widget expects.

date / time / datetime / timedelta become ISO strings (a value that already is a string is passed through unchanged, so JSON-sourced data works without pre-conversion); a None multi-selection becomes an empty list. Everything else is returned unchanged.

'modelselect' is not handled here — the key lookup needs a repository (see ModelForm).

Source code in niceview/widgets.py
def to_widget_value(field_info: FieldInfo, value: Any, *, local_tz: str | None = None) -> Any:
    """
    Convert a Python value to the representation its widget expects.

    date / time / datetime / timedelta become ISO strings (a value that already is a string is
    passed through unchanged, so JSON-sourced data works without pre-conversion); a None
    multi-selection becomes an empty list. Everything else is returned unchanged.

    'modelselect' is not handled here — the key lookup needs a repository (see ModelForm).
    """
    widget_type = field_info.widget_type

    if widget_type == 'ui.select' and field_info.multiple:
        # A multi-select widget expects a list; map a None model value to [].
        if value is None:
            value = []

    elif widget_type == 'checkbox_group':
        # Always multi-valued by construction; map a None model value to [].
        if value is None:
            value = []

    elif widget_type == 'datetime':
        if isinstance(value, str):
            pass  # already an ISO string (e.g. from JSON) — the widget takes it verbatim
        elif value is not None:
            tz = ZoneInfo(local_tz) if local_tz else None
            value = value.astimezone(tz).replace(tzinfo=None, microsecond=0).isoformat()
        else:
            value = ''

    elif widget_type == 'date':
        if isinstance(value, str):
            pass
        else:
            value = value.isoformat() if value is not None else ''

    elif widget_type == 'time':
        if isinstance(value, str):
            pass
        else:
            value = value.replace(microsecond=0).isoformat() if value is not None else ''

    elif widget_type == 'timedelta':
        if not isinstance(value, str):
            timedelta_adapter = TypeAdapter(datetime.timedelta)
            value = timedelta_adapter.dump_python(value, mode="json")

    if isinstance(value, SecretStr):
        # The widget edits the plain text; field_value() wraps it up again.
        value = value.get_secret_value()

    return value

CheckboxGroup

Composite widget for list[Literal[...]] fields rendered as a row/column of ui.checkbox elements. There is no built-in NiceGUI/Quasar multi-select checkbox-group widget, so this composes plain ui.checkbox elements and exposes the .value / on_value_change() surface that ModelForm's value-conversion and event-wiring code expects from a widget.

checkboxes (options -> ui.checkbox) and widget (the ui.row/ui.column holding them) are public so callers can style individual checkboxes or the container after rendering, e.g. form.w('perms', CheckboxGroup).checkboxes['admin'].classes('text-negative').

Source code in niceview/widgets.py
class CheckboxGroup:
    """
    Composite widget for list[Literal[...]] fields rendered as a row/column of ui.checkbox
    elements. There is no built-in NiceGUI/Quasar multi-select checkbox-group widget, so this
    composes plain ui.checkbox elements and exposes the .value / on_value_change() surface that
    ModelForm's value-conversion and event-wiring code expects from a widget.

    `checkboxes` (options -> ui.checkbox) and `widget` (the ui.row/ui.column holding them)
    are public so callers can style individual checkboxes or the container after rendering,
    e.g. `form.w('perms', CheckboxGroup).checkboxes['admin'].classes('text-negative')`.
    """

    def __init__(self, options: list[Any], checkboxes: dict[Any, ui.checkbox], widget: ui.element) -> None:
        self.options = options
        self.checkboxes = checkboxes
        self.widget = widget
        self._value_change_handlers: list[Handler[ValueChangeEventArguments]] = []
        self._disabled = False
        for checkbox in self.checkboxes.values():
            checkbox.on_value_change(self._relay)

    @property
    def value(self) -> list[Any]:
        return [opt for opt in self.options if self.checkboxes[opt].value]

    @value.setter
    def value(self, new_value: list[Any] | None) -> None:
        selected = set(new_value or [])
        for opt, checkbox in self.checkboxes.items():
            checkbox.value = opt in selected

    @property
    def parent_slot(self) -> Any:
        # nicegui.events.handle_event() needs this to run the handler in the right UI context.
        return self.widget.parent_slot

    @property
    def client(self) -> Any:
        return self.widget.client

    def on_value_change(self, handler: Handler[ValueChangeEventArguments]) -> None:
        self._value_change_handlers.append(handler)

    # Styling is forwarded to the container, mirroring what apply_field_info() does to a native
    # widget: ui.radio also gets the classes itself, not the caption column wrapping it.

    def classes(self, add: str | None = None, **kwargs: Any) -> 'CheckboxGroup':
        self.widget.classes(add, **kwargs)
        return self

    def style(self, add: str | None = None, **kwargs: Any) -> 'CheckboxGroup':
        self.widget.style(add, **kwargs)
        return self

    def props(self, add: str | None = None, **kwargs: Any) -> 'CheckboxGroup':
        self.widget.props(add, **kwargs)
        return self

    def tooltip(self, text: str) -> 'CheckboxGroup':
        self.widget.tooltip(text)
        return self

    def _relay(self, e: ValueChangeEventArguments) -> None:
        vce = ValueChangeEventArguments(sender=self, client=e.client, value=self.value, previous_value=None)  # type: ignore[arg-type]
        for handler in self._value_change_handlers:
            handle_event(handler, vce)

    def set_options(self, options: 'list | dict') -> None:
        """Replace the checkboxes with a new option set, keeping the current selection where possible."""
        items = list(options.items()) if isinstance(options, dict) else [(opt, opt) for opt in options]
        selected = set(self.value)
        self.widget.clear()
        self.checkboxes = {}
        with self.widget:
            for opt, label in items:
                # initial value in the constructor does not fire on_value_change
                checkbox = ui.checkbox(text=str(label), value=opt in selected)
                checkbox.on_value_change(self._relay)
                self.checkboxes[opt] = checkbox
        self.options = [opt for opt, _ in items]
        if self._disabled:
            self.disable()

    def disable(self) -> None:
        self._disabled = True
        for checkbox in self.checkboxes.values():
            checkbox.disable()

set_options

set_options(options: list | dict) -> None

Replace the checkboxes with a new option set, keeping the current selection where possible.

Source code in niceview/widgets.py
def set_options(self, options: 'list | dict') -> None:
    """Replace the checkboxes with a new option set, keeping the current selection where possible."""
    items = list(options.items()) if isinstance(options, dict) else [(opt, opt) for opt in options]
    selected = set(self.value)
    self.widget.clear()
    self.checkboxes = {}
    with self.widget:
        for opt, label in items:
            # initial value in the constructor does not fire on_value_change
            checkbox = ui.checkbox(text=str(label), value=opt in selected)
            checkbox.on_value_change(self._relay)
            self.checkboxes[opt] = checkbox
    self.options = [opt for opt, _ in items]
    if self._disabled:
        self.disable()

reserves_bottom_space

reserves_bottom_space(
    field_info: FieldInfo,
    description_as: DescriptionTarget = DESCRIPTION_AS,
) -> bool

Whether this field is taller than its box: Quasar keeps a strip of 20px free below a field that can show a message, so that the layout does not jump when one appears (q-field--with-bottom).

Two things ask for that strip, and both are niceview's own doing: a validation — NiceGUI reserves the space for it (error=False), and ModelForm wires one on every VALIDATED_WIDGET — and a hint, on the QInput based widgets that have a slot for one. Everything else, a switch or a slider or a group of checkboxes, is exactly as tall as it looks.

Independent of outlined, filled and friends: those change how tall the box is, not what sits below it.

Source code in niceview/widgets.py
def reserves_bottom_space(field_info: FieldInfo, description_as: DescriptionTarget = DESCRIPTION_AS) -> bool:
    """
    Whether this field is taller than its box: Quasar keeps a strip of 20px free below a field
    that *can* show a message, so that the layout does not jump when one appears
    (`q-field--with-bottom`).

    Two things ask for that strip, and both are niceview's own doing: a validation — NiceGUI
    reserves the space for it (`error=False`), and ModelForm wires one on every VALIDATED_WIDGET
    — and a hint, on the QInput based widgets that have a slot for one. Everything else, a
    switch or a slider or a group of checkboxes, is exactly as tall as it looks.

    Independent of `outlined`, `filled` and friends: those change how tall the box is, not what
    sits below it.
    """
    widget_type = field_info.widget_type or ''
    if widget_type in VALIDATED_WIDGETS:
        return True
    hint, _ = resolve_help_texts(field_info, description_as)
    return bool(hint) and widget_type in HINT_WIDGETS