// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactElement, ReactNode } from 'react'; import React, { useRef, useState } from 'react'; import type { ContentRect, MeasuredComponentProps } from 'react-measure'; import Measure from 'react-measure'; 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 { getClassNamesFor } from '../util/getClassNamesFor'; import { useAnimated } from '../hooks/useAnimated'; import { useHasWrapped } from '../hooks/useHasWrapped'; import { useRefMerger } from '../hooks/useRefMerger'; 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 & { 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, padded = true, }: Readonly): ReactElement { 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 ( {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 refMerger = useRefMerger(); const bodyRef = useRef(null); const [scrolled, setScrolled] = useState(false); const [hasOverflow, setHasOverflow] = useState(false); const hasHeader = Boolean(hasXButton || title || onBackButtonClick); const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName); function handleResize({ scroll }: ContentRect) { const modalNode = modalRef?.current; if (!modalNode) { return; } if (scroll) { setHasOverflow(scroll.height > modalNode.clientHeight); } } 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 && (
)} {({ measureRef }: MeasuredComponentProps) => (
{ const scrollTop = bodyRef.current?.scrollTop || 0; setScrolled(scrollTop > 2); }} ref={refMerger(measureRef, bodyRef)} > {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): ReactElement { 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 ( {children(close)} ); } export type RenderModalPage = (onClose: () => void) => JSX.Element;