From 08e73f64cee51e4f8c8fc2c3b1c34cab60af42df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Sanouiller?= Date: Mon, 2 Mar 2026 18:06:52 +0100 Subject: [PATCH 1/2] 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 --- cypress/e2e/sidebar-resize.cy.ts | 50 ++++++++ src/components/AppLayout.tsx | 202 +++++++++++++++++++++++++------ src/components/LayerListItem.tsx | 11 +- src/libs/sidebar.test.ts | 142 ++++++++++++++++++++++ src/libs/sidebar.ts | 73 +++++++++++ src/styles/_components.scss | 3 +- src/styles/_layout.scss | 53 +++++++- 7 files changed, 491 insertions(+), 43 deletions(-) create mode 100644 cypress/e2e/sidebar-resize.cy.ts create mode 100644 src/libs/sidebar.test.ts create mode 100644 src/libs/sidebar.ts diff --git a/cypress/e2e/sidebar-resize.cy.ts b/cypress/e2e/sidebar-resize.cy.ts new file mode 100644 index 000000000..5ff4dec26 --- /dev/null +++ b/cypress/e2e/sidebar-resize.cy.ts @@ -0,0 +1,50 @@ +import { MaputnikDriver } from "./maputnik-driver"; + +describe("sidebar resize", () => { + const { beforeAndAfter, when } = new MaputnikDriver(); + beforeAndAfter(); + + beforeEach(() => { + when.setStyle("both"); + }); + + it("resize handle is visible", () => { + cy.get("[data-testid='sidebar-resize-handle']").should("exist").and("be.visible"); + }); + + it("inner resize handle is visible", () => { + cy.get("[data-testid='inner-resize-handle']").should("exist").and("be.visible"); + }); + + it("dragging the handle changes sidebar width", () => { + cy.get(".maputnik-layout-list").then(($list) => { + const initialWidth = $list[0].getBoundingClientRect().width; + + cy.get("[data-testid='sidebar-resize-handle']") + .realMouseDown({ position: "center" }) + .realMouseMove(100, 0, { position: "center" }) + .realMouseUp(); + + cy.get(".maputnik-layout-list").should(($listAfter) => { + const newWidth = $listAfter[0].getBoundingClientRect().width; + expect(newWidth).to.be.greaterThan(initialWidth); + }); + }); + }); + + it("dragging inner handle changes list/drawer split", () => { + cy.get(".maputnik-layout-list").then(($list) => { + const initialWidth = $list[0].getBoundingClientRect().width; + + cy.get("[data-testid='inner-resize-handle']") + .realMouseDown({ position: "center" }) + .realMouseMove(80, 0, { position: "center" }) + .realMouseUp(); + + cy.get(".maputnik-layout-list").should(($listAfter) => { + const newWidth = $listAfter[0].getBoundingClientRect().width; + expect(newWidth).to.be.greaterThan(initialWidth); + }); + }); + }); +}); diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 5431cb7d9..a8e2c8cbe 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,9 +1,20 @@ -import React from "react"; +import React, {useCallback, useEffect, useRef, useState} from "react"; import ScrollContainer from "./ScrollContainer"; -import { type WithTranslation, withTranslation } from "react-i18next"; -import { IconContext } from "react-icons"; +import {useTranslation} from "react-i18next"; +import {IconContext} from "react-icons"; +import { + DEFAULT_LIST_RATIO, + DEFAULT_SIDEBAR_WIDTH, + MIN_LIST_WIDTH, + MIN_DRAWER_WIDTH, + clampSidebarWidth, + getSavedSidebarWidth, + getSavedListRatio, + saveSidebarWidth, + saveListRatio, +} from "../libs/sidebar"; -type AppLayoutInternalProps = { +type AppLayoutProps = { toolbar: React.ReactElement layerList: React.ReactElement layerEditor?: React.ReactElement @@ -11,44 +22,161 @@ type AppLayoutInternalProps = { map: React.ReactElement bottom?: React.ReactElement modals?: React.ReactNode -} & WithTranslation; +}; -class AppLayoutInternal extends React.Component { +export default function AppLayout(props: AppLayoutProps) { + const {i18n} = useTranslation(); - render() { - document.body.dir = this.props.i18n.dir(); + useEffect(() => { + document.body.dir = i18n.dir(); + }, [i18n]); - return -
- {this.props.toolbar} -
- {this.props.codeEditor &&
+ const [sidebarWidth, setSidebarWidth] = useState( + () => getSavedSidebarWidth() ?? DEFAULT_SIDEBAR_WIDTH + ); + const [listRatio, setListRatio] = useState( + () => getSavedListRatio() ?? DEFAULT_LIST_RATIO + ); + + // Outer handle (sidebar <-> map) drag state + const isDragging = useRef(false); + const startX = useRef(0); + const startWidth = useRef(0); + + // Inner handle (list <-> drawer) drag state + const isInnerDragging = useRef(false); + const innerStartX = useRef(0); + const innerStartListWidth = useRef(0); + + // Compute sub-widths from ratio + const listWidth = Math.round(sidebarWidth * listRatio); + const drawerWidth = sidebarWidth - listWidth; + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isDragging.current = true; + startX.current = e.clientX; + startWidth.current = sidebarWidth; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, [sidebarWidth]); + + // Inner handle: resize list <-> drawer split + const handleInnerMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isInnerDragging.current = true; + innerStartX.current = e.clientX; + innerStartListWidth.current = listWidth; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, [listWidth]); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + const isRtl = document.body.dir === "rtl"; + + // Outer drag + if (isDragging.current) { + const delta = isRtl + ? startX.current - e.clientX + : e.clientX - startX.current; + const newWidth = clampSidebarWidth(startWidth.current + delta); + setSidebarWidth(newWidth); + } + + // Inner drag + if (isInnerDragging.current) { + const delta = isRtl + ? innerStartX.current - e.clientX + : e.clientX - innerStartX.current; + const newListWidth = innerStartListWidth.current + delta; + setSidebarWidth((sw) => { + const clampedList = Math.max(MIN_LIST_WIDTH, Math.min(sw - MIN_DRAWER_WIDTH, newListWidth)); + const newRatio = clampedList / sw; + setListRatio(newRatio); + return sw; + }); + } + }; + + const handleMouseUp = () => { + if (isDragging.current) { + isDragging.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + setSidebarWidth((w) => { + saveSidebarWidth(w); + return w; + }); + } + if (isInnerDragging.current) { + isInnerDragging.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + setListRatio((r) => { + saveListRatio(r); + return r; + }); + } + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, []); + + const layoutStyle = { + "--sidebar-list-width": `${listWidth}px`, + "--sidebar-drawer-width": `${drawerWidth}px`, + "--sidebar-total-width": `${sidebarWidth}px`, + } as React.CSSProperties; + + return +
+ {props.toolbar} +
+ {props.codeEditor &&
+ + {props.codeEditor} + +
+ } + {!props.codeEditor && <> +
+ {props.layerList} +
+ - {this.props.bottom &&
- {this.props.bottom} -
- } - {this.props.modals} + - ; - } + {props.bottom &&
+ {props.bottom} +
+ } + {props.modals} +
+ ; } - -const AppLayout = withTranslation()(AppLayoutInternal); -export default AppLayout; diff --git a/src/components/LayerListItem.tsx b/src/components/LayerListItem.tsx index 2af2be596..b6076f665 100644 --- a/src/components/LayerListItem.tsx +++ b/src/components/LayerListItem.tsx @@ -13,11 +13,19 @@ type DraggableLabelProps = { layerType: string dragAttributes?: React.HTMLAttributes dragListeners?: React.HTMLAttributes + onSelect?: () => void }; const DraggableLabel: React.FC = (props) => { const {dragAttributes, dragListeners} = props; - return
+ + const handleClick = (e: React.MouseEvent) => { + // Ensure layer selection fires even when dnd-kit captures the pointer + e.stopPropagation(); + props.onSelect?.(); + }; + + return
((props layerType={props.layerType} dragAttributes={attributes} dragListeners={listeners} + onSelect={() => props.onLayerSelect(props.layerIndex)} /> { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +})(); +vi.stubGlobal("localStorage", localStorageMock); + +describe("sidebar helpers", () => { + describe("clampSidebarWidth", () => { + it("returns MIN when value is below minimum", () => { + expect(clampSidebarWidth(100)).toBe(MIN_SIDEBAR_WIDTH); + }); + + it("returns MAX when value exceeds maximum", () => { + expect(clampSidebarWidth(1200)).toBe(MAX_SIDEBAR_WIDTH); + }); + + it("returns the value when within range", () => { + expect(clampSidebarWidth(500)).toBe(500); + }); + + it("returns exactly MIN at boundary", () => { + expect(clampSidebarWidth(MIN_SIDEBAR_WIDTH)).toBe(MIN_SIDEBAR_WIDTH); + }); + + it("returns exactly MAX at boundary", () => { + expect(clampSidebarWidth(MAX_SIDEBAR_WIDTH)).toBe(MAX_SIDEBAR_WIDTH); + }); + }); + + describe("getSavedSidebarWidth", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("returns null when nothing is stored", () => { + expect(getSavedSidebarWidth()).toBeNull(); + }); + + it("returns the stored width when valid", () => { + localStorage.setItem("maputnik:sidebarWidth", "600"); + expect(getSavedSidebarWidth()).toBe(600); + }); + + it("returns null when stored value is below MIN", () => { + localStorage.setItem("maputnik:sidebarWidth", "100"); + expect(getSavedSidebarWidth()).toBeNull(); + }); + + it("returns null when stored value is above MAX", () => { + localStorage.setItem("maputnik:sidebarWidth", "9999"); + expect(getSavedSidebarWidth()).toBeNull(); + }); + + it("returns null when stored value is not a number", () => { + localStorage.setItem("maputnik:sidebarWidth", "abc"); + expect(getSavedSidebarWidth()).toBeNull(); + }); + }); + + describe("saveSidebarWidth", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("persists the width to localStorage", () => { + saveSidebarWidth(500); + expect(localStorage.getItem("maputnik:sidebarWidth")).toBe("500"); + }); + }); + + describe("DEFAULT_SIDEBAR_WIDTH", () => { + it("is the sum of list and drawer widths", () => { + expect(DEFAULT_SIDEBAR_WIDTH).toBe(570); + }); + }); + + describe("DEFAULT_LIST_RATIO", () => { + it("is list / total", () => { + expect(DEFAULT_LIST_RATIO).toBeCloseTo(200 / 570, 5); + }); + }); + + describe("getSavedListRatio", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("returns null when nothing is stored", () => { + expect(getSavedListRatio()).toBeNull(); + }); + + it("returns the stored ratio when valid", () => { + localStorage.setItem("maputnik:listRatio", "0.4"); + expect(getSavedListRatio()).toBeCloseTo(0.4); + }); + + it("returns null when stored value is below 0.1", () => { + localStorage.setItem("maputnik:listRatio", "0.05"); + expect(getSavedListRatio()).toBeNull(); + }); + + it("returns null when stored value is above 0.9", () => { + localStorage.setItem("maputnik:listRatio", "0.95"); + expect(getSavedListRatio()).toBeNull(); + }); + + it("returns null when stored value is not a number", () => { + localStorage.setItem("maputnik:listRatio", "abc"); + expect(getSavedListRatio()).toBeNull(); + }); + }); + + describe("saveListRatio", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("persists the ratio to localStorage", () => { + saveListRatio(0.35); + expect(localStorage.getItem("maputnik:listRatio")).toBe("0.35"); + }); + }); +}); diff --git a/src/libs/sidebar.ts b/src/libs/sidebar.ts new file mode 100644 index 000000000..b1f49f059 --- /dev/null +++ b/src/libs/sidebar.ts @@ -0,0 +1,73 @@ +/** + * Sidebar resize helpers. + * + * Extracted from AppLayout so they can be tested independently and + * reused if needed elsewhere. + */ + +export const DEFAULT_LIST_WIDTH = 200; +export const DEFAULT_DRAWER_WIDTH = 370; +export const DEFAULT_SIDEBAR_WIDTH = DEFAULT_LIST_WIDTH + DEFAULT_DRAWER_WIDTH; +export const MIN_SIDEBAR_WIDTH = 280; +export const MAX_SIDEBAR_WIDTH = 800; +export const MIN_LIST_WIDTH = 100; +export const MIN_DRAWER_WIDTH = 150; +export const DEFAULT_LIST_RATIO = DEFAULT_LIST_WIDTH / DEFAULT_SIDEBAR_WIDTH; + +const STORAGE_KEY = "maputnik:sidebarWidth"; +const RATIO_STORAGE_KEY = "maputnik:listRatio"; + +/** Read the persisted sidebar width from localStorage (if valid). */ +export function getSavedSidebarWidth(): number | null { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const val = parseInt(saved, 10); + if (!isNaN(val) && val >= MIN_SIDEBAR_WIDTH && val <= MAX_SIDEBAR_WIDTH) { + return val; + } + } + } catch { + // localStorage may be unavailable + } + return null; +} + +/** Persist the sidebar width to localStorage. */ +export function saveSidebarWidth(width: number): void { + try { + localStorage.setItem(STORAGE_KEY, String(width)); + } catch { + // ignore + } +} + +/** Read the persisted list/drawer ratio from localStorage (if valid). */ +export function getSavedListRatio(): number | null { + try { + const saved = localStorage.getItem(RATIO_STORAGE_KEY); + if (saved) { + const val = parseFloat(saved); + if (!isNaN(val) && val >= 0.1 && val <= 0.9) { + return val; + } + } + } catch { + // localStorage may be unavailable + } + return null; +} + +/** Persist the list/drawer ratio to localStorage. */ +export function saveListRatio(ratio: number): void { + try { + localStorage.setItem(RATIO_STORAGE_KEY, String(ratio)); + } catch { + // ignore + } +} + +/** Clamp a width value to the allowed sidebar range. */ +export function clampSidebarWidth(width: number): number { + return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, width)); +} diff --git a/src/styles/_components.scss b/src/styles/_components.scss index 9ef3c829e..8438f4666 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; + flex: 1; + min-width: 0; &--error { align-items: center; diff --git a/src/styles/_layout.scss b/src/styles/_layout.scss index cfc5109ac..b967935bc 100644 --- a/src/styles/_layout.scss +++ b/src/styles/_layout.scss @@ -13,6 +13,11 @@ //APP LAYOUT .maputnik-layout { + // Default CSS custom properties (overridden inline by AppLayout component) + --sidebar-list-width: #{vars.$layout-list-width}; + --sidebar-drawer-width: #{vars.$layout-editor-width}; + --sidebar-total-width: #{vars.$layout-list-width + vars.$layout-editor-width}; + font-family: vars.$font-family; color: vars.$color-white; @@ -29,19 +34,59 @@ } &-list { - width: 200px; + width: var(--sidebar-list-width); + min-width: 0; + flex-shrink: 0; background-color: vars.$color-black; } &-drawer { - width: 370px; + width: var(--sidebar-drawer-width); + min-width: 0; + flex-shrink: 0; background-color: vars.$color-black; // scroll-container is position: absolute position: relative; } + &-resize-handle { + width: 5px; + flex-shrink: 0; + cursor: col-resize; + background-color: transparent; + position: relative; + z-index: 5; + transition: background-color 0.15s ease; + + &:hover, + &:active { + background-color: rgba(vars.$color-lowgray, 0.5); + } + + &::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 { + opacity: 0.7; + } + } + &-code-editor { - width: 570px; + width: var(--sidebar-total-width); + min-width: 0; + flex-shrink: 0; background-color: vars.$color-black; // scroll-container is position: absolute position: relative; @@ -52,7 +97,7 @@ bottom: 0; right: 0; z-index: 10; - width: vars.$layout-map-width; + width: calc(100% - var(--sidebar-total-width)); background-color: vars.$color-black; } } From 7219c55df830614d195572bd01fa4061187ffb90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Sanouiller?= Date: Tue, 3 Mar 2026 17:15:34 +0100 Subject: [PATCH 2/2] fix: address review feedback driver pattern, i18n titles, inline style, required onSelect --- cypress/e2e/sidebar-resize.cy.ts | 18 +++++++++--------- src/components/AppLayout.tsx | 22 ++++++++++------------ src/components/LayerListItem.tsx | 4 ++-- src/locales/de/translation.json | 4 +++- src/locales/fr/translation.json | 4 +++- src/locales/he/translation.json | 4 +++- src/locales/it/translation.json | 4 +++- src/locales/ja/translation.json | 4 +++- src/locales/ko/translation.json | 4 +++- src/locales/zh/translation.json | 4 +++- 10 files changed, 42 insertions(+), 30 deletions(-) diff --git a/cypress/e2e/sidebar-resize.cy.ts b/cypress/e2e/sidebar-resize.cy.ts index 5ff4dec26..f8dc3b56a 100644 --- a/cypress/e2e/sidebar-resize.cy.ts +++ b/cypress/e2e/sidebar-resize.cy.ts @@ -1,7 +1,7 @@ import { MaputnikDriver } from "./maputnik-driver"; describe("sidebar resize", () => { - const { beforeAndAfter, when } = new MaputnikDriver(); + const { beforeAndAfter, get, when, then } = new MaputnikDriver(); beforeAndAfter(); beforeEach(() => { @@ -9,23 +9,23 @@ describe("sidebar resize", () => { }); it("resize handle is visible", () => { - cy.get("[data-testid='sidebar-resize-handle']").should("exist").and("be.visible"); + then(get.elementByTestId("sidebar-resize-handle")).shouldBeVisible(); }); it("inner resize handle is visible", () => { - cy.get("[data-testid='inner-resize-handle']").should("exist").and("be.visible"); + then(get.elementByTestId("inner-resize-handle")).shouldBeVisible(); }); it("dragging the handle changes sidebar width", () => { - cy.get(".maputnik-layout-list").then(($list) => { + get.element(".maputnik-layout-list").then(($list) => { const initialWidth = $list[0].getBoundingClientRect().width; - cy.get("[data-testid='sidebar-resize-handle']") + get.elementByTestId("sidebar-resize-handle") .realMouseDown({ position: "center" }) .realMouseMove(100, 0, { position: "center" }) .realMouseUp(); - cy.get(".maputnik-layout-list").should(($listAfter) => { + get.element(".maputnik-layout-list").should(($listAfter) => { const newWidth = $listAfter[0].getBoundingClientRect().width; expect(newWidth).to.be.greaterThan(initialWidth); }); @@ -33,15 +33,15 @@ describe("sidebar resize", () => { }); it("dragging inner handle changes list/drawer split", () => { - cy.get(".maputnik-layout-list").then(($list) => { + get.element(".maputnik-layout-list").then(($list) => { const initialWidth = $list[0].getBoundingClientRect().width; - cy.get("[data-testid='inner-resize-handle']") + get.elementByTestId("inner-resize-handle") .realMouseDown({ position: "center" }) .realMouseMove(80, 0, { position: "center" }) .realMouseUp(); - cy.get(".maputnik-layout-list").should(($listAfter) => { + get.element(".maputnik-layout-list").should(($listAfter) => { const newWidth = $listAfter[0].getBoundingClientRect().width; expect(newWidth).to.be.greaterThan(initialWidth); }); diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index a8e2c8cbe..1c14eb6be 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -25,7 +25,7 @@ type AppLayoutProps = { }; export default function AppLayout(props: AppLayoutProps) { - const {i18n} = useTranslation(); + const {t, i18n} = useTranslation(); useEffect(() => { document.body.dir = i18n.dir(); @@ -128,14 +128,12 @@ export default function AppLayout(props: AppLayoutProps) { }; }, []); - const layoutStyle = { - "--sidebar-list-width": `${listWidth}px`, - "--sidebar-drawer-width": `${drawerWidth}px`, - "--sidebar-total-width": `${sidebarWidth}px`, - } as React.CSSProperties; - return -
+
{props.toolbar}
{props.codeEditor &&
@@ -150,9 +148,9 @@ export default function AppLayout(props: AppLayoutProps) {