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 @@
{
Comment thread
robertpenner marked this conversation as resolved.
"type": "minor",
"comment": "feat: add motionSlot() as parallel to presenceMotionSlot()",
"packageName": "@fluentui/react-motion",
"email": "robertpenner@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,20 @@ export const MotionRefForwarderReset: React_2.FC<{
children: React_2.ReactElement;
}>;

// @public (undocumented)
export function motionSlot<MotionParams extends Record<string, MotionParam> = {}>(motion: MotionSlotProps<MotionParams> | null | undefined, options: {
elementType: React_2.FC<MotionComponentProps & MotionParams>;
defaultProps: MotionSlotRenderProps & MotionParams;
}): SlotComponentType<MotionSlotRenderProps & MotionParams>;

// @public (undocumented)
export type MotionSlotProps<MotionParams extends Record<string, MotionParam> = {}> = Pick<MotionComponentProps, 'imperativeRef' | 'onMotionFinish' | 'onMotionStart' | 'onMotionCancel'> & {
as?: JSXIntrinsicElementKeys;
children?: SlotRenderFunction<MotionSlotRenderProps & MotionParams & {
children: JSXElement;
}>;
};

// @public (undocumented)
export const motionTokens: {
curveAccelerateMax: "cubic-bezier(0.9,0.1,1,0.2)";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export { createPresenceComponentVariant } from './factories/createPresenceCompon
export { PresenceGroup } from './components/PresenceGroup';
export { MotionRefForwarder, MotionRefForwarderReset, useMotionForwardedRef } from './components/MotionRefForwarder';

export { motionSlot, type MotionSlotProps } from './slots/motionSlot';
export { presenceMotionSlot, type PresenceMotionSlotProps } from './slots/presenceMotionSlot';

export {
Expand Down
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();
});
});
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;
}
Loading