I can show and hide the columns comanger

This commit is contained in:
2026-01-25 11:29:18 +01:00
parent 7f3e6270a2
commit 3abfab8e97
8 changed files with 656 additions and 228 deletions

View File

@@ -685,6 +685,56 @@ assert matches(element, expected)
---
### UTR-16: Propose Parameterized Tests
**Rule:** When proposing a test plan, systematically identify tests that can be parameterized and propose them as such.
**When to parameterize:**
- Tests that follow the same pattern with different input values
- Tests that verify the same behavior for different sides/directions (left/right, up/down)
- Tests that check the same logic with different states (visible/hidden, enabled/disabled)
- Tests that validate the same method with different valid inputs
**How to identify candidates:**
1. Look for tests with similar names differing only by a value (e.g., `test_left_panel_...` and `test_right_panel_...`)
2. Look for tests that have identical structure but different parameters
3. Look for combinatorial scenarios (side × state combinations)
**How to propose:**
In your test plan, explicitly show:
1. The individual tests that would be written without parameterization
2. The parameterized version with all test cases
3. The reduction in test count
**Example proposal:**
```
**Without parameterization (4 tests):**
- test_i_can_toggle_left_panel_from_visible_to_hidden
- test_i_can_toggle_left_panel_from_hidden_to_visible
- test_i_can_toggle_right_panel_from_visible_to_hidden
- test_i_can_toggle_right_panel_from_hidden_to_visible
**With parameterization (1 test, 4 cases):**
@pytest.mark.parametrize("side, initial, expected", [
("left", True, False),
("left", False, True),
("right", True, False),
("right", False, True),
])
def test_i_can_toggle_panel_visibility(...)
**Result:** 1 test instead of 4, same coverage
```
**Benefits:**
- Reduces code duplication
- Makes it easier to add new test cases
- Improves maintainability
- Makes the test matrix explicit
---
## Managing Rules
To disable a specific rule, the user can say:

View File

