Auto Collapse Behaviour - Collapse left panel on window resize (#32964)

* Add functionality to UIStore

- Make it possible to query if the window is currently being resized
- Emit WidthIncreased/WidthDecreased events

* Change resize behaviour of panel

So that the panel does not become smaller when the window is resized.
This is consistent with the old room-list design.

* Introduce a `CollapseHandler` object

This should be used by the collapse behaviours to collapse/expand the
panel. There's a good reason to not have the behaviours depend directly
on the react-resizable-panels API methods: We dont want the collapse/expand
calls to conflict with each other. See the comments in the code for more
information.

* Introduce a base class for collapse behaviour logic

Behaviours should extend this class to describe when the panel should
automatically collapse and expand.

* Add the window resize collapse behaviour

* Create a central file from which to export all behaviours

* Add a class to orchestrate the collapse behaviours

ResizerViewModel will only have a dependency on this class.

* Collapse panel on app start if necessary

For eg, if the app is started with a small window width, the panel should be
collapsed.

* Wire auto collapse code into the viewmodel

* Write jest tests

* Fix e2e tests

* Fix lint error

* Fix e2e test failures

* Expand the panel before taking screenshot

Fixes incorrect narrow screenshots in RTE.spec.ts and CIDER.spec.ts

* Make comments consistent

* Move tests from jest to vitest

* Fix lint errors

* Improve comment

* Remove variable

* Remove mock

* Fix comment formatting
This commit is contained in:
R Midhun Suresh
2026-07-21 16:37:28 +00:00
committed by GitHub
parent 363ef74248
commit 3bff251010
18 changed files with 560 additions and 6 deletions
@@ -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%");
});
});
@@ -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();
}
};
@@ -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);
});
});
@@ -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();
};
}
@@ -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);
});
});
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
});
});
@@ -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;
}
}
@@ -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];