Browse Source

Fullscreen control element (#4165)

I recently created a component to make use Quasars's AppFullscreen
plugin and thought it were also helpful for other users, so I documented
it and added it to the NiceGUI docu.

It enables the user to switch to fullscreen programmatically which
especially on a smartphone or tablet context greatly improves the user
experience.


![image](https://github.com/user-attachments/assets/b55be2a6-785c-4a46-8740-30811a3626da)

---------

Co-authored-by: Falko Schindler <falko@zauberzeug.com>
Michael Ikemann 3 months ago
parent
commit
ce015a49b4

+ 32 - 0
nicegui/elements/fullscreen.js

@@ -0,0 +1,32 @@
+export default {
+  props: {
+    requireEscapeHold: Boolean,
+  },
+  mounted() {
+    document.addEventListener("fullscreenchange", this.handleFullscreenChange);
+    document.addEventListener("mozfullscreenchange", this.handleFullscreenChange);
+    document.addEventListener("webkitfullscreenchange", this.handleFullscreenChange);
+    document.addEventListener("msfullscreenchange", this.handleFullscreenChange);
+  },
+  unmounted() {
+    document.removeEventListener("fullscreenchange", this.handleFullscreenChange);
+    document.removeEventListener("mozfullscreenchange", this.handleFullscreenChange);
+    document.removeEventListener("webkitfullscreenchange", this.handleFullscreenChange);
+    document.removeEventListener("msfullscreenchange", this.handleFullscreenChange);
+  },
+  methods: {
+    handleFullscreenChange() {
+      this.$emit("update:model-value", Quasar.AppFullscreen.isActive);
+    },
+    enter() {
+      Quasar.AppFullscreen.request().then(() => {
+        if (this.requireEscapeHold && navigator.keyboard && typeof navigator.keyboard.lock === "function") {
+          navigator.keyboard.lock(["Escape"]);
+        }
+      });
+    },
+    exit() {
+      Quasar.AppFullscreen.exit();
+    },
+  },
+};

+ 59 - 0
nicegui/elements/fullscreen.py

@@ -0,0 +1,59 @@
+from typing import Optional
+
+from ..events import Handler, ValueChangeEventArguments
+from .mixins.value_element import ValueElement
+
+
+class Fullscreen(ValueElement, component='fullscreen.js'):
+    LOOPBACK = None
+
+    def __init__(self, *,
+                 require_escape_hold: bool = False,
+                 on_value_change: Optional[Handler[ValueChangeEventArguments]] = None) -> None:
+        """Fullscreen control element
+
+        This element is based on Quasar's `AppFullscreen <https://quasar.dev/quasar-plugins/app-fullscreen>`_ plugin
+        and provides a way to enter, exit and toggle the fullscreen mode.
+
+        Important notes:
+
+        * Due to security reasons, the fullscreen mode can only be entered from a previous user interaction such as a button click.
+        * The long-press escape requirement only works in some browsers like Google Chrome or Microsoft Edge.
+
+        *Added in version 2.11.0*
+
+        :param require_escape_hold: whether the user needs to long-press the escape key to exit fullscreen mode
+        :param on_value_change: callback which is invoked when the fullscreen state changes
+        """
+        super().__init__(value=False, on_value_change=on_value_change)
+        self._props['requireEscapeHold'] = require_escape_hold
+
+    @property
+    def require_escape_hold(self) -> bool:
+        """Whether the user needs to long-press of the escape key to exit fullscreen mode.
+
+        This feature is only supported in some browsers like Google Chrome or Microsoft Edge.
+        In unsupported browsers, this setting has no effect.
+        """
+        return self._props['requireEscapeHold']
+
+    @require_escape_hold.setter
+    def require_escape_hold(self, value: bool) -> None:
+        self._props['requireEscapeHold'] = value
+        self.update()
+
+    def enter(self) -> None:
+        """Enter fullscreen mode."""
+        self.value = True
+
+    def exit(self) -> None:
+        """Exit fullscreen mode."""
+        self.value = False
+
+    def toggle(self) -> None:
+        """Toggle fullscreen mode."""
+        self.value = not self.value
+
+    def _handle_value_change(self, value: bool) -> None:
+        super()._handle_value_change(value)
+        self.run_method('enter' if value else 'exit')

+ 2 - 0
nicegui/ui.py

@@ -39,6 +39,7 @@ __all__ = [
     'element',
     'element',
     'expansion',
     'expansion',
     'footer',
     'footer',
+    'fullscreen',
     'grid',
     'grid',
     'header',
     'header',
     'highchart',
     'highchart',
@@ -154,6 +155,7 @@ from .elements.dialog import Dialog as dialog
 from .elements.echart import EChart as echart
 from .elements.echart import EChart as echart
 from .elements.editor import Editor as editor
 from .elements.editor import Editor as editor
 from .elements.expansion import Expansion as expansion
 from .elements.expansion import Expansion as expansion
+from .elements.fullscreen import Fullscreen as fullscreen
 from .elements.grid import Grid as grid
 from .elements.grid import Grid as grid
 from .elements.highchart import highchart
 from .elements.highchart import highchart
 from .elements.html import Html as html
 from .elements.html import Html as html

+ 68 - 0
tests/test_fullscreen.py

@@ -0,0 +1,68 @@
+from unittest.mock import patch
+
+import pytest
+
+from nicegui import ui
+from nicegui.testing import Screen
+
+
+@pytest.mark.parametrize('require_escape_hold', [True, False])
+def test_fullscreen_creation(screen: Screen, require_escape_hold: bool):
+    fullscreen = ui.fullscreen(require_escape_hold=require_escape_hold)
+    assert not fullscreen.value
+    assert fullscreen.require_escape_hold == require_escape_hold
+
+    screen.open('/')
+
+
+def test_fullscreen_methods(screen: Screen):
+    values = []
+
+    fullscreen = ui.fullscreen(on_value_change=lambda e: values.append(e.value))
+
+    screen.open('/')
+
+    with patch.object(fullscreen, 'run_method') as mock_run:
+        fullscreen.enter()
+        mock_run.assert_called_once_with('enter')
+        mock_run.reset_mock()
+
+        fullscreen.exit()
+        mock_run.assert_called_once_with('exit')
+        mock_run.reset_mock()
+
+        fullscreen.toggle()
+        mock_run.assert_called_once_with('enter')
+        mock_run.reset_mock()
+
+        fullscreen.value = False
+        mock_run.assert_called_once_with('exit')
+        mock_run.reset_mock()
+
+        fullscreen.value = True
+        mock_run.assert_called_once_with('enter')
+        mock_run.reset_mock()
+
+    assert values == [True, False, True, False, True]
+
+
+def test_fullscreen_button_click(screen: Screen):
+    """Test that clicking a button to enter fullscreen creates the correct JavaScript call.
+
+    Note: We cannot test actual fullscreen behavior as it requires user interaction,
+    but we can verify the JavaScript method is called correctly.
+    """
+    values = []
+
+    fullscreen = ui.fullscreen(on_value_change=lambda e: values.append(e.value))
+    ui.button('Enter Fullscreen', on_click=fullscreen.enter)
+    ui.button('Exit Fullscreen', on_click=fullscreen.exit)
+
+    screen.open('/')
+    screen.click('Enter Fullscreen')
+    screen.wait(0.5)
+    assert values == [True]
+
+    screen.click('Exit Fullscreen')
+    screen.wait(0.5)
+    assert values == [True, False]

+ 42 - 0
website/documentation/content/fullscreen_documentation.py

@@ -0,0 +1,42 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.fullscreen)
+def main_demo() -> None:
+    fullscreen = ui.fullscreen()
+
+    ui.button('Enter Fullscreen', on_click=fullscreen.enter)
+    ui.button('Exit Fullscreen', on_click=fullscreen.exit)
+    ui.button('Toggle Fullscreen', on_click=fullscreen.toggle)
+
+
+@doc.demo('Requiring long-press to exit', '''
+    You can require users to long-press the escape key to exit fullscreen mode.
+    This is useful to prevent accidental exits, for example when working on forms or editing data.
+
+    Note that this feature only works in some browsers like Google Chrome or Microsoft Edge.
+''')
+def long_press_demo():
+    fullscreen = ui.fullscreen()
+    ui.switch('Require escape hold').bind_value_to(fullscreen, 'require_escape_hold')
+    ui.button('Toggle Fullscreen', on_click=fullscreen.toggle)
+
+
+@doc.demo('Tracking fullscreen state', '''
+    You can track when the fullscreen state changes.
+
+    Note that due to security reasons, fullscreen mode can only be entered from a previous user interaction
+    such as a button click.
+''')
+def state_demo():
+    fullscreen = ui.fullscreen(
+        on_value_change=lambda e: ui.notify('Enter' if e.value else 'Exit')
+    )
+    ui.button('Toggle Fullscreen', on_click=fullscreen.toggle)
+    ui.label().bind_text_from(fullscreen, 'state',
+                              lambda state: 'Fullscreen' if state else '')
+
+
+doc.reference(ui.fullscreen)

+ 1 - 0
website/documentation/content/overview.py

@@ -291,6 +291,7 @@ def map_of_nicegui():
             - `ui.context`: get the current UI context including the `client` and `request` objects
             - `ui.context`: get the current UI context including the `client` and `request` objects
             - [`ui.dark_mode`](/documentation/dark_mode): get and set the dark mode on a page
             - [`ui.dark_mode`](/documentation/dark_mode): get and set the dark mode on a page
             - [`ui.download`](/documentation/download): download a file to the client
             - [`ui.download`](/documentation/download): download a file to the client
+            - [`ui.fullscreen`](/documentation/fullscreen): enter, exit and toggle fullscreen mode
             - [`ui.keyboard`](/documentation/keyboard): define keyboard event handlers
             - [`ui.keyboard`](/documentation/keyboard): define keyboard event handlers
             - [`ui.navigate`](/documentation/navigate): let the browser navigate to another location
             - [`ui.navigate`](/documentation/navigate): let the browser navigate to another location
             - [`ui.notify`](/documentation/notification): show a notification
             - [`ui.notify`](/documentation/notification): show a notification

+ 2 - 0
website/documentation/content/section_page_layout.py

@@ -8,6 +8,7 @@ from . import (
     dialog_documentation,
     dialog_documentation,
     doc,
     doc,
     expansion_documentation,
     expansion_documentation,
+    fullscreen_documentation,
     grid_documentation,
     grid_documentation,
     list_documentation,
     list_documentation,
     menu_documentation,
     menu_documentation,
@@ -55,6 +56,7 @@ doc.intro(column_documentation)
 doc.intro(row_documentation)
 doc.intro(row_documentation)
 doc.intro(grid_documentation)
 doc.intro(grid_documentation)
 doc.intro(list_documentation)
 doc.intro(list_documentation)
+doc.intro(fullscreen_documentation)
 
 
 
 
 @doc.demo('Clear Containers', '''
 @doc.demo('Clear Containers', '''