Repository: zauberzeug/nicegui
Pull request #3444 (2024-08-06 08:19:18)

Fix Quasar's layout rules for `ui.card` that remove children's borders and shadows
Quasar's QCard is the base for `ui.card`. But in the early days of NiceGUI we decided to set a default gap and padding, so that you don't need to wrap content in card sections. If you do need Quasar's original tight layout, you can call `.tight()`.

But this decision caused quite some trouble, because Quasar keeps removing borders and shadows of child elements, which makes sense for the tight layout without padding, but not with NiceGUI's default card style (#726, #1295, #2265, #2301). We already planned to introduce a new `ui.box` element to avoid confusion between both behaviors, which would have caused a _major_ breaking change.

Now I finally found a rather neat solution to change Quasar's CSS rules to apply to tight cards only. In my point of view, this is exactly what we needed. I just didn't think of the possibility to manipulate existing rules using JavaScript, which turned out to be pretty easy and powerful.

With this change, the following cards behave as expected:
```py
with ui.card():
    ui.label('Borders and shadows').classes('border shadow')
    ui.label('of child divs').classes('border shadow')
    ui.label('are preserved').classes('border shadow')

with ui.card().tight():
    ui.label('Borders and shadows').classes('border shadow')
    ui.label('of child divs').classes('border shadow')
    ui.label('are removed').classes('border shadow')
```

