From 9ff8261fe32e27783d236369dc510be312614b69 Mon Sep 17 00:00:00 2001 From: Robert Penner Date: Fri, 27 Feb 2026 12:53:50 -0500 Subject: [PATCH] refactor(Popover, Menu): migrate slide animation from CSS to motion component (#35763) Co-authored-by: Oleksandr Fediashov --- ...-84a7c9b8-4c96-469e-bd81-ab504dc3e1b2.json | 7 + ...-4d715610-504c-4e63-8bb9-87cd24f37cd6.json | 7 + ...-9944ff0a-e3bf-476a-9566-d495eda8b9b3.json | 7 + ...-21632be5-a50c-4637-a8fc-84ec89d3796f.json | 7 + .../__snapshots__/GanttChart.test.tsx.snap | 4 +- .../react-menu/library/etc/react-menu.api.md | 9 +- .../react-menu/library/package.json | 2 + .../library/src/components/Menu/Menu.types.ts | 19 +- .../src/components/Menu/MenuSurfaceMotion.ts | 42 +++ .../src/components/Menu/renderMenu.tsx | 21 +- .../library/src/components/Menu/useMenu.tsx | 37 ++- .../components/MenuPopover/useMenuPopover.ts | 8 +- .../useMenuPopoverStyles.styles.ts | 2 - .../src/Menu/MenuMotionCustom.stories.tsx | 51 ++++ .../src/Menu/MenuMotionDisabled.stories.tsx | 28 ++ .../stories/src/Menu/index.stories.tsx | 2 + .../library/etc/react-motion.api.md | 2 +- .../src/components/MotionRefForwarder.tsx | 2 +- .../library/etc/react-popover.api.md | 7 +- .../react-popover/library/package.json | 2 + .../src/components/Popover/Popover.types.ts | 267 +++++++++--------- .../Popover/PopoverSurfaceMotion.ts | 42 +++ .../src/components/Popover/renderPopover.tsx | 21 +- .../src/components/Popover/usePopover.ts | 22 ++ .../PopoverSurface/usePopoverSurface.ts | 4 +- .../usePopoverSurfaceStyles.styles.ts | 3 +- .../Popover/PopoverMotionCustom.stories.tsx | 35 +++ .../Popover/PopoverMotionDisabled.stories.tsx | 24 ++ .../stories/src/Popover/index.stories.tsx | 2 + .../library/etc/react-positioning.api.md | 11 +- .../library/src/constants.ts | 8 + .../library/src/createSlideStyles.ts | 3 + .../react-positioning/library/src/index.ts | 3 + .../src/usePositioningSlideDirection.test.ts | 158 +++++++++++ .../src/usePositioningSlideDirection.ts | 98 +++++++ 35 files changed, 810 insertions(+), 157 deletions(-) create mode 100644 change/@fluentui-react-menu-84a7c9b8-4c96-469e-bd81-ab504dc3e1b2.json create mode 100644 change/@fluentui-react-motion-4d715610-504c-4e63-8bb9-87cd24f37cd6.json create mode 100644 change/@fluentui-react-popover-9944ff0a-e3bf-476a-9566-d495eda8b9b3.json create mode 100644 change/@fluentui-react-positioning-21632be5-a50c-4637-a8fc-84ec89d3796f.json create mode 100644 packages/react-components/react-menu/library/src/components/Menu/MenuSurfaceMotion.ts create mode 100644 packages/react-components/react-menu/stories/src/Menu/MenuMotionCustom.stories.tsx create mode 100644 packages/react-components/react-menu/stories/src/Menu/MenuMotionDisabled.stories.tsx create mode 100644 packages/react-components/react-popover/library/src/components/Popover/PopoverSurfaceMotion.ts create mode 100644 packages/react-components/react-popover/stories/src/Popover/PopoverMotionCustom.stories.tsx create mode 100644 packages/react-components/react-popover/stories/src/Popover/PopoverMotionDisabled.stories.tsx create mode 100644 packages/react-components/react-positioning/library/src/usePositioningSlideDirection.test.ts create mode 100644 packages/react-components/react-positioning/library/src/usePositioningSlideDirection.ts diff --git a/change/@fluentui-react-menu-84a7c9b8-4c96-469e-bd81-ab504dc3e1b2.json b/change/@fluentui-react-menu-84a7c9b8-4c96-469e-bd81-ab504dc3e1b2.json new file mode 100644 index 00000000000000..709bf610084cf5 --- /dev/null +++ b/change/@fluentui-react-menu-84a7c9b8-4c96-469e-bd81-ab504dc3e1b2.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "refactor: migrate slide animation from CSS to motion components with surfaceMotion slot", + "packageName": "@fluentui/react-menu", + "email": "robertpenner@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-motion-4d715610-504c-4e63-8bb9-87cd24f37cd6.json b/change/@fluentui-react-motion-4d715610-504c-4e63-8bb9-87cd24f37cd6.json new file mode 100644 index 00000000000000..d671749837db2d --- /dev/null +++ b/change/@fluentui-react-motion-4d715610-504c-4e63-8bb9-87cd24f37cd6.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "fix: make children optional in MotionRefForwarder to resolve type issue", + "packageName": "@fluentui/react-motion", + "email": "robertpenner@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-popover-9944ff0a-e3bf-476a-9566-d495eda8b9b3.json b/change/@fluentui-react-popover-9944ff0a-e3bf-476a-9566-d495eda8b9b3.json new file mode 100644 index 00000000000000..12ba2952478bba --- /dev/null +++ b/change/@fluentui-react-popover-9944ff0a-e3bf-476a-9566-d495eda8b9b3.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "refactor: migrate slide animation from CSS to motion components with surfaceMotion slot", + "packageName": "@fluentui/react-popover", + "email": "robertpenner@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-positioning-21632be5-a50c-4637-a8fc-84ec89d3796f.json b/change/@fluentui-react-positioning-21632be5-a50c-4637-a8fc-84ec89d3796f.json new file mode 100644 index 00000000000000..48f78b4497c21a --- /dev/null +++ b/change/@fluentui-react-positioning-21632be5-a50c-4637-a8fc-84ec89d3796f.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "refactor: deprecate createSlideStyles", + "packageName": "@fluentui/react-positioning", + "email": "robertpenner@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/charts/react-charts/library/src/components/GanttChart/__snapshots__/GanttChart.test.tsx.snap b/packages/charts/react-charts/library/src/components/GanttChart/__snapshots__/GanttChart.test.tsx.snap index 6cdabc93cfd572..ab30f5e0acb33d 100644 --- a/packages/charts/react-charts/library/src/components/GanttChart/__snapshots__/GanttChart.test.tsx.snap +++ b/packages/charts/react-charts/library/src/components/GanttChart/__snapshots__/GanttChart.test.tsx.snap @@ -428,7 +428,7 @@ exports[`GanttChart interaction and accessibility tests should render custom cal data-popper-reference-hidden="" data-tabster="{\\"restorer\\":{\\"type\\":0}}" role="group" - style="position: absolute; left: 0px; top: 0px; margin: 0px; box-sizing: border-box; max-width: 0px; max-height: -20px; overflow-y: auto; transform: translate(-10px, -40px);" + style="position: absolute; left: 0px; top: 0px; margin: 0px; box-sizing: border-box; max-width: 0px; max-height: -20px; overflow-y: auto; transform: translate(-10px, -40px); --fui-positioning-slide-direction-x: 0px; --fui-positioning-slide-direction-y: 1px;" >
& Pick & Pick & Pick & { +export type MenuProps = ComponentProps> & Pick & Pick & { children: [JSXElement, JSXElement] | JSXElement; hoverDelay?: number; inline?: boolean; @@ -355,7 +356,9 @@ export type MenuProps = ComponentProps & Pick & React_2.FC>; // @public (undocumented) -export type MenuSlots = {}; +export type MenuSlots = { + surfaceMotion: Slot; +}; // @public export const MenuSplitGroup: ForwardRefComponent; @@ -375,7 +378,7 @@ export type MenuSplitGroupSlots = { export type MenuSplitGroupState = ComponentState & Pick; // @public (undocumented) -export type MenuState = ComponentState & Required> & { +export type MenuState = ComponentState & Required> & { contextTarget?: PositioningVirtualElement; isSubmenu: boolean; menuPopover: React_2.ReactNode; diff --git a/packages/react-components/react-menu/library/package.json b/packages/react-components/react-menu/library/package.json index 74b9c9ad3ec269..d0e9551d502782 100644 --- a/packages/react-components/react-menu/library/package.json +++ b/packages/react-components/react-menu/library/package.json @@ -16,6 +16,8 @@ "@fluentui/react-aria": "^9.17.10", "@fluentui/react-context-selector": "^9.2.15", "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-motion": "^9.11.6", + "@fluentui/react-motion-components-preview": "^0.15.0", "@fluentui/react-portal": "^9.8.11", "@fluentui/react-positioning": "^9.21.0", "@fluentui/react-shared-contexts": "^9.26.2", diff --git a/packages/react-components/react-menu/library/src/components/Menu/Menu.types.ts b/packages/react-components/react-menu/library/src/components/Menu/Menu.types.ts index 8f843a90ad4f8e..ccd3929c2e8caf 100644 --- a/packages/react-components/react-menu/library/src/components/Menu/Menu.types.ts +++ b/packages/react-components/react-menu/library/src/components/Menu/Menu.types.ts @@ -1,17 +1,28 @@ import * as React from 'react'; +import type { PresenceMotionSlotProps } from '@fluentui/react-motion'; import { PositioningVirtualElement, SetVirtualMouseTarget } from '@fluentui/react-positioning'; import type { PositioningShorthand } from '@fluentui/react-positioning'; import type { PortalProps } from '@fluentui/react-portal'; -import type { ComponentProps, ComponentState, JSXElement } from '@fluentui/react-utilities'; +import type { ComponentProps, ComponentState, JSXElement, Slot } from '@fluentui/react-utilities'; import type { MenuContextValue } from '../../contexts/menuContext'; import type { MenuListProps } from '../MenuList/MenuList.types'; -export type MenuSlots = {}; +export type MenuSlots = { + /** + * Slot for the surface motion animation. + * For more information refer to the [Motion docs page](https://react.fluentui.dev/?path=/docs/motion-motion-slot--docs). + */ + surfaceMotion: Slot; +}; + +export type InternalMenuSlots = { + surfaceMotion: NonNullable>; +}; /** * Extends and drills down Menulist props to simplify API */ -export type MenuProps = ComponentProps & +export type MenuProps = ComponentProps> & Pick & Pick< MenuListProps, @@ -91,7 +102,7 @@ export type MenuProps = ComponentProps & closeOnScroll?: boolean; }; -export type MenuState = ComponentState & +export type MenuState = ComponentState & Required< Pick< MenuProps, diff --git a/packages/react-components/react-menu/library/src/components/Menu/MenuSurfaceMotion.ts b/packages/react-components/react-menu/library/src/components/Menu/MenuSurfaceMotion.ts new file mode 100644 index 00000000000000..286295197da00b --- /dev/null +++ b/packages/react-components/react-menu/library/src/components/Menu/MenuSurfaceMotion.ts @@ -0,0 +1,42 @@ +import { createPresenceComponent, motionTokens } from '@fluentui/react-motion'; +import { fadeAtom, slideAtom } from '@fluentui/react-motion-components-preview'; +import { + POSITIONING_SLIDE_DIRECTION_VAR_X as slideDirectionVarX, + POSITIONING_SLIDE_DIRECTION_VAR_Y as slideDirectionVarY, +} from '@fluentui/react-positioning'; + +// Shared timing constants for the enter animation. +const duration = motionTokens.durationSlower; +const easing = motionTokens.curveDecelerateMid; + +/** + * Default `surfaceMotion` slot for ``. + * + * Enter-only animation combining a fade and a direction-aware slide. + * The slide reads CSS variables set by `usePositioningSlideDirection` and scales + * them by `distance` pixels. There is no exit animation; the surface unmounts immediately. + * + * @param distance - Travel distance (px) for the enter slide. Defaults to `10`. + */ +export const MenuSurfaceMotion = createPresenceComponent(({ distance = 10 }: { distance?: number }) => ({ + enter: [ + fadeAtom({ duration, easing, direction: 'enter' }), + { + // slideAtom produces translate keyframes from `outX`/`outY` → `0px`. + // The `outX`/`outY` values read the positioning-provided CSS variables and scale + // them by `distance` so the surface slides in from the correct direction. + ...slideAtom({ + duration, + easing, + direction: 'enter', + outX: `calc(var(${slideDirectionVarX}, 0px) * ${distance})`, + outY: `calc(var(${slideDirectionVarY}, 0px) * ${distance})`, + }), + // 'accumulate' compositing adds this effect's transform on top of the element's + // existing transform, preserving any transform applied by the positioning engine. + composite: 'accumulate', + }, + ], + // No exit animation — the surface unmounts immediately on close. + exit: [], +})); diff --git a/packages/react-components/react-menu/library/src/components/Menu/renderMenu.tsx b/packages/react-components/react-menu/library/src/components/Menu/renderMenu.tsx index 8693e8177d64be..c7f74d640068af 100644 --- a/packages/react-components/react-menu/library/src/components/Menu/renderMenu.tsx +++ b/packages/react-components/react-menu/library/src/components/Menu/renderMenu.tsx @@ -1,16 +1,31 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + import * as React from 'react'; -import { MenuProvider } from '../../contexts/menuContext'; -import type { MenuContextValues, MenuState } from './Menu.types'; +import { MotionRefForwarder } from '@fluentui/react-motion'; +import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; +import { MenuProvider } from '../../contexts/menuContext'; +import type { InternalMenuSlots, MenuContextValues, MenuState } from './Menu.types'; /** * Render the final JSX of Menu */ export const renderMenu_unstable = (state: MenuState, contextValues: MenuContextValues): JSXElement => { + assertSlots(state); + return ( {state.menuTrigger} - {state.open && state.menuPopover} + {state.menuPopover && ( + + + {/* Casting here as content should be equivalent to */} + {/* FIXME: content should not be ReactNode it should be ReactElement instead. */} + {state.menuPopover as React.ReactElement} + + + )} ); }; diff --git a/packages/react-components/react-menu/library/src/components/Menu/useMenu.tsx b/packages/react-components/react-menu/library/src/components/Menu/useMenu.tsx index ec2d403139d4be..d644f06c6e7d69 100644 --- a/packages/react-components/react-menu/library/src/components/Menu/useMenu.tsx +++ b/packages/react-components/react-menu/library/src/components/Menu/useMenu.tsx @@ -6,8 +6,10 @@ import { usePositioningMouseTarget, usePositioning, useSafeZoneArea, + usePositioningSlideDirection, type PositioningShorthandValue, } from '@fluentui/react-positioning'; +import { presenceMotionSlot } from '@fluentui/react-motion'; import { useControllableState, useId, @@ -26,6 +28,7 @@ import { useMenuContext_unstable } from '../../contexts/menuContext'; import { MENU_SAFEZONE_TIMEOUT_EVENT, MENU_ENTER_EVENT, useOnMenuMouseEnter, useIsSubmenu } from '../../utils'; import { menuItemClassNames } from '../MenuItem/useMenuItemStyles.styles'; import type { MenuOpenChangeData, MenuOpenEvent, MenuProps, MenuState } from './Menu.types'; +import { MenuSurfaceMotion } from './MenuSurfaceMotion'; // If it's not possible to position the submenu in smaller viewports, try // and fallback to this order of positions @@ -66,12 +69,19 @@ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { tim const triggerId = useId('menu'); const [contextTarget, setContextTarget] = usePositioningMouseTarget(); + const resolvedPositioning = resolvePositioningShorthand(props.positioning); + const handlePositionEnd = usePositioningSlideDirection({ + targetDocument, + onPositioningEnd: resolvedPositioning.onPositioningEnd, + }); + const positioningOptions = { position: isSubmenu ? 'after' : 'below', align: isSubmenu ? 'top' : 'start', target: props.openOnContext ? contextTarget : undefined, fallbackPositions: isSubmenu ? submenuFallbackPositions : undefined, - ...resolvePositioningShorthand(props.positioning), + ...resolvedPositioning, + onPositioningEnd: handlePositionEnd, } as const; const children = React.Children.toArray(props.children) as React.ReactElement[]; @@ -180,7 +190,9 @@ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { tim mountNode, triggerRef, menuPopoverRef, - components: {}, + components: { + surfaceMotion: MenuSurfaceMotion, + }, openOnContext, open, setOpen, @@ -188,6 +200,14 @@ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { tim onCheckedValueChange, persistOnItemClick, safeZone: safeZoneHandle.elementToRender, + surfaceMotion: presenceMotionSlot(props.surfaceMotion, { + elementType: MenuSurfaceMotion, + defaultProps: { + visible: open, + appear: true, + unmountOnExit: true, + }, + }), }; }; @@ -333,8 +353,19 @@ const useMenuOpenState = ( if (open) { focusFirst(); } else { + // Skip the initial render — focus should only be restored when the menu + // transitions from open → closed, not on mount. if (!firstMount) { - if (targetDocument?.activeElement === targetDocument?.body) { + if ( + // Focus landed on after the popover was removed from the DOM, + // meaning the user's focus has nowhere meaningful to go. + targetDocument?.activeElement === targetDocument?.body || + // The surfaceMotion presence component delays unmounting the popover + // (e.g. during an exit animation), so focus may still be inside the + // popover even though `open` is already false. Proactively move it + // to the trigger before the DOM element is eventually removed. + state.menuPopoverRef.current?.contains(targetDocument?.activeElement ?? null) + ) { // We know that React effects are sync so we focus the trigger here // after any event handler (event handlers will update state and re-render). // Since the browser only performs the default behaviour for the Tab key once diff --git a/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts b/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts index 84f9947d222333..70819a0ce1772c 100644 --- a/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts +++ b/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts @@ -2,6 +2,7 @@ import { ArrowLeft, Tab, ArrowRight, Escape } from '@fluentui/keyboard-keys'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import { useMotionForwardedRef } from '@fluentui/react-motion'; import { useRestoreFocusSource } from '@fluentui/react-tabster'; import { getIntrinsicElementProps, useEventCallback, useMergedRefs, slot, useTimeout } from '@fluentui/react-utilities'; import * as React from 'react'; @@ -74,7 +75,12 @@ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref< // FIXME: // `ref` is wrongly assigned to be `HTMLElement` instead of `HTMLDivElement` // but since it would be a breaking change to fix it, we are casting ref to it's proper type - ref: useMergedRefs(ref, popoverRef, mouseOverListenerCallbackRef) as React.Ref, + ref: useMergedRefs( + ref, + popoverRef, + mouseOverListenerCallbackRef, + useMotionForwardedRef(), + ) as React.Ref, }), { elementType: 'div' }, ); diff --git a/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopoverStyles.styles.ts b/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopoverStyles.styles.ts index 69a1d6b56d9dbe..5dfee73088e26f 100644 --- a/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopoverStyles.styles.ts +++ b/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopoverStyles.styles.ts @@ -4,7 +4,6 @@ import { mergeClasses, makeStyles } from '@griffel/react'; import { tokens, typographyStyles } from '@fluentui/react-theme'; import type { MenuPopoverSlots, MenuPopoverState } from './MenuPopover.types'; import type { SlotClassNames } from '@fluentui/react-utilities'; -import { createSlideStyles } from '@fluentui/react-positioning'; export const menuPopoverClassNames: SlotClassNames = { root: 'fui-MenuPopover', @@ -24,7 +23,6 @@ const useStyles = makeStyles({ padding: '4px', border: `1px solid ${tokens.colorTransparentStroke}`, ...typographyStyles.body1, - ...createSlideStyles(10), }, }); diff --git a/packages/react-components/react-menu/stories/src/Menu/MenuMotionCustom.stories.tsx b/packages/react-components/react-menu/stories/src/Menu/MenuMotionCustom.stories.tsx new file mode 100644 index 00000000000000..ae65c425cf8a84 --- /dev/null +++ b/packages/react-components/react-menu/stories/src/Menu/MenuMotionCustom.stories.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { + Button, + createPresenceComponent, + Menu, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, + motionTokens, +} from '@fluentui/react-components'; +import { fadeAtom, blurAtom } from '@fluentui/react-motion-components-preview'; + +const FadeInBlurOut = createPresenceComponent({ + enter: fadeAtom({ direction: 'enter', duration: 500 }), + exit: [ + fadeAtom({ direction: 'exit', duration: 500 }), + blurAtom({ direction: 'exit', duration: 500, easing: motionTokens.curveEasyEase }), + ], +}); + +export const MotionCustom = (): JSXElement => ( + , + }} + > + + + + + + + New + New Window + Open File + Open Folder + + + +); + +MotionCustom.parameters = { + docs: { + description: { + story: + 'Menu animations can be customized using the [Motion APIs](?path=/docs/motion-apis-createpresencecomponent--docs), together with the `surfaceMotion` slot.', + }, + }, +}; diff --git a/packages/react-components/react-menu/stories/src/Menu/MenuMotionDisabled.stories.tsx b/packages/react-components/react-menu/stories/src/Menu/MenuMotionDisabled.stories.tsx new file mode 100644 index 00000000000000..147e311569d32d --- /dev/null +++ b/packages/react-components/react-menu/stories/src/Menu/MenuMotionDisabled.stories.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Button, Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from '@fluentui/react-components'; + +export const MotionDisabled = (): JSXElement => ( + + + + + + + + New + New Window + Open File + Open Folder + + + +); + +MotionDisabled.parameters = { + docs: { + description: { + story: 'To disable the Menu transition animation, set the `surfaceMotion` prop to `null`.', + }, + }, +}; diff --git a/packages/react-components/react-menu/stories/src/Menu/index.stories.tsx b/packages/react-components/react-menu/stories/src/Menu/index.stories.tsx index f6bf01427c187b..439c062600952c 100644 --- a/packages/react-components/react-menu/stories/src/Menu/index.stories.tsx +++ b/packages/react-components/react-menu/stories/src/Menu/index.stories.tsx @@ -42,6 +42,8 @@ export { RenderFunctionTrigger } from './MenuRenderFunctionTrigger.stories'; export { MemoizedMenuItems } from './MenuMemoizedMenuItems.stories'; export { SplitMenuItem } from './MenuSplitMenuItem.stories'; export { MenuTriggerWithTooltip } from './MenuTriggerWithTooltip.stories'; +export { MotionCustom } from './MenuMotionCustom.stories'; +export { MotionDisabled } from './MenuMotionDisabled.stories'; export default { title: 'Components/Menu/Menu', diff --git a/packages/react-components/react-motion/library/etc/react-motion.api.md b/packages/react-components/react-motion/library/etc/react-motion.api.md index 2df9469b49ad0f..6ed82fcdee9789 100644 --- a/packages/react-components/react-motion/library/etc/react-motion.api.md +++ b/packages/react-components/react-motion/library/etc/react-motion.api.md @@ -85,7 +85,7 @@ export type MotionParam = boolean | number | string; // @internal export const MotionRefForwarder: React_2.ForwardRefExoticComponent<{ - children: React_2.ReactElement; + children?: React_2.ReactElement; } & React_2.RefAttributes>; // @public (undocumented) diff --git a/packages/react-components/react-motion/library/src/components/MotionRefForwarder.tsx b/packages/react-components/react-motion/library/src/components/MotionRefForwarder.tsx index c56be7d5bba8f3..1363c33bb9c355 100644 --- a/packages/react-components/react-motion/library/src/components/MotionRefForwarder.tsx +++ b/packages/react-components/react-motion/library/src/components/MotionRefForwarder.tsx @@ -21,7 +21,7 @@ export function useMotionForwardedRef(): React.Ref | undefined { * * @internal */ -export const MotionRefForwarder = React.forwardRef((props, ref) => { +export const MotionRefForwarder = React.forwardRef((props, ref) => { return {props.children}; }); diff --git a/packages/react-components/react-popover/library/etc/react-popover.api.md b/packages/react-components/react-popover/library/etc/react-popover.api.md index b8c615b055ef44..03c439682bb15d 100644 --- a/packages/react-components/react-popover/library/etc/react-popover.api.md +++ b/packages/react-components/react-popover/library/etc/react-popover.api.md @@ -11,10 +11,11 @@ import type { ComponentState } from '@fluentui/react-utilities'; import type { ContextSelector } from '@fluentui/react-context-selector'; import { FC } from 'react'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; -import type { JSXElement } from '@fluentui/react-utilities'; +import { JSXElement } from '@fluentui/react-utilities'; import type { PortalProps } from '@fluentui/react-portal'; import type { PositioningShorthand } from '@fluentui/react-positioning'; import type { PositioningVirtualElement } from '@fluentui/react-positioning'; +import type { PresenceMotionSlotProps } from '@fluentui/react-motion'; import { Provider } from 'react'; import { ProviderProps } from 'react'; import * as React_2 from 'react'; @@ -41,7 +42,7 @@ export const Popover: React_2.FC; export type PopoverContextValue = Pick; // @public -export type PopoverProps = Pick & { +export type PopoverProps = ComponentProps> & Pick & { appearance?: 'brand' | 'inverted'; children: [JSXElement, JSXElement] | JSXElement; closeOnScroll?: boolean; @@ -69,7 +70,7 @@ export const PopoverProvider: Provider & FC & Required> & Pick & { +export type PopoverState = ComponentState & Pick & Required> & Pick & { arrowRef: React_2.MutableRefObject; contentRef: React_2.MutableRefObject; contextTarget: PositioningVirtualElement | undefined; diff --git a/packages/react-components/react-popover/library/package.json b/packages/react-components/react-popover/library/package.json index 58c205b4b29ea6..228162f11a0371 100644 --- a/packages/react-components/react-popover/library/package.json +++ b/packages/react-components/react-popover/library/package.json @@ -15,6 +15,8 @@ "@fluentui/keyboard-keys": "^9.0.8", "@fluentui/react-aria": "^9.17.10", "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-motion": "^9.11.6", + "@fluentui/react-motion-components-preview": "^0.15.0", "@fluentui/react-portal": "^9.8.11", "@fluentui/react-positioning": "^9.21.0", "@fluentui/react-shared-contexts": "^9.26.2", diff --git a/packages/react-components/react-popover/library/src/components/Popover/Popover.types.ts b/packages/react-components/react-popover/library/src/components/Popover/Popover.types.ts index 8ac54ca698801b..39452d0d55dd50 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/Popover.types.ts +++ b/packages/react-components/react-popover/library/src/components/Popover/Popover.types.ts @@ -1,5 +1,6 @@ import * as React from 'react'; -import type { JSXElement } from '@fluentui/react-utilities'; +import type { PresenceMotionSlotProps } from '@fluentui/react-motion'; +import type { ComponentProps, ComponentState, JSXElement, Slot } from '@fluentui/react-utilities'; import type { PositioningVirtualElement, PositioningShorthand, @@ -7,6 +8,18 @@ import type { } from '@fluentui/react-positioning'; import type { PortalProps } from '@fluentui/react-portal'; +export type PopoverSlots = { + /** + * Slot for the surface motion animation. + * For more information refer to the [Motion docs page](https://react.fluentui.dev/?path=/docs/motion-motion-slot--docs). + */ + surfaceMotion: Slot; +}; + +export type InternalPopoverSlots = { + surfaceMotion: NonNullable>; +}; + /** * Determines popover padding and arrow size */ @@ -15,152 +28,154 @@ export type PopoverSize = 'small' | 'medium' | 'large'; /** * Popover Props */ -export type PopoverProps = Pick & { - /** - * A popover can appear styled with brand or inverted. - * When not specified, the default style is used. - */ - appearance?: 'brand' | 'inverted'; +export type PopoverProps = ComponentProps> & + Pick & { + /** + * A popover can appear styled with brand or inverted. + * When not specified, the default style is used. + */ + appearance?: 'brand' | 'inverted'; - /** - * Can contain two children including `PopoverTrigger` and `PopoverSurface`. - * Alternatively can only contain `PopoverSurface` if using a custom `target`. - */ - children: [JSXElement, JSXElement] | JSXElement; + /** + * Can contain two children including `PopoverTrigger` and `PopoverSurface`. + * Alternatively can only contain `PopoverSurface` if using a custom `target`. + */ + children: [JSXElement, JSXElement] | JSXElement; - /** - * Close when scroll outside of it - * - * @default false - */ - closeOnScroll?: boolean; + /** + * Close when scroll outside of it + * + * @default false + */ + closeOnScroll?: boolean; - /** - * Used to set the initial open state of the Popover in uncontrolled mode - * - * @default false - */ - defaultOpen?: boolean; + /** + * Used to set the initial open state of the Popover in uncontrolled mode + * + * @default false + */ + defaultOpen?: boolean; - /** - * Popovers are rendered out of DOM order on `document.body` by default, use this to render the popover in DOM order - * - * @default false - */ - inline?: boolean; + /** + * Popovers are rendered out of DOM order on `document.body` by default, use this to render the popover in DOM order + * + * @default false + */ + inline?: boolean; - /** - * Sets the delay for closing popover on mouse leave - */ - mouseLeaveDelay?: number; + /** + * Sets the delay for closing popover on mouse leave + */ + mouseLeaveDelay?: number; - /** - * Display an arrow pointing to the target. - * - * @default false - */ - withArrow?: boolean; + /** + * Display an arrow pointing to the target. + * + * @default false + */ + withArrow?: boolean; - /** - * Call back when the component requests to change value - * The `open` value is used as a hint when directly controlling the component - */ - // eslint-disable-next-line @nx/workspace-consistent-callback-type -- can't change type of existing callback - onOpenChange?: (e: OpenPopoverEvents, data: OnOpenChangeData) => void; + /** + * Call back when the component requests to change value + * The `open` value is used as a hint when directly controlling the component + */ + // eslint-disable-next-line @nx/workspace-consistent-callback-type -- can't change type of existing callback + onOpenChange?: (e: OpenPopoverEvents, data: OnOpenChangeData) => void; - /** - * Controls the opening of the Popover - * - * @default false - */ - open?: boolean; + /** + * Controls the opening of the Popover + * + * @default false + */ + open?: boolean; - /** - * Flag to open the Popover as a context menu. Disables all other interactions - * - * @default false - */ - openOnContext?: boolean; + /** + * Flag to open the Popover as a context menu. Disables all other interactions + * + * @default false + */ + openOnContext?: boolean; - /** - * Flag to open the Popover by hovering the trigger - * - * @default false - */ - openOnHover?: boolean; + /** + * Flag to open the Popover by hovering the trigger + * + * @default false + */ + openOnHover?: boolean; - /** - * Flag to close the Popover when an iframe outside a PopoverSurface is focused - * - * @default true - */ - closeOnIframeFocus?: boolean; + /** + * Flag to close the Popover when an iframe outside a PopoverSurface is focused + * + * @default true + */ + closeOnIframeFocus?: boolean; - /** - * Configures the position of the Popover. - * Explore [Positioning docs](https://react.fluentui.dev/?path=/docs/concepts-developer-positioning-components--docs) for more options. - */ - positioning?: PositioningShorthand; + /** + * Configures the position of the Popover. + * Explore [Positioning docs](https://react.fluentui.dev/?path=/docs/concepts-developer-positioning-components--docs) for more options. + */ + positioning?: PositioningShorthand; - /** - * Determines popover padding and arrow size - * - * @default medium - */ - size?: PopoverSize; + /** + * Determines popover padding and arrow size + * + * @default medium + */ + size?: PopoverSize; - /** - * Should trap focus - * - * @default false - */ - trapFocus?: boolean; + /** + * Should trap focus + * + * @default false + */ + trapFocus?: boolean; - /** - * Must be used with the `trapFocus` prop - * Enables older Fluent UI focus trap behavior where the user - * cannot tab into the window outside of the document. This is now - * non-standard behavior according to the [HTML dialog spec](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal) - * where the focus trap involves setting outside elements inert. - * - * @deprecated this behavior is default provided now, to opt-out of it in favor of standard behavior use the `inertTrapFocus` property - */ - legacyTrapFocus?: boolean; - /** - * Enables standard behavior according to the [HTML dialog spec](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal) - * where the focus trap involves setting outside elements inert, - * making navigation leak from the trapped area back to the browser toolbar and vice-versa. - * - * @default false - */ - inertTrapFocus?: boolean; + /** + * Must be used with the `trapFocus` prop + * Enables older Fluent UI focus trap behavior where the user + * cannot tab into the window outside of the document. This is now + * non-standard behavior according to the [HTML dialog spec](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal) + * where the focus trap involves setting outside elements inert. + * + * @deprecated this behavior is default provided now, to opt-out of it in favor of standard behavior use the `inertTrapFocus` property + */ + legacyTrapFocus?: boolean; + /** + * Enables standard behavior according to the [HTML dialog spec](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal) + * where the focus trap involves setting outside elements inert, + * making navigation leak from the trapped area back to the browser toolbar and vice-versa. + * + * @default false + */ + inertTrapFocus?: boolean; - /** - * By default Popover focuses the first focusable element in PopoverSurface on open. - * Specify `disableAutoFocus` to prevent this behavior. - * - * @default false - */ - // eslint-disable-next-line @typescript-eslint/naming-convention - unstable_disableAutoFocus?: boolean; -}; + /** + * By default Popover focuses the first focusable element in PopoverSurface on open. + * Specify `disableAutoFocus` to prevent this behavior. + * + * @default false + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_disableAutoFocus?: boolean; + }; export type PopoverBaseProps = Omit; /** * Popover State */ -export type PopoverState = Pick< - PopoverProps, - | 'appearance' - | 'mountNode' - | 'onOpenChange' - | 'openOnContext' - | 'openOnHover' - | 'trapFocus' - | 'withArrow' - | 'inertTrapFocus' -> & +export type PopoverState = ComponentState & + Pick< + PopoverProps, + | 'appearance' + | 'mountNode' + | 'onOpenChange' + | 'openOnContext' + | 'openOnHover' + | 'trapFocus' + | 'withArrow' + | 'inertTrapFocus' + > & Required> & Pick & { /** @@ -208,7 +223,7 @@ export type PopoverState = Pick< triggerRef: React.MutableRefObject; }; -export type PopoverBaseState = Omit; +export type PopoverBaseState = Omit; /** * Data attached to open/close events diff --git a/packages/react-components/react-popover/library/src/components/Popover/PopoverSurfaceMotion.ts b/packages/react-components/react-popover/library/src/components/Popover/PopoverSurfaceMotion.ts new file mode 100644 index 00000000000000..fb0db77df6681f --- /dev/null +++ b/packages/react-components/react-popover/library/src/components/Popover/PopoverSurfaceMotion.ts @@ -0,0 +1,42 @@ +import { createPresenceComponent, motionTokens } from '@fluentui/react-motion'; +import { fadeAtom, slideAtom } from '@fluentui/react-motion-components-preview'; +import { + POSITIONING_SLIDE_DIRECTION_VAR_X as slideDirectionVarX, + POSITIONING_SLIDE_DIRECTION_VAR_Y as slideDirectionVarY, +} from '@fluentui/react-positioning'; + +// Shared timing constants for the enter animation. +const duration = motionTokens.durationSlower; +const easing = motionTokens.curveDecelerateMid; + +/** + * Default `surfaceMotion` slot for ``. + * + * Enter-only animation combining a fade and a direction-aware slide. + * The slide reads CSS variables set by `usePositioningSlideDirection` and scales + * them by `distance` pixels. There is no exit animation; the surface unmounts immediately. + * + * @param distance - Travel distance (px) for the enter slide. Defaults to `10`. + */ +export const PopoverSurfaceMotion = createPresenceComponent(({ distance = 10 }: { distance?: number }) => ({ + enter: [ + fadeAtom({ duration, easing, direction: 'enter' }), + { + // slideAtom produces translate keyframes from `outX`/`outY` → `0px`. + // The `outX`/`outY` values read the positioning-provided CSS variables and scale + // them by `distance` so the surface slides in from the correct direction. + ...slideAtom({ + duration, + easing, + direction: 'enter', + outX: `calc(var(${slideDirectionVarX}, 0px) * ${distance})`, + outY: `calc(var(${slideDirectionVarY}, 0px) * ${distance})`, + }), + // 'accumulate' compositing adds this effect's transform on top of the element's + // existing transform, preserving any transform applied by the positioning engine. + composite: 'accumulate', + }, + ], + // No exit animation — the surface unmounts immediately on close. + exit: [], +})); diff --git a/packages/react-components/react-popover/library/src/components/Popover/renderPopover.tsx b/packages/react-components/react-popover/library/src/components/Popover/renderPopover.tsx index 4095c65437700c..633c9e4762c422 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/renderPopover.tsx +++ b/packages/react-components/react-popover/library/src/components/Popover/renderPopover.tsx @@ -1,13 +1,18 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + import * as React from 'react'; -import type { JSXElement } from '@fluentui/react-utilities'; +import { assertSlots, type JSXElement } from '@fluentui/react-utilities'; +import { MotionRefForwarder } from '@fluentui/react-motion'; import { PopoverContext } from '../../popoverContext'; -import type { PopoverState } from './Popover.types'; +import type { InternalPopoverSlots, PopoverState } from './Popover.types'; /** * Render the final JSX of Popover */ - export const renderPopover_unstable = (state: PopoverState): JSXElement => { + assertSlots(state); + const { appearance, arrowRef, @@ -47,7 +52,15 @@ export const renderPopover_unstable = (state: PopoverState): JSXElement => { }} > {state.popoverTrigger} - {state.open && state.popoverSurface} + {state.popoverSurface && ( + + + {/* Casting here as content should be equivalent to */} + {/* FIXME: content should not be ReactNode it should be ReactElement instead. */} + {state.popoverSurface as React.ReactElement} + + + )} ); }; diff --git a/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts b/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts index 312e8407e90d14..265425dbad1006 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts +++ b/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts @@ -15,6 +15,7 @@ import { resolvePositioningShorthand, mergeArrowOffset, usePositioningMouseTarget, + usePositioningSlideDirection, } from '@fluentui/react-positioning'; import { useFocusFinders, useActivateModal } from '@fluentui/react-tabster'; import { arrowHeights } from '../PopoverSurface/index'; @@ -26,6 +27,8 @@ import type { PopoverState, } from './Popover.types'; import { popoverSurfaceBorderRadius } from './constants'; +import { presenceMotionSlot } from '@fluentui/react-motion'; +import { PopoverSurfaceMotion } from './PopoverSurfaceMotion'; /** * Create the state required to render Popover. @@ -40,19 +43,38 @@ export const usePopover_unstable = (props: PopoverProps): PopoverState => { const positioning = resolvePositioningShorthand(props.positioning); const withArrow = props.withArrow && !positioning.coverTarget; + const { targetDocument } = useFluent(); + + const handlePositionEnd = usePositioningSlideDirection({ + targetDocument, + onPositioningEnd: positioning.onPositioningEnd, + }); + const state = usePopoverBase_unstable({ ...props, positioning: { ...positioning, + onPositioningEnd: handlePositionEnd, // Update the offset with the arrow size only when it's available ...(withArrow ? { offset: mergeArrowOffset(positioning.offset, arrowHeights[size]) } : {}), }, }); return { + components: { + surfaceMotion: PopoverSurfaceMotion, + }, appearance, size, ...state, + surfaceMotion: presenceMotionSlot(props.surfaceMotion, { + elementType: PopoverSurfaceMotion, + defaultProps: { + visible: state.open, + appear: true, + unmountOnExit: true, + }, + }), }; }; diff --git a/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurface.ts b/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurface.ts index e9d030c6c66711..bec7ea9c48652f 100644 --- a/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurface.ts +++ b/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurface.ts @@ -10,6 +10,7 @@ import type { PopoverSurfaceBaseProps, PopoverSurfaceBaseState, } from './PopoverSurface.types'; +import { useMotionForwardedRef } from '@fluentui/react-motion'; /** * Create the state required to render PopoverSurface. @@ -26,7 +27,8 @@ export const usePopoverSurface_unstable = ( ): PopoverSurfaceState => { const size = usePopoverContext_unstable(context => context.size); const appearance = usePopoverContext_unstable(context => context.appearance); - const state = usePopoverSurfaceBase_unstable(props, ref); + const motionForwardedRef = useMotionForwardedRef(); + const state = usePopoverSurfaceBase_unstable(props, useMergedRefs(ref, motionForwardedRef)); return { appearance, diff --git a/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurfaceStyles.styles.ts b/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurfaceStyles.styles.ts index 2b1b25f014463a..e82b598017fc00 100644 --- a/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurfaceStyles.styles.ts +++ b/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurfaceStyles.styles.ts @@ -1,7 +1,7 @@ 'use client'; import { makeStyles, mergeClasses } from '@griffel/react'; -import { createArrowHeightStyles, createArrowStyles, createSlideStyles } from '@fluentui/react-positioning'; +import { createArrowHeightStyles, createArrowStyles } from '@fluentui/react-positioning'; import { tokens, typographyStyles } from '@fluentui/react-theme'; import type { PopoverSize } from '../Popover/Popover.types'; import type { PopoverSurfaceSlots, PopoverSurfaceState } from './PopoverSurface.types'; @@ -27,7 +27,6 @@ const useStyles = makeStyles({ borderRadius: tokens.borderRadiusMedium, border: `1px solid ${tokens.colorTransparentStroke}`, ...typographyStyles.body1, - ...createSlideStyles(10), // TODO need to add versions of tokens.alias.shadow.shadow16, etc. that work with filter filter: diff --git a/packages/react-components/react-popover/stories/src/Popover/PopoverMotionCustom.stories.tsx b/packages/react-components/react-popover/stories/src/Popover/PopoverMotionCustom.stories.tsx new file mode 100644 index 00000000000000..3a9b4d981ccc39 --- /dev/null +++ b/packages/react-components/react-popover/stories/src/Popover/PopoverMotionCustom.stories.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Button, createPresenceComponent, Popover, PopoverSurface, PopoverTrigger } from '@fluentui/react-components'; +import { fadeAtom, blurAtom } from '@fluentui/react-motion-components-preview'; + +const FadeInBlurOut = createPresenceComponent({ + enter: fadeAtom({ duration: 500, direction: 'enter' }), + exit: [fadeAtom({ duration: 500, direction: 'exit' }), blurAtom({ duration: 500, direction: 'exit' })], +}); + +export const MotionCustom = (): JSXElement => ( + , + }} + > + + + + + +

Popover content

+

This popover fades in and blurs out.

+
+
+); + +MotionCustom.parameters = { + docs: { + description: { + story: + 'Popover animations can be customized using the [Motion APIs](?path=/docs/motion-apis-createpresencecomponent--docs), together with the `surfaceMotion` slot.', + }, + }, +}; diff --git a/packages/react-components/react-popover/stories/src/Popover/PopoverMotionDisabled.stories.tsx b/packages/react-components/react-popover/stories/src/Popover/PopoverMotionDisabled.stories.tsx new file mode 100644 index 00000000000000..067dd736fd9578 --- /dev/null +++ b/packages/react-components/react-popover/stories/src/Popover/PopoverMotionDisabled.stories.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Button, Popover, PopoverSurface, PopoverTrigger } from '@fluentui/react-components'; + +export const MotionDisabled = (): JSXElement => ( + + + + + + +

Popover content

+

This popover has motion disabled

+
+
+); + +MotionDisabled.parameters = { + docs: { + description: { + story: 'To disable the Popover transition animation, set the `surfaceMotion` prop to `null`.', + }, + }, +}; diff --git a/packages/react-components/react-popover/stories/src/Popover/index.stories.tsx b/packages/react-components/react-popover/stories/src/Popover/index.stories.tsx index a1d998ad893eee..78f93aadeaebe2 100644 --- a/packages/react-components/react-popover/stories/src/Popover/index.stories.tsx +++ b/packages/react-components/react-popover/stories/src/Popover/index.stories.tsx @@ -8,6 +8,8 @@ export { WithArrow } from './PopoverWithArrow.stories'; export { WithArrowAutosize } from './PopoverWithArrowAutosize.stories'; export { TrappingFocus } from './PopoverTrappingFocus.stories'; export { ControllingOpenAndClose } from './PopoverControllingOpenAndClose.stories'; +export { MotionCustom } from './PopoverMotionCustom.stories'; +export { MotionDisabled } from './PopoverMotionDisabled.stories'; export { NestedPopovers } from './PopoverNestedPopovers.stories'; export { AnchorToCustomTarget } from './PopoverAnchorToCustomTarget.stories'; export { CustomTrigger } from './PopoverCustomTrigger.stories'; diff --git a/packages/react-components/react-positioning/library/etc/react-positioning.api.md b/packages/react-components/react-positioning/library/etc/react-positioning.api.md index 23afaabefee931..9a2e65951c1e13 100644 --- a/packages/react-components/react-positioning/library/etc/react-positioning.api.md +++ b/packages/react-components/react-positioning/library/etc/react-positioning.api.md @@ -32,7 +32,7 @@ export type CreateArrowStylesOptions = { borderColor?: GriffelStyle['borderBottomColor']; }; -// @public +// @public @deprecated export function createSlideStyles(mainAxis: number): GriffelStyle; // @public @@ -67,6 +67,12 @@ export type OffsetShorthand = number; // @public (undocumented) export type Position = 'above' | 'below' | 'before' | 'after'; +// @public +export const POSITIONING_SLIDE_DIRECTION_VAR_X = "--fui-positioning-slide-direction-x"; + +// @public (undocumented) +export const POSITIONING_SLIDE_DIRECTION_VAR_Y = "--fui-positioning-slide-direction-y"; + // @public (undocumented) export type PositioningBoundary = PositioningRect | HTMLElement | Array | 'clippingParents' | 'scrollParent' | 'window'; @@ -136,6 +142,9 @@ export function usePositioning(options: PositioningProps & PositioningOptions): // @internal export const usePositioningMouseTarget: (initialState?: PositioningVirtualElement | (() => PositioningVirtualElement)) => readonly [PositioningVirtualElement | undefined, SetVirtualMouseTarget]; +// @public +export function usePositioningSlideDirection(options: UsePositioningSlideDirectionOptions): NonNullable; + // @public (undocumented) export function useSafeZoneArea({ debug, disabled, onSafeZoneEnter, onSafeZoneMove, onSafeZoneLeave, onSafeZoneTimeout, timeout, }?: UseSafeZoneOptions): { containerRef: RefObjectFunction; diff --git a/packages/react-components/react-positioning/library/src/constants.ts b/packages/react-components/react-positioning/library/src/constants.ts index e6fd421d130c84..9f2ef12574bec8 100644 --- a/packages/react-components/react-positioning/library/src/constants.ts +++ b/packages/react-components/react-positioning/library/src/constants.ts @@ -3,3 +3,11 @@ export const DATA_POSITIONING_ESCAPED = 'data-popper-escaped'; export const DATA_POSITIONING_HIDDEN = 'data-popper-reference-hidden'; export const DATA_POSITIONING_PLACEMENT = 'data-popper-placement'; export const POSITIONING_END_EVENT = 'fui-positioningend'; + +/** + * CSS custom properties used to encode the slide direction for positioning-aware enter animations. + * Set at runtime by `usePositioningSlideDirection` and registered via the CSS + * `registerProperty()` API so browsers can interpolate them as `` values. + */ +export const POSITIONING_SLIDE_DIRECTION_VAR_X = '--fui-positioning-slide-direction-x'; +export const POSITIONING_SLIDE_DIRECTION_VAR_Y = '--fui-positioning-slide-direction-y'; diff --git a/packages/react-components/react-positioning/library/src/createSlideStyles.ts b/packages/react-components/react-positioning/library/src/createSlideStyles.ts index 1d221c99bd91cb..a6a661fe85f7f9 100644 --- a/packages/react-components/react-positioning/library/src/createSlideStyles.ts +++ b/packages/react-components/react-positioning/library/src/createSlideStyles.ts @@ -6,6 +6,9 @@ import { DATA_POSITIONING_PLACEMENT } from './constants'; * Creates animation styles so that positioned elements slide in from the main axis * @param mainAxis - distance than the element sides for * @returns Griffel styles to spread to a slot + * + * @deprecated The popover-related components now use the Slide motion component, + * which they inject using the `surfaceMotion` slot. */ export function createSlideStyles(mainAxis: number): GriffelStyle { // With 'accumulate' animation composition, these opacity keyframes are added onto the default opacity of 1. diff --git a/packages/react-components/react-positioning/library/src/index.ts b/packages/react-components/react-positioning/library/src/index.ts index fe5e0f371a9949..26430e1129759a 100644 --- a/packages/react-components/react-positioning/library/src/index.ts +++ b/packages/react-components/react-positioning/library/src/index.ts @@ -1,5 +1,8 @@ export { createVirtualElementFromClick } from './createVirtualElementFromClick'; +export { usePositioningSlideDirection } from './usePositioningSlideDirection'; +export { POSITIONING_SLIDE_DIRECTION_VAR_X, POSITIONING_SLIDE_DIRECTION_VAR_Y } from './constants'; export { createArrowHeightStyles, createArrowStyles } from './createArrowStyles'; +// eslint-disable-next-line @typescript-eslint/no-deprecated export { createSlideStyles } from './createSlideStyles'; export type { CreateArrowStylesOptions } from './createArrowStyles'; diff --git a/packages/react-components/react-positioning/library/src/usePositioningSlideDirection.test.ts b/packages/react-components/react-positioning/library/src/usePositioningSlideDirection.test.ts new file mode 100644 index 00000000000000..65bd894ee7893f --- /dev/null +++ b/packages/react-components/react-positioning/library/src/usePositioningSlideDirection.test.ts @@ -0,0 +1,158 @@ +import { getPlacementSlideDirections, usePositioningSlideDirection } from './usePositioningSlideDirection'; +import { renderHook, act } from '@testing-library/react-hooks'; +import type { OnPositioningEndEvent } from './types'; + +describe('getPlacementSlideDirections', () => { + it('returns { x: 0, y: 1 } for "top" placement (slides down from top)', () => { + expect(getPlacementSlideDirections('top')).toEqual({ x: 0, y: 1 }); + }); + + it('returns { x: 0, y: 1 } for "top-start" placement', () => { + expect(getPlacementSlideDirections('top-start')).toEqual({ x: 0, y: 1 }); + }); + + it('returns { x: 0, y: 1 } for "top-end" placement', () => { + expect(getPlacementSlideDirections('top-end')).toEqual({ x: 0, y: 1 }); + }); + + it('returns { x: -1, y: 0 } for "right" placement (slides left from right)', () => { + expect(getPlacementSlideDirections('right')).toEqual({ x: -1, y: 0 }); + }); + + it('returns { x: -1, y: 0 } for "right-start" placement', () => { + expect(getPlacementSlideDirections('right-start')).toEqual({ x: -1, y: 0 }); + }); + + it('returns { x: 0, y: -1 } for "bottom" placement (slides up from bottom)', () => { + expect(getPlacementSlideDirections('bottom')).toEqual({ x: 0, y: -1 }); + }); + + it('returns { x: 0, y: -1 } for "bottom-end" placement', () => { + expect(getPlacementSlideDirections('bottom-end')).toEqual({ x: 0, y: -1 }); + }); + + it('returns { x: 1, y: 0 } for "left" placement (slides right from left)', () => { + expect(getPlacementSlideDirections('left')).toEqual({ x: 1, y: 0 }); + }); + + it('returns { x: 1, y: 0 } for "left-end" placement', () => { + expect(getPlacementSlideDirections('left-end')).toEqual({ x: 1, y: 0 }); + }); +}); + +describe('usePositioningSlideDirection', () => { + it('sets CSS custom properties on the positioned element', () => { + const { result } = renderHook(() => + usePositioningSlideDirection({ + targetDocument: document, + }), + ); + + const element = document.createElement('div'); + const setPropertySpy = jest.spyOn(element.style, 'setProperty'); + + act(() => { + const event: OnPositioningEndEvent = new CustomEvent('positioningend', { + detail: { placement: 'bottom' }, + }); + Object.defineProperty(event, 'target', { value: element }); + result.current(event); + }); + + // For 'bottom' placement, direction is { x: 0, y: -1 } + expect(setPropertySpy).toHaveBeenCalledWith('--fui-positioning-slide-direction-x', '0px'); + expect(setPropertySpy).toHaveBeenCalledWith('--fui-positioning-slide-direction-y', '-1px'); + }); + + it('sets CSS custom properties for "right" placement', () => { + const { result } = renderHook(() => + usePositioningSlideDirection({ + targetDocument: document, + }), + ); + + const element = document.createElement('div'); + const setPropertySpy = jest.spyOn(element.style, 'setProperty'); + + act(() => { + const event: OnPositioningEndEvent = new CustomEvent('positioningend', { + detail: { placement: 'right-start' }, + }); + Object.defineProperty(event, 'target', { value: element }); + result.current(event); + }); + + // For 'right' placement, direction is { x: -1, y: 0 } + expect(setPropertySpy).toHaveBeenCalledWith('--fui-positioning-slide-direction-x', '-1px'); + expect(setPropertySpy).toHaveBeenCalledWith('--fui-positioning-slide-direction-y', '0px'); + }); + + it('chains the original onPositioningEnd callback', () => { + const originalCallback = jest.fn(); + + const { result } = renderHook(() => + usePositioningSlideDirection({ + targetDocument: document, + onPositioningEnd: originalCallback, + }), + ); + + const element = document.createElement('div'); + + act(() => { + const event: OnPositioningEndEvent = new CustomEvent('positioningend', { + detail: { placement: 'top' }, + }); + // CustomEvent doesn't set target automatically, so we dispatch it from element + Object.defineProperty(event, 'target', { value: element }); + result.current(event); + }); + + expect(originalCallback).toHaveBeenCalledTimes(1); + }); + + it('calls CSS.registerProperty on mount', () => { + const registerProperty = jest.fn(); + const mockDocument = { + defaultView: { CSS: { registerProperty } }, + } as unknown as Document; + + renderHook(() => + usePositioningSlideDirection({ + targetDocument: mockDocument, + }), + ); + + expect(registerProperty).toHaveBeenCalledTimes(2); + expect(registerProperty).toHaveBeenCalledWith({ + name: '--fui-positioning-slide-direction-x', + syntax: '', + inherits: false, + initialValue: '0px', + }); + expect(registerProperty).toHaveBeenCalledWith({ + name: '--fui-positioning-slide-direction-y', + syntax: '', + inherits: false, + initialValue: '0px', + }); + }); + + it('ignores errors from CSS.registerProperty (already registered)', () => { + const registerProperty = jest.fn().mockImplementation(() => { + throw new Error('Property already registered'); + }); + const mockDocument = { + defaultView: { CSS: { registerProperty } }, + } as unknown as Document; + + // Should not throw + expect(() => { + renderHook(() => + usePositioningSlideDirection({ + targetDocument: mockDocument, + }), + ); + }).not.toThrow(); + }); +}); diff --git a/packages/react-components/react-positioning/library/src/usePositioningSlideDirection.ts b/packages/react-components/react-positioning/library/src/usePositioningSlideDirection.ts new file mode 100644 index 00000000000000..89d874ce22fdc8 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/usePositioningSlideDirection.ts @@ -0,0 +1,98 @@ +'use client'; + +import * as React from 'react'; +import { useEventCallback, isHTMLElement } from '@fluentui/react-utilities'; +import type { PositioningProps } from './types'; +import { POSITIONING_SLIDE_DIRECTION_VAR_X, POSITIONING_SLIDE_DIRECTION_VAR_Y } from './constants'; + +/** + * Returns the slide direction unit vectors for a given Floating UI placement. + * Values are -1, 0, or 1, representing the direction the element slides in from. + */ +export function getPlacementSlideDirections(placement: string): { x: number; y: number } { + const side = placement.split('-')[0]; + // Default to sliding down from the top side + let x = 0; + let y = 1; + + if (side === 'right') { + x = -1; + y = 0; + } else if (side === 'bottom') { + x = 0; + y = -1; + } else if (side === 'left') { + x = 1; + y = 0; + } + + return { x, y }; +} + +type UsePositioningSlideDirectionOptions = { + /** The target document for CSS.registerProperty. */ + targetDocument: Document | undefined; + /** The user's original onPositioningEnd callback, if any. */ + onPositioningEnd?: PositioningProps['onPositioningEnd']; +}; + +/** + * A hook that manages CSS custom properties for slide direction based on positioning placement. + * + * It wraps the `onPositioningEnd` callback to set `--fui-positioning-slide-direction-x` and + * `--fui-positioning-slide-direction-y` CSS custom properties on the positioned element, + * and registers them via `CSS.registerProperty` to avoid properties propagation down to a DOM tree. + * + * @returns The wrapped `onPositioningEnd` handler to pass to the positioning config. + */ +export function usePositioningSlideDirection( + options: UsePositioningSlideDirectionOptions, +): NonNullable { + const { targetDocument, onPositioningEnd } = options; + + const handlePositionEnd: NonNullable = useEventCallback(e => { + onPositioningEnd?.(e); + + const element = e.target; + const placement = e.detail.placement; + + if (!isHTMLElement(element)) { + return; + } + + const { x, y } = getPlacementSlideDirections(placement); + + element.style.setProperty(POSITIONING_SLIDE_DIRECTION_VAR_X, `${x}px`); + element.style.setProperty(POSITIONING_SLIDE_DIRECTION_VAR_Y, `${y}px`); + }); + + // Register the CSS custom properties so they can be interpolated during animations. + // CSS.registerProperty is idempotent — the try/catch handles the case where + // properties are already registered. + React.useEffect(() => { + const registerProperty = + targetDocument?.defaultView?.CSS?.registerProperty ?? + (() => { + // No-op if registerProperty is not supported + }); + + try { + registerProperty({ + name: POSITIONING_SLIDE_DIRECTION_VAR_X, + syntax: '', + inherits: false, + initialValue: '0px', + }); + registerProperty({ + name: POSITIONING_SLIDE_DIRECTION_VAR_Y, + syntax: '', + inherits: false, + initialValue: '0px', + }); + } catch (e) { + // Ignore errors from registerProperty, which can occur if the properties are already registered + } + }, [targetDocument]); + + return handlePositionEnd; +}