mirror of
https://github.com/enso-org/enso.git
synced 2024-12-24 12:51:44 +03:00
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:
parent
b5f110617e
commit
0c7e79cccf
@ -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",
|
||||
|
@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
|
@ -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,42 +520,40 @@ function AppRouter(props: AppRouterProps) {
|
||||
|
||||
return (
|
||||
<FeatureFlagsProvider>
|
||||
<MotionConfig reducedMotion="user" transition={DEFAULT_TRANSITION_OPTIONS}>
|
||||
<RouterProvider navigate={navigate}>
|
||||
<SessionProvider
|
||||
saveAccessToken={authService.cognito.saveAccessToken.bind(authService.cognito)}
|
||||
mainPageUrl={mainPageUrl}
|
||||
userSession={userSession}
|
||||
registerAuthEventListener={registerAuthEventListener}
|
||||
refreshUserSession={refreshUserSession}
|
||||
>
|
||||
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
|
||||
<AuthProvider
|
||||
shouldStartInOfflineMode={isAuthenticationDisabled}
|
||||
authService={authService}
|
||||
onAuthenticated={onAuthenticated}
|
||||
>
|
||||
<InputBindingsProvider inputBindings={inputBindings}>
|
||||
{/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here
|
||||
* due to modals being in `TheModal`. */}
|
||||
<DriveProvider>
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<LocalBackendPathSynchronizer />
|
||||
<VersionChecker />
|
||||
{routes}
|
||||
<suspense.Suspense>
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<devtools.EnsoDevtools />
|
||||
</errorBoundary.ErrorBoundary>
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
</DriveProvider>
|
||||
</InputBindingsProvider>
|
||||
</AuthProvider>
|
||||
</BackendProvider>
|
||||
</SessionProvider>
|
||||
</RouterProvider>
|
||||
</MotionConfig>
|
||||
<RouterProvider navigate={navigate}>
|
||||
<SessionProvider
|
||||
saveAccessToken={authService.cognito.saveAccessToken.bind(authService.cognito)}
|
||||
mainPageUrl={mainPageUrl}
|
||||
userSession={userSession}
|
||||
registerAuthEventListener={registerAuthEventListener}
|
||||
refreshUserSession={refreshUserSession}
|
||||
>
|
||||
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
|
||||
<AuthProvider
|
||||
shouldStartInOfflineMode={isAuthenticationDisabled}
|
||||
authService={authService}
|
||||
onAuthenticated={onAuthenticated}
|
||||
>
|
||||
<InputBindingsProvider inputBindings={inputBindings}>
|
||||
{/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here
|
||||
* due to modals being in `TheModal`. */}
|
||||
<DriveProvider>
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<LocalBackendPathSynchronizer />
|
||||
<VersionChecker />
|
||||
{routes}
|
||||
<suspense.Suspense>
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<devtools.EnsoDevtools />
|
||||
</errorBoundary.ErrorBoundary>
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
</DriveProvider>
|
||||
</InputBindingsProvider>
|
||||
</AuthProvider>
|
||||
</BackendProvider>
|
||||
</SessionProvider>
|
||||
</RouterProvider>
|
||||
</FeatureFlagsProvider>
|
||||
)
|
||||
}
|
||||
|
@ -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',
|
||||
},
|
||||
}
|
@ -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,97 +191,19 @@ export function Dialog(props: DialogProps) {
|
||||
shouldCloseOnInteractOutside={() => false}
|
||||
{...modalProps}
|
||||
>
|
||||
{(values) => {
|
||||
overlayState.current = values.state
|
||||
|
||||
return (
|
||||
<aria.Modal
|
||||
className={({ isEntering, isExiting }) => MODAL_STYLES({ type, isEntering, isExiting })}
|
||||
isDismissable={isDismissable}
|
||||
isKeyboardDismissDisabled={isKeyboardDismissDisabled}
|
||||
UNSTABLE_portalContainer={root}
|
||||
onOpenChange={onOpenChange}
|
||||
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>
|
||||
</aria.Modal>
|
||||
)
|
||||
}}
|
||||
{(values) => (
|
||||
<aria.Modal
|
||||
className={({ isEntering, isExiting }) => MODAL_STYLES({ type, isEntering, isExiting })}
|
||||
isDismissable={isDismissable}
|
||||
isKeyboardDismissDisabled={isKeyboardDismissDisabled}
|
||||
UNSTABLE_portalContainer={root}
|
||||
onOpenChange={onOpenChange}
|
||||
shouldCloseOnInteractOutside={() => false}
|
||||
{...modalProps}
|
||||
>
|
||||
<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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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()
|
||||
},
|
||||
}
|
@ -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,22 +103,92 @@ export function Popover(props: PopoverProps) {
|
||||
{...ariaPopoverProps}
|
||||
>
|
||||
{(opts) => (
|
||||
<dialogStackProvider.DialogStackRegistrar id={dialogId} type="popover">
|
||||
<div
|
||||
id={dialogId}
|
||||
ref={dialogRef}
|
||||
className={POPOVER_STYLES({ ...opts, size, rounded }).dialog()}
|
||||
>
|
||||
<dialogProvider.DialogProvider value={dialogContextValue}>
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<suspense.Suspense loaderProps={SUSPENSE_LOADER_PROPS}>
|
||||
{typeof children === 'function' ? children({ ...opts, close }) : children}
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
</dialogProvider.DialogProvider>
|
||||
</div>
|
||||
</dialogStackProvider.DialogStackRegistrar>
|
||||
<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 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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' },
|
||||
)
|
||||
}
|
||||
|
@ -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' },
|
||||
})
|
||||
|
||||
|
@ -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 { level = 1, ...textProps } = props
|
||||
return <Text ref={ref} elementType={`h${level}`} variant="h1" balance {...textProps} />
|
||||
})
|
||||
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 */
|
||||
|
@ -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,19 +156,36 @@ 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="enableVersionChecker"
|
||||
label={getText('enableVersionChecker')}
|
||||
description={getText('enableVersionCheckerDescription')}
|
||||
onChange={(value) => {
|
||||
setEnableVersionChecker(value)
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<ariaComponents.Switch
|
||||
form={form}
|
||||
name="disableAnimations"
|
||||
label={getText('disableAnimations')}
|
||||
description={getText('disableAnimationsDescription')}
|
||||
onChange={(value) => {
|
||||
setAnimationsDisabled(value)
|
||||
}}
|
||||
/>
|
||||
|
||||
<ariaComponents.Switch
|
||||
form={form}
|
||||
name="enableVersionChecker"
|
||||
label={getText('enableVersionChecker')}
|
||||
description={getText('enableVersionCheckerDescription')}
|
||||
onChange={(value) => {
|
||||
setEnableVersionChecker(value)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ariaComponents.Form>
|
||||
|
||||
|
@ -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(() => {
|
||||
window.toggleDevtools = ensoDevtoolsStore.getState().toggleDevtools
|
||||
}, [])
|
||||
|
||||
return <>{props.children}</>
|
||||
if (typeof window !== 'undefined') {
|
||||
window.toggleDevtools = ensoDevtoolsStore.getState().toggleDevtools
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
@ -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 (
|
||||
<PortalProvider value={portalRoot}>
|
||||
<DialogStackProvider>
|
||||
<I18nProvider locale={locale}>{children}</I18nProvider>
|
||||
</DialogStackProvider>
|
||||
</PortalProvider>
|
||||
<MotionConfig reducedMotion="user" transition={DEFAULT_TRANSITION_OPTIONS}>
|
||||
<PortalProvider value={portalRoot}>
|
||||
<DialogStackProvider>
|
||||
<I18nProvider locale={locale}>{children}</I18nProvider>
|
||||
</DialogStackProvider>
|
||||
</PortalProvider>
|
||||
</MotionConfig>
|
||||
)
|
||||
}
|
||||
|
@ -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,21 +71,29 @@ 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)
|
||||
})
|
||||
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,39 +134,49 @@ 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()
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
frame.read(measureCallback)
|
||||
})
|
||||
|
||||
const frameDebounceCallback = useDebouncedCallback(callback, frameDebounce, frameMaxWait)
|
||||
const resizeDebounceCallback = useDebouncedCallback(callback, resizeDebounce, resizeMaxWait)
|
||||
const scrollDebounceCallback = useDebouncedCallback(callback, scrollDebounce, scrollMaxWait)
|
||||
const measureCallback = useEventCallback(() => {
|
||||
const element = state.current.element
|
||||
|
||||
const [resizeObserver] = useState(() => new ResizeObserver(resizeDebounceCallback))
|
||||
const [mutationObserver] = useState(() => new MutationObserver(resizeDebounceCallback))
|
||||
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 (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)
|
||||
|
||||
@ -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])
|
||||
}
|
||||
|
@ -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>
|
||||
<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />
|
||||
</HttpClientProvider>
|
||||
</LoggerProvider>
|
||||
</OfflineNotificationManager>
|
||||
@ -132,7 +141,7 @@ export function run(props: DashboardProps) {
|
||||
</ErrorBoundary>
|
||||
|
||||
<ReactQueryDevtools />
|
||||
</DevtoolsProvider>
|
||||
</UIProviders>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user