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 { .module-modal-host__overlay-container {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
width: var(--window-width); width: var(--window-width);
height: var(--window-height); height: var(--window-height);
left: var(--window-border); left: var(--window-border);
top: var(--titlebar-height); top: var(--titlebar-height);
justify-content: center; justify-content: center;
align-items: center;
overflow: hidden; overflow: hidden;
padding: 20px; padding: 20px;
position: fixed; position: fixed;
z-index: $z-index-modal-host; z-index: $z-index-modal-host;
} }
.module-modal-host__width-container {
max-width: 360px;
width: 95%;
}
.module-modal-host--on-top-of-everything { .module-modal-host--on-top-of-everything {
$loading-screen-modal-overlay: $z-index-on-top-of-everything + 1; $loading-screen-modal-overlay: $z-index-on-top-of-everything + 1;

View file

@ -4,9 +4,6 @@
.module-Modal { .module-Modal {
@include popper-shadow(); @include popper-shadow();
border-radius: 8px; 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 // 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. // buttons the bottom doesn't bleed through by 1px.
max-height: 89vh; max-height: 89vh;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -92,6 +92,7 @@ import type { UUIDStringType } from '../../types/UUID';
import { DAY, HOUR, MINUTE, SECOND } from '../../util/durations'; import { DAY, HOUR, MINUTE, SECOND } from '../../util/durations';
import { BadgeImageTheme } from '../../badges/BadgeImageTheme'; import { BadgeImageTheme } from '../../badges/BadgeImageTheme';
import { getBadgeImageFileLocalPath } from '../../badges/getBadgeImageFileLocalPath'; import { getBadgeImageFileLocalPath } from '../../badges/getBadgeImageFileLocalPath';
import { handleOutsideClick } from '../../util/handleOutsideClick';
type Trigger = { type Trigger = {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void; handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
@ -381,7 +382,9 @@ type State = {
prevSelectedCounter?: number; prevSelectedCounter?: number;
reactionViewerRoot: HTMLDivElement | null; reactionViewerRoot: HTMLDivElement | null;
reactionViewerOutsideClickDestructor?: () => void;
reactionPickerRoot: HTMLDivElement | null; reactionPickerRoot: HTMLDivElement | null;
reactionPickerOutsideClickDestructor?: () => void;
giftBadgeCounter: number | null; giftBadgeCounter: number | null;
showOutgoingGiftBadgeModal: boolean; showOutgoingGiftBadgeModal: boolean;
@ -2394,29 +2397,34 @@ export class Message extends React.PureComponent<Props, State> {
} }
public toggleReactionViewer = (onlyRemove = false): void => { public toggleReactionViewer = (onlyRemove = false): void => {
this.setState(({ reactionViewerRoot }) => { this.setState(oldState => {
const { reactionViewerRoot } = oldState;
if (reactionViewerRoot) { if (reactionViewerRoot) {
document.body.removeChild(reactionViewerRoot); document.body.removeChild(reactionViewerRoot);
document.body.removeEventListener(
'click',
this.handleClickOutsideReactionViewer,
true
);
return { reactionViewerRoot: null }; oldState.reactionViewerOutsideClickDestructor?.();
return {
reactionViewerRoot: null,
reactionViewerOutsideClickDestructor: undefined,
};
} }
if (!onlyRemove) { if (!onlyRemove) {
const root = document.createElement('div'); const root = document.createElement('div');
document.body.appendChild(root); document.body.appendChild(root);
document.body.addEventListener(
'click', const reactionViewerOutsideClickDestructor = handleOutsideClick(
this.handleClickOutsideReactionViewer, () => {
true this.toggleReactionViewer(true);
return true;
},
{ containerElements: [root, this.reactionsContainerRef] }
); );
return { return {
reactionViewerRoot: root, reactionViewerRoot: root,
reactionViewerOutsideClickDestructor,
}; };
} }
@ -2425,29 +2433,34 @@ export class Message extends React.PureComponent<Props, State> {
}; };
public toggleReactionPicker = (onlyRemove = false): void => { public toggleReactionPicker = (onlyRemove = false): void => {
this.setState(({ reactionPickerRoot }) => { this.setState(oldState => {
const { reactionPickerRoot } = oldState;
if (reactionPickerRoot) { if (reactionPickerRoot) {
document.body.removeChild(reactionPickerRoot); document.body.removeChild(reactionPickerRoot);
document.body.removeEventListener(
'click',
this.handleClickOutsideReactionPicker,
true
);
return { reactionPickerRoot: null }; oldState.reactionPickerOutsideClickDestructor?.();
return {
reactionPickerRoot: null,
reactionPickerOutsideClickDestructor: undefined,
};
} }
if (!onlyRemove) { if (!onlyRemove) {
const root = document.createElement('div'); const root = document.createElement('div');
document.body.appendChild(root); document.body.appendChild(root);
document.body.addEventListener(
'click', const reactionPickerOutsideClickDestructor = handleOutsideClick(
this.handleClickOutsideReactionPicker, () => {
true this.toggleReactionPicker(true);
return true;
},
{ containerElements: [root] }
); );
return { return {
reactionPickerRoot: root, 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 { public renderReactions(outgoing: boolean): JSX.Element | null {
const { getPreferredBadge, reactions = [], i18n, theme } = this.props; 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 { EmojiPicker } from './EmojiPicker';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import { useRefMerger } from '../../hooks/useRefMerger'; import { useRefMerger } from '../../hooks/useRefMerger';
import { handleOutsideClick } from '../../util/handleOutsideClick';
import * as KeyboardLayout from '../../services/keyboardLayout'; import * as KeyboardLayout from '../../services/keyboardLayout';
export type OwnProps = Readonly<{ export type OwnProps = Readonly<{
@ -88,21 +89,9 @@ export const EmojiButton = React.memo(
const root = document.createElement('div'); const root = document.createElement('div');
setPopperRoot(root); setPopperRoot(root);
document.body.appendChild(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 () => { return () => {
document.body.removeChild(root); document.body.removeChild(root);
document.removeEventListener('click', handleOutsideClick);
setPopperRoot(null); setPopperRoot(null);
}; };
} }
@ -110,6 +99,20 @@ export const EmojiButton = React.memo(
return noop; return noop;
}, [open, setOpen, setPopperRoot, handleClose]); }, [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 // Install keyboard shortcut to open emoji picker
React.useEffect(() => { React.useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => { const handleKeydown = (event: KeyboardEvent) => {

View file

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