mirror of
https://github.com/element-hq/element-web
synced 2024-10-23 11:15:47 +03:00
Close context menu when a modal is opened to prevent user getting stuck (#9560)
This commit is contained in:
parent
7fbdd8bb5d
commit
da779531f1
4 changed files with 81 additions and 3 deletions
|
@ -19,6 +19,7 @@ import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { defer, sleep } from "matrix-js-sdk/src/utils";
|
import { defer, sleep } from "matrix-js-sdk/src/utils";
|
||||||
|
import { TypedEventEmitter } from 'matrix-js-sdk/src/models/typed-event-emitter';
|
||||||
|
|
||||||
import dis from './dispatcher/dispatcher';
|
import dis from './dispatcher/dispatcher';
|
||||||
import AsyncWrapper from './AsyncWrapper';
|
import AsyncWrapper from './AsyncWrapper';
|
||||||
|
@ -54,7 +55,15 @@ interface IOptions<T extends any[]> {
|
||||||
|
|
||||||
type ParametersWithoutFirst<T extends (...args: any) => any> = T extends (a: any, ...args: infer P) => any ? P : never;
|
type ParametersWithoutFirst<T extends (...args: any) => any> = T extends (a: any, ...args: infer P) => any ? P : never;
|
||||||
|
|
||||||
export class ModalManager {
|
export enum ModalManagerEvent {
|
||||||
|
Opened = "opened",
|
||||||
|
}
|
||||||
|
|
||||||
|
type HandlerMap = {
|
||||||
|
[ModalManagerEvent.Opened]: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMap> {
|
||||||
private counter = 0;
|
private counter = 0;
|
||||||
// The modal to prioritise over all others. If this is set, only show
|
// The modal to prioritise over all others. If this is set, only show
|
||||||
// this modal. Remove all other modals from the stack when this modal
|
// this modal. Remove all other modals from the stack when this modal
|
||||||
|
@ -244,6 +253,7 @@ export class ModalManager {
|
||||||
isStaticModal = false,
|
isStaticModal = false,
|
||||||
options: IOptions<T> = {},
|
options: IOptions<T> = {},
|
||||||
): IHandle<T> {
|
): IHandle<T> {
|
||||||
|
const beforeModal = this.getCurrentModal();
|
||||||
const { modal, closeDialog, onFinishedProm } = this.buildModal<T>(prom, props, className, options);
|
const { modal, closeDialog, onFinishedProm } = this.buildModal<T>(prom, props, className, options);
|
||||||
if (isPriorityModal) {
|
if (isPriorityModal) {
|
||||||
// XXX: This is destructive
|
// XXX: This is destructive
|
||||||
|
@ -256,6 +266,8 @@ export class ModalManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.reRender();
|
this.reRender();
|
||||||
|
this.emitIfChanged(beforeModal);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
close: closeDialog,
|
close: closeDialog,
|
||||||
finished: onFinishedProm,
|
finished: onFinishedProm,
|
||||||
|
@ -267,16 +279,26 @@ export class ModalManager {
|
||||||
props?: IProps<T>,
|
props?: IProps<T>,
|
||||||
className?: string,
|
className?: string,
|
||||||
): IHandle<T> {
|
): IHandle<T> {
|
||||||
|
const beforeModal = this.getCurrentModal();
|
||||||
const { modal, closeDialog, onFinishedProm } = this.buildModal<T>(prom, props, className, {});
|
const { modal, closeDialog, onFinishedProm } = this.buildModal<T>(prom, props, className, {});
|
||||||
|
|
||||||
this.modals.push(modal);
|
this.modals.push(modal);
|
||||||
|
|
||||||
this.reRender();
|
this.reRender();
|
||||||
|
this.emitIfChanged(beforeModal);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
close: closeDialog,
|
close: closeDialog,
|
||||||
finished: onFinishedProm,
|
finished: onFinishedProm,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private emitIfChanged(beforeModal?: IModal<any>): void {
|
||||||
|
if (beforeModal !== this.getCurrentModal()) {
|
||||||
|
this.emit(ModalManagerEvent.Opened);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private onBackgroundClick = () => {
|
private onBackgroundClick = () => {
|
||||||
const modal = this.getCurrentModal();
|
const modal = this.getCurrentModal();
|
||||||
if (!modal) {
|
if (!modal) {
|
||||||
|
|
|
@ -26,6 +26,7 @@ import UIStore from "../../stores/UIStore";
|
||||||
import { checkInputableElement, RovingTabIndexProvider } from "../../accessibility/RovingTabIndex";
|
import { checkInputableElement, RovingTabIndexProvider } from "../../accessibility/RovingTabIndex";
|
||||||
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
|
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
|
||||||
import { getKeyBindingsManager } from "../../KeyBindingsManager";
|
import { getKeyBindingsManager } from "../../KeyBindingsManager";
|
||||||
|
import Modal, { ModalManagerEvent } from "../../Modal";
|
||||||
|
|
||||||
// Shamelessly ripped off Modal.js. There's probably a better way
|
// Shamelessly ripped off Modal.js. There's probably a better way
|
||||||
// of doing reusable widgets like dialog boxes & menus where we go and
|
// of doing reusable widgets like dialog boxes & menus where we go and
|
||||||
|
@ -127,11 +128,20 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
|
||||||
this.initialFocus = document.activeElement as HTMLElement;
|
this.initialFocus = document.activeElement as HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
public componentDidMount() {
|
||||||
|
Modal.on(ModalManagerEvent.Opened, this.onModalOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentWillUnmount() {
|
||||||
|
Modal.off(ModalManagerEvent.Opened, this.onModalOpen);
|
||||||
// return focus to the thing which had it before us
|
// return focus to the thing which had it before us
|
||||||
this.initialFocus.focus();
|
this.initialFocus.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onModalOpen = () => {
|
||||||
|
this.props.onFinished?.();
|
||||||
|
};
|
||||||
|
|
||||||
private collectContextMenuRect = (element: HTMLDivElement) => {
|
private collectContextMenuRect = (element: HTMLDivElement) => {
|
||||||
// We don't need to clean up when unmounting, so ignore
|
// We don't need to clean up when unmounting, so ignore
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
@ -183,7 +193,7 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
|
||||||
private onFinished = (ev: React.MouseEvent) => {
|
private onFinished = (ev: React.MouseEvent) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (this.props.onFinished) this.props.onFinished();
|
this.props.onFinished?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onClick = (ev: React.MouseEvent) => {
|
private onClick = (ev: React.MouseEvent) => {
|
||||||
|
|
|
@ -20,6 +20,8 @@ import { mount } from "enzyme";
|
||||||
|
|
||||||
import ContextMenu, { ChevronFace } from "../../../../src/components/structures/ContextMenu";
|
import ContextMenu, { ChevronFace } from "../../../../src/components/structures/ContextMenu";
|
||||||
import UIStore from "../../../../src/stores/UIStore";
|
import UIStore from "../../../../src/stores/UIStore";
|
||||||
|
import Modal from "../../../../src/Modal";
|
||||||
|
import BaseDialog from "../../../../src/components/views/dialogs/BaseDialog";
|
||||||
|
|
||||||
describe("<ContextMenu />", () => {
|
describe("<ContextMenu />", () => {
|
||||||
// Hardcode window and menu dimensions
|
// Hardcode window and menu dimensions
|
||||||
|
@ -141,4 +143,45 @@ describe("<ContextMenu />", () => {
|
||||||
expect(actualChevronOffset).toEqual(targetChevronOffset + targetX - actualX);
|
expect(actualChevronOffset).toEqual(targetChevronOffset + targetX - actualX);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should automatically close when a modal is opened", () => {
|
||||||
|
const targetX = -50;
|
||||||
|
const onFinished = jest.fn();
|
||||||
|
|
||||||
|
mount(
|
||||||
|
<ContextMenu
|
||||||
|
top={0}
|
||||||
|
right={windowSize - targetX - menuSize}
|
||||||
|
chevronFace={ChevronFace.Bottom}
|
||||||
|
onFinished={onFinished}
|
||||||
|
chevronOffset={targetChevronOffset}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onFinished).not.toHaveBeenCalled();
|
||||||
|
Modal.createDialog(BaseDialog);
|
||||||
|
expect(onFinished).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not automatically close when a modal is opened under the existing one", () => {
|
||||||
|
const targetX = -50;
|
||||||
|
const onFinished = jest.fn();
|
||||||
|
|
||||||
|
Modal.createDialog(BaseDialog);
|
||||||
|
mount(
|
||||||
|
<ContextMenu
|
||||||
|
top={0}
|
||||||
|
right={windowSize - targetX - menuSize}
|
||||||
|
chevronFace={ChevronFace.Bottom}
|
||||||
|
onFinished={onFinished}
|
||||||
|
chevronOffset={targetChevronOffset}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onFinished).not.toHaveBeenCalled();
|
||||||
|
Modal.createDialog(BaseDialog, {}, "", false, true);
|
||||||
|
expect(onFinished).not.toHaveBeenCalled();
|
||||||
|
Modal.appendDialog(BaseDialog);
|
||||||
|
expect(onFinished).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -69,6 +69,9 @@ jest.mock('../../../../src/stores/OwnProfileStore', () => ({
|
||||||
|
|
||||||
jest.mock('../../../../src/Modal', () => ({
|
jest.mock('../../../../src/Modal', () => ({
|
||||||
createDialog: jest.fn(),
|
createDialog: jest.fn(),
|
||||||
|
on: jest.fn(),
|
||||||
|
off: jest.fn(),
|
||||||
|
ModalManagerEvent: { Opened: "opened" },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('<LocationShareMenu />', () => {
|
describe('<LocationShareMenu />', () => {
|
||||||
|
|
Loading…
Reference in a new issue