Skip to content

Commit

Permalink
Animated resizing for dialogs (#11466)
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
somebody1234 authored Nov 13, 2024
1 parent 3d38b71 commit af0b95b
Show file tree
Hide file tree
Showing 3 changed files with 110 additions and 15 deletions.
48 changes: 34 additions & 14 deletions app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<VariantProps<typeof DIALOG_STYLES>, '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',
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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' },
Expand All @@ -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<VariantProps<typeof DIALOG_STYLES>, 'scrolledToTop'> {}

/**
* A dialog is an overlay shown above other content in an application.
* Can be used to display alerts, confirmations, or other content.
Expand Down Expand Up @@ -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<HTMLDivElement>(null)
const overlayState = React.useRef<aria.OverlayTriggerState | null>(null)
const root = portal.useStrictPortalContext()
Expand Down Expand Up @@ -250,7 +266,11 @@ export function Dialog(props: DialogProps) {
id={dialogId}
type={TYPE_TO_DIALOG_TYPE[type]}
>
<aria.Dialog
<MotionDialog
layout
layoutId={dialogLayoutId}
animate={{ width: dialogWidth, height: dialogHeight }}
transition={RESIZE_TRANSITION_STYLES}
id={dialogId}
ref={mergeRefs.mergeRefs(dialogRef, (element) => {
if (element) {
Expand All @@ -268,8 +288,8 @@ export function Dialog(props: DialogProps) {
aria-labelledby={titleId}
{...ariaDialogProps}
>
{(opts) => {
return (
{(opts) => (
<div className={styles.measuredContent()} ref={contentDimensionsRef}>
<dialogProvider.DialogProvider value={{ close: opts.close, dialogId }}>
{(closeButton !== 'none' || title != null) && (
<aria.Header className={styles.header({ scrolledToTop: isScrolledToTop })}>
Expand Down Expand Up @@ -313,9 +333,9 @@ export function Dialog(props: DialogProps) {
</errorBoundary.ErrorBoundary>
</div>
</dialogProvider.DialogProvider>
)
}}
</aria.Dialog>
</div>
)}
</MotionDialog>
</dialogStackProvider.DialogStackRegistrar>
</aria.Modal>
)
Expand Down
2 changes: 1 addition & 1 deletion app/gui/src/dashboard/hooks/dimensionsHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions app/gui/src/dashboard/utilities/motion.ts
Original file line number Diff line number Diff line change
@@ -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> = F extends DetailedHTMLFactory<any, infer P> ? P : never
/** Get the inner type of a {@link SVGProps}. */
type UnwrapSVGFactoryElement<F> = F extends SVGProps<infer P> ? 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 (<Props extends object>(
Component: ComponentType<PropsWithChildren<Props>> | string,
customMotionComponentConfig?: CustomMotionComponentConfig,
) => ForwardRefExoticComponent<
PropsWithoutRef<
Omit<MotionProps & Props, 'children' | 'style'> &
(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<HTMLElement | SVGElement>
>) & {
[K in keyof HTMLElementTagNameMap]: ForwardRefComponent<
UnwrapFactoryElement<ReactHTML[K]>,
HTMLMotionProps<K>
>
} & {
[K in keyof SVGElementTagNameMap]: ForwardRefComponent<
UnwrapSVGFactoryElement<JSX.IntrinsicElements[K]>,
SVGMotionProps<UnwrapSVGFactoryElement<JSX.IntrinsicElements[K]>>
>
}

0 comments on commit af0b95b

Please sign in to comment.