The separator has to tell a click apart from a drag, because dragging it also ends in a click, and it did so by treating any pointer movement at all between press and release as a drag. A pointer rarely holds perfectly still, least of all on a trackpad, so clicking the separator to open the room list often did nothing and had to be tried again. Movement is now measured from where the pointer went down and only counts as a drag past a few pixels, which means the handlers need the pointer position and the separator passes its events through to get it. Movement with nothing held down is ignored too: those events fire on hover, and one of them used to spend the click that came after it. Tests: a click that wanders a couple of pixels still opens the panel, as does one that follows moving across the separator, while a real drag is still no click. Co-authored-by: R Midhun Suresh <hi@midhun.dev>
191 lines
7.2 KiB
TypeScript
191 lines
7.2 KiB
TypeScript
/*
|
|
* 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 { vi, describe, it, expect, afterEach } from "vitest";
|
|
import { type PointerEvent } from "react";
|
|
|
|
import { waitFor } from "test-utils-rtl";
|
|
import { type PanelImperativeHandle } from "@element-hq/web-shared-components";
|
|
|
|
import { ResizerViewModel } from "./ResizerViewModel";
|
|
import SettingsStore from "../../settings/SettingsStore";
|
|
import { SettingLevel } from "../../settings/SettingLevel";
|
|
import { CallStore } from "../../stores/CallStore";
|
|
|
|
/** The pointer handlers only read where the pointer is, so that is all a test has to give them. */
|
|
const pointerAt = (x: number, y: number) => ({ clientX: x, clientY: y }) as PointerEvent;
|
|
|
|
describe("LeftPanelResizerViewModel", () => {
|
|
afterEach(() => {
|
|
localStorage.clear();
|
|
SettingsStore.reset();
|
|
});
|
|
|
|
describe("Initial state is correct", () => {
|
|
it("should have correct initial state when panel was previously collapsed", () => {
|
|
SettingsStore.setValue("RoomList.isPanelCollapsed", null, SettingLevel.DEVICE, true);
|
|
const vm = new ResizerViewModel(CallStore.instance);
|
|
expect(vm.getSnapshot()).toStrictEqual({
|
|
isCollapsed: true,
|
|
initialSize: 0,
|
|
});
|
|
});
|
|
|
|
it("should have correct initial state when panel was previously resized", () => {
|
|
SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, 34);
|
|
const vm = new ResizerViewModel(CallStore.instance);
|
|
expect(vm.getSnapshot()).toStrictEqual({
|
|
isCollapsed: false,
|
|
initialSize: 34,
|
|
});
|
|
});
|
|
|
|
it("should have correct initial state when panel was neither resized nor collapsed", () => {
|
|
const vm = new ResizerViewModel(CallStore.instance);
|
|
expect(vm.getSnapshot()).toStrictEqual({
|
|
isCollapsed: false,
|
|
initialSize: undefined,
|
|
});
|
|
});
|
|
});
|
|
|
|
it("should update isCollapsed on onLeftPanelResized()", async () => {
|
|
const vm = new ResizerViewModel(CallStore.instance);
|
|
vm.onLeftPanelResize({ inPixels: 100, asPercentage: 6 });
|
|
await waitFor(() => {
|
|
expect(vm.getSnapshot().isCollapsed).toStrictEqual(false);
|
|
});
|
|
vm.onLeftPanelResize({ inPixels: 0, asPercentage: 6 });
|
|
await waitFor(() => {
|
|
expect(vm.getSnapshot().isCollapsed).toStrictEqual(true);
|
|
});
|
|
});
|
|
|
|
it("should noop on click when handle is not yet set", () => {
|
|
const vm = new ResizerViewModel(CallStore.instance);
|
|
expect(() => {
|
|
// Click
|
|
vm.onPointerDown(pointerAt(100, 100));
|
|
vm.onPointerUp();
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it("should noop on mouse drag", () => {
|
|
const vm = new ResizerViewModel(CallStore.instance);
|
|
SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, 34);
|
|
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);
|
|
|
|
// Simulate drag
|
|
vm.onPointerDown(pointerAt(100, 100));
|
|
vm.onPointerMove(pointerAt(160, 100));
|
|
vm.onPointerUp();
|
|
|
|
expect(mockHandle.resize).not.toHaveBeenCalledWith("34%");
|
|
});
|
|
|
|
it("should expand panel when a click wanders a little", () => {
|
|
const vm = new ResizerViewModel(CallStore.instance);
|
|
SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, 34);
|
|
const mockHandle = {
|
|
resize: vi.fn(),
|
|
isCollapsed: vi.fn().mockReturnValue(true),
|
|
getSize: vi.fn().mockReturnValue(0),
|
|
} as unknown as PanelImperativeHandle;
|
|
vm.setPanelHandle(mockHandle);
|
|
|
|
// A trackpad rarely holds the pointer still between press and release.
|
|
vm.onPointerDown(pointerAt(100, 100));
|
|
vm.onPointerMove(pointerAt(101, 102));
|
|
vm.onPointerUp();
|
|
|
|
expect(mockHandle.resize).toHaveBeenCalledWith("34%");
|
|
});
|
|
|
|
it("should expand panel on a click that follows moving across the separator", () => {
|
|
const vm = new ResizerViewModel(CallStore.instance);
|
|
SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, 34);
|
|
const mockHandle = {
|
|
resize: vi.fn(),
|
|
isCollapsed: vi.fn().mockReturnValue(true),
|
|
getSize: vi.fn().mockReturnValue(0),
|
|
} as unknown as PanelImperativeHandle;
|
|
vm.setPanelHandle(mockHandle);
|
|
|
|
// Pointer moves fire on hover too, with no button held.
|
|
vm.onPointerMove(pointerAt(300, 300));
|
|
vm.onPointerDown(pointerAt(100, 100));
|
|
vm.onPointerUp();
|
|
|
|
expect(mockHandle.resize).toHaveBeenCalledWith("34%");
|
|
});
|
|
|
|
describe("should expand panel on double click when panel is collapsed", () => {
|
|
it("to last non-zero width that the user set", () => {
|
|
const vm = new ResizerViewModel(CallStore.instance);
|
|
SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, 34);
|
|
const mockHandle = {
|
|
resize: vi.fn(),
|
|
isCollapsed: vi.fn().mockReturnValue(true),
|
|
getSize: vi.fn().mockReturnValue(0),
|
|
} as unknown as PanelImperativeHandle;
|
|
vm.setPanelHandle(mockHandle);
|
|
// Simulate click
|
|
vm.onPointerDown(pointerAt(100, 100));
|
|
vm.onPointerUp();
|
|
expect(mockHandle.resize).toHaveBeenCalledWith("34%");
|
|
});
|
|
|
|
it("to maximum size of the panel", () => {
|
|
const vm = new ResizerViewModel(CallStore.instance);
|
|
const mockHandle = {
|
|
resize: vi.fn(),
|
|
isCollapsed: vi.fn().mockReturnValue(true),
|
|
getSize: vi.fn().mockReturnValue(0),
|
|
} as unknown as PanelImperativeHandle;
|
|
vm.setPanelHandle(mockHandle);
|
|
// Simulate click
|
|
vm.onPointerDown(pointerAt(100, 100));
|
|
vm.onPointerUp();
|
|
expect(mockHandle.resize).toHaveBeenCalledWith("100%");
|
|
});
|
|
});
|
|
|
|
it("should collapse panel on click when panel is expanded", () => {
|
|
const vm = new ResizerViewModel(CallStore.instance);
|
|
const mockHandle = {
|
|
collapse: vi.fn(),
|
|
isCollapsed: vi.fn().mockReturnValue(false),
|
|
} as unknown as PanelImperativeHandle;
|
|
vm.setPanelHandle(mockHandle);
|
|
|
|
vm.onDoubleClick();
|
|
expect(mockHandle.collapse).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should ignore first resized event", () => {
|
|
const vm = new ResizerViewModel(CallStore.instance);
|
|
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();
|
|
});
|
|
});
|