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.", "cannotCreateAssetsHere": "You do not have the permissions to create assets here.",
"enableVersionChecker": "Enable Version Checker", "enableVersionChecker": "Enable Version Checker",
"enableVersionCheckerDescription": "Show a dialog if the current version of the desktop app does not match the latest version.", "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", "removeTheLocalDirectoryXFromFavorites": "remove the local folder '$0' from your favorites",
"changeLocalRootDirectoryInSettings": "Change your root folder in Settings.", "changeLocalRootDirectoryInSettings": "Change your root folder in Settings.",
"localStorage": "Local Storage", "localStorage": "Local Storage",

View File

@ -4,7 +4,7 @@
import type { Preview as ReactPreview } from '@storybook/react' import type { Preview as ReactPreview } from '@storybook/react'
import type { Preview as VuePreview } from '@storybook/vue3' import type { Preview as VuePreview } from '@storybook/vue3'
import isChromatic from 'chromatic/isChromatic' import isChromatic from 'chromatic/isChromatic'
import { useLayoutEffect, useState } from 'react' import { StrictMode, useLayoutEffect, useState } from 'react'
import invariant from 'tiny-invariant' import invariant from 'tiny-invariant'
import UIProviders from '../src/dashboard/components/UIProviders' import UIProviders from '../src/dashboard/components/UIProviders'
@ -59,22 +59,34 @@ const reactPreview: ReactPreview = {
return ( return (
<UIProviders locale="en-US" portalRoot={portalRoot}> <UIProviders locale="en-US" portalRoot={portalRoot}>
{Story(context)} <Story {...context} />
</UIProviders> </UIProviders>
) )
}, },
(Story, context) => ( (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" /> <div id="enso-portal-root" className="enso-portal-root" />
</> </>
), ),
(Story, context) => { (Story, context) => {
const [queryClient] = useState(() => createQueryClient()) 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 * as navigator2DProvider from '#/providers/Navigator2DProvider'
import SessionProvider from '#/providers/SessionProvider' import SessionProvider from '#/providers/SessionProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
import type { Spring } from 'framer-motion'
import { MotionConfig } from 'framer-motion'
import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration' import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
import ForgotPassword from '#/pages/authentication/ForgotPassword' import ForgotPassword from '#/pages/authentication/ForgotPassword'
@ -105,16 +103,6 @@ import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal'
// === Global configuration === // === 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' { declare module '#/utilities/LocalStorage' {
/** */ /** */
interface LocalStorageData { interface LocalStorageData {
@ -532,42 +520,40 @@ function AppRouter(props: AppRouterProps) {
return ( return (
<FeatureFlagsProvider> <FeatureFlagsProvider>
<MotionConfig reducedMotion="user" transition={DEFAULT_TRANSITION_OPTIONS}> <RouterProvider navigate={navigate}>
<RouterProvider navigate={navigate}> <SessionProvider
<SessionProvider saveAccessToken={authService.cognito.saveAccessToken.bind(authService.cognito)}
saveAccessToken={authService.cognito.saveAccessToken.bind(authService.cognito)} mainPageUrl={mainPageUrl}
mainPageUrl={mainPageUrl} userSession={userSession}
userSession={userSession} registerAuthEventListener={registerAuthEventListener}
registerAuthEventListener={registerAuthEventListener} refreshUserSession={refreshUserSession}
refreshUserSession={refreshUserSession} >
> <BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}> <AuthProvider
<AuthProvider shouldStartInOfflineMode={isAuthenticationDisabled}
shouldStartInOfflineMode={isAuthenticationDisabled} authService={authService}
authService={authService} onAuthenticated={onAuthenticated}
onAuthenticated={onAuthenticated} >
> <InputBindingsProvider inputBindings={inputBindings}>
<InputBindingsProvider inputBindings={inputBindings}> {/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here
{/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here * due to modals being in `TheModal`. */}
* due to modals being in `TheModal`. */} <DriveProvider>
<DriveProvider> <errorBoundary.ErrorBoundary>
<errorBoundary.ErrorBoundary> <LocalBackendPathSynchronizer />
<LocalBackendPathSynchronizer /> <VersionChecker />
<VersionChecker /> {routes}
{routes} <suspense.Suspense>
<suspense.Suspense> <errorBoundary.ErrorBoundary>
<errorBoundary.ErrorBoundary> <devtools.EnsoDevtools />
<devtools.EnsoDevtools /> </errorBoundary.ErrorBoundary>
</errorBoundary.ErrorBoundary> </suspense.Suspense>
</suspense.Suspense> </errorBoundary.ErrorBoundary>
</errorBoundary.ErrorBoundary> </DriveProvider>
</DriveProvider> </InputBindingsProvider>
</InputBindingsProvider> </AuthProvider>
</AuthProvider> </BackendProvider>
</BackendProvider> </SessionProvider>
</SessionProvider> </RouterProvider>
</RouterProvider>
</MotionConfig>
</FeatureFlagsProvider> </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 * as mergeRefs from '#/utilities/mergeRefs'
import { useDimensions } from '#/hooks/dimensionsHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks'
import type { Spring } from '#/utilities/motion' import { useMeasure } from '#/hooks/measureHooks'
import { motion } from '#/utilities/motion' import { motion, type Spring } from '#/utilities/motion'
import type { VariantProps } from '#/utilities/tailwindVariants' import type { VariantProps } from '#/utilities/tailwindVariants'
import { tv } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants'
import { Close } from './Close' import { Close } from './Close'
@ -24,7 +24,6 @@ import type * as types from './types'
import * as utlities from './utilities' import * as utlities from './utilities'
import { DIALOG_BACKGROUND } from './variants' 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 // eslint-disable-next-line no-restricted-syntax
const MotionDialog = motion(aria.Dialog) const MotionDialog = motion(aria.Dialog)
@ -33,7 +32,7 @@ const OVERLAY_STYLES = tv({
variants: { variants: {
isEntering: { true: 'animate-in fade-in duration-200 ease-out' }, isEntering: { true: 'animate-in fade-in duration-200 ease-out' },
isExiting: { true: 'animate-out fade-out duration-200 ease-in' }, 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: { modal: {
base: 'w-full min-h-[100px] max-h-[90vh]', base: 'w-full min-h-[100px] max-h-[90vh]',
header: 'px-3.5 pt-[3px] pb-0.5 min-h-[42px]', header: 'px-3.5 pt-[3px] pb-0.5 min-h-[42px]',
measuredContent: 'max-h-[90vh]',
}, },
fullscreen: { fullscreen: {
base: 'w-full h-full max-w-full max-h-full bg-clip-border', 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', header: 'px-4 pt-[5px] pb-1.5 min-h-12',
measuredContent: 'max-h-[100vh]',
}, },
}, },
fitContent: { fitContent: {
@ -92,9 +89,9 @@ const DIALOG_STYLES = tv({
medium: { base: 'rounded-md' }, medium: { base: 'rounded-md' },
large: { base: 'rounded-lg' }, large: { base: 'rounded-lg' },
xlarge: { base: 'rounded-xl' }, xlarge: { base: 'rounded-xl' },
xxlarge: { base: 'rounded-2xl', content: 'scroll-offset-edge-2xl' }, xxlarge: { base: 'rounded-2xl', scroller: 'scroll-offset-edge-2xl' },
xxxlarge: { base: 'rounded-3xl', content: 'scroll-offset-edge-4xl' }, xxxlarge: { base: 'rounded-3xl', scroller: 'scroll-offset-edge-3xl' },
xxxxlarge: { base: 'rounded-4xl', content: 'scroll-offset-edge-6xl' }, xxxxlarge: { base: 'rounded-4xl', scroller: 'scroll-offset-edge-4xl' },
}, },
/** /**
* The size of the dialog. * 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', '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', closeButton: 'col-start-1 col-end-1 mr-auto',
heading: 'col-start-2 col-end-2 my-0 text-center', heading: 'col-start-2 col-end-2 my-0 text-center',
content: 'relative flex-auto overflow-y-auto max-h-[inherit]', scroller: 'flex flex-col overflow-y-auto max-h-[inherit]',
measuredContent: 'flex flex-col', 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: [ compoundVariants: [
{ type: 'modal', size: 'small', class: 'max-w-sm' }, { type: 'modal', size: 'small', class: 'max-w-sm' },
@ -142,17 +141,17 @@ const DIALOG_STYLES = tv({
closeButton: 'normal', closeButton: 'normal',
hideCloseButton: false, hideCloseButton: false,
size: 'medium', size: 'medium',
padding: 'medium', padding: 'none',
rounded: 'xxxlarge', 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', 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, mass: 1,
} }
@ -171,79 +170,15 @@ export interface DialogProps
*/ */
export function Dialog(props: DialogProps) { export function Dialog(props: DialogProps) {
const { const {
children,
title,
type = 'modal', type = 'modal',
closeButton = 'normal',
isDismissable = true, isDismissable = true,
isKeyboardDismissDisabled = false, isKeyboardDismissDisabled = false,
hideCloseButton = false,
className,
onOpenChange = () => {}, onOpenChange = () => {},
modalProps = {}, modalProps = {},
testId = 'dialog',
size,
rounded,
padding: paddingRaw,
fitContent,
variants = DIALOG_STYLES,
...ariaDialogProps
} = props } = 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 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 ( return (
<aria.ModalOverlay <aria.ModalOverlay
className={({ isEntering, isExiting }) => className={({ isEntering, isExiting }) =>
@ -256,97 +191,19 @@ export function Dialog(props: DialogProps) {
shouldCloseOnInteractOutside={() => false} shouldCloseOnInteractOutside={() => false}
{...modalProps} {...modalProps}
> >
{(values) => { {(values) => (
overlayState.current = values.state <aria.Modal
className={({ isEntering, isExiting }) => MODAL_STYLES({ type, isEntering, isExiting })}
return ( isDismissable={isDismissable}
<aria.Modal isKeyboardDismissDisabled={isKeyboardDismissDisabled}
className={({ isEntering, isExiting }) => MODAL_STYLES({ type, isEntering, isExiting })} UNSTABLE_portalContainer={root}
isDismissable={isDismissable} onOpenChange={onOpenChange}
isKeyboardDismissDisabled={isKeyboardDismissDisabled} shouldCloseOnInteractOutside={() => false}
UNSTABLE_portalContainer={root} {...modalProps}
onOpenChange={onOpenChange} >
shouldCloseOnInteractOutside={() => false} <DialogContent {...props} modalState={values.state} />
{...modalProps} </aria.Modal>
> )}
<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>
</aria.Modal>
)
}}
</aria.ModalOverlay> </aria.ModalOverlay>
) )
} }
@ -359,4 +216,248 @@ const TYPE_TO_DIALOG_TYPE: Record<
fullscreen: 'dialog-fullscreen', 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 Dialog.Close = Close

View File

@ -4,6 +4,7 @@
* The context value for a dialog. * The context value for a dialog.
*/ */
import * as React from 'react' import * as React from 'react'
import invariant from 'tiny-invariant'
/** The context value for a dialog. */ /** The context value for a dialog. */
export interface DialogContextValue { export interface DialogContextValue {
@ -15,10 +16,25 @@ export interface DialogContextValue {
const DialogContext = React.createContext<DialogContextValue | null>(null) const DialogContext = React.createContext<DialogContextValue | null>(null)
/** The provider for a dialog. */ /** The provider for a dialog. */
// eslint-disable-next-line no-restricted-syntax export function DialogProvider(props: DialogContextValue & React.PropsWithChildren) {
export const DialogProvider = DialogContext.Provider 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. */ /** Custom hook to get the dialog context. */
export function useDialogContext() { export function useDialogContext() {
return React.useContext(DialogContext) 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. */ /** DialogStackRegistrar is a React component that registers a dialog in the dialog stack. */
export function DialogStackRegistrar(props: React.PropsWithChildren<DialogStackItem>) { export const DialogStackRegistrar = React.memo(function DialogStackRegistrar(
const { children, id, type } = props props: DialogStackItem,
) {
const { id, type } = props
const store = React.useContext(DialogStackContext) const store = React.useContext(DialogStackContext)
invariant(store, 'DialogStackRegistrar must be used within a DialogStackProvider') invariant(store, 'DialogStackRegistrar must be used within a DialogStackProvider')
@ -88,8 +90,8 @@ export function DialogStackRegistrar(props: React.PropsWithChildren<DialogStackI
} }
}, [add, slice, id, type]) }, [add, slice, id, type])
return children return null
} })
/** Props for {@link useDialogStackState} */ /** Props for {@link useDialogStackState} */
export interface UseDialogStackStateProps { 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: readonly children:
| React.ReactNode | React.ReactNode
| ((opts: aria.PopoverRenderProps & { readonly close: () => void }) => React.ReactNode) | ((opts: aria.PopoverRenderProps & { readonly close: () => void }) => React.ReactNode)
readonly isDismissable?: boolean
} }
export const POPOVER_STYLES = twv.tv({ export const POPOVER_STYLES = twv.tv({
@ -47,21 +48,19 @@ export const POPOVER_STYLES = twv.tv({
}, },
rounded: { rounded: {
none: { base: 'rounded-none', dialog: 'rounded-none' }, none: { base: 'rounded-none', dialog: 'rounded-none' },
small: { base: 'rounded-sm', dialog: 'rounded-sm' }, small: { base: 'rounded-sm', dialog: 'rounded-sm scroll-offset-edge-md' },
medium: { base: 'rounded-md', dialog: 'rounded-md' }, medium: { base: 'rounded-md', dialog: 'rounded-md scroll-offset-edge-xl' },
large: { base: 'rounded-lg', dialog: 'rounded-lg' }, large: { base: 'rounded-lg', dialog: 'rounded-lg scroll-offset-edge-xl' },
xlarge: { base: 'rounded-xl', dialog: 'rounded-xl' }, xlarge: { base: 'rounded-xl', dialog: 'rounded-xl scroll-offset-edge-xl' },
xxlarge: { base: 'rounded-2xl', dialog: 'rounded-2xl' }, xxlarge: { base: 'rounded-2xl', dialog: 'rounded-2xl scroll-offset-edge-2xl' },
xxxlarge: { base: 'rounded-3xl', dialog: 'rounded-3xl' }, xxxlarge: { base: 'rounded-3xl', dialog: 'rounded-3xl scroll-offset-edge-3xl' },
xxxxlarge: { base: 'rounded-4xl', dialog: 'rounded-4xl' }, xxxxlarge: { base: 'rounded-4xl', dialog: 'rounded-4xl scroll-offset-edge-4xl' },
}, },
}, },
slots: { slots: {
dialog: variants.DIALOG_BACKGROUND({ dialog: variants.DIALOG_BACKGROUND({ class: 'flex-auto overflow-y-auto max-h-[inherit]' }),
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 const SUSPENSE_LOADER_PROPS = { minHeight: 'h32' } as const
@ -77,34 +76,17 @@ export function Popover(props: PopoverProps) {
size, size,
rounded, rounded,
placement = 'bottom start', placement = 'bottom start',
isDismissable = true,
...ariaPopoverProps ...ariaPopoverProps
} = props } = props
const dialogRef = React.useRef<HTMLDivElement>(null) const popoverRef = 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 root = portal.useStrictPortalContext() const root = portal.useStrictPortalContext()
const dialogId = aria.useId() const popoverStyle = { zIndex: '' }
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: '' }), [])
return ( return (
<aria.Popover <aria.Popover
ref={popoverRef}
className={(values) => className={(values) =>
POPOVER_STYLES({ POPOVER_STYLES({
isEntering: values.isEntering, isEntering: values.isEntering,
@ -121,22 +103,92 @@ export function Popover(props: PopoverProps) {
{...ariaPopoverProps} {...ariaPopoverProps}
> >
{(opts) => ( {(opts) => (
<dialogStackProvider.DialogStackRegistrar id={dialogId} type="popover"> <PopoverContent
<div popoverRef={popoverRef}
id={dialogId} size={size}
ref={dialogRef} rounded={rounded}
className={POPOVER_STYLES({ ...opts, size, rounded }).dialog()} opts={opts}
> isDismissable={isDismissable}
<dialogProvider.DialogProvider value={dialogContextValue}> >
<errorBoundary.ErrorBoundary> {children}
<suspense.Suspense loaderProps={SUSPENSE_LOADER_PROPS}> </PopoverContent>
{typeof children === 'function' ? children({ ...opts, close }) : children}
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</dialogProvider.DialogProvider>
</div>
</dialogStackProvider.DialogStackRegistrar>
)} )}
</aria.Popover> </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 dialogId={dialogId} close={close}>
<errorBoundary.ErrorBoundary>
<suspense.Suspense loaderProps={SUSPENSE_LOADER_PROPS}>
{typeof children === 'function' ? children({ ...opts, close }) : children}
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</dialogProvider.DialogProvider>
</div>
</>
)
}

View File

@ -65,3 +65,14 @@ export function useInteractOutside(props: UseInteractOutsideProps) {
onInteractOutside: onInteractOutsideCb, 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({ export const DIALOG_BACKGROUND = twv.tv({
base: 'backdrop-blur-md', 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' }, defaultVariants: { variant: 'light' },
}) })

View File

@ -241,13 +241,13 @@ export interface HeadingProps extends Omit<TextProps, 'elementType'> {
/** Heading component */ /** Heading component */
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const Heading = forwardRef(function Heading( const Heading = memo(
props: HeadingProps, forwardRef(function Heading(props: HeadingProps, ref: React.Ref<HTMLHeadingElement>) {
ref: React.Ref<HTMLHeadingElement>, const { level = 1, ...textProps } = props
) { return <Text ref={ref} elementType={`h${level}`} variant="h1" balance {...textProps} />
const { level = 1, ...textProps } = props }),
return <Text ref={ref} elementType={`h${level}`} variant="h1" balance {...textProps} /> )
})
Text.Heading = Heading Text.Heading = Heading
/** Text group component. It's used to visually group text elements together */ /** 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 { UserSessionType } from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
import { import {
useAnimationsDisabled,
useEnableVersionChecker, useEnableVersionChecker,
usePaywallDevtools, usePaywallDevtools,
useSetAnimationsDisabled,
useSetEnableVersionChecker, useSetEnableVersionChecker,
useShowDevtools, useShowDevtools,
} from './EnsoDevtoolsProvider' } from './EnsoDevtoolsProvider'
@ -60,6 +62,10 @@ export function EnsoDevtools() {
const { features, setFeature } = usePaywallDevtools() const { features, setFeature } = usePaywallDevtools()
const enableVersionChecker = useEnableVersionChecker() const enableVersionChecker = useEnableVersionChecker()
const setEnableVersionChecker = useSetEnableVersionChecker() const setEnableVersionChecker = useSetEnableVersionChecker()
const animationsDisabled = useAnimationsDisabled()
const setAnimationsDisabled = useSetAnimationsDisabled()
const { localStorage } = useLocalStorage() const { localStorage } = useLocalStorage()
const [localStorageState, setLocalStorageState] = React.useState<Partial<LocalStorageData>>({}) const [localStorageState, setLocalStorageState] = React.useState<Partial<LocalStorageData>>({})
@ -150,19 +156,36 @@ export function EnsoDevtools() {
</ariaComponents.Text> </ariaComponents.Text>
<ariaComponents.Form <ariaComponents.Form
schema={(z) => z.object({ enableVersionChecker: z.boolean() })} schema={(z) =>
defaultValues={{ enableVersionChecker: enableVersionChecker ?? !IS_DEV_MODE }} z.object({ enableVersionChecker: z.boolean(), disableAnimations: z.boolean() })
}
defaultValues={{
enableVersionChecker: enableVersionChecker ?? !IS_DEV_MODE,
disableAnimations: animationsDisabled,
}}
> >
{({ form }) => ( {({ form }) => (
<ariaComponents.Switch <>
form={form} <ariaComponents.Switch
name="enableVersionChecker" form={form}
label={getText('enableVersionChecker')} name="disableAnimations"
description={getText('enableVersionCheckerDescription')} label={getText('disableAnimations')}
onChange={(value) => { description={getText('disableAnimationsDescription')}
setEnableVersionChecker(value) onChange={(value) => {
}} setAnimationsDisabled(value)
/> }}
/>
<ariaComponents.Switch
form={form}
name="enableVersionChecker"
label={getText('enableVersionChecker')}
description={getText('enableVersionCheckerDescription')}
onChange={(value) => {
setEnableVersionChecker(value)
}}
/>
</>
)} )}
</ariaComponents.Form> </ariaComponents.Form>

View File

@ -5,7 +5,7 @@
import type { PaywallFeatureName } from '#/hooks/billing' import type { PaywallFeatureName } from '#/hooks/billing'
import * as zustand from '#/utilities/zustand' import * as zustand from '#/utilities/zustand'
import { IS_DEV_MODE } from 'enso-common/src/detect' import { IS_DEV_MODE } from 'enso-common/src/detect'
import * as React from 'react' import { MotionGlobalConfig } from 'framer-motion'
/** Configuration for a paywall feature. */ /** Configuration for a paywall feature. */
export interface PaywallDevtoolsFeatureConfiguration { export interface PaywallDevtoolsFeatureConfiguration {
@ -25,6 +25,8 @@ interface EnsoDevtoolsStore {
readonly paywallFeatures: Record<PaywallFeatureName, PaywallDevtoolsFeatureConfiguration> readonly paywallFeatures: Record<PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
readonly setPaywallFeature: (feature: PaywallFeatureName, isForceEnabled: boolean | null) => void readonly setPaywallFeature: (feature: PaywallFeatureName, isForceEnabled: boolean | null) => void
readonly setEnableVersionChecker: (showVersionChecker: boolean | null) => void readonly setEnableVersionChecker: (showVersionChecker: boolean | null) => void
readonly animationsDisabled: boolean
readonly setAnimationsDisabled: (animationsDisabled: boolean) => void
} }
export const ensoDevtoolsStore = zustand.createStore<EnsoDevtoolsStore>((set) => ({ export const ensoDevtoolsStore = zustand.createStore<EnsoDevtoolsStore>((set) => ({
@ -52,6 +54,20 @@ export const ensoDevtoolsStore = zustand.createStore<EnsoDevtoolsStore>((set) =>
setEnableVersionChecker: (showVersionChecker) => { setEnableVersionChecker: (showVersionChecker) => {
set({ 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. */ /** A hook that provides access to the paywall devtools. */
export function usePaywallDevtools() { export function usePaywallDevtools() {
return zustand.useStore( return zustand.useStore(
@ -95,17 +125,6 @@ export function useShowDevtools() {
}) })
} }
// ================================= if (typeof window !== 'undefined') {
// === DevtoolsProvider === window.toggleDevtools = ensoDevtoolsStore.getState().toggleDevtools
// =================================
/**
* Provide the Enso devtools to the app.
*/
export function DevtoolsProvider(props: { children: React.ReactNode }) {
React.useEffect(() => {
window.toggleDevtools = ensoDevtoolsStore.getState().toggleDevtools
}, [])
return <>{props.children}</>
} }

View File

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

View File

@ -4,10 +4,18 @@ import * as React from 'react'
import { I18nProvider } from '#/components/aria' import { I18nProvider } from '#/components/aria'
import { DialogStackProvider } from '#/components/AriaComponents' import { DialogStackProvider } from '#/components/AriaComponents'
import { PortalProvider } from '#/components/Portal' import { PortalProvider } from '#/components/Portal'
import type { Spring } from 'framer-motion'
import { MotionConfig } from 'framer-motion'
// =================== const DEFAULT_TRANSITION_OPTIONS: Spring = {
// === UIProviders === 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}. */ /** Props for a {@link UIProviders}. */
export interface UIProvidersProps extends Readonly<React.PropsWithChildren> { export interface UIProvidersProps extends Readonly<React.PropsWithChildren> {
@ -19,10 +27,12 @@ export interface UIProvidersProps extends Readonly<React.PropsWithChildren> {
export default function UIProviders(props: UIProvidersProps) { export default function UIProviders(props: UIProvidersProps) {
const { portalRoot, locale, children } = props const { portalRoot, locale, children } = props
return ( return (
<PortalProvider value={portalRoot}> <MotionConfig reducedMotion="user" transition={DEFAULT_TRANSITION_OPTIONS}>
<DialogStackProvider> <PortalProvider value={portalRoot}>
<I18nProvider locale={locale}>{children}</I18nProvider> <DialogStackProvider>
</DialogStackProvider> <I18nProvider locale={locale}>{children}</I18nProvider>
</PortalProvider> </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. * 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 { unsafeMutable } from '../utilities/object'
import { useDebouncedCallback } from './debounceCallbackHooks' import { useDebouncedCallback } from './debounceCallbackHooks'
import { useEventCallback } from './eventCallbackHooks' import { useEventCallback } from './eventCallbackHooks'
@ -33,7 +33,7 @@ type HTMLOrSVGElement = HTMLElement | SVGElement
/** /**
* A type that represents the result of the useMeasure hook. * 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. * A type that represents the state of the useMeasure hook.
@ -41,7 +41,7 @@ type Result = [(element: HTMLOrSVGElement | null) => void, RectReadOnly, () => v
interface State { interface State {
readonly element: HTMLOrSVGElement | null readonly element: HTMLOrSVGElement | null
readonly scrollContainers: 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. * A type that represents the options for the useMeasure hook.
*/ */
export interface Options { export interface Options {
readonly debounce?: readonly debounce?: number | { readonly scroll: number; readonly resize: number }
| number
| { readonly scroll: number; readonly resize: number; readonly frame: number }
readonly scroll?: boolean readonly scroll?: boolean
readonly offsetSize?: boolean readonly offsetSize?: boolean
readonly onResize?: OnResizeCallback readonly onResize?: OnResizeCallback
readonly maxWait?: readonly maxWait?: number | { readonly scroll: number; readonly resize: number }
| number
| { readonly scroll: number; readonly resize: number; readonly frame: number }
/** /**
* Whether to use RAF to measure the element. * Whether to use RAF to measure the element.
*/ */
readonly useRAF?: boolean readonly useRAF?: boolean
readonly isDisabled?: boolean
} }
/** /**
@ -74,21 +71,29 @@ export interface Options {
export function useMeasure(options: Options = {}): Result { export function useMeasure(options: Options = {}): Result {
const { onResize } = options const { onResize } = options
const [bounds, set] = useState<RectReadOnly>({ const [bounds, set] = useState<RectReadOnly | null>(null)
left: 0,
top: 0,
width: 0,
height: 0,
bottom: 0,
right: 0,
x: 0,
y: 0,
})
const onResizeStableCallback = useEventCallback<OnResizeCallback>((nextBounds) => { const onResizeStableCallback = useEventCallback<OnResizeCallback>((nextBounds) => {
startTransition(() => { set(nextBounds)
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) onResize?.(nextBounds)
}) })
@ -113,22 +118,14 @@ export function useMeasureCallback(options: Options & Required<Pick<Options, 'on
onResize, onResize,
maxWait = DEFAULT_MAX_WAIT, maxWait = DEFAULT_MAX_WAIT,
useRAF = true, useRAF = true,
isDisabled = false,
} = options } = options
// keep all state in a ref // keep all state in a ref
const state = useRef<State>({ const state = useRef<State>({
element: null, element: null,
scrollContainers: null, scrollContainers: null,
lastBounds: { lastBounds: null,
left: 0,
top: 0,
width: 0,
height: 0,
bottom: 0,
right: 0,
x: 0,
y: 0,
},
}) })
// make sure to update state only as long as the component is truly mounted // make sure to update state only as long as the component is truly mounted
const mounted = useRef(false) const mounted = useRef(false)
@ -137,39 +134,49 @@ export function useMeasureCallback(options: Options & Required<Pick<Options, 'on
const scrollMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.scroll const scrollMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.scroll
const resizeMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.resize 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 // set actual debounce values early, so effects know if they should react accordingly
const scrollDebounce = typeof debounce === 'number' ? debounce : debounce.scroll const scrollDebounce = typeof debounce === 'number' ? debounce : debounce.scroll
const resizeDebounce = typeof debounce === 'number' ? debounce : debounce.resize const resizeDebounce = typeof debounce === 'number' ? debounce : debounce.resize
const frameDebounce = typeof debounce === 'number' ? debounce : debounce.frame
const callback = useEventCallback(() => { const callback = useEventCallback(() => {
frame.read(() => { frame.read(measureCallback)
if (!state.current.element) return
const { left, top, width, height, bottom, right, x, y } =
state.current.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 (mounted.current && !areBoundsEqual(state.current.lastBounds, size)) {
unsafeMutable(state.current).lastBounds = size
onResizeStableCallback(size)
}
})
}) })
const frameDebounceCallback = useDebouncedCallback(callback, frameDebounce, frameMaxWait) const measureCallback = useEventCallback(() => {
const resizeDebounceCallback = useDebouncedCallback(callback, resizeDebounce, resizeMaxWait) const element = state.current.element
const scrollDebounceCallback = useDebouncedCallback(callback, scrollDebounce, scrollMaxWait)
const [resizeObserver] = useState(() => new ResizeObserver(resizeDebounceCallback)) if (!element || isDisabled) return
const [mutationObserver] = useState(() => new MutationObserver(resizeDebounceCallback))
const { left, top, width, height, bottom, right, x, y } = element.getBoundingClientRect()
const size = { left, top, width, height, bottom, right, x, y }
if (element instanceof HTMLElement && offsetSize) {
size.height = element.offsetHeight
size.width = element.offsetWidth
}
if (mounted.current && !areBoundsEqual(state.current.lastBounds, size)) {
unsafeMutable(state.current).lastBounds = size
onResizeStableCallback(size)
}
})
const resizeDebounceCallback = useDebouncedCallback(
measureCallback,
resizeDebounce,
resizeMaxWait,
)
const scrollDebounceCallback = useDebouncedCallback(
measureCallback,
scrollDebounce,
scrollMaxWait,
)
const [resizeObserver] = useState(() => new ResizeObserver(measureCallback))
const [mutationObserver] = useState(() => new MutationObserver(measureCallback))
const forceRefresh = useDebouncedCallback(callback, 0) const forceRefresh = useDebouncedCallback(callback, 0)
@ -196,7 +203,9 @@ export function useMeasureCallback(options: Options & Required<Pick<Options, 'on
}) })
if (useRAF) { if (useRAF) {
frame.read(frameDebounceCallback, true) frame.read(() => {
measureCallback()
}, true)
} }
if (scroll && state.current.scrollContainers) { if (scroll && state.current.scrollContainers) {
@ -214,12 +223,13 @@ export function useMeasureCallback(options: Options & Required<Pick<Options, 'on
mounted.current = node != null mounted.current = node != null
if (!node || node === state.current.element) return if (!node || node === state.current.element) return
removeListeners() removeListeners()
unsafeMutable(state.current).element = node unsafeMutable(state.current).element = node
unsafeMutable(state.current).scrollContainers = findScrollContainers(node) unsafeMutable(state.current).scrollContainers = findScrollContainers(node)
callback() measureCallback()
addListeners() addListeners()
}) })
@ -302,6 +312,8 @@ const RECT_KEYS: readonly (keyof RectReadOnly)[] = [
* @param b - Second RectReadOnly object * @param b - Second RectReadOnly object
* @returns boolean indicating whether the boundaries are equal * @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]) 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 LoadingScreen from '#/pages/authentication/LoadingScreen'
import { DevtoolsProvider, ReactQueryDevtools } from '#/components/Devtools' import { ReactQueryDevtools } from '#/components/Devtools'
import { ErrorBoundary } from '#/components/ErrorBoundary' import { ErrorBoundary } from '#/components/ErrorBoundary'
import { OfflineNotificationManager } from '#/components/OfflineNotificationManager' import { OfflineNotificationManager } from '#/components/OfflineNotificationManager'
import { Suspense } from '#/components/Suspense' import { Suspense } from '#/components/Suspense'
@ -32,7 +32,18 @@ import { MotionGlobalConfig } from 'framer-motion'
export type { GraphEditorRunner } from '#/layouts/Editor' 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 === // === Constants ===
@ -116,15 +127,13 @@ export function run(props: DashboardProps) {
reactDOM.createRoot(root).render( reactDOM.createRoot(root).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<DevtoolsProvider> <UIProviders locale="en-US" portalRoot={portalRoot}>
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback={<LoadingScreen />}> <Suspense fallback={<LoadingScreen />}>
<OfflineNotificationManager> <OfflineNotificationManager>
<LoggerProvider logger={logger}> <LoggerProvider logger={logger}>
<HttpClientProvider httpClient={httpClient}> <HttpClientProvider httpClient={httpClient}>
<UIProviders locale="en-US" portalRoot={portalRoot}> <App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />
<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />
</UIProviders>
</HttpClientProvider> </HttpClientProvider>
</LoggerProvider> </LoggerProvider>
</OfflineNotificationManager> </OfflineNotificationManager>
@ -132,7 +141,7 @@ export function run(props: DashboardProps) {
</ErrorBoundary> </ErrorBoundary>
<ReactQueryDevtools /> <ReactQueryDevtools />
</DevtoolsProvider> </UIProviders>
</QueryClientProvider> </QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,
) )