Repository: zauberzeug/nicegui
Pull request #2855 (2024-04-08 13:18:16)

refactor list and menu items
This PR updates the existing list item to be used with the menu or a list and with more features:
* The `menu` and the `context_menu` get a new parameter `auto_close` that previously was given to the `menu_item`.
* The `menu_item` element is deprecated in favor of the updated `item`.
* The updated `item` can now be given a text which comes in handy when creating simple lists that don't need the functionality provided by `item_section` and `item_label`. Initializing an `item` with text, adds a `q-item-section` with the text under the hood for better styling. 
* The `item_section` can also receive a `text` as a parameter. This aligns its functionality more closely to Quasar's `item_section`.
* The `item`, `item_section` and `item_label` are moved to their own file.
* The documentation and tests are updated to use the new `item` and `item_section` consistently.


Patch:
diff --git a/examples/audio_recorder/audio_recorder.py b/examples/audio_recorder/audio_recorder.py
new file mode 100644
index 00000000..482b4010
--- /dev/null
+++ b/examples/audio_recorder/audio_recorder.py
@@ -0,0 +1,26 @@
+from typing import Callable, Optional
+import base64
+
+from nicegui import ui, events
+
+
+class AudioRecorder(ui.element, component='audio_recorder.vue'):
+
+    def __init__(self, *, on_audio_ready: Optional[Callable] = None) -> None:
+        super().__init__()
+        self.recording = b''
+
+        def handle_audio(e: events.GenericEventArguments) -> None:
+            self.recording = base64.b64decode(e.args['audioBlobBase64'].encode())
+            if on_audio_ready:
+                on_audio_ready(self.recording)
+        self.on('audio_ready', handle_audio)
+
+    def start_recording(self) -> None:
+        self.run_method('startRecording')
+
+    def stop_recording(self) -> None:
+        self.run_method('stopRecording')
+
+    def play_recorded_audio(self) -> None:
+        self.run_method('playRecordedAudio')
diff --git a/examples/audio_recorder/audio_recorder.vue b/examples/audio_recorder/audio_recorder.vue
new file mode 100644
index 00000000..578a9ed1
--- /dev/null
+++ b/examples/audio_recorder/audio_recorder.vue
@@ -0,0 +1,103 @@
+<template>
+  <div>
+    <button class="record-button" @mousedown="startRecording" @mouseup="stopRecording">Hold to Record</button>
+    <audio ref="audioPlayer"></audio>
+  </div>
+</template>
+
+<script>
+export default {
+  data() {
+    return {
+      isRecording: false,
+      audioChunks: [],
+      mediaRecorder: null,
+      stream: null,
+      audioURL: null,
+      audioBlob: null,
+    };
+  },
+  mounted() {
+    this.requestMicrophonePermission();
+  },
+  watch: {
+    audioBlob(newBlob, oldBlob) {
+      if (newBlob && newBlob !== oldBlob) {
+        this.emitBlob(); // Emit the blob when it's non-null and changes
+      }
+    },
+  },
+  methods: {
+    async requestMicrophonePermission() {
+      try {
+        this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+      } catch (error) {
+        console.error("Error accessing microphone:", error);
+      }
+    },
+    async startRecording() {
+      try {
+        if (!this.stream) {
+          await this.requestMicrophonePermission();
+        }
+        this.audioChunks = [];
+        this.mediaRecorder = new MediaRecorder(this.stream);
+        this.mediaRecorder.addEventListener("dataavailable", (event) => {
+          if (event.data.size > 0) {
+            this.audioChunks.push(event.data);
+          }
+        });
+        this.mediaRecorder.start();
+        this.isRecording = true;
+      } catch (error) {
+        console.error("Error accessing microphone:", error);
+      }
+    },
+    stopRecording() {
+      if (this.isRecording) {
+        this.mediaRecorder.addEventListener("stop", () => {
+          this.isRecording = false;
+          this.saveBlob();
+          // this.playRecordedAudio();
+        });
+        this.mediaRecorder.stop();
+      }
+    },
+    async playRecordedAudio() {
+      this.audioURL = window.URL.createObjectURL(this.audioBlob);
+      this.$refs.audioPlayer.src = this.audioURL;
+      this.$refs.audioPlayer.play();
+    },
+    saveBlob() {
+      this.audioBlob = new Blob(this.audioChunks, { type: "audio/wav" });
+    },
+    emitBlob() {
+      const reader = new FileReader();
+      reader.onload = () => {
+        const base64Data = reader.result.split(",")[1]; // Extracting base64 data from the result
+        this.$emit("audio_ready", { audioBlobBase64: base64Data });
+      };
+      reader.readAsDataURL(this.audioBlob);
+    },
+  },
+};
+</script>
+
+<style scoped>
+.record-button {
+  width: 100px;
+  height: 100px;
+  border-radius: 50%;
+  background-color: red;
+  color: white;
+  border: 2px solid white;
+  font-size: 16px;
+  box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.3);
+  transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.record-button:active {
+  transform: translateY(2px);
+  box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3);
+}
+</style>
diff --git a/examples/audio_recorder/main.py b/examples/audio_recorder/main.py
new file mode 100755
index 00000000..c38bad2c
--- /dev/null
+++ b/examples/audio_recorder/main.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python3
+from audio_recorder import AudioRecorder
+from nicegui import ui
+
+with ui.row().classes('w-full justify-center'):
+    audio_recorder = AudioRecorder(on_audio_ready=lambda data: ui.notify(f'Recorded {len(data)} bytes'))
+
+with ui.row().classes('w-full justify-center'):
+    ui.button('Play', on_click=audio_recorder.play_recorded_audio) \
+        .bind_enabled_from(audio_recorder, 'recording')
+    ui.button('Download', on_click=lambda: ui.download(audio_recorder.recording, 'audio.ogx')) \
+        .bind_enabled_from(audio_recorder, 'recording')
+
+ui.run()
diff --git a/nicegui/client.py b/nicegui/client.py
index 74c266ed..06c6bf68 100644
--- a/nicegui/client.py
+++ b/nicegui/client.py
@@ -20,6 +20,7 @@ from .element import Element
 from .favicon import get_favicon_url
 from .javascript_request import JavaScriptRequest
 from .logging import log
