feat: make sidebar panels resizable (#2094)

- Replaces #1870, which replaced #1682, #1677

This mostly uses the code from there and addresses the comments I've
left. I didn't change the code much.

## Launch Checklist

Makes the sidebar panel resizable

<img width="707" height="511" alt="image"
src="https://github.com/user-attachments/assets/d010df38-faac-441c-bf7f-a4960e471c61"
/>


 - [x] Briefly describe the changes in this PR.
 - [x] Link to related issues.
- [x] Include before/after visuals or gifs if this PR includes visual
changes.
 - [x] Write tests for all new functionality.
 - [x] Add an entry to `CHANGELOG.md` under the `## main` section.

---------

Co-authored-by: Bart Louwers <bart.louwers@gmail.com>
This commit is contained in:
Harel M
2026-09-27 13:44:22 +00:00
committed by GitHub
co-authored by Bart Louwers
parent e4629339e8
commit 2384adf517
17 changed files with 249 additions and 55 deletions
+116 -38
View File
@@ -1,9 +1,22 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { Group, Panel, Separator, useDefaultLayout } from "react-resizable-panels";
import { ScrollContainer } from "./ScrollContainer";
import { type WithTranslation, withTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import { IconContext } from "react-icons";
type AppLayoutInternalProps = {
const DEFAULT_LIST_WIDTH = 200;
const DEFAULT_DRAWER_WIDTH = 370;
const DEFAULT_SIDEBAR_WIDTH = DEFAULT_LIST_WIDTH + DEFAULT_DRAWER_WIDTH;
const PANEL_STYLE: React.CSSProperties = { overflow: "hidden" };
const SIDEBAR_LAYOUT_ID = "maputnik:sidebar-layout";
const SIDEBAR_INNER_LAYOUT_ID = "maputnik:sidebar-inner-layout";
const SIDEBAR_PANEL_ID = "sidebar";
const MAP_PANEL_ID = "map";
const LIST_PANEL_ID = "list";
const DRAWER_PANEL_ID = "drawer";
type AppLayoutProps = {
toolbar: React.ReactElement
layerList: React.ReactElement
layerEditor?: React.ReactElement
@@ -11,43 +24,108 @@ type AppLayoutInternalProps = {
map: React.ReactElement
bottom?: React.ReactElement
modals?: React.ReactNode
} & WithTranslation;
};
class AppLayoutInternal extends React.Component<AppLayoutInternalProps> {
export const AppLayout: React.FC<AppLayoutProps> = (props) => {
const { t, i18n } = useTranslation();
render() {
document.body.dir = this.props.i18n.dir();
const sidebarLayout = useDefaultLayout({
id: SIDEBAR_LAYOUT_ID,
panelIds: [SIDEBAR_PANEL_ID, MAP_PANEL_ID],
});
const innerLayout = useDefaultLayout({
id: SIDEBAR_INNER_LAYOUT_ID,
panelIds: [LIST_PANEL_ID, DRAWER_PANEL_ID],
});
return <IconContext.Provider value={{size: "14px"}}>
<div className="maputnik-layout">
{this.props.toolbar}
<div className="maputnik-layout-main">
{this.props.codeEditor && <div className="maputnik-layout-code-editor">
<ScrollContainer>
{this.props.codeEditor}
// The bottom panel is position: fixed, so it can't be a flex sibling of the
// map panel; it follows the sidebar through this custom property instead.
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH);
useEffect(() => {
document.body.dir = i18n.dir();
}, [i18n, i18n.language]);
return <IconContext.Provider value={{size: "14px"}}>
<div
className="maputnik-layout"
style={{"--sidebar-width": `${sidebarWidth}px`} as React.CSSProperties}
>
{props.toolbar}
<div className="maputnik-layout-main">
<Group
className="maputnik-layout-panels"
orientation="horizontal"
id={SIDEBAR_LAYOUT_ID}
defaultLayout={sidebarLayout.defaultLayout}
onLayoutChanged={sidebarLayout.onLayoutChanged}
>
<Panel
id={SIDEBAR_PANEL_ID}
data-wd-key="sidebar-panel"
className={props.codeEditor ? "maputnik-layout-code-editor" : "maputnik-layout-sidebar"}
style={PANEL_STYLE}
defaultSize={`${DEFAULT_SIDEBAR_WIDTH}px`}
minSize="280px"
onResize={({inPixels}) => setSidebarWidth(inPixels)}
>
{props.codeEditor && <ScrollContainer>
{props.codeEditor}
</ScrollContainer>
</div>
}
{!this.props.codeEditor && <>
<div className="maputnik-layout-list">
{this.props.layerList}
</div>
<div className="maputnik-layout-drawer">
<ScrollContainer>
{this.props.layerEditor}
</ScrollContainer>
</div>
</>}
{this.props.map}
</div>
{this.props.bottom && <div className="maputnik-layout-bottom">
{this.props.bottom}
</div>
}
{this.props.modals}
}
{!props.codeEditor && <Group
className="maputnik-layout-sidebar-panels"
orientation="horizontal"
id={SIDEBAR_INNER_LAYOUT_ID}
defaultLayout={innerLayout.defaultLayout}
onLayoutChanged={innerLayout.onLayoutChanged}
>
<Panel
id={LIST_PANEL_ID}
data-wd-key="layer-list-panel"
className="maputnik-layout-list"
style={PANEL_STYLE}
defaultSize={`${DEFAULT_LIST_WIDTH}px`}
minSize="100px"
>
{props.layerList}
</Panel>
<Separator
className="maputnik-layout-resize-handle"
data-wd-key="inner-resize-handle"
title={t("Drag to resize the layer list")}
aria-label={t("Drag to resize the layer list")}
/>
<Panel
id={DRAWER_PANEL_ID}
className="maputnik-layout-drawer"
style={PANEL_STYLE}
defaultSize={`${DEFAULT_DRAWER_WIDTH}px`}
minSize="150px"
>
<ScrollContainer>
{props.layerEditor}
</ScrollContainer>
</Panel>
</Group>
}
</Panel>
<Separator
className="maputnik-layout-resize-handle"
data-wd-key="sidebar-resize-handle"
title={t("Drag to resize the sidebar")}
aria-label={t("Drag to resize the sidebar")}
/>
<Panel id={MAP_PANEL_ID} className="maputnik-layout-map" style={PANEL_STYLE} minSize="200px">
{props.map}
</Panel>
</Group>
</div>
</IconContext.Provider>;
}
}
export const AppLayout = withTranslation()(AppLayoutInternal);
{props.bottom && <div className="maputnik-layout-bottom">
{props.bottom}
</div>
}
{props.modals}
</div>
</IconContext.Provider>;
};
+2
View File
@@ -54,6 +54,8 @@
"Delete expression": "Ausdruck löschen",
"Delete filter block": "Filterblock löschen",
"Deuteranopia filter": "Deuteranopie-Filter",
"Drag to resize the layer list": "Ziehen, um die Ebenenliste zu skalieren",
"Drag to resize the sidebar": "Ziehen, um die Seitenleiste zu skalieren",
"Duplicate": "Duplizieren",
"Encoding": "Kodierung",
"Enter URL...": "URL eingeben...",
+2
View File
@@ -54,6 +54,8 @@
"Delete expression": "Supprimer l'expression",
"Delete filter block": "Supprimer le bloc de filtre",
"Deuteranopia filter": "Filtre Deutéranopie",
"Drag to resize the layer list": "Glisser pour redimensionner la liste des calques",
"Drag to resize the sidebar": "Glisser pour redimensionner la barre latérale",
"Duplicate": "Dupliquer",
"Encoding": "Encodage",
"Enter URL...": "Entrez l'URL...",
+2
View File
@@ -54,6 +54,8 @@
"Delete expression": "מחיקת ביטוי",
"Delete filter block": "מחיקת גוש מסנן",
"Deuteranopia filter": "Deuteranopia filter",
"Drag to resize the layer list": "יש לגרור כדי לשנות את גודל רשימת השכבות",
"Drag to resize the sidebar": "יש לגרור כדי לשנות את גודל סרגל הצד",
"Duplicate": "שכפול",
"Encoding": "קידוד",
"Enter URL...": "הכנסו כתובת",
+2
View File
@@ -54,6 +54,8 @@
"Delete expression": "Elimina espressione",
"Delete filter block": "Elimina blocco filtro",
"Deuteranopia filter": "Filtro deuteranopia",
"Drag to resize the layer list": "Trascina per ridimensionare l'elenco dei livelli",
"Drag to resize the sidebar": "Trascina per ridimensionare la barra laterale",
"Duplicate": "Duplica",
"Encoding": "Codifica",
"Enter URL...": "Inserisci URL...",
+2
View File
@@ -54,6 +54,8 @@
"Delete expression": "式を削除",
"Delete filter block": "フィルタブロックを削除",
"Deuteranopia filter": "緑色盲フィルタ",
"Drag to resize the layer list": "ドラッグしてレイヤーリストのサイズを変更",
"Drag to resize the sidebar": "ドラッグしてサイドバーのサイズを変更",
"Duplicate": "複製",
"Encoding": "エンコーディング",
"Enter URL...": "URLを入力",
+2
View File
@@ -54,6 +54,8 @@
"Delete expression": "표현식 삭제",
"Delete filter block": "필터 블록 삭제",
"Deuteranopia filter": "녹색맹 필터",
"Drag to resize the layer list": "드래그하여 레이어 목록 크기 조정",
"Drag to resize the sidebar": "드래그하여 사이드바 크기 조정",
"Duplicate": "복제",
"Encoding": "인코딩",
"Enter URL...": "URL 입력...",
+2
View File
@@ -55,6 +55,8 @@
"Delete filter block": "Filtre bloğunu sil",
"Deuteranopia filter": "Döteranopi filtresi",
"Drag and drop a style JSON file here or click to browse": "Bir stil JSON dosyasını buraya sürükleyip bırakın veya göz atmak için tıklayın",
"Drag to resize the layer list": "Katman listesini yeniden boyutlandırmak için sürükleyin",
"Drag to resize the sidebar": "Kenar çubuğunu yeniden boyutlandırmak için sürükleyin",
"Duplicate": "Kopyala",
"Encoding": "Kodlama",
"Enter URL...": "URL girin...",
+2
View File
@@ -54,6 +54,8 @@
"Delete expression": "删除表达式",
"Delete filter block": "删除过滤器块",
"Deuteranopia filter": "绿色盲滤镜",
"Drag to resize the layer list": "拖动以调整图层列表大小",
"Drag to resize the sidebar": "拖动以调整侧边栏大小",
"Duplicate": "复制",
"Encoding": "编码",
"Enter URL...": "输入URL...",
+2 -1
View File
@@ -6,7 +6,8 @@
.maputnik-map__container {
background: white;
display: flex;
width: vars.$layout-map-width;
width: 100%;
height: 100%;
&--error {
align-items: center;
+57 -12
View File
@@ -28,31 +28,76 @@
display: flex;
}
&-list {
width: 200px;
background-color: vars.$color-black;
// The resizable panel group filling the main area.
&-panels {
flex: 1;
min-width: 0;
height: 100%;
}
&-sidebar-panels,
&-sidebar,
&-code-editor,
&-list,
&-drawer,
&-map {
height: 100%;
// scroll-container is position: absolute
position: relative;
}
&-sidebar,
&-code-editor,
&-list,
&-drawer {
width: 370px;
background-color: vars.$color-black;
// scroll-container is position: absolute
position: relative;
}
&-code-editor {
width: 570px;
background-color: vars.$color-black;
// scroll-container is position: absolute
&-resize-handle {
width: 5px;
background-color: transparent;
position: relative;
z-index: 5;
transition: background-color 0.15s ease;
&:hover,
&:active,
&:focus-visible {
background-color: rgba(vars.$color-lowgray, 0.5);
}
// The separator is keyboard resizable, so it needs a visible focus ring.
&:focus-visible {
outline: #8e8e8e auto 1px;
}
&::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 3px;
height: 30px;
border-radius: 2px;
background-color: vars.$color-lowgray;
opacity: 0;
transition: opacity 0.15s ease;
}
&:hover::after,
&:active::after,
&:focus-visible::after {
opacity: 0.7;
}
}
&-bottom {
position: fixed;
bottom: 0;
right: 0;
z-index: 10;
width: vars.$layout-map-width;
inset-inline-start: var(--sidebar-width);
inset-inline-end: 0;
background-color: vars.$color-black;
}
}
-4
View File
@@ -21,10 +21,6 @@ $font-family: Roboto, sans-serif;
$toolbar-height: 40px;
$toolbar-offset: 0;
$layout-list-width: 200px;
$layout-editor-width: 370px;
$layout-map-width: calc(100% - #{$layout-list-width + $layout-editor-width});
// 'menu-down' from 'https://materialdesignicons.com/'
// See <https://github.com/Templarian/MaterialDesign/blob/master/LICENSE>
$icon-down-arrow: "data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'><path fill='white' d='M7,10L12,15L17,10H7Z' /></svg>"