Redesign widget pip and move into shared component (#32654)
* redesign widget pip and move into shared component * fix onBackClick handler * fix ci * Update README.md prepare -> prepack * add vm tests * Update WidgetPipView.stories.tsx * fix tests * playwright tests * fix test id * remove unused files (reappeared after rebase) * update storybook screenshot tests * update playwright tests * adjust padding * review * comment and docstring corrections * fix imports and `this.props` * fix double `complementary` item * add WidgetPipView tests and revmoe `setViewingRoom` from WidgetPipViewModelInterface. * add doc sting to `setViewingRoom` * Update RoomStatusBarView.test.tsx * fix copyright * Update RoomView-test.tsx.snap * revert accidental Copyright year changes * update snapshot RoomView-test
This commit is contained in:
BIN
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -3,6 +3,7 @@
|
||||
"seek_bar_label": "Audio seek bar"
|
||||
},
|
||||
"action": {
|
||||
"back": "Back",
|
||||
"delete": "Delete",
|
||||
"dismiss": "Dismiss",
|
||||
"edit": "Edit",
|
||||
|
||||
@@ -27,6 +27,7 @@ export * from "./message-body/TimelineSeparator/";
|
||||
export * from "./pill-input/Pill";
|
||||
export * from "./pill-input/PillInput";
|
||||
export * from "./room/RoomStatusBar";
|
||||
export * from "./room/WidgetPip";
|
||||
export * from "./profile/DisambiguatedProfile";
|
||||
export * from "./room/HistoryVisibilityBadge";
|
||||
export * from "./rich-list/RichItem";
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.
|
||||
*/
|
||||
|
||||
.container {
|
||||
color: var(--cpd-color-text-primary);
|
||||
svg {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright (c) 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.
|
||||
*/
|
||||
|
||||
.container {
|
||||
width: 304px; /* hardcoded to match figma -> might become a resizable pip in the future */
|
||||
height: 278px; /* hardcoded to match figma -> might become a resizable pip in the future */
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--cpd-space-3x);
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
border: 1px solid var(--cpd-color-border-interactive-secondary);
|
||||
border-radius: var(--cpd-space-6x);
|
||||
gap: var(--cpd-space-2x);
|
||||
box-shadow: 0 0 var(--cpd-space-2x) rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
top: 0;
|
||||
gap: var(--cpd-space-2x);
|
||||
display: flex;
|
||||
font: var(--cpd-font-body-md-semibold);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.roundedCornerContainer {
|
||||
border-radius: var(--cpd-space-4x);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
Copyright (c) 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 Meta, type StoryFn } from "@storybook/react-vite";
|
||||
import React, { type JSX } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import { useMockedViewModel } from "../../viewmodel";
|
||||
import { WidgetPipView, type WidgetPipViewActions, type WidgetPipViewSnapshot } from "./WidgetPipView";
|
||||
import { withViewDocs } from "../../../.storybook/withViewDocs";
|
||||
|
||||
type WidgetPipViewProps = WidgetPipViewSnapshot & WidgetPipViewActions;
|
||||
|
||||
// Helper components that are provided outside of this storybook
|
||||
const RoomAvatarMock: React.FC = () => (
|
||||
<div style={{ width: 20, height: 20, borderRadius: "50%", backgroundColor: "grey" }} />
|
||||
);
|
||||
const PersistentAppMock: React.FC = () => <div style={{ backgroundColor: "grey", flexGrow: 1 }} />;
|
||||
|
||||
const WidgetPipViewWrapperImpl = ({
|
||||
onBackClick,
|
||||
persistentAppComponent,
|
||||
onStartMoving,
|
||||
...rest
|
||||
}: WidgetPipViewProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {
|
||||
onBackClick,
|
||||
persistentAppComponent,
|
||||
onStartMoving,
|
||||
});
|
||||
return <WidgetPipView vm={vm} RoomAvatar={RoomAvatarMock} />;
|
||||
};
|
||||
|
||||
const WidgetPipViewWrapper = withViewDocs(WidgetPipViewWrapperImpl, WidgetPipView);
|
||||
|
||||
export default {
|
||||
title: "room/WidgetPipView",
|
||||
component: WidgetPipViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {},
|
||||
args: {
|
||||
widgetId: "xyz",
|
||||
roomId: "roomId",
|
||||
roomName: "Room Name",
|
||||
onBackClick: fn(),
|
||||
persistentAppComponent: PersistentAppMock,
|
||||
onStartMoving: fn(),
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/aOEkaJtaBmPy058V7uoqVr/Element-Call-Updates---Q1-2026--New-?node-id=21-31333&p=f&t=zBuFi63PKdQ0Nhab-0",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof WidgetPipViewWrapper>;
|
||||
|
||||
const Template: StoryFn<typeof WidgetPipViewWrapper> = (args) => <WidgetPipViewWrapper {...args} />;
|
||||
|
||||
/**
|
||||
* Rendered when using a widget with just a grey background.
|
||||
*/
|
||||
export const WithGreyWidget = Template.bind({});
|
||||
WithGreyWidget.args = {};
|
||||
|
||||
/**
|
||||
* Rendered when using a transparent background widget like Element Call.
|
||||
*/
|
||||
export const WithElementCallWidgetMock = Template.bind({});
|
||||
const CallPill: React.FC = () => (
|
||||
<div style={{ borderRadius: "50%", width: "50px", height: "50px", backgroundColor: "grey" }} />
|
||||
);
|
||||
WithElementCallWidgetMock.args = {
|
||||
roomName: "Element Call Room",
|
||||
persistentAppComponent: () => (
|
||||
<div style={{ flexGrow: 1, display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ flexGrow: 4, backgroundColor: "gray", borderRadius: "24px" }} />
|
||||
<div style={{ display: "flex", justifyContent: "space-between", margin: "10px" }}>
|
||||
<CallPill />
|
||||
<CallPill />
|
||||
<CallPill />
|
||||
<CallPill />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (c) 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 } from "@test-utils";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, vi, expect } from "vitest";
|
||||
import { getByTestId } from "storybook/test";
|
||||
|
||||
import * as stories from "./WidgetPipView.stories.tsx";
|
||||
|
||||
const { WithGreyWidget } = composeStories(stories);
|
||||
|
||||
describe("WidgetPipView", () => {
|
||||
it("renders with gray widget", () => {
|
||||
const { container } = render(<WithGreyWidget />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("detects back click action", async () => {
|
||||
const onBackClick = vi.fn();
|
||||
const { container, getByRole } = render(<WithGreyWidget onBackClick={onBackClick} />);
|
||||
expect(container).toMatchSnapshot();
|
||||
|
||||
const button = getByRole("button", { name: "Back" });
|
||||
await userEvent.click(button);
|
||||
expect(onBackClick).toHaveBeenCalled();
|
||||
});
|
||||
it("detects double click triggers back", async () => {
|
||||
const onBackClick = vi.fn();
|
||||
const { container } = render(<WithGreyWidget onStartMoving={onBackClick} />);
|
||||
expect(container).toMatchSnapshot();
|
||||
|
||||
const pipContainer = getByTestId(container, "widget-pip-container");
|
||||
await userEvent.dblClick(pipContainer);
|
||||
expect(onBackClick).toHaveBeenCalled();
|
||||
});
|
||||
it("detects on mouse down for drag", async () => {
|
||||
const onStartMoving = vi.fn();
|
||||
const { container } = render(<WithGreyWidget onStartMoving={onStartMoving} />);
|
||||
expect(container).toMatchSnapshot();
|
||||
|
||||
const pipContainer = getByTestId(container, "widget-pip-container");
|
||||
await userEvent.click(pipContainer);
|
||||
expect(onStartMoving).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
Copyright (c) 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 FC } from "react";
|
||||
import { ChevronLeftIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { IconButton } from "@vector-im/compound-web";
|
||||
|
||||
import styles from "./WidgetPipView.module.css";
|
||||
import { useViewModel, type ViewModel } from "../../viewmodel";
|
||||
import { useI18n } from "../..";
|
||||
|
||||
export interface WidgetPipViewActions {
|
||||
/**
|
||||
* Call this once the back button is clicked in the pip view.
|
||||
* The view model will handle navigating back to the associated room.
|
||||
* @param ev The mouse event that triggered the back click.
|
||||
*/
|
||||
onBackClick: (ev: React.MouseEvent<Element, MouseEvent>) => void;
|
||||
/**
|
||||
* The view model exposes the `<PersistentApp />` component via this action.
|
||||
* `PersistentApp` is not available in shared components.
|
||||
* It can be any React component that renders a widget.
|
||||
* It will be mounted inside the PipView.
|
||||
*/
|
||||
persistentAppComponent: React.FC<{
|
||||
persistentWidgetId: string;
|
||||
persistentRoomId: string;
|
||||
}>;
|
||||
/**
|
||||
* Action that needs to be called when the pip view starts to get dragged.
|
||||
* @param ev The mouse event that triggered the drag start.
|
||||
*/
|
||||
onStartMoving: (ev: React.MouseEvent<Element, MouseEvent>) => void;
|
||||
}
|
||||
|
||||
export interface WidgetPipViewSnapshot {
|
||||
/**
|
||||
* The widget ID this view is rendering.
|
||||
*/
|
||||
widgetId: string;
|
||||
/**
|
||||
* The room name the Pip View should use in the header.
|
||||
*/
|
||||
roomName: string;
|
||||
/**
|
||||
* The room ID this PiP view’s widget is associated with.
|
||||
*/
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model for the widget PiP view.
|
||||
*/
|
||||
export type WidgetPipViewModel = ViewModel<WidgetPipViewSnapshot> & WidgetPipViewActions;
|
||||
|
||||
export interface WidgetPipViewProps {
|
||||
/**
|
||||
* The WidgetPipViewModel to expose the WidgetPipViewSnapshot and to:
|
||||
* - handling the back button callback.
|
||||
* - exposing the persistentApp react component to the view.
|
||||
*/
|
||||
vm: WidgetPipViewModel;
|
||||
/**
|
||||
* The avatar is passed as a React component.
|
||||
* This allows any avatar implementation to be used in this view (like RoomAvatar).
|
||||
*/
|
||||
// In the future the avatar component can/should also become a shared component.
|
||||
// It would then be accessible in the shared component package and we could remove this prop.
|
||||
RoomAvatar: React.FC<{ size: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A picture-in-picture view for a widget. Additional controls are shown if the
|
||||
* widget represents a call.
|
||||
*/
|
||||
export const WidgetPipView: FC<WidgetPipViewProps> = ({ vm, RoomAvatar }) => {
|
||||
const snapshot = useViewModel(vm);
|
||||
const { translate: _t } = useI18n();
|
||||
return (
|
||||
// The interaction where we use the `onMouseDown` handler is only useful for dragging the widget around,
|
||||
// which is not possible via the keyboard. The outcome of this interaction can only be changed
|
||||
// if the user interacts with a mouse. Hence there is no use in providing an accessible alternative.
|
||||
// In the future we might consider introducing alternative shortcuts for moving the PiP around
|
||||
// with the keyboard.
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div data-testid="widget-pip-container" className={styles.container} onMouseDown={vm.onStartMoving}>
|
||||
<div className={styles.header}>
|
||||
<IconButton
|
||||
size="28px"
|
||||
data-testid="base-card-back-button"
|
||||
onClick={(ev) => vm.onBackClick(ev)}
|
||||
tooltip={_t("action|back")}
|
||||
kind="secondary"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
<RoomAvatar size="20px" />
|
||||
{snapshot.roomName}
|
||||
</div>
|
||||
<div className={styles.roundedCornerContainer}>
|
||||
<vm.persistentAppComponent persistentWidgetId={snapshot.widgetId} persistentRoomId={snapshot.roomId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`WidgetPipView > detects back click action 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
data-testid="widget-pip-container"
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_6_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="secondary"
|
||||
data-testid="base-card-back-button"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
|
||||
/>
|
||||
Room Name
|
||||
</div>
|
||||
<div
|
||||
class="roundedCornerContainer"
|
||||
>
|
||||
<div
|
||||
style="background-color: grey; flex-grow: 1;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`WidgetPipView > detects double click triggers back 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
data-testid="widget-pip-container"
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_c_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="secondary"
|
||||
data-testid="base-card-back-button"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
|
||||
/>
|
||||
Room Name
|
||||
</div>
|
||||
<div
|
||||
class="roundedCornerContainer"
|
||||
>
|
||||
<div
|
||||
style="background-color: grey; flex-grow: 1;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`WidgetPipView > detects on mouse down for drag 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
data-testid="widget-pip-container"
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_i_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="secondary"
|
||||
data-testid="base-card-back-button"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
|
||||
/>
|
||||
Room Name
|
||||
</div>
|
||||
<div
|
||||
class="roundedCornerContainer"
|
||||
>
|
||||
<div
|
||||
style="background-color: grey; flex-grow: 1;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`WidgetPipView > renders with gray widget 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
data-testid="widget-pip-container"
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_0_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="secondary"
|
||||
data-testid="base-card-back-button"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
|
||||
/>
|
||||
Room Name
|
||||
</div>
|
||||
<div
|
||||
class="roundedCornerContainer"
|
||||
>
|
||||
<div
|
||||
style="background-color: grey; flex-grow: 1;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 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.
|
||||
*/
|
||||
|
||||
export * from "./WidgetPipView";
|
||||
Reference in New Issue
Block a user