Skip to content

Commit 08e73f6

Browse files
committed
feat: add adjustable sidebar width with drag handles
- Convert AppLayout from class to functional component - Add outer resize handle between sidebar and map (drag to resize) - Add inner resize handle between layer list and properties drawer - Use CSS custom properties for dynamic sidebar/panel widths - Persist both sidebar width and list/drawer ratio to localStorage - Fix layer click selection: clicking layer name text now opens properties - Extract sidebar helpers to src/libs/sidebar.ts - Add unit tests for sidebar helpers - Add Cypress e2e test for resize handles
1 parent 4e6009a commit 08e73f6

7 files changed

Lines changed: 491 additions & 43 deletions

File tree

cypress/e2e/sidebar-resize.cy.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { MaputnikDriver } from "./maputnik-driver";
2+
3+
describe("sidebar resize", () => {
4+
const { beforeAndAfter, when } = new MaputnikDriver();
5+
beforeAndAfter();
6+
7+
beforeEach(() => {
8+
when.setStyle("both");
9+
});
10+
11+
it("resize handle is visible", () => {
12+
cy.get("[data-testid='sidebar-resize-handle']").should("exist").and("be.visible");
13+
});
14+
15+
it("inner resize handle is visible", () => {
16+
cy.get("[data-testid='inner-resize-handle']").should("exist").and("be.visible");
17+
});
18+
19+
it("dragging the handle changes sidebar width", () => {
20+
cy.get(".maputnik-layout-list").then(($list) => {
21+
const initialWidth = $list[0].getBoundingClientRect().width;
22+
23+
cy.get("[data-testid='sidebar-resize-handle']")
24+
.realMouseDown({ position: "center" })
25+
.realMouseMove(100, 0, { position: "center" })
26+
.realMouseUp();
27+
28+
cy.get(".maputnik-layout-list").should(($listAfter) => {
29+
const newWidth = $listAfter[0].getBoundingClientRect().width;
30+
expect(newWidth).to.be.greaterThan(initialWidth);
31+
});
32+
});
33+
});
34+
35+
it("dragging inner handle changes list/drawer split", () => {
36+
cy.get(".maputnik-layout-list").then(($list) => {
37+
const initialWidth = $list[0].getBoundingClientRect().width;
38+
39+
cy.get("[data-testid='inner-resize-handle']")
40+
.realMouseDown({ position: "center" })
41+
.realMouseMove(80, 0, { position: "center" })
42+
.realMouseUp();
43+
44+
cy.get(".maputnik-layout-list").should(($listAfter) => {
45+
const newWidth = $listAfter[0].getBoundingClientRect().width;
46+
expect(newWidth).to.be.greaterThan(initialWidth);
47+
});
48+
});
49+
});
50+
});

src/components/AppLayout.tsx

Lines changed: 165 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,182 @@
1-
import React from "react";
1+
import React, {useCallback, useEffect, useRef, useState} from "react";
22
import ScrollContainer from "./ScrollContainer";
3-
import { type WithTranslation, withTranslation } from "react-i18next";
4-
import { IconContext } from "react-icons";
3+
import {useTranslation} from "react-i18next";
4+
import {IconContext} from "react-icons";
5+
import {
6+
DEFAULT_LIST_RATIO,
7+
DEFAULT_SIDEBAR_WIDTH,
8+
MIN_LIST_WIDTH,
9+
MIN_DRAWER_WIDTH,
10+
clampSidebarWidth,
11+
getSavedSidebarWidth,
12+
getSavedListRatio,
13+
saveSidebarWidth,
14+
saveListRatio,
15+
} from "../libs/sidebar";
516

6-
type AppLayoutInternalProps = {
17+
type AppLayoutProps = {
718
toolbar: React.ReactElement
819
layerList: React.ReactElement
920
layerEditor?: React.ReactElement
1021
codeEditor?: React.ReactElement
1122
map: React.ReactElement
1223
bottom?: React.ReactElement
1324
modals?: React.ReactNode
14-
} & WithTranslation;
25+
};
1526

