// 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; 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, moduleClassName, noMouseClose, onBackButtonClick, onClose = noop, theme, title, useFocusTrap, hasHeaderDivider = false, hasFooterDivider = false, noTransform = false, padded = true, }: Readonly): 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 ( {children} ); } type ModalPageProps = Readonly<{ // should be the one provided by PagedModal onClose: () => void; }> & Omit, '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, moduleClassName, onBackButtonClick, onClose, title, padded = true, hasHeaderDivider = false, hasFooterDivider = false, }: ModalPageProps): JSX.Element { const modalRef = useRef(null); const bodyRef = useRef(null); const bodyInnerRef = useRef(null); const [scrolled, setScrolled] = useState(false); const [scrolledToBottom, setScrolledToBottom] = useState(false); const [hasOverflow, setHasOverflow] = useState(false); const hasHeader = Boolean(hasXButton || title || 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 */}
{ event.stopPropagation(); }} > {hasHeader && (
{onBackButtonClick && (
)}
{children}
{modalFooter && {modalFooter}}
); } function ButtonFooter({ children, }: Readonly<{ children: ReactNode; }>): ReactElement { const [ref, hasWrapped] = useHasWrapped(); const className = getClassNamesFor(BASE_CLASS_NAME)('__button-footer'); return (
{children}
); } 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 ( {children(close)} ); } export type RenderModalPage = (onClose: () => void) => JSX.Element;