tab spa example
This commit is contained in:
+178
@@ -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)
|
||||
Reference in New Issue
Block a user