![Screenshot 2024-08-06 at 10 16 03](https://github.com/user-attachments/assets/9e3c6622-0103-4a36-ac47-558546c1e651)

Old behavior for reference:

![Screenshot 2024-08-06 at 10 16 57](https://github.com/user-attachments/assets/75230495-055a-4efb-94eb-886dc9344be4)

Open tasks:

- [x] add pytest to avoid regression when updating Quasar
- [x] update documentation



Patch:
diff --git a/nicegui/client.py b/nicegui/client.py
index aa5dd5d8..d9656db8 100644
--- a/nicegui/client.py
+++ b/nicegui/client.py
@@ -90,8 +90,9 @@ class Client:
 
     @property
     def ip(self) -> Optional[str]:
-        """Return the IP address of the client, or None if the client is not connected."""
-        return self.environ['asgi.scope']['client'][0] if self.environ else None  # pylint: disable=unsubscriptable-object
+        """Return the IP address of the client, or None if it is an
+        `auto-index page <https://nicegui.io/documentation/section_pages_routing#auto-index_page>`_."""
+        return self.request.client.host if self.request is not None and self.request.client is not None else None
 
     @property
     def has_socket_connection(self) -> bool:
diff --git a/nicegui/elements/card.py b/nicegui/elements/card.py
index 02a5fde9..3839b8ad 100644
--- a/nicegui/elements/card.py
+++ b/nicegui/elements/card.py
@@ -16,10 +16,9 @@ class Card(Element):
         It provides a container with a dropped shadow.
 
         Note:
-        There are subtle differences between the Quasar component and this element.
-        In contrast to this element, the original QCard has no padding by default and hides outer borders of nested elements.
+        In contrast to this element,
+        the original QCard has no padding by default and hides outer borders and shadows of nested elements.
         If you want the original behavior, use the `tight` method.
-        If you want the padding and borders for nested children, move the children into another container.
 
         :param align_items: alignment of the items in the card (default: `None`)
         """
diff --git a/nicegui/elements/mixins/validation_element.py b/nicegui/elements/mixins/validation_element.py
index ff3d9f7e..6a284add 100644
--- a/nicegui/elements/mixins/validation_element.py
+++ b/nicegui/elements/mixins/validation_element.py
@@ -12,6 +12,7 @@ class ValidationElement(ValueElement):
         self._auto_validation = True
         self._error: Optional[str] = None
         super().__init__(**kwargs)
+        self._props['error'] = False  # NOTE: reserve bottom space for error message
 
     @property
     def error(self) -> Optional[str]:
diff --git a/nicegui/static/nicegui.js b/nicegui/static/nicegui.js
index 948e5e64..630d3c49 100644
--- a/nicegui/static/nicegui.js
+++ b/nicegui/static/nicegui.js
@@ -383,3 +383,12 @@ function createApp(elements, options) {
     config: options.quasarConfig,
   }));
 }
+
+// HACK: remove Quasar's rules for divs in QCard (#2265, #2301)
+for (let sheet of document.styleSheets) {
+  if (/\/quasar(?:\.prod)?\.css$/.test(sheet.href)) {
+    for (let rule of sheet.cssRules) {
+      if (/\.q-card > div/.test(rule.selectorText)) rule.selectorText = ".nicegui-card-tight" + rule.selectorText;
+    }
+  }
+}
diff --git a/website/documentation/content/card_documentation.py b/website/documentation/content/card_documentation.py
index 2b88e217..e7900f42 100644
--- a/website/documentation/content/card_documentation.py
+++ b/website/documentation/content/card_documentation.py
@@ -14,25 +14,21 @@ def main_demo() -> None:
 @doc.demo('Card without shadow', '''
     You can remove the shadow from a card by adding the `no-shadow` class.
     The following demo shows a 1 pixel wide border instead.
+
+    Alternatively, you can use Quasar's "flat" and "bordered" props to achieve the same effect.
 ''')
 def card_without_shadow() -> None:
     with ui.card().classes('no-shadow border-[1px]'):
         ui.label('See, no shadow!')
 
+    with ui.card().props('flat bordered'):
+        ui.label('Also no shadow!')
 
-@doc.demo('The issue with nested borders', '''
-    The following example shows a table nested in a card.
-    Cards have a default padding in NiceGUI, so the table is not flush with the card's border.
-    The table has the `flat` and `bordered` props set, so it should have a border.
-    However, due to the way QCard is designed, the border is not visible (first card).
-    There are two ways to fix this:
-
-    - To get the original QCard behavior, use the `tight` method (second card).
-        It removes the padding and the table border collapses with the card border.
-
-    - To preserve the padding _and_ the table border, move the table into another container like a `ui.row` (third card).
 
-    See https://github.com/zauberzeug/nicegui/issues/726 for more information.
+@doc.demo('Tight card layout', '''
+    By default, cards have a padding.
+    You can remove the padding and gaps between nested elements by using the `tight` method.
+    This also hides outer borders and shadows of nested elements, like in an original QCard.
 ''')
 def custom_context_menu() -> None:
     columns = [{'name': 'age', 'label': 'Age', 'field': 'age'}]
@@ -45,9 +41,5 @@ def custom_context_menu() -> None:
         with ui.card().tight():
             ui.table(columns, rows).props('flat bordered')
 
-        with ui.card():
-            with ui.row():
-                ui.table(columns, rows).props('flat bordered')
-
 
 doc.reference(ui.card)
diff --git a/website/documentation/content/page_documentation.py b/website/documentation/content/page_documentation.py
index 5a39bedd..b802aa2d 100644
--- a/website/documentation/content/page_documentation.py
+++ b/website/documentation/content/page_documentation.py
@@ -48,7 +48,6 @@ def wait_for_connected_demo():
         await ui.context.client.connected()
         await asyncio.sleep(2)
         ui.label('This text is displayed 2 seconds after the page has been fully loaded.')
-        ui.label(f'The IP address {ui.context.client.ip} was obtained from the websocket.')
 
     ui.link('wait for connection', wait_for_connection)
 


Test patch:
diff --git a/tests/test_card.py b/tests/test_card.py
new file mode 100644
index 00000000..74c27c53
--- /dev/null
+++ b/tests/test_card.py
@@ -0,0 +1,13 @@
+from nicegui import ui
+from nicegui.testing import Screen
+
+
+def test_preserve_borders(screen: Screen):
+    with ui.card():
+        ui.label('A').classes('border shadow')
+    with ui.card().tight():
+        ui.label('B').classes('border shadow')
+
+    screen.open('/')
+    assert screen.find('A').value_of_css_property('border') == '1px solid rgb(229, 231, 235)'
+    assert screen.find('B').value_of_css_property('border') == '0px none rgb(0, 0, 0)'
diff --git a/tests/test_page.py b/tests/test_page.py
index 5ea37b55..8b033343 100644
--- a/tests/test_page.py
+++ b/tests/test_page.py
@@ -313,3 +313,12 @@ def test_reconnecting_without_page_reload(screen: Screen):
     screen.wait(2.0)
     element = screen.selenium.find_element(By.XPATH, '//*[@aria-label="Input"]')
     assert element.get_attribute('value') == 'hello', 'input should be preserved after reconnect (i.e. no page reload)'
+
+
+def test_ip(screen: Screen):
+    @ui.page('/')
+    def page():
+        ui.label(ui.context.client.ip or 'unknown')
+
+    screen.open('/')
+    screen.should_contain('127.0.0.1')