Bläddra i källkod

Merge branch 'main' into feature/enhanced_validations

# Conflicts:
#	nicegui/elements/mixins/validation_element.py
Falko Schindler 1 år sedan
förälder
incheckning
5cbbf2af07
99 ändrade filer med 2431 tillägg och 277 borttagningar
  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. 25 8
      nicegui/client.py
  9. 21 1
      nicegui/dependencies.py
  10. 15 4
      nicegui/element.py
  11. 7 6
      nicegui/elements/aggrid.py
  12. 1 3
      nicegui/elements/card.py
  13. 1 0
      nicegui/elements/editor.py
  14. 6 4
      nicegui/elements/expansion.py
  15. 3 3
      nicegui/elements/input.js
  16. 2 2
      nicegui/elements/input.py
  17. 18 10
      nicegui/elements/interactive_image.js
  18. 8 2
      nicegui/elements/interactive_image.py
  19. 126 0
      nicegui/elements/leaflet.js
  20. 131 0
      nicegui/elements/leaflet.py
  21. 29 0
      nicegui/elements/leaflet_layer.py
  22. 48 0
      nicegui/elements/leaflet_layers.py
  23. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/layers-2x.png
  24. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/layers.png
  25. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/marker-icon-2x.png
  26. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/marker-icon.png
  27. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/marker-shadow.png
  28. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/spritesheet-2x.png
  29. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/spritesheet.png
  30. 156 0
      nicegui/elements/lib/leaflet/leaflet-draw/images/spritesheet.svg
  31. 10 0
      nicegui/elements/lib/leaflet/leaflet-draw/leaflet.draw.css
  32. 7 0
      nicegui/elements/lib/leaflet/leaflet-draw/leaflet.draw.js
  33. BIN
      nicegui/elements/lib/leaflet/leaflet/images/layers-2x.png
  34. BIN
      nicegui/elements/lib/leaflet/leaflet/images/layers.png
  35. BIN
      nicegui/elements/lib/leaflet/leaflet/images/marker-icon-2x.png
  36. BIN
      nicegui/elements/lib/leaflet/leaflet/images/marker-icon.png
  37. BIN
      nicegui/elements/lib/leaflet/leaflet/images/marker-shadow.png
  38. 661 0
      nicegui/elements/lib/leaflet/leaflet/leaflet.css
  39. 4 0
      nicegui/elements/lib/leaflet/leaflet/leaflet.js
  40. 0 0
      nicegui/elements/lib/leaflet/leaflet/leaflet.js.map
  41. 1 21
      nicegui/elements/markdown.py
  42. 5 4
      nicegui/elements/mixins/validation_element.py
  43. 1 1
      nicegui/elements/mixins/value_element.py
  44. 11 0
      nicegui/elements/notification.js
  45. 165 0
      nicegui/elements/notification.py
  46. 4 2
      nicegui/elements/pyplot.py
  47. 58 12
      nicegui/elements/query.py
  48. 0 5
      nicegui/elements/scene.py
  49. 2 1
      nicegui/elements/select.js
  50. 13 0
      nicegui/elements/space.py
  51. 38 7
      nicegui/elements/table.py
  52. 5 3
      nicegui/elements/tooltip.py
  53. 1 2
      nicegui/events.py
  54. 31 6
      nicegui/functions/html.py
  55. 23 0
      nicegui/functions/on.py
  56. 14 0
      nicegui/functions/page_title.py
  57. 5 0
      nicegui/helpers.py
  58. 12 1
      nicegui/nicegui.py
  59. 6 5
      nicegui/page_layout.py
  60. 0 0
      nicegui/py.typed
  61. 132 19
      nicegui/static/nicegui.css
  62. 27 0
      nicegui/static/utils/resources.js
  63. 6 1
      nicegui/storage.py
  64. 10 9
      nicegui/templates/index.html
  65. 11 1
      nicegui/ui.py
  66. 19 0
      npm.json
  67. 6 0
      npm.py
  68. 2 2
      pyproject.toml
  69. 12 2
      tests/conftest.py
  70. 26 5
      tests/screen.py
  71. 2 0
      tests/test_endpoint_docs.py
  72. 2 0
      tests/test_input.py
  73. 39 0
      tests/test_leaflet.py
  74. 24 0
      tests/test_notification.py
  75. 11 0
      tests/test_number.py
  76. 21 0
      tests/test_page_title.py
  77. 10 4
      tests/test_select.py
  78. 13 3
      tests/test_table.py
  79. 18 16
      website/documentation/content/generic_events_documentation.py
  80. 18 0
      website/documentation/content/grid_documentation.py
  81. 15 2
      website/documentation/content/interactive_image_documentation.py
  82. 124 0
      website/documentation/content/leaflet_documentation.py
  83. 22 0
      website/documentation/content/notification_documentation.py
  84. 8 0
      website/documentation/content/page_title_documentation.py
  85. 3 0
      website/documentation/content/query_documentation.py
  86. 5 3
      website/documentation/content/section_data_elements.py
  87. 6 16
      website/documentation/content/section_page_layout.py
  88. 2 1
      website/documentation/content/section_pages_routing.py
  89. 28 0
      website/documentation/content/section_styling_appearance.py
  90. 24 0
      website/documentation/content/space_documentation.py
  91. 12 0
      website/documentation/content/table_documentation.py
  92. 25 0
      website/documentation/content/tabs_documentation.py
  93. 37 0
      website/documentation/content/tooltip_documentation.py
  94. 25 9
      website/documentation/reference.py
  95. 1 3
      website/documentation/rendering.py
  96. 2 2
      website/documentation/windows.py
  97. 0 2
      website/examples.py
  98. 1 1
      website/static/style.css
  99. 1 2
      website/style.py

+ 1 - 1
.vscode/settings.json

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

+ 3 - 3
CITATION.cff

@@ -8,7 +8,7 @@ authors:
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
 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
-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))
 - aggrid: 30.2.0 ([MIT](https://opensource.org/licenses/MIT))
 - 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))
 - nipplejs: 0.10.1 ([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()
 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]
 if not matching_milestones:
     print(f'Milestone "{milestone_title}" not found!')

+ 25 - 8
nicegui/client.py

@@ -11,10 +11,9 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterable, Iter
 from fastapi import Request
 from fastapi.responses import Response
 from fastapi.templating import Jinja2Templates
+from typing_extensions import Self
 
-from nicegui import json
-
-from . import background_tasks, binding, core, helpers, outbox
+from . import background_tasks, binding, core, helpers, json, outbox
 from .awaitable_response import AwaitableResponse
 from .dependencies import generate_resources
 from .element import Element
@@ -38,6 +37,12 @@ class Client:
     auto_index_client: Client
     """The client that is used to render the auto-index page."""
 
+    shared_head_html = ''
+    """HTML to be inserted in the <head> of every page template."""
+
+    shared_body_html = ''
+    """HTML to be inserted in the <body> of every page template."""
+
     def __init__(self, page: page, *, shared: bool = False) -> None:
         self.id = str(uuid.uuid4())
         self.created = time.time()
@@ -59,8 +64,10 @@ class Client:
 
         self.waiting_javascript_commands: Dict[str, Any] = {}
 
-        self.head_html = ''
-        self.body_html = ''
+        self.title: Optional[str] = None
+
+        self._head_html = ''
+        self._body_html = ''
 
         self.page = page
 
@@ -84,11 +91,21 @@ class Client:
         """Return True if the client is connected, False otherwise."""
         return self.environ is not None
 
-    def __enter__(self):
+    @property
+    def head_html(self) -> str:
+        """Return the HTML code to be inserted in the <head> of the page template."""
+        return self.shared_head_html + self._head_html
+
+    @property
+    def body_html(self) -> str:
+        """Return the HTML code to be inserted in the <body> of the page template."""
+        return self.shared_body_html + self._body_html
+
+    def __enter__(self) -> Self:
         self.content.__enter__()
         return self
 
-    def __exit__(self, *_):
+    def __exit__(self, *_) -> None:
         self.content.__exit__()
 
     def build_response(self, request: Request, status_code: int = 200) -> Response:
@@ -113,7 +130,7 @@ class Client:
             'imports': json.dumps(imports),
             'js_imports': '\n'.join(js_imports),
             '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(),
             'favicon_url': get_favicon_url(self.page, prefix),
             'dark': str(self.page.resolve_dark()),

+ 21 - 1
nicegui/dependencies.py

@@ -38,6 +38,12 @@ class JsComponent(Component):
     pass
 
 
+@dataclass(**KWONLY_SLOTS)
+class Resource:
+    key: str
+    path: Path
+
+
 @dataclass(**KWONLY_SLOTS)
 class Library:
     key: str
@@ -49,6 +55,7 @@ class Library:
 vue_components: Dict[str, VueComponent] = {}
 js_components: Dict[str, JsComponent] = {}
 libraries: Dict[str, Library] = {}
+resources: Dict[str, Resource] = {}
 
 
 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}"')
 
 
