Repository: zauberzeug/nicegui
Pull request #4019 (2024-11-21 08:26:02)

Allow toggling classes
Inspired by feature request #4013, this PR adds the ability to toggle classes (and default classes) like this:

```py
label = ui.label('Hello')
ui.button('Toggle', on_click=lambda: label.classes(toggle='bg-red-500'))
```


Patch:
diff --git a/nicegui/classes.py b/nicegui/classes.py
index 74f90c1e..062684b1 100644
--- a/nicegui/classes.py
+++ b/nicegui/classes.py
@@ -15,8 +15,9 @@ class Classes(list, Generic[T]):
     def __call__(self,
                  add: Optional[str] = None, *,
                  remove: Optional[str] = None,
+                 toggle: Optional[str] = None,
                  replace: Optional[str] = None) -> T:
-        """Apply, remove, or replace HTML classes.
+        """Apply, remove, toggle, 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.
 
@@ -24,9 +25,10 @@ class Classes(list, Generic[T]):
 
         :param add: whitespace-delimited string of classes
         :param remove: whitespace-delimited string of classes to remove from the element
+        :param toggle: whitespace-delimited string of classes to toggle
         :param replace: whitespace-delimited string of classes to use instead of existing ones
         """
-        new_classes = self.update_list(self, add, remove, replace)
+        new_classes = self.update_list(self, add, remove, toggle, replace)
         if self != new_classes:
             self[:] = new_classes
             self.element.update()
@@ -36,10 +38,18 @@ class Classes(list, Generic[T]):
     def update_list(classes: List[str],
                     add: Optional[str] = None,
                     remove: Optional[str] = None,
+                    toggle: 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
+        class_list = list(dict.fromkeys(class_list))  # NOTE: remove duplicates while preserving order
+        if toggle is not None:
+            for class_ in toggle.split():
+                if class_ in class_list:
+                    class_list.remove(class_)
+                else:
+                    class_list.append(class_)
+        return class_list
diff --git a/nicegui/element.py b/nicegui/element.py
index 29af8709..ffa8e13b 100644
--- a/nicegui/element.py
+++ b/nicegui/element.py
@@ -222,8 +222,9 @@ class Element(Visibility):
     def default_classes(cls,
                         add: Optional[str] = None, *,
                         remove: Optional[str] = None,
+                        toggle: Optional[str] = None,
                         replace: Optional[str] = None) -> type[Self]:
-        """Apply, remove, or replace default HTML classes.
+        """Apply, remove, toggle, or replace default HTML classes.
 
         This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
 
@@ -233,9 +234,10 @@ class Element(Visibility):
 
         :param add: whitespace-delimited string of classes
         :param remove: whitespace-delimited string of classes to remove from the element
+        :param toggle: whitespace-delimited string of classes to toggle
         :param replace: whitespace-delimited string of classes to use instead of existing ones
         """
-        cls._default_classes = Classes.update_list(cls._default_classes, add, remove, replace)
+        cls._default_classes = Classes.update_list(cls._default_classes, add, remove, toggle, replace)
         return cls
 
     @property
diff --git a/nicegui/elements/query.py b/nicegui/elements/query.py
index 67d4a78b..7c603afd 100644
--- a/nicegui/elements/query.py
+++ b/nicegui/elements/query.py
@@ -37,9 +37,13 @@ class Query:
         else:
             self.element = QueryElement(selector)
 
-    def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
-            -> Self:
-        """Apply, remove, or replace HTML classes.
+    def classes(self,
+                add: Optional[str] = None, *,
+                remove: Optional[str] = None,
+                toggle: Optional[str] = None,
+                replace: Optional[str] = None,
+                ) -> Self:
+        """Apply, remove, toggle, 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.
 
@@ -47,9 +51,10 @@ class Query:
 
         :param add: whitespace-delimited string of classes
         :param remove: whitespace-delimited string of classes to remove from the element
+        :param toggle: whitespace-delimited string of classes to toggle
         :param replace: whitespace-delimited string of classes to use instead of existing ones
         """
-        classes = Classes.update_list(self.element.props['classes'], add, remove, replace)
+        classes = Classes.update_list(self.element.props['classes'], add, remove, toggle, 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:


Test patch:
diff --git a/tests/test_element.py b/tests/test_element.py
index 66cce207..e7592e0a 100644
--- a/tests/test_element.py
+++ b/tests/test_element.py
@@ -35,6 +35,12 @@ def test_classes(screen: Screen):
     label.classes(replace='four')
     assert_classes('four')
 
+    label.classes(toggle='bg-red-500')
+    assert_classes('four bg-red-500')
+
+    label.classes(toggle='bg-red-500')
+    assert_classes('four')
+
 
 @pytest.mark.parametrize('value,expected', [
     (None, {}),