Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion examples/reference/page/Page.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
105 changes: 101 additions & 4 deletions src/panel_material_ui/template/Page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,14 @@ 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_resizable] = model.useState("sidebar_resizable")
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 [site_url] = model.useState("site_url")
const [title] = model.useState("title")
const [variant] = model.useState("sidebar_variant")
Expand Down Expand Up @@ -136,19 +142,78 @@ 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)
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 = 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(Math.round(newWidth))
}
e.preventDefault()
}, [isDragging, dragStartX, dragStartWidth, setOpen])

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

const drawer = sidebar.length > 0 ? (
<Drawer
PaperProps={{className: "sidebar"}}
anchor="left"
disablePortal
open={open}
onClose={drawer_variant === "temporary" ? (() => setOpen(false)) : null}
sx={{
display: "flex",
flexDirection: "column",
flexShrink: 0,
[`& .MuiDrawer-paper`]: {width: sidebar_width, boxSizing: "border-box"},
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
overflowX: "hidden"
},
}}
variant={drawer_variant}
>
Expand All @@ -161,14 +226,46 @@ export function render({model, view}) {
return object
})}
</Box>
{sidebar_resizable && (
<Box
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
sx={{
position: "absolute",
top: 0,
right: 0,
width: "4px",
height: "100%",
cursor: "col-resize",
backgroundColor: "transparent",
borderRight: `1px solid ${theme.palette.divider}`,
zIndex: 1000,
"&:hover": {
borderRightWidth: "2px",
borderRightColor: theme.palette.divider,
},
// 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"
/>
)}
</Drawer>
) : null

const context_drawer = contextbar.length > 0 ? (
<Drawer
PaperProps={{className: "contextbar"}}
anchor="right"
disablePortal
open={contextbar_open}
onClose={() => contextOpen(false)}
sx={{
Expand Down
2 changes: 2 additions & 0 deletions src/panel_material_ui/template/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.""")
Expand Down
164 changes: 161 additions & 3 deletions tests/ui/template/test_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")