+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:
     """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.
     """
     nicegui_base = Path(__file__).parent
+    is_file = path.is_file()
     try:
         path = path.relative_to(nicegui_base)
     except ValueError:
         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:

+ 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 . import context, core, events, json, outbox, storage
+from . import context, core, events, helpers, json, outbox, storage
 from .awaitable_response import AwaitableResponse, NullResponse
-from .dependencies import Component, Library, register_library, register_vue_component
+from .dependencies import Component, Library, register_library, register_resource, register_vue_component
 from .elements.mixins.visibility import Visibility
 from .event_listener import EventListener
 from .slot import Slot
 from .tailwind import Tailwind
+from .version import __version__
 
 if TYPE_CHECKING:
     from .client import Client
@@ -138,6 +139,14 @@ class Element(Visibility):
         cls._default_classes = copy(cls._default_classes)
         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:
         """Add a slot to the element.
 
@@ -152,7 +161,7 @@ class Element(Visibility):
         self.default_slot.__enter__()
         return self
 
-    def __exit__(self, *_):
+    def __exit__(self, *_) -> None:
         self.default_slot.__exit__(*_)
 
     def __iter__(self) -> Iterator[Element]:
@@ -388,7 +397,7 @@ class Element(Visibility):
         if handler:
             listener = EventListener(
                 element_id=self.id,
-                type=type,
+                type=helpers.kebab_to_camel_case(type),
                 args=[args] if args and isinstance(args[0], str) else args,  # type: ignore
                 handler=handler,
                 throttle=throttle,
@@ -408,6 +417,8 @@ class Element(Visibility):
 
     def update(self) -> None:
         """Update the element on the client side."""
+        if self.is_deleted:
+            return
         outbox.enqueue_update(self)
 
     def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:

+ 7 - 6
nicegui/elements/aggrid.py

@@ -1,7 +1,7 @@
-from __future__ import annotations
-
 from typing import Dict, List, Optional, cast
 
+from typing_extensions import Self
+
 from .. import optional_features
 from ..awaitable_response import AwaitableResponse
 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(f'ag-theme-{theme}')
 
-    @staticmethod
-    def from_pandas(df: pd.DataFrame, *,
+    @classmethod
+    def from_pandas(cls,
+                    df: 'pd.DataFrame', *,
                     theme: str = 'balham',
                     auto_size_columns: bool = True,
-                    options: Dict = {}) -> AgGrid:
+                    options: Dict = {}) -> Self:
         """Create an AG Grid from a Pandas DataFrame.
 
         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[period_cols] = df[period_cols].astype(str)
 