@@ -9,6 +9,7 @@ panels.
**Key features:**
- Three customizable zones (left panel, main content, right panel)
- Configurable panel titles with sticky headers
- Toggle visibility with hide/show icons
- Resizable panels with drag handles
- Smooth CSS animations for show/hide transitions
@@ -108,10 +109,37 @@ The Panel component consists of three zones with optional side panels:
| 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 |
| Hide icon () | Inside each panel header, right side |
| Show icon (⋯) | In main area when panel is hidden |
| Resizer (║) | Drag handle to resize panels manually |
**Panel with title (default):**
When `show_left_title` or `show_right_title` is `True` (default), panels display a sticky header with title and hide icon:
```
┌─────────────────────────────┐
│ Title [] │ ← Header (sticky, always visible)
├─────────────────────────────┤
│ │
│ Scrollable Content │ ← Content area (scrolls independently)
│ │
└─────────────────────────────┘
```
**Panel without title:**
When `show_left_title` or `show_right_title` is `False`, panels use the legacy layout:
```
┌─────────────────────────────┐
│ [] │ ← Hide icon at top-right (absolute)
│ │
│ Content │
│ │
└─────────────────────────────┘
```
### Creating a Panel
The Panel is a `MultipleInstance`, meaning you can create multiple independent panels in your application. Create it by
@@ -196,7 +224,7 @@ panel = Panel(parent=root_instance)
### Panel Configuration
By default, both left and right panels are enabled. You can customize this with `PanelConf`:
By default, both left and right panels are enabled with titles. You can customize this with `PanelConf`:
```python
from myfasthtml.controls.Panel import PanelConf
@@ -218,6 +246,49 @@ conf = PanelConf(left=False, right=False)
panel = Panel(parent=root_instance, conf=conf)
```
**Customizing panel titles:**
```python
# Custom titles for panels
conf = PanelConf(
left=True,
right=True,
left_title="Explorer", # Custom title for left panel
right_title="Properties" # Custom title for right panel
)
panel = Panel(parent=root_instance, conf=conf)
```
**Disabling panel titles:**
When titles are disabled, panels use the legacy layout without a sticky header:
```python
# Disable titles (legacy layout)
conf = PanelConf(
left=True,
right=True,
show_left_title=False,
show_right_title=False
)
panel = Panel(parent=root_instance, conf=conf)
```
**Disabling show icons:**
You can hide the show icons (⋯) that appear when panels are hidden. This means users can only show panels programmatically:
```python
# Disable show icons (programmatic control only)
conf = PanelConf(
left=True,
right=True,
show_display_left=False, # No show icon for left panel
show_display_right=False # No show icon for right panel
)
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.
@@ -321,8 +392,8 @@ 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
toggle_left = panel.commands.set_side_visible("left", visible=False) # Hide left
toggle_right = panel.commands.set_side_visible("right", visible=True) # Show right
# Update panel width
update_left_width = panel.commands.update_side_width("left")
@@ -335,8 +406,8 @@ These commands are typically used with buttons or other interactive elements:
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))
hide_left_btn = mk.button("Hide Left", command=panel.commands.set_side_visible("left", False))
show_left_btn = mk.button("Show Left", command=panel.commands.set_side_visible("left", True))
# Add to your layout
panel.set_main(
@@ -365,11 +436,15 @@ panel.set_main(
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-with-title` | Panel using title layout (no padding-top) |
| `mf-panel-body` | Grid container for header + content |
| `mf-panel-header` | Sticky header with title and hide icon |
| `mf-panel-content` | Scrollable 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 |
@@ -641,13 +716,13 @@ panel.set_right(
# Create control buttons
toggle_left_btn = mk.button(
"Toggle Left Panel",
command=panel.commands.toggle_side("left", False),
command=panel.commands.set_side_visible("left", False),
cls="btn btn-sm"
)
toggle_right_btn = mk.button(
"Toggle Right Panel",
command=panel.commands.toggle_side("right", False),
command=panel.commands.set_side_visible("right", False),
cls="btn btn-sm"
)
@@ -697,9 +772,15 @@ This section contains technical details for developers working on the Panel comp
The Panel component uses `PanelConf` dataclass for configuration:
| Property | Type | Description | Default |
|----------|---------|----------------------------|---------|
| `left` | boolean | Enable/disable left panel | `True` |
|----------------------|---------|-------------------------------------------|-----------|
| `left` | boolean | Enable/disable left panel | `False` |
| `right` | boolean | Enable/disable right panel | `True` |
| `left_title` | string | Title displayed in left panel header | `"Left"` |
| `right_title` | string | Title displayed in right panel header | `"Right"` |
| `show_left_title` | boolean | Show title header on left panel | `True` |
| `show_right_title` | boolean | Show title header on right panel | `True` |
| `show_display_left` | boolean | Show the "show" icon when left is hidden | `True` |
| `show_display_right` | boolean | Show the "show" icon when right is hidden | `True` |
### State
@@ -735,10 +816,40 @@ codebase.
### High Level Hierarchical Structure
**With title (default, `show_*_title=True`):**
```
Div(id="{id}", cls="mf-panel")
├── Div(id="{id}_pl", cls="mf-panel-left mf-panel-with-title [mf-hidden]")
│ ├── Div(cls="mf-panel-body")
│ │ ├── Div(cls="mf-panel-header")
│ │ │ ├── Div [Title text]
│ │ │ └── Div (hide icon)
│ │ └── Div(id="{id}_cl", cls="mf-panel-content")
│ │ └── [Left content - scrollable]
│ └── 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-panel-with-title [mf-hidden]")
│ ├── Div (resizer-right)
│ └── Div(cls="mf-panel-body")
│ ├── Div(cls="mf-panel-header")
│ │ ├── Div [Title text]
│ │ └── Div (hide icon)
│ └── Div(id="{id}_cr", cls="mf-panel-content")
│ └── [Right content - scrollable]
└── Script # initResizer('{id}')
```
**Without title (legacy, `show_*_title=False`):**
```
Div(id="{id}", cls="mf-panel")
├── Div(id="{id}_pl", cls="mf-panel-left [mf-hidden]")
│ ├── Div (hide icon)
│ ├── Div (hide icon - absolute positioned)
│ ├── Div(id="{id}_cl")
│ │ └── [Left content]
│ └── Div (resizer-left)
@@ -749,7 +860,7 @@ Div(id="{id}", cls="mf-panel")
│ └── 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 (hide icon - absolute positioned)
│ └── Div(id="{id}_cr")
│ └── [Right content]
└── Script # initResizer('{id}')
@@ -757,11 +868,12 @@ Div(id="{id}", cls="mf-panel")
**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
- With title: uses grid layout (`mf-panel-body`) with sticky header and scrollable content
- Without title: hide icon is absolutely positioned at top-right with padding-top on panel
- Left panel: body/content then resizer (resizer on right edge)
- Right panel: resizer then body/content (resizer on left edge)
- `[mf-hidden]` class is conditionally applied when panel is hidden
- `mf-panel-with-title` class removes default padding-top when using title layout
### Element IDs

View File

@@ -459,7 +459,7 @@
.mf-search-results {
margin-top: 0.5rem;
max-height: 400px;
/*max-height: 400px;*/
overflow: auto;
}
@@ -769,6 +769,38 @@
right: 0.5rem;
}
/* Panel with title - grid layout for header + scrollable content */
.mf-panel-body {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
overflow: hidden;
}
.mf-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.5rem;
background-color: var(--color-base-200);
border-bottom: 1px solid var(--color-border);
}
/* Override absolute positioning for hide icon when inside header */
.mf-panel-header .mf-panel-hide-icon {
position: static;
}
.mf-panel-content {
overflow-y: auto;
}
/* Remove padding-top when using title layout */
.mf-panel-left.mf-panel-with-title,
.mf-panel-right.mf-panel-with-title {
padding-top: 0;
}
/* *********************************************** */
/* ************* Properties Component ************ */
/* *********************************************** */

View File

@@ -14,7 +14,7 @@ from myfasthtml.controls.CycleStateControl import CycleStateControl
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER
from myfasthtml.controls.Mouse import Mouse
from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.Panel import Panel, PanelConf
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
from myfasthtml.controls.helpers import mk
@@ -26,6 +26,7 @@ from myfasthtml.core.optimized_ft import OptimizedDiv
from myfasthtml.core.utils import make_safe_id
from myfasthtml.icons.carbon import row, column, grid
from myfasthtml.icons.fluent import checkbox_unchecked16_regular
from myfasthtml.icons.fluent_p1 import settings16_regular
from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular
# OPTIMIZATION: Pre-compiled regex to detect HTML special characters
@@ -130,6 +131,13 @@ class Commands(BaseCommands):
self._owner.on_click
).htmx(target=f"#tsm_{self._id}")
def toggle_columns_manager(self):
return Command("ToggleColumnsManager",
"Toggle Columns Manager",
self._owner,
self._owner.toggle_columns_manager
).htmx(target=None)
class DataGrid(MultipleInstance):
def __init__(self, parent, settings=None, save_state=None, _id=None):
@@ -139,7 +147,10 @@ class DataGrid(MultipleInstance):
self.commands = Commands(self)
self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState
self._panel = Panel(self, _id="-panel")
# add Panel
self._panel = Panel(self, conf=PanelConf(right_title="Columns", show_display_right=False), _id="-panel")
self._panel.set_side_visible("right", False) # the right Panel always starts closed
self.bind_command("ToggleColumnsManager", self._panel.commands.toggle_side("right"))
# add DataGridQuery
self._datagrid_filter = DataGridQuery(self)
@@ -362,6 +373,10 @@ class DataGrid(MultipleInstance):
self._state.save()
return self.render_partial()
def toggle_columns_manager(self):
logger.debug(f"toggle_columns_manager")
self._panel.set_right(self._columns_manager)
def mk_headers(self):
resize_cmd = self.commands.set_column_width()
move_cmd = self.commands.move_column()
@@ -649,7 +664,7 @@ class DataGrid(MultipleInstance):
Div(self._datagrid_filter,
Div(
self._selection_mode_selector,
self._columns_manager,
mk.icon(settings16_regular, command=self.commands.toggle_columns_manager(), tooltip="Show sidebar"),
cls="flex"),
cls="flex items-center justify-between mb-2"),
self._panel.set_main(self.mk_table()),

View File

@@ -1,17 +1,17 @@
from fasthtml.components import *
from myfasthtml.controls.Dropdown import Dropdown
from myfasthtml.controls.Search import Search
from myfasthtml.controls.datagrid_objects import DataGridColumnState
from myfasthtml.controls.helpers import mk
from myfasthtml.icons.fluent_p1 import settings16_regular
from myfasthtml.core.instances import MultipleInstance
class DataGridColumnsManager(Dropdown):
class DataGridColumnsManager(MultipleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id, align="right")
self.button = mk.icon(settings16_regular)
self.content = self.create_content()
super().__init__(parent, _id=_id)
@property
def columns(self):
return self._parent._state.columns
def mk_column(self, col_def: DataGridColumnState):
return Div(
@@ -20,14 +20,14 @@ class DataGridColumnsManager(Dropdown):
cls="flex mb-1",
)
def create_content(self):
def render(self):
return Search(self,
items_names="Columns",
items=self.columns,
get_attr=lambda x: x.col_id,
template=self.mk_column
template=self.mk_column,
max_height=None
)
@property
def columns(self):
return self._parent._state.columns
def __ft__(self):
return self.render()

View File

@@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import Literal
from typing import Literal, Optional
from fasthtml.components import Div
from fasthtml.xtend import Script
@@ -42,6 +42,12 @@ class PanelIds:
class PanelConf:
left: bool = False
right: bool = True
left_title: str = "Left"
right_title: str = "Right"
show_left_title: bool = True
show_right_title: bool = True
show_display_left: bool = True
show_display_right: bool = True
class PanelState(DbObject):
@@ -55,12 +61,19 @@ class PanelState(DbObject):
class Commands(BaseCommands):
def toggle_side(self, side: Literal["left", "right"], visible: bool = None):
def set_side_visible(self, side: Literal["left", "right"], visible: bool = None):
return Command("TogglePanelSide",
f"Toggle {side} side panel",
self._owner,
self._owner.set_side_visible,
args=[side, visible]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
def toggle_side(self, side: Literal["left", "right"]):
return Command("TogglePanelSide",
f"Toggle {side} side panel",
self._owner,
self._owner.toggle_side,
args=[side, visible]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
args=[side]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
def update_side_width(self, side: Literal["left", "right"]):
"""
@@ -89,7 +102,7 @@ class Panel(MultipleInstance):
the panel with appropriate HTML elements and JavaScript for interactivity.
"""
def __init__(self, parent, conf=None, _id=None):
def __init__(self, parent, conf: Optional[PanelConf] = None, _id=None):
super().__init__(parent, _id=_id)
self.conf = conf or PanelConf()
self.commands = Commands(self)
@@ -110,7 +123,7 @@ class Panel(MultipleInstance):
return self._mk_panel(side)
def toggle_side(self, side, visible):
def set_side_visible(self, side, visible):
if side == "left":
self._state.left_visible = visible
else:
@@ -118,6 +131,10 @@ class Panel(MultipleInstance):
return self._mk_panel(side), self._mk_show_icon(side)
def toggle_side(self, side):
current_visible = self._state.left_visible if side == "left" else self._state.right_visible
return self.set_side_visible(side, not current_visible)
def set_main(self, main):
self._main = main
return self
@@ -137,6 +154,8 @@ class Panel(MultipleInstance):
visible = self._state.left_visible if side == "left" else self._state.right_visible
content = self._right if side == "right" else self._left
show_title = self.conf.show_left_title if side == "left" else self.conf.show_right_title
title = self.conf.left_title if side == "left" else self.conf.right_title
resizer = Div(
cls=f"mf-resizer mf-resizer-{side}",
@@ -147,16 +166,44 @@ class Panel(MultipleInstance):
hide_icon = mk.icon(
subtract20_regular,
size=20,
command=self.commands.toggle_side(side, False),
command=self.commands.set_side_visible(side, False),
cls="mf-panel-hide-icon"
)
panel_cls = f"mf-panel-{side}"
if not visible:
panel_cls += " mf-hidden"
if show_title:
panel_cls += " mf-panel-with-title"
# Left panel: content then resizer (resizer on the right)
# Right panel: resizer then content (resizer on the left)
if show_title:
header = Div(
Div(title),
hide_icon,
cls="mf-panel-header"
)
body = Div(
header,
Div(content, id=self._ids.content(side), cls="mf-panel-content"),
cls="mf-panel-body"
)
if side == "left":
return Div(
body,
resizer,
cls=panel_cls,
id=self._ids.panel(side)
)
else:
return Div(
resizer,
body,
cls=panel_cls,
id=self._ids.panel(side)
)
else:
if side == "left":
return Div(
hide_icon,
@@ -196,12 +243,16 @@ class Panel(MultipleInstance):
if not enabled:
return None
show_display = self.conf.show_display_left if side == "left" else self.conf.show_display_right
if not show_display:
return None
is_visible = self._state.left_visible if side == "left" else self._state.right_visible
icon_cls = "hidden" if is_visible else f"mf-panel-show-icon mf-panel-show-icon-{side}"
return mk.icon(
more_horizontal20_regular,
command=self.commands.toggle_side(side, True),
command=self.commands.set_side_visible(side, True),
cls=icon_cls,
id=f"{self._id}_show_{side}"
)

View File

@@ -45,7 +45,8 @@ class Search(MultipleInstance):
items_names=None, # what is the name of the items to filter
items=None, # first set of items to filter
get_attr: Callable[[Any], str] = None, # items is a list of objects: how to get the str to filter
template: Callable[[Any], Any] = None): # once filtered, what to render ?
template: Callable[[Any], Any] = None, # once filtered, what to render ?
max_height: int = 400):
"""
Represents a component for managing and filtering a list of items based on specific criteria.
@@ -66,6 +67,7 @@ class Search(MultipleInstance):
self.get_attr = get_attr or (lambda x: x)
self.template = template or (lambda x: Div(self.get_attr(x)))
self.commands = Commands(self)
self.max_height = max_height
def set_items(self, items):
self.items = items
@@ -106,6 +108,7 @@ class Search(MultipleInstance):
*self._mk_search_results(),
id=f"{self._id}-results",
cls="mf-search-results",
style="max-height: 400px;" if self.max_height else None
),
id=f"{self._id}",
)

View File

@@ -96,7 +96,7 @@ class TestPanelBehaviour:
"""Test that toggle_side('left', False) sets _state.left_visible to False."""
panel = Panel(root_instance)
panel.toggle_side("left", False)
panel.set_side_visible("left", False)
assert panel._state.left_visible is False
@@ -105,7 +105,7 @@ class TestPanelBehaviour:
panel = Panel(root_instance)
panel._state.left_visible = False
panel.toggle_side("left", True)
panel.set_side_visible("left", True)
assert panel._state.left_visible is True
@@ -113,7 +113,7 @@ class TestPanelBehaviour:
"""Test that toggle_side('right', False) sets _state.right_visible to False."""
panel = Panel(root_instance)
panel.toggle_side("right", False)
panel.set_side_visible("right", False)
assert panel._state.right_visible is False
@@ -122,15 +122,45 @@ class TestPanelBehaviour:
panel = Panel(root_instance)
panel._state.right_visible = False
panel.toggle_side("right", True)
panel.set_side_visible("right", True)
assert panel._state.right_visible is True
def test_set_side_visible_returns_panel_and_icon(self, root_instance):
"""Test that set_side_visible() returns a tuple (panel_element, show_icon_element)."""
panel = Panel(root_instance)
result = panel.set_side_visible("left", False)
assert isinstance(result, tuple)
assert len(result) == 2
@pytest.mark.parametrize("side, initial_visible, expected_visible", [
("left", True, False), # left visible → hidden
("left", False, True), # left hidden → visible
("right", True, False), # right visible → hidden
("right", False, True), # right hidden → visible
])
def test_i_can_toggle_panel_visibility(self, root_instance, side, initial_visible, expected_visible):
"""Test that toggle_side() inverts the visibility state."""
panel = Panel(root_instance)
if side == "left":
panel._state.left_visible = initial_visible
else:
panel._state.right_visible = initial_visible
panel.toggle_side(side)
if side == "left":
assert panel._state.left_visible is expected_visible
else:
assert panel._state.right_visible is expected_visible
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)
result = panel.toggle_side("left")
assert isinstance(result, tuple)
assert len(result) == 2
@@ -190,13 +220,39 @@ class TestPanelBehaviour:
assert result is None
@pytest.mark.parametrize("side, conf_kwargs", [
("left", {"show_display_left": False}),
("right", {"show_display_right": False}),
])
def test_show_icon_returns_none_when_show_display_disabled(self, root_instance, side, conf_kwargs):
"""Test that _mk_show_icon() returns None when show_display is disabled."""
custom_conf = PanelConf(left=True, right=True, **conf_kwargs)
panel = Panel(root_instance, conf=custom_conf)
result = panel._mk_show_icon(side)
assert result is None
class TestPanelRender:
"""Tests for Panel HTML rendering."""
@pytest.fixture
def panel(self, root_instance):
panel = Panel(root_instance, PanelConf(True, True))
"""Panel with titles (default behavior)."""
panel = Panel(root_instance, PanelConf(left=True, right=True))
panel.set_main(Div("Main content"))
panel.set_left(Div("Left content"))
panel.set_right(Div("Right content"))
return panel
@pytest.fixture
def panel_no_title(self, root_instance):
"""Panel without titles (legacy behavior)."""
panel = Panel(root_instance, PanelConf(
left=True, right=True,
show_left_title=False, show_right_title=False
))
panel.set_main(Div("Main content"))
panel.set_left(Div("Left content"))
panel.set_right(Div("Right content"))
@@ -225,22 +281,58 @@ class TestPanelRender:
# 2. Left panel
def test_left_panel_renders_with_correct_structure(self, panel):
"""Test that left panel has content div before resizer.
def test_left_panel_renders_with_title_structure(self, panel):
"""Test that left panel with title has header + scrollable content.
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
- mf-panel-body: Grid container for header + content layout
- mf-panel-header: Contains title and hide icon
- mf-panel-content: Scrollable content area
- mf-panel-with-title: Removes default padding-top
"""
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-panel-body")), # body with header + content
Div(cls=Contains("mf-resizer-left")), # resizer
id=panel.get_ids().panel("left"),
cls=Contains("mf-panel-left", "mf-panel-with-title")
)
assert matches(left_panel, expected)
def test_left_panel_header_contains_title_and_icon(self, panel):
"""Test that left panel header has title and hide icon.
Why these elements matter:
- Title: Displays the panel title (left aligned)
- Hide icon: Allows user to collapse the panel (right aligned)
"""
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
header = find_one(left_panel, Div(cls=Contains("mf-panel-header")))
expected = Div(
Div("Left"), # title
Div(cls=Contains("mf-panel-hide-icon")), # hide icon
cls="mf-panel-header"
)
assert matches(header, expected)
def test_left_panel_renders_without_title_structure(self, panel_no_title):
"""Test that left panel without title has legacy structure.
Why these elements matter:
- Order (hide icon, content, resizer): Legacy layout without header
- No mf-panel-with-title class
"""
left_panel = find_one(panel_no_title.render(), Div(id=panel_no_title.get_ids().panel("left")))
expected = Div(
TestIcon("subtract20_regular"),
Div(id=panel_no_title.get_ids().left),
Div(cls=Contains("mf-resizer-left")),
id=panel_no_title.get_ids().panel("left"),
cls=Contains("mf-panel-left")
)
@@ -274,22 +366,58 @@ class TestPanelRender:
# 3. Right panel
def test_right_panel_renders_with_correct_structure(self, panel):
"""Test that right panel has resizer before content div.
def test_right_panel_renders_with_title_structure(self, panel):
"""Test that right panel with title has header + scrollable content.
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
- mf-panel-body: Grid container for header + content layout
- mf-panel-header: Contains title and hide icon
- mf-panel-content: Scrollable content area
- mf-panel-with-title: Removes default padding-top
"""
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
Div(cls=Contains("mf-panel-body")), # body with header + content
id=panel.get_ids().panel("right"),
cls=Contains("mf-panel-right", "mf-panel-with-title")
)
assert matches(right_panel, expected)
def test_right_panel_header_contains_title_and_icon(self, panel):
"""Test that right panel header has title and hide icon.
Why these elements matter:
- Title: Displays the panel title (left aligned)
- Hide icon: Allows user to collapse the panel (right aligned)
"""
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
header = find_one(right_panel, Div(cls=Contains("mf-panel-header")))
expected = Div(
Div("Right"), # title
Div(cls=Contains("mf-panel-hide-icon")), # hide icon
cls="mf-panel-header"
)
assert matches(header, expected)
def test_right_panel_renders_without_title_structure(self, panel_no_title):
"""Test that right panel without title has legacy structure.
Why these elements matter:
- Order (resizer, hide icon, content): Legacy layout without header
- No mf-panel-with-title class
"""
right_panel = find_one(panel_no_title.render(), Div(id=panel_no_title.get_ids().panel("right")))
expected = Div(
Div(cls=Contains("mf-resizer-right")),
TestIcon("subtract20_regular"),
Div(id=panel_no_title.get_ids().right),
id=panel_no_title.get_ids().panel("right"),
cls=Contains("mf-panel-right")
)
@@ -367,40 +495,57 @@ class TestPanelRender:
# 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.
def test_hide_icon_in_left_panel_header(self, panel):
"""Test that hide icon in left panel header has correct structure.
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
- cls Contains "mf-panel-hide-icon": CSS class for hide icon styling
- Icon is inside header when title is shown
"""
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
header = find_one(left_panel, Div(cls=Contains("mf-panel-header")))
# 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"
hide_icons = find(header, Div(cls=Contains("mf-panel-hide-icon")))
assert len(hide_icons) == 1, "Header 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.
def test_hide_icon_in_right_panel_header(self, panel):
"""Test that hide icon in right panel header has correct structure.
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
- cls Contains "mf-panel-hide-icon": CSS class for hide icon styling
- Icon is inside header when title is shown
"""
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
header = find_one(right_panel, Div(cls=Contains("mf-panel-header")))
# 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"
hide_icons = find(header, Div(cls=Contains("mf-panel-hide-icon")))
assert len(hide_icons) == 1, "Header should contain exactly one hide icon"
expected = Div(
TestIconNotStr("subtract20_regular"),
cls=Contains("mf-panel-hide-icon")
)
assert matches(hide_icons[0], expected)
def test_hide_icon_in_panel_without_title(self, panel_no_title):
"""Test that hide icon is at root level when no title.
Why these elements matter:
- Hide icon should be direct child of panel (legacy behavior)
"""
left_panel = find_one(panel_no_title.render(), Div(id=panel_no_title.get_ids().panel("left")))
hide_icons = find(left_panel, Div(cls=Contains("mf-panel-hide-icon")))
assert len(hide_icons) == 1, "Panel should contain exactly one hide icon"
# Verify it contains the subtract icon
expected = Div(
TestIconNotStr("subtract20_regular"),
cls=Contains("mf-panel-hide-icon")
@@ -459,6 +604,26 @@ class TestPanelRender:
assert matches(show_icon, expected)
@pytest.mark.parametrize("side, conf_kwargs", [
("left", {"show_display_left": False}),
("right", {"show_display_right": False}),
])
def test_show_icon_not_in_main_panel_when_show_display_disabled(self, root_instance, side, conf_kwargs):
"""Test that show icon is not rendered when show_display is disabled.
Why these elements matter:
- Absence of show icon: When show_display_* is False, the icon should not exist
- This prevents users from showing the panel via UI (only programmatically)
"""
custom_conf = PanelConf(left=True, right=True, **conf_kwargs)
panel = Panel(root_instance, conf=custom_conf)
panel.set_main(Div("Main content"))
rendered = panel.render()
show_icons = find(rendered, Div(id=f"{panel._id}_show_{side}"))
assert len(show_icons) == 0, f"Show icon for {side} should not be present when show_display_{side}=False"
# 6. Main panel
def test_main_panel_contains_show_icons_and_content(self, panel):