MVVM WidgetContextMenu component to shared component (#31190)

* Create WidgetContextMenu component in shared-components

* Modify WidgetMenuContext call (apptile, extensioncard, widgetcard), test and stories

* Correctly use new widgetcontextmenu component

* WidgetContextMenuViewModel unit test

* Lint and add comments

* Finalize widgetcontextmenuviewmodel test

* fix lint errors

* Fix test error

* Update playwright screenshots

* add userWidget in widgetcontexstmenu props

* Fix some a11y issues on playwright

* fix linter error widget card

* Use new i18n way for share component widget context menu

* Add i18n context provider for widget context menu

* chore: lint and update snapshot widgetcontextmenu
This commit is contained in:
Marc
2026-01-29 11:22:47 +01:00
committed by GitHub
parent 8769165e88
commit 8bb1cb5e63
20 changed files with 1176 additions and 155 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -5,6 +5,7 @@
"action": {
"delete": "Delete",
"dismiss": "Dismiss",
"edit": "Edit",
"explore_rooms": "Explore rooms",
"invite": "Invite",
"new_conversation": "New conversation",
@@ -13,6 +14,7 @@
"open_menu": "Open menu",
"pause": "Pause",
"play": "Play",
"remove": "Remove",
"retry": "Retry",
"search": "Search",
"start_chat": "Start chat"
@@ -83,5 +85,15 @@
"error_downloading_audio": "Error downloading audio",
"unnamed_audio": "Unnamed audio"
}
},
"widget": {
"context_menu": {
"move_left": "Move left",
"move_right": "Move right",
"remove": "Remove for everyone",
"revoke": "Revoke permissions",
"screenshot": "Take a picture",
"start_audio_stream": "Start audio stream"
}
}
}

View File

@@ -23,6 +23,7 @@ export * from "./room-list/RoomListHeaderView";
export * from "./room-list/RoomListSearchView";
export * from "./utils/Box";
export * from "./utils/Flex";
export * from "./right-panel/WidgetContextMenu";
export * from "./utils/VirtualizedList";
// Utils

View File

