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
@@ -0,0 +1,103 @@
/*
* 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 {
BaseViewModel,
type LeftResizablePanelViewActions,
type SeparatorViewActions,
type PanelSize,
type PanelImperativeHandle,
type GroupViewActions,
type ResizerViewSnapshot,
} from "@element-hq/web-shared-components";
import { debounce } from "lodash";
import whatInput from "what-input";
import SettingsStore from "../../settings/SettingsStore";
import { SettingLevel } from "../../settings/SettingLevel";
function getInitialState(): ResizerViewSnapshot {
if (SettingsStore.getValue("RoomList.isPanelCollapsed")) {
return {
isCollapsed: true,
initialSize: 0,
isFocusedViaKeyboard: false,
};
}
return {
isCollapsed: false,
initialSize: SettingsStore.getValue("RoomList.panelSize") ?? undefined,
isFocusedViaKeyboard: false,
};
}
/**
* Viewmodel that drives the resizable left panel.
*/
export class ResizerViewModel
extends BaseViewModel<ResizerViewSnapshot, void>
implements SeparatorViewActions, LeftResizablePanelViewActions, GroupViewActions
{
/**
* This object gives us access to the API methods of react-resizable-panels library.
*/
private panelHandle?: PanelImperativeHandle;
public constructor() {
super(undefined, getInitialState());
}
public onLeftPanelResize = debounce((panelSize: PanelSize): void => {
const newSize = panelSize.inPixels;
this.snapshot.merge({ isCollapsed: newSize === 0 });
}, 50);
public onLeftPanelResized = (newSize: number): void => {
const isCollapsed = newSize === 0;
// Store the size if the panel isn't collapsed.
if (!isCollapsed) {
SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, newSize);
}
// Store whether the panel was collapsed.
// This is stored separately instead of being inferred from the stored panel size so that
// the panel can be restored to its last known non-zero width even after app reload, which
// we wouldn't be able to do if we stored panelSize as zero.
SettingsStore.setValue("RoomList.isPanelCollapsed", null, SettingLevel.DEVICE, isCollapsed);
};
public setPanelHandle = (handle: PanelImperativeHandle): void => {
this.panelHandle = handle;
};
public onSeparatorClick = (): void => {
if (this.panelHandle?.isCollapsed()) {
this.panelHandle.resize(`100%`);
}
};
public onFocus = (): void => {
/**
* The intention here is to make the separator visible when it is focused by keyboard
* navigation i.e tabbing through the app.
*
* There's a good reason to take this approach instead of just relying on the focus-visible
* selector:
* When exactly an element gets focus-visible is determined by browser heuristics and usually
* interacting with the mouse will not give an element focus-visible.
* However with this separator on chrome, mouse interaction occasionally gives it focus-visible.
* The leads to flakey separator behaviour.
*/
const currentNavigation = whatInput.ask();
if (currentNavigation === "keyboard") {
this.snapshot.merge({ isFocusedViaKeyboard: true });
}
};
public onBlur = (): void => {
if (this.getSnapshot().isFocusedViaKeyboard) this.snapshot.merge({ isFocusedViaKeyboard: false });
};
}