+from .observables import ObservableDict
 from .outbox import Outbox
 from .version import __version__
 
@@ -73,6 +74,7 @@ class Client:
         self._body_html = ''
 
         self.page = page
+        self.storage = ObservableDict()
 
         self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
         self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
diff --git a/nicegui/elements/context_menu.py b/nicegui/elements/context_menu.py
index c49c9de8..a54b90b4 100644
--- a/nicegui/elements/context_menu.py
+++ b/nicegui/elements/context_menu.py
@@ -13,6 +13,7 @@ class ContextMenu(Element):
         super().__init__('q-menu')
         self._props['context-menu'] = True
         self._props['touch-position'] = True
+        self._props['auto-close'] = True
 
     def open(self) -> None:
         """Open the context menu."""
diff --git a/nicegui/elements/item.py b/nicegui/elements/item.py
new file mode 100644
index 00000000..03e97fd6
--- /dev/null
+++ b/nicegui/elements/item.py
@@ -0,0 +1,63 @@
+from typing import Any, Callable, Optional
+
+from typing_extensions import Self
+
+from ..events import ClickEventArguments, handle_event
+from .mixins.disableable_element import DisableableElement
+from .mixins.text_element import TextElement
+
+
+class Item(DisableableElement):
+
+    def __init__(self, text: str = '', *, on_click: Optional[Callable[..., Any]] = None) -> None:
+        """List Item
+
+        Creates a clickable list item based on Quasar's
+        `QItem <https://quasar.dev/vue-components/list-and-list-items#qitem-api>`_ component.
+        The item should be placed inside a ``ui.list`` or ``ui.menu`` element.
+        If the text parameter is provided, an item section will be created with the given text.
+        If you want to customize how the text is displayed, you need to create your own item section and label elements.
+
+        :param text: text to be displayed (default: "")
+        :param on_click: callback to be executed when clicking on the item (sets the "clickable" prop to True)
+        """
+        super().__init__(tag='q-item')
+
+        if on_click:
+            self.on_click(on_click)
+
+        if text:
+            with self:
+                ItemSection(text=text)
+
+    def on_click(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the List Item is clicked."""
+        self._props['clickable'] = True  # idempotent
+        self.on('click', lambda _: handle_event(callback, ClickEventArguments(sender=self, client=self.client)))
+        return self
+
+
+class ItemSection(TextElement):
+
+    def __init__(self, text: str = '') -> None:
+        """List Item Section
+
+        Creates an item section based on Quasar's
+        `QItemSection <https://quasar.dev/vue-components/list-and-list-items#qitemsection-api>`_ component.
+        The section should be placed inside a ``ui.item`` element.
+
+        :param text: text to be displayed (default: "")
+        """
+        super().__init__(tag='q-item-section', text=text)
+
+
+class ItemLabel(TextElement):
+
+    def __init__(self, text: str = '') -> None:
+        """List Item Label
+
+        Creates an item label based on Quasar's `QItemLabel <https://quasar.dev/vue-components/list-and-list-items#qitemlabel-api>`_ component.
+
+        :param text: text to be displayed (default: "")
+        """
+        super().__init__(tag='q-item-label', text=text)
diff --git a/nicegui/elements/list.py b/nicegui/elements/list.py
index c645b0ef..0bf57740 100644
--- a/nicegui/elements/list.py
+++ b/nicegui/elements/list.py
@@ -1,11 +1,4 @@
-from typing import Any, Callable, Optional
-
-from typing_extensions import Self
-
 from ..element import Element
-from ..events import ClickEventArguments, handle_event
-from .mixins.disableable_element import DisableableElement
-from .mixins.text_element import TextElement
 
 
 class List(Element):
@@ -14,51 +7,6 @@ class List(Element):
         """List
 
         A list element based on Quasar's `QList <https://quasar.dev/vue-components/list-and-list-items#qlist-api>`_ component.
-        It provides a container for list items.
+        It provides a container for ``ui.item`` elements.
         """
         super().__init__('q-list')
-
-
-class Item(DisableableElement):
-
-    def __init__(self, *, on_click: Optional[Callable[..., Any]] = None) -> None:
-        """List Item
-
-        Creates a list item based on Quasar's `QItem <https://quasar.dev/vue-components/list-and-list-items#qitem-api>`_ component.
-        The item should be placed inside a list element.
-        """
-        super().__init__(tag='q-item')
-
-        if on_click:
-            self.on_click(on_click)
-
-    def on_click(self, callback: Callable[..., Any]) -> Self:
-        """Add a callback to be invoked when the List Item is clicked."""
-        self._props['clickable'] = True  # idempotent
-        self.on('click', lambda _: handle_event(callback, ClickEventArguments(sender=self, client=self.client)))
-        return self
-
-
-class ItemSection(Element):
-
-    def __init__(self) -> None:
-        """
-        List Item Section
-
-        Creates an item section based on Quasar's `QItemList <https://quasar.dev/vue-components/list-and-list-items#qitemsection-api>`_ component.
-        The section should be placed inside a list item element.
-        """
-        super().__init__('q-item-section')
-
-
-class ItemLabel(TextElement):
-
-    def __init__(self, text: str = '') -> None:
-        """
-        List Item Label
-
-        Creates an item label based on Quasar's `QItemLabel <https://quasar.dev/vue-components/list-and-list-items#qitemlabel-api>`_ component.
-
-        :param text: text to be displayed (default: "")
-        """
-        super().__init__(tag='q-item-label', text=text)
diff --git a/nicegui/elements/menu.py b/nicegui/elements/menu.py
index c17c9038..ce3ffdc8 100644
--- a/nicegui/elements/menu.py
+++ b/nicegui/elements/menu.py
@@ -1,12 +1,11 @@
-from typing import Any, Callable, Optional
+from typing import Any, Callable, Optional, Union
 
 from typing_extensions import Self
 
-from .. import context
-from ..events import ClickEventArguments, handle_event
+from ..element import Element
 from ..logging import log
 from .context_menu import ContextMenu
-from .mixins.text_element import TextElement
+from .item import Item
 from .mixins.value_element import ValueElement
 
 
@@ -21,6 +20,7 @@ class Menu(ValueElement):
         :param value: whether the menu is already opened (default: `False`)
         """
         super().__init__(tag='q-menu', value=value, on_value_change=None)
+        self._props['auto-close'] = True
 
     def open(self) -> None:
         """Open the menu."""
@@ -44,7 +44,7 @@ class Menu(ValueElement):
         return self
 
 
-class MenuItem(TextElement):
+class MenuItem(Item):
 
     def __init__(self,
                  text: str = '',
@@ -60,20 +60,21 @@ class MenuItem(TextElement):
         :param on_click: callback to be executed when selecting the menu item
         :param auto_close: whether the menu should be closed after a click event (default: `True`)
         """
-        super().__init__(tag='q-item', text=text)
-        self.menu = context.get_slot().parent
+        super().__init__(text=text, on_click=on_click)
+
         self._props['clickable'] = True
-        self._click_handlers = [on_click] if on_click else []
 
-        def handle_click(_) -> None:
-            for handler in self._click_handlers:
-                handle_event(handler, ClickEventArguments(sender=self, client=self.client))
+        self.menu = self._find_menu()
+        if self.menu:
             if auto_close:
-                assert isinstance(self.menu, (Menu, ContextMenu))
-                self.menu.close()
-        self.on('click', handle_click, [])
-
-    def on_click(self, callback: Callable[..., Any]) -> Self:
-        """Add a callback to be invoked when the menu item is clicked."""
-        self._click_handlers.append(callback)
-        return self
+                self.on_click(self.menu.close)
+            else:
+                self.menu.props(remove='auto-close')
+
+    def _find_menu(self) -> Optional[Union[Menu, ContextMenu]]:
+        element: Element = self
+        while element.parent_slot:
+            element = element.parent_slot.parent
+            if isinstance(element, (Menu, ContextMenu)):
+                return element
+        return None
diff --git a/nicegui/elements/pyplot.py b/nicegui/elements/pyplot.py
index 4f9b7cac..08c2f904 100644
--- a/nicegui/elements/pyplot.py
+++ b/nicegui/elements/pyplot.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import asyncio
 import io
 import os
@@ -11,8 +13,22 @@ from ..element import Element
 
 try:
     if os.environ.get('MATPLOTLIB', 'true').lower() == 'true':
+        import matplotlib.figure
         import matplotlib.pyplot as plt
         optional_features.register('matplotlib')
+
+        class MatplotlibFigure(matplotlib.figure.Figure):
+
+            def __init__(self, element: Matplotlib, *args: Any, **kwargs: Any) -> None:
+                super().__init__(*args, **kwargs)
+                self.element = element
+
+            def __enter__(self) -> Self:
+                return self
+
+            def __exit__(self, *_) -> None:
+                self.element.update()
+
 except ImportError:
     pass
 
@@ -57,3 +73,30 @@ class Pyplot(Element):
         while self.client.id in Client.instances:
             await asyncio.sleep(1.0)
         plt.close(self.fig)
+
+
+class Matplotlib(Element):
+
+    def __init__(self, **kwargs: Any) -> None:
+        """Matplotlib
+
+        Create a `Matplotlib <https://matplotlib.org/>`_ element rendering a Matplotlib figure.
+        The figure is automatically updated when leaving the figure context.
+
+        :param kwargs: arguments like `figsize` which should be passed to `matplotlib.figure.Figure <https://matplotlib.org/stable/api/figure_api.html#matplotlib.figure.Figure>`_
+        """
+        if not optional_features.has('matplotlib'):
+            raise ImportError('Matplotlib is not installed. Please run "pip install matplotlib".')
+
+        super().__init__('div')
+        self.figure = MatplotlibFigure(self, **kwargs)
+        self._convert_to_html()
+
+    def _convert_to_html(self) -> None:
+        with io.StringIO() as output:
+            self.figure.savefig(output, format='svg')
+            self._props['innerHTML'] = output.getvalue()
+
+    def update(self) -> None:
+        self._convert_to_html()
+        return super().update()
diff --git a/nicegui/elements/timer.py b/nicegui/elements/timer.py
index 550fa510..b9b624b5 100644
--- a/nicegui/elements/timer.py
+++ b/nicegui/elements/timer.py
@@ -1,9 +1,10 @@
 import asyncio
 import time
 from contextlib import nullcontext
-from typing import Any, Callable, Optional
+from typing import Any, Callable, Optional, Awaitable
 
-from .. import background_tasks, core, helpers
+from .. import background_tasks, core
+from ..awaitable_response import AwaitableResponse
 from ..binding import BindableProperty
 from ..client import Client
 from ..element import Element
@@ -91,7 +92,7 @@ class Timer(Element, component='timer.js'):
         try:
             assert self.callback is not None
             result = self.callback()
-            if helpers.is_coroutine_function(self.callback):
+            if isinstance(result, Awaitable) and not isinstance(result, AwaitableResponse):
                 await result
         except Exception as e:
             core.app.handle_exception(e)
diff --git a/nicegui/static/nicegui.js b/nicegui/static/nicegui.js
index 03ad3e3d..c87474a5 100644
--- a/nicegui/static/nicegui.js
+++ b/nicegui/static/nicegui.js
@@ -252,6 +252,17 @@ async function loadDependencies(element, prefix, version) {
   }
 }
 
+function createRandomUUID() {
+  try {
+    return crypto.randomUUID();
+  } catch (e) {
+    // https://stackoverflow.com/a/2117523/3419103
+    return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
+      (+c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))).toString(16)
+    );
+  }
+}
+
 function createApp(elements, options) {
   return (app = Vue.createApp({
     data() {
@@ -277,7 +288,7 @@ function createApp(elements, options) {
         connect: () => {
           let tabId = sessionStorage.getItem("__nicegui_tab_id");
           if (!tabId) {
-            tabId = crypto.randomUUID();
+            tabId = createRandomUUID();
             sessionStorage.setItem("__nicegui_tab_id", tabId);
           }
           window.socket.emit("handshake", { client_id: window.clientId, tab_id: tabId }, (ok) => {
diff --git a/nicegui/storage.py b/nicegui/storage.py
index 64086869..1cbdecfe 100644
--- a/nicegui/storage.py
+++ b/nicegui/storage.py
@@ -17,6 +17,7 @@ from starlette.responses import Response
 
 from . import background_tasks, context, core, json, observables
 from .logging import log
+from .observables import ObservableDict
 
 request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None)
 
@@ -156,6 +157,18 @@ class Storage:
         """General storage shared between all users that is persisted on the server (where NiceGUI is executed)."""
         return self._general
 
+    @property
+    def client(self) -> ObservableDict:
+        """A volatile storage that is only kept during the current connection to the client.
+
+        Like `app.storage.tab` data is unique per browser tab but is even more volatile as it is already discarded
+        when the connection to the client is lost through a page reload or a navigation.
+        """
+        if self._is_in_auto_index_context():
+            raise RuntimeError('app.storage.client can only be used with page builder functions '
+                               '(https://nicegui.io/documentation/page)')
+        return context.get_client().storage
+
     @property
     def tab(self) -> observables.ObservableDict:
         """A volatile storage that is only kept during the current tab session."""
@@ -183,6 +196,12 @@ class Storage:
         """Clears all storage."""
         self._general.clear()
         self._users.clear()
+        try:
+            client = context.get_client()
+        except RuntimeError:
+            pass  # no client, could be a pytest
+        else:
+            client.storage.clear()
         self._tabs.clear()
         for filepath in self.path.glob('storage-*.json'):
             filepath.unlink()
diff --git a/nicegui/ui.py b/nicegui/ui.py
index e888c454..5c5e61a3 100644
--- a/nicegui/ui.py
+++ b/nicegui/ui.py
@@ -50,6 +50,7 @@ __all__ = [
     'list',
     'log',
     'markdown',
+    'matplotlib',
     'menu',
     'menu_item',
     'mermaid',
@@ -155,6 +156,9 @@ from .elements.icon import Icon as icon
 from .elements.image import Image as image
 from .elements.input import Input as input  # pylint: disable=redefined-builtin
 from .elements.interactive_image import InteractiveImage as interactive_image
+from .elements.item import Item as item
+from .elements.item import ItemLabel as item_label
+from .elements.item import ItemSection as item_section
 from .elements.joystick import Joystick as joystick
 from .elements.json_editor import JsonEditor as json_editor
 from .elements.keyboard import Keyboard as keyboard
@@ -164,9 +168,6 @@ 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
-from .elements.list import Item as item
-from .elements.list import ItemLabel as item_label
-from .elements.list import ItemSection as item_section
 from .elements.list import List as list  # pylint: disable=redefined-builtin
 from .elements.log import Log as log
 from .elements.markdown import Markdown as markdown
@@ -179,6 +180,7 @@ 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 Matplotlib as matplotlib
 from .elements.pyplot import Pyplot as pyplot
 from .elements.query import Query as query
 from .elements.radio import Radio as radio
diff --git a/website/documentation/content/button_dropdown_documentation.py b/website/documentation/content/button_dropdown_documentation.py
index f52e4387..52858587 100644
--- a/website/documentation/content/button_dropdown_documentation.py
+++ b/website/documentation/content/button_dropdown_documentation.py
@@ -6,13 +6,8 @@ from . import doc
 @doc.demo(ui.dropdown_button)
 def main_demo() -> None:
     with ui.dropdown_button('Open me!', auto_close=True):
-        with ui.list():
-            with ui.item(on_click=lambda: ui.notify('You clicked item 1')):
-                with ui.item_section():
-                    ui.item_label('Item 1')
-            with ui.item(on_click=lambda: ui.notify('You clicked item 2')):
-                with ui.item_section():
-                    ui.item_label('Item 2')
+        ui.item('Item 1', on_click=lambda: ui.notify('You clicked item 1'))
+        ui.item('Item 2', on_click=lambda: ui.notify('You clicked item 2'))
 
 
 @doc.demo('Custom elements inside dropdown button', '''
diff --git a/website/documentation/content/button_group_documentation.py b/website/documentation/content/button_group_documentation.py
index c3e9a401..a069590f 100644
--- a/website/documentation/content/button_group_documentation.py
+++ b/website/documentation/content/button_group_documentation.py
@@ -19,13 +19,8 @@ def with_dropdown() -> None:
         ui.button('One')
         ui.button('Two')
         with ui.dropdown_button('Dropdown'):
-            with ui.list():
-                with ui.item(on_click=lambda: ui.notify('Item 1')):
-                    with ui.item_section():
-                        ui.item_label('Item 1')
-                with ui.item(on_click=lambda: ui.notify('Item 2')):
-                    with ui.item_section():
-                        ui.item_label('Item 2')
+            ui.item('Item 1', on_click=lambda: ui.notify('Item 1'))
+            ui.item('Item 2', on_click=lambda: ui.notify('Item 2'))
 
 
 @doc.demo('Button group styling', '''
diff --git a/website/documentation/content/context_menu_documentation.py b/website/documentation/content/context_menu_documentation.py
index 55621731..290f7240 100644
--- a/website/documentation/content/context_menu_documentation.py
+++ b/website/documentation/content/context_menu_documentation.py
@@ -10,7 +10,7 @@ def main_demo() -> None:
             ui.menu_item('Flip horizontally')
             ui.menu_item('Flip vertically')
             ui.separator()
-            ui.menu_item('Reset')
+            ui.menu_item('Reset', auto_close=False)
 
 
 doc.reference(ui.context_menu)
diff --git a/website/documentation/content/list_documentation.py b/website/documentation/content/list_documentation.py
index 14742e6d..8e73d98b 100644
--- a/website/documentation/content/list_documentation.py
+++ b/website/documentation/content/list_documentation.py
@@ -6,18 +6,10 @@ from . import doc
 @doc.demo(ui.list)
 def main_demo() -> None:
     with ui.list().props('dense separator'):
-        with ui.item():
-            with ui.item_section():
-                ui.item_label('3 Apples')
-        with ui.item():
-            with ui.item_section():
-                ui.item_label('5 Bananas')
-        with ui.item():
-            with ui.item_section():
-                ui.item_label('8 Strawberries')
-        with ui.item():
-            with ui.item_section():
-                ui.item_label('13 Walnuts')
+        ui.item('3 Apples')
+        ui.item('5 Bananas')
+        ui.item('8 Strawberries')
+        ui.item('13 Walnuts')
 
 
 @doc.demo('Items, Sections and Labels', '''
diff --git a/website/documentation/content/matplotlib_documentation.py b/website/documentation/content/matplotlib_documentation.py
new file mode 100644
index 00000000..12a42a82
--- /dev/null
+++ b/website/documentation/content/matplotlib_documentation.py
@@ -0,0 +1,17 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.matplotlib)
+def main_demo() -> None:
+    import numpy as np
+
+    with ui.matplotlib(figsize=(3, 2)).figure as fig:
+        x = np.linspace(0.0, 5.0)
+        y = np.cos(2 * np.pi * x) * np.exp(-x)
+        ax = fig.gca()
+        ax.plot(x, y, '-')
+
+
+doc.reference(ui.matplotlib)
diff --git a/website/documentation/content/menu_documentation.py b/website/documentation/content/menu_documentation.py
index 520f5ee5..04704557 100644
--- a/website/documentation/content/menu_documentation.py
+++ b/website/documentation/content/menu_documentation.py
@@ -14,7 +14,7 @@ def main_demo() -> None:
                 ui.menu_item('Menu item 3 (keep open)',
                              lambda: result.set_text('Selected item 3'), auto_close=False)
                 ui.separator()
-                ui.menu_item('Close', on_click=menu.close)
+                ui.menu_item('Close', menu.close)
 
 
 doc.reference(ui.menu)
diff --git a/website/documentation/content/section_data_elements.py b/website/documentation/content/section_data_elements.py
index 2d98c301..36c3bfdb 100644
--- a/website/documentation/content/section_data_elements.py
+++ b/website/documentation/content/section_data_elements.py
@@ -2,9 +2,9 @@ from nicegui import optional_features
 
 from . import (aggrid_documentation, circular_progress_documentation, code_documentation, doc, echart_documentation,
                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)
+               line_plot_documentation, linear_progress_documentation, log_documentation, matplotlib_documentation,
+               plotly_documentation, pyplot_documentation, scene_documentation, spinner_documentation,
+               table_documentation, tree_documentation)
 
 doc.title('*Data* Elements')
 
@@ -15,6 +15,7 @@ if optional_features.has('highcharts'):
 doc.intro(echart_documentation)
 if optional_features.has('matplotlib'):
     doc.intro(pyplot_documentation)
+    doc.intro(matplotlib_documentation)
     doc.intro(line_plot_documentation)
 if optional_features.has('plotly'):
     doc.intro(plotly_documentation)
diff --git a/website/documentation/content/storage_documentation.py b/website/documentation/content/storage_documentation.py
index ccd045b5..2a0441a9 100644
--- a/website/documentation/content/storage_documentation.py
+++ b/website/documentation/content/storage_documentation.py
@@ -14,13 +14,20 @@ doc.title('Storage')
 
 @doc.demo('Storage', '''
     NiceGUI offers a straightforward mechanism for data persistence within your application. 
-    It features four built-in storage types:
+    It features five built-in storage types:
 
     - `app.storage.tab`:
         Stored server-side in memory, this dictionary is unique to each tab session and can hold arbitrary objects.
         Data will be lost when restarting the server until <https://github.com/zauberzeug/nicegui/discussions/2841> is implemented.
         This storage is only available within [page builder functions](/documentation/page) 
         and requires an established connection, obtainable via [`await client.connected()`](/documentation/page#wait_for_client_connection).
+    - `app.storage.client`:
+        Also stored server-side in memory, this dictionary is unique to each client connection and can hold arbitrary objects.
+        Data will be discarded when the page is reloaded or the user navigates to another page.
+        Unlike data stored in `app.storage.tab` which can be persisted on the server even for days, 
+        `app.storage.client` helps caching resource-hungry objects such as a streaming or database connection you need to keep alive 
+        for dynamic site updates but would like to discard as soon as the user leaves the page or closes the browser. 
+        This storage is only available within [page builder functions](/documentation/page).
     - `app.storage.user`:
         Stored server-side, each dictionary is associated with a unique identifier held in a browser session cookie.
         Unique to each user, this storage is accessible across all their browser tabs.
@@ -35,6 +42,16 @@ doc.title('Storage')
     The user storage and browser storage are only available within `page builder functions </documentation/page>`_
     because they are accessing the underlying `Request` object from FastAPI.
     Additionally these two types require the `storage_secret` parameter in`ui.run()` to encrypt the browser session cookie.
+    
+    | Storage type                | `tab`  | `client` | `user` | `general` | `browser` |
+    |-----------------------------|--------|----------|--------|-----------|-----------|
+    | Location                    | Server | Server   | Server | Server    | Browser   |
+    | Across tabs                 | No     | No       | Yes    | Yes       | Yes       |
+    | Across browsers             | No     | No       | No     | Yes       | No        |
+    | Across page reloads         | Yes    | No       | Yes    | Yes       | Yes       |
+    | Needs page builder function | Yes    | Yes      | Yes    | No        | Yes       |
+    | Needs client connection     | Yes    | No       | No     | No        | No        |
+    | Write only before response  | No     | No       | No     | No        | Yes       |
 ''')
 def storage_demo():
     from nicegui import app
@@ -99,11 +116,37 @@ def ui_state():
     It is also more secure to use such a volatile storage for scenarios like logging into a bank account or accessing a password manager.
 ''')
 def tab_storage():
-    from nicegui import app
+    from nicegui import app, Client
 
     # @ui.page('/')
-    # async def index(client):
+    # async def index(client: Client):
     #     await client.connected()
     with ui.column():  # HIDE
         app.storage.tab['count'] = app.storage.tab.get('count', 0) + 1
         ui.label(f'Tab reloaded {app.storage.tab["count"]} times')
+        ui.button('Reload page', on_click=ui.navigate.reload)
+
+
+@doc.demo('Short-term memory', '''
+    The goal of `app.storage.client` is to store data only for the duration of the current page visit.
+    In difference to data stored in `app.storage.tab`
+    - which is persisted between page changes and even browser restarts as long as the tab is kept open -
+    the data in `app.storage.client` will be discarded if the user closes the browser, reloads the page or navigates to another page.
+    This is beneficial for resource-hungry, intentionally short-lived or sensitive data.
+    An example is a database connection, which should be closed as soon as the user leaves the page.
+    Additionally, this storage useful if you want to return a page with default settings every time a user reloads.
+    Meanwhile, it keeps the data alive during in-page navigation.
+    This is also helpful when updating elements on the site at intervals, such as a live feed.
+''')
+def short_term_memory():
+    from nicegui import app
+
+    # @ui.page('/')
+    # async def index():
+    with ui.column():  # HIDE
+        cache = app.storage.client
+        cache['count'] = 0
+        ui.label().bind_text_from(cache, 'count', lambda n: f'Updated {n} times')
+        ui.button('Update content',
+                  on_click=lambda: cache.update(count=cache['count'] + 1))
+        ui.button('Reload page', on_click=ui.navigate.reload)
diff --git a/website/documentation/rendering.py b/website/documentation/rendering.py
index c0c71d22..cba64a79 100644
--- a/website/documentation/rendering.py
+++ b/website/documentation/rendering.py
@@ -43,7 +43,9 @@ def render_page(documentation: DocumentationPage, *, with_menu: bool = True) ->
                     element = ui.restructured_text(part.description.replace(':param ', ':'))
                 else:
                     element = ui.markdown(part.description)
-                element.classes('bold-links arrow-links rst-param-tables')
+                element.classes('bold-links arrow-links')
+                if ':param' in part.description:
+                    element.classes('rst-param-tables')
             if part.ui:
                 part.ui()
             if part.demo:
diff --git a/website/examples.py b/website/examples.py
index e5d16c34..86f83adf 100644
--- a/website/examples.py
+++ b/website/examples.py
@@ -64,4 +64,5 @@ examples: List[Example] = [
     Example('Pyserial', 'communicate with a serial device'),
     Example('Webserial', 'communicate with a serial device using the WebSerial API'),
     Example('Websockets', 'use [websockets library](https://websockets.readthedocs.io/) to start a websocket server'),
+    Example('Audio Recorder', 'Record audio, play it back or download it'),
 ]


Test patch:
diff --git a/nicegui/testing/conftest.py b/nicegui/testing/conftest.py
index bb043103..109ba0b1 100644
--- a/nicegui/testing/conftest.py
+++ b/nicegui/testing/conftest.py
@@ -25,7 +25,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('disable-dev-shm-usage')
     chrome_options.add_argument('no-sandbox')
     chrome_options.add_argument('headless')
     # check if we are running on GitHub Actions
diff --git a/tests/test_button_dropdown.py b/tests/test_button_dropdown.py
index 025e8cd3..a97da71c 100644
--- a/tests/test_button_dropdown.py
+++ b/tests/test_button_dropdown.py
@@ -4,9 +4,7 @@ from nicegui.testing import Screen
 
 def test_dropdown_button(screen: Screen):
     with ui.dropdown_button('Button', on_click=lambda: ui.label('Button clicked')):
-        with ui.list():
-            with ui.item(on_click=lambda: ui.label('Item clicked')):
-                ui.item_label('Item')
+        ui.item('Item', on_click=lambda: ui.label('Item clicked'))
 
     screen.open('/')
     screen.click('Button')
diff --git a/tests/test_button_group.py b/tests/test_button_group.py
index 9d726d99..979890f0 100644
--- a/tests/test_button_group.py
+++ b/tests/test_button_group.py
@@ -7,9 +7,7 @@ def test_button_group(screen: Screen):
         ui.button('Button 1', on_click=lambda: ui.label('Button 1 clicked'))
         ui.button('Button 2', on_click=lambda: ui.label('Button 2 clicked'))
         with ui.dropdown_button('Button 3', on_click=lambda: ui.label('Button 3 clicked')):
-            with ui.list():
-                with ui.item(on_click=lambda: ui.label('Item clicked')):
-                    ui.item_label('Item')
+            ui.item('Item', on_click=lambda: ui.label('Item clicked'))
 
     screen.open('/')
     screen.click('Button 1')
diff --git a/tests/test_context_menu.py b/tests/test_context_menu.py
index 17c64cd2..ace84740 100644
--- a/tests/test_context_menu.py
+++ b/tests/test_context_menu.py
@@ -5,12 +5,12 @@ from nicegui.testing import Screen
 def test_context_menu(screen: Screen):
     with ui.label('Right-click me'):
         with ui.context_menu():
-            ui.menu_item('Item 1', on_click=lambda: ui.label('Item 1 clicked'))
+            ui.menu_item('Item 1', on_click=lambda: ui.notify('You clicked'))
             ui.menu_item('Item 2')
 
     screen.open('/')
     screen.context_click('Right-click me')
     screen.click('Item 1')
-    screen.should_contain('Item 1 clicked')
+    screen.should_contain('You clicked')
     screen.wait(0.5)
-    screen.should_not_contain('Item 2')
+    screen.should_not_contain('Item 1')
diff --git a/tests/test_list.py b/tests/test_list.py
index 5a6d3a3a..cad7a398 100644
--- a/tests/test_list.py
+++ b/tests/test_list.py
@@ -4,12 +4,8 @@ from nicegui.testing import Screen
 
 def test_clicking_items(screen: Screen):
     with ui.list():
-        with ui.item(on_click=lambda: ui.notify('Clicked item 1')):
-            with ui.item_section():
-                ui.item_label('Item 1')
-        with ui.item(on_click=lambda: ui.notify('Clicked item 2')):
-            with ui.item_section():
-                ui.item_label('Item 2')
+        ui.item('Item 1', on_click=lambda: ui.notify('Clicked item 1'))
+        with ui.item('Item 2', on_click=lambda: ui.notify('Clicked item 2')):
             with ui.item_section():
                 ui.button('Button').on('click.stop', lambda: ui.notify('Clicked button!'))
 
diff --git a/tests/test_storage.py b/tests/test_storage.py
index 8db90f2a..a9d3ecad 100644
--- a/tests/test_storage.py
+++ b/tests/test_storage.py
@@ -1,5 +1,6 @@
 import asyncio
 from pathlib import Path
+import pytest
 
 import httpx
 
@@ -227,3 +228,39 @@ def test_clear_tab_storage(screen: Screen):
     screen.click('clear')
     screen.wait(0.5)
     assert not tab_storages
+
+
+def test_client_storage(screen: Screen):
+    def increment():
+        app.storage.client['counter'] = app.storage.client['counter'] + 1
+
+    @ui.page('/')
+    def page():
+        app.storage.client['counter'] = 123
+        ui.button('Increment').on_click(increment)
+        ui.label().bind_text(app.storage.client, 'counter')
+
+    screen.open('/')
+    screen.should_contain('123')
+    screen.click('Increment')
+    screen.wait_for('124')
+
+    screen.switch_to(1)
+    screen.open('/')
+    screen.should_contain('123')
+
+    screen.switch_to(0)
+    screen.should_contain('124')
+
+
+def test_clear_client_storage(screen: Screen):
+    with pytest.raises(RuntimeError):  # no context (auto index)
+        app.storage.client.clear()
+
+    @ui.page('/')
+    def page():
+        app.storage.client['counter'] = 123
+        app.storage.client.clear()
+        assert app.storage.client == {}
+
+    screen.open('/')
diff --git a/tests/test_timer.py b/tests/test_timer.py
index cd678309..cced9804 100644
--- a/tests/test_timer.py
+++ b/tests/test_timer.py
@@ -77,20 +77,6 @@ def test_setting_visibility(screen: Screen, once: bool):
     screen.should_not_contain('Some Label')
 
 
-def test_awaiting_coroutine(screen: Screen):
-    user = {'name': 'John Doe'}
-
-    async def update_user():
-        await asyncio.sleep(0.1)
-        user['name'] = 'Jane Doe'
-
-    ui.timer(1, update_user)
-
-    screen.open('/')
-    screen.wait(1)
-    assert user['name'] == 'Jane Doe'
-
-
 def test_timer_on_deleted_container(screen: Screen):
     state = {'count': 0}
     with ui.row() as outer_container:
@@ -105,3 +91,27 @@ def test_timer_on_deleted_container(screen: Screen):
     count = state['count']
     screen.wait(0.5)
     assert state['count'] == count, 'timer is not running anymore after deleting the container'
+
+
+def test_different_callbacks(screen: Screen):
+    def sync_function():
+        ui.label('a synchronous function')
+
+    async def async_function():
+        await asyncio.sleep(0.1)
+        ui.label('an asynchronous function')
+
+    async def async_lambda(msg: str):
+        await asyncio.sleep(0.1)
+        ui.label(f'an asynchronous lambda: {msg}')
+
+    ui.timer(0.1, sync_function, once=True)
+    ui.timer(0.1, async_function, once=True)
+    ui.timer(0.1, lambda: ui.label('a synchronous lambda'), once=True)
+    ui.timer(0.1, lambda: async_lambda('Hi!'), once=True)
+
+    screen.open('/')
+    screen.should_contain('a synchronous function')
+    screen.should_contain('an asynchronous function')
+    screen.should_contain('a synchronous lambda')
+    screen.should_contain('an asynchronous lambda: Hi!')