Skip to content

Commit 8c131ff

Browse files
authored
Add main_width and app_bar_width to Page (#684)
* widths * by breakpoint
1 parent dcfb6aa commit 8c131ff

3 files changed

Lines changed: 153 additions & 2 deletions

File tree

src/panel_material_ui/template/Page.jsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,22 @@ const PAGE_DRAWER_RESIZE_HANDLE_SX = {
5151
}
5252
}
5353

54+
// Normalize a width value into an MUI sx `maxWidth`.
55+
// Accepts a number (interpreted as pixels), a CSS length string
56+
// (e.g. "70ch", "60rem", "90%"), or a breakpoint dict
57+
// (e.g. {xs: "100%", md: 720, lg: 960}) which maps to a responsive sx object
58+
// where each value applies at that breakpoint and up. Returns undefined when unset.
59+
const to_css_width = (v) => (typeof v === "number" ? `${v}px` : v)
60+
const to_max_width = (v) => {
61+
if (v == null) { return undefined }
62+
if (typeof v === "object") {
63+
return Object.fromEntries(
64+
Object.entries(v).map(([bp, w]) => [bp, to_css_width(w)])
65+
)
66+
}
67+
return to_css_width(v)
68+
}
69+
5470
const Main = styled("main", {shouldForwardProp: (prop) => !["open", "variant", "sidebar_width", "contextbar_open", "context_variant", "contextbar_width"].includes(prop)})(
5571
({sidebar_width, contextbar_width, theme, open, variant, contextbar_open, context_variant}) => {
5672
return ({
@@ -101,6 +117,8 @@ export function render({model, view}) {
101117
const [contextbar_resizable] = model.useState("contextbar_resizable")
102118
const [contextbar_variant] = model.useState("contextbar_variant")
103119
const [contextbar_width, setContextbarWidth] = model.useState("contextbar_width")
120+
const [main_width] = model.useState("main_width")
121+
const [app_bar_width] = model.useState("app_bar_width")
104122
const [dark_theme, setDarkTheme] = model.useState("dark_theme")
105123
const [logo] = model.useState("logo")
106124
const [open, setOpen] = model.useState("sidebar_open")
@@ -426,11 +444,30 @@ export function render({model, view}) {
426444
[primary_color]
427445
)
428446
const appBarSx = React.useMemo(() => [PAGE_APPBAR_SX, header_sx], [header_sx])
447+
const appBarToolbarSx = React.useMemo(() => {
448+
// app_bar_width follows main_width when unset, so the header stays aligned
449+
// with the clamped main content; an explicit app_bar_width overrides it.
450+
const maxWidth = to_max_width(app_bar_width ?? main_width)
451+
return maxWidth == null ? undefined : {maxWidth, width: "100%", alignSelf: "center"}
452+
}, [app_bar_width, main_width])
453+
const mainContentSx = React.useMemo(() => {
454+
const maxWidth = to_max_width(main_width)
455+
return {
456+
flexGrow: 1,
457+
display: "flex",
458+
minHeight: 0,
459+
flexDirection: "column",
460+
overflowY: main_stretch ? "hidden" : "auto",
461+
// alignSelf centers the clamped content within the (column) flex parent;
462+
// margin:auto would compute to resolved pixels and is harder to assert on.
463+
...(maxWidth == null ? {} : {maxWidth, width: "100%", alignSelf: "center"}),
464+
}
465+
}, [main_stretch, main_width])
429466

430467
return (
431468
<Box className={`mui-${color_scheme}`} sx={pageRootSx}>
432469
<AppBar position="fixed" color="primary" className="header" sx={appBarSx}>
433-
<Toolbar>
470+
<Toolbar sx={appBarToolbarSx}>
434471
{(model.sidebar.length > 0 && drawer_variant !== "permanent") &&
435472
<Tooltip enterDelay={500} title={open ? "Close drawer" : "Open drawer"}>
436473
<IconButton
@@ -534,7 +571,7 @@ export function render({model, view}) {
534571
<Toolbar sx={toolbarSx}>
535572
<Typography variant="h5">&nbsp;</Typography>
536573
</Toolbar>
537-
<Box sx={{flexGrow: 1, display: "flex", minHeight: 0, flexDirection: "column", overflowY: main_stretch ? "hidden" : "auto"}}>
574+
<Box className="main-content" sx={mainContentSx}>
538575
{main.map((object, index) => {
539576
apply_flex(view.get_child_view(model.main[index]), "column")
540577
return object

src/panel_material_ui/template/base.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ class Page(MaterialComponent, ResourceComponent):
7171
>>> Page(main=['# Content'], title='My App')
7272
"""
7373

74+
app_bar_width = param.ClassSelector(default=None, class_=(int, str, dict), doc="""
75+
Maximum width of the app bar (header) content. When set, the toolbar
76+
content is clamped to this width and centered, aligning it with a
77+
clamped main area. Accepts a number (interpreted as pixels), a CSS
78+
length string (e.g. '70ch', '60rem', '90%'), or a dict mapping Material
79+
UI breakpoints to widths (e.g. {'xs': '100%', 'md': 720, 'lg': 960}),
80+
where each value applies at that breakpoint and up. Defaults to None
81+
(full width); when unset it follows ``main_width`` so the header stays
82+
aligned with the main content.""")
83+
7484
busy = param.Boolean(default=False, readonly=True, doc="Whether the page is busy.")
7585

7686
busy_indicator: t.Literal["circular", "linear"] | None = param.Selector(default="linear", objects=["circular", "linear", None], doc="""
@@ -100,6 +110,14 @@ class Page(MaterialComponent, ResourceComponent):
100110

101111
main = Children(doc="Items rendered in the main area.")
102112

113+
main_width = param.ClassSelector(default=None, class_=(int, str, dict), doc="""
114+
Maximum width of the main content area. When set, the main content is
115+
clamped to this width and centered to improve readability. Accepts a
116+
number (interpreted as pixels), a CSS length string (e.g. '70ch',
117+
'60rem', '90%'), or a dict mapping Material UI breakpoints to widths
118+
(e.g. {'xs': '100%', 'md': 720, 'lg': 960}), where each value applies
119+
at that breakpoint and up. Defaults to None (full width).""")
120+
103121
meta = param.ClassSelector(default=None, class_=Meta, doc="Meta tags and other HTML head elements.")
104122

105123
logo = param.ClassSelector(default=None, class_=(str, pathlib.Path, dict), doc="""

tests/ui/template/test_page.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,102 @@ def test_page_sidebar_width_persistence(page):
188188
expect(sidebar_paper).to_have_css("width", "450px")
189189

190190

191+
def test_page_main_width_default_unclamped(page):
192+
"""By default the main content area is not clamped (no max-width)."""
193+
pg = Page(main=[pn.pane.Markdown("# Content")])
194+
195+
serve_component(page, pg)
196+
197+
main_content = page.locator(".main-content")
198+
expect(main_content).to_be_attached()
199+
expect(main_content).to_have_css("max-width", "none")
200+
201+
202+
def test_page_main_width_custom(page):
203+
"""main_width clamps and centers the main content area."""
204+
pg = Page(main=[pn.pane.Markdown("# Content")], main_width=800)
205+
206+
serve_component(page, pg)
207+
208+
main_content = page.locator(".main-content")
209+
expect(main_content).to_have_css("max-width", "800px")
210+
# Centered via alignSelf in the column flex parent (margin:auto would
211+
# compute to resolved pixels, so we assert on align-self instead).
212+
expect(main_content).to_have_css("align-self", "center")
213+
214+
215+
def test_page_app_bar_width_default_unclamped(page):
216+
"""By default the app bar toolbar is not clamped (no max-width)."""
217+
pg = Page(main=[pn.pane.Markdown("# Content")])
218+
219+
serve_component(page, pg)
220+
221+
toolbar = page.locator(".header .MuiToolbar-root")
222+
expect(toolbar).to_have_css("max-width", "none")
223+
224+
225+
def test_page_app_bar_width_custom(page):
226+
"""app_bar_width clamps and centers the app bar toolbar content."""
227+
pg = Page(main=[pn.pane.Markdown("# Content")], app_bar_width=900)
228+
229+
serve_component(page, pg)
230+
231+
toolbar = page.locator(".header .MuiToolbar-root")
232+
expect(toolbar).to_have_css("max-width", "900px")
233+
expect(toolbar).to_have_css("align-self", "center")
234+
235+
236+
def test_page_main_width_css_string(page):
237+
"""main_width accepts a CSS length string (resolved by the browser)."""
238+
# 60rem resolves to 960px at the default 16px root font size.
239+
pg = Page(main=[pn.pane.Markdown("# Content")], main_width="60rem")
240+
241+
serve_component(page, pg)
242+
243+
main_content = page.locator(".main-content")
244+
expect(main_content).to_have_css("max-width", "960px")
245+
expect(main_content).to_have_css("align-self", "center")
246+
247+
248+
def test_page_main_width_breakpoint_dict(page):
249+
"""main_width accepts a breakpoint dict, applying per-breakpoint clamps."""
250+
pg = Page(main=[pn.pane.Markdown("# Content")], main_width={"xs": "100%", "md": 800})
251+
252+
serve_component(page, pg)
253+
254+
main_content = page.locator(".main-content")
255+
# At/above the md breakpoint the 800px clamp applies.
256+
page.set_viewport_size({"width": 1200, "height": 800})
257+
expect(main_content).to_have_css("max-width", "800px")
258+
# Below md the clamp falls back to the xs entry (100%), i.e. not 800px.
259+
page.set_viewport_size({"width": 500, "height": 800})
260+
expect(main_content).not_to_have_css("max-width", "800px")
261+
262+
263+
def test_page_app_bar_width_follows_main_width(page):
264+
"""When app_bar_width is unset, the toolbar follows main_width so the
265+
header stays aligned with the clamped main content."""
266+
pg = Page(main=[pn.pane.Markdown("# Content")], main_width=800)
267+
268+
serve_component(page, pg)
269+
270+
toolbar = page.locator(".header .MuiToolbar-root")
271+
expect(toolbar).to_have_css("max-width", "800px")
272+
expect(toolbar).to_have_css("align-self", "center")
273+
274+
275+
def test_page_app_bar_width_overrides_main_width(page):
276+
"""An explicit app_bar_width takes precedence over main_width for the toolbar."""
277+
pg = Page(main=[pn.pane.Markdown("# Content")], main_width=800, app_bar_width=1200)
278+
279+
serve_component(page, pg)
280+
281+
toolbar = page.locator(".header .MuiToolbar-root")
282+
expect(toolbar).to_have_css("max-width", "1200px")
283+
main_content = page.locator(".main-content")
284+
expect(main_content).to_have_css("max-width", "800px")
285+
286+
191287
def test_page_linear_progress_hidden_when_idle(page):
192288
"""Test that linear progress bar is hidden (opacity: 0) when not busy."""
193289
pg = Page(

0 commit comments

Comments
 (0)