diff --git a/examples/authentication_spa/main.py b/examples/authentication_spa/main.py
new file mode 100755
index 000000000..268931f54
--- /dev/null
+++ b/examples/authentication_spa/main.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+import html
+import uuid
+from typing import Optional, Union
+
+from nicegui import app, ui
+from nicegui.single_page_target import SinglePageTarget
+
+INDEX_URL = '/'
+SECRET_AREA_URL = '/secret_area'
+
+DUMMY_LOGINS = {"admin": "NicePass"}
+
+
+def portal_rerouting(url) -> str:
+ # automatically redirect to the secret area if the user is already logged in
+ if 'login_token' in app.storage.user:
+ return SECRET_AREA_URL
+ return '/login'
+
+
+@ui.outlet('/', on_navigate=portal_rerouting)
+def main_layout():
+ with ui.header():
+ ui.link('SPA Login Example', '/').style('text-decoration: none; color: inherit;').classes('text-h3')
+ ui.query('.nicegui-content').classes('flex-center')
+ with ui.column().classes('m-12'):
+ yield
+
+
+@main_layout.outlet('/login', title='Login Page')
+def main_app_index():
+ def handle_login():
+ username = username_input.value
+ password = password_input.value
+ if username in DUMMY_LOGINS and DUMMY_LOGINS[username] == password:
+ # NOTE you can also use app.storage.tab to ensure the login is only valid for the current tab
+ app.storage.user['login_token'] = uuid.uuid4().hex
+ app.storage.user['username'] = username
+ ui.navigate.to(SECRET_AREA_URL)
+ else:
+ password_input.props('error')
+
+ with ui.column().classes('w-96 mt-[40%] items-center'):
+ username_input = ui.input('Username').classes('w-full')\
+ .on('keydown.enter', lambda: password_input.run_method('focus'))
+ password_input = ui.input('Password', password=True, password_toggle_button=True) \
+ .classes('w-full').props('error-message="Invalid password!"') \
+ .on('keydown.enter', handle_login)
+ ui.button('Login').classes('w-48').on_click(handle_login)
+ ui.label(f'Hint: {DUMMY_LOGINS}').classes('text-grey mt-12')
+ ui.link('Try to access secret area without login', SECRET_AREA_URL).classes('text-grey mt-12')
+
+
+def logout(): # logs the user out and redirects to the login page
+ app.storage.user.clear()
+ ui.navigate.to(INDEX_URL)
+
+
+def check_login(url) -> Optional[Union[str, SinglePageTarget]]:
+ def error_page():
+ with ui.column().classes('w-96 mt-[40%] items-center'):
+ ui.label('⚠️').classes('text-6xl')
+ ui.label('You are not logged in!').classes('text-3xl')
+ ui.button('Go to login page').on_click(lambda: ui.navigate.to(INDEX_URL)).classes('w-48 mt-12')
+
+ if 'login_token' not in app.storage.user: # check if the user is not logged in
+ return SinglePageTarget(url, builder=error_page, title='Not logged in')
+ return url # default behavior
+
+
+@main_layout.outlet(SECRET_AREA_URL, title='🔒 Secret Area', on_navigate=check_login)
+def secret_area_index():
+ username = app.storage.user['username']
+ ui.label(f'Hello {html.escape(username)}. Welcome to the secret area!').classes('text-3xl')
+ ui.button('Logout').on_click(logout).classes('w-48 mt-12')
+
+
+ui.run(storage_secret='secret', title='SPA Login')
diff --git a/examples/single_page_app_complex/cms_config.py b/examples/single_page_app_complex/cms_config.py
new file mode 100644
index 000000000..582302f2b
--- /dev/null
+++ b/examples/single_page_app_complex/cms_config.py
@@ -0,0 +1,28 @@
+import os
+from typing import Dict
+
+from pydantic import BaseModel, Field
+
+
+class SubServiceDefinition(BaseModel):
+ title: str = Field(..., description='The title of the sub-service', examples=['Digital Twin'])
+ emoji: str = Field(..., description='An emoji representing the sub-service', examples=['🤖'])
+ description: str = Field(..., description='A short description of the sub-service',
+ examples=['Manage your digital twin'])
+
+
+class ServiceDefinition(BaseModel):
+ title: str = Field(..., description='The title of the cloud service', examples=['Virtual Machines'])
+ emoji: str = Field(..., description='An emoji representing the cloud service', examples=['💻'])
+ description: str = Field(..., description='A short description of the cloud service',
+ examples=['Create and manage virtual machines'])
+ sub_services: Dict[str, SubServiceDefinition] = Field(...,
+ description='The sub-services of the cloud service')
+
+
+class ServiceDefinitions(BaseModel):
+ services: Dict[str, ServiceDefinition] = Field(...,
+ description='The cloud services provided by the cloud provider')
+
+
+services = ServiceDefinitions.parse_file(os.path.join(os.path.dirname(__file__), 'services.json')).services
diff --git a/examples/single_page_app_complex/main.py b/examples/single_page_app_complex/main.py
new file mode 100644
index 000000000..3972b34ae
--- /dev/null
+++ b/examples/single_page_app_complex/main.py
@@ -0,0 +1,136 @@
+# Advanced demo showing how to use the ui.outlet and outlet.view decorators to create a nested multi-page app with a
+# static header, footer and menu which is shared across all pages and hidden when the user navigates to the root page.
+
+from nicegui import ui
+from nicegui.page_layout import LeftDrawer
+from nicegui.single_page_router import SinglePageRouter
+from nicegui.single_page_target import SinglePageTarget
+
+from examples.single_page_app_complex.cms_config import SubServiceDefinition, ServiceDefinition, services
+
+
+# --- Other app ---
+
+@ui.outlet('/other_app') # Needs to be defined before the main outlet / to avoid conflicts
+def other_app_router():
+ ui.label('Other app header').classes('text-h2')
+ ui.html('
')
+ yield
+ ui.html('
')
+ ui.label('Other app footer')
+
+
+@other_app_router.view('/')
+def other_app_index():
+ ui.label('Welcome to the index page of the other application')
+
+
+# --- Main app ---
+
+@ui.outlet('/') # main app outlet
+def main_router(url_path: str):
+ with ui.header():
+ with ui.link('', '/').style('text-decoration: none; color: inherit;') as lnk:
+ ui.html('Nice'
+ 'CLOUD').classes('text-h3')
+ menu_visible = '/services/' in url_path # make instantly visible if the initial path is a service
+ menu_drawer = ui.left_drawer(bordered=True, value=menu_visible, fixed=True).classes('bg-primary')
+ with ui.footer():
+ with ui.element('a').props('href="/about"'):
+ ui.label('Copyright 2024 by NiceCLOUD Inc.').classes('text-h7')
+ with ui.element().classes('p-8'):
+ yield {'menu_drawer': menu_drawer} # pass menu drawer to all sub elements (views and outlets)
+
+
+@main_router.view('/')
+def main_index(menu_drawer: LeftDrawer): # main app index page
+ menu_drawer.clear() # clear drawer
+ menu_drawer.hide() # hide drawer
+ ui.label('Welcome to NiceCLOUD!').classes('text-3xl')
+ ui.html('
')
+ with ui.grid(columns=3) as grid:
+ grid.classes('gap-16')
+ for key, info in services.items():
+ link = f'/services/{key}'
+ with ui.element():
+ with ui.link(target=link) as lnk:
+ with ui.row().classes('text-2xl'):
+ ui.label(info.emoji)
+ ui.label(info.title)
+ lnk.style('text-decoration: none; color: inherit;')
+ ui.label(info.description)
+ ui.html('
')
+ # add a link to the other app
+ ui.markdown('Click [here](/other_app) to visit the other app.')
+
+
+@main_router.view('/about')
+def about_page(menu_drawer: LeftDrawer):
+ menu_drawer.clear()
+ menu_drawer.hide()
+ ui.label('About NiceCLOUD').classes('text-3xl')
+ ui.html('
')
+ ui.label('NiceCLOUD Inc.')
+ ui.label('1234 Nice Street')
+ ui.label('Nice City')
+ ui.label('Nice Country')
+
+
+@main_router.outlet('/services/{service_name}') # service outlet
+def services_router(service_name: str, menu_drawer: LeftDrawer):
+ service: ServiceDefinition = services[service_name]
+ menu_drawer.clear()
+ with menu_drawer:
+ menu_drawer.show()
+ with ui.row() as row:
+ ui.label(service.emoji)
+ ui.label(service.title)
+ row.classes('text-h5 text-white').style('text-shadow: 2px 2px #00000070;')
+ ui.html('
')
+ menu_items = service.sub_services
+ for key, info in menu_items.items():
+ with ui.row() as service_element:
+ ui.label(info.emoji)
+ ui.label(info.title)
+ service_element.classes('text-white text-h6 bg-gray cursor-pointer')
+ service_element.style('text-shadow: 2px 2px #00000070;')
+ service_element.on('click', lambda url=f'/services/{service_name}/{key}': ui.navigate.to(url))
+ yield {'service': service} # pass service object to all sub elements (views and outlets)
+
+
+def update_title(service: ServiceDefinition = None,
+ sub_service: SubServiceDefinition = None):
+ # Is called for every page within the service_router and sub_service_router via the on_load callback
+ # and updates the title of each page
+ SinglePageRouter.current_router().target.title = \
+ 'NiceCLOUD - ' + (f'{sub_service.title}' if sub_service else f'{service.title}')
+
+
+@services_router.view('/') # service index page
+def show_index(service: ServiceDefinition):
+ update_title(service, None)
+ with ui.row() as row:
+ ui.label(service.emoji).classes('text-h4 vertical-middle')
+ with ui.column():
+ ui.label(service.title).classes('text-h2')
+ ui.label(service.description)
+ ui.html('
')
+
+
+@services_router.outlet('/{sub_service_name}') # sub service outlet
+def sub_service_router(service: ServiceDefinition, sub_service_name: str):
+ sub_service: SubServiceDefinition = service.sub_services[sub_service_name]
+ ui.label(f'{service.title} > {sub_service.title}').classes('text-h4')
+ ui.html('
')
+ yield {'sub_service': sub_service} # pass sub_service object to all sub elements (views and outlets)
+
+
+@sub_service_router.view('/') # sub service index page
+def sub_service_index(service: ServiceDefinition, sub_service: SubServiceDefinition):
+ update_title(service, sub_service)
+ ui.label(sub_service.emoji).classes('text-h1')
+ ui.html('
')
+ ui.label(sub_service.description)
+
+
+ui.run(title='NiceCLOUD Portal', show=False)
diff --git a/examples/single_page_app_complex/main_oop_version.py b/examples/single_page_app_complex/main_oop_version.py
new file mode 100644
index 000000000..8c84aa396
--- /dev/null
+++ b/examples/single_page_app_complex/main_oop_version.py
@@ -0,0 +1,160 @@
+# Advanced demo showing how to use the ui.outlet and outlet.view decorators to create a nested multi-page app with a
+# static header, footer and menu which is shared across all pages and hidden when the user navigates to the root page.
+
+from nicegui import ui
+from nicegui.single_page_router import SinglePageRouter
+from examples.single_page_app_complex.cms_config import SubServiceDefinition, ServiceDefinition, services
+
+
+# --- Other app ---
+
+class OtherApp(SinglePageRouter):
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+
+ @staticmethod
+ def other_app_index():
+ ui.label('Welcome to the index page of the other application')
+
+ @staticmethod
+ def page_template():
+ ui.label('Other app header').classes('text-h2')
+ ui.html('
')
+ yield
+ ui.html('
')
+ ui.label('Other app footer')
+
+
+ui.outlet('/other_app', router_class=OtherApp)
+
+
+# --- Main app ---
+
+class MainAppInstance(SinglePageRouter):
+ def __init__(self, menu_drawer, **kwargs):
+ super().__init__(**kwargs)
+ self.menu_drawer = menu_drawer
+ self.add_view('/', self.index)
+ self.add_view('/about', self.about_page, title='About NiceCLOUD')
+
+ def index(self): # main app index page
+ self.menu_drawer.clear() # clear drawer
+ self.menu_drawer.hide() # hide drawer
+ ui.label('Welcome to NiceCLOUD!').classes('text-3xl')
+ ui.html('
')
+ with ui.grid(columns=3) as grid:
+ grid.classes('gap-16')
+ for key, info in services.items():
+ link = f'/services/{key}'
+ with ui.element():
+ with ui.link(target=link) as lnk:
+ with ui.row().classes('text-2xl'):
+ ui.label(info.emoji)
+ ui.label(info.title)
+ lnk.style('text-decoration: none; color: inherit;')
+ ui.label(info.description)
+ ui.html('
')
+ # add a link to the other app
+ ui.markdown('Click [here](/other_app) to visit the other app.')
+
+ def about_page(self):
+ self.menu_drawer.clear() # clear drawer
+ self.menu_drawer.hide() # hide drawer
+ ui.label('About NiceCLOUD').classes('text-3xl')
+ ui.html('
')
+ ui.label('NiceCLOUD Inc.')
+ ui.label('1234 Nice Street')
+ ui.label('Nice City')
+ ui.label('Nice Country')
+
+ @staticmethod
+ def page_template(url_path: str):
+ with ui.header():
+ with ui.link('', '/').style('text-decoration: none; color: inherit;') as lnk:
+ ui.html('Nice'
+ 'CLOUD').classes('text-h3')
+ menu_visible = '/services/' in url_path # make instantly visible if the initial path is a service
+ menu_drawer = ui.left_drawer(bordered=True, value=menu_visible, fixed=True).classes('bg-primary')
+ with ui.footer():
+ with ui.element('a').props('href="/about"'):
+ ui.label('Copyright 2024 by NiceCLOUD Inc.').classes('text-h7')
+
+ with ui.element().classes('p-8'):
+ yield {'menu_drawer': menu_drawer} # pass menu drawer to all sub elements (views and outlets)
+
+
+class ServicesRouter(SinglePageRouter):
+ def __init__(self, service, **kwargs):
+ super().__init__(**kwargs)
+ assert isinstance(self.parent, MainAppInstance)
+ self.main_router: MainAppInstance = self.parent
+ self.service = service
+ self.add_view('/', self.show_index)
+
+ def update_title(self):
+ self.target.title = f'NiceCLOUD - {self.service.title}'
+
+ def show_index(self):
+ self.update_title()
+ with ui.row() as row:
+ ui.label(self.service.emoji).classes('text-h4 vertical-middle')
+ with ui.column():
+ ui.label(self.service.title).classes('text-h2')
+ ui.label(self.service.description)
+ ui.html('
')
+
+ @staticmethod
+ def page_template(service_name: str, menu_drawer):
+ service: ServiceDefinition = services[service_name]
+ menu_drawer.clear()
+ with menu_drawer:
+ menu_drawer.show()
+ with ui.row() as row:
+ ui.label(service.emoji)
+ ui.label(service.title)
+ row.classes('text-h5 text-white').style('text-shadow: 2px 2px #00000070;')
+ ui.html('
')
+ menu_items = service.sub_services
+ for key, info in menu_items.items():
+ with ui.row() as service_element:
+ ui.label(info.emoji)
+ ui.label(info.title)
+ service_element.classes('text-white text-h6 bg-gray cursor-pointer')
+ service_element.style('text-shadow: 2px 2px #00000070;')
+ service_element.on('click', lambda url=f'/services/{service_name}/{key}': ui.navigate.to(url))
+ yield {'service': service} # pass service object to all sub elements (views and outlets)
+
+
+class SubServiceRouter(SinglePageRouter):
+ def __init__(self, sub_service, **kwargs):
+ super().__init__(**kwargs)
+ assert isinstance(self.parent, ServicesRouter)
+ self.services_router: ServicesRouter = self.parent
+ self.sub_service = sub_service
+ self.add_view('/', self.sub_service_index)
+
+ def update_title(self):
+ self.target.title = f'NiceCLOUD - {self.sub_service.title}'
+
+ def sub_service_index(self):
+ self.update_title()
+ ui.label(self.sub_service.emoji).classes('text-h1')
+ ui.html('
')
+ ui.label(self.sub_service.description)
+
+ @staticmethod
+ def page_template(service: ServiceDefinition, sub_service_name: str):
+ sub_service: SubServiceDefinition = service.sub_services[sub_service_name]
+ ui.label(f'{service.title} > {sub_service.title}').classes('text-h4')
+ ui.html('
')
+ yield {'sub_service': sub_service} # pass sub_service object to all sub elements (views and outlets)
+
+
+# main app outlet
+main_router = ui.outlet('/', router_class=MainAppInstance)
+# service outlet
+services_router = main_router.outlet('/services/{service_name}', router_class=ServicesRouter)
+# sub service outlet
+sub_service_router = services_router.outlet('/{sub_service_name}', router_class=SubServiceRouter)
+
+ui.run(title='NiceCLOUD Portal', show=False)
diff --git a/examples/single_page_app_complex/services.json b/examples/single_page_app_complex/services.json
new file mode 100644
index 000000000..4dc764dc0
--- /dev/null
+++ b/examples/single_page_app_complex/services.json
@@ -0,0 +1,202 @@
+{
+ "services": {
+ "compute": {
+ "title": "Compute",
+ "emoji": "🖥",
+ "description": "Powerful virtual machines",
+ "sub_services": {
+ "vm_instances": {
+ "title": "VM Instances",
+ "emoji": "💻",
+ "description": "Scalable virtual machines available in seconds."
+ },
+ "kubernetes_services": {
+ "title": "Kubernetes Services",
+ "emoji": "🐳",
+ "description": "Managed Kubernetes clusters to orchestrate containers."
+ },
+ "serverless_functions": {
+ "title": "Serverless Functions",
+ "emoji": "⚡",
+ "description": "Run code without provisioning servers."
+ }
+ }
+ },
+ "storage": {
+ "title": "Storage",
+ "emoji": "🗄️",
+ "description": "Secure and scalable object storage",
+ "sub_services": {
+ "blob_storage": {
+ "title": "Blob Storage",
+ "emoji": "📦",
+ "description": "Massively scalable object storage for unstructured data."
+ },
+ "file_storage": {
+ "title": "File Storage",
+ "emoji": "📂",
+ "description": "Managed file shares for cloud or on-premises deployments."
+ },
+ "queue_storage": {
+ "title": "Queue Storage",
+ "emoji": "📬",
+ "description": "A messaging store for reliable messaging between application components."
+ }
+ }
+ },
+ "networking": {
+ "title": "Networking",
+ "emoji": "🔗",
+ "description": "Private networks and connectivity",
+ "sub_services": {
+ "vnetwork": {
+ "title": "Virtual Network",
+ "emoji": "🌐",
+ "description": "Provision private networks, optionally connect to on-premises datacenters."
+ },
+ "load_balancer": {
+ "title": "Load Balancer",
+ "emoji": "⚖️",
+ "description": "Deliver high availability and network performance to your applications."
+ },
+ "vpn_gateway": {
+ "title": "VPN Gateway",
+ "emoji": "🔐",
+ "description": "Establish secure, cross-premises connectivity."
+ }
+ }
+ },
+ "database": {
+ "title": "Database",
+ "emoji": "💾",
+ "description": "Managed SQL and NoSQL databases",
+ "sub_services": {
+ "sql_database": {
+ "title": "SQL Database",
+ "emoji": "🗃️",
+ "description": "Managed, intelligent SQL in the cloud."
+ },
+ "cosmos_db": {
+ "title": "Cosmos DB",
+ "emoji": "🌍",
+ "description": "Globally distributed, multi-model database service."
+ },
+ "database_migration": {
+ "title": "Database Migration",
+ "emoji": "🚚",
+ "description": "Simplify on-premises database migration to the cloud."
+ }
+ }
+ },
+ "ai": {
+ "title": "AI & Machine Learning",
+ "emoji": "🤖",
+ "description": "AI services to build machine learning models",
+ "sub_services": {
+ "machine_learning": {
+ "title": "Machine Learning",
+ "emoji": "🧠",
+ "description": "Build, train, and deploy models from the cloud to the edge."
+ },
+ "cognitive_services": {
+ "title": "Cognitive Services",
+ "emoji": "🧩",
+ "description": "Infuse your apps, websites and bots with intelligent algorithms."
+ },
+ "bot_services": {
+ "title": "Bot Services",
+ "emoji": "🤖",
+ "description": "Develop intelligent, enterprise-grade bots."
+ }
+ }
+ },
+ "analytics": {
+ "title": "Analytics",
+ "emoji": "📊",
+ "description": "Big data processing and analytics",
+ "sub_services": {
+ "hdinsight": {
+ "title": "HDInsight",
+ "emoji": "🔬",
+ "description": "Provision cloud Hadoop, Spark, R Server, HBase, and Storm clusters."
+ },
+ "data_lake_analytics": {
+ "title": "Data Lake Analytics",
+ "emoji": "🏞️",
+ "description": "On-demand analytics job service using a simple developer experience."
+ },
+ "stream_analytics": {
+ "title": "Stream Analytics",
+ "emoji": "🌊",
+ "description": "Real-time data stream processing from millions of IoT devices."
+ }
+ }
+ },
+ "devops": {
+ "title": "DevOps",
+ "emoji": "🛠️",
+ "description": "Tools and services for CI/CD",
+ "sub_services": {
+ "devops_pipelines": {
+ "title": "DevOps Pipelines",
+ "emoji": "🚀",
+ "description": "CI/CD that works with any language, platform, and cloud."
+ },
+ "artifacts": {
+ "title": "Artifacts",
+ "emoji": "📦",
+ "description": "Create, host, and share packages with your team."
+ },
+ "source_repositories": {
+ "title": "Source Repositories",
+ "emoji": "🗂️",
+ "description": "Host private Git repositories."
+ }
+ }
+ },
+ "security": {
+ "title": "Security & Identity",
+ "emoji": "🔒",
+ "description": "Identity management and threat detection",
+ "sub_services": {
+ "identity_services": {
+ "title": "Identity Services",
+ "emoji": "🔑",
+ "description": "Protect and manage identities as a primary security perimeter."
+ },
+ "threat_protection": {
+ "title": "Threat Protection",
+ "emoji": "🛡️",
+ "description": "Advanced threat detection and protection across cloud services."
+ },
+ "information_protection": {
+ "title": "Information Protection",
+ "emoji": "📄",
+ "description": "Secure sensitive data everywhere it resides."
+ }
+ }
+ },
+ "iot": {
+ "title": "Internet of Things",
+ "emoji": "🌐",
+ "description": "Internet of Things services",
+ "sub_services": {
+ "iot_hub": {
+ "title": "IoT Hub",
+ "emoji": "🔗",
+ "description": "Connect, monitor and manage billions of IoT assets."
+ },
+ "iot_edge": {
+ "title": "IoT Edge",
+ "emoji": "🛠️",
+ "description": "Deploy cloud intelligence directly on IoT devices to operate in offline mode."
+ },
+ "digital_twins": {
+ "title": "Digital Twins",
+ "emoji": "👥",
+ "description": "Create digital representations of connected environments."
+ }
+ }
+ }
+ }
+}
diff --git a/nicegui/client.py b/nicegui/client.py
index f016e11a4..3b3fbdc32 100644
--- a/nicegui/client.py
+++ b/nicegui/client.py
@@ -13,6 +13,8 @@
from fastapi.templating import Jinja2Templates
from typing_extensions import Self
+from nicegui.context import context
+
from . import background_tasks, binding, core, helpers, json
from .awaitable_response import AwaitableResponse
from .dependencies import generate_resources
@@ -25,7 +27,10 @@
from .version import __version__
if TYPE_CHECKING:
+ from nicegui.outlet import Outlet
+
from .page import page
+ from .single_page_router import SinglePageRouter
templates = Jinja2Templates(Path(__file__).parent / 'templates')
@@ -34,6 +39,12 @@ class Client:
page_routes: ClassVar[Dict[Callable[..., Any], str]] = {}
"""Maps page builders to their routes."""
+ page_configs: ClassVar[Dict[Callable[..., Any], 'page']] = {}
+ """Maps page builders to their page configuration."""
+
+ top_level_outlets: ClassVar[Dict[str, 'Outlet']] = {}
+ """Maps paths to the associated single page routers."""
+
instances: ClassVar[Dict[str, Client]] = {}
"""Maps client IDs to clients."""
@@ -78,6 +89,7 @@ def __init__(self, page: page, *, request: Optional[Request]) -> None:
self.page = page
self.storage = ObservableDict()
+ self.single_page_router: Optional[SinglePageRouter] = None
self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
@@ -226,6 +238,12 @@ async def send_and_wait():
def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> None:
"""Open a new page in the client."""
path = target if isinstance(target, str) else self.page_routes[target]
+ for outlet in self.top_level_outlets.values():
+ outlet_target = outlet.resolve_target(path)
+ if outlet_target.valid:
+ assert context.client.single_page_router
+ context.client.single_page_router.navigate_to(path)
+ return
self.outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id)
def download(self, src: Union[str, bytes], filename: Optional[str] = None, media_type: str = '') -> None:
diff --git a/nicegui/elements/link.py b/nicegui/elements/link.py
index 2c559f036..90189c225 100644
--- a/nicegui/elements/link.py
+++ b/nicegui/elements/link.py
@@ -2,6 +2,7 @@
from ..client import Client
from ..element import Element
+from ..outlet_view import OutletView
from .mixins.text_element import TextElement
@@ -28,8 +29,14 @@ def __init__(self,
self._props['href'] = target
elif isinstance(target, Element):
self._props['href'] = f'#c{target.id}'
+ elif isinstance(target, OutletView):
+ self._props['href'] = target.url
elif callable(target):
- self._props['href'] = Client.page_routes[target]
+ if target in Client.page_routes:
+ self._props['href'] = Client.page_routes[target]
+ else:
+ self._props['href'] = "#"
+ self.on('click', lambda: target())
self._props['target'] = '_blank' if new_tab else '_self'
self._classes.append('nicegui-link')
diff --git a/nicegui/elements/router_frame.js b/nicegui/elements/router_frame.js
new file mode 100644
index 000000000..08a49b804
--- /dev/null
+++ b/nicegui/elements/router_frame.js
@@ -0,0 +1,96 @@
+export default {
+ template: '',
+ mounted() {
+ if (this._debug) console.log('Mounted RouterFrame ' + this.base_path);
+
+ let router = this;
+
+ function normalize_path(path) {
+ let href = path.split('?')[0].split('#')[0]
+ if (href.endsWith('/')) href = href.slice(0, -1);
+ return href;
+ }
+
+ function validate_path(path) {
+ let href = normalize_path(path);
+ // for all excluded path masks
+ for (let mask of router.excluded_path_masks) {
+ // apply filename matching with * and ? wildcards
+ let regex = new RegExp(mask.replace(/\?/g, '.').replace(/\*/g, '.*'));
+ if (!regex.test(href)) continue;
+ return false;
+ }
+ // for all included path masks
+ for (let mask of router.included_path_masks) {
+ // apply filename matching with * and ? wildcards
+ let regex = new RegExp(mask.replace(/\?/g, '.').replace(/\*/g, '.*'));
+ if (!regex.test(href)) continue;
+ return true;
+ }
+ return false;
+ }
+
+ function is_handled_by_child_frame(path) {
+ // check child frames
+ let href = normalize_path(path);
+ for (let frame of router.child_frame_paths) {
+ if (path.startsWith(frame + '/') || (href === frame)) {
+ if (router._debug) console.log(href + ' handled by child RouterFrame ' + frame + ', skipping...');
+ return true;
+ }
+ }
+ return false;
+ }
+
+ this.clickEventListener = function (e) {
+ // Check if the clicked element is a link
+ // Use closest to find the nearest parent tag
+ let link = e.target.closest('a');
+
+ // If there's no tag, or the tag has no href attribute, do nothing
+ if (!link || !link.hasAttribute('href')) return;
+ let href = link.getAttribute('href');
+ if (href === '#') {
+ e.preventDefault();
+ return;
+ }
+ // remove query and anchor
+ if (validate_path(href)) {
+ e.preventDefault(); // Prevent the default link behavior
+ if (!is_handled_by_child_frame(href)) {
+ router.$emit('open', href, true);
+ if (router._debug) console.log('Opening ' + href + ' by ' + router.base_path);
+ }
+ }
+ };
+ this.popstateEventListener = function (event) {
+ let state = event.state;
+ let href = window.location.pathname;
+ event.preventDefault();
+ if (window.location.hash) {
+ return;
+ }
+ if (validate_path(href) && !is_handled_by_child_frame(href)) {
+ router.$emit('open', href, false);
+ if (router._debug) console.log('Pop opening ' + href + ' by ' + router.base_path);
+ }
+ };
+
+ document.addEventListener('click', this.clickEventListener);
+ window.addEventListener('popstate', this.popstateEventListener);
+ },
+ unmounted() {
+ document.removeEventListener('click', this.clickEventListener);
+ window.removeEventListener('popstate', this.popstateEventListener);
+ if (this._debug) console.log('Unmounted RouterFrame ' + this.base_path);
+ },
+ props: {
+ base_path: {type: String},
+ target_url: {type: String},
+ included_path_masks: [],
+ excluded_path_masks: [],
+ use_browser_history: {type: Boolean, default: true},
+ child_frame_paths: [],
+ _debug: {type: Boolean, default: false},
+ },
+};
diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py
new file mode 100644
index 000000000..aa79dd75e
--- /dev/null
+++ b/nicegui/elements/router_frame.py
@@ -0,0 +1,150 @@
+import inspect
+from typing import Optional, Any, Callable
+
+from nicegui import ui, background_tasks
+
+
+class RouterFrame(ui.element, component='router_frame.js'):
+ """The RouterFrame is a special element which is used by the SinglePageRouter to exchange the content of
+ the current page with the content of the new page. It serves as container and overrides the browser's history
+ management to prevent the browser from reloading the whole page."""
+
+ def __init__(self,
+ base_path: str,
+ target_url: Optional[str] = None,
+ included_paths: Optional[list[str]] = None,
+ excluded_paths: Optional[list[str]] = None,
+ use_browser_history: bool = True,
+ on_navigate: Optional[Callable[[str, Optional[bool]], Any]] = None,
+ user_data: Optional[dict] = None
+ ):
+ """
+ :param base_path: The base URL path all relative paths are based on
+ :param included_paths: A list of valid path masks which shall be allowed to be opened by the router
+ :param excluded_paths: A list of path masks which shall be excluded from the router
+ :param use_browser_history: Optional flag to enable or disable the browser history management. Default is True.
+ :param target_url: The initial url of the router frame
+ :param on_navigate: Optional callback which is called when the browser / JavaScript navigates to a new url
+ :param user_data: Optional user data which is passed to the builder functions of the router frame
+ """
+ super().__init__()
+ included_masks = []
+ excluded_masks = []
+ if included_paths is not None:
+ for path in included_paths:
+ cleaned = path.rstrip('/')
+ included_masks.append(cleaned)
+ included_masks.append(cleaned + '/*')
+ if excluded_paths is not None:
+ for path in excluded_paths:
+ cleaned = path.rstrip('/')
+ excluded_masks.append(cleaned)
+ excluded_masks.append(cleaned + '/*')
+ self._props['target_url'] = target_url
+ self._props['included_path_masks'] = included_masks
+ self._props['excluded_path_masks'] = excluded_masks
+ self._props['base_path'] = base_path
+ self._props['browser_history'] = use_browser_history
+ self._props['child_frames'] = []
+ self.on('open', lambda e: self.handle_navigate(e.args[0], e.args[1]))
+ self.on_navigate = on_navigate
+ self.user_data = user_data if user_data is not None else {}
+
+ def handle_navigate(self, url: str, history=True):
+ """Navigate to a new url
+
+ :param url: The url to navigate to
+ :param history: Optional flag to enable or disable the browser history management. Default is True.
+ """
+ if self.on_navigate is not None:
+ self.on_navigate(url, history)
+
+ @property
+ def target_url(self) -> str:
+ """The current target url of the router frame"""
+ return self._props['target_url']
+
+ @target_url.setter
+ def target_url(self, value: str):
+ """Set the target url of the router frame"""
+ self._props['target_url'] = value
+
+ def add_included_path(self, path: str):
+ """Add a path to the included paths list"""
+ self._props['included_path_masks'] += [path]
+
+ def update_content(self,
+ builder: Callable,
+ builder_kwargs: dict,
+ title: Optional[str],
+ target_fragment: Optional[str],
+ sync: bool = False):
+ """Update the content of the router frame
+
+ :param builder: The builder function which builds the content of the page
+ :param builder_kwargs: The keyword arguments to pass to the builder function
+ :param title: The title of the page
+ :param target_fragment: The fragment to navigate to after the content has been loaded
+ :param sync: Optional flag to define if the content should be updated synchronously. Default is False."""
+
+ def exec_builder():
+ """Execute the builder function with the given keyword arguments"""
+ self.run_safe(builder, **builder_kwargs)
+
+ async def build() -> None:
+ with self:
+ exec_builder()
+ if target_fragment is not None:
+ await ui.run_javascript(f'window.location.href = "#{target_fragment}";')
+
+ if sync:
+ with self:
+ exec_builder()
+ if target_fragment is not None:
+ ui.run_javascript(f'window.location.href = "#{target_fragment}";')
+ else:
+ background_tasks.create(build())
+
+ def clear(self) -> None:
+ """Clear the content of the router frame and removes all references to sub frames"""
+ self._props['child_frame_paths'] = []
+ super().clear()
+
+ @property
+ def child_frame_paths(self) -> list[str]:
+ """The child paths of the router frame"""
+ return self._props['child_frame_paths']
+
+ @child_frame_paths.setter
+ def child_frame_paths(self, paths: list[str]) -> None:
+ """Update the child paths of the router frame
+
+ :param paths: The list of child paths"""
+ self._props['child_frame_paths'] = paths
+
+ @staticmethod
+ def run_safe(builder, type_check: bool = True, **kwargs) -> Any:
+ """Run a builder function but only pass the keyword arguments which are expected by the builder function
+
+ :param builder: The builder function
+ :param type_check: Optional flag to enable or disable the type checking of the keyword arguments.
+ Default is True.
+ :param kwargs: The keyword arguments to pass to the builder function
+ """
+ sig = inspect.signature(builder)
+ args = sig.parameters.keys()
+ has_kwargs = any([param.kind == inspect.Parameter.VAR_KEYWORD for param in
+ inspect.signature(builder).parameters.values()])
+ if type_check:
+ for func_param_name, func_param_info in sig.parameters.items():
+ if func_param_name not in kwargs:
+ continue
+ if func_param_info.annotation is inspect.Parameter.empty:
+ continue
+ # ensure type is correct
+ if not isinstance(kwargs[func_param_name], func_param_info.annotation):
+ raise ValueError(f'Invalid type for parameter {func_param_name}, '
+ f'expected {func_param_info.annotation}')
+
+ filtered = {k: v for k, v in kwargs.items() if k in args} if not has_kwargs else kwargs
+ return builder(**filtered)
diff --git a/nicegui/outlet.py b/nicegui/outlet.py
new file mode 100644
index 000000000..5aa9b72c5
--- /dev/null
+++ b/nicegui/outlet.py
@@ -0,0 +1,359 @@
+from typing import Any, Callable, Generator, Optional, Self, Union, Dict, Set, List
+
+import inspect
+import re
+from fnmatch import fnmatch
+
+from nicegui import ui
+from nicegui.client import Client
+from nicegui.elements.router_frame import RouterFrame
+from nicegui.outlet_view import OutletView
+from nicegui.single_page_router import SinglePageRouter
+from nicegui.single_page_target import SinglePageTarget
+from nicegui.context import context
+
+PAGE_TEMPLATE_METHOD_NAME = "page_template"
+
+
+class Outlet:
+ """An outlet allows the creation of single page applications which do not reload the page when navigating between
+ different views. The outlet is a container for multiple views and can contain further, nested outlets.
+
+ To define a new outlet, use the @ui.outlet decorator on a function which defines the layout of the outlet.
+ The layout function must be a generator function and contain a yield statement to separate the layout from the
+ actual content area. The yield can also be used to pass properties to the content are by return a dictionary
+ with the properties. Each property can be received as function argument in all nested views and outlets.
+
+ Once the outlet is defined, multiple views can be added to the outlet using the @.view decorator on
+ a function."""
+
+ def __init__(self,
+ path: str,
+ outlet_builder: Optional[Callable] = None,
+ browser_history: bool = True,
+ parent: Optional['Outlet'] = None,
+ on_instance_created: Optional[Callable[['SinglePageRouter'], None]] = None,
+ on_navigate: Optional[Callable[[str], Optional[Union[SinglePageTarget, str]]]] = None,
+ router_class: Optional[Callable[..., SinglePageRouter]] = None,
+ **kwargs) -> None:
+ """
+ :param path: the base path of the single page router.
+ :param outlet_builder: A layout definition function which defines the layout of the page. The layout builder
+ must be a generator function and contain a yield statement to separate the layout from the content area.
+ :param layout_builder: A layout builder function which defines the layout of the page. The layout builder
+ must be a generator function and contain a yield statement to separate the layout from the content area.
+ :param browser_history: Optional flag to enable or disable the browser history management. Default is True.
+ :param on_instance_created: Optional callback which is called when a new instance is created. Each browser tab
+ or window is a new instance. This can be used to initialize the state of the application.
+ :param on_navigate: Optional callback which is called when a navigation event is triggered. Can be used to
+ prevent or modify the navigation. Return the new URL if the navigation should be allowed, modify the URL
+ or return None to prevent the navigation.
+ :param router_class: Optional class which is used to create the router instance. The class must be a subclass
+ of SinglePageRouter. If not provided, the default SinglePageRouter is used.
+
+ If the class defines a method with the name 'page_template', this method is used as the outlet builder.
+ :param parent: The parent outlet of this outlet.
+ :param kwargs: Additional arguments
+ """
+ super().__init__()
+ self.routes: Dict[str, 'OutletPath'] = {}
+ self.base_path = path
+ self.included_paths: Set[str] = set()
+ self.excluded_paths: Set[str] = set()
+ self.on_instance_created: Optional[Callable] = on_instance_created
+ self.on_navigate: Optional[Callable[[str], Optional[Union[SinglePageTarget, str]]]] = on_navigate
+ self.use_browser_history = browser_history
+ self.page_template = None
+ self._setup_configured = False
+ self.parent_config = parent
+ if self.parent_config is not None:
+ self.parent_config._register_child_outlet(self)
+ self.child_routers: List['Outlet'] = []
+ self.page_kwargs = kwargs
+ self.router_class = SinglePageRouter if router_class is None else router_class
+ self.outlet_builder: Optional[Callable] = None
+ if parent is None:
+ Client.top_level_outlets[path] = self
+ if router_class is not None:
+ # check if class defines outlet builder function
+ if hasattr(router_class, PAGE_TEMPLATE_METHOD_NAME):
+ outlet_builder = getattr(router_class, PAGE_TEMPLATE_METHOD_NAME)
+ if outlet_builder is not None:
+ self(outlet_builder)
+
+ def build_page_template(self, **kwargs):
+ """Setups the content area for the single page router"""
+ if self.outlet_builder is None:
+ raise ValueError('The outlet builder function is not defined. Use the @outlet decorator to define it or'
+ ' pass it as an argument to the SinglePageRouter constructor.')
+ frame = RouterFrame.run_safe(self.outlet_builder, **kwargs)
+ # if not isinstance(frame, Generator):
+ # raise ValueError('The outlet builder must be a generator function and contain a yield statement'
+ # ' to separate the layout from the content area.')
+ properties = {}
+
+ def add_properties(result):
+ if isinstance(result, dict):
+ properties.update(result)
+
+ router_frame = SinglePageRouter.current_router()
+ if isinstance(frame, Generator):
+ add_properties(next(frame)) # insert ui elements before yield
+ if router_frame is not None:
+ router_frame.update_user_data(properties)
+ yield properties
+ try:
+ if isinstance(frame, Generator):
+ add_properties(next(frame)) # if provided insert ui elements after yield
+ if router_frame is not None:
+ router_frame.update_user_data(properties)
+ except StopIteration:
+ pass
+
+ def __call__(self, func: Callable[..., Any]) -> Self:
+ """Decorator for the layout builder / "outlet" function"""
+
+ def outlet_view(**kwargs):
+ self.build_page(**kwargs)
+
+ self.outlet_builder = func
+ if self.parent_config is None:
+ self.setup_page()
+ else:
+ relative_path = self.base_path[len(self.parent_config.base_path):]
+ OutletView(self.parent_config, relative_path)(outlet_view)
+ return self
+
+ def setup_page(self, overwrite=False) -> Self:
+ """Setup the NiceGUI page with all it's endpoints and their base UI structure for the root routers
+
+ :param overwrite: Optional flag to force the setup of a given page even if one with a conflicting path is
+ already existing. Default is False. Classes such as SinglePageApp use this flag to avoid conflicts with
+ other routers and resolve those conflicts by rerouting the pages."""
+ for key, route in Client.page_routes.items():
+ if route.startswith(self.base_path.rstrip('/') + '/') and route.rstrip('/') not in self.included_paths:
+ self.excluded_paths.add(route)
+ if overwrite:
+ continue
+ if self.base_path.startswith(route.rstrip('/') + '/'): # '/sub_router' after '/' - forbidden
+ raise ValueError(f'Another router with path "{route.rstrip("/")}/*" is already registered which '
+ f'includes this router\'s base path "{self.base_path}". You can declare the nested '
+ f'router first to prioritize it and avoid this issue.')
+
+ @ui.page(self.base_path, **self.page_kwargs)
+ @ui.page(f'{self.base_path}' + '{_:path}', **self.page_kwargs) # all other pages
+ async def root_page():
+ await ui.context.client.connected() # to ensure storage.tab and storage.client availability
+ request = context.client.request
+ initial_url = request.url.path
+ query = request.url.query
+ if query:
+ initial_url += '?' + query
+ self.build_page(initial_url=initial_url)
+
+ return self
+
+ def add_view(self, path: str, builder: Callable, title: Optional[str] = None,
+ on_open: Optional[Callable[[SinglePageTarget], SinglePageTarget]] = None) -> None:
+ """Add a new route to the single page router
+
+ :param path: The path of the route, including FastAPI path parameters
+ :param builder: The builder function (the view to be displayed)
+ :param title: Optional title of the page
+ :param on_open: Optional on_resolve function which is called when this path was selected.
+ """
+ path_mask = OutletPath.create_path_mask(path.rstrip('/'))
+ self.included_paths.add(path_mask)
+ self.routes[path] = OutletPath(path, builder, title, on_open=on_open).verify()
+
+ def add_router_entry(self, entry: 'OutletPath') -> None:
+ """Adds a fully configured OutletPath to the router
+
+ :param entry: The OutletPath to add"""
+ self.routes[entry.path] = entry.verify()
+
+ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget:
+ """Tries to resolve a target such as a builder function or a URL path with route and query parameters.
+
+ :param target: The URL path to open or a builder function
+ :return: The resolved target. Defines .valid if the target is valid"""
+ if callable(target):
+ for target, entry in self.routes.items():
+ if entry.builder == target:
+ return SinglePageTarget(router_path=entry)
+ raise ValueError('The target builder function is not registered in the router.')
+ resolved = None
+ path = target.split('#')[0].split('?')[0]
+ for router in self.child_routers:
+ # replace {} placeholders with * to match the fnmatch pattern
+ mask = OutletPath.create_path_mask(router.base_path.rstrip('/') + '/*')
+ if fnmatch(path, mask) or path == router.base_path:
+ resolved = router.resolve_target(target)
+ if resolved.valid:
+ target = router.base_path
+ if '*' in mask:
+ # isolate the real path elements and update target accordingly
+ target = '/'.join(path.split('/')[:len(router.base_path.split('/'))])
+ break
+ result = SinglePageTarget(target).parse_url_path(routes=self.routes)
+ if resolved is not None:
+ result.original_path = resolved.original_path
+ return result
+
+ def handle_navigate(self, url: str) -> Optional[Union[SinglePageTarget, str]]:
+ """Handles a navigation event and returns the new URL if the navigation should be allowed
+
+ :param url: The URL to navigate to
+ :return: The new URL if the navigation should be allowed, None otherwise"""
+ if self.on_navigate is not None:
+ new_target = self.on_navigate(url)
+ if isinstance(new_target, SinglePageTarget):
+ return new_target
+ if new_target != url:
+ return new_target
+ if self.parent_config is not None:
+ return self.parent_config.handle_navigate(url)
+ return url
+
+ def build_page(self, initial_url: Optional[str] = None, **kwargs) -> None:
+ """Builds the page with the given initial URL
+
+ :param initial_url: The initial URL to initialize the router's content with
+ :param kwargs: Additional keyword arguments passed to the page template generator function"""
+ kwargs['url_path'] = initial_url
+ template = RouterFrame.run_safe(self.build_page_template, **kwargs)
+ if not isinstance(template, Generator):
+ raise ValueError('The page template method must yield a value to separate the layout from the content '
+ 'area.')
+ new_user_data = {}
+ new_properties = next(template)
+ if isinstance(new_properties, dict):
+ new_user_data.update(new_properties)
+ content_area = self.create_router_instance(initial_url, user_data=new_user_data)
+ try:
+ new_properties = next(template)
+ if isinstance(new_properties, dict):
+ new_user_data.update(new_properties)
+ except StopIteration:
+ pass
+ content_area.update_user_data(new_user_data)
+
+ def create_router_instance(self,
+ initial_url: Optional[str] = None,
+ user_data: Optional[Dict] = None) -> SinglePageRouter:
+ """Creates a new router instance for the current visitor.
+
+ :param initial_url: The initial URL to initialize the router's content with
+ :param user_data: Optional user data to pass to the content area
+ :return: The created router instance"""
+
+ user_data_kwargs = {}
+
+ def prepare_arguments():
+ nonlocal parent_router, user_data_kwargs
+ init_params = inspect.signature(self.router_class.__init__).parameters
+ param_names = set(init_params.keys()) - {'self'}
+ user_data_kwargs = {k: v for k, v in user_data.items() if k in param_names}
+ cur_parent = parent_router
+ while cur_parent is not None:
+ user_data_kwargs.update({k: v for k, v in cur_parent.user_data.items() if k in param_names})
+ cur_parent = cur_parent.parent
+
+ parent_router = SinglePageRouter.current_router()
+ prepare_arguments()
+ content = self.router_class(config=self,
+ included_paths=sorted(list(self.included_paths)),
+ excluded_paths=sorted(list(self.excluded_paths)),
+ use_browser_history=self.use_browser_history,
+ parent=parent_router,
+ target_url=initial_url,
+ user_data=user_data,
+ **user_data_kwargs)
+ if parent_router is None: # register root routers to the client
+ context.client.single_page_router = content
+ initial_url = content.target_url
+ if self.on_instance_created is not None:
+ self.on_instance_created(content)
+ if initial_url is not None:
+ content.navigate_to(initial_url, server_side=True, sync=True, history=False)
+ return content
+
+ def view(self,
+ path: str,
+ title: Optional[str] = None
+ ) -> 'OutletView':
+ """Decorator for the view function.
+
+ With the view function you define the actual content of the page. The view function is called when the user
+ navigates to the specified path relative to the outlet's base path.
+
+ :param path: The path of the view, relative to the base path of the outlet
+ :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab
+ when the view is active, otherwise the default title of the application is displayed.
+ """
+ return OutletView(self, path, title=title)
+
+ def outlet(self, path: str, **kwargs) -> 'Outlet':
+ """Defines a nested outlet
+
+ :param path: The relative path of the outlet
+ :param kwargs: Additional arguments for the nested ui.outlet
+ """
+ abs_path = self.base_path.rstrip('/') + path
+ return Outlet(abs_path, parent=self, **kwargs)
+
+ @property
+ def current_url(self) -> str:
+ """Returns the current URL of the outlet.
+
+ Only works when called from within the outlet or view builder function.
+
+ :return: The current URL of the outlet"""
+ cur_router = SinglePageRouter.current_router()
+ if cur_router is None:
+ raise ValueError('The current URL can only be retrieved from within a nested outlet or view builder '
+ 'function.')
+ return cur_router.target_url
+
+ def _register_child_outlet(self, router_config: 'Outlet') -> None:
+ """Registers a child outlet config to the parent router config"""
+ self.child_routers.append(router_config)
+
+
+class OutletPath:
+ """The OutletPath is a data class which holds the configuration of one router path"""
+
+ def __init__(self, path: str, builder: Callable, title: Union[str, None] = None,
+ on_open: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None):
+ """
+ :param path: The path of the route
+ :param builder: The builder function which is called when the route is opened
+ :param title: Optional title of the page
+ :param on_open: Optional on_resolve function which is called when this path was selected."""
+ self.path = path
+ self.builder = builder
+ self.title = title
+ self.on_open = on_open
+
+ def verify(self) -> Self:
+ """Verifies a OutletPath for correctness. Raises a ValueError if the entry is invalid."""
+ path = self.path
+ if '{' in path:
+ # verify only a single open and close curly bracket is present
+ elements = path.split('/')
+ for cur_element in elements:
+ if '{' in cur_element:
+ if cur_element.count('{') != 1 or cur_element.count('}') != 1 or len(cur_element) < 3 or \
+ not (cur_element.startswith('{') and cur_element.endswith('}')):
+ raise ValueError('Only simple path parameters are supported. /path/{value}/{another_value}\n'
+ f'failed for path: {path}')
+ return self
+
+ @staticmethod
+ def create_path_mask(path: str) -> str:
+ """Converts a path to a mask which can be used for fnmatch matching
+
+ /site/{value}/{other_value} --> /site/*/*
+ :param path: The path to convert
+ :return: The mask with all path parameters replaced by a wildcard"""
+ return re.sub(r'{[^}]+}', '*', path)
diff --git a/nicegui/outlet_view.py b/nicegui/outlet_view.py
new file mode 100644
index 000000000..415cc9691
--- /dev/null
+++ b/nicegui/outlet_view.py
@@ -0,0 +1,44 @@
+from typing import Any, Callable, Optional, TYPE_CHECKING
+
+from nicegui.single_page_target import SinglePageTarget
+
+if TYPE_CHECKING:
+ from nicegui.outlet import Outlet
+
+
+class OutletView:
+ """Defines a single view / "content page" which is displayed in an outlet"""
+
+ def __init__(self, parent: 'Outlet', path: str, title: Optional[str] = None):
+ """
+ :param parent: The parent outlet in which this view is displayed
+ :param path: The path of the view, relative to the base path of the outlet
+ :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab
+ when the view is active, otherwise the default title of the application is displayed.
+ """
+ self.path = path
+ self.title = title
+ self.parent_outlet = parent
+
+ @property
+ def url(self) -> str:
+ """The absolute URL of the view
+
+ :return: The absolute URL of the view
+ """
+ return (self.parent_outlet.base_path.rstrip('/') + '/' + self.path.lstrip('/')).rstrip('/')
+
+ def handle_resolve(self, target: SinglePageTarget, **kwargs) -> SinglePageTarget:
+ """Is called when the target is resolved to this view
+
+ :param target: The resolved target
+ :return: The resolved target or a modified target
+ """
+ return target
+
+ def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
+ """Decorator for the view function"""
+ abs_path = self.url
+ self.parent_outlet.add_view(
+ abs_path, func, title=self.title, on_open=self.handle_resolve)
+ return self
diff --git a/nicegui/page.py b/nicegui/page.py
index 7f692d40a..8b2aaaa90 100644
--- a/nicegui/page.py
+++ b/nicegui/page.py
@@ -114,6 +114,7 @@ async def decorated(*dec_args, **dec_kwargs) -> Response:
async def wait_for_result() -> None:
with client:
return await result
+
task = background_tasks.create(wait_for_result())
deadline = time.time() + self.response_timeout
while task and not client.is_waiting_for_connection and not task.done():
@@ -142,4 +143,5 @@ async def wait_for_result() -> None:
self.api_router.get(self._path, **self.kwargs)(decorated)
Client.page_routes[func] = self.path
+ Client.page_configs[func] = self
return func
diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py
new file mode 100644
index 000000000..8ad78e576
--- /dev/null
+++ b/nicegui/single_page_router.py
@@ -0,0 +1,288 @@
+from fnmatch import fnmatch
+from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Self, Union
+
+from nicegui import core, ui
+from nicegui.context import context
+from nicegui.elements.router_frame import RouterFrame
+from nicegui.single_page_target import SinglePageTarget
+
+PATH_RESOLVING_MAX_RECURSION = 100
+
+
+class SinglePageRouter:
+ """The SinglePageRouter manages the SinglePage navigation and content updates for a SinglePageApp instance.
+
+ When ever a new page is opened, the SinglePageRouter exchanges the content of the current page with the content
+ of the new page. The SinglePageRouter also manages the browser history and title updates.
+
+ See ui.outlet for more information."""
+
+ def __init__(self,
+ config: 'SinglePageRouterConfig',
+ included_paths: Optional[list[str]] = None,
+ excluded_paths: Optional[list[str]] = None,
+ use_browser_history: bool = True,
+ change_title: bool = True,
+ parent: 'SinglePageRouter' = None,
+ target_url: Optional[str] = None,
+ user_data: Optional[Dict] = None
+ ):
+ """
+ :param config: The SinglePageRouter which controls this router frame
+ :param included_paths: A list of valid path masks which shall be allowed to be opened by the router
+ :param excluded_paths: A list of path masks which shall be excluded from the router
+ :param use_browser_history: Optional flag to enable or disable the browser history management. Default is True.
+ :param change_title: Optional flag to enable or disable the title change. Default is True.
+ :param target_url: The initial url of the router frame
+ :param user_data: Optional user data which is passed to the builder functions of the router frame
+ """
+ super().__init__()
+ self.router_config = config
+ self.base_path = config.base_path
+ if target_url is None:
+ if parent is not None and parent.target_url is not None:
+ target_url = parent.target_url
+ else:
+ target_url = self.router_config.base_path
+ self.user_data = user_data
+ self.child_routers: dict[str, "SinglePageRouter"] = {}
+ self.use_browser_history = use_browser_history
+ self.change_title = change_title
+ self.parent = parent
+ # split base path into it's elements
+ base_path_elements = self.router_config.base_path.split('/')
+ # replace all asterisks with the actual path elements from target url where possible
+ target_url_elements = target_url.split('/')
+ for i, element in enumerate(base_path_elements):
+ if element.startswith("{") and element.endswith("}"):
+ if i < len(target_url_elements):
+ base_path_elements[i] = target_url_elements[i]
+ # repeat the same for all included paths
+ if included_paths is not None:
+ for i, path in enumerate(included_paths):
+ path_elements = path.split('/')
+ for j, element in enumerate(path_elements):
+ if element == '*':
+ if j < len(base_path_elements):
+ path_elements[j] = base_path_elements[j]
+ included_paths[i] = '/'.join(path_elements)
+ self.base_path = '/'.join(base_path_elements)
+ if parent is not None:
+ parent._register_child_router(self.base_path, self)
+ self.router_frame = RouterFrame(base_path=self.base_path,
+ target_url=target_url,
+ included_paths=included_paths,
+ excluded_paths=excluded_paths,
+ use_browser_history=use_browser_history,
+ on_navigate=lambda url, history: self.navigate_to(url, history=history),
+ user_data={'router': self})
+ self._on_navigate: Optional[Callable[[str], Optional[Union[SinglePageTarget, str]]]] = None
+ self.views = {}
+
+ def add_view(self, path: str, builder: Callable, title: Optional[str] = None, **kwargs) -> Self:
+ """Add a view to the router
+
+ :param path: The path of the view
+ :param builder: The builder function of the view
+ :param title: The title of the view
+ :param kwargs: Additional arguments"""
+ path = path.lstrip('/')
+ if path in self.views:
+ raise ValueError(f'View with path {path} already exists')
+ self.views[path] = RouterView(path, builder, title, **kwargs)
+ absolute_path = (self.base_path.rstrip('/') + path).rstrip('/')
+ self.router_frame.add_included_path(absolute_path)
+ return self
+
+ @property
+ def target_url(self) -> str:
+ """The current target url of the router frame
+
+ :return: The target url of the router frame"""
+ return self.router_frame.target_url
+
+ def resolve_url(self, target: str) -> SinglePageTarget:
+ """Resolves a URL target to a SinglePageTarget which contains details about the builder function to
+ be called and the arguments to pass to the builder function.
+
+ :param target: The target object such as a URL or Callable
+ :return: The resolved SinglePageTarget object"""
+ outlet_target = self.router_config.resolve_target(target)
+ assert outlet_target.path is not None
+ if isinstance(outlet_target, SinglePageTarget) and not outlet_target.valid and outlet_target.path.startswith(
+ self.base_path):
+ rem_path = outlet_target.path[len(self.base_path):]
+ if rem_path in self.views:
+ outlet_target.builder = self.views[rem_path].builder
+ outlet_target.title = self.views[rem_path].title
+ outlet_target.valid = True
+ if outlet_target.valid and outlet_target.router is None:
+ outlet_target.router = self
+ if outlet_target is None:
+ raise NotImplementedError
+ return outlet_target
+
+ def handle_navigate(self, target: str) -> Union[SinglePageTarget, str, None]:
+ """Is called when there was a navigation event in the browser or by the navigate_to method.
+
+ By default, the original target is returned. The SinglePageRouter and the router config (the outlet) can
+ manipulate the target before it is resolved. If the target is None, the navigation is suppressed.
+
+ :param target: The target URL
+ :return: The target URL, a completely resolved target or None if the navigation is suppressed"""
+ if self._on_navigate is not None:
+ custom_target = self._on_navigate(target)
+ if custom_target is None:
+ return None
+ if isinstance(custom_target, SinglePageTarget):
+ return custom_target
+ target = custom_target
+ new_target = self.router_config.handle_navigate(target)
+ if isinstance(target, SinglePageTarget):
+ return target
+ if new_target is None or new_target != target:
+ return new_target
+ return target
+
+ def navigate_to(self, target: Union[SinglePageTarget, str, None], server_side=True, sync=False,
+ history: bool = True) -> None:
+ """Open a new page in the browser by exchanging the content of the router frame
+
+ :param target: The target page or path.
+ :param server_side: Optional flag which defines if the call is originated on the server side and thus
+ the browser history should be updated. Default is False.
+ :param sync: Optional flag to define if the content should be updated synchronously. Default is False.
+ :param history: Optional flag defining if the history setting shall be respected. Default is True."""
+ # check if sub router is active and might handle the target
+ if isinstance(target, str):
+ for path_mask, frame in self.child_routers.items():
+ if fnmatch(target, path_mask) or fnmatch(target, path_mask.rstrip('/') + '/*'):
+ frame.navigate_to(target, server_side)
+ return
+ target = self.handle_navigate(target)
+ if target is None:
+ return
+ handler_kwargs = SinglePageRouter.get_user_data() | self.user_data | self.router_frame.user_data | \
+ {'previous_url_path': self.router_frame.target_url}
+ if target is None:
+ # TODO in which cases can the target be None? What should be the behavior?
+ raise ValueError('Target is None')
+ handler_kwargs['url_path'] = target if isinstance(target, str) else target.original_path
+ if not isinstance(target, SinglePageTarget):
+ target = self.resolve_url(target)
+ if target is None or not target.valid: # navigation suppressed
+ return
+ target_url = target.original_path
+ assert target_url is not None
+ handler_kwargs['target_url'] = target_url
+ self._update_target_url(target_url)
+ js_code = ''
+ if history and self.use_browser_history: # triggered by the browser
+ js_code += f'window.history.pushState({{page: "{target_url}"}}, "", "{target_url}");'
+ if target.builder is None:
+ if target.fragment is not None:
+ js_code += f'window.location.href = "#{target.fragment}";' # go to fragment
+ ui.run_javascript(js_code)
+ return
+ target = SinglePageTarget(builder=self._page_not_found, title='Page not found')
+ if len(js_code) > 0:
+ ui.run_javascript(js_code)
+ handler_kwargs = {**target.path_args, **target.query_args, 'target': target} | handler_kwargs
+ self.update_content(target, handler_kwargs=handler_kwargs, sync=sync)
+
+ def update_content(self, target: SinglePageTarget, handler_kwargs: dict, sync: bool):
+ """Update the content of the router frame"""
+ if target.on_pre_update is not None:
+ RouterFrame.run_safe(target.on_pre_update, **handler_kwargs)
+ self.clear()
+ self.user_data['target'] = target
+ # check if object address of real target and user_data target are the same
+ self.router_frame.update_content(target.builder, handler_kwargs, target.title, target.fragment, sync)
+ if self.change_title and target.builder and len(self.child_routers) == 0:
+ # note: If the router is just a container for sub routers, the title is not updated here but
+ # in the sub router's update_content method
+ title = target.title if target.title is not None else core.app.config.title
+ ui.page_title(title)
+ if target.on_post_update is not None:
+ RouterFrame.run_safe(target.on_post_update, **handler_kwargs)
+
+ def clear(self) -> None:
+ """Clear the content of the router frame and removes all references to sub frames"""
+ self.child_routers.clear()
+ self.router_frame.clear()
+
+ def update_user_data(self, new_data: dict) -> None:
+ """Update the user data of the router frame
+
+ :param new_data: The new user data to set"""
+ self.user_data.update(new_data)
+
+ def on_navigate(self, callback: Callable[[str], Optional[Union[SinglePageTarget, str]]]) -> Self:
+ """Set the on navigate callback which is called when a navigation event is triggered
+
+ :param callback: The callback function"""
+ self._on_navigate = callback
+ return self
+
+ @staticmethod
+ def get_user_data() -> Dict:
+ """Returns a combined dictionary of all user data of the parent router frames"""
+ result_dict = {}
+ for slot in context.slot_stack:
+ if isinstance(slot.parent, RouterFrame):
+ result_dict.update(slot.parent.user_data['router'].user_data)
+ return result_dict
+
+ @property
+ def target(self) -> Optional[SinglePageTarget]:
+ """The current target of the router frame. Only valid while a view is being built."""
+ return self.user_data.get('target', None)
+
+ @staticmethod
+ def current_router() -> Optional['SinglePageRouter']:
+ """Get the current router frame from the context stack
+
+ :return: The current router or None if no router in the context stack"""
+ for slot in reversed(context.slot_stack): # we need to inform the parent router frame about
+ if isinstance(slot.parent, RouterFrame): # our existence so it can navigate to our pages
+ return slot.parent.user_data['router']
+ return None
+
+ def _register_child_router(self, path: str, frame: 'SinglePageRouter') -> None:
+ """Registers a child router which handles a certain sub path
+
+ :param path: The path of the child router
+ :param frame: The child router"""
+ self.child_routers[path] = frame
+ self.router_frame.child_frame_paths = list(self.child_routers.keys())
+
+ @staticmethod
+ def _page_not_found() -> None:
+ """Default builder function for the page not found error page"""
+ ui.label(f'Oops! Page Not Found 🚧').classes('text-3xl')
+ ui.label(f'Sorry, the page you are looking for could not be found. 😔')
+
+ def _update_target_url(self, target_url: str) -> None:
+ """Updates the target url of the router and all parent routers
+
+ :param target_url: The new target url"""
+ cur_router = self
+ for _ in range(PATH_RESOLVING_MAX_RECURSION):
+ cur_router.router_frame.target_url = target_url
+ cur_router = cur_router.parent
+ if cur_router is None:
+ return
+
+
+class RouterView:
+ """Defines a single, router instance specific view / "content page" which is displayed in an outlet"""
+
+ def __init__(self, path: str, builder: Callable, title: Optional[str] = None, **kwargs):
+ self.path = path
+ self.builder = builder
+ self.title = title
+ self.kwargs = kwargs
+
+ def build_page(self, **kwargs):
+ """Build the page content"""
+ self.builder(**kwargs)
diff --git a/nicegui/single_page_target.py b/nicegui/single_page_target.py
new file mode 100644
index 000000000..0646e8763
--- /dev/null
+++ b/nicegui/single_page_target.py
@@ -0,0 +1,123 @@
+import inspect
+import urllib.parse
+from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Self
+
+if TYPE_CHECKING:
+ from nicegui.single_page_router import SinglePageRouter
+ from nicegui.outlet import OutletPath
+
+
+class SinglePageTarget:
+ """A helper class which is used to resolve and URL path, it's query and fragment parameters to find the matching
+ OutletPath and extract path and query parameters."""
+
+ def __init__(self,
+ path: Optional[str] = None,
+ fragment: Optional[str] = None,
+ query_string: Optional[str] = None,
+ builder: Optional[Callable] = None,
+ title: Optional[str] = None,
+ router: Optional['SinglePageRouter'] = None,
+ router_path: Optional['OutletPath'] = None,
+ on_pre_update: Optional[Callable[[Any], None]] = None,
+ on_post_update: Optional[Callable[[Any], None]] = None
+ ):
+ """
+ :param builder: The builder function to be called when the URL is opened
+ :param path: The path of the URL (to be shown in the browser)
+ :param fragment: The fragment of the URL
+ :param query_string: The query string of the URL
+ :param title: The title of the URL to be displayed in the browser tab
+ :param router: The SinglePageRouter which is used to resolve the URL
+ :param router_path: The OutletPath which is matched by the URL
+ :param on_pre_update: Optional callback which is called before content is updated. It can be used to modify the
+ target or execute JavaScript code before the content is updated.
+ :param on_post_update: Optional callback which is called after content is updated. It can be used to modify the
+ target or execute JavaScript code after the content is updated, e.g. showing a message box or redirecting
+ the user to another page.
+ """
+ self.original_path = path
+ self.path = path
+ self.path_args = {}
+ self.path_elements = []
+ self.fragment = fragment
+ self.query_string = query_string
+ self.query_args = urllib.parse.parse_qs(self.query_string)
+ self.title = title
+ self.builder = builder
+ self.valid = builder is not None
+ self.router = router
+ self.router_path: Optional["OutletPath"] = router_path
+ self.on_pre_update = on_pre_update
+ self.on_post_update = on_post_update
+ if router_path is not None and router_path.builder is not None:
+ self.builder = router_path.builder
+ self.title = router_path.title
+ self.valid = True
+
+ def parse_url_path(self, routes: Dict[str, 'OutletPath']) -> Self:
+ """
+ Parses the route using the provided routes dictionary and path.
+
+ :param routes: All routes of the single page router
+ """
+ parsed_url = urllib.parse.urlparse(urllib.parse.unquote(self.path))
+ self.path = parsed_url.path # url path w/o query
+ self.fragment = parsed_url.fragment if len(parsed_url.fragment) > 0 else None
+ self.query_string = parsed_url.query if len(parsed_url.query) > 0 else None
+ self.path_args = {}
+ self.query_args = urllib.parse.parse_qs(self.query_string)
+ if self.fragment is not None and len(self.path) == 0:
+ self.valid = True
+ return self
+ entry = self.parse_path(routes)
+ self.router_path = entry
+ if entry is not None:
+ self.builder = entry.builder
+ self.title = entry.title
+ self.convert_arguments()
+ self.valid = True
+ else:
+ self.builder = None
+ self.title = None
+ self.valid = False
+ return self
+
+ def parse_path(self, routes) -> Optional['OutletPath']:
+ """Splits the path into its components, tries to match it with the routes and extracts the path arguments
+ into their corresponding variables.
+
+ :param routes: All valid routes of the single page router"""
+ path_elements = self.path.lstrip('/').rstrip('/').split('/')
+ self.path_elements = path_elements
+ for route, entry in routes.items():
+ route_elements = route.lstrip('/').split('/')
+ if len(route_elements) != len(path_elements): # can't match
+ continue
+ match = True
+ for i, route_element_path in enumerate(route_elements):
+ if route_element_path.startswith('{') and route_element_path.endswith('}') and len(
+ route_element_path) > 2:
+ self.path_args[route_element_path[1:-1]] = path_elements[i]
+ elif path_elements[i] != route_element_path:
+ match = False
+ break
+ if match:
+ return entry
+ return None
+
+ def convert_arguments(self):
+ """Converts the path and query arguments to the expected types of the builder function"""
+ if not self.builder:
+ return
+ sig = inspect.signature(self.builder)
+ for func_param_name, func_param_info in sig.parameters.items():
+ for params in [self.path_args, self.query_args]:
+ if func_param_name in params:
+ if func_param_info.annotation is inspect.Parameter.empty:
+ continue
+ try:
+ params[func_param_name] = func_param_info.annotation(
+ params[func_param_name]) # Convert parameter to the expected type
+ except ValueError as e:
+ raise ValueError(f'Could not convert parameter {func_param_name}: {e}')
diff --git a/nicegui/ui.py b/nicegui/ui.py
index f0fba8fb4..97206e1b6 100644
--- a/nicegui/ui.py
+++ b/nicegui/ui.py
@@ -116,6 +116,7 @@
'add_style',
'update',
'page',
+ 'outlet',
'drawer',
'footer',
'header',
@@ -125,6 +126,7 @@
'right_drawer',
'run',
'run_with',
+ 'router_frame'
]
from .context import context
@@ -195,6 +197,7 @@
from .elements.radio import Radio as radio
from .elements.range import Range as range # pylint: disable=redefined-builtin
from .elements.restructured_text import ReStructuredText as restructured_text
+from .elements.router_frame import RouterFrame as router_frame
from .elements.row import Row as row
from .elements.scene import Scene as scene
from .elements.scene_view import SceneView as scene_view
@@ -239,6 +242,7 @@
from .functions.style import add_css, add_sass, add_scss, add_style
from .functions.update import update
from .page import page
+from .outlet import Outlet as outlet
from .page_layout import Drawer as drawer
from .page_layout import Footer as footer
from .page_layout import Header as header
diff --git a/tests/test_single_page_apps.py b/tests/test_single_page_apps.py
new file mode 100644
index 000000000..784d82b22
--- /dev/null
+++ b/tests/test_single_page_apps.py
@@ -0,0 +1,18 @@
+from nicegui import ui
+from nicegui.testing import Screen
+
+
+def test_routing_url(screen: Screen):
+ @ui.outlet('/', on_navigate=lambda _: '/main')
+ def layout():
+ ui.label('main layout')
+ yield
+
+ @layout.view('/main')
+ def main_content():
+ ui.label('main content')
+
+ screen.open('/')
+ screen.should_contain('main layout')
+ screen.should_contain('main content')
+ assert '/main' in screen.selenium.current_url