瀏覽代碼

Provide public API for accessing `_props`, `_classes` and `_style`. (#3588)

* move props parsing into separate class derived from dict

* replace use of old `_parse_props`

* tiny fix

* extend principle to classes and style

* fix linting errors in query.py

* fix `ui.tree` and `ui.menu`

* remove obsolete pylint markers

* parameterize parsing tests

* use new API where possible
Falko Schindler 8 月之前
父節點
當前提交
392e0d0cf2

+ 45 - 0
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 <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ 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

+ 39 - 141
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 @@ class Element(Visibility):
         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 @@ class Element(Visibility):
             },
         }
 
-    @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 <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ 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 @@ class Element(Visibility):
         :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 @@ class Element(Visibility):
         """
         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 <https://quasar.dev/>`_ 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 @@ class Element(Visibility):
         :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
 

+ 6 - 7
nicegui/element_filter.py

@@ -91,7 +91,6 @@ class ElementFilter(Generic[T]):
         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 @@ class ElementFilter(Generic[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 @@ class ElementFilter(Generic[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())

+ 2 - 2
nicegui/elements/carousel.py

@@ -33,11 +33,11 @@ class Carousel(ValueElement):
         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}')

+ 5 - 12
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 @@ class Menu(ValueElement):
         """
         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 @@ class Menu(ValueElement):
         """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):
 

+ 1 - 1
nicegui/elements/mixins/visibility.py

@@ -100,7 +100,7 @@ class Visibility:
         :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

+ 30 - 41
nicegui/elements/query.py

@@ -2,8 +2,11 @@ from typing import Optional
 
 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 @@ class QueryElement(Element, component='query.js'):
         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 @@ class Query:
         :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 @@ class Query:
         :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 @@ class Query:
         :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 @@ class Query:
         :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

+ 2 - 2
nicegui/elements/stepper.py

@@ -34,11 +34,11 @@ class Stepper(ValueElement):
         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}')

+ 3 - 3
nicegui/elements/tabs.py

@@ -25,7 +25,7 @@ class Tabs(ValueElement):
         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 @@ class TabPanels(ValueElement):
         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 @@ class TabPanel(DisableableElement):
         :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')

+ 5 - 10
nicegui/elements/tree.py

@@ -2,7 +2,6 @@ from typing import Any, Callable, Dict, Iterator, List, Literal, Optional, Set
 
 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 @@ class Tree(FilterElement):
         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 @@ class Tree(FilterElement):
                 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

+ 6 - 6
nicegui/page_layout.py

@@ -52,9 +52,9 @@ class Header(ValueElement):
         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 @@ class Drawer(Element):
         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 @@ class Footer(ValueElement):
         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)
 

+ 93 - 0
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 <https://quasar.dev/>`_ 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

+ 47 - 0
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

+ 1 - 1
nicegui/testing/user_interaction.py

@@ -57,7 +57,7 @@ class UserInteraction(Generic[T]):
         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):

+ 81 - 75
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 test_classes(screen: Screen):
     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):

+ 4 - 4
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):

+ 1 - 1
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

+ 2 - 2
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: []')

+ 2 - 2
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)

+ 3 - 1
website/documentation/content/section_styling_appearance.py

@@ -74,7 +74,9 @@ def styling_demo():
                         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: