Fix resize animations in Dialog (#11643)

This PR fixes the Resize animations in Dialog component:

1. Removes resize for initial mount / fullscreen dialogs
2. Fixes measuring the content size
3. Fixes bugs in `useMeasure` hook
4. Adds memoization for Text and Loader components (because of react-compiler and because this components accept only primitive values)
This commit is contained in:
Sergei Garin 2024-11-28 22:15:34 +03:00 committed by GitHub
parent b5f110617e
commit 0c7e79cccf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 806 additions and 388 deletions

View File

@ -477,6 +477,8 @@
"cannotCreateAssetsHere": "You do not have the permissions to create assets here.",
"enableVersionChecker": "Enable Version Checker",
"enableVersionCheckerDescription": "Show a dialog if the current version of the desktop app does not match the latest version.",
"disableAnimations": "Disable animations",
"disableAnimationsDescription": "Disable all animations in the app.",
"removeTheLocalDirectoryXFromFavorites": "remove the local folder '$0' from your favorites",
"changeLocalRootDirectoryInSettings": "Change your root folder in Settings.",
"localStorage": "Local Storage",

View File

@ -4,7 +4,7 @@
import type { Preview as ReactPreview } from '@storybook/react'
import type { Preview as VuePreview } from '@storybook/vue3'
import isChromatic from 'chromatic/isChromatic'
import { useLayoutEffect, useState } from 'react'
import { StrictMode, useLayoutEffect, useState } from 'react'
import invariant from 'tiny-invariant'
import UIProviders from '../src/dashboard/components/UIProviders'
@ -59,22 +59,34 @@ const reactPreview: ReactPreview = {
return (
<UIProviders locale="en-US" portalRoot={portalRoot}>
{Story(context)}
<Story {...context} />
</UIProviders>
)
},
(Story, context) => (
<>
<div className="enso-dashboard">{Story(context)}</div>
<div className="enso-dashboard">
<Story {...context} />
</div>
<div id="enso-portal-root" className="enso-portal-root" />
</>
),
(Story, context) => {
const [queryClient] = useState(() => createQueryClient())
return <QueryClientProvider client={queryClient}>{Story(context)}</QueryClientProvider>
return (
<QueryClientProvider client={queryClient}>
<Story {...context} />
</QueryClientProvider>
)
},
(Story, context) => (
<StrictMode>
<Story {...context} />
</StrictMode>
),
],
}

View File

@ -59,8 +59,6 @@ import ModalProvider, * as modalProvider from '#/providers/ModalProvider'
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
import SessionProvider from '#/providers/SessionProvider'
import * as textProvider from '#/providers/TextProvider'
import type { Spring } from 'framer-motion'
import { MotionConfig } from 'framer-motion'
import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
import ForgotPassword from '#/pages/authentication/ForgotPassword'
@ -105,16 +103,6 @@ import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal'
// === Global configuration ===
// ============================
const DEFAULT_TRANSITION_OPTIONS: Spring = {
type: 'spring',
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
stiffness: 200,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
damping: 30,
mass: 1,
velocity: 0,
}
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
@ -532,7 +520,6 @@ function AppRouter(props: AppRouterProps) {
return (
<FeatureFlagsProvider>
<MotionConfig reducedMotion="user" transition={DEFAULT_TRANSITION_OPTIONS}>
<RouterProvider navigate={navigate}>
<SessionProvider
saveAccessToken={authService.cognito.saveAccessToken.bind(authService.cognito)}
@ -567,7 +554,6 @@ function AppRouter(props: AppRouterProps) {
</BackendProvider>
</SessionProvider>
</RouterProvider>
</MotionConfig>
</FeatureFlagsProvider>
)
}

View File

@ -0,0 +1,105 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useLayoutEffect, useRef } from 'react'
import { DialogTrigger } from 'react-aria-components'
import { Button } from '../Button'
import { Dialog, type DialogProps } from './Dialog'
type Story = StoryObj<DialogProps>
export default {
title: 'AriaComponents/Dialog',
component: Dialog,
render: (args) => (
<DialogTrigger defaultOpen>
<Button>Open Dialog</Button>
<Dialog {...args} />
</DialogTrigger>
),
args: {
type: 'modal',
title: 'Dialog Title',
children: 'Dialog Content',
},
} as Meta<DialogProps>
export const Default = {}
// Use a random query key to avoid caching
const QUERY_KEY = Math.random().toString()
function SuspenseContent({ delay = 10_000 }: { delay?: number }): React.ReactNode {
useSuspenseQuery({
queryKey: [QUERY_KEY],
gcTime: 0,
initialDataUpdatedAt: 0,
queryFn: () =>
new Promise((resolve) => {
setTimeout(() => {
resolve('resolved')
}, delay)
}),
})
return (
<div className="flex h-[250px] flex-col items-center justify-center text-center">
Unsuspended content
</div>
)
}
export const Suspened = {
args: {
children: <SuspenseContent delay={10_000_000_000} />,
},
}
function BrokenContent(): React.ReactNode {
throw new Error('💣')
}
export const Broken = {
args: {
children: <BrokenContent />,
},
}
function ResizableContent() {
const divRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const getRandomHeight = () => Math.floor(Math.random() * 250 + 100)
if (divRef.current) {
divRef.current.style.height = `${getRandomHeight()}px`
setInterval(() => {
if (divRef.current) {
divRef.current.style.height = `${getRandomHeight()}px`
}
}, 2_000)
}
}, [])
return (
<div ref={divRef} className="flex flex-none items-center justify-center text-center">
This dialog should resize with animation
</div>
)
}
export const AnimateSize: Story = {
args: {
children: <ResizableContent />,
},
parameters: {
chromatic: { disableSnapshot: true },
},
}
export const Fullscreen = {
args: {
type: 'fullscreen',
},
}

View File

