Skip to content

Commit a5ea2d6

Browse files
authored
Add draggable/resizable sidebar functionality to MUI Drawer-based sidebar (#476)
1 parent 4ce8413 commit a5ea2d6

File tree

4 files changed

+266
-8
lines changed

4 files changed

+266
-8
lines changed

examples/reference/page/Page.ipynb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@
4444
"### Sidebar\n",
4545
"\n",
4646
"* **`sidebar_open`** (`boolean`): Whether the sidebar is open or closed.\n",
47-
"* **`sidebar_width`** (`int`): Width of the sidebar.\n",
47+
"* **`sidebar_resizable`** (`boolean`): Whether the sidebar is resizable.\n",
4848
"* **`sidebar_variant`** (`Literal[\"persistent\", \"temporary\", \"permanent\", \"auto\"]`): Whether the sidebar is persistent, temporary, permanent or automatically adapts based on screen size.\n",
49+
"* **`sidebar_width`** (`int`): Width of the sidebar.\n",
4950
"\n",
5051
"### Contextbar\n",
5152
"\n",

src/panel_material_ui/template/Page.jsx

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,14 @@ export function render({model, view}) {
5656
const [dark_theme, setDarkTheme] = model.useState("dark_theme")
5757
const [logo] = model.useState("logo")
5858
const [open, setOpen] = model.useState("sidebar_open")
59-
const [sidebar_width] = model.useState("sidebar_width")
59+
const [sidebar_resizable] = model.useState("sidebar_resizable")
60+
const [sidebar_width, setSidebarWidth] = model.useState("sidebar_width")
6061
const [theme_toggle] = model.useState("theme_toggle")
62+
63+
// Draggable sidebar state and constants
64+
const [isDragging, setIsDragging] = React.useState(false)
65+
const [dragStartX, setDragStartX] = React.useState(0)
66+
const [dragStartWidth, setDragStartWidth] = React.useState(0)
6167
const [site_url] = model.useState("site_url")
6268
const [title] = model.useState("title")
6369
const [variant] = model.useState("sidebar_variant")
@@ -136,19 +142,78 @@ export function render({model, view}) {
136142
setup_global_styles(view, theme)
137143
React.useEffect(() => dark_mode.set_value(dark_theme), [dark_theme])
138144

145+
// Drag handlers for sidebar resizing
146+
const handleDragStart = React.useCallback((e) => {
147+
const clientX = e.type.startsWith("touch") ? e.touches[0].clientX : e.clientX
148+
setIsDragging(true)
149+
setDragStartX(clientX)
150+
setDragStartWidth(sidebar_width)
151+
e.preventDefault()
152+
}, [sidebar_width])
153+
154+
const handleDragMove = React.useCallback((e) => {
155+
if (!isDragging) { return }
156+
157+
const clientX = e.type.startsWith("touch") ? e.touches[0].clientX : e.clientX
158+
const deltaX = clientX - dragStartX
159+
const newWidth = dragStartWidth + deltaX
160+
161+
// If width gets close to 0, collapse the sidebar completely
162+
if (newWidth < 50) {
163+
setOpen(false)
164+
} else {
165+
// Update width immediately for responsive feedback
166+
setSidebarWidth(Math.round(newWidth))
167+
}
168+
e.preventDefault()
169+
}, [isDragging, dragStartX, dragStartWidth, setOpen])
170+
171+
const handleDragEnd = React.useCallback(() => {
172+
setIsDragging(false)
173+
}, [])
174+
175+
// Add global mouse/touch event listeners when dragging
176+
React.useEffect(() => {
177+
if (isDragging) {
178+
const handleMouseMove = (e) => handleDragMove(e)
179+
const handleMouseUp = () => handleDragEnd()
180+
const handleTouchMove = (e) => handleDragMove(e)
181+
const handleTouchEnd = () => handleDragEnd()
182+
183+
document.addEventListener("mousemove", handleMouseMove)
184+
document.addEventListener("mouseup", handleMouseUp)
185+
document.addEventListener("touchmove", handleTouchMove, {passive: false})
186+
document.addEventListener("touchend", handleTouchEnd)
187+
188+
return () => {
189+
document.removeEventListener("mousemove", handleMouseMove)
190+
document.removeEventListener("mouseup", handleMouseUp)
191+
document.removeEventListener("touchmove", handleTouchMove)
192+
document.removeEventListener("touchend", handleTouchEnd)
193+
}
194+
}
195+
}, [isDragging, handleDragMove, handleDragEnd])
196+
139197
const drawer_variant = variant === "auto" ? (isMobile ? "temporary": "persistent") : variant
198+
140199
const drawer = sidebar.length > 0 ? (
141200
<Drawer
142201
PaperProps={{className: "sidebar"}}
143202
anchor="left"
144-
disablePortal
145203
open={open}
146204
onClose={drawer_variant === "temporary" ? (() => setOpen(false)) : null}
147205
sx={{
148206
display: "flex",
149207
flexDirection: "column",
150208
flexShrink: 0,
151-
[`& .MuiDrawer-paper`]: {width: sidebar_width, boxSizing: "border-box"},
209+
height: "100vh", // Full viewport height
210+
[`& .MuiDrawer-paper`]: {
211+
width: sidebar_width,
212+
height: "100vh", // Full viewport height
213+
boxSizing: "border-box",
214+
position: "relative", // Enable positioning for drag handle
215+
overflowX: "hidden"
216+
},
152217
}}
153218
variant={drawer_variant}
154219
>
@@ -161,14 +226,46 @@ export function render({model, view}) {
161226
return object
162227
})}
163228
</Box>
229+
{sidebar_resizable && (
230+
<Box
231+
onMouseDown={handleDragStart}
232+
onTouchStart={handleDragStart}
233+
sx={{
234+
position: "absolute",
235+
top: 0,
236+
right: 0,
237+
width: "4px",
238+
height: "100%",
239+
cursor: "col-resize",
240+
backgroundColor: "transparent",
241+
borderRight: `1px solid ${theme.palette.divider}`,
242+
zIndex: 1000,
243+
"&:hover": {
244+
borderRightWidth: "2px",
245+
borderRightColor: theme.palette.divider,
246+
},
247+
// Make the handle slightly larger for easier interaction
248+
"&:before": {
249+
content: '""',
250+
position: "absolute",
251+
top: 0,
252+
right: "-3px", // Extend hit area beyond visual boundary
253+
width: "6px",
254+
height: "100%",
255+
backgroundColor: "transparent"
256+
}
257+
}}
258+
aria-label="Resize sidebar"
259+
title="Drag to resize sidebar"
260+
/>
261+
)}
164262
</Drawer>
165263
) : null
166264

167265
const context_drawer = contextbar.length > 0 ? (
168266
<Drawer
169267
PaperProps={{className: "contextbar"}}
170268
anchor="right"
171-
disablePortal
172269
open={contextbar_open}
173270
onClose={() => contextOpen(false)}
174271
sx={{

src/panel_material_ui/template/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ class Page(MaterialComponent, ResourceComponent):
103103

104104
sidebar_open = param.Boolean(default=True, doc="Whether the sidebar is open or closed.")
105105

106+
sidebar_resizable = param.Boolean(default=True, doc="Whether the sidebar can be resized by dragging.")
107+
106108
sidebar_variant = param.Selector(default="auto", objects=SIDEBAR_VARIANTS, doc="""
107109
Whether the sidebar is persistent, a temporary drawer, a permanent drawer, or automatically
108110
switches between the two based on screen size.""")

tests/ui/template/test_page.py

Lines changed: 161 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
pytest.importorskip('playwright')
44

5-
from panel_material_ui.template import Page
6-
7-
from playwright.sync_api import expect
5+
import panel as pn
86
from panel.tests.util import serve_component
7+
from playwright.sync_api import expect
8+
9+
from panel_material_ui.template import Page
910

1011
pytestmark = pytest.mark.ui
1112

@@ -26,3 +27,160 @@ def test_page_theme_config_header_color(page):
2627
}
2728
}
2829
expect(header).to_have_css("background-color", "rgb(0, 0, 0)")
30+
31+
32+
def test_page_sidebar_resizable_handle_present(page):
33+
"""Test that the resize handle is present when sidebar has content."""
34+
pg = Page(sidebar=[pn.pane.Markdown("# Sidebar Content")])
35+
36+
serve_component(page, pg)
37+
38+
# Check that sidebar is present
39+
sidebar = page.locator(".sidebar")
40+
expect(sidebar).to_be_visible()
41+
42+
# Check that resize handle is present
43+
resize_handle = page.locator('[aria-label="Resize sidebar"]')
44+
expect(resize_handle).to_be_visible()
45+
expect(resize_handle).to_have_attribute("title", "Drag to resize sidebar")
46+
47+
48+
def test_page_sidebar_default_width(page):
49+
"""Test that sidebar has the default width of 320px."""
50+
pg = Page(sidebar=[pn.pane.Markdown("# Sidebar Content")])
51+
52+
serve_component(page, pg)
53+
54+
sidebar_paper = page.locator(".MuiDrawer-paper.sidebar")
55+
expect(sidebar_paper).to_have_css("width", "320px")
56+
57+
58+
def test_page_sidebar_custom_width(page):
59+
"""Test that sidebar respects custom width setting."""
60+
pg = Page(
61+
sidebar=[pn.pane.Markdown("# Sidebar Content")],
62+
sidebar_width=400
63+
)
64+
65+
serve_component(page, pg)
66+
67+
sidebar_paper = page.locator(".MuiDrawer-paper.sidebar")
68+
expect(sidebar_paper).to_have_css("width", "400px")
69+
70+
71+
def test_page_sidebar_resize_drag(page):
72+
"""Test that dragging the resize handle changes sidebar width."""
73+
pg = Page(sidebar=[pn.pane.Markdown("# Sidebar Content")])
74+
75+
serve_component(page, pg)
76+
77+
# Get initial sidebar width
78+
sidebar_paper = page.locator(".MuiDrawer-paper.sidebar")
79+
expect(sidebar_paper).to_have_css("width", "320px")
80+
81+
# Get resize handle
82+
resize_handle = page.locator('[aria-label="Resize sidebar"]')
83+
expect(resize_handle).to_be_visible()
84+
85+
# Get the bounding box for drag calculation
86+
handle_box = resize_handle.bounding_box()
87+
assert handle_box is not None
88+
89+
# Drag the handle to the right to increase width
90+
page.mouse.move(handle_box["x"] + handle_box["width"] / 2, handle_box["y"] + handle_box["height"] / 2)
91+
page.mouse.down()
92+
page.mouse.move(handle_box["x"] + 100, handle_box["y"] + handle_box["height"] / 2)
93+
page.mouse.up()
94+
95+
# Wait for the change to be applied
96+
page.wait_for_timeout(100)
97+
98+
# Check that the width has increased (should be around 420px)
99+
# Using a range check since exact pixel values can vary
100+
assert pg.sidebar_width > 380, f"Expected sidebar_width > 380, got {pg.sidebar_width}"
101+
assert pg.sidebar_width < 440, f"Expected sidebar_width < 440, got {pg.sidebar_width}"
102+
103+
104+
def test_page_sidebar_collapse_on_small_drag(page):
105+
"""Test that dragging sidebar to very small width collapses it."""
106+
pg = Page(
107+
sidebar=[pn.pane.Markdown("# Sidebar Content")],
108+
sidebar_width=200 # Start with smaller width for easier testing
109+
)
110+
111+
serve_component(page, pg)
112+
113+
# Verify sidebar is initially open
114+
assert pg.sidebar_open is True
115+
sidebar_paper = page.locator(".MuiDrawer-paper.sidebar")
116+
expect(sidebar_paper).to_be_visible()
117+
118+
# Get resize handle
119+
resize_handle = page.locator('[aria-label="Resize sidebar"]')
120+
expect(resize_handle).to_be_visible()
121+
122+
# Get the bounding box for drag calculation
123+
handle_box = resize_handle.bounding_box()
124+
assert handle_box is not None
125+
126+
# Drag the handle far to the left to trigger collapse (more than 150px to get below 50px threshold)
127+
page.mouse.move(handle_box["x"] + handle_box["width"] / 2, handle_box["y"] + handle_box["height"] / 2)
128+
page.mouse.down()
129+
page.mouse.move(handle_box["x"] - 180, handle_box["y"] + handle_box["height"] / 2)
130+
page.mouse.up()
131+
132+
# Wait for the change to be applied
133+
page.wait_for_timeout(200)
134+
135+
# Check that sidebar is now collapsed
136+
assert pg.sidebar_open is False, "Sidebar should be collapsed when dragged to small width"
137+
138+
139+
def test_page_sidebar_no_handle_when_empty(page):
140+
"""Test that no resize handle is present when sidebar is empty."""
141+
pg = Page() # No sidebar content
142+
143+
serve_component(page, pg)
144+
145+
# Check that resize handle is not present
146+
resize_handle = page.locator('[aria-label="Resize sidebar"]')
147+
expect(resize_handle).not_to_be_visible()
148+
149+
150+
def test_page_sidebar_handle_styling(page):
151+
"""Test that the resize handle has proper styling and hover effects."""
152+
pg = Page(sidebar=[pn.pane.Markdown("# Sidebar Content")])
153+
154+
serve_component(page, pg)
155+
156+
resize_handle = page.locator('[aria-label="Resize sidebar"]')
157+
expect(resize_handle).to_be_visible()
158+
159+
# Check that handle has col-resize cursor
160+
expect(resize_handle).to_have_css("cursor", "col-resize")
161+
162+
# Check that handle is positioned at the right edge
163+
expect(resize_handle).to_have_css("position", "absolute")
164+
expect(resize_handle).to_have_css("right", "0px")
165+
expect(resize_handle).to_have_css("top", "0px")
166+
167+
168+
def test_page_sidebar_width_persistence(page):
169+
"""Test that sidebar width changes are reflected in the model."""
170+
pg = Page(sidebar=[pn.pane.Markdown("# Sidebar Content")])
171+
172+
serve_component(page, pg)
173+
174+
# Get initial width from model
175+
initial_width = pg.sidebar_width
176+
assert initial_width == 320
177+
178+
# Simulate a programmatic width change
179+
pg.sidebar_width = 450
180+
181+
# Wait for change to be applied
182+
page.wait_for_timeout(100)
183+
184+
# Check that the CSS reflects the new width
185+
sidebar_paper = page.locator(".MuiDrawer-paper.sidebar")
186+
expect(sidebar_paper).to_have_css("width", "450px")

0 commit comments

Comments
 (0)