Properly handle closing click events in modals
This commit is contained in:
parent
b348bf9b70
commit
635840cd99
13 changed files with 257 additions and 178 deletions
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
68
ts/util/handleOutsideClick.ts
Normal file
68
ts/util/handleOutsideClick.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
};
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue