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
@@ -84,26 +84,6 @@ const showMoveButtons = (app: IWidget, room: Room | undefined, showUnpin: boolea
return [widgetIndex > 0, widgetIndex < pinnedWidgets.length - 1];
};
export const showContextMenu = (
cli: MatrixClient,
room: Room | undefined,
app: IWidget,
userWidget: boolean,
showUnpin: boolean,
onDeleteClick: (() => void) | undefined,
): boolean => {
const canModify = userWidget || WidgetUtils.canUserModifyWidgets(cli, room?.roomId);
const widgetMessaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(app));
return (
showStreamAudioStreamButton(app) ||
showEditButton(app, canModify) ||
showRevokeButton(cli, room?.roomId, app, userWidget) ||
showDeleteButton(canModify, onDeleteClick) ||
showSnapshotButton(widgetMessaging) ||
showMoveButtons(app, room, showUnpin).some(Boolean)
);
};
export const WidgetContextMenu: React.FC<IProps> = ({
onFinished,
app,
+25 -39
View File
@@ -30,6 +30,7 @@ import {
CollapseIcon,
PopOutIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { I18nContext } from "@element-hq/web-shared-components";
import AccessibleButton from "./AccessibleButton";
import { _t } from "../../../languageHandler";
@@ -39,11 +40,10 @@ import Spinner from "./Spinner";
import dis from "../../../dispatcher/dispatcher";
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
import SettingsStore from "../../../settings/SettingsStore";
import { aboveLeftOf, ContextMenuButton } from "../../structures/ContextMenu";
import { ContextMenuButton } from "../../structures/ContextMenu";
import PersistedElement, { getPersistKey } from "./PersistedElement";
import { WidgetType } from "../../../widgets/WidgetType";
import { ElementWidget, WidgetMessaging, WidgetMessagingEvent } from "../../../stores/widgets/WidgetMessaging";
import { showContextMenu, WidgetContextMenu } from "../context_menus/WidgetContextMenu";
import WidgetAvatar from "../avatars/WidgetAvatar";
import LegacyCallHandler from "../../../LegacyCallHandler";
import { type IApp, isAppWidget } from "../../../stores/WidgetStore";
@@ -61,6 +61,7 @@ import { ModuleRunner } from "../../../modules/ModuleRunner";
import { parseUrl } from "../../../utils/UrlUtils";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore.ts";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases.ts";
import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel.tsx";
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
// because that would allow the iframe to programmatically remove the sandbox attribute, but
@@ -132,7 +133,6 @@ interface IState {
error: Error | null;
menuDisplayed: boolean;
requiresClient: boolean;
hasContextMenuOptions: boolean;
}
export default class AppTile extends React.Component<IProps, IState> {
@@ -276,14 +276,6 @@ export default class AppTile extends React.Component<IProps, IState> {
error: null,
menuDisplayed: false,
requiresClient: this.determineInitialRequiresClientState(),
hasContextMenuOptions: showContextMenu(
this.context,
this.props.room,
newProps.app,
newProps.userWidget,
!newProps.userWidget,
newProps.onDeleteClick,
),
};
}
@@ -768,21 +760,6 @@ export default class AppTile extends React.Component<IProps, IState> {
}
appTileClasses = classNames(appTileClasses);
let contextMenu;
if (this.state.menuDisplayed) {
contextMenu = (
<WidgetContextMenu
{...aboveLeftOf(this.contextMenuButton.current.getBoundingClientRect())}
app={this.props.app}
onFinished={this.closeContextMenu}
showUnpin={!this.props.userWidget}
userWidget={this.props.userWidget}
onEditClick={this.props.onEditClick}
onDeleteClick={this.props.onDeleteClick}
/>
);
}
const layoutButtons: ReactNode[] = [];
if (this.props.showLayoutButtons) {
const isMaximised =
@@ -838,24 +815,33 @@ export default class AppTile extends React.Component<IProps, IState> {
<PopOutIcon className="mx_Icon mx_Icon_12" />
</AccessibleButton>
)}
{this.state.hasContextMenuOptions && (
<ContextMenuButton
className="mx_AppTileMenuBar_widgets_button"
label={_t("common|options")}
isExpanded={this.state.menuDisplayed}
ref={this.contextMenuButton}
onClick={this.onContextMenuClick}
>
<OverflowHorizontalIcon className="mx_Icon mx_Icon_12" />
</ContextMenuButton>
)}
<I18nContext.Provider value={window.mxModuleApi.i18n}>
<WidgetContextMenu
trigger={
<ContextMenuButton
className="mx_AppTileMenuBar_widgets_button"
label={_t("common|options")}
isExpanded={this.state.menuDisplayed}
ref={this.contextMenuButton}
onClick={this.onContextMenuClick}
>
<OverflowHorizontalIcon className="mx_Icon mx_Icon_12" />
</ContextMenuButton>
}
app={this.props.app}
onFinished={this.closeContextMenu}
showUnpin={!this.props.userWidget}
userWidget={this.props.userWidget}
onEditClick={this.props.onEditClick}
onDeleteClick={this.props.onDeleteClick}
menuDisplayed={this.state.menuDisplayed}
/>
</I18nContext.Provider>
</span>
</div>
)}
{appTileBody}
</div>
{contextMenu}
</React.Fragment>
);
}
@@ -20,9 +20,7 @@ import {
import BaseCard from "./BaseCard";
import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils";
import { _t } from "../../../languageHandler";
import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
import UIStore from "../../../stores/UIStore";
import { useContextMenu } from "../../structures/ContextMenu";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { type IApp } from "../../../stores/WidgetStore";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
@@ -33,6 +31,7 @@ import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
import EmptyState from "./EmptyState";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents.ts";
import { UIComponent } from "../../../settings/UIFeature.ts";
import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel.tsx";
interface Props {
room: Room;
@@ -69,21 +68,6 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
};
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
let contextMenu;
if (menuDisplayed) {
const rect = handle.current?.getBoundingClientRect();
const rightMargin = rect?.right ?? 0;
const topMargin = rect?.top ?? 0;
contextMenu = (
<WidgetContextMenu
chevronFace={ChevronFace.None}
right={UIStore.instance.windowWidth - rightMargin}
bottom={UIStore.instance.windowHeight - topMargin}
onFinished={closeMenu}
app={app}
/>
);
}
const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top);
@@ -108,7 +92,7 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
});
return (
<div className={classes} ref={handle}>
<div className={classes}>
<AccessibleButton
className="mx_ExtensionsCard_icon_app"
onClick={onOpenWidgetClick}
@@ -123,14 +107,21 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
</AccessibleButton>
{canModifyWidget && (
<ContextMenuTooltipButton
className="mx_ExtensionsCard_app_options"
isExpanded={menuDisplayed}
onClick={openMenu}
title={_t("common|options")}
>
<OverflowHorizontalIcon />
</ContextMenuTooltipButton>
<WidgetContextMenu
app={app}
onFinished={closeMenu}
menuDisplayed={menuDisplayed}
trigger={
<AccessibleButton
ref={handle}
className="mx_ExtensionsCard_app_options"
onClick={openMenu}
title={_t("common|options")}
>
<OverflowHorizontalIcon />
</AccessibleButton>
}
/>
)}
<AccessibleButton
@@ -141,8 +132,6 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
>
<PinSolidIcon />
</AccessibleButton>
{contextMenu}
</div>
);
};
+18 -28
View File
@@ -8,19 +8,17 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX, useContext, useEffect } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { OverflowHorizontalIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import BaseCard from "./BaseCard";
import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils";
import AppTile from "../elements/AppTile";
import { _t } from "../../../languageHandler";
import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
import { ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import UIStore from "../../../stores/UIStore";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import Heading from "../typography/Heading";
import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel";
interface IProps {
room: Room;
@@ -47,36 +45,28 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
// Don't render anything as we are about to transition
if (!app || !isRight) return null;
let contextMenu: JSX.Element | undefined;
if (menuDisplayed) {
const rect = handle.current?.getBoundingClientRect();
const rightMargin = rect ? rect.right : 0;
const bottomMargin = rect ? rect.bottom : 0;
contextMenu = (
<WidgetContextMenu
chevronFace={ChevronFace.None}
right={UIStore.instance.windowWidth - rightMargin - 12}
top={bottomMargin + 12}
onFinished={closeMenu}
app={app}
/>
);
}
const contextMenu: JSX.Element = (
<WidgetContextMenu
trigger={
<ContextMenuButton
className="mx_BaseCard_header_title_button--option"
ref={handle}
onClick={openMenu}
isExpanded={menuDisplayed}
label={_t("common|options")}
/>
}
onFinished={closeMenu}
app={app}
menuDisplayed={menuDisplayed}
/>
);
const header = (
<div className="mx_BaseCard_header_title">
<Heading size="4" className="mx_BaseCard_header_title_heading" as="h1">
{WidgetUtils.getWidgetName(app)}
</Heading>
<ContextMenuButton
className="mx_BaseCard_header_title_button--option"
ref={handle}
onClick={openMenu}
isExpanded={menuDisplayed}
label={_t("common|options")}
>
<OverflowHorizontalIcon />
</ContextMenuButton>
{contextMenu}
</div>
);