Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c8254e9
working on making an element's options a typed dataclass but still al…
jdoiro3 Oct 20, 2025
643b093
working on typing support.
jdoiro3 Oct 20, 2025
7d481a6
working on getting new select API working with current tests
jdoiro3 Oct 20, 2025
89a226a
got toggling working and other new_value_mode options
jdoiro3 Oct 20, 2025
9adcc8f
fixing type overloading and getting more tests passing.
jdoiro3 Oct 20, 2025
0e850bb
working on test code.
jdoiro3 Oct 20, 2025
9f53a37
removed typevar
jdoiro3 Oct 20, 2025
ee16f5d
fixing some typing
jdoiro3 Oct 20, 2025
0a05a6f
wow... this typing is complex.
jdoiro3 Oct 21, 2025
f00cd46
fixed mypy errors in select.py
jdoiro3 Oct 22, 2025
746273a
fixed typing syntax in select and ran pre-commit
jdoiro3 Oct 22, 2025
6b04eea
updated demo and working on function overloads
jdoiro3 Oct 22, 2025
c3e36c3
working on overload signatures
jdoiro3 Oct 22, 2025
ff20af8
update toggle
jdoiro3 Oct 23, 2025
f62a852
updating typing for toggle and getting toggling working again
jdoiro3 Oct 23, 2025
c738188
removed print statement
jdoiro3 Oct 23, 2025
f383bf2
updating select docs code based on new api
jdoiro3 Oct 23, 2025
436e432
updating radio based on new choice element interface and added more s…
jdoiro3 Oct 23, 2025
8c9893d
upodated documentation wording
jdoiro3 Oct 23, 2025
4b181ce
working on updating element docs and making toggle typing support
jdoiro3 Oct 25, 2025
c036712
fixed mypy error and ran pre-commit
jdoiro3 Oct 25, 2025
5f4ec10
removed prints and fixing user interaction for the select component.
jdoiro3 Oct 26, 2025
a9fbcff
updated radio element and working on updating tests
jdoiro3 Oct 29, 2025
246e155
added two util function for converting select options and values
jdoiro3 Oct 29, 2025
178a93f
updated radio doc site examples. Still need to update docstrings of c…
jdoiro3 Oct 29, 2025
2408804
updated user interaction module based on choice element changes and g…
jdoiro3 Oct 29, 2025
5fae8d4
ran pre-commit
jdoiro3 Oct 29, 2025
21c0e8c
resolving pylinting errors
jdoiro3 Oct 29, 2025
8b11af9
ran pre-commit
jdoiro3 Oct 29, 2025
fe0f92e
working on backwards compatability
jdoiro3 Nov 6, 2025
f5d02fb
fixed typing and pylint errors
jdoiro3 Nov 6, 2025
c10dc43
continue to work on keeping backwards compatability and minimal test …
jdoiro3 Nov 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions nicegui/binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
from collections import defaultdict
from collections.abc import Iterable, Mapping
from contextvars import ContextVar
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar
from typing import TYPE_CHECKING, Any, Callable, Generic, Literal

from typing_extensions import dataclass_transform
from typing_extensions import TypeVar, dataclass_transform

from . import core
from .logging import log
Expand All @@ -27,7 +27,8 @@
active_links: list[tuple[Any, str, Any, str, Callable[[Any], Any] | None]] = []

TC = TypeVar('TC', bound=type)
T = TypeVar('T')
T = TypeVar('T', default=Any)
E = TypeVar('E', default=Any)


