Properly handle closing click events in modals

This commit is contained in:
Fedor Indutny 2022-09-14 18:58:35 -07:00 committed by GitHub
parent b348bf9b70
commit 635840cd99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 257 additions and 178 deletions

View File

@ -7490,18 +7490,24 @@ button.module-image__border-overlay:focus {
.module-modal-host__overlay-container {
display: flex;
flex-direction: column;
flex-direction: row;
width: var(--window-width);
height: var(--window-height);
left: var(--window-border);
top: var(--titlebar-height);
justify-content: center;
align-items: center;
overflow: hidden;
padding: 20px;
position: fixed;
z-index: $z-index-modal-host;
}
.module-modal-host__width-container {
max-width: 360px;
width: 95%;
}
.module-modal-host--on-top-of-everything {
$loading-screen-modal-overlay: $z-index-on-top-of-everything + 1;

View File

@ -4,9 +4,6 @@
.module-Modal {
@include popper-shadow();
border-radius: 8px;
margin: 0 auto;
max-width: 360px;
width: 95%;
// We need this to be a number not divisible by 5 so that if we have sticky
// buttons the bottom doesn't bleed through by 1px.
max-height: 89vh;

View File

@ -13,6 +13,7 @@ import type { Theme } from '../util/theme';
import type { LocalizerType } from '../types/Util';
import { getClassNamesFor } from '../util/getClassNamesFor';
import { themeClassName } from '../util/theme';
import { handleOutsideClick } from '../util/handleOutsideClick';
export type ContextMenuOptionType<T> = {
readonly description?: string;
@ -104,20 +105,15 @@ export function ContextMenu<T>({
return noop;
}
const handleOutsideClick = (event: MouseEvent) => {
if (!referenceElement?.contains(event.target as Node)) {
return handleOutsideClick(
() => {
setIsMenuShowing(false);
closeCurrentOpenContextMenu = undefined;
event.stopPropagation();
event.preventDefault();
}
};
document.addEventListener('click', handleOutsideClick);
return () => {
document.removeEventListener('click', handleOutsideClick);
};
}, [isMenuShowing, referenceElement]);
return true;
},
{ containerElements: [referenceElement, popperElement] }
);
}, [isMenuShowing, referenceElement, popperElement]);
const handleKeyDown = (ev: KeyboardEvent) => {
if (!isMenuShowing) {

View File

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useState } from 'react';
import React, { useState, useEffect } from 'react';
import { usePopper } from 'react-popper';
import { isEqual, noop } from 'lodash';
@ -17,6 +17,7 @@ import { EmojiPicker } from './emoji/EmojiPicker';
import { DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES } from '../reactions/constants';
import { convertShortName } from './emoji/lib';
import { offsetDistanceModifier } from '../util/popperUtil';
import { handleOutsideClick } from '../util/handleOutsideClick';
type PropsType = {
draftPreferredReactions: Array<string>;
@ -77,22 +78,13 @@ export function CustomizingPreferredReactionsModal({
return noop;
}
const onBodyClick = (event: MouseEvent) => {
const { target } = event;
if (!(target instanceof HTMLElement) || !popperElement) {
return;
}
const isClickOutsidePicker = !popperElement.contains(target);
if (isClickOutsidePicker) {
return handleOutsideClick(
() => {
deselectDraftEmoji();
}
};
document.body.addEventListener('click', onBodyClick);
return () => {
document.body.removeEventListener('click', onBodyClick);
};
return true;
},
{ containerElements: [popperElement] }
);
}, [isSomethingSelected, popperElement, deselectDraftEmoji]);
const hasChanged = !isEqual(

View File

@ -11,6 +11,7 @@ import { AvatarPopup } from './AvatarPopup';
import type { LocalizerType, ThemeType } from '../types/Util';
import type { AvatarColorType } from '../types/Colors';
import type { BadgeType } from '../badges/types';
import { handleOutsideClick } from '../util/handleOutsideClick';
export type PropsType = {
areStoriesEnabled: boolean;
@ -38,6 +39,7 @@ export type PropsType = {
type StateType = {
showingAvatarPopup: boolean;
popperRoot: HTMLDivElement | null;
outsideClickDestructor?: () => void;
};
export class MainHeader extends React.Component<PropsType, StateType> {
@ -52,54 +54,62 @@ export class MainHeader extends React.Component<PropsType, StateType> {
};
}
public handleOutsideClick = ({ target }: MouseEvent): void => {
const { popperRoot, showingAvatarPopup } = this.state;
if (
showingAvatarPopup &&
popperRoot &&
!popperRoot.contains(target as Node) &&
!this.containerRef.current?.contains(target as Node)
) {
this.hideAvatarPopup();
}
};
public showAvatarPopup = (): void => {
const popperRoot = document.createElement('div');
document.body.appendChild(popperRoot);
const outsideClickDestructor = handleOutsideClick(
() => {
const { showingAvatarPopup } = this.state;
if (!showingAvatarPopup) {
return false;
}
this.hideAvatarPopup();
return true;
},
{ containerElements: [popperRoot, this.containerRef] }
);
this.setState({
showingAvatarPopup: true,
popperRoot,
outsideClickDestructor,
});
document.addEventListener('click', this.handleOutsideClick);
};
public hideAvatarPopup = (): void => {
const { popperRoot } = this.state;
document.removeEventListener('click', this.handleOutsideClick);
const { popperRoot, outsideClickDestructor } = this.state;
this.setState({
showingAvatarPopup: false,
popperRoot: null,
outsideClickDestructor: undefined,
});
outsideClickDestructor?.();
if (popperRoot && document.body.contains(popperRoot)) {
document.body.removeChild(popperRoot);
}
};
public override componentDidMount(): void {
document.addEventListener('keydown', this.handleGlobalKeyDown);
const useCapture = true;
document.addEventListener('keydown', this.handleGlobalKeyDown, useCapture);
}
public override componentWillUnmount(): void {
const { popperRoot } = this.state;
const { popperRoot, outsideClickDestructor } = this.state;
document.removeEventListener('click', this.handleOutsideClick);
document.removeEventListener('keydown', this.handleGlobalKeyDown);
const useCapture = true;
outsideClickDestructor?.();
document.removeEventListener(
'keydown',
this.handleGlobalKeyDown,
useCapture
);
if (popperRoot && document.body.contains(popperRoot)) {
document.body.removeChild(popperRoot);

View File

@ -9,6 +9,7 @@ import classNames from 'classnames';
import { Manager, Popper, Reference } from 'react-popper';
import type { LocalizerType } from '../types/Util';
import { useRefMerger } from '../hooks/useRefMerger';
import { handleOutsideClick } from '../util/handleOutsideClick';
export type PropsType = {
i18n: LocalizerType;
@ -66,21 +67,9 @@ export const MediaQualitySelector = ({
const root = document.createElement('div');
setPopperRoot(root);
document.body.appendChild(root);
const handleOutsideClick = (event: MouseEvent) => {
if (
!root.contains(event.target as Node) &&
event.target !== buttonRef.current
) {
handleClose();
event.stopPropagation();
event.preventDefault();
}
};
document.addEventListener('click', handleOutsideClick);
return () => {
document.body.removeChild(root);
document.removeEventListener('click', handleOutsideClick);
setPopperRoot(null);
};
}
@ -88,6 +77,20 @@ export const MediaQualitySelector = ({
return noop;
}, [menuShowing, setPopperRoot, handleClose]);
useEffect(() => {
if (!menuShowing) {
return noop;
}
return handleOutsideClick(
() => {
handleClose();
return true;
},
{ containerElements: [popperRoot, buttonRef] }
);
}, [menuShowing, popperRoot, handleClose]);
return (
<Manager>
<Reference>

View File

@ -7,12 +7,14 @@ import FocusTrap from 'focus-trap-react';
import type { SpringValues } from '@react-spring/web';
import { animated } from '@react-spring/web';
import classNames from 'classnames';
import { noop } from 'lodash';
import type { ModalConfigType } from '../hooks/useAnimated';
import type { Theme } from '../util/theme';
import { getClassNamesFor } from '../util/getClassNamesFor';
import { themeClassName } from '../util/theme';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
import { handleOutsideClick } from '../util/handleOutsideClick';
export type PropsType = Readonly<{
children: React.ReactElement;
@ -39,7 +41,7 @@ export const ModalHost = React.memo(
useFocusTrap = true,
}: PropsType) => {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
const [isMouseDown, setIsMouseDown] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement | null>(null);
useEffect(() => {
const div = document.createElement('div');
@ -53,27 +55,18 @@ export const ModalHost = React.memo(
}, []);
useEscapeHandling(onEscape || onClose);
// This makes it easier to write dialogs to be hosted here; they won't have to worry
// as much about preventing propagation of mouse events.
const handleMouseDown = React.useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
setIsMouseDown(true);
}
},
[setIsMouseDown]
);
const handleMouseUp = React.useCallback(
(e: React.MouseEvent) => {
setIsMouseDown(false);
if (e.target === e.currentTarget && isMouseDown) {
useEffect(() => {
if (noMouseClose) {
return noop;
}
return handleOutsideClick(
() => {
onClose();
}
},
[onClose, isMouseDown, setIsMouseDown]
);
return true;
},
{ containerElements: [containerRef] }
);
}, [noMouseClose, onClose]);
const className = classNames([
theme ? themeClassName(theme) : undefined,
@ -88,12 +81,10 @@ export const ModalHost = React.memo(
className={getClassName('__overlay')}
style={overlayStyles}
/>
<div
className={getClassName('__overlay-container')}
onMouseDown={noMouseClose ? undefined : handleMouseDown}
onMouseUp={noMouseClose ? undefined : handleMouseUp}
>
{children}
<div className={getClassName('__overlay-container')}>
<div ref={containerRef} className={getClassName('__width-container')}>
{children}
</div>
</div>
</div>
);

View File

@ -4,7 +4,7 @@
import FocusTrap from 'focus-trap-react';
import React, { useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { get, has } from 'lodash';
import { get, has, noop } from 'lodash';
import { usePopper } from 'react-popper';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
@ -26,6 +26,7 @@ import {
getBackgroundColor,
} from '../util/getStoryBackground';
import { objectMap } from '../util/objectMap';
import { handleOutsideClick } from '../util/handleOutsideClick';
export type PropsType = {
debouncedMaybeGrabLinkPreview: (
@ -232,13 +233,6 @@ export const TextStoryCreator = ({
);
useEffect(() => {
const handleOutsideClick = (event: MouseEvent) => {
if (!colorPickerPopperButtonRef?.contains(event.target as Node)) {
setIsColorPickerShowing(false);
event.stopPropagation();
event.preventDefault();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (
@ -257,12 +251,11 @@ export const TextStoryCreator = ({
}
};
document.addEventListener('click', handleOutsideClick);
document.addEventListener('keydown', handleEscape);
const useCapture = true;
document.addEventListener('keydown', handleEscape, useCapture);
return () => {
document.removeEventListener('click', handleOutsideClick);
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('keydown', handleEscape, useCapture);
};
}, [
isColorPickerShowing,
@ -272,6 +265,19 @@ export const TextStoryCreator = ({
onClose,
]);
useEffect(() => {
if (!isColorPickerShowing) {
return noop;
}
return handleOutsideClick(
() => {
setIsColorPickerShowing(false);
return true;
},
{ containerElements: [colorPickerPopperButtonRef] }
);
}, [isColorPickerShowing, colorPickerPopperButtonRef]);
const sliderColorNumber = getRGBANumber(sliderValue);
let textForegroundColor = sliderColorNumber;

View File

@ -92,6 +92,7 @@ import type { UUIDStringType } from '../../types/UUID';
import { DAY, HOUR, MINUTE, SECOND } from '../../util/durations';
import { BadgeImageTheme } from '../../badges/BadgeImageTheme';
import { getBadgeImageFileLocalPath } from '../../badges/getBadgeImageFileLocalPath';
import { handleOutsideClick } from '../../util/handleOutsideClick';
type Trigger = {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
@ -381,7 +382,9 @@ type State = {
prevSelectedCounter?: number;
reactionViewerRoot: HTMLDivElement | null;
reactionViewerOutsideClickDestructor?: () => void;
reactionPickerRoot: HTMLDivElement | null;
reactionPickerOutsideClickDestructor?: () => void;
giftBadgeCounter: number | null;
showOutgoingGiftBadgeModal: boolean;
@ -2394,29 +2397,34 @@ export class Message extends React.PureComponent<Props, State> {
}
public toggleReactionViewer = (onlyRemove = false): void => {
this.setState(({ reactionViewerRoot }) => {
this.setState(oldState => {
const { reactionViewerRoot } = oldState;
if (reactionViewerRoot) {
document.body.removeChild(reactionViewerRoot);
document.body.removeEventListener(
'click',
this.handleClickOutsideReactionViewer,
true
);
return { reactionViewerRoot: null };
oldState.reactionViewerOutsideClickDestructor?.();
return {
reactionViewerRoot: null,
reactionViewerOutsideClickDestructor: undefined,
};
}
if (!onlyRemove) {
const root = document.createElement('div');
document.body.appendChild(root);
document.body.addEventListener(
'click',
this.handleClickOutsideReactionViewer,
true
const reactionViewerOutsideClickDestructor = handleOutsideClick(
() => {
this.toggleReactionViewer(true);
return true;
},
{ containerElements: [root, this.reactionsContainerRef] }
);
return {
reactionViewerRoot: root,
reactionViewerOutsideClickDestructor,
};
}
@ -2425,29 +2433,34 @@ export class Message extends React.PureComponent<Props, State> {
};
public toggleReactionPicker = (onlyRemove = false): void => {
this.setState(({ reactionPickerRoot }) => {
this.setState(oldState => {
const { reactionPickerRoot } = oldState;
if (reactionPickerRoot) {
document.body.removeChild(reactionPickerRoot);
document.body.removeEventListener(
'click',
this.handleClickOutsideReactionPicker,
true
);
return { reactionPickerRoot: null };
oldState.reactionPickerOutsideClickDestructor?.();
return {
reactionPickerRoot: null,
reactionPickerOutsideClickDestructor: undefined,
};
}
if (!onlyRemove) {
const root = document.createElement('div');
document.body.appendChild(root);
document.body.addEventListener(
'click',
this.handleClickOutsideReactionPicker,
true
const reactionPickerOutsideClickDestructor = handleOutsideClick(
() => {
this.toggleReactionPicker(true);
return true;
},
{ containerElements: [root] }
);
return {
reactionPickerRoot: root,
reactionPickerOutsideClickDestructor,
};
}
@ -2455,28 +2468,6 @@ export class Message extends React.PureComponent<Props, State> {
});
};
public handleClickOutsideReactionViewer = (e: MouseEvent): void => {
const { reactionViewerRoot } = this.state;
const { current: reactionsContainer } = this.reactionsContainerRef;
if (reactionViewerRoot && reactionsContainer) {
if (
!reactionViewerRoot.contains(e.target as HTMLElement) &&
!reactionsContainer.contains(e.target as HTMLElement)
) {
this.toggleReactionViewer(true);
}
}
};
public handleClickOutsideReactionPicker = (e: MouseEvent): void => {
const { reactionPickerRoot } = this.state;
if (reactionPickerRoot) {
if (!reactionPickerRoot.contains(e.target as HTMLElement)) {
this.toggleReactionPicker(true);
}
}
};
public renderReactions(outgoing: boolean): JSX.Element | null {
const { getPreferredBadge, reactions = [], i18n, theme } = this.props;

View File

@ -12,6 +12,7 @@ import type { Props as EmojiPickerProps } from './EmojiPicker';
import { EmojiPicker } from './EmojiPicker';
import type { LocalizerType } from '../../types/Util';
import { useRefMerger } from '../../hooks/useRefMerger';
import { handleOutsideClick } from '../../util/handleOutsideClick';
import * as KeyboardLayout from '../../services/keyboardLayout';
export type OwnProps = Readonly<{
@ -88,21 +89,9 @@ export const EmojiButton = React.memo(
const root = document.createElement('div');
setPopperRoot(root);
document.body.appendChild(root);
const handleOutsideClick = (event: MouseEvent) => {
if (
!root.contains(event.target as Node) &&
event.target !== buttonRef.current
) {
handleClose();
event.stopPropagation();
event.preventDefault();
}
};
document.addEventListener('click', handleOutsideClick);
return () => {
document.body.removeChild(root);
document.removeEventListener('click', handleOutsideClick);
setPopperRoot(null);
};
}
@ -110,6 +99,20 @@ export const EmojiButton = React.memo(
return noop;
}, [open, setOpen, setPopperRoot, handleClose]);
React.useEffect(() => {
if (!open) {
return noop;
}
return handleOutsideClick(
() => {
handleClose();
return true;
},
{ containerElements: [popperRoot, buttonRef] }
);
}, [open, handleClose, popperRoot]);
// Install keyboard shortcut to open emoji picker
React.useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {

View File

@ -14,6 +14,7 @@ import { StickerPicker } from './StickerPicker';
import { countStickers } from './lib';
import { offsetDistanceModifier } from '../../util/popperUtil';
import { themeClassName } from '../../util/theme';
import { handleOutsideClick } from '../../util/handleOutsideClick';
import * as KeyboardLayout from '../../services/keyboardLayout';
import { useRefMerger } from '../../hooks/useRefMerger';
@ -136,7 +137,23 @@ export const StickerButton = React.memo(
const root = document.createElement('div');
setPopperRoot(root);
document.body.appendChild(root);
const handleOutsideClick = ({ target }: MouseEvent) => {
return () => {
document.body.removeChild(root);
setPopperRoot(null);
};
}
return noop;
}, [open, setOpen, setPopperRoot]);
React.useEffect(() => {
if (!open) {
return noop;
}
return handleOutsideClick(
target => {
const targetElement = target as HTMLElement;
const targetClassName = targetElement
? targetElement.className || ''
@ -149,25 +166,16 @@ export const StickerButton = React.memo(
targetClassName.indexOf('module-sticker-picker__header__button') <
0;
if (
!root.contains(targetElement) &&
isMissingButtonClass &&
targetElement !== buttonRef.current
) {
setOpen(false);
if (!isMissingButtonClass) {
return false;
}
};
document.addEventListener('click', handleOutsideClick);
return () => {
document.body.removeChild(root);
document.removeEventListener('click', handleOutsideClick);
setPopperRoot(null);
};
}
return noop;
}, [open, setOpen, setPopperRoot]);
setOpen(false);
return true;
},
{ containerElements: [popperRoot, buttonRef] }
);
}, [open, popperRoot, setOpen]);
// Install keyboard shortcut to open sticker picker
React.useEffect(() => {

View File

@ -0,0 +1,68 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { RefObject } from 'react';
export type ClickHandlerType = (target: Node) => boolean;
export type ContainerElementType = Node | RefObject<Node> | null | undefined;
// TODO(indutny): DESKTOP-4177
// A stack of handlers. Handlers are executed from the top to the bottom
const fakeClickHandlers = new Array<(event: MouseEvent) => boolean>();
function runFakeClickHandlers(event: MouseEvent): void {
for (const handler of fakeClickHandlers.slice().reverse()) {
if (handler(event)) {
break;
}
}
}
export type HandleOutsideClickOptionsType = Readonly<{
containerElements: ReadonlyArray<ContainerElementType>;
}>;
export const handleOutsideClick = (
handler: ClickHandlerType,
{ containerElements }: HandleOutsideClickOptionsType
): (() => void) => {
const handleEvent = (event: MouseEvent) => {
const target = event.target as Node;
const isInside = containerElements.some(elem => {
if (!elem) {
return false;
}
if (elem instanceof Node) {
return elem.contains(target);
}
return elem.current?.contains(target);
});
if (isInside) {
return false;
}
const isHandled = handler(target);
if (!isHandled) {
return false;
}
return true;
};
fakeClickHandlers.push(handleEvent);
if (fakeClickHandlers.length === 1) {
const useCapture = true;
document.addEventListener('click', runFakeClickHandlers, useCapture);
}
return () => {
const index = fakeClickHandlers.indexOf(handleEvent);
fakeClickHandlers.splice(index, 1);
if (fakeClickHandlers.length === 0) {
const useCapture = true;
document.removeEventListener('click', handleEvent, useCapture);
}
};
};

View File

@ -9181,6 +9181,14 @@
"reasonCategory": "usageTrusted",
"updated": "2021-09-21T01:40:08.534Z"
},
{
"rule": "React-useRef",
"path": "ts/components/ModalHost.tsx",
"line": " const containerRef = React.useRef<HTMLDivElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2022-09-14T16:20:15.384Z",
"reasonDetail": "Holds a reference to a container element to prevent outside clicks"
},
{
"rule": "React-useRef",
"path": "ts/components/ProfileEditor.tsx",