From af0b95b1d98ccb9076b028df128e5a8d1aed4194 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 14 Nov 2024 06:12:21 +1000 Subject: [PATCH] Animated resizing for dialogs (#11466) - Cherry-picked out of #10827 - Add `framer-motion` to dialog to animate between dialog sizes. - Currently visible when switching between types in the Datalink modal. - Will also be visible when switching between types in the Schedule modal. # Important Notes None --- .../AriaComponents/Dialog/Dialog.tsx | 48 ++++++++---- .../src/dashboard/hooks/dimensionsHooks.ts | 2 +- app/gui/src/dashboard/utilities/motion.ts | 75 +++++++++++++++++++ 3 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 app/gui/src/dashboard/utilities/motion.ts diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx index 9fde9308a89..80ed466767f 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx @@ -12,6 +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 type { VariantProps } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants' import * as dialogProvider from './DialogProvider' @@ -20,13 +23,9 @@ import type * as types from './types' import * as utlities from './utilities' import { DIALOG_BACKGROUND } from './variants' -// ================= -// === Constants === -// ================= -/** Props for the {@link Dialog} component. */ -export interface DialogProps - extends types.DialogProps, - Omit, 'scrolledToTop'> {} +// 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) const OVERLAY_STYLES = tv({ base: 'fixed inset-0 isolate flex items-center justify-center bg-primary/20 z-tooltip', @@ -54,7 +53,7 @@ const MODAL_STYLES = tv({ const DIALOG_STYLES = tv({ base: DIALOG_BACKGROUND({ - className: 'w-full max-w-full flex flex-col text-left align-middle shadow-xl', + className: 'w-full max-w-full flex flex-col text-left align-middle shadow-xl overflow-clip', }), variants: { type: { @@ -124,6 +123,7 @@ const DIALOG_STYLES = tv({ 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 max-h-[90vh]', }, compoundVariants: [ { type: 'modal', size: 'small', class: 'max-w-sm' }, @@ -144,10 +144,24 @@ const DIALOG_STYLES = tv({ }, }) +const RESIZE_TRANSITION_STYLES: 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, +} + // ============== // === Dialog === // ============== +/** Props for the {@link Dialog} component. */ +export interface DialogProps + extends types.DialogProps, + Omit, 'scrolledToTop'> {} + /** * A dialog is an overlay shown above other content in an application. * Can be used to display alerts, confirmations, or other content. @@ -187,8 +201,10 @@ export function Dialog(props: DialogProps) { } const dialogId = aria.useId() + const dialogLayoutId = `dialog-${dialogId}` const titleId = `${dialogId}-title` + const [contentDimensionsRef, { width: dialogWidth, height: dialogHeight }] = useDimensions() const dialogRef = React.useRef(null) const overlayState = React.useRef(null) const root = portal.useStrictPortalContext() @@ -250,7 +266,11 @@ export function Dialog(props: DialogProps) { id={dialogId} type={TYPE_TO_DIALOG_TYPE[type]} > - { if (element) { @@ -268,8 +288,8 @@ export function Dialog(props: DialogProps) { aria-labelledby={titleId} {...ariaDialogProps} > - {(opts) => { - return ( + {(opts) => ( +
{(closeButton !== 'none' || title != null) && ( @@ -313,9 +333,9 @@ export function Dialog(props: DialogProps) {
- ) - }} -
+ + )} + ) diff --git a/app/gui/src/dashboard/hooks/dimensionsHooks.ts b/app/gui/src/dashboard/hooks/dimensionsHooks.ts index c024918e2a4..0ce7735e3c9 100644 --- a/app/gui/src/dashboard/hooks/dimensionsHooks.ts +++ b/app/gui/src/dashboard/hooks/dimensionsHooks.ts @@ -106,7 +106,7 @@ export function useDimensions({ (entries) => { if (entries[0]) { measure() - updateChildPosition() // entries[0].boundingClientRect) + updateChildPosition() } }, // eslint-disable-next-line @typescript-eslint/no-magic-numbers diff --git a/app/gui/src/dashboard/utilities/motion.ts b/app/gui/src/dashboard/utilities/motion.ts new file mode 100644 index 00000000000..09b53853ef1 --- /dev/null +++ b/app/gui/src/dashboard/utilities/motion.ts @@ -0,0 +1,75 @@ +/** @file Type-safe `motion` from `framer-motion`. */ +import { + motion as originalMotion, + type ForwardRefComponent, + type HTMLMotionProps, + type MotionProps, + type SVGMotionProps, +} from 'framer-motion' + +import type { + ComponentType, + DetailedHTMLFactory, + ForwardRefExoticComponent, + PropsWithChildren, + PropsWithoutRef, + ReactHTML, + RefAttributes, + SVGProps, +} from 'react' + +/** The options parameter for {@link motion}. */ +interface CustomMotionComponentConfig { + readonly forwardMotionProps?: boolean +} + +/** Get the inner type of a {@link DetailedHTMLFactory}. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type UnwrapFactoryElement = F extends DetailedHTMLFactory ? P : never +/** Get the inner type of a {@link SVGProps}. */ +type UnwrapSVGFactoryElement = F extends SVGProps ? P : never + +export * from 'framer-motion' + +/** + * HTML & SVG components, optimised for use with gestures and animation. + * These can be used as drop-in replacements for any HTML & SVG component - + * all CSS & SVG properties are supported. + */ +// This is a function, even though it does not contain function syntax. +// eslint-disable-next-line no-restricted-syntax +export const motion = originalMotion as unknown as (( + Component: ComponentType> | string, + customMotionComponentConfig?: CustomMotionComponentConfig, +) => ForwardRefExoticComponent< + PropsWithoutRef< + Omit & + (Props extends { readonly children?: infer Children } ? + // `Props` has a key `Children` but it may be optional. + // Use a homomorphic mapped type (a mapped type with `keyof T` in the key set) + // to preserve modifiers (optional and readonly). + { + [K in keyof Props as K extends 'children' ? K : never]: Children | MotionProps['children'] + } + : // `Props` has no key `Children`. + { children?: MotionProps['children'] }) & + (Props extends { readonly style?: infer Style } ? + // `Props` has a key `Style` but it may be optional. + // Use a homomorphic mapped type (a mapped type with `keyof T` in the key set) + // to preserve modifiers (optional and readonly). + { [K in keyof Props as K extends 'style' ? K : never]: MotionProps['style'] | Style } + : // `Props` has no key `Style`. + { style?: MotionProps['style'] }) + > & + RefAttributes +>) & { + [K in keyof HTMLElementTagNameMap]: ForwardRefComponent< + UnwrapFactoryElement, + HTMLMotionProps + > +} & { + [K in keyof SVGElementTagNameMap]: ForwardRefComponent< + UnwrapSVGFactoryElement, + SVGMotionProps> + > +}