refactor: mobile modals (#2173)
This commit is contained in:
@@ -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
|
||||
215
packages/web/src/javascripts/Components/Shared/Modal.tsx
Normal file
215
packages/web/src/javascripts/Components/Shared/Modal.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useMediaQuery, MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
|
||||
import { isIOS } from '@/Utils'
|
||||
import { AlertDialogContent, AlertDialogLabel } from '@reach/alert-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 ModalAndroidBackHandler from './ModalAndroidBackHandler'
|
||||
import ModalDialogDescription from './ModalDialogDescription'
|
||||
|
||||
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
|
||||
customFooter?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const Modal = ({ title, close, actions = [], className = {}, customHeader, 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 hasCancelAction = sortedActions.some((action) => action.type === 'cancel')
|
||||
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} />
|
||||
<AlertDialogContent
|
||||
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,
|
||||
)}
|
||||
>
|
||||
{customHeader ? (
|
||||
customHeader
|
||||
) : (
|
||||
<AlertDialogLabel
|
||||
className={classNames(
|
||||
'flex 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() ? 'pt-safe-top' : 'py-1.5 px-2',
|
||||
)}
|
||||
>
|
||||
<div className="grid w-full grid-cols-[0.35fr_1fr_0.35fr] flex-row items-center justify-between gap-2 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="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>
|
||||
) : sortedActions.length === 0 || !hasCancelAction ? (
|
||||
<MobileModalAction children="Done" action={close} slot="right" />
|
||||
) : null}
|
||||
</div>
|
||||
</AlertDialogLabel>
|
||||
)}
|
||||
<ModalDialogDescription className={className.description}>{children}</ModalDialogDescription>
|
||||
{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>
|
||||
)}
|
||||
</AlertDialogContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Modal
|
||||
@@ -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
|
||||
@@ -12,11 +12,11 @@ const ModalDialog = ({ children, onDismiss, className }: Props) => {
|
||||
const ldRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
return (
|
||||
<AlertDialogOverlay className="px-4 md:px-0" leastDestructiveRef={ldRef} onDismiss={onDismiss}>
|
||||
<AlertDialogOverlay className="p-0 md:px-0" leastDestructiveRef={ldRef} onDismiss={onDismiss}>
|
||||
<AlertDialogContent
|
||||
tabIndex={0}
|
||||
className={classNames(
|
||||
'flex max-h-[85vh] w-full flex-col rounded border border-solid border-border bg-default p-0 shadow-main md:w-160',
|
||||
'm-0 flex w-full flex-col border-solid border-border bg-default p-0 shadow-main md:max-h-[85vh] md:w-160 md:rounded md:border',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isIOS } from '@/Utils'
|
||||
import { classNames } from '@standardnotes/utils'
|
||||
import { FunctionComponent, ReactNode } from 'react'
|
||||
|
||||
@@ -9,7 +10,11 @@ type Props = {
|
||||
const ModalDialogButtons: FunctionComponent<Props> = ({ children, className }) => (
|
||||
<>
|
||||
<hr className="m-0 h-[1px] border-none bg-border" />
|
||||
<div className={classNames('flex items-center justify-end gap-3 px-4 py-4', className)}>{children}</div>
|
||||
<div
|
||||
className={classNames('flex items-center justify-end gap-3 px-4 py-4', isIOS() && 'pb-safe-bottom', className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ type Props = {
|
||||
}
|
||||
|
||||
const ModalDialogDescription: FunctionComponent<Props> = ({ children, className = '' }) => (
|
||||
<AlertDialogDescription className={`overflow-y-auto px-4 py-4 ${className}`}>{children}</AlertDialogDescription>
|
||||
<AlertDialogDescription className={`flex-grow overflow-y-auto ${className}`}>{children}</AlertDialogDescription>
|
||||
)
|
||||
|
||||
export default ModalDialogDescription
|
||||
|
||||
@@ -3,15 +3,26 @@ import { AlertDialogLabel } from '@reach/alert-dialog'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { classNames } from '@standardnotes/utils'
|
||||
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
|
||||
import { isIOS } from '@/Utils'
|
||||
import MobileModalAction from './MobileModalAction'
|
||||
|
||||
type Props = {
|
||||
closeDialog: () => void
|
||||
className?: string
|
||||
headerButtons?: ReactNode
|
||||
leftMobileButton?: ReactNode
|
||||
rightMobileButton?: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const ModalDialogLabel: FunctionComponent<Props> = ({ children, closeDialog, className, headerButtons }) => {
|
||||
const ModalDialogLabel: FunctionComponent<Props> = ({
|
||||
children,
|
||||
closeDialog,
|
||||
className,
|
||||
headerButtons,
|
||||
leftMobileButton,
|
||||
rightMobileButton,
|
||||
}) => {
|
||||
const addAndroidBackHandler = useAndroidBackHandler()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -29,18 +40,31 @@ const ModalDialogLabel: FunctionComponent<Props> = ({ children, closeDialog, cla
|
||||
return (
|
||||
<AlertDialogLabel
|
||||
className={classNames(
|
||||
'flex flex-shrink-0 items-center justify-between rounded-t border-b border-solid border-border bg-default px-4.5 py-3 text-text',
|
||||
'flex flex-shrink-0 items-center justify-between rounded-t border-b border-solid border-border bg-default py-1.5 px-1 text-text md:px-4.5 md:py-3',
|
||||
isIOS() && 'pt-safe-top',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full flex-row items-center justify-between">
|
||||
<div className="flex-grow text-lg font-semibold text-text">{children}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="grid w-full grid-cols-[0.35fr_1fr_0.35fr] flex-row items-center justify-between gap-2 md:flex md:gap-0">
|
||||
{leftMobileButton ? leftMobileButton : <div className="md:hidden" />}
|
||||
<div
|
||||
className={classNames(
|
||||
'overflow-hidden text-ellipsis whitespace-nowrap text-center text-base font-semibold text-text md:flex-grow md:text-left md:text-lg',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
{headerButtons}
|
||||
<button tabIndex={0} className="rounded p-1 font-bold hover:bg-contrast" onClick={closeDialog}>
|
||||
<button tabIndex={0} className="ml-2 rounded p-1 font-bold hover:bg-contrast" onClick={closeDialog}>
|
||||
<Icon type="close" />
|
||||
</button>
|
||||
</div>
|
||||
{rightMobileButton ? (
|
||||
rightMobileButton
|
||||
) : (
|
||||
<MobileModalAction slot="right" children="Done" action={closeDialog} />
|
||||
)}
|
||||
</div>
|
||||
<hr className="h-1px no-border m-0 bg-border" />
|
||||
</AlertDialogLabel>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AlertDialogOverlay } from '@reach/alert-dialog'
|
||||
import { classNames } from '@standardnotes/snjs'
|
||||
import { ReactNode, useRef } from 'react'
|
||||
import { useModalAnimation } from './useModalAnimation'
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean
|
||||
onDismiss?: () => void
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ModalOverlay = ({ isOpen, onDismiss, children, className }: Props) => {
|
||||
const ldRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const [isMounted, setElement] = useModalAnimation(isOpen)
|
||||
|
||||
if (!isMounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialogOverlay
|
||||
className={classNames('p-0 md:px-0 md:opacity-100', className)}
|
||||
leastDestructiveRef={ldRef}
|
||||
onDismiss={onDismiss}
|
||||
ref={setElement}
|
||||
>
|
||||
{children}
|
||||
</AlertDialogOverlay>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalOverlay
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user