@ -12,9 +12,9 @@ import * as suspense from '#/components/Suspense'
import * as mergeRefs from '#/utilities/mergeRefs'
import { useDimensions } from '#/hooks/dimensionsHooks'
import type { Spring } from '#/utilities/motion'
import { motion } from '#/utilities/motion'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useMeasure } from '#/hooks/measureHooks'
import { motion, type Spring } from '#/utilities/motion'
import type { VariantProps } from '#/utilities/tailwindVariants'
import { tv } from '#/utilities/tailwindVariants'
import { Close } from './Close'
@ -24,7 +24,6 @@ import type * as types from './types'
import * as utlities from './utilities'
import { DIALOG_BACKGROUND } from './variants'
// This is a JSX component, even though it does not contain function syntax.
// eslint-disable-next-line no-restricted-syntax
const MotionDialog = motion(aria.Dialog)
@ -33,7 +32,7 @@ const OVERLAY_STYLES = tv({
variants: {
isEntering: { true: 'animate-in fade-in duration-200 ease-out' },
isExiting: { true: 'animate-out fade-out duration-200 ease-in' },
blockInteractions: { true: 'backdrop-blur-md' },
blockInteractions: { true: 'backdrop-blur-md transition-[backdrop-filter] duration-200' },
},
})
@ -61,12 +60,10 @@ const DIALOG_STYLES = tv({
modal: {
base: 'w-full min-h-[100px] max-h-[90vh]',
header: 'px-3.5 pt-[3px] pb-0.5 min-h-[42px]',
measuredContent: 'max-h-[90vh]',
},
fullscreen: {
base: 'w-full h-full max-w-full max-h-full bg-clip-border',
header: 'px-4 pt-[5px] pb-1.5 min-h-12',
measuredContent: 'max-h-[100vh]',
},
},
fitContent: {
@ -92,9 +89,9 @@ const DIALOG_STYLES = tv({
medium: { base: 'rounded-md' },
large: { base: 'rounded-lg' },
xlarge: { base: 'rounded-xl' },
xxlarge: { base: 'rounded-2xl', content: 'scroll-offset-edge-2xl' },
xxxlarge: { base: 'rounded-3xl', content: 'scroll-offset-edge-4xl' },
xxxxlarge: { base: 'rounded-4xl', content: 'scroll-offset-edge-6xl' },
xxlarge: { base: 'rounded-2xl', scroller: 'scroll-offset-edge-2xl' },
xxxlarge: { base: 'rounded-3xl', scroller: 'scroll-offset-edge-3xl' },
xxxxlarge: { base: 'rounded-4xl', scroller: 'scroll-offset-edge-4xl' },
},
/**
* The size of the dialog.
@ -125,8 +122,10 @@ const DIALOG_STYLES = tv({
'sticky z-1 top-0 grid grid-cols-[1fr_auto_1fr] items-center border-b border-primary/10 transition-[border-color] duration-150',
closeButton: 'col-start-1 col-end-1 mr-auto',
heading: 'col-start-2 col-end-2 my-0 text-center',
content: 'relative flex-auto overflow-y-auto max-h-[inherit]',
measuredContent: 'flex flex-col',
scroller: 'flex flex-col overflow-y-auto max-h-[inherit]',
measurerWrapper: 'inline-grid h-fit max-h-fit min-h-fit w-full grid-rows-[auto]',
measurer: 'pointer-events-none block [grid-area:1/1]',
content: 'inline-block h-fit max-h-fit min-h-fit [grid-area:1/1] min-w-0',
},
compoundVariants: [
{ type: 'modal', size: 'small', class: 'max-w-sm' },
@ -142,17 +141,17 @@ const DIALOG_STYLES = tv({
closeButton: 'normal',
hideCloseButton: false,
size: 'medium',
padding: 'medium',
padding: 'none',
rounded: 'xxxlarge',
},
})
const RESIZE_TRANSITION_STYLES: Spring = {
const TRANSITION: Spring = {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
stiffness: 2_000,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
damping: 90,
type: 'spring',
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
stiffness: 300,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
damping: 25,
mass: 1,
}
@ -171,79 +170,15 @@ export interface DialogProps
*/
export function Dialog(props: DialogProps) {
const {
children,
title,
type = 'modal',
closeButton = 'normal',
isDismissable = true,
isKeyboardDismissDisabled = false,
hideCloseButton = false,
className,
onOpenChange = () => {},
modalProps = {},
testId = 'dialog',
size,
rounded,
padding: paddingRaw,
fitContent,
variants = DIALOG_STYLES,
...ariaDialogProps
} = props
const padding = paddingRaw ?? (type === 'modal' ? 'medium' : 'xlarge')
const [isScrolledToTop, setIsScrolledToTop] = React.useState(true)
/** Handles the scroll event on the dialog content. */
const handleScroll = (scrollTop: number) => {
React.startTransition(() => {
if (scrollTop > 0) {
setIsScrolledToTop(false)
} else {
setIsScrolledToTop(true)
}
})
}
const dialogId = aria.useId()
const dialogLayoutId = `dialog-${dialogId}`
const titleId = `${dialogId}-title`
const [contentDimensionsRef, dimensions] = useDimensions()
const dialogWidth = dimensions.width || '100%'
const dialogHeight = dimensions.height || '100%'
const dialogRef = React.useRef<HTMLDivElement>(null)
const overlayState = React.useRef<aria.OverlayTriggerState | null>(null)
const root = portal.useStrictPortalContext()
const styles = variants({
className,
type,
rounded,
hideCloseButton,
closeButton,
scrolledToTop: isScrolledToTop,
size,
padding,
fitContent,
})
utlities.useInteractOutside({
ref: dialogRef,
id: dialogId,
onInteractOutside: () => {
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' },
)
}
},
})
return (
<aria.ModalOverlay
className={({ isEntering, isExiting }) =>
@ -256,10 +191,7 @@ export function Dialog(props: DialogProps) {
shouldCloseOnInteractOutside={() => false}
{...modalProps}
>
{(values) => {
overlayState.current = values.state
return (
{(values) => (
<aria.Modal
className={({ isEntering, isExiting }) => MODAL_STYLES({ type, isEntering, isExiting })}
isDismissable={isDismissable}
@ -269,84 +201,9 @@ export function Dialog(props: DialogProps) {
shouldCloseOnInteractOutside={() => false}
{...modalProps}
>
<dialogStackProvider.DialogStackRegistrar
id={dialogId}
type={TYPE_TO_DIALOG_TYPE[type]}
>
<MotionDialog
layout
layoutId={dialogLayoutId}
animate={{ width: dialogWidth, height: dialogHeight }}
transition={RESIZE_TRANSITION_STYLES}
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
element.dataset.testId = testId
}
})}
className={styles.base()}
aria-labelledby={titleId}
{...ariaDialogProps}
>
{(opts) => (
<div className={styles.measuredContent()} ref={contentDimensionsRef}>
<dialogProvider.DialogProvider value={{ close: opts.close, dialogId }}>
{(closeButton !== 'none' || title != null) && (
<aria.Header className={styles.header({ scrolledToTop: isScrolledToTop })}>
{closeButton !== 'none' && (
<ariaComponents.CloseButton
className={styles.closeButton()}
onPress={opts.close}
/>
)}
{title != null && (
<ariaComponents.Text.Heading
id={titleId}
level={2}
className={styles.heading()}
weight="semibold"
>
{title}
</ariaComponents.Text.Heading>
)}
</aria.Header>
)}
<div
ref={(ref) => {
if (ref) {
handleScroll(ref.scrollTop)
}
}}
className={styles.content()}
onScroll={(event) => {
handleScroll(event.currentTarget.scrollTop)
}}
>
<errorBoundary.ErrorBoundary>
<suspense.Suspense
loaderProps={{ minHeight: type === 'fullscreen' ? 'full' : 'h32' }}
>
{typeof children === 'function' ? children(opts) : children}
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</div>
</dialogProvider.DialogProvider>
</div>
)}
</MotionDialog>
</dialogStackProvider.DialogStackRegistrar>
<DialogContent {...props} modalState={values.state} />
</aria.Modal>
)
}}
)}
</aria.ModalOverlay>
)
}
@ -359,4 +216,248 @@ const TYPE_TO_DIALOG_TYPE: Record<
fullscreen: 'dialog-fullscreen',
}
/**
* Props for the {@link DialogContent} component.
*/
interface DialogContentProps extends DialogProps, VariantProps<typeof DIALOG_STYLES> {
readonly modalState: aria.OverlayTriggerState
}
/**
* The content of a dialog.
* @internal
*/
function DialogContent(props: DialogContentProps) {
const {
variants = DIALOG_STYLES,
modalState,
className,
type = 'modal',
rounded,
hideCloseButton = false,
closeButton = 'normal',
size,
padding: paddingRaw,
fitContent,
testId = 'dialog',
title,
children,
isDismissable = true,
...ariaDialogProps
} = props
const dialogRef = React.useRef<HTMLDivElement>(null)
const dialogId = aria.useId()
const titleId = `${dialogId}-title`
const padding = paddingRaw ?? (type === 'modal' ? 'medium' : 'xlarge')
const isFullscreen = type === 'fullscreen'
const [isScrolledToTop, setIsScrolledToTop] = React.useState(true)
const [isLayoutDisabled, setIsLayoutDisabled] = React.useState(true)
const [contentDimensionsRef, dimensions] = useMeasure({
isDisabled: isLayoutDisabled,
useRAF: true,
})
const [headerDimensionsRef, headerDimensions] = useMeasure({
isDisabled: isLayoutDisabled,
useRAF: true,
})
utlities.useInteractOutside({
ref: dialogRef,
id: dialogId,
onInteractOutside: () => {
if (isDismissable) {
modalState.close()
} else {
if (dialogRef.current) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
utlities.animateScale(dialogRef.current, 1.02)
}
}
},
})
/** Handles the scroll event on the dialog content. */
const handleScroll = useEventCallback((ref: HTMLDivElement | null) => {
React.startTransition(() => {
if (ref && ref.scrollTop > 0) {
setIsScrolledToTop(false)
} else {
setIsScrolledToTop(true)
}
})
})
const handleScrollEvent = useEventCallback((event: React.UIEvent<HTMLDivElement>) => {
handleScroll(event.currentTarget)
})
React.useEffect(() => {
if (isFullscreen) {
return
}
setIsLayoutDisabled(false)
return () => {
setIsLayoutDisabled(true)
}
}, [isFullscreen])
const styles = variants({
className,
type,
rounded,
hideCloseButton,
closeButton,
scrolledToTop: isScrolledToTop,
size,
padding,
fitContent,
})
const dialogHeight =
dimensions == null || headerDimensions == null ?
null
: dimensions.height + headerDimensions.height
return (
<>
<MotionDialog
layout
transition={TRANSITION}
style={dialogHeight != null ? { height: dialogHeight } : undefined}
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
element.dataset.testId = testId
}
})
}
className={styles.base()}
aria-labelledby={titleId}
{...ariaDialogProps}
>
{(opts) => (
<>
<dialogProvider.DialogProvider close={opts.close} dialogId={dialogId}>
<motion.div layout className="w-full" transition={{ duration: 0 }}>
<DialogHeader
closeButton={closeButton}
title={title}
titleId={titleId}
headerClassName={styles.header({ scrolledToTop: isScrolledToTop })}
closeButtonClassName={styles.closeButton()}
headingClassName={styles.heading()}
headerDimensionsRef={headerDimensionsRef}
/>
</motion.div>
<motion.div
layout
layoutScroll
className={styles.scroller()}
ref={handleScroll}
onScroll={handleScrollEvent}
transition={{ duration: 0 }}
>
<div className={styles.measurerWrapper()}>
{/* eslint-disable jsdoc/check-alignment */}
{/**
* This div is used to measure the content dimensions.
* It's takes the same grid area as the content, thus
* resizes together with the content.
*
* We use grid + grid-area to avoid setting `position: relative`
* on the element, which would interfere with the layout.
*
* It's set to `pointer-events-none` so that it doesn't
* interfere with the layout.
*/}
{/* eslint-enable jsdoc/check-alignment */}
<div ref={contentDimensionsRef} className={styles.measurer()} />
<div className={styles.content()}>
<errorBoundary.ErrorBoundary>
<suspense.Suspense
loaderProps={{ minHeight: type === 'fullscreen' ? 'full' : 'h32' }}
>
{typeof children === 'function' ? children(opts) : children}
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</div>
</div>
</motion.div>
</dialogProvider.DialogProvider>
</>
)}
</MotionDialog>
<dialogStackProvider.DialogStackRegistrar id={dialogId} type={TYPE_TO_DIALOG_TYPE[type]} />
</>
)
}
/**
* Props for the {@link DialogHeader} component.
*/
interface DialogHeaderProps {
readonly headerClassName: string
readonly closeButtonClassName: string
readonly headingClassName: string
readonly closeButton: DialogProps['closeButton']
readonly title: DialogProps['title']
readonly titleId: string
readonly headerDimensionsRef: (node: HTMLElement | null) => void
}
/**
* The header of a dialog.
* @internal
*/
// eslint-disable-next-line no-restricted-syntax
const DialogHeader = React.memo(function DialogHeader(props: DialogHeaderProps) {
const {
closeButton,
title,
titleId,
headerClassName,
closeButtonClassName,
headingClassName,
headerDimensionsRef,
} = props
const { close } = dialogProvider.useDialogStrictContext()
return (
<aria.Header ref={headerDimensionsRef} className={headerClassName}>
{closeButton !== 'none' && (
<ariaComponents.CloseButton className={closeButtonClassName} onPress={close} />
)}
{title != null && (
<ariaComponents.Text.Heading
id={titleId}
level={2}
className={headingClassName}
weight="semibold"
>
{title}
</ariaComponents.Text.Heading>
)}
</aria.Header>
)
})
Dialog.Close = Close

View File

@ -4,6 +4,7 @@
* The context value for a dialog.
*/
import * as React from 'react'
import invariant from 'tiny-invariant'
/** The context value for a dialog. */
export interface DialogContextValue {
@ -15,10 +16,25 @@ export interface DialogContextValue {
const DialogContext = React.createContext<DialogContextValue | null>(null)
/** The provider for a dialog. */
// eslint-disable-next-line no-restricted-syntax
export const DialogProvider = DialogContext.Provider
export function DialogProvider(props: DialogContextValue & React.PropsWithChildren) {
const { children, close, dialogId } = props
const value = React.useMemo(() => ({ close, dialogId }), [close, dialogId])
return <DialogContext.Provider value={value}>{children}</DialogContext.Provider>
}
/** Custom hook to get the dialog context. */
export function useDialogContext() {
return React.useContext(DialogContext)
}
/**
* Custom hook to get the dialog context.
* @throws if the hook is used outside of a DialogProvider
*/
export function useDialogStrictContext() {
const context = useDialogContext()
invariant(context != null, 'useDialogStrictContext must be used within a DialogProvider')
return context
}

View File

@ -68,8 +68,10 @@ export function DialogStackProvider(props: React.PropsWithChildren) {
}
/** 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
export const DialogStackRegistrar = React.memo(function DialogStackRegistrar(
props: DialogStackItem,
) {
const { id, type } = props
const store = React.useContext(DialogStackContext)
invariant(store, 'DialogStackRegistrar must be used within a DialogStackProvider')
@ -88,8 +90,8 @@ export function DialogStackRegistrar(props: React.PropsWithChildren<DialogStackI
}
}, [add, slice, id, type])
return children
}
return null
})
/** Props for {@link useDialogStackState} */
export interface UseDialogStackStateProps {

View File

@ -0,0 +1,56 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect, userEvent, waitFor, within } from '@storybook/test'
import { Button } from '../Button'
import { DialogTrigger } from './DialogTrigger'
import { Popover, type PopoverProps } from './Popover'
type Story = StoryObj<PopoverProps>
export default {
title: 'AriaComponents/Popover',
component: Popover,
args: {
children: 'Popover content',
},
render: (props: PopoverProps) => (
<DialogTrigger>
<Button>Open Dialog</Button>
<Popover {...props} />
</DialogTrigger>
),
} satisfies Meta<PopoverProps>
export const Default: Story = {
args: {
isOpen: true,
},
}
export const Dismissible: Story = {
play: async ({ canvasElement }) => {
const { getByRole, queryByRole } = within(canvasElement)
await userEvent.click(getByRole('button'))
await expect(getByRole('dialog')).toBeInTheDocument()
await userEvent.click(document.body)
await waitFor(() => expect(queryByRole('dialog')).not.toBeInTheDocument())
},
}
export const NonDidmissible: Story = {
args: {
isDismissable: false,
},
play: async ({ canvasElement }) => {
const { getByRole } = within(canvasElement)
await userEvent.click(getByRole('button'))
await expect(getByRole('dialog')).toBeInTheDocument()
await userEvent.click(document.body)
await expect(getByRole('dialog')).toBeInTheDocument()
},
}

View File

@ -25,6 +25,7 @@ export interface PopoverProps
readonly children:
| React.ReactNode
| ((opts: aria.PopoverRenderProps & { readonly close: () => void }) => React.ReactNode)
readonly isDismissable?: boolean
}
export const POPOVER_STYLES = twv.tv({
@ -47,21 +48,19 @@ export const POPOVER_STYLES = twv.tv({
},
rounded: {
none: { base: 'rounded-none', dialog: 'rounded-none' },
small: { base: 'rounded-sm', dialog: 'rounded-sm' },
medium: { base: 'rounded-md', dialog: 'rounded-md' },
large: { base: 'rounded-lg', dialog: 'rounded-lg' },
xlarge: { base: 'rounded-xl', dialog: 'rounded-xl' },
xxlarge: { base: 'rounded-2xl', dialog: 'rounded-2xl' },
xxxlarge: { base: 'rounded-3xl', dialog: 'rounded-3xl' },
xxxxlarge: { base: 'rounded-4xl', dialog: 'rounded-4xl' },
small: { base: 'rounded-sm', dialog: 'rounded-sm scroll-offset-edge-md' },
medium: { base: 'rounded-md', dialog: 'rounded-md scroll-offset-edge-xl' },
large: { base: 'rounded-lg', dialog: 'rounded-lg scroll-offset-edge-xl' },
xlarge: { base: 'rounded-xl', dialog: 'rounded-xl scroll-offset-edge-xl' },
xxlarge: { base: 'rounded-2xl', dialog: 'rounded-2xl scroll-offset-edge-2xl' },
xxxlarge: { base: 'rounded-3xl', dialog: 'rounded-3xl scroll-offset-edge-3xl' },
xxxxlarge: { base: 'rounded-4xl', dialog: 'rounded-4xl scroll-offset-edge-4xl' },
},
},
slots: {
dialog: variants.DIALOG_BACKGROUND({
class: 'flex-auto overflow-y-auto max-h-[inherit]',
}),
dialog: variants.DIALOG_BACKGROUND({ class: 'flex-auto overflow-y-auto max-h-[inherit]' }),
},
defaultVariants: { rounded: 'xxlarge', size: 'small' },
defaultVariants: { rounded: 'xxxlarge', size: 'small' },
})
const SUSPENSE_LOADER_PROPS = { minHeight: 'h32' } as const
@ -77,34 +76,17 @@ export function Popover(props: PopoverProps) {
size,
rounded,
placement = 'bottom start',
isDismissable = true,
...ariaPopoverProps
} = props
const dialogRef = React.useRef<HTMLDivElement>(null)
// We use as here to make the types more accurate
// eslint-disable-next-line no-restricted-syntax
const contextState = React.useContext(
aria.OverlayTriggerStateContext,
) as aria.OverlayTriggerState | null
const popoverRef = React.useRef<HTMLDivElement>(null)
const root = portal.useStrictPortalContext()
const dialogId = aria.useId()
const close = useEventCallback(() => {
contextState?.close()
})
utlities.useInteractOutside({
ref: dialogRef,
id: dialogId,
onInteractOutside: close,
})
const dialogContextValue = React.useMemo(() => ({ close, dialogId }), [close, dialogId])
const popoverStyle = React.useMemo(() => ({ zIndex: '' }), [])
const popoverStyle = { zIndex: '' }
return (
<aria.Popover
ref={popoverRef}
className={(values) =>
POPOVER_STYLES({
isEntering: values.isEntering,
@ -121,13 +103,85 @@ export function Popover(props: PopoverProps) {
{...ariaPopoverProps}
>
{(opts) => (
<dialogStackProvider.DialogStackRegistrar id={dialogId} type="popover">
<PopoverContent
popoverRef={popoverRef}
size={size}
rounded={rounded}
opts={opts}
isDismissable={isDismissable}
>
{children}
</PopoverContent>
)}
</aria.Popover>
)
}
/**
* Props for a {@link PopoverContent}.
*/
interface PopoverContentProps {
readonly children: PopoverProps['children']
readonly size: PopoverProps['size']
readonly rounded: PopoverProps['rounded']
readonly opts: aria.PopoverRenderProps
readonly popoverRef: React.RefObject<HTMLDivElement>
readonly isDismissable: boolean
}
/**
* The content of a popover.
*/
function PopoverContent(props: PopoverContentProps) {
const { children, size, rounded, opts, isDismissable, popoverRef } = props
const dialogRef = React.useRef<HTMLDivElement>(null)
const dialogId = aria.useId()
// We use as here to make the types more accurate
// eslint-disable-next-line no-restricted-syntax
const contextState = React.useContext(
aria.OverlayTriggerStateContext,
) as aria.OverlayTriggerState | null
const dialogContext = React.useContext(aria.DialogContext)
// This is safe, because the labelledBy provided by DialogTrigger is always
// passed to the DialogContext, and we check for undefined below.
// eslint-disable-next-line no-restricted-syntax
const labelledBy = (dialogContext as aria.DialogProps | undefined)?.['aria-labelledby']
const close = useEventCallback(() => {
contextState?.close()
})
utlities.useInteractOutside({
ref: dialogRef,
id: dialogId,
onInteractOutside: useEventCallback(() => {
if (isDismissable) {
close()
} else {
if (popoverRef.current) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
utlities.animateScale(popoverRef.current, 1.025)
}
}
}),
})
return (
<>
<dialogStackProvider.DialogStackRegistrar id={dialogId} type="popover" />
<div
id={dialogId}
ref={dialogRef}
role="dialog"
aria-labelledby={labelledBy}
tabIndex={-1}
className={POPOVER_STYLES({ ...opts, size, rounded }).dialog()}
>
<dialogProvider.DialogProvider value={dialogContextValue}>
<dialogProvider.DialogProvider dialogId={dialogId} close={close}>
<errorBoundary.ErrorBoundary>
<suspense.Suspense loaderProps={SUSPENSE_LOADER_PROPS}>
{typeof children === 'function' ? children({ ...opts, close }) : children}
@ -135,8 +189,6 @@ export function Popover(props: PopoverProps) {
</errorBoundary.ErrorBoundary>
</dialogProvider.DialogProvider>
</div>
</dialogStackProvider.DialogStackRegistrar>
)}
</aria.Popover>
</>
)
}

View File

@ -65,3 +65,14 @@ export function useInteractOutside(props: UseInteractOutsideProps) {
onInteractOutside: onInteractOutsideCb,
})
}
/**
* Animates the scale of the element.
*/
export function animateScale(element: HTMLElement, scale: number) {
const duration = 200
element.animate(
[{ transform: 'scale(1)' }, { transform: `scale(${scale})` }, { transform: 'scale(1)' }],
{ duration, iterations: 1, direction: 'alternate' },
)
}

View File

@ -7,7 +7,7 @@ import * as twv from '#/utilities/tailwindVariants'
export const DIALOG_BACKGROUND = twv.tv({
base: 'backdrop-blur-md',
variants: { variant: { light: 'bg-white/80', dark: 'bg-primary/80' } },
variants: { variant: { light: 'bg-background/75', dark: 'bg-primary' } },
defaultVariants: { variant: 'light' },
})

View File

@ -241,13 +241,13 @@ export interface HeadingProps extends Omit<TextProps, 'elementType'> {
/** Heading component */
// eslint-disable-next-line no-restricted-syntax
const Heading = forwardRef(function Heading(
props: HeadingProps,
ref: React.Ref<HTMLHeadingElement>,
) {
const Heading = memo(
forwardRef(function Heading(props: HeadingProps, ref: React.Ref<HTMLHeadingElement>) {
const { level = 1, ...textProps } = props
return <Text ref={ref} elementType={`h${level}`} variant="h1" balance {...textProps} />
})
}),
)
Text.Heading = Heading
/** Text group component. It's used to visually group text elements together */

View File

@ -21,8 +21,10 @@ import * as authProvider from '#/providers/AuthProvider'
import { UserSessionType } from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider'
import {
useAnimationsDisabled,
useEnableVersionChecker,
usePaywallDevtools,
useSetAnimationsDisabled,
useSetEnableVersionChecker,
useShowDevtools,
} from './EnsoDevtoolsProvider'
@ -60,6 +62,10 @@ export function EnsoDevtools() {
const { features, setFeature } = usePaywallDevtools()
const enableVersionChecker = useEnableVersionChecker()
const setEnableVersionChecker = useSetEnableVersionChecker()
const animationsDisabled = useAnimationsDisabled()
const setAnimationsDisabled = useSetAnimationsDisabled()
const { localStorage } = useLocalStorage()
const [localStorageState, setLocalStorageState] = React.useState<Partial<LocalStorageData>>({})
@ -150,10 +156,26 @@ export function EnsoDevtools() {
</ariaComponents.Text>
<ariaComponents.Form
schema={(z) => z.object({ enableVersionChecker: z.boolean() })}
defaultValues={{ enableVersionChecker: enableVersionChecker ?? !IS_DEV_MODE }}
schema={(z) =>
z.object({ enableVersionChecker: z.boolean(), disableAnimations: z.boolean() })
}
defaultValues={{
enableVersionChecker: enableVersionChecker ?? !IS_DEV_MODE,
disableAnimations: animationsDisabled,
}}
>
{({ form }) => (
<>
<ariaComponents.Switch
form={form}
name="disableAnimations"
label={getText('disableAnimations')}
description={getText('disableAnimationsDescription')}
onChange={(value) => {
setAnimationsDisabled(value)
}}
/>
<ariaComponents.Switch
form={form}
name="enableVersionChecker"
@ -163,6 +185,7 @@ export function EnsoDevtools() {
setEnableVersionChecker(value)
}}
/>
</>
)}
</ariaComponents.Form>

View File

@ -5,7 +5,7 @@
import type { PaywallFeatureName } from '#/hooks/billing'
import * as zustand from '#/utilities/zustand'
import { IS_DEV_MODE } from 'enso-common/src/detect'
import * as React from 'react'
import { MotionGlobalConfig } from 'framer-motion'
/** Configuration for a paywall feature. */
export interface PaywallDevtoolsFeatureConfiguration {
@ -25,6 +25,8 @@ interface EnsoDevtoolsStore {
readonly paywallFeatures: Record<PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
readonly setPaywallFeature: (feature: PaywallFeatureName, isForceEnabled: boolean | null) => void
readonly setEnableVersionChecker: (showVersionChecker: boolean | null) => void
readonly animationsDisabled: boolean
readonly setAnimationsDisabled: (animationsDisabled: boolean) => void
}
export const ensoDevtoolsStore = zustand.createStore<EnsoDevtoolsStore>((set) => ({
@ -52,6 +54,20 @@ export const ensoDevtoolsStore = zustand.createStore<EnsoDevtoolsStore>((set) =>
setEnableVersionChecker: (showVersionChecker) => {
set({ showVersionChecker })
},
animationsDisabled: localStorage.getItem('disableAnimations') === 'true',
setAnimationsDisabled: (animationsDisabled) => {
if (animationsDisabled) {
localStorage.setItem('disableAnimations', 'true')
MotionGlobalConfig.skipAnimations = true
document.documentElement.classList.add('disable-animations')
} else {
localStorage.setItem('disableAnimations', 'false')
MotionGlobalConfig.skipAnimations = false
document.documentElement.classList.remove('disable-animations')
}
set({ animationsDisabled })
},
}))
// ===============================
@ -76,6 +92,20 @@ export function useSetEnableVersionChecker() {
})
}
/** A function to get whether animations are disabled. */
export function useAnimationsDisabled() {
return zustand.useStore(ensoDevtoolsStore, (state) => state.animationsDisabled, {
unsafeEnableTransition: true,
})
}
/** A function to set whether animations are disabled. */
export function useSetAnimationsDisabled() {
return zustand.useStore(ensoDevtoolsStore, (state) => state.setAnimationsDisabled, {
unsafeEnableTransition: true,
})
}
/** A hook that provides access to the paywall devtools. */
export function usePaywallDevtools() {
return zustand.useStore(
@ -95,17 +125,6 @@ export function useShowDevtools() {
})
}
// =================================
// === DevtoolsProvider ===
// =================================
/**
* Provide the Enso devtools to the app.
*/
export function DevtoolsProvider(props: { children: React.ReactNode }) {
React.useEffect(() => {
if (typeof window !== 'undefined') {
window.toggleDevtools = ensoDevtoolsStore.getState().toggleDevtools
}, [])
return <>{props.children}</>
}

View File

@ -2,6 +2,7 @@
import { StatelessSpinner, type SpinnerState } from '#/components/StatelessSpinner'
import * as twv from '#/utilities/tailwindVariants'
import { memo } from 'react'
// =================
// === Constants ===
@ -61,7 +62,8 @@ export interface LoaderProps extends twv.VariantProps<typeof STYLES> {
}
/** A full-screen loading spinner. */
export function Loader(props: LoaderProps) {
// eslint-disable-next-line no-restricted-syntax
export const Loader = memo(function Loader(props: LoaderProps) {
const {
className,
size: sizeRaw = 'medium',
@ -77,4 +79,4 @@ export function Loader(props: LoaderProps) {
<StatelessSpinner size={size} state={state} className="text-current" />
</div>
)
}
})

View File

@ -4,10 +4,18 @@ import * as React from 'react'
import { I18nProvider } from '#/components/aria'
import { DialogStackProvider } from '#/components/AriaComponents'
import { PortalProvider } from '#/components/Portal'
import type { Spring } from 'framer-motion'
import { MotionConfig } from 'framer-motion'
// ===================
// === UIProviders ===
// ===================
const DEFAULT_TRANSITION_OPTIONS: Spring = {
type: 'spring',
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
stiffness: 200,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
damping: 30,
mass: 1,
velocity: 0,
}
/** Props for a {@link UIProviders}. */
export interface UIProvidersProps extends Readonly<React.PropsWithChildren> {
@ -19,10 +27,12 @@ export interface UIProvidersProps extends Readonly<React.PropsWithChildren> {
export default function UIProviders(props: UIProvidersProps) {
const { portalRoot, locale, children } = props
return (
<MotionConfig reducedMotion="user" transition={DEFAULT_TRANSITION_OPTIONS}>
<PortalProvider value={portalRoot}>
<DialogStackProvider>
<I18nProvider locale={locale}>{children}</I18nProvider>
</DialogStackProvider>
</PortalProvider>
</MotionConfig>
)
}

View File

@ -3,9 +3,9 @@
*
* This file contains the useMeasure hook, which is used to measure the size and position of an element.
*/
import { frame } from 'framer-motion'
import { frame, useMotionValue } from 'framer-motion'
import { startTransition, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { unsafeMutable } from '../utilities/object'
import { useDebouncedCallback } from './debounceCallbackHooks'
import { useEventCallback } from './eventCallbackHooks'
@ -33,7 +33,7 @@ type HTMLOrSVGElement = HTMLElement | SVGElement
/**
* A type that represents the result of the useMeasure hook.
*/
type Result = [(element: HTMLOrSVGElement | null) => void, RectReadOnly, () => void]
type Result = [(element: HTMLOrSVGElement | null) => void, RectReadOnly | null, () => void]
/**
* A type that represents the state of the useMeasure hook.
@ -41,7 +41,7 @@ type Result = [(element: HTMLOrSVGElement | null) => void, RectReadOnly, () => v
interface State {
readonly element: HTMLOrSVGElement | null
readonly scrollContainers: HTMLOrSVGElement[] | null
readonly lastBounds: RectReadOnly
readonly lastBounds: RectReadOnly | null
}
/**
@ -53,19 +53,16 @@ export type OnResizeCallback = (bounds: RectReadOnly) => void
* A type that represents the options for the useMeasure hook.
*/
export interface Options {
readonly debounce?:
| number
| { readonly scroll: number; readonly resize: number; readonly frame: number }
readonly debounce?: number | { readonly scroll: number; readonly resize: number }
readonly scroll?: boolean
readonly offsetSize?: boolean
readonly onResize?: OnResizeCallback
readonly maxWait?:
| number
| { readonly scroll: number; readonly resize: number; readonly frame: number }
readonly maxWait?: number | { readonly scroll: number; readonly resize: number }
/**
* Whether to use RAF to measure the element.
*/
readonly useRAF?: boolean
readonly isDisabled?: boolean
}
/**
@ -74,22 +71,30 @@ export interface Options {
export function useMeasure(options: Options = {}): Result {
const { onResize } = options
const [bounds, set] = useState<RectReadOnly>({
left: 0,
top: 0,
width: 0,
height: 0,
bottom: 0,
right: 0,
x: 0,
y: 0,
})
const [bounds, set] = useState<RectReadOnly | null>(null)
const onResizeStableCallback = useEventCallback<OnResizeCallback>((nextBounds) => {
startTransition(() => {
set(nextBounds)
onResize?.(nextBounds)
})
const [ref, forceRefresh] = useMeasureCallback({ ...options, onResize: onResizeStableCallback })
return [ref, bounds, forceRefresh] as const
}
/**
* Helper hook that uses motion primitive to optimize renders, works best with motion components
*/
export function useMeasureSignal(options: Options = {}) {
const { onResize } = options
const bounds = useMotionValue<RectReadOnly | null>(null)
const onResizeStableCallback = useEventCallback<OnResizeCallback>((nextBounds) => {
bounds.set(nextBounds)
onResize?.(nextBounds)
})
@ -113,22 +118,14 @@ export function useMeasureCallback(options: Options & Required<Pick<Options, 'on
onResize,
maxWait = DEFAULT_MAX_WAIT,
useRAF = true,
isDisabled = false,
} = options
// keep all state in a ref
const state = useRef<State>({
element: null,
scrollContainers: null,
lastBounds: {
left: 0,
top: 0,
width: 0,
height: 0,
bottom: 0,
right: 0,
x: 0,
y: 0,
},
lastBounds: null,
})
// make sure to update state only as long as the component is truly mounted
const mounted = useRef(false)
@ -137,24 +134,27 @@ export function useMeasureCallback(options: Options & Required<Pick<Options, 'on
const scrollMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.scroll
const resizeMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.resize
const frameMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.frame
// set actual debounce values early, so effects know if they should react accordingly
const scrollDebounce = typeof debounce === 'number' ? debounce : debounce.scroll
const resizeDebounce = typeof debounce === 'number' ? debounce : debounce.resize
const frameDebounce = typeof debounce === 'number' ? debounce : debounce.frame
const callback = useEventCallback(() => {
frame.read(() => {
if (!state.current.element) return
const { left, top, width, height, bottom, right, x, y } =
state.current.element.getBoundingClientRect()
frame.read(measureCallback)
})
const measureCallback = useEventCallback(() => {
const element = state.current.element
if (!element || isDisabled) return
const { left, top, width, height, bottom, right, x, y } = element.getBoundingClientRect()
const size = { left, top, width, height, bottom, right, x, y }
if (state.current.element instanceof HTMLElement && offsetSize) {
size.height = state.current.element.offsetHeight
size.width = state.current.element.offsetWidth
if (element instanceof HTMLElement && offsetSize) {
size.height = element.offsetHeight
size.width = element.offsetWidth
}
if (mounted.current && !areBoundsEqual(state.current.lastBounds, size)) {
@ -162,14 +162,21 @@ export function useMeasureCallback(options: Options & Required<Pick<Options, 'on
onResizeStableCallback(size)
}
})
})
const frameDebounceCallback = useDebouncedCallback(callback, frameDebounce, frameMaxWait)
const resizeDebounceCallback = useDebouncedCallback(callback, resizeDebounce, resizeMaxWait)
const scrollDebounceCallback = useDebouncedCallback(callback, scrollDebounce, scrollMaxWait)
const resizeDebounceCallback = useDebouncedCallback(
measureCallback,
resizeDebounce,
resizeMaxWait,
)
const [resizeObserver] = useState(() => new ResizeObserver(resizeDebounceCallback))
const [mutationObserver] = useState(() => new MutationObserver(resizeDebounceCallback))
const scrollDebounceCallback = useDebouncedCallback(
measureCallback,
scrollDebounce,
scrollMaxWait,
)
const [resizeObserver] = useState(() => new ResizeObserver(measureCallback))
const [mutationObserver] = useState(() => new MutationObserver(measureCallback))
const forceRefresh = useDebouncedCallback(callback, 0)
@ -196,7 +203,9 @@ export function useMeasureCallback(options: Options & Required<Pick<Options, 'on
})
if (useRAF) {
frame.read(frameDebounceCallback, true)
frame.read(() => {
measureCallback()
}, true)
}
if (scroll && state.current.scrollContainers) {
@ -214,12 +223,13 @@ export function useMeasureCallback(options: Options & Required<Pick<Options, 'on
mounted.current = node != null
if (!node || node === state.current.element) return
removeListeners()
unsafeMutable(state.current).element = node
unsafeMutable(state.current).scrollContainers = findScrollContainers(node)
callback()
measureCallback()
addListeners()
})
@ -302,6 +312,8 @@ const RECT_KEYS: readonly (keyof RectReadOnly)[] = [
* @param b - Second RectReadOnly object
* @returns boolean indicating whether the boundaries are equal
*/
function areBoundsEqual(a: RectReadOnly, b: RectReadOnly): boolean {
function areBoundsEqual(a: RectReadOnly | null, b: RectReadOnly | null): boolean {
if (a == null || b == null) return false
return RECT_KEYS.every((key) => a[key] === b[key])
}

View File

@ -21,7 +21,7 @@ import LoggerProvider, { type Logger } from '#/providers/LoggerProvider'
import LoadingScreen from '#/pages/authentication/LoadingScreen'
import { DevtoolsProvider, ReactQueryDevtools } from '#/components/Devtools'
import { ReactQueryDevtools } from '#/components/Devtools'
import { ErrorBoundary } from '#/components/ErrorBoundary'
import { OfflineNotificationManager } from '#/components/OfflineNotificationManager'
import { Suspense } from '#/components/Suspense'
@ -32,7 +32,18 @@ import { MotionGlobalConfig } from 'framer-motion'
export type { GraphEditorRunner } from '#/layouts/Editor'
MotionGlobalConfig.skipAnimations = window.DISABLE_ANIMATIONS ?? false
const ARE_ANIMATIONS_DISABLED =
window.DISABLE_ANIMATIONS === true ||
localStorage.getItem('disableAnimations') === 'true' ||
false
MotionGlobalConfig.skipAnimations = ARE_ANIMATIONS_DISABLED
if (ARE_ANIMATIONS_DISABLED) {
document.documentElement.classList.add('disable-animations')
} else {
document.documentElement.classList.remove('disable-animations')
}
// =================
// === Constants ===
@ -116,15 +127,13 @@ export function run(props: DashboardProps) {
reactDOM.createRoot(root).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<DevtoolsProvider>
<UIProviders locale="en-US" portalRoot={portalRoot}>
<ErrorBoundary>
<Suspense fallback={<LoadingScreen />}>
<OfflineNotificationManager>
<LoggerProvider logger={logger}>
<HttpClientProvider httpClient={httpClient}>
<UIProviders locale="en-US" portalRoot={portalRoot}>
<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />
</UIProviders>
</HttpClientProvider>
</LoggerProvider>
</OfflineNotificationManager>
@ -132,7 +141,7 @@ export function run(props: DashboardProps) {
</ErrorBoundary>
<ReactQueryDevtools />
</DevtoolsProvider>
</UIProviders>
</QueryClientProvider>
</React.StrictMode>,
)