@@ -0,0 +1,84 @@
/*
* Copyright 2025 New Vector 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 { IconButton } from "@vector-im/compound-web";
import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import type { Meta, StoryFn } from "@storybook/react-vite";
import {
type WidgetContextMenuAction,
type WidgetContextMenuSnapshot,
WidgetContextMenuView,
} from "./WidgetContextMenuView";
import { useMockedViewModel } from "../../viewmodel/useMockedViewModel";
type WidgetContextMenuViewModelProps = WidgetContextMenuSnapshot & WidgetContextMenuAction;
const WidgetContextMenuViewWrapper = ({
onStreamAudioClick,
onEditClick,
onSnapshotClick,
onDeleteClick,
onRevokeClick,
onFinished,
onMoveButton,
...rest
}: WidgetContextMenuViewModelProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onStreamAudioClick,
onEditClick,
onSnapshotClick,
onDeleteClick,
onRevokeClick,
onFinished,
onMoveButton,
});
return <WidgetContextMenuView vm={vm} />;
};
export default {
title: "RightPanel/WidgetContextMenuView",
component: WidgetContextMenuViewWrapper,
tags: ["autodocs"],
args: {
showStreamAudioStreamButton: true,
showEditButton: true,
showRevokeButton: true,
showDeleteButton: true,
showSnapshotButton: true,
showMoveButtons: [true, true],
canModify: true,
widgetMessaging: undefined,
isMenuOpened: true,
trigger: (
<IconButton size="24px" aria-label="context menu trigger button" inert={true} tabIndex={-1}>
<TriggerIcon />
</IconButton>
),
onStreamAudioClick: fn(),
onEditClick: fn(),
onSnapshotClick: fn(),
onDeleteClick: fn(),
onRevokeClick: fn(),
onFinished: fn(),
onMoveButton: fn(),
},
} as Meta<typeof WidgetContextMenuViewWrapper>;
const Template: StoryFn<typeof WidgetContextMenuViewWrapper> = (args) => <WidgetContextMenuViewWrapper {...args} />;
export const Default = Template.bind({});
export const OnlyBasicModification = Template.bind({});
OnlyBasicModification.args = {
showSnapshotButton: false,
showMoveButtons: [false, false],
showStreamAudioStreamButton: false,
showEditButton: false,
};

View File

@@ -0,0 +1,116 @@
/*
* Copyright 2025 New Vector 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 { screen, render } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { IconButton } from "@vector-im/compound-web";
import { composeStories } from "@storybook/react-vite";
import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import { describe, vi, expect, it, afterEach } from "vitest";
import {
type WidgetContextMenuAction,
type WidgetContextMenuSnapshot,
WidgetContextMenuView,
} from "./WidgetContextMenuView";
import * as stories from "./WidgetContextMenuView.stories.tsx";
import { MockViewModel } from "../../viewmodel/MockViewModel.ts";
import { I18nApi } from "../../utils/I18nApi.ts";
import { I18nContext } from "../../utils/i18nContext.ts";
const { Default, OnlyBasicModification } = composeStories(stories);
describe("<WidgetContextMenuView />", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("renders widget contextmenu with all options", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders widget contextmenu without only basic modification", () => {
const { container } = render(<OnlyBasicModification />);
expect(container).toMatchSnapshot();
});
const onKeyDown = vi.fn();
const togglePlay = vi.fn();
const onSeekbarChange = vi.fn();
const onStreamAudioClick = vi.fn();
const onEditClick = vi.fn();
const onSnapshotClick = vi.fn();
const onDeleteClick = vi.fn();
const onRevokeClick = vi.fn();
const onFinished = vi.fn();
const onMoveButton = vi.fn();
class WidgetContextMenuViewModel
extends MockViewModel<WidgetContextMenuSnapshot>
implements WidgetContextMenuAction
{
public onKeyDown = onKeyDown;
public togglePlay = togglePlay;
public onSeekbarChange = onSeekbarChange;
public onStreamAudioClick = onStreamAudioClick;
public onEditClick = onEditClick;
public onSnapshotClick = onSnapshotClick;
public onDeleteClick = onDeleteClick;
public onRevokeClick = onRevokeClick;
public onFinished = onFinished;
public onMoveButton = onMoveButton;
}
const defaultValue: WidgetContextMenuSnapshot = {
showStreamAudioStreamButton: true,
showEditButton: true,
showRevokeButton: true,
showDeleteButton: true,
showSnapshotButton: true,
showMoveButtons: [true, true],
canModify: true,
isMenuOpened: true,
userWidget: false,
trigger: (
<IconButton size="24px" aria-label="context menu trigger button">
<TriggerIcon />
</IconButton>
),
};
it("should attach vm methods", async () => {
const vm = new WidgetContextMenuViewModel(defaultValue);
render(<WidgetContextMenuView vm={vm} />, {
wrapper: ({ children }) => <I18nContext.Provider value={new I18nApi()}>{children}</I18nContext.Provider>,
});
await userEvent.click(screen.getByRole("menuitem", { name: "Start audio stream" }));
expect(onStreamAudioClick).toHaveBeenCalled();
await userEvent.click(screen.getByRole("menuitem", { name: "Edit" }));
expect(onEditClick).toHaveBeenCalled();
await userEvent.click(screen.getByRole("menuitem", { name: "Take a picture" }));
expect(onSnapshotClick).toHaveBeenCalled();
await userEvent.click(screen.getByRole("menuitem", { name: "Revoke permissions" }));
expect(onRevokeClick).toHaveBeenCalled();
await userEvent.click(screen.getByRole("menuitem", { name: "Remove for everyone" }));
expect(onDeleteClick).toHaveBeenCalled();
await userEvent.click(screen.getByRole("menuitem", { name: "Move left" }));
expect(onMoveButton).toHaveBeenCalledWith(-1);
await userEvent.click(screen.getByRole("menuitem", { name: "Move right" }));
expect(onMoveButton).toHaveBeenCalledWith(1);
});
});

View File

@@ -0,0 +1,197 @@
/*
* Copyright 2025 New Vector 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 ReactNode, type JSX } from "react";
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import { type ViewModel } from "../../viewmodel/ViewModel.ts";
import { useI18n } from "../../utils/i18nContext.ts";
import { useViewModel } from "../../viewmodel/useViewModel.ts";
export interface WidgetContextMenuSnapshot {
/**
* Indicates if the audio stream button needs to be shown or not
* depending on the config value audio_stream_url and widget type jitsi
*/
showStreamAudioStreamButton: boolean;
/**
* Indicates if the edit button is shown depending the user permission to modify
*/
showEditButton: boolean;
/**
* Indicates if revoke widget button needs to be shown or not
*/
showRevokeButton: boolean;
/**
* Indicates if delete widget button needs to be shown or not
*/
showDeleteButton: boolean;
/**
* Show take screenshot button or not dependning on config value enableWidgetScreenshots
*/
showSnapshotButton: boolean;
/**
* show move widget position button
*/
showMoveButtons: [boolean, boolean];
/**
* Indicates if user can modify the widget settings
*/
canModify: boolean;
/**
* Indicates if the widget menu is opened or not
*/
isMenuOpened: boolean;
/**
* A component that is displayed which trigger the menu to open or close
*/
trigger: ReactNode;
/**
* If it's an instance of a user widget
*/
userWidget: boolean;
}
export interface WidgetContextMenuAction {
/**
* Function triggered when stream audio is clicked
*/
onStreamAudioClick: () => Promise<void>;
/**
* Function triggered when edit button is clicked
*/
onEditClick: () => void;
/**
* Function triggered when snapshot button is clicked
*/
onSnapshotClick: () => void;
/**
* Function triggered when delete button is clicked
*/
onDeleteClick: () => void;
/**
* Function triggered when revoke button is clicked
*/
onRevokeClick: () => void;
/**
* Called when the action is finished, to close the menu
*/
onFinished: () => void;
/**
* Button used to move up or down in the list the widget position
* @param direction 1 or -1
*/
onMoveButton: (direction: number) => void;
}
export type WidgetContextMenuViewModel = ViewModel<WidgetContextMenuSnapshot> & WidgetContextMenuAction;
interface WidgetContextMenuViewProps {
vm: WidgetContextMenuViewModel;
}
/**
* A context menu component used to display the correct items that needs to be displayed for a widget item menu
*/
export const WidgetContextMenuView: React.FC<WidgetContextMenuViewProps> = ({ vm }) => {
const { translate: _t } = useI18n();
const {
showStreamAudioStreamButton,
showEditButton,
showSnapshotButton,
showDeleteButton,
showRevokeButton,
showMoveButtons,
isMenuOpened,
userWidget,
trigger,
} = useViewModel(vm);
let streamAudioStreamButton: JSX.Element | undefined;
if (showStreamAudioStreamButton) {
streamAudioStreamButton = (
<MenuItem onSelect={vm.onStreamAudioClick} label={_t("widget|context_menu|start_audio_stream")} />
);
}
let editButton: JSX.Element | undefined;
if (showEditButton) {
editButton = <MenuItem onSelect={vm.onEditClick} label={_t("action|edit")} />;
}
let snapshotButton: JSX.Element | undefined;
if (showSnapshotButton) {
snapshotButton = <MenuItem onSelect={vm.onSnapshotClick} label={_t("widget|context_menu|screenshot")} />;
}
let deleteButton: JSX.Element | undefined;
if (showDeleteButton) {
deleteButton = (
<MenuItem
onSelect={vm.onDeleteClick}
label={userWidget ? _t("action|remove") : _t("widget|context_menu|remove")}
/>
);
}
let revokeButton: JSX.Element | undefined;
if (showRevokeButton) {
revokeButton = <MenuItem onSelect={vm.onRevokeClick} label={_t("widget|context_menu|revoke")} />;
}
const [showMoveLeftButton, showMoveRightButton] = showMoveButtons;
let moveLeftButton: JSX.Element | undefined;
if (showMoveLeftButton) {
moveLeftButton = <MenuItem onSelect={() => vm.onMoveButton(-1)} label={_t("widget|context_menu|move_left")} />;
}
let moveRightButton: JSX.Element | undefined;
if (showMoveRightButton) {
moveRightButton = <MenuItem onSelect={() => vm.onMoveButton(1)} label={_t("widget|context_menu|move_right")} />;
}
// Only render menu items when the menu is open to prevent focusable elements in aria-hidden container
const renderMenuItems = (): React.ReactNode => {
if (!isMenuOpened) return null;
return (
<>
{streamAudioStreamButton}
{editButton}
{revokeButton}
{deleteButton}
{snapshotButton}
{moveLeftButton}
{moveRightButton}
</>
);
};
// Default trigger icon if no valid trigger element was passed
const wrappedTrigger = React.isValidElement(trigger) ? (
trigger
) : (
<IconButton size="24px" aria-label="context menu trigger button" inert={true} tabIndex={-1}>
<TriggerIcon />
</IconButton>
);
return (
<Menu
title="Widget context menu"
open={isMenuOpened}
showTitle={false}
side="right"
align="start"
trigger={wrappedTrigger}
onOpenChange={vm.onFinished}
>
{renderMenuItems()}
</Menu>
);
};

View File

@@ -0,0 +1,83 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<WidgetContextMenuView /> > renders widget contextmenu with all options 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-controls="radix-_r_1_"
aria-disabled="false"
aria-expanded="true"
aria-haspopup="menu"
aria-label="context menu trigger button"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="open"
id="radix-_r_0_"
inert=""
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="-1"
type="button"
>
<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="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
`;
exports[`<WidgetContextMenuView /> > renders widget contextmenu without only basic modification 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-controls="radix-_r_b_"
aria-disabled="false"
aria-expanded="true"
aria-haspopup="menu"
aria-label="context menu trigger button"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="open"
id="radix-_r_a_"
inert=""
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="-1"
type="button"
>
<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="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
`;

View File

@@ -0,0 +1,9 @@
/*
* Copyright 2025 New Vector 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 type { WidgetContextMenuSnapshot, WidgetContextMenuViewModel } from "./WidgetContextMenuView";
export { WidgetContextMenuView } from "./WidgetContextMenuView";