Pārlūkot izejas kodu

Merge branch 'main' into frankvp11/main

# Conflicts:
#	website/documentation/content/section_data_elements.py
Falko Schindler 1 gadu atpakaļ
vecāks
revīzija
da7107a6d0
81 mainītis faili ar 2129 papildinājumiem un 215 dzēšanām
  1. 1 1
      .vscode/settings.json
  2. 3 3
      CITATION.cff
  3. 2 0
      DEPENDENCIES.md
  4. 0 20
      examples/map/leaflet.js
  5. 0 14
      examples/map/leaflet.py
  6. 0 22
      examples/map/main.py
  7. 1 1
      fetch_milestone.py
  8. 6 3
      nicegui/client.py
  9. 21 1
      nicegui/dependencies.py
  10. 15 4
      nicegui/element.py
  11. 7 6
      nicegui/elements/aggrid.py
  12. 1 0
      nicegui/elements/editor.py
  13. 3 3
      nicegui/elements/input.js
  14. 2 2
      nicegui/elements/input.py
  15. 126 0
      nicegui/elements/leaflet.js
  16. 131 0
      nicegui/elements/leaflet.py
  17. 29 0
      nicegui/elements/leaflet_layer.py
  18. 48 0
      nicegui/elements/leaflet_layers.py
  19. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/layers-2x.png
  20. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/layers.png
  21. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/marker-icon-2x.png
  22. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/marker-icon.png
  23. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/marker-shadow.png
  24. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/spritesheet-2x.png
  25. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/spritesheet.png
  26. 156 0
      nicegui/elements/lib/leaflet/leaflet-draw/images/spritesheet.svg
  27. 10 0
      nicegui/elements/lib/leaflet/leaflet-draw/leaflet.draw.css
  28. 7 0
      nicegui/elements/lib/leaflet/leaflet-draw/leaflet.draw.js
  29. BIN
      nicegui/elements/lib/leaflet/leaflet/images/layers-2x.png
  30. BIN
      nicegui/elements/lib/leaflet/leaflet/images/layers.png
  31. BIN
      nicegui/elements/lib/leaflet/leaflet/images/marker-icon-2x.png
  32. BIN
      nicegui/elements/lib/leaflet/leaflet/images/marker-icon.png
  33. BIN
      nicegui/elements/lib/leaflet/leaflet/images/marker-shadow.png
  34. 661 0
      nicegui/elements/lib/leaflet/leaflet/leaflet.css
  35. 4 0
      nicegui/elements/lib/leaflet/leaflet/leaflet.js
  36. 0 0
      nicegui/elements/lib/leaflet/leaflet/leaflet.js.map
  37. 1 21
      nicegui/elements/markdown.py
  38. 11 0
      nicegui/elements/notification.js
  39. 163 0
      nicegui/elements/notification.py
  40. 4 2
      nicegui/elements/pyplot.py
  41. 0 5
      nicegui/elements/scene.py
  42. 2 1
      nicegui/elements/select.js
  43. 38 7
      nicegui/elements/table.py
  44. 5 3
      nicegui/elements/tooltip.py
  45. 1 2
      nicegui/events.py
  46. 23 0
      nicegui/functions/on.py
  47. 14 0
      nicegui/functions/page_title.py
  48. 5 0
      nicegui/helpers.py
  49. 12 1
      nicegui/nicegui.py
  50. 0 0
      nicegui/py.typed
  51. 128 19
      nicegui/static/nicegui.css
  52. 27 0
      nicegui/static/utils/resources.js
  53. 6 1
      nicegui/storage.py
  54. 10 9
      nicegui/templates/index.html
  55. 8 0
      nicegui/ui.py
  56. 19 0
      npm.json
  57. 6 0
      npm.py
  58. 2 2
      pyproject.toml
  59. 2 0
      tests/test_endpoint_docs.py
  60. 39 0
      tests/test_leaflet.py
  61. 24 0
      tests/test_notification.py
  62. 11 0
      tests/test_number.py
  63. 21 0
      tests/test_page_title.py
  64. 10 4
      tests/test_select.py
  65. 13 3
      tests/test_table.py
  66. 18 16
      website/documentation/content/generic_events_documentation.py
  67. 124 0
      website/documentation/content/leaflet_documentation.py
  68. 22 0
      website/documentation/content/notification_documentation.py
  69. 8 0
      website/documentation/content/page_title_documentation.py
  70. 4 3
      website/documentation/content/section_data_elements.py
  71. 5 16
      website/documentation/content/section_page_layout.py
  72. 2 1
      website/documentation/content/section_pages_routing.py
  73. 28 0
      website/documentation/content/section_styling_appearance.py
  74. 12 0
      website/documentation/content/table_documentation.py
  75. 37 0
      website/documentation/content/tooltip_documentation.py
  76. 25 9
      website/documentation/reference.py
  77. 1 3
      website/documentation/rendering.py
  78. 2 2
      website/documentation/windows.py
  79. 0 2
      website/examples.py
  80. 1 1
      website/static/style.css
  81. 1 2
      website/style.py

+ 1 - 1
.vscode/settings.json

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

+ 3 - 3
CITATION.cff

@@ -8,7 +8,7 @@ authors:
   given-names: Rodja
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
   orcid: https://orcid.org/0009-0009-4735-6227
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
-version: v1.4.4
-date-released: '2023-12-04'
+version: v1.4.6
+date-released: '2023-12-18'
 url: https://github.com/zauberzeug/nicegui
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.10256862
+doi: 10.5281/zenodo.10402969

+ 2 - 0
DEPENDENCIES.md

