refactor: modals

This commit is contained in:
Aman Harwara
2023-01-26 22:08:12 +05:30
parent c772b5a854
commit d583311de7
39 changed files with 333 additions and 446 deletions

View File

@@ -0,0 +1,31 @@
import { classNames } from '@standardnotes/snjs'
import { ComponentPropsWithoutRef, ForwardedRef, forwardRef, ReactNode } from 'react'
type Props = {
children: ReactNode
action: () => void
slot: 'left' | 'right'
type?: 'primary' | 'secondary' | 'destructive' | 'cancel'
} & Omit<ComponentPropsWithoutRef<'button'>, 'onClick' | 'type'>
const MobileModalAction = forwardRef(
({ children, action, type = 'primary', slot, className, ...props }: Props, ref: ForwardedRef<HTMLButtonElement>) => {
return (
<button
ref={ref}
className={classNames(
'flex whitespace-nowrap py-1 px-1 text-base font-semibold focus:shadow-none focus:outline-none active:shadow-none active:outline-none disabled:text-neutral md:hidden',
slot === 'left' ? 'justify-start text-left' : 'justify-end text-right',
type === 'cancel' || type === 'destructive' ? 'text-danger' : 'text-info',
className,
)}
onClick={action}
{...props}
>
{children}
</button>
)
},
)
export default MobileModalAction

View File

@@ -0,0 +1,13 @@
import { classNames } from '@standardnotes/utils'
import { ReactNode } from 'react'
type Props = {
className?: string
children: ReactNode
}
const MobileModalHeader = ({ className, children }: Props) => {
return <div className={classNames('grid w-full grid-cols-[0.35fr_1fr_0.35fr] gap-2', className)}>{children}</div>
}
export default MobileModalHeader

View File

