diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json
index f92ad01eaf..1e8274d398 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 519a1672e1..a984a54811 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 b4791b73f2..4f2765589c 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 0000000000..0e0da16f94
--- /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) => (
+
+ Open Dialog
+
+
+
+ ),
+ 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 1a894face3..4dbf997105 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 a77f206d29..8e2272511b 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 8570b1deed..ec34593a3c 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) => (
+
+ Open Dialog
+
+
+ ),
+} 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 285b0c42d3..1cb6cdce45 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 (
+ <>
+
+
+
+
+
+ {typeof children === 'function' ? children({ ...opts, close }) : children}
+
+
+
+
+ >
+ )
+}
diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/utilities.ts b/app/gui/src/dashboard/components/AriaComponents/Dialog/utilities.ts
index 3c909bc678..276bab7583 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 c8f7ea6bb3..5ed864c012 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 dd288e1318..c18da69f59 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 726098750e..e41c813d55 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 37e3c98a90..c90492d39c 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 37670ed4f0..c2f8897469 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 486d067f4b..9c1e930c9d 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 a8d6e1f1e8..339b72986d 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 89b2748c17..b096cad78a 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) {
-
+
,
)