-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat(motion): add motionSlot() as parallel to presenceMotionSlot() #35888
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
robertpenner
merged 2 commits into
microsoft:master
from
robertpenner:feat/motionslot-api
Mar 27, 2026
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
7 changes: 7 additions & 0 deletions
7
change/@fluentui-react-motion-6685a977-419c-4108-986c-b85c61ed6dcd.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "type": "minor", | ||
| "comment": "feat: add motionSlot() as parallel to presenceMotionSlot()", | ||
| "packageName": "@fluentui/react-motion", | ||
| "email": "robertpenner@microsoft.com", | ||
| "dependentChangeType": "patch" | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
91 changes: 91 additions & 0 deletions
91
packages/react-components/react-motion/library/src/slots/motionSlot.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| /** @jsxRuntime automatic */ | ||
| /** @jsxImportSource @fluentui/react-jsx-runtime */ | ||
|
|
||
| import { assertSlots, type ComponentProps, type ComponentState, type Slot } from '@fluentui/react-utilities'; | ||
| import { render } from '@testing-library/react'; | ||
| import * as React from 'react'; | ||
|
|
||
| import { createMotionComponent } from '../factories/createMotionComponent'; | ||
| import { motionSlot, type MotionSlotProps } from './motionSlot'; | ||
|
|
||
| type TestComponentSlots = { motion: Slot<MotionSlotProps> }; | ||
| type TestComponentProps = ComponentProps<Partial<TestComponentSlots>>; | ||
| type TestComponentState = ComponentState<TestComponentSlots>; | ||
|
|
||
| const TestMotion = jest.fn( | ||
| createMotionComponent({ | ||
| keyframes: [{ opacity: 0 }, { opacity: 1 }], | ||
| duration: 300, | ||
| }), | ||
| ); | ||
| const TestComponent: React.FC<TestComponentProps> = props => { | ||
| const state: TestComponentState = { | ||
| components: { | ||
| motion: TestMotion, | ||
| }, | ||
| motion: motionSlot(props.motion, { | ||
| elementType: TestMotion, | ||
| defaultProps: {}, | ||
| }), | ||
| }; | ||
|
|
||
| assertSlots<TestComponentSlots>(state); | ||
|
|
||
| return ( | ||
| <div data-testid="root"> | ||
| { | ||
| // TODO: state.motion is non nullable, but assertSlots asserts it as nullable | ||
| // FIXME: this should be resolved by properly splitting props and state slots declaration | ||
| state.motion && ( | ||
| <state.motion> | ||
| <div data-testid="content" /> | ||
| </state.motion> | ||
| ) | ||
| } | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| describe('motionSlot', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('renders a component with a slot', () => { | ||
| const { queryByTestId } = render(<TestComponent />); | ||
|
|
||
| expect(queryByTestId('root')).not.toBeNull(); | ||
| expect(queryByTestId('content')).not.toBeNull(); | ||
|
|
||
| expect(TestMotion).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('handles object as value', () => { | ||
| const onMotionStart = jest.fn(); | ||
| const { queryByTestId } = render(<TestComponent motion={{ onMotionStart }} />); | ||
|
|
||
| expect(queryByTestId('content')).not.toBeNull(); | ||
|
|
||
| expect(TestMotion).toHaveBeenCalled(); | ||
| const firstCall = TestMotion.mock.calls[0]; | ||
| expect(firstCall[0]).toEqual(expect.objectContaining({ onMotionStart })); | ||
| }); | ||
|
|
||
| it('handles function as value', () => { | ||
| const renderFn = jest.fn((Component, props) => <Component {...props} />); | ||
| const { queryByTestId } = render(<TestComponent motion={{ children: renderFn }} />); | ||
|
|
||
| expect(queryByTestId('content')).not.toBeNull(); | ||
| expect(renderFn).toHaveBeenCalled(); | ||
| expect(renderFn).toHaveBeenCalledWith(TestMotion, { | ||
| children: expect.objectContaining({ type: 'div' }), | ||
| }); | ||
| }); | ||
|
|
||
| it('handles "null" as value', () => { | ||
| const { queryByTestId } = render(<TestComponent motion={null} />); | ||
|
|
||
| expect(queryByTestId('content')).not.toBeNull(); | ||
| expect(TestMotion).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
91 changes: 91 additions & 0 deletions
91
packages/react-components/react-motion/library/src/slots/motionSlot.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import * as React from 'react'; | ||
| import { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from '@fluentui/react-utilities'; | ||
| import type { | ||
| JSXElement, | ||
| JSXIntrinsicElementKeys, | ||
| SlotComponentType, | ||
| SlotRenderFunction, | ||
| } from '@fluentui/react-utilities'; | ||
|
|
||
| import type { MotionComponentProps } from '../factories/createMotionComponent'; | ||
| import type { MotionParam } from '../types'; | ||
|
|
||
| /** | ||
| * @internal | ||
| */ | ||
| type MotionSlotRenderProps = Pick<MotionComponentProps, 'onMotionFinish' | 'onMotionStart' | 'onMotionCancel'>; | ||
|
|
||
| export type MotionSlotProps<MotionParams extends Record<string, MotionParam> = {}> = Pick< | ||
| MotionComponentProps, | ||
| 'imperativeRef' | 'onMotionFinish' | 'onMotionStart' | 'onMotionCancel' | ||
| > & { | ||
| // FIXME: 'as' property is required by design on the slot AP but it does not support components, only intrinsic | ||
| // elements motion slots do not support intrinsic elements, only custom components. | ||
| /** | ||
| * @deprecated Do not use. Motion Slots do not support intrinsic elements. | ||
| * | ||
| * If you want to override the animation, use the children render function instead. | ||
| */ | ||
| as?: JSXIntrinsicElementKeys; | ||
|
|
||
| // TODO: remove once React v18 slot API is modified ComponentProps is not properly adding render function as a | ||
| // possible value for children | ||
| children?: SlotRenderFunction<MotionSlotRenderProps & MotionParams & { children: JSXElement }>; | ||
| }; | ||
|
|
||
| export function motionSlot<MotionParams extends Record<string, MotionParam> = {}>( | ||
| motion: MotionSlotProps<MotionParams> | null | undefined, | ||
| options: { | ||
| elementType: React.FC<MotionComponentProps & MotionParams>; | ||
| defaultProps: MotionSlotRenderProps & MotionParams; | ||
| }, | ||
| ): SlotComponentType<MotionSlotRenderProps & MotionParams> { | ||
| // eslint-disable-next-line @typescript-eslint/no-deprecated | ||
| const { as, children, ...rest } = motion ?? {}; | ||
|
|
||
| if (process.env.NODE_ENV !== 'production') { | ||
| if (typeof as !== 'undefined') { | ||
| throw new Error(`@fluentui/react-motion: "as" property is not supported on motion slots.`); | ||
| } | ||
| } | ||
|
|
||
| if (motion === null) { | ||
| // Heads up! | ||
| // Render function is used there to avoid rendering a motion component and render children directly | ||
| const renderFn: SlotRenderFunction<MotionSlotRenderProps & MotionParams & { children: JSXElement }> = ( | ||
| _, | ||
| props, | ||
| ) => <>{props.children}</>; | ||
|
|
||
| /** | ||
| * Casting is required here as SlotComponentType is a function, not an object. | ||
| * Although SlotComponentType has a function signature, it is still just an object. | ||
| * This is required to make a slot callable (JSX compatible), this is the exact same approach | ||
| * that is used on `@types/react` components | ||
| */ | ||
| return { | ||
| [SLOT_RENDER_FUNCTION_SYMBOL]: renderFn, | ||
| [SLOT_ELEMENT_TYPE_SYMBOL]: options.elementType, | ||
| } as SlotComponentType<MotionSlotRenderProps & MotionParams>; | ||
| } | ||
|
|
||
| /** | ||
| * Casting is required here as SlotComponentType is a function, not an object. | ||
| * Although SlotComponentType has a function signature, it is still just an object. | ||
| * This is required to make a slot callable (JSX compatible), this is the exact same approach | ||
| * that is used on `@types/react` components | ||
| */ | ||
| const propsWithMetadata = { | ||
| ...options.defaultProps, | ||
| ...rest, | ||
| [SLOT_ELEMENT_TYPE_SYMBOL]: options.elementType, | ||
| } as SlotComponentType<MotionSlotRenderProps & MotionParams>; | ||
|
|
||
| if (typeof children === 'function') { | ||
| propsWithMetadata[SLOT_RENDER_FUNCTION_SYMBOL] = children as SlotRenderFunction< | ||
| MotionSlotRenderProps & MotionParams | ||
| >; | ||
| } | ||
|
|
||
| return propsWithMetadata; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.