Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "refactor: deprecate createSlideStyles",
"packageName": "@fluentui/react-positioning",
"email": "robertpenner@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;"
>
<div
class="fui-cart__calloutContentRoot"
Expand Down Expand Up @@ -4763,7 +4763,7 @@ exports[`GanttChart rendering and behavior tests should render callout correctly
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;"
>
<div
class="fui-cart__calloutContentRoot"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { JSXElement } from '@fluentui/react-utilities';
import type { PortalProps } from '@fluentui/react-portal';
import type { PositioningShorthand } from '@fluentui/react-positioning';
import { PositioningVirtualElement } from '@fluentui/react-positioning';
import type { PresenceMotionSlotProps } from '@fluentui/react-motion';
import * as React_2 from 'react';
import { SetVirtualMouseTarget } from '@fluentui/react-positioning';
import type { Slot } from '@fluentui/react-utilities';
Expand Down Expand Up @@ -337,7 +338,7 @@ export type MenuPopoverState = ComponentState<MenuPopoverSlots> & Pick<PortalPro
};

// @public
export type MenuProps = ComponentProps<MenuSlots> & Pick<PortalProps, 'mountNode'> & Pick<MenuListProps, 'checkedValues' | 'defaultCheckedValues' | 'hasCheckmarks' | 'hasIcons' | 'onCheckedValueChange'> & {
export type MenuProps = ComponentProps<Partial<MenuSlots>> & Pick<PortalProps, 'mountNode'> & Pick<MenuListProps, 'checkedValues' | 'defaultCheckedValues' | 'hasCheckmarks' | 'hasIcons' | 'onCheckedValueChange'> & {
children: [JSXElement, JSXElement] | JSXElement;
hoverDelay?: number;
inline?: boolean;
Expand All @@ -355,7 +356,9 @@ export type MenuProps = ComponentProps<MenuSlots> & Pick<PortalProps, 'mountNode
export const MenuProvider: React_2.Provider<MenuContextValue> & React_2.FC<React_2.ProviderProps<MenuContextValue>>;

// @public (undocumented)
export type MenuSlots = {};
export type MenuSlots = {
surfaceMotion: Slot<PresenceMotionSlotProps>;
};

// @public
export const MenuSplitGroup: ForwardRefComponent<MenuSplitGroupProps>;
Expand All @@ -375,7 +378,7 @@ export type MenuSplitGroupSlots = {
export type MenuSplitGroupState = ComponentState<MenuSplitGroupSlots> & Pick<MenuSplitGroupContextValue, 'setMultiline'>;

// @public (undocumented)
export type MenuState = ComponentState<MenuSlots> & Required<Pick<MenuProps, 'hasCheckmarks' | 'hasIcons' | 'mountNode' | 'inline' | 'checkedValues' | 'onCheckedValueChange' | 'open' | 'openOnHover' | 'closeOnScroll' | 'hoverDelay' | 'openOnContext' | 'persistOnItemClick'>> & {
export type MenuState = ComponentState<InternalMenuSlots> & Required<Pick<MenuProps, 'hasCheckmarks' | 'hasIcons' | 'mountNode' | 'inline' | 'checkedValues' | 'onCheckedValueChange' | 'open' | 'openOnHover' | 'closeOnScroll' | 'hoverDelay' | 'openOnContext' | 'persistOnItemClick'>> & {
contextTarget?: PositioningVirtualElement;
isSubmenu: boolean;
menuPopover: React_2.ReactNode;
Expand Down
2 changes: 2 additions & 0 deletions packages/react-components/react-menu/library/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PresenceMotionSlotProps>;
};

export type InternalMenuSlots = {
surfaceMotion: NonNullable<Slot<PresenceMotionSlotProps>>;
};

/**
* Extends and drills down Menulist props to simplify API
*/
export type MenuProps = ComponentProps<MenuSlots> &
export type MenuProps = ComponentProps<Partial<MenuSlots>> &
Pick<PortalProps, 'mountNode'> &
Pick<
MenuListProps,
Expand Down Expand Up @@ -91,7 +102,7 @@ export type MenuProps = ComponentProps<MenuSlots> &
closeOnScroll?: boolean;
};

export type MenuState = ComponentState<MenuSlots> &
export type MenuState = ComponentState<InternalMenuSlots> &
Required<
Pick<
MenuProps,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<Menu>`.
*
* 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: [],
}));
Original file line number Diff line number Diff line change
@@ -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<InternalMenuSlots>(state);

return (
<MenuProvider value={contextValues.menu}>
{state.menuTrigger}
{state.open && state.menuPopover}
{state.menuPopover && (
<state.surfaceMotion>
<MotionRefForwarder>
{/* Casting here as content should be equivalent to <MenuPopover /> */}
{/* FIXME: content should not be ReactNode it should be ReactElement instead. */}
{state.menuPopover as React.ReactElement}
</MotionRefForwarder>
</state.surfaceMotion>
)}
</MenuProvider>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {
usePositioningMouseTarget,
usePositioning,
useSafeZoneArea,
usePositioningSlideDirection,
type PositioningShorthandValue,
} from '@fluentui/react-positioning';
import { presenceMotionSlot } from '@fluentui/react-motion';
import {
useControllableState,
useId,
Expand All @@ -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
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -180,14 +190,24 @@ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { tim
mountNode,
triggerRef,
menuPopoverRef,
components: {},
components: {
surfaceMotion: MenuSurfaceMotion,
},
openOnContext,
open,
setOpen,
checkedValues,
onCheckedValueChange,
persistOnItemClick,
safeZone: safeZoneHandle.elementToRender,
surfaceMotion: presenceMotionSlot(props.surfaceMotion, {
elementType: MenuSurfaceMotion,
defaultProps: {
visible: open,
appear: true,
unmountOnExit: true,
},
}),
};
};

Expand Down Expand Up @@ -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 <body> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<HTMLDivElement>,
ref: useMergedRefs(
ref,
popoverRef,
mouseOverListenerCallbackRef,
useMotionForwardedRef(),
) as React.Ref<HTMLDivElement>,
}),
{ elementType: 'div' },
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MenuPopoverSlots> = {
root: 'fui-MenuPopover',
Expand All @@ -24,7 +23,6 @@ const useStyles = makeStyles({
padding: '4px',
border: `1px solid ${tokens.colorTransparentStroke}`,
...typographyStyles.body1,
...createSlideStyles(10),
},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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 => (
<Menu
surfaceMotion={{
children: (_, motionProps) => <FadeInBlurOut {...motionProps} />,
}}
>
<MenuTrigger disableButtonEnhancement>
<Button>Toggle menu</Button>
</MenuTrigger>

<MenuPopover>
<MenuList>
<MenuItem>New</MenuItem>
<MenuItem>New Window</MenuItem>
<MenuItem disabled>Open File</MenuItem>
<MenuItem>Open Folder</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
);

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.',
},
},
};
Loading