From 6c655ba36c74f0fd844e828dcd2295af38fd3077 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 11:16:24 +0000 Subject: [PATCH 1/8] Initial plan From 09366760a108f6d92eb0d30b3fff0bca5f501dd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 11:27:17 +0000 Subject: [PATCH 2/8] Implement draggable sidebar functionality with drag handle and state management Co-authored-by: ahuang11 <15331990+ahuang11@users.noreply.github.com> --- src/panel_material_ui/template/Page.jsx | 104 +++++++++++++++++++++++- 1 file changed, 100 insertions(+), 4 deletions(-) diff --git a/src/panel_material_ui/template/Page.jsx b/src/panel_material_ui/template/Page.jsx index 506ca04a..f21a5dce 100644 --- a/src/panel_material_ui/template/Page.jsx +++ b/src/panel_material_ui/template/Page.jsx @@ -56,8 +56,16 @@ export function render({model, view}) { const [dark_theme, setDarkTheme] = model.useState("dark_theme") const [logo] = model.useState("logo") const [open, setOpen] = model.useState("sidebar_open") - const [sidebar_width] = model.useState("sidebar_width") + const [sidebar_width, setSidebarWidth] = model.useState("sidebar_width") const [theme_toggle] = model.useState("theme_toggle") + + // Draggable sidebar state and constants + const [isDragging, setIsDragging] = React.useState(false) + const [dragStartX, setDragStartX] = React.useState(0) + const [dragStartWidth, setDragStartWidth] = React.useState(0) + const MIN_SIDEBAR_WIDTH = 180 + const MAX_SIDEBAR_WIDTH = 600 + const DEFAULT_SIDEBAR_WIDTH = 240 const [site_url] = model.useState("site_url") const [title] = model.useState("title") const [variant] = model.useState("sidebar_variant") @@ -136,7 +144,58 @@ export function render({model, view}) { setup_global_styles(view, theme) React.useEffect(() => dark_mode.set_value(dark_theme), [dark_theme]) + // Drag handlers for sidebar resizing + const handleDragStart = React.useCallback((e) => { + const clientX = e.type.startsWith('touch') ? e.touches[0].clientX : e.clientX + setIsDragging(true) + setDragStartX(clientX) + setDragStartWidth(sidebar_width || DEFAULT_SIDEBAR_WIDTH) + e.preventDefault() + }, [sidebar_width]) + + const handleDragMove = React.useCallback((e) => { + if (!isDragging) return + + const clientX = e.type.startsWith('touch') ? e.touches[0].clientX : e.clientX + const deltaX = clientX - dragStartX + const newWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, dragStartWidth + deltaX)) + + // Update width immediately for responsive feedback + setSidebarWidth(newWidth) + e.preventDefault() + }, [isDragging, dragStartX, dragStartWidth]) + + const handleDragEnd = React.useCallback(() => { + setIsDragging(false) + }, []) + + // Add global mouse/touch event listeners when dragging + React.useEffect(() => { + if (isDragging) { + const handleMouseMove = (e) => handleDragMove(e) + const handleMouseUp = () => handleDragEnd() + const handleTouchMove = (e) => handleDragMove(e) + const handleTouchEnd = () => handleDragEnd() + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + document.addEventListener('touchmove', handleTouchMove, { passive: false }) + document.addEventListener('touchend', handleTouchEnd) + + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.removeEventListener('touchmove', handleTouchMove) + document.removeEventListener('touchend', handleTouchEnd) + } + } + }, [isDragging, handleDragMove, handleDragEnd]) + const drawer_variant = variant === "auto" ? (isMobile ? "temporary": "persistent") : variant + + // Use fallback width if sidebar_width is not set + const effectiveSidebarWidth = sidebar_width || DEFAULT_SIDEBAR_WIDTH + const drawer = sidebar.length > 0 ? ( @@ -161,6 +224,39 @@ export function render({model, view}) { return object })} + {/* Drag handle for resizing sidebar */} + ) : null @@ -270,13 +366,13 @@ export function render({model, view}) { drawer_variant === "temporary" ? ( {width: 0, flexShrink: {xs: 0}} ) : ( - {width: {sm: sidebar_width}, flexShrink: {sm: 0}} + {width: {sm: effectiveSidebarWidth}, flexShrink: {sm: 0}} ) } > {drawer} } -
+
  From fdfe14e74da1ad68e39e291091b9f15930aa9a83 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 29 Sep 2025 16:11:12 -0700 Subject: [PATCH 3/8] add param, tests, and better defaults --- src/panel_material_ui/template/Page.jsx | 96 +++++++------- src/panel_material_ui/template/base.py | 2 + tests/ui/template/test_page.py | 164 +++++++++++++++++++++++- 3 files changed, 212 insertions(+), 50 deletions(-) diff --git a/src/panel_material_ui/template/Page.jsx b/src/panel_material_ui/template/Page.jsx index f21a5dce..1f0575d3 100644 --- a/src/panel_material_ui/template/Page.jsx +++ b/src/panel_material_ui/template/Page.jsx @@ -56,6 +56,7 @@ export function render({model, view}) { const [dark_theme, setDarkTheme] = model.useState("dark_theme") const [logo] = model.useState("logo") const [open, setOpen] = model.useState("sidebar_open") + const [sidebar_resizable] = model.useState("sidebar_resizable") const [sidebar_width, setSidebarWidth] = model.useState("sidebar_width") const [theme_toggle] = model.useState("theme_toggle") @@ -63,9 +64,6 @@ export function render({model, view}) { const [isDragging, setIsDragging] = React.useState(false) const [dragStartX, setDragStartX] = React.useState(0) const [dragStartWidth, setDragStartWidth] = React.useState(0) - const MIN_SIDEBAR_WIDTH = 180 - const MAX_SIDEBAR_WIDTH = 600 - const DEFAULT_SIDEBAR_WIDTH = 240 const [site_url] = model.useState("site_url") const [title] = model.useState("title") const [variant] = model.useState("sidebar_variant") @@ -149,21 +147,26 @@ export function render({model, view}) { const clientX = e.type.startsWith('touch') ? e.touches[0].clientX : e.clientX setIsDragging(true) setDragStartX(clientX) - setDragStartWidth(sidebar_width || DEFAULT_SIDEBAR_WIDTH) + setDragStartWidth(sidebar_width) e.preventDefault() }, [sidebar_width]) const handleDragMove = React.useCallback((e) => { if (!isDragging) return - + const clientX = e.type.startsWith('touch') ? e.touches[0].clientX : e.clientX const deltaX = clientX - dragStartX - const newWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, dragStartWidth + deltaX)) - - // Update width immediately for responsive feedback - setSidebarWidth(newWidth) + const newWidth = dragStartWidth + deltaX + + // If width gets close to 0, collapse the sidebar completely + if (newWidth < 50) { + setOpen(false) + } else { + // Update width immediately for responsive feedback + setSidebarWidth(newWidth) + } e.preventDefault() - }, [isDragging, dragStartX, dragStartWidth]) + }, [isDragging, dragStartX, dragStartWidth, setOpen]) const handleDragEnd = React.useCallback(() => { setIsDragging(false) @@ -192,10 +195,7 @@ export function render({model, view}) { }, [isDragging, handleDragMove, handleDragEnd]) const drawer_variant = variant === "auto" ? (isMobile ? "temporary": "persistent") : variant - - // Use fallback width if sidebar_width is not set - const effectiveSidebarWidth = sidebar_width || DEFAULT_SIDEBAR_WIDTH - + const drawer = sidebar.length > 0 ? ( - {/* Drag handle for resizing sidebar */} - + cursor: "col-resize", + backgroundColor: "transparent", + borderRight: `1px solid ${theme.palette.divider}`, + zIndex: 1000, + "&:hover": { + backgroundColor: theme.palette.action.hover, + borderRightWidth: "2px", + borderRightColor: theme.palette.primary.main, + }, + // Make the handle slightly larger for easier interaction + "&:before": { + content: '""', + position: "absolute", + top: 0, + right: "-3px", // Extend hit area beyond visual boundary + width: "6px", + height: "100%", + backgroundColor: "transparent" + } + }} + aria-label="Resize sidebar" + title="Drag to resize sidebar" + /> + )} ) : null @@ -366,13 +368,13 @@ export function render({model, view}) { drawer_variant === "temporary" ? ( {width: 0, flexShrink: {xs: 0}} ) : ( - {width: {sm: effectiveSidebarWidth}, flexShrink: {sm: 0}} + {width: {sm: sidebar_width}, flexShrink: {sm: 0}} ) } > {drawer} } -
+
  diff --git a/src/panel_material_ui/template/base.py b/src/panel_material_ui/template/base.py index c08d164f..d555e17c 100644 --- a/src/panel_material_ui/template/base.py +++ b/src/panel_material_ui/template/base.py @@ -103,6 +103,8 @@ class Page(MaterialComponent, ResourceComponent): sidebar_open = param.Boolean(default=True, doc="Whether the sidebar is open or closed.") + sidebar_resizable = param.Boolean(default=True, doc="Whether the sidebar can be resized by dragging.") + sidebar_variant = param.Selector(default="auto", objects=SIDEBAR_VARIANTS, doc=""" Whether the sidebar is persistent, a temporary drawer, a permanent drawer, or automatically switches between the two based on screen size.""") diff --git a/tests/ui/template/test_page.py b/tests/ui/template/test_page.py index a3662be4..c32d4c36 100644 --- a/tests/ui/template/test_page.py +++ b/tests/ui/template/test_page.py @@ -2,10 +2,11 @@ pytest.importorskip('playwright') -from panel_material_ui.template import Page - -from playwright.sync_api import expect +import panel as pn from panel.tests.util import serve_component +from playwright.sync_api import expect + +from panel_material_ui.template import Page pytestmark = pytest.mark.ui @@ -26,3 +27,160 @@ def test_page_theme_config_header_color(page): } } expect(header).to_have_css("background-color", "rgb(0, 0, 0)") + + +def test_page_sidebar_resizable_handle_present(page): + """Test that the resize handle is present when sidebar has content.""" + pg = Page(sidebar=[pn.pane.Markdown("# Sidebar Content")]) + + serve_component(page, pg) + + # Check that sidebar is present + sidebar = page.locator(".sidebar") + expect(sidebar).to_be_visible() + + # Check that resize handle is present + resize_handle = page.locator('[aria-label="Resize sidebar"]') + expect(resize_handle).to_be_visible() + expect(resize_handle).to_have_attribute("title", "Drag to resize sidebar") + + +def test_page_sidebar_default_width(page): + """Test that sidebar has the default width of 320px.""" + pg = Page(sidebar=[pn.pane.Markdown("# Sidebar Content")]) + + serve_component(page, pg) + + sidebar_paper = page.locator(".MuiDrawer-paper.sidebar") + expect(sidebar_paper).to_have_css("width", "320px") + + +def test_page_sidebar_custom_width(page): + """Test that sidebar respects custom width setting.""" + pg = Page( + sidebar=[pn.pane.Markdown("# Sidebar Content")], + sidebar_width=400 + ) + + serve_component(page, pg) + + sidebar_paper = page.locator(".MuiDrawer-paper.sidebar") + expect(sidebar_paper).to_have_css("width", "400px") + + +def test_page_sidebar_resize_drag(page): + """Test that dragging the resize handle changes sidebar width.""" + pg = Page(sidebar=[pn.pane.Markdown("# Sidebar Content")]) + + serve_component(page, pg) + + # Get initial sidebar width + sidebar_paper = page.locator(".MuiDrawer-paper.sidebar") + expect(sidebar_paper).to_have_css("width", "320px") + + # Get resize handle + resize_handle = page.locator('[aria-label="Resize sidebar"]') + expect(resize_handle).to_be_visible() + + # Get the bounding box for drag calculation + handle_box = resize_handle.bounding_box() + assert handle_box is not None + + # Drag the handle to the right to increase width + page.mouse.move(handle_box["x"] + handle_box["width"] / 2, handle_box["y"] + handle_box["height"] / 2) + page.mouse.down() + page.mouse.move(handle_box["x"] + 100, handle_box["y"] + handle_box["height"] / 2) + page.mouse.up() + + # Wait for the change to be applied + page.wait_for_timeout(100) + + # Check that the width has increased (should be around 420px) + # Using a range check since exact pixel values can vary + assert pg.sidebar_width > 380, f"Expected sidebar_width > 380, got {pg.sidebar_width}" + assert pg.sidebar_width < 440, f"Expected sidebar_width < 440, got {pg.sidebar_width}" + + +def test_page_sidebar_collapse_on_small_drag(page): + """Test that dragging sidebar to very small width collapses it.""" + pg = Page( + sidebar=[pn.pane.Markdown("# Sidebar Content")], + sidebar_width=200 # Start with smaller width for easier testing + ) + + serve_component(page, pg) + + # Verify sidebar is initially open + assert pg.sidebar_open is True + sidebar_paper = page.locator(".MuiDrawer-paper.sidebar") + expect(sidebar_paper).to_be_visible() + + # Get resize handle + resize_handle = page.locator('[aria-label="Resize sidebar"]') + expect(resize_handle).to_be_visible() + + # Get the bounding box for drag calculation + handle_box = resize_handle.bounding_box() + assert handle_box is not None + + # Drag the handle far to the left to trigger collapse (more than 150px to get below 50px threshold) + page.mouse.move(handle_box["x"] + handle_box["width"] / 2, handle_box["y"] + handle_box["height"] / 2) + page.mouse.down() + page.mouse.move(handle_box["x"] - 180, handle_box["y"] + handle_box["height"] / 2) + page.mouse.up() + + # Wait for the change to be applied + page.wait_for_timeout(200) + + # Check that sidebar is now collapsed + assert pg.sidebar_open is False, "Sidebar should be collapsed when dragged to small width" + + +def test_page_sidebar_no_handle_when_empty(page): + """Test that no resize handle is present when sidebar is empty.""" + pg = Page() # No sidebar content + + serve_component(page, pg) + + # Check that resize handle is not present + resize_handle = page.locator('[aria-label="Resize sidebar"]') + expect(resize_handle).not_to_be_visible() + + +def test_page_sidebar_handle_styling(page): + """Test that the resize handle has proper styling and hover effects.""" + pg = Page(sidebar=[pn.pane.Markdown("# Sidebar Content")]) + + serve_component(page, pg) + + resize_handle = page.locator('[aria-label="Resize sidebar"]') + expect(resize_handle).to_be_visible() + + # Check that handle has col-resize cursor + expect(resize_handle).to_have_css("cursor", "col-resize") + + # Check that handle is positioned at the right edge + expect(resize_handle).to_have_css("position", "absolute") + expect(resize_handle).to_have_css("right", "0px") + expect(resize_handle).to_have_css("top", "0px") + + +def test_page_sidebar_width_persistence(page): + """Test that sidebar width changes are reflected in the model.""" + pg = Page(sidebar=[pn.pane.Markdown("# Sidebar Content")]) + + serve_component(page, pg) + + # Get initial width from model + initial_width = pg.sidebar_width + assert initial_width == 320 + + # Simulate a programmatic width change + pg.sidebar_width = 450 + + # Wait for change to be applied + page.wait_for_timeout(100) + + # Check that the CSS reflects the new width + sidebar_paper = page.locator(".MuiDrawer-paper.sidebar") + expect(sidebar_paper).to_have_css("width", "450px") From 8461b004797ac23c84eb9bb8f2cd7b37e10c9f0b Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 29 Sep 2025 16:19:21 -0700 Subject: [PATCH 4/8] eslint --- src/panel_material_ui/template/Page.jsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/panel_material_ui/template/Page.jsx b/src/panel_material_ui/template/Page.jsx index 1f0575d3..9069ef85 100644 --- a/src/panel_material_ui/template/Page.jsx +++ b/src/panel_material_ui/template/Page.jsx @@ -144,7 +144,7 @@ export function render({model, view}) { // Drag handlers for sidebar resizing const handleDragStart = React.useCallback((e) => { - const clientX = e.type.startsWith('touch') ? e.touches[0].clientX : e.clientX + const clientX = e.type.startsWith("touch") ? e.touches[0].clientX : e.clientX setIsDragging(true) setDragStartX(clientX) setDragStartWidth(sidebar_width) @@ -152,9 +152,9 @@ export function render({model, view}) { }, [sidebar_width]) const handleDragMove = React.useCallback((e) => { - if (!isDragging) return + if (!isDragging) { return } - const clientX = e.type.startsWith('touch') ? e.touches[0].clientX : e.clientX + const clientX = e.type.startsWith("touch") ? e.touches[0].clientX : e.clientX const deltaX = clientX - dragStartX const newWidth = dragStartWidth + deltaX @@ -180,16 +180,16 @@ export function render({model, view}) { const handleTouchMove = (e) => handleDragMove(e) const handleTouchEnd = () => handleDragEnd() - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - document.addEventListener('touchmove', handleTouchMove, { passive: false }) - document.addEventListener('touchend', handleTouchEnd) + document.addEventListener("mousemove", handleMouseMove) + document.addEventListener("mouseup", handleMouseUp) + document.addEventListener("touchmove", handleTouchMove, {passive: false}) + document.addEventListener("touchend", handleTouchEnd) return () => { - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) - document.removeEventListener('touchmove', handleTouchMove) - document.removeEventListener('touchend', handleTouchEnd) + document.removeEventListener("mousemove", handleMouseMove) + document.removeEventListener("mouseup", handleMouseUp) + document.removeEventListener("touchmove", handleTouchMove) + document.removeEventListener("touchend", handleTouchEnd) } } }, [isDragging, handleDragMove, handleDragEnd]) From 7e57cf2f2d64056ec3f4cf413f319436ea90ea56 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Tue, 30 Sep 2025 04:29:18 -0700 Subject: [PATCH 5/8] height --- src/panel_material_ui/template/Page.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/panel_material_ui/template/Page.jsx b/src/panel_material_ui/template/Page.jsx index 9069ef85..11b3c463 100644 --- a/src/panel_material_ui/template/Page.jsx +++ b/src/panel_material_ui/template/Page.jsx @@ -207,8 +207,10 @@ export function render({model, view}) { display: "flex", flexDirection: "column", flexShrink: 0, + height: "100vh", // Full viewport height [`& .MuiDrawer-paper`]: { width: sidebar_width, + height: "100vh", // Full viewport height boxSizing: "border-box", position: "relative" // Enable positioning for drag handle }, From a2cb35b13c0a03aaf6aab59e9454aec014e9d01e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 3 Nov 2025 13:15:58 +0100 Subject: [PATCH 6/8] Small tweaks --- src/panel_material_ui/template/Page.jsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/panel_material_ui/template/Page.jsx b/src/panel_material_ui/template/Page.jsx index 11b3c463..47455235 100644 --- a/src/panel_material_ui/template/Page.jsx +++ b/src/panel_material_ui/template/Page.jsx @@ -200,7 +200,6 @@ export function render({model, view}) { setOpen(false)) : null} sx={{ @@ -212,7 +211,8 @@ export function render({model, view}) { width: sidebar_width, height: "100vh", // Full viewport height boxSizing: "border-box", - position: "relative" // Enable positioning for drag handle + position: "relative", // Enable positioning for drag handle + overflowX: "hidden" }, }} variant={drawer_variant} @@ -226,7 +226,6 @@ export function render({model, view}) { return object })} - {/* Drag handle for resizing sidebar - only show if resizable */} {sidebar_resizable && ( contextOpen(false)} sx={{ From 989f038e22329094dcca05cd0fe55add4a1721eb Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 3 Nov 2025 13:25:31 +0100 Subject: [PATCH 7/8] Small fixes --- src/panel_material_ui/template/Page.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/panel_material_ui/template/Page.jsx b/src/panel_material_ui/template/Page.jsx index 47455235..0d505e05 100644 --- a/src/panel_material_ui/template/Page.jsx +++ b/src/panel_material_ui/template/Page.jsx @@ -163,7 +163,7 @@ export function render({model, view}) { setOpen(false) } else { // Update width immediately for responsive feedback - setSidebarWidth(newWidth) + setSidebarWidth(Math.round(newWidth)) } e.preventDefault() }, [isDragging, dragStartX, dragStartWidth, setOpen]) @@ -212,7 +212,7 @@ export function render({model, view}) { height: "100vh", // Full viewport height boxSizing: "border-box", position: "relative", // Enable positioning for drag handle - overflowX: "hidden" + overflowX: "hidden" }, }} variant={drawer_variant} From b769c533da4989f6fd00451114a7018e163b43c0 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 3 Nov 2025 13:28:42 +0100 Subject: [PATCH 8/8] Update docs --- examples/reference/page/Page.ipynb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/reference/page/Page.ipynb b/examples/reference/page/Page.ipynb index 6ea953ec..91578f88 100644 --- a/examples/reference/page/Page.ipynb +++ b/examples/reference/page/Page.ipynb @@ -44,8 +44,9 @@ "### Sidebar\n", "\n", "* **`sidebar_open`** (`boolean`): Whether the sidebar is open or closed.\n", - "* **`sidebar_width`** (`int`): Width of the sidebar.\n", + "* **`sidebar_resizable`** (`boolean`): Whether the sidebar is resizable.\n", "* **`sidebar_variant`** (`Literal[\"persistent\", \"temporary\", \"permanent\", \"auto\"]`): Whether the sidebar is persistent, temporary, permanent or automatically adapts based on screen size.\n", + "* **`sidebar_width`** (`int`): Width of the sidebar.\n", "\n", "### Contextbar\n", "\n",