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:
@@ -85,6 +85,9 @@ test.describe("Composer", () => {
|
|||||||
test("renders in narrow viewports", { tag: "@screenshot" }, async ({ page, bot, app }) => {
|
test("renders in narrow viewports", { tag: "@screenshot" }, async ({ page, bot, app }) => {
|
||||||
// Shrink the viewport
|
// Shrink the viewport
|
||||||
await page.setViewportSize({ width: 500, height: 1080 });
|
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");
|
await expect(app.getComposer()).toMatchScreenshot("narrow.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,9 @@ test.describe("Composer", () => {
|
|||||||
test("renders in narrow viewports", { tag: "@screenshot" }, async ({ page, bot, app }) => {
|
test("renders in narrow viewports", { tag: "@screenshot" }, async ({ page, bot, app }) => {
|
||||||
// Shrink the viewport
|
// Shrink the viewport
|
||||||
await page.setViewportSize({ width: 750, height: 1080 });
|
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");
|
await expect(page.locator(".mx_MessageComposer_wrapper")).toMatchScreenshot("narrow.png");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { test, expect } from "../../../element-web-test";
|
|||||||
import { getRoomListView } from "./utils";
|
import { getRoomListView } from "./utils";
|
||||||
|
|
||||||
test.describe("Room list panel", () => {
|
test.describe("Room list panel", () => {
|
||||||
|
test.use({
|
||||||
|
displayName: "Eve",
|
||||||
|
});
|
||||||
|
|
||||||
test.beforeEach(async ({ page, app, user }) => {
|
test.beforeEach(async ({ page, app, user }) => {
|
||||||
// The toasts are displayed above the search section
|
// The toasts are displayed above the search section
|
||||||
await rejectToast(page, "Verify this device");
|
await rejectToast(page, "Verify this device");
|
||||||
@@ -36,8 +40,7 @@ test.describe("Room list panel", () => {
|
|||||||
test.use({ lockLeftPanelWidth: false });
|
test.use({ lockLeftPanelWidth: false });
|
||||||
test("should respond to small screen sizes", { tag: "@screenshot" }, async ({ page }) => {
|
test("should respond to small screen sizes", { tag: "@screenshot" }, async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 575, height: 600 });
|
await page.setViewportSize({ width: 575, height: 600 });
|
||||||
const roomListPanel = getRoomListView(page);
|
await expect(page).toMatchScreenshot("room-list-panel-smallscreen.png");
|
||||||
await expect(roomListPanel).toMatchScreenshot("room-list-panel-smallscreen.png");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -96,6 +96,13 @@ export class ElementAppPage {
|
|||||||
* @param name The exact room name to find and click on/open.
|
* @param name The exact room name to find and click on/open.
|
||||||
*/
|
*/
|
||||||
public async viewRoomByName(name: string): Promise<void> {
|
public async viewRoomByName(name: string): Promise<void> {
|
||||||
|
// 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,
|
// Make sure the room list is actually present before we try closing toasts,
|
||||||
// otherwise we may race with page loading
|
// otherwise we may race with page loading
|
||||||
await this.page.getByTestId("room-list").waitFor();
|
await this.page.getByTestId("room-list").waitFor();
|
||||||
@@ -387,4 +394,22 @@ export class ElementAppPage {
|
|||||||
await this.page.mouse.wheel(0, 1000);
|
await this.page.mouse.wheel(0, 1000);
|
||||||
} while (await needsScroll());
|
} 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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.6 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 26 KiB |
@@ -16,8 +16,6 @@ import { ResizerViewModel } from "./ResizerViewModel";
|
|||||||
import SettingsStore from "../../settings/SettingsStore";
|
import SettingsStore from "../../settings/SettingsStore";
|
||||||
import { SettingLevel } from "../../settings/SettingLevel";
|
import { SettingLevel } from "../../settings/SettingLevel";
|
||||||
|
|
||||||
vi.mock("what-input");
|
|
||||||
|
|
||||||
describe("LeftPanelResizerViewModel", () => {
|
describe("LeftPanelResizerViewModel", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -79,6 +77,10 @@ describe("LeftPanelResizerViewModel", () => {
|
|||||||
const mockHandle = {
|
const mockHandle = {
|
||||||
resize: vi.fn(),
|
resize: vi.fn(),
|
||||||
isCollapsed: vi.fn().mockReturnValue(true),
|
isCollapsed: vi.fn().mockReturnValue(true),
|
||||||
|
getSize: vi.fn().mockReturnValue({
|
||||||
|
inPixels: 0,
|
||||||
|
}),
|
||||||
|
collapse: vi.fn(),
|
||||||
} as unknown as PanelImperativeHandle;
|
} as unknown as PanelImperativeHandle;
|
||||||
vm.setPanelHandle(mockHandle);
|
vm.setPanelHandle(mockHandle);
|
||||||
|
|
||||||
@@ -97,6 +99,7 @@ describe("LeftPanelResizerViewModel", () => {
|
|||||||
const mockHandle = {
|
const mockHandle = {
|
||||||
resize: vi.fn(),
|
resize: vi.fn(),
|
||||||
isCollapsed: vi.fn().mockReturnValue(true),
|
isCollapsed: vi.fn().mockReturnValue(true),
|
||||||
|
getSize: vi.fn().mockReturnValue(0),
|
||||||
} as unknown as PanelImperativeHandle;
|
} as unknown as PanelImperativeHandle;
|
||||||
vm.setPanelHandle(mockHandle);
|
vm.setPanelHandle(mockHandle);
|
||||||
// Simulate click
|
// Simulate click
|
||||||
@@ -110,6 +113,7 @@ describe("LeftPanelResizerViewModel", () => {
|
|||||||
const mockHandle = {
|
const mockHandle = {
|
||||||
resize: vi.fn(),
|
resize: vi.fn(),
|
||||||
isCollapsed: vi.fn().mockReturnValue(true),
|
isCollapsed: vi.fn().mockReturnValue(true),
|
||||||
|
getSize: vi.fn().mockReturnValue(0),
|
||||||
} as unknown as PanelImperativeHandle;
|
} as unknown as PanelImperativeHandle;
|
||||||
vm.setPanelHandle(mockHandle);
|
vm.setPanelHandle(mockHandle);
|
||||||
// Simulate click
|
// Simulate click
|
||||||
@@ -131,14 +135,30 @@ describe("LeftPanelResizerViewModel", () => {
|
|||||||
expect(mockHandle.collapse).toHaveBeenCalled();
|
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", () => {
|
it("should resize to nearest whole number", () => {
|
||||||
const vm = new ResizerViewModel();
|
const vm = new ResizerViewModel();
|
||||||
const mockHandle = {
|
const mockHandle = {
|
||||||
resize: vi.fn(),
|
resize: vi.fn(),
|
||||||
|
getSize: vi.fn().mockReturnValue(0),
|
||||||
} as unknown as PanelImperativeHandle;
|
} as unknown as PanelImperativeHandle;
|
||||||
vm.setPanelHandle(mockHandle);
|
vm.setPanelHandle(mockHandle);
|
||||||
|
|
||||||
|
// Initial call is ignored
|
||||||
|
vm.onLeftPanelResized(70);
|
||||||
|
// This should be processed
|
||||||
vm.onLeftPanelResized(25.515);
|
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 SettingsStore from "../../settings/SettingsStore";
|
||||||
import { SettingLevel } from "../../settings/SettingLevel";
|
import { SettingLevel } from "../../settings/SettingLevel";
|
||||||
|
import { AutoCollapse } from "./auto-collapse/AutoCollapse";
|
||||||
|
|
||||||
function getInitialState(): ResizerViewSnapshot {
|
function getInitialState(): ResizerViewSnapshot {
|
||||||
if (SettingsStore.getValue("RoomList.isPanelCollapsed")) {
|
const shouldStartCollapsed =
|
||||||
|
SettingsStore.getValue("RoomList.isPanelCollapsed") || AutoCollapse.shouldStartCollapsed();
|
||||||
|
if (shouldStartCollapsed) {
|
||||||
return {
|
return {
|
||||||
isCollapsed: true,
|
isCollapsed: true,
|
||||||
initialSize: 0,
|
initialSize: 0,
|
||||||
@@ -49,10 +52,27 @@ export class ResizerViewModel
|
|||||||
*/
|
*/
|
||||||
private readonly mouseClickHandler: MouseClickHandler;
|
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() {
|
public constructor() {
|
||||||
super(undefined, getInitialState());
|
super(undefined, getInitialState());
|
||||||
|
|
||||||
// Run onSeparatorClick when the separator is clicked.
|
// Run onSeparatorClick when the separator is clicked.
|
||||||
this.mouseClickHandler = new MouseClickHandler(this.onSeparatorClick);
|
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 => {
|
public onLeftPanelResize = debounce((panelSize: PanelSize): void => {
|
||||||
@@ -61,6 +81,19 @@ export class ResizerViewModel
|
|||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
public onLeftPanelResized = (newSize: number): void => {
|
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.
|
// We don't want the panels to have fractional widths as that can cause blurry UI elements.
|
||||||
if (!Number.isInteger(newSize)) {
|
if (!Number.isInteger(newSize)) {
|
||||||
this.panelHandle?.resize(`${Math.round(newSize)}%`);
|
this.panelHandle?.resize(`${Math.round(newSize)}%`);
|
||||||
@@ -88,6 +121,7 @@ export class ResizerViewModel
|
|||||||
if (this.panelHandle?.isCollapsed()) {
|
if (this.panelHandle?.isCollapsed()) {
|
||||||
const lastSize = SettingsStore.getValue("RoomList.panelSize");
|
const lastSize = SettingsStore.getValue("RoomList.panelSize");
|
||||||
this.panelHandle.resize(`${lastSize ?? 100}%`);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+76
@@ -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];
|
||||||
@@ -66,6 +66,7 @@ export function LeftResizablePanelView({
|
|||||||
defaultSize={defaultSize}
|
defaultSize={defaultSize}
|
||||||
onResize={vm.onLeftPanelResize}
|
onResize={vm.onLeftPanelResize}
|
||||||
panelRef={setPanelRef}
|
panelRef={setPanelRef}
|
||||||
|
groupResizeBehavior="preserve-pixel-size"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
Reference in New Issue
Block a user