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 ## Managing Rules
To disable a specific rule, the user can say: To disable a specific rule, the user can say:

View File

@@ -9,6 +9,7 @@ panels.
**Key features:** **Key features:**
- Three customizable zones (left panel, main content, right panel) - Three customizable zones (left panel, main content, right panel)
- Configurable panel titles with sticky headers
- Toggle visibility with hide/show icons - Toggle visibility with hide/show icons
- Resizable panels with drag handles - Resizable panels with drag handles
- Smooth CSS animations for show/hide transitions - 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) | | Left panel | Optional collapsible panel (default: visible) |
| Main content | Always-visible central content area | | Main content | Always-visible central content area |
| Right panel | Optional collapsible panel (default: visible) | | 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 | | Show icon (⋯) | In main area when panel is hidden |
| Resizer (║) | Drag handle to resize panels manually | | 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 ### Creating a Panel
The Panel is a `MultipleInstance`, meaning you can create multiple independent panels in your application. Create it by 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 ### 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 ```python
from myfasthtml.controls.Panel import PanelConf from myfasthtml.controls.Panel import PanelConf
@@ -218,6 +246,49 @@ conf = PanelConf(left=False, right=False)
panel = Panel(parent=root_instance, conf=conf) 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 **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. renders but with zero width and overflow hidden.
@@ -321,8 +392,8 @@ You can control panels programmatically using commands:
```python ```python
# Toggle panel visibility # Toggle panel visibility
toggle_left = panel.commands.toggle_side("left", visible=False) # Hide left toggle_left = panel.commands.set_side_visible("left", visible=False) # Hide left
toggle_right = panel.commands.toggle_side("right", visible=True) # Show right toggle_right = panel.commands.set_side_visible("right", visible=True) # Show right
# Update panel width # Update panel width
update_left_width = panel.commands.update_side_width("left") 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 from myfasthtml.controls.helpers import mk
# Add buttons to toggle panels # Add buttons to toggle panels
hide_left_btn = mk.button("Hide Left", command=panel.commands.toggle_side("left", False)) 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.toggle_side("left", True)) show_left_btn = mk.button("Show Left", command=panel.commands.set_side_visible("left", True))
# Add to your layout # Add to your layout
panel.set_main( panel.set_main(
@@ -365,11 +436,15 @@ panel.set_main(
The Panel uses CSS classes that you can customize: The Panel uses CSS classes that you can customize:
| Class | Element | | Class | Element |
|----------------------------|------------------------------------------| |----------------------------|--------------------------------------------|
| `mf-panel` | Root panel container | | `mf-panel` | Root panel container |
| `mf-panel-left` | Left panel container | | `mf-panel-left` | Left panel container |
| `mf-panel-right` | Right panel container | | `mf-panel-right` | Right panel container |
| `mf-panel-main` | Main content area | | `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-hide-icon` | Hide icon () inside panels |
| `mf-panel-show-icon` | Show icon (⋯) in main area | | `mf-panel-show-icon` | Show icon (⋯) in main area |
| `mf-panel-show-icon-left` | Show icon for left panel | | `mf-panel-show-icon-left` | Show icon for left panel |
@@ -641,13 +716,13 @@ panel.set_right(
# Create control buttons # Create control buttons
toggle_left_btn = mk.button( toggle_left_btn = mk.button(
"Toggle Left Panel", "Toggle Left Panel",
command=panel.commands.toggle_side("left", False), command=panel.commands.set_side_visible("left", False),
cls="btn btn-sm" cls="btn btn-sm"
) )
toggle_right_btn = mk.button( toggle_right_btn = mk.button(
"Toggle Right Panel", "Toggle Right Panel",
command=panel.commands.toggle_side("right", False), command=panel.commands.set_side_visible("right", False),
cls="btn btn-sm" 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: The Panel component uses `PanelConf` dataclass for configuration:
| Property | Type | Description | Default | | 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` | | `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 ### State
@@ -735,10 +816,40 @@ codebase.
### High Level Hierarchical Structure ### 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}", cls="mf-panel")
├── Div(id="{id}_pl", cls="mf-panel-left [mf-hidden]") ├── Div(id="{id}_pl", cls="mf-panel-left [mf-hidden]")
│ ├── Div (hide icon) │ ├── Div (hide icon - absolute positioned)
│ ├── Div(id="{id}_cl") │ ├── Div(id="{id}_cl")
│ │ └── [Left content] │ │ └── [Left content]
│ └── Div (resizer-left) │ └── 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}_show_right", cls="hidden|mf-panel-show-icon-right")
├── Div(id="{id}_pr", cls="mf-panel-right [mf-hidden]") ├── Div(id="{id}_pr", cls="mf-panel-right [mf-hidden]")
│ ├── Div (resizer-right) │ ├── Div (resizer-right)
│ ├── Div (hide icon) │ ├── Div (hide icon - absolute positioned)
│ └── Div(id="{id}_cr") │ └── Div(id="{id}_cr")
│ └── [Right content] │ └── [Right content]
└── Script # initResizer('{id}') └── Script # initResizer('{id}')
@@ -757,11 +868,12 @@ Div(id="{id}", cls="mf-panel")
**Note:** **Note:**
- Left panel: hide icon, then content, then resizer (resizer on right edge) - With title: uses grid layout (`mf-panel-body`) with sticky header and scrollable content
- Right panel: resizer, then hide icon, then content (resizer on left edge) - Without title: hide icon is absolutely positioned at top-right with padding-top on panel
- Hide icons are positioned at panel root level (not inside content div) - Left panel: body/content then resizer (resizer on right edge)
- Main content has an outer wrapper and inner content div with ID - Right panel: resizer then body/content (resizer on left edge)
- `[mf-hidden]` class is conditionally applied when panel is hidden - `[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 ### Element IDs

View File

@@ -459,7 +459,7 @@
.mf-search-results { .mf-search-results {
margin-top: 0.5rem; margin-top: 0.5rem;
max-height: 400px; /*max-height: 400px;*/
overflow: auto; overflow: auto;
} }
@@ -769,6 +769,38 @@
right: 0.5rem; 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 ************ */ /* ************* Properties Component ************ */
/* *********************************************** */ /* *********************************************** */

View File

@@ -14,7 +14,7 @@ from myfasthtml.controls.CycleStateControl import CycleStateControl
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER
from myfasthtml.controls.Mouse import Mouse 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, \ from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
from myfasthtml.controls.helpers import mk 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.core.utils import make_safe_id
from myfasthtml.icons.carbon import row, column, grid from myfasthtml.icons.carbon import row, column, grid
from myfasthtml.icons.fluent import checkbox_unchecked16_regular 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 from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular
# OPTIMIZATION: Pre-compiled regex to detect HTML special characters # OPTIMIZATION: Pre-compiled regex to detect HTML special characters
@@ -130,6 +131,13 @@ class Commands(BaseCommands):
self._owner.on_click self._owner.on_click
).htmx(target=f"#tsm_{self._id}") ).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): class DataGrid(MultipleInstance):
def __init__(self, parent, settings=None, save_state=None, _id=None): def __init__(self, parent, settings=None, save_state=None, _id=None):
@@ -139,7 +147,10 @@ class DataGrid(MultipleInstance):
self.commands = Commands(self) self.commands = Commands(self)
self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState 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 # add DataGridQuery
self._datagrid_filter = DataGridQuery(self) self._datagrid_filter = DataGridQuery(self)
@@ -362,6 +373,10 @@ class DataGrid(MultipleInstance):
self._state.save() self._state.save()
return self.render_partial() 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): def mk_headers(self):
resize_cmd = self.commands.set_column_width() resize_cmd = self.commands.set_column_width()
move_cmd = self.commands.move_column() move_cmd = self.commands.move_column()
@@ -649,7 +664,7 @@ class DataGrid(MultipleInstance):
Div(self._datagrid_filter, Div(self._datagrid_filter,
Div( Div(
self._selection_mode_selector, self._selection_mode_selector,
self._columns_manager, mk.icon(settings16_regular, command=self.commands.toggle_columns_manager(), tooltip="Show sidebar"),
cls="flex"), cls="flex"),
cls="flex items-center justify-between mb-2"), cls="flex items-center justify-between mb-2"),
self._panel.set_main(self.mk_table()), self._panel.set_main(self.mk_table()),

View File

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

View File

@@ -1,5 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import Literal, Optional
from fasthtml.components import Div from fasthtml.components import Div
from fasthtml.xtend import Script from fasthtml.xtend import Script
@@ -42,6 +42,12 @@ class PanelIds:
class PanelConf: class PanelConf:
left: bool = False left: bool = False
right: bool = True 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): class PanelState(DbObject):
@@ -55,12 +61,19 @@ class PanelState(DbObject):
class Commands(BaseCommands): 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", 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, 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"]): 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. 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) super().__init__(parent, _id=_id)
self.conf = conf or PanelConf() self.conf = conf or PanelConf()
self.commands = Commands(self) self.commands = Commands(self)
@@ -110,7 +123,7 @@ class Panel(MultipleInstance):
return self._mk_panel(side) return self._mk_panel(side)
def toggle_side(self, side, visible): def set_side_visible(self, side, visible):
if side == "left": if side == "left":
self._state.left_visible = visible self._state.left_visible = visible
else: else:
@@ -118,6 +131,10 @@ class Panel(MultipleInstance):
return self._mk_panel(side), self._mk_show_icon(side) 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): def set_main(self, main):
self._main = main self._main = main
return self return self
@@ -137,6 +154,8 @@ class Panel(MultipleInstance):
visible = self._state.left_visible if side == "left" else self._state.right_visible visible = self._state.left_visible if side == "left" else self._state.right_visible
content = self._right if side == "right" else self._left 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( resizer = Div(
cls=f"mf-resizer mf-resizer-{side}", cls=f"mf-resizer mf-resizer-{side}",
@@ -147,16 +166,44 @@ class Panel(MultipleInstance):
hide_icon = mk.icon( hide_icon = mk.icon(
subtract20_regular, subtract20_regular,
size=20, size=20,
command=self.commands.toggle_side(side, False), command=self.commands.set_side_visible(side, False),
cls="mf-panel-hide-icon" cls="mf-panel-hide-icon"
) )
panel_cls = f"mf-panel-{side}" panel_cls = f"mf-panel-{side}"
if not visible: if not visible:
panel_cls += " mf-hidden" panel_cls += " mf-hidden"
if show_title:
panel_cls += " mf-panel-with-title"
# Left panel: content then resizer (resizer on the right) # Left panel: content then resizer (resizer on the right)
# Right panel: resizer then content (resizer on the left) # 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": if side == "left":
return Div( return Div(
hide_icon, hide_icon,
@@ -196,12 +243,16 @@ class Panel(MultipleInstance):
if not enabled: if not enabled:
return None 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 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}" icon_cls = "hidden" if is_visible else f"mf-panel-show-icon mf-panel-show-icon-{side}"
return mk.icon( return mk.icon(
more_horizontal20_regular, more_horizontal20_regular,
command=self.commands.toggle_side(side, True), command=self.commands.set_side_visible(side, True),
cls=icon_cls, cls=icon_cls,
id=f"{self._id}_show_{side}" 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_names=None, # what is the name of the items to filter
items=None, # first set of 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 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. 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.get_attr = get_attr or (lambda x: x)
self.template = template or (lambda x: Div(self.get_attr(x))) self.template = template or (lambda x: Div(self.get_attr(x)))
self.commands = Commands(self) self.commands = Commands(self)
self.max_height = max_height
def set_items(self, items): def set_items(self, items):
self.items = items self.items = items
@@ -106,6 +108,7 @@ class Search(MultipleInstance):
*self._mk_search_results(), *self._mk_search_results(),
id=f"{self._id}-results", id=f"{self._id}-results",
cls="mf-search-results", cls="mf-search-results",
style="max-height: 400px;" if self.max_height else None
), ),
id=f"{self._id}", id=f"{self._id}",
) )

View File

@@ -96,7 +96,7 @@ class TestPanelBehaviour:
"""Test that toggle_side('left', False) sets _state.left_visible to False.""" """Test that toggle_side('left', False) sets _state.left_visible to False."""
panel = Panel(root_instance) panel = Panel(root_instance)
panel.toggle_side("left", False) panel.set_side_visible("left", False)
assert panel._state.left_visible is False assert panel._state.left_visible is False
@@ -105,7 +105,7 @@ class TestPanelBehaviour:
panel = Panel(root_instance) panel = Panel(root_instance)
panel._state.left_visible = False panel._state.left_visible = False
panel.toggle_side("left", True) panel.set_side_visible("left", True)
assert panel._state.left_visible is 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.""" """Test that toggle_side('right', False) sets _state.right_visible to False."""
panel = Panel(root_instance) panel = Panel(root_instance)
panel.toggle_side("right", False) panel.set_side_visible("right", False)
assert panel._state.right_visible is False assert panel._state.right_visible is False
@@ -122,15 +122,45 @@ class TestPanelBehaviour:
panel = Panel(root_instance) panel = Panel(root_instance)
panel._state.right_visible = False panel._state.right_visible = False
panel.toggle_side("right", True) panel.set_side_visible("right", True)
assert panel._state.right_visible is 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): def test_toggle_side_returns_panel_and_icon(self, root_instance):
"""Test that toggle_side() returns a tuple (panel_element, show_icon_element).""" """Test that toggle_side() returns a tuple (panel_element, show_icon_element)."""
panel = Panel(root_instance) panel = Panel(root_instance)
result = panel.toggle_side("left", False) result = panel.toggle_side("left")
assert isinstance(result, tuple) assert isinstance(result, tuple)
assert len(result) == 2 assert len(result) == 2
@@ -190,13 +220,39 @@ class TestPanelBehaviour:
assert result is None 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: class TestPanelRender:
"""Tests for Panel HTML rendering.""" """Tests for Panel HTML rendering."""
@pytest.fixture @pytest.fixture
def panel(self, root_instance): 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_main(Div("Main content"))
panel.set_left(Div("Left content")) panel.set_left(Div("Left content"))
panel.set_right(Div("Right content")) panel.set_right(Div("Right content"))
@@ -225,22 +281,58 @@ class TestPanelRender:
# 2. Left panel # 2. Left panel
def test_left_panel_renders_with_correct_structure(self, panel): def test_left_panel_renders_with_title_structure(self, panel):
"""Test that left panel has content div before resizer. """Test that left panel with title has header + scrollable content.
Why these elements matter: Why these elements matter:
- Order (content then resizer): Critical for positioning resizer on the right side - mf-panel-body: Grid container for header + content layout
- id: Required for HTMX targeting during toggle/resize operations - mf-panel-header: Contains title and hide icon
- cls Contains "mf-panel-left": CSS class for left panel styling - 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"))) left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
# Step 1: Validate left panel global structure
expected = Div( expected = Div(
TestIcon("subtract20_regular"), Div(cls=Contains("mf-panel-body")), # body with header + content
Div(id=panel.get_ids().left), # content div, tested in detail later
Div(cls=Contains("mf-resizer-left")), # resizer Div(cls=Contains("mf-resizer-left")), # resizer
id=panel.get_ids().panel("left"), 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") cls=Contains("mf-panel-left")
) )
@@ -274,22 +366,58 @@ class TestPanelRender:
# 3. Right panel # 3. Right panel
def test_right_panel_renders_with_correct_structure(self, panel): def test_right_panel_renders_with_title_structure(self, panel):
"""Test that right panel has resizer before content div. """Test that right panel with title has header + scrollable content.
Why these elements matter: Why these elements matter:
- Order (resizer then hide icon then content): Critical for positioning resizer on the left side - mf-panel-body: Grid container for header + content layout
- id: Required for HTMX targeting during toggle/resize operations - mf-panel-header: Contains title and hide icon
- cls Contains "mf-panel-right": CSS class for right panel styling - 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"))) right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
# Step 1: Validate right panel global structure
expected = Div( expected = Div(
Div(cls=Contains("mf-resizer-right")), # resizer Div(cls=Contains("mf-resizer-right")), # resizer
TestIcon("subtract20_regular"), # hide icon Div(cls=Contains("mf-panel-body")), # body with header + content
Div(id=panel.get_ids().right), # content div, tested in detail later
id=panel.get_ids().panel("right"), 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") cls=Contains("mf-panel-right")
) )
@@ -367,40 +495,57 @@ class TestPanelRender:
# 5. Icons # 5. Icons
def test_hide_icon_in_left_panel_has_correct_command(self, panel): def test_hide_icon_in_left_panel_header(self, panel):
"""Test that hide icon in left panel triggers toggle_side command. """Test that hide icon in left panel header has correct structure.
Why these elements matter: Why these elements matter:
- TestIconNotStr("subtract20_regular"): Verify correct icon is used for hiding - 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"))) 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(header, Div(cls=Contains("mf-panel-hide-icon")))
hide_icons = find(left_panel, Div(cls=Contains("mf-panel-hide-icon"))) assert len(hide_icons) == 1, "Header should contain exactly one hide icon"
assert len(hide_icons) == 1, "Left panel should contain exactly one hide icon"
# Verify it contains the subtract icon
expected = Div( expected = Div(
TestIconNotStr("subtract20_regular"), TestIconNotStr("subtract20_regular"),
cls=Contains("mf-panel-hide-icon") cls=Contains("mf-panel-hide-icon")
) )
assert matches(hide_icons[0], expected) assert matches(hide_icons[0], expected)
def test_hide_icon_in_right_panel_has_correct_command(self, panel): def test_hide_icon_in_right_panel_header(self, panel):
"""Test that hide icon in right panel triggers toggle_side command. """Test that hide icon in right panel header has correct structure.
Why these elements matter: Why these elements matter:
- TestIconNotStr("subtract20_regular"): Verify correct icon is used for hiding - 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"))) 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(header, Div(cls=Contains("mf-panel-hide-icon")))
hide_icons = find(right_panel, Div(cls=Contains("mf-panel-hide-icon"))) assert len(hide_icons) == 1, "Header should contain exactly one hide icon"
assert len(hide_icons) == 1, "Right panel 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( expected = Div(
TestIconNotStr("subtract20_regular"), TestIconNotStr("subtract20_regular"),
cls=Contains("mf-panel-hide-icon") cls=Contains("mf-panel-hide-icon")
@@ -459,6 +604,26 @@ class TestPanelRender:
assert matches(show_icon, expected) 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 # 6. Main panel
def test_main_panel_contains_show_icons_and_content(self, panel): def test_main_panel_contains_show_icons_and_content(self, panel):