Переглянути джерело

Merge branch 'main' into leaflet

Falko Schindler 1 рік тому
батько
коміт
048566500b

+ 1 - 1
.vscode/settings.json

@@ -30,7 +30,7 @@
   "[python]": {
     "editor.defaultFormatter": "ms-python.autopep8",
     "editor.codeActionsOnSave": {
-      "source.organizeImports": true
+      "source.organizeImports": "explicit"
     }
   }
 }

+ 4 - 2
nicegui/element.py

@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional,
 
 from typing_extensions import Self
 
-from . import context, core, events, json, outbox, storage
+from . import context, core, events, helpers, json, outbox, storage
 from .awaitable_response import AwaitableResponse, NullResponse
 from .dependencies import Component, Library, register_library, register_resource, register_vue_component
 from .elements.mixins.visibility import Visibility
@@ -397,7 +397,7 @@ class Element(Visibility):
         if handler:
             listener = EventListener(
                 element_id=self.id,
-                type=type,
+                type=helpers.kebab_to_camel_case(type),
                 args=[args] if args and isinstance(args[0], str) else args,  # type: ignore
                 handler=handler,
                 throttle=throttle,
@@ -417,6 +417,8 @@ class Element(Visibility):
 
     def update(self) -> None:
         """Update the element on the client side."""
+        if self.is_deleted:
+            return
         outbox.enqueue_update(self)
 
     def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:

+ 1 - 0
nicegui/elements/editor.py

@@ -21,5 +21,6 @@ class Editor(ValueElement, DisableableElement):
         :param on_change: callback to be invoked when the value changes
         """
         super().__init__(tag='q-editor', value=value, on_value_change=on_change)
+        self._classes.append('nicegui-editor')
         if placeholder is not None:
             self._props['placeholder'] = placeholder

+ 3 - 3
nicegui/elements/input.js

@@ -18,7 +18,7 @@ export default {
   `,
   props: {
     id: String,
-    autocomplete: Array,
+    _autocomplete: Array,
     value: String,
   },
   data() {
@@ -41,14 +41,14 @@ export default {
   computed: {
     shadowText() {
       if (!this.inputValue) return "";
-      const matchingOption = this.autocomplete.find((option) =>
+      const matchingOption = this._autocomplete.find((option) =>
         option.toLowerCase().startsWith(this.inputValue.toLowerCase())
       );
       return matchingOption ? matchingOption.slice(this.inputValue.length) : "";
     },
     withDatalist() {
       const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
-      return isMobile && this.autocomplete && this.autocomplete.length > 0;
+      return isMobile && this._autocomplete && this._autocomplete.length > 0;
     },
   },
   methods: {

+ 2 - 2
nicegui/elements/input.py

@@ -59,11 +59,11 @@ class Input(ValidationElement, DisableableElement, component='input.js'):
                     self.props(f'type={"text" if is_hidden else "password"}')
                 icon = Icon('visibility_off').classes('cursor-pointer').on('click', toggle_type)
 
-        self._props['autocomplete'] = autocomplete or []
+        self._props['_autocomplete'] = autocomplete or []
 
     def set_autocomplete(self, autocomplete: Optional[List[str]]) -> None:
         """Set the autocomplete list."""
-        self._props['autocomplete'] = autocomplete
+        self._props['_autocomplete'] = autocomplete
         self.update()
 
     def _handle_value_change(self, value: Any) -> None:

+ 1 - 21
nicegui/elements/markdown.py

@@ -1,5 +1,4 @@
 import os
-import re
 from functools import lru_cache
 from typing import List
 
@@ -41,26 +40,7 @@ class Markdown(ContentElement, component='markdown.js'):
 @lru_cache(maxsize=int(os.environ.get('MARKDOWN_CONTENT_CACHE_SIZE', '1000')))
 def prepare_content(content: str, extras: str) -> str:
     """Render Markdown content to HTML."""
-    html = markdown2.markdown(remove_indentation(content), extras=extras.split())
-    return apply_tailwind(html)  # we need explicit Markdown styling because tailwind CSS removes all default styles
-
-
-def apply_tailwind(html: str) -> str:
-    """Apply tailwind CSS classes to the HTML."""
-    rep = {
-        '<h1': '<h1 class="text-5xl mb-4 mt-6"',
-        '<h2': '<h2 class="text-4xl mb-3 mt-5"',
-        '<h3': '<h3 class="text-3xl mb-2 mt-4"',
-        '<h4': '<h4 class="text-2xl mb-1 mt-3"',
-        '<h5': '<h5 class="text-1xl mb-0.5 mt-2"',
-        '<a': '<a class="underline text-blue-600 hover:text-blue-800 visited:text-purple-600"',
-        '<ul': '<ul class="list-disc ml-6"',
-        '<p>': '<p class="mb-2">',
-        r'<div\ class="codehilite">': '<div class="codehilite mb-2 p-2">',
-        '<code': '<code style="background-color: transparent"',
-    }
-    pattern = re.compile('|'.join(rep.keys()))
-    return pattern.sub(lambda m: rep[re.escape(m.group(0))], html)
+    return markdown2.markdown(remove_indentation(content), extras=extras.split())
 
 
 def remove_indentation(text: str) -> str:

+ 11 - 0
nicegui/elements/notification.js

@@ -0,0 +1,11 @@
+export default {
+  mounted() {
+    this.notification = Quasar.Notify.create(this.options);
+  },
+  updated() {
+    this.notification(this.options);
+  },
+  props: {
+    options: Object,
+  },
+};

+ 163 - 0
nicegui/elements/notification.py

@@ -0,0 +1,163 @@
+from typing import Any, Literal, Optional, Union
+
+from .. import context
+from ..element import Element
+from .timer import Timer
+
+NotificationPosition = Literal[
+    'top-left',
+    'top-right',
+    'bottom-left',
+    'bottom-right',
+    'top',
+    'bottom',
+    'left',
+    'right',
+    'center',
+]
+
+NotificationType = Optional[Literal[
+    'positive',
+    'negative',
+    'warning',
+    'info',
+    'ongoing',
+]]
+
+
+class Notification(Element, component='notification.js'):
+
+    def __init__(self,
+                 message: Any = '', *,
+                 position: NotificationPosition = 'bottom',
+                 close_button: Union[bool, str] = False,
+                 type: NotificationType = None,  # pylint: disable=redefined-builtin
+                 color: Optional[str] = None,
+                 multi_line: bool = False,
+                 icon: Optional[str] = None,
+                 spinner: bool = False,
+                 timeout: Optional[float] = 5.0,
+                 **kwargs: Any,
+                 ) -> None:
+        """Notification element
+
+        Displays a notification on the screen.
+        In contrast to `ui.notify`, this element allows to update the notification message and other properties once the notification is displayed.
+
+        :param message: content of the notification
+        :param position: position on the screen ("top-left", "top-right", "bottom-left", "bottom-right", "top", "bottom", "left", "right" or "center", default: "bottom")
+        :param close_button: optional label of a button to dismiss the notification (default: `False`)
+        :param type: optional type ("positive", "negative", "warning", "info" or "ongoing")
+        :param color: optional color name
+        :param multi_line: enable multi-line notifications
+        :param timeout: optional timeout in seconds after which the notification is dismissed (default: 5.0)
+
+        Note: You can pass additional keyword arguments according to `Quasar's Notify API <https://quasar.dev/quasar-plugins/notify#notify-api>`_.
+        """
+        with context.get_client().layout:
+            super().__init__()
+        self._props['options'] = {
+            'message': str(message),
+            'position': position,
+            'type': type,
+            'color': color,
+            'multiLine': multi_line,
+            'icon': icon,
+            'spinner': spinner,
+            'closeBtn': close_button,
+            'timeout': (timeout or 0) * 1000,
+            'group': False,
+            'attrs': {'data-id': f'nicegui-dialog-{self.id}'},
+        }
+        self._props['options'].update(kwargs)
+        with self:
+            def delete():
+                self.clear()
+                self.delete()
+
+            async def try_delete():
+                query = f'''!!document.querySelector("[data-id='nicegui-dialog-{self.id}']")'''
+                if not await self.client.run_javascript(query):
+                    delete()
+
+            Timer(1.0, try_delete)
+
+    @property
+    def message(self) -> str:
+        """Message text."""
+        return self._props['options']['message']
+
+    @message.setter
+    def message(self, value: Any) -> None:
+        self._props['options']['message'] = str(value)
+        self.update()
+
+    @property
+    def position(self) -> NotificationPosition:
+        """Position on the screen."""
+        return self._props['options']['position']
+
+    @position.setter
+    def position(self, value: NotificationPosition) -> None:
+        self._props['options']['position'] = value
+        self.update()
+
+    @property
+    def type(self) -> NotificationType:
+        """Type of the notification."""
+        return self._props['options']['type']
+
+    @type.setter
+    def type(self, value: NotificationType) -> None:
+        self._props['options']['type'] = value
+        self.update()
+
+    @property
+    def color(self) -> Optional[str]:
+        """Color of the notification."""
+        return self._props['options']['color']
+
+    @color.setter
+    def color(self, value: Optional[str]) -> None:
+        self._props['options']['color'] = value
+        self.update()
+
+    @property
+    def multi_line(self) -> bool:
+        """Whether the notification is multi-line."""
+        return self._props['options']['multiLine']
+
+    @multi_line.setter
+    def multi_line(self, value: bool) -> None:
+        self._props['options']['multiLine'] = value
+        self.update()
+
+    @property
+    def icon(self) -> Optional[str]:
+        """Icon of the notification."""
+        return self._props['options']['icon']
+
+    @icon.setter
+    def icon(self, value: Optional[str]) -> None:
+        self._props['options']['icon'] = value
+        self.update()
+
+    @property
+    def spinner(self) -> bool:
+        """Whether the notification is a spinner."""
+        return self._props['options']['spinner']
+
+    @spinner.setter
+    def spinner(self, value: bool) -> None:
+        self._props['options']['spinner'] = value
+        self.update()
+
+    @property
+    def close_button(self) -> Union[bool, str]:
+        """Whether the notification has a close button."""
+        return self._props['options']['closeBtn']
+
+    @close_button.setter
+    def close_button(self, value: Union[bool, str]) -> None:
+        self._props['options']['closeBtn'] = value
+        self.update()

+ 2 - 1
nicegui/elements/select.js

@@ -20,7 +20,7 @@ export default {
   },
   methods: {
     filterFn(val, update, abort) {
-      update(() => (this.filteredOptions = this.findFilteredOptions()));
+      update(() => (this.filteredOptions = val ? this.findFilteredOptions() : this.initialOptions));
     },
     findFilteredOptions() {
       const needle = this.$el.querySelector("input[type=search]")?.value.toLocaleLowerCase();
@@ -30,6 +30,7 @@ export default {
     },
   },
   updated() {
+    if (!this.$attrs.multiple) return;
     const newFilteredOptions = this.findFilteredOptions();
     if (newFilteredOptions.length !== this.filteredOptions.length) {
       this.filteredOptions = newFilteredOptions;

+ 20 - 1
nicegui/elements/table.py

@@ -4,7 +4,7 @@ from typing import Any, Callable, Dict, List, Literal, Optional, Union
 
 from .. import optional_features
 from ..element import Element
-from ..events import GenericEventArguments, TableSelectionEventArguments, handle_event
+from ..events import GenericEventArguments, TableSelectionEventArguments, ValueChangeEventArguments, handle_event
 from .mixins.filter_element import FilterElement
 
 try:
@@ -24,6 +24,7 @@ class Table(FilterElement, component='table.js'):
                  selection: Optional[Literal['single', 'multiple']] = None,
                  pagination: Optional[Union[int, dict]] = None,
                  on_select: Optional[Callable[..., Any]] = None,
+                 on_pagination_change: Optional[Callable[..., Any]] = None,
                  ) -> None:
         """Table
 
@@ -36,6 +37,7 @@ class Table(FilterElement, component='table.js'):
         :param selection: selection type ("single" or "multiple"; default: `None`)
         :param pagination: a dictionary correlating to a pagination object or number of rows per page (`None` hides the pagination, 0 means "infinite"; default: `None`).
         :param on_select: callback which is invoked when the selection changes
+        :param on_pagination_change: callback which is invoked when the pagination changes
 
         If selection is 'single' or 'multiple', then a `selected` property is accessible containing the selected rows.
         """
@@ -63,6 +65,13 @@ class Table(FilterElement, component='table.js'):
             handle_event(on_select, arguments)
         self.on('selection', handle_selection, ['added', 'rows', 'keys'])
 
+        def handle_pagination_change(e: GenericEventArguments) -> None:
+            self.pagination = e.args
+            self.update()
+            arguments = ValueChangeEventArguments(sender=self, client=self.client, value=self.pagination)
+            handle_event(on_pagination_change, arguments)
+        self.on('update:pagination', handle_pagination_change)
+
     @staticmethod
     def from_pandas(df: pd.DataFrame,
                     row_key: str = 'id',
@@ -146,6 +155,16 @@ class Table(FilterElement, component='table.js'):
         self._props['selected'][:] = value
         self.update()
 
+    @property
+    def pagination(self) -> dict:
+        """Pagination object."""
+        return self._props['pagination']
+
+    @pagination.setter
+    def pagination(self, value: dict) -> None:
+        self._props['pagination'] = value
+        self.update()
+
     @property
     def is_fullscreen(self) -> bool:
         """Whether the table is in fullscreen mode."""

+ 5 - 0
nicegui/helpers.py

@@ -85,3 +85,8 @@ def schedule_browser(host: str, port: int) -> Tuple[threading.Thread, threading.
     thread = threading.Thread(target=in_thread, args=(host, port), daemon=True)
     thread.start()
     return thread, cancel
+
+
+def kebab_to_camel_case(string: str) -> str:
+    """Convert a kebab-case string to camelCase."""
+    return ''.join(word.capitalize() if i else word for i, word in enumerate(string.split('-')))

+ 0 - 0
nicegui/py.typed


+ 113 - 13
nicegui/static/nicegui.css

@@ -71,6 +71,119 @@
   content: ""; /* the gap compensates for the missing vertical padding */
 }
 
+/* revert Tailwind's CSS reset for ui.editor and ui.markdown */
+.nicegui-editor .q-editor__content h1,
+.nicegui-markdown h1 {
+  font-size: 3rem;
+  line-height: 1;
+  margin-bottom: 1rem;
+  margin-top: 1.5rem;
+  font-weight: 300;
+}
+.nicegui-editor .q-editor__content h2,
+.nicegui-markdown h2 {
+  font-size: 2.25rem;
+  line-height: 2.5rem;
+  margin-bottom: 0.75rem;
+  margin-top: 1.25rem;
+  font-weight: 300;
+}
+.nicegui-editor .q-editor__content h3,
+.nicegui-markdown h3 {
+  font-size: 1.875rem;
+  line-height: 2.25rem;
+  margin-bottom: 0.5rem;
+  margin-top: 1rem;
+  font-weight: 400;
+}
+.nicegui-editor .q-editor__content h4,
+.nicegui-markdown h4 {
+  font-size: 1.5rem;
+  line-height: 2rem;
+  margin-bottom: 0.25rem;
+  margin-top: 0.75rem;
+  font-weight: 400;
+}
+.nicegui-editor .q-editor__content h5,
+.nicegui-markdown h5 {
+  font-size: 1.25rem;
+  line-height: 1.75rem;
+  margin-bottom: 0.125rem;
+  margin-top: 0.5rem;
+  font-weight: 400;
+}
+.nicegui-editor .q-editor__content h6,
+.nicegui-markdown h6 {
+  font-size: 1.125rem;
+  line-height: 1.75rem;
+  margin-bottom: 0.125rem;
+  margin-top: 0.5rem;
+  font-weight: 500;
+}
+.nicegui-editor .q-editor__content a,
+.nicegui-markdown a {
+  text-decoration-line: underline;
+  color: rgb(37 99 235); /* blue-600 */
+}
+.nicegui-editor .q-editor__content a:hover,
+.nicegui-markdown a:hover {
+  color: rgb(30 64 175); /* blue-800 */
+}
+.nicegui-editor .q-editor__content a:visited,
+.nicegui-markdown a:visited {
+  color: rgb(147 51 234); /* purple-600 */
+}
+.nicegui-editor .q-editor__content hr,
+.nicegui-markdown hr {
+  margin-block-start: 0.5em;
+  margin-block-end: 0.5em;
+  height: 1px;
+  border-style: none;
+  background-color: rgba(128, 128, 128, 0.25);
+}
+.nicegui-editor .q-editor__content ul,
+.nicegui-markdown ul {
+  list-style-type: initial;
+  padding-inline-start: 2.5rem;
+  margin-block-start: 0.25rem;
+  margin-block-end: 0.25rem;
+}
+.nicegui-editor .q-editor__content ol,
+.nicegui-markdown ol {
+  list-style-type: decimal;
+  padding-inline-start: 2.5rem;
+  margin-block-start: 0.25rem;
+  margin-block-end: 0.25rem;
+}
+.nicegui-editor .q-editor__content blockquote,
+.nicegui-markdown blockquote {
+  border-left: 0.25rem solid #8884;
+  padding: 0.25rem 1rem 0.25rem 1rem;
+  margin: 0.5rem 0;
+}
+.nicegui-editor .q-editor__content p,
+.nicegui-markdown p {
+  margin: 0.5rem 0;
+}
+.nicegui-editor .q-editor__content table,
+.nicegui-markdown table {
+  border-collapse: collapse;
+  margin: 0.5rem 0;
+}
+.nicegui-editor .q-editor__content th,
+.nicegui-markdown th {
+  padding: 0.5rem;
+  border: 1px solid #8884;
+}
+.nicegui-editor .q-editor__content td,
+.nicegui-markdown td {
+  padding: 0.5rem;
+  border: 1px solid #8884;
+}
+.nicegui-markdown .codehilite pre {
+  margin: 0.5rem 0;
+}
+
 /* other NiceGUI elements */
 .nicegui-grid {
   display: grid;
@@ -103,19 +216,6 @@
   opacity: 1 !important;
   cursor: text !important;
 }
-.nicegui-markdown blockquote {
-  border-left: 0.25rem solid #8884;
-  padding: 1rem 1rem 0.5rem 1rem;
-  margin: 1rem 0;
-}
-.nicegui-markdown th {
-  padding: 0.5rem;
-  border: 1px solid #8884;
-}
-.nicegui-markdown td {
-  padding: 0.5rem;
-  border: 1px solid #8884;
-}
 h6.q-timeline__title {
   font-size: 1.25rem;
   font-weight: 500;

+ 6 - 1
nicegui/storage.py

@@ -13,6 +13,7 @@ from starlette.requests import Request
 from starlette.responses import Response
 
 from . import background_tasks, context, core, json, observables
+from .logging import log
 
 request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None)
 
@@ -43,7 +44,11 @@ class PersistentDict(observables.ObservableDict):
 
     def __init__(self, filepath: Path) -> None:
         self.filepath = filepath
-        data = json.loads(filepath.read_text()) if filepath.exists() else {}
+        try:
+            data = json.loads(filepath.read_text()) if filepath.exists() else {}
+        except Exception:
+            log.warning(f'Could not load storage file {filepath}')
+            data = {}
         super().__init__(data, on_change=self.backup)
 
     def backup(self) -> None:

+ 6 - 8
nicegui/templates/index.html

@@ -160,7 +160,7 @@
               listener_id: event.listener_id,
               args: stringifyEventArgs(args, event.args),
             };
-            const emitter = () => window.socket.emit("event", data);
+            const emitter = () => window.socket?.emit("event", data);
             throttle(emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id);
             if (element.props["loopback"] === False && event.type == "update:model-value") {
               element.props["model-value"] = args;
@@ -272,13 +272,11 @@
                 window.location.reload(); // see https://github.com/zauberzeug/nicegui/issues/198
               }
             },
-            try_reconnect: () => {
-              const checkAndReload = async () => {
-                await fetch(window.location.href, { headers: { 'NiceGUI-Check': 'try_reconnect' } });
-                console.log('reloading because reconnect was requested')
-                window.location.reload();
-              };
-              setInterval(checkAndReload, 500);
+            try_reconnect: async () => {
+              document.getElementById('popup').style.opacity = 1;
+              await fetch(window.location.href, { headers: { 'NiceGUI-Check': 'try_reconnect' } });
+              console.log('reloading because reconnect was requested')
+              window.location.reload();
             },
             disconnect: () => {
               document.getElementById('popup').style.opacity = 1;

+ 2 - 0
nicegui/ui.py

@@ -46,6 +46,7 @@ __all__ = [
     'menu',
     'menu_item',
     'mermaid',
+    'notification',
     'number',
     'pagination',
     'plotly',
@@ -148,6 +149,7 @@ from .elements.markdown import Markdown as markdown
 from .elements.menu import Menu as menu
 from .elements.menu import MenuItem as menu_item
 from .elements.mermaid import Mermaid as mermaid
+from .elements.notification import Notification as notification
 from .elements.number import Number as number
 from .elements.pagination import Pagination as pagination
 from .elements.plotly import Plotly as plotly

+ 2 - 2
pyproject.toml

@@ -11,7 +11,7 @@ keywords = ["gui", "ui", "web", "interface", "live"]
 [tool.poetry.dependencies]
 python = "^3.8"
 typing-extensions = ">=4.0.0"
-markdown2 = "^2.4.7"
+markdown2 = ">=2.4.7,<2.4.11"
 Pygments = ">=2.15.1,<3.0.0"
 uvicorn = {extras = ["standard"], version = "^0.22.0"}
 fastapi = ">=0.93,<1.0.0"
@@ -70,4 +70,4 @@ addopts = "--driver Chrome"
 asyncio_mode = "auto"
 
 [tool.mypy]
-ignore_missing_imports = true
+ignore_missing_imports = true

+ 24 - 0
tests/test_notification.py

@@ -0,0 +1,24 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_notification(screen: Screen):
+    ui.button('Notify', on_click=lambda: ui.notification('Hi!'))
+
+    screen.open('/')
+    screen.click('Notify')
+    screen.should_contain('Hi!')
+
+
+def test_close_button(screen: Screen):
+    b = ui.button('Notify', on_click=lambda: ui.notification('Hi!', timeout=None, close_button=True))
+
+    screen.open('/')
+    screen.click('Notify')
+    screen.should_contain('Hi!')
+    assert len(b.client.layout.default_slot.children) == 2
+    screen.click('Close')
+    screen.wait(1.5)
+    screen.should_not_contain('Hi!')
+    assert len(b.client.layout.default_slot.children) == 1

+ 10 - 4
tests/test_select.py

@@ -142,8 +142,9 @@ def test_add_new_values(screen:  Screen, option_dict: bool, multiple: bool, new_
                                   "options = ['a', 'b', 'c']")
 
 
-def test_keep_filtered_options(screen: Screen):
-    ui.select(options=['A1', 'A2', 'B1', 'B2'], with_input=True, multiple=True)
+@pytest.mark.parametrize('multiple', [False, True])
+def test_keep_filtered_options(multiple: bool, screen: Screen):
+    ui.select(options=['A1', 'A2', 'B1', 'B2'], with_input=True, multiple=multiple)
 
     screen.open('/')
     screen.find_by_tag('input').click()
@@ -161,7 +162,12 @@ def test_keep_filtered_options(screen: Screen):
 
     screen.click('A1')
     screen.wait(0.5)
+    screen.find_by_tag('input').click()
     screen.should_contain('A1')
     screen.should_contain('A2')
-    screen.should_not_contain('B1')
-    screen.should_not_contain('B2')
+    if multiple:
+        screen.should_not_contain('B1')
+        screen.should_not_contain('B2')
+    else:
+        screen.should_contain('B1')
+        screen.should_contain('B2')

+ 22 - 0
website/documentation/content/notification_documentation.py

@@ -0,0 +1,22 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.notification)
+def main_demo() -> None:
+    import asyncio
+
+    async def compute():
+        n = ui.notification()
+        for i in range(10):
+            n.message = f'Computing {i/10:.0%}'
+            n.spinner = True
+            await asyncio.sleep(0.2)
+        n.message = 'Done!'
+        n.spinner = False
+
+    ui.button('Compute', on_click=compute)
+
+
+doc.reference(ui.notification)

+ 4 - 3
website/documentation/content/section_page_layout.py

@@ -2,9 +2,9 @@ from nicegui import ui
 
 from . import (card_documentation, carousel_documentation, column_documentation, context_menu_documentation,
                dialog_documentation, doc, expansion_documentation, grid_documentation, menu_documentation,
-               notify_documentation, pagination_documentation, row_documentation, scroll_area_documentation,
-               separator_documentation, splitter_documentation, stepper_documentation, tabs_documentation,
-               timeline_documentation)
+               notification_documentation, notify_documentation, pagination_documentation, row_documentation,
+               scroll_area_documentation, separator_documentation, splitter_documentation, stepper_documentation,
+               tabs_documentation, timeline_documentation)
 
 doc.title('Page *Layout*')
 
@@ -85,4 +85,5 @@ def tooltips_demo():
 
 
 doc.intro(notify_documentation)
+doc.intro(notification_documentation)
 doc.intro(dialog_documentation)

+ 12 - 0
website/documentation/content/table_documentation.py

@@ -235,6 +235,18 @@ def pagination() -> None:
     ui.table(columns=columns, rows=rows, pagination={'rowsPerPage': 4, 'sortBy': 'age', 'page': 2})
 
 
+@doc.demo('Handle pagination changes', '''
+    You can handle pagination changes using the `on_pagination_change` parameter.
+''')
+def handle_pagination_changes() -> None:
+    ui.table(
+        columns=[{'id': 'Name', 'label': 'Name', 'field': 'Name', 'align': 'left'}],
+        rows=[{'Name': f'Person {i}'} for i in range(100)],
+        pagination=3,
+        on_pagination_change=lambda e: ui.notify(e.value),
+    )
+
+
 @doc.demo('Computed fields', '''
     You can use functions to compute the value of a column.
     The function receives the row as an argument.

+ 13 - 7
website/documentation/reference.py

@@ -5,7 +5,6 @@ from typing import Callable, Optional
 import docutils.core
 
 from nicegui import binding, ui
-from nicegui.elements.markdown import apply_tailwind, remove_indentation
 
 from ..style import subheading
 
@@ -38,9 +37,7 @@ def generate_class_doc(class_obj: type) -> None:
                     _render_docstring(method.__doc__).classes('ml-8')
     if ancestors:
         subheading('Inherited from')
-        with ui.column().classes('gap-2'):
-            for ancestor in ancestors:
-                ui.markdown(f'- `{ancestor.__name__}`')
+        ui.markdown('\n'.join(f'- `{ancestor.__name__}`' for ancestor in ancestors))
 
 
 def _is_method_or_property(cls: type, attribute_name: str) -> bool:
@@ -92,10 +89,19 @@ def _generate_method_signature_description(method: Callable) -> str:
 
 
 def _render_docstring(doc: str, with_params: bool = True) -> ui.html:
-    doc = remove_indentation(doc)
+    doc = _remove_indentation_from_docstring(doc)
     doc = doc.replace('param ', '')
     html = docutils.core.publish_parts(doc, writer_name='html5_polyglot')['html_body']
-    html = apply_tailwind(html)
     if not with_params:
         html = re.sub(r'<dl class=".* simple">.*?</dl>', '', html, flags=re.DOTALL)
-    return ui.html(html).classes('documentation bold-links arrow-links')
+    return ui.html(html).classes('bold-links arrow-links nicegui-markdown')
+
+
+def _remove_indentation_from_docstring(text: str) -> str:
+    lines = text.splitlines()
+    if not lines:
+        return ''
+    if len(lines) == 1:
+        return lines[0]
+    indentation = min(len(line) - len(line.lstrip()) for line in lines[1:] if line.strip())
+    return lines[0] + '\n'.join(line[indentation:] for line in lines[1:])

+ 1 - 3
website/documentation/rendering.py

@@ -1,7 +1,6 @@
 import docutils.core
 
 from nicegui import ui
-from nicegui.elements.markdown import apply_tailwind
 
 from ..header import add_head_html, add_header
 from ..style import section_heading, subheading
@@ -43,8 +42,7 @@ def render_page(documentation: DocumentationPage, *, with_menu: bool = True) ->
                 if part.description_format == 'rst':
                     description = part.description.replace('param ', '')
                     html = docutils.core.publish_parts(description, writer_name='html5_polyglot')['html_body']
-                    html = apply_tailwind(html)
-                    ui.html(html).classes('bold-links arrow-links')
+                    ui.html(html).classes('bold-links arrow-links nicegui-markdown')
                 else:
                     ui.markdown(part.description).classes('bold-links arrow-links')
             if part.ui:

+ 1 - 1
website/static/style.css

@@ -119,7 +119,7 @@ dl.docinfo dd {
 }
 dl.field-list p,
 dl.docinfo p {
-  margin-bottom: 0;
+  margin: 0;
 }
 
 .dark-box {

+ 1 - 2
website/style.py

@@ -51,8 +51,7 @@ def features(icon: str, title_: str, items: List[str]) -> None:
     with ui.column().classes('gap-1'):
         ui.icon(icon).classes('max-sm:hidden text-3xl md:text-5xl mb-3 text-primary opacity-80')
         ui.label(title_).classes('font-bold mb-3')
-        for item in items:
-            ui.markdown(f'- {item}').classes('bold-links arrow-links')
+        ui.markdown('\n'.join(f'- {item}' for item in items)).classes('bold-links arrow-links -ml-4')
 
 
 def side_menu() -> ui.left_drawer: