diff --git a/nicegui/classes.py b/nicegui/classes.py
new file mode 100644
index 000000000..74f90c1e1
--- /dev/null
+++ b/nicegui/classes.py
@@ -0,0 +1,45 @@
+from typing import TYPE_CHECKING, Generic, List, Optional, TypeVar
+
+if TYPE_CHECKING:
+ from .element import Element
+
+T = TypeVar('T', bound='Element')
+
+
+class Classes(list, Generic[T]):
+
+ def __init__(self, *args, element: T, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.element = element
+
+ def __call__(self,
+ add: Optional[str] = None, *,
+ remove: Optional[str] = None,
+ replace: Optional[str] = None) -> T:
+ """Apply, remove, or replace HTML classes.
+
+ This allows modifying the look of the element or its layout using `Tailwind `_ or `Quasar `_ classes.
+
+ Removing or replacing classes can be helpful if predefined classes are not desired.
+
+ :param add: whitespace-delimited string of classes
+ :param remove: whitespace-delimited string of classes to remove from the element
+ :param replace: whitespace-delimited string of classes to use instead of existing ones
+ """
+ new_classes = self.update_list(self, add, remove, replace)
+ if self != new_classes:
+ self[:] = new_classes
+ self.element.update()
+ return self.element
+
+ @staticmethod
+ def update_list(classes: List[str],
+ add: Optional[str] = None,
+ remove: Optional[str] = None,
+ replace: Optional[str] = None) -> List[str]:
+ """Update a list of classes."""
+ class_list = classes if replace is None else []
+ class_list = [c for c in class_list if c not in (remove or '').split()]
+ class_list += (add or '').split()
+ class_list += (replace or '').split()
+ return list(dict.fromkeys(class_list)) # NOTE: remove duplicates while preserving order
diff --git a/nicegui/element.py b/nicegui/element.py
index 71f830d6d..20c836a33 100644
--- a/nicegui/element.py
+++ b/nicegui/element.py
@@ -1,53 +1,42 @@
from __future__ import annotations
-import ast
import inspect
import re
-from copy import copy, deepcopy
+from copy import copy
from pathlib import Path
-from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, Iterator, List, Optional, Sequence, Union, overload
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Callable,
+ ClassVar,
+ Dict,
+ Iterator,
+ List,
+ Optional,
+ Sequence,
+ Union,
+ cast,
+ overload,
+)
from typing_extensions import Self
from . import core, events, helpers, json, storage
from .awaitable_response import AwaitableResponse, NullResponse
+from .classes import Classes
from .context import context
from .dependencies import Component, Library, register_library, register_resource, register_vue_component
from .elements.mixins.visibility import Visibility
from .event_listener import EventListener
+from .props import Props
from .slot import Slot
+from .style import Style
from .tailwind import Tailwind
from .version import __version__
if TYPE_CHECKING:
from .client import Client
-PROPS_PATTERN = re.compile(r'''
-# Match a key-value pair optionally followed by whitespace or end of string
-([:\w\-]+) # Capture group 1: Key
-(?: # Optional non-capturing group for value
- = # Match the equal sign
- (?: # Non-capturing group for value options
- ( # Capture group 2: Value enclosed in double quotes
- " # Match double quote
- [^"\\]* # Match any character except quotes or backslashes zero or more times
- (?:\\.[^"\\]*)* # Match any escaped character followed by any character except quotes or backslashes zero or more times
- " # Match the closing quote
- )
- |
- ( # Capture group 3: Value enclosed in single quotes
- ' # Match a single quote
- [^'\\]* # Match any character except quotes or backslashes zero or more times
- (?:\\.[^'\\]*)* # Match any escaped character followed by any character except quotes or backslashes zero or more times
- ' # Match the closing quote
- )
- | # Or
- ([\w\-.,%:\/=]+) # Capture group 4: Value without quotes
- )
-)? # End of optional non-capturing group for value
-(?:$|\s) # Match end of string or whitespace
-''', re.VERBOSE)
-
# https://www.w3.org/TR/xml/#sec-common-syn
TAG_START_CHAR = r':|[A-Z]|_|[a-z]|[\u00C0-\u00D6]|[\u00D8-\u00F6]|[\u00F8-\u02FF]|[\u0370-\u037D]|[\u037F-\u1FFF]|[\u200C-\u200D]|[\u2070-\u218F]|[\u2C00-\u2FEF]|[\u3001-\uD7FF]|[\uF900-\uFDCF]|[\uFDF0-\uFFFD]|[\U00010000-\U000EFFFF]'
TAG_CHAR = TAG_START_CHAR + r'|-|\.|[0-9]|\u00B7|[\u0300-\u036F]|[\u203F-\u2040]'
@@ -79,12 +68,9 @@ def __init__(self, tag: Optional[str] = None, *, _client: Optional[Client] = Non
self.tag = tag if tag else self.component.tag if self.component else 'div'
if not TAG_PATTERN.match(self.tag):
raise ValueError(f'Invalid HTML tag: {self.tag}')
- self._classes: List[str] = []
- self._classes.extend(self._default_classes)
- self._style: Dict[str, str] = {}
- self._style.update(self._default_style)
- self._props: Dict[str, Any] = {}
- self._props.update(self._default_props)
+ self._classes: Classes[Self] = Classes(self._default_classes, element=cast(Self, self))
+ self._style: Style[Self] = Style(self._default_style, element=cast(Self, self))
+ self._props: Props[Self] = Props(self._default_props, element=cast(Self, self))
self._markers: List[str] = []
self._event_listeners: Dict[str, EventListener] = {}
self._text: Optional[str] = None
@@ -220,36 +206,10 @@ def _to_dict(self) -> Dict[str, Any]:
},
}
- @staticmethod
- def _update_classes_list(classes: List[str],
- add: Optional[str] = None,
- remove: Optional[str] = None,
- replace: Optional[str] = None) -> List[str]:
- class_list = classes if replace is None else []
- class_list = [c for c in class_list if c not in (remove or '').split()]
- class_list += (add or '').split()
- class_list += (replace or '').split()
- return list(dict.fromkeys(class_list)) # NOTE: remove duplicates while preserving order
-
- def classes(self,
- add: Optional[str] = None, *,
- remove: Optional[str] = None,
- replace: Optional[str] = None) -> Self:
- """Apply, remove, or replace HTML classes.
-
- This allows modifying the look of the element or its layout using `Tailwind `_ or `Quasar `_ classes.
-
- Removing or replacing classes can be helpful if predefined classes are not desired.
-
- :param add: whitespace-delimited string of classes
- :param remove: whitespace-delimited string of classes to remove from the element
- :param replace: whitespace-delimited string of classes to use instead of existing ones
- """
- new_classes = self._update_classes_list(self._classes, add, remove, replace)
- if self._classes != new_classes:
- self._classes = new_classes
- self.update()
- return self
+ @property
+ def classes(self) -> Classes[Self]:
+ """The classes of the element."""
+ return self._classes
@classmethod
def default_classes(cls,
@@ -268,40 +228,13 @@ def default_classes(cls,
:param remove: whitespace-delimited string of classes to remove from the element
:param replace: whitespace-delimited string of classes to use instead of existing ones
"""
- cls._default_classes = cls._update_classes_list(cls._default_classes, add, remove, replace)
+ cls._default_classes = Classes.update_list(cls._default_classes, add, remove, replace)
return cls
- @staticmethod
- def _parse_style(text: Optional[str]) -> Dict[str, str]:
- result = {}
- for word in (text or '').split(';'):
- word = word.strip() # noqa: PLW2901
- if word:
- key, value = word.split(':', 1)
- result[key.strip()] = value.strip()
- return result
-
- def style(self,
- add: Optional[str] = None, *,
- remove: Optional[str] = None,
- replace: Optional[str] = None) -> Self:
- """Apply, remove, or replace CSS definitions.
-
- Removing or replacing styles can be helpful if the predefined style is not desired.
-
- :param add: semicolon-separated list of styles to add to the element
- :param remove: semicolon-separated list of styles to remove from the element
- :param replace: semicolon-separated list of styles to use instead of existing ones
- """
- style_dict = deepcopy(self._style) if replace is None else {}
- for key in self._parse_style(remove):
- style_dict.pop(key, None)
- style_dict.update(self._parse_style(add))
- style_dict.update(self._parse_style(replace))
- if self._style != style_dict:
- self._style = style_dict
- self.update()
- return self
+ @property
+ def style(self) -> Style[Self]:
+ """The style of the element."""
+ return self._style
@classmethod
def default_style(cls,
@@ -320,51 +253,16 @@ def default_style(cls,
"""
if replace is not None:
cls._default_style.clear()
- for key in cls._parse_style(remove):
+ for key in Style.parse(remove):
cls._default_style.pop(key, None)
- cls._default_style.update(cls._parse_style(add))
- cls._default_style.update(cls._parse_style(replace))
+ cls._default_style.update(Style.parse(add))
+ cls._default_style.update(Style.parse(replace))
return cls
- @staticmethod
- def _parse_props(text: Optional[str]) -> Dict[str, Any]:
- dictionary = {}
- for match in PROPS_PATTERN.finditer(text or ''):
- key = match.group(1)
- value = match.group(2) or match.group(3) or match.group(4)
- if value is None:
- dictionary[key] = True
- else:
- if (value.startswith("'") and value.endswith("'")) or (value.startswith('"') and value.endswith('"')):
- value = ast.literal_eval(value)
- dictionary[key] = value
- return dictionary
-
- def props(self,
- add: Optional[str] = None, *,
- remove: Optional[str] = None) -> Self:
- """Add or remove props.
-
- This allows modifying the look of the element or its layout using `Quasar `_ props.
- Since props are simply applied as HTML attributes, they can be used with any HTML element.
-
- Boolean properties are assumed ``True`` if no value is specified.
-
- :param add: whitespace-delimited list of either boolean values or key=value pair to add
- :param remove: whitespace-delimited list of property keys to remove
- """
- needs_update = False
- for key in self._parse_props(remove):
- if key in self._props:
- needs_update = True
- del self._props[key]
- for key, value in self._parse_props(add).items():
- if self._props.get(key) != value:
- needs_update = True
- self._props[key] = value
- if needs_update:
- self.update()
- return self
+ @property
+ def props(self) -> Props[Self]:
+ """The props of the element."""
+ return self._props
@classmethod
def default_props(cls,
@@ -382,10 +280,10 @@ def default_props(cls,
:param add: whitespace-delimited list of either boolean values or key=value pair to add
:param remove: whitespace-delimited list of property keys to remove
"""
- for key in cls._parse_props(remove):
+ for key in Props.parse(remove):
if key in cls._default_props:
del cls._default_props[key]
- for key, value in cls._parse_props(add).items():
+ for key, value in Props.parse(add).items():
cls._default_props[key] = value
return cls
diff --git a/nicegui/element_filter.py b/nicegui/element_filter.py
index a6c662c58..1c0208b70 100644
--- a/nicegui/element_filter.py
+++ b/nicegui/element_filter.py
@@ -91,7 +91,6 @@ def __init__(self, *,
self._scope = context.slot.parent if local_scope else context.client.layout
def __iter__(self) -> Iterator[T]:
- # pylint: disable=protected-access
for element in self._scope.descendants():
if self._kind and not isinstance(element, self._kind):
continue
@@ -105,11 +104,11 @@ def __iter__(self) -> Iterator[T]:
if self._contents or self._exclude_content:
element_contents = [content for content in (
- element._props.get('text'),
- element._props.get('label'),
- element._props.get('icon'),
- element._props.get('placeholder'),
- element._props.get('value'),
+ element.props.get('text'),
+ element.props.get('label'),
+ element.props.get('icon'),
+ element.props.get('placeholder'),
+ element.props.get('value'),
element.text if isinstance(element, TextElement) else None,
element.content if isinstance(element, ContentElement) else None,
element.source if isinstance(element, SourceElement) else None,
@@ -117,7 +116,7 @@ def __iter__(self) -> Iterator[T]:
if isinstance(element, Notification):
element_contents.append(element.message)
if isinstance(element, Select):
- options = {option['value']: option['label'] for option in element._props.get('options', [])}
+ options = {option['value']: option['label'] for option in element.props.get('options', [])}
element_contents.append(options.get(element.value, ''))
if element.is_showing_popup:
element_contents.extend(options.values())
diff --git a/nicegui/elements/carousel.py b/nicegui/elements/carousel.py
index bea26c569..0a168d542 100644
--- a/nicegui/elements/carousel.py
+++ b/nicegui/elements/carousel.py
@@ -33,11 +33,11 @@ def __init__(self, *,
self._props['navigation'] = navigation
def _value_to_model_value(self, value: Any) -> Any:
- return value._props['name'] if isinstance(value, CarouselSlide) else value # pylint: disable=protected-access
+ return value.props['name'] if isinstance(value, CarouselSlide) else value
def _handle_value_change(self, value: Any) -> None:
super()._handle_value_change(value)
- names = [slide._props['name'] for slide in self] # pylint: disable=protected-access
+ names = [slide.props['name'] for slide in self]
for i, slide in enumerate(self):
done = i < names.index(value) if value in names else False
slide.props(f':done={done}')
diff --git a/nicegui/elements/menu.py b/nicegui/elements/menu.py
index f741176e6..2ab15fde7 100644
--- a/nicegui/elements/menu.py
+++ b/nicegui/elements/menu.py
@@ -1,8 +1,5 @@
from typing import Any, Callable, Optional, Union
-from typing_extensions import Self
-
-from .. import helpers
from ..element import Element
from .context_menu import ContextMenu
from .item import Item
@@ -24,6 +21,11 @@ def __init__(self, *, value: bool = False) -> None:
"""
super().__init__(tag='q-menu', value=value, on_value_change=None)
+ # https://github.com/zauberzeug/nicegui/issues/1738
+ self._props.add_warning('touch-position',
+ 'The prop "touch-position" is not supported by `ui.menu`. '
+ 'Use "ui.context_menu()" instead.')
+
def open(self) -> None:
"""Open the menu."""
self.value = True
@@ -36,15 +38,6 @@ def toggle(self) -> None:
"""Toggle the menu."""
self.value = not self.value
- def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self:
- super().props(add, remove=remove)
- if 'touch-position' in self._props:
- # https://github.com/zauberzeug/nicegui/issues/1738
- del self._props['touch-position']
- helpers.warn_once('The prop "touch-position" is not supported by `ui.menu`.\n'
- 'Use "ui.context_menu()" instead.')
- return self
-
class MenuItem(Item):
diff --git a/nicegui/elements/mixins/visibility.py b/nicegui/elements/mixins/visibility.py
index 620ed6f54..c853d46bd 100644
--- a/nicegui/elements/mixins/visibility.py
+++ b/nicegui/elements/mixins/visibility.py
@@ -100,7 +100,7 @@ def _handle_visibility_change(self, visible: str) -> None:
:param visible: Whether the element should be visible.
"""
element: Element = cast('Element', self)
- classes = element._classes # pylint: disable=protected-access, no-member
+ classes = element.classes # pylint: disable=no-member
if visible and 'hidden' in classes:
classes.remove('hidden')
element.update() # pylint: disable=no-member
diff --git a/nicegui/elements/query.py b/nicegui/elements/query.py
index 2c9e85a1c..67d4a78b8 100644
--- a/nicegui/elements/query.py
+++ b/nicegui/elements/query.py
@@ -2,8 +2,11 @@
from typing_extensions import Self
+from ..classes import Classes
from ..context import context
from ..element import Element
+from ..props import Props
+from ..style import Style
class QueryElement(Element, component='query.js'):
@@ -15,43 +18,6 @@ def __init__(self, selector: str) -> None:
self._props['style'] = {}
self._props['props'] = {}
- def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
- -> Self:
- classes = self._update_classes_list(self._props['classes'], add, remove, replace)
- new_classes = [c for c in classes if c not in self._props['classes']]
- old_classes = [c for c in self._props['classes'] if c not in classes]
- if new_classes:
- self.run_method('add_classes', new_classes)
- if old_classes:
- self.run_method('remove_classes', old_classes)
- self._props['classes'] = classes
- return self
-
- def style(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
- -> Self:
- old_style = Element._parse_style(remove)
- for key in old_style:
- self._props['style'].pop(key, None)
- if old_style:
- self.run_method('remove_style', list(old_style))
- self._props['style'].update(Element._parse_style(add))
- self._props['style'].update(Element._parse_style(replace))
- if self._props['style']:
- self.run_method('add_style', self._props['style'])
- return self
-
- def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self:
- old_props = self._parse_props(remove)
- for key in old_props:
- self._props['props'].pop(key, None)
- if old_props:
- self.run_method('remove_props', list(old_props))
- new_props = self._parse_props(add)
- self._props['props'].update(new_props)
- if self._props['props']:
- self.run_method('add_props', self._props['props'])
- return self
-
class Query:
@@ -65,7 +31,7 @@ def __init__(self, selector: str) -> None:
:param selector: the CSS selector (e.g. "body", "#my-id", ".my-class", "div > p")
"""
for element in context.client.elements.values():
- if isinstance(element, QueryElement) and element._props['selector'] == selector: # pylint: disable=protected-access
+ if isinstance(element, QueryElement) and element.props['selector'] == selector:
self.element = element
break
else:
@@ -83,7 +49,14 @@ def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, re
:param remove: whitespace-delimited string of classes to remove from the element
:param replace: whitespace-delimited string of classes to use instead of existing ones
"""
- self.element.classes(add, remove=remove, replace=replace)
+ classes = Classes.update_list(self.element.props['classes'], add, remove, replace)
+ new_classes = [c for c in classes if c not in self.element.props['classes']]
+ old_classes = [c for c in self.element.props['classes'] if c not in classes]
+ if new_classes:
+ self.element.run_method('add_classes', new_classes)
+ if old_classes:
+ self.element.run_method('remove_classes', old_classes)
+ self.element.props['classes'] = classes
return self
def style(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
@@ -96,7 +69,15 @@ def style(self, add: Optional[str] = None, *, remove: Optional[str] = None, repl
:param remove: semicolon-separated list of styles to remove from the element
:param replace: semicolon-separated list of styles to use instead of existing ones
"""
- self.element.style(add, remove=remove, replace=replace)
+ old_style = Style.parse(remove)
+ for key in old_style:
+ self.element.props['style'].pop(key, None)
+ if old_style:
+ self.element.run_method('remove_style', list(old_style))
+ self.element.props['style'].update(Style.parse(add))
+ self.element.props['style'].update(Style.parse(replace))
+ if self.element.props['style']:
+ self.element.run_method('add_style', self.element.props['style'])
return self
def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self:
@@ -110,5 +91,13 @@ def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> S
:param add: whitespace-delimited list of either boolean values or key=value pair to add
:param remove: whitespace-delimited list of property keys to remove
"""
- self.element.props(add, remove=remove)
+ old_props = Props.parse(remove)
+ for key in old_props:
+ self.element.props['props'].pop(key, None)
+ if old_props:
+ self.element.run_method('remove_props', list(old_props))
+ new_props = Props.parse(add)
+ self.element.props['props'].update(new_props)
+ if self.element.props['props']:
+ self.element.run_method('add_props', self.element.props['props'])
return self
diff --git a/nicegui/elements/stepper.py b/nicegui/elements/stepper.py
index 3db7750c3..1c762c301 100644
--- a/nicegui/elements/stepper.py
+++ b/nicegui/elements/stepper.py
@@ -34,11 +34,11 @@ def __init__(self, *,
self._classes.append('nicegui-stepper')
def _value_to_model_value(self, value: Any) -> Any:
- return value._props['name'] if isinstance(value, Step) else value # pylint: disable=protected-access
+ return value.props['name'] if isinstance(value, Step) else value
def _handle_value_change(self, value: Any) -> None:
super()._handle_value_change(value)
- names = [step._props['name'] for step in self] # pylint: disable=protected-access
+ names = [step.props['name'] for step in self]
for i, step in enumerate(self):
done = i < names.index(value) if value in names else False
step.props(f':done={done}')
diff --git a/nicegui/elements/tabs.py b/nicegui/elements/tabs.py
index b6053a33e..cb14a798b 100644
--- a/nicegui/elements/tabs.py
+++ b/nicegui/elements/tabs.py
@@ -25,7 +25,7 @@ def __init__(self, *,
super().__init__(tag='q-tabs', value=value, on_value_change=on_change)
def _value_to_model_value(self, value: Any) -> Any:
- return value._props['name'] if isinstance(value, (Tab, TabPanel)) else value # pylint: disable=protected-access
+ return value.props['name'] if isinstance(value, (Tab, TabPanel)) else value
class Tab(IconElement, DisableableElement):
@@ -77,7 +77,7 @@ def __init__(self,
self._props['keep-alive'] = keep_alive
def _value_to_model_value(self, value: Any) -> Any:
- return value._props['name'] if isinstance(value, (Tab, TabPanel)) else value # pylint: disable=protected-access
+ return value.props['name'] if isinstance(value, (Tab, TabPanel)) else value
class TabPanel(DisableableElement):
@@ -91,5 +91,5 @@ def __init__(self, name: Union[Tab, str]) -> None:
:param name: `ui.tab` or the name of a tab element
"""
super().__init__(tag='q-tab-panel')
- self._props['name'] = name._props['name'] if isinstance(name, Tab) else name
+ self._props['name'] = name.props['name'] if isinstance(name, Tab) else name
self._classes.append('nicegui-tab-panel')
diff --git a/nicegui/elements/tree.py b/nicegui/elements/tree.py
index e814b7bc8..9fc74f6a6 100644
--- a/nicegui/elements/tree.py
+++ b/nicegui/elements/tree.py
@@ -2,7 +2,6 @@
from typing_extensions import Self
-from .. import helpers
from ..events import GenericEventArguments, ValueChangeEventArguments, handle_event
from .mixins.filter_element import FilterElement
@@ -50,6 +49,11 @@ def __init__(self,
self._expand_handlers = [on_expand] if on_expand else []
self._tick_handlers = [on_tick] if on_tick else []
+ # https://github.com/zauberzeug/nicegui/issues/1385
+ self._props.add_warning('default-expand-all',
+ 'The prop "default-expand-all" is not supported by `ui.tree`. '
+ 'Use ".expand()" instead.')
+
def update_prop(name: str, value: Any) -> None:
if self._props[name] != value:
self._props[name] = value
@@ -150,12 +154,3 @@ def iterate_nodes(nodes: List[Dict]) -> Iterator[Dict]:
yield node
yield from iterate_nodes(node.get(CHILDREN_KEY, []))
return {node[NODE_KEY] for node in iterate_nodes(self._props['nodes'])}
-
- def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self:
- super().props(add, remove=remove)
- if 'default-expand-all' in self._props:
- # https://github.com/zauberzeug/nicegui/issues/1385
- del self._props['default-expand-all']
- helpers.warn_once('The prop "default-expand-all" is not supported by `ui.tree`.\n'
- 'Use ".expand()" instead.')
- return self
diff --git a/nicegui/page_layout.py b/nicegui/page_layout.py
index 87afd09e3..ff3a059d7 100644
--- a/nicegui/page_layout.py
+++ b/nicegui/page_layout.py
@@ -52,9 +52,9 @@ def __init__(self, *,
self._props['elevated'] = elevated
if wrap:
self._classes.append('wrap')
- code = list(self.client.layout._props['view'])
+ code = list(self.client.layout.props['view'])
code[1] = 'H' if fixed else 'h'
- self.client.layout._props['view'] = ''.join(code)
+ self.client.layout.props['view'] = ''.join(code)
self.move(target_index=0)
@@ -119,11 +119,11 @@ def __init__(self,
self._props['bordered'] = bordered
self._props['elevated'] = elevated
self._classes.append('nicegui-drawer')
- code = list(self.client.layout._props['view'])
+ code = list(self.client.layout.props['view'])
code[0 if side == 'left' else 2] = side[0].lower() if top_corner else 'h'
code[4 if side == 'left' else 6] = side[0].upper() if fixed else side[0].lower()
code[8 if side == 'left' else 10] = side[0].lower() if bottom_corner else 'f'
- self.client.layout._props['view'] = ''.join(code)
+ self.client.layout.props['view'] = ''.join(code)
page_container_index = self.client.layout.default_slot.children.index(self.client.page_container)
self.move(target_index=page_container_index if side == 'left' else page_container_index + 1)
@@ -235,9 +235,9 @@ def __init__(self, *,
self._props['elevated'] = elevated
if wrap:
self._classes.append('wrap')
- code = list(self.client.layout._props['view'])
+ code = list(self.client.layout.props['view'])
code[9] = 'F' if fixed else 'f'
- self.client.layout._props['view'] = ''.join(code)
+ self.client.layout.props['view'] = ''.join(code)
self.move(target_index=-1)
diff --git a/nicegui/props.py b/nicegui/props.py
new file mode 100644
index 000000000..96cb293e9
--- /dev/null
+++ b/nicegui/props.py
@@ -0,0 +1,93 @@
+import ast
+import re
+from typing import TYPE_CHECKING, Any, Dict, Generic, Optional, TypeVar
+
+from . import helpers
+
+if TYPE_CHECKING:
+ from .element import Element
+
+PROPS_PATTERN = re.compile(r'''
+# Match a key-value pair optionally followed by whitespace or end of string
+([:\w\-]+) # Capture group 1: Key
+(?: # Optional non-capturing group for value
+ = # Match the equal sign
+ (?: # Non-capturing group for value options
+ ( # Capture group 2: Value enclosed in double quotes
+ " # Match double quote
+ [^"\\]* # Match any character except quotes or backslashes zero or more times
+ (?:\\.[^"\\]*)* # Match any escaped character followed by any character except quotes or backslashes zero or more times
+ " # Match the closing quote
+ )
+ |
+ ( # Capture group 3: Value enclosed in single quotes
+ ' # Match a single quote
+ [^'\\]* # Match any character except quotes or backslashes zero or more times
+ (?:\\.[^'\\]*)* # Match any escaped character followed by any character except quotes or backslashes zero or more times
+ ' # Match the closing quote
+ )
+ | # Or
+ ([\w\-.,%:\/=]+) # Capture group 4: Value without quotes
+ )
+)? # End of optional non-capturing group for value
+(?:$|\s) # Match end of string or whitespace
+''', re.VERBOSE)
+
+T = TypeVar('T', bound='Element')
+
+
+class Props(dict, Generic[T]):
+
+ def __init__(self, *args, element: T, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.element = element
+ self._warnings: Dict[str, str] = {}
+
+ def add_warning(self, prop: str, message: str) -> None:
+ """Add a warning message for a prop."""
+ self._warnings[prop] = message
+
+ def __call__(self,
+ add: Optional[str] = None, *,
+ remove: Optional[str] = None) -> T:
+ """Add or remove props.
+
+ This allows modifying the look of the element or its layout using `Quasar `_ props.
+ Since props are simply applied as HTML attributes, they can be used with any HTML element.
+
+ Boolean properties are assumed ``True`` if no value is specified.
+
+ :param add: whitespace-delimited list of either boolean values or key=value pair to add
+ :param remove: whitespace-delimited list of property keys to remove
+ """
+ needs_update = False
+ for key in self.parse(remove):
+ if key in self:
+ needs_update = True
+ del self[key]
+ for key, value in self.parse(add).items():
+ if self.get(key) != value:
+ needs_update = True
+ self[key] = value
+ if needs_update:
+ self.element.update()
+ for name, message in self._warnings.items():
+ if name in self:
+ del self[name]
+ helpers.warn_once(message)
+ return self.element
+
+ @staticmethod
+ def parse(text: Optional[str]) -> Dict[str, Any]:
+ """Parse a string of props into a dictionary."""
+ dictionary = {}
+ for match in PROPS_PATTERN.finditer(text or ''):
+ key = match.group(1)
+ value = match.group(2) or match.group(3) or match.group(4)
+ if value is None:
+ dictionary[key] = True
+ else:
+ if (value.startswith("'") and value.endswith("'")) or (value.startswith('"') and value.endswith('"')):
+ value = ast.literal_eval(value)
+ dictionary[key] = value
+ return dictionary
diff --git a/nicegui/style.py b/nicegui/style.py
new file mode 100644
index 000000000..9b4093a05
--- /dev/null
+++ b/nicegui/style.py
@@ -0,0 +1,47 @@
+from typing import TYPE_CHECKING, Dict, Generic, Optional, TypeVar
+
+if TYPE_CHECKING:
+ from .element import Element
+
+T = TypeVar('T', bound='Element')
+
+
+class Style(dict, Generic[T]):
+
+ def __init__(self, *args, element: T, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.element = element
+
+ def __call__(self,
+ add: Optional[str] = None, *,
+ remove: Optional[str] = None,
+ replace: Optional[str] = None) -> T:
+ """Apply, remove, or replace CSS definitions.
+
+ Removing or replacing styles can be helpful if the predefined style is not desired.
+
+ :param add: semicolon-separated list of styles to add to the element
+ :param remove: semicolon-separated list of styles to remove from the element
+ :param replace: semicolon-separated list of styles to use instead of existing ones
+ """
+ style_dict = {**self} if replace is None else {}
+ for key in self.parse(remove):
+ style_dict.pop(key, None)
+ style_dict.update(self.parse(add))
+ style_dict.update(self.parse(replace))
+ if self != style_dict:
+ self.clear()
+ self.update(style_dict)
+ self.element.update()
+ return self.element
+
+ @staticmethod
+ def parse(text: Optional[str]) -> Dict[str, str]:
+ """Parse a string of styles into a dictionary."""
+ result = {}
+ for word in (text or '').split(';'):
+ word = word.strip() # noqa: PLW2901
+ if word:
+ key, value = word.split(':', 1)
+ result[key.strip()] = value.strip()
+ return result
diff --git a/nicegui/testing/user_interaction.py b/nicegui/testing/user_interaction.py
index 848aefa87..e396992d8 100644
--- a/nicegui/testing/user_interaction.py
+++ b/nicegui/testing/user_interaction.py
@@ -57,7 +57,7 @@ def click(self) -> Self:
with self.user.client:
for element in self.elements:
if isinstance(element, ui.link):
- href = element._props.get('href', '#') # pylint: disable=protected-access
+ href = element.props.get('href', '#')
background_tasks.create(self.user.open(href))
return self
if isinstance(element, ui.select):
diff --git a/tests/test_element.py b/tests/test_element.py
index eb9be98da..66cce207e 100644
--- a/tests/test_element.py
+++ b/tests/test_element.py
@@ -1,7 +1,11 @@
+from typing import Dict, Optional
+
import pytest
from selenium.webdriver.common.by import By
from nicegui import background_tasks, ui
+from nicegui.props import Props
+from nicegui.style import Style
from nicegui.testing import Screen
@@ -32,39 +36,41 @@ def assert_classes(classes: str) -> None:
assert_classes('four')
-def test_style_parsing(nicegui_reset_globals):
- # pylint: disable=protected-access
- assert ui.element._parse_style(None) == {} # pylint: disable=use-implicit-booleaness-not-comparison
- assert ui.element._parse_style('color: red; background-color: blue') == {'color': 'red', 'background-color': 'blue'}
- assert ui.element._parse_style('width:12em;height:34.5em') == {'width': '12em', 'height': '34.5em'}
- assert ui.element._parse_style('transform: translate(120.0px, 50%)') == {'transform': 'translate(120.0px, 50%)'}
- assert ui.element._parse_style('box-shadow: 0 0 0.5em #1976d2') == {'box-shadow': '0 0 0.5em #1976d2'}
-
-
-def test_props_parsing(nicegui_reset_globals):
- # pylint: disable=protected-access
- assert ui.element._parse_props(None) == {} # pylint: disable=use-implicit-booleaness-not-comparison
- assert ui.element._parse_props('one two=1 three="abc def"') == {'one': True, 'two': '1', 'three': 'abc def'}
- assert ui.element._parse_props('loading percentage=12.5') == {'loading': True, 'percentage': '12.5'}
- assert ui.element._parse_props('size=50%') == {'size': '50%'}
- assert ui.element._parse_props('href=http://192.168.42.100/') == {'href': 'http://192.168.42.100/'}
- assert ui.element._parse_props('hint="Your \\"given\\" name"') == {'hint': 'Your "given" name'}
- assert ui.element._parse_props('input-style="{ color: #ff0000 }"') == {'input-style': '{ color: #ff0000 }'}
- assert ui.element._parse_props('accept=.jpeg,.jpg,.png') == {'accept': '.jpeg,.jpg,.png'}
-
- assert ui.element._parse_props('empty=""') == {'empty': ''}
- assert ui.element._parse_props("empty=''") == {'empty': ''}
-
- assert ui.element._parse_props("""hint='Your \\"given\\" name'""") == {'hint': 'Your "given" name'}
- assert ui.element._parse_props("one two=1 three='abc def'") == {'one': True, 'two': '1', 'three': 'abc def'}
- assert ui.element._parse_props('''three='abc def' four="hhh jjj"''') == {'three': 'abc def', 'four': 'hhh jjj', }
- assert ui.element._parse_props('''foo="quote'quote"''') == {'foo': "quote'quote"}
- assert ui.element._parse_props("""foo='quote"quote'""") == {'foo': 'quote"quote'}
- assert ui.element._parse_props("""foo="single '" bar='double "'""") == {'foo': "single '", 'bar': 'double "'}
- assert ui.element._parse_props("""foo="single '" bar='double \\"'""") == {'foo': "single '", 'bar': 'double "'}
- assert ui.element._parse_props("input-style='{ color: #ff0000 }'") == {'input-style': '{ color: #ff0000 }'}
- assert ui.element._parse_props("""input-style='{ myquote: "quote" }'""") == {'input-style': '{ myquote: "quote" }'}
- assert ui.element._parse_props('filename=foo=bar.txt') == {'filename': 'foo=bar.txt'}
+@pytest.mark.parametrize('value,expected', [
+ (None, {}),
+ ('color: red; background-color: blue', {'color': 'red', 'background-color': 'blue'}),
+ ('width:12em;height:34.5em', {'width': '12em', 'height': '34.5em'}),
+ ('transform: translate(120.0px, 50%)', {'transform': 'translate(120.0px, 50%)'}),
+ ('box-shadow: 0 0 0.5em #1976d2', {'box-shadow': '0 0 0.5em #1976d2'}),
+])
+def test_style_parsing(value: Optional[str], expected: Dict[str, str]):
+ assert Style.parse(value) == expected
+
+
+@pytest.mark.parametrize('value,expected', [
+ (None, {}),
+ ('one two=1 three="abc def"', {'one': True, 'two': '1', 'three': 'abc def'}),
+ ('loading percentage=12.5', {'loading': True, 'percentage': '12.5'}),
+ ('size=50%', {'size': '50%'}),
+ ('href=http://192.168.42.100/', {'href': 'http://192.168.42.100/'}),
+ ('hint="Your \\"given\\" name"', {'hint': 'Your "given" name'}),
+ ('input-style="{ color: #ff0000 }"', {'input-style': '{ color: #ff0000 }'}),
+ ('accept=.jpeg,.jpg,.png', {'accept': '.jpeg,.jpg,.png'}),
+ ('empty=""', {'empty': ''}),
+ ("empty=''", {'empty': ''}),
+ ("""hint='Your \\"given\\" name'""", {'hint': 'Your "given" name'}),
+ ("one two=1 three='abc def'", {'one': True, 'two': '1', 'three': 'abc def'}),
+ ('''three='abc def' four="hhh jjj"''', {'three': 'abc def', 'four': 'hhh jjj', }),
+ ('''foo="quote'quote"''', {'foo': "quote'quote"}),
+ ("""foo='quote"quote'""", {'foo': 'quote"quote'}),
+ ("""foo="single '" bar='double "'""", {'foo': "single '", 'bar': 'double "'}),
+ ("""foo="single '" bar='double \\"'""", {'foo': "single '", 'bar': 'double "'}),
+ ("input-style='{ color: #ff0000 }'", {'input-style': '{ color: #ff0000 }'}),
+ ("""input-style='{ myquote: "quote" }'""", {'input-style': '{ myquote: "quote" }'}),
+ ('filename=foo=bar.txt', {'filename': 'foo=bar.txt'}),
+])
+def test_props_parsing(value: Optional[str], expected: Dict[str, str]):
+ assert Props.parse(value) == expected
def test_style(screen: Screen):
@@ -198,105 +204,105 @@ def test_default_props(nicegui_reset_globals):
ui.button.default_props('rounded outline')
button_a = ui.button('Button A')
button_b = ui.button('Button B')
- assert button_a._props.get('rounded') is True, 'default props are set'
- assert button_a._props.get('outline') is True
- assert button_b._props.get('rounded') is True
- assert button_b._props.get('outline') is True
+ assert button_a.props.get('rounded') is True, 'default props are set'
+ assert button_a.props.get('outline') is True
+ assert button_b.props.get('rounded') is True
+ assert button_b.props.get('outline') is True
ui.button.default_props(remove='outline')
button_c = ui.button('Button C')
- assert button_c._props.get('outline') is None, '"outline" prop was removed'
- assert button_c._props.get('rounded') is True, 'other props are still there'
+ assert button_c.props.get('outline') is None, '"outline" prop was removed'
+ assert button_c.props.get('rounded') is True, 'other props are still there'
ui.input.default_props('filled')
input_a = ui.input()
- assert input_a._props.get('filled') is True
- assert input_a._props.get('rounded') is None, 'default props of ui.button do not affect ui.input'
+ assert input_a.props.get('filled') is True
+ assert input_a.props.get('rounded') is None, 'default props of ui.button do not affect ui.input'
class MyButton(ui.button):
pass
MyButton.default_props('flat')
button_d = MyButton()
button_e = ui.button()
- assert button_d._props.get('flat') is True
- assert button_d._props.get('rounded') is True, 'default props are inherited'
- assert button_e._props.get('flat') is None, 'default props of MyButton do not affect ui.button'
- assert button_e._props.get('rounded') is True
+ assert button_d.props.get('flat') is True
+ assert button_d.props.get('rounded') is True, 'default props are inherited'
+ assert button_e.props.get('flat') is None, 'default props of MyButton do not affect ui.button'
+ assert button_e.props.get('rounded') is True
ui.button.default_props('no-caps').default_props('no-wrap')
button_f = ui.button()
- assert button_f._props.get('no-caps') is True
- assert button_f._props.get('no-wrap') is True
+ assert button_f.props.get('no-caps') is True
+ assert button_f.props.get('no-wrap') is True
def test_default_classes(nicegui_reset_globals):
ui.button.default_classes('bg-white text-green')
button_a = ui.button('Button A')
button_b = ui.button('Button B')
- assert 'bg-white' in button_a._classes, 'default classes are set'
- assert 'text-green' in button_a._classes
- assert 'bg-white' in button_b._classes
- assert 'text-green' in button_b._classes
+ assert 'bg-white' in button_a.classes, 'default classes are set'
+ assert 'text-green' in button_a.classes
+ assert 'bg-white' in button_b.classes
+ assert 'text-green' in button_b.classes
ui.button.default_classes(remove='text-green')
button_c = ui.button('Button C')
- assert 'text-green' not in button_c._classes, '"text-green" class was removed'
- assert 'bg-white' in button_c._classes, 'other classes are still there'
+ assert 'text-green' not in button_c.classes, '"text-green" class was removed'
+ assert 'bg-white' in button_c.classes, 'other classes are still there'
ui.input.default_classes('text-black')
input_a = ui.input()
- assert 'text-black' in input_a._classes
- assert 'bg-white' not in input_a._classes, 'default classes of ui.button do not affect ui.input'
+ assert 'text-black' in input_a.classes
+ assert 'bg-white' not in input_a.classes, 'default classes of ui.button do not affect ui.input'
class MyButton(ui.button):
pass
MyButton.default_classes('w-full')
button_d = MyButton()
button_e = ui.button()
- assert 'w-full' in button_d._classes
- assert 'bg-white' in button_d._classes, 'default classes are inherited'
- assert 'w-full' not in button_e._classes, 'default classes of MyButton do not affect ui.button'
- assert 'bg-white' in button_e._classes
+ assert 'w-full' in button_d.classes
+ assert 'bg-white' in button_d.classes, 'default classes are inherited'
+ assert 'w-full' not in button_e.classes, 'default classes of MyButton do not affect ui.button'
+ assert 'bg-white' in button_e.classes
ui.button.default_classes('h-40').default_classes('max-h-80')
button_f = ui.button()
- assert 'h-40' in button_f._classes
- assert 'max-h-80' in button_f._classes
+ assert 'h-40' in button_f.classes
+ assert 'max-h-80' in button_f.classes
def test_default_style(nicegui_reset_globals):
ui.button.default_style('color: green; font-size: 200%')
button_a = ui.button('Button A')
button_b = ui.button('Button B')
- assert button_a._style.get('color') == 'green', 'default style is set'
- assert button_a._style.get('font-size') == '200%'
- assert button_b._style.get('color') == 'green'
- assert button_b._style.get('font-size') == '200%'
+ assert button_a.style.get('color') == 'green', 'default style is set'
+ assert button_a.style.get('font-size') == '200%'
+ assert button_b.style.get('color') == 'green'
+ assert button_b.style.get('font-size') == '200%'
ui.button.default_style(remove='color: green')
button_c = ui.button('Button C')
- assert button_c._style.get('color') is None, '"color" style was removed'
- assert button_c._style.get('font-size') == '200%', 'other style are still there'
+ assert button_c.style.get('color') is None, '"color" style was removed'
+ assert button_c.style.get('font-size') == '200%', 'other style are still there'
ui.input.default_style('font-weight: 300')
input_a = ui.input()
- assert input_a._style.get('font-weight') == '300'
- assert input_a._style.get('font-size') is None, 'default style of ui.button does not affect ui.input'
+ assert input_a.style.get('font-weight') == '300'
+ assert input_a.style.get('font-size') is None, 'default style of ui.button does not affect ui.input'
class MyButton(ui.button):
pass
MyButton.default_style('font-family: courier')
button_d = MyButton()
button_e = ui.button()
- assert button_d._style.get('font-family') == 'courier'
- assert button_d._style.get('font-size') == '200%', 'default style is inherited'
- assert button_e._style.get('font-family') is None, 'default style of MyButton does not affect ui.button'
- assert button_e._style.get('font-size') == '200%'
+ assert button_d.style.get('font-family') == 'courier'
+ assert button_d.style.get('font-size') == '200%', 'default style is inherited'
+ assert button_e.style.get('font-family') is None, 'default style of MyButton does not affect ui.button'
+ assert button_e.style.get('font-size') == '200%'
ui.button.default_style('border: 2px').default_style('padding: 30px')
button_f = ui.button()
- assert button_f._style.get('border') == '2px'
- assert button_f._style.get('padding') == '30px'
+ assert button_f.style.get('border') == '2px'
+ assert button_f.style.get('padding') == '30px'
def test_invalid_tags(screen: Screen):
diff --git a/tests/test_element_filter.py b/tests/test_element_filter.py
index 0f3f92706..decf8c644 100644
--- a/tests/test_element_filter.py
+++ b/tests/test_element_filter.py
@@ -26,7 +26,7 @@ def test_find_all() -> None:
assert len(elements) == 8
assert elements[0].tag == 'q-page-container'
assert elements[1].tag == 'q-page'
- assert elements[2]._classes == ['nicegui-content'] # pylint: disable=protected-access
+ assert elements[2].classes == ['nicegui-content']
assert elements[3].text == 'button A' # type: ignore
assert elements[4].text == 'label A' # type: ignore
assert elements[5].__class__ == ui.row
@@ -180,7 +180,7 @@ async def test_setting_classes(user: User):
await user.open('/')
for label in user.find('label').elements:
- assert label._classes == ['text-2xl'] # pylint: disable=protected-access
+ assert label.classes == ['text-2xl']
async def test_setting_style(user: User):
@@ -191,7 +191,7 @@ async def test_setting_style(user: User):
await user.open('/')
for label in user.find('label').elements:
- assert label._style['color'] == 'red' # pylint: disable=protected-access
+ assert label.style['color'] == 'red'
async def test_setting_props(user: User):
@@ -202,7 +202,7 @@ async def test_setting_props(user: User):
await user.open('/')
for button in user.find('button').elements:
- assert button._props['flat'] # pylint: disable=protected-access
+ assert button.props['flat']
async def test_typing(user: User):
diff --git a/tests/test_tailwind.py b/tests/test_tailwind.py
index a7198123e..3719c20ba 100644
--- a/tests/test_tailwind.py
+++ b/tests/test_tailwind.py
@@ -31,4 +31,4 @@ def test_tailwind_apply(screen: Screen):
def test_empty_values(nicegui_reset_globals):
label = ui.label('A')
label.tailwind.border_width('')
- assert 'border' in label._classes
+ assert 'border' in label.classes
diff --git a/tests/test_tree.py b/tests/test_tree.py
index b59c44c42..b0320c01c 100644
--- a/tests/test_tree.py
+++ b/tests/test_tree.py
@@ -72,7 +72,7 @@ def test_select_deselect_node(screen: Screen):
ui.button('Select', on_click=lambda: tree.select('2'))
ui.button('Deselect', on_click=tree.deselect)
- ui.label().bind_text_from(tree._props, 'selected', lambda x: f'Selected: {x}')
+ ui.label().bind_text_from(tree.props, 'selected', lambda x: f'Selected: {x}')
screen.open('/')
screen.click('Select')
@@ -92,7 +92,7 @@ def test_tick_untick_node_or_nodes(screen: Screen):
ui.button('Untick some', on_click=lambda: tree.untick(['1', 'B']))
ui.button('Tick all', on_click=tree.tick)
ui.button('Untick all', on_click=tree.untick)
- ui.label().bind_text_from(tree._props, 'ticked', lambda x: f'Ticked: {sorted(x)}')
+ ui.label().bind_text_from(tree.props, 'ticked', lambda x: f'Ticked: {sorted(x)}')
screen.open('/')
screen.should_contain('Ticked: []')
diff --git a/website/documentation/content/mermaid_documentation.py b/website/documentation/content/mermaid_documentation.py
index 6a1f84bcd..87dd791a5 100644
--- a/website/documentation/content/mermaid_documentation.py
+++ b/website/documentation/content/mermaid_documentation.py
@@ -11,7 +11,7 @@ def main_demo() -> None:
A --> C;
''')
# END OF DEMO
- list(ui.context.client.elements.values())[-1]._props['config'] = {'securityLevel': 'loose'} # HACK: for click_demo
+ list(ui.context.client.elements.values())[-1].props['config'] = {'securityLevel': 'loose'} # HACK: for click_demo
@doc.demo('Handle click events', '''
@@ -38,7 +38,7 @@ def error_demo() -> None:
A -> C;
''').on('error', lambda e: print(e.args['message']))
# END OF DEMO
- list(ui.context.client.elements.values())[-1]._props['config'] = {'securityLevel': 'loose'} # HACK: for click_demo
+ list(ui.context.client.elements.values())[-1].props['config'] = {'securityLevel': 'loose'} # HACK: for click_demo
doc.reference(ui.mermaid)
diff --git a/website/documentation/content/section_styling_appearance.py b/website/documentation/content/section_styling_appearance.py
index dbc511233..6f52ea0cd 100644
--- a/website/documentation/content/section_styling_appearance.py
+++ b/website/documentation/content/section_styling_appearance.py
@@ -74,7 +74,9 @@ def handle_classes(e: events.ValueChangeEventArguments):
ui.markdown("`')`")
with ui.row().classes('items-center gap-0 w-full px-2'):
def handle_props(e: events.ValueChangeEventArguments):
- element._props = {'label': 'Button', 'color': 'primary'}
+ element.props.clear()
+ element.props['label'] = 'Button'
+ element.props['color'] = 'primary'
try:
element.props(e.value)
except ValueError: