Files
web/packages/design-system/lib/components/Modal/index.tsx
Christel Westerberg cd8b30f2ec Merged in fix/STAY-133 (pull request #3313)
Fix/STAY-133

* fix: Add static summary buttons row on add ancillary flow

* fix: refactor handling of modals

* fix: refactor file structure for add ancillary flow

* Merged in chore/replace-deprecated-body (pull request #3300)

Replace deprecated <Body> with <Typography>

* chore: replace deprecated body component

* refactor: replace Body component with Typography across various components

* merge

Approved-by: Bianca Widstam
Approved-by: Matilda Landström


Approved-by: Bianca Widstam
Approved-by: Matilda Landström
2025-12-11 07:29:36 +00:00

231 lines
6.1 KiB
TypeScript

'use client'
import { cx } from 'class-variance-authority'
import { AnimatePresence, motion } from 'motion/react'
import { type PropsWithChildren, useEffect, useState } from 'react'
import {
Modal as AriaModal,
Dialog,
DialogTrigger,
ModalOverlay,
} from 'react-aria-components'
import { useIntl } from 'react-intl'
import { MaterialIcon } from '../Icons/MaterialIcon'
import {
type AnimationState,
AnimationStateEnum,
type InnerModalProps,
type ModalProps,
} from './modal'
import { fade, slideInOut } from './motionVariants'
import { modalContentVariants } from './variants'
import { IconButton } from '../IconButton'
import { Typography } from '../Typography'
import styles from './modal.module.css'
const MotionOverlay = motion.create(ModalOverlay)
const MotionModal = motion.create(AriaModal)
function InnerModal({
animation,
onAnimationComplete = () => undefined,
setAnimation,
onToggle,
isOpen,
children,
title,
subtitle,
withActions,
hideHeader,
className,
contentClassName,
}: PropsWithChildren<InnerModalProps>) {
const intl = useIntl()
const contentClassNames = modalContentVariants({
withActions,
className: contentClassName,
})
function modalStateHandler(newAnimationState: AnimationState) {
setAnimation((currentAnimationState) =>
newAnimationState === AnimationStateEnum.hidden &&
currentAnimationState === AnimationStateEnum.hidden
? AnimationStateEnum.unmounted
: currentAnimationState
)
if (newAnimationState === AnimationStateEnum.visible) {
onAnimationComplete()
}
}
function onOpenChange(state: boolean) {
onToggle!(state)
}
return (
<MotionOverlay
animate={animation}
className={styles.overlay}
initial="hidden"
isDismissable
// TODO: Enabling this causes the modal to never unmount.
// Seems to be an issue with react-aria-components, see https://github.com/adobe/react-spectrum/issues/7563.
// Exit animations can probably be fixed by rewriting to a solution similar to
// https://react-spectrum.adobe.com/react-aria/examples/framer-modal-sheet.html.
// isExiting={animation === AnimationStateEnum.hidden}
onAnimationComplete={modalStateHandler}
variants={fade}
isOpen={isOpen}
onOpenChange={onOpenChange}
>
<MotionModal
className={cx(styles.modal, className)}
variants={slideInOut}
animate={animation}
initial="hidden"
>
<Dialog
className={styles.dialog}
aria-label={intl.formatMessage({
id: 'modal.dialog',
defaultMessage: 'Dialog',
})}
>
{({ close }) => (
<>
{!hideHeader && (
<header
className={cx(styles.header, {
[styles.verticalCenter]: !subtitle,
})}
>
<div>
{title && (
<Typography variant="Title/Subtitle/lg">
<h3>{title}</h3>
</Typography>
)}
{subtitle && (
<Typography variant="Body/Lead text">
<span>{subtitle}</span>
</Typography>
)}
</div>
<IconButton
onPress={close}
className={styles.close}
type="button"
aria-label={intl.formatMessage({
id: 'common.close',
defaultMessage: 'Close',
})}
theme="Black"
style="Muted"
>
<MaterialIcon
icon="close"
color="Icon/Feedback/Neutral"
size={24}
/>
</IconButton>
</header>
)}
<div className={contentClassNames}>{children}</div>
</>
)}
</Dialog>
</MotionModal>
</MotionOverlay>
)
}
export default function Modal({
onAnimationComplete = () => undefined,
trigger,
isOpen,
onToggle,
onOpenChange,
title,
subtitle,
children,
withActions = false,
hideHeader = false,
className = '',
contentClassName = '',
}: PropsWithChildren<ModalProps>) {
const [animation, setAnimation] = useState<AnimationState>(
AnimationStateEnum.visible
)
useEffect(() => {
if (typeof isOpen === 'boolean') {
setAnimation(
isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden
)
}
if (isOpen === undefined) {
setAnimation(AnimationStateEnum.unmounted)
}
}, [isOpen])
const shouldRender = isOpen || animation !== AnimationStateEnum.unmounted
if (!trigger) {
return (
<AnimatePresence>
{shouldRender && (
<InnerModal
onAnimationComplete={onAnimationComplete}
animation={animation}
setAnimation={setAnimation}
onToggle={onToggle}
isOpen={isOpen}
title={title}
subtitle={subtitle}
withActions={withActions}
hideHeader={hideHeader}
className={className}
contentClassName={contentClassName}
>
{children}
</InnerModal>
)}
</AnimatePresence>
)
}
return (
<DialogTrigger
onOpenChange={(isOpen) => {
setAnimation(
isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden
)
onOpenChange?.(isOpen)
}}
>
{trigger}
<AnimatePresence>
{shouldRender && (
<InnerModal
onAnimationComplete={onAnimationComplete}
animation={animation}
setAnimation={setAnimation}
title={title}
subtitle={subtitle}
withActions={withActions}
className={className}
>
{children}
</InnerModal>
)}
</AnimatePresence>
</DialogTrigger>
)
}