16-
class AppLayoutInternal extends React.Component<AppLayoutInternalProps> {
27+
export default function AppLayout(props: AppLayoutProps) {
28+
const {i18n} = useTranslation();
1729

18-
render() {
19-
document.body.dir = this.props.i18n.dir();
30+
useEffect(() => {
31+
document.body.dir = i18n.dir();
32+
}, [i18n]);
2033

21-
return <IconContext.Provider value={{size: "14px"}}>
22-
<div className="maputnik-layout">
23-
{this.props.toolbar}
24-
<div className="maputnik-layout-main">
25-
{this.props.codeEditor && <div className="maputnik-layout-code-editor">
34+
const [sidebarWidth, setSidebarWidth] = useState<number>(
35+
() => getSavedSidebarWidth() ?? DEFAULT_SIDEBAR_WIDTH
36+
);
37+
const [listRatio, setListRatio] = useState<number>(
38+
() => getSavedListRatio() ?? DEFAULT_LIST_RATIO
39+
);
40+
41+
// Outer handle (sidebar <-> map) drag state
42+
const isDragging = useRef(false);
43+
const startX = useRef(0);
44+
const startWidth = useRef(0);
45+
46+
// Inner handle (list <-> drawer) drag state
47+
const isInnerDragging = useRef(false);
48+
const innerStartX = useRef(0);
49+
const innerStartListWidth = useRef(0);
50+
51+
// Compute sub-widths from ratio
52+
const listWidth = Math.round(sidebarWidth * listRatio);
53+
const drawerWidth = sidebarWidth - listWidth;
54+
55+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
56+
e.preventDefault();
57+
isDragging.current = true;
58+
startX.current = e.clientX;
59+
startWidth.current = sidebarWidth;
60+
document.body.style.cursor = "col-resize";
61+
document.body.style.userSelect = "none";
62+
}, [sidebarWidth]);
63+
64+
// Inner handle: resize list <-> drawer split
65+
const handleInnerMouseDown = useCallback((e: React.MouseEvent) => {
66+
e.preventDefault();
67+
isInnerDragging.current = true;
68+
innerStartX.current = e.clientX;
69+
innerStartListWidth.current = listWidth;
70+
document.body.style.cursor = "col-resize";
71+
document.body.style.userSelect = "none";
72+
}, [listWidth]);
73+
74+
useEffect(() => {
75+
const handleMouseMove = (e: MouseEvent) => {
76+
const isRtl = document.body.dir === "rtl";
77+
78+
// Outer drag
79+
if (isDragging.current) {
80+
const delta = isRtl
81+
? startX.current - e.clientX
82+
: e.clientX - startX.current;
83+
const newWidth = clampSidebarWidth(startWidth.current + delta);
84+
setSidebarWidth(newWidth);
85+
}
86+
87+
// Inner drag
88+
if (isInnerDragging.current) {
89+
const delta = isRtl
90+
? innerStartX.current - e.clientX
91+
: e.clientX - innerStartX.current;
92+
const newListWidth = innerStartListWidth.current + delta;
93+
setSidebarWidth((sw) => {
94+
const clampedList = Math.max(MIN_LIST_WIDTH, Math.min(sw - MIN_DRAWER_WIDTH, newListWidth));
95+
const newRatio = clampedList / sw;
96+
setListRatio(newRatio);
97+
return sw;
98+
});
99+
}
100+
};
101+
102+
const handleMouseUp = () => {
103+
if (isDragging.current) {
104+
isDragging.current = false;
105+
document.body.style.cursor = "";
106+
document.body.style.userSelect = "";
107+
setSidebarWidth((w) => {
108+
saveSidebarWidth(w);
109+
return w;
110+
});
111+
}
112+
if (isInnerDragging.current) {
113+
isInnerDragging.current = false;
114+
document.body.style.cursor = "";
115+
document.body.style.userSelect = "";
116+
setListRatio((r) => {
117+
saveListRatio(r);
118+
return r;
119+
});
120+
}
121+
};
122+
123+
document.addEventListener("mousemove", handleMouseMove);
124+
document.addEventListener("mouseup", handleMouseUp);
125+
return () => {
126+
document.removeEventListener("mousemove", handleMouseMove);
127+
document.removeEventListener("mouseup", handleMouseUp);
128+
};
129+
}, []);
130+
131+
const layoutStyle = {
132+
"--sidebar-list-width": `${listWidth}px`,
133+
"--sidebar-drawer-width": `${drawerWidth}px`,
134+
"--sidebar-total-width": `${sidebarWidth}px`,
135+
} as React.CSSProperties;
136+
137+
return <IconContext.Provider value={{size: "14px"}}>
138+
<div className="maputnik-layout" style={layoutStyle}>
139+
{props.toolbar}
140+
<div className="maputnik-layout-main">
141+
{props.codeEditor && <div className="maputnik-layout-code-editor">
142+
<ScrollContainer>
143+
{props.codeEditor}
144+
</ScrollContainer>
145+
</div>
146+
}
147+
{!props.codeEditor && <>
148+
<div className="maputnik-layout-list">
149+
{props.layerList}
150+
</div>
151+
<div
152+
className="maputnik-layout-resize-handle maputnik-layout-resize-handle--inner"
153+
data-testid="inner-resize-handle"
154+
onMouseDown={handleInnerMouseDown}
155+
title="Drag to resize list / editor split"
156+
tabIndex={-1}
157+
aria-hidden="true"
158+
/>
159+
<div className="maputnik-layout-drawer">
26160
<ScrollContainer>
27-
{this.props.codeEditor}
161+
{props.layerEditor}
28162
</ScrollContainer>
29163
</div>
30-
}
31-
{!this.props.codeEditor && <>
32-
<div className="maputnik-layout-list">
33-
{this.props.layerList}
34-
</div>
35-
<div className="maputnik-layout-drawer">
36-
<ScrollContainer>
37-
{this.props.layerEditor}
38-
</ScrollContainer>
39-
</div>
40-
</>}
41-
{this.props.map}
42-
</div>
43-
{this.props.bottom && <div className="maputnik-layout-bottom">
44-
{this.props.bottom}
45-
</div>
46-
}
47-
{this.props.modals}
164+
<div
165+
className="maputnik-layout-resize-handle"
166+
data-testid="sidebar-resize-handle"
167+
onMouseDown={handleMouseDown}
168+
title="Drag to resize sidebar"
169+
tabIndex={-1}
170+
aria-hidden="true"
171+
/>
172+
</>}
173+
{props.map}
48174
</div>
49-
</IconContext.Provider>;
50-
}
175+
{props.bottom && <div className="maputnik-layout-bottom">
176+
{props.bottom}
177+
</div>
178+
}
179+
{props.modals}
180+
</div>
181+
</IconContext.Provider>;
51182
}
52-
53-
const AppLayout = withTranslation()(AppLayoutInternal);
54-
export default AppLayout;

