Animates ModalHost overlay

This commit is contained in:
Josh Perez 2021-10-14 12:52:42 -04:00 committed by GitHub
parent cfc5407d03
commit d0e8fbd5a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 340 additions and 292 deletions

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { MouseEvent, useCallback } from 'react';
import { animated } from '@react-spring/web';
import { Button, ButtonVariant } from './Button';
import { LocalizerType } from '../types/Util';
import { ModalHost } from './ModalHost';
@ -63,17 +64,10 @@ export const ConfirmationDialog = React.memo(
title,
hasXButton,
}: Props) => {
const { close, renderAnimation } = useAnimated(
{
from: { opacity: 0, transform: 'scale(0.25)' },
enter: { opacity: 1, transform: 'scale(1)' },
leave: { opacity: 0, onRest: () => onClose() },
config: {
duration: 150,
},
},
onClose
);
const { close, overlayStyles, modalStyles } = useAnimated(onClose, {
getFrom: () => ({ opacity: 0, transform: 'scale(0.25)' }),
getTo: isOpen => ({ opacity: isOpen ? 1 : 0, transform: 'scale(1)' }),
});
const cancelAndClose = useCallback(() => {
if (onCancel) {
@ -94,8 +88,8 @@ export const ConfirmationDialog = React.memo(
const hasActions = Boolean(actions.length);
return (
<ModalHost onClose={close} theme={theme}>
{renderAnimation(
<ModalHost onClose={close} theme={theme} overlayStyles={overlayStyles}>
<animated.div style={modalStyles}>
<ModalWindow
hasXButton={hasXButton}
i18n={i18n}
@ -129,7 +123,7 @@ export const ConfirmationDialog = React.memo(
))}
</Modal.ButtonFooter>
</ModalWindow>
)}
</animated.div>
</ModalHost>
);
}

View file

@ -11,6 +11,7 @@ import React, {
} from 'react';
import Measure, { MeasuredComponentProps } from 'react-measure';
import { noop } from 'lodash';
import { animated } from '@react-spring/web';
import classNames from 'classnames';
import { AttachmentList } from './conversation/AttachmentList';
@ -199,20 +200,16 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
[contactLookup, selectedContacts, setSelectedContacts]
);
const { close, renderAnimation } = useAnimated(
{
from: { opacity: 0, transform: 'translateY(48px)' },
enter: { opacity: 1, transform: 'translateY(0px)' },
leave: {
opacity: 0,
transform: 'translateY(48px)',
},
config: {
duration: 200,
},
},
onClose
);
const { close, modalStyles, overlayStyles } = useAnimated(onClose, {
getFrom: () => ({ opacity: 0, transform: 'translateY(48px)' }),
getTo: isOpen =>
isOpen
? { opacity: 1, transform: 'translateY(0px)' }
: {
opacity: 0,
transform: 'translateY(48px)',
},
});
const handleBackOrClose = useCallback(() => {
if (isEditingMessage) {
@ -265,188 +262,189 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
{i18n('GroupV2--cannot-send')}
</ConfirmationDialog>
)}
<ModalHost onEscape={handleBackOrClose} onClose={close}>
{renderAnimation(
<div className="module-ForwardMessageModal">
<div
className={classNames('module-ForwardMessageModal__header', {
'module-ForwardMessageModal__header--edit': isEditingMessage,
})}
>
{isEditingMessage ? (
<button
aria-label={i18n('back')}
className="module-ForwardMessageModal__header--back"
onClick={() => setIsEditingMessage(false)}
type="button"
>
&nbsp;
</button>
) : (
<button
aria-label={i18n('close')}
className="module-ForwardMessageModal__header--close"
onClick={close}
type="button"
/>
)}
<h1>{i18n('forwardMessage')}</h1>
</div>
<ModalHost
onEscape={handleBackOrClose}
onClose={close}
overlayStyles={overlayStyles}
>
<animated.div
className="module-ForwardMessageModal"
style={modalStyles}
>
<div
className={classNames('module-ForwardMessageModal__header', {
'module-ForwardMessageModal__header--edit': isEditingMessage,
})}
>
{isEditingMessage ? (
<div className="module-ForwardMessageModal__main-body">
{linkPreview ? (
<div className="module-ForwardMessageModal--link-preview">
<StagedLinkPreview
date={linkPreview.date || null}
description={linkPreview.description || ''}
domain={linkPreview.url}
i18n={i18n}
image={linkPreview.image}
onClose={() => removeLinkPreview()}
title={linkPreview.title}
/>
</div>
) : null}
{attachmentsToForward && attachmentsToForward.length ? (
<AttachmentList
attachments={attachmentsToForward}
<button
aria-label={i18n('back')}
className="module-ForwardMessageModal__header--back"
onClick={() => setIsEditingMessage(false)}
type="button"
>
&nbsp;
</button>
) : (
<button
aria-label={i18n('close')}
className="module-ForwardMessageModal__header--close"
onClick={close}
type="button"
/>
)}
<h1>{i18n('forwardMessage')}</h1>
</div>
{isEditingMessage ? (
<div className="module-ForwardMessageModal__main-body">
{linkPreview ? (
<div className="module-ForwardMessageModal--link-preview">
<StagedLinkPreview
date={linkPreview.date || null}
description={linkPreview.description || ''}
domain={linkPreview.url}
i18n={i18n}
onCloseAttachment={(attachment: AttachmentType) => {
const newAttachments = attachmentsToForward.filter(
currentAttachment => currentAttachment !== attachment
);
setAttachmentsToForward(newAttachments);
}}
image={linkPreview.image}
onClose={() => removeLinkPreview()}
title={linkPreview.title}
/>
) : null}
<div className="module-ForwardMessageModal__text-edit-area">
<CompositionInput
clearQuotedMessage={shouldNeverBeCalled}
draftText={messageBodyText}
getQuotedMessage={noop}
</div>
) : null}
{attachmentsToForward && attachmentsToForward.length ? (
<AttachmentList
attachments={attachmentsToForward}
i18n={i18n}
onCloseAttachment={(attachment: AttachmentType) => {
const newAttachments = attachmentsToForward.filter(
currentAttachment => currentAttachment !== attachment
);
setAttachmentsToForward(newAttachments);
}}
/>
) : null}
<div className="module-ForwardMessageModal__text-edit-area">
<CompositionInput
clearQuotedMessage={shouldNeverBeCalled}
draftText={messageBodyText}
getQuotedMessage={noop}
i18n={i18n}
inputApi={inputApiRef}
large
moduleClassName="module-ForwardMessageModal__input"
onEditorStateChange={(
messageText,
bodyRanges,
caretLocation
) => {
setMessageBodyText(messageText);
onEditorStateChange(messageText, bodyRanges, caretLocation);
}}
onPickEmoji={onPickEmoji}
onSubmit={forwardMessage}
onTextTooLong={onTextTooLong}
/>
<div className="module-ForwardMessageModal__emoji">
<EmojiButton
i18n={i18n}
inputApi={inputApiRef}
large
moduleClassName="module-ForwardMessageModal__input"
onEditorStateChange={(
messageText,
bodyRanges,
caretLocation
) => {
setMessageBodyText(messageText);
onEditorStateChange(
messageText,
bodyRanges,
caretLocation
);
}}
onPickEmoji={onPickEmoji}
onSubmit={forwardMessage}
onTextTooLong={onTextTooLong}
onClose={focusTextEditInput}
onPickEmoji={insertEmoji}
onSetSkinTone={onSetSkinTone}
recentEmojis={recentEmojis}
skinTone={skinTone}
/>
<div className="module-ForwardMessageModal__emoji">
<EmojiButton
i18n={i18n}
onClose={focusTextEditInput}
onPickEmoji={insertEmoji}
onSetSkinTone={onSetSkinTone}
recentEmojis={recentEmojis}
skinTone={skinTone}
/>
</div>
</div>
</div>
) : (
<div className="module-ForwardMessageModal__main-body">
<SearchInput
disabled={candidateConversations.length === 0}
placeholder={i18n('contactSearchPlaceholder')}
onChange={event => {
setSearchTerm(event.target.value);
}}
ref={inputRef}
value={searchTerm}
/>
{candidateConversations.length ? (
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => {
// We disable this ESLint rule because we're capturing a bubbled
// keydown event. See [this note in the jsx-a11y docs][0].
//
// [0]: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/c275964f52c35775208bd00cb612c6f82e42e34f/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
/* eslint-disable jsx-a11y/no-static-element-interactions */
return (
<div
className="module-ForwardMessageModal__list-wrapper"
ref={measureRef}
>
<ConversationList
dimensions={contentRect.bounds}
getRow={getRow}
i18n={i18n}
onClickArchiveButton={shouldNeverBeCalled}
onClickContactCheckbox={(
conversationId: string,
disabledReason:
| undefined
| ContactCheckboxDisabledReason
) => {
if (
disabledReason !==
ContactCheckboxDisabledReason.MaximumContactsSelected
) {
toggleSelectedConversation(conversationId);
}
}}
onSelectConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => {
shouldNeverBeCalled();
return <div />;
}}
rowCount={rowCount}
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
startNewConversationFromPhoneNumber={
shouldNeverBeCalled
</div>
) : (
<div className="module-ForwardMessageModal__main-body">
<SearchInput
disabled={candidateConversations.length === 0}
placeholder={i18n('contactSearchPlaceholder')}
onChange={event => {
setSearchTerm(event.target.value);
}}
ref={inputRef}
value={searchTerm}
/>
{candidateConversations.length ? (
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => {
// We disable this ESLint rule because we're capturing a bubbled
// keydown event. See [this note in the jsx-a11y docs][0].
//
// [0]: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/c275964f52c35775208bd00cb612c6f82e42e34f/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
/* eslint-disable jsx-a11y/no-static-element-interactions */
return (
<div
className="module-ForwardMessageModal__list-wrapper"
ref={measureRef}
>
<ConversationList
dimensions={contentRect.bounds}
getRow={getRow}
i18n={i18n}
onClickArchiveButton={shouldNeverBeCalled}
onClickContactCheckbox={(
conversationId: string,
disabledReason:
| undefined
| ContactCheckboxDisabledReason
) => {
if (
disabledReason !==
ContactCheckboxDisabledReason.MaximumContactsSelected
) {
toggleSelectedConversation(conversationId);
}
/>
</div>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
}}
</Measure>
) : (
<div className="module-ForwardMessageModal__no-candidate-contacts">
{i18n('noContactsFound')}
</div>
)}
</div>
)}
<div className="module-ForwardMessageModal__footer">
<div>
{Boolean(selectedContacts.length) &&
selectedContacts.map(contact => contact.title).join(', ')}
</div>
<div>
{isEditingMessage || !isMessageEditable ? (
<Button
aria-label={i18n('ForwardMessageModal--continue')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward"
disabled={!canForwardMessage}
onClick={forwardMessage}
/>
) : (
<Button
aria-label={i18n('forwardMessage')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--continue"
disabled={!hasContactsSelected}
onClick={() => setIsEditingMessage(true)}
/>
)}
</div>
}}
onSelectConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => {
shouldNeverBeCalled();
return <div />;
}}
rowCount={rowCount}
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
startNewConversationFromPhoneNumber={
shouldNeverBeCalled
}
/>
</div>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
}}
</Measure>
) : (
<div className="module-ForwardMessageModal__no-candidate-contacts">
{i18n('noContactsFound')}
</div>
)}
</div>
)}
<div className="module-ForwardMessageModal__footer">
<div>
{Boolean(selectedContacts.length) &&
selectedContacts.map(contact => contact.title).join(', ')}
</div>
<div>
{isEditingMessage || !isMessageEditable ? (
<Button
aria-label={i18n('ForwardMessageModal--continue')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward"
disabled={!canForwardMessage}
onClick={forwardMessage}
/>
) : (
<Button
aria-label={i18n('forwardMessage')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--continue"
disabled={!hasContactsSelected}
onClick={() => setIsEditingMessage(true)}
/>
)}
</div>
</div>
)}
</animated.div>
</ModalHost>
</>
);

View file

@ -47,6 +47,12 @@ const INITIAL_IMAGE_TRANSFORM = {
scale: 1,
translateX: 0,
translateY: 0,
config: {
clamp: true,
friction: 20,
mass: 0.5,
tension: 350,
},
};
export function Lightbox({

View file

@ -5,6 +5,7 @@ import React, { ReactElement, ReactNode, useRef, useState } from 'react';
import Measure, { ContentRect, MeasuredComponentProps } from 'react-measure';
import classNames from 'classnames';
import { noop } from 'lodash';
import { animated } from '@react-spring/web';
import { LocalizerType } from '../types/Util';
import { ModalHost } from './ModalHost';
@ -42,24 +43,22 @@ export function Modal({
title,
theme,
}: Readonly<ModalPropsType>): ReactElement {
const { close, renderAnimation } = useAnimated(
{
from: { opacity: 0, transform: 'translateY(48px)' },
enter: { opacity: 1, transform: 'translateY(0px)' },
leave: {
opacity: 0,
transform: 'translateY(48px)',
},
config: {
duration: 200,
},
},
onClose
);
const { close, modalStyles, overlayStyles } = useAnimated(onClose, {
getFrom: () => ({ opacity: 0, transform: 'translateY(48px)' }),
getTo: isOpen =>
isOpen
? { opacity: 1, transform: 'translateY(0px)' }
: { opacity: 0, transform: 'translateY(48px)' },
});
return (
<ModalHost noMouseClose={noMouseClose} onClose={close} theme={theme}>
{renderAnimation(
<ModalHost
noMouseClose={noMouseClose}
onClose={close}
overlayStyles={overlayStyles}
theme={theme}
>
<animated.div style={modalStyles}>
<ModalWindow
hasStickyButtons={hasStickyButtons}
hasXButton={hasXButton}
@ -70,7 +69,7 @@ export function Modal({
>
{children}
</ModalWindow>
)}
</animated.div>
</ModalHost>
);
}

View file

@ -5,20 +5,30 @@ import React, { useEffect } from 'react';
import classNames from 'classnames';
import { createPortal } from 'react-dom';
import FocusTrap from 'focus-trap-react';
import { SpringValues, animated } from '@react-spring/web';
import type { ModalConfigType } from '../hooks/useAnimated';
import { Theme, themeClassName } from '../util/theme';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
export type PropsType = {
readonly noMouseClose?: boolean;
readonly onEscape?: () => unknown;
readonly onClose: () => unknown;
readonly children: React.ReactElement;
readonly noMouseClose?: boolean;
readonly onClose: () => unknown;
readonly onEscape?: () => unknown;
readonly overlayStyles?: SpringValues<ModalConfigType>;
readonly theme?: Theme;
};
export const ModalHost = React.memo(
({ onEscape, onClose, children, noMouseClose, theme }: PropsType) => {
({
children,
noMouseClose,
onClose,
onEscape,
theme,
overlayStyles,
}: PropsType) => {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
const [isMouseDown, setIsMouseDown] = React.useState(false);
@ -64,16 +74,18 @@ export const ModalHost = React.memo(
allowOutsideClick: false,
}}
>
<div
role="presentation"
className={classNames(
'module-modal-host__overlay',
theme ? themeClassName(theme) : undefined
)}
onMouseDown={noMouseClose ? undefined : handleMouseDown}
onMouseUp={noMouseClose ? undefined : handleMouseUp}
>
{children}
<div>
<animated.div
role="presentation"
className={classNames(
'module-modal-host__overlay',
theme ? themeClassName(theme) : undefined
)}
onMouseDown={noMouseClose ? undefined : handleMouseDown}
onMouseUp={noMouseClose ? undefined : handleMouseUp}
style={overlayStyles}
/>
<div className="module-modal-host__container">{children}</div>
</div>
</FocusTrap>,
root

View file

@ -1,37 +1,72 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState, ReactElement } from 'react';
import { animated, useTransition, UseTransitionProps } from '@react-spring/web';
import cubicBezier from 'bezier-easing';
import { useState } from 'react';
import {
SpringValues,
useChain,
useSpring,
useSpringRef,
} from '@react-spring/web';
export function useAnimated<Props extends Record<string, unknown>>(
props: UseTransitionProps,
onClose: () => unknown
export type ModalConfigType = {
opacity: number;
transform?: string;
};
export function useAnimated(
onClose: () => unknown,
{
getFrom,
getTo,
}: {
getFrom: (isOpen: boolean) => ModalConfigType;
getTo: (isOpen: boolean) => ModalConfigType;
}
): {
close: () => unknown;
renderAnimation: (children: ReactElement) => JSX.Element;
modalStyles: SpringValues<ModalConfigType>;
overlayStyles: SpringValues<ModalConfigType>;
} {
const [isOpen, setIsOpen] = useState(true);
const transitions = useTransition<boolean, Props>(isOpen, {
...props,
leave: {
...props.leave,
onRest: () => onClose(),
const modalRef = useSpringRef();
const modalStyles = useSpring({
from: getFrom(isOpen),
to: getTo(isOpen),
onRest: () => {
if (!isOpen) {
onClose();
}
},
config: {
duration: 200,
easing: cubicBezier(0.17, 0.17, 0, 1),
...props.config,
clamp: true,
friction: 20,
mass: 0.5,
tension: 350,
},
ref: modalRef,
});
const overlayRef = useSpringRef();
const overlayStyles = useSpring({
from: { opacity: 0 },
to: { opacity: isOpen ? 1 : 0 },
config: {
clamp: true,
friction: 22,
tension: 360,
},
ref: overlayRef,
});
useChain(isOpen ? [overlayRef, modalRef] : [modalRef, overlayRef]);
return {
close: () => setIsOpen(false),
renderAnimation: children =>
transitions((style, item) =>
item ? <animated.div style={style}>{children}</animated.div> : null
),
overlayStyles,
modalStyles,
};
}