From 3abfab8e97ddb6c9d545988de64ef2cb8041129a Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sun, 25 Jan 2026 11:29:18 +0100 Subject: [PATCH] I can show and hide the columns comanger --- .claude/commands/unit-tester.md | 50 ++ docs/Panel.md | 188 +++++-- src/myfasthtml/assets/myfasthtml.css | 34 +- src/myfasthtml/controls/DataGrid.py | 21 +- .../controls/DataGridColumnsManager.py | 24 +- src/myfasthtml/controls/Panel.py | 105 ++-- src/myfasthtml/controls/Search.py | 5 +- tests/controls/test_panel.py | 457 ++++++++++++------ 8 files changed, 656 insertions(+), 228 deletions(-) diff --git a/.claude/commands/unit-tester.md b/.claude/commands/unit-tester.md index 5e9357e..419b5e9 100644 --- a/.claude/commands/unit-tester.md +++ b/.claude/commands/unit-tester.md @@ -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: diff --git a/docs/Panel.md b/docs/Panel.md index 47afbf1..8383a67 100644 --- a/docs/Panel.md +++ b/docs/Panel.md @@ -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( @@ -364,21 +435,25 @@ 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-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 | +| 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 | +| `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:** @@ -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" ) @@ -657,8 +732,8 @@ show_all_btn = mk.button( "show_all", "Show all panels", lambda: ( - panel.toggle_side("left", True), - panel.toggle_side("right", True) + panel.toggle_side("left", True), + panel.toggle_side("right", True) ) ), cls="btn btn-sm btn-primary" @@ -668,17 +743,17 @@ show_all_btn = mk.button( 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" ) ) @@ -696,10 +771,16 @@ 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` | -| `right` | boolean | Enable/disable right panel | `True` | +| Property | Type | Description | Default | +|----------------------|---------|-------------------------------------------|-----------| +| `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 diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css index 9053953..4e64e87 100644 --- a/src/myfasthtml/assets/myfasthtml.css +++ b/src/myfasthtml/assets/myfasthtml.css @@ -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 ************ */ /* *********************************************** */ diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index cf96926..a2f6b44 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -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 @@ -129,6 +130,13 @@ class Commands(BaseCommands): self._owner, 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): @@ -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()), diff --git a/src/myfasthtml/controls/DataGridColumnsManager.py b/src/myfasthtml/controls/DataGridColumnsManager.py index 574a946..4114093 100644 --- a/src/myfasthtml/controls/DataGridColumnsManager.py +++ b/src/myfasthtml/controls/DataGridColumnsManager.py @@ -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() diff --git a/src/myfasthtml/controls/Panel.py b/src/myfasthtml/controls/Panel.py index d27e46d..621c148 100644 --- a/src/myfasthtml/controls/Panel.py +++ b/src/myfasthtml/controls/Panel.py @@ -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 @@ -134,45 +151,75 @@ class Panel(MultipleInstance): enabled = self.conf.left if side == "left" else self.conf.right if not enabled: return None - + 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}", data_command_id=self.commands.update_side_width(side).id, data_side=side ) - + 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 side == "left": - return Div( + if show_title: + header = Div( + Div(title), hide_icon, - Div(content, id=self._ids.content(side)), - resizer, - cls=panel_cls, - id=self._ids.panel(side) + 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: - return Div( - resizer, - hide_icon, - Div(content, id=self._ids.content(side)), - cls=panel_cls, - id=self._ids.panel(side) - ) + 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( @@ -195,13 +242,17 @@ class Panel(MultipleInstance): enabled = self.conf.left if side == "left" else self.conf.right 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}" ) diff --git a/src/myfasthtml/controls/Search.py b/src/myfasthtml/controls/Search.py index 61c25ab..ce05ea2 100644 --- a/src/myfasthtml/controls/Search.py +++ b/src/myfasthtml/controls/Search.py @@ -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}", ) diff --git a/tests/controls/test_panel.py b/tests/controls/test_panel.py index 44b29ce..a2a5173 100644 --- a/tests/controls/test_panel.py +++ b/tests/controls/test_panel.py @@ -16,194 +16,250 @@ def cleanup_db(): 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 False 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) - + + panel.set_side_visible("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) - + + panel.set_side_visible("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) - + + panel.set_side_visible("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) - + + 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 - + # 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("right", 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 + + @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")) + 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. @@ -220,32 +276,68 @@ class TestPanelRender: 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. + + 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") ) - + 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. @@ -254,11 +346,11 @@ class TestPanelRender: """ 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. @@ -267,34 +359,70 @@ class TestPanelRender: """ 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. + + 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") ) - + 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. @@ -303,11 +431,11 @@ class TestPanelRender: """ 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. @@ -316,13 +444,13 @@ class TestPanelRender: """ 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. @@ -334,16 +462,16 @@ class TestPanelRender: """ 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. @@ -355,58 +483,75 @@ class TestPanelRender: """ 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. + + 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"))) - - # 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 + header = find_one(left_panel, Div(cls=Contains("mf-panel-header"))) + + 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_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"))) - - # 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 + header = find_one(right_panel, Div(cls=Contains("mf-panel-header"))) + + 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" + + 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. @@ -415,14 +560,14 @@ class TestPanelRender: - 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. @@ -432,15 +577,15 @@ class TestPanelRender: """ 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. @@ -450,17 +595,37 @@ class TestPanelRender: """ 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) + + @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): """Test that main panel contains show icons and content in correct order. @@ -473,10 +638,10 @@ class TestPanelRender: # 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 @@ -484,11 +649,11 @@ class TestPanelRender: 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. @@ -496,7 +661,7 @@ class TestPanelRender: - 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)