def _has_attribute(obj: object | Mapping, name: str) -> Any:
Expand Down Expand Up @@ -203,18 +204,18 @@ def bind(self_obj: Any, self_name: str, other_obj: Any, other_name: str, *,
bind_to(self_obj, self_name, other_obj, other_name, forward=forward, self_strict=False, other_strict=False)


class BindableProperty:
class BindableProperty(Generic[T, E]):

def __init__(self, on_change: Callable[..., Any] | None = None) -> None:
def __init__(self, on_change: Callable[[E, T], Any] | None = None) -> None:
self._change_handler = on_change

def __set_name__(self, _, name: str) -> None:
self.name = name # pylint: disable=attribute-defined-outside-init

def __get__(self, owner: Any, _=None) -> Any:
def __get__(self, owner: E, _=None) -> T:
return getattr(owner, '___' + self.name)

def __set__(self, owner: Any, value: Any) -> None:
def __set__(self, owner: E, value: T) -> None:
has_attr = hasattr(owner, '___' + self.name)
if not has_attr:
_make_copyable(type(owner))
Expand Down
5 changes: 2 additions & 3 deletions nicegui/element_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,8 @@ def __iter__(self) -> Iterator[T]:
element_contents.append(element.message)
if isinstance(element, ChoiceElement):
if isinstance(element, Select):
values = element.value if element.multiple else [element.value]
labels = [value if isinstance(element.options, list) else element.options.get(value, '')
for value in values]
value_options = element.value if isinstance(element.value, tuple) else (element.value,) if element.value else ()
labels = [option.label for option in value_options]
element_contents.extend(labels)
if not isinstance(element, Select) or element.is_showing_popup:
element_contents.extend(element._labels) # pylint: disable=protected-access
Expand Down
103 changes: 82 additions & 21 deletions nicegui/elements/choice_element.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,111 @@
from typing import Any, Optional, Union
import hashlib
from dataclasses import dataclass
from typing import Any, Generic, Optional, Union, overload, Iterable # pylint: disable=unused-import

# NOTE: pylint ignore is for the `Any` import.
from typing_extensions import TypedDict, TypeVar

from ..events import Handler, ValueChangeEventArguments
from .mixins.value_element import ValueElement

JsonPrimitive = Union[str, int, float, bool, None]
P = TypeVar('P', bound=JsonPrimitive)
V = TypeVar('V')
T = TypeVar('T', bound='Option[Any, Any]')

class DEFAULT:
pass

default = DEFAULT()
# ^ used as default value in set_options below

@dataclass
class Option(Generic[P, V]):
label: P
value: V

def __post_init__(self) -> None:
self.id = hashlib.md5(f'{self.label}{self.value}'.encode()).hexdigest()

def __str__(self) -> str:
return f"{self.__class__.__name__}({self.label}, {self.value})"

def __repr__(self) -> str:
return self.__str__()


class ChoiceElement(ValueElement):
class OptionDict(TypedDict, Generic[P, V]):
label: P
value: V
id: str


@overload
def to_option(v: P) -> Option[P, P]:
...

@overload
def to_option(v: Option[P, V]) -> Option[P, V]:
...

def to_option(v: Union[P, Option[P, V]]) -> Union[Option[P, V], Option[P, P]]:
"""Converts a primitive type to an `Option`. If the value is already an `Option`, it will return that option unchanged.

:param v: The primitive type (`int`, `float`, `str`, `bool`, `None`)
"""
if isinstance(v, Option):
return v
return Option(label=v, value=v)


class ChoiceElement(ValueElement[V], Generic[V, T]):

def __init__(self, *,
tag: Optional[str] = None,
options: Union[list, dict],
value: Any,
on_change: Optional[Handler[ValueChangeEventArguments]] = None,
options: Iterable[T],
value: V,
on_change: Optional[Handler[ValueChangeEventArguments[V]]] = None,
) -> None:
self.options = options
self._values: list[str] = []
self._labels: list[str] = []
self._update_values_and_labels()
if not isinstance(value, list) and value is not None and value not in self._values:
raise ValueError(f'Invalid value: {value}')
self.value = value
self.options = list(options)
self._values = [o.value for o in self.options]
self._labels = [o.label for o in self.options]
self._id_to_option = {o.id: o for o in self.options}
if (invalid_values := self._invalid_values(value)):
raise ValueError(f'Invalid values: {invalid_values}')
super().__init__(tag=tag, value=value, on_value_change=on_change)
self._update_options()

def _invalid_values(self, value: V) -> tuple[V, ...]:
if value is None:
return tuple()
return tuple(set([value]) - set(self._values))

def _update_values_and_labels(self) -> None:
self._values = self.options if isinstance(self.options, list) else list(self.options.keys())
self._labels = self.options if isinstance(self.options, list) else list(self.options.values())
self._values = [o.value for o in self.options]
self._labels = [o.label for o in self.options]
self._id_to_option = {o.id: o for o in self.options}

def _update_options(self) -> None:
before_value = self.value
self._props['options'] = [{'value': index, 'label': option} for index, option in enumerate(self._labels)]
self._props[self.VALUE_PROP] = self._value_to_model_value(before_value)
if not isinstance(before_value, list): # NOTE: no need to update value in case of multi-select
self.value = before_value if before_value in self._values else None
self._props['options'] = self.options
new_value = self._value_to_model_value(before_value)
self._props[self.VALUE_PROP] = new_value
self.value = new_value

def update(self) -> None:
with self._props.suspend_updates():
self._update_values_and_labels()
self._update_options()
super().update()

def set_options(self, options: Union[list, dict], *, value: Any = ...) -> None:
def set_options(self, options: Iterable[T], *, value: Union[V, DEFAULT] = default) -> None:
"""Set the options of this choice element.

:param options: The new options.
:param value: The new value. If not given, the current value is kept.
"""
self.options = options
if value is not ...:
self.value = value
self.options = list(options)
if not isinstance(value, DEFAULT):
self.value: V = value
self.update()
4 changes: 2 additions & 2 deletions nicegui/elements/dark_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
from .mixins.value_element import ValueElement


class DarkMode(ValueElement, component='dark_mode.js'):
class DarkMode(ValueElement[Optional[bool]], component='dark_mode.js'):
VALUE_PROP = 'value'

def __init__(self, value: Optional[bool] = False, *, on_change: Optional[Handler[ValueChangeEventArguments]] = None) -> None:
def __init__(self, value: Optional[bool] = False, *, on_change: Optional[Handler[ValueChangeEventArguments[Optional[bool]]]] = None) -> None:
"""Dark mode

You can use this element to enable, disable or toggle dark mode on the page.
Expand Down
14 changes: 7 additions & 7 deletions nicegui/elements/mixins/validation_element.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,28 @@
from typing_extensions import Self

from ... import background_tasks, helpers
from .value_element import ValueElement
from .value_element import V, ValueElement

ValidationFunction = Callable[[Any], Union[Optional[str], Awaitable[Optional[str]]]]
ValidationDict = dict[str, Callable[[Any], bool]]
ValidationFunction = Callable[[V], Union[Optional[str], Awaitable[Optional[str]]]]
ValidationDict = dict[str, Callable[[V], bool]]


class ValidationElement(ValueElement):
class ValidationElement(ValueElement[V]):

def __init__(self, validation: Optional[Union[ValidationFunction, ValidationDict]], **kwargs: Any) -> None:
def __init__(self, validation: Optional[Union[ValidationFunction[V], ValidationDict[V]]], **kwargs: Any) -> None:
self._validation = validation
self._auto_validation = True
self._error: Optional[str] = None
super().__init__(**kwargs)
self._props['error'] = None if validation is None else False # NOTE: reserve bottom space for error message

@property
def validation(self) -> Optional[Union[ValidationFunction, ValidationDict]]:
def validation(self) -> Optional[Union[ValidationFunction[V], ValidationDict[V]]]:
"""The validation function or dictionary of validation functions."""
return self._validation

@validation.setter
def validation(self, validation: Optional[Union[ValidationFunction, ValidationDict]]) -> None:
def validation(self, validation: Optional[Union[ValidationFunction[V], ValidationDict[V]]]) -> None:
"""Sets the validation function or dictionary of validation functions.

:param validation: validation function or dictionary of validation functions (``None`` to disable validation)
Expand Down
34 changes: 18 additions & 16 deletions nicegui/elements/mixins/value_element.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
from typing import Any, Callable, Optional, cast
from typing import Any, Callable, Generic, Optional, cast

from typing_extensions import Self
from typing_extensions import Self, TypeVar

from ...binding import BindableProperty, bind, bind_from, bind_to
from ...element import Element
from ...events import GenericEventArguments, Handler, ValueChangeEventArguments, handle_event

V = TypeVar('V')

class ValueElement(Element):

class ValueElement(Element, Generic[V]):
VALUE_PROP: str = 'model-value'
'''Name of the prop that holds the value of the element'''

Expand All @@ -19,12 +21,12 @@ class ValueElement(Element):
- ``None``: The value is updated automatically by the Vue element.
'''

value = BindableProperty(
on_change=lambda sender, value: cast(Self, sender)._handle_value_change(value)) # pylint: disable=protected-access
value = BindableProperty[V, 'ValueElement[V]'](
on_change=lambda sender, value: sender._handle_value_change(value)) # pylint: disable=protected-access

def __init__(self, *,
value: Any,
on_value_change: Optional[Handler[ValueChangeEventArguments]] = None,
value: V,
on_value_change: Optional[Handler[ValueChangeEventArguments[V]]] = None,
throttle: float = 0,
**kwargs: Any,
) -> None:
Expand All @@ -33,15 +35,15 @@ def __init__(self, *,
self.set_value(value)
self._props[self.VALUE_PROP] = self._value_to_model_value(value)
self._props['loopback'] = self.LOOPBACK
self._change_handlers: list[Handler[ValueChangeEventArguments]] = [on_value_change] if on_value_change else []
self._change_handlers: list[Handler[ValueChangeEventArguments[V]]] = [on_value_change] if on_value_change else []

def handle_change(e: GenericEventArguments) -> None:
def handle_change(e: GenericEventArguments[V]) -> None:
self._send_update_on_value_change = self.LOOPBACK is True
self.set_value(self._event_args_to_value(e))
self._send_update_on_value_change = True
self.on(f'update:{self.VALUE_PROP}', handle_change, [None], throttle=throttle)

def on_value_change(self, callback: Handler[ValueChangeEventArguments]) -> Self:
def on_value_change(self, callback: Handler[ValueChangeEventArguments[V]]) -> Self:
"""Add a callback to be invoked when the value changes."""
self._change_handlers.append(callback)
return self
Expand Down Expand Up @@ -111,15 +113,15 @@ def bind_value(self,
self_strict=False, other_strict=strict)
return self

def set_value(self, value: Any) -> None:
def set_value(self, value: V) -> None:
"""Set the value of this element.

:param value: The value to set.
"""
self.value = value

def _handle_value_change(self, value: Any) -> None:
previous_value = self._props.get(self.VALUE_PROP)
def _handle_value_change(self, value: V) -> None:
previous_value = cast(V, self._props.get(self.VALUE_PROP))
with self._props.suspend_updates():
self._props[self.VALUE_PROP] = self._value_to_model_value(value)
if self._send_update_on_value_change:
Expand All @@ -130,11 +132,11 @@ def _handle_value_change(self, value: Any) -> None:
for handler in self._change_handlers:
handle_event(handler, args)

def _event_args_to_value(self, e: GenericEventArguments) -> Any:
def _event_args_to_value(self, e: GenericEventArguments[Any]) -> Any:
return e.args

def _value_to_model_value(self, value: Any) -> Any:
def _value_to_model_value(self, value: V) -> Any:
return value

def _value_to_event_value(self, value: Any) -> Any:
def _value_to_event_value(self, value: V) -> Any:
return value
55 changes: 44 additions & 11 deletions nicegui/elements/radio.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
from typing import Any, Optional, Union
from typing import Any, Generic, Optional, Union, overload
from typing_extensions import TypeVar

from ..events import GenericEventArguments, Handler, ValueChangeEventArguments
from .choice_element import ChoiceElement
from ..helpers import add_docstring_from
from .choice_element import ChoiceElement, Option, P, to_option
from .mixins.disableable_element import DisableableElement

V = TypeVar('V')

class Radio(ChoiceElement, DisableableElement):

class Radio(ChoiceElement[Optional[V], Option[P, V]], DisableableElement, Generic[V, P]):

def __init__(self,
options: Union[list, dict], *,
value: Any = None,
on_change: Optional[Handler[ValueChangeEventArguments]] = None,
options: Union[list[P], dict[V, P]], *,
value: Optional[V] = None,
on_change: Optional[Handler[ValueChangeEventArguments[Optional[V]]]] = None,
) -> None:
"""Radio Selection

Expand All @@ -23,10 +27,39 @@ def __init__(self,
:param value: the initial value
:param on_change: callback to execute when selection changes
"""
super().__init__(tag='q-option-group', options=options, value=value, on_change=on_change)
if isinstance(options, dict):
super().__init__(tag='q-option-group', options=[Option(v, k) for k, v in options.items()], value=value, on_change=on_change)
else:
super().__init__(tag='q-option-group', options=[to_option(v) for v in options], value=value, on_change=on_change)

def _event_args_to_value(self, e: GenericEventArguments[Optional[V]]) -> Any:
return e.args

def _value_to_model_value(self, value: Optional[V]) -> Optional[V]:
return value if value in self._values else None


@overload
def radio(
options: list[P], *,
value: Optional[P] = ...,
on_change: Optional[Handler[ValueChangeEventArguments[Optional[P]]]] = ...,
) -> Radio[P, P]:
...

def _event_args_to_value(self, e: GenericEventArguments) -> Any:
return self._values[e.args]
@overload
def radio(
options: dict[V, P], *,
value: Optional[V] = ...,
on_change: Optional[Handler[ValueChangeEventArguments[Optional[V]]]] = ...,
) -> Radio[V, P]:
...

def _value_to_model_value(self, value: Any) -> Any:
return self._values.index(value) if value in self._values else None
# pylint: disable=missing-function-docstring
@add_docstring_from(Radio.__init__)
def radio(
options: Union[list[P], dict[V, P]], *,
value: Optional[V] = None,
on_change: Optional[Handler[ValueChangeEventArguments[Optional[V]]]] = None,
) -> Union[Radio[V, P], Radio[P, P]]:
return Radio(options, value=value, on_change=on_change)
Loading