From 3c1c3b6a74522e946e5891a7728c45f9d63e622a Mon Sep 17 00:00:00 2001 From: HarelM Date: Fri, 21 Aug 2026 13:02:35 +0300 Subject: [PATCH] Adopted changes to have a sidebar that's dragable --- CHANGELOG.md | 1 + e2e/playwright-helper.ts | 18 ++++ e2e/sidebar-resize.spec.ts | 27 ++++++ package-lock.json | 11 +++ package.json | 1 + src/components/AppLayout.tsx | 151 ++++++++++++++++++++++++-------- src/locales/de/translation.json | 2 + src/locales/fr/translation.json | 2 + src/locales/he/translation.json | 2 + src/locales/it/translation.json | 2 + src/locales/ja/translation.json | 2 + src/locales/ko/translation.json | 2 + src/locales/tr/translation.json | 2 + src/locales/zh/translation.json | 2 + src/styles/_components.scss | 3 +- src/styles/_layout.scss | 75 +++++++++++++--- src/styles/_vars.scss | 1 - 17 files changed, 252 insertions(+), 52 deletions(-) create mode 100644 e2e/sidebar-resize.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b28beb7d..7847cfce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/e2e/playwright-helper.ts b/e2e/playwright-helper.ts index 80f23f84..2fca2c3e 100644 --- a/e2e/playwright-helper.ts +++ b/e2e/playwright-helper.ts @@ -122,6 +122,8 @@ export class Assertable { // 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(() => this.testId(testId).first().inputValue()), + elementWidth: (testId: string) => + new Query(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(() => this.testId(testId).first().innerText()), locationHash: () => new Query(async () => new URL(this.page.url()).hash), diff --git a/e2e/sidebar-resize.spec.ts b/e2e/sidebar-resize.spec.ts new file mode 100644 index 00000000..caebf894 --- /dev/null +++ b/e2e/sidebar-resize.spec.ts @@ -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); + }); +}); diff --git a/package-lock.json b/package-lock.json index d835ae28..ec10fe06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index daef1461..76f7ad45 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 67c1e11d..81da691f 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -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 { +export const AppLayout: React.FC = (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 -
- {this.props.toolbar} -
- {this.props.codeEditor &&
- - {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 +
+ {props.toolbar} +
+ + setSidebarWidth(inPixels)} + > + {props.codeEditor && + {props.codeEditor} -
- } - {!this.props.codeEditor && <> -
- {this.props.layerList} -
-
- - {this.props.layerEditor} - -
- } - {this.props.map} -
- {this.props.bottom &&
- {this.props.bottom} -
- } - {this.props.modals} + } + {!props.codeEditor && + + {props.layerList} + + + + + {props.layerEditor} + + + + } + + + + {props.map} + +
- ; - } -} - -export const AppLayout = withTranslation()(AppLayoutInternal); + {props.bottom &&
+ {props.bottom} +
+ } + {props.modals} +
+ ; +}; diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 9ffa501e..ff2e968f 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -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...", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index f48cda4f..d033ba97 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -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...", diff --git a/src/locales/he/translation.json b/src/locales/he/translation.json index ce2f7c26..7e860a79 100644 --- a/src/locales/he/translation.json +++ b/src/locales/he/translation.json @@ -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...": "הכנסו כתובת", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index f2580af0..6ee675b3 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -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...", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index 8390a346..84eb8fc1 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -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を入力", diff --git a/src/locales/ko/translation.json b/src/locales/ko/translation.json index 62f28388..c4151b17 100644 --- a/src/locales/ko/translation.json +++ b/src/locales/ko/translation.json @@ -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 입력...", diff --git a/src/locales/tr/translation.json b/src/locales/tr/translation.json index 773aff6b..a4d13798 100644 --- a/src/locales/tr/translation.json +++ b/src/locales/tr/translation.json @@ -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...", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 0c531bf5..552448b9 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -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...", diff --git a/src/styles/_components.scss b/src/styles/_components.scss index 179d9ebc..de1e15de 100644 --- a/src/styles/_components.scss +++ b/src/styles/_components.scss @@ -6,7 +6,8 @@ .maputnik-map__container { background: white; display: flex; - width: vars.$layout-map-width; + width: 100%; + height: 100%; &--error { align-items: center; diff --git a/src/styles/_layout.scss b/src/styles/_layout.scss index cfc5109a..54c7efd3 100644 --- a/src/styles/_layout.scss +++ b/src/styles/_layout.scss @@ -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; } } diff --git a/src/styles/_vars.scss b/src/styles/_vars.scss index c0ab1bde..56084e81 100644 --- a/src/styles/_vars.scss +++ b/src/styles/_vars.scss @@ -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