src/components/LayerListItem.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,19 @@ type DraggableLabelProps = {
1313
layerType: string
1414
dragAttributes?: React.HTMLAttributes<HTMLElement>
1515
dragListeners?: React.HTMLAttributes<HTMLElement>
16+
onSelect?: () => void
1617
};
1718

1819
const DraggableLabel: React.FC<DraggableLabelProps> = (props) => {
1920
const {dragAttributes, dragListeners} = props;
20-
return <div className="maputnik-layer-list-item-handle" {...dragAttributes} {...dragListeners}>
21+
22+
const handleClick = (e: React.MouseEvent) => {
23+
// Ensure layer selection fires even when dnd-kit captures the pointer
24+
e.stopPropagation();
25+
props.onSelect?.();
26+
};
27+
28+
return <div className="maputnik-layer-list-item-handle" {...dragAttributes} {...dragListeners} onClick={handleClick}>
2129
<IconLayer
2230
className="layer-handle__icon"
2331
type={props.layerType}
@@ -137,6 +145,7 @@ const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>((props
137145
layerType={props.layerType}
138146
dragAttributes={attributes}
139147
dragListeners={listeners}
148+
onSelect={() => props.onLayerSelect(props.layerIndex)}
140149
/>
141150
<span style={{flexGrow: 1}} />
142151
<IconAction

0 commit comments

Comments
 (0)