Added Controls testing + documentation
This commit is contained in:
583
docs/Layout.md
Normal file
583
docs/Layout.md
Normal file
@@ -0,0 +1,583 @@
|
||||
# Layout Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The Layout component provides a complete application structure with fixed header and footer, a scrollable main content
|
||||
area, and optional collapsible side drawers. It's designed to be the foundation of your FastHTML application's UI.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Fixed header and footer that stay visible while scrolling
|
||||
- Collapsible left and right drawers for navigation, tools, or auxiliary content
|
||||
- Resizable drawers with drag handles
|
||||
- Automatic state persistence per session
|
||||
- Single instance per session (singleton pattern)
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Application with navigation sidebar
|
||||
- Dashboard with tools panel
|
||||
- Admin interface with settings drawer
|
||||
- Documentation site with table of contents
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing an application with a navigation sidebar:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
# Create the layout instance
|
||||
layout = Layout(parent=root_instance, app_name="My App")
|
||||
|
||||
# Add navigation items to the left drawer
|
||||
layout.left_drawer.add(
|
||||
mk.mk(Div("Home"), command=Command(...))
|
||||
)
|
||||
layout.left_drawer.add(
|
||||
mk.mk(Div("About"), command=Command(...))
|
||||
)
|
||||
layout.left_drawer.add(
|
||||
mk.mk(Div("Contact"), command=Command(...))
|
||||
)
|
||||
|
||||
# Set the main content
|
||||
layout.set_main(
|
||||
Div(
|
||||
H1("Welcome"),
|
||||
P("This is the main content area")
|
||||
)
|
||||
)
|
||||
|
||||
# Render the layout
|
||||
return layout
|
||||
```
|
||||
|
||||
This creates a complete application layout with:
|
||||
|
||||
- A header displaying the app name and drawer toggle button
|
||||
- A collapsible left drawer with interactive navigation items
|
||||
- A main content area that updates when navigation items are clicked
|
||||
- An empty footer
|
||||
|
||||
**Note:** Navigation items use commands to update the main content area without page reload. See the Commands section
|
||||
below for details.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating a Layout
|
||||
|
||||
The Layout component is a `SingleInstance`, meaning there's only one instance per session. Create it by providing a
|
||||
parent instance and an application name:
|
||||
|
||||
```python
|
||||
layout = Layout(parent=root_instance, app_name="My Application")
|
||||
```
|
||||
|
||||
### Content Zones
|
||||
|
||||
The Layout provides six content zones where you can add components:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Header │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ header_left │ │ header_right │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
├─────────┬────────────────────────────────────┬───────────┤
|
||||
│ │ │ │
|
||||
│ left │ │ right │
|
||||
│ drawer │ Main Content │ drawer │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
├─────────┴────────────────────────────────────┴───────────┤
|
||||
│ Footer │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ footer_left │ │ footer_right │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Zone details:**
|
||||
|
||||
| Zone | Typical Use |
|
||||
|----------------|-----------------------------------------------|
|
||||
| `header_left` | App logo, menu button, breadcrumbs |
|
||||
| `header_right` | User profile, notifications, settings |
|
||||
| `left_drawer` | Navigation menu, tree view, filters |
|
||||
| `right_drawer` | Tools panel, properties inspector, debug info |
|
||||
| `footer_left` | Copyright, legal links, version |
|
||||
| `footer_right` | Status indicators, connection state |
|
||||
|
||||
### Adding Content to Zones
|
||||
|
||||
Use the `.add()` method to add components to any zone:
|
||||
|
||||
```python
|
||||
# Header
|
||||
layout.header_left.add(Div("Logo"))
|
||||
layout.header_right.add(Div("User: Admin"))
|
||||
|
||||
# Drawers
|
||||
layout.left_drawer.add(Div("Navigation"))
|
||||
layout.right_drawer.add(Div("Tools"))
|
||||
|
||||
# Footer
|
||||
layout.footer_left.add(Div("© 2024 My App"))
|
||||
layout.footer_right.add(Div("v1.0.0"))
|
||||
```
|
||||
|
||||
### Setting Main Content
|
||||
|
||||
The main content area displays your page content and can be updated dynamically:
|
||||
|
||||
```python
|
||||
# Set initial content
|
||||
layout.set_main(
|
||||
Div(
|
||||
H1("Dashboard"),
|
||||
P("Welcome to your dashboard")
|
||||
)
|
||||
)
|
||||
|
||||
# Update later (typically via commands)
|
||||
layout.set_main(
|
||||
Div(
|
||||
H1("Settings"),
|
||||
P("Configure your preferences")
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Controlling Drawers
|
||||
|
||||
By default, both drawers are visible. The drawer state is managed automatically:
|
||||
|
||||
- Users can toggle drawers using the icon buttons in the header
|
||||
- Users can resize drawers by dragging the handle
|
||||
- Drawer state persists within the session
|
||||
|
||||
The initial drawer widths are:
|
||||
|
||||
- Left drawer: 250px
|
||||
- Right drawer: 250px
|
||||
|
||||
These can be adjusted by users and the state is preserved automatically.
|
||||
|
||||
## Content System
|
||||
|
||||
### Understanding Groups
|
||||
|
||||
Each content zone (header_left, header_right, drawers, footer) supports **groups** to organize related items. Groups are
|
||||
separated visually by dividers and can have optional labels.
|
||||
|
||||
### Adding Content to Groups
|
||||
|
||||
When adding content, you can optionally specify a group name:
|
||||
|
||||
```python
|
||||
# Add items to different groups in the left drawer
|
||||
layout.left_drawer.add(Div("Dashboard"), group="main")
|
||||
layout.left_drawer.add(Div("Analytics"), group="main")
|
||||
layout.left_drawer.add(Div("Settings"), group="preferences")
|
||||
layout.left_drawer.add(Div("Profile"), group="preferences")
|
||||
```
|
||||
|
||||
This creates two groups:
|
||||
|
||||
- **main**: Dashboard, Analytics
|
||||
- **preferences**: Settings, Profile
|
||||
|
||||
A visual divider automatically appears between groups.
|
||||
|
||||
### Custom Group Labels
|
||||
|
||||
You can provide a custom FastHTML element to display as the group header:
|
||||
|
||||
```python
|
||||
# Add a styled group header
|
||||
layout.left_drawer.add_group(
|
||||
"Navigation",
|
||||
group_ft=Div("MAIN MENU", cls="font-bold text-sm opacity-60 px-2 py-1")
|
||||
)
|
||||
|
||||
# Then add items to this group
|
||||
layout.left_drawer.add(Div("Home"), group="Navigation")
|
||||
layout.left_drawer.add(Div("About"), group="Navigation")
|
||||
```
|
||||
|
||||
### Ungrouped Content
|
||||
|
||||
If you don't specify a group, content is added to the default (`None`) group:
|
||||
|
||||
```python
|
||||
# These items are in the default group
|
||||
layout.left_drawer.add(Div("Quick Action 1"))
|
||||
layout.left_drawer.add(Div("Quick Action 2"))
|
||||
```
|
||||
|
||||
### Preventing Duplicates
|
||||
|
||||
The Content system automatically prevents adding duplicate items based on their `id` attribute:
|
||||
|
||||
```python
|
||||
item = Div("Unique Item", id="my-item")
|
||||
layout.left_drawer.add(item)
|
||||
layout.left_drawer.add(item) # Ignored - already added
|
||||
```
|
||||
|
||||
### Group Rendering Options
|
||||
|
||||
Groups render differently depending on the zone:
|
||||
|
||||
**In drawers** (vertical layout):
|
||||
|
||||
- Groups stack vertically
|
||||
- Dividers are horizontal lines
|
||||
- Group labels appear above their content
|
||||
|
||||
**In header/footer** (horizontal layout):
|
||||
|
||||
- Groups arrange side-by-side
|
||||
- Dividers are vertical lines
|
||||
- Group labels are typically hidden
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Resizable Drawers
|
||||
|
||||
Both drawers can be resized by users via drag handles:
|
||||
|
||||
- **Drag handle location**:
|
||||
- Left drawer: Right edge
|
||||
- Right drawer: Left edge
|
||||
- **Width constraints**: 150px (minimum) to 600px (maximum)
|
||||
- **Persistence**: Resized width is automatically saved in the session state
|
||||
|
||||
Users can drag the handle to adjust drawer width. The new width is preserved throughout their session.
|
||||
|
||||
### Programmatic Drawer Control
|
||||
|
||||
You can control drawers programmatically using commands:
|
||||
|
||||
```python
|
||||
# Toggle drawer visibility
|
||||
toggle_left = layout.commands.toggle_drawer("left")
|
||||
toggle_right = layout.commands.toggle_drawer("right")
|
||||
|
||||
# Update drawer width
|
||||
update_left_width = layout.commands.update_drawer_width("left", width=300)
|
||||
update_right_width = layout.commands.update_drawer_width("right", width=350)
|
||||
```
|
||||
|
||||
These commands are typically used with buttons or other interactive elements:
|
||||
|
||||
```python
|
||||
# Add a button to toggle the right drawer
|
||||
button = mk.button("Toggle Tools", command=layout.commands.toggle_drawer("right"))
|
||||
layout.header_right.add(button)
|
||||
```
|
||||
|
||||
### State Persistence
|
||||
|
||||
The Layout automatically persists its state within the user's session:
|
||||
|
||||
| State Property | Description | Default |
|
||||
|----------------------|---------------------------------|---------|
|
||||
| `left_drawer_open` | Whether left drawer is visible | `True` |
|
||||
| `right_drawer_open` | Whether right drawer is visible | `True` |
|
||||
| `left_drawer_width` | Left drawer width in pixels | `250` |
|
||||
| `right_drawer_width` | Right drawer width in pixels | `250` |
|
||||
|
||||
State changes (toggle, resize) are automatically saved and restored within the session.
|
||||
|
||||
### Dynamic Content Updates
|
||||
|
||||
Content zones can be updated dynamically during the session:
|
||||
|
||||
```python
|
||||
# Initial setup
|
||||
layout.left_drawer.add(Div("Item 1"))
|
||||
|
||||
|
||||
# Later, add more items (e.g., in a command handler)
|
||||
def add_dynamic_content():
|
||||
layout.left_drawer.add(Div("New Item"), group="dynamic")
|
||||
return layout.left_drawer # Return updated drawer for HTMX swap
|
||||
```
|
||||
|
||||
**Note**: When updating content dynamically, you typically return the updated zone to trigger an HTMX swap.
|
||||
|
||||
### CSS Customization
|
||||
|
||||
The Layout uses CSS classes that you can customize:
|
||||
|
||||
| Class | Element |
|
||||
|----------------------------|----------------------------------|
|
||||
| `mf-layout` | Root layout container |
|
||||
| `mf-layout-header` | Header section |
|
||||
| `mf-layout-footer` | Footer section |
|
||||
| `mf-layout-main` | Main content area |
|
||||
| `mf-layout-drawer` | Drawer container |
|
||||
| `mf-layout-left-drawer` | Left drawer specifically |
|
||||
| `mf-layout-right-drawer` | Right drawer specifically |
|
||||
| `mf-layout-drawer-content` | Scrollable content within drawer |
|
||||
| `mf-resizer` | Resize handle |
|
||||
| `mf-layout-group` | Content group wrapper |
|
||||
|
||||
You can override these classes in your custom CSS to change colors, spacing, or behavior.
|
||||
|
||||
### User Profile Integration
|
||||
|
||||
The Layout automatically includes a UserProfile component in the header right area. This component handles user
|
||||
authentication display and logout functionality when auth is enabled.
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Dashboard with Navigation Sidebar
|
||||
|
||||
A typical dashboard application with a navigation menu in the left drawer:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
# Create layout
|
||||
layout = Layout(parent=root_instance, app_name="Analytics Dashboard")
|
||||
|
||||
|
||||
# Navigation menu in left drawer
|
||||
def show_dashboard():
|
||||
layout.set_main(Div(H1("Dashboard"), P("Overview of your metrics")))
|
||||
return layout._mk_main()
|
||||
|
||||
|
||||
def show_reports():
|
||||
layout.set_main(Div(H1("Reports"), P("Detailed analytics reports")))
|
||||
return layout._mk_main()
|
||||
|
||||
|
||||
def show_settings():
|
||||
layout.set_main(Div(H1("Settings"), P("Configure your preferences")))
|
||||
return layout._mk_main()
|
||||
|
||||
|
||||
# Add navigation items with groups
|
||||
layout.left_drawer.add_group("main", group_ft=Div("MENU", cls="font-bold text-xs px-2 opacity-60"))
|
||||
layout.left_drawer.add(mk.mk(Div("Dashboard"), command=Command("nav_dash", "Show dashboard", show_dashboard)),
|
||||
group="main")
|
||||
layout.left_drawer.add(mk.mk(Div("Reports"), command=Command("nav_reports", "Show reports", show_reports)),
|
||||
group="main")
|
||||
|
||||
layout.left_drawer.add_group("config", group_ft=Div("CONFIGURATION", cls="font-bold text-xs px-2 opacity-60"))
|
||||
layout.left_drawer.add(mk.mk(Div("Settings"), command=Command("nav_settings", "Show settings", show_settings)),
|
||||
group="config")
|
||||
|
||||
# Header content
|
||||
layout.header_left.add(Div("📊 Analytics", cls="font-bold"))
|
||||
|
||||
# Footer
|
||||
layout.footer_left.add(Div("© 2024 Analytics Co."))
|
||||
layout.footer_right.add(Div("v1.0.0"))
|
||||
|
||||
# Set initial main content
|
||||
layout.set_main(Div(H1("Dashboard"), P("Overview of your metrics")))
|
||||
```
|
||||
|
||||
### Example 2: Development Tool with Debug Panel
|
||||
|
||||
An application with development tools in the right drawer:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
# Create layout
|
||||
layout = Layout(parent=root_instance, app_name="Dev Tools")
|
||||
|
||||
# Main content: code editor
|
||||
layout.set_main(
|
||||
Div(
|
||||
H2("Code Editor"),
|
||||
Textarea("# Write your code here", rows=20, cls="w-full font-mono")
|
||||
)
|
||||
)
|
||||
|
||||
# Right drawer: debug and tools
|
||||
layout.right_drawer.add_group("debug", group_ft=Div("DEBUG INFO", cls="font-bold text-xs px-2 opacity-60"))
|
||||
layout.right_drawer.add(Div("Console output here..."), group="debug")
|
||||
layout.right_drawer.add(Div("Variables: x=10, y=20"), group="debug")
|
||||
|
||||
layout.right_drawer.add_group("tools", group_ft=Div("TOOLS", cls="font-bold text-xs px-2 opacity-60"))
|
||||
layout.right_drawer.add(Button("Run Code"), group="tools")
|
||||
layout.right_drawer.add(Button("Clear Console"), group="tools")
|
||||
|
||||
# Header
|
||||
layout.header_left.add(Div("DevTools IDE"))
|
||||
layout.header_right.add(Button("Save"))
|
||||
```
|
||||
|
||||
### Example 3: Minimal Layout (Main Content Only)
|
||||
|
||||
A simple layout without drawers, focusing only on main content:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
|
||||
# Create layout
|
||||
layout = Layout(parent=root_instance, app_name="Simple Blog")
|
||||
|
||||
# Header
|
||||
layout.header_left.add(Div("My Blog", cls="text-xl font-bold"))
|
||||
layout.header_right.add(A("About", href="/about"))
|
||||
|
||||
# Main content
|
||||
layout.set_main(
|
||||
Article(
|
||||
H1("Welcome to My Blog"),
|
||||
P("This is a simple blog layout without side drawers."),
|
||||
P("The focus is on the content in the center.")
|
||||
)
|
||||
)
|
||||
|
||||
# Footer
|
||||
layout.footer_left.add(Div("© 2024 Blog Author"))
|
||||
layout.footer_right.add(A("RSS", href="/rss"))
|
||||
|
||||
# Note: Drawers are present but can be collapsed by users if not needed
|
||||
```
|
||||
|
||||
### Example 4: Dynamic Content Loading
|
||||
|
||||
Loading content dynamically based on user interaction:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
layout = Layout(parent=root_instance, app_name="Dynamic App")
|
||||
|
||||
|
||||
# Function that loads content dynamically
|
||||
def load_page(page_name):
|
||||
# Simulate loading different content
|
||||
content = {
|
||||
"home": Div(H1("Home"), P("Welcome to the home page")),
|
||||
"profile": Div(H1("Profile"), P("User profile information")),
|
||||
"settings": Div(H1("Settings"), P("Application settings")),
|
||||
}
|
||||
layout.set_main(content.get(page_name, Div("Page not found")))
|
||||
return layout._mk_main()
|
||||
|
||||
|
||||
# Create navigation commands
|
||||
pages = ["home", "profile", "settings"]
|
||||
for page in pages:
|
||||
cmd = Command(f"load_{page}", f"Load {page} page", load_page, page)
|
||||
layout.left_drawer.add(
|
||||
mk.mk(Div(page.capitalize()), command=cmd)
|
||||
)
|
||||
|
||||
# Set initial content
|
||||
layout.set_main(Div(H1("Home"), P("Welcome to the home page")))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the Layout component itself.
|
||||
|
||||
### State
|
||||
|
||||
The Layout component maintains the following state properties:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|----------------------|---------|----------------------------------|---------|
|
||||
| `left_drawer_open` | boolean | True if the left drawer is open | True |
|
||||
| `right_drawer_open` | boolean | True if the right drawer is open | True |
|
||||
| `left_drawer_width` | integer | Width of the left drawer | 250 |
|
||||
| `right_drawer_width` | integer | Width of the right drawer | 250 |
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|-----------------------------------------|----------------------------------------------------------------------------------------|
|
||||
| `toggle_drawer(side)` | Toggles the drawer on the specified side |
|
||||
| `update_drawer_width(side, width=None)` | Updates the drawer width on the specified side. The width is given by the HTMX request |
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description |
|
||||
|---------------------|-----------------------------|
|
||||
| `set_main(content)` | Sets the main content area |
|
||||
| `render()` | Renders the complete layout |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
```
|
||||
Div(id="layout")
|
||||
├── Header
|
||||
│ ├── Div(id="layout_hl")
|
||||
│ │ ├── Icon # Left drawer icon button
|
||||
│ │ └── Div # Left content for the header
|
||||
│ └── Div(id="layout_hr")
|
||||
│ ├── Div # Right content for the header
|
||||
│ └── UserProfile # user profile icon button
|
||||
├── Div # Left Drawer
|
||||
├── Main # Main content
|
||||
├── Div # Right Drawer
|
||||
├── Footer # Footer
|
||||
└── Script # To initialize the resizing
|
||||
```
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Name | Description |
|
||||
|-------------|-------------------------------------|
|
||||
| `layout` | Root layout container (singleton) |
|
||||
| `layout_h` | Header section (not currently used) |
|
||||
| `layout_hl` | Header left side |
|
||||
| `layout_hr` | Header right side |
|
||||
| `layout_f` | Footer section (not currently used) |
|
||||
| `layout_fl` | Footer left side |
|
||||
| `layout_fr` | Footer right side |
|
||||
| `layout_ld` | Left drawer |
|
||||
| `layout_rd` | Right drawer |
|
||||
|
||||
### Internal Methods
|
||||
|
||||
These methods are used internally for rendering:
|
||||
|
||||
| Method | Description |
|
||||
|---------------------------|--------------------------------------------------------|
|
||||
| `_mk_header()` | Renders the header component |
|
||||
| `_mk_footer()` | Renders the footer component |
|
||||
| `_mk_main()` | Renders the main content area |
|
||||
| `_mk_left_drawer()` | Renders the left drawer |
|
||||
| `_mk_right_drawer()` | Renders the right drawer |
|
||||
| `_mk_left_drawer_icon()` | Renders the left drawer toggle icon |
|
||||
| `_mk_right_drawer_icon()` | Renders the right drawer toggle icon |
|
||||
| `_mk_content_wrapper()` | Static method to wrap content with groups and dividers |
|
||||
|
||||
### Content Class
|
||||
|
||||
The `Layout.Content` nested class manages content zones:
|
||||
|
||||
| Method | Description |
|
||||
|-----------------------------------|----------------------------------------------------------|
|
||||
| `add(content, group=None)` | Adds content to a group, prevents duplicates based on ID |
|
||||
| `add_group(group, group_ft=None)` | Creates a new group with optional custom header element |
|
||||
| `get_content()` | Returns dictionary of groups and their content |
|
||||
| `get_groups()` | Returns list of (group_name, group_ft) tuples |
|
||||
596
docs/TreeView.md
Normal file
596
docs/TreeView.md
Normal file
@@ -0,0 +1,596 @@
|
||||
# TreeView Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The TreeView component provides an interactive hierarchical data visualization with full CRUD operations. It's designed for displaying tree-structured data like file systems, organizational charts, or navigation menus with inline editing capabilities.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Expand/collapse nodes with visual indicators
|
||||
- Add child and sibling nodes dynamically
|
||||
- Inline rename with keyboard support (ESC to cancel)
|
||||
- Delete nodes (only leaf nodes without children)
|
||||
- Node selection tracking
|
||||
- Persistent state per session
|
||||
- Configurable icons per node type
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- File/folder browser
|
||||
- Category/subcategory management
|
||||
- Organizational hierarchy viewer
|
||||
- Navigation menu builder
|
||||
- Document outline editor
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing a file system tree:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
|
||||
# Create TreeView instance
|
||||
tree = TreeView(parent=root_instance, _id="file-tree")
|
||||
|
||||
# Add root folder
|
||||
root = TreeNode(id="root", label="Documents", type="folder")
|
||||
tree.add_node(root)
|
||||
|
||||
# Add some files
|
||||
file1 = TreeNode(id="file1", label="report.pdf", type="file")
|
||||
file2 = TreeNode(id="file2", label="budget.xlsx", type="file")
|
||||
tree.add_node(file1, parent_id="root")
|
||||
tree.add_node(file2, parent_id="root")
|
||||
|
||||
# Expand root to show children
|
||||
tree.expand_all()
|
||||
|
||||
# Render the tree
|
||||
return tree
|
||||
```
|
||||
|
||||
This creates an interactive tree where users can:
|
||||
- Click chevrons to expand/collapse folders
|
||||
- Click labels to select items
|
||||
- Use action buttons (visible on hover) to add, rename, or delete nodes
|
||||
|
||||
**Note:** All interactions use commands and update via HTMX without page reload.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating a TreeView
|
||||
|
||||
TreeView is a `MultipleInstance`, allowing multiple trees per session. Create it with a parent instance:
|
||||
|
||||
```python
|
||||
tree = TreeView(parent=root_instance, _id="my-tree")
|
||||
```
|
||||
|
||||
### TreeNode Structure
|
||||
|
||||
Nodes are represented by the `TreeNode` dataclass:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.TreeView import TreeNode
|
||||
|
||||
node = TreeNode(
|
||||
id="unique-id", # Auto-generated UUID if not provided
|
||||
label="Node Label", # Display text
|
||||
type="default", # Type for icon mapping
|
||||
parent=None, # Parent node ID (None for root)
|
||||
children=[] # List of child node IDs
|
||||
)
|
||||
```
|
||||
|
||||
### Adding Nodes
|
||||
|
||||
Add nodes using the `add_node()` method:
|
||||
|
||||
```python
|
||||
# Add root node
|
||||
root = TreeNode(id="root", label="Root", type="folder")
|
||||
tree.add_node(root)
|
||||
|
||||
# Add child node
|
||||
child = TreeNode(label="Child 1", type="item")
|
||||
tree.add_node(child, parent_id="root")
|
||||
|
||||
# Add with specific position
|
||||
sibling = TreeNode(label="Child 2", type="item")
|
||||
tree.add_node(sibling, parent_id="root", insert_index=0) # Insert at start
|
||||
```
|
||||
|
||||
### Visual Structure
|
||||
|
||||
```
|
||||
TreeView
|
||||
├── Root Node 1
|
||||
│ ├── [>] Child 1-1 # Collapsed node with children
|
||||
│ ├── [ ] Child 1-2 # Leaf node (no children)
|
||||
│ └── [v] Child 1-3 # Expanded node
|
||||
│ ├── [ ] Grandchild
|
||||
│ └── [ ] Grandchild
|
||||
└── Root Node 2
|
||||
└── [>] Child 2-1
|
||||
```
|
||||
|
||||
**Legend:**
|
||||
- `[>]` - Collapsed node (has children)
|
||||
- `[v]` - Expanded node (has children)
|
||||
- `[ ]` - Leaf node (no children)
|
||||
|
||||
### Expanding Nodes
|
||||
|
||||
Control node expansion programmatically:
|
||||
|
||||
```python
|
||||
# Expand all nodes with children
|
||||
tree.expand_all()
|
||||
|
||||
# Expand specific nodes by adding to opened list
|
||||
tree._state.opened.append("node-id")
|
||||
```
|
||||
|
||||
**Note:** Users can also toggle nodes by clicking the chevron icon.
|
||||
|
||||
## Interactive Features
|
||||
|
||||
### Node Selection
|
||||
|
||||
Users can select nodes by clicking on labels. The selected node is visually highlighted:
|
||||
|
||||
```python
|
||||
# Programmatically select a node
|
||||
tree._state.selected = "node-id"
|
||||
|
||||
# Check current selection
|
||||
current = tree._state.selected
|
||||
```
|
||||
|
||||
### Adding Nodes
|
||||
|
||||
Users can add nodes via action buttons (visible on hover):
|
||||
|
||||
**Add Child:**
|
||||
- Adds a new node as a child of the target node
|
||||
- Automatically expands the parent
|
||||
- Creates node with same type as parent
|
||||
|
||||
**Add Sibling:**
|
||||
- Adds a new node next to the target node (same parent)
|
||||
- Inserts after the target node
|
||||
- Cannot add sibling to root nodes
|
||||
|
||||
```python
|
||||
# Programmatically add child
|
||||
tree._add_child(parent_id="root", new_label="New Child")
|
||||
|
||||
# Programmatically add sibling
|
||||
tree._add_sibling(node_id="child1", new_label="New Sibling")
|
||||
```
|
||||
|
||||
### Renaming Nodes
|
||||
|
||||
Users can rename nodes via the edit button:
|
||||
|
||||
1. Click the edit icon (visible on hover)
|
||||
2. Input field appears with current label
|
||||
3. Press Enter to save (triggers command)
|
||||
4. Press ESC to cancel (keyboard shortcut)
|
||||
|
||||
```python
|
||||
# Programmatically start rename
|
||||
tree._start_rename("node-id")
|
||||
|
||||
# Save rename
|
||||
tree._save_rename("node-id", "New Label")
|
||||
|
||||
# Cancel rename
|
||||
tree._cancel_rename()
|
||||
```
|
||||
|
||||
### Deleting Nodes
|
||||
|
||||
Users can delete nodes via the delete button:
|
||||
|
||||
**Restrictions:**
|
||||
- Can only delete leaf nodes (no children)
|
||||
- Attempting to delete a node with children raises an error
|
||||
- Deleted node is removed from parent's children list
|
||||
|
||||
```python
|
||||
# Programmatically delete node
|
||||
tree._delete_node("node-id") # Raises ValueError if node has children
|
||||
```
|
||||
|
||||
## Content System
|
||||
|
||||
### Node Types and Icons
|
||||
|
||||
Assign types to nodes for semantic grouping and custom icon display:
|
||||
|
||||
```python
|
||||
# Define node types
|
||||
root = TreeNode(label="Project", type="project")
|
||||
folder = TreeNode(label="src", type="folder")
|
||||
file = TreeNode(label="main.py", type="python-file")
|
||||
|
||||
# Configure icons for types
|
||||
tree.set_icon_config({
|
||||
"project": "fluent.folder_open",
|
||||
"folder": "fluent.folder",
|
||||
"python-file": "fluent.document_python"
|
||||
})
|
||||
```
|
||||
|
||||
**Note:** Icon configuration is stored in state and persists within the session.
|
||||
|
||||
### Hierarchical Organization
|
||||
|
||||
Nodes automatically maintain parent-child relationships:
|
||||
|
||||
```python
|
||||
# Get node's children
|
||||
node = tree._state.items["node-id"]
|
||||
child_ids = node.children
|
||||
|
||||
# Get node's parent
|
||||
parent_id = node.parent
|
||||
|
||||
# Navigate tree programmatically
|
||||
for child_id in node.children:
|
||||
child_node = tree._state.items[child_id]
|
||||
print(child_node.label)
|
||||
```
|
||||
|
||||
### Finding Root Nodes
|
||||
|
||||
Root nodes are nodes without a parent:
|
||||
|
||||
```python
|
||||
root_nodes = [
|
||||
node_id for node_id, node in tree._state.items.items()
|
||||
if node.parent is None
|
||||
]
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
TreeView includes keyboard support for common operations:
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `ESC` | Cancel rename operation |
|
||||
|
||||
Additional shortcuts can be added via the Keyboard component:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
|
||||
tree = TreeView(parent=root_instance)
|
||||
# ESC handler is automatically included for cancel rename
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
TreeView maintains persistent state within the session:
|
||||
|
||||
| State Property | Type | Description |
|
||||
|----------------|------|-------------|
|
||||
| `items` | `dict[str, TreeNode]` | All nodes indexed by ID |
|
||||
| `opened` | `list[str]` | IDs of expanded nodes |
|
||||
| `selected` | `str \| None` | Currently selected node ID |
|
||||
| `editing` | `str \| None` | Node being renamed (if any) |
|
||||
| `icon_config` | `dict[str, str]` | Type-to-icon mapping |
|
||||
|
||||
### Dynamic Updates
|
||||
|
||||
TreeView updates are handled via commands that return the updated tree:
|
||||
|
||||
```python
|
||||
# Commands automatically target the tree for HTMX swap
|
||||
cmd = tree.commands.toggle_node("node-id")
|
||||
# When executed, returns updated TreeView with new state
|
||||
```
|
||||
|
||||
### CSS Customization
|
||||
|
||||
TreeView uses CSS classes for styling:
|
||||
|
||||
| Class | Element |
|
||||
|-------|---------|
|
||||
| `mf-treeview` | Root container |
|
||||
| `mf-treenode-container` | Container for node and its children |
|
||||
| `mf-treenode` | Individual node row |
|
||||
| `mf-treenode.selected` | Selected node highlight |
|
||||
| `mf-treenode-label` | Node label text |
|
||||
| `mf-treenode-input` | Input field during rename |
|
||||
| `mf-treenode-actions` | Action buttons container (hover) |
|
||||
|
||||
You can override these classes to customize appearance:
|
||||
|
||||
```css
|
||||
.mf-treenode.selected {
|
||||
background-color: #e0f2fe;
|
||||
border-left: 3px solid #0284c7;
|
||||
}
|
||||
|
||||
.mf-treenode-actions {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.mf-treenode:hover .mf-treenode-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: File System Browser
|
||||
|
||||
A file/folder browser with different node types:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
|
||||
# Create tree
|
||||
tree = TreeView(parent=root_instance, _id="file-browser")
|
||||
|
||||
# Configure icons
|
||||
tree.set_icon_config({
|
||||
"folder": "fluent.folder",
|
||||
"python": "fluent.document_python",
|
||||
"text": "fluent.document_text"
|
||||
})
|
||||
|
||||
# Build file structure
|
||||
root = TreeNode(id="root", label="my-project", type="folder")
|
||||
tree.add_node(root)
|
||||
|
||||
src = TreeNode(id="src", label="src", type="folder")
|
||||
tree.add_node(src, parent_id="root")
|
||||
|
||||
main = TreeNode(label="main.py", type="python")
|
||||
utils = TreeNode(label="utils.py", type="python")
|
||||
tree.add_node(main, parent_id="src")
|
||||
tree.add_node(utils, parent_id="src")
|
||||
|
||||
readme = TreeNode(label="README.md", type="text")
|
||||
tree.add_node(readme, parent_id="root")
|
||||
|
||||
# Expand to show structure
|
||||
tree.expand_all()
|
||||
|
||||
return tree
|
||||
```
|
||||
|
||||
### Example 2: Category Management
|
||||
|
||||
Managing product categories with inline editing:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
|
||||
tree = TreeView(parent=root_instance, _id="categories")
|
||||
|
||||
# Root categories
|
||||
electronics = TreeNode(id="elec", label="Electronics", type="category")
|
||||
tree.add_node(electronics)
|
||||
|
||||
# Subcategories
|
||||
computers = TreeNode(label="Computers", type="subcategory")
|
||||
phones = TreeNode(label="Phones", type="subcategory")
|
||||
tree.add_node(computers, parent_id="elec")
|
||||
tree.add_node(phones, parent_id="elec")
|
||||
|
||||
# Products (leaf nodes)
|
||||
laptop = TreeNode(label="Laptops", type="product")
|
||||
desktop = TreeNode(label="Desktops", type="product")
|
||||
tree.add_node(laptop, parent_id=computers.id)
|
||||
tree.add_node(desktop, parent_id=computers.id)
|
||||
|
||||
tree.expand_all()
|
||||
return tree
|
||||
```
|
||||
|
||||
### Example 3: Document Outline Editor
|
||||
|
||||
Building a document outline with headings:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
|
||||
tree = TreeView(parent=root_instance, _id="outline")
|
||||
|
||||
# Document structure
|
||||
doc = TreeNode(id="doc", label="My Document", type="document")
|
||||
tree.add_node(doc)
|
||||
|
||||
# Chapters
|
||||
ch1 = TreeNode(id="ch1", label="Chapter 1: Introduction", type="heading1")
|
||||
ch2 = TreeNode(id="ch2", label="Chapter 2: Methods", type="heading1")
|
||||
tree.add_node(ch1, parent_id="doc")
|
||||
tree.add_node(ch2, parent_id="doc")
|
||||
|
||||
# Sections
|
||||
sec1_1 = TreeNode(label="1.1 Background", type="heading2")
|
||||
sec1_2 = TreeNode(label="1.2 Objectives", type="heading2")
|
||||
tree.add_node(sec1_1, parent_id="ch1")
|
||||
tree.add_node(sec1_2, parent_id="ch1")
|
||||
|
||||
# Subsections
|
||||
subsec = TreeNode(label="1.1.1 Historical Context", type="heading3")
|
||||
tree.add_node(subsec, parent_id=sec1_1.id)
|
||||
|
||||
tree.expand_all()
|
||||
return tree
|
||||
```
|
||||
|
||||
### Example 4: Dynamic Tree with Event Handling
|
||||
|
||||
Responding to tree events with custom logic:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
tree = TreeView(parent=root_instance, _id="dynamic-tree")
|
||||
|
||||
# Initial structure
|
||||
root = TreeNode(id="root", label="Tasks", type="folder")
|
||||
tree.add_node(root)
|
||||
|
||||
# Function to handle selection
|
||||
def on_node_selected(node_id):
|
||||
# Custom logic when node is selected
|
||||
node = tree._state.items[node_id]
|
||||
tree._select_node(node_id)
|
||||
|
||||
# Update a detail panel elsewhere in the UI
|
||||
return Div(
|
||||
H3(f"Selected: {node.label}"),
|
||||
P(f"Type: {node.type}"),
|
||||
P(f"Children: {len(node.children)}")
|
||||
)
|
||||
|
||||
# Override select command with custom handler
|
||||
# (In practice, you'd extend the Commands class or use event callbacks)
|
||||
|
||||
tree.expand_all()
|
||||
return tree
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the TreeView component itself.
|
||||
|
||||
### State
|
||||
|
||||
The TreeView component maintains the following state properties:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|------|------|-------------|---------|
|
||||
| `items` | `dict[str, TreeNode]` | All nodes indexed by ID | `{}` |
|
||||
| `opened` | `list[str]` | Expanded node IDs | `[]` |
|
||||
| `selected` | `str \| None` | Selected node ID | `None` |
|
||||
| `editing` | `str \| None` | Node being renamed | `None` |
|
||||
| `icon_config` | `dict[str, str]` | Type-to-icon mapping | `{}` |
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `toggle_node(node_id)` | Toggle expand/collapse state |
|
||||
| `add_child(parent_id)` | Add child node to parent |
|
||||
| `add_sibling(node_id)` | Add sibling node after target |
|
||||
| `start_rename(node_id)` | Enter rename mode for node |
|
||||
| `save_rename(node_id)` | Save renamed node label |
|
||||
| `cancel_rename()` | Cancel rename operation |
|
||||
| `delete_node(node_id)` | Delete node (if no children) |
|
||||
| `select_node(node_id)` | Select a node |
|
||||
|
||||
All commands automatically target the TreeView component for HTMX updates.
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `add_node(node, parent_id, insert_index)` | Add a node to the tree |
|
||||
| `expand_all()` | Expand all nodes with children |
|
||||
| `set_icon_config(config)` | Configure icons for node types |
|
||||
| `render()` | Render the complete TreeView |
|
||||
|
||||
### TreeNode Dataclass
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class TreeNode:
|
||||
id: str # Unique identifier (auto-generated UUID)
|
||||
label: str = "" # Display text
|
||||
type: str = "default" # Node type for icon mapping
|
||||
parent: Optional[str] = None # Parent node ID
|
||||
children: list[str] = [] # Child node IDs
|
||||
```
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
```
|
||||
Div(id="treeview", cls="mf-treeview")
|
||||
├── Div(cls="mf-treenode-container", data-node-id="root1")
|
||||
│ ├── Div(cls="mf-treenode")
|
||||
│ │ ├── Icon # Toggle chevron
|
||||
│ │ ├── Span(cls="mf-treenode-label") | Input(cls="mf-treenode-input")
|
||||
│ │ └── Div(cls="mf-treenode-actions")
|
||||
│ │ ├── Icon # Add child
|
||||
│ │ ├── Icon # Rename
|
||||
│ │ └── Icon # Delete
|
||||
│ └── Div(cls="mf-treenode-container") # Child nodes (if expanded)
|
||||
│ └── ...
|
||||
├── Div(cls="mf-treenode-container", data-node-id="root2")
|
||||
│ └── ...
|
||||
└── Keyboard # ESC handler
|
||||
```
|
||||
|
||||
### Element IDs and Attributes
|
||||
|
||||
| Attribute | Element | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `id` | Root Div | TreeView component ID |
|
||||
| `data-node-id` | Node container | Node's unique ID |
|
||||
|
||||
### Internal Methods
|
||||
|
||||
These methods are used internally for rendering and state management:
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `_toggle_node(node_id)` | Toggle expand/collapse state |
|
||||
| `_add_child(parent_id, new_label)` | Add child node implementation |
|
||||
| `_add_sibling(node_id, new_label)` | Add sibling node implementation |
|
||||
| `_start_rename(node_id)` | Enter rename mode |
|
||||
| `_save_rename(node_id, node_label)` | Save renamed node |
|
||||
| `_cancel_rename()` | Cancel rename operation |
|
||||
| `_delete_node(node_id)` | Delete node if no children |
|
||||
| `_select_node(node_id)` | Select a node |
|
||||
| `_render_action_buttons(node_id)` | Render hover action buttons |
|
||||
| `_render_node(node_id, level)` | Recursively render node and children |
|
||||
|
||||
### Commands Class
|
||||
|
||||
The `Commands` nested class provides command factory methods:
|
||||
|
||||
| Method | Returns |
|
||||
|--------|---------|
|
||||
| `toggle_node(node_id)` | Command to toggle node |
|
||||
| `add_child(parent_id)` | Command to add child |
|
||||
| `add_sibling(node_id)` | Command to add sibling |
|
||||
| `start_rename(node_id)` | Command to start rename |
|
||||
| `save_rename(node_id)` | Command to save rename |
|
||||
| `cancel_rename()` | Command to cancel rename |
|
||||
| `delete_node(node_id)` | Command to delete node |
|
||||
| `select_node(node_id)` | Command to select node |
|
||||
|
||||
All commands are automatically configured with HTMX targeting.
|
||||
|
||||
### Integration with Keyboard Component
|
||||
|
||||
TreeView includes a Keyboard component for ESC key handling:
|
||||
|
||||
```python
|
||||
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="-keyboard")
|
||||
```
|
||||
|
||||
This enables users to press ESC to cancel rename operations without clicking.
|
||||
895
docs/testing_rendered_components.md
Normal file
895
docs/testing_rendered_components.md
Normal file
@@ -0,0 +1,895 @@
|
||||
# Testing Rendered Components with Matcher
|
||||
|
||||
## Introduction
|
||||
|
||||
When testing FastHTML components, you need to verify that the HTML they generate is correct. Traditional approaches like string comparison are fragile and hard to maintain. The matcher module provides two powerful functions that make component testing simple and reliable:
|
||||
|
||||
- **`matches(actual, expected)`** - Validates that a rendered element matches your expectations
|
||||
- **`find(ft, expected)`** - Searches for specific elements within an HTML tree
|
||||
|
||||
**Key principle**: Test only what matters. The matcher compares only the elements and attributes you explicitly define in your `expected` pattern, ignoring everything else.
|
||||
|
||||
### Why use matcher?
|
||||
|
||||
**Without matcher:**
|
||||
```python
|
||||
# Fragile - breaks if whitespace or attribute order changes
|
||||
assert str(component.render()) == '<div id="x" class="y"><p>Text</p></div>'
|
||||
```
|
||||
|
||||
**With matcher:**
|
||||
```python
|
||||
# Robust - tests only what matters
|
||||
from myfasthtml.test.matcher import matches
|
||||
from fasthtml.common import Div, P
|
||||
|
||||
actual = component.render()
|
||||
expected = Div(P("Text"))
|
||||
matches(actual, expected) # Passes - ignores id and class
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Function Reference
|
||||
|
||||
### matches() - Validate Elements
|
||||
|
||||
#### Purpose
|
||||
|
||||
`matches()` validates that a rendered element structure corresponds exactly to an expected pattern. It's the primary tool for testing component rendering.
|
||||
|
||||
**When to use it:**
|
||||
- Verifying component output in tests
|
||||
- Checking HTML structure
|
||||
- Validating attributes and content
|
||||
|
||||
#### Basic Syntax
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import matches
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
- **`actual`**: The element to test (usually from `component.render()`)
|
||||
- **`expected`**: The pattern to match against (only include what you want to test)
|
||||
- **Returns**: `True` if matches, raises `AssertionError` if not
|
||||
|
||||
#### Simple Examples
|
||||
|
||||
**Example 1: Basic structure matching**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import matches
|
||||
from fasthtml.common import Div, P
|
||||
|
||||
# The actual rendered element
|
||||
actual = Div(P("Hello World"), id="container", cls="main")
|
||||
|
||||
# Expected pattern - tests only the structure
|
||||
expected = Div(P("Hello World"))
|
||||
|
||||
matches(actual, expected) # ✅ Passes - id and cls are ignored
|
||||
```
|
||||
|
||||
**Example 2: Testing specific attributes**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Button
|
||||
|
||||
actual = Button("Click me",
|
||||
id="btn-1",
|
||||
cls="btn btn-primary",
|
||||
hx_post="/submit",
|
||||
hx_target="#result")
|
||||
|
||||
# Test only the HTMX attribute we care about
|
||||
expected = Button("Click me", hx_post="/submit")
|
||||
|
||||
matches(actual, expected) # ✅ Passes
|
||||
```
|
||||
|
||||
**Example 3: Nested structure**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, H1, Form, Input, Button
|
||||
|
||||
actual = Div(
|
||||
H1("Registration Form"),
|
||||
Form(
|
||||
Input(name="email", type="email"),
|
||||
Input(name="password", type="password"),
|
||||
Button("Submit", type="submit")
|
||||
),
|
||||
id="page",
|
||||
cls="container"
|
||||
)
|
||||
|
||||
# Test only the important parts
|
||||
expected = Div(
|
||||
H1("Registration Form"),
|
||||
Form(
|
||||
Input(name="email"),
|
||||
Button("Submit")
|
||||
)
|
||||
)
|
||||
|
||||
matches(actual, expected) # ✅ Passes - ignores password field and attributes
|
||||
```
|
||||
|
||||
#### Predicates Reference
|
||||
|
||||
Predicates allow flexible validation when you don't know the exact value but want to validate a pattern.
|
||||
|
||||
##### AttrPredicate - For attribute values
|
||||
|
||||
**Contains(value)** - Attribute contains the value
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import Contains
|
||||
from fasthtml.common import Div
|
||||
|
||||
actual = Div(cls="container main-content active")
|
||||
expected = Div(cls=Contains("main-content"))
|
||||
|
||||
matches(actual, expected) # ✅ Passes
|
||||
```
|
||||
|
||||
**StartsWith(value)** - Attribute starts with the value
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import StartsWith
|
||||
from fasthtml.common import Input
|
||||
|
||||
actual = Input(id="input-username-12345")
|
||||
expected = Input(id=StartsWith("input-username"))
|
||||
|
||||
matches(actual, expected) # ✅ Passes
|
||||
```
|
||||
|
||||
**DoesNotContain(value)** - Attribute does not contain the value
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import DoesNotContain
|
||||
from fasthtml.common import Div
|
||||
|
||||
actual = Div(cls="container active")
|
||||
expected = Div(cls=DoesNotContain("disabled"))
|
||||
|
||||
matches(actual, expected) # ✅ Passes
|
||||
```
|
||||
|
||||
**AnyValue()** - Attribute exists with any non-None value
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import AnyValue
|
||||
from fasthtml.common import Button
|
||||
|
||||
actual = Button("Click", data_action="submit-form", data_id="123")
|
||||
expected = Button("Click", data_action=AnyValue())
|
||||
|
||||
matches(actual, expected) # ✅ Passes - just checks data_action exists
|
||||
```
|
||||
|
||||
##### ChildrenPredicate - For element children
|
||||
|
||||
**Empty()** - Element has no children and no attributes
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import Empty
|
||||
from fasthtml.common import Div
|
||||
|
||||
actual = Div()
|
||||
expected = Div(Empty())
|
||||
|
||||
matches(actual, expected) # ✅ Passes
|
||||
```
|
||||
|
||||
**NoChildren()** - Element has no children (but can have attributes)
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import NoChildren
|
||||
from fasthtml.common import Div
|
||||
|
||||
actual = Div(id="container", cls="empty")
|
||||
expected = Div(NoChildren())
|
||||
|
||||
matches(actual, expected) # ✅ Passes - has attributes but no children
|
||||
```
|
||||
|
||||
**AttributeForbidden(attr_name)** - Attribute must not be present
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import AttributeForbidden
|
||||
from fasthtml.common import Button
|
||||
|
||||
actual = Button("Click me")
|
||||
expected = Button("Click me", AttributeForbidden("disabled"))
|
||||
|
||||
matches(actual, expected) # ✅ Passes - disabled attribute is not present
|
||||
```
|
||||
|
||||
#### Error Messages Explained
|
||||
|
||||
When a test fails, `matches()` provides a visual diff showing exactly where the problem is:
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, Button
|
||||
|
||||
actual = Div(Button("Submit", cls="btn-primary"), id="form")
|
||||
expected = Div(Button("Cancel", cls="btn-secondary"))
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Error output:**
|
||||
```
|
||||
Path : 'div.button'
|
||||
Error : The values are different
|
||||
|
||||
(div "id"="form" | (div
|
||||
(button "cls"="btn-prim | (button "cls"="btn-seco
|
||||
^^^^^^^^^^^^^^^^ |
|
||||
"Submit") | "Cancel")
|
||||
^^^^^^^ |
|
||||
) | )
|
||||
```
|
||||
|
||||
**Reading the error:**
|
||||
- **Left side**: Actual element
|
||||
- **Right side**: Expected pattern
|
||||
- **`^^^` markers**: Highlight differences 'only on the left side', the right side (the expected pattern) is always correct
|
||||
- **Path**: Shows location in the tree (`div.button` = button inside div)
|
||||
|
||||
---
|
||||
|
||||
### find() - Search Elements
|
||||
|
||||
#### Purpose
|
||||
|
||||
`find()` searches for all elements matching a pattern within an HTML tree. It's useful when you need to verify the presence of specific elements without knowing their exact position. Or when you want to validate (using matches) a subset of elements.
|
||||
|
||||
**When to use it:**
|
||||
- Finding elements by attributes
|
||||
- Verifying element count
|
||||
- Extracting elements for further validation
|
||||
- Testing without strict hierarchy requirements
|
||||
|
||||
#### Basic Syntax
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find
|
||||
|
||||
results = find(ft, expected)
|
||||
```
|
||||
|
||||
- **`ft`**: Element or list of elements to search in
|
||||
- **`expected`**: Pattern to match, follows the same syntax and rules as `matches()`
|
||||
- **Returns**: List of all matching elements
|
||||
- **Raises**: `AssertionError` if no matches found
|
||||
|
||||
#### Simple Examples
|
||||
|
||||
**Example 1: Find all elements of a type**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find
|
||||
from fasthtml.common import Div, P
|
||||
|
||||
page = Div(
|
||||
Div(P("First paragraph")),
|
||||
Div(P("Second paragraph")),
|
||||
P("Third paragraph")
|
||||
)
|
||||
|
||||
# Find all paragraphs
|
||||
paragraphs = find(page, P())
|
||||
|
||||
assert len(paragraphs) == 3
|
||||
```
|
||||
|
||||
**Example 2: Find by attribute**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, Button
|
||||
from myfasthtml.test.matcher import find
|
||||
|
||||
page = Div(
|
||||
Button("Cancel", cls="btn-secondary"),
|
||||
Div(Button("Submit", cls="btn-primary", id="submit"))
|
||||
)
|
||||
|
||||
# Find the primary button
|
||||
primary_buttons = find(page, Button(cls="btn-primary"))
|
||||
|
||||
assert len(primary_buttons) == 1
|
||||
assert primary_buttons[0].attrs["id"] == "submit"
|
||||
```
|
||||
|
||||
**Example 3: Find nested structure**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, Form, Input
|
||||
|
||||
page = Div(
|
||||
Div(Input(name="search")),
|
||||
Form(
|
||||
Input(name="email", type="email"),
|
||||
Input(name="password", type="password")
|
||||
)
|
||||
)
|
||||
|
||||
# Find all email inputs
|
||||
email_inputs = find(page, Input(type="email"))
|
||||
|
||||
assert len(email_inputs) == 1
|
||||
assert email_inputs[0].attrs["name"] == "email"
|
||||
```
|
||||
|
||||
**Example 4: Search in a list**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, P, Span
|
||||
|
||||
elements = [
|
||||
Div(P("First")),
|
||||
Div(P("Second")),
|
||||
Span(P("Third"))
|
||||
]
|
||||
|
||||
# Find all paragraphs across all elements
|
||||
all_paragraphs = find(elements, P())
|
||||
|
||||
assert len(all_paragraphs) == 3
|
||||
```
|
||||
|
||||
#### Common Patterns
|
||||
|
||||
**Verify element count:**
|
||||
|
||||
```python
|
||||
buttons = find(page, Button())
|
||||
assert len(buttons) == 3, f"Expected 3 buttons, found {len(buttons)}"
|
||||
```
|
||||
|
||||
**Check element exists:**
|
||||
|
||||
```python
|
||||
submit_buttons = find(page, Button(type="submit"))
|
||||
assert len(submit_buttons) > 0, "No submit button found"
|
||||
```
|
||||
|
||||
**Extract for further testing:**
|
||||
|
||||
```python
|
||||
form = find(page, Form())[0] # Get first form
|
||||
inputs = find(form, Input()) # Find inputs within form
|
||||
assert all(inp.attrs.get("type") in ["text", "email"] for inp in inputs)
|
||||
```
|
||||
|
||||
**Handle missing elements:**
|
||||
|
||||
```python
|
||||
try:
|
||||
admin_section = find(page, Div(id="admin"))
|
||||
print("Admin section found")
|
||||
except AssertionError:
|
||||
print("Admin section not present")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Testing Rendered Components Guide
|
||||
|
||||
This section provides practical patterns for testing different aspects of rendered components.
|
||||
|
||||
### 1. Testing Element Structure
|
||||
|
||||
**Goal**: Verify the hierarchy and organization of elements.
|
||||
|
||||
**Pattern**: Use `matches()` with only the structural elements you care about.
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import matches
|
||||
from fasthtml.common import Div, Header, Nav, Main, Footer
|
||||
|
||||
# Your component renders a page layout
|
||||
actual = page_component.render()
|
||||
|
||||
# Test only the main structure
|
||||
expected = Div(
|
||||
Header(Nav()),
|
||||
Main(),
|
||||
Footer()
|
||||
)
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Tip**: Don't include every single child element. Focus on the important structural elements.
|
||||
|
||||
### 2. Testing Attributes
|
||||
|
||||
**Goal**: Verify that specific attributes are set correctly.
|
||||
|
||||
**Pattern**: Include only the attributes you want to test.
|
||||
|
||||
```python
|
||||
from fasthtml.common import Button
|
||||
|
||||
# Component renders a button with HTMX
|
||||
actual = button_component.render()
|
||||
|
||||
# Test only HTMX attributes
|
||||
expected = Button(
|
||||
hx_post="/api/submit",
|
||||
hx_target="#result",
|
||||
hx_swap="innerHTML"
|
||||
)
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**With predicates for dynamic values:**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import StartsWith
|
||||
|
||||
# Test that id follows a pattern
|
||||
expected = Button(id=StartsWith("btn-"))
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
### 3. Testing Content
|
||||
|
||||
**Goal**: Verify text content in elements.
|
||||
|
||||
**Pattern**: Match elements with their text content.
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, H1, P
|
||||
|
||||
actual = article_component.render()
|
||||
|
||||
expected = Div(
|
||||
H1("Article Title"),
|
||||
P("First paragraph content")
|
||||
)
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Partial content matching:**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import Contains
|
||||
|
||||
# Check that paragraph contains key phrase
|
||||
expected = P(Contains("important information"))
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
### 4. Testing with Predicates
|
||||
|
||||
**Goal**: Validate patterns rather than exact values.
|
||||
|
||||
**Pattern**: Use predicates for flexibility.
|
||||
|
||||
**Example: Testing generated IDs**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import StartsWith, AnyValue
|
||||
from fasthtml.common import Div
|
||||
|
||||
# Component generates unique IDs
|
||||
actual = widget_component.render()
|
||||
|
||||
expected = Div(
|
||||
id=StartsWith("widget-"),
|
||||
data_timestamp=AnyValue() # Just check it exists
|
||||
)
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Example: Testing CSS classes**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import Contains
|
||||
from fasthtml.common import Button
|
||||
|
||||
actual = dynamic_button.render()
|
||||
|
||||
# Check button has 'active' class among others
|
||||
expected = Button(cls=Contains("active"))
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Example: Forbidden attributes**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import AttributeForbidden
|
||||
from fasthtml.common import Input
|
||||
|
||||
# Verify input is NOT disabled
|
||||
actual = input_component.render()
|
||||
|
||||
expected = Input(
|
||||
name="username",
|
||||
AttributeForbidden("disabled")
|
||||
)
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
### 5. Combining matches() and find()
|
||||
|
||||
**Goal**: First find elements, then validate them in detail.
|
||||
|
||||
**Pattern**: Use `find()` to locate, then `matches()` to validate.
|
||||
|
||||
**Example: Testing a form**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find, matches
|
||||
from fasthtml.common import Form, Input, Button
|
||||
|
||||
# Render a complex page
|
||||
actual = page_component.render()
|
||||
|
||||
# Step 1: Find the registration form
|
||||
forms = find(actual, Form(id="registration"))
|
||||
assert len(forms) == 1
|
||||
|
||||
# Step 2: Validate the form structure
|
||||
registration_form = forms[0]
|
||||
expected_form = Form(
|
||||
Input(name="email", type="email"),
|
||||
Input(name="password", type="password"),
|
||||
Button("Register", type="submit")
|
||||
)
|
||||
|
||||
matches(registration_form, expected_form)
|
||||
```
|
||||
|
||||
**Example: Testing multiple similar elements**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, Card
|
||||
|
||||
actual = dashboard.render()
|
||||
|
||||
# Find all cards
|
||||
cards = find(actual, Card())
|
||||
|
||||
# Verify we have the right number
|
||||
assert len(cards) == 3
|
||||
|
||||
# Validate each card has required structure
|
||||
for card in cards:
|
||||
expected_card = Card(
|
||||
Div(cls="card-header"),
|
||||
Div(cls="card-body")
|
||||
)
|
||||
matches(card, expected_card)
|
||||
```
|
||||
|
||||
### 6. Testing Edge Cases
|
||||
|
||||
**Testing empty elements:**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import Empty, NoChildren
|
||||
from fasthtml.common import Div
|
||||
|
||||
# Test completely empty element
|
||||
actual = Div()
|
||||
expected = Div(Empty())
|
||||
matches(actual, expected)
|
||||
|
||||
# Test element with attributes but no children
|
||||
actual = Div(id="container", cls="empty-state")
|
||||
expected = Div(NoChildren())
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Testing absence of elements:**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find
|
||||
from fasthtml.common import Div
|
||||
|
||||
actual = basic_view.render()
|
||||
|
||||
# Verify admin section is not present
|
||||
try:
|
||||
find(actual, Div(id="admin-section"))
|
||||
assert False, "Admin section should not be present"
|
||||
except AssertionError as e:
|
||||
if "No element found" in str(e):
|
||||
pass # Expected - admin section absent
|
||||
else:
|
||||
raise
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3: How It Works (Technical Overview)
|
||||
|
||||
Understanding how the matcher works helps you write better tests and debug failures more effectively.
|
||||
|
||||
### The Matching Algorithm
|
||||
|
||||
When you call `matches(actual, expected)`, here's what happens:
|
||||
|
||||
1. **Type Comparison**: Checks if elements have the same type/tag
|
||||
- For FastHTML elements: compares `.tag` (e.g., "div", "button")
|
||||
- For Python objects: compares class types
|
||||
|
||||
2. **Attribute Validation**: For each attribute in `expected`:
|
||||
- Checks if attribute exists in `actual`
|
||||
- If it's a `Predicate`: calls `.validate(actual_value)`
|
||||
- Otherwise: checks for exact equality
|
||||
- **Key point**: Only attributes in `expected` are checked
|
||||
|
||||
3. **Children Validation**:
|
||||
- Applies `ChildrenPredicate` validators if present
|
||||
- Recursively matches children in order
|
||||
- **Key point**: Only checks as many children as defined in `expected`
|
||||
|
||||
4. **Path Tracking**: Maintains a path through the tree for error reporting
|
||||
|
||||
### Understanding the Path
|
||||
|
||||
The matcher builds a path as it traverses your element tree:
|
||||
|
||||
```
|
||||
div#container.form.input[name=email]
|
||||
```
|
||||
|
||||
This path means:
|
||||
- Started at a `div` with `id="container"`
|
||||
- Went to a `form` element
|
||||
- Then to an `input` with `name="email"`
|
||||
|
||||
The path appears in error messages to help you locate problems:
|
||||
|
||||
```
|
||||
Path : 'div#form.input[name=email]'
|
||||
Error : 'type' is not found in Actual
|
||||
```
|
||||
|
||||
### How Predicates Work
|
||||
|
||||
Predicates are objects that implement a `validate()` method:
|
||||
|
||||
```python
|
||||
class Contains(AttrPredicate):
|
||||
def validate(self, actual):
|
||||
return self.value in actual
|
||||
```
|
||||
|
||||
When matching encounters a predicate:
|
||||
1. Gets the actual attribute value
|
||||
2. Calls `predicate.validate(actual_value)`
|
||||
3. If returns `False`, reports validation error
|
||||
|
||||
This allows flexible matching without hardcoding exact values.
|
||||
|
||||
### Error Output Generation
|
||||
|
||||
When a test fails, the matcher generates a side-by-side comparison:
|
||||
|
||||
**Process:**
|
||||
1. Renders both `actual` and `expected` as tree structures
|
||||
2. Compares them element by element
|
||||
3. Marks differences with `^^^` characters
|
||||
4. Aligns output for easy visual comparison
|
||||
|
||||
**Example:**
|
||||
```
|
||||
(div "id"="old" | (div "id"="new"
|
||||
^^^^ |
|
||||
```
|
||||
|
||||
The `^^^` appears under attributes or content that don't match.
|
||||
|
||||
### The find() Algorithm
|
||||
|
||||
`find()` uses depth-first search:
|
||||
|
||||
1. **Check current element**: Does it match the pattern?
|
||||
- If yes: add to results
|
||||
|
||||
2. **Search children**: Recursively search all children
|
||||
|
||||
3. **Return all matches**: Collects matches from entire tree
|
||||
|
||||
**Key difference from matches()**: `find()` looks for any occurrence anywhere in the tree, while `matches()` validates exact structure.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do's ✅
|
||||
|
||||
**Test only what matters**
|
||||
```python
|
||||
# ✅ Good - tests only the submit action
|
||||
expected = Button("Submit", hx_post="/api/save")
|
||||
|
||||
# ❌ Bad - tests irrelevant details
|
||||
expected = Button("Submit", hx_post="/api/save", id="btn-123", cls="btn btn-primary")
|
||||
```
|
||||
|
||||
**Use predicates for dynamic values**
|
||||
```python
|
||||
# ✅ Good - flexible for generated IDs
|
||||
expected = Div(id=StartsWith("generated-"))
|
||||
|
||||
# ❌ Bad - brittle, will break on regeneration
|
||||
expected = Div(id="generated-12345")
|
||||
```
|
||||
|
||||
**Structure tests in layers**
|
||||
```python
|
||||
# ✅ Good - separate concerns
|
||||
# Test 1: Overall structure
|
||||
matches(page, Div(Header(), Main(), Footer()))
|
||||
|
||||
# Test 2: Header details
|
||||
header = find(page, Header())[0]
|
||||
matches(header, Header(Nav(), Div(cls="user-menu")))
|
||||
|
||||
# ❌ Bad - everything in one giant test
|
||||
matches(page, Div(
|
||||
Header(Nav(...), Div(...)),
|
||||
Main(...),
|
||||
Footer(...)
|
||||
))
|
||||
```
|
||||
|
||||
**Verify element counts with find()**
|
||||
```python
|
||||
# ✅ Good - explicit count check
|
||||
buttons = find(page, Button())
|
||||
assert len(buttons) == 3, f"Expected 3 buttons, found {len(buttons)}"
|
||||
|
||||
# ❌ Bad - implicit assumption
|
||||
button = find(page, Button())[0] # Fails if 0 or multiple buttons
|
||||
```
|
||||
|
||||
### Don'ts ❌
|
||||
|
||||
**Don't test implementation details**
|
||||
```python
|
||||
# ❌ Bad - internal div structure might change
|
||||
expected = Div(
|
||||
Div(
|
||||
Div(Button("Click"))
|
||||
)
|
||||
)
|
||||
|
||||
# ✅ Good - test the important element
|
||||
expected = Div(Button("Click"))
|
||||
```
|
||||
|
||||
**Don't use exact string matching**
|
||||
```python
|
||||
# ❌ Bad - fragile
|
||||
assert str(component.render()) == '<div><p>Text</p></div>'
|
||||
|
||||
# ✅ Good - structural matching
|
||||
matches(component.render(), Div(P("Text")))
|
||||
```
|
||||
|
||||
**Don't over-specify**
|
||||
```python
|
||||
# ❌ Bad - tests too much
|
||||
expected = Form(
|
||||
Input(name="email", type="email", id="email-input", cls="form-control"),
|
||||
Input(name="password", type="password", id="pwd-input", cls="form-control"),
|
||||
Button("Submit", type="submit", id="submit-btn", cls="btn btn-primary")
|
||||
)
|
||||
|
||||
# ✅ Good - tests what matters
|
||||
expected = Form(
|
||||
Input(name="email", type="email"),
|
||||
Input(name="password", type="password"),
|
||||
Button("Submit", type="submit")
|
||||
)
|
||||
```
|
||||
|
||||
### Performance Tips
|
||||
|
||||
**Reuse patterns**
|
||||
```python
|
||||
# Define reusable patterns
|
||||
STANDARD_BUTTON = Button(cls=Contains("btn"))
|
||||
|
||||
# Use in multiple tests
|
||||
matches(actual, Div(STANDARD_BUTTON))
|
||||
```
|
||||
|
||||
**Use find() efficiently**
|
||||
```python
|
||||
# ✅ Good - specific pattern
|
||||
buttons = find(page, Button(cls="primary"))
|
||||
|
||||
# ❌ Inefficient - too broad then filter
|
||||
all_buttons = find(page, Button())
|
||||
primary = [b for b in all_buttons if "primary" in b.attrs.get("cls", "")]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Import Statements
|
||||
|
||||
```python
|
||||
# Core functions
|
||||
from myfasthtml.test.matcher import matches, find
|
||||
|
||||
# AttrPredicates
|
||||
from myfasthtml.test.matcher import Contains, StartsWith, DoesNotContain, AnyValue
|
||||
|
||||
# ChildrenPredicates
|
||||
from myfasthtml.test.matcher import Empty, NoChildren, AttributeForbidden
|
||||
|
||||
# For custom test objects
|
||||
from myfasthtml.test.matcher import TestObject
|
||||
```
|
||||
|
||||
### Common Patterns Cheatsheet
|
||||
|
||||
```python
|
||||
# Basic validation
|
||||
matches(actual, expected)
|
||||
|
||||
# Find and validate
|
||||
elements = find(tree, pattern)
|
||||
assert len(elements) == 1
|
||||
matches(elements[0], detailed_pattern)
|
||||
|
||||
# Flexible attribute matching
|
||||
expected = Div(
|
||||
id=StartsWith("prefix-"),
|
||||
cls=Contains("active"),
|
||||
data_value=AnyValue()
|
||||
)
|
||||
|
||||
# Empty element validation
|
||||
expected = Div(Empty()) # No children, no attributes
|
||||
expected = Div(NoChildren()) # No children, attributes OK
|
||||
|
||||
# Forbidden attribute
|
||||
expected = Button(AttributeForbidden("disabled"))
|
||||
|
||||
# Multiple children
|
||||
expected = Div(
|
||||
Header(),
|
||||
Main(),
|
||||
Footer()
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The matcher module provides powerful tools for testing FastHTML components:
|
||||
|
||||
- **`matches()`** validates structure with precise, readable error messages
|
||||
- **`find()`** locates elements anywhere in your HTML tree
|
||||
- **Predicates** enable flexible, maintainable tests
|
||||
|
||||
By focusing on what matters and using the right patterns, you can write component tests that are both robust and easy to maintain.
|
||||
|
||||
**Next steps:**
|
||||
- Practice with simple components first
|
||||
- Gradually introduce predicates as needed
|
||||
- Review error messages carefully - they guide you to the problem
|
||||
- Refactor tests to remove duplication
|
||||
|
||||
Happy testing! 🧪
|
||||
Reference in New Issue
Block a user