From 360d8e57d2eca119943887925d4e9b0098f974b5 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 11 May 2026 15:16:15 -0700 Subject: [PATCH 1/3] Add threshold --- src/panel_splitjs/base.py | 7 +++ src/panel_splitjs/models/split.js | 10 +++- tests/test_ui.py | 93 +++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/src/panel_splitjs/base.py b/src/panel_splitjs/base.py index bc93f81..55bccea 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=0, 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. + Default 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..e523c2d 100644 --- a/src/panel_splitjs/models/split.js +++ b/src/panel_splitjs/models/split.js @@ -87,7 +87,10 @@ 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 within_threshold = model.collapse_threshold > 0 && diff <= model.collapse_threshold + if (left_click_count === 1 && (other_collapsed || (model.sizes[0] < model.expanded_sizes[0] && !within_threshold))) { new_sizes = model.expanded_sizes is_collapsed = null } else { @@ -103,7 +106,10 @@ 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 within_threshold = model.collapse_threshold > 0 && diff <= model.collapse_threshold + if (right_click_count === 1 && (other_collapsed || (model.sizes[1] < model.expanded_sizes[1] && !within_threshold))) { new_sizes = model.expanded_sizes is_collapsed = null } else { diff --git a/tests/test_ui.py b/tests/test_ui.py index 469e3a5..c620695 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -223,6 +223,99 @@ 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_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) From f7ba35d9fcecb528a576bdaed10008a92c90b1e2 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 11 May 2026 15:24:52 -0700 Subject: [PATCH 2/3] fix --- src/panel_splitjs/models/split.js | 12 +++++++---- tests/test_ui.py | 33 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/panel_splitjs/models/split.js b/src/panel_splitjs/models/split.js index e523c2d..3d76421 100644 --- a/src/panel_splitjs/models/split.js +++ b/src/panel_splitjs/models/split.js @@ -89,8 +89,10 @@ export function render({ model, el }) { let new_sizes const other_collapsed = model.sizes[1] <= COLLAPSED_SIZE const diff = Math.abs(model.sizes[0] - model.expanded_sizes[0]) - const within_threshold = model.collapse_threshold > 0 && diff <= model.collapse_threshold - if (left_click_count === 1 && (other_collapsed || (model.sizes[0] < model.expanded_sizes[0] && !within_threshold))) { + 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 { @@ -108,8 +110,10 @@ export function render({ model, el }) { let new_sizes const other_collapsed = model.sizes[0] <= COLLAPSED_SIZE const diff = Math.abs(model.sizes[1] - model.expanded_sizes[1]) - const within_threshold = model.collapse_threshold > 0 && diff <= model.collapse_threshold - if (right_click_count === 1 && (other_collapsed || (model.sizes[1] < model.expanded_sizes[1] && !within_threshold))) { + 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 c620695..e7eeb33 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -275,6 +275,39 @@ def test_split_collapse_threshold_far_from_expanded(page, orientation): 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.""" From c8953234e961b79c58360679fb7583b5a13fd26d Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 11 May 2026 15:27:12 -0700 Subject: [PATCH 3/3] default to 5% --- src/panel_splitjs/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/panel_splitjs/base.py b/src/panel_splitjs/base.py index 55bccea..ce4378c 100644 --- a/src/panel_splitjs/base.py +++ b/src/panel_splitjs/base.py @@ -97,11 +97,11 @@ 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=0, bounds=(0, None), doc=""" + 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. - Default 0 disables the behavior and preserves the two-step + 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="""