@@ -7,6 +7,8 @@
 - es-module-shims: 1.8.0 ([MIT](https://opensource.org/licenses/MIT))
 - es-module-shims: 1.8.0 ([MIT](https://opensource.org/licenses/MIT))
 - aggrid: 30.2.0 ([MIT](https://opensource.org/licenses/MIT))
 - aggrid: 30.2.0 ([MIT](https://opensource.org/licenses/MIT))
 - echarts: 5.4.3 ([Apache-2.0](https://opensource.org/licenses/Apache-2.0))
 - echarts: 5.4.3 ([Apache-2.0](https://opensource.org/licenses/Apache-2.0))
+- leaflet: 1.9.4 ([BSD-2-Clause](https://opensource.org/licenses/BSD-2-Clause))
+- leaflet-draw: 1.0.4 ([MIT](https://opensource.org/licenses/MIT))
 - mermaid: 10.5.1 ([MIT](https://opensource.org/licenses/MIT))
 - mermaid: 10.5.1 ([MIT](https://opensource.org/licenses/MIT))
 - nipplejs: 0.10.1 ([MIT](https://opensource.org/licenses/MIT))
 - nipplejs: 0.10.1 ([MIT](https://opensource.org/licenses/MIT))
 - plotly: 2.27.0 ([MIT](https://opensource.org/licenses/MIT))
 - plotly: 2.27.0 ([MIT](https://opensource.org/licenses/MIT))

+ 0 - 20
examples/map/leaflet.js

@@ -1,20 +0,0 @@
-export default {
-  template: "<div></div>",
-  mounted() {
-    this.map = L.map(this.$el);
-    L.tileLayer("http://{s}.tile.osm.org/{z}/{x}/{y}.png", {
-      attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>',
-    }).addTo(this.map);
-  },
-  methods: {
-    set_location(latitude, longitude) {
-      this.target = L.latLng(latitude, longitude);
-      this.map.setView(this.target, 9);
-      if (this.marker) {
-        this.map.removeLayer(this.marker);
-      }
-      this.marker = L.marker(this.target);
-      this.marker.addTo(this.map);
-    },
-  },
-};

+ 0 - 14
examples/map/leaflet.py

@@ -1,14 +0,0 @@
-from typing import Tuple
-
-from nicegui import ui
-
-
-class leaflet(ui.element, component='leaflet.js'):
-
-    def __init__(self) -> None:
-        super().__init__()
-        ui.add_head_html('<link href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css" rel="stylesheet"/>')
-        ui.add_head_html('<script src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js"></script>')
-
-    def set_location(self, location: Tuple[float, float]) -> None:
-        self.run_method('set_location', location[0], location[1])

+ 0 - 22
examples/map/main.py

@@ -1,22 +0,0 @@
-#!/usr/bin/env python3
-from leaflet import leaflet  # this module wraps the JavaScript lib leafletjs.com into an easy-to-use NiceGUI element
-
-from nicegui import Client, ui
-
-locations = {
-    (52.5200, 13.4049): 'Berlin',
-    (40.7306, -74.0060): 'New York',
-    (39.9042, 116.4074): 'Beijing',
-    (35.6895, 139.6917): 'Tokyo',
-}
-
-
-@ui.page('/')
-async def main_page(client: Client):
-    map = leaflet().classes('w-full h-96')
-    selection = ui.select(locations, on_change=lambda e: map.set_location(e.value)).classes('w-40')
-    await client.connected()  # wait for websocket connection
-    selection.set_value(next(iter(locations)))  # trigger map.set_location with first location in selection
-
-
-ui.run()

+ 1 - 1
fetch_milestone.py

@@ -13,7 +13,7 @@ parser.add_argument('milestone_title', help='Title of the milestone to fetch.')
 args = parser.parse_args()
 args = parser.parse_args()
 milestone_title: str = args.milestone_title
 milestone_title: str = args.milestone_title
 
 
-milestones = requests.get(f'{BASE_URL}/milestones', timeout=5).json()
+milestones = requests.get(f'{BASE_URL}/milestones?state=all&per_page=100', timeout=5).json()
 matching_milestones = [milestone for milestone in milestones if milestone['title'] == milestone_title]
 matching_milestones = [milestone for milestone in milestones if milestone['title'] == milestone_title]
 if not matching_milestones:
 if not matching_milestones:
     print(f'Milestone "{milestone_title}" not found!')
     print(f'Milestone "{milestone_title}" not found!')

+ 6 - 3
nicegui/client.py

@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterable, Iter
 from fastapi import Request
 from fastapi import Request
 from fastapi.responses import Response
 from fastapi.responses import Response
 from fastapi.templating import Jinja2Templates
 from fastapi.templating import Jinja2Templates
+from typing_extensions import Self
 
 
 from . import background_tasks, binding, core, helpers, json, outbox
 from . import background_tasks, binding, core, helpers, json, outbox
 from .awaitable_response import AwaitableResponse
 from .awaitable_response import AwaitableResponse
@@ -63,6 +64,8 @@ class Client:
 
 
         self.waiting_javascript_commands: Dict[str, Any] = {}
         self.waiting_javascript_commands: Dict[str, Any] = {}
 
 
+        self.title: Optional[str] = None
+
         self._head_html = ''
         self._head_html = ''
         self._body_html = ''
         self._body_html = ''
 
 
@@ -98,11 +101,11 @@ class Client:
         """Return the HTML code to be inserted in the <body> of the page template."""
         """Return the HTML code to be inserted in the <body> of the page template."""
         return self.shared_body_html + self._body_html
         return self.shared_body_html + self._body_html
 
 
-    def __enter__(self):
+    def __enter__(self) -> Self:
         self.content.__enter__()
         self.content.__enter__()
         return self
         return self
 
 
-    def __exit__(self, *_):
+    def __exit__(self, *_) -> None:
         self.content.__exit__()
         self.content.__exit__()
 
 
     def build_response(self, request: Request, status_code: int = 200) -> Response:
     def build_response(self, request: Request, status_code: int = 200) -> Response:
@@ -127,7 +130,7 @@ class Client:
             'imports': json.dumps(imports),
             'imports': json.dumps(imports),
             'js_imports': '\n'.join(js_imports),
             'js_imports': '\n'.join(js_imports),
             'quasar_config': json.dumps(core.app.config.quasar_config),
             'quasar_config': json.dumps(core.app.config.quasar_config),
-            'title': self.page.resolve_title(),
+            'title': self.page.resolve_title() if self.title is None else self.title,
             'viewport': self.page.resolve_viewport(),
             'viewport': self.page.resolve_viewport(),
             'favicon_url': get_favicon_url(self.page, prefix),
             'favicon_url': get_favicon_url(self.page, prefix),
             'dark': str(self.page.resolve_dark()),
             'dark': str(self.page.resolve_dark()),

+ 21 - 1
nicegui/dependencies.py

@@ -38,6 +38,12 @@ class JsComponent(Component):
     pass
     pass
 
 
 
 
+@dataclass(**KWONLY_SLOTS)
+class Resource:
+    key: str
+    path: Path
+
+
 @dataclass(**KWONLY_SLOTS)
 @dataclass(**KWONLY_SLOTS)
 class Library:
 class Library:
     key: str
     key: str
@@ -49,6 +55,7 @@ class Library:
 vue_components: Dict[str, VueComponent] = {}
 vue_components: Dict[str, VueComponent] = {}
 js_components: Dict[str, JsComponent] = {}
 js_components: Dict[str, JsComponent] = {}
 libraries: Dict[str, Library] = {}
 libraries: Dict[str, Library] = {}
+resources: Dict[str, Resource] = {}
 
 
 
 
 def register_vue_component(path: Path) -> Component:
 def register_vue_component(path: Path) -> Component:
@@ -89,17 +96,30 @@ def register_library(path: Path, *, expose: bool = False) -> Library:
     raise ValueError(f'Unsupported library type "{path.suffix}"')
     raise ValueError(f'Unsupported library type "{path.suffix}"')
 
 
 
 
+def register_resource(path: Path) -> Resource:
+    """Register a resource."""
+    key = compute_key(path)
+    if key in resources and resources[key].path == path:
+        return resources[key]
+    assert key not in resources, f'Duplicate resource {key}'
+    resources[key] = Resource(key=key, path=path)
+    return resources[key]
+
+
 def compute_key(path: Path) -> str:
 def compute_key(path: Path) -> str:
     """Compute a key for a given path using a hash function.
     """Compute a key for a given path using a hash function.
 
 
     If the path is relative to the NiceGUI base directory, the key is computed from the relative path.
     If the path is relative to the NiceGUI base directory, the key is computed from the relative path.
     """
     """
     nicegui_base = Path(__file__).parent
     nicegui_base = Path(__file__).parent
+    is_file = path.is_file()
     try:
     try:
         path = path.relative_to(nicegui_base)
         path = path.relative_to(nicegui_base)
     except ValueError:
     except ValueError:
         pass
         pass
-    return f'{hash_file_path(path.parent)}/{path.name}'
+    if is_file:
+        return f'{hash_file_path(path.parent)}/{path.name}'
+    return f'{hash_file_path(path)}'
 
 
 
 
 def _get_name(path: Path) -> str:
 def _get_name(path: Path) -> str:

+ 15 - 4
nicegui/element.py

@@ -9,13 +9,14 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional,
 
 
 from typing_extensions import Self
 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 .awaitable_response import AwaitableResponse, NullResponse
-from .dependencies import Component, Library, register_library, register_vue_component
+from .dependencies import Component, Library, register_library, register_resource, register_vue_component
 from .elements.mixins.visibility import Visibility
 from .elements.mixins.visibility import Visibility
 from .event_listener import EventListener
 from .event_listener import EventListener
 from .slot import Slot
 from .slot import Slot
 from .tailwind import Tailwind
 from .tailwind import Tailwind
+from .version import __version__
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .client import Client
     from .client import Client
@@ -138,6 +139,14 @@ class Element(Visibility):
         cls._default_classes = copy(cls._default_classes)
         cls._default_classes = copy(cls._default_classes)
         cls._default_style = copy(cls._default_style)
         cls._default_style = copy(cls._default_style)
 
 
+    def add_resource(self, path: Union[str, Path]) -> None:
+        """Add a resource to the element.
+
+        :param path: path to the resource (e.g. folder with CSS and JavaScript files)
+        """
+        resource = register_resource(Path(path))
+        self._props['resource_path'] = f'/_nicegui/{__version__}/resources/{resource.key}'
+
     def add_slot(self, name: str, template: Optional[str] = None) -> Slot:
     def add_slot(self, name: str, template: Optional[str] = None) -> Slot:
         """Add a slot to the element.
         """Add a slot to the element.
 
 
@@ -152,7 +161,7 @@ class Element(Visibility):
         self.default_slot.__enter__()
         self.default_slot.__enter__()
         return self
         return self
 
 
-    def __exit__(self, *_):
+    def __exit__(self, *_) -> None:
         self.default_slot.__exit__(*_)
         self.default_slot.__exit__(*_)
 
 
     def __iter__(self) -> Iterator[Element]:
     def __iter__(self) -> Iterator[Element]:
@@ -388,7 +397,7 @@ class Element(Visibility):
         if handler:
         if handler:
             listener = EventListener(
             listener = EventListener(
                 element_id=self.id,
                 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
                 args=[args] if args and isinstance(args[0], str) else args,  # type: ignore
                 handler=handler,
                 handler=handler,
                 throttle=throttle,
                 throttle=throttle,
@@ -408,6 +417,8 @@ class Element(Visibility):
 
 
     def update(self) -> None:
     def update(self) -> None:
         """Update the element on the client side."""
         """Update the element on the client side."""
+        if self.is_deleted:
+            return
         outbox.enqueue_update(self)
         outbox.enqueue_update(self)
 
 
     def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
     def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:

+ 7 - 6
nicegui/elements/aggrid.py

@@ -1,7 +1,7 @@
-from __future__ import annotations
-
 from typing import Dict, List, Optional, cast
 from typing import Dict, List, Optional, cast
 
 
+from typing_extensions import Self
+
 from .. import optional_features
 from .. import optional_features
 from ..awaitable_response import AwaitableResponse
 from ..awaitable_response import AwaitableResponse
 from ..element import Element
 from ..element import Element
@@ -39,11 +39,12 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
         self._classes.append('nicegui-aggrid')
         self._classes.append('nicegui-aggrid')
         self._classes.append(f'ag-theme-{theme}')
         self._classes.append(f'ag-theme-{theme}')
 
 
-    @staticmethod
-    def from_pandas(df: pd.DataFrame, *,
+    @classmethod
+    def from_pandas(cls,
+                    df: pd.DataFrame, *,
                     theme: str = 'balham',
                     theme: str = 'balham',
                     auto_size_columns: bool = True,
                     auto_size_columns: bool = True,
-                    options: Dict = {}) -> AgGrid:
+                    options: Dict = {}) -> Self:
         """Create an AG Grid from a Pandas DataFrame.
         """Create an AG Grid from a Pandas DataFrame.
 
 
         Note:
         Note:
@@ -69,7 +70,7 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
             df[complex_cols] = df[complex_cols].astype(str)
             df[complex_cols] = df[complex_cols].astype(str)
             df[period_cols] = df[period_cols].astype(str)
             df[period_cols] = df[period_cols].astype(str)
 
 
-        return AgGrid({
+        return cls({
             'columnDefs': [{'field': str(col)} for col in df.columns],
             'columnDefs': [{'field': str(col)} for col in df.columns],
             'rowData': df.to_dict('records'),
             'rowData': df.to_dict('records'),
             'suppressDotNotation': True,
             'suppressDotNotation': True,

+ 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
         :param on_change: callback to be invoked when the value changes
         """
         """
         super().__init__(tag='q-editor', value=value, on_value_change=on_change)
         super().__init__(tag='q-editor', value=value, on_value_change=on_change)
+        self._classes.append('nicegui-editor')
         if placeholder is not None:
         if placeholder is not None:
             self._props['placeholder'] = placeholder
             self._props['placeholder'] = placeholder

+ 3 - 3
nicegui/elements/input.js

@@ -18,7 +18,7 @@ export default {
   `,
   `,
   props: {
   props: {
     id: String,
     id: String,
-    autocomplete: Array,
+    _autocomplete: Array,
     value: String,
     value: String,
   },
   },
   data() {
   data() {
@@ -41,14 +41,14 @@ export default {
   computed: {
   computed: {
     shadowText() {
     shadowText() {
       if (!this.inputValue) return "";
       if (!this.inputValue) return "";
-      const matchingOption = this.autocomplete.find((option) =>
+      const matchingOption = this._autocomplete.find((option) =>
         option.toLowerCase().startsWith(this.inputValue.toLowerCase())
         option.toLowerCase().startsWith(this.inputValue.toLowerCase())
       );
       );
       return matchingOption ? matchingOption.slice(this.inputValue.length) : "";
       return matchingOption ? matchingOption.slice(this.inputValue.length) : "";
     },
     },
     withDatalist() {
     withDatalist() {
       const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
       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: {
   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"}')
                     self.props(f'type={"text" if is_hidden else "password"}')
                 icon = Icon('visibility_off').classes('cursor-pointer').on('click', toggle_type)
                 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:
     def set_autocomplete(self, autocomplete: Optional[List[str]]) -> None:
         """Set the autocomplete list."""
         """Set the autocomplete list."""
-        self._props['autocomplete'] = autocomplete
+        self._props['_autocomplete'] = autocomplete
         self.update()
         self.update()
 
 
     def _handle_value_change(self, value: Any) -> None:
     def _handle_value_change(self, value: Any) -> None:

+ 126 - 0
nicegui/elements/leaflet.js

@@ -0,0 +1,126 @@
+import { loadResource } from "../../static/utils/resources.js";
+
+export default {
+  template: "<div></div>",
+  props: {
+    center: Array,
+    zoom: Number,
+    options: Object,
+    draw_control: Object,
+    resource_path: String,
+  },
+  async mounted() {
+    await this.$nextTick(); // NOTE: wait for window.path_prefix to be set
+    await Promise.all([
+      loadResource(window.path_prefix + `${this.resource_path}/leaflet/leaflet.css`),
+      loadResource(window.path_prefix + `${this.resource_path}/leaflet/leaflet.js`),
+    ]);
+    if (this.draw_control) {
+      await Promise.all([
+        loadResource(window.path_prefix + `${this.resource_path}/leaflet-draw/leaflet.draw.css`),
+        loadResource(window.path_prefix + `${this.resource_path}/leaflet-draw/leaflet.draw.js`),
+      ]);
+    }
+    this.map = L.map(this.$el, {
+      ...this.options,
+      center: this.center,
+      zoom: this.zoom,
+    });
+    for (const type of [
+      "baselayerchange",
+      "overlayadd",
+      "overlayremove",
+      "layeradd",
+      "layerremove",
+      "zoomlevelschange",
+      "resize",
+      "unload",
+      "viewreset",
+      "load",
+      "zoomstart",
+      "movestart",
+      "zoom",
+      "move",
+      "zoomend",
+      "moveend",
+      "popupopen",
+      "popupclose",
+      "autopanstart",
+      "tooltipopen",
+      "tooltipclose",
+      "locationerror",
+      "locationfound",
+      "click",
+      "dblclick",
+      "mousedown",
+      "mouseup",
+      "mouseover",
+      "mouseout",
+      "mousemove",
+      "contextmenu",
+      "keypress",
+      "keydown",
+      "keyup",
+      "preclick",
+      "zoomanim",
+    ]) {
+      this.map.on(type, (e) => {
+        this.$emit(`map-${type}`, {
+          ...e,
+          originalEvent: undefined,
+          target: undefined,
+          sourceTarget: undefined,
+          center: [e.target.getCenter().lat, e.target.getCenter().lng],
+          zoom: e.target.getZoom(),
+        });
+      });
+    }
+    if (this.draw_control) {
+      for (const key in L.Draw.Event) {
+        const type = L.Draw.Event[key];
+        this.map.on(type, (e) => {
+          this.$emit(type, {
+            ...e,
+            layer: e.layer ? { ...e.layer, editing: undefined, _events: undefined } : undefined,
+            target: undefined,
+            sourceTarget: undefined,
+          });
+        });
+      }
+      const drawnItems = new L.FeatureGroup();
+      this.map.addLayer(drawnItems);
+      const drawControl = new L.Control.Draw({
+        edit: { featureGroup: drawnItems },
+        ...this.draw_control,
+      });
+      this.map.addControl(drawControl);
+    }
+    const connectInterval = setInterval(async () => {
+      if (window.socket.id === undefined) return;
+      this.$emit("init", { socket_id: window.socket.id });
+      clearInterval(connectInterval);
+    }, 100);
+  },
+  methods: {
+    setCenter(center) {
+      this.map.panTo(center);
+    },
+    setZoom(zoom) {
+      this.map.setZoom(zoom);
+    },
+    add_layer(layer, id) {
+      const l = L[layer.type](...layer.args);
+      l.id = id;
+      l.addTo(this.map);
+    },
+    remove_layer(id) {
+      this.map.eachLayer((layer) => layer.id === id && this.map.removeLayer(layer));
+    },
+    clear_layers() {
+      this.map.eachLayer((layer) => this.map.removeLayer(layer));
+    },
+    run_map_method(name, ...args) {
+      return this.map[name](...args);
+    },
+  },
+};

+ 131 - 0
nicegui/elements/leaflet.py

@@ -0,0 +1,131 @@
+from pathlib import Path
+from typing import Any, Dict, List, Tuple, Union, cast
+
+from typing_extensions import Self
+
+from .. import binding
+from ..awaitable_response import AwaitableResponse, NullResponse
+from ..element import Element
+from ..events import GenericEventArguments
+from .leaflet_layer import Layer
+
+
+class Leaflet(Element, component='leaflet.js'):
+    # pylint: disable=import-outside-toplevel
+    from .leaflet_layers import GenericLayer as generic_layer
+    from .leaflet_layers import Marker as marker
+    from .leaflet_layers import TileLayer as tile_layer
+
+    center = binding.BindableProperty(lambda sender, value: cast(Leaflet, sender).set_center(value))
+    zoom = binding.BindableProperty(lambda sender, value: cast(Leaflet, sender).set_zoom(value))
+
+    def __init__(self,
+                 center: Tuple[float, float] = (0.0, 0.0),
+                 zoom: int = 13,
+                 *,
+                 options: Dict = {},
+                 draw_control: Union[bool, Dict] = False,
+                 ) -> None:
+        """Leaflet map
+
+        This element is a wrapper around the `Leaflet <https://leafletjs.com/>`_ JavaScript library.
+
+        :param center: initial center location of the map (latitude/longitude, default: (0.0, 0.0))
+        :param zoom: initial zoom level of the map (default: 13)
+        :param draw_control: whether to show the draw toolbar (default: False)
+        :param options: additional options passed to the Leaflet map (default: {})
+        """
+        super().__init__()
+        self.add_resource(Path(__file__).parent / 'lib' / 'leaflet')
+        self._classes.append('nicegui-leaflet')
+
+        self.layers: List[Layer] = []
+        self.is_initialized = False
+
+        self.center = center
+        self.zoom = zoom
+        self._props['center'] = center
+        self._props['zoom'] = zoom
+        self._props['options'] = options
+        self._props['draw_control'] = draw_control
+
+        self.on('init', self._handle_init)
+        self.on('map-moveend', self._handle_moveend)
+        self.on('map-zoomend', self._handle_zoomend)
+
+        self.tile_layer(
+            url_template=r'https://{s}.tile.osm.org/{z}/{x}/{y}.png',
+            options={'attribution': '&copy; <a href="https://openstreetmap.org">OpenStreetMap</a> contributors'},
+        )
+
+    def __enter__(self) -> Self:
+        Layer.current_leaflet = self
+        return super().__enter__()
+
+    def __getattribute__(self, name: str) -> Any:
+        attribute = super().__getattribute__(name)
+        if isinstance(attribute, type) and issubclass(attribute, Layer):
+            Layer.current_leaflet = self
+        return attribute
+
+    def _handle_init(self, e: GenericEventArguments) -> None:
+        self.is_initialized = True
+        with self.client.individual_target(e.args['socket_id']):
+            for layer in self.layers:
+                self.run_method('add_layer', layer.to_dict(), layer.id)
+
+    def _handle_moveend(self, e: GenericEventArguments) -> None:
+        self.center = e.args['center']
+
+    def _handle_zoomend(self, e: GenericEventArguments) -> None:
+        self.zoom = e.args['zoom']
+
+    def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
+        if not self.is_initialized:
+            return NullResponse()
+        return super().run_method(name, *args, timeout=timeout, check_interval=check_interval)
+
+    def set_center(self, center: Tuple[float, float]) -> None:
+        """Set the center location of the map."""
+        if self._props['center'] == center:
+            return
+        self._props['center'] = center
+        self.run_method('setCenter', center)
+
+    def set_zoom(self, zoom: int) -> None:
+        """Set the zoom level of the map."""
+        if self._props['zoom'] == zoom:
+            return
+        self._props['zoom'] = zoom
+        self.run_method('setZoom', zoom)
+
+    def remove_layer(self, layer: Layer) -> None:
+        """Remove a layer from the map."""
+        self.layers.remove(layer)
+        self.run_method('remove_layer', layer.id)
+
+    def clear_layers(self) -> None:
+        """Remove all layers from the map."""
+        self.layers.clear()
+        self.run_method('clear_layers')
+
+    def run_map_method(self, name: str, *args, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
+        """Run a method of the Leaflet map instance.
+
+        Refer to the `Leaflet documentation <https://leafletjs.com/reference.html#map-methods-for-modifying-map-state>`_ for a list of methods.
+
+        If the function is awaited, the result of the method call is returned.
+        Otherwise, the method is executed without waiting for a response.
+
+        :param name: name of the method
+        :param args: arguments to pass to the method
+        :param timeout: timeout in seconds (default: 1 second)
+        :param check_interval: interval in seconds to check for a response (default: 0.01 seconds)
+
+        :return: AwaitableResponse that can be awaited to get the result of the method call
+        """
+        return self.run_method('run_map_method', name, *args, timeout=timeout, check_interval=check_interval)
+
+    def _handle_delete(self) -> None:
+        binding.remove(self.layers, Layer)
+        super().delete()

+ 29 - 0
nicegui/elements/leaflet_layer.py

@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+import uuid
+from abc import abstractmethod
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, ClassVar, Optional
+
+from ..dataclasses import KWONLY_SLOTS
+
+if TYPE_CHECKING:
+    from .leaflet import Leaflet
+
+
+@dataclass(**KWONLY_SLOTS)
+class Layer:
+    current_leaflet: ClassVar[Optional[Leaflet]] = None
+    leaflet: Leaflet = field(init=False)
+    id: str = field(init=False)
+
+    def __post_init__(self) -> None:
+        self.id = str(uuid.uuid4())
+        assert self.current_leaflet is not None
+        self.leaflet = self.current_leaflet
+        self.leaflet.layers.append(self)
+        self.leaflet.run_method('add_layer', self.to_dict(), self.id)
+
+    @abstractmethod
+    def to_dict(self) -> dict:
+        """Return a dictionary representation of the layer."""

+ 48 - 0
nicegui/elements/leaflet_layers.py

@@ -0,0 +1,48 @@
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Tuple
+
+from typing_extensions import Self
+
+from ..dataclasses import KWONLY_SLOTS
+from .leaflet_layer import Layer
+
+
+@dataclass(**KWONLY_SLOTS)
+class GenericLayer(Layer):
+    name: str
+    args: List[Any] = field(default_factory=list)
+
+    def to_dict(self) -> Dict:
+        return {
+            'type': self.name,
+            'args': self.args,
+        }
+
+
+@dataclass(**KWONLY_SLOTS)
+class TileLayer(Layer):
+    url_template: str
+    options: Dict = field(default_factory=dict)
+
+    def to_dict(self) -> Dict:
+        return {
+            'type': 'tileLayer',
+            'args': [self.url_template, self.options],
+        }
+
+
+@dataclass(**KWONLY_SLOTS)
+class Marker(Layer):
+    latlng: Tuple[float, float]
+    options: Dict = field(default_factory=dict)
+
+    def to_dict(self) -> Dict:
+        return {
+            'type': 'marker',
+            'args': [{'lat': self.latlng[0], 'lng': self.latlng[1]}, self.options],
+        }
+
+    def draggable(self, value: bool = True) -> Self:
+        """Make the marker draggable."""
+        self.options['draggable'] = value
+        return self

BIN
nicegui/elements/lib/leaflet/leaflet-draw/images/layers-2x.png


BIN
nicegui/elements/lib/leaflet/leaflet-draw/images/layers.png


BIN
nicegui/elements/lib/leaflet/leaflet-draw/images/marker-icon-2x.png


BIN
nicegui/elements/lib/leaflet/leaflet-draw/images/marker-icon.png


BIN
nicegui/elements/lib/leaflet/leaflet-draw/images/marker-shadow.png


BIN
nicegui/elements/lib/leaflet/leaflet-draw/images/spritesheet-2x.png


BIN
nicegui/elements/lib/leaflet/leaflet-draw/images/spritesheet.png


+ 156 - 0
nicegui/elements/lib/leaflet/leaflet-draw/images/spritesheet.svg

@@ -0,0 +1,156 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   viewBox="0 0 600 60"
+   height="60"
+   width="600"
+   id="svg4225"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="spritesheet.svg"
+   inkscape:export-filename="/home/fpuga/development/upstream/icarto.Leaflet.draw/src/images/spritesheet-2x.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <metadata
+     id="metadata4258">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs
+     id="defs4256" />
+  <sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="1920"
+     inkscape:window-height="1056"
+     id="namedview4254"
+     showgrid="false"
+     inkscape:zoom="1.3101852"
+     inkscape:cx="237.56928"
+     inkscape:cy="7.2419621"
+     inkscape:window-x="1920"
+     inkscape:window-y="24"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg4225" />
+  <g
+     id="enabled"
+     style="fill:#464646;fill-opacity:1">
+    <g
+       id="polyline"
+       style="fill:#464646;fill-opacity:1">
+      <path
+         d="m 18,36 0,6 6,0 0,-6 -6,0 z m 4,4 -2,0 0,-2 2,0 0,2 z"
+         id="path4229"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+      <path
+         d="m 36,18 0,6 6,0 0,-6 -6,0 z m 4,4 -2,0 0,-2 2,0 0,2 z"
+         id="path4231"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+      <path
+         d="m 23.142,39.145 -2.285,-2.29 16,-15.998 2.285,2.285 z"
+         id="path4233"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+    </g>
+    <path
+       id="polygon"
+       d="M 100,24.565 97.904,39.395 83.07,42 76,28.773 86.463,18 Z"
+       inkscape:connector-curvature="0"
+       style="fill:#464646;fill-opacity:1" />
+    <path
+       id="rectangle"
+       d="m 140,20 20,0 0,20 -20,0 z"
+       inkscape:connector-curvature="0"
+       style="fill:#464646;fill-opacity:1" />
+    <path
+       id="circle"
+       d="m 221,30 c 0,6.078 -4.926,11 -11,11 -6.074,0 -11,-4.922 -11,-11 0,-6.074 4.926,-11 11,-11 6.074,0 11,4.926 11,11 z"
+       inkscape:connector-curvature="0"
+       style="fill:#464646;fill-opacity:1" />
+    <path
+       id="marker"
+       d="m 270,19 c -4.971,0 -9,4.029 -9,9 0,4.971 5.001,12 9,14 4.001,-2 9,-9.029 9,-14 0,-4.971 -4.029,-9 -9,-9 z m 0,12.5 c -2.484,0 -4.5,-2.014 -4.5,-4.5 0,-2.484 2.016,-4.5 4.5,-4.5 2.485,0 4.5,2.016 4.5,4.5 0,2.486 -2.015,4.5 -4.5,4.5 z"
+       inkscape:connector-curvature="0"
+       style="fill:#464646;fill-opacity:1" />
+    <g
+       id="edit"
+       style="fill:#464646;fill-opacity:1">
+      <path
+         d="m 337,30.156 0,0.407 0,5.604 c 0,1.658 -1.344,3 -3,3 l -10,0 c -1.655,0 -3,-1.342 -3,-3 l 0,-10 c 0,-1.657 1.345,-3 3,-3 l 6.345,0 3.19,-3.17 -9.535,0 c -3.313,0 -6,2.687 -6,6 l 0,10 c 0,3.313 2.687,6 6,6 l 10,0 c 3.314,0 6,-2.687 6,-6 l 0,-8.809 -3,2.968"
+         id="path4240"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+      <path
+         d="m 338.72,24.637 -8.892,8.892 -2.828,0 0,-2.829 8.89,-8.89 z"
+         id="path4242"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+      <path
+         d="m 338.697,17.826 4,0 0,4 -4,0 z"
+         transform="matrix(-0.70698336,-0.70723018,0.70723018,-0.70698336,567.55917,274.78273)"
+         id="path4244"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+    </g>
+    <g
+       id="remove"
+       style="fill:#464646;fill-opacity:1">
+      <path
+         d="m 381,42 18,0 0,-18 -18,0 0,18 z m 14,-16 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z"
+         id="path4247"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+      <path
+         d="m 395,20 0,-4 -10,0 0,4 -6,0 0,2 22,0 0,-2 -6,0 z m -2,0 -6,0 0,-2 6,0 0,2 z"
+         id="path4249"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+    </g>
+  </g>
+  <g
+     id="disabled"
+     transform="translate(120,0)"
+     style="fill:#bbbbbb">
+    <use
+       xlink:href="#edit"
+       id="edit-disabled"
+       x="0"
+       y="0"
+       width="100%"
+       height="100%" />
+    <use
+       xlink:href="#remove"
+       id="remove-disabled"
+       x="0"
+       y="0"
+       width="100%"
+       height="100%" />
+  </g>
+  <path
+     style="fill:none;stroke:#464646;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+     id="circle-3"
+     d="m 581.65725,30 c 0,6.078 -4.926,11 -11,11 -6.074,0 -11,-4.922 -11,-11 0,-6.074 4.926,-11 11,-11 6.074,0 11,4.926 11,11 z"
+     inkscape:connector-curvature="0" />
+</svg>

+ 10 - 0
nicegui/elements/lib/leaflet/leaflet-draw/leaflet.draw.css

@@ -0,0 +1,10 @@
+.leaflet-draw-section{position:relative}.leaflet-draw-toolbar{margin-top:12px}.leaflet-draw-toolbar-top{margin-top:0}.leaflet-draw-toolbar-notop a:first-child{border-top-right-radius:0}.leaflet-draw-toolbar-nobottom a:last-child{border-bottom-right-radius:0}.leaflet-draw-toolbar a{background-image:url('images/spritesheet.png');background-image:linear-gradient(transparent,transparent),url('images/spritesheet.svg');background-repeat:no-repeat;background-size:300px 30px;background-clip:padding-box}.leaflet-retina .leaflet-draw-toolbar a{background-image:url('images/spritesheet-2x.png');background-image:linear-gradient(transparent,transparent),url('images/spritesheet.svg')}
+.leaflet-draw a{display:block;text-align:center;text-decoration:none}.leaflet-draw a .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.leaflet-draw-actions{display:none;list-style:none;margin:0;padding:0;position:absolute;left:26px;top:0;white-space:nowrap}.leaflet-touch .leaflet-draw-actions{left:32px}.leaflet-right .leaflet-draw-actions{right:26px;left:auto}.leaflet-touch .leaflet-right .leaflet-draw-actions{right:32px;left:auto}.leaflet-draw-actions li{display:inline-block}
+.leaflet-draw-actions li:first-child a{border-left:0}.leaflet-draw-actions li:last-child a{-webkit-border-radius:0 4px 4px 0;border-radius:0 4px 4px 0}.leaflet-right .leaflet-draw-actions li:last-child a{-webkit-border-radius:0;border-radius:0}.leaflet-right .leaflet-draw-actions li:first-child a{-webkit-border-radius:4px 0 0 4px;border-radius:4px 0 0 4px}.leaflet-draw-actions a{background-color:#919187;border-left:1px solid #AAA;color:#FFF;font:11px/19px "Helvetica Neue",Arial,Helvetica,sans-serif;line-height:28px;text-decoration:none;padding-left:10px;padding-right:10px;height:28px}
+.leaflet-touch .leaflet-draw-actions a{font-size:12px;line-height:30px;height:30px}.leaflet-draw-actions-bottom{margin-top:0}.leaflet-draw-actions-top{margin-top:1px}.leaflet-draw-actions-top a,.leaflet-draw-actions-bottom a{height:27px;line-height:27px}.leaflet-draw-actions a:hover{background-color:#a0a098}.leaflet-draw-actions-top.leaflet-draw-actions-bottom a{height:26px;line-height:26px}.leaflet-draw-toolbar .leaflet-draw-draw-polyline{background-position:-2px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-polyline{background-position:0 -1px}
+.leaflet-draw-toolbar .leaflet-draw-draw-polygon{background-position:-31px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-polygon{background-position:-29px -1px}.leaflet-draw-toolbar .leaflet-draw-draw-rectangle{background-position:-62px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-rectangle{background-position:-60px -1px}.leaflet-draw-toolbar .leaflet-draw-draw-circle{background-position:-92px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-circle{background-position:-90px -1px}
+.leaflet-draw-toolbar .leaflet-draw-draw-marker{background-position:-122px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-marker{background-position:-120px -1px}.leaflet-draw-toolbar .leaflet-draw-draw-circlemarker{background-position:-273px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-circlemarker{background-position:-271px -1px}.leaflet-draw-toolbar .leaflet-draw-edit-edit{background-position:-152px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-edit{background-position:-150px -1px}
+.leaflet-draw-toolbar .leaflet-draw-edit-remove{background-position:-182px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-remove{background-position:-180px -1px}.leaflet-draw-toolbar .leaflet-draw-edit-edit.leaflet-disabled{background-position:-212px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-edit.leaflet-disabled{background-position:-210px -1px}.leaflet-draw-toolbar .leaflet-draw-edit-remove.leaflet-disabled{background-position:-242px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-remove.leaflet-disabled{background-position:-240px -2px}
+.leaflet-mouse-marker{background-color:#fff;cursor:crosshair}.leaflet-draw-tooltip{background:#363636;background:rgba(0,0,0,0.5);border:1px solid transparent;-webkit-border-radius:4px;border-radius:4px;color:#fff;font:12px/18px "Helvetica Neue",Arial,Helvetica,sans-serif;margin-left:20px;margin-top:-21px;padding:4px 8px;position:absolute;visibility:hidden;white-space:nowrap;z-index:6}.leaflet-draw-tooltip:before{border-right:6px solid black;border-right-color:rgba(0,0,0,0.5);border-top:6px solid transparent;border-bottom:6px solid transparent;content:"";position:absolute;top:7px;left:-7px}
+.leaflet-error-draw-tooltip{background-color:#f2dede;border:1px solid #e6b6bd;color:#b94a48}.leaflet-error-draw-tooltip:before{border-right-color:#e6b6bd}.leaflet-draw-tooltip-single{margin-top:-12px}.leaflet-draw-tooltip-subtext{color:#f8d5e4}.leaflet-draw-guide-dash{font-size:1%;opacity:.6;position:absolute;width:5px;height:5px}.leaflet-edit-marker-selected{background-color:rgba(254,87,161,0.1);border:4px dashed rgba(254,87,161,0.6);-webkit-border-radius:4px;border-radius:4px;box-sizing:content-box}
+.leaflet-edit-move{cursor:move}.leaflet-edit-resize{cursor:pointer}.leaflet-oldie .leaflet-draw-toolbar{border:1px solid #999}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 7 - 0
nicegui/elements/lib/leaflet/leaflet-draw/leaflet.draw.js


BIN
nicegui/elements/lib/leaflet/leaflet/images/layers-2x.png


BIN
nicegui/elements/lib/leaflet/leaflet/images/layers.png


BIN
nicegui/elements/lib/leaflet/leaflet/images/marker-icon-2x.png


BIN
nicegui/elements/lib/leaflet/leaflet/images/marker-icon.png


BIN
nicegui/elements/lib/leaflet/leaflet/images/marker-shadow.png


+ 661 - 0
nicegui/elements/lib/leaflet/leaflet/leaflet.css

@@ -0,0 +1,661 @@
+/* required styles */
+
+.leaflet-pane,
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow,
+.leaflet-tile-container,
+.leaflet-pane > svg,
+.leaflet-pane > canvas,
+.leaflet-zoom-box,
+.leaflet-image-layer,
+.leaflet-layer {
+	position: absolute;
+	left: 0;
+	top: 0;
+	}
+.leaflet-container {
+	overflow: hidden;
+	}
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+	-webkit-user-select: none;
+	   -moz-user-select: none;
+	        user-select: none;
+	  -webkit-user-drag: none;
+	}
+/* Prevents IE11 from highlighting tiles in blue */
+.leaflet-tile::selection {
+	background: transparent;
+}
+/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
+.leaflet-safari .leaflet-tile {
+	image-rendering: -webkit-optimize-contrast;
+	}
+/* hack that prevents hw layers "stretching" when loading new tiles */
+.leaflet-safari .leaflet-tile-container {
+	width: 1600px;
+	height: 1600px;
+	-webkit-transform-origin: 0 0;
+	}
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+	display: block;
+	}
+/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
+/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
+.leaflet-container .leaflet-overlay-pane svg {
+	max-width: none !important;
+	max-height: none !important;
+	}
+.leaflet-container .leaflet-marker-pane img,
+.leaflet-container .leaflet-shadow-pane img,
+.leaflet-container .leaflet-tile-pane img,
+.leaflet-container img.leaflet-image-layer,
+.leaflet-container .leaflet-tile {
+	max-width: none !important;
+	max-height: none !important;
+	width: auto;
+	padding: 0;
+	}
+
+.leaflet-container img.leaflet-tile {
+	/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
+	mix-blend-mode: plus-lighter;
+}
+
+.leaflet-container.leaflet-touch-zoom {
+	-ms-touch-action: pan-x pan-y;
+	touch-action: pan-x pan-y;
+	}
+.leaflet-container.leaflet-touch-drag {
+	-ms-touch-action: pinch-zoom;
+	/* Fallback for FF which doesn't support pinch-zoom */
+	touch-action: none;
+	touch-action: pinch-zoom;
+}
+.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
+	-ms-touch-action: none;
+	touch-action: none;
+}
+.leaflet-container {
+	-webkit-tap-highlight-color: transparent;
+}
+.leaflet-container a {
+	-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
+}
+.leaflet-tile {
+	filter: inherit;
+	visibility: hidden;
+	}
+.leaflet-tile-loaded {
+	visibility: inherit;
+	}
+.leaflet-zoom-box {
+	width: 0;
+	height: 0;
+	-moz-box-sizing: border-box;
+	     box-sizing: border-box;
+	z-index: 800;
+	}
+/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
+.leaflet-overlay-pane svg {
+	-moz-user-select: none;
+	}
+
+.leaflet-pane         { z-index: 400; }
+
+.leaflet-tile-pane    { z-index: 200; }
+.leaflet-overlay-pane { z-index: 400; }
+.leaflet-shadow-pane  { z-index: 500; }
+.leaflet-marker-pane  { z-index: 600; }
+.leaflet-tooltip-pane   { z-index: 650; }
+.leaflet-popup-pane   { z-index: 700; }
+
+.leaflet-map-pane canvas { z-index: 100; }
+.leaflet-map-pane svg    { z-index: 200; }
+
+.leaflet-vml-shape {
+	width: 1px;
+	height: 1px;
+	}
+.lvml {
+	behavior: url(#default#VML);
+	display: inline-block;
+	position: absolute;
+	}
+
+
+/* control positioning */
+
+.leaflet-control {
+	position: relative;
+	z-index: 800;
+	pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
+	pointer-events: auto;
+	}
+.leaflet-top,
+.leaflet-bottom {
+	position: absolute;
+	z-index: 1000;
+	pointer-events: none;
+	}
+.leaflet-top {
+	top: 0;
+	}
+.leaflet-right {
+	right: 0;
+	}
+.leaflet-bottom {
+	bottom: 0;
+	}
+.leaflet-left {
+	left: 0;
+	}
+.leaflet-control {
+	float: left;
+	clear: both;
+	}
+.leaflet-right .leaflet-control {
+	float: right;
+	}
+.leaflet-top .leaflet-control {
+	margin-top: 10px;
+	}
+.leaflet-bottom .leaflet-control {
+	margin-bottom: 10px;
+	}
+.leaflet-left .leaflet-control {
+	margin-left: 10px;
+	}
+.leaflet-right .leaflet-control {
+	margin-right: 10px;
+	}
+
+
+/* zoom and fade animations */
+
+.leaflet-fade-anim .leaflet-popup {
+	opacity: 0;
+	-webkit-transition: opacity 0.2s linear;
+	   -moz-transition: opacity 0.2s linear;
+	        transition: opacity 0.2s linear;
+	}
+.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
+	opacity: 1;
+	}
+.leaflet-zoom-animated {
+	-webkit-transform-origin: 0 0;
+	    -ms-transform-origin: 0 0;
+	        transform-origin: 0 0;
+	}
+svg.leaflet-zoom-animated {
+	will-change: transform;
+}
+
+.leaflet-zoom-anim .leaflet-zoom-animated {
+	-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
+	   -moz-transition:    -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
+	        transition:         transform 0.25s cubic-bezier(0,0,0.25,1);
+	}
+.leaflet-zoom-anim .leaflet-tile,
+.leaflet-pan-anim .leaflet-tile {
+	-webkit-transition: none;
+	   -moz-transition: none;
+	        transition: none;
+	}
+
+.leaflet-zoom-anim .leaflet-zoom-hide {
+	visibility: hidden;
+	}
+
+
+/* cursors */
+
+.leaflet-interactive {
+	cursor: pointer;
+	}
+.leaflet-grab {
+	cursor: -webkit-grab;
+	cursor:    -moz-grab;
+	cursor:         grab;
+	}
+.leaflet-crosshair,
+.leaflet-crosshair .leaflet-interactive {
+	cursor: crosshair;
+	}
+.leaflet-popup-pane,
+.leaflet-control {
+	cursor: auto;
+	}
+.leaflet-dragging .leaflet-grab,
+.leaflet-dragging .leaflet-grab .leaflet-interactive,
+.leaflet-dragging .leaflet-marker-draggable {
+	cursor: move;
+	cursor: -webkit-grabbing;
+	cursor:    -moz-grabbing;
+	cursor:         grabbing;
+	}
+
+/* marker & overlays interactivity */
+.leaflet-marker-icon,
+.leaflet-marker-shadow,
+.leaflet-image-layer,
+.leaflet-pane > svg path,
+.leaflet-tile-container {
+	pointer-events: none;
+	}
+
+.leaflet-marker-icon.leaflet-interactive,
+.leaflet-image-layer.leaflet-interactive,
+.leaflet-pane > svg path.leaflet-interactive,
+svg.leaflet-image-layer.leaflet-interactive path {
+	pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
+	pointer-events: auto;
+	}
+
+/* visual tweaks */
+
+.leaflet-container {
+	background: #ddd;
+	outline-offset: 1px;
+	}
+.leaflet-container a {
+	color: #0078A8;
+	}
+.leaflet-zoom-box {
+	border: 2px dotted #38f;
+	background: rgba(255,255,255,0.5);
+	}
+
+
+/* general typography */
+.leaflet-container {
+	font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
+	font-size: 12px;
+	font-size: 0.75rem;
+	line-height: 1.5;
+	}
+
+
+/* general toolbar styles */
+
+.leaflet-bar {
+	box-shadow: 0 1px 5px rgba(0,0,0,0.65);
+	border-radius: 4px;
+	}
+.leaflet-bar a {
+	background-color: #fff;
+	border-bottom: 1px solid #ccc;
+	width: 26px;
+	height: 26px;
+	line-height: 26px;
+	display: block;
+	text-align: center;
+	text-decoration: none;
+	color: black;
+	}
+.leaflet-bar a,
+.leaflet-control-layers-toggle {
+	background-position: 50% 50%;
+	background-repeat: no-repeat;
+	display: block;
+	}
+.leaflet-bar a:hover,
+.leaflet-bar a:focus {
+	background-color: #f4f4f4;
+	}
+.leaflet-bar a:first-child {
+	border-top-left-radius: 4px;
+	border-top-right-radius: 4px;
+	}
+.leaflet-bar a:last-child {
+	border-bottom-left-radius: 4px;
+	border-bottom-right-radius: 4px;
+	border-bottom: none;
+	}
+.leaflet-bar a.leaflet-disabled {
+	cursor: default;
+	background-color: #f4f4f4;
+	color: #bbb;
+	}
+
+.leaflet-touch .leaflet-bar a {
+	width: 30px;
+	height: 30px;
+	line-height: 30px;
+	}
+.leaflet-touch .leaflet-bar a:first-child {
+	border-top-left-radius: 2px;
+	border-top-right-radius: 2px;
+	}
+.leaflet-touch .leaflet-bar a:last-child {
+	border-bottom-left-radius: 2px;
+	border-bottom-right-radius: 2px;
+	}
+
+/* zoom control */
+
+.leaflet-control-zoom-in,
+.leaflet-control-zoom-out {
+	font: bold 18px 'Lucida Console', Monaco, monospace;
+	text-indent: 1px;
+	}
+
+.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out  {
+	font-size: 22px;
+	}
+
+
+/* layers control */
+
+.leaflet-control-layers {
+	box-shadow: 0 1px 5px rgba(0,0,0,0.4);
+	background: #fff;
+	border-radius: 5px;
+	}
+.leaflet-control-layers-toggle {
+	background-image: url(images/layers.png);
+	width: 36px;
+	height: 36px;
+	}
+.leaflet-retina .leaflet-control-layers-toggle {
+	background-image: url(images/layers-2x.png);
+	background-size: 26px 26px;
+	}
+.leaflet-touch .leaflet-control-layers-toggle {
+	width: 44px;
+	height: 44px;
+	}
+.leaflet-control-layers .leaflet-control-layers-list,
+.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
+	display: none;
+	}
+.leaflet-control-layers-expanded .leaflet-control-layers-list {
+	display: block;
+	position: relative;
+	}
+.leaflet-control-layers-expanded {
+	padding: 6px 10px 6px 6px;
+	color: #333;
+	background: #fff;
+	}
+.leaflet-control-layers-scrollbar {
+	overflow-y: scroll;
+	overflow-x: hidden;
+	padding-right: 5px;
+	}
+.leaflet-control-layers-selector {
+	margin-top: 2px;
+	position: relative;
+	top: 1px;
+	}
+.leaflet-control-layers label {
+	display: block;
+	font-size: 13px;
+	font-size: 1.08333em;
+	}
+.leaflet-control-layers-separator {
+	height: 0;
+	border-top: 1px solid #ddd;
+	margin: 5px -10px 5px -6px;
+	}
+
+/* Default icon URLs */
+.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
+	background-image: url(images/marker-icon.png);
+	}
+
+
+/* attribution and scale controls */
+
+.leaflet-container .leaflet-control-attribution {
+	background: #fff;
+	background: rgba(255, 255, 255, 0.8);
+	margin: 0;
+	}
+.leaflet-control-attribution,
+.leaflet-control-scale-line {
+	padding: 0 5px;
+	color: #333;
+	line-height: 1.4;
+	}
+.leaflet-control-attribution a {
+	text-decoration: none;
+	}
+.leaflet-control-attribution a:hover,
+.leaflet-control-attribution a:focus {
+	text-decoration: underline;
+	}
+.leaflet-attribution-flag {
+	display: inline !important;
+	vertical-align: baseline !important;
+	width: 1em;
+	height: 0.6669em;
+	}
+.leaflet-left .leaflet-control-scale {
+	margin-left: 5px;
+	}
+.leaflet-bottom .leaflet-control-scale {
+	margin-bottom: 5px;
+	}
+.leaflet-control-scale-line {
+	border: 2px solid #777;
+	border-top: none;
+	line-height: 1.1;
+	padding: 2px 5px 1px;
+	white-space: nowrap;
+	-moz-box-sizing: border-box;
+	     box-sizing: border-box;
+	background: rgba(255, 255, 255, 0.8);
+	text-shadow: 1px 1px #fff;
+	}
+.leaflet-control-scale-line:not(:first-child) {
+	border-top: 2px solid #777;
+	border-bottom: none;
+	margin-top: -2px;
+	}
+.leaflet-control-scale-line:not(:first-child):not(:last-child) {
+	border-bottom: 2px solid #777;
+	}
+
+.leaflet-touch .leaflet-control-attribution,
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+	box-shadow: none;
+	}
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+	border: 2px solid rgba(0,0,0,0.2);
+	background-clip: padding-box;
+	}
+
+
+/* popup */
+
+.leaflet-popup {
+	position: absolute;
+	text-align: center;
+	margin-bottom: 20px;
+	}
+.leaflet-popup-content-wrapper {
+	padding: 1px;
+	text-align: left;
+	border-radius: 12px;
+	}
+.leaflet-popup-content {
+	margin: 13px 24px 13px 20px;
+	line-height: 1.3;
+	font-size: 13px;
+	font-size: 1.08333em;
+	min-height: 1px;
+	}
+.leaflet-popup-content p {
+	margin: 17px 0;
+	margin: 1.3em 0;
+	}
+.leaflet-popup-tip-container {
+	width: 40px;
+	height: 20px;
+	position: absolute;
+	left: 50%;
+	margin-top: -1px;
+	margin-left: -20px;
+	overflow: hidden;
+	pointer-events: none;
+	}
+.leaflet-popup-tip {
+	width: 17px;
+	height: 17px;
+	padding: 1px;
+
+	margin: -10px auto 0;
+	pointer-events: auto;
+
+	-webkit-transform: rotate(45deg);
+	   -moz-transform: rotate(45deg);
+	    -ms-transform: rotate(45deg);
+	        transform: rotate(45deg);
+	}
+.leaflet-popup-content-wrapper,
+.leaflet-popup-tip {
+	background: white;
+	color: #333;
+	box-shadow: 0 3px 14px rgba(0,0,0,0.4);
+	}
+.leaflet-container a.leaflet-popup-close-button {
+	position: absolute;
+	top: 0;
+	right: 0;
+	border: none;
+	text-align: center;
+	width: 24px;
+	height: 24px;
+	font: 16px/24px Tahoma, Verdana, sans-serif;
+	color: #757575;
+	text-decoration: none;
+	background: transparent;
+	}
+.leaflet-container a.leaflet-popup-close-button:hover,
+.leaflet-container a.leaflet-popup-close-button:focus {
+	color: #585858;
+	}
+.leaflet-popup-scrolled {
+	overflow: auto;
+	}
+
+.leaflet-oldie .leaflet-popup-content-wrapper {
+	-ms-zoom: 1;
+	}
+.leaflet-oldie .leaflet-popup-tip {
+	width: 24px;
+	margin: 0 auto;
+
+	-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
+	filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
+	}
+
+.leaflet-oldie .leaflet-control-zoom,
+.leaflet-oldie .leaflet-control-layers,
+.leaflet-oldie .leaflet-popup-content-wrapper,
+.leaflet-oldie .leaflet-popup-tip {
+	border: 1px solid #999;
+	}
+
+
+/* div icon */
+
+.leaflet-div-icon {
+	background: #fff;
+	border: 1px solid #666;
+	}
+
+
+/* Tooltip */
+/* Base styles for the element that has a tooltip */
+.leaflet-tooltip {
+	position: absolute;
+	padding: 6px;
+	background-color: #fff;
+	border: 1px solid #fff;
+	border-radius: 3px;
+	color: #222;
+	white-space: nowrap;
+	-webkit-user-select: none;
+	-moz-user-select: none;
+	-ms-user-select: none;
+	user-select: none;
+	pointer-events: none;
+	box-shadow: 0 1px 3px rgba(0,0,0,0.4);
+	}
+.leaflet-tooltip.leaflet-interactive {
+	cursor: pointer;
+	pointer-events: auto;
+	}
+.leaflet-tooltip-top:before,
+.leaflet-tooltip-bottom:before,
+.leaflet-tooltip-left:before,
+.leaflet-tooltip-right:before {
+	position: absolute;
+	pointer-events: none;
+	border: 6px solid transparent;
+	background: transparent;
+	content: "";
+	}
+
+/* Directions */
+
+.leaflet-tooltip-bottom {
+	margin-top: 6px;
+}
+.leaflet-tooltip-top {
+	margin-top: -6px;
+}
+.leaflet-tooltip-bottom:before,
+.leaflet-tooltip-top:before {
+	left: 50%;
+	margin-left: -6px;
+	}
+.leaflet-tooltip-top:before {
+	bottom: 0;
+	margin-bottom: -12px;
+	border-top-color: #fff;
+	}
+.leaflet-tooltip-bottom:before {
+	top: 0;
+	margin-top: -12px;
+	margin-left: -6px;
+	border-bottom-color: #fff;
+	}
+.leaflet-tooltip-left {
+	margin-left: -6px;
+}
+.leaflet-tooltip-right {
+	margin-left: 6px;
+}
+.leaflet-tooltip-left:before,
+.leaflet-tooltip-right:before {
+	top: 50%;
+	margin-top: -6px;
+	}
+.leaflet-tooltip-left:before {
+	right: 0;
+	margin-right: -12px;
+	border-left-color: #fff;
+	}
+.leaflet-tooltip-right:before {
+	left: 0;
+	margin-left: -12px;
+	border-right-color: #fff;
+	}
+
+/* Printing */
+
+@media print {
+	/* Prevent printers from removing background-images of controls. */
+	.leaflet-control {
+		-webkit-print-color-adjust: exact;
+		print-color-adjust: exact;
+		}
+	}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 4 - 0
nicegui/elements/lib/leaflet/leaflet/leaflet.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
nicegui/elements/lib/leaflet/leaflet/leaflet.js.map


+ 1 - 21
nicegui/elements/markdown.py

@@ -1,5 +1,4 @@
 import os
 import os
-import re
 from functools import lru_cache
 from functools import lru_cache
 from typing import List
 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')))
 @lru_cache(maxsize=int(os.environ.get('MARKDOWN_CONTENT_CACHE_SIZE', '1000')))
 def prepare_content(content: str, extras: str) -> str:
 def prepare_content(content: str, extras: str) -> str:
     """Render Markdown content to HTML."""
     """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:
 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()

+ 4 - 2
nicegui/elements/pyplot.py

@@ -3,6 +3,8 @@ import io
 import os
 import os
 from typing import Any
 from typing import Any
 
 
+from typing_extensions import Self
+
 from .. import background_tasks, optional_features
 from .. import background_tasks, optional_features
 from ..client import Client
 from ..client import Client
 from ..element import Element
 from ..element import Element
@@ -41,11 +43,11 @@ class Pyplot(Element):
             self.fig.savefig(output, format='svg')
             self.fig.savefig(output, format='svg')
             self._props['innerHTML'] = output.getvalue()
             self._props['innerHTML'] = output.getvalue()
 
 
-    def __enter__(self):
+    def __enter__(self) -> Self:
         plt.figure(self.fig)
         plt.figure(self.fig)
         return self
         return self
 
 
-    def __exit__(self, *_):
+    def __exit__(self, *_) -> None:
         self._convert_to_html()
         self._convert_to_html()
         if self.close:
         if self.close:
             plt.close(self.fig)
             plt.close(self.fig)

+ 0 - 5
nicegui/elements/scene.py

@@ -118,11 +118,6 @@ class Scene(Element,
                 obj.send()
                 obj.send()
 
 
     def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
     def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
-        """Run a method on the client.
-
-        :param name: name of the method
-        :param args: arguments to pass to the method
-        """
         if not self.is_initialized:
         if not self.is_initialized:
             return NullResponse()
             return NullResponse()
         return super().run_method(name, *args, timeout=timeout, check_interval=check_interval)
         return super().run_method(name, *args, timeout=timeout, check_interval=check_interval)

+ 2 - 1
nicegui/elements/select.js

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

+ 38 - 7
nicegui/elements/table.py

@@ -1,10 +1,10 @@
-from __future__ import annotations
-
 from typing import Any, Callable, Dict, List, Literal, Optional, Union
 from typing import Any, Callable, Dict, List, Literal, Optional, Union
 
 
+from typing_extensions import Self
+
 from .. import optional_features
 from .. import optional_features
 from ..element import Element
 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
 from .mixins.filter_element import FilterElement
 
 
 try:
 try:
@@ -24,6 +24,7 @@ class Table(FilterElement, component='table.js'):
                  selection: Optional[Literal['single', 'multiple']] = None,
                  selection: Optional[Literal['single', 'multiple']] = None,
                  pagination: Optional[Union[int, dict]] = None,
                  pagination: Optional[Union[int, dict]] = None,
                  on_select: Optional[Callable[..., Any]] = None,
                  on_select: Optional[Callable[..., Any]] = None,
+                 on_pagination_change: Optional[Callable[..., Any]] = None,
                  ) -> None:
                  ) -> None:
         """Table
         """Table
 
 
@@ -36,6 +37,7 @@ class Table(FilterElement, component='table.js'):
         :param selection: selection type ("single" or "multiple"; default: `None`)
         :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 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_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.
         If selection is 'single' or 'multiple', then a `selected` property is accessible containing the selected rows.
         """
         """
@@ -63,13 +65,21 @@ class Table(FilterElement, component='table.js'):
             handle_event(on_select, arguments)
             handle_event(on_select, arguments)
         self.on('selection', handle_selection, ['added', 'rows', 'keys'])
         self.on('selection', handle_selection, ['added', 'rows', 'keys'])
 
 
-    @staticmethod
-    def from_pandas(df: pd.DataFrame,
+        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)
+
+    @classmethod
+    def from_pandas(cls,
+                    df: pd.DataFrame,
                     row_key: str = 'id',
                     row_key: str = 'id',
                     title: Optional[str] = None,
                     title: Optional[str] = None,
                     selection: Optional[Literal['single', 'multiple']] = None,
                     selection: Optional[Literal['single', 'multiple']] = None,
                     pagination: Optional[Union[int, dict]] = None,
                     pagination: Optional[Union[int, dict]] = None,
-                    on_select: Optional[Callable[..., Any]] = None) -> Table:
+                    on_select: Optional[Callable[..., Any]] = None) -> Self:
         """Create a table from a Pandas DataFrame.
         """Create a table from a Pandas DataFrame.
 
 
         Note:
         Note:
@@ -97,7 +107,7 @@ class Table(FilterElement, component='table.js'):
             df[complex_cols] = df[complex_cols].astype(str)
             df[complex_cols] = df[complex_cols].astype(str)
             df[period_cols] = df[period_cols].astype(str)
             df[period_cols] = df[period_cols].astype(str)
 
 
-        return Table(
+        return cls(
             columns=[{'name': col, 'label': col, 'field': col} for col in df.columns],
             columns=[{'name': col, 'label': col, 'field': col} for col in df.columns],
             rows=df.to_dict('records'),
             rows=df.to_dict('records'),
             row_key=row_key,
             row_key=row_key,
@@ -146,6 +156,16 @@ class Table(FilterElement, component='table.js'):
         self._props['selected'][:] = value
         self._props['selected'][:] = value
         self.update()
         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
     @property
     def is_fullscreen(self) -> bool:
     def is_fullscreen(self) -> bool:
         """Whether the table is in fullscreen mode."""
         """Whether the table is in fullscreen mode."""
@@ -177,6 +197,17 @@ class Table(FilterElement, component='table.js'):
         self.selected[:] = [row for row in self.selected if row[self.row_key] not in keys]
         self.selected[:] = [row for row in self.selected if row[self.row_key] not in keys]
         self.update()
         self.update()
 
 
+    def update_rows(self, rows: List[Dict], *, clear_selection: bool = True) -> None:
+        """Update rows in the table.
+
+        :param rows: list of rows to update
+        :param clear_selection: whether to clear the selection (default: True)
+        """
+        self.rows[:] = rows
+        if clear_selection:
+            self.selected.clear()
+        self.update()
+
     class row(Element):
     class row(Element):
 
 
         def __init__(self) -> None:
         def __init__(self) -> None:

+ 5 - 3
nicegui/elements/tooltip.py

@@ -3,12 +3,14 @@ from .mixins.text_element import TextElement
 
 
 class Tooltip(TextElement):
 class Tooltip(TextElement):
 
 
-    def __init__(self, text: str) -> None:
+    def __init__(self, text: str = '') -> None:
         """Tooltip
         """Tooltip
 
 
         This element is based on Quasar's `QTooltip <https://quasar.dev/vue-components/tooltip>`_ component.
         This element is based on Quasar's `QTooltip <https://quasar.dev/vue-components/tooltip>`_ component.
-        It be placed in another element to show additional information on hover.
+        It can be placed in another element to show additional information on hover.
 
 
-        :param text: the content of the tooltip
+        Instead of passing a string as the first argument, you can also nest other elements inside the tooltip.
+
+        :param text: the content of the tooltip (default: '')
         """
         """
         super().__init__(tag='q-tooltip', text=text)
         super().__init__(tag='q-tooltip', text=text)

+ 1 - 2
nicegui/events.py

@@ -392,8 +392,7 @@ def handle_event(handler: Optional[Callable[..., Any]], arguments: EventArgument
         if isinstance(arguments, UiEventArguments):
         if isinstance(arguments, UiEventArguments):
             if arguments.sender.is_ignoring_events:
             if arguments.sender.is_ignoring_events:
                 return
                 return
-            assert arguments.sender.parent_slot is not None
-            parent_slot = arguments.sender.parent_slot
+            parent_slot = arguments.sender.parent_slot or arguments.sender.client.layout.default_slot
         else:
         else:
             parent_slot = nullcontext()
             parent_slot = nullcontext()
 
 

+ 23 - 0
nicegui/functions/on.py

@@ -0,0 +1,23 @@
+from typing import Any, Callable, Optional, Sequence, Union
+
+from .. import context
+
+
+def on(type: str,  # pylint: disable=redefined-builtin
+       handler: Optional[Callable[..., Any]] = None,
+       args: Union[None, Sequence[str], Sequence[Optional[Sequence[str]]]] = None, *,
+       throttle: float = 0.0,
+       leading_events: bool = True,
+       trailing_events: bool = True,
+       ):
+    """Subscribe to a global event.
+
+    :param type: name of the event
+    :param handler: callback that is called upon occurrence of the event
+    :param args: arguments included in the event message sent to the event handler (default: `None` meaning all)
+    :param throttle: minimum time (in seconds) between event occurrences (default: 0.0)
+    :param leading_events: whether to trigger the event handler immediately upon the first event occurrence (default: `True`)
+    :param trailing_events: whether to trigger the event handler after the last event occurrence (default: `True`)
+    """
+    context.get_client().layout.on(type, handler, args,
+                                   throttle=throttle, leading_events=leading_events, trailing_events=trailing_events)

+ 14 - 0
nicegui/functions/page_title.py

@@ -0,0 +1,14 @@
+from .. import context, json
+
+
+def page_title(title: str) -> None:
+    """Page title
+
+    Set the page title for the current client.
+
+    :param title: page title
+    """
+    client = context.get_client()
+    client.title = title
+    if client.has_socket_connection:
+        client.run_javascript(f'document.title = {json.dumps(title)}')

+ 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 = threading.Thread(target=in_thread, args=(host, port), daemon=True)
     thread.start()
     thread.start()
     return thread, cancel
     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('-')))

+ 12 - 1
nicegui/nicegui.py

@@ -14,7 +14,7 @@ from fastapi_socketio import SocketManager
 from . import air, background_tasks, binding, core, favicon, helpers, json, outbox, run, welcome
 from . import air, background_tasks, binding, core, favicon, helpers, json, outbox, run, welcome
 from .app import App
 from .app import App
 from .client import Client
 from .client import Client
-from .dependencies import js_components, libraries
+from .dependencies import js_components, libraries, resources
 from .error import error_content
 from .error import error_content
 from .json import NiceGUIJSONResponse
 from .json import NiceGUIJSONResponse
 from .logging import log
 from .logging import log
@@ -76,6 +76,17 @@ def _get_component(key: str) -> FileResponse:
     raise HTTPException(status_code=404, detail=f'component "{key}" not found')
     raise HTTPException(status_code=404, detail=f'component "{key}" not found')
 
 
 
 
+@app.get(f'/_nicegui/{__version__}' + '/resources/{key}/{path:path}')
+def _get_resource(key: str, path: str) -> FileResponse:
+    if key in resources:
+        filepath = resources[key].path / path
+        if filepath.exists():
+            headers = {'Cache-Control': 'public, max-age=3600'}
+            media_type, _ = mimetypes.guess_type(filepath)
+            return FileResponse(filepath, media_type=media_type, headers=headers)
+    raise HTTPException(status_code=404, detail=f'resource "{key}" not found')
+
+
 async def _startup() -> None:
 async def _startup() -> None:
     """Handle the startup event."""
     """Handle the startup event."""
     if not app.config.has_run_config:
     if not app.config.has_run_config:

+ 0 - 0
nicegui/py.typed


+ 128 - 19
nicegui/static/nicegui.css

@@ -1,3 +1,9 @@
+/* variables */
+:root {
+  --nicegui-default-padding: 1rem;
+  --nicegui-default-gap: 1rem;
+}
+
 /* prevent q-layout from getting strange outline when focussed */
 /* prevent q-layout from getting strange outline when focussed */
 .nicegui-layout {
 .nicegui-layout {
   outline: 2px solid transparent;
   outline: 2px solid transparent;
@@ -23,8 +29,8 @@
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
   align-items: flex-start;
   align-items: flex-start;
-  gap: 1rem;
-  padding: 1rem;
+  gap: var(--nicegui-default-gap);
+  padding: var(--nicegui-default-padding);
 }
 }
 .nicegui-header,
 .nicegui-header,
 .nicegui-footer,
 .nicegui-footer,
@@ -64,20 +70,132 @@
 
 
 /* HACK: avoid stutter when expansion item is toggled */
 /* HACK: avoid stutter when expansion item is toggled */
 .nicegui-expansion .q-expansion-item__content {
 .nicegui-expansion .q-expansion-item__content {
-  padding: 0 1rem;
+  padding: 0 var(--nicegui-default-padding);
 }
 }
 .nicegui-expansion .q-expansion-item__content::before,
 .nicegui-expansion .q-expansion-item__content::before,
 .nicegui-expansion .q-expansion-item__content::after {
 .nicegui-expansion .q-expansion-item__content::after {
   content: ""; /* the gap compensates for the missing vertical padding */
   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 */
 /* other NiceGUI elements */
 .nicegui-grid {
 .nicegui-grid {
   display: grid;
   display: grid;
-  gap: 1rem;
+  gap: var(--nicegui-default-gap);
 }
 }
-.nicegui-link:link,
-.nicegui-link:visited {
+.nicegui-link {
   text-decoration-line: underline;
   text-decoration-line: underline;
   color: rgb(59 130 246);
   color: rgb(59 130 246);
 }
 }
@@ -90,6 +208,7 @@
 }
 }
 .nicegui-aggrid,
 .nicegui-aggrid,
 .nicegui-echart,
 .nicegui-echart,
+.nicegui-leaflet,
 .nicegui-scroll-area {
 .nicegui-scroll-area {
   width: 100%;
   width: 100%;
   height: 16rem;
   height: 16rem;
@@ -102,19 +221,6 @@
   opacity: 1 !important;
   opacity: 1 !important;
   cursor: text !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 {
 h6.q-timeline__title {
   font-size: 1.25rem;
   font-size: 1.25rem;
   font-weight: 500;
   font-weight: 500;
@@ -126,6 +232,9 @@ h6.q-timeline__title {
   box-shadow: 0 0 0.5em rgba(127, 159, 191, 0.05);
   box-shadow: 0 0 0.5em rgba(127, 159, 191, 0.05);
   border-radius: 0.25rem;
   border-radius: 0.25rem;
 }
 }
+.nicegui-code .codehilite {
+  padding: 0 0.5rem;
+}
 
 
 /* connection popup */
 /* connection popup */
 #popup {
 #popup {

+ 27 - 0
nicegui/static/utils/resources.js

@@ -0,0 +1,27 @@
+const resourceLoadPromises = {};
+
+export function loadResource(url) {
+  if (resourceLoadPromises[url]) return resourceLoadPromises[url];
+  const loadPromise = new Promise((resolve, reject) => {
+    const dataAttribute = `data-${url.split("/").pop().replace(/\./g, "-")}`;
+    if (document.querySelector(`[${dataAttribute}]`)) {
+      resolve();
+      return;
+    }
+    let element;
+    if (url.endsWith(".css")) {
+      element = document.createElement("link");
+      element.setAttribute("rel", "stylesheet");
+      element.setAttribute("href", url);
+    } else if (url.endsWith(".js")) {
+      element = document.createElement("script");
+      element.setAttribute("src", url);
+    }
+    element.setAttribute(dataAttribute, "");
+    document.head.appendChild(element);
+    element.onload = resolve;
+    element.onerror = reject;
+  });
+  resourceLoadPromises[url] = loadPromise;
+  return loadPromise;
+}

+ 6 - 1
nicegui/storage.py

@@ -13,6 +13,7 @@ from starlette.requests import Request
 from starlette.responses import Response
 from starlette.responses import Response
 
 
 from . import background_tasks, context, core, json, observables
 from . import background_tasks, context, core, json, observables
+from .logging import log
 
 
 request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None)
 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:
     def __init__(self, filepath: Path) -> None:
         self.filepath = filepath
         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)
         super().__init__(data, on_change=self.backup)
 
 
     def backup(self) -> None:
     def backup(self) -> None:

+ 10 - 9
nicegui/templates/index.html

@@ -54,6 +54,9 @@
           return element.$refs.qRef[method_name](...args);
           return element.$refs.qRef[method_name](...args);
         }
         }
       }
       }
+      function emitEvent(event_name, ...args) {
+        getElement(0).$emit(event_name, ...args);
+      }
     </script>
     </script>
     <script type="module">
     <script type="module">
       const True = true;
       const True = true;
@@ -160,9 +163,9 @@
               listener_id: event.listener_id,
               listener_id: event.listener_id,
               args: stringifyEventArgs(args, event.args),
               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);
             throttle(emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id);
-            if (element.props["loopback"] === False && event.type == "update:model-value") {
+            if (element.props["loopback"] === False && event.type == "update:modelValue") {
               element.props["model-value"] = args;
               element.props["model-value"] = args;
             }
             }
           };
           };
@@ -272,13 +275,11 @@
                 window.location.reload(); // see https://github.com/zauberzeug/nicegui/issues/198
                 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: () => {
             disconnect: () => {
               document.getElementById('popup').style.opacity = 1;
               document.getElementById('popup').style.opacity = 1;

+ 8 - 0
nicegui/ui.py

@@ -38,6 +38,7 @@ __all__ = [
     'keyboard',
     'keyboard',
     'knob',
     'knob',
     'label',
     'label',
+    'leaflet',
     'line_plot',
     'line_plot',
     'link',
     'link',
     'link_target',
     'link_target',
@@ -46,6 +47,7 @@ __all__ = [
     'menu',
     'menu',
     'menu_item',
     'menu_item',
     'mermaid',
     'mermaid',
+    'notification',
     'number',
     'number',
     'pagination',
     'pagination',
     'plotly',
     'plotly',
@@ -87,6 +89,7 @@ __all__ = [
     'run_javascript',
     'run_javascript',
     'notify',
     'notify',
     'open',
     'open',
+    'page_title',
     'refreshable',
     'refreshable',
     'state',
     'state',
     'update',
     'update',
@@ -95,6 +98,7 @@ __all__ = [
     'footer',
     'footer',
     'header',
     'header',
     'left_drawer',
     'left_drawer',
+    'on',
     'page_sticky',
     'page_sticky',
     'right_drawer',
     'right_drawer',
     'run',
     'run',
@@ -140,6 +144,7 @@ from .elements.json_editor import JsonEditor as json_editor
 from .elements.keyboard import Keyboard as keyboard
 from .elements.keyboard import Keyboard as keyboard
 from .elements.knob import Knob as knob
 from .elements.knob import Knob as knob
 from .elements.label import Label as label
 from .elements.label import Label as label
+from .elements.leaflet import Leaflet as leaflet
 from .elements.line_plot import LinePlot as line_plot
 from .elements.line_plot import LinePlot as line_plot
 from .elements.link import Link as link
 from .elements.link import Link as link
 from .elements.link import LinkTarget as link_target
 from .elements.link import LinkTarget as link_target
@@ -148,6 +153,7 @@ from .elements.markdown import Markdown as markdown
 from .elements.menu import Menu as menu
 from .elements.menu import Menu as menu
 from .elements.menu import MenuItem as menu_item
 from .elements.menu import MenuItem as menu_item
 from .elements.mermaid import Mermaid as mermaid
 from .elements.mermaid import Mermaid as mermaid
+from .elements.notification import Notification as notification
 from .elements.number import Number as number
 from .elements.number import Number as number
 from .elements.pagination import Pagination as pagination
 from .elements.pagination import Pagination as pagination
 from .elements.plotly import Plotly as plotly
 from .elements.plotly import Plotly as plotly
@@ -187,7 +193,9 @@ from .functions.download import download
 from .functions.html import add_body_html, add_head_html
 from .functions.html import add_body_html, add_head_html
 from .functions.javascript import run_javascript
 from .functions.javascript import run_javascript
 from .functions.notify import notify
 from .functions.notify import notify
+from .functions.on import on
 from .functions.open import open  # pylint: disable=redefined-builtin
 from .functions.open import open  # pylint: disable=redefined-builtin
+from .functions.page_title import page_title
 from .functions.refreshable import refreshable, state
 from .functions.refreshable import refreshable, state
 from .functions.update import update
 from .functions.update import update
 from .page import page
 from .page import page

+ 19 - 0
npm.json

@@ -52,6 +52,25 @@
       "package/dist/": ""
       "package/dist/": ""
     }
     }
   },
   },
+  "leaflet": {
+    "destination": "nicegui/elements/lib/leaflet",
+    "keep": [
+      "package/dist/leaflet\\.css",
+      "package/dist/leaflet\\.js",
+      "package/dist/leaflet\\.js\\.map",
+      "package/dist/images/.*"
+    ],
+    "rename": {
+      "package/dist/": ""
+    }
+  },
+  "leaflet-draw": {
+    "destination": "nicegui/elements/lib/leaflet",
+    "keep": ["package/dist/leaflet\\.draw\\.css", "package/dist/leaflet\\.draw\\.js", "package/dist/images/.*"],
+    "rename": {
+      "package/dist/": ""
+    }
+  },
   "mermaid": {
   "mermaid": {
     "destination": "nicegui/elements/lib",
     "destination": "nicegui/elements/lib",
     "keep": ["package/dist/.*\\.m?js"],
     "keep": ["package/dist/.*\\.m?js"],

+ 6 - 0
npm.py

@@ -22,8 +22,10 @@ import requests
 
 
 parser = ArgumentParser()
 parser = ArgumentParser()
 parser.add_argument('path', default='.', help='path to the root of the repository')
 parser.add_argument('path', default='.', help='path to the root of the repository')
+parser.add_argument('--name', nargs='*', help='filter library updates by name')
 args = parser.parse_args()
 args = parser.parse_args()
 root_path = Path(args.path)
 root_path = Path(args.path)
+names = args.name or None
 
 
 
 
 def prepare(path: Path) -> Path:
 def prepare(path: Path) -> Path:
@@ -56,6 +58,7 @@ KNOWN_LICENSES = {
     'MIT': 'https://opensource.org/licenses/MIT',
     'MIT': 'https://opensource.org/licenses/MIT',
     'ISC': 'https://opensource.org/licenses/ISC',
     'ISC': 'https://opensource.org/licenses/ISC',
     'Apache-2.0': 'https://opensource.org/licenses/Apache-2.0',
     'Apache-2.0': 'https://opensource.org/licenses/Apache-2.0',
+    'BSD-2-Clause': 'https://opensource.org/licenses/BSD-2-Clause',
 }
 }
 
 
 # Create a hidden folder to work in.
 # Create a hidden folder to work in.
@@ -63,6 +66,9 @@ tmp = cleanup(root_path / '.npm')
 
 
 dependencies: dict[str, dict] = json.loads((root_path / 'npm.json').read_text())
 dependencies: dict[str, dict] = json.loads((root_path / 'npm.json').read_text())
 for key, dependency in dependencies.items():
 for key, dependency in dependencies.items():
+    if names is not None and key not in names:
+        continue
+
     # Reset destination folder.
     # Reset destination folder.
     destination = prepare(root_path / dependency['destination'] / key)
     destination = prepare(root_path / dependency['destination'] / key)
 
 

+ 2 - 2
pyproject.toml

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

+ 2 - 0
tests/test_endpoint_docs.py

@@ -28,6 +28,7 @@ def test_endpoint_documentation_internal_only(screen: Screen):
     assert get_openapi_paths() == {
     assert get_openapi_paths() == {
         f'/_nicegui/{__version__}/libraries/{{key}}',
         f'/_nicegui/{__version__}/libraries/{{key}}',
         f'/_nicegui/{__version__}/components/{{key}}',
         f'/_nicegui/{__version__}/components/{{key}}',
+        f'/_nicegui/{__version__}/resources/{{key}}/{{path}}',
     }
     }
 
 
 
 
@@ -38,4 +39,5 @@ def test_endpoint_documentation_all(screen: Screen):
         '/',
         '/',
         f'/_nicegui/{__version__}/libraries/{{key}}',
         f'/_nicegui/{__version__}/libraries/{{key}}',
         f'/_nicegui/{__version__}/components/{{key}}',
         f'/_nicegui/{__version__}/components/{{key}}',
+        f'/_nicegui/{__version__}/resources/{{key}}/{{path}}',
     }
     }

+ 39 - 0
tests/test_leaflet.py

@@ -0,0 +1,39 @@
+import time
+
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_leaflet(screen: Screen):
+    m = ui.leaflet(center=(51.505, -0.09), zoom=13)
+    ui.label().bind_text_from(m, 'center', lambda center: f'Center: {center[0]:.3f}, {center[1]:.3f}')
+    ui.label().bind_text_from(m, 'zoom', lambda zoom: f'Zoom: {zoom}')
+
+    ui.button('Zoom in', on_click=lambda: m.set_zoom(m.zoom + 1))
+    ui.button('Zoom out', on_click=lambda: m.set_zoom(m.zoom - 1))
+
+    ui.button('Berlin', on_click=lambda: m.set_center((52.520, 13.405)))
+    ui.button('London', on_click=lambda: m.set_center((51.505, -0.090)))
+
+    screen.open('/')
+    assert screen.find_all_by_class('leaflet-pane')
+    assert screen.find_all_by_class('leaflet-control-container')
+
+    deadline = time.time() + 3
+    while not m.is_initialized and time.time() < deadline:
+        screen.wait(0.1)
+    screen.should_contain('Center: 51.505, -0.090')
+    screen.should_contain('Zoom: 13')
+
+    screen.click('Zoom in')
+    screen.should_contain('Zoom: 14')
+
+    screen.click('Zoom out')
+    screen.should_contain('Zoom: 13')
+
+    screen.click('Berlin')
+    screen.should_contain('Center: 52.520, 13.405')
+
+    screen.click('London')
+    screen.should_contain('Center: 51.505, -0.090')

+ 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

+ 11 - 0
tests/test_number.py

@@ -6,6 +6,17 @@ from nicegui import ui
 from .screen import Screen
 from .screen import Screen
 
 
 
 
+def test_number_input(screen: Screen):
+    ui.number('Number')
+    ui.button('Button')
+
+    screen.open('/')
+    element = screen.selenium.find_element(By.XPATH, '//*[@aria-label="Number"]')
+    element.send_keys('42')
+    screen.click('Button')
+    screen.should_contain_input('42')
+
+
 def test_apply_format_on_blur(screen: Screen):
 def test_apply_format_on_blur(screen: Screen):
     ui.number('Number', format='%.4f', value=3.14159)
     ui.number('Number', format='%.4f', value=3.14159)
     ui.button('Button')
     ui.button('Button')

+ 21 - 0
tests/test_page_title.py

@@ -0,0 +1,21 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_page_title(screen: Screen):
+    ui.page_title('Initial title')
+    ui.button('Change title', on_click=lambda: ui.page_title('"New title"'))
+
+    @ui.page('/{title}')
+    def page(title: str):
+        ui.page_title(f'Title: {title}')
+
+    screen.open('/')
+    screen.should_contain('Initial title')
+
+    screen.click('Change title')
+    screen.should_contain('"New title"')
+
+    screen.open('/test')
+    screen.should_contain('Title: test')

+ 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']")
                                   "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.open('/')
     screen.find_by_tag('input').click()
     screen.find_by_tag('input').click()
@@ -161,7 +162,12 @@ def test_keep_filtered_options(screen: Screen):
 
 
     screen.click('A1')
     screen.click('A1')
     screen.wait(0.5)
     screen.wait(0.5)
+    screen.find_by_tag('input').click()
     screen.should_contain('A1')
     screen.should_contain('A1')
     screen.should_contain('A2')
     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')

+ 13 - 3
tests/test_table.py

@@ -145,22 +145,32 @@ def test_remove_selection(screen: Screen):
 def test_replace_rows(screen: Screen):
 def test_replace_rows(screen: Screen):
     t = ui.table(columns=columns(), rows=rows())
     t = ui.table(columns=columns(), rows=rows())
 
 
-    def replace_rows():
+    def replace_rows_with_carol():
         t.rows = [{'id': 3, 'name': 'Carol', 'age': 32}]
         t.rows = [{'id': 3, 'name': 'Carol', 'age': 32}]
-    ui.button('Replace rows', on_click=replace_rows)
+
+    def replace_rows_with_daniel():
+        t.update_rows([{'id': 4, 'name': 'Daniel', 'age': 33}])
+
+    ui.button('Replace rows with C.', on_click=replace_rows_with_carol)
+    ui.button('Replace rows with D.', on_click=replace_rows_with_daniel)
 
 
     screen.open('/')
     screen.open('/')
     screen.should_contain('Alice')
     screen.should_contain('Alice')
     screen.should_contain('Bob')
     screen.should_contain('Bob')
     screen.should_contain('Lionel')
     screen.should_contain('Lionel')
 
 
-    screen.click('Replace rows')
+    screen.click('Replace rows with C.')
     screen.wait(0.5)
     screen.wait(0.5)
     screen.should_not_contain('Alice')
     screen.should_not_contain('Alice')
     screen.should_not_contain('Bob')
     screen.should_not_contain('Bob')
     screen.should_not_contain('Lionel')
     screen.should_not_contain('Lionel')
     screen.should_contain('Carol')
     screen.should_contain('Carol')
 
 
+    screen.click('Replace rows with D.')
+    screen.wait(0.5)
+    screen.should_not_contain('Carol')
+    screen.should_contain('Daniel')
+
 
 
 def test_create_from_pandas(screen: Screen):
 def test_create_from_pandas(screen: Screen):
     df = pd.DataFrame({'name': ['Alice', 'Bob'], 'age': [18, 21], 42: 'answer'})
     df = pd.DataFrame({'name': ['Alice', 'Bob'], 'age': [18, 21], 42: 'answer'})

+ 18 - 16
website/documentation/content/generic_events_documentation.py

@@ -99,26 +99,28 @@ def modifiers() -> None:
 
 
 
 
 @doc.demo('Custom events', '''
 @doc.demo('Custom events', '''
-    It is fairly easy to emit custom events from JavaScript which can be listened to with `element.on(...)`.
+    It is fairly easy to emit custom events from JavaScript with `emitEvent(...)` which can be listened to with `ui.on(...)`.
     This can be useful if you want to call Python code when something happens in JavaScript.
     This can be useful if you want to call Python code when something happens in JavaScript.
     In this example we are listening to the `visibilitychange` event of the browser tab.
     In this example we are listening to the `visibilitychange` event of the browser tab.
 ''')
 ''')
 async def custom_events() -> None:
 async def custom_events() -> None:
-    tabwatch = ui.checkbox('Watch browser tab re-entering') \
-        .on('tabvisible', lambda: ui.notify('Welcome back!') if tabwatch.value else None, args=[])
-    ui.add_head_html(f'''
-        <script>
-        document.addEventListener('visibilitychange', () => {{
-            if (document.visibilityState === 'visible')
-                getElement({tabwatch.id}).$emit('tabvisible');
-        }});
-        </script>
-    ''')
+    tabwatch = ui.checkbox('Watch browser tab re-entering')
+    ui.on('tabvisible', lambda: ui.notify('Welcome back!') if tabwatch.value else None)
+    # ui.add_head_html('''
+    #     <script>
+    #     document.addEventListener('visibilitychange', () => {
+    #         if (document.visibilityState === 'visible') {
+    #             emitEvent('tabvisible');
+    #         }
+    #     });
+    #     </script>
+    # ''')
     # END OF DEMO
     # END OF DEMO
     await context.get_client().connected()
     await context.get_client().connected()
-    ui.run_javascript(f'''
-        document.addEventListener('visibilitychange', () => {{
-            if (document.visibilityState === 'visible')
-                getElement({tabwatch.id}).$emit('tabvisible');
-        }});
+    ui.run_javascript('''
+        document.addEventListener('visibilitychange', () => {
+            if (document.visibilityState === 'visible') {
+                emitEvent('tabvisible');
+            }
+        });
     ''')
     ''')

+ 124 - 0
website/documentation/content/leaflet_documentation.py

@@ -0,0 +1,124 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.leaflet)
+def main_demo() -> None:
+    m = ui.leaflet(center=(51.505, -0.09))
+    ui.label().bind_text_from(m, 'center', lambda center: f'Center: {center[0]:.3f}, {center[1]:.3f}')
+    ui.label().bind_text_from(m, 'zoom', lambda zoom: f'Zoom: {zoom}')
+
+    with ui.grid(columns=2):
+        ui.button('London', on_click=lambda: m.set_center((51.505, -0.090)))
+        ui.button('Berlin', on_click=lambda: m.set_center((52.520, 13.405)))
+        ui.button(icon='zoom_in', on_click=lambda: m.set_zoom(m.zoom + 1))
+        ui.button(icon='zoom_out', on_click=lambda: m.set_zoom(m.zoom - 1))
+
+
+@doc.demo('Changing the Map Style', '''
+    The default map style is OpenStreetMap.
+    You can find more map styles at <https://leaflet-extras.github.io/leaflet-providers/preview/>.
+    Each call to `tile_layer` stacks upon the previous ones.
+    So if you want to change the map style, you have to remove the default one first.
+''')
+def map_style() -> None:
+    m = ui.leaflet(center=(51.505, -0.090), zoom=3)
+    m.clear_layers()
+    m.tile_layer(
+        url_template=r'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
+        options={
+            'maxZoom': 17,
+            'attribution':
+                'Map data: &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | '
+                'Map style: &copy; <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)'
+        },
+    )
+
+
+@doc.demo('Add Markers on Click', '''
+    You can add markers to the map with `marker`. 
+    The `center` argument is a tuple of latitude and longitude.
+    This demo adds markers by clicking on the map.
+    Note that the "map-click" event refers to the click event of the map object,
+    while the "click" event refers to the click event of the container div.
+''')
+def markers() -> None:
+    from nicegui import events
+
+    m = ui.leaflet(center=(51.505, -0.09))
+
+    def handle_click(e: events.GenericEventArguments):
+        lat = e.args['latlng']['lat']
+        lng = e.args['latlng']['lng']
+        m.marker(latlng=(lat, lng))
+    m.on('map-click', handle_click)
+
+
+@doc.demo('Vector Layers', '''
+    Leaflet supports a set of [vector layers](https://leafletjs.com/reference.html#:~:text=VideoOverlay-,Vector%20Layers,-Path) like circle, polygon etc.
+    These can be added with the `generic_layer` method.
+    We are happy to review any pull requests to add more specific layers to simplify usage.
+''')
+def vector_layers() -> None:
+    m = ui.leaflet(center=(51.505, -0.09)).classes('h-32')
+    m.generic_layer(name='circle',
+                    args=[[51.505, -0.09], {'color': 'red', 'radius': 300}])
+
+
+@doc.demo('Disable Pan and Zoom', '''
+    There are [several options to configure the map in Leaflet](https://leafletjs.com/reference.html#map).
+    This demo disables the pan and zoom controls.
+''')
+def disable_pan_zoom() -> None:
+    options = {
+        'zoomControl': False,
+        'scrollWheelZoom': False,
+        'doubleClickZoom': False,
+        'boxZoom': False,
+        'keyboard': False,
+        'dragging': False,
+    }
+    ui.leaflet(center=(51.505, -0.09), options=options)
+
+
+@doc.demo('Draw on Map', '''
+    You can enable a toolbar to draw on the map.
+    The `draw_control` can be used to configure the toolbar.
+    This demo adds markers and polygons by clicking on the map.
+''')
+def draw_on_map() -> None:
+    from nicegui import events
+
+    def handle_draw(e: events.GenericEventArguments):
+        if e.args['layerType'] == 'marker':
+            m.marker(latlng=(e.args['layer']['_latlng']['lat'],
+                             e.args['layer']['_latlng']['lng']))
+        if e.args['layerType'] == 'polygon':
+            m.generic_layer(name='polygon', args=[e.args['layer']['_latlngs']])
+
+    draw_control = {
+        'draw': {
+            'polygon': True,
+            'marker': True,
+            'circle': False,
+            'rectangle': False,
+            'polyline': False,
+            'circlemarker': False,
+        },
+        'edit': False,
+    }
+    m = ui.leaflet(center=(51.505, -0.09), zoom=13, draw_control=draw_control)
+    m.on('draw:created', handle_draw)
+
+
+@doc.demo('Run Map Methods', '''
+    You can run methods of the Leaflet map object with `run_map_method`.
+    This demo shows how to fit the map to the whole world.
+''')
+def run_map_methods() -> None:
+    m = ui.leaflet(center=(51.505, -0.09)).classes('h-32')
+    ui.button('Fit world', on_click=lambda: m.run_map_method('fitWorld'))
+
+
+doc.reference(ui.leaflet)

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

+ 8 - 0
website/documentation/content/page_title_documentation.py

@@ -0,0 +1,8 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.page_title)
+def main_demo() -> None:
+    ui.button('Change page title', on_click=lambda: ui.page_title('New Title'))

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

@@ -2,9 +2,9 @@ from nicegui import optional_features
 
 
 from . import (aggrid_documentation, circular_progress_documentation, code_documentation, doc, echart_documentation,
 from . import (aggrid_documentation, circular_progress_documentation, code_documentation, doc, echart_documentation,
                editor_documentation, full_calendar_documentation, highchart_documentation, json_editor_documentation,
                editor_documentation, full_calendar_documentation, highchart_documentation, json_editor_documentation,
-               line_plot_documentation, linear_progress_documentation, log_documentation, plotly_documentation,
-               pyplot_documentation, scene_documentation, spinner_documentation, table_documentation,
-               tree_documentation)
+               leaflet_documentation, line_plot_documentation, linear_progress_documentation, log_documentation,
+               plotly_documentation, pyplot_documentation, scene_documentation, spinner_documentation,
+               table_documentation, tree_documentation)
 
 
 doc.title('*Data* Elements')
 doc.title('*Data* Elements')
 
 
@@ -22,6 +22,7 @@ doc.intro(linear_progress_documentation)
 doc.intro(circular_progress_documentation)
 doc.intro(circular_progress_documentation)
 doc.intro(spinner_documentation)
 doc.intro(spinner_documentation)
 doc.intro(scene_documentation)
 doc.intro(scene_documentation)
+doc.intro(leaflet_documentation)
 doc.intro(tree_documentation)
 doc.intro(tree_documentation)
 doc.intro(log_documentation)
 doc.intro(log_documentation)
 doc.intro(editor_documentation)
 doc.intro(editor_documentation)

+ 5 - 16
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,
 from . import (card_documentation, carousel_documentation, column_documentation, context_menu_documentation,
                dialog_documentation, doc, expansion_documentation, grid_documentation, 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, tooltip_documentation)
 
 
 doc.title('Page *Layout*')
 doc.title('Page *Layout*')
 
 
@@ -71,18 +71,7 @@ doc.intro(carousel_documentation)
 doc.intro(pagination_documentation)
 doc.intro(pagination_documentation)
 doc.intro(menu_documentation)
 doc.intro(menu_documentation)
 doc.intro(context_menu_documentation)
 doc.intro(context_menu_documentation)
-
-
-@doc.demo('Tooltips', '''
-    Simply call the `tooltip(text:str)` method on UI elements to provide a tooltip.
-
-    For more artistic control you can nest tooltip elements and apply props, classes and styles.
-''')
-def tooltips_demo():
-    ui.label('Tooltips...').tooltip('...are shown on mouse over')
-    with ui.button(icon='thumb_up'):
-        ui.tooltip('I like this').classes('bg-green')
-
-
+doc.intro(tooltip_documentation)
 doc.intro(notify_documentation)
 doc.intro(notify_documentation)
+doc.intro(notification_documentation)
 doc.intro(dialog_documentation)
 doc.intro(dialog_documentation)

+ 2 - 1
website/documentation/content/section_pages_routing.py

@@ -2,7 +2,7 @@ import uuid
 
 
 from nicegui import app, ui
 from nicegui import app, ui
 
 
-from . import doc, download_documentation, open_documentation, page_documentation
+from . import doc, download_documentation, open_documentation, page_documentation, page_title_documentation
 
 
 CONSTANT_UUID = str(uuid.uuid4())
 CONSTANT_UUID = str(uuid.uuid4())
 
 
@@ -79,6 +79,7 @@ def parameter_demo():
     ui.link('Water', '/icon/water_drop?amount=3')
     ui.link('Water', '/icon/water_drop?amount=3')
 
 
 
 
+doc.intro(page_title_documentation)
 doc.intro(open_documentation)
 doc.intro(open_documentation)
 doc.intro(download_documentation)
 doc.intro(download_documentation)
 
 

+ 28 - 0
website/documentation/content/section_styling_appearance.py

@@ -124,4 +124,32 @@ def tailwind_demo():
 
 
 doc.intro(query_documentation)
 doc.intro(query_documentation)
 doc.intro(colors_documentation)
 doc.intro(colors_documentation)
+
+
+@doc.demo('CSS Variables', '''
+    You can customize the appearance of NiceGUI by setting CSS variables.
+    Currently, the following variables with their default values are available:
+    
+    - `--nicegui-default-padding: 1rem`
+    - `--nicegui-default-gap: 1rem`
+
+''')
+def css_variables_demo():
+    # ui.add_head_html('''
+    #     <style>
+    #         :root {
+    #             --nicegui-default-padding: 0.5rem;
+    #             --nicegui-default-gap: 3rem;
+    #         }
+    #     </style>
+    # ''')
+    # with ui.card():
+    #     ui.label('small padding')
+    #     ui.label('large gap')
+    # END OF DEMO
+    with ui.card().classes('p-[0.5rem] gap-[3rem]'):
+        ui.label('small padding')
+        ui.label('large gap')
+
+
 doc.intro(dark_mode_documentation)
 doc.intro(dark_mode_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})
     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', '''
 @doc.demo('Computed fields', '''
     You can use functions to compute the value of a column.
     You can use functions to compute the value of a column.
     The function receives the row as an argument.
     The function receives the row as an argument.

+ 37 - 0
website/documentation/content/tooltip_documentation.py

@@ -0,0 +1,37 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.tooltip)
+def tooltips_demo():
+    with ui.button(icon='thumb_up'):
+        ui.tooltip('I like this').classes('bg-green')
+
+
+@doc.demo('Tooltip method', '''
+    Instead of nesting a tooltip element inside another element, you can also use the `tooltip` method.
+''')
+def tooltip_method_demo():
+    ui.label('Tooltips...').tooltip('...are shown on mouse over')
+
+
+@doc.demo('Tooltip with HTML', '''
+    You can use HTML in tooltips by nesting a `ui.html` element.
+''')
+def tooltip_html_demo():
+    with ui.label('HTML...'):
+        with ui.tooltip():
+            ui.html('<b>b</b>, <em>em</em>, <u>u</u>, <s>s</s>')
+
+
+@doc.demo('Tooltip with other content', '''
+    You can use HTML in tooltips.
+''')
+def tooltip_html_demo():
+    with ui.label('Mountains...'):
+        with ui.tooltip().classes('bg-transparent'):
+            ui.image('https://picsum.photos/id/377/640/360').classes('w-64')
+
+
+doc.reference(ui.tooltip)

+ 25 - 9
website/documentation/reference.py

@@ -5,7 +5,6 @@ from typing import Callable, Optional
 import docutils.core
 import docutils.core
 
 
 from nicegui import binding, ui
 from nicegui import binding, ui
-from nicegui.elements.markdown import apply_tailwind, remove_indentation
 
 
 from ..style import subheading
 from ..style import subheading
 
 
@@ -33,14 +32,17 @@ def generate_class_doc(class_obj: type) -> None:
         subheading('Methods')
         subheading('Methods')
         with ui.column().classes('gap-2'):
         with ui.column().classes('gap-2'):
             for name, method in sorted(methods.items()):
             for name, method in sorted(methods.items()):
-                ui.markdown(f'**`{name}`**`{_generate_method_signature_description(method)}`')
+                decorator = ''
+                if isinstance(class_obj.__dict__.get(name), staticmethod):
+                    decorator += '`@staticmethod`<br />'
+                if isinstance(class_obj.__dict__.get(name), classmethod):
+                    decorator += '`@classmethod`<br />'
+                ui.markdown(f'{decorator}**`{name}`**`{_generate_method_signature_description(method)}`')
                 if method.__doc__:
                 if method.__doc__:
                     _render_docstring(method.__doc__).classes('ml-8')
                     _render_docstring(method.__doc__).classes('ml-8')
     if ancestors:
     if ancestors:
         subheading('Inherited from')
         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:
 def _is_method_or_property(cls: type, attribute_name: str) -> bool:
@@ -48,7 +50,12 @@ def _is_method_or_property(cls: type, attribute_name: str) -> bool:
     return (
     return (
         inspect.isfunction(attribute) or
         inspect.isfunction(attribute) or
         inspect.ismethod(attribute) or
         inspect.ismethod(attribute) or
-        isinstance(attribute, (property, binding.BindableProperty))
+        isinstance(attribute, (
+            staticmethod,
+            classmethod,
+            property,
+            binding.BindableProperty,
+        ))
     )
     )
 
 
 
 
@@ -92,10 +99,19 @@ def _generate_method_signature_description(method: Callable) -> str:
 
 
 
 
 def _render_docstring(doc: str, with_params: bool = True) -> ui.html:
 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 ', '')
     doc = doc.replace('param ', '')
     html = docutils.core.publish_parts(doc, writer_name='html5_polyglot')['html_body']
     html = docutils.core.publish_parts(doc, writer_name='html5_polyglot')['html_body']
-    html = apply_tailwind(html)
     if not with_params:
     if not with_params:
         html = re.sub(r'<dl class=".* simple">.*?</dl>', '', html, flags=re.DOTALL)
         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
 import docutils.core
 
 
 from nicegui import ui
 from nicegui import ui
-from nicegui.elements.markdown import apply_tailwind
 
 
 from ..header import add_head_html, add_header
 from ..header import add_head_html, add_header
 from ..style import section_heading, subheading
 from ..style import section_heading, subheading
@@ -43,8 +42,7 @@ def render_page(documentation: DocumentationPage, *, with_menu: bool = True) ->
                 if part.description_format == 'rst':
                 if part.description_format == 'rst':
                     description = part.description.replace('param ', '')
                     description = part.description.replace('param ', '')
                     html = docutils.core.publish_parts(description, writer_name='html5_polyglot')['html_body']
                     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:
                 else:
                     ui.markdown(part.description).classes('bold-links arrow-links')
                     ui.markdown(part.description).classes('bold-links arrow-links')
             if part.ui:
             if part.ui:

+ 2 - 2
website/documentation/windows.py

@@ -47,12 +47,12 @@ def _window(type_: WindowType, *, title: str = '', tab: Union[str, Callable] = '
 
 
 def python_window(title: Optional[str] = None, *, classes: str = '') -> ui.column:
 def python_window(title: Optional[str] = None, *, classes: str = '') -> ui.column:
     """Create a window for Python code."""
     """Create a window for Python code."""
-    return _window('python', title=title or 'main.py', classes=classes).classes('p-2 python-window')
+    return _window('python', title=title or 'main.py', classes=classes).classes('px-4 py-2 python-window')
 
 
 
 
 def bash_window(*, classes: str = '') -> ui.column:
 def bash_window(*, classes: str = '') -> ui.column:
     """Create a window for Bash code."""
     """Create a window for Bash code."""
-    return _window('bash', title='bash', classes=classes).classes('p-2 bash-window')
+    return _window('bash', title='bash', classes=classes).classes('px-4 py-2 bash-window')
 
 
 
 
 def browser_window(title: Optional[Union[str, Callable]] = None, *, classes: str = '') -> ui.column:
 def browser_window(title: Optional[Union[str, Callable]] = None, *, classes: str = '') -> ui.column:

+ 0 - 2
website/examples.py

@@ -24,8 +24,6 @@ examples: List[Example] = [
     Example('Authentication', 'shows how to use sessions to build a login screen'),
     Example('Authentication', 'shows how to use sessions to build a login screen'),
     Example('Modularization', 'provides an example of how to modularize your application into multiple files and reuse code'),
     Example('Modularization', 'provides an example of how to modularize your application into multiple files and reuse code'),
     Example('FastAPI', 'illustrates the integration of NiceGUI with an existing FastAPI application'),
     Example('FastAPI', 'illustrates the integration of NiceGUI with an existing FastAPI application'),
-    Example('Map',
-            'demonstrates wrapping the JavaScript library [leaflet](https://leafletjs.com/) to display a map at specific locations'),
     Example('AI Interface',
     Example('AI Interface',
             'utilizes the [replicate](https://replicate.com) library to perform voice-to-text transcription and generate images from prompts with Stable Diffusion'),
             'utilizes the [replicate](https://replicate.com) library to perform voice-to-text transcription and generate images from prompts with Stable Diffusion'),
     Example('3D Scene', 'creates a webGL view and loads an STL mesh illuminated with a spotlight'),
     Example('3D Scene', 'creates a webGL view and loads an STL mesh illuminated with a spotlight'),

+ 1 - 1
website/static/style.css

@@ -119,7 +119,7 @@ dl.docinfo dd {
 }
 }
 dl.field-list p,
 dl.field-list p,
 dl.docinfo p {
 dl.docinfo p {
-  margin-bottom: 0;
+  margin: 0;
 }
 }
 
 
 .dark-box {
 .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'):
     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.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')
         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:
 def side_menu() -> ui.left_drawer:

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels