Improve Dialog behavior (#10176)

This PR improves the DX and UX of dialogs/popovers:
1. Now the content is wrapped in `<Suspense />` and `<ErrorBoundary />`
2. Dialogs no longer close if they are underlay below the other dialogs
3. Provides an ability to close the dialog underlay component using dialog context
This commit is contained in:
Sergei Garin 2024-06-05 13:41:40 +03:00 committed by GitHub
parent 4e92d784e2
commit e6ecaff4c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 468 additions and 101 deletions

View File

@ -0,0 +1,36 @@
/**
* @file
*
* Close button for a dialog.
*/
import * as React from 'react'
import invariant from 'tiny-invariant'
import * as eventCallback from '#/hooks/eventCallbackHooks'
import * as button from '../Button'
import * as dialogProvider from './DialogProvider'
/**
* Props for {@link Close} component.
*/
export type CloseProps = button.ButtonProps
/**
* Close button for a dialog.
*/
export function Close(props: CloseProps) {
const dialogContext = dialogProvider.useDialogContext()
invariant(dialogContext, 'Close must be used inside a DialogProvider')
const onPressCallback = eventCallback.useEventCallback<
NonNullable<button.ButtonProps['onPress']>
>(event => {
dialogContext.close()
return props.onPress?.(event)
})
return <button.Button {...props} onPress={onPressCallback} />
}

View File

@ -7,13 +7,23 @@ import * as twv from 'tailwind-variants'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as loader from '#/components/Loader'
import * as portal from '#/components/Portal'
import * as mergeRefs from '#/utilities/mergeRefs'
import * as dialogProvider from './DialogProvider'
import * as dialogStackProvider from './DialogStackProvider'
import type * as types from './types'
import * as utlities from './utilities'
import * as variants from './variants'
/**
* Props for the {@link Dialog} component.
*/
export interface DialogProps extends types.DialogProps, twv.VariantProps<typeof DIALOG_STYLES> {}
const OVERLAY_STYLES = twv.tv({
base: 'fixed inset-0 isolate flex items-center justify-center bg-black/[25%]',
variants: {
@ -45,15 +55,13 @@ const DIALOG_STYLES = twv.tv({
},
})
const IGNORE_INTERACT_OUTSIDE_ELEMENTS = ['Toastify__toast-container', 'tsqd-parent-container']
// ==============
// === Dialog ===
// ==============
/** A dialog is an overlay shown above other content in an application.
* Can be used to display alerts, confirmations, or other content. */
export function Dialog(props: types.DialogProps) {
export function Dialog(props: DialogProps) {
const {
children,
title,
@ -65,44 +73,31 @@ export function Dialog(props: types.DialogProps) {
onOpenChange = () => {},
modalProps = {},
testId = 'dialog',
rounded,
...ariaDialogProps
} = props
const shouldCloseOnInteractOutsideRef = React.useRef(false)
const dialogId = aria.useId()
const dialogRef = React.useRef<HTMLDivElement>(null)
const overlayState = React.useRef<aria.OverlayTriggerState | null>(null)
const root = portal.useStrictPortalContext()
const shouldRenderTitle = typeof title === 'string'
const dialogSlots = DIALOG_STYLES({ className, type })
const dialogSlots = DIALOG_STYLES({ className, type, rounded })
aria.useInteractOutside({
utlities.useInteractOutside({
ref: dialogRef,
// we need to prevent the dialog from closing when interacting with the toastify container
// and when interaction starts, we check if the target is inside the toastify container
// and in the next callback we prevent the dialog from closing
// For some reason aria doesn't fire onInteractOutsideStart if onInteractOutside is not defined
onInteractOutsideStart: e => {
// eslint-disable-next-line no-restricted-syntax
const target = e.target as HTMLElement
shouldCloseOnInteractOutsideRef.current = !IGNORE_INTERACT_OUTSIDE_ELEMENTS.some(selector =>
target.closest(`.${selector}`)
)
},
id: dialogId,
onInteractOutside: () => {
if (shouldCloseOnInteractOutsideRef.current) {
if (isDismissable) {
overlayState.current?.close()
} else {
const duration = 200 // 200ms
dialogRef.current?.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(1.015)' }, { transform: 'scale(1)' }],
{ duration, iterations: 1, direction: 'alternate' }
)
}
if (isDismissable) {
overlayState.current?.close()
} else {
const duration = 200 // 200ms
dialogRef.current?.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(1.015)' }, { transform: 'scale(1)' }],
{ duration, iterations: 1, direction: 'alternate' }
)
}
shouldCloseOnInteractOutsideRef.current = false
},
})
@ -129,53 +124,71 @@ export function Dialog(props: types.DialogProps) {
shouldCloseOnInteractOutside={() => false}
{...modalProps}
>
<aria.Dialog
ref={mergeRefs.mergeRefs(dialogRef, element => {
if (element) {
// This is a workaround for the `data-testid` attribute not being
// supported by the 'react-aria-components' library.
// We need to set the `data-testid` attribute on the dialog element
// so that we can use it in our tests.
// This is a temporary solution until we refactor the Dialog component
// to use `useDialog` hook from the 'react-aria-components' library.
// this will allow us to set the `data-testid` attribute on the dialog
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
element.dataset.testId = testId
}
})}
className={dialogSlots.base()}
{...ariaDialogProps}
<dialogStackProvider.DialogStackRegistrar
id={dialogId}
type={TYPE_TO_DIALOG_TYPE[type]}
>
{opts => (
<>
{shouldRenderTitle && (
<aria.Header className={dialogSlots.header()}>
<ariaComponents.CloseButton
className={clsx('col-start-1 col-end-1 mr-auto mt-0.5', {
hidden: hideCloseButton,
})}
onPress={opts.close}
/>
<aria.Dialog
id={dialogId}
ref={mergeRefs.mergeRefs(dialogRef, element => {
if (element) {
// This is a workaround for the `data-testid` attribute not being
// supported by the 'react-aria-components' library.
// We need to set the `data-testid` attribute on the dialog element
// so that we can use it in our tests.
// This is a temporary solution until we refactor the Dialog component
// to use `useDialog` hook from the 'react-aria-components' library.
// this will allow us to set the `data-testid` attribute on the dialog
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
element.dataset.testId = testId
}
})}
className={dialogSlots.base()}
{...ariaDialogProps}
>
{opts => (
<dialogProvider.DialogProvider value={{ close: opts.close, dialogId }}>
{shouldRenderTitle && (
<aria.Header className={dialogSlots.header()}>
<ariaComponents.CloseButton
className={clsx('col-start-1 col-end-1 mr-auto mt-0.5', {
hidden: hideCloseButton,
})}
onPress={opts.close}
/>
<aria.Heading
slot="title"
level={2}
className="col-start-2 col-end-2 my-0 text-base font-semibold leading-6"
>
{title}
</aria.Heading>
</aria.Header>
)}
<aria.Heading
slot="title"
level={2}
className="col-start-2 col-end-2 my-0 text-base font-semibold leading-6"
>
{title}
</aria.Heading>
</aria.Header>
)}
<div className="relative flex-auto overflow-y-auto p-3.5">
{typeof children === 'function' ? children(opts) : children}
</div>
</>
)}
</aria.Dialog>
<div className="relative flex-auto overflow-y-auto p-3.5">
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader minHeight="h32" />}>
{typeof children === 'function' ? children(opts) : children}
</React.Suspense>
</errorBoundary.ErrorBoundary>
</div>
</dialogProvider.DialogProvider>
)}
</aria.Dialog>
</dialogStackProvider.DialogStackRegistrar>
</aria.Modal>
)
}}
</aria.ModalOverlay>
)
}
const TYPE_TO_DIALOG_TYPE: Record<
NonNullable<DialogProps['type']>,
dialogStackProvider.DialogStackItem['type']
> = {
modal: 'dialog',
fullscreen: 'dialog-fullscreen',
}

View File

@ -0,0 +1,32 @@
/**
* @file
*
* The context value for a dialog.
*/
import * as React from 'react'
/**
* The context value for a dialog.
*/
export interface DialogContextValue {
readonly close: () => void
readonly dialogId: string
}
/**
* The context for a dialog.
*/
const DialogContext = React.createContext<DialogContextValue | null>(null)
/**
* The provider for a dialog.
*/
// eslint-disable-next-line no-restricted-syntax
export const DialogProvider = DialogContext.Provider
/**
* Custom hook to get the dialog context.
*/
export function useDialogContext() {
return React.useContext(DialogContext)
}

View File

@ -0,0 +1,122 @@
/**
* @file This file provides the DialogStackProvider component and related functionality.
*/
import * as React from 'react'
import invariant from 'tiny-invariant'
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
/**
* DialogStackItem represents an item in the dialog stack.
*/
export interface DialogStackItem {
readonly id: string
readonly type: 'dialog-fullscreen' | 'dialog' | 'popover'
}
/**
* DialogStackContextType represents the context for the dialog stack.
*/
export interface DialogStackContextType {
readonly stack: DialogStackItem[]
readonly dialogsStack: DialogStackItem[]
readonly add: (item: DialogStackItem) => void
readonly slice: (currentId: string) => void
}
const DialogStackContext = React.createContext<DialogStackContextType | null>(null)
/**
* DialogStackProvider is a React component that provides the dialog stack context to its children.
*/
export function DialogStackProvider(props: React.PropsWithChildren) {
const { children } = props
const [stack, setStack] = React.useState<DialogStackItem[]>([])
const addToStack = eventCallbackHooks.useEventCallback((item: DialogStackItem) => {
setStack(currentStack => [...currentStack, item])
})
const sliceFromStack = eventCallbackHooks.useEventCallback((currentId: string) => {
setStack(currentStack => {
const lastItem = currentStack.at(-1)
if (lastItem?.id === currentId) {
return currentStack.slice(0, -1)
} else {
// eslint-disable-next-line no-restricted-properties
console.warn(`
DialogStackProvider: sliceFromStack: currentId ${currentId} does not match the last item in the stack \n
This is no-op but it might be a sign of a bug in the application \n
Usually, this means that the underlaying component was closed manually or
the stack was not updated properly. \n
`)
return currentStack
}
})
})
const value = React.useMemo<DialogStackContextType>(
() => ({
stack,
dialogsStack: stack.filter(item => ['dialog-fullscreen', 'dialog'].includes(item.type)),
add: addToStack,
slice: sliceFromStack,
}),
[stack, addToStack, sliceFromStack]
)
return <DialogStackContext.Provider value={value}>{children}</DialogStackContext.Provider>
}
/**
* DialogStackRegistrar is a React component that registers a dialog in the dialog stack.
*/
export function DialogStackRegistrar(props: React.PropsWithChildren<DialogStackItem>) {
const { children, id, type } = props
const ctx = React.useContext(DialogStackContext)
invariant(ctx, 'DialogStackRegistrar must be used within a DialogStackProvider')
React.useEffect(() => {
ctx.add({ id, type })
return () => {
ctx.slice(id)
}
// We don't want to re-run this effect on every render
// As well as we don't want to re-run it when the id or type changes
// This effect should run only once when the component mounts
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return children
}
/**
* Props for {@link useDialogStackState}
*/
export interface UseDialogStackStateProps {
readonly id: string
}
/**
* useDialogStackState is a custom hook that provides the state of the dialog stack.
*/
export function useDialogStackState(props: UseDialogStackStateProps) {
const ctx = React.useContext(DialogStackContext)
invariant(ctx, 'useDialogStackState must be used within a DialogStackProvider')
const { id } = props
const isLatest = ctx.stack.at(-1)?.id === id
const index = ctx.stack.findIndex(item => item.id === id)
return { isLatest, index }
}

View File

@ -9,8 +9,13 @@ import type * as types from './types'
const PLACEHOLDER = <div />
/**
* Props for a {@link DialogTrigger}.
*/
export interface DialogTriggerProps extends types.DialogTriggerProps {}
/** A DialogTrigger opens a dialog when a trigger element is pressed. */
export function DialogTrigger(props: types.DialogTriggerProps) {
export function DialogTrigger(props: DialogTriggerProps) {
const { children, onOpenChange, ...triggerProps } = props
const { setModal, unsetModal } = modalProvider.useSetModal()
@ -31,6 +36,8 @@ export function DialogTrigger(props: types.DialogTriggerProps) {
)
return (
<aria.DialogTrigger children={children} onOpenChange={onOpenChangeInternal} {...triggerProps} />
<aria.DialogTrigger onOpenChange={onOpenChangeInternal} {...triggerProps}>
{children}
</aria.DialogTrigger>
)
}

View File

@ -8,15 +8,30 @@ import * as React from 'react'
import * as twv from 'tailwind-variants'
import * as aria from '#/components/aria'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as loader from '#/components/Loader'
import * as portal from '#/components/Portal'
import * as dialogProvider from './DialogProvider'
import * as dialogStackProvider from './DialogStackProvider'
import * as utlities from './utilities'
import * as variants from './variants'
/**
* Props for the Popover component.
*/
export interface PopoverProps extends aria.PopoverProps, twv.VariantProps<typeof POPOVER_STYLES> {}
export interface PopoverProps
extends Omit<aria.PopoverProps, 'children'>,
twv.VariantProps<typeof POPOVER_STYLES> {
readonly children:
| React.ReactNode
// eslint-disable-next-line no-restricted-syntax
| ((opts: aria.PopoverRenderProps & { readonly close: () => void }) => React.ReactNode)
}
export const POPOVER_STYLES = twv.tv({
base: 'bg-white shadow-lg',
extend: variants.DIALOG_BACKGROUND,
base: 'shadow-2xl w-full',
variants: {
isEntering: {
true: 'animate-in fade-in placement-bottom:slide-in-from-top-1 placement-top:slide-in-from-bottom-1 placement-left:slide-in-from-right-1 placement-right:slide-in-from-left-1 ease-out duration-200',
@ -25,20 +40,26 @@ export const POPOVER_STYLES = twv.tv({
true: 'animate-out fade-out placement-bottom:slide-out-to-top-1 placement-top:slide-out-to-bottom-1 placement-left:slide-out-to-right-1 placement-right:slide-out-to-left-1 ease-in duration-150',
},
size: {
xsmall: 'max-w-xs',
small: 'max-w-sm',
medium: 'max-w-md',
large: 'max-w-lg',
hero: 'max-w-xl',
xsmall: { base: 'max-w-xs', content: 'p-2.5' },
small: { base: 'max-w-sm', content: 'p-3.5' },
medium: { base: 'max-w-md', content: 'p-3.5' },
large: { base: 'max-w-lg', content: 'px-4 py-4' },
hero: { base: 'max-w-xl', content: 'px-6 py-5' },
},
roundings: {
rounded: {
none: '',
small: 'rounded-sm',
medium: 'rounded-md',
large: 'rounded-lg',
full: 'rounded-full',
small: 'rounded-sm before:rounded-sm',
medium: 'rounded-md before:rounded-md',
large: 'rounded-lg before:rounded-lg',
xlarge: 'rounded-xl before:rounded-xl',
xxlarge: 'rounded-2xl before:rounded-2xl',
xxxlarge: 'rounded-3xl before:rounded-3xl',
},
},
slots: {
content: 'flex-auto overflow-y-auto',
},
defaultVariants: { rounded: 'xxlarge', size: 'small' },
})
/**
@ -46,8 +67,26 @@ export const POPOVER_STYLES = twv.tv({
* It can be used to display additional content or actions.*
*/
export function Popover(props: PopoverProps) {
const { children, className, size = 'small', roundings = 'large', ...ariaPopoverProps } = props
const {
children,
className,
size,
rounded,
placement = 'bottom start',
...ariaPopoverProps
} = props
const dialogRef = React.useRef<HTMLDivElement>(null)
const closeRef = React.useRef<(() => void) | null>(null)
const root = portal.useStrictPortalContext()
const dialogId = aria.useId()
utlities.useInteractOutside({
ref: dialogRef,
id: dialogId,
onInteractOutside: closeRef.current,
})
return (
<aria.Popover
@ -55,19 +94,36 @@ export function Popover(props: PopoverProps) {
POPOVER_STYLES({
...values,
size,
roundings,
rounded,
className: typeof className === 'function' ? className(values) : className,
})
}).base()
}
UNSTABLE_portalContainer={root}
placement={placement}
style={{ zIndex: 'unset' }}
shouldCloseOnInteractOutside={() => false}
{...ariaPopoverProps}
>
{opts => (
<aria.Dialog>
<div className="relative flex-auto overflow-y-auto p-3.5">
{typeof children === 'function' ? children(opts) : children}
</div>
</aria.Dialog>
<dialogStackProvider.DialogStackRegistrar id={dialogId} type="popover">
<aria.Dialog id={dialogId} ref={dialogRef}>
{({ close }) => {
closeRef.current = close
return (
<div className={POPOVER_STYLES({ ...opts, size, rounded }).content()}>
<dialogProvider.DialogProvider value={{ close, dialogId }}>
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader minHeight="h16" />}>
{typeof children === 'function' ? children({ ...opts, close }) : children}
</React.Suspense>
</errorBoundary.ErrorBoundary>
</dialogProvider.DialogProvider>
</div>
)
}}
</aria.Dialog>
</dialogStackProvider.DialogStackRegistrar>
)}
</aria.Popover>
)

View File

@ -4,6 +4,16 @@
* Re-exports the Dialog component.
*/
export * from './Dialog'
export * from './types'
export * from './DialogTrigger'
export * from './Popover'
export * from './Close'
// eslint-disable-next-line no-restricted-syntax
export { useDialogContext, type DialogContextValue } from './DialogProvider'
export {
// eslint-disable-next-line no-restricted-syntax
DialogStackProvider,
// eslint-disable-next-line no-restricted-syntax
type DialogStackItem,
// eslint-disable-next-line no-restricted-syntax
type DialogStackContextType,
} from './DialogStackProvider'

View File

@ -1,14 +1,10 @@
/** @file Types for the Dialog component. */
import type * as aria from '#/components/aria'
/** The type of Dialog. */
export type DialogType = 'fullscreen' | 'modal'
/** Props for the Dialog component. */
export interface DialogProps extends aria.DialogProps {
/** The type of dialog to render.
* @default 'modal' */
readonly type?: DialogType
readonly title?: string
readonly isDismissable?: boolean
readonly hideCloseButton?: boolean

View File

@ -0,0 +1,72 @@
/**
* @file
*
* This file contains a function that checks if the element is a part of a component that should ignore the interact outside event.
*/
import * as React from 'react'
import * as eventCallback from '#/hooks/eventCallbackHooks'
import * as aria from '#/components/aria'
import * as dialogStackProvider from './DialogStackProvider'
const IGNORE_INTERACT_OUTSIDE_ELEMENTS = [
// Toastify toasts
'.Toastify__toast-container',
// ReactQuery devtools
'.tsqd-parent-container',
// Our components that should ignore the interact outside event
':is(.enso-dashboard, .enso-chat, .enso-portal-root) [data-ignore-click-outside]',
]
const IGNORE_INTERACT_OUTSIDE_ELEMENTS_SELECTOR = `:is(${IGNORE_INTERACT_OUTSIDE_ELEMENTS.join(', ')})`
/**
* Check if the element is a part of a component that should ignore the interact outside event
*/
export function shouldIgnoreInteractOutside(element: HTMLElement) {
return element.closest(IGNORE_INTERACT_OUTSIDE_ELEMENTS_SELECTOR)
}
/**
* Props for {@link useInteractOutside}
*/
export interface UseInteractOutsideProps {
readonly ref: React.RefObject<HTMLElement>
readonly id: string
readonly onInteractOutside?: (() => void) | null
readonly isDisabled?: boolean
}
/**
* Hook that handles the interact outside event for the dialog
*/
export function useInteractOutside(props: UseInteractOutsideProps) {
const { ref, id, onInteractOutside, isDisabled = false } = props
const shouldCloseOnInteractOutsideRef = React.useRef(false)
const { isLatest } = dialogStackProvider.useDialogStackState({ id })
const onInteractOutsideStartCb = eventCallback.useEventCallback((e: MouseEvent) => {
// eslint-disable-next-line no-restricted-syntax
shouldCloseOnInteractOutsideRef.current = !shouldIgnoreInteractOutside(e.target as HTMLElement)
})
const onInteractOutsideCb = eventCallback.useEventCallback(() => {
if (shouldCloseOnInteractOutsideRef.current) {
onInteractOutside?.()
shouldCloseOnInteractOutsideRef.current = false
}
})
aria.useInteractOutside({
ref,
isDisabled: isDisabled || !isLatest,
// we need to prevent the dialog from closing when interacting with the toastify container
// and when interaction starts, we check if the target is inside the toastify container
// and in the next callback we prevent the dialog from closing
// For some reason aria doesn't fire onInteractOutsideStart if onInteractOutside is not defined
onInteractOutsideStart: onInteractOutsideStartCb,
onInteractOutside: onInteractOutsideCb,
})
}

View File

@ -5,6 +5,25 @@
*/
import * as twv from 'tailwind-variants'
export const DIALOG_STYLES = twv.tv({
base: 'relative flex flex-col overflow-hidden rounded-default text-left align-middle shadow-sm bg-clip-padding border border-primary/10 before:absolute before:inset before:h-full before:w-full before:rounded-xl before:bg-selected-frame before:backdrop-blur-default',
export const DIALOG_BACKGROUND = twv.tv({
base: 'bg-clip-padding relative before:absolute before:inset before:h-full before:w-full before:bg-selected-frame before:backdrop-blur-default [:where(&>*)]:relative',
})
export const DIALOG_STYLES = twv.tv({
extend: DIALOG_BACKGROUND,
base: 'flex flex-col overflow-hidden text-left align-middle shadow-sm border border-primary/10',
variants: {
rounded: {
none: '',
small: 'rounded-sm before:rounded-sm',
medium: 'rounded-md before:rounded-md',
large: 'rounded-lg before:rounded-lg',
xlarge: 'rounded-xl before:rounded-xl',
xxlarge: 'rounded-2xl before:rounded-2xl',
xxxlarge: 'rounded-3xl before:rounded-3xl',
},
},
defaultVariants: {
rounded: 'xxlarge',
},
})

View File

@ -79,6 +79,7 @@ export function Tooltip(props: TooltipProps) {
className={aria.composeRenderProps(className, (classNames, values) =>
TOOLTIP_STYLES({ className: classNames, ...values })
)}
data-ignore-click-outside
{...ariaTooltipProps}
/>
)

View File

@ -2,6 +2,7 @@
import * as React from 'react'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as portal from '#/components/Portal'
// ============
@ -22,7 +23,9 @@ export function Root(props: RootProps) {
return (
<portal.PortalProvider value={portalRoot}>
<aria.RouterProvider navigate={navigate}>
<aria.I18nProvider locale={locale}>{children}</aria.I18nProvider>
<aria.I18nProvider locale={locale}>
<ariaComponents.DialogStackProvider>{children}</ariaComponents.DialogStackProvider>
</aria.I18nProvider>
</aria.RouterProvider>
</portal.PortalProvider>
)