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:
co-authored by
Andy Balaam
parent
89261bbe00
commit
99e6ede9f1
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+64
@@ -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>
|
||||
);
|
||||
}
|
||||
+238
@@ -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>
|
||||
`;
|
||||
Reference in New Issue
Block a user