diff --git a/docs/prompts/nicegui-ui-customization.md b/docs/prompts/nicegui-ui-customization.md index 95005bd..9084ead 100644 --- a/docs/prompts/nicegui-ui-customization.md +++ b/docs/prompts/nicegui-ui-customization.md @@ -55,60 +55,24 @@ src/app/ ``` **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). +- **`ui/pages/`**: Route-level adapters that call `services/` +- **`ui/components/`**: Reusable presentation components (no business logic) +- **`ui/static/`**: Assets loaded at startup in `bootstrap.py` +- **`services/`**: Business logic (independent of UI) +- **`api/`**: HTTP endpoints -**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/`) +**Dependency flow**: `pages/` → `components/` + `services/` (no reverse imports) --- ## UI Component Patterns -Components are the building blocks of reusable UI. They live in `ui/components/` and are consumed by pages in `ui/pages/`. +Components are the building blocks of reusable UI. They live in `ui/components/` and are consumed by pages in `ui/pages/`. Each component maps to [NiceGUI elements](https://nicegui.io/documentation/element), which expose a consistent API for structure, styling, and event handling. -### Reusable Component Architecture +### Reusable Components -A reusable component is a Python module in `ui/components/` that encapsulates a cohesive piece of UI with no business side effects. +Extract to `ui/components/` when patterns repeat across pages. Keep in-page layouts unique to that page. -**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: @@ -116,822 +80,270 @@ def card_section(title: str, content: str) -> ui.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 +### Layout Patterns -Use containers (`ui.row()`, `ui.column()`) to organize structure; apply Tailwind utility classes for responsive behavior. +Use Tailwind for all structural styling. Reserve `.style()` for computed values. -**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") + with ui.row().classes("w-full gap-4 flex-wrap sm:flex-nowrap"): + ui.card().classes("flex-1 min-w-64") + ui.card().classes("flex-1 min-w-64") ``` -**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 -### State Management Between Components +Use [bindable dataclasses](https://nicegui.io/documentation/section_binding_properties) for automatic two-way binding. -For simple interactions within a page, use NiceGUI's `ui.state` or signal patterns. - -**Example — Reactive update via signal**: ```python -from nicegui import ui +from nicegui import binding, ui +from dataclasses import field -# Page-level state -selected_id = ui.state(value=None) +@binding.bindable_dataclass +class PageState: + selected_id: int | None = None + items: list = field(default_factory=list) -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) +state = PageState() +detail_label = ui.label().bind_text_from(state, 'selected_id') -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 +def on_select(item_id): + state.selected_id = item_id # Auto-updates UI + +state.bind_on_change(lambda: load_detail(), 'selected_id') ``` -**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 +## Styling -### Tailwind Classes via `.classes()` +Use Tailwind for all styling. Use breakpoint prefixes (`sm:`, `md:`, `lg:`) for responsive design. -NiceGUI includes TailwindCSS by default. Use `.classes()` for all structural and responsive styling. +### Custom CSS Injection ⚠️ Avoid This Pattern -**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") -``` +**Avoid injecting custom CSS** via `ui.add_css()` at app startup. Custom CSS injection creates maintenance burden, breaks IDE autocomplete, and makes styling harder to reason about. Instead: -### Custom CSS Injection +**✅ Prefer these approaches**: +1. **Use [Tailwind utility classes](https://tailwindcss.com/docs/utility-first)** — covers 95% of styling needs with zero CSS overhead. + ```python + with ui.column().classes("w-full bg-gradient-to-br from-gray-100 to-gray-200"): + with ui.card().classes("rounded-lg border border-gray-300 shadow-md hover:shadow-lg transition-shadow"): + ui.label("Content") + ``` -Inject custom CSS at app startup for styling that TailwindCSS doesn't cover (colors, transitions, custom properties). +2. **Use [Quasar props](https://quasar.dev/vue-components)** — high-level component theming built into NiceGUI elements. + ```python + ui.button("Submit").props("color=primary size=lg") + ``` -**Setup in `bootstrap.py`**: -```python -# src/app/bootstrap.py -from nicegui import ui +3. **Extract reusable components** — encapsulate styling patterns in Python functions rather than CSS classes. + ```python + def styled_card(title: str, content: str) -> ui.card: + with ui.card().classes("rounded-lg border border-gray-300 shadow-md") as card: + ui.label(title).classes("text-lg font-bold") + ui.label(content).classes("text-gray-600") + return card + ``` -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); - } - """) -``` +**If you absolutely must inject CSS** (rare edge cases only): +- Store CSS in `src/app/static/css/` files +- Load via `ui.add_css(open('src/app/static/css/custom.css').read())` in `bootstrap.py` +- Keep injected CSS minimal and well-documented +- Avoid hardcoding colors or spacing; use CSS variables instead -**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 Assets -### 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: +Load CSS files at app startup in `bootstrap.py`: ```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 +app.mount("/static", StaticFiles(directory="src/app/static"), name="static") +ui.add_css(open('src/app/static/css/base.css').read()) ``` -**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`**: +Use CSS variables for design tokens: ```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. +Use `ui.run_javascript()` only for unsupported interactions. Prefer [NiceGUI APIs](https://nicegui.io/documentation/section_action_events) first. --- ## Event-Driven Patterns -### File Upload Handling +NiceGUI provides event-driven APIs for [actions and events](https://nicegui.io/documentation/section_action_events). This section covers common patterns: file uploads, form submissions, WebSocket/SSE for real-time updates, and background tasks. -Handle uploads with validation; emit success/error notifications. +### File Upload + +Validate and notify; delegate storage to service layer. -**Page module**: ```python -from nicegui import ui -from services.file_service import validate_and_store_file +async def handle_upload(e: ui.events.UploadEventArguments): + try: + if e.size > 10 * 1024 * 1024: + raise ValueError("File too large") + if not e.name.endswith('.pdf'): + raise ValueError("Only PDF allowed") + await file_service.store(e.content.read(), e.name) + ui.notify(f"Uploaded: {e.name}", type="positive") + except ValueError as err: + ui.notify(str(err), type="negative") -@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) +ui.upload(on_upload=handle_upload, auto_upload=True) ``` -**Service module**: +### Form Submission + +Bind form inputs to dataclass; validate in service. + ```python -# services/file_service.py -import asyncio -from pathlib import Path +@binding.bindable_dataclass +class FormData: + name: str = "" + email: str = "" -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) -``` +data = FormData() +ui.input("Name").bind_value(data, 'name') +ui.input("Email").bind_value(data, 'email') -### 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") + await user_service.create_user(name=data.name, email=data.email) + ui.notify("User created", type="positive") + data.name = data.email = "" 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.notify(str(err), 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) +### Real-Time Updates (WebSocket / SSE) -For real-time updates without polling, use WebSocket or SSE. +For streaming updates, use [SSE](https://fastapi.tiangolo.com/advanced/server-sent-events/) (one-way) or [WebSocket](https://fastapi.tiangolo.com/advanced/websockets/) (bidirectional). -**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") +@app.get("/events/status") async def status_stream(): - """Stream status updates to client.""" - async def event_generator(): + async def gen(): 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") + yield f"data: {await get_status()}\\n\\n" + await asyncio.sleep(1) + return StreamingResponse(gen(), 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())) +# Page +status_label = ui.label() +async def listen(): + async with aiohttp.ClientSession() as s: + async with s.get("/api/events/status") as r: + async for line in r.content: + if line.startswith(b"data:"): + status_label.set_text(line.decode().replace("data:", "").strip()) + +ui.timer(0.1, lambda: asyncio.create_task(listen())) ``` -### Background Tasks with UI Signaling +### Background Tasks -Long-running operations should run in background tasks; signal UI via updates or notifications. +Delegate long-running work to background tasks; signal UI via polling or WebSocket. -**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 +# API @app.post("/process") async def process(background_tasks: BackgroundTasks): - background_tasks.add_task(long_running_job) + background_tasks.add_task(long_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 +async def on_start(): + await client.post("/api/process") + ui.notify("Started") -# 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) +async def check(): + status = await client.get("/api/process/status") + if status == "done": + ui.notify("Complete", type="positive") + +ui.button("Start").on_click(on_start) +ui.timer(1, check) ``` -### Reactive Refresh Patterns +### Reactive Refresh -Use `@ui.refreshable` for components that need periodic updates, but prefer event-driven triggers. +Use `@ui.refreshable` with explicit refresh triggers; avoid polling everything. -**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() -``` +@ui.refreshable +async def item_list(): + items = await service.list() + for item in items: + ui.label(item.name) -**Avoid**: `ui.timer()` polling everything. Use timers only for monitoring that cannot be event-driven. +ui.button("Refresh").on_click(lambda: item_list.refresh()) +``` --- ## 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. +### Dialogs ```python -# ui/components/confirm_dialog.py -from nicegui import ui -from typing import Callable +dialog = ui.dialog() -async def show_confirm_dialog( - title: str, - message: str, - on_confirm: Callable -): - """Reusable confirm dialog.""" - dialog = ui.dialog() - +async def show_confirm(): 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) - + ui.label("Are you sure?") + 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) ``` -### Progress Indicators +### Progress -**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) -``` +progress = ui.linear_progress(value=0).classes("w-full") -### 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""" -