@@ -0,0 +1,223 @@
import { useMediaQuery, MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
import { isIOS } from '@/Utils'
import { DialogContent } from '@reach/dialog'
import { classNames } from '@standardnotes/snjs'
import { ReactNode, useMemo, useRef, useState } from 'react'
import Button from '../Button/Button'
import Icon from '../Icon/Icon'
import Popover from '../Popover/Popover'
import MobileModalAction from './MobileModalAction'
import MobileModalHeader from './MobileModalHeader'
import ModalAndroidBackHandler from './ModalAndroidBackHandler'
export type ModalAction = {
label: NonNullable<ReactNode>
type: 'primary' | 'secondary' | 'destructive' | 'cancel'
onClick: () => void
mobileSlot?: 'left' | 'right'
hidden?: boolean
disabled?: boolean
}
type Props = {
title: string
close: () => void
actions?: ModalAction[]
className?: {
content?: string
description?: string
}
customHeader?: ReactNode
disableCustomHeader?: boolean
customFooter?: ReactNode
children: ReactNode
}
const Modal = ({
title,
close,
actions = [],
className = {},
customHeader,
disableCustomHeader = false,
customFooter,
children,
}: Props) => {
const sortedActions = useMemo(
() =>
actions
.sort((a, b) => {
if (a.type === 'cancel') {
return -1
}
if (b.type === 'cancel') {
return 1
}
if (a.type === 'destructive') {
return -1
}
if (b.type === 'destructive') {
return 1
}
if (a.type === 'secondary') {
return -1
}
if (b.type === 'secondary') {
return 1
}
return 0
})
.filter((action) => !action.hidden),
[actions],
)
const primaryActions = sortedActions.filter((action) => action.type === 'primary')
if (primaryActions.length > 1) {
throw new Error('Modal can only have 1 primary action')
}
const cancelActions = sortedActions.filter((action) => action.type === 'cancel')
if (cancelActions.length > 1) {
throw new Error('Modal can only have 1 cancel action')
}
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
const leftSlotAction = sortedActions.find((action) => action.mobileSlot === 'left')
const rightSlotAction = sortedActions.find((action) => action.mobileSlot === 'right')
const firstPrimaryActionIndex = sortedActions.findIndex((action) => action.type === 'primary')
const extraActions = sortedActions.filter((action) => action.type !== 'primary' && action.type !== 'cancel')
const [showAdvanced, setShowAdvanced] = useState(false)
const advancedOptionRef = useRef<HTMLButtonElement>(null)
return (
<>
<ModalAndroidBackHandler close={close} />
<DialogContent
tabIndex={0}
className={classNames(
'm-0 flex h-full w-full flex-col border-solid border-border bg-default p-0 md:h-auto md:max-h-[85vh] md:w-160 md:rounded md:border md:shadow-main',
className.content,
)}
aria-label={title}
>
{customHeader && !disableCustomHeader ? (
customHeader
) : (
<div
className={classNames(
'flex w-full flex-shrink-0 items-center justify-between rounded-t border-b border-solid border-border bg-default text-text md:px-4.5 md:py-3',
isIOS() ? 'px-2 pt-safe-top pb-1.5' : 'py-1.5 px-2',
)}
>
<MobileModalHeader className="flex-row items-center justify-between md:flex md:gap-0">
{leftSlotAction ? (
<MobileModalAction
type={leftSlotAction.type}
action={leftSlotAction.onClick}
disabled={leftSlotAction.disabled}
slot="left"
>
{leftSlotAction.label}
</MobileModalAction>
) : (
<div className="md:hidden" />
)}
<div className="flex items-center justify-center gap-2 overflow-hidden text-center text-base font-semibold text-text md:flex-grow md:text-left md:text-lg">
{extraActions.length > 0 && (
<>
<MobileModalAction
type="secondary"
action={() => setShowAdvanced((show) => !show)}
slot="left"
ref={advancedOptionRef}
>
<div className="rounded-full border border-border p-0.5">
<Icon type="more" />
</div>
</MobileModalAction>
<Popover
title="Advanced"
open={showAdvanced}
anchorElement={advancedOptionRef.current}
disableMobileFullscreenTakeover={true}
togglePopover={() => setShowAdvanced((show) => !show)}
align="start"
portal={false}
className="!fixed w-1/2 !min-w-0 divide-y divide-border border border-border"
>
{extraActions
.filter((action) => action.type !== 'cancel')
.map((action, index) => (
<button
className={classNames(
'p-2 text-base font-semibold hover:bg-contrast focus:bg-info-backdrop focus:shadow-none focus:outline-none',
action.type === 'destructive' && 'text-danger',
)}
key={index}
onClick={action.onClick}
disabled={action.disabled}
>
{action.label}
</button>
))}
</Popover>
</>
)}
<span className="overflow-hidden text-ellipsis whitespace-nowrap ">{title}</span>
</div>
<div className="hidden items-center gap-2 md:flex">
<button tabIndex={0} className="ml-2 rounded p-1 font-bold hover:bg-contrast" onClick={close}>
<Icon type="close" />
</button>
</div>
{rightSlotAction ? (
<MobileModalAction
type={rightSlotAction.type}
action={rightSlotAction.onClick}
disabled={rightSlotAction.disabled}
slot="right"
>
{rightSlotAction.label}
</MobileModalAction>
) : null}
</MobileModalHeader>
</div>
)}
<div className={classNames('flex-grow overflow-y-auto', className.description)}>{children}</div>
{customFooter
? customFooter
: sortedActions.length > 0 && (
<div
className={classNames(
'hidden items-center justify-start gap-3 border-t border-border py-2 px-2.5 md:flex md:px-4 md:py-4',
isIOS() && 'pb-safe-bottom',
)}
>
{sortedActions.map((action, index) => (
<Button
primary={action.type === 'primary'}
colorStyle={action.type === 'destructive' ? 'danger' : undefined}
key={action.label.toString()}
onClick={action.onClick}
className={classNames(
action.mobileSlot ? 'hidden md:block' : '',
index === firstPrimaryActionIndex && 'ml-auto',
)}
data-type={action.type}
disabled={action.disabled}
small={isMobileScreen}
>
{action.label}
</Button>
))}
</div>
)}
</DialogContent>
</>
)
}
export default Modal

View File

@@ -0,0 +1,28 @@
import { useStateRef } from '@/Hooks/useStateRef'
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
import { useEffect } from 'react'
type Props = {
close: () => void
}
const ModalAndroidBackHandler = ({ close }: Props) => {
const addAndroidBackHandler = useAndroidBackHandler()
const closeFnRef = useStateRef(close)
useEffect(() => {
const removeListener = addAndroidBackHandler(() => {
closeFnRef.current()
return true
})
return () => {
if (removeListener) {
removeListener()
}
}
}, [addAndroidBackHandler, closeFnRef])
return null
}
export default ModalAndroidBackHandler

View File

@@ -0,0 +1,22 @@
import { isIOS } from '@/Utils'
import { classNames } from '@standardnotes/utils'
import { FunctionComponent, ReactNode } from 'react'
type Props = {
className?: string
children?: ReactNode
}
const ModalDialogButtons: FunctionComponent<Props> = ({ children, className }) => (
<div
className={classNames(
'flex items-center justify-end gap-3 border-t border-border px-4 py-4',
isIOS() && 'pb-safe-bottom',
className,
)}
>
{children}
</div>
)
export default ModalDialogButtons

View File

@@ -0,0 +1,32 @@
import { DialogOverlay, DialogOverlayProps } from '@reach/dialog'
import { classNames } from '@standardnotes/snjs'
import { ReactNode } from 'react'
import { useModalAnimation } from '../Modal/useModalAnimation'
type Props = {
isOpen: boolean
onDismiss?: () => void
children: ReactNode
className?: string
} & DialogOverlayProps
const ModalOverlay = ({ isOpen, onDismiss, children, className, ...props }: Props) => {
const [isMounted, setElement] = useModalAnimation(isOpen)
if (!isMounted) {
return null
}
return (
<DialogOverlay
className={classNames('p-0 md:px-0 md:opacity-100', className)}
onDismiss={onDismiss}
ref={setElement}
{...props}
>
{children}
</DialogOverlay>
)
}
export default ModalOverlay

View File

@@ -0,0 +1,52 @@
import { useLifecycleAnimation } from '@/Hooks/useLifecycleAnimation'
import { useMediaQuery, MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
export const useModalAnimation = (isOpen: boolean) => {
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
return useLifecycleAnimation(
{
open: isOpen,
enter: {
keyframes: [
{
transform: 'translateY(100%)',
},
{
transform: 'translateY(0)',
},
],
options: {
easing: 'cubic-bezier(.36,.66,.04,1)',
duration: 250,
fill: 'forwards',
},
initialStyle: {
transformOrigin: 'bottom',
},
},
enterCallback: (element) => {
element.scrollTop = 0
},
exit: {
keyframes: [
{
transform: 'translateY(0)',
},
{
transform: 'translateY(100%)',
},
],
options: {
easing: 'cubic-bezier(.36,.66,.04,1)',
duration: 250,
fill: 'forwards',
},
initialStyle: {
transformOrigin: 'bottom',
},
},
},
!isMobileScreen,
)
}