customization skill
This commit is contained in:
@@ -0,0 +1,937 @@
|
|||||||
|
---
|
||||||
|
name: nicegui-ui-customization
|
||||||
|
description: Style, customize, and build interactive UIs in a production-ready NiceGUI app.
|
||||||
|
---
|
||||||
|
|
||||||
|
# NiceGUI UI Customization & Component Patterns
|
||||||
|
|
||||||
|
Customize, style, and build interactive user interfaces in a production-ready NiceGUI application. This skill covers component architecture, styling systems, event-driven interactions, and practical troubleshooting for production deployments.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Create a well-architected, responsive, accessible UI that:
|
||||||
|
- Separates structure (Python layout) from cosmetics (CSS styling)
|
||||||
|
- Handles user interactions via event-driven patterns, not polling
|
||||||
|
- Composes reusable components with clear responsibilities
|
||||||
|
- Integrates custom styling and assets cleanly
|
||||||
|
- Troubleshoots common failures (race conditions, asset caching, upload errors)
|
||||||
|
|
||||||
|
## Project Structure & Conceptual Boundaries
|
||||||
|
|
||||||
|
This skill assumes a project organized with clear, modular boundaries:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/app/
|
||||||
|
├─ ui/
|
||||||
|
│ ├─ pages/ # Page-level UI modules (route handlers)
|
||||||
|
│ │ ├─ __init__.py
|
||||||
|
│ │ ├─ home.py
|
||||||
|
│ │ ├─ dashboard.py
|
||||||
|
│ │ └─ about.py
|
||||||
|
│ ├─ components/ # Reusable presentation components
|
||||||
|
│ │ ├─ __init__.py
|
||||||
|
│ │ ├─ nav.py
|
||||||
|
│ │ ├─ card_section.py
|
||||||
|
│ │ └─ form_section.py
|
||||||
|
│ └─ static/ # Static assets (CSS, JS, images)
|
||||||
|
│ ├─ css/
|
||||||
|
│ │ ├─ base.css
|
||||||
|
│ │ ├─ theme.css
|
||||||
|
│ │ └─ components.css
|
||||||
|
│ ├─ js/
|
||||||
|
│ └─ images/
|
||||||
|
├─ services/ # Business logic layer
|
||||||
|
│ ├─ __init__.py
|
||||||
|
│ ├─ file_service.py
|
||||||
|
│ ├─ user_service.py
|
||||||
|
│ └─ ...
|
||||||
|
├─ api/ # HTTP endpoints
|
||||||
|
│ ├─ __init__.py
|
||||||
|
│ ├─ health.py
|
||||||
|
│ └─ routes.py
|
||||||
|
├─ bootstrap.py # App composition, CSS/asset setup
|
||||||
|
├─ config.py # Settings and configuration
|
||||||
|
└─ ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key boundaries**:
|
||||||
|
- **`ui/pages/`**: Route-level modules that mount pages and handle page-specific events. Thin adapters that call `services/` for business logic.
|
||||||
|
- **`ui/components/`**: Reusable presentation components with no business side effects. Accept inputs (props), return UI elements, handle layout and styling concerns.
|
||||||
|
- **`ui/static/`**: CSS, JS, and image assets loaded at app startup (in `bootstrap.py`), shared across all pages.
|
||||||
|
- **`services/`**: Business logic orchestration, independent of UI rendering. Called by both pages and API endpoints.
|
||||||
|
- **`api/`**: HTTP endpoints for API consumers (independent of NiceGUI pages).
|
||||||
|
|
||||||
|
**Dependency direction to maintain**:
|
||||||
|
- `ui/pages/` → `ui/components/` + `services/`
|
||||||
|
- `services/` → business logic (repositories, clients, helpers)
|
||||||
|
- **Avoid**: reverse imports (e.g., `services/` importing from `ui/pages/`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Component Patterns
|
||||||
|
|
||||||
|
Components are the building blocks of reusable UI. They live in `ui/components/` and are consumed by pages in `ui/pages/`.
|
||||||
|
|
||||||
|
### Reusable Component Architecture
|
||||||
|
|
||||||
|
A reusable component is a Python module in `ui/components/` that encapsulates a cohesive piece of UI with no business side effects.
|
||||||
|
|
||||||
|
**Structure**:
|
||||||
|
```python
|
||||||
|
# ui/components/card_section.py
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
def card_section(title: str, content: str) -> ui.card:
|
||||||
|
"""Reusable card wrapper with consistent styling."""
|
||||||
|
with ui.card().classes("w-full max-w-md") as card:
|
||||||
|
ui.label(title).classes("text-lg font-bold")
|
||||||
|
ui.label(content).classes("text-gray-600")
|
||||||
|
return card
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to extract to a component**:
|
||||||
|
- Visual pattern repeats across multiple pages
|
||||||
|
- Component has clear input (props) and output (return)
|
||||||
|
- No business logic; presentation only
|
||||||
|
- Reusable styles/layout can be applied to similar contexts
|
||||||
|
|
||||||
|
**When to keep in a page**:
|
||||||
|
- Layout is unique to that page
|
||||||
|
- Component is tightly coupled to page state/events
|
||||||
|
- No other page will reuse it
|
||||||
|
|
||||||
|
**Example — Before (duplication)**:
|
||||||
|
```python
|
||||||
|
# Pages have repeated card patterns
|
||||||
|
with ui.card().classes("w-full max-w-md"):
|
||||||
|
ui.label("Title 1").classes("text-lg font-bold")
|
||||||
|
# ... repeated layout
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — After (extracted)**:
|
||||||
|
```python
|
||||||
|
# ui/components/card_section.py
|
||||||
|
def card_section(title: str, content: str) -> ui.card:
|
||||||
|
with ui.card().classes("w-full max-w-md") as card:
|
||||||
|
ui.label(title).classes("text-lg font-bold")
|
||||||
|
ui.label(content).classes("text-gray-600")
|
||||||
|
return card
|
||||||
|
|
||||||
|
# Pages now use: card_section("Title", "Content")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout Composition Patterns
|
||||||
|
|
||||||
|
Use containers (`ui.row()`, `ui.column()`) to organize structure; apply Tailwind utility classes for responsive behavior.
|
||||||
|
|
||||||
|
**Example — Responsive grid**:
|
||||||
|
```python
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
with ui.column().classes("w-full"):
|
||||||
|
with ui.row().classes("w-full gap-4 flex-wrap"):
|
||||||
|
# On mobile: stack vertically (default block)
|
||||||
|
# On desktop: row with gap-4
|
||||||
|
with ui.card().classes("flex-1 min-w-64"):
|
||||||
|
ui.label("Card 1")
|
||||||
|
with ui.card().classes("flex-1 min-w-64"):
|
||||||
|
ui.label("Card 2")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key principles**:
|
||||||
|
- Prefer `.classes()` for structure and layout (grid, flex, spacing, responsive)
|
||||||
|
- Reserve `.style()` for one-off dynamic values computed at runtime
|
||||||
|
- Use semantic Tailwind classes for clarity (`gap-4`, `flex-1`, `min-w-64`)
|
||||||
|
- Avoid nested inline styles; centralize reusable patterns in components
|
||||||
|
|
||||||
|
### State Management Between Components
|
||||||
|
|
||||||
|
For simple interactions within a page, use NiceGUI's `ui.state` or signal patterns.
|
||||||
|
|
||||||
|
**Example — Reactive update via signal**:
|
||||||
|
```python
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
# Page-level state
|
||||||
|
selected_id = ui.state(value=None)
|
||||||
|
|
||||||
|
def item_list():
|
||||||
|
"""Component that updates page state on selection."""
|
||||||
|
with ui.column():
|
||||||
|
for item in items:
|
||||||
|
def on_click(item_id=item.id):
|
||||||
|
selected_id.value = item_id
|
||||||
|
ui.button(item.name).on_click(on_click)
|
||||||
|
|
||||||
|
def detail_view():
|
||||||
|
"""Component that observes state."""
|
||||||
|
with ui.card():
|
||||||
|
def update_detail():
|
||||||
|
if selected_id.value:
|
||||||
|
detail = load_detail(selected_id.value)
|
||||||
|
ui.label(f"Details: {detail}")
|
||||||
|
|
||||||
|
selected_id.on_value_changed(update_detail)
|
||||||
|
update_detail() # Initial render
|
||||||
|
```
|
||||||
|
|
||||||
|
**For complex state across multiple pages**: Use a `services/` layer to manage business state, and pass data via page callbacks. Avoid sharing NiceGUI state across pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Styling & CSS/JS Injection
|
||||||
|
|
||||||
|
### Tailwind Classes via `.classes()`
|
||||||
|
|
||||||
|
NiceGUI includes TailwindCSS by default. Use `.classes()` for all structural and responsive styling.
|
||||||
|
|
||||||
|
**Example — Mobile-first responsive layout**:
|
||||||
|
```python
|
||||||
|
with ui.card().classes("w-full max-w-2xl mx-auto p-4"):
|
||||||
|
# w-full: full width on mobile
|
||||||
|
# max-w-2xl: cap at 2xl breakpoint
|
||||||
|
# mx-auto: center horizontally
|
||||||
|
# p-4: padding on all sides
|
||||||
|
|
||||||
|
with ui.row().classes("w-full gap-4 flex-wrap sm:flex-nowrap"):
|
||||||
|
# flex-wrap: wrap on mobile (< 640px)
|
||||||
|
# sm:flex-nowrap: don't wrap on small screens (>= 640px)
|
||||||
|
|
||||||
|
ui.input("Name").classes("flex-1 min-w-60")
|
||||||
|
ui.input("Email").classes("flex-1 min-w-60")
|
||||||
|
|
||||||
|
ui.button("Submit").classes("w-full py-2")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom CSS Injection
|
||||||
|
|
||||||
|
Inject custom CSS at app startup for styling that TailwindCSS doesn't cover (colors, transitions, custom properties).
|
||||||
|
|
||||||
|
**Setup in `bootstrap.py`**:
|
||||||
|
```python
|
||||||
|
# src/app/bootstrap.py
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
def setup_custom_styles(app):
|
||||||
|
"""Register custom CSS for the entire app."""
|
||||||
|
ui.add_css("""
|
||||||
|
:root {
|
||||||
|
--primary-color: #3b82f6;
|
||||||
|
--border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #f5f5f5 0%, #e0e0e0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-section {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-section:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
```
|
||||||
|
|
||||||
|
**In page modules, use semantic class names**:
|
||||||
|
```python
|
||||||
|
with ui.column().classes("w-full app-shell"):
|
||||||
|
with ui.card().classes("page-section"):
|
||||||
|
ui.label("Content")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Static Asset Pipeline
|
||||||
|
|
||||||
|
#### Setup `/app/static/` directory
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/app/
|
||||||
|
├─ static/
|
||||||
|
│ ├─ css/
|
||||||
|
│ │ ├─ base.css # Resets, common patterns
|
||||||
|
│ │ ├─ theme.css # Design tokens, colors
|
||||||
|
│ │ └─ components.css # Component-specific styles
|
||||||
|
│ ├─ js/
|
||||||
|
│ │ └─ custom.js # Rare edge cases requiring JS
|
||||||
|
│ └─ images/
|
||||||
|
└─ ...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Include CSS at app startup
|
||||||
|
|
||||||
|
In `bootstrap.py`, load CSS files once:
|
||||||
|
```python
|
||||||
|
def setup_static_assets(app):
|
||||||
|
"""Load static CSS files for all pages."""
|
||||||
|
# Load from static folder
|
||||||
|
ui.add_css(open('src/app/static/css/base.css').read())
|
||||||
|
ui.add_css(open('src/app/static/css/theme.css').read())
|
||||||
|
ui.add_css(open('src/app/static/css/components.css').read())
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — `static/css/theme.css`**:
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--color-primary: #3b82f6;
|
||||||
|
--color-success: #10b981;
|
||||||
|
--color-error: #ef4444;
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--spacing-xl: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background: var(--color-success);
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error {
|
||||||
|
background: var(--color-error);
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Serving static files via FastAPI
|
||||||
|
|
||||||
|
In `bootstrap.py`, mount the static directory:
|
||||||
|
```python
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# Register API routes, NiceGUI pages, etc.
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# Mount static files (CSS, JS, images)
|
||||||
|
app.mount("/static", StaticFiles(directory="src/app/static"), name="static")
|
||||||
|
|
||||||
|
return app
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production note**: Behind a reverse proxy (nginx), ensure the proxy correctly rewrites paths. For example, if your app is at `/myapp/`, static files should be served from `/myapp/static/`.
|
||||||
|
|
||||||
|
### CSS Custom Properties for Theming
|
||||||
|
|
||||||
|
Define design tokens as CSS variables for centralized, dynamic theming.
|
||||||
|
|
||||||
|
**Example — `static/css/theme.css`**:
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Colors */
|
||||||
|
--color-primary: #3b82f6;
|
||||||
|
--color-background: #f9fafb;
|
||||||
|
--color-border: #e5e7eb;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--spacing-unit: 1rem;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-family-sans: system-ui, -apple-system, sans-serif;
|
||||||
|
--font-size-base: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family-sans);
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use in Python via semantic classes**:
|
||||||
|
```python
|
||||||
|
ui.card().classes("card") # Automatically picks up CSS variables
|
||||||
|
ui.label("Text").classes("text-lg") # Uses --font-size-base
|
||||||
|
```
|
||||||
|
|
||||||
|
### JS Injection (Rare)
|
||||||
|
|
||||||
|
Use `ui.run_javascript()` only for interactions that NiceGUI APIs don't support. Always prefer NiceGUI methods first.
|
||||||
|
|
||||||
|
**Example — Only if NiceGUI doesn't provide this behavior**:
|
||||||
|
```python
|
||||||
|
# Avoid this pattern if NiceGUI has a built-in API
|
||||||
|
ui.run_javascript("""
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
console.log('User pressed Escape');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
""")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better**: Use NiceGUI's event handlers when available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event-Driven Patterns
|
||||||
|
|
||||||
|
### File Upload Handling
|
||||||
|
|
||||||
|
Handle uploads with validation; emit success/error notifications.
|
||||||
|
|
||||||
|
**Page module**:
|
||||||
|
```python
|
||||||
|
from nicegui import ui
|
||||||
|
from services.file_service import validate_and_store_file
|
||||||
|
|
||||||
|
@ui.page("/upload")
|
||||||
|
def upload_page():
|
||||||
|
async def handle_upload(e: ui.events.UploadEventArguments):
|
||||||
|
try:
|
||||||
|
# Validate file
|
||||||
|
if e.size > 10 * 1024 * 1024: # 10 MB limit
|
||||||
|
ui.notify("File too large (max 10 MB)", type="negative")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store file
|
||||||
|
file_path = await validate_and_store_file(e.content.read(), e.name)
|
||||||
|
|
||||||
|
# Notify success
|
||||||
|
ui.notify(f"File uploaded: {e.name}", type="positive")
|
||||||
|
|
||||||
|
except ValueError as err:
|
||||||
|
ui.notify(f"Validation error: {err}", type="negative")
|
||||||
|
except Exception as err:
|
||||||
|
ui.notify("Upload failed", type="negative")
|
||||||
|
|
||||||
|
ui.upload(on_upload=handle_upload, auto_upload=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Service module**:
|
||||||
|
```python
|
||||||
|
# services/file_service.py
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
async def validate_and_store_file(content: bytes, filename: str) -> str:
|
||||||
|
"""Validate and persist file. Raise ValueError on invalid input."""
|
||||||
|
if not filename:
|
||||||
|
raise ValueError("Filename is required")
|
||||||
|
|
||||||
|
# Validate MIME type, size, etc.
|
||||||
|
allowed_types = {'.txt', '.pdf', '.csv'}
|
||||||
|
ext = Path(filename).suffix.lower()
|
||||||
|
if ext not in allowed_types:
|
||||||
|
raise ValueError(f"File type {ext} not allowed")
|
||||||
|
|
||||||
|
# Store file
|
||||||
|
output_dir = Path("./uploads")
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
output_path = output_dir / filename
|
||||||
|
|
||||||
|
await asyncio.to_thread(output_path.write_bytes, content)
|
||||||
|
return str(output_path)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Submission Callbacks
|
||||||
|
|
||||||
|
Collect user input via form callbacks; validate and delegate to service layer.
|
||||||
|
|
||||||
|
**Example — Before (tightly coupled)**:
|
||||||
|
```python
|
||||||
|
# Avoid: business logic in page
|
||||||
|
def on_submit():
|
||||||
|
name = name_input.value
|
||||||
|
# Direct validation in page
|
||||||
|
if not name:
|
||||||
|
ui.notify("Name required")
|
||||||
|
return
|
||||||
|
# Direct DB call in page (bad pattern)
|
||||||
|
db_session.add(User(name=name))
|
||||||
|
db_session.commit()
|
||||||
|
ui.notify("User created")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — After (service-oriented)**:
|
||||||
|
```python
|
||||||
|
# Page: thin adapter
|
||||||
|
async def on_submit():
|
||||||
|
try:
|
||||||
|
result = await user_service.create_user(name_input.value)
|
||||||
|
ui.notify(f"User created: {result.id}", type="positive")
|
||||||
|
except ValueError as err:
|
||||||
|
ui.notify(f"Validation error: {err}", type="negative")
|
||||||
|
except Exception as err:
|
||||||
|
ui.notify("Failed to create user", type="negative")
|
||||||
|
|
||||||
|
ui.button("Submit").on_click(on_submit)
|
||||||
|
|
||||||
|
# Service: orchestrates logic
|
||||||
|
async def create_user(name: str) -> UserDTO:
|
||||||
|
if not name or len(name) < 2:
|
||||||
|
raise ValueError("Name must be at least 2 characters")
|
||||||
|
# Delegate to repository/DB layer
|
||||||
|
user = await repository.create_user(name)
|
||||||
|
return UserDTO.from_orm(user)
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket & Server-Sent Events (SSE)
|
||||||
|
|
||||||
|
For real-time updates without polling, use WebSocket or SSE.
|
||||||
|
|
||||||
|
**SSE pattern (simpler, one-way updates)**:
|
||||||
|
```python
|
||||||
|
# api/events.py
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/events/status")
|
||||||
|
async def status_stream():
|
||||||
|
"""Stream status updates to client."""
|
||||||
|
async def event_generator():
|
||||||
|
while True:
|
||||||
|
status = await get_current_status() # Service call
|
||||||
|
yield f"data: {status}\n\n"
|
||||||
|
await asyncio.sleep(1) # Emit every 1 second
|
||||||
|
|
||||||
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
# In NiceGUI page:
|
||||||
|
@ui.page("/dashboard")
|
||||||
|
def dashboard():
|
||||||
|
with ui.card():
|
||||||
|
status_label = ui.label("Loading...")
|
||||||
|
|
||||||
|
async def listen_for_updates():
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get("/api/events/status") as resp:
|
||||||
|
async for line in resp.content:
|
||||||
|
if line.startswith(b"data:"):
|
||||||
|
status = line.decode().replace("data:", "").strip()
|
||||||
|
status_label.set_text(f"Status: {status}")
|
||||||
|
|
||||||
|
ui.timer(0.1, lambda: asyncio.create_task(listen_for_updates()))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Background Tasks with UI Signaling
|
||||||
|
|
||||||
|
Long-running operations should run in background tasks; signal UI via updates or notifications.
|
||||||
|
|
||||||
|
**Example — Before (blocking)**:
|
||||||
|
```python
|
||||||
|
# Avoid: blocks UI while processing
|
||||||
|
def on_process():
|
||||||
|
for i in range(1000000):
|
||||||
|
expensive_calculation(i) # Blocks UI
|
||||||
|
ui.notify("Done")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — After (background task)**:
|
||||||
|
```python
|
||||||
|
from fastapi import BackgroundTasks
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# API endpoint
|
||||||
|
@app.post("/process")
|
||||||
|
async def process(background_tasks: BackgroundTasks):
|
||||||
|
background_tasks.add_task(long_running_job)
|
||||||
|
return {"status": "processing"}
|
||||||
|
|
||||||
|
# Long-running task
|
||||||
|
async def long_running_job():
|
||||||
|
for i in range(1000000):
|
||||||
|
await asyncio.sleep(0) # Yield control
|
||||||
|
expensive_calculation(i)
|
||||||
|
# Signal UI via WebSocket or polling endpoint
|
||||||
|
await cache.set("process_status", "complete")
|
||||||
|
|
||||||
|
# Page: poll endpoint for completion
|
||||||
|
@ui.page("/process")
|
||||||
|
def process_page():
|
||||||
|
async def trigger():
|
||||||
|
response = await client.post("/api/process")
|
||||||
|
ui.notify("Processing started")
|
||||||
|
|
||||||
|
async def check_status():
|
||||||
|
status = await client.get("/api/process/status")
|
||||||
|
if status == "complete":
|
||||||
|
ui.notify("Processing complete", type="positive")
|
||||||
|
|
||||||
|
ui.button("Start").on_click(trigger)
|
||||||
|
ui.timer(1, check_status) # Poll every 1 second (fallback only)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reactive Refresh Patterns
|
||||||
|
|
||||||
|
Use `@ui.refreshable` for components that need periodic updates, but prefer event-driven triggers.
|
||||||
|
|
||||||
|
**Example — Refreshable list with manual refresh button**:
|
||||||
|
```python
|
||||||
|
@ui.page("/items")
|
||||||
|
def items_page():
|
||||||
|
@ui.refreshable
|
||||||
|
async def item_list():
|
||||||
|
items = await item_service.list_items()
|
||||||
|
with ui.column():
|
||||||
|
for item in items:
|
||||||
|
ui.label(f"{item.name}: {item.status}")
|
||||||
|
|
||||||
|
async def on_refresh():
|
||||||
|
item_list.refresh()
|
||||||
|
|
||||||
|
ui.button("Refresh").on_click(on_refresh)
|
||||||
|
item_list()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avoid**: `ui.timer()` polling everything. Use timers only for monitoring that cannot be event-driven.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Interactions
|
||||||
|
|
||||||
|
### Dialogs & Modals
|
||||||
|
|
||||||
|
**Simple dialog via `ui.dialog()`**:
|
||||||
|
```python
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
@ui.page("/form")
|
||||||
|
def form_page():
|
||||||
|
dialog = ui.dialog()
|
||||||
|
|
||||||
|
async def show_confirm():
|
||||||
|
with dialog:
|
||||||
|
ui.label("Are you sure?")
|
||||||
|
with ui.row():
|
||||||
|
ui.button("Yes", on_click=lambda: (
|
||||||
|
confirm_action(),
|
||||||
|
dialog.close()
|
||||||
|
))
|
||||||
|
ui.button("No", on_click=dialog.close)
|
||||||
|
|
||||||
|
dialog.open()
|
||||||
|
|
||||||
|
ui.button("Delete").on_click(show_confirm)
|
||||||
|
|
||||||
|
def confirm_action():
|
||||||
|
ui.notify("Confirmed!", type="positive")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Complex modal workflow**: Wrap dialog logic in a component for reusability.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ui/components/confirm_dialog.py
|
||||||
|
from nicegui import ui
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
async def show_confirm_dialog(
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
on_confirm: Callable
|
||||||
|
):
|
||||||
|
"""Reusable confirm dialog."""
|
||||||
|
dialog = ui.dialog()
|
||||||
|
|
||||||
|
with dialog:
|
||||||
|
ui.label(title).classes("text-lg font-bold")
|
||||||
|
ui.label(message)
|
||||||
|
with ui.row():
|
||||||
|
async def confirm():
|
||||||
|
await on_confirm()
|
||||||
|
dialog.close()
|
||||||
|
ui.button("Confirm").on_click(confirm)
|
||||||
|
ui.button("Cancel").on_click(dialog.close)
|
||||||
|
|
||||||
|
dialog.open()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Progress Indicators
|
||||||
|
|
||||||
|
**Simple progress bar**:
|
||||||
|
```python
|
||||||
|
@ui.page("/upload")
|
||||||
|
def upload_page():
|
||||||
|
progress_bar = ui.linear_progress(value=0).classes("w-full")
|
||||||
|
|
||||||
|
async def handle_upload(e: ui.events.UploadEventArguments):
|
||||||
|
total = e.size
|
||||||
|
uploaded = 0
|
||||||
|
|
||||||
|
# Simulate chunked upload
|
||||||
|
for chunk in read_in_chunks(e.content, chunk_size=1024):
|
||||||
|
uploaded += len(chunk)
|
||||||
|
progress_bar.set_value(uploaded / total)
|
||||||
|
await asyncio.sleep(0.01) # Simulate processing
|
||||||
|
|
||||||
|
ui.notify("Upload complete", type="positive")
|
||||||
|
|
||||||
|
ui.upload(on_upload=handle_upload)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drag & Drop (via Quasar Fallback)
|
||||||
|
|
||||||
|
NiceGUI doesn't have built-in drag-and-drop; use Quasar internals for this advanced pattern.
|
||||||
|
|
||||||
|
**Practical approach** (minimal JS):
|
||||||
|
```python
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
@ui.page("/dnd")
|
||||||
|
def dnd_page():
|
||||||
|
# Use Quasar's q-sortable for drag-and-drop lists
|
||||||
|
items = ["Item 1", "Item 2", "Item 3"]
|
||||||
|
|
||||||
|
with ui.column():
|
||||||
|
ui.html(f"""
|
||||||
|
<div class="q-pa-md">
|
||||||
|
<div class="text-h6">Drag items to reorder</div>
|
||||||
|
<div
|
||||||
|
class="q-gutter-md"
|
||||||
|
style="display: flex; flex-direction: column;"
|
||||||
|
>
|
||||||
|
{''.join(f'<div class="q-pa-md bg-blue-1" draggable="true">{item}</div>' for item in items)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
""")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trade-off note**: Direct HTML/Quasar usage bypasses NiceGUI's reactivity. Prefer NiceGUI component APIs when possible; use raw HTML only for interactions NiceGUI doesn't support.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting Matrix
|
||||||
|
|
||||||
|
### Upload Validation & Size/Type Errors
|
||||||
|
|
||||||
|
**Problem**: Upload fails silently or accepts invalid files.
|
||||||
|
|
||||||
|
**Checklist**:
|
||||||
|
- [ ] Validate file size before storage: `if e.size > MAX_BYTES`
|
||||||
|
- [ ] Validate MIME type or file extension: `if not filename.endswith('.pdf')`
|
||||||
|
- [ ] Return early on validation failure; don't attempt storage
|
||||||
|
- [ ] Catch exceptions in `handle_upload` and emit `ui.notify()` with type "negative"
|
||||||
|
- [ ] Log errors to see why upload failed: `logger.error(f"Upload error: {err}")`
|
||||||
|
|
||||||
|
**Example fix**:
|
||||||
|
```python
|
||||||
|
async def handle_upload(e):
|
||||||
|
try:
|
||||||
|
if e.size > 10 * 1024 * 1024:
|
||||||
|
raise ValueError("File too large")
|
||||||
|
if not e.name.endswith('.pdf'):
|
||||||
|
raise ValueError("Only PDF files allowed")
|
||||||
|
# ... proceed
|
||||||
|
except ValueError as err:
|
||||||
|
ui.notify(str(err), type="negative")
|
||||||
|
logger.warning(f"Upload rejected: {err}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Refresh/Update Race Conditions
|
||||||
|
|
||||||
|
**Problem**: Multiple refresh/update requests cause UI flicker, duplicate data, or inconsistent state.
|
||||||
|
|
||||||
|
**Checklist**:
|
||||||
|
- [ ] Use async/await to serialize updates: `await service.get_data()` before updating UI
|
||||||
|
- [ ] Avoid multiple timers updating the same component
|
||||||
|
- [ ] If using `@ui.refreshable`, call `.refresh()` explicitly; don't auto-refresh on every event
|
||||||
|
- [ ] Add a loading state or disable button during async work: `button.enabled = False` / `button.enabled = True` after completion
|
||||||
|
- [ ] Test with slow network (dev tools throttle) to catch race conditions
|
||||||
|
|
||||||
|
**Example fix**:
|
||||||
|
```python
|
||||||
|
async def on_click():
|
||||||
|
button.enabled = False
|
||||||
|
try:
|
||||||
|
data = await service.fetch_data() # Await before UI update
|
||||||
|
list_component.refresh()
|
||||||
|
finally:
|
||||||
|
button.enabled = True
|
||||||
|
```
|
||||||
|
|
||||||
|
### Asset Loading & Caching Issues
|
||||||
|
|
||||||
|
**Problem**: CSS or JS not applying; styles stale after deploy.
|
||||||
|
|
||||||
|
**Checklist**:
|
||||||
|
- [ ] Verify CSS is injected at app startup (in `bootstrap.py`), not per-page
|
||||||
|
- [ ] Check browser DevTools Network tab: are CSS files loading with correct path?
|
||||||
|
- [ ] Clear browser cache (Ctrl+Shift+Del or DevTools Cache Storage)
|
||||||
|
- [ ] For production, use cache-busting query params: `link href="/static/css/base.css?v=20240101"`
|
||||||
|
- [ ] Verify `/static/` mount path matches file system structure
|
||||||
|
- [ ] Check reverse proxy (nginx) is not caching static files incorrectly
|
||||||
|
|
||||||
|
**Example cache-busting**:
|
||||||
|
```python
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def get_css_with_hash(css_path: str) -> str:
|
||||||
|
"""Return CSS URL with file hash for cache-busting."""
|
||||||
|
full_path = Path(css_path)
|
||||||
|
if full_path.exists():
|
||||||
|
content_hash = hashlib.md5(full_path.read_bytes()).hexdigest()[:8]
|
||||||
|
return f"{css_path}?v={content_hash}"
|
||||||
|
return css_path
|
||||||
|
|
||||||
|
# In bootstrap.py:
|
||||||
|
css_url = get_css_with_hash("/static/css/base.css")
|
||||||
|
ui.add_css(f'<link rel="stylesheet" href="{css_url}">')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation & State Desync
|
||||||
|
|
||||||
|
**Problem**: After navigation, UI shows stale data or component state is lost.
|
||||||
|
|
||||||
|
**Checklist**:
|
||||||
|
- [ ] Don't store UI state in globals; keep it in `services/` or request-scoped
|
||||||
|
- [ ] Use `ui.navigate.to()` for client-side navigation; verify URL matches a registered `@ui.page()`
|
||||||
|
- [ ] After navigate, reload data in the new page's `@ui.page()` decorator
|
||||||
|
- [ ] If using `@ui.refreshable`, ensure component re-fetches data when needed
|
||||||
|
- [ ] Test navigation flow end-to-end; check browser history
|
||||||
|
|
||||||
|
**Example pattern**:
|
||||||
|
```python
|
||||||
|
# Page A: navigate to Page B
|
||||||
|
def go_to_detail(item_id: int):
|
||||||
|
ui.navigate.to(f"/detail/{item_id}")
|
||||||
|
|
||||||
|
# Page B: fetch fresh data on page load
|
||||||
|
@ui.page("/detail/{item_id}")
|
||||||
|
def detail_page(item_id: int):
|
||||||
|
@ui.refreshable
|
||||||
|
async def load_detail():
|
||||||
|
item = await service.get_item(item_id) # Fetch fresh
|
||||||
|
ui.label(item.name)
|
||||||
|
|
||||||
|
load_detail()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment & Reverse Proxy Static Path Issues
|
||||||
|
|
||||||
|
**Problem**: Static files return 404 in production behind nginx/reverse proxy.
|
||||||
|
|
||||||
|
**Checklist**:
|
||||||
|
- [ ] Verify app is mounted at correct path: `/myapp/` vs. `/` vs. subpath
|
||||||
|
- [ ] Update `docs_mount_path`, static paths in proxy config
|
||||||
|
- [ ] Test static file URLs in browser: `/static/css/base.css` should return CSS
|
||||||
|
- [ ] Nginx should rewrite requests: `location /static { proxy_pass http://app/static; }`
|
||||||
|
- [ ] Check nginx logs for rewrite errors: `tail -f /var/log/nginx/error.log`
|
||||||
|
- [ ] If using path prefix, ensure all CSS/JS links are relative or include prefix
|
||||||
|
|
||||||
|
**Example nginx config** (app at `/myapp/`):
|
||||||
|
```nginx
|
||||||
|
location /myapp {
|
||||||
|
proxy_pass http://localhost:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /myapp/static {
|
||||||
|
alias /path/to/app/src/app/static;
|
||||||
|
expires 1d; # Cache static files
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
### Phase 1: Component Architecture
|
||||||
|
- [ ] Review existing page modules in `ui/pages/`
|
||||||
|
- [ ] Identify repeated UI patterns (cards, forms, lists)
|
||||||
|
- [ ] Extract 2–3 reusable components to `ui/components/`
|
||||||
|
- [ ] Define component API (inputs, outputs, no side effects)
|
||||||
|
- [ ] Test components in isolation on a debug page
|
||||||
|
|
||||||
|
### Phase 2: Styling & Assets
|
||||||
|
- [ ] Create `/app/static/css/` directory structure
|
||||||
|
- [ ] Write `base.css` with resets and common patterns
|
||||||
|
- [ ] Write `theme.css` with CSS custom properties (colors, spacing)
|
||||||
|
- [ ] Inject CSS in `bootstrap.py` at app startup
|
||||||
|
- [ ] Test Tailwind classes on all pages; verify responsive breakpoints (mobile, tablet, desktop)
|
||||||
|
|
||||||
|
### Phase 3: Event-Driven Interactions
|
||||||
|
- [ ] Implement file upload with validation in one page module
|
||||||
|
- [ ] Add form submission callback with error handling
|
||||||
|
- [ ] Test upload/form via manual UI checklist (success, validation errors, edge cases)
|
||||||
|
- [ ] Add `uv run pytest` tests for service-layer validation
|
||||||
|
|
||||||
|
### Phase 4: Advanced Features (Optional)
|
||||||
|
- [ ] Add dialogs/modals if needed
|
||||||
|
- [ ] Implement progress indicator for long operations
|
||||||
|
- [ ] Test WebSocket/SSE for real-time updates (if applicable)
|
||||||
|
|
||||||
|
### Phase 5: Troubleshooting & Verification
|
||||||
|
- [ ] Run Ruff checks on all page/component modules: `uv run ruff check src/app/ui/`
|
||||||
|
- [ ] Test static file loading in browser DevTools (verify network requests)
|
||||||
|
- [ ] Test on multiple screen sizes (mobile, tablet, desktop)
|
||||||
|
- [ ] Verify accessibility: keyboard navigation, color contrast, alt text
|
||||||
|
- [ ] Deployment check: static files accessible behind reverse proxy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- **Do not** put all component logic in `ui/pages/`. Extract reusable patterns to `ui/components/`.
|
||||||
|
- **Do not** style everything with inline `.style()`. Use `.classes()` and centralized CSS files.
|
||||||
|
- **Do not** poll for updates when events are available. Use WebSocket/SSE or manual refresh buttons.
|
||||||
|
- **Do not** call database or service logic directly in page modules. Delegate to `services/`.
|
||||||
|
- **Do not** inject CSS per-page. Load all CSS at app startup for consistency.
|
||||||
|
- **Do not** use `ui.run_javascript()` unless NiceGUI APIs don't support the interaction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Minimal Verification Example
|
||||||
|
|
||||||
|
**Prerequisites**: A FastAPI + NiceGUI project with the structure described above (`src/app/ui/pages/`, `src/app/ui/components/`, `services/`).
|
||||||
|
|
||||||
|
**Test scenario**:
|
||||||
|
1. Add a reusable card component to `ui/components/`
|
||||||
|
2. Use it in two different page modules
|
||||||
|
3. Add custom CSS in `bootstrap.py` with a semantic class name
|
||||||
|
4. Implement a file upload with validation in one page
|
||||||
|
5. Manually verify: responsive layout on mobile, upload validation works, styles apply
|
||||||
|
|
||||||
|
**Commands**:
|
||||||
|
```bash
|
||||||
|
# Lint components and pages
|
||||||
|
uv run ruff check src/app/ui/
|
||||||
|
|
||||||
|
# Test service-layer validation
|
||||||
|
uv run pytest tests/
|
||||||
|
|
||||||
|
# Run app and test manually
|
||||||
|
uv run nicegui src/app/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source Documentation (recommended references)
|
||||||
|
|
||||||
|
- NiceGUI components overview:
|
||||||
|
- https://www.nicegui.io/documentation/section_elements
|
||||||
|
- NiceGUI pages & routing:
|
||||||
|
- https://www.nicegui.io/documentation/section_pages_routing
|
||||||
|
- NiceGUI security (input handling, HTML injection):
|
||||||
|
- https://www.nicegui.io/documentation/section_security
|
||||||
|
- TailwindCSS utility classes:
|
||||||
|
- https://tailwindcss.com/docs/utility-first
|
||||||
|
- Responsive design with Tailwind:
|
||||||
|
- https://tailwindcss.com/docs/responsive-design
|
||||||
|
- CSS custom properties (variables):
|
||||||
|
- https://developer.mozilla.org/en-US/docs/Web/CSS/--*
|
||||||
|
- FastAPI background tasks:
|
||||||
|
- https://fastapi.tiangolo.com/tutorial/background-tasks/
|
||||||
|
- FastAPI server-sent events (SSE):
|
||||||
|
- https://fastapi.tiangolo.com/advanced/server-sent-events/
|
||||||
|
- WebSocket with FastAPI:
|
||||||
|
- https://fastapi.tiangolo.com/advanced/websockets/
|
||||||
Reference in New Issue
Block a user