mirror of
https://github.com/enso-org/enso.git
synced 2024-12-20 08:31:50 +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.",
|
"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",
|
||||||
|
@ -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>
|
||||||
|
),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 * 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
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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:
|
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -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' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -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' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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 */
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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}</>
|
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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])
|
||||||
}
|
}
|
||||||
|
@ -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>,
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user