Implement collapsible panels for the new room list (#32742)

* Add `react-resizable-panels` library

* Implement a custom SeparatorView

* Add a `LeftResizablePanelView`

* Add a custom `GroupView`

* Export everything from shared-components

* Make it possible to track width/collapse state through settings

* Add a view model to drive the views

* Render views without disrupting the old room list

* Fix lint error

* Disable user interaction on collapsed panel

* Prevent widgets fron hijacking pointer events

* Expand to full width on separator click

* Separator should be shown when focused via keyboard

* Update tests

* Use data-attribute for hover

* Write stories for SeperatorView

* Write vite tests for SeparatorView

* Write tests for LeftResizablePanelView

* More tests

* Fix lint errors

* Fix flakey border on the roomlst

* Fix storybook axe violation

* Update snapshots

* Fix playwright tests

* Fix sonarcloud issues

* Use translated string

* Add better js-doc comments

* Rename `ResizerSnapshot` to `ResizerViewSnapshot`

* Externalize react-resizable-panels

* Link figma designs to stories

* Write playwright tests

* Update screenshots

* Fix lint errors

* Update more screenshots

* Update more screenshots

* Fix flaky toast test

* Update apps/web/playwright/e2e/crypto/toasts.spec.ts

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>

* Fix indentation

---------

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
This commit is contained in:
R Midhun Suresh
2026-03-23 13:33:32 +00:00
committed by GitHub
co-authored by Andy Balaam
parent 89261bbe00
commit 99e6ede9f1
197 changed files with 1592 additions and 40 deletions
@@ -30,7 +30,8 @@
"state_encryption_enabled": "Experimental state encryption enabled"
},
"left_panel": {
"open_dial_pad": "Open dial pad"
"open_dial_pad": "Open dial pad",
"separator_label": "Click or drag to expand"
},
"notifications": {
"all_messages": "All messages",
+1
View File
@@ -49,6 +49,7 @@ export * from "./utils/Flex";
export * from "./utils/LinkedText";
export * from "./right-panel/WidgetContextMenu";
export * from "./utils/VirtualizedList";
export * from "./resize";
// Utils
export * from "./utils/i18n";
@@ -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 React, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { GroupView, type GroupViewActions, Panel, Separator } from "..";
import { useMockedViewModel } from "../../viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs";
import { Flex } from "../../utils/Flex";
type GroupViewProps = GroupViewActions;
const Wrapper = ({ onLeftPanelResized }: GroupViewProps): JSX.Element => {
const vm = useMockedViewModel(undefined, { onLeftPanelResized });
return (
<GroupView vm={vm}>
<Panel minSize="200px" maxSize="400px">
<Flex align="center" justify="center">
LEFT CONTENT
</Flex>
</Panel>
<Separator />
<Panel>
<Flex align="center" justify="center">
MAIN CONTENT
</Flex>
</Panel>
</GroupView>
);
};
const GroupViewWrapper = withViewDocs(Wrapper, GroupView);
const meta = {
title: "Resize/GroupView",
component: GroupViewWrapper,
// This is a structural component, so nothing to visually test.
tags: ["autodocs", "!snapshot"],
args: {
onLeftPanelResized: fn(),
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel?node-id=2503-46137&t=d52sHg9vUDKnQS1Y-4",
},
},
} satisfies Meta<typeof GroupViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
decorators: [
(Story) => (
<div
style={{
height: "600px",
}}
>
<Story />
</div>
),
],
};
@@ -0,0 +1,57 @@
/*
* 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 React from "react";
import { render, screen } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import * as stories from "./GroupView.stories";
import { BaseViewModel } from "../../viewmodel";
import { GroupView, type GroupViewActions, Panel, Separator } from "..";
const { Default } = composeStories(stories);
class MockViewModel extends BaseViewModel<unknown, unknown> implements GroupViewActions {
public constructor() {
super(undefined, undefined);
}
public onLeftPanelResized: (newSize: number) => void = vi.fn();
}
function renderPanel(): MockViewModel {
const vm = new MockViewModel();
render(
<GroupView vm={vm}>
<Panel>Test</Panel>
<Separator />
<Panel>Test</Panel>
</GroupView>,
);
return vm;
}
describe("<GroupView />", () => {
it("renders Default story", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("should call onLeftPanelResize", async () => {
const vm = renderPanel();
const separator = screen.getByRole("separator");
const user = userEvent.setup();
await user.pointer([
{ target: separator, keys: "[MouseLeft>]" },
{ coords: { x: 400, y: 200 } },
{ keys: "[/MouseLeft]" },
]);
expect(vm.onLeftPanelResized).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,42 @@
/*
* 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 React, { type PropsWithChildren } from "react";
import { Group, type Layout } from "react-resizable-panels";
import { type ViewModel, useViewModel } from "../../viewmodel";
import { LEFT_PANEL_ID } from "..";
export interface GroupViewActions {
/**
* Indicates to the view-model that the left panel was resized.
* @param newSize The new size of the left panel
*/
onLeftPanelResized: (newSize: number) => void;
}
interface Props {
vm: ViewModel<unknown, GroupViewActions>;
}
/**
* This the root component for collapsible left panel. Based on {@link Group} from react-resizable-panels.
*/
export function GroupView({ vm, children }: PropsWithChildren<Props>): React.ReactNode {
useViewModel(vm);
/**
* Take layout data provided by the library and pass just the size
* of the left panel to the vm.
*/
const onLayoutChanged = (layout: Layout): void => {
const newSize = layout[LEFT_PANEL_ID];
vm.onLeftPanelResized(newSize);
};
return <Group onLayoutChanged={onLayoutChanged}>{children}</Group>;
}
@@ -0,0 +1,64 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<GroupView /> > renders Default story 1`] = `
<div>
<div
style="height: 600px;"
>
<div
data-group="true"
data-testid="_r_0_"
id="_r_0_"
style="height: 100%; width: 100%; overflow: hidden; display: flex; flex-flow: row; touch-action: pan-y;"
>
<div
data-panel="true"
data-testid="_r_1_"
id="_r_1_"
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px none; padding: 0px; margin: 0px; display: flex; flex: 50 1 0px; overflow: visible;"
>
<div
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
LEFT CONTENT
</div>
</div>
</div>
<div
aria-controls="_r_1_"
aria-orientation="vertical"
aria-valuemax="96.618"
aria-valuemin="48.309"
aria-valuenow="50"
data-separator="inactive"
data-testid="_r_2_"
id="_r_2_"
role="separator"
style="flex: 0 0 auto; touch-action: none;"
tabindex="0"
/>
<div
data-panel="true"
data-testid="_r_3_"
id="_r_3_"
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px none; padding: 0px; margin: 0px; display: flex; flex: 50 1 0px; overflow: visible;"
>
<div
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
MAIN CONTENT
</div>
</div>
</div>
</div>
</div>
</div>
`;
@@ -0,0 +1,46 @@
/*
* 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.
*/
/**
* This is the id given to the resizable container that holds
* the left panel contents.
*/
export const LEFT_PANEL_ID = "left-panel";
export * from "./group/GroupView";
export * from "./separator/SeparatorView";
export * from "./panel/LeftResizablePanelView";
/**
* Common snapshot for GroupView, SeparatorView and LeftResizablePanelView.
*/
export interface ResizerViewSnapshot {
/**
* Whether the left panel is collapsed or not.
*/
isCollapsed: boolean;
/**
* This is the initial size of the panel if available; should be interpreted as percentage.
*/
initialSize?: number;
/**
* Whether the separator is currently focused by navigating
* to it using keyboard input.
*/
isFocusedViaKeyboard: boolean;
}
/**
* Export relevant parts of the underlying library.
*/
export {
Group as ResizableGroup,
Panel,
Separator,
type PanelSize,
type PanelImperativeHandle,
} from "react-resizable-panels";
@@ -0,0 +1,81 @@
/*
* 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 React, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
ResizableGroup,
LeftResizablePanelView,
Panel,
Separator,
type LeftResizablePanelViewActions,
type ResizerViewSnapshot,
} from "..";
import { useMockedViewModel } from "../../viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs";
import { Flex } from "../../utils/Flex";
type LeftResizablePanelViewProps = ResizerViewSnapshot & LeftResizablePanelViewActions;
const Wrapper = ({ onLeftPanelResize, setPanelHandle, ...snapshot }: LeftResizablePanelViewProps): JSX.Element => {
const vm = useMockedViewModel(snapshot, { onLeftPanelResize, setPanelHandle });
return (
<LeftResizablePanelView vm={vm} minSize="200px" maxSize="400px">
<Flex align="center" justify="center">
LEFT CONTENT
</Flex>
</LeftResizablePanelView>
);
};
const LeftResizablePanelViewWrapper = withViewDocs(Wrapper, LeftResizablePanelView);
const meta = {
title: "Resize/LeftResizablePanelView",
component: LeftResizablePanelViewWrapper,
// This is a structural component, so nothing to visually test.
tags: ["autodocs", "!snapshot"],
argTypes: {
// This snapshot state is not relevant for this View.
isFocusedViaKeyboard: { table: { disable: true } },
},
args: {
initialSize: 20,
isCollapsed: false,
isFocusedViaKeyboard: false,
onLeftPanelResize: fn(),
setPanelHandle: fn(),
},
} satisfies Meta<typeof LeftResizablePanelViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
decorators: [
(Story) => (
<div
style={{
height: "600px",
}}
>
<ResizableGroup>
<Story />
<Separator />
<Panel>
<Flex align="center" justify="center">
MAIN CONTENT
</Flex>
</Panel>
</ResizableGroup>
</div>
),
],
};
@@ -0,0 +1,80 @@
/*
* 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 React from "react";
import { render, screen } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import * as stories from "./LeftResizablePanelView.stories";
import { BaseViewModel } from "../../viewmodel";
import {
ResizableGroup,
LEFT_PANEL_ID,
LeftResizablePanelView,
type LeftResizablePanelViewActions,
Panel,
type PanelImperativeHandle,
type PanelSize,
type ResizerViewSnapshot,
Separator,
} from "..";
const { Default } = composeStories(stories);
class MockViewModel extends BaseViewModel<ResizerViewSnapshot, unknown> implements LeftResizablePanelViewActions {
public constructor(snapshot: ResizerViewSnapshot) {
super(undefined, snapshot);
}
public onLeftPanelResize: (panelSize: PanelSize) => void = vi.fn();
public setPanelHandle: (handle: PanelImperativeHandle) => void = vi.fn();
}
function renderPanel(initialSnapshot?: Partial<ResizerViewSnapshot>): MockViewModel {
const snapshot = { isCollapsed: false, isFocusedViaKeyboard: false, initialSize: 20, ...initialSnapshot };
const vm = new MockViewModel(snapshot);
render(
<ResizableGroup>
<LeftResizablePanelView vm={vm}>Left</LeftResizablePanelView>
<Separator />
<Panel>Test</Panel>
</ResizableGroup>,
);
return vm;
}
describe("<LeftResizablePanelView />", () => {
it("renders Default story", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("should call setPanelHandle", () => {
const vm = renderPanel();
expect(vm.setPanelHandle).toHaveBeenCalled();
});
it("should call onLeftPanelResize", async () => {
const vm = renderPanel();
const separator = screen.getByRole("separator");
const user = userEvent.setup();
await user.pointer([
{ target: separator, keys: "[MouseLeft>]" },
{ coords: { x: 400, y: 200 } },
{ keys: "[/MouseLeft]" },
]);
expect(vm.onLeftPanelResize).toHaveBeenCalled();
});
it("should be inert when collapsed", () => {
renderPanel({ isCollapsed: true });
const panel = screen.getByTestId(LEFT_PANEL_ID);
expect(panel.getAttribute("inert")).not.toBeNull();
});
});
@@ -0,0 +1,73 @@
/*
* 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 React, { useEffect, type PropsWithChildren } from "react";
import {
Panel,
type PanelProps,
usePanelCallbackRef,
type PanelImperativeHandle,
type PanelSize,
} from "react-resizable-panels";
import { type ViewModel, useViewModel } from "../../viewmodel";
import { LEFT_PANEL_ID, type ResizerViewSnapshot } from "..";
export interface LeftResizablePanelViewActions {
/**
* Indicates to the view-model that the left panel is being resized.
* @param panelSize The new panel size.
*/
onLeftPanelResize: (panelSize: PanelSize) => void;
/**
* Pass the vm the object containing the API to interact with this panel.
* @param handle Object that can be used to access the imperative methods of the panel.
*/
setPanelHandle: (handle: PanelImperativeHandle) => void;
}
type Props = {
vm: ViewModel<ResizerViewSnapshot, LeftResizablePanelViewActions>;
className?: string;
} & Pick<PanelProps, "minSize" | "maxSize" | "defaultSize">;
/**
* This is a custom panel component for the left-panel. It is used along with SeparatorView, Group and Panel to render
* collapsible room list.
*/
export function LeftResizablePanelView({
vm,
className,
children,
...props
}: PropsWithChildren<Props>): React.ReactNode {
const { initialSize, isCollapsed } = useViewModel(vm);
const [panelRef, setPanelRef] = usePanelCallbackRef();
useEffect(() => {
if (panelRef) vm.setPanelHandle(panelRef);
}, [vm, panelRef]);
const defaultSize = initialSize === undefined ? props.defaultSize : `${initialSize}%`;
return (
<Panel
inert={isCollapsed}
id={LEFT_PANEL_ID}
className={className}
collapsible
minSize={props.minSize}
maxSize={props.maxSize}
defaultSize={defaultSize}
onResize={vm.onLeftPanelResize}
panelRef={setPanelRef}
>
{children}
</Panel>
);
}
@@ -0,0 +1,64 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<LeftResizablePanelView /> > renders Default story 1`] = `
<div>
<div
style="height: 600px;"
>
<div
data-group="true"
data-testid="_r_0_"
id="_r_0_"
style="height: 100%; width: 100%; overflow: hidden; display: flex; flex-flow: row; touch-action: pan-y;"
>
<div
data-panel="true"
data-testid="left-panel"
id="left-panel"
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px none; padding: 0px; margin: 0px; display: flex; flex: 0 1 0px; overflow: visible;"
>
<div
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
LEFT CONTENT
</div>
</div>
</div>
<div
aria-controls="left-panel"
aria-orientation="vertical"
aria-valuemax="96.618"
aria-valuemin="0"
aria-valuenow="0"
data-separator="inactive"
data-testid="_r_2_"
id="_r_2_"
role="separator"
style="flex: 0 0 auto; touch-action: none;"
tabindex="0"
/>
<div
data-panel="true"
data-testid="_r_3_"
id="_r_3_"
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px none; padding: 0px; margin: 0px; display: flex; flex: 100 1 0px; overflow: visible;"
>
<div
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
MAIN CONTENT
</div>
</div>
</div>
</div>
</div>
</div>
`;
@@ -0,0 +1,28 @@
/*
* 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.
*/
.separator {
display: flex;
align-items: center;
/* Hide the separator by default */
width: 0px;
opacity: 0;
/* Necessary to avoid weird focus outlines (doubled on one side, absent on one side etc...) */
outline-offset: -2px;
}
.separator[data-separator="hover"],
.separator:focus-visible {
background: var(--cpd-color-bg-action-tertiary-hovered);
}
.visible {
/* Show the separator when the left panel is collapsed */
opacity: 100%;
width: 12px;
border-right: 1px solid var(--cpd-color-bg-subtle-primary);
}
@@ -0,0 +1,152 @@
/*
* 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 React, { type JSX } from "react";
import { expect, fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ResizableGroup, Panel, type ResizerViewSnapshot, SeparatorView, type SeparatorViewActions } from "..";
import { useMockedViewModel } from "../../viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs";
import { Flex } from "../../utils/Flex";
type SeparatorViewProps = ResizerViewSnapshot & SeparatorViewActions;
const Wrapper = ({ onFocus, onBlur, onSeparatorClick, ...snapshot }: SeparatorViewProps): JSX.Element => {
const vm = useMockedViewModel(snapshot, { onFocus, onBlur, onSeparatorClick });
return <SeparatorView className="Separator" vm={vm} />;
};
const SeparatorViewWrapper = withViewDocs(Wrapper, SeparatorView);
const meta = {
title: "Resize/SeparatorView",
component: SeparatorViewWrapper,
tags: ["autodocs"],
args: {
onFocus: fn(),
onBlur: fn(),
onSeparatorClick: fn(),
isCollapsed: true,
isFocusedViaKeyboard: false,
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=10603-14568&t=hvg0p1vDW5Cg6ZKY-4",
},
},
} satisfies Meta<typeof SeparatorViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
decorators: [
(Story) => (
<div
style={{
height: "600px",
}}
>
<ResizableGroup>
<Panel collapsible defaultSize="0" minSize="200px" maxSize="400px">
<Flex tabIndex={0} align="center" justify="center">
LEFT CONTENT
</Flex>
</Panel>
<Story />
<Panel>
<Flex align="center" justify="center">
MAIN CONTENT
</Flex>
</Panel>
</ResizableGroup>
</div>
),
],
};
const commonDecorator = (Story: React.ComponentType): React.ReactElement => {
return (
<div
style={{
height: "600px",
}}
>
<ResizableGroup>
<Panel collapsible defaultSize="200px" minSize="200px" maxSize="400px">
<Flex tabIndex={0} align="center" justify="center">
LEFT CONTENT
</Flex>
</Panel>
<Story />
<Panel>
<Flex align="center" justify="center">
MAIN CONTENT
</Flex>
</Panel>
</ResizableGroup>
</div>
);
};
export const LeftPanelExpanded: Story = {
decorators: [(Story) => commonDecorator(Story)],
args: {
isCollapsed: false,
},
};
export const KeyboardFocused: Story = {
// We'll manually take a screenshot for this story
tags: ["autodocs", "!snapshot"],
decorators: [(Story) => commonDecorator(Story)],
args: {
isCollapsed: false,
isFocusedViaKeyboard: true,
},
play: async ({ canvas, canvasElement }) => {
const separator = canvas.getByRole("separator");
separator.focus();
await expect(canvasElement).toMatchImageSnapshot();
},
};
export const Hover: Story = {
// We'll manually take a screenshot for this story
tags: ["autodocs", "!snapshot"],
decorators: [
(Story) => (
<div
style={{
height: "600px",
}}
>
<ResizableGroup>
<Panel collapsible defaultSize="0" minSize="200px" maxSize="400px">
<Flex tabIndex={0} align="center" justify="center">
LEFT CONTENT
</Flex>
</Panel>
<Story />
<Panel>
<Flex align="center" justify="center">
MAIN CONTENT
</Flex>
</Panel>
</ResizableGroup>
</div>
),
],
play: async ({ canvas, canvasElement }) => {
const separator = canvas.getByRole("separator");
separator.dataset.separator = "hover";
await expect(canvasElement).toMatchImageSnapshot();
},
};
@@ -0,0 +1,73 @@
/*
* 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 React from "react";
import { render, screen } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect, vi } from "vitest";
import { userEvent } from "vitest/browser";
import * as stories from "./SeparatorView.stories";
import { BaseViewModel } from "../../viewmodel";
import { ResizableGroup, Panel, type ResizerViewSnapshot, SeparatorView, type SeparatorViewActions } from "..";
const { Default, LeftPanelExpanded, KeyboardFocused } = composeStories(stories);
class MockViewModel extends BaseViewModel<ResizerViewSnapshot, unknown> implements SeparatorViewActions {
public constructor(snapshot: ResizerViewSnapshot) {
super(undefined, snapshot);
}
public onBlur: () => void = vi.fn();
public onFocus: () => void = vi.fn();
public onSeparatorClick: () => void = vi.fn();
}
function renderPanel(initialSnapshot?: Partial<ResizerViewSnapshot>): MockViewModel {
const snapshot = { isCollapsed: true, isFocusedViaKeyboard: false, initialSize: 20, ...initialSnapshot };
const vm = new MockViewModel(snapshot);
render(
<ResizableGroup>
<Panel>Left</Panel>
<SeparatorView vm={vm} />
<Panel>Test</Panel>
</ResizableGroup>,
);
return vm;
}
describe("<SeparatorView />", () => {
it("renders Default story", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders LeftPanelExpanded story", () => {
const { container } = render(<LeftPanelExpanded />);
expect(container).toMatchSnapshot();
});
it("renders KeyboardFocused story", () => {
const { container } = render(<KeyboardFocused />);
expect(container).toMatchSnapshot();
});
it("should call onSeparatorClick() when clicked", async () => {
const vm = renderPanel();
const separator = screen.getByRole("separator");
await userEvent.click(separator);
expect(vm.onSeparatorClick).toHaveBeenCalledOnce();
});
it("should call onFocus and onBlur when receiving/loosing focus", async () => {
const vm = renderPanel();
const separator = screen.getByRole("separator");
separator.focus();
expect(vm.onFocus).toHaveBeenCalled();
separator.blur();
expect(vm.onBlur).toHaveBeenCalled();
});
});
@@ -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 React from "react";
import { Separator } from "react-resizable-panels";
import DragIcon from "@vector-im/compound-design-tokens/assets/web/icons/drag-list";
import classNames from "classnames";
import { Tooltip } from "@vector-im/compound-web";
import { type ViewModel, useViewModel } from "../../viewmodel";
import styles from "./SeparatorView.module.css";
import { type ResizerViewSnapshot } from "..";
import { useI18n } from "../../utils/i18nContext";
export interface SeparatorViewActions {
/**
* onClick handler for the separator.
*/
onSeparatorClick: () => void;
/**
* onFocus handler for the separator.
*/
onFocus: () => void;
/**
* onBlur handler for the separator.
*/
onBlur: () => void;
}
interface Props {
vm: ViewModel<ResizerViewSnapshot, SeparatorViewActions>;
className?: string;
}
/**
* Custom separator for collapsible left-panel based on {@link Separator}.
*/
export function SeparatorView({ vm, className }: Props): React.ReactNode {
const { translate: _t } = useI18n();
const { isCollapsed, isFocusedViaKeyboard } = useViewModel(vm);
const classes = classNames(styles.separator, className, {
[styles.visible]: isCollapsed || isFocusedViaKeyboard,
});
return (
<Separator
className={classes}
onClick={vm.onSeparatorClick}
onFocus={vm.onFocus}
onBlur={vm.onBlur}
aria-label={_t("left_panel|separator_label")}
>
<Tooltip description={_t("left_panel|separator_label")} placement="right">
<DragIcon
width="20px"
height="12px"
// Without a custom view-box, this svg would scale incorrectly and would appear tiny within the separator.
// See https://github.com/element-hq/compound/issues/242
viewBox="3.999704360961914 8.999704360961914 16.000295639038086 6.000591278076172"
transform="rotate(90)"
/>
</Tooltip>
</Separator>
);
}
@@ -0,0 +1,238 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<SeparatorView /> > renders Default story 1`] = `
<div>
<div
style="height: 600px;"
>
<div
data-group="true"
data-testid="_r_0_"
id="_r_0_"
style="height: 100%; width: 100%; overflow: hidden; display: flex; flex-flow: row; touch-action: pan-y;"
>
<div
data-panel="true"
data-testid="_r_1_"
id="_r_1_"
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px none; padding: 0px; margin: 0px; display: flex; flex: 0 1 0px; overflow: visible;"
>
<div
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
tabindex="0"
>
LEFT CONTENT
</div>
</div>
</div>
<div
aria-controls="_r_1_"
aria-label="Click or drag to expand"
aria-orientation="vertical"
aria-valuemax="99.502"
aria-valuemin="0"
aria-valuenow="0"
class="separator Separator visible"
data-separator="inactive"
data-testid="_r_2_"
id="_r_2_"
role="separator"
style="flex: 0 0 auto; touch-action: none;"
tabindex="0"
>
<svg
fill="currentColor"
height="12px"
transform="rotate(90)"
viewBox="3.999704360961914 8.999704360961914 16.000295639038086 6.000591278076172"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 15a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 14q0-.424.287-.713A.97.97 0 0 1 5 13h14q.424 0 .712.287.288.288.288.713 0 .424-.288.713A.97.97 0 0 1 19 15zm0-4a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 10q0-.424.287-.713A.97.97 0 0 1 5 9h14q.424 0 .712.287Q20 9.576 20 10t-.288.713A.97.97 0 0 1 19 11z"
/>
</svg>
</div>
<div
data-panel="true"
data-testid="_r_7_"
id="_r_7_"
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px none; padding: 0px; margin: 0px; display: flex; flex: 100 1 0px; overflow: visible;"
>
<div
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
MAIN CONTENT
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`<SeparatorView /> > renders KeyboardFocused story 1`] = `
<div>
<div
style="height: 600px;"
>
<div
data-group="true"
data-testid="_r_g_"
id="_r_g_"
style="height: 100%; width: 100%; overflow: hidden; display: flex; flex-flow: row; touch-action: pan-y;"
>
<div
data-panel="true"
data-testid="_r_h_"
id="_r_h_"
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px none; padding: 0px; margin: 0px; display: flex; flex: 49.751 1 0px; overflow: visible;"
>
<div
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
tabindex="0"
>
LEFT CONTENT
</div>
</div>
</div>
<div
aria-controls="_r_h_"
aria-label="Click or drag to expand"
aria-orientation="vertical"
aria-valuemax="99.502"
aria-valuemin="0"
aria-valuenow="49.751"
class="separator Separator visible"
data-separator="inactive"
data-testid="_r_i_"
id="_r_i_"
role="separator"
style="flex: 0 0 auto; touch-action: none;"
tabindex="0"
>
<svg
fill="currentColor"
height="12px"
transform="rotate(90)"
viewBox="3.999704360961914 8.999704360961914 16.000295639038086 6.000591278076172"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 15a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 14q0-.424.287-.713A.97.97 0 0 1 5 13h14q.424 0 .712.287.288.288.288.713 0 .424-.288.713A.97.97 0 0 1 19 15zm0-4a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 10q0-.424.287-.713A.97.97 0 0 1 5 9h14q.424 0 .712.287Q20 9.576 20 10t-.288.713A.97.97 0 0 1 19 11z"
/>
</svg>
</div>
<div
data-panel="true"
data-testid="_r_n_"
id="_r_n_"
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px none; padding: 0px; margin: 0px; display: flex; flex: 50.249 1 0px; overflow: visible;"
>
<div
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
MAIN CONTENT
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`<SeparatorView /> > renders LeftPanelExpanded story 1`] = `
<div>
<div
style="height: 600px;"
>
<div
data-group="true"
data-testid="_r_8_"
id="_r_8_"
style="height: 100%; width: 100%; overflow: hidden; display: flex; flex-flow: row; touch-action: pan-y;"
>
<div
data-panel="true"
data-testid="_r_9_"
id="_r_9_"
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px none; padding: 0px; margin: 0px; display: flex; flex: 48.309 1 0px; overflow: visible;"
>
<div
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
tabindex="0"
>
LEFT CONTENT
</div>
</div>
</div>
<div
aria-controls="_r_9_"
aria-label="Click or drag to expand"
aria-orientation="vertical"
aria-valuemax="96.618"
aria-valuemin="0"
aria-valuenow="48.309"
class="separator Separator"
data-separator="inactive"
data-testid="_r_a_"
id="_r_a_"
role="separator"
style="flex: 0 0 auto; touch-action: none;"
tabindex="0"
>
<svg
fill="currentColor"
height="12px"
transform="rotate(90)"
viewBox="3.999704360961914 8.999704360961914 16.000295639038086 6.000591278076172"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 15a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 14q0-.424.287-.713A.97.97 0 0 1 5 13h14q.424 0 .712.287.288.288.288.713 0 .424-.288.713A.97.97 0 0 1 19 15zm0-4a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 10q0-.424.287-.713A.97.97 0 0 1 5 9h14q.424 0 .712.287Q20 9.576 20 10t-.288.713A.97.97 0 0 1 19 11z"
/>
</svg>
</div>
<div
data-panel="true"
data-testid="_r_f_"
id="_r_f_"
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px none; padding: 0px; margin: 0px; display: flex; flex: 51.691 1 0px; overflow: visible;"
>
<div
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
MAIN CONTENT
</div>
</div>
</div>
</div>
</div>
</div>
`;