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
This commit is contained in:
somebody1234 2024-11-14 06:12:21 +10:00 committed by GitHub
parent 3d38b7174f
commit af0b95b1d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 110 additions and 15 deletions

View File

@ -12,6 +12,9 @@ import * as suspense from '#/components/Suspense'
import * as mergeRefs from '#/utilities/mergeRefs' import * as mergeRefs from '#/utilities/mergeRefs'
import { useDimensions } from '#/hooks/dimensionsHooks'
import type { Spring } from '#/utilities/motion'
import { motion } from '#/utilities/motion'
import type { VariantProps } from '#/utilities/tailwindVariants' import type { VariantProps } from '#/utilities/tailwindVariants'
import { tv } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants'
import * as dialogProvider from './DialogProvider' import * as dialogProvider from './DialogProvider'
@ -20,13 +23,9 @@ import type * as types from './types'
import * as utlities from './utilities' import * as utlities from './utilities'
import { DIALOG_BACKGROUND } from './variants' import { DIALOG_BACKGROUND } from './variants'
// ================= // This is a JSX component, even though it does not contain function syntax.
// === Constants === // eslint-disable-next-line no-restricted-syntax
// ================= const MotionDialog = motion(aria.Dialog)
/** Props for the {@link Dialog} component. */
export interface DialogProps
extends types.DialogProps,
Omit<VariantProps<typeof DIALOG_STYLES>, 'scrolledToTop'> {}
const OVERLAY_STYLES = tv({ const OVERLAY_STYLES = tv({
base: 'fixed inset-0 isolate flex items-center justify-center bg-primary/20 z-tooltip', 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({ const DIALOG_STYLES = tv({
base: DIALOG_BACKGROUND({ 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: { variants: {
type: { type: {
@ -124,6 +123,7 @@ const DIALOG_STYLES = tv({
closeButton: 'col-start-1 col-end-1 mr-auto', closeButton: 'col-start-1 col-end-1 mr-auto',
heading: 'col-start-2 col-end-2 my-0 text-center', heading: 'col-start-2 col-end-2 my-0 text-center',
content: 'relative flex-auto overflow-y-auto max-h-[inherit]', content: 'relative flex-auto overflow-y-auto max-h-[inherit]',
measuredContent: 'flex flex-col max-h-[90vh]',
}, },
compoundVariants: [ compoundVariants: [
{ type: 'modal', size: 'small', class: 'max-w-sm' }, { 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 === // === 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. * A dialog is an overlay shown above other content in an application.
* Can be used to display alerts, confirmations, or other content. * Can be used to display alerts, confirmations, or other content.
@ -187,8 +201,10 @@ export function Dialog(props: DialogProps) {
} }
const dialogId = aria.useId() const dialogId = aria.useId()
const dialogLayoutId = `dialog-${dialogId}`
const titleId = `${dialogId}-title` const titleId = `${dialogId}-title`
const [contentDimensionsRef, { width: dialogWidth, height: dialogHeight }] = useDimensions()
const dialogRef = React.useRef<HTMLDivElement>(null) const dialogRef = React.useRef<HTMLDivElement>(null)
const overlayState = React.useRef<aria.OverlayTriggerState | null>(null) const overlayState = React.useRef<aria.OverlayTriggerState | null>(null)
const root = portal.useStrictPortalContext() const root = portal.useStrictPortalContext()
@ -250,7 +266,11 @@ export function Dialog(props: DialogProps) {
id={dialogId} id={dialogId}
type={TYPE_TO_DIALOG_TYPE[type]} type={TYPE_TO_DIALOG_TYPE[type]}
> >
<aria.Dialog <MotionDialog
layout
layoutId={dialogLayoutId}
animate={{ width: dialogWidth, height: dialogHeight }}
transition={RESIZE_TRANSITION_STYLES}
id={dialogId} id={dialogId}
ref={mergeRefs.mergeRefs(dialogRef, (element) => { ref={mergeRefs.mergeRefs(dialogRef, (element) => {
if (element) { if (element) {
@ -268,8 +288,8 @@ export function Dialog(props: DialogProps) {
aria-labelledby={titleId} aria-labelledby={titleId}
{...ariaDialogProps} {...ariaDialogProps}
> >
{(opts) => { {(opts) => (
return ( <div className={styles.measuredContent()} ref={contentDimensionsRef}>
<dialogProvider.DialogProvider value={{ close: opts.close, dialogId }}> <dialogProvider.DialogProvider value={{ close: opts.close, dialogId }}>
{(closeButton !== 'none' || title != null) && ( {(closeButton !== 'none' || title != null) && (
<aria.Header className={styles.header({ scrolledToTop: isScrolledToTop })}> <aria.Header className={styles.header({ scrolledToTop: isScrolledToTop })}>
@ -313,9 +333,9 @@ export function Dialog(props: DialogProps) {
</errorBoundary.ErrorBoundary> </errorBoundary.ErrorBoundary>
</div> </div>
</dialogProvider.DialogProvider> </dialogProvider.DialogProvider>
) </div>
}} )}
</aria.Dialog> </MotionDialog>
</dialogStackProvider.DialogStackRegistrar> </dialogStackProvider.DialogStackRegistrar>
</aria.Modal> </aria.Modal>
) )

View File

@ -106,7 +106,7 @@ export function useDimensions({
(entries) => { (entries) => {
if (entries[0]) { if (entries[0]) {
measure() measure()
updateChildPosition() // entries[0].boundingClientRect) updateChildPosition()
} }
}, },
// eslint-disable-next-line @typescript-eslint/no-magic-numbers // eslint-disable-next-line @typescript-eslint/no-magic-numbers

View File

@ -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]>>
>
}