371 lines
10 KiB
TypeScript
371 lines
10 KiB
TypeScript
// Copyright 2021 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import type { ReactElement, ReactNode } from 'react';
|
|
import React, { useEffect, useRef, useState } from 'react';
|
|
import classNames from 'classnames';
|
|
import { noop } from 'lodash';
|
|
import { animated } from '@react-spring/web';
|
|
|
|
import type { LocalizerType } from '../types/Util';
|
|
import { ModalHost } from './ModalHost';
|
|
import type { Theme } from '../util/theme';
|
|
import { assertDev } from '../util/assert';
|
|
import { getClassNamesFor } from '../util/getClassNamesFor';
|
|
import { useAnimated } from '../hooks/useAnimated';
|
|
import { useHasWrapped } from '../hooks/useHasWrapped';
|
|
import * as log from '../logging/log';
|
|
import {
|
|
isOverflowing,
|
|
isScrolled,
|
|
isScrolledToBottom,
|
|
useScrollObserver,
|
|
} from '../hooks/useSizeObserver';
|
|
|
|
type PropsType = {
|
|
children: ReactNode;
|
|
modalName: string;
|
|
hasXButton?: boolean;
|
|
hasHeaderDivider?: boolean;
|
|
hasFooterDivider?: boolean;
|
|
i18n: LocalizerType;
|
|
modalFooter?: JSX.Element;
|
|
modalHeaderChildren?: ReactNode;
|
|
moduleClassName?: string;
|
|
onBackButtonClick?: () => unknown;
|
|
onClose?: () => void;
|
|
title?: ReactNode;
|
|
useFocusTrap?: boolean;
|
|
padded?: boolean;
|
|
};
|
|
|
|
export type ModalPropsType = PropsType & {
|
|
noTransform?: boolean;
|
|
noMouseClose?: boolean;
|
|
theme?: Theme;
|
|
};
|
|
|
|
const BASE_CLASS_NAME = 'module-Modal';
|
|
|
|
export function Modal({
|
|
children,
|
|
modalName,
|
|
hasXButton,
|
|
i18n,
|
|
modalFooter,
|
|
modalHeaderChildren,
|
|
moduleClassName,
|
|
noMouseClose,
|
|
onBackButtonClick,
|
|
onClose = noop,
|
|
theme,
|
|
title,
|
|
useFocusTrap,
|
|
hasHeaderDivider = false,
|
|
hasFooterDivider = false,
|
|
noTransform = false,
|
|
padded = true,
|
|
}: Readonly<ModalPropsType>): JSX.Element | null {
|
|
const { close, isClosed, modalStyles, overlayStyles } = useAnimated(
|
|
onClose,
|
|
|
|
// `background-position: fixed` cannot properly detect the viewport when
|
|
// the parent element has `transform: translate*`. Even though it requires
|
|
// layout recalculation - use `margin-top` if asked by the embedder.
|
|
noTransform
|
|
? {
|
|
getFrom: () => ({ opacity: 0, marginTop: '48px' }),
|
|
getTo: isOpen =>
|
|
isOpen
|
|
? { opacity: 1, marginTop: '0px' }
|
|
: { opacity: 0, marginTop: '48px' },
|
|
}
|
|
: {
|
|
getFrom: () => ({ opacity: 0, transform: 'translateY(48px)' }),
|
|
getTo: isOpen =>
|
|
isOpen
|
|
? { opacity: 1, transform: 'translateY(0px)' }
|
|
: { opacity: 0, transform: 'translateY(48px)' },
|
|
}
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!isClosed) {
|
|
return noop;
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
log.error(`Modal ${modalName} is closed, but still visible`);
|
|
assertDev(false, `Invisible modal ${modalName}`);
|
|
}, 0);
|
|
return () => {
|
|
clearTimeout(timer);
|
|
};
|
|
}, [modalName, isClosed]);
|
|
|
|
if (isClosed) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<ModalHost
|
|
modalName={modalName}
|
|
moduleClassName={moduleClassName}
|
|
noMouseClose={noMouseClose}
|
|
onClose={close}
|
|
onEscape={onBackButtonClick}
|
|
overlayStyles={overlayStyles}
|
|
theme={theme}
|
|
useFocusTrap={useFocusTrap}
|
|
>
|
|
<animated.div style={modalStyles}>
|
|
<ModalPage
|
|
modalName={modalName}
|
|
hasXButton={hasXButton}
|
|
i18n={i18n}
|
|
modalFooter={modalFooter}
|
|
modalHeaderChildren={modalHeaderChildren}
|
|
moduleClassName={moduleClassName}
|
|
onBackButtonClick={onBackButtonClick}
|
|
onClose={close}
|
|
title={title}
|
|
padded={padded}
|
|
hasHeaderDivider={hasHeaderDivider}
|
|
hasFooterDivider={hasFooterDivider}
|
|
>
|
|
{children}
|
|
</ModalPage>
|
|
</animated.div>
|
|
</ModalHost>
|
|
);
|
|
}
|
|
|
|
type ModalPageProps = Readonly<{
|
|
// should be the one provided by PagedModal
|
|
onClose: () => void;
|
|
}> &
|
|
Omit<Readonly<PropsType>, 'onClose'>;
|
|
|
|
/**
|
|
* Represents a single instance (or page) of a modal window.
|
|
*
|
|
* It should not be used by itself, either wrap it with PagedModal,
|
|
* render it in a component that has PagedModal as an ancestor, or
|
|
* use Modal instead.
|
|
*
|
|
* It does not provide open/close animation.
|
|
*
|
|
* NOTE: When used in conjunction with PagedModal (almost always the case):
|
|
* onClose" handler should be the one provided by the parent PagedModal,
|
|
* not one that has any logic. If you have some logic to execute when the
|
|
* modal closes, pass it to PagedModal.
|
|
*/
|
|
export function ModalPage({
|
|
children,
|
|
hasXButton,
|
|
i18n,
|
|
modalFooter,
|
|
modalHeaderChildren,
|
|
moduleClassName,
|
|
onBackButtonClick,
|
|
onClose,
|
|
title,
|
|
padded = true,
|
|
hasHeaderDivider = false,
|
|
hasFooterDivider = false,
|
|
}: ModalPageProps): JSX.Element {
|
|
const modalRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const bodyRef = useRef<HTMLDivElement>(null);
|
|
const bodyInnerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [scrolled, setScrolled] = useState(false);
|
|
const [scrolledToBottom, setScrolledToBottom] = useState(false);
|
|
const [hasOverflow, setHasOverflow] = useState(false);
|
|
|
|
const hasHeader = Boolean(
|
|
hasXButton || title || modalHeaderChildren || onBackButtonClick
|
|
);
|
|
const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName);
|
|
|
|
useScrollObserver(bodyRef, bodyInnerRef, scroll => {
|
|
setScrolled(isScrolled(scroll));
|
|
setScrolledToBottom(isScrolledToBottom(scroll));
|
|
setHasOverflow(isOverflowing(scroll));
|
|
});
|
|
|
|
return (
|
|
<>
|
|
{/* We don't want the click event to propagate to its container node. */}
|
|
{/* eslint-disable-next-line max-len */}
|
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
|
|
<div
|
|
className={classNames(
|
|
getClassName(''),
|
|
getClassName(hasHeader ? '--has-header' : '--no-header'),
|
|
Boolean(modalFooter) && getClassName('--has-footer'),
|
|
padded && getClassName('--padded'),
|
|
hasHeaderDivider && getClassName('--header-divider'),
|
|
hasFooterDivider && getClassName('--footer-divider')
|
|
)}
|
|
ref={modalRef}
|
|
onClick={event => {
|
|
event.stopPropagation();
|
|
}}
|
|
>
|
|
{hasHeader && (
|
|
<div
|
|
className={classNames(
|
|
getClassName('__header'),
|
|
onBackButtonClick
|
|
? getClassName('__header--with-back-button')
|
|
: null
|
|
)}
|
|
>
|
|
<div className={getClassName('__headerTitle')}>
|
|
{onBackButtonClick && (
|
|
<button
|
|
aria-label={i18n('icu:back')}
|
|
className={getClassName('__back-button')}
|
|
onClick={onBackButtonClick}
|
|
tabIndex={0}
|
|
type="button"
|
|
/>
|
|
)}
|
|
{title && (
|
|
<h1
|
|
className={classNames(
|
|
getClassName('__title'),
|
|
hasXButton ? getClassName('__title--with-x-button') : null
|
|
)}
|
|
>
|
|
{title}
|
|
</h1>
|
|
)}
|
|
{hasXButton && !title && (
|
|
<div className={getClassName('__title')} />
|
|
)}
|
|
{hasXButton && (
|
|
<button
|
|
aria-label={i18n('icu:close')}
|
|
className={getClassName('__close-button')}
|
|
onClick={onClose}
|
|
tabIndex={0}
|
|
type="button"
|
|
/>
|
|
)}
|
|
</div>
|
|
{modalHeaderChildren}
|
|
</div>
|
|
)}
|
|
<div
|
|
className={classNames(
|
|
getClassName('__body'),
|
|
scrolled ? getClassName('__body--scrolled') : null,
|
|
scrolledToBottom ? getClassName('__body--scrolledToBottom') : null,
|
|
hasOverflow || scrolled ? getClassName('__body--overflow') : null
|
|
)}
|
|
ref={bodyRef}
|
|
>
|
|
<div ref={bodyInnerRef} className={getClassName('__body_inner')}>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
{modalFooter && <Modal.ButtonFooter>{modalFooter}</Modal.ButtonFooter>}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ButtonFooter({
|
|
children,
|
|
}: Readonly<{
|
|
children: ReactNode;
|
|
}>): ReactElement {
|
|
const [ref, hasWrapped] = useHasWrapped<HTMLDivElement>();
|
|
|
|
const className = getClassNamesFor(BASE_CLASS_NAME)('__button-footer');
|
|
|
|
return (
|
|
<div
|
|
className={classNames(
|
|
className,
|
|
hasWrapped ? `${className}--one-button-per-line` : undefined
|
|
)}
|
|
ref={ref}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
Modal.ButtonFooter = ButtonFooter;
|
|
|
|
type PagedModalProps = Readonly<{
|
|
modalName: string;
|
|
children: RenderModalPage;
|
|
moduleClassName?: string;
|
|
onClose?: () => void;
|
|
useFocusTrap?: boolean;
|
|
noMouseClose?: boolean;
|
|
theme?: Theme;
|
|
}>;
|
|
|
|
/**
|
|
* Provides modal animation and click to close functionality to a
|
|
* ModalPage descendant.
|
|
*
|
|
* Useful when we want to swap between different ModalPages (possibly
|
|
* rendered by different components) without triggering an open/close
|
|
* transition animation.
|
|
*/
|
|
export function PagedModal({
|
|
modalName,
|
|
children,
|
|
moduleClassName,
|
|
noMouseClose,
|
|
onClose = noop,
|
|
theme,
|
|
useFocusTrap,
|
|
}: PagedModalProps): JSX.Element | null {
|
|
const { close, isClosed, modalStyles, overlayStyles } = useAnimated(onClose, {
|
|
getFrom: () => ({ opacity: 0, transform: 'translateY(48px)' }),
|
|
getTo: isOpen =>
|
|
isOpen
|
|
? { opacity: 1, transform: 'translateY(0px)' }
|
|
: { opacity: 0, transform: 'translateY(48px)' },
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!isClosed) {
|
|
return noop;
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
log.error(`PagedModal ${modalName} is closed, but still visible`);
|
|
assertDev(false, `Invisible paged modal ${modalName}`);
|
|
}, 0);
|
|
return () => {
|
|
clearTimeout(timer);
|
|
};
|
|
}, [modalName, isClosed]);
|
|
|
|
if (isClosed) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<ModalHost
|
|
modalName={modalName}
|
|
moduleClassName={moduleClassName}
|
|
noMouseClose={noMouseClose}
|
|
onClose={close}
|
|
overlayStyles={overlayStyles}
|
|
theme={theme}
|
|
useFocusTrap={useFocusTrap}
|
|
>
|
|
<animated.div style={modalStyles}>{children(close)}</animated.div>
|
|
</ModalHost>
|
|
);
|
|
}
|
|
|
|
export type RenderModalPage = (onClose: () => void) => JSX.Element;
|