UI Component Usage Guide¶
Overview¶
This guide demonstrates how to use and create UI components in the Duckalog dashboard using htpy and Datastar.
Table of Contents¶
Layout Components¶
Base Layout¶
The foundation for all dashboard pages:
from duckalog.dashboard.components import base_layout
def my_page():
content = div[
h1["My Page"],
p["Content goes here"]
]
return base_layout("Page Title", content)
Page Header¶
Add titles and subtitles to pages:
from duckalog.dashboard.components import page_header
def my_page():
content = [
page_header(
"Dashboard",
subtitle="Welcome to your dashboard",
action=button["New Item"]
),
div[...content...]
]
return base_layout("Dashboard", content)
Card Container¶
Organize content into cards:
from duckalog.dashboard.components import card
def views_page():
content = [
card("Views", views_div),
card("Statistics", stats_div),
card("Recent Activity", activity_div)
]
return base_layout("Views", content)
Data Display¶
Table Component¶
Display tabular data:
from duckalog.dashboard.components import table_component
def views_table(views):
headers = ["Name", "Schema", "Type", "Description"]
rows = [
[
view["name"],
view["schema"],
view["source_type"],
view.get("description", "")
]
for view in views
]
return table_component(headers, rows)
Custom Table with htpy¶
For more control:
def custom_table(headers, rows):
return div(class_="overflow-x-auto")[
table(class_="table min-w-full divide-y divide-gray-200")[
thead(class_="bg-gray-50 dark:bg-gray-800")[
tr[
th(class_="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider")[
header
]
for header in headers
]
],
tbody(class_="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700")[
tr[
td(class_="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100")[
cell
]
for cell in row
]
for row in rows
]
]
]
Responsive Table¶
Tables with horizontal scroll on mobile:
def responsive_table(headers, rows):
return div(class_="overflow-x-auto shadow ring-1 ring-black ring-opacity-5 md:rounded-lg")[
table(class_="min-w-full divide-y divide-gray-300 dark:divide-gray-700")[
# ... table content
]
]
Forms¶
Text Input¶
from htpy import input, label, div
def text_input_field(label_text, bind_to, placeholder=""):
return div(class_="space-y-1")[
label(for_=bind_to, class_="block text-sm font-medium")[
label_text
],
input(
id=bind_to,
type="text",
class_="input w-full",
placeholder=placeholder,
**{
"data-bind": bind_to
}
)
]
Textarea¶
from htpy import textarea
def sql_input():
return textarea(
id="sql-input",
rows="6",
class_="mt-1 block w-full rounded-md border-gray-300 shadow-sm",
placeholder="SELECT * FROM table",
**{
"data-bind": "sql"
}
)
Select Dropdown¶
def select_field(options, bind_to):
return select(class_="select w-full", **{
"data-bind": bind_to
})[
option(value="", disabled=True, selected=True)[
"Select an option"
],
*[
option(value=opt["value"])[opt["label"]]
for opt in options
]
]
Checkbox¶
def checkbox_field(label_text, bind_to):
return div(class_="flex items-center")[
input(
type="checkbox",
class_="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded",
**{
"data-bind": bind_to,
"data-checked": f"${bind_to}"
}
),
label(class_="ml-2 block text-sm")[
label_text
]
]
Form with Validation¶
def query_form():
return form(class_="space-y-4")[
div[
label(for_="sql", class_="block text-sm font-medium")[
"SQL Query"
],
textarea(
id="sql",
rows="6",
class_="mt-1 block w-full rounded-md border-gray-300",
**{
"data-bind": "sql",
"data-validate": "required"
}
)
],
div(
id="error",
class_="text-sm text-red-600",
**{"data-show": "$error"}
)[f"{$error}"],
button(
type="submit",
class_="btn btn-primary",
**{
"data-on-click": "$$post('/query/execute')",
"data-indicator": "loading"
}
)[
"Execute Query"
],
span(**{"data-show": "$loading"})["Running..."]
]
Navigation¶
Navigation Links¶
from duckalog.dashboard.components import nav_link
def navigation():
return nav(class_="flex space-x-4")[
nav_link("/", "Home"),
nav_link("/views", "Views"),
nav_link("/query", "Query")
]
Breadcrumbs¶
def breadcrumbs(items):
return nav(class_="flex", aria_label="Breadcrumb")[
ol(class_="flex items-center space-x-2")[
li(class_="flex items-center")[
a(href="/", class_="text-gray-500 hover:text-gray-700")[
"Home"
]
],
*[
li(class_="flex items-center")[
span(class_="mx-2 text-gray-400")["/"],
a(
href=item["href"],
class_="text-gray-500 hover:text-gray-700"
)[item["label"]]
]
for item in items
]
]
]
Tabs¶
def tab_interface(tabs, active_tab):
return div(class_="border-b border-gray-200 dark:border-gray-700")[
nav(class_="-mb-px flex space-x-8")[
*[
a(
href=tab["href"],
class_=f"py-2 px-1 border-b-2 font-medium text-sm {
'border-indigo-500 text-indigo-600' if tab['id'] == active_tab
else 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}"
)[tab["label"]]
for tab in tabs
]
]
]
Feedback¶
Alert Messages¶
def alert(message, type_="info"):
"""Display alert messages.
Args:
message: Alert text
type_: info, success, warning, error
"""
type_classes = {
"info": "bg-blue-50 border-blue-200 text-blue-800",
"success": "bg-green-50 border-green-200 text-green-800",
"warning": "bg-yellow-50 border-yellow-200 text-yellow-800",
"error": "bg-red-50 border-red-200 text-red-800"
}
return div(
class_=f"rounded-md border p-4 {type_classes.get(type_, type_classes['info'])}",
**{
"data-show": f"$showAlert && $alertType === '{type_}'"
}
)[
div(class_="flex")[
div(class_="ml-3")[
p(class_="text-sm font-medium")[f"{$alertMessage}"]
]
]
]
Loading Spinner¶
def spinner(size="md"):
"""Show loading spinner.
Args:
size: sm, md, lg
"""
size_classes = {
"sm": "h-4 w-4",
"md": "h-8 w-8",
"lg": "h-12 w-12"
}
return div(
class_="flex justify-center",
**{
"data-show": "$loading"
}
)[
div(class_="animate-spin rounded-full border-b-2 border-indigo-600 " +
size_classes.get(size, size_classes["md"]))
]
Progress Bar¶
def progress_bar(value, max_value=100):
"""Show progress bar.
Args:
value: Current value
max_value: Maximum value
"""
percentage = min(100, (value / max_value) * 100)
return div(class_="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700")[
div(
class_="bg-blue-600 h-2.5 rounded-full transition-all duration-300",
style=f"width: {percentage}%"
)
]
Status Badge¶
def status_badge(status):
"""Show status badge.
Args:
status: Status text
"""
status_classes = {
"complete": "bg-green-100 text-green-800",
"building": "bg-blue-100 text-blue-800",
"error": "bg-red-100 text-red-800",
"idle": "bg-gray-100 text-gray-800"
}
return span(
class_="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium " +
status_classes.get(status.lower(), status_classes["idle"])
)[status]
Creating Custom Components¶
Component Structure¶
from htpy import div, Element
from typing import Any
def custom_component(
title: str,
content: Element | list[Element] | str,
*,
action: Element | None = None,
**kwargs: Any
) -> Element:
"""Custom card component with action button.
Args:
title: Card title
content: Card content
action: Optional action button
**kwargs: Additional HTML attributes
Returns:
HTPy element
"""
return div(class_="bg-white shadow rounded-lg p-6", **kwargs)[
div(class_="flex items-center justify-between mb-4")[
h3(class_="text-lg font-medium")[title],
action
],
div(content)
]
Using Custom Component¶
def my_page():
action_btn = button(
class_="btn btn-primary",
**{
"data-on-click": "$$post('/api/create')"
}
)["Create Item"]
content = div[
p["This is the card content"]
]
card = custom_component(
"My Card",
content,
action=action_btn
)
return base_layout("Custom Component", card)
Composable Components¶
Build complex UIs from simple components:
def data_table_with_search(headers, rows):
"""Table with built-in search."""
search_input = input(
type="text",
placeholder="Search...",
class_="input w-full mb-4",
**{
"data-bind": "search",
"data-debounce": "300"
}
)
table = custom_table(headers, rows)
return div[
search_input,
table
]
Component with Datastar¶
def reactive_counter():
"""Counter with reactive updates."""
return div(class_="text-center")[
button(
class_="btn btn-ghost btn-icon",
**{
"data-on-click": "decrement()"
}
)["-"],
span(
class_="mx-4 text-2xl font-bold",
**{"data-text": "$count"}
)["0"],
button(
class_="btn btn-ghost btn-icon",
**{
"data-on-click": "increment()"
}
)["+"]
]
# Add to base_layout signals
signals = {
"count": 0
}
Styling Best Practices¶
Use Tailwind Classes¶
# Good - semantic Tailwind classes
div(class_="flex items-center justify-between p-4 bg-white rounded-lg shadow")
# Bad - custom classes
div(class_="my-custom-div another-class")
Responsive Design¶
# Good - responsive classes
div(class_="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4")
# Bad - non-responsive
div(class_="grid grid-cols-3 gap-4")
Dark Mode Support¶
# Good - dark mode classes
div(class_="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100")
# Bad - no dark mode
div(class_="bg-white text-gray-900")
Consistent Spacing¶
# Use consistent spacing scale
div(class_="space-y-4") # 1rem gap
div(class_="space-y-6") # 1.5rem gap
div(class_="space-y-8") # 2rem gap
Accessibility¶
ARIA Labels¶
# Good - has accessible name
button(
aria_label="Close dialog",
class_="btn btn-ghost"
)[...]
# Bad - no accessible name
button(class_="btn btn-ghost")[...]
Semantic HTML¶
# Good - semantic elements
header[...]
nav[...]
main[...]
section[...]
article[...]
footer[...]
# Bad - generic divs
div[...]
div[...]
div[...]
Focus Management¶
# Open modal and focus first input
def open_modal():
modal = document.getElementById('modal')
first_input = modal.querySelector('input')
if first_input:
first_input.focus()
Component Testing¶
Unit Testing Components¶
import pytest
from htpy import render
from duckalog.dashboard.components.layout import card
def test_card_renders_title():
result = card("Test Title", "Test Content")
html = render(result)
assert "Test Title" in html
assert "Test Content" in html
Integration Testing¶
from litestar.testing import TestClient
def test_query_form(client: TestClient):
response = client.get("/query")
assert response.status_code == 200
assert 'data-bind="sql"' in response.text
assert 'data-on-click="$$post' in response.text
Performance Tips¶
Minimize DOM Updates¶
# Good - batch updates
yield SSE.patch_elements(
render_full_table(data),
selector="#table",
mode="morph"
)
# Bad - many small updates
for item in data:
yield SSE.patch_elements(
f"<tr>...</tr>",
selector="#table",
mode="append"
)
Use Keys for Lists¶
# When rendering lists, ensure stable keys
div[
tr(key=item["id"])[...] # if supported
for item in items
]
Lazy Load Components¶
# Load heavy components on demand
div(**{
"data-show": "$showHeavyComponent"
})[
heavy_component()
]
Common Patterns¶
Search with Results¶
def search_with_results():
return div(class_="space-y-4")[
# Search input
input(
placeholder="Search...",
class_="input w-full",
**{
"data-bind": "search",
"data-debounce": "300"
}
),
# Results
div(id="search-results")[
# Results will be patched via Datastar
]
]
Modal Dialog¶
def modal_dialog(title, content, footer):
return div(
id="modal",
class_="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden",
**{
"data-show": "$showModal"
}
)[
div(class_="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white")[
# Header
div(class_="flex items-center justify-between mb-4")[
h3(class_="text-xl font-bold")[title],
button(
class_="text-gray-400 hover:text-gray-600",
**{
"data-on-click": "closeModal()"
}
)["×"]
],
# Content
div(class_="mb-4")[content],
# Footer
div(class_="flex justify-end space-x-2")[footer]
]
]
Pagination¶
def pagination(current_page, total_pages):
return nav(class_="flex items-center justify-between")[
div(class_="flex-1 flex justify-between sm:hidden")[
button(
class_="btn",
**{
"data-show": f"$currentPage > 1",
"data-on-click": f"goToPage({current_page - 1})"
}
)["Previous"],
button(
class_="btn",
**{
"data-show": f"$currentPage < {total_pages}",
"data-on-click": f"goToPage({current_page + 1})"
}
)["Next"]
],
div(class_="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between")[
div[
p(class_="text-sm text-gray-700")[
f"Showing page {current_page} of {total_pages}"
]
],
div(className="hidden md:-mt-px md:flex")[
# Page numbers
*[
a(
href="#",
class_=f"px-4 py-2 border text-sm font-medium {'bg-indigo-50 border-indigo-500 text-indigo-600' if i == current_page else 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'}",
**{
"data-on-click": f"goToPage({i})"
}
)[str(i)]
for i in range(1, total_pages + 1)
]
]
]
]