Merged in feat/sw-3238-move-modal-to-design-system (pull request #2628)
feat(SW-3238): Move modal to design system * Move Modal to design-system * Remove temp modal from booking-flow Approved-by: Joakim Jäderberg
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
||||
import { OldDSButton as Button } from '../../OldDSButton'
|
||||
import Subtitle from '../../Subtitle'
|
||||
|
||||
import styles from './modalContent.module.css'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface ModalContentProps {
|
||||
title?: string
|
||||
content: ReactNode
|
||||
primaryAction: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
intent?: 'primary' | 'secondary' | 'text'
|
||||
isLoading?: boolean
|
||||
disabled?: boolean
|
||||
} | null
|
||||
secondaryAction: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
intent?: 'primary' | 'secondary' | 'text'
|
||||
} | null
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function ModalContentWithActions({
|
||||
title,
|
||||
content,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
onClose,
|
||||
}: ModalContentProps) {
|
||||
return (
|
||||
<>
|
||||
{title && (
|
||||
<header className={styles.header}>
|
||||
<Subtitle>{title}</Subtitle>
|
||||
<button onClick={onClose} type="button" className={styles.close}>
|
||||
<MaterialIcon icon="close" color="Icon/Interactive/Placeholder" />
|
||||
</button>
|
||||
</header>
|
||||
)}
|
||||
<div className={styles.content}>{content}</div>
|
||||
<footer className={styles.footer}>
|
||||
{secondaryAction && (
|
||||
<Button
|
||||
theme="base"
|
||||
intent={secondaryAction.intent ?? 'text'}
|
||||
color="burgundy"
|
||||
onClick={secondaryAction.onClick}
|
||||
>
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
{primaryAction && (
|
||||
<Button
|
||||
theme="base"
|
||||
intent={primaryAction.intent ?? 'secondary'}
|
||||
onClick={primaryAction.onClick}
|
||||
disabled={primaryAction.isLoading || primaryAction.disabled}
|
||||
>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
padding: var(--Spacing-x1) var(--Spacing-x3) var(--Spacing-x4);
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x1)
|
||||
var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
padding: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.content {
|
||||
width: 640px;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
213
packages/design-system/lib/components/Modal/index.tsx
Normal file
213
packages/design-system/lib/components/Modal/index.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import { cx } from 'class-variance-authority'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { type PropsWithChildren, useEffect, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Modal as AriaModal,
|
||||
ModalOverlay,
|
||||
} from 'react-aria-components'
|
||||
import { useIntl } from 'react-intl'
|
||||
|
||||
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||
import Preamble from '../Preamble'
|
||||
import Subtitle from '../Subtitle'
|
||||
|
||||
import {
|
||||
type AnimationState,
|
||||
AnimationStateEnum,
|
||||
type InnerModalProps,
|
||||
type ModalProps,
|
||||
} from './modal'
|
||||
import { fade, slideInOut } from './motionVariants'
|
||||
import { modalContentVariants } from './variants'
|
||||
|
||||
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,
|
||||
}: PropsWithChildren<InnerModalProps>) {
|
||||
const intl = useIntl()
|
||||
|
||||
const contentClassNames = modalContentVariants({
|
||||
withActions: withActions,
|
||||
})
|
||||
|
||||
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({
|
||||
defaultMessage: 'Dialog',
|
||||
})}
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
{!hideHeader && (
|
||||
<header
|
||||
className={`${styles.header} ${!subtitle ? styles.verticalCenter : ''}`}
|
||||
>
|
||||
<div>
|
||||
{title && (
|
||||
<Subtitle type="one" color="uiTextHighContrast">
|
||||
{title}
|
||||
</Subtitle>
|
||||
)}
|
||||
{subtitle && (
|
||||
<Preamble asChild>
|
||||
<span>{subtitle}</span>
|
||||
</Preamble>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={close}
|
||||
type="button"
|
||||
className={styles.close}
|
||||
>
|
||||
<MaterialIcon icon="close" color="Icon/Feedback/Neutral" />
|
||||
</button>
|
||||
</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 = '',
|
||||
}: 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}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
95
packages/design-system/lib/components/Modal/modal.module.css
Normal file
95
packages/design-system/lib/components/Modal/modal.module.css
Normal file
@@ -0,0 +1,95 @@
|
||||
.overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
height: var(--visual-viewport-height);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
z-index: var(--default-modal-overlay-z-index);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
|
||||
box-shadow: var(--modal-box-shadow);
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: var(--default-modal-z-index);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* For removing focus outline when modal opens first time */
|
||||
outline: 0 none;
|
||||
|
||||
max-height: 100dvh;
|
||||
}
|
||||
|
||||
.header {
|
||||
--button-dimension: 32px;
|
||||
|
||||
box-sizing: content-box;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-height: var(--button-dimension);
|
||||
position: relative;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x3) 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.contentWithActions {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.contentWithoutActions {
|
||||
padding: 0 var(--Spacing-x3) var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: var(--Spacing-x2);
|
||||
width: var(--button-dimension);
|
||||
height: var(--button-dimension);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.verticalCenter {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal {
|
||||
left: auto;
|
||||
bottom: auto;
|
||||
width: auto;
|
||||
border-radius: var(--Corner-radius-md);
|
||||
max-width: var(--max-width-page);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
max-height: 90dvh;
|
||||
}
|
||||
}
|
||||
36
packages/design-system/lib/components/Modal/modal.ts
Normal file
36
packages/design-system/lib/components/Modal/modal.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Dispatch, JSX, SetStateAction } from 'react'
|
||||
|
||||
export enum AnimationStateEnum {
|
||||
unmounted = 'unmounted',
|
||||
hidden = 'hidden',
|
||||
visible = 'visible',
|
||||
}
|
||||
|
||||
export type AnimationState = keyof typeof AnimationStateEnum
|
||||
|
||||
export type ModalProps = {
|
||||
onAnimationComplete?: () => void
|
||||
title?: string
|
||||
subtitle?: string
|
||||
withActions?: boolean
|
||||
hideHeader?: boolean
|
||||
className?: string
|
||||
} & (
|
||||
| {
|
||||
trigger: JSX.Element
|
||||
isOpen?: never
|
||||
onToggle?: never
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
| {
|
||||
trigger?: never
|
||||
isOpen: boolean
|
||||
onToggle: (open: boolean) => void
|
||||
onOpenChange?: never
|
||||
}
|
||||
)
|
||||
|
||||
export type InnerModalProps = Omit<ModalProps, 'trigger'> & {
|
||||
animation: AnimationState
|
||||
setAnimation: Dispatch<SetStateAction<AnimationState>>
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const fade = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
transition: { duration: 0.4, ease: 'easeInOut' },
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { duration: 0.4, ease: 'easeInOut' },
|
||||
},
|
||||
}
|
||||
|
||||
export const slideInOut = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 32,
|
||||
transition: { duration: 0.4, ease: 'easeInOut' },
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4, ease: 'easeInOut' },
|
||||
},
|
||||
}
|
||||
17
packages/design-system/lib/components/Modal/variants.ts
Normal file
17
packages/design-system/lib/components/Modal/variants.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
import styles from './modal.module.css'
|
||||
|
||||
const config = {
|
||||
variants: {
|
||||
withActions: {
|
||||
true: styles.contentWithActions,
|
||||
false: styles.contentWithoutActions,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
withActions: false,
|
||||
},
|
||||
} as const
|
||||
|
||||
export const modalContentVariants = cva(styles.content, config)
|
||||
Reference in New Issue
Block a user