tab spa example

This commit is contained in:
John Lancaster
2026-09-03 23:31:39 -05:00
parent f75b24705e
commit 86c7d54244
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
# "nicegui==3.16.0",
# ]
# ///
"""Demonstrate URL-backed tabs with persistent parameterized-route state."""
from collections.abc import Callable
from dataclasses import dataclass
from urllib.parse import urlsplit
from nicegui import events
from nicegui import ui
from nicegui.elements.tabs import Tab
from nicegui.elements.tabs import TabPanel
from nicegui.sub_pages_router import SubPagesRouter
type PageBuilder = Callable[..., None]
DEFAULT_REPORT_PATH = "/reports/a"
REPORTS_TAB = "reports"
def page_heading(title: str, description: str) -> None:
"""Render a shared heading for sub-page content."""
with ui.column().classes("w-full gap-1"):
ui.label(title).classes("text-3xl font-semibold text-stone-900")
ui.label(description).classes("text-base text-stone-600")
def overview_page() -> None:
"""Render the overview sub-page."""
with ui.column().classes("w-full max-w-6xl mx-auto gap-6 px-4 py-8"):
page_heading("Overview", "A quick read on the workspace today.")
metrics = (
("Active projects", "8", "folder_open", "primary"),
("Tasks completed", "24", "task_alt", "positive"),
("Needs attention", "3", "error_outline", "warning"),
)
with ui.grid().classes("w-full grid-cols-1 gap-4 md:grid-cols-3"):
for label, value, icon, color in metrics:
with ui.card().classes("w-full p-5 gap-3"):
with ui.row().classes("w-full items-center justify-between"):
ui.label(label).classes("text-sm font-medium text-stone-600")
ui.icon(icon, color=color).classes("text-2xl")
ui.label(value).classes("text-3xl font-semibold text-stone-900")
def projects_page() -> None:
"""Render the projects sub-page."""
with ui.column().classes("w-full max-w-6xl mx-auto gap-6 px-4 py-8"):
page_heading("Projects", "Each route builds its own content inside the shared shell.")
with ui.list().props("bordered separator").classes("w-full bg-white rounded"):
for name, status, color in (
("Client portal", "On track", "positive"),
("Mobile refresh", "In review", "primary"),
("Data migration", "Blocked", "negative"),
):
with ui.item():
with ui.item_section().props("avatar"):
ui.icon("folder", color=color)
with ui.item_section():
ui.item_label(name)
ui.item_label(status).props("caption")
with ui.item_section().props("side"):
ui.badge(status, color=color)
def report_page(report_id: str) -> None:
"""Render the report selected by the route parameter."""
with ui.column().classes("w-full max-w-6xl mx-auto gap-6 px-4 py-8"):
page_heading(f"Report {report_id.upper()}", "The report ID is injected from the URL path.")
with ui.card().classes("w-full max-w-2xl p-5 gap-4"):
ui.label("Parameterized route").classes("text-xl font-semibold text-stone-900")
ui.label(f"Loaded /reports/{report_id}").classes("text-stone-600")
with ui.row().classes("gap-2"):
ui.button("Report A", on_click=lambda: ui.navigate.to("/reports/a")).props("outline")
ui.button("Report B", on_click=lambda: ui.navigate.to("/reports/b")).props("outline")
def settings_page() -> None:
"""Render the settings sub-page."""
with ui.column().classes("w-full max-w-6xl mx-auto gap-6 px-4 py-8"):
page_heading("Settings", "Controls here are recreated when this sub-page is opened.")
with ui.card().classes("w-full max-w-2xl p-5 gap-4"):
ui.label("Notifications").classes("text-xl font-semibold text-stone-900")
ui.switch("Weekly summary", value=True)
ui.switch("Project status changes", value=True)
ui.switch("Product announcements", value=False)
def normalize_route(path: str) -> str:
"""Extract and normalize the path portion of a route."""
return urlsplit(path).path.rstrip("/") or "/"
def tab_name_for_route(route: str) -> str:
"""Return the tab name associated with a concrete route."""
if route.startswith("/reports/"):
return REPORTS_TAB
return route if route in ROUTES else "/"
@dataclass(slots=True)
class NavigationState:
"""Store client-local navigation state for parameterized tabs."""
active_report_path: str = DEFAULT_REPORT_PATH
type TabHandler = events.ValueChangeEventArguments[str | Tab | TabPanel | None]
def create_tabs(router: SubPagesRouter) -> ui.tabs:
"""Create route-aware tabs and retain the last selected report."""
state = NavigationState()
initial_route = normalize_route(router.current_path)
if tab_name_for_route(initial_route) == REPORTS_TAB:
state.active_report_path = initial_route
def navigate(event: TabHandler) -> None:
"""Navigate to the route represented by the selected tab."""
match event.value:
case str(tabname):
destination = state.active_report_path if tabname == REPORTS_TAB else tabname
ui.navigate.to(destination)
case _:
return
with ui.column().classes("mx-auto"), ui.tabs() as tabs:
ui.tab("/", label="Overview", icon="space_dashboard")
ui.tab("/projects", label="Projects", icon="folder_open")
ui.tab(REPORTS_TAB, label="Reports", icon="summarize")
ui.tab("/settings", label="Settings", icon="settings")
tabs.set_value(tab_name_for_route(initial_route))
tabs.on_value_change(navigate)
def sync_tab(path: str) -> None:
"""Synchronize tab and report state after the route changes."""
route = normalize_route(path)
tab_name = tab_name_for_route(route)
if tab_name == REPORTS_TAB:
state.active_report_path = route
tabs.set_value(tab_name)
router.on_path_changed(sync_tab)
return tabs
ROUTES: dict[str, PageBuilder] = {
"/": overview_page,
"/projects": projects_page,
"/reports/{report_id}": report_page,
"/settings": settings_page,
}
def root() -> None:
"""Build the persistent application shell and sub-page container."""
router = ui.context.client.sub_pages_router
with ui.header(elevated=True).classes("py-0 items-center"):
create_tabs(router)
ui.button(icon="settings").classes("text-white").props("round flat").tooltip("Settings")
ui.sub_pages(ROUTES).classes("w-full")
if __name__ in {"__main__", "__mp_main__"}:
ui.run(root, title="Northstar", port=8888, reload=True)