using tab panels in the spa

This commit is contained in:
John Lancaster
2026-09-03 23:45:10 -05:00
parent 86c7d54244
commit 09d2a4bcaf
@@ -7,20 +7,22 @@
"""Demonstrate URL-backed tabs with persistent parameterized-route state.""" """Demonstrate URL-backed tabs with persistent parameterized-route state."""
from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass
from urllib.parse import urlsplit from urllib.parse import urlsplit
from nicegui import binding
from nicegui import events from nicegui import events
from nicegui import ui from nicegui import ui
from nicegui.elements.tabs import Tab from nicegui.elements.tabs import Tab
from nicegui.elements.tabs import TabPanel from nicegui.elements.tabs import TabPanel
from nicegui.sub_pages_router import SubPagesRouter
type PageBuilder = Callable[..., None] type PageBuilder = Callable[..., None]
DEFAULT_REPORT_PATH = "/reports/a" DEFAULT_REPORT_PATH = "/reports/a"
REPORTS_TAB = "reports" REPORTS_TAB = "reports"
TAB_ROUTES = frozenset({"/", "/projects", "/settings"})
def page_heading(title: str, description: str) -> None: def page_heading(title: str, description: str) -> None:
@@ -70,14 +72,24 @@ def projects_page() -> None:
ui.badge(status, color=color) ui.badge(status, color=color)
def report_page(report_id: str) -> None: def report_page(state: NavigationState) -> None:
"""Render the report selected by the route parameter.""" """Render report content bound to the active route parameter."""
with ui.column().classes("w-full max-w-6xl mx-auto gap-6 px-4 py-8"): 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.column().classes("w-full gap-1"):
ui.label().bind_text_from(
state,
"active_report_path",
backward=lambda path: f"Report {report_id_from_path(path).upper()}",
).classes("text-3xl font-semibold text-stone-900")
ui.label("The report ID is injected from the URL path.").classes("text-base text-stone-600")
with ui.card().classes("w-full max-w-2xl p-5 gap-4"): 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("Parameterized route").classes("text-xl font-semibold text-stone-900")
ui.label(f"Loaded /reports/{report_id}").classes("text-stone-600") ui.label().bind_text_from(
state,
"active_report_path",
backward=lambda path: f"Loaded {path}",
).classes("text-stone-600")
with ui.row().classes("gap-2"): with ui.row().classes("gap-2"):
ui.button("Report A", on_click=lambda: ui.navigate.to("/reports/a")).props("outline") 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") ui.button("Report B", on_click=lambda: ui.navigate.to("/reports/b")).props("outline")
@@ -104,10 +116,10 @@ def tab_name_for_route(route: str) -> str:
"""Return the tab name associated with a concrete route.""" """Return the tab name associated with a concrete route."""
if route.startswith("/reports/"): if route.startswith("/reports/"):
return REPORTS_TAB return REPORTS_TAB
return route if route in ROUTES else "/" return route if route in TAB_ROUTES else "/"
@dataclass(slots=True) @binding.bindable_dataclass
class NavigationState: class NavigationState:
"""Store client-local navigation state for parameterized tabs.""" """Store client-local navigation state for parameterized tabs."""
@@ -117,12 +129,13 @@ class NavigationState:
type TabHandler = events.ValueChangeEventArguments[str | Tab | TabPanel | None] type TabHandler = events.ValueChangeEventArguments[str | Tab | TabPanel | None]
def create_tabs(router: SubPagesRouter) -> ui.tabs: def report_id_from_path(path: str) -> str:
"""Extract the report ID from a normalized report route."""
return path.rsplit("/", maxsplit=1)[-1]
def create_tabs(state: NavigationState, initial_route: str) -> ui.tabs:
"""Create route-aware tabs and retain the last selected report.""" """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: def navigate(event: TabHandler) -> None:
"""Navigate to the route represented by the selected tab.""" """Navigate to the route represented by the selected tab."""
@@ -142,36 +155,55 @@ def create_tabs(router: SubPagesRouter) -> ui.tabs:
tabs.set_value(tab_name_for_route(initial_route)) tabs.set_value(tab_name_for_route(initial_route))
tabs.on_value_change(navigate) 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 return tabs
ROUTES: dict[str, PageBuilder] = { def render_tab_panels(tabs: ui.tabs, state: NavigationState, active_tab: str) -> None:
"/": overview_page, """Render all tabbed page content inside a tab panels container."""
"/projects": projects_page, with ui.tab_panels(tabs, value=active_tab, animated=True).classes("w-full"):
"/reports/{report_id}": report_page, with ui.tab_panel("/"):
"/settings": settings_page, overview_page()
} with ui.tab_panel("/projects"):
projects_page()
with ui.tab_panel(REPORTS_TAB):
report_page(state)
with ui.tab_panel("/settings"):
settings_page()
def root() -> None: def root() -> None:
"""Build the persistent application shell and sub-page container.""" """Build the persistent application shell and sub-page container."""
router = ui.context.client.sub_pages_router initial_route = normalize_route(ui.context.client.sub_pages_router.current_path)
state = NavigationState()
if tab_name_for_route(initial_route) == REPORTS_TAB:
state.active_report_path = initial_route
with ui.header(elevated=True).classes("py-0 items-center"): with ui.header(elevated=True).classes("py-0 items-center"):
create_tabs(router) tabs = create_tabs(state, initial_route)
ui.button(icon="settings").classes("text-white").props("round flat").tooltip("Settings") ui.button(icon="settings").classes("text-white").props("round flat").tooltip("Settings")
ui.sub_pages(ROUTES).classes("w-full") render_tab_panels(tabs, state, tab_name_for_route(initial_route))
def route_overview() -> None:
tabs.set_value("/")
def route_projects() -> None:
tabs.set_value("/projects")
def route_reports(report_id: str) -> None:
state.active_report_path = f"/reports/{report_id}"
tabs.set_value(REPORTS_TAB)
def route_settings() -> None:
tabs.set_value("/settings")
routes: dict[str, PageBuilder] = {
"/": route_overview,
"/projects": route_projects,
"/reports/{report_id}": route_reports,
"/settings": route_settings,
}
ui.sub_pages(routes).classes("hidden")
if __name__ in {"__main__", "__mp_main__"}: if __name__ in {"__main__", "__mp_main__"}: