Adopted changes to have a sidebar that's dragable

This commit is contained in:
HarelM
2026-08-21 13:02:35 +03:00
parent 5c1c3fd8cb
commit 3c1c3b6a74
17 changed files with 252 additions and 52 deletions
+1
View File
@@ -5,6 +5,7 @@
- Upgrade to MapLibre GL JS 6 and Vite 8, and update the remaining dependencies
- Serve the RTL text plugin from `@mapbox/mapbox-gl-rtl-text` instead of a pinned CDN URL, so its version is tracked in `package.json`
- Exported HTML now loads MapLibre GL JS as an ES module, since v6 no longer ships a UMD bundle
- The sidebar can now be resized, both as a whole and in the split between the layer list and the layer editor
- _...Add new stuff here..._
### 🐞 Bug fixes
+18
View File
@@ -122,6 +122,8 @@ export class Assertable<T> {
// Value assertions (auto-retrying for Query targets).
shouldEqual = (value: any) => this.assertValue((actual) => expect(actual).toBe(value));
shouldBeGreaterThan = (value: number) => this.assertValue((actual) => expect(actual).toBeGreaterThan(value));
shouldInclude = (value: any) =>
this.assertValue((actual) => {
if (typeof value === "object" && value !== null) {
@@ -349,6 +351,15 @@ export class PlaywrightHelper {
await this.page.mouse.up();
},
/** Presses at the centre of an element and drags it by the given offset. */
dragBy: async (testId: string, deltaX: number, deltaY = 0) => {
const { x, y } = await centerOf(this.testId(testId));
await this.page.mouse.move(x, y);
await this.page.mouse.down();
await this.page.mouse.move(x + deltaX, y + deltaY, { steps: 10 });
await this.page.mouse.up();
},
clickCenter: async (testId: string) => {
const { x, y } = await centerOf(this.testId(testId));
await this.page.mouse.move(x, y);
@@ -446,6 +457,13 @@ export class PlaywrightHelper {
inputValue: (testId: string) => new Query<string>(() => this.testId(testId).first().inputValue()),
elementWidth: (testId: string) =>
new Query<number>(async () => {
const box = await this.testId(testId).first().boundingBox();
if (!box) throw new Error(`Element "${testId}" has no bounding box`);
return box.width;
}),
elementsText: (testId: string) => new Query<string>(() => this.testId(testId).first().innerText()),
locationHash: () => new Query<string>(async () => new URL(this.page.url()).hash),
+27
View File
@@ -0,0 +1,27 @@
import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver";
describe("sidebar resize", () => {
const { given, get, when, then } = new MaputnikDriver();
beforeEach(async () => {
await given.setupMockBackedResponses();
await when.setStyle("layer");
});
test("dragging the outer handle widens the sidebar", async () => {
const initialWidth = await get.elementWidth("sidebar-panel").get();
await when.dragBy("sidebar-resize-handle", 100);
await then(get.elementWidth("sidebar-panel")).shouldBeGreaterThan(initialWidth + 50);
});
test("dragging the inner handle widens the layer list", async () => {
const initialWidth = await get.elementWidth("layer-list-panel").get();
await when.dragBy("inner-resize-handle", 50);
await then(get.elementWidth("layer-list-panel")).shouldBeGreaterThan(initialWidth + 20);
});
});
+11
View File
@@ -58,6 +58,7 @@
"react-i18next": "^17.0.11",
"react-icons": "^5.7.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.11.0",
"reconnecting-websocket": "^4.4.0",
"slugify": "^1.6.9",
"string-hash": "^1.1.3",
@@ -10863,6 +10864,16 @@
"react": ">=18"
}
},
"node_modules/react-resizable-panels": {
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.11.0.tgz",
"integrity": "sha512-LPk/AkFDGkg7SsbOyL93ojrE6E7lhrxxDwnYNjfmnSeI6BE7Sje6dB24PXgZk8DeugdeXNk1LO+ohRqIjhxiLw==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/reactcss": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
+1
View File
@@ -76,6 +76,7 @@
"react-i18next": "^17.0.11",
"react-icons": "^5.7.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.11.0",
"reconnecting-websocket": "^4.4.0",
"slugify": "^1.6.9",
"string-hash": "^1.1.3",
+113 -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 = {
// Keep these in sync with $layout-list-width/$layout-editor-width in _vars.scss
const DEFAULT_LIST_WIDTH = 200;
const DEFAULT_DRAWER_WIDTH = 370;
const DEFAULT_SIDEBAR_WIDTH = DEFAULT_LIST_WIDTH + DEFAULT_DRAWER_WIDTH;
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,105 @@ 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"}
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"
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"
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" 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;
+63 -12
View File
@@ -13,6 +13,9 @@
//APP LAYOUT
.maputnik-layout {
// Kept up to date by AppLayout as the sidebar is resized.
--sidebar-width: #{vars.$layout-list-width + vars.$layout-editor-width};
font-family: vars.$font-family;
color: vars.$color-white;
@@ -28,31 +31,79 @@
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;
overflow: hidden;
}
&-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;
// Logical properties so the panel sits over the map in RTL too, where the
// sidebar is flipped to the other side.
inset-inline-start: var(--sidebar-width);
inset-inline-end: 0;
background-color: vars.$color-black;
}
}
-1
View File
@@ -23,7 +23,6 @@ $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>