-        return AgGrid({
+        return cls({
             'columnDefs': [{'field': str(col)} for col in df.columns],
             'rowData': df.to_dict('records'),
             'suppressDotNotation': True,

+ 1 - 3
nicegui/elements/card.py

@@ -22,9 +22,7 @@ class Card(Element):
 
     def tight(self) -> Self:
         """Remove padding and gaps between nested elements."""
-        self._classes.clear()
-        self._style.clear()
-        return self
+        return self.classes('nicegui-card-tight')
 
 
 class CardSection(Element):

+ 1 - 0
nicegui/elements/editor.py

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

+ 6 - 4
nicegui/elements/expansion.py

@@ -1,10 +1,11 @@
 from typing import Any, Callable, Optional
 
 from .mixins.disableable_element import DisableableElement
+from .mixins.text_element import TextElement
 from .mixins.value_element import ValueElement
 
 
-class Expansion(ValueElement, DisableableElement):
+class Expansion(TextElement, ValueElement, DisableableElement):
 
     def __init__(self,
                  text: Optional[str] = None, *,
@@ -21,9 +22,7 @@ class Expansion(ValueElement, DisableableElement):
         :param value: whether the expansion should be opened on creation (default: `False`)
         :param on_value_change: callback to execute when value changes
         """
-        super().__init__(tag='q-expansion-item', value=value, on_value_change=on_value_change)
-        if text is not None:
-            self._props['label'] = text
+        super().__init__(tag='q-expansion-item', text=text, value=value, on_value_change=on_value_change)
         self._props['icon'] = icon
         self._classes.append('nicegui-expansion')
 
@@ -34,3 +33,6 @@ class Expansion(ValueElement, DisableableElement):
     def close(self) -> None:
         """Close the expansion."""
         self.value = False
+
+    def _text_to_model_text(self, text: str) -> None:
+        self._props['label'] = text

+ 3 - 3
nicegui/elements/input.js

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

+ 2 - 2
nicegui/elements/input.py

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

+ 18 - 10
nicegui/elements/interactive_image.js

@@ -1,17 +1,17 @@
 export default {
   template: `
-    <div style="position:relative">
+    <div :style="{ position: 'relative', aspectRatio: size ? size[0] / size[1] : undefined }">
       <img
         ref="img"
         :src="computed_src"
-        style="width:100%; height:100%;"
+        :style="{ width: '100%', height: '100%', opacity: src ? 1 : 0 }"
         @load="onImageLoaded"
         v-on="onCrossEvents"
         v-on="onUserEvents"
         draggable="false"
       />
       <svg style="position:absolute;top:0;left:0;pointer-events:none" :viewBox="viewBox">
-        <g v-if="cross" :style="{ display: cssDisplay }">
+        <g v-if="cross" :style="{ display: showCross ? 'block' : 'none' }">
           <line :x1="x" y1="0" :x2="x" y2="100%" stroke="black" />
           <line x1="0" :y1="y" x2="100%" :y2="y" stroke="black" />
         </g>
@@ -25,7 +25,7 @@ export default {
       viewBox: "0 0 0 0",
       x: 100,
       y: 100,
-      cssDisplay: "none",
+      showCross: false,
       computed_src: undefined,
       waiting_source: undefined,
       loading: false,
@@ -60,19 +60,26 @@ export default {
         this.computed_src = new_src;
         this.loading = true;
       }
+      if (!this.src && this.size) {
+        this.viewBox = `0 0 ${this.size[0]} ${this.size[1]}`;
+      }
     },
     updateCrossHair(e) {
-      this.x = (e.offsetX * e.target.naturalWidth) / e.target.clientWidth;
-      this.y = (e.offsetY * e.target.naturalHeight) / e.target.clientHeight;
+      const width = this.src ? e.target.naturalWidth : this.size ? this.size[0] : 1;
+      const height = this.src ? e.target.naturalHeight : this.size ? this.size[1] : 1;
+      this.x = (e.offsetX * width) / e.target.clientWidth;
+      this.y = (e.offsetY * height) / e.target.clientHeight;
     },
     onImageLoaded(e) {
       this.viewBox = `0 0 ${e.target.naturalWidth} ${e.target.naturalHeight}`;
     },
     onMouseEvent(type, e) {
+      const width = this.src ? e.target.naturalWidth : this.size ? this.size[0] : 1;
+      const height = this.src ? e.target.naturalHeight : this.size ? this.size[1] : 1;
       this.$emit("mouse", {
         mouse_event_type: type,
-        image_x: (e.offsetX * e.target.naturalWidth) / e.target.clientWidth,
-        image_y: (e.offsetY * e.target.naturalHeight) / e.target.clientHeight,
+        image_x: (e.offsetX * width) / e.target.clientWidth,
+        image_y: (e.offsetY * height) / e.target.clientHeight,
         button: e.button,
         buttons: e.buttons,
         altKey: e.altKey,
@@ -86,8 +93,8 @@ export default {
     onCrossEvents() {
       if (!this.cross) return {};
       return {
-        mouseenter: () => (this.cssDisplay = "block"),
-        mouseleave: () => (this.cssDisplay = "none"),
+        mouseenter: () => (this.showCross = true),
+        mouseleave: () => (this.showCross = false),
         mousemove: (event) => this.updateCrossHair(event),
       };
     },
@@ -102,6 +109,7 @@ export default {
   props: {
     src: String,
     content: String,
+    size: Object,
     events: Array,
     cross: Boolean,
     t: String,

+ 8 - 2
nicegui/elements/interactive_image.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import time
 from pathlib import Path
-from typing import Any, Callable, List, Optional, Union, cast
+from typing import Any, Callable, List, Optional, Tuple, Union, cast
 
 from .. import optional_features
 from ..events import GenericEventArguments, MouseEventArguments, handle_event
@@ -24,6 +24,7 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
     def __init__(self,
                  source: Union[str, Path, 'PIL_Image'] = '', *,
                  content: str = '',
+                 size: Optional[Tuple[int, int]] = None,
                  on_mouse: Optional[Callable[..., Any]] = None,
                  events: List[str] = ['click'],
                  cross: bool = False,
@@ -36,8 +37,12 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
         Thereby repeatedly updating the image source will automatically adapt to the available bandwidth.
         See `OpenCV Webcam <https://github.com/zauberzeug/nicegui/tree/main/examples/opencv_webcam/main.py>`_ for an example.
 
-        :param source: the source of the image; can be an URL, local file path or a base64 string
+        You can also pass a tuple of width and height instead of an image source.
+        This will create an empty image with the given size.
+
+        :param source: the source of the image; can be an URL, local file path, a base64 string or just an image size
         :param content: SVG content which should be overlaid; viewport has the same dimensions as the image
+        :param size: size of the image (width, height) in pixels; only used if `source` is not set
         :param on_mouse: callback for mouse events (yields `type`, `image_x` and `image_y`)
         :param events: list of JavaScript events to subscribe to (default: `['click']`)
         :param cross: whether to show crosshairs (default: `False`)
@@ -45,6 +50,7 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
         super().__init__(source=source, content=content)
         self._props['events'] = events
         self._props['cross'] = cross
+        self._props['size'] = size
 
         def handle_mouse(e: GenericEventArguments) -> None:
             if on_mouse is 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}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 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;
+		}
+	}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
nicegui/elements/lib/leaflet/leaflet/leaflet.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
nicegui/elements/lib/leaflet/leaflet/leaflet.js.map


+ 1 - 21
nicegui/elements/markdown.py

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

+ 5 - 4
nicegui/elements/mixins/validation_element.py

@@ -1,4 +1,4 @@
-from typing import Any, Callable, Dict, Optional, Union, Tuple, List
+from typing import Any, Callable, Dict, List, Optional, Tuple, Union
 
 from .value_element import ValueElement
 
@@ -31,20 +31,21 @@ class ValidationElement(ValueElement):
         self._error = None
         self.props(remove='error')
 
-    def validate(self) -> None:
+    def validate(self) -> bool:
         """Validate the current value and set the error message if necessary."""
         if isinstance(self.validation, dict):
             for message, check in self.validation.items():
                 if not check(self.value):
                     self.set_error_message(message)
-                    return
+                    return False
         else:
             for check in self.validation:
                 ret, message = check(self.value)
                 if not ret:
                     self.set_error_message(message)
-                    return
+                    return False
         self.clear_error_message()
+        return True
 
     def _handle_value_change(self, value: Any) -> None:
         super()._handle_value_change(value)

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

@@ -20,10 +20,10 @@ class ValueElement(Element):
                  **kwargs: Any,
                  ) -> None:
         super().__init__(**kwargs)
+        self._send_update_on_value_change = True
         self.set_value(value)
         self._props[self.VALUE_PROP] = self._value_to_model_value(value)
         self._props['loopback'] = self.LOOPBACK
-        self._send_update_on_value_change = True
         self._change_handler = on_value_change
 
         def handle_change(e: GenericEventArguments) -> None:

+ 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,
+  },
+};

+ 165 - 0
nicegui/elements/notification.py

@@ -0,0 +1,165 @@
+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 icon: optional name of an icon to be displayed in the notification (default: `None`)
+        :param spinner: display a spinner in the notification (default: False)
+        :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
 from typing import Any
 
+from typing_extensions import Self
+
 from .. import background_tasks, optional_features
 from ..client import Client
 from ..element import Element
@@ -41,11 +43,11 @@ class Pyplot(Element):
             self.fig.savefig(output, format='svg')
             self._props['innerHTML'] = output.getvalue()
 
-    def __enter__(self):
+    def __enter__(self) -> Self:
         plt.figure(self.fig)
         return self
 
-    def __exit__(self, *_):
+    def __exit__(self, *_) -> None:
         self._convert_to_html()
         if self.close:
             plt.close(self.fig)

+ 58 - 12
nicegui/elements/query.py

@@ -6,7 +6,7 @@ from .. import context
 from ..element import Element
 
 
-class Query(Element, component='query.js'):
+class QueryElement(Element, component='query.js'):
 
     def __init__(self, selector: str) -> None:
         super().__init__()
@@ -53,16 +53,62 @@ class Query(Element, component='query.js'):
         return self
 
 
-def query(selector: str) -> Query:
-    """Query Selector
+class Query:
 
-    To manipulate elements like the document body, you can use the `ui.query` function.
-    With the query result you can add classes, styles, and attributes like with every other UI element.
-    This can be useful for example to change the background color of the page (e.g. `ui.query('body').classes('bg-green')`).
+    def __init__(self, selector: str) -> None:
+        """Query Selector
+
+        To manipulate elements like the document body, you can use the `ui.query` function.
+        With the query result you can add classes, styles, and attributes like with every other UI element.
+        This can be useful for example to change the background color of the page (e.g. `ui.query('body').classes('bg-green')`).
+
+        :param selector: the CSS selector (e.g. "body", "#my-id", ".my-class", "div > p")
+        """
+        for element in context.get_client().elements.values():
+            if isinstance(element, QueryElement) and element._props['selector'] == selector:  # pylint: disable=protected-access
+                self.element = element
+                break
+        else:
+            self.element = QueryElement(selector)
+
+    def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
+            -> Self:
+        """Apply, remove, or replace HTML classes.
 
-    :param selector: the CSS selector (e.g. "body", "#my-id", ".my-class", "div > p")
-    """
-    for element in context.get_client().elements.values():
-        if isinstance(element, Query) and element._props['selector'] == selector:  # pylint: disable=protected-access
-            return element
-    return Query(selector)
+        This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
+
+        Removing or replacing classes can be helpful if predefined classes are not desired.
+
+        :param add: whitespace-delimited string of classes
+        :param remove: whitespace-delimited string of classes to remove from the element
+        :param replace: whitespace-delimited string of classes to use instead of existing ones
+        """
+        self.element.classes(add, remove=remove, replace=replace)
+        return self
+
+    def style(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
+            -> Self:
+        """Apply, remove, or replace CSS definitions.
+
+        Removing or replacing styles can be helpful if the predefined style is not desired.
+
+        :param add: semicolon-separated list of styles to add to the element
+        :param remove: semicolon-separated list of styles to remove from the element
+        :param replace: semicolon-separated list of styles to use instead of existing ones
+        """
+        self.element.style(add, remove=remove, replace=replace)
+        return self
+
+    def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self:
+        """Add or remove props.
+
+        This allows modifying the look of the element or its layout using `Quasar <https://quasar.dev/>`_ props.
+        Since props are simply applied as HTML attributes, they can be used with any HTML element.
+
+        Boolean properties are assumed ``True`` if no value is specified.
+
+        :param add: whitespace-delimited list of either boolean values or key=value pair to add
+        :param remove: whitespace-delimited list of property keys to remove
+        """
+        self.element.props(add, remove=remove)
+        return self

+ 0 - 5
nicegui/elements/scene.py

@@ -118,11 +118,6 @@ class Scene(Element,
                 obj.send()
 
     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:
             return NullResponse()
         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: {
     filterFn(val, update, abort) {
-      update(() => (this.filteredOptions = this.findFilteredOptions()));
+      update(() => (this.filteredOptions = val ? this.findFilteredOptions() : this.initialOptions));
     },
     findFilteredOptions() {
       const needle = this.$el.querySelector("input[type=search]")?.value.toLocaleLowerCase();
@@ -30,6 +30,7 @@ export default {
     },
   },
   updated() {
+    if (!this.$attrs.multiple) return;
     const newFilteredOptions = this.findFilteredOptions();
     if (newFilteredOptions.length !== this.filteredOptions.length) {
       this.filteredOptions = newFilteredOptions;

+ 13 - 0
nicegui/elements/space.py

@@ -0,0 +1,13 @@
+from ..element import Element
+
+
+class Space(Element):
+
+    def __init__(self) -> None:
+        """Space
+
+        This element is based on Quasar's `QSpace <https://quasar.dev/vue-components/space>`_ component.
+
+        Its purpose is to simply fill all available space inside of a flexbox element.
+        """
+        super().__init__('q-space')

+ 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_extensions import Self
+
 from .. import optional_features
 from ..element import Element
-from ..events import GenericEventArguments, TableSelectionEventArguments, handle_event
+from ..events import GenericEventArguments, TableSelectionEventArguments, ValueChangeEventArguments, handle_event
 from .mixins.filter_element import FilterElement
 
 try:
@@ -24,6 +24,7 @@ class Table(FilterElement, component='table.js'):
                  selection: Optional[Literal['single', 'multiple']] = None,
                  pagination: Optional[Union[int, dict]] = None,
                  on_select: Optional[Callable[..., Any]] = None,
+                 on_pagination_change: Optional[Callable[..., Any]] = None,
                  ) -> None:
         """Table
 
@@ -36,6 +37,7 @@ class Table(FilterElement, component='table.js'):
         :param selection: selection type ("single" or "multiple"; default: `None`)
         :param pagination: a dictionary correlating to a pagination object or number of rows per page (`None` hides the pagination, 0 means "infinite"; default: `None`).
         :param on_select: callback which is invoked when the selection changes
+        :param on_pagination_change: callback which is invoked when the pagination changes
 
         If selection is 'single' or 'multiple', then a `selected` property is accessible containing the selected rows.
         """
@@ -63,13 +65,21 @@ class Table(FilterElement, component='table.js'):
             handle_event(on_select, arguments)
         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',
                     title: Optional[str] = None,
                     selection: Optional[Literal['single', 'multiple']] = 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.
 
         Note:
@@ -97,7 +107,7 @@ class Table(FilterElement, component='table.js'):
             df[complex_cols] = df[complex_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],
             rows=df.to_dict('records'),
             row_key=row_key,
@@ -146,6 +156,16 @@ class Table(FilterElement, component='table.js'):
         self._props['selected'][:] = value
         self.update()
 
+    @property
+    def pagination(self) -> dict:
+        """Pagination object."""
+        return self._props['pagination']
+
+    @pagination.setter
+    def pagination(self, value: dict) -> None:
+        self._props['pagination'] = value
+        self.update()
+
     @property
     def is_fullscreen(self) -> bool:
         """Whether the table is in fullscreen mode."""
@@ -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.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):
 
         def __init__(self) -> None:

+ 5 - 3
nicegui/elements/tooltip.py

@@ -3,12 +3,14 @@ from .mixins.text_element import TextElement
 
 class Tooltip(TextElement):
 
-    def __init__(self, text: str) -> None:
+    def __init__(self, text: str = '') -> None:
         """Tooltip
 
         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)

+ 1 - 2
nicegui/events.py

@@ -392,8 +392,7 @@ def handle_event(handler: Optional[Callable[..., Any]], arguments: EventArgument
         if isinstance(arguments, UiEventArguments):
             if arguments.sender.is_ignoring_events:
                 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:
             parent_slot = nullcontext()
 

+ 31 - 6
nicegui/functions/html.py

@@ -1,11 +1,36 @@
 from .. import context
+from ..client import Client
 
 
-def add_body_html(code: str) -> None:
-    """Add HTML code to the body of the page."""
-    context.get_client().body_html += code + '\n'
+def add_head_html(code: str, *, shared: bool = False) -> None:
+    """Add HTML code to the head of the page.
 
+    Note that this function can only be called before the page is sent to the client.
 
-def add_head_html(code: str) -> None:
-    """Add HTML code to the head of the page."""
-    context.get_client().head_html += code + '\n'
+    :param code: HTML code to add
+    :param shared: if True, the code is added to all pages
+    """
+    if shared:
+        Client.shared_head_html += code + '\n'
+    else:
+        client = context.get_client()
+        if client.has_socket_connection:
+            raise RuntimeError('Cannot add head HTML after the page has been sent to the client.')
+        client._head_html += code + '\n'  # pylint: disable=protected-access
+
+
+def add_body_html(code: str, *, shared: bool = False) -> None:
+    """Add HTML code to the body of the page.
+
+    Note that this function can only be called before the page is sent to the client.
+
+    :param code: HTML code to add
+    :param shared: if True, the code is added to all pages
+    """
+    if shared:
+        Client.shared_body_html += code + '\n'
+    else:
+        client = context.get_client()
+        if client.has_socket_connection:
+            raise RuntimeError('Cannot add body HTML after the page has been sent to the client.')
+        client._body_html += code + '\n'  # pylint: disable=protected-access

+ 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.start()
     return thread, cancel
+
+
+def kebab_to_camel_case(string: str) -> str:
+    """Convert a kebab-case string to camelCase."""
+    return ''.join(word.capitalize() if i else word for i, word in enumerate(string.split('-')))

+ 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 .app import App
 from .client import Client
-from .dependencies import js_components, libraries
+from .dependencies import js_components, libraries, resources
 from .error import error_content
 from .json import NiceGUIJSONResponse
 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')
 
 
+@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:
     """Handle the startup event."""
     if not app.config.has_run_config:

+ 6 - 5
nicegui/page_layout.py

@@ -44,7 +44,7 @@ class Header(ValueElement):
         :param wrap: whether the header should wrap its content (default: `True`)
         :param add_scroll_padding: whether to automatically prevent link targets from being hidden behind the header (default: `True`)
         """
-        _check_current_slot()
+        _check_current_slot(self)
         with context.get_client().layout:
             super().__init__(tag='q-header', value=value, on_value_change=None)
         self._classes.append('nicegui-header')
@@ -108,7 +108,7 @@ class Drawer(Element):
         :param top_corner: whether the drawer expands into the top corner (default: `False`)
         :param bottom_corner: whether the drawer expands into the bottom corner (default: `False`)
         """
-        _check_current_slot()
+        _check_current_slot(self)
         with context.get_client().layout:
             super().__init__('q-drawer')
         if value is None:
@@ -227,7 +227,7 @@ class Footer(ValueElement):
         :param elevated: whether the footer should have a shadow (default: `False`)
         :param wrap: whether the footer should wrap its content (default: `True`)
         """
-        _check_current_slot()
+        _check_current_slot(self)
         with context.get_client().layout:
             super().__init__(tag='q-footer', value=value, on_value_change=None)
         self.classes('nicegui-footer')
@@ -270,8 +270,9 @@ class PageSticky(Element):
         self._props['offset'] = [x_offset, y_offset]
 
 
-def _check_current_slot() -> None:
+def _check_current_slot(element: Element) -> None:
     parent = context.get_slot().parent
     if parent != parent.client.content:
-        log.warning('Layout elements should not be nested but must be direct children of the page content. '
+        log.warning(f'Found top level layout element "{element.__class__.__name__}" inside element "{parent.__class__.__name__}". '
+                    'Top level layout elements should not be nested but must be direct children of the page content. '
                     'This will be raising an exception in NiceGUI 1.5')  # DEPRECATED

+ 0 - 0
nicegui/py.typed


+ 132 - 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 */
 .nicegui-layout {
   outline: 2px solid transparent;
@@ -23,8 +29,8 @@
   display: flex;
   flex-direction: column;
   align-items: flex-start;
-  gap: 1rem;
-  padding: 1rem;
+  gap: var(--nicegui-default-gap);
+  padding: var(--nicegui-default-padding);
 }
 .nicegui-header,
 .nicegui-footer,
@@ -36,6 +42,10 @@
 .nicegui-column {
   padding: 0;
 }
+.nicegui-card-tight {
+  padding: 0;
+  gap: 0;
+}
 
 /* original padding for some Quasar elements */
 .nicegui-step .q-stepper__nav {
@@ -64,20 +74,132 @@
 
 /* HACK: avoid stutter when expansion item is toggled */
 .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::after {
   content: ""; /* the gap compensates for the missing vertical padding */
 }
 
+/* revert Tailwind's CSS reset for ui.editor and ui.markdown */
+.nicegui-editor .q-editor__content h1,
+.nicegui-markdown h1 {
+  font-size: 3rem;
+  line-height: 1;
+  margin-bottom: 1rem;
+  margin-top: 1.5rem;
+  font-weight: 300;
+}
+.nicegui-editor .q-editor__content h2,
+.nicegui-markdown h2 {
+  font-size: 2.25rem;
+  line-height: 2.5rem;
+  margin-bottom: 0.75rem;
+  margin-top: 1.25rem;
+  font-weight: 300;
+}
+.nicegui-editor .q-editor__content h3,
+.nicegui-markdown h3 {
+  font-size: 1.875rem;
+  line-height: 2.25rem;
+  margin-bottom: 0.5rem;
+  margin-top: 1rem;
+  font-weight: 400;
+}
+.nicegui-editor .q-editor__content h4,
+.nicegui-markdown h4 {
+  font-size: 1.5rem;
+  line-height: 2rem;
+  margin-bottom: 0.25rem;
+  margin-top: 0.75rem;
+  font-weight: 400;
+}
+.nicegui-editor .q-editor__content h5,
+.nicegui-markdown h5 {
+  font-size: 1.25rem;
+  line-height: 1.75rem;
+  margin-bottom: 0.125rem;
+  margin-top: 0.5rem;
+  font-weight: 400;
+}
+.nicegui-editor .q-editor__content h6,
+.nicegui-markdown h6 {
+  font-size: 1.125rem;
+  line-height: 1.75rem;
+  margin-bottom: 0.125rem;
+  margin-top: 0.5rem;
+  font-weight: 500;
+}
+.nicegui-editor .q-editor__content a,
+.nicegui-markdown a {
+  text-decoration-line: underline;
+  color: rgb(37 99 235); /* blue-600 */
+}
+.nicegui-editor .q-editor__content a:hover,
+.nicegui-markdown a:hover {
+  color: rgb(30 64 175); /* blue-800 */
+}
+.nicegui-editor .q-editor__content a:visited,
+.nicegui-markdown a:visited {
+  color: rgb(147 51 234); /* purple-600 */
+}
+.nicegui-editor .q-editor__content hr,
+.nicegui-markdown hr {
+  margin-block-start: 0.5em;
+  margin-block-end: 0.5em;
+  height: 1px;
+  border-style: none;
+  background-color: rgba(128, 128, 128, 0.25);
+}
+.nicegui-editor .q-editor__content ul,
+.nicegui-markdown ul {
+  list-style-type: initial;
+  padding-inline-start: 2.5rem;
+  margin-block-start: 0.25rem;
+  margin-block-end: 0.25rem;
+}
+.nicegui-editor .q-editor__content ol,
+.nicegui-markdown ol {
+  list-style-type: decimal;
+  padding-inline-start: 2.5rem;
+  margin-block-start: 0.25rem;
+  margin-block-end: 0.25rem;
+}
+.nicegui-editor .q-editor__content blockquote,
+.nicegui-markdown blockquote {
+  border-left: 0.25rem solid #8884;
+  padding: 0.25rem 1rem 0.25rem 1rem;
+  margin: 0.5rem 0;
+}
+.nicegui-editor .q-editor__content p,
+.nicegui-markdown p {
+  margin: 0.5rem 0;
+}
+.nicegui-editor .q-editor__content table,
+.nicegui-markdown table {
+  border-collapse: collapse;
+  margin: 0.5rem 0;
+}
+.nicegui-editor .q-editor__content th,
+.nicegui-markdown th {
+  padding: 0.5rem;
+  border: 1px solid #8884;
+}
+.nicegui-editor .q-editor__content td,
+.nicegui-markdown td {
+  padding: 0.5rem;
+  border: 1px solid #8884;
+}
+.nicegui-markdown .codehilite pre {
+  margin: 0.5rem 0;
+}
+
 /* other NiceGUI elements */
 .nicegui-grid {
   display: grid;
-  gap: 1rem;
+  gap: var(--nicegui-default-gap);
 }
-.nicegui-link:link,
-.nicegui-link:visited {
+.nicegui-link {
   text-decoration-line: underline;
   color: rgb(59 130 246);
 }
@@ -90,6 +212,7 @@
 }
 .nicegui-aggrid,
 .nicegui-echart,
+.nicegui-leaflet,
 .nicegui-scroll-area {
   width: 100%;
   height: 16rem;
@@ -102,19 +225,6 @@
   opacity: 1 !important;
   cursor: text !important;
 }
-.nicegui-markdown blockquote {
-  border-left: 0.25rem solid #8884;
-  padding: 1rem 1rem 0.5rem 1rem;
-  margin: 1rem 0;
-}
-.nicegui-markdown th {
-  padding: 0.5rem;
-  border: 1px solid #8884;
-}
-.nicegui-markdown td {
-  padding: 0.5rem;
-  border: 1px solid #8884;
-}
 h6.q-timeline__title {
   font-size: 1.25rem;
   font-weight: 500;
@@ -126,6 +236,9 @@ h6.q-timeline__title {
   box-shadow: 0 0 0.5em rgba(127, 159, 191, 0.05);
   border-radius: 0.25rem;
 }
+.nicegui-code .codehilite {
+  padding: 0 0.5rem;
+}
 
 /* connection 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 . import background_tasks, context, core, json, observables
+from .logging import log
 
 request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None)
 
@@ -43,7 +44,11 @@ class PersistentDict(observables.ObservableDict):
 
     def __init__(self, filepath: Path) -> None:
         self.filepath = filepath
-        data = json.loads(filepath.read_text()) if filepath.exists() else {}
+        try:
+            data = json.loads(filepath.read_text()) if filepath.exists() else {}
+        except Exception:
+            log.warning(f'Could not load storage file {filepath}')
+            data = {}
         super().__init__(data, on_change=self.backup)
 
     def backup(self) -> None:

+ 10 - 9
nicegui/templates/index.html

@@ -54,6 +54,9 @@
           return element.$refs.qRef[method_name](...args);
         }
       }
+      function emitEvent(event_name, ...args) {
+        getElement(0).$emit(event_name, ...args);
+      }
     </script>
     <script type="module">
       const True = true;
@@ -160,9 +163,9 @@
               listener_id: event.listener_id,
               args: stringifyEventArgs(args, event.args),
             };
-            const emitter = () => window.socket.emit("event", data);
+            const emitter = () => window.socket?.emit("event", data);
             throttle(emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id);
-            if (element.props["loopback"] === False && event.type == "update:model-value") {
+            if (element.props["loopback"] === False && event.type == "update:modelValue") {
               element.props["model-value"] = args;
             }
           };
@@ -272,13 +275,11 @@
                 window.location.reload(); // see https://github.com/zauberzeug/nicegui/issues/198
               }
             },
-            try_reconnect: () => {
-              const checkAndReload = async () => {
-                await fetch(window.location.href, { headers: { 'NiceGUI-Check': 'try_reconnect' } });
-                console.log('reloading because reconnect was requested')
-                window.location.reload();
-              };
-              setInterval(checkAndReload, 500);
+            try_reconnect: async () => {
+              document.getElementById('popup').style.opacity = 1;
+              await fetch(window.location.href, { headers: { 'NiceGUI-Check': 'try_reconnect' } });
+              console.log('reloading because reconnect was requested')
+              window.location.reload();
             },
             disconnect: () => {
               document.getElementById('popup').style.opacity = 1;

+ 11 - 1
nicegui/ui.py

@@ -37,6 +37,7 @@ __all__ = [
     'keyboard',
     'knob',
     'label',
+    'leaflet',
     'line_plot',
     'link',
     'link_target',
@@ -45,6 +46,7 @@ __all__ = [
     'menu',
     'menu_item',
     'mermaid',
+    'notification',
     'number',
     'pagination',
     'plotly',
@@ -59,6 +61,7 @@ __all__ = [
     'select',
     'separator',
     'slider',
+    'space',
     'spinner',
     'splitter',
     'step',
@@ -86,6 +89,7 @@ __all__ = [
     'run_javascript',
     'notify',
     'open',
+    'page_title',
     'refreshable',
     'state',
     'update',
@@ -94,6 +98,7 @@ __all__ = [
     'footer',
     'header',
     'left_drawer',
+    'on',
     'page_sticky',
     'right_drawer',
     'run',
@@ -138,6 +143,7 @@ from .elements.json_editor import JsonEditor as json_editor
 from .elements.keyboard import Keyboard as keyboard
 from .elements.knob import Knob as knob
 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.link import Link as link
 from .elements.link import LinkTarget as link_target
@@ -146,13 +152,14 @@ from .elements.markdown import Markdown as markdown
 from .elements.menu import Menu as menu
 from .elements.menu import MenuItem as menu_item
 from .elements.mermaid import Mermaid as mermaid
+from .elements.notification import Notification as notification
 from .elements.number import Number as number
 from .elements.pagination import Pagination as pagination
 from .elements.plotly import Plotly as plotly
 from .elements.progress import CircularProgress as circular_progress
 from .elements.progress import LinearProgress as linear_progress
 from .elements.pyplot import Pyplot as pyplot
-from .elements.query import query
+from .elements.query import Query as query
 from .elements.radio import Radio as radio
 from .elements.row import Row as row
 from .elements.scene import Scene as scene
@@ -160,6 +167,7 @@ from .elements.scroll_area import ScrollArea as scroll_area
 from .elements.select import Select as select
 from .elements.separator import Separator as separator
 from .elements.slider import Slider as slider
+from .elements.space import Space as space
 from .elements.spinner import Spinner as spinner
 from .elements.splitter import Splitter as splitter
 from .elements.stepper import Step as step
@@ -185,7 +193,9 @@ from .functions.download import download
 from .functions.html import add_body_html, add_head_html
 from .functions.javascript import run_javascript
 from .functions.notify import notify
+from .functions.on import on
 from .functions.open import open  # pylint: disable=redefined-builtin
+from .functions.page_title import page_title
 from .functions.refreshable import refreshable, state
 from .functions.update import update
 from .page import page

+ 19 - 0
npm.json

@@ -52,6 +52,25 @@
       "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": {
     "destination": "nicegui/elements/lib",
     "keep": ["package/dist/.*\\.m?js"],

+ 6 - 0
npm.py

@@ -22,8 +22,10 @@ import requests
 
 parser = ArgumentParser()
 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()
 root_path = Path(args.path)
+names = args.name or None
 
 
 def prepare(path: Path) -> Path:
@@ -56,6 +58,7 @@ KNOWN_LICENSES = {
     'MIT': 'https://opensource.org/licenses/MIT',
     'ISC': 'https://opensource.org/licenses/ISC',
     '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.
@@ -63,6 +66,9 @@ tmp = cleanup(root_path / '.npm')
 
 dependencies: dict[str, dict] = json.loads((root_path / 'npm.json').read_text())
 for key, dependency in dependencies.items():
+    if names is not None and key not in names:
+        continue
+
     # Reset destination folder.
     destination = prepare(root_path / dependency['destination'] / key)
 

+ 2 - 2
pyproject.toml

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

+ 12 - 2
tests/conftest.py

@@ -8,12 +8,15 @@ import icecream
 import pytest
 from selenium import webdriver
 from selenium.webdriver.chrome.service import Service
+from starlette.routing import Route
 
 from nicegui import Client, app, binding, core
 from nicegui.page import page
 
 from .screen import Screen
 
+# pylint: disable=redefined-outer-name
+
 DOWNLOAD_DIR = Path(__file__).parent / 'download'
 
 icecream.install()
@@ -21,6 +24,7 @@ icecream.install()
 
 @pytest.fixture
 def chrome_options(chrome_options: webdriver.ChromeOptions) -> webdriver.ChromeOptions:
+    """Configure the Chrome driver options."""
     chrome_options.add_argument('disable-dev-shm-using')
     chrome_options.add_argument('no-sandbox')
     chrome_options.add_argument('headless')
@@ -38,14 +42,16 @@ def chrome_options(chrome_options: webdriver.ChromeOptions) -> webdriver.ChromeO
 
 @pytest.fixture
 def capabilities(capabilities: Dict) -> Dict:
+    """Configure the Chrome driver capabilities."""
     capabilities['goog:loggingPrefs'] = {'browser': 'ALL'}
     return capabilities
 
 
 @pytest.fixture(autouse=True)
 def reset_globals() -> Generator[None, None, None]:
+    """Reset the global state of the NiceGUI package."""
     for route in app.routes:
-        if route.path.startswith('/_nicegui/auto/static/'):
+        if isinstance(route, Route) and route.path.startswith('/_nicegui/auto/static/'):
             app.remove_route(route.path)
     for path in {'/'}.union(Client.page_routes.values()):
         app.remove_route(path)
@@ -54,7 +60,7 @@ def reset_globals() -> Generator[None, None, None]:
     app.user_middleware.clear()
     # NOTE favicon routes must be removed separately because they are not "pages"
     for route in app.routes:
-        if route.path.endswith('/favicon.ico'):
+        if isinstance(route, Route) and route.path.endswith('/favicon.ico'):
             app.routes.remove(route)
     importlib.reload(core)
     Client.instances.clear()
@@ -64,10 +70,12 @@ def reset_globals() -> Generator[None, None, None]:
     # NOTE we need to re-add the auto index route because we removed all routes above
     app.get('/')(Client.auto_index_client.build_response)
     binding.reset()
+    yield
 
 
 @pytest.fixture(scope='session', autouse=True)
 def remove_all_screenshots() -> None:
+    """Remove all screenshots from the screenshot directory before the test session."""
     if os.path.exists(Screen.SCREENSHOT_DIR):
         for name in os.listdir(Screen.SCREENSHOT_DIR):
             os.remove(os.path.join(Screen.SCREENSHOT_DIR, name))
@@ -75,6 +83,7 @@ def remove_all_screenshots() -> None:
 
 @pytest.fixture(scope='function')
 def driver(chrome_options: webdriver.ChromeOptions) -> webdriver.Chrome:
+    """Create a new Chrome driver instance."""
     s = Service()
     driver_ = webdriver.Chrome(service=s, options=chrome_options)
     driver_.implicitly_wait(Screen.IMPLICIT_WAIT)
@@ -86,6 +95,7 @@ def driver(chrome_options: webdriver.ChromeOptions) -> webdriver.Chrome:
 @pytest.fixture
 def screen(driver: webdriver.Chrome, request: pytest.FixtureRequest, caplog: pytest.LogCaptureFixture) \
         -> Generator[Screen, None, None]:
+    """Create a new Screen instance."""
     screen_ = Screen(driver, caplog)
     yield screen_
     if screen_.is_open:

+ 26 - 5
tests/screen.py

@@ -38,7 +38,8 @@ class Screen:
         self.server_thread.start()
 
     @property
-    def is_open(self) -> None:
+    def is_open(self) -> bool:
+        """Check if the browser is open."""
         # https://stackoverflow.com/a/66150779/3419103
         try:
             self.selenium.current_url  # pylint: disable=pointless-statement
@@ -74,31 +75,37 @@ class Screen:
                 if time.time() > deadline:
                     raise
                 time.sleep(0.1)
+                assert self.server_thread is not None
                 if not self.server_thread.is_alive():
                     raise RuntimeError('The NiceGUI server has stopped running') from e
 
     def close(self) -> None:
+        """Close the browser."""
         if self.is_open:
             self.selenium.close()
 
     def switch_to(self, tab_id: int) -> None:
+        """Switch to the tab with the given index, or create it if it does not exist."""
         window_count = len(self.selenium.window_handles)
         if tab_id > window_count:
             raise IndexError(f'Could not go to or create tab {tab_id}, there are only {window_count} tabs')
-        elif tab_id == window_count:
+        if tab_id == window_count:
             self.selenium.switch_to.new_window('tab')
         else:
             self.selenium.switch_to.window(self.selenium.window_handles[tab_id])
 
     def should_contain(self, text: str) -> None:
+        """Assert that the page contains the given text."""
         if self.selenium.title == text:
             return
         self.find(text)
 
     def wait_for(self, text: str) -> None:
+        """Wait until the page contains the given text."""
         self.should_contain(text)
 
     def should_not_contain(self, text: str, wait: float = 0.5) -> None:
+        """Assert that the page does not contain the given text."""
         assert self.selenium.title != text
         self.selenium.implicitly_wait(wait)
         with pytest.raises(AssertionError):
@@ -106,6 +113,7 @@ class Screen:
         self.selenium.implicitly_wait(self.IMPLICIT_WAIT)
 
     def should_contain_input(self, text: str) -> None:
+        """Assert that the page contains an input with the given value."""
         deadline = time.time() + self.IMPLICIT_WAIT
         while time.time() < deadline:
             for input_element in self.find_all_by_tag('input'):
@@ -115,6 +123,7 @@ class Screen:
         raise AssertionError(f'Could not find input with value "{text}"')
 
     def should_load_image(self, image: WebElement, *, timeout: float = 2.0) -> None:
+        """Assert that the given image has loaded."""
         deadline = time.time() + timeout
         while time.time() < deadline:
             js = 'return arguments[0].naturalWidth > 0 && arguments[0].naturalHeight > 0'
@@ -123,6 +132,7 @@ class Screen:
         raise AssertionError(f'Image not loaded: {image.get_attribute("outerHTML")}')
 
     def click(self, target_text: str) -> WebElement:
+        """Click on the element containing the given text."""
         element = self.find(target_text)
         try:
             element.click()
@@ -131,21 +141,25 @@ class Screen:
         return element
 
     def context_click(self, target_text: str) -> WebElement:
+        """Right-click on the element containing the given text."""
         element = self.find(target_text)
         action = ActionChains(self.selenium)
         action.context_click(element).perform()
         return element
 
     def click_at_position(self, element: WebElement, x: int, y: int) -> None:
+        """Click on the given element at the given position."""
         action = ActionChains(self.selenium)
         action.move_to_element_with_offset(element, x, y).click().perform()
 
     def type(self, text: str) -> None:
+        """Type the given text into the currently focused element."""
         self.selenium.execute_script("window.focus();")
         self.wait(0.2)
         self.selenium.switch_to.active_element.send_keys(text)
 
     def find(self, text: str) -> WebElement:
+        """Find the element containing the given text."""
         try:
             query = f'//*[not(self::script) and not(self::style) and text()[contains(., "{text}")]]'
             element = self.selenium.find_element(By.XPATH, query)
@@ -161,35 +175,41 @@ class Screen:
             raise AssertionError(f'Could not find "{text}"') from e
 
     def find_all(self, text: str) -> List[WebElement]:
+        """Find all elements containing the given text."""
         query = f'//*[not(self::script) and not(self::style) and text()[contains(., "{text}")]]'
         return self.selenium.find_elements(By.XPATH, query)
 
     def find_element(self, element: ui.element) -> WebElement:
+        """Find the given NiceGUI element."""
         return self.selenium.find_element(By.ID, f'c{element.id}')
 
     def find_by_class(self, name: str) -> WebElement:
+        """Find the element with the given CSS class."""
         return self.selenium.find_element(By.CLASS_NAME, name)
 
     def find_all_by_class(self, name: str) -> List[WebElement]:
+        """Find all elements with the given CSS class."""
         return self.selenium.find_elements(By.CLASS_NAME, name)
 
     def find_by_tag(self, name: str) -> WebElement:
+        """Find the element with the given HTML tag."""
         return self.selenium.find_element(By.TAG_NAME, name)
 
     def find_all_by_tag(self, name: str) -> List[WebElement]:
+        """Find all elements with the given HTML tag."""
         return self.selenium.find_elements(By.TAG_NAME, name)
 
     def render_js_logs(self) -> str:
+        """Render the browser console logs as a string."""
         console = '\n'.join(l['message'] for l in self.selenium.get_log('browser'))
         return f'-- console logs ---\n{console}\n---------------------'
 
-    def get_attributes(self, tag: str, attribute: str) -> List[str]:
-        return [t.get_attribute(attribute) for t in self.find_all_by_tag(tag)]
-
     def wait(self, t: float) -> None:
+        """Wait for the given number of seconds."""
         time.sleep(t)
 
     def shot(self, name: str) -> None:
+        """Take a screenshot and store it in the screenshots directory."""
         os.makedirs(self.SCREENSHOT_DIR, exist_ok=True)
         filename = f'{self.SCREENSHOT_DIR}/{name}.png'
         print(f'Storing screenshot to {filename}')
@@ -212,6 +232,7 @@ class Screen:
 
     @contextmanager
     def implicitly_wait(self, t: float) -> None:
+        """Temporarily change the implicit wait time."""
         self.selenium.implicitly_wait(t)
         yield
         self.selenium.implicitly_wait(self.IMPLICIT_WAIT)

+ 2 - 0
tests/test_endpoint_docs.py

@@ -28,6 +28,7 @@ def test_endpoint_documentation_internal_only(screen: Screen):
     assert get_openapi_paths() == {
         f'/_nicegui/{__version__}/libraries/{{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__}/components/{{key}}',
+        f'/_nicegui/{__version__}/resources/{{key}}/{{path}}',
     }

+ 2 - 0
tests/test_input.py

@@ -64,11 +64,13 @@ def test_input_validation(screen: Screen):
     element.send_keys('John')
     screen.should_contain('Too short')
     assert input_.error == 'Too short'
+    assert not input_.validate()
 
     element.send_keys(' Doe')
     screen.wait(0.5)
     screen.should_not_contain('Too short')
     assert input_.error is None
+    assert input_.validate()
 
 
 def test_input_with_multi_word_error_message(screen: Screen):

+ 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
 
 
+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):
     ui.number('Number', format='%.4f', value=3.14159)
     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']")
 
 
-def test_keep_filtered_options(screen: Screen):
-    ui.select(options=['A1', 'A2', 'B1', 'B2'], with_input=True, multiple=True)
+@pytest.mark.parametrize('multiple', [False, True])
+def test_keep_filtered_options(multiple: bool, screen: Screen):
+    ui.select(options=['A1', 'A2', 'B1', 'B2'], with_input=True, multiple=multiple)
 
     screen.open('/')
     screen.find_by_tag('input').click()
@@ -161,7 +162,12 @@ def test_keep_filtered_options(screen: Screen):
 
     screen.click('A1')
     screen.wait(0.5)
+    screen.find_by_tag('input').click()
     screen.should_contain('A1')
     screen.should_contain('A2')
-    screen.should_not_contain('B1')
-    screen.should_not_contain('B2')
+    if multiple:
+        screen.should_not_contain('B1')
+        screen.should_not_contain('B2')
+    else:
+        screen.should_contain('B1')
+        screen.should_contain('B2')

+ 13 - 3
tests/test_table.py

@@ -145,22 +145,32 @@ def test_remove_selection(screen: Screen):
 def test_replace_rows(screen: Screen):
     t = ui.table(columns=columns(), rows=rows())
 
-    def replace_rows():
+    def replace_rows_with_carol():
         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.should_contain('Alice')
     screen.should_contain('Bob')
     screen.should_contain('Lionel')
 
-    screen.click('Replace rows')
+    screen.click('Replace rows with C.')
     screen.wait(0.5)
     screen.should_not_contain('Alice')
     screen.should_not_contain('Bob')
     screen.should_not_contain('Lionel')
     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):
     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', '''
-    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.
     In this example we are listening to the `visibilitychange` event of the browser tab.
 ''')
 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
     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');
+            }
+        });
     ''')

+ 18 - 0
website/documentation/content/grid_documentation.py

@@ -16,4 +16,22 @@ def main_demo() -> None:
         ui.label('1.80m')
 
 
+@doc.demo('Cells spanning multiple columns', '''
+    This demo shows how to span cells over multiple columns.
+
+    Note that there is [no Tailwind class for spanning 15 columns](https://tailwindcss.com/docs/grid-column),
+    but we can set [arbitrary values](https://tailwindcss.com/docs/grid-column#arbitrary-values) using square brackets.
+    Alternatively you could use the corresponding CSS definition: `.style('grid-column: span 15 / span 15')`.
+''')
+def span_demo() -> None:
+    with ui.grid(columns=16).classes('w-full gap-0'):
+        ui.label('full').classes('col-span-full border p-1')
+        ui.label('8').classes('col-span-8 border p-1')
+        ui.label('8').classes('col-span-8 border p-1')
+        ui.label('12').classes('col-span-12 border p-1')
+        ui.label('4').classes('col-span-4 border p-1')
+        ui.label('15').classes('col-[span_15] border p-1')
+        ui.label('1').classes('col-span-1 border p-1')
+
+
 doc.reference(ui.grid)

+ 15 - 2
website/documentation/content/interactive_image_documentation.py

@@ -5,9 +5,9 @@ from . import doc
 
 @doc.demo(ui.interactive_image)
 def main_demo() -> None:
-    from nicegui.events import MouseEventArguments
+    from nicegui import events
 
-    def mouse_handler(e: MouseEventArguments):
+    def mouse_handler(e: events.MouseEventArguments):
         color = 'SkyBlue' if e.type == 'mousedown' else 'SteelBlue'
         ii.content += f'<circle cx="{e.image_x}" cy="{e.image_y}" r="15" fill="none" stroke="{color}" stroke-width="4" />'
         ui.notify(f'{e.type} at ({e.image_x:.1f}, {e.image_y:.1f})')
@@ -38,4 +38,17 @@ def force_reload():
     ui.button('Force reload', on_click=img.force_reload)
 
 
+@doc.demo('Blank canvas', '''
+    You can also create a blank canvas with a given size.
+    This is useful if you want to draw something without loading a background image.
+''')
+def blank_canvas():
+    ui.interactive_image(
+        size=(800, 600), cross=True,
+        on_mouse=lambda e: e.sender.set_content(f'''
+            <circle cx="{e.image_x}" cy="{e.image_y}" r="50" fill="orange" />
+        '''),
+    ).classes('w-64 bg-blue-50')
+
+
 doc.reference(ui.interactive_image)

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

+ 3 - 0
website/documentation/content/query_documentation.py

@@ -36,3 +36,6 @@ def remove_padding():
     with ui.column().classes('h-full w-full bg-gray-400 justify-between'):  # HIDE
         ui.label('top left')
         ui.label('bottom right').classes('self-end')
+
+
+doc.reference(ui.query)

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

@@ -1,9 +1,10 @@
 from nicegui import optional_features
 
 from . import (aggrid_documentation, circular_progress_documentation, code_documentation, doc, echart_documentation,
-               editor_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)
+               editor_documentation, highchart_documentation, json_editor_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')
 
@@ -21,6 +22,7 @@ doc.intro(linear_progress_documentation)
 doc.intro(circular_progress_documentation)
 doc.intro(spinner_documentation)
 doc.intro(scene_documentation)
+doc.intro(leaflet_documentation)
 doc.intro(tree_documentation)
 doc.intro(log_documentation)
 doc.intro(editor_documentation)

+ 6 - 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,
                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, space_documentation, splitter_documentation,
+               stepper_documentation, tabs_documentation, timeline_documentation, tooltip_documentation)
 
 doc.title('Page *Layout*')
 
@@ -63,6 +63,7 @@ def clear_containers_demo():
 doc.intro(expansion_documentation)
 doc.intro(scroll_area_documentation)
 doc.intro(separator_documentation)
+doc.intro(space_documentation)
 doc.intro(splitter_documentation)
 doc.intro(tabs_documentation)
 doc.intro(stepper_documentation)
@@ -71,18 +72,7 @@ doc.intro(carousel_documentation)
 doc.intro(pagination_documentation)
 doc.intro(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(notification_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 . 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())
 
@@ -79,6 +79,7 @@ def parameter_demo():
     ui.link('Water', '/icon/water_drop?amount=3')
 
 
+doc.intro(page_title_documentation)
 doc.intro(open_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(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)

+ 24 - 0
website/documentation/content/space_documentation.py

@@ -0,0 +1,24 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.space)
+def main_demo() -> None:
+    with ui.row().classes('w-full border'):
+        ui.label('Left')
+        ui.space()
+        ui.label('Right')
+
+
+@doc.demo('Vertical space', '''
+    This element can also be used to fill vertical space.
+''')
+def vertical_demo() -> None:
+    with ui.column().classes('h-32 border'):
+        ui.label('Top')
+        ui.space()
+        ui.label('Bottom')
+
+
+doc.reference(ui.space)

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

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

+ 25 - 0
website/documentation/content/tabs_documentation.py

@@ -57,6 +57,31 @@ def switch_tabs():
     ui.button('GoTo 2', on_click=lambda: tabs.set_value('Tab 2'))
 
 
+@doc.demo('Vertical tabs with splitter', '''
+    Like in [Quasar's vertical tabs example](https://quasar.dev/vue-components/tabs#vertical),
+    we can combine `ui.splitter` and tab elements to create a vertical tabs layout.
+''')
+def vertical_tabs():
+    with ui.splitter(value=30).classes('w-full h-56') as splitter:
+        with splitter.before:
+            with ui.tabs().props('vertical').classes('w-full') as tabs:
+                mail = ui.tab('Mails', icon='mail')
+                alarm = ui.tab('Alarms', icon='alarm')
+                movie = ui.tab('Movies', icon='movie')
+        with splitter.after:
+            with ui.tab_panels(tabs, value=mail) \
+                    .props('vertical').classes('w-full h-full'):
+                with ui.tab_panel(mail):
+                    ui.label('Mails').classes('text-h4')
+                    ui.label('Content of mails')
+                with ui.tab_panel(alarm):
+                    ui.label('Alarms').classes('text-h4')
+                    ui.label('Content of alarms')
+                with ui.tab_panel(movie):
+                    ui.label('Movies').classes('text-h4')
+                    ui.label('Content of movies')
+
+
 doc.reference(ui.tabs, title='Reference for ui.tabs')
 doc.reference(ui.tabs, title='Reference for ui.tab')
 doc.reference(ui.tabs, title='Reference for ui.tab_panels')

+ 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
 
 from nicegui import binding, ui
-from nicegui.elements.markdown import apply_tailwind, remove_indentation
 
 from ..style import subheading
 
@@ -33,14 +32,17 @@ def generate_class_doc(class_obj: type) -> None:
         subheading('Methods')
         with ui.column().classes('gap-2'):
             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__:
                     _render_docstring(method.__doc__).classes('ml-8')
     if ancestors:
         subheading('Inherited from')
-        with ui.column().classes('gap-2'):
-            for ancestor in ancestors:
-                ui.markdown(f'- `{ancestor.__name__}`')
+        ui.markdown('\n'.join(f'- `{ancestor.__name__}`' for ancestor in ancestors))
 
 
 def _is_method_or_property(cls: type, attribute_name: str) -> bool:
@@ -48,7 +50,12 @@ def _is_method_or_property(cls: type, attribute_name: str) -> bool:
     return (
         inspect.isfunction(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:
-    doc = remove_indentation(doc)
+    doc = _remove_indentation_from_docstring(doc)
     doc = doc.replace('param ', '')
     html = docutils.core.publish_parts(doc, writer_name='html5_polyglot')['html_body']
-    html = apply_tailwind(html)
     if not with_params:
         html = re.sub(r'<dl class=".* simple">.*?</dl>', '', html, flags=re.DOTALL)
-    return ui.html(html).classes('documentation bold-links arrow-links')
+    return ui.html(html).classes('bold-links arrow-links nicegui-markdown')
+
+
+def _remove_indentation_from_docstring(text: str) -> str:
+    lines = text.splitlines()
+    if not lines:
+        return ''
+    if len(lines) == 1:
+        return lines[0]
+    indentation = min(len(line) - len(line.lstrip()) for line in lines[1:] if line.strip())
+    return lines[0] + '\n'.join(line[indentation:] for line in lines[1:])

+ 1 - 3
website/documentation/rendering.py

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

+ 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:
     """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:
     """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:

+ 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('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('Map',
-            'demonstrates wrapping the JavaScript library [leaflet](https://leafletjs.com/) to display a map at specific locations'),
     Example('AI Interface',
             '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'),

+ 1 - 1
website/static/style.css

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

+ 1 - 2
website/style.py

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

Vissa filer visades inte eftersom för många filer har ändrats