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 {
|
.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;
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
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",
|
"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",
|
||||||
|
|
Loading…
Reference in a new issue