mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 22:21:40 +03:00
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:
parent
3d38b7174f
commit
af0b95b1d9
@ -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>
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
75
app/gui/src/dashboard/utilities/motion.ts
Normal file
75
app/gui/src/dashboard/utilities/motion.ts
Normal 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]>>
|
||||||
|
>
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user