diff --git a/src/panel_splitjs/base.py b/src/panel_splitjs/base.py index bc93f81..ce4378c 100644 --- a/src/panel_splitjs/base.py +++ b/src/panel_splitjs/base.py @@ -97,6 +97,13 @@ class Split(SplitBase): collapsed = param.Integer(default=None, doc=""" Whether the first or second panel is collapsed. 0 for first panel, 1 for second panel, None for not collapsed.""") + collapse_threshold = param.Number(default=5, bounds=(0, None), doc=""" + When set to a value > 0, clicking a collapse button will collapse the panel + directly (instead of first snapping to expanded_sizes) when the panel's + current size is within this many percentage points of its expanded size. + Setting to 0 disables the behavior and preserves the two-step + expand-then-collapse interaction.""") + expanded_sizes = Size(default=(50, 50), allow_None=True, length=2, doc=""" The sizes of the two panels when expanded (as percentages). Default is (50, 50) . diff --git a/src/panel_splitjs/models/split.js b/src/panel_splitjs/models/split.js index 5782c1a..3d76421 100644 --- a/src/panel_splitjs/models/split.js +++ b/src/panel_splitjs/models/split.js @@ -87,7 +87,12 @@ export function render({ model, el }) { right_click_count = 0 let new_sizes - if (left_click_count === 1 && model.sizes[1] < model.expanded_sizes[1]) { + const other_collapsed = model.sizes[1] <= COLLAPSED_SIZE + const diff = Math.abs(model.sizes[0] - model.expanded_sizes[0]) + const at_expanded = model.collapse_threshold > 0 + ? diff <= model.collapse_threshold + : diff < 1 + if (left_click_count === 1 && (other_collapsed || !at_expanded)) { new_sizes = model.expanded_sizes is_collapsed = null } else { @@ -103,7 +108,12 @@ export function render({ model, el }) { left_click_count = 0 let new_sizes - if (right_click_count === 1 && model.sizes[0] < model.expanded_sizes[0]) { + const other_collapsed = model.sizes[0] <= COLLAPSED_SIZE + const diff = Math.abs(model.sizes[1] - model.expanded_sizes[1]) + const at_expanded = model.collapse_threshold > 0 + ? diff <= model.collapse_threshold + : diff < 1 + if (right_click_count === 1 && (other_collapsed || !at_expanded)) { new_sizes = model.expanded_sizes is_collapsed = null } else { diff --git a/tests/test_ui.py b/tests/test_ui.py index 469e3a5..e7eeb33 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -223,6 +223,132 @@ def test_multi_split_replace_panel(page): expect(page.locator(".markdown").nth(1)).to_have_text("MIDDLE") expect(page.locator(".markdown").last).to_have_text("RIGHT") +@pytest.mark.parametrize('orientation', ['horizontal', 'vertical']) +def test_split_collapse_threshold_near_expanded(page, orientation): + """When within collapse_threshold of expanded_sizes, a single click collapses directly.""" + kwargs = {'width': 400} if orientation == 'horizontal' else {'height': 400} + split = Split( + Button(name='Left'), Button(name='Right'), + orientation=orientation, + sizes=(38, 62), + expanded_sizes=(40, 60), + collapse_threshold=5, + show_buttons=True, + **kwargs + ) + serve_component(page, split) + + attr = "width" if orientation == "horizontal" else "height" + btn1 = "left" if orientation == "horizontal" else "up" + + # sizes=(38, 62): diff from expanded is 2, which is <= threshold 5. + # A single click on the left button should collapse directly (skip expand step). + page.locator(f'.toggle-button-{btn1}').click() + expect(page.locator('.split-panel').first).to_have_attribute('style', f'{attr}: calc(1% - 4px);') + expect(page.locator('.split-panel').last).to_have_attribute('style', f'{attr}: calc(99% - 4px);') + wait_until(lambda: split.collapsed == 0, page) + + +@pytest.mark.parametrize('orientation', ['horizontal', 'vertical']) +def test_split_collapse_threshold_far_from_expanded(page, orientation): + """When outside collapse_threshold, first click restores to expanded_sizes.""" + kwargs = {'width': 400} if orientation == 'horizontal' else {'height': 400} + split = Split( + Button(name='Left'), Button(name='Right'), + orientation=orientation, + sizes=(25, 75), + expanded_sizes=(40, 60), + collapse_threshold=5, + show_buttons=True, + **kwargs + ) + serve_component(page, split) + + attr = "width" if orientation == "horizontal" else "height" + btn1 = "left" if orientation == "horizontal" else "up" + + # sizes=(25, 75): diff from expanded is 15, which is > threshold 5. + # First click should restore to expanded_sizes (40, 60), not collapse. + page.locator(f'.toggle-button-{btn1}').click() + expect(page.locator('.split-panel').first).to_have_attribute('style', f'{attr}: calc(40% - 4px);') + expect(page.locator('.split-panel').last).to_have_attribute('style', f'{attr}: calc(60% - 4px);') + wait_until(lambda: split.collapsed is None, page) + + +@pytest.mark.parametrize('orientation', ['horizontal', 'vertical']) +def test_split_no_threshold_restores_when_above_expanded(page, orientation): + """With collapse_threshold=0, clicking button when sizes differ from expanded + (even above) should restore to expanded_sizes first.""" + kwargs = {'width': 400} if orientation == 'horizontal' else {'height': 400} + split = Split( + Button(name='Left'), Button(name='Right'), + orientation=orientation, + sizes=(50, 50), + expanded_sizes=(40, 60), + collapse_threshold=0, + show_buttons=True, + **kwargs + ) + serve_component(page, split) + + attr = "width" if orientation == "horizontal" else "height" + btn1 = "left" if orientation == "horizontal" else "up" + + # sizes=(50, 50) with expanded=(40, 60) and threshold=0. + # diff=10 >= 1, so not at expanded. First click should restore to (40, 60). + page.locator(f'.toggle-button-{btn1}').click() + expect(page.locator('.split-panel').first).to_have_attribute('style', f'{attr}: calc(40% - 4px);') + expect(page.locator('.split-panel').last).to_have_attribute('style', f'{attr}: calc(60% - 4px);') + wait_until(lambda: split.collapsed is None, page) + + # Second click at expanded should now collapse. + page.locator(f'.toggle-button-{btn1}').click() + expect(page.locator('.split-panel').first).to_have_attribute('style', f'{attr}: calc(1% - 4px);') + expect(page.locator('.split-panel').last).to_have_attribute('style', f'{attr}: calc(99% - 4px);') + wait_until(lambda: split.collapsed == 0, page) + + +@pytest.mark.parametrize('orientation', ['horizontal', 'vertical']) +def test_split_button_restores_when_other_panel_collapsed(page, orientation): + """When one panel is collapsed, clicking its collapse button should restore to expanded_sizes.""" + kwargs = {'width': 400} if orientation == 'horizontal' else {'height': 400} + split = Split( + Button(name='Left'), Button(name='Right'), + orientation=orientation, + expanded_sizes=(40, 60), + show_buttons=True, + **kwargs + ) + serve_component(page, split) + + attr = "width" if orientation == "horizontal" else "height" + btn1 = "left" if orientation == "horizontal" else "up" + btn2 = "right" if orientation == "horizontal" else "down" + + # Collapse the right panel via right button (two clicks: expand then collapse) + page.locator(f'.toggle-button-{btn2}').click() + page.locator(f'.toggle-button-{btn2}').click() + wait_until(lambda: split.collapsed == 1, page) + + # Now left panel is ~100%, right is ~0%. + # Clicking left '<' should restore to expanded_sizes, NOT collapse the left panel. + page.locator(f'.toggle-button-{btn1}').click() + expect(page.locator('.split-panel').first).to_have_attribute('style', f'{attr}: calc(40% - 4px);') + expect(page.locator('.split-panel').last).to_have_attribute('style', f'{attr}: calc(60% - 4px);') + wait_until(lambda: split.collapsed is None, page) + + # Now do the mirror: collapse left panel + page.locator(f'.toggle-button-{btn1}').click() + wait_until(lambda: split.collapsed == 0, page) + + # Right panel is ~100%, left is ~0%. + # Clicking right '>' should restore to expanded_sizes, NOT collapse the right panel. + page.locator(f'.toggle-button-{btn2}').click() + expect(page.locator('.split-panel').first).to_have_attribute('style', f'{attr}: calc(40% - 4px);') + expect(page.locator('.split-panel').last).to_have_attribute('style', f'{attr}: calc(60% - 4px);') + wait_until(lambda: split.collapsed is None, page) + + def test_multi_split_append_panel(page): split = MultiSplit(Markdown("LEFT"), Markdown("MIDDLE"), Markdown("RIGHT")) serve_component(page, split)