diff --git a/apps/web/playwright/e2e/composer/CIDER.spec.ts b/apps/web/playwright/e2e/composer/CIDER.spec.ts index 3c78beec43..d50b24f3a0 100644 --- a/apps/web/playwright/e2e/composer/CIDER.spec.ts +++ b/apps/web/playwright/e2e/composer/CIDER.spec.ts @@ -85,6 +85,9 @@ test.describe("Composer", () => { test("renders in narrow viewports", { tag: "@screenshot" }, async ({ page, bot, app }) => { // Shrink the viewport await page.setViewportSize({ width: 500, height: 1080 }); + // Shrinking the viewport will collapse the left-panel, so manually expand it. + await app.resizeLeftPanel(150); + // Now take the screenshot await expect(app.getComposer()).toMatchScreenshot("narrow.png"); }); diff --git a/apps/web/playwright/e2e/composer/RTE.spec.ts b/apps/web/playwright/e2e/composer/RTE.spec.ts index fc79208cfd..a2f0990907 100644 --- a/apps/web/playwright/e2e/composer/RTE.spec.ts +++ b/apps/web/playwright/e2e/composer/RTE.spec.ts @@ -224,6 +224,9 @@ test.describe("Composer", () => { test("renders in narrow viewports", { tag: "@screenshot" }, async ({ page, bot, app }) => { // Shrink the viewport await page.setViewportSize({ width: 750, height: 1080 }); + // Shrinking the viewport will collapse the left-panel, so manually expand it. + await app.resizeLeftPanel(150); + // Now take the screenshot await expect(page.locator(".mx_MessageComposer_wrapper")).toMatchScreenshot("narrow.png"); }); }); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts index a04f8e851d..1630e44a4c 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts @@ -11,6 +11,10 @@ import { test, expect } from "../../../element-web-test"; import { getRoomListView } from "./utils"; test.describe("Room list panel", () => { + test.use({ + displayName: "Eve", + }); + test.beforeEach(async ({ page, app, user }) => { // The toasts are displayed above the search section await rejectToast(page, "Verify this device"); @@ -36,8 +40,7 @@ test.describe("Room list panel", () => { test.use({ lockLeftPanelWidth: false }); test("should respond to small screen sizes", { tag: "@screenshot" }, async ({ page }) => { await page.setViewportSize({ width: 575, height: 600 }); - const roomListPanel = getRoomListView(page); - await expect(roomListPanel).toMatchScreenshot("room-list-panel-smallscreen.png"); + await expect(page).toMatchScreenshot("room-list-panel-smallscreen.png"); }); }); }); diff --git a/apps/web/playwright/pages/ElementAppPage.ts b/apps/web/playwright/pages/ElementAppPage.ts index a7394f8282..4cd8ff0f76 100644 --- a/apps/web/playwright/pages/ElementAppPage.ts +++ b/apps/web/playwright/pages/ElementAppPage.ts @@ -96,6 +96,13 @@ export class ElementAppPage { * @param name The exact room name to find and click on/open. */ public async viewRoomByName(name: string): Promise { + // Expand the left panel if necessary + const separator = this.page.getByRole("separator", { name: "Click or drag to expand" }); + const type = await separator.getAttribute("data-separator-type"); + if (type === "bar") { + await separator.click(); + } + // Make sure the room list is actually present before we try closing toasts, // otherwise we may race with page loading await this.page.getByTestId("room-list").waitFor(); @@ -387,4 +394,22 @@ export class ElementAppPage { await this.page.mouse.wheel(0, 1000); } while (await needsScroll()); } + + /** + * Resize the left panel by a given number of pixels. + * @param delta The number of pixels to resize by. Negative value makes the panel smaller. + */ + public async resizeLeftPanel(delta: number): Promise { + const separator = this.page.getByRole("separator", { name: "Click or drag to expand" }); + const boundingRectangle = await separator.boundingBox(); + + // Place the cursor in the center of the separator + const centerX = boundingRectangle.x + boundingRectangle.width / 2; + await this.page.mouse.move(centerX, boundingRectangle.y); + + // Drag the cursor by delta pixels + await this.page.mouse.down(); + await this.page.mouse.move(centerX + delta, boundingRectangle.y); + await this.page.mouse.up(); + } } diff --git a/apps/web/playwright/snapshots/composer/CIDER.spec.ts/narrow-linux.png b/apps/web/playwright/snapshots/composer/CIDER.spec.ts/narrow-linux.png index ad6104ee09..7fece3ebe2 100644 Binary files a/apps/web/playwright/snapshots/composer/CIDER.spec.ts/narrow-linux.png and b/apps/web/playwright/snapshots/composer/CIDER.spec.ts/narrow-linux.png differ diff --git a/apps/web/playwright/snapshots/composer/RTE.spec.ts/narrow-linux.png b/apps/web/playwright/snapshots/composer/RTE.spec.ts/narrow-linux.png index 6fffd4825d..5beeb1f861 100644 Binary files a/apps/web/playwright/snapshots/composer/RTE.spec.ts/narrow-linux.png and b/apps/web/playwright/snapshots/composer/RTE.spec.ts/narrow-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png index 8690f3cf31..ca488e79d2 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png differ diff --git a/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts b/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts index 97bab42366..dceb0a9391 100644 --- a/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts +++ b/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts @@ -16,8 +16,6 @@ import { ResizerViewModel } from "./ResizerViewModel"; import SettingsStore from "../../settings/SettingsStore"; import { SettingLevel } from "../../settings/SettingLevel"; -vi.mock("what-input"); - describe("LeftPanelResizerViewModel", () => { afterEach(() => { localStorage.clear(); @@ -79,6 +77,10 @@ describe("LeftPanelResizerViewModel", () => { const mockHandle = { resize: vi.fn(), isCollapsed: vi.fn().mockReturnValue(true), + getSize: vi.fn().mockReturnValue({ + inPixels: 0, + }), + collapse: vi.fn(), } as unknown as PanelImperativeHandle; vm.setPanelHandle(mockHandle); @@ -97,6 +99,7 @@ describe("LeftPanelResizerViewModel", () => { const mockHandle = { resize: vi.fn(), isCollapsed: vi.fn().mockReturnValue(true), + getSize: vi.fn().mockReturnValue(0), } as unknown as PanelImperativeHandle; vm.setPanelHandle(mockHandle); // Simulate click @@ -110,6 +113,7 @@ describe("LeftPanelResizerViewModel", () => { const mockHandle = { resize: vi.fn(), isCollapsed: vi.fn().mockReturnValue(true), + getSize: vi.fn().mockReturnValue(0), } as unknown as PanelImperativeHandle; vm.setPanelHandle(mockHandle); // Simulate click @@ -131,14 +135,30 @@ describe("LeftPanelResizerViewModel", () => { expect(mockHandle.collapse).toHaveBeenCalled(); }); + it("should ignore first resized event", () => { + const vm = new ResizerViewModel(); + const mockHandle = { + resize: vi.fn(), + getSize: vi.fn().mockReturnValue(0), + } as unknown as PanelImperativeHandle; + vm.setPanelHandle(mockHandle); + + vm.onLeftPanelResized(50); + expect(mockHandle.resize).not.toHaveBeenCalled(); + }); + it("should resize to nearest whole number", () => { const vm = new ResizerViewModel(); const mockHandle = { resize: vi.fn(), + getSize: vi.fn().mockReturnValue(0), } as unknown as PanelImperativeHandle; vm.setPanelHandle(mockHandle); + // Initial call is ignored + vm.onLeftPanelResized(70); + // This should be processed vm.onLeftPanelResized(25.515); - expect(mockHandle.resize).toHaveBeenCalledWith("26%"); + expect(mockHandle.resize).toHaveBeenLastCalledWith("26%"); }); }); diff --git a/apps/web/src/viewmodels/structures/ResizerViewModel.ts b/apps/web/src/viewmodels/structures/ResizerViewModel.ts index 2d9da268db..3aadd62cb8 100644 --- a/apps/web/src/viewmodels/structures/ResizerViewModel.ts +++ b/apps/web/src/viewmodels/structures/ResizerViewModel.ts @@ -18,9 +18,12 @@ import { debounce } from "lodash"; import SettingsStore from "../../settings/SettingsStore"; import { SettingLevel } from "../../settings/SettingLevel"; +import { AutoCollapse } from "./auto-collapse/AutoCollapse"; function getInitialState(): ResizerViewSnapshot { - if (SettingsStore.getValue("RoomList.isPanelCollapsed")) { + const shouldStartCollapsed = + SettingsStore.getValue("RoomList.isPanelCollapsed") || AutoCollapse.shouldStartCollapsed(); + if (shouldStartCollapsed) { return { isCollapsed: true, initialSize: 0, @@ -49,10 +52,27 @@ export class ResizerViewModel */ private readonly mouseClickHandler: MouseClickHandler; + /** + * Orchestrator for auto collapse behaviour. + */ + private readonly autoCollapse: AutoCollapse; + + /** + * Tracks whether we've seen the first resized event. + */ + private firstResizedEventSeen = false; + public constructor() { super(undefined, getInitialState()); + // Run onSeparatorClick when the separator is clicked. this.mouseClickHandler = new MouseClickHandler(this.onSeparatorClick); + this.autoCollapse = this.disposables.track( + new AutoCollapse(this.onSeparatorClick, () => { + this.panelHandle?.collapse(); + this.snapshot.merge({ isCollapsed: true }); + }), + ); } public onLeftPanelResize = debounce((panelSize: PanelSize): void => { @@ -61,6 +81,19 @@ export class ResizerViewModel }, 50); public onLeftPanelResized = (newSize: number): void => { + if (!this.firstResizedEventSeen) { + // When the panel is first rendered, we get a resized event. + // This should be ignored to prevent rewriting the setting value and + // to avoid confusing the collapse behaviour code. + this.firstResizedEventSeen = true; + return; + } + + // Early return if we should be ignoring this event due to some auto-collapse behaviour. + if (this.autoCollapse.shouldIgnoreResize) return; + + this.autoCollapse.onLeftPanelResized(); + // We don't want the panels to have fractional widths as that can cause blurry UI elements. if (!Number.isInteger(newSize)) { this.panelHandle?.resize(`${Math.round(newSize)}%`); @@ -88,6 +121,7 @@ export class ResizerViewModel if (this.panelHandle?.isCollapsed()) { const lastSize = SettingsStore.getValue("RoomList.panelSize"); this.panelHandle.resize(`${lastSize ?? 100}%`); + this.autoCollapse.onLeftPanelResized(); } }; diff --git a/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.test.ts b/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.test.ts new file mode 100644 index 0000000000..5aa49f939e --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { describe, expect, beforeEach, it, vi } from "vitest"; +import { AutoCollapse } from "./AutoCollapse"; +import { BaseCollapseBehaviour } from "./behaviours/BaseCollapseBehaviour"; +import type { CollapseHandler } from "./CollapseHandler"; + +let instances: BaseCollapseBehaviour[] = []; + +class MockBehaviour extends BaseCollapseBehaviour { + public constructor(collapseHandler: CollapseHandler) { + super(collapseHandler); + instances.push(this); + } + + public onLeftPanelResized = vi.fn(); +} + +class MockBehaviourWithStartCollapsed extends MockBehaviour { + public static shouldStartCollapsed(): boolean { + return true; + } +} + +class MockBehaviourWithIgnoreResize extends MockBehaviour { + public get shouldIgnoreResize(): boolean { + return true; + } +} + +vi.mock("../../../../src/viewmodels/structures/auto-collapse/behaviours/behaviours", () => { + return { + get Behaviours() { + return [MockBehaviour, MockBehaviour, MockBehaviourWithIgnoreResize, MockBehaviourWithStartCollapsed]; + }, + }; +}); + +describe("AutoCollapse", () => { + beforeEach(() => { + instances = []; + }); + + it("should calculate initial collapse count correctly", () => { + const autoCollapse = new AutoCollapse(vi.fn(), vi.fn()); + // Since we have one behaviour that tells the app to start collapsed (MockBehaviourWithStartCollapsed), + // isAutoCollapsed should be true from initialization. + expect(autoCollapse.isAutoCollapsed).toBe(true); + }); + + it("should proxy onLeftPanelResized to collapseHandler", () => { + const autoCollapse = new AutoCollapse(vi.fn(), vi.fn()); + expect(autoCollapse.isAutoCollapsed).toBe(true); + autoCollapse.onLeftPanelResized(); + expect(autoCollapse.isAutoCollapsed).toBe(false); + }); + + it("should calculate shouldStartCollapsed correctly", () => { + expect(AutoCollapse.shouldStartCollapsed()).toBe(true); + }); + + it("should calculate shouldIgnoreResize correctly", () => { + const autoCollapse = new AutoCollapse(vi.fn(), vi.fn()); + // Because of MockBehaviourWithIgnoreResize, shouldIgnoreResize should be true. + expect(autoCollapse.shouldIgnoreResize).toBe(true); + }); +}); diff --git a/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.ts b/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.ts new file mode 100644 index 0000000000..855625b45d --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { CollapseHandler } from "./CollapseHandler"; +import type { BaseCollapseBehaviour } from "./behaviours/BaseCollapseBehaviour"; +import { Behaviours } from "./behaviours/behaviours"; + +/** + * This class orchestrates all the auto-collapse behaviours. + */ +export class AutoCollapse { + private readonly behaviours: BaseCollapseBehaviour[] = []; + private readonly collapseHandler: CollapseHandler; + + /** + * @param expandPanel Callback that should expand the left panel + * @param collapsePanel Callback that should collapse the left panel + */ + public constructor(expandPanel: () => void, collapsePanel: () => void) { + // Calculate the initial value for autoCollapsedCount + const initialAutoCollapsedCount = Behaviours.reduce( + (count, B) => (B.shouldStartCollapsed() ? count + 1 : count), + 0, + ); + this.collapseHandler = new CollapseHandler(expandPanel, collapsePanel, initialAutoCollapsedCount); + + for (const Behaviour of Behaviours) { + this.behaviours.push(new Behaviour(this.collapseHandler)); + } + } + + /** + * When this returns true, any left panel resized events should be ignored. + */ + public get shouldIgnoreResize(): boolean { + return this.behaviours.some((b) => b.shouldIgnoreResize); + } + + /** + * Whether the panel is currently auto-collapsed. + */ + public get isAutoCollapsed(): boolean { + return this.collapseHandler.isAutoCollapsed; + } + + /** + * Returns boolean indicating whether the left panel should be collapsed at app start. + */ + public static shouldStartCollapsed(): boolean { + return Behaviours.some((B) => B.shouldStartCollapsed()); + } + + /** + * Dispose the behaviours in sequence. + */ + public dispose = (): void => { + for (const behaviour of this.behaviours) { + behaviour.dispose(); + } + }; + + /** + * Should be called when the left panel is resized. + */ + public onLeftPanelResized = (): void => { + this.collapseHandler.onLeftPanelResized(); + }; +} diff --git a/apps/web/src/viewmodels/structures/auto-collapse/CollapseHandler.test.ts b/apps/web/src/viewmodels/structures/auto-collapse/CollapseHandler.test.ts new file mode 100644 index 0000000000..a217d65eb5 --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/CollapseHandler.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { describe, it, expect, vi } from "vitest"; +import { CollapseHandler } from "./CollapseHandler"; + +function getCollapseHandler() { + const expandPanel = vi.fn(); + const collapsePanel = vi.fn(); + const collapseHandler = new CollapseHandler(expandPanel, collapsePanel, 0); + return { collapseHandler, expandPanel, collapsePanel }; +} + +describe("CollapseHandler", () => { + it("should be possible to collapse and expand the panel", () => { + const { collapseHandler, expandPanel, collapsePanel } = getCollapseHandler(); + collapseHandler.collapse(); + expect(collapseHandler.isAutoCollapsed).toBe(true); + expect(collapsePanel).toHaveBeenCalledTimes(1); + + collapseHandler.expand(); + expect(collapseHandler.isAutoCollapsed).toBe(false); + expect(expandPanel).toHaveBeenCalledTimes(1); + }); + + it("should retain auto collapsed state on sequential calls of expand and collapse", () => { + const { collapseHandler, expandPanel, collapsePanel } = getCollapseHandler(); + // behaviour X collapses the panel + collapseHandler.collapse(); + expect(collapsePanel).toHaveBeenCalledTimes(1); + + // behaviour Y collapses the panel + collapseHandler.collapse(); + // Since panel is already collapsed, we do not expect another call. + expect(collapsePanel).toHaveBeenCalledTimes(1); + + // behaviour Y expands the panel + collapseHandler.expand(); + // should still be auto collapsed because behaviour X hasn't expanded the panel + expect(collapseHandler.isAutoCollapsed).toBe(true); + // The actual panel should not be expanded yet + expect(expandPanel).toHaveBeenCalledTimes(0); + + // behaviour Y expands the panel + collapseHandler.expand(); + // all behaviours have expanded the panel, so no longer auto collapsed + expect(collapseHandler.isAutoCollapsed).toBe(false); + expect(expandPanel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/viewmodels/structures/auto-collapse/CollapseHandler.ts b/apps/web/src/viewmodels/structures/auto-collapse/CollapseHandler.ts new file mode 100644 index 0000000000..e9a897ba7d --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/CollapseHandler.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +/** + * Contains auto-collapsed state and methods to expand/collapse the panel. + * This class is used by the different auto-collapse behaviours. + */ +export class CollapseHandler { + /** + * We use this count to control the expand/collapse calls so that the + * behaviours do not override each other. + * + * For example, consider the sequence: + * 1. Behaviour A collapses the panel + * 2. Behaviour B collapses the panel + * 3. Behaviour B expands the panel + * 4. Behaviour A expands the panel + * + * The expand() call made by behaviour B in step 3 should noop since the panel was + * also collapsed by behaviour A which hasn't yet expanded the panel. + */ + private autoCollapsedCount: number; + + /** + * @param expandPanel Callback that should expand the left panel + * @param collapsePanel Callback that should collapse the left panel + * @param initialAutoCollapsedCount The initial value for autoCollapsedCount, defaults to 0. + */ + public constructor( + private expandPanel: () => void, + private collapsePanel: () => void, + initialAutoCollapsedCount = 0, + ) { + this.autoCollapsedCount = initialAutoCollapsedCount; + } + + /** + * Collapse the left panel. + */ + public collapse = (): void => { + this.autoCollapsedCount++; + if (this.autoCollapsedCount === 1) { + this.collapsePanel(); + } + }; + + /** + * Expand the left panel. + */ + public expand = (): void => { + /** + * Some behaviour is asking us to expand the panel but the count is zero. + * This happens when the user manually resized the left panel after some + * behaviour collapsed the panel. + * We can ignore this request to expand the panel since we don't want to + * override the manual changes the user made. + */ + if (this.autoCollapsedCount === 0) return; + + this.autoCollapsedCount--; + if (this.autoCollapsedCount === 0) { + this.expandPanel(); + } + }; + + /** + * Whether the panel is collapsed due to some behaviour. + */ + public get isAutoCollapsed(): boolean { + return this.autoCollapsedCount > 0; + } + + public onLeftPanelResized(): void { + /** + * The user has manually resized the left-panel, reset the count + * so that some collapse behaviour does not override the user + * choice. + */ + this.autoCollapsedCount = 0; + } +} diff --git a/apps/web/src/viewmodels/structures/auto-collapse/behaviours/BaseCollapseBehaviour.ts b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/BaseCollapseBehaviour.ts new file mode 100644 index 0000000000..e54fba2a0e --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/BaseCollapseBehaviour.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import type { CollapseHandler } from "../CollapseHandler"; + +/** + * The left panel should be auto-collapsed under certain app states. + * This class provides a base for writing such logic. + */ +export class BaseCollapseBehaviour { + public constructor(protected readonly collapseHandler: CollapseHandler) {} + + /** + * This method should be used to remove any event listeners that this behaviour + * uses. + */ + public dispose = (): void => { + return; + }; + + /** + * Whether currently arriving left panel resized events should be ignored according + * to this behaviour. + * This can be used to tell the ResizerViewModel to not process incoming resize + * events. + */ + public get shouldIgnoreResize(): boolean { + return false; + } + + /** + * Whether the panel should be collapsed at app start according to this behaviour. + */ + public static shouldStartCollapsed(): boolean { + return false; + } +} diff --git a/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.test.ts b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.test.ts new file mode 100644 index 0000000000..d9aa1b7241 --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from "vitest"; +import UIStore, { UI_EVENTS } from "../../../../stores/UIStore"; +import { CollapseHandler } from "../CollapseHandler"; +import { CollapseOnWindowResizeBehaviour } from "./CollapseOnWindowResizeBehaviour"; + +vi.useFakeTimers(); + +describe("CollapseOnWindowResizeBehaviour", () => { + it("Should collapse/expand the panel when the window is resized", () => { + const expandPanel = vi.fn(); + const collapsePanel = vi.fn(); + const collapseHandler = new CollapseHandler(expandPanel, collapsePanel, 0); + new CollapseOnWindowResizeBehaviour(collapseHandler); + // Making the window smaller should collapse the panel. + UIStore.instance.emit(UI_EVENTS.WidthDecreased, 750); + expect(collapsePanel).toHaveBeenCalledTimes(1); + // Making the window larger should expand the panel. + UIStore.instance.emit(UI_EVENTS.WidthIncreased, 950); + vi.runAllTimers(); + expect(expandPanel).toHaveBeenCalledTimes(1); + }); + + it("should set shouldIgnoreResize to true when window is being resized", () => { + const collapseHandler = new CollapseHandler(vi.fn(), vi.fn(), 0); + const behaviour = new CollapseOnWindowResizeBehaviour(collapseHandler); + expect(behaviour.shouldIgnoreResize).toBe(false); + // When the window is being resized, this behaviour should indicate that resize events + // should be ignored. + UIStore.instance.isWindowBeingResized = true; + expect(behaviour.shouldIgnoreResize).toBe(true); + }); + + it("should return correct shouldStartCollapsed", () => { + const collapseHandler = new CollapseHandler(vi.fn(), vi.fn(), 0); + new CollapseOnWindowResizeBehaviour(collapseHandler); + // When the window is smaller than 768px, start collapsed. + UIStore.instance.windowWidth = 750; + expect(CollapseOnWindowResizeBehaviour.shouldStartCollapsed()).toBe(true); + // When the window is larger than 768px, start expanded. + UIStore.instance.windowWidth = 900; + expect(CollapseOnWindowResizeBehaviour.shouldStartCollapsed()).toBe(false); + }); +}); diff --git a/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.ts b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.ts new file mode 100644 index 0000000000..c6d2a3085f --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { throttle } from "lodash"; + +import UIStore, { UI_EVENTS } from "../../../../stores/UIStore"; +import { BaseCollapseBehaviour } from "./BaseCollapseBehaviour"; +import type { CollapseHandler } from "../CollapseHandler"; + +/** + * The viewport width below which the left panel will be auto-collapsed. + */ +const AUTO_COLLAPSE_WIDTH = 768; + +/** + * Implements auto-collapse logic that collapses and expands the left panel when the + * app window is resized. + */ +export class CollapseOnWindowResizeBehaviour extends BaseCollapseBehaviour { + private isAutoCollapsed = CollapseOnWindowResizeBehaviour.shouldStartCollapsed(); + + public constructor(collapseHandler: CollapseHandler) { + super(collapseHandler); + UIStore.instance.on(UI_EVENTS.WidthIncreased, this.onWindowWidthIncreased); + UIStore.instance.on(UI_EVENTS.WidthDecreased, this.onWindowWidthDecreased); + } + + private onWindowWidthDecreased = throttle((currentWindowWidth: number): void => { + // If the panel is already collapsed, we have nothing else left to do. + if (this.isAutoCollapsed) return; + + if (currentWindowWidth <= AUTO_COLLAPSE_WIDTH) { + this.collapseHandler.collapse(); + this.isAutoCollapsed = true; + } + }, 50); + + private onWindowWidthIncreased = throttle((currentWindowWidth: number): void => { + if (currentWindowWidth > AUTO_COLLAPSE_WIDTH) { + // If the panel isn't already collapsed, we don't need to expand the panel. + if (!this.isAutoCollapsed) return; + // As the window is resized, react-resizable-panels is also resizing the panels. + // We'll expand the panel after a second to avoid racing with the library logic. + window.setTimeout(() => { + this.collapseHandler.expand(); + this.isAutoCollapsed = false; + }, 1000); + } + }, 50); + + /** + * Whether the window is currently being resized. + */ + public get shouldIgnoreResize(): boolean { + // When the window is resized, the panel is resized in various ways. + // These transient changes should not be persisted in settings. + // So early return if that is the case. + return UIStore.instance.isWindowBeingResized; + } + + /** + * Remove's any event listeners used by this class. + */ + public dispose = (): void => { + UIStore.instance.off(UI_EVENTS.WidthIncreased, this.onWindowWidthIncreased); + UIStore.instance.off(UI_EVENTS.WidthDecreased, this.onWindowWidthDecreased); + }; + + public static shouldStartCollapsed(): boolean { + return UIStore.instance.windowWidth <= AUTO_COLLAPSE_WIDTH; + } +} diff --git a/apps/web/src/viewmodels/structures/auto-collapse/behaviours/behaviours.ts b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/behaviours.ts new file mode 100644 index 0000000000..216fae7291 --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/behaviours.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { CollapseOnWindowResizeBehaviour } from "./CollapseOnWindowResizeBehaviour"; + +/** + * The auto-collapse behaviours used by the app. + */ +export const Behaviours = [CollapseOnWindowResizeBehaviour]; diff --git a/packages/shared-components/src/resize/panel/LeftResizablePanelView.tsx b/packages/shared-components/src/resize/panel/LeftResizablePanelView.tsx index b2ab2fb62e..9b3ac2fe7e 100644 --- a/packages/shared-components/src/resize/panel/LeftResizablePanelView.tsx +++ b/packages/shared-components/src/resize/panel/LeftResizablePanelView.tsx @@ -66,6 +66,7 @@ export function LeftResizablePanelView({ defaultSize={defaultSize} onResize={vm.onLeftPanelResize} panelRef={setPanelRef} + groupResizeBehavior="preserve-pixel-size" > {children}