From 0c7e79cccffdaf6e8bef37cf66f7f8bf1b8291d3 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Thu, 28 Nov 2024 22:15:34 +0300 Subject: [PATCH] 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) --- app/common/src/text/english.json | 2 + app/gui/.storybook/preview.tsx | 20 +- app/gui/src/dashboard/App.tsx | 82 ++-- .../AriaComponents/Dialog/Dialog.stories.tsx | 105 ++++ .../AriaComponents/Dialog/Dialog.tsx | 447 +++++++++++------- .../AriaComponents/Dialog/DialogProvider.tsx | 20 +- .../Dialog/DialogStackProvider.tsx | 10 +- .../AriaComponents/Dialog/Popover.stories.tsx | 56 +++ .../AriaComponents/Dialog/Popover.tsx | 146 ++++-- .../AriaComponents/Dialog/utilities.ts | 11 + .../AriaComponents/Dialog/variants.ts | 2 +- .../components/AriaComponents/Text/Text.tsx | 14 +- .../components/Devtools/EnsoDevtools.tsx | 45 +- .../Devtools/EnsoDevtoolsProvider.tsx | 47 +- app/gui/src/dashboard/components/Loader.tsx | 6 +- .../src/dashboard/components/UIProviders.tsx | 26 +- app/gui/src/dashboard/hooks/measureHooks.ts | 132 +++--- app/gui/src/dashboard/index.tsx | 23 +- 18 files changed, 806 insertions(+), 388 deletions(-) create mode 100644 app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.stories.tsx create mode 100644 app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.stories.tsx diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index f92ad01eaf3..1e8274d3980 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -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", diff --git a/app/gui/.storybook/preview.tsx b/app/gui/.storybook/preview.tsx index 519a1672e18..a984a548119 100644 --- a/app/gui/.storybook/preview.tsx +++ b/app/gui/.storybook/preview.tsx @@ -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 ( - {Story(context)} + ) }, (Story, context) => ( <> -
{Story(context)}
+
+ +
), (Story, context) => { const [queryClient] = useState(() => createQueryClient()) - return {Story(context)} + return ( + + + + ) }, + + (Story, context) => ( + + + + ), ], } diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index b4791b73f2e..4f2765589c8 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -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 ( - - - - - - - {/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here - * due to modals being in `TheModal`. */} - - - - - {routes} - - - - - - - - - - - - - + + + + + + {/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here + * due to modals being in `TheModal`. */} + + + + + {routes} + + + + + + + + + + + + ) } diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.stories.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.stories.tsx new file mode 100644 index 00000000000..0e0da16f94a --- /dev/null +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.stories.tsx @@ -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 + +export default { + title: 'AriaComponents/Dialog', + component: Dialog, + render: (args) => ( + + + + + + ), + args: { + type: 'modal', + title: 'Dialog Title', + children: 'Dialog Content', + }, +} as Meta + +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 ( +
+ Unsuspended content +
+ ) +} + +export const Suspened = { + args: { + children: , + }, +} + +function BrokenContent(): React.ReactNode { + throw new Error('💣') +} + +export const Broken = { + args: { + children: , + }, +} + +function ResizableContent() { + const divRef = useRef(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 ( +
+ This dialog should resize with animation +
+ ) +} + +export const AnimateSize: Story = { + args: { + children: , + }, + parameters: { + chromatic: { disableSnapshot: true }, + }, +} + +export const Fullscreen = { + args: { + type: 'fullscreen', + }, +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx index 1a894face33..4dbf9971059 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx @@ -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(null) - const overlayState = React.useRef(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 ( @@ -256,97 +191,19 @@ export function Dialog(props: DialogProps) { shouldCloseOnInteractOutside={() => false} {...modalProps} > - {(values) => { - overlayState.current = values.state - - return ( - MODAL_STYLES({ type, isEntering, isExiting })} - isDismissable={isDismissable} - isKeyboardDismissDisabled={isKeyboardDismissDisabled} - UNSTABLE_portalContainer={root} - onOpenChange={onOpenChange} - shouldCloseOnInteractOutside={() => false} - {...modalProps} - > - - { - 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) => ( -
- - {(closeButton !== 'none' || title != null) && ( - - {closeButton !== 'none' && ( - - )} - - {title != null && ( - - {title} - - )} - - )} - -
{ - if (ref) { - handleScroll(ref.scrollTop) - } - }} - className={styles.content()} - onScroll={(event) => { - handleScroll(event.currentTarget.scrollTop) - }} - > - - - {typeof children === 'function' ? children(opts) : children} - - -
-
-
- )} -
-
-
- ) - }} + {(values) => ( + MODAL_STYLES({ type, isEntering, isExiting })} + isDismissable={isDismissable} + isKeyboardDismissDisabled={isKeyboardDismissDisabled} + UNSTABLE_portalContainer={root} + onOpenChange={onOpenChange} + shouldCloseOnInteractOutside={() => false} + {...modalProps} + > + + + )}
) } @@ -359,4 +216,248 @@ const TYPE_TO_DIALOG_TYPE: Record< fullscreen: 'dialog-fullscreen', } +/** + * Props for the {@link DialogContent} component. + */ +interface DialogContentProps extends DialogProps, VariantProps { + 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(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) => { + 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 ( + <> + + 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) => ( + <> + + + + + + +
+ {/* 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 */} +
+
+ + + {typeof children === 'function' ? children(opts) : children} + + +
+
+ + + + )} + + + + + ) +} + +/** + * 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 ( + + {closeButton !== 'none' && ( + + )} + + {title != null && ( + + {title} + + )} + + ) +}) + Dialog.Close = Close diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogProvider.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogProvider.tsx index a77f206d297..8e2272511bb 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogProvider.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogProvider.tsx @@ -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(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 {children} +} /** 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 +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogStackProvider.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogStackProvider.tsx index 8570b1deed7..ec34593a3cd 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogStackProvider.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogStackProvider.tsx @@ -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) { - 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 + +export default { + title: 'AriaComponents/Popover', + component: Popover, + args: { + children: 'Popover content', + }, + render: (props: PopoverProps) => ( + + + + + ), +} satisfies Meta + +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() + }, +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx index 285b0c42d38..1cb6cdce459 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx @@ -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(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(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 ( POPOVER_STYLES({ isEntering: values.isEntering, @@ -121,22 +103,92 @@ export function Popover(props: PopoverProps) { {...ariaPopoverProps} > {(opts) => ( - -
- - - - {typeof children === 'function' ? children({ ...opts, close }) : children} - - - -
-
+ + {children} + )}
) } + +/** + * 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 + readonly isDismissable: boolean +} + +/** + * The content of a popover. + */ +function PopoverContent(props: PopoverContentProps) { + const { children, size, rounded, opts, isDismissable, popoverRef } = props + + const dialogRef = React.useRef(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 ( + <> + + + + ) +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/utilities.ts b/app/gui/src/dashboard/components/AriaComponents/Dialog/utilities.ts index 3c909bc678d..276bab75834 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/utilities.ts +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/utilities.ts @@ -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' }, + ) +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/variants.ts b/app/gui/src/dashboard/components/AriaComponents/Dialog/variants.ts index c8f7ea6bb3b..5ed864c0123 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/variants.ts +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/variants.ts @@ -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' }, }) diff --git a/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx b/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx index dd288e13188..c18da69f59c 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx @@ -241,13 +241,13 @@ export interface HeadingProps extends Omit { /** Heading component */ // eslint-disable-next-line no-restricted-syntax -const Heading = forwardRef(function Heading( - props: HeadingProps, - ref: React.Ref, -) { - const { level = 1, ...textProps } = props - return -}) +const Heading = memo( + forwardRef(function Heading(props: HeadingProps, ref: React.Ref) { + const { level = 1, ...textProps } = props + return + }), +) + Text.Heading = Heading /** Text group component. It's used to visually group text elements together */ diff --git a/app/gui/src/dashboard/components/Devtools/EnsoDevtools.tsx b/app/gui/src/dashboard/components/Devtools/EnsoDevtools.tsx index 726098750e8..e41c813d552 100644 --- a/app/gui/src/dashboard/components/Devtools/EnsoDevtools.tsx +++ b/app/gui/src/dashboard/components/Devtools/EnsoDevtools.tsx @@ -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>({}) @@ -150,19 +156,36 @@ export function EnsoDevtools() { 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 }) => ( - { - setEnableVersionChecker(value) - }} - /> + <> + { + setAnimationsDisabled(value) + }} + /> + + { + setEnableVersionChecker(value) + }} + /> + )} diff --git a/app/gui/src/dashboard/components/Devtools/EnsoDevtoolsProvider.tsx b/app/gui/src/dashboard/components/Devtools/EnsoDevtoolsProvider.tsx index 37e3c98a908..c90492d39ce 100644 --- a/app/gui/src/dashboard/components/Devtools/EnsoDevtoolsProvider.tsx +++ b/app/gui/src/dashboard/components/Devtools/EnsoDevtoolsProvider.tsx @@ -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 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((set) => ({ @@ -52,6 +54,20 @@ export const ensoDevtoolsStore = zustand.createStore((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 } diff --git a/app/gui/src/dashboard/components/Loader.tsx b/app/gui/src/dashboard/components/Loader.tsx index 37670ed4f07..c2f88974691 100644 --- a/app/gui/src/dashboard/components/Loader.tsx +++ b/app/gui/src/dashboard/components/Loader.tsx @@ -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 { } /** 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) {
) -} +}) diff --git a/app/gui/src/dashboard/components/UIProviders.tsx b/app/gui/src/dashboard/components/UIProviders.tsx index 486d067f4b3..9c1e930c9d0 100644 --- a/app/gui/src/dashboard/components/UIProviders.tsx +++ b/app/gui/src/dashboard/components/UIProviders.tsx @@ -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 { @@ -19,10 +27,12 @@ export interface UIProvidersProps extends Readonly { export default function UIProviders(props: UIProvidersProps) { const { portalRoot, locale, children } = props return ( - - - {children} - - + + + + {children} + + + ) } diff --git a/app/gui/src/dashboard/hooks/measureHooks.ts b/app/gui/src/dashboard/hooks/measureHooks.ts index a8d6e1f1e8a..339b72986d6 100644 --- a/app/gui/src/dashboard/hooks/measureHooks.ts +++ b/app/gui/src/dashboard/hooks/measureHooks.ts @@ -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({ - left: 0, - top: 0, - width: 0, - height: 0, - bottom: 0, - right: 0, - x: 0, - y: 0, - }) + const [bounds, set] = useState(null) const onResizeStableCallback = useEventCallback((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(null) + + const onResizeStableCallback = useEventCallback((nextBounds) => { + bounds.set(nextBounds) onResize?.(nextBounds) }) @@ -113,22 +118,14 @@ export function useMeasureCallback(options: Options & Required({ 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 { - 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 { + measureCallback() + }, true) } if (scroll && state.current.scrollContainers) { @@ -214,12 +223,13 @@ export function useMeasureCallback(options: Options & Required a[key] === b[key]) } diff --git a/app/gui/src/dashboard/index.tsx b/app/gui/src/dashboard/index.tsx index 89b2748c179..b096cad78a9 100644 --- a/app/gui/src/dashboard/index.tsx +++ b/app/gui/src/dashboard/index.tsx @@ -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( - + }> - - - + @@ -132,7 +141,7 @@ export function run(props: DashboardProps) { - + , )