signal-desktop/ts/components/Modal.tsx

389 lines
11 KiB
TypeScript
Raw Normal View History

2023-01-03 19:55:46 +00:00
// 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';
2021-10-14 16:52:42 +00:00
import { animated } from '@react-spring/web';
2024-03-12 16:29:31 +00:00
import { v4 as uuid } from 'uuid';
import type { LocalizerType } from '../types/Util';
import { ModalHost } from './ModalHost';
import type { Theme } from '../util/theme';
import { assertDev } from '../util/assert';
2021-05-11 00:50:43 +00:00
import { getClassNamesFor } from '../util/getClassNamesFor';
import { useAnimated } from '../hooks/useAnimated';
2021-09-17 22:24:21 +00:00
import { useHasWrapped } from '../hooks/useHasWrapped';
import * as log from '../logging/log';
import {
isOverflowing,
isScrolled,
isScrolledToBottom,
useScrollObserver,
} from '../hooks/useSizeObserver';
type PropsType = {
children: ReactNode;
2022-09-27 20:24:21 +00:00
modalName: string;
hasXButton?: boolean;
2022-10-04 23:17:15 +00:00
hasHeaderDivider?: boolean;
hasFooterDivider?: boolean;
i18n: LocalizerType;
2022-08-10 18:37:19 +00:00
modalFooter?: JSX.Element;
2023-11-06 21:19:23 +00:00
modalHeaderChildren?: ReactNode;
2021-04-21 16:31:12 +00:00
moduleClassName?: string;
onBackButtonClick?: () => unknown;
onClose?: () => void;
title?: ReactNode;
2022-03-04 21:14:52 +00:00
useFocusTrap?: boolean;
padded?: boolean;
2024-03-12 16:29:31 +00:00
['aria-describedby']?: string;
};
export type ModalPropsType = PropsType & {
noTransform?: boolean;
2024-06-25 18:56:28 +00:00
noEscapeClose?: boolean;
noMouseClose?: boolean;
theme?: Theme;
};
2021-05-11 00:50:43 +00:00
const BASE_CLASS_NAME = 'module-Modal';
export function Modal({
children,
2022-09-27 20:24:21 +00:00
modalName,
hasXButton,
i18n,
2022-08-10 18:37:19 +00:00
modalFooter,
2023-11-06 21:19:23 +00:00
modalHeaderChildren,
2021-04-21 16:31:12 +00:00
moduleClassName,
2024-06-25 18:56:28 +00:00
noEscapeClose,
2021-05-28 16:15:17 +00:00
noMouseClose,
onBackButtonClick,
onClose = noop,
theme,
2022-08-10 18:37:19 +00:00
title,
2022-03-04 21:14:52 +00:00
useFocusTrap,
2022-10-04 23:17:15 +00:00
hasHeaderDivider = false,
hasFooterDivider = false,
noTransform = false,
padded = true,
2024-03-12 16:29:31 +00:00
'aria-describedby': ariaDescribedBy,
}: 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]);
2022-12-21 20:41:36 +00:00
if (isClosed) {
return null;
}
return (
2021-10-14 16:52:42 +00:00
<ModalHost
2022-09-27 20:24:21 +00:00
modalName={modalName}
2022-03-04 21:14:52 +00:00
moduleClassName={moduleClassName}
2024-06-25 18:56:28 +00:00
noEscapeClose={noEscapeClose}
2021-10-14 16:52:42 +00:00
noMouseClose={noMouseClose}
onClose={close}
onEscape={onBackButtonClick}
2021-10-14 16:52:42 +00:00
overlayStyles={overlayStyles}
theme={theme}
2022-03-04 21:14:52 +00:00
useFocusTrap={useFocusTrap}
2021-10-14 16:52:42 +00:00
>
<animated.div style={modalStyles}>
<ModalPage
2022-09-27 20:24:21 +00:00
modalName={modalName}
hasXButton={hasXButton}
i18n={i18n}
2022-08-10 18:37:19 +00:00
modalFooter={modalFooter}
2023-11-06 21:19:23 +00:00
modalHeaderChildren={modalHeaderChildren}
moduleClassName={moduleClassName}
onBackButtonClick={onBackButtonClick}
onClose={close}
title={title}
padded={padded}
2022-10-04 23:17:15 +00:00
hasHeaderDivider={hasHeaderDivider}
hasFooterDivider={hasFooterDivider}
2024-03-12 16:29:31 +00:00
aria-describedby={ariaDescribedBy}
>
{children}
</ModalPage>
2021-10-14 16:52:42 +00:00
</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,
2022-08-10 18:37:19 +00:00
modalFooter,
2023-11-06 21:19:23 +00:00
modalHeaderChildren,
2024-06-05 21:48:54 +00:00
modalName,
moduleClassName,
onBackButtonClick,
onClose,
title,
padded = true,
2022-10-04 23:17:15 +00:00
hasHeaderDivider = false,
hasFooterDivider = false,
2024-03-12 16:29:31 +00:00
'aria-describedby': ariaDescribedBy,
}: ModalPageProps): JSX.Element {
2021-08-06 00:17:05 +00:00
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);
2021-08-06 00:17:05 +00:00
const [hasOverflow, setHasOverflow] = useState(false);
2023-11-06 21:19:23 +00:00
const hasHeader = Boolean(
hasXButton || title || modalHeaderChildren || onBackButtonClick
);
2021-05-11 00:50:43 +00:00
const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName);
2024-03-12 16:29:31 +00:00
const [id] = useState(() => uuid());
useScrollObserver(bodyRef, bodyInnerRef, scroll => {
setScrolled(isScrolled(scroll));
setScrolledToBottom(isScrolledToBottom(scroll));
setHasOverflow(isOverflowing(scroll));
});
2021-08-06 00:17:05 +00:00
return (
<>
{/* We don't want the click event to propagate to its container node. */}
{/* eslint-disable-next-line max-len */}
2024-03-12 16:29:31 +00:00
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */}
<div
className={classNames(
2021-05-11 00:50:43 +00:00
getClassName(''),
2021-08-06 00:17:05 +00:00
getClassName(hasHeader ? '--has-header' : '--no-header'),
2022-10-04 23:17:15 +00:00
Boolean(modalFooter) && getClassName('--has-footer'),
padded && getClassName('--padded'),
hasHeaderDivider && getClassName('--header-divider'),
hasFooterDivider && getClassName('--footer-divider')
)}
2021-08-06 00:17:05 +00:00
ref={modalRef}
2024-03-12 16:29:31 +00:00
role="dialog"
tabIndex={-1}
aria-labelledby={title ? `${id}-title` : undefined}
aria-describedby={ariaDescribedBy}
2024-06-05 21:48:54 +00:00
data-testid={modalName}
onClick={event => {
event.stopPropagation();
}}
>
{hasHeader && (
<div
className={classNames(
getClassName('__header'),
onBackButtonClick
? getClassName('__header--with-back-button')
: null
)}
>
2023-11-06 21:19:23 +00:00
<div className={getClassName('__headerTitle')}>
{onBackButtonClick && (
<button
aria-label={i18n('icu:back')}
className={getClassName('__back-button')}
onClick={onBackButtonClick}
tabIndex={0}
type="button"
/>
)}
{title && (
<h1
2024-03-12 16:29:31 +00:00
id={`${id}-title`}
2023-11-06 21:19:23 +00:00
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
2021-05-11 00:50:43 +00:00
)}
ref={bodyRef}
>
<div ref={bodyInnerRef} className={getClassName('__body_inner')}>
{children}
</div>
</div>
{modalFooter && <Modal.ButtonFooter>{modalFooter}</Modal.ButtonFooter>}
</div>
</>
);
}
2022-11-18 00:45:19 +00:00
function ButtonFooter({
children,
2021-05-11 00:50:43 +00:00
}: 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>
);
2022-11-18 00:45:19 +00:00
}
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;