diff --git a/change/@fluentui-react-dialog-c4b3b805-3dcc-4caa-9c9e-ceb9a1580a1a.json b/change/@fluentui-react-dialog-c4b3b805-3dcc-4caa-9c9e-ceb9a1580a1a.json new file mode 100644 index 00000000000000..7e23e0231e7d80 --- /dev/null +++ b/change/@fluentui-react-dialog-c4b3b805-3dcc-4caa-9c9e-ceb9a1580a1a.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: adds motion to DialogSurface", + "packageName": "@fluentui/react-dialog", + "email": "bernardo.sunderhus@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-dialog/etc/react-dialog.api.md b/packages/react-components/react-dialog/etc/react-dialog.api.md index 0caab37a910b8c..95f4b3d4b2e9df 100644 --- a/packages/react-components/react-dialog/etc/react-dialog.api.md +++ b/packages/react-components/react-dialog/etc/react-dialog.api.md @@ -166,7 +166,9 @@ export type DialogSurfaceSlots = { }; // @public -export type DialogSurfaceState = ComponentState & Partial> & Pick; +export type DialogSurfaceState = ComponentState & Pick & Pick & { + transitionStatus?: 'entering' | 'entered' | 'idle' | 'exiting' | 'exited' | 'unmounted'; +}; // @public export const DialogTitle: ForwardRefComponent; diff --git a/packages/react-components/react-dialog/package.json b/packages/react-components/react-dialog/package.json index 3e0ab46a28ca87..8c2e93ce6ab2c2 100644 --- a/packages/react-components/react-dialog/package.json +++ b/packages/react-components/react-dialog/package.json @@ -38,6 +38,7 @@ "dependencies": { "@fluentui/react-utilities": "^9.15.1", "@fluentui/react-jsx-runtime": "^9.0.18", + "react-transition-group": "^4.4.1", "@fluentui/keyboard-keys": "^9.0.6", "@fluentui/react-context-selector": "^9.1.41", "@fluentui/react-shared-contexts": "^9.10.0", diff --git a/packages/react-components/react-dialog/src/components/Dialog/renderDialog.tsx b/packages/react-components/react-dialog/src/components/Dialog/renderDialog.tsx index 99b9eb63dc329a..c21b8812d832fb 100644 --- a/packages/react-components/react-dialog/src/components/Dialog/renderDialog.tsx +++ b/packages/react-components/react-dialog/src/components/Dialog/renderDialog.tsx @@ -1,8 +1,10 @@ /** @jsxRuntime automatic */ /** @jsxImportSource @fluentui/react-jsx-runtime */ +import { Transition } from 'react-transition-group'; import { DialogProvider, DialogSurfaceProvider } from '../../contexts'; import type { DialogState, DialogContextValues } from './Dialog.types'; +import { DialogTransitionProvider } from '../../contexts/dialogTransitionContext'; /** * Render the final JSX of Dialog @@ -14,7 +16,16 @@ export const renderDialog_unstable = (state: DialogState, contextValues: DialogC {trigger} - {content} + + {status => {content}} + ); diff --git a/packages/react-components/react-dialog/src/components/Dialog/useDialog.ts b/packages/react-components/react-dialog/src/components/Dialog/useDialog.ts index b08036b9ca834c..290db8cda0473d 100644 --- a/packages/react-components/react-dialog/src/components/Dialog/useDialog.ts +++ b/packages/react-components/react-dialog/src/components/Dialog/useDialog.ts @@ -58,7 +58,7 @@ export const useDialog_unstable = (props: DialogProps): DialogState => { inertTrapFocus, open, modalType, - content: open ? content : null, + content, trigger, requestOpenChange, dialogTitleId: useId('dialog-title-'), diff --git a/packages/react-components/react-dialog/src/components/DialogSurface/DialogSurface.types.ts b/packages/react-components/react-dialog/src/components/DialogSurface/DialogSurface.types.ts index 2ae1eb10c14091..67828d03081384 100644 --- a/packages/react-components/react-dialog/src/components/DialogSurface/DialogSurface.types.ts +++ b/packages/react-components/react-dialog/src/components/DialogSurface/DialogSurface.types.ts @@ -33,5 +33,8 @@ export type DialogSurfaceContextValues = { */ export type DialogSurfaceState = ComponentState & // This is only partial to avoid breaking changes, it should be mandatory and in fact it is always defined internally. - Partial> & - Pick; + Pick & + Pick & { + // This is only optional to avoid breaking changes, it should be mandatory and in fact it is always defined internally. + transitionStatus?: 'entering' | 'entered' | 'idle' | 'exiting' | 'exited' | 'unmounted'; + }; diff --git a/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurface.ts b/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurface.ts index 00f41d523407bd..529b52ec201ca1 100644 --- a/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurface.ts +++ b/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurface.ts @@ -9,6 +9,7 @@ import { import type { DialogSurfaceElement, DialogSurfaceProps, DialogSurfaceState } from './DialogSurface.types'; import { useDialogContext_unstable } from '../../contexts'; import { Escape } from '@fluentui/keyboard-keys'; +import { useDialogTransitionContext_unstable } from '../../contexts/dialogTransitionContext'; /** * Create the state required to render DialogSurface. @@ -25,9 +26,9 @@ export const useDialogSurface_unstable = ( ): DialogSurfaceState => { const modalType = useDialogContext_unstable(ctx => ctx.modalType); const isNestedDialog = useDialogContext_unstable(ctx => ctx.isNestedDialog); + const transitionStatus = useDialogTransitionContext_unstable(); const modalAttributes = useDialogContext_unstable(ctx => ctx.modalAttributes); const dialogRef = useDialogContext_unstable(ctx => ctx.dialogRef); - const open = useDialogContext_unstable(ctx => ctx.open); const requestOpenChange = useDialogContext_unstable(ctx => ctx.requestOpenChange); const dialogTitleID = useDialogContext_unstable(ctx => ctx.dialogTitleId); @@ -60,7 +61,7 @@ export const useDialogSurface_unstable = ( }); const backdrop = slot.optional(props.backdrop, { - renderByDefault: open && modalType !== 'non-modal', + renderByDefault: modalType !== 'non-modal', defaultProps: { 'aria-hidden': 'true', }, @@ -73,6 +74,7 @@ export const useDialogSurface_unstable = ( components: { backdrop: 'div', root: 'div' }, backdrop, isNestedDialog, + transitionStatus, mountNode: props.mountNode, root: slot.always( getIntrinsicElementProps('div', { diff --git a/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurfaceStyles.styles.ts b/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurfaceStyles.styles.ts index d8bcec4a628dc9..935253d1e0e86e 100644 --- a/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurfaceStyles.styles.ts +++ b/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurfaceStyles.styles.ts @@ -13,7 +13,7 @@ export const dialogSurfaceClassNames: SlotClassNames = { /** * Styles for the root slot */ -const useRootResetStyles = makeResetStyles({ +const useRootBaseStyle = makeResetStyles({ ...createFocusOutlineStyle(), ...shorthands.inset(0), ...shorthands.padding(0), @@ -33,46 +33,96 @@ const useRootResetStyles = makeResetStyles({ maxWidth: '600px', maxHeight: '100vh', boxSizing: 'border-box', - boxShadow: tokens.shadow64, backgroundColor: tokens.colorNeutralBackground1, color: tokens.colorNeutralForeground1, [MEDIA_QUERY_BREAKPOINT_SELECTOR]: { maxWidth: '100vw', }, + + // initial style before animation: + opacity: 0, + transitionDuration: tokens.durationGentle, + transitionProperty: 'opacity, transform, box-shadow', + // // FIXME: https://github.com/microsoft/fluentui/issues/29473 + transitionTimingFunction: tokens.curveDecelerateMid, + boxShadow: '0px 0px 0px 0px rgba(0, 0, 0, 0.1)', + transform: 'scale(0.85) translateZ(0)', }); -const useBackdropStyles = makeStyles({ - nestedDialogBackdrop: { - backgroundColor: tokens.colorTransparentBackground, +const useRootStyles = makeStyles({ + unmounted: {}, + entering: {}, + entered: { + boxShadow: tokens.shadow64, + transform: 'scale(1) translateZ(0)', + opacity: 1, }, + idle: {}, + exiting: { + transitionTimingFunction: tokens.curveAccelerateMin, + }, + exited: {}, }); /** * Styles for the backdrop slot */ -const useBackdropResetStyles = makeResetStyles({ +const useBackdropBaseStyle = makeResetStyles({ ...shorthands.inset('0px'), backgroundColor: 'rgba(0, 0, 0, 0.4)', position: 'fixed', + + // initial style before animation: + transitionDuration: tokens.durationGentle, + transitionTimingFunction: tokens.curveLinear, + transitionProperty: 'opacity', + willChange: 'opacity', + opacity: 0, +}); + +const useBackdropStyles = makeStyles({ + nestedDialogBackdrop: { + backgroundColor: tokens.colorTransparentBackground, + }, + unmounted: {}, + entering: {}, + entered: { + opacity: 1, + }, + idle: {}, + exiting: { + transitionTimingFunction: tokens.curveAccelerateMin, + }, + exited: {}, }); /** * Apply styling to the DialogSurface slots based on the state */ export const useDialogSurfaceStyles_unstable = (state: DialogSurfaceState): DialogSurfaceState => { - const surfaceResetStyles = useRootResetStyles(); - const styles = useBackdropStyles(); - const backdropResetStyles = useBackdropResetStyles(); + const { isNestedDialog, root, backdrop, transitionStatus = 'unmounted' } = state; + + const rootBaseStyle = useRootBaseStyle(); + const rootStyles = useRootStyles(); + + const backdropBaseStyle = useBackdropBaseStyle(); + const backdropStyles = useBackdropStyles(); - state.root.className = mergeClasses(dialogSurfaceClassNames.root, surfaceResetStyles, state.root.className); + root.className = mergeClasses( + dialogSurfaceClassNames.root, + rootBaseStyle, + rootStyles[transitionStatus], + root.className, + ); - if (state.backdrop) { - state.backdrop.className = mergeClasses( + if (backdrop) { + backdrop.className = mergeClasses( dialogSurfaceClassNames.backdrop, - backdropResetStyles, - state.isNestedDialog && styles.nestedDialogBackdrop, - state.backdrop.className, + backdropBaseStyle, + isNestedDialog && backdropStyles.nestedDialogBackdrop, + backdropStyles[transitionStatus], + backdrop.className, ); } diff --git a/packages/react-components/react-dialog/src/contexts/dialogTransitionContext.ts b/packages/react-components/react-dialog/src/contexts/dialogTransitionContext.ts new file mode 100644 index 00000000000000..c3501013a97e58 --- /dev/null +++ b/packages/react-components/react-dialog/src/contexts/dialogTransitionContext.ts @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { TransitionStatus } from 'react-transition-group'; + +/** + * @internal + */ +export type DialogTransitionContextValue = TransitionStatus; + +/** + * @internal + */ +const defaultContextValue: DialogTransitionContextValue = 'unmounted'; + +// Contexts should default to undefined +/** + * @internal + */ +export const DialogTransitionContext: React.Context = React.createContext< + DialogTransitionContextValue | undefined +>(undefined); + +/** + * @internal + */ +export const DialogTransitionProvider = DialogTransitionContext.Provider; + +/** + * @internal + */ +export const useDialogTransitionContext_unstable = (): DialogTransitionContextValue => { + return React.useContext(DialogTransitionContext) ?? defaultContextValue; +};