Compare commits
2 Commits
1347f12618
...
9f69a6bc5b
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f69a6bc5b | |||
| 81a80a47b6 |
832
docs/Panel.md
Normal file
832
docs/Panel.md
Normal file
@@ -0,0 +1,832 @@
|
|||||||
|
# Panel Component
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The Panel component provides a flexible three-zone layout with optional collapsible side panels. It's designed to
|
||||||
|
organize content into left panel, main area, and right panel sections, with smooth toggle animations and resizable
|
||||||
|
panels.
|
||||||
|
|
||||||
|
**Key features:**
|
||||||
|
|
||||||
|
- Three customizable zones (left panel, main content, right panel)
|
||||||
|
- Toggle visibility with hide/show icons
|
||||||
|
- Resizable panels with drag handles
|
||||||
|
- Smooth CSS animations for show/hide transitions
|
||||||
|
- Automatic state persistence per session
|
||||||
|
- Configurable panel presence (enable/disable left or right)
|
||||||
|
- Session-based width and visibility state
|
||||||
|
|
||||||
|
**Common use cases:**
|
||||||
|
|
||||||
|
- Code editor with file explorer and properties panel
|
||||||
|
- Data visualization with filters sidebar and details panel
|
||||||
|
- Admin interface with navigation menu and tools panel
|
||||||
|
- Documentation viewer with table of contents and metadata
|
||||||
|
- Dashboard with configuration panel and information sidebar
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Here's a minimal example showing a three-panel layout for a code editor:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import *
|
||||||
|
from myfasthtml.controls.Panel import Panel
|
||||||
|
|
||||||
|
# Create the panel instance
|
||||||
|
panel = Panel(parent=root_instance)
|
||||||
|
|
||||||
|
# Set content for each zone
|
||||||
|
panel.set_left(
|
||||||
|
Div(
|
||||||
|
H3("Files"),
|
||||||
|
Ul(
|
||||||
|
Li("app.py"),
|
||||||
|
Li("config.py"),
|
||||||
|
Li("utils.py")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
panel.set_main(
|
||||||
|
Div(
|
||||||
|
H2("Editor"),
|
||||||
|
Textarea("# Write your code here", rows=20, cls="w-full font-mono")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
panel.set_right(
|
||||||
|
Div(
|
||||||
|
H3("Properties"),
|
||||||
|
Div("Language: Python"),
|
||||||
|
Div("Lines: 120"),
|
||||||
|
Div("Size: 3.2 KB")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Render the panel
|
||||||
|
return panel
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a complete panel layout with:
|
||||||
|
|
||||||
|
- A left panel displaying a file list with a hide icon (−) at the top right
|
||||||
|
- A main content area with a code editor
|
||||||
|
- A right panel showing file properties with a hide icon (−) at the top right
|
||||||
|
- Show icons (⋯) that appear in the main area when panels are hidden
|
||||||
|
- Drag handles between panels for manual resizing
|
||||||
|
- Automatic state persistence (visibility and width)
|
||||||
|
|
||||||
|
**Note:** Users can hide panels by clicking the hide icon (−) inside each panel. When hidden, a show icon (⋯) appears in
|
||||||
|
the main area (left side for left panel, right side for right panel). Panels can be resized by dragging the handles, and
|
||||||
|
all state is automatically saved in the session.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Visual Structure
|
||||||
|
|
||||||
|
The Panel component consists of three zones with optional side panels:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ │ ┌──────────────────────┐ │ ┌──────────┐ │
|
||||||
|
│ │ │ │ │ │ │ │ │ │
|
||||||
|
│ │ Left │ ║ │ │ ║ │ Right │ │
|
||||||
|
│ │ Panel │ │ │ Main Content │ │ │ Panel │ │
|
||||||
|
│ │ │ │ │ │ │ │ │ │
|
||||||
|
│ │ [−] │ │ │ [⋯] [⋯] │ │ │ [−] │ │
|
||||||
|
│ └──────────┘ │ └──────────────────────┘ │ └──────────┘ │
|
||||||
|
│ ║ ║ │
|
||||||
|
│ Resizer Resizer │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Component details:**
|
||||||
|
|
||||||
|
| Element | Description |
|
||||||
|
|---------------|-----------------------------------------------|
|
||||||
|
| Left panel | Optional collapsible panel (default: visible) |
|
||||||
|
| Main content | Always-visible central content area |
|
||||||
|
| Right panel | Optional collapsible panel (default: visible) |
|
||||||
|
| Hide icon (−) | Inside each panel, top right corner |
|
||||||
|
| Show icon (⋯) | In main area when panel is hidden |
|
||||||
|
| Resizer (║) | Drag handle to resize panels manually |
|
||||||
|
|
||||||
|
### Creating a Panel
|
||||||
|
|
||||||
|
The Panel is a `MultipleInstance`, meaning you can create multiple independent panels in your application. Create it by
|
||||||
|
providing a parent instance:
|
||||||
|
|
||||||
|
```python
|
||||||
|
panel = Panel(parent=root_instance)
|
||||||
|
|
||||||
|
# Or with a custom ID
|
||||||
|
panel = Panel(parent=root_instance, _id="my-panel")
|
||||||
|
|
||||||
|
# Or with custom configuration
|
||||||
|
from myfasthtml.controls.Panel import PanelConf
|
||||||
|
|
||||||
|
conf = PanelConf(left=True, right=False) # Only left panel enabled
|
||||||
|
panel = Panel(parent=root_instance, conf=conf)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content Zones
|
||||||
|
|
||||||
|
The Panel provides three content zones:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Left Panel │ Main Content │ Right Panel │
|
||||||
|
│ (optional) │ (required) │ (optional) │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zone details:**
|
||||||
|
|
||||||
|
| Zone | Typical Use | Required |
|
||||||
|
|---------|-------------------------------------------------------|----------|
|
||||||
|
| `left` | Navigation, file explorer, filters, table of contents | No |
|
||||||
|
| `main` | Primary content, editor, visualization, results | Yes |
|
||||||
|
| `right` | Properties, tools, metadata, debug info, settings | No |
|
||||||
|
|
||||||
|
### Setting Content
|
||||||
|
|
||||||
|
Use the `set_*()` methods to add content to each zone:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Main content (always visible)
|
||||||
|
panel.set_main(
|
||||||
|
Div(
|
||||||
|
H1("Dashboard"),
|
||||||
|
P("This is the main content area")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Left panel (optional)
|
||||||
|
panel.set_left(
|
||||||
|
Div(
|
||||||
|
H3("Navigation"),
|
||||||
|
Ul(
|
||||||
|
Li("Home"),
|
||||||
|
Li("Settings"),
|
||||||
|
Li("About")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Right panel (optional)
|
||||||
|
panel.set_right(
|
||||||
|
Div(
|
||||||
|
H3("Tools"),
|
||||||
|
Button("Export"),
|
||||||
|
Button("Refresh")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Method chaining:**
|
||||||
|
|
||||||
|
The `set_main()` method returns `self`, enabling method chaining:
|
||||||
|
|
||||||
|
```python
|
||||||
|
panel = Panel(parent=root_instance)
|
||||||
|
.set_main(Div("Main content"))
|
||||||
|
.set_left(Div("Left content"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Panel Configuration
|
||||||
|
|
||||||
|
By default, both left and right panels are enabled. You can customize this with `PanelConf`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.controls.Panel import PanelConf
|
||||||
|
|
||||||
|
# Only left panel enabled
|
||||||
|
conf = PanelConf(left=True, right=False)
|
||||||
|
panel = Panel(parent=root_instance, conf=conf)
|
||||||
|
|
||||||
|
# Only right panel enabled
|
||||||
|
conf = PanelConf(left=False, right=True)
|
||||||
|
panel = Panel(parent=root_instance, conf=conf)
|
||||||
|
|
||||||
|
# Both panels enabled (default)
|
||||||
|
conf = PanelConf(left=True, right=True)
|
||||||
|
panel = Panel(parent=root_instance, conf=conf)
|
||||||
|
|
||||||
|
# No side panels (main content only)
|
||||||
|
conf = PanelConf(left=False, right=False)
|
||||||
|
panel = Panel(parent=root_instance, conf=conf)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** When a panel is disabled in configuration, it won't render at all. When a panel is hidden (via toggle), it
|
||||||
|
renders but with zero width and overflow hidden.
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
|
||||||
|
### Toggling Panel Visibility
|
||||||
|
|
||||||
|
Each visible panel includes a hide icon (−) in its top-right corner. When hidden, a show icon (⋯) appears in the main
|
||||||
|
area:
|
||||||
|
|
||||||
|
**User interaction:**
|
||||||
|
|
||||||
|
- **Hide panel**: Click the − icon inside the panel
|
||||||
|
- **Show panel**: Click the ⋯ icon in the main area
|
||||||
|
|
||||||
|
**Icon positions:**
|
||||||
|
|
||||||
|
- Hide icons (−): Always at top-right of each panel
|
||||||
|
- Show icon for left panel (⋯): Top-left of main area
|
||||||
|
- Show icon for right panel (⋯): Top-right of main area
|
||||||
|
|
||||||
|
**Visual states:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Panel Visible:
|
||||||
|
┌──────────┐
|
||||||
|
│ Content │
|
||||||
|
│ [−] │ ← Hide icon visible
|
||||||
|
└──────────┘
|
||||||
|
|
||||||
|
Panel Hidden:
|
||||||
|
┌──────────────────┐
|
||||||
|
│ [⋯] Main │ ← Show icon visible in main
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Animation:**
|
||||||
|
|
||||||
|
When toggling visibility:
|
||||||
|
|
||||||
|
- **Hiding**: Panel width animates to 0px over 0.3s
|
||||||
|
- **Showing**: Panel width animates to its saved width over 0.3s
|
||||||
|
- Content remains in DOM (state preserved)
|
||||||
|
- Smooth CSS transition with ease timing
|
||||||
|
|
||||||
|
**Note:** The animation only works when showing (panel appearing). When hiding, the transition currently doesn't apply
|
||||||
|
due to HTMX swap timing. This is a known limitation.
|
||||||
|
|
||||||
|
### Resizable Panels
|
||||||
|
|
||||||
|
Both left and right panels can be resized by users via drag handles:
|
||||||
|
|
||||||
|
- **Drag handle location**:
|
||||||
|
- Left panel: Right edge (vertical bar)
|
||||||
|
- Right panel: Left edge (vertical bar)
|
||||||
|
- **Width constraints**: 150px (minimum) to 500px (maximum)
|
||||||
|
- **Persistence**: Resized width is automatically saved in session state
|
||||||
|
- **No transition during resize**: CSS transitions are disabled during manual dragging for smooth performance
|
||||||
|
|
||||||
|
**How to resize:**
|
||||||
|
|
||||||
|
1. Hover over the panel edge (cursor changes to resize cursor)
|
||||||
|
2. Click and drag left/right
|
||||||
|
3. Release to set the new width
|
||||||
|
4. Width is saved automatically and persists in the session
|
||||||
|
|
||||||
|
**Initial widths:**
|
||||||
|
|
||||||
|
- Left panel: 250px
|
||||||
|
- Right panel: 250px
|
||||||
|
|
||||||
|
These defaults can be customized via state after creation if needed.
|
||||||
|
|
||||||
|
### State Persistence
|
||||||
|
|
||||||
|
The Panel automatically persists its state within the user's session:
|
||||||
|
|
||||||
|
| State Property | Description | Default |
|
||||||
|
|-----------------|--------------------------------|---------|
|
||||||
|
| `left_visible` | Whether left panel is visible | `True` |
|
||||||
|
| `right_visible` | Whether right panel is visible | `True` |
|
||||||
|
| `left_width` | Left panel width in pixels | `250` |
|
||||||
|
| `right_width` | Right panel width in pixels | `250` |
|
||||||
|
|
||||||
|
State changes (toggle visibility, resize width) are automatically saved and restored within the session.
|
||||||
|
|
||||||
|
**Accessing state:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Check current state
|
||||||
|
is_left_visible = panel._state.left_visible
|
||||||
|
left_panel_width = panel._state.left_width
|
||||||
|
|
||||||
|
# Programmatically update state (not recommended - use commands instead)
|
||||||
|
panel._state.left_visible = False # Better to use toggle_side command
|
||||||
|
```
|
||||||
|
|
||||||
|
### Programmatic Control
|
||||||
|
|
||||||
|
You can control panels programmatically using commands:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Toggle panel visibility
|
||||||
|
toggle_left = panel.commands.toggle_side("left", visible=False) # Hide left
|
||||||
|
toggle_right = panel.commands.toggle_side("right", visible=True) # Show right
|
||||||
|
|
||||||
|
# Update panel width
|
||||||
|
update_left_width = panel.commands.update_side_width("left")
|
||||||
|
update_right_width = panel.commands.update_side_width("right")
|
||||||
|
```
|
||||||
|
|
||||||
|
These commands are typically used with buttons or other interactive elements:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
|
||||||
|
# Add buttons to toggle panels
|
||||||
|
hide_left_btn = mk.button("Hide Left", command=panel.commands.toggle_side("left", False))
|
||||||
|
show_left_btn = mk.button("Show Left", command=panel.commands.toggle_side("left", True))
|
||||||
|
|
||||||
|
# Add to your layout
|
||||||
|
panel.set_main(
|
||||||
|
Div(
|
||||||
|
hide_left_btn,
|
||||||
|
show_left_btn,
|
||||||
|
H1("Main Content")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Command details:**
|
||||||
|
|
||||||
|
- `toggle_side(side, visible)`: Sets panel visibility explicitly
|
||||||
|
- `side`: `"left"` or `"right"`
|
||||||
|
- `visible`: `True` (show) or `False` (hide)
|
||||||
|
- Returns: tuple of (panel_element, show_icon_element) for HTMX swap
|
||||||
|
|
||||||
|
- `update_side_width(side)`: Updates panel width from HTMX request
|
||||||
|
- `side`: `"left"` or `"right"`
|
||||||
|
- Width value comes from JavaScript resize handler
|
||||||
|
- Returns: updated panel element for HTMX swap
|
||||||
|
|
||||||
|
### CSS Customization
|
||||||
|
|
||||||
|
The Panel uses CSS classes that you can customize:
|
||||||
|
|
||||||
|
| Class | Element |
|
||||||
|
|----------------------------|------------------------------------------|
|
||||||
|
| `mf-panel` | Root panel container |
|
||||||
|
| `mf-panel-left` | Left panel container |
|
||||||
|
| `mf-panel-right` | Right panel container |
|
||||||
|
| `mf-panel-main` | Main content area |
|
||||||
|
| `mf-panel-hide-icon` | Hide icon (−) inside panels |
|
||||||
|
| `mf-panel-show-icon` | Show icon (⋯) in main area |
|
||||||
|
| `mf-panel-show-icon-left` | Show icon for left panel |
|
||||||
|
| `mf-panel-show-icon-right` | Show icon for right panel |
|
||||||
|
| `mf-resizer` | Resize handle base class |
|
||||||
|
| `mf-resizer-left` | Left panel resize handle |
|
||||||
|
| `mf-resizer-right` | Right panel resize handle |
|
||||||
|
| `mf-hidden` | Applied to hidden panels |
|
||||||
|
| `no-transition` | Disables transition during manual resize |
|
||||||
|
|
||||||
|
**Example customization:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Change panel background color */
|
||||||
|
.mf-panel-left,
|
||||||
|
.mf-panel-right {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Customize hide icon appearance */
|
||||||
|
.mf-panel-hide-icon:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Change transition timing */
|
||||||
|
.mf-panel-left,
|
||||||
|
.mf-panel-right {
|
||||||
|
transition: width 0.5s ease-in-out; /* Slower animation */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style resizer handles */
|
||||||
|
.mf-resizer {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-resizer:hover {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example 1: Code Editor Layout
|
||||||
|
|
||||||
|
A typical code editor with file explorer, editor, and properties panel:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import *
|
||||||
|
from myfasthtml.controls.Panel import Panel
|
||||||
|
|
||||||
|
# Create panel
|
||||||
|
panel = Panel(parent=root_instance)
|
||||||
|
|
||||||
|
# Left panel: File Explorer
|
||||||
|
panel.set_left(
|
||||||
|
Div(
|
||||||
|
H3("Explorer", cls="font-bold mb-2"),
|
||||||
|
Div(
|
||||||
|
Div("📁 src", cls="font-mono cursor-pointer"),
|
||||||
|
Div(" 📄 app.py", cls="font-mono ml-4 cursor-pointer"),
|
||||||
|
Div(" 📄 config.py", cls="font-mono ml-4 cursor-pointer"),
|
||||||
|
Div("📁 tests", cls="font-mono cursor-pointer"),
|
||||||
|
Div(" 📄 test_app.py", cls="font-mono ml-4 cursor-pointer"),
|
||||||
|
cls="space-y-1"
|
||||||
|
),
|
||||||
|
cls="p-4"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Main: Code Editor
|
||||||
|
panel.set_main(
|
||||||
|
Div(
|
||||||
|
Div(
|
||||||
|
Span("app.py", cls="font-bold"),
|
||||||
|
Span("Python", cls="text-sm opacity-60 ml-2"),
|
||||||
|
cls="border-b pb-2 mb-2"
|
||||||
|
),
|
||||||
|
Textarea(
|
||||||
|
"""def main():
|
||||||
|
print("Hello, World!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()""",
|
||||||
|
rows=20,
|
||||||
|
cls="w-full font-mono text-sm p-2 border rounded"
|
||||||
|
),
|
||||||
|
cls="p-4"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Right panel: Properties and Tools
|
||||||
|
panel.set_right(
|
||||||
|
Div(
|
||||||
|
H3("Properties", cls="font-bold mb-2"),
|
||||||
|
Div("Language: Python", cls="text-sm mb-1"),
|
||||||
|
Div("Lines: 5", cls="text-sm mb-1"),
|
||||||
|
Div("Size: 87 bytes", cls="text-sm mb-4"),
|
||||||
|
|
||||||
|
H3("Tools", cls="font-bold mb-2 mt-4"),
|
||||||
|
Button("Run", cls="btn btn-sm btn-primary w-full mb-2"),
|
||||||
|
Button("Debug", cls="btn btn-sm w-full mb-2"),
|
||||||
|
Button("Format", cls="btn btn-sm w-full"),
|
||||||
|
cls="p-4"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return panel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Dashboard with Filters
|
||||||
|
|
||||||
|
A data dashboard with filters sidebar and details panel:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import *
|
||||||
|
from myfasthtml.controls.Panel import Panel
|
||||||
|
|
||||||
|
# Create panel
|
||||||
|
panel = Panel(parent=root_instance)
|
||||||
|
|
||||||
|
# Left panel: Filters
|
||||||
|
panel.set_left(
|
||||||
|
Div(
|
||||||
|
H3("Filters", cls="font-bold mb-3"),
|
||||||
|
|
||||||
|
Div(
|
||||||
|
Label("Date Range", cls="label"),
|
||||||
|
Select(
|
||||||
|
Option("Last 7 days"),
|
||||||
|
Option("Last 30 days"),
|
||||||
|
Option("Last 90 days"),
|
||||||
|
cls="select select-bordered w-full"
|
||||||
|
),
|
||||||
|
cls="mb-3"
|
||||||
|
),
|
||||||
|
|
||||||
|
Div(
|
||||||
|
Label("Category", cls="label"),
|
||||||
|
Div(
|
||||||
|
Label(Input(type="checkbox", cls="checkbox"), " Sales", cls="label cursor-pointer"),
|
||||||
|
Label(Input(type="checkbox", cls="checkbox"), " Marketing", cls="label cursor-pointer"),
|
||||||
|
Label(Input(type="checkbox", cls="checkbox"), " Support", cls="label cursor-pointer"),
|
||||||
|
cls="space-y-2"
|
||||||
|
),
|
||||||
|
cls="mb-3"
|
||||||
|
),
|
||||||
|
|
||||||
|
Button("Apply Filters", cls="btn btn-primary w-full"),
|
||||||
|
cls="p-4"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Main: Dashboard Charts
|
||||||
|
panel.set_main(
|
||||||
|
Div(
|
||||||
|
H1("Analytics Dashboard", cls="text-2xl font-bold mb-4"),
|
||||||
|
|
||||||
|
Div(
|
||||||
|
Div(
|
||||||
|
Div("Total Revenue", cls="stat-title"),
|
||||||
|
Div("$45,231", cls="stat-value"),
|
||||||
|
Div("+12% from last month", cls="stat-desc"),
|
||||||
|
cls="stat"
|
||||||
|
),
|
||||||
|
Div(
|
||||||
|
Div("Active Users", cls="stat-title"),
|
||||||
|
Div("2,345", cls="stat-value"),
|
||||||
|
Div("+8% from last month", cls="stat-desc"),
|
||||||
|
cls="stat"
|
||||||
|
),
|
||||||
|
cls="stats shadow mb-4"
|
||||||
|
),
|
||||||
|
|
||||||
|
Div("[Chart placeholder - Revenue over time]", cls="border rounded p-8 text-center"),
|
||||||
|
cls="p-4"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Right panel: Details and Insights
|
||||||
|
panel.set_right(
|
||||||
|
Div(
|
||||||
|
H3("Key Insights", cls="font-bold mb-3"),
|
||||||
|
|
||||||
|
Div(
|
||||||
|
Div("🎯 Top Performing", cls="font-bold mb-1"),
|
||||||
|
Div("Product A: $12,450", cls="text-sm"),
|
||||||
|
Div("Product B: $8,920", cls="text-sm mb-3")
|
||||||
|
),
|
||||||
|
|
||||||
|
Div(
|
||||||
|
Div("📊 Trending Up", cls="font-bold mb-1"),
|
||||||
|
Div("Category: Electronics", cls="text-sm"),
|
||||||
|
Div("+23% this week", cls="text-sm mb-3")
|
||||||
|
),
|
||||||
|
|
||||||
|
Div(
|
||||||
|
Div("⚠️ Needs Attention", cls="font-bold mb-1"),
|
||||||
|
Div("Low stock: Item X", cls="text-sm"),
|
||||||
|
Div("Response time: +15%", cls="text-sm")
|
||||||
|
),
|
||||||
|
cls="p-4"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return panel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Simple Layout (Main Content Only)
|
||||||
|
|
||||||
|
A minimal panel with no side panels, focusing only on main content:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import *
|
||||||
|
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||||
|
|
||||||
|
# Create panel with both side panels disabled
|
||||||
|
conf = PanelConf(left=False, right=False)
|
||||||
|
panel = Panel(parent=root_instance, conf=conf)
|
||||||
|
|
||||||
|
# Only main content
|
||||||
|
panel.set_main(
|
||||||
|
Article(
|
||||||
|
H1("Welcome to My Blog", cls="text-3xl font-bold mb-4"),
|
||||||
|
P("This is a simple layout focusing entirely on the main content."),
|
||||||
|
P("No side panels distract from the reading experience."),
|
||||||
|
P("The content takes up the full width of the container."),
|
||||||
|
cls="prose max-w-none p-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return panel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 4: Dynamic Panel Updates
|
||||||
|
|
||||||
|
Controlling panels programmatically based on user interaction:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import *
|
||||||
|
from myfasthtml.controls.Panel import Panel
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
from myfasthtml.core.commands import Command
|
||||||
|
|
||||||
|
# Create panel
|
||||||
|
panel = Panel(parent=root_instance)
|
||||||
|
|
||||||
|
# Set up content
|
||||||
|
panel.set_left(
|
||||||
|
Div(
|
||||||
|
H3("Navigation"),
|
||||||
|
Ul(
|
||||||
|
Li("Dashboard"),
|
||||||
|
Li("Reports"),
|
||||||
|
Li("Settings")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
panel.set_right(
|
||||||
|
Div(
|
||||||
|
H3("Debug Info"),
|
||||||
|
Div("Session ID: abc123"),
|
||||||
|
Div("User: Admin"),
|
||||||
|
Div("Timestamp: 2024-01-15")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create control buttons
|
||||||
|
toggle_left_btn = mk.button(
|
||||||
|
"Toggle Left Panel",
|
||||||
|
command=panel.commands.toggle_side("left", False),
|
||||||
|
cls="btn btn-sm"
|
||||||
|
)
|
||||||
|
|
||||||
|
toggle_right_btn = mk.button(
|
||||||
|
"Toggle Right Panel",
|
||||||
|
command=panel.commands.toggle_side("right", False),
|
||||||
|
cls="btn btn-sm"
|
||||||
|
)
|
||||||
|
|
||||||
|
show_all_btn = mk.button(
|
||||||
|
"Show All Panels",
|
||||||
|
command=Command(
|
||||||
|
"show_all",
|
||||||
|
"Show all panels",
|
||||||
|
lambda: (
|
||||||
|
panel.toggle_side("left", True),
|
||||||
|
panel.toggle_side("right", True)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
cls="btn btn-sm btn-primary"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Main content with controls
|
||||||
|
panel.set_main(
|
||||||
|
Div(
|
||||||
|
H1("Panel Controls Demo", cls="text-2xl font-bold mb-4"),
|
||||||
|
|
||||||
|
Div(
|
||||||
|
toggle_left_btn,
|
||||||
|
toggle_right_btn,
|
||||||
|
show_all_btn,
|
||||||
|
cls="space-x-2 mb-4"
|
||||||
|
),
|
||||||
|
|
||||||
|
P("Use the buttons above to toggle panels programmatically."),
|
||||||
|
P("You can also use the hide (−) and show (⋯) icons."),
|
||||||
|
|
||||||
|
cls="p-4"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return panel
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Developer Reference
|
||||||
|
|
||||||
|
This section contains technical details for developers working on the Panel component itself.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The Panel component uses `PanelConf` dataclass for configuration:
|
||||||
|
|
||||||
|
| Property | Type | Description | Default |
|
||||||
|
|----------|---------|----------------------------|---------|
|
||||||
|
| `left` | boolean | Enable/disable left panel | `True` |
|
||||||
|
| `right` | boolean | Enable/disable right panel | `True` |
|
||||||
|
|
||||||
|
### State
|
||||||
|
|
||||||
|
The Panel component maintains the following state properties via `PanelState`:
|
||||||
|
|
||||||
|
| Name | Type | Description | Default |
|
||||||
|
|-----------------|---------|------------------------------------|---------|
|
||||||
|
| `left_visible` | boolean | True if the left panel is visible | `True` |
|
||||||
|
| `right_visible` | boolean | True if the right panel is visible | `True` |
|
||||||
|
| `left_width` | integer | Width of the left panel in pixels | `250` |
|
||||||
|
| `right_width` | integer | Width of the right panel in pixels | `250` |
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
Available commands for programmatic control:
|
||||||
|
|
||||||
|
| Name | Description |
|
||||||
|
|------------------------------|-------------------------------------------------------------------|
|
||||||
|
| `toggle_side(side, visible)` | Sets panel visibility (side: "left"/"right", visible: True/False) |
|
||||||
|
| `update_side_width(side)` | Updates panel width from HTMX request (side: "left"/"right") |
|
||||||
|
|
||||||
|
**Note:** The old `toggle_side(side)` command without the `visible` parameter is deprecated but still available in the
|
||||||
|
codebase.
|
||||||
|
|
||||||
|
### Public Methods
|
||||||
|
|
||||||
|
| Method | Description | Returns |
|
||||||
|
|----------------------|------------------------------|---------|
|
||||||
|
| `set_main(content)` | Sets the main content area | `self` |
|
||||||
|
| `set_left(content)` | Sets the left panel content | `Div` |
|
||||||
|
| `set_right(content)` | Sets the right panel content | `Div` |
|
||||||
|
| `render()` | Renders the complete panel | `Div` |
|
||||||
|
|
||||||
|
### High Level Hierarchical Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Div(id="{id}", cls="mf-panel")
|
||||||
|
├── Div(id="{id}_pl", cls="mf-panel-left [mf-hidden]")
|
||||||
|
│ ├── Div (hide icon)
|
||||||
|
│ ├── Div(id="{id}_cl")
|
||||||
|
│ │ └── [Left content]
|
||||||
|
│ └── Div (resizer-left)
|
||||||
|
├── Div(cls="mf-panel-main")
|
||||||
|
│ ├── Div(id="{id}_show_left", cls="hidden|mf-panel-show-icon-left")
|
||||||
|
│ ├── Div(id="{id}_m", cls="mf-panel-main")
|
||||||
|
│ │ └── [Main content]
|
||||||
|
│ └── Div(id="{id}_show_right", cls="hidden|mf-panel-show-icon-right")
|
||||||
|
├── Div(id="{id}_pr", cls="mf-panel-right [mf-hidden]")
|
||||||
|
│ ├── Div (resizer-right)
|
||||||
|
│ ├── Div (hide icon)
|
||||||
|
│ └── Div(id="{id}_cr")
|
||||||
|
│ └── [Right content]
|
||||||
|
└── Script # initResizer('{id}')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:**
|
||||||
|
|
||||||
|
- Left panel: hide icon, then content, then resizer (resizer on right edge)
|
||||||
|
- Right panel: resizer, then hide icon, then content (resizer on left edge)
|
||||||
|
- Hide icons are positioned at panel root level (not inside content div)
|
||||||
|
- Main content has an outer wrapper and inner content div with ID
|
||||||
|
- `[mf-hidden]` class is conditionally applied when panel is hidden
|
||||||
|
|
||||||
|
### Element IDs
|
||||||
|
|
||||||
|
| Name | Description |
|
||||||
|
|------------------|-------------------------------------|
|
||||||
|
| `{id}` | Root panel container |
|
||||||
|
| `{id}_pl` | Left panel container |
|
||||||
|
| `{id}_pr` | Right panel container |
|
||||||
|
| `{id}_cl` | Left panel content wrapper |
|
||||||
|
| `{id}_cr` | Right panel content wrapper |
|
||||||
|
| `{id}_m` | Main content wrapper |
|
||||||
|
| `{id}_show_left` | Show icon for left panel (in main) |
|
||||||
|
| `{id}_show_right`| Show icon for right panel (in main) |
|
||||||
|
|
||||||
|
**Note:** `{id}` is the Panel instance ID (auto-generated UUID or custom `_id`).
|
||||||
|
|
||||||
|
**ID Management:**
|
||||||
|
|
||||||
|
The Panel component uses the `PanelIds` helper class to manage element IDs consistently. Access IDs programmatically:
|
||||||
|
|
||||||
|
```python
|
||||||
|
panel = Panel(parent=root_instance)
|
||||||
|
|
||||||
|
# Access IDs via get_ids()
|
||||||
|
panel.get_ids().panel("left") # Returns "{id}_pl"
|
||||||
|
panel.get_ids().panel("right") # Returns "{id}_pr"
|
||||||
|
panel.get_ids().left # Returns "{id}_cl"
|
||||||
|
panel.get_ids().right # Returns "{id}_cr"
|
||||||
|
panel.get_ids().main # Returns "{id}_m"
|
||||||
|
panel.get_ids().content("left") # Returns "{id}_cl"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Internal Methods
|
||||||
|
|
||||||
|
These methods are used internally for rendering:
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|-----------------------|---------------------------------------------------|
|
||||||
|
| `_mk_panel(side)` | Renders a panel (left or right) with all elements |
|
||||||
|
| `_mk_show_icon(side)` | Renders the show icon for a panel |
|
||||||
|
|
||||||
|
**Method details:**
|
||||||
|
|
||||||
|
- `_mk_panel(side)`:
|
||||||
|
- Checks if panel is enabled in config
|
||||||
|
- Creates resizer with command and data attributes
|
||||||
|
- Creates hide icon with toggle command
|
||||||
|
- Applies `mf-hidden` class if panel is not visible
|
||||||
|
- Returns None if panel is disabled
|
||||||
|
|
||||||
|
- `_mk_show_icon(side)`:
|
||||||
|
- Checks if panel is enabled in config
|
||||||
|
- Returns None if panel is disabled or visible
|
||||||
|
- Applies `hidden` (Tailwind) class if panel is visible
|
||||||
|
- Applies positioning class based on side
|
||||||
|
|
||||||
|
### JavaScript Integration
|
||||||
|
|
||||||
|
The Panel component uses JavaScript for manual resizing:
|
||||||
|
|
||||||
|
**initResizer(panelId):**
|
||||||
|
|
||||||
|
- Initializes drag-and-drop resize functionality
|
||||||
|
- Adds/removes `no-transition` class during drag
|
||||||
|
- Sends width updates to server via HTMX
|
||||||
|
- Constrains width between 150px and 500px
|
||||||
|
|
||||||
|
**File:** `src/myfasthtml/assets/myfasthtml.js`
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
The TabsManager component provides a dynamic tabbed interface for organizing multiple views within your FastHTML application. It handles tab creation, activation, closing, and content management with automatic state persistence and HTMX-powered interactions.
|
The TabsManager component provides a dynamic tabbed interface for organizing multiple views within your FastHTML
|
||||||
|
application. It handles tab creation, activation, closing, and content management with automatic state persistence and
|
||||||
|
HTMX-powered interactions.
|
||||||
|
|
||||||
**Key features:**
|
**Key features:**
|
||||||
|
|
||||||
@@ -52,7 +54,9 @@ This creates a complete tabbed interface with:
|
|||||||
- A search menu (⊞ icon) for quick tab navigation when many tabs are open
|
- A search menu (⊞ icon) for quick tab navigation when many tabs are open
|
||||||
- Automatic HTMX updates when switching or closing tabs
|
- Automatic HTMX updates when switching or closing tabs
|
||||||
|
|
||||||
**Note:** Tabs are interactive by default. Users can click tab labels to switch views, click close buttons to remove tabs, or use the search menu to find tabs quickly. All interactions update the UI without page reload thanks to HTMX integration.
|
**Note:** Tabs are interactive by default. Users can click tab labels to switch views, click close buttons to remove
|
||||||
|
tabs, or use the search menu to find tabs quickly. All interactions update the UI without page reload thanks to HTMX
|
||||||
|
integration.
|
||||||
|
|
||||||
## Basic Usage
|
## Basic Usage
|
||||||
|
|
||||||
@@ -63,9 +67,9 @@ The TabsManager component consists of a header with tab buttons and a content ar
|
|||||||
```
|
```
|
||||||
┌────────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────────┐
|
||||||
│ Tab Header │
|
│ Tab Header │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────┐ │
|
||||||
│ │ Tab 1 × │ │ Tab 2 × │ │ Tab 3 × │ │ ⊞ │ │
|
│ │ Tab 1 × │ │ Tab 2 × │ │ Tab 3 × │ │ ⊞ │ │
|
||||||
│ └──────────┘ └──────────┘ └──────────┘ └────┘ │
|
│ └──────────┘ └──────────┘ └──────────┘ └────┘ │
|
||||||
├────────────────────────────────────────────────────────────┤
|
├────────────────────────────────────────────────────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ │
|
│ │
|
||||||
@@ -77,16 +81,17 @@ The TabsManager component consists of a header with tab buttons and a content ar
|
|||||||
|
|
||||||
**Component details:**
|
**Component details:**
|
||||||
|
|
||||||
| Element | Description |
|
| Element | Description |
|
||||||
|-------------------|--------------------------------------------------|
|
|------------------|-----------------------------------------|
|
||||||
| Tab buttons | Clickable labels to switch between tabs |
|
| Tab buttons | Clickable labels to switch between tabs |
|
||||||
| Close button (×) | Removes the tab and its content |
|
| Close button (×) | Removes the tab and its content |
|
||||||
| Search menu (⊞) | Dropdown menu to search and filter tabs |
|
| Search menu (⊞) | Dropdown menu to search and filter tabs |
|
||||||
| Content area | Displays the active tab's content |
|
| Content area | Displays the active tab's content |
|
||||||
|
|
||||||
### Creating a TabsManager
|
### Creating a TabsManager
|
||||||
|
|
||||||
The TabsManager is a `MultipleInstance`, meaning you can create multiple independent tab managers in your application. Create it by providing a parent instance:
|
The TabsManager is a `MultipleInstance`, meaning you can create multiple independent tab managers in your application.
|
||||||
|
Create it by providing a parent instance:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
tabs = TabsManager(parent=root_instance)
|
tabs = TabsManager(parent=root_instance)
|
||||||
@@ -102,12 +107,13 @@ Use the `create_tab()` method to add a new tab:
|
|||||||
```python
|
```python
|
||||||
# Create a tab with custom content
|
# Create a tab with custom content
|
||||||
tab_id = tabs.create_tab(
|
tab_id = tabs.create_tab(
|
||||||
label="My Tab",
|
label="My Tab",
|
||||||
component=Div(H1("Content"), P("Tab content here"))
|
component=Div(H1("Content"), P("Tab content here"))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create with a MyFastHtml control
|
# Create with a MyFastHtml control
|
||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||||
|
|
||||||
network = VisNetwork(parent=tabs, nodes=nodes_data, edges=edges_data)
|
network = VisNetwork(parent=tabs, nodes=nodes_data, edges=edges_data)
|
||||||
tab_id = tabs.create_tab("Network View", network)
|
tab_id = tabs.create_tab("Network View", network)
|
||||||
|
|
||||||
@@ -116,6 +122,7 @@ tab_id = tabs.create_tab("Background Tab", content, activate=False)
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
|
|
||||||
- `label` (str): Display text shown in the tab button
|
- `label` (str): Display text shown in the tab button
|
||||||
- `component` (Any): Content to display in the tab (FastHTML elements or MyFastHtml controls)
|
- `component` (Any): Content to display in the tab (FastHTML elements or MyFastHtml controls)
|
||||||
- `activate` (bool): Whether to make this tab active immediately (default: True)
|
- `activate` (bool): Whether to make this tab active immediately (default: True)
|
||||||
@@ -135,10 +142,12 @@ tabs.show_tab(tab_id, activate=False)
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
|
|
||||||
- `tab_id` (str): The UUID of the tab to show
|
- `tab_id` (str): The UUID of the tab to show
|
||||||
- `activate` (bool): Whether to make this tab active (default: True)
|
- `activate` (bool): Whether to make this tab active (default: True)
|
||||||
|
|
||||||
**Note:** The first time a tab is shown, its content is sent to the client and cached. Subsequent activations just toggle visibility without re-sending content.
|
**Note:** The first time a tab is shown, its content is sent to the client and cached. Subsequent activations just
|
||||||
|
toggle visibility without re-sending content.
|
||||||
|
|
||||||
### Closing Tabs
|
### Closing Tabs
|
||||||
|
|
||||||
@@ -150,6 +159,7 @@ tabs.close_tab(tab_id)
|
|||||||
```
|
```
|
||||||
|
|
||||||
**What happens when closing:**
|
**What happens when closing:**
|
||||||
|
|
||||||
1. Tab is removed from the tab list and order
|
1. Tab is removed from the tab list and order
|
||||||
2. Content is removed from cache and client
|
2. Content is removed from cache and client
|
||||||
3. If the closed tab was active, the first remaining tab becomes active
|
3. If the closed tab was active, the first remaining tab becomes active
|
||||||
@@ -163,14 +173,15 @@ Use the `change_tab_content()` method to update an existing tab's content and la
|
|||||||
# Update tab content and label
|
# Update tab content and label
|
||||||
new_content = Div(H1("Updated"), P("New content"))
|
new_content = Div(H1("Updated"), P("New content"))
|
||||||
tabs.change_tab_content(
|
tabs.change_tab_content(
|
||||||
tab_id=tab_id,
|
tab_id=tab_id,
|
||||||
label="Updated Tab",
|
label="Updated Tab",
|
||||||
component=new_content,
|
component=new_content,
|
||||||
activate=True
|
activate=True
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
|
|
||||||
- `tab_id` (str): The UUID of the tab to update
|
- `tab_id` (str): The UUID of the tab to update
|
||||||
- `label` (str): New label for the tab
|
- `label` (str): New label for the tab
|
||||||
- `component` (Any): New content to display
|
- `component` (Any): New content to display
|
||||||
@@ -187,13 +198,14 @@ When creating multiple tabs programmatically, you can use auto-increment to gene
|
|||||||
```python
|
```python
|
||||||
# Using the on_new_tab method with auto_increment
|
# Using the on_new_tab method with auto_increment
|
||||||
def create_multiple_tabs():
|
def create_multiple_tabs():
|
||||||
# Creates "Untitled_0", "Untitled_1", "Untitled_2"
|
# Creates "Untitled_0", "Untitled_1", "Untitled_2"
|
||||||
tabs.on_new_tab("Untitled", content, auto_increment=True)
|
tabs.on_new_tab("Untitled", content, auto_increment=True)
|
||||||
tabs.on_new_tab("Untitled", content, auto_increment=True)
|
tabs.on_new_tab("Untitled", content, auto_increment=True)
|
||||||
tabs.on_new_tab("Untitled", content, auto_increment=True)
|
tabs.on_new_tab("Untitled", content, auto_increment=True)
|
||||||
```
|
```
|
||||||
|
|
||||||
**How it works:**
|
**How it works:**
|
||||||
|
|
||||||
- The TabsManager maintains an internal counter (`_tab_count`)
|
- The TabsManager maintains an internal counter (`_tab_count`)
|
||||||
- When `auto_increment=True`, the counter value is appended to the label
|
- When `auto_increment=True`, the counter value is appended to the label
|
||||||
- Counter increments with each auto-incremented tab creation
|
- Counter increments with each auto-incremented tab creation
|
||||||
@@ -218,11 +230,13 @@ tab_id_2 = tabs.create_tab("Network", network)
|
|||||||
|
|
||||||
**Detection criteria:**
|
**Detection criteria:**
|
||||||
A tab is considered a duplicate if all three match:
|
A tab is considered a duplicate if all three match:
|
||||||
|
|
||||||
- Same `label`
|
- Same `label`
|
||||||
- Same `component_type` (component class prefix)
|
- Same `component_type` (component class prefix)
|
||||||
- Same `component_id` (component instance ID)
|
- Same `component_id` (component instance ID)
|
||||||
|
|
||||||
**Note:** This only works with `BaseInstance` components (MyFastHtml controls). Plain FastHTML elements don't have IDs and will always create new tabs.
|
**Note:** This only works with `BaseInstance` components (MyFastHtml controls). Plain FastHTML elements don't have IDs
|
||||||
|
and will always create new tabs.
|
||||||
|
|
||||||
### Dynamic Content Updates
|
### Dynamic Content Updates
|
||||||
|
|
||||||
@@ -232,14 +246,16 @@ You can update tabs dynamically during the session:
|
|||||||
# Initial tab creation
|
# Initial tab creation
|
||||||
tab_id = tabs.create_tab("Data View", Div("Loading..."))
|
tab_id = tabs.create_tab("Data View", Div("Loading..."))
|
||||||
|
|
||||||
|
|
||||||
# Later, update with actual data
|
# Later, update with actual data
|
||||||
def load_data():
|
def load_data():
|
||||||
data_content = Div(H2("Data"), P("Loaded content"))
|
data_content = Div(H2("Data"), P("Loaded content"))
|
||||||
tabs.change_tab_content(tab_id, "Data View", data_content)
|
tabs.change_tab_content(tab_id, "Data View", data_content)
|
||||||
# Returns HTMX response to update the UI
|
# Returns HTMX response to update the UI
|
||||||
```
|
```
|
||||||
|
|
||||||
**Use cases:**
|
**Use cases:**
|
||||||
|
|
||||||
- Loading data asynchronously
|
- Loading data asynchronously
|
||||||
- Refreshing tab content based on user actions
|
- Refreshing tab content based on user actions
|
||||||
- Updating visualizations with new data
|
- Updating visualizations with new data
|
||||||
@@ -257,6 +273,7 @@ The built-in search menu helps users navigate when many tabs are open:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**How to access:**
|
**How to access:**
|
||||||
|
|
||||||
- Click the ⊞ icon in the tab header
|
- Click the ⊞ icon in the tab header
|
||||||
- Start typing to filter tabs by label
|
- Start typing to filter tabs by label
|
||||||
- Click a result to activate that tab
|
- Click a result to activate that tab
|
||||||
@@ -276,11 +293,13 @@ tabs.show_tab(tab_id, oob=False) # Swap into HTMX target only
|
|||||||
```
|
```
|
||||||
|
|
||||||
**When to use `oob=False`:**
|
**When to use `oob=False`:**
|
||||||
|
|
||||||
- When you want to control the exact HTMX target
|
- When you want to control the exact HTMX target
|
||||||
- When combining with other HTMX responses
|
- When combining with other HTMX responses
|
||||||
- When the tab activation is triggered by a command with a specific target
|
- When the tab activation is triggered by a command with a specific target
|
||||||
|
|
||||||
**When to use `oob=True` (default):**
|
**When to use `oob=True` (default):**
|
||||||
|
|
||||||
- Most common use case
|
- Most common use case
|
||||||
- Allows other controls to trigger tab changes without caring about targets
|
- Allows other controls to trigger tab changes without caring about targets
|
||||||
- Enables automatic UI updates across multiple elements
|
- Enables automatic UI updates across multiple elements
|
||||||
@@ -289,18 +308,18 @@ tabs.show_tab(tab_id, oob=False) # Swap into HTMX target only
|
|||||||
|
|
||||||
The TabsManager uses CSS classes that you can customize:
|
The TabsManager uses CSS classes that you can customize:
|
||||||
|
|
||||||
| Class | Element |
|
| Class | Element |
|
||||||
|----------------------------|----------------------------------|
|
|--------------------------|---------------------------------|
|
||||||
| `mf-tabs-manager` | Root tabs manager container |
|
| `mf-tabs-manager` | Root tabs manager container |
|
||||||
| `mf-tabs-header-wrapper` | Header wrapper (buttons + menu) |
|
| `mf-tabs-header-wrapper` | Header wrapper (buttons + menu) |
|
||||||
| `mf-tabs-header` | Tab buttons container |
|
| `mf-tabs-header` | Tab buttons container |
|
||||||
| `mf-tab-button` | Individual tab button |
|
| `mf-tab-button` | Individual tab button |
|
||||||
| `mf-tab-active` | Active tab button (modifier) |
|
| `mf-tab-active` | Active tab button (modifier) |
|
||||||
| `mf-tab-label` | Tab label text |
|
| `mf-tab-label` | Tab label text |
|
||||||
| `mf-tab-close-btn` | Close button (×) |
|
| `mf-tab-close-btn` | Close button (×) |
|
||||||
| `mf-tab-content-wrapper` | Content area container |
|
| `mf-tab-content-wrapper` | Content area container |
|
||||||
| `mf-tab-content` | Individual tab content |
|
| `mf-tab-content` | Individual tab content |
|
||||||
| `mf-empty-content` | Empty state when no tabs |
|
| `mf-empty-content` | Empty state when no tabs |
|
||||||
|
|
||||||
**Example customization:**
|
**Example customization:**
|
||||||
|
|
||||||
@@ -340,30 +359,30 @@ tabs = TabsManager(parent=root, _id="app-tabs")
|
|||||||
|
|
||||||
# Dashboard view
|
# Dashboard view
|
||||||
dashboard = Div(
|
dashboard = Div(
|
||||||
H1("Dashboard"),
|
H1("Dashboard"),
|
||||||
Div(
|
Div(
|
||||||
Div("Total Users: 1,234", cls="stat"),
|
Div("Total Users: 1,234", cls="stat"),
|
||||||
Div("Active Sessions: 56", cls="stat"),
|
Div("Active Sessions: 56", cls="stat"),
|
||||||
Div("Revenue: $12,345", cls="stat"),
|
Div("Revenue: $12,345", cls="stat"),
|
||||||
cls="stats-grid"
|
cls="stats-grid"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Analytics view
|
# Analytics view
|
||||||
analytics = Div(
|
analytics = Div(
|
||||||
H1("Analytics"),
|
H1("Analytics"),
|
||||||
P("Detailed analytics and reports"),
|
P("Detailed analytics and reports"),
|
||||||
Div("Chart placeholder", cls="chart-container")
|
Div("Chart placeholder", cls="chart-container")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Settings view
|
# Settings view
|
||||||
settings = Div(
|
settings = Div(
|
||||||
H1("Settings"),
|
H1("Settings"),
|
||||||
Form(
|
Form(
|
||||||
Label("Username:", Input(name="username", value="admin")),
|
Label("Username:", Input(name="username", value="admin")),
|
||||||
Label("Email:", Input(name="email", value="admin@example.com")),
|
Label("Email:", Input(name="email", value="admin@example.com")),
|
||||||
Button("Save", type="submit"),
|
Button("Save", type="submit"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create tabs
|
# Create tabs
|
||||||
@@ -392,28 +411,30 @@ tabs = TabsManager(parent=root, _id="network-tabs")
|
|||||||
|
|
||||||
# Create initial tab with welcome message
|
# Create initial tab with welcome message
|
||||||
tabs.create_tab("Welcome", Div(
|
tabs.create_tab("Welcome", Div(
|
||||||
H1("Network Visualizer"),
|
H1("Network Visualizer"),
|
||||||
P("Click 'Add Network' to create a new network visualization")
|
P("Click 'Add Network' to create a new network visualization")
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
# Function to create a new network tab
|
# Function to create a new network tab
|
||||||
def add_network_tab():
|
def add_network_tab():
|
||||||
# Define network data
|
# Define network data
|
||||||
nodes = [
|
nodes = [
|
||||||
{"id": 1, "label": "Node 1"},
|
{"id": 1, "label": "Node 1"},
|
||||||
{"id": 2, "label": "Node 2"},
|
{"id": 2, "label": "Node 2"},
|
||||||
{"id": 3, "label": "Node 3"}
|
{"id": 3, "label": "Node 3"}
|
||||||
]
|
]
|
||||||
edges = [
|
edges = [
|
||||||
{"from": 1, "to": 2},
|
{"from": 1, "to": 2},
|
||||||
{"from": 2, "to": 3}
|
{"from": 2, "to": 3}
|
||||||
]
|
]
|
||||||
|
|
||||||
# Create network instance
|
# Create network instance
|
||||||
network = VisNetwork(parent=tabs, nodes=nodes, edges=edges)
|
network = VisNetwork(parent=tabs, nodes=nodes, edges=edges)
|
||||||
|
|
||||||
|
# Use auto-increment to create unique labels
|
||||||
|
return tabs.on_new_tab("Network", network, auto_increment=True)
|
||||||
|
|
||||||
# Use auto-increment to create unique labels
|
|
||||||
return tabs.on_new_tab("Network", network, auto_increment=True)
|
|
||||||
|
|
||||||
# Create command for adding networks
|
# Create command for adding networks
|
||||||
add_cmd = Command("add_network", "Add network tab", add_network_tab)
|
add_cmd = Command("add_network", "Add network tab", add_network_tab)
|
||||||
@@ -443,12 +464,14 @@ tabs = TabsManager(parent=root, _id="editor-tabs")
|
|||||||
doc1_id = tabs.create_tab("Document 1", Textarea("Initial content 1", rows=10))
|
doc1_id = tabs.create_tab("Document 1", Textarea("Initial content 1", rows=10))
|
||||||
doc2_id = tabs.create_tab("Document 2", Textarea("Initial content 2", rows=10))
|
doc2_id = tabs.create_tab("Document 2", Textarea("Initial content 2", rows=10))
|
||||||
|
|
||||||
|
|
||||||
# Function to refresh a document's content
|
# Function to refresh a document's content
|
||||||
def refresh_document(tab_id, doc_name):
|
def refresh_document(tab_id, doc_name):
|
||||||
# Simulate loading new content
|
# Simulate loading new content
|
||||||
new_content = Textarea(f"Refreshed content for {doc_name}\nTimestamp: {datetime.now()}", rows=10)
|
new_content = Textarea(f"Refreshed content for {doc_name}\nTimestamp: {datetime.now()}", rows=10)
|
||||||
tabs.change_tab_content(tab_id, doc_name, new_content)
|
tabs.change_tab_content(tab_id, doc_name, new_content)
|
||||||
return tabs._mk_tabs_controller(oob=True), tabs._mk_tabs_header_wrapper(oob=True)
|
return tabs._mk_tabs_controller(oob=True), tabs._mk_tabs_header_wrapper(oob=True)
|
||||||
|
|
||||||
|
|
||||||
# Create refresh commands
|
# Create refresh commands
|
||||||
refresh_doc1 = Command("refresh_1", "Refresh doc 1", refresh_document, doc1_id, "Document 1")
|
refresh_doc1 = Command("refresh_1", "Refresh doc 1", refresh_document, doc1_id, "Document 1")
|
||||||
@@ -456,9 +479,9 @@ refresh_doc2 = Command("refresh_2", "Refresh doc 2", refresh_document, doc2_id,
|
|||||||
|
|
||||||
# Add refresh buttons
|
# Add refresh buttons
|
||||||
controls = Div(
|
controls = Div(
|
||||||
mk.button("Refresh Document 1", command=refresh_doc1, cls="btn btn-sm"),
|
mk.button("Refresh Document 1", command=refresh_doc1, cls="btn btn-sm"),
|
||||||
mk.button("Refresh Document 2", command=refresh_doc2, cls="btn btn-sm"),
|
mk.button("Refresh Document 2", command=refresh_doc2, cls="btn btn-sm"),
|
||||||
cls="controls-bar"
|
cls="controls-bar"
|
||||||
)
|
)
|
||||||
|
|
||||||
return Div(controls, tabs)
|
return Div(controls, tabs)
|
||||||
@@ -480,19 +503,21 @@ tabs = TabsManager(parent=root, _id="dynamic-tabs")
|
|||||||
|
|
||||||
# Create initial placeholder tab
|
# Create initial placeholder tab
|
||||||
tabs.create_tab("Start", Div(
|
tabs.create_tab("Start", Div(
|
||||||
H2("Welcome"),
|
H2("Welcome"),
|
||||||
P("Click 'New Tab' to create numbered tabs")
|
P("Click 'New Tab' to create numbered tabs")
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
# Function to create a new numbered tab
|
# Function to create a new numbered tab
|
||||||
def create_numbered_tab():
|
def create_numbered_tab():
|
||||||
content = Div(
|
content = Div(
|
||||||
H2("New Tab Content"),
|
H2("New Tab Content"),
|
||||||
P(f"This tab was created dynamically"),
|
P(f"This tab was created dynamically"),
|
||||||
Input(placeholder="Enter some text...", cls="input")
|
Input(placeholder="Enter some text...", cls="input")
|
||||||
)
|
)
|
||||||
# Auto-increment creates "Tab_0", "Tab_1", "Tab_2", etc.
|
# Auto-increment creates "Tab_0", "Tab_1", "Tab_2", etc.
|
||||||
return tabs.on_new_tab("Tab", content, auto_increment=True)
|
return tabs.on_new_tab("Tab", content, auto_increment=True)
|
||||||
|
|
||||||
|
|
||||||
# Create command
|
# Create command
|
||||||
new_tab_cmd = Command("new_tab", "Create new tab", create_numbered_tab)
|
new_tab_cmd = Command("new_tab", "Create new tab", create_numbered_tab)
|
||||||
@@ -501,8 +526,8 @@ new_tab_cmd = Command("new_tab", "Create new tab", create_numbered_tab)
|
|||||||
new_tab_button = mk.button("New Tab", command=new_tab_cmd, cls="btn btn-primary")
|
new_tab_button = mk.button("New Tab", command=new_tab_cmd, cls="btn btn-primary")
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
Div(new_tab_button, cls="toolbar"),
|
Div(new_tab_button, cls="toolbar"),
|
||||||
tabs
|
tabs
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -516,13 +541,13 @@ This section contains technical details for developers working on the TabsManage
|
|||||||
|
|
||||||
The TabsManager component maintains the following state properties:
|
The TabsManager component maintains the following state properties:
|
||||||
|
|
||||||
| Name | Type | Description | Default |
|
| Name | Type | Description | Default |
|
||||||
|----------------------------|-----------------|------------------------------------------------------|---------|
|
|--------------------------|----------------|---------------------------------------------------|---------|
|
||||||
| `tabs` | dict[str, Any] | Dictionary of tab metadata (id, label, component) | `{}` |
|
| `tabs` | dict[str, Any] | Dictionary of tab metadata (id, label, component) | `{}` |
|
||||||
| `tabs_order` | list[str] | Ordered list of tab IDs | `[]` |
|
| `tabs_order` | list[str] | Ordered list of tab IDs | `[]` |
|
||||||
| `active_tab` | str \| None | ID of the currently active tab | `None` |
|
| `active_tab` | str \| None | ID of the currently active tab | `None` |
|
||||||
| `ns_tabs_content` | dict[str, Any] | Cache of tab content (raw, not wrapped) | `{}` |
|
| `ns_tabs_content` | dict[str, Any] | Cache of tab content (raw, not wrapped) | `{}` |
|
||||||
| `ns_tabs_sent_to_client` | set | Set of tab IDs already sent to client | `set()` |
|
| `ns_tabs_sent_to_client` | set | Set of tab IDs already sent to client | `set()` |
|
||||||
|
|
||||||
**Note:** Properties prefixed with `ns_` are not persisted in the database and exist only for the session.
|
**Note:** Properties prefixed with `ns_` are not persisted in the database and exist only for the session.
|
||||||
|
|
||||||
@@ -530,24 +555,24 @@ The TabsManager component maintains the following state properties:
|
|||||||
|
|
||||||
Available commands for programmatic control:
|
Available commands for programmatic control:
|
||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
|-----------------------------------------|------------------------------------------------|
|
|---------------------------------------------|--------------------------------------------|
|
||||||
| `show_tab(tab_id)` | Activate or show a specific tab |
|
| `show_tab(tab_id)` | Activate or show a specific tab |
|
||||||
| `close_tab(tab_id)` | Close a specific tab |
|
| `close_tab(tab_id)` | Close a specific tab |
|
||||||
| `add_tab(label, component, auto_increment)` | Add a new tab with optional auto-increment |
|
| `add_tab(label, component, auto_increment)` | Add a new tab with optional auto-increment |
|
||||||
|
|
||||||
### Public Methods
|
### Public Methods
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|---------------------------------------------------------|------------------------------------------------------|
|
|---------------------------------------------------------------|-------------------------------------------------|
|
||||||
| `create_tab(label, component, activate=True)` | Create a new tab or reuse existing duplicate |
|
| `create_tab(label, component, activate=True)` | Create a new tab or reuse existing duplicate |
|
||||||
| `show_tab(tab_id, activate=True, oob=True)` | Send tab to client and/or activate it |
|
| `show_tab(tab_id, activate=True, oob=True)` | Send tab to client and/or activate it |
|
||||||
| `close_tab(tab_id)` | Close and remove a tab |
|
| `close_tab(tab_id)` | Close and remove a tab |
|
||||||
| `change_tab_content(tab_id, label, component, activate=True)` | Update existing tab's label and content |
|
| `change_tab_content(tab_id, label, component, activate=True)` | Update existing tab's label and content |
|
||||||
| `on_new_tab(label, component, auto_increment=False)` | Create and show tab with auto-increment support |
|
| `on_new_tab(label, component, auto_increment=False)` | Create and show tab with auto-increment support |
|
||||||
| `add_tab_btn()` | Returns add tab button element |
|
| `add_tab_btn()` | Returns add tab button element |
|
||||||
| `get_state()` | Returns the TabsManagerState object |
|
| `get_state()` | Returns the TabsManagerState object |
|
||||||
| `render()` | Renders the complete TabsManager component |
|
| `render()` | Renders the complete TabsManager component |
|
||||||
|
|
||||||
### High Level Hierarchical Structure
|
### High Level Hierarchical Structure
|
||||||
|
|
||||||
@@ -573,15 +598,15 @@ Div(id="{id}", cls="mf-tabs-manager")
|
|||||||
|
|
||||||
### Element IDs
|
### Element IDs
|
||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
|-------------------------------|------------------------------------------|
|
|-------------------------|-----------------------------------------|
|
||||||
| `{id}` | Root tabs manager container |
|
| `{id}` | Root tabs manager container |
|
||||||
| `{id}-controller` | Hidden controller managing active state |
|
| `{id}-controller` | Hidden controller managing active state |
|
||||||
| `{id}-header-wrapper` | Header wrapper (buttons + search) |
|
| `{id}-header-wrapper` | Header wrapper (buttons + search) |
|
||||||
| `{id}-header` | Tab buttons container |
|
| `{id}-header` | Tab buttons container |
|
||||||
| `{id}-content-wrapper` | Content area wrapper |
|
| `{id}-content-wrapper` | Content area wrapper |
|
||||||
| `{id}-{tab_id}-content` | Individual tab content |
|
| `{id}-{tab_id}-content` | Individual tab content |
|
||||||
| `{id}-search` | Search component ID |
|
| `{id}-search` | Search component ID |
|
||||||
|
|
||||||
**Note:** `{id}` is the TabsManager instance ID, `{tab_id}` is the UUID of each tab.
|
**Note:** `{id}` is the TabsManager instance ID, `{tab_id}` is the UUID of each tab.
|
||||||
|
|
||||||
@@ -589,22 +614,22 @@ Div(id="{id}", cls="mf-tabs-manager")
|
|||||||
|
|
||||||
These methods are used internally for rendering:
|
These methods are used internally for rendering:
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|-----------------------------------------|-------------------------------------------------------|
|
|-----------------------------------------|-----------------------------------------------------|
|
||||||
| `_mk_tabs_controller(oob=False)` | Renders the hidden controller element |
|
| `_mk_tabs_controller(oob=False)` | Renders the hidden controller element |
|
||||||
| `_mk_tabs_header_wrapper(oob=False)` | Renders the header wrapper with buttons and search |
|
| `_mk_tabs_header_wrapper(oob=False)` | Renders the header wrapper with buttons and search |
|
||||||
| `_mk_tab_button(tab_data)` | Renders a single tab button |
|
| `_mk_tab_button(tab_data)` | Renders a single tab button |
|
||||||
| `_mk_tab_content_wrapper()` | Renders the content wrapper with active tab content |
|
| `_mk_tab_content_wrapper()` | Renders the content wrapper with active tab content |
|
||||||
| `_mk_tab_content(tab_id, content)` | Renders individual tab content div |
|
| `_mk_tab_content(tab_id, content)` | Renders individual tab content div |
|
||||||
| `_mk_show_tabs_menu()` | Renders the search dropdown menu |
|
| `_mk_show_tabs_menu()` | Renders the search dropdown menu |
|
||||||
| `_wrap_tab_content(tab_content)` | Wraps tab content for HTMX out-of-band insertion |
|
| `_wrap_tab_content(tab_content)` | Wraps tab content for HTMX out-of-band insertion |
|
||||||
| `_get_or_create_tab_content(tab_id)` | Gets tab content from cache or creates it |
|
| `_get_or_create_tab_content(tab_id)` | Gets tab content from cache or creates it |
|
||||||
| `_dynamic_get_content(tab_id)` | Retrieves component from InstancesManager |
|
| `_dynamic_get_content(tab_id)` | Retrieves component from InstancesManager |
|
||||||
| `_tab_already_exists(label, component)` | Checks if duplicate tab exists |
|
| `_tab_already_exists(label, component)` | Checks if duplicate tab exists |
|
||||||
| `_add_or_update_tab(...)` | Internal method to add/update tab in state |
|
| `_add_or_update_tab(...)` | Internal method to add/update tab in state |
|
||||||
| `_get_ordered_tabs()` | Returns tabs ordered by tabs_order list |
|
| `_get_ordered_tabs()` | Returns tabs ordered by tabs_order list |
|
||||||
| `_get_tab_list()` | Returns list of tab dictionaries in order |
|
| `_get_tab_list()` | Returns list of tab dictionaries in order |
|
||||||
| `_get_tab_count()` | Returns and increments internal tab counter |
|
| `_get_tab_count()` | Returns and increments internal tab counter |
|
||||||
|
|
||||||
### Tab Metadata Structure
|
### Tab Metadata Structure
|
||||||
|
|
||||||
@@ -612,11 +637,12 @@ Each tab in the `tabs` dictionary has the following structure:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
'id': 'uuid-string', # Unique tab identifier
|
'id': 'uuid-string', # Unique tab identifier
|
||||||
'label': 'Tab Label', # Display label
|
'label': 'Tab Label', # Display label
|
||||||
'component_type': 'prefix', # Component class prefix (or None)
|
'component_type': 'prefix', # Component class prefix (or None)
|
||||||
'component_id': 'instance-id' # Component instance ID (or None)
|
'component_id': 'instance-id' # Component instance ID (or None)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** `component_type` and `component_id` are `None` for plain FastHTML elements that don't inherit from `BaseInstance`.
|
**Note:** `component_type` and `component_id` are `None` for plain FastHTML elements that don't inherit from
|
||||||
|
`BaseInstance`.
|
||||||
|
|||||||
@@ -439,7 +439,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
padding: 1rem;
|
padding: 0.5rem;
|
||||||
border-top: 1px solid var(--color-border-primary);
|
border-top: 1px solid var(--color-border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,17 +662,31 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mf-panel-left {
|
/* Common properties for side panels */
|
||||||
|
.mf-panel-left,
|
||||||
|
.mf-panel-right {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 250px;
|
width: 250px;
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
max-width: 400px;
|
max-width: 500px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
transition: width 0.3s ease, min-width 0.3s ease, max-width 0.3s ease;
|
||||||
|
padding-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left panel specific */
|
||||||
|
.mf-panel-left {
|
||||||
border-right: 1px solid var(--color-border-primary);
|
border-right: 1px solid var(--color-border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Right panel specific */
|
||||||
|
.mf-panel-right {
|
||||||
|
border-left: 1px solid var(--color-border-primary);
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mf-panel-main {
|
.mf-panel-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -680,16 +694,46 @@
|
|||||||
min-width: 0; /* Important to allow the shrinking of flexbox */
|
min-width: 0; /* Important to allow the shrinking of flexbox */
|
||||||
}
|
}
|
||||||
|
|
||||||
.mf-panel-right {
|
/* Hidden state - common for both panels */
|
||||||
position: relative;
|
.mf-panel-left.mf-hidden,
|
||||||
flex-shrink: 0;
|
.mf-panel-right.mf-hidden {
|
||||||
width: 300px;
|
width: 0;
|
||||||
min-width: 150px;
|
min-width: 0;
|
||||||
max-width: 500px;
|
max-width: 0;
|
||||||
height: 100%;
|
overflow: hidden;
|
||||||
overflow: auto;
|
border: none;
|
||||||
border-left: 1px solid var(--color-border-primary);
|
padding: 0;
|
||||||
padding: 0.5rem;
|
}
|
||||||
|
|
||||||
|
/* No transition during manual resize - common for both panels */
|
||||||
|
.mf-panel-left.no-transition,
|
||||||
|
.mf-panel-right.no-transition {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Common properties for panel toggle icons */
|
||||||
|
.mf-panel-hide-icon,
|
||||||
|
.mf-panel-show-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-panel-hide-icon:hover,
|
||||||
|
.mf-panel-show-icon:hover {
|
||||||
|
background-color: var(--color-bg-hover, rgba(0, 0, 0, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show icon positioning */
|
||||||
|
.mf-panel-show-icon-left {
|
||||||
|
left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-panel-show-icon-right {
|
||||||
|
right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* *********************************************** */
|
/* *********************************************** */
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ function initResizer(containerId, options = {}) {
|
|||||||
// Add resizing class for visual feedback
|
// Add resizing class for visual feedback
|
||||||
document.body.classList.add('mf-resizing');
|
document.body.classList.add('mf-resizing');
|
||||||
currentItem.classList.add('mf-item-resizing');
|
currentItem.classList.add('mf-item-resizing');
|
||||||
|
// Disable transition during manual resize
|
||||||
|
currentItem.classList.add('no-transition');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,6 +116,8 @@ function initResizer(containerId, options = {}) {
|
|||||||
// Remove resizing classes
|
// Remove resizing classes
|
||||||
document.body.classList.remove('mf-resizing');
|
document.body.classList.remove('mf-resizing');
|
||||||
currentItem.classList.remove('mf-item-resizing');
|
currentItem.classList.remove('mf-item-resizing');
|
||||||
|
// Re-enable transition after manual resize
|
||||||
|
currentItem.classList.remove('no-transition');
|
||||||
|
|
||||||
// Get final width
|
// Get final width
|
||||||
const finalWidth = currentItem.offsetWidth;
|
const finalWidth = currentItem.offsetWidth;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class InstancesDebugger(SingleInstance):
|
|||||||
self._command = Command("ShowInstance",
|
self._command = Command("ShowInstance",
|
||||||
"Display selected Instance",
|
"Display selected Instance",
|
||||||
self,
|
self,
|
||||||
self.on_network_event).htmx(target=f"#{self._panel.get_id()}_r")
|
self.on_network_event).htmx(target=f"#{self._panel.get_ids().right}")
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
nodes, edges = self._get_nodes_and_edges()
|
nodes, edges = self._get_nodes_and_edges()
|
||||||
|
|||||||
@@ -5,23 +5,62 @@ from fasthtml.components import Div
|
|||||||
from fasthtml.xtend import Script
|
from fasthtml.xtend import Script
|
||||||
|
|
||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import MultipleInstance
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
from myfasthtml.icons.fluent_p1 import more_horizontal20_regular
|
||||||
|
from myfasthtml.icons.fluent_p2 import subtract20_regular
|
||||||
|
|
||||||
|
|
||||||
|
class PanelIds:
|
||||||
|
def __init__(self, owner):
|
||||||
|
self._owner = owner
|
||||||
|
|
||||||
|
@property
|
||||||
|
def main(self):
|
||||||
|
return f"{self._owner.get_id()}_m"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def right(self):
|
||||||
|
""" Right panel's content"""
|
||||||
|
return f"{self._owner.get_id()}_cr"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def left(self):
|
||||||
|
""" Left panel's content"""
|
||||||
|
return f"{self._owner.get_id()}_cl"
|
||||||
|
|
||||||
|
def panel(self, side: Literal["left", "right"]):
|
||||||
|
return f"{self._owner.get_id()}_pl" if side == "left" else f"{self._owner.get_id()}_pr"
|
||||||
|
|
||||||
|
def content(self, side: Literal["left", "right"]):
|
||||||
|
return self.left if side == "left" else self.right
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PanelConf:
|
class PanelConf:
|
||||||
left: bool = False
|
left: bool = True
|
||||||
right: bool = True
|
right: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class PanelState(DbObject):
|
||||||
|
def __init__(self, owner, name=None):
|
||||||
|
super().__init__(owner, name=name)
|
||||||
|
with self.initializing():
|
||||||
|
self.left_visible: bool = True
|
||||||
|
self.right_visible: bool = True
|
||||||
|
self.left_width: int = 250
|
||||||
|
self.right_width: int = 250
|
||||||
|
|
||||||
|
|
||||||
class Commands(BaseCommands):
|
class Commands(BaseCommands):
|
||||||
def toggle_side(self, side: Literal["left", "right"]):
|
def toggle_side(self, side: Literal["left", "right"], visible: bool = None):
|
||||||
return Command("TogglePanelSide",
|
return Command("TogglePanelSide",
|
||||||
f"Toggle {side} side panel",
|
f"Toggle {side} side panel",
|
||||||
self._owner,
|
self._owner,
|
||||||
self._owner.toggle_side,
|
self._owner.toggle_side,
|
||||||
args=[side])
|
args=[side, visible]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
||||||
|
|
||||||
def update_side_width(self, side: Literal["left", "right"]):
|
def update_side_width(self, side: Literal["left", "right"]):
|
||||||
"""
|
"""
|
||||||
@@ -37,7 +76,7 @@ class Commands(BaseCommands):
|
|||||||
f"Update {side} side panel width",
|
f"Update {side} side panel width",
|
||||||
self._owner,
|
self._owner,
|
||||||
self._owner.update_side_width,
|
self._owner.update_side_width,
|
||||||
args=[side])
|
args=[side]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
||||||
|
|
||||||
|
|
||||||
class Panel(MultipleInstance):
|
class Panel(MultipleInstance):
|
||||||
@@ -54,15 +93,30 @@ class Panel(MultipleInstance):
|
|||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.conf = conf or PanelConf()
|
self.conf = conf or PanelConf()
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
|
self._state = PanelState(self)
|
||||||
self._main = None
|
self._main = None
|
||||||
self._right = None
|
self._right = None
|
||||||
self._left = None
|
self._left = None
|
||||||
|
self._ids = PanelIds(self)
|
||||||
|
|
||||||
|
def get_ids(self):
|
||||||
|
return self._ids
|
||||||
|
|
||||||
def update_side_width(self, side, width):
|
def update_side_width(self, side, width):
|
||||||
pass
|
if side == "left":
|
||||||
|
self._state.left_width = width
|
||||||
|
else:
|
||||||
|
self._state.right_width = width
|
||||||
|
|
||||||
def toggle_side(self, side):
|
return self._mk_panel(side)
|
||||||
pass
|
|
||||||
|
def toggle_side(self, side, visible):
|
||||||
|
if side == "left":
|
||||||
|
self._state.left_visible = visible
|
||||||
|
else:
|
||||||
|
self._state.right_visible = visible
|
||||||
|
|
||||||
|
return self._mk_panel(side), self._mk_show_icon(side)
|
||||||
|
|
||||||
def set_main(self, main):
|
def set_main(self, main):
|
||||||
self._main = main
|
self._main = main
|
||||||
@@ -70,41 +124,93 @@ class Panel(MultipleInstance):
|
|||||||
|
|
||||||
def set_right(self, right):
|
def set_right(self, right):
|
||||||
self._right = right
|
self._right = right
|
||||||
return Div(self._right, id=f"{self._id}_r")
|
return Div(self._right, id=self._ids.right)
|
||||||
|
|
||||||
def set_left(self, left):
|
def set_left(self, left):
|
||||||
self._left = left
|
self._left = left
|
||||||
return Div(self._left, id=f"{self._id}_l")
|
return Div(self._left, id=self._ids.left)
|
||||||
|
|
||||||
def _mk_right(self):
|
def _mk_panel(self, side: Literal["left", "right"]):
|
||||||
if not self.conf.right:
|
enabled = self.conf.left if side == "left" else self.conf.right
|
||||||
|
if not enabled:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
visible = self._state.left_visible if side == "left" else self._state.right_visible
|
||||||
|
content = self._right if side == "right" else self._left
|
||||||
|
|
||||||
resizer = Div(
|
resizer = Div(
|
||||||
cls="mf-resizer mf-resizer-right",
|
cls=f"mf-resizer mf-resizer-{side}",
|
||||||
data_command_id=self.commands.update_side_width("right").id,
|
data_command_id=self.commands.update_side_width(side).id,
|
||||||
data_side="right"
|
data_side=side
|
||||||
)
|
)
|
||||||
|
|
||||||
return Div(resizer, Div(self._right, id=f"{self._id}_r"), cls="mf-panel-right")
|
hide_icon = mk.icon(
|
||||||
|
subtract20_regular,
|
||||||
|
size=20,
|
||||||
|
command=self.commands.toggle_side(side, False),
|
||||||
|
cls="mf-panel-hide-icon"
|
||||||
|
)
|
||||||
|
|
||||||
def _mk_left(self):
|
panel_cls = f"mf-panel-{side}"
|
||||||
if not self.conf.left:
|
if not visible:
|
||||||
|
panel_cls += " mf-hidden"
|
||||||
|
|
||||||
|
# Left panel: content then resizer (resizer on the right)
|
||||||
|
# Right panel: resizer then content (resizer on the left)
|
||||||
|
if side == "left":
|
||||||
|
return Div(
|
||||||
|
hide_icon,
|
||||||
|
Div(content, id=self._ids.content(side)),
|
||||||
|
resizer,
|
||||||
|
cls=panel_cls,
|
||||||
|
id=self._ids.panel(side)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Div(
|
||||||
|
resizer,
|
||||||
|
hide_icon,
|
||||||
|
Div(content, id=self._ids.content(side)),
|
||||||
|
cls=panel_cls,
|
||||||
|
id=self._ids.panel(side)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mk_main(self):
|
||||||
|
return Div(
|
||||||
|
self._mk_show_icon("left"),
|
||||||
|
Div(self._main, id=self._ids.main, cls="mf-panel-main"),
|
||||||
|
self._mk_show_icon("right"),
|
||||||
|
cls="mf-panel-main"
|
||||||
|
),
|
||||||
|
|
||||||
|
def _mk_show_icon(self, side: Literal["left", "right"]):
|
||||||
|
"""
|
||||||
|
Create show icon for a panel side if it's hidden.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
side: Which panel side ("left" or "right")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Div with icon if panel is hidden, None otherwise
|
||||||
|
"""
|
||||||
|
enabled = self.conf.left if side == "left" else self.conf.right
|
||||||
|
if not enabled:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
resizer = Div(
|
is_visible = self._state.left_visible if side == "left" else self._state.right_visible
|
||||||
cls="mf-resizer mf-resizer-left",
|
icon_cls = "hidden" if is_visible else f"mf-panel-show-icon mf-panel-show-icon-{side}"
|
||||||
data_command_id=self.commands.update_side_width("left").id,
|
|
||||||
data_side="left"
|
|
||||||
)
|
|
||||||
|
|
||||||
return Div(Div(self._left, id=f"{self._id}_l"), resizer, cls="mf-panel-left")
|
return mk.icon(
|
||||||
|
more_horizontal20_regular,
|
||||||
|
command=self.commands.toggle_side(side, True),
|
||||||
|
cls=icon_cls,
|
||||||
|
id=f"{self._id}_show_{side}"
|
||||||
|
)
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
return Div(
|
return Div(
|
||||||
self._mk_left(),
|
self._mk_panel("left"),
|
||||||
Div(self._main, cls="mf-panel-main"),
|
self._mk_main(),
|
||||||
self._mk_right(),
|
self._mk_panel("right"),
|
||||||
Script(f"initResizer('{self._id}');"),
|
Script(f"initResizer('{self._id}');"),
|
||||||
cls="mf-panel",
|
cls="mf-panel",
|
||||||
id=self._id,
|
id=self._id,
|
||||||
|
|||||||
502
tests/controls/test_panel.py
Normal file
502
tests/controls/test_panel.py
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
"""Unit tests for Panel component."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fasthtml.components import *
|
||||||
|
|
||||||
|
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||||
|
from myfasthtml.test.matcher import matches, find, Contains, find_one, TestIcon, TestScript, TestIconNotStr
|
||||||
|
from .conftest import root_instance
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def cleanup_db():
|
||||||
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPanelBehaviour:
|
||||||
|
"""Tests for Panel behavior and logic."""
|
||||||
|
|
||||||
|
# 1. Creation and initialization
|
||||||
|
|
||||||
|
def test_i_can_create_panel_with_default_config(self, root_instance):
|
||||||
|
"""Test that a Panel can be created with default configuration."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
|
||||||
|
assert panel is not None
|
||||||
|
assert panel.conf.left is True
|
||||||
|
assert panel.conf.right is True
|
||||||
|
|
||||||
|
def test_i_can_create_panel_with_custom_config(self, root_instance):
|
||||||
|
"""Test that a Panel accepts a custom PanelConf."""
|
||||||
|
custom_conf = PanelConf(left=False, right=True)
|
||||||
|
panel = Panel(root_instance, conf=custom_conf)
|
||||||
|
|
||||||
|
assert panel.conf.left is False
|
||||||
|
assert panel.conf.right is True
|
||||||
|
|
||||||
|
def test_panel_has_default_state_after_creation(self, root_instance):
|
||||||
|
"""Test that _state has correct initial values."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
state = panel._state
|
||||||
|
|
||||||
|
assert state.left_visible is True
|
||||||
|
assert state.right_visible is True
|
||||||
|
assert state.left_width == 250
|
||||||
|
assert state.right_width == 250
|
||||||
|
|
||||||
|
def test_panel_creates_commands_instance(self, root_instance):
|
||||||
|
"""Test that panel.commands exists and is of type Commands."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
|
||||||
|
assert panel.commands is not None
|
||||||
|
assert panel.commands.__class__.__name__ == "Commands"
|
||||||
|
|
||||||
|
# 2. Content management
|
||||||
|
|
||||||
|
def test_i_can_set_main_content(self, root_instance):
|
||||||
|
"""Test that set_main() stores content in _main."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
content = Div("Main content")
|
||||||
|
|
||||||
|
panel.set_main(content)
|
||||||
|
|
||||||
|
assert panel._main == content
|
||||||
|
|
||||||
|
def test_set_main_returns_self(self, root_instance):
|
||||||
|
"""Test that set_main() returns self for method chaining."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
content = Div("Main content")
|
||||||
|
|
||||||
|
result = panel.set_main(content)
|
||||||
|
|
||||||
|
assert result is panel
|
||||||
|
|
||||||
|
def test_i_can_set_left_content(self, root_instance):
|
||||||
|
"""Test that set_left() stores content in _left."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
content = Div("Left content")
|
||||||
|
|
||||||
|
panel.set_left(content)
|
||||||
|
|
||||||
|
assert panel._left == content
|
||||||
|
|
||||||
|
def test_i_can_set_right_content(self, root_instance):
|
||||||
|
"""Test that set_right() stores content in _right."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
content = Div("Right content")
|
||||||
|
|
||||||
|
panel.set_right(content)
|
||||||
|
|
||||||
|
assert panel._right == content
|
||||||
|
|
||||||
|
# 3. Toggle visibility
|
||||||
|
|
||||||
|
def test_i_can_hide_left_panel(self, root_instance):
|
||||||
|
"""Test that toggle_side('left', False) sets _state.left_visible to False."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
|
||||||
|
panel.toggle_side("left", False)
|
||||||
|
|
||||||
|
assert panel._state.left_visible is False
|
||||||
|
|
||||||
|
def test_i_can_show_left_panel(self, root_instance):
|
||||||
|
"""Test that toggle_side('left', True) sets _state.left_visible to True."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
panel._state.left_visible = False
|
||||||
|
|
||||||
|
panel.toggle_side("left", True)
|
||||||
|
|
||||||
|
assert panel._state.left_visible is True
|
||||||
|
|
||||||
|
def test_i_can_hide_right_panel(self, root_instance):
|
||||||
|
"""Test that toggle_side('right', False) sets _state.right_visible to False."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
|
||||||
|
panel.toggle_side("right", False)
|
||||||
|
|
||||||
|
assert panel._state.right_visible is False
|
||||||
|
|
||||||
|
def test_i_can_show_right_panel(self, root_instance):
|
||||||
|
"""Test that toggle_side('right', True) sets _state.right_visible to True."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
panel._state.right_visible = False
|
||||||
|
|
||||||
|
panel.toggle_side("right", True)
|
||||||
|
|
||||||
|
assert panel._state.right_visible is True
|
||||||
|
|
||||||
|
def test_toggle_side_returns_panel_and_icon(self, root_instance):
|
||||||
|
"""Test that toggle_side() returns a tuple (panel_element, show_icon_element)."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
|
||||||
|
result = panel.toggle_side("left", False)
|
||||||
|
|
||||||
|
assert isinstance(result, tuple)
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
# 4. Width management
|
||||||
|
|
||||||
|
def test_i_can_update_left_panel_width(self, root_instance):
|
||||||
|
"""Test that update_side_width('left', 300) sets _state.left_width to 300."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
|
||||||
|
panel.update_side_width("left", 300)
|
||||||
|
|
||||||
|
assert panel._state.left_width == 300
|
||||||
|
|
||||||
|
def test_i_can_update_right_panel_width(self, root_instance):
|
||||||
|
"""Test that update_side_width('right', 400) sets _state.right_width to 400."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
|
||||||
|
panel.update_side_width("right", 400)
|
||||||
|
|
||||||
|
assert panel._state.right_width == 400
|
||||||
|
|
||||||
|
def test_update_width_returns_panel_element(self, root_instance):
|
||||||
|
"""Test that update_side_width() returns a panel element."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
|
||||||
|
result = panel.update_side_width("left", 300)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
# 5. Configuration
|
||||||
|
|
||||||
|
def test_disabled_left_panel_returns_none(self, root_instance):
|
||||||
|
"""Test that _mk_panel('left') returns None when conf.left=False."""
|
||||||
|
custom_conf = PanelConf(left=False, right=True)
|
||||||
|
panel = Panel(root_instance, conf=custom_conf)
|
||||||
|
|
||||||
|
result = panel._mk_panel("left")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_disabled_right_panel_returns_none(self, root_instance):
|
||||||
|
"""Test that _mk_panel('right') returns None when conf.right=False."""
|
||||||
|
custom_conf = PanelConf(left=True, right=False)
|
||||||
|
panel = Panel(root_instance, conf=custom_conf)
|
||||||
|
|
||||||
|
result = panel._mk_panel("right")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_disabled_panel_show_icon_returns_none(self, root_instance):
|
||||||
|
"""Test that _mk_show_icon() returns None when the panel is disabled."""
|
||||||
|
custom_conf = PanelConf(left=False, right=True)
|
||||||
|
panel = Panel(root_instance, conf=custom_conf)
|
||||||
|
|
||||||
|
result = panel._mk_show_icon("left")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestPanelRender:
|
||||||
|
"""Tests for Panel HTML rendering."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def panel(self, root_instance):
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
panel.set_main(Div("Main content"))
|
||||||
|
panel.set_left(Div("Left content"))
|
||||||
|
panel.set_right(Div("Right content"))
|
||||||
|
return panel
|
||||||
|
|
||||||
|
# 1. Global structure (UTR-11.1 - FIRST TEST)
|
||||||
|
|
||||||
|
def test_i_can_render_panel_with_default_state(self, panel):
|
||||||
|
"""Test that Panel renders with correct global structure.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- 4 children: Verifies all main sections are rendered (left panel, main, right panel, script)
|
||||||
|
- _id: Essential for panel identification and resizer initialization
|
||||||
|
- cls="mf-panel": Root CSS class for panel styling
|
||||||
|
"""
|
||||||
|
expected = Div(
|
||||||
|
Div(), # left panel
|
||||||
|
Div(), # main
|
||||||
|
Div(), # right panel
|
||||||
|
Script(),
|
||||||
|
id=panel._id,
|
||||||
|
cls="mf-panel"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(panel.render(), expected)
|
||||||
|
|
||||||
|
# 2. Left panel
|
||||||
|
|
||||||
|
def test_left_panel_renders_with_correct_structure(self, panel):
|
||||||
|
"""Test that left panel has content div before resizer.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Order (content then resizer): Critical for positioning resizer on the right side
|
||||||
|
- id: Required for HTMX targeting during toggle/resize operations
|
||||||
|
- cls Contains "mf-panel-left": CSS class for left panel styling
|
||||||
|
"""
|
||||||
|
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||||
|
|
||||||
|
# Step 1: Validate left panel global structure
|
||||||
|
expected = Div(
|
||||||
|
TestIcon("subtract20_regular"),
|
||||||
|
Div(id=panel.get_ids().left), # content div, tested in detail later
|
||||||
|
Div(cls=Contains("mf-resizer-left")), # resizer
|
||||||
|
id=panel.get_ids().panel("left"),
|
||||||
|
cls=Contains("mf-panel-left")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(left_panel, expected)
|
||||||
|
|
||||||
|
def test_left_panel_has_mf_hidden_class_when_not_visible(self, panel):
|
||||||
|
"""Test that left panel has 'mf-hidden' class when not visible.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- cls Contains "mf-hidden": CSS class required for width animation
|
||||||
|
"""
|
||||||
|
panel._state.left_visible = False
|
||||||
|
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||||
|
|
||||||
|
expected = Div(cls=Contains("mf-hidden"))
|
||||||
|
|
||||||
|
assert matches(left_panel, expected)
|
||||||
|
|
||||||
|
def test_left_panel_does_not_render_when_disabled(self, panel):
|
||||||
|
"""Test that render() does not contain left panel when conf.left=False.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Absence of left panel: Configuration must prevent rendering
|
||||||
|
"""
|
||||||
|
panel.conf.left = False
|
||||||
|
rendered = panel.render()
|
||||||
|
|
||||||
|
# Verify left panel is not present
|
||||||
|
left_panels = find(rendered, Div(id=panel.get_ids().panel("left")))
|
||||||
|
assert len(left_panels) == 0, "Left panel should not be present when conf.left=False"
|
||||||
|
|
||||||
|
# 3. Right panel
|
||||||
|
|
||||||
|
def test_right_panel_renders_with_correct_structure(self, panel):
|
||||||
|
"""Test that right panel has resizer before content div.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Order (resizer then hide icon then content): Critical for positioning resizer on the left side
|
||||||
|
- id: Required for HTMX targeting during toggle/resize operations
|
||||||
|
- cls Contains "mf-panel-right": CSS class for right panel styling
|
||||||
|
"""
|
||||||
|
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||||
|
|
||||||
|
# Step 1: Validate right panel global structure
|
||||||
|
expected = Div(
|
||||||
|
Div(cls=Contains("mf-resizer-right")), # resizer
|
||||||
|
TestIcon("subtract20_regular"), # hide icon
|
||||||
|
Div(id=panel.get_ids().right), # content div, tested in detail later
|
||||||
|
id=panel.get_ids().panel("right"),
|
||||||
|
cls=Contains("mf-panel-right")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(right_panel, expected)
|
||||||
|
|
||||||
|
def test_right_panel_has_mf_hidden_class_when_not_visible(self, panel):
|
||||||
|
"""Test that right panel has 'mf-hidden' class when not visible.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- cls Contains "mf-hidden": CSS class required for width animation
|
||||||
|
"""
|
||||||
|
panel._state.right_visible = False
|
||||||
|
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||||
|
|
||||||
|
expected = Div(cls=Contains("mf-hidden"))
|
||||||
|
|
||||||
|
assert matches(right_panel, expected)
|
||||||
|
|
||||||
|
def test_right_panel_does_not_render_when_disabled(self, panel):
|
||||||
|
"""Test that render() does not contain right panel when conf.right=False.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Absence of right panel: Configuration must prevent rendering
|
||||||
|
"""
|
||||||
|
panel.conf.right = False
|
||||||
|
rendered = panel.render()
|
||||||
|
|
||||||
|
# Verify right panel is not present
|
||||||
|
right_panels = find(rendered, Div(id=panel.get_ids().panel("right")))
|
||||||
|
assert len(right_panels) == 0, "Right panel should not be present when conf.right=False"
|
||||||
|
|
||||||
|
# 4. Resizers
|
||||||
|
|
||||||
|
def test_left_panel_has_resizer_with_correct_attributes(self, panel):
|
||||||
|
"""Test that left panel resizer has required attributes.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- data_side="left": JavaScript uses this to determine which side is being resized
|
||||||
|
- data_command_id: Required to trigger update_side_width command via HTMX
|
||||||
|
- cls Contains "mf-resizer": Base CSS class for resizer styling
|
||||||
|
- cls Contains "mf-resizer-left": Left-specific CSS class for positioning
|
||||||
|
"""
|
||||||
|
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||||
|
resizer = find_one(left_panel, Div(cls=Contains("mf-resizer-left")))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
data_side="left",
|
||||||
|
cls=Contains("mf-resizer", "mf-resizer-left")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(resizer, expected)
|
||||||
|
# Verify data-command-id exists (value is dynamic, HTML uses hyphens)
|
||||||
|
assert "data-command-id" in resizer.attrs
|
||||||
|
|
||||||
|
def test_right_panel_has_resizer_with_correct_attributes(self, panel):
|
||||||
|
"""Test that right panel resizer has required attributes.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- data_side="right": JavaScript uses this to determine which side is being resized
|
||||||
|
- data_command_id: Required to trigger update_side_width command via HTMX
|
||||||
|
- cls Contains "mf-resizer": Base CSS class for resizer styling
|
||||||
|
- cls Contains "mf-resizer-right": Right-specific CSS class for positioning
|
||||||
|
"""
|
||||||
|
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||||
|
resizer = find_one(right_panel, Div(cls=Contains("mf-resizer-right")))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
data_side="right",
|
||||||
|
cls=Contains("mf-resizer", "mf-resizer-right")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(resizer, expected)
|
||||||
|
# Verify data-command-id exists (value is dynamic, HTML uses hyphens)
|
||||||
|
assert "data-command-id" in resizer.attrs
|
||||||
|
|
||||||
|
# 5. Icons
|
||||||
|
|
||||||
|
def test_hide_icon_in_left_panel_has_correct_command(self, panel):
|
||||||
|
"""Test that hide icon in left panel triggers toggle_side command.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- TestIconNotStr("subtract20_regular"): Verify correct icon is used for hiding
|
||||||
|
- cls Contains "mf-panel-hide-icon": CSS class for hide icon positioning
|
||||||
|
"""
|
||||||
|
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||||
|
|
||||||
|
# Find the hide icon (should be wrapped by mk.icon)
|
||||||
|
hide_icons = find(left_panel, Div(cls=Contains("mf-panel-hide-icon")))
|
||||||
|
assert len(hide_icons) == 1, "Left panel should contain exactly one hide icon"
|
||||||
|
|
||||||
|
# Verify it contains the subtract icon
|
||||||
|
expected = Div(
|
||||||
|
TestIconNotStr("subtract20_regular"),
|
||||||
|
cls=Contains("mf-panel-hide-icon")
|
||||||
|
)
|
||||||
|
assert matches(hide_icons[0], expected)
|
||||||
|
|
||||||
|
def test_hide_icon_in_right_panel_has_correct_command(self, panel):
|
||||||
|
"""Test that hide icon in right panel triggers toggle_side command.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- TestIconNotStr("subtract20_regular"): Verify correct icon is used for hiding
|
||||||
|
- cls Contains "mf-panel-hide-icon": CSS class for hide icon positioning
|
||||||
|
"""
|
||||||
|
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||||
|
|
||||||
|
# Find the hide icon (should be wrapped by mk.icon)
|
||||||
|
hide_icons = find(right_panel, Div(cls=Contains("mf-panel-hide-icon")))
|
||||||
|
assert len(hide_icons) == 1, "Right panel should contain exactly one hide icon"
|
||||||
|
|
||||||
|
# Verify it contains the subtract icon
|
||||||
|
expected = Div(
|
||||||
|
TestIconNotStr("subtract20_regular"),
|
||||||
|
cls=Contains("mf-panel-hide-icon")
|
||||||
|
)
|
||||||
|
assert matches(hide_icons[0], expected)
|
||||||
|
|
||||||
|
def test_show_icon_left_is_hidden_when_panel_visible(self, panel):
|
||||||
|
"""Test that show icon has 'hidden' class when left panel is visible.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- cls Contains "hidden": Tailwind class to hide icon when panel is visible
|
||||||
|
- id: Required for HTMX swap-oob targeting
|
||||||
|
"""
|
||||||
|
show_icon = find_one(panel.render(), Div(id=f"{panel._id}_show_left"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
cls=Contains("hidden"),
|
||||||
|
id=f"{panel._id}_show_left"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(show_icon, expected)
|
||||||
|
|
||||||
|
def test_show_icon_left_is_visible_when_panel_hidden(self, panel):
|
||||||
|
"""Test that show icon is positioned left when left panel is hidden.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- cls Contains "mf-panel-show-icon-left": CSS class for left positioning in main panel
|
||||||
|
- TestIconNotStr("more_horizontal20_regular"): Verify correct icon is used for showing
|
||||||
|
"""
|
||||||
|
panel._state.left_visible = False
|
||||||
|
show_icon = find_one(panel.render(), Div(id=f"{panel._id}_show_left"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
TestIconNotStr("more_horizontal20_regular"),
|
||||||
|
cls=Contains("mf-panel-show-icon-left"),
|
||||||
|
id=f"{panel._id}_show_left"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(show_icon, expected)
|
||||||
|
|
||||||
|
def test_show_icon_right_is_visible_when_panel_hidden(self, panel):
|
||||||
|
"""Test that show icon is positioned right when right panel is hidden.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- cls Contains "mf-panel-show-icon-right": CSS class for right positioning in main panel
|
||||||
|
- TestIconNotStr("more_horizontal20_regular"): Verify correct icon is used for showing
|
||||||
|
"""
|
||||||
|
panel._state.right_visible = False
|
||||||
|
show_icon = find_one(panel.render(), Div(id=f"{panel._id}_show_right"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
TestIconNotStr("more_horizontal20_regular"),
|
||||||
|
cls=Contains("mf-panel-show-icon-right"),
|
||||||
|
id=f"{panel._id}_show_right"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(show_icon, expected)
|
||||||
|
|
||||||
|
# 6. Main panel
|
||||||
|
|
||||||
|
def test_main_panel_contains_show_icons_and_content(self, panel):
|
||||||
|
"""Test that main panel contains show icons and content in correct order.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- 3 children: show_icon_left + inner main div + show_icon_right
|
||||||
|
- Order: Show icons must be positioned correctly (left then right)
|
||||||
|
- cls="mf-panel-main": CSS class for main panel styling
|
||||||
|
- Inner div with id: Main content wrapper for HTMX targeting
|
||||||
|
"""
|
||||||
|
# Find all Divs with cls="mf-panel-main" (there are 2: outer wrapper and inner content)
|
||||||
|
main_panels = find(panel.render(), Div(cls=Contains("mf-panel-main")))
|
||||||
|
assert len(main_panels) == 2, "Should find outer wrapper and inner content div"
|
||||||
|
|
||||||
|
# The outer wrapper is the first one (depth-first search)
|
||||||
|
main_panel = main_panels[0]
|
||||||
|
|
||||||
|
# Step 1: Validate main panel structure
|
||||||
|
expected = Div(
|
||||||
|
Div(id=f"{panel._id}_show_left"), # show icon left
|
||||||
|
Div(id=panel.get_ids().main), # inner main content wrapper
|
||||||
|
Div(id=f"{panel._id}_show_right"), # show icon right
|
||||||
|
cls="mf-panel-main"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(main_panel, expected)
|
||||||
|
|
||||||
|
# 7. Script
|
||||||
|
|
||||||
|
def test_init_resizer_script_is_present(self, panel):
|
||||||
|
"""Test that initResizer script is present with correct panel ID.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Script content: Must call initResizer with panel ID for resize functionality
|
||||||
|
"""
|
||||||
|
script = find_one(panel.render(), Script())
|
||||||
|
|
||||||
|
expected = TestScript(f"initResizer('{panel._id}');")
|
||||||
|
|
||||||
|
assert matches(script, expected)
|
||||||
Reference in New Issue
Block a user