mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 21:41:34 +03:00
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:
parent
4e92d784e2
commit
e6ecaff4c4
@ -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} />
|
||||
}
|
@ -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',
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
@ -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 }
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
@ -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',
|
||||
},
|
||||
})
|
||||
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user