Skip to content
Closed
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": "prerelease",
"comment": "chore: update createElement to support new slot methods",
"packageName": "@fluentui/react-jsx-runtime",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: implement new slot methods (slot and assertSlots)",
"packageName": "@fluentui/react-utilities",
"email": "[email protected]",
"dependentChangeType": "patch"
}
197 changes: 193 additions & 4 deletions packages/react-components/react-jsx-runtime/src/createElement.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
/* eslint-disable jsdoc/check-tag-names */
/** @jsxRuntime classic */
/** @jsxFrag Fragment */
/** @jsx createElement */
/* eslint-enable jsdoc/check-tag-names */

import { render } from '@testing-library/react';
import { ComponentProps, ComponentState, Slot, getSlotsNext, resolveShorthand } from '@fluentui/react-utilities';
import {
ComponentProps,
ComponentState,
Slot,
assertSlots,
getSlotsNext,
resolveShorthand,
slot,
} from '@fluentui/react-utilities';
import { createElement } from './createElement';

describe('createElement', () => {
describe('createElement with getSlotsNext', () => {
describe('general behavior tests', () => {
it('handles a string', () => {
const result = render(<div>Hello world</div>);
Expand Down Expand Up @@ -166,3 +172,186 @@ describe('createElement', () => {
});
});
});

describe('createElement with assertSlots', () => {
describe('general behavior tests', () => {
it('handles a string', () => {
const result = render(<div>Hello world</div>);

expect(result.container.firstChild).toMatchInlineSnapshot(`
<div>
Hello world
</div>
`);
});

it('handles an array', () => {
const result = render(
<div>
{Array.from({ length: 3 }, (_, i) => (
<div key={i}>{i}</div>
))}
</div>,
);

expect(result.container.firstChild).toMatchInlineSnapshot(`
<div>
<div>
0
</div>
<div>
1
</div>
<div>
2
</div>
</div>
`);
});

it('handles an array of children', () => {
const result = render(
<div>
<div>1</div>
<div>2</div>
</div>,
);

expect(result.container.firstChild).toMatchInlineSnapshot(`
<div>
<div>
1
</div>
<div>
2
</div>
</div>
`);
});
});

describe('custom behavior tests', () => {
it('keeps children from "defaultProps" in a render callback', () => {
type TestComponentSlots = {
someSlot: NonNullable<Slot<'div'>>;
};
type TestComponentProps = ComponentProps<Partial<TestComponentSlots>>;
type TestComponentState = ComponentState<TestComponentSlots>;

const TestComponent = (props: TestComponentProps) => {
const state: TestComponentState = {
components: { someSlot: 'div' },
someSlot: slot(props.someSlot, {
required: true,
elementType: 'div',
defaultProps: { children: 'Default Children', id: 'slot' },
}),
};
assertSlots<TestComponentSlots>(state);
return <state.someSlot />;
};

const children = jest.fn().mockImplementation((Component, props) => (
<div id="render-fn">
<Component {...props} />
</div>
));
const result = render(<TestComponent someSlot={{ children }} />);

expect(children).toHaveBeenCalledTimes(1);
expect(children).toHaveBeenCalledWith('div', { children: 'Default Children', id: 'slot' });

expect(result.container.firstChild).toMatchInlineSnapshot(`
<div
id="render-fn"
>
<div
id="slot"
>
Default Children
</div>
</div>
`);
});

it('keeps children from a render template in a render callback', () => {
type TestComponentSlots = { outer: NonNullable<Slot<'div'>>; inner: NonNullable<Slot<'div'>> };
type TestComponentState = ComponentState<TestComponentSlots>;
type TestComponentProps = ComponentProps<Partial<TestComponentSlots>>;

const TestComponent = (props: TestComponentProps) => {
const state: TestComponentState = {
components: { outer: 'div', inner: 'div' },
inner: slot(props.inner, { required: true, defaultProps: { id: 'inner' }, elementType: 'div' }),
outer: slot(props.outer, { required: true, defaultProps: { id: 'outer' }, elementType: 'div' }),
};
assertSlots<TestComponentSlots>(state);
return (
<state.outer>
<state.inner />
</state.outer>
);
};

const children = jest.fn().mockImplementation((Component, props) => (
<div id="render-fn">
<Component {...props} />
</div>
));
const result = render(<TestComponent outer={{ children }} inner={{ children: 'Inner children' }} />);

expect(children).toHaveBeenCalledTimes(1);
expect(children.mock.calls[0][0]).toBe('div');
expect(children.mock.calls[0][1].id).toBe('outer');
expect(children.mock.calls[0][1].children).toMatchInlineSnapshot(`
<React.Fragment>
<div
id="inner"
>
Inner children
</div>
</React.Fragment>
`);

expect(result.container.firstChild).toMatchInlineSnapshot(`
<div
id="render-fn"
>
<div
id="outer"
>
<div
id="inner"
>
Inner children
</div>
</div>
</div>
`);
});

it("should support 'as' property to opt-out of base element type", () => {
type TestComponentSlots = { slot: NonNullable<Slot<'div', 'span'>> };
type TestComponentState = ComponentState<TestComponentSlots>;
type TestComponentProps = ComponentProps<Partial<TestComponentSlots>>;

const TestComponent = (props: TestComponentProps) => {
const state: TestComponentState = {
components: { slot: 'div' },
slot: slot(props.slot, {
required: true,
elementType: 'div',
}),
};
assertSlots<TestComponentSlots>(state);
return <state.slot />;
};

const result = render(<TestComponent slot={{ as: 'span' }} />);

expect(result.container.firstChild).toMatchInlineSnapshot(`<span />`);
});

it.todo("should pass 'as' property to base element that aren't html element");
});
});
73 changes: 50 additions & 23 deletions packages/react-components/react-jsx-runtime/src/createElement.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,65 @@
import * as React from 'react';
import { SlotRenderFunction, UnknownSlotProps, SLOT_RENDER_FUNCTION_SYMBOL } from '@fluentui/react-utilities';

type WithMetadata<Props extends {}> = Props & {
[SLOT_RENDER_FUNCTION_SYMBOL]: SlotRenderFunction<Props>;
};
import {
SLOT_ELEMENT_TYPE_SYMBOL,
SLOT_RENDER_FUNCTION_SYMBOL,
SlotComponent,
UnknownSlotProps,
isSlot,
slot,
} from '@fluentui/react-utilities';

export function createElement<P extends {}>(
type: React.ElementType<P>,
props?: P | null,
...children: React.ReactNode[]
): React.ReactElement<P> | null {
return hasRenderFunction(props)
? createElementFromRenderFunction(type, props, children)
: React.createElement(type, props, ...children);
// TODO:
// this is for backwards compatibility with getSlotsNext
// it should be removed once getSlotsNext is obsolete
if (isSlot<P>(props)) {
return createElementFromSlotComponent(
slot(props, { required: true, elementType: type as React.ComponentType<P> }),
children,
);
}
if (isSlot<P>(type)) {
return createElementFromSlotComponent(type, children);
}
return React.createElement(type, props, ...children);
}

function createElementFromRenderFunction<P extends UnknownSlotProps>(
type: React.ElementType<P>,
props: WithMetadata<P>,
function createElementFromSlotComponent<Props extends UnknownSlotProps>(
slotComponent: SlotComponent<Props>,
overrideChildren: React.ReactNode[],
): React.ReactElement<P> | null {
const { [SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction, ...renderProps } = props;
): React.ReactElement<Props> | null {
const {
[SLOT_ELEMENT_TYPE_SYMBOL]: baseElementType,
[SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction,
as,
...propsWithoutMetadata
} = slotComponent;
const props = propsWithoutMetadata as UnknownSlotProps as Props;

if (overrideChildren.length > 0) {
renderProps.children = React.createElement(React.Fragment, {}, ...overrideChildren);
const elementType =
baseElementType === undefined || typeof baseElementType === 'string'
? as ?? baseElementType ?? 'div'
: baseElementType;

if (typeof elementType !== 'string' && as) {
props.as = as;
}

return React.createElement(
React.Fragment,
{},
renderFunction(type, renderProps as UnknownSlotProps as P),
) as React.ReactElement<P>;
}
if (renderFunction) {
if (overrideChildren.length > 0) {
props.children = React.createElement(React.Fragment, {}, ...overrideChildren);
}

return React.createElement(
React.Fragment,
{},
renderFunction(elementType as React.ElementType<Props>, props),
) as React.ReactElement<Props>;
}

export function hasRenderFunction<Props extends {}>(props?: Props | null): props is WithMetadata<Props> {
return Boolean(props?.hasOwnProperty(SLOT_RENDER_FUNCTION_SYMBOL));
return React.createElement(elementType, props, ...overrideChildren);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import * as React_2 from 'react';
// @internal
export function applyTriggerPropsToChildren<TriggerChildProps>(children: TriggerProps<TriggerChildProps>['children'], triggerChildProps: TriggerChildProps): React_2.ReactElement | null;

// @internal
export function assertSlots<Slots extends SlotPropsRecord>(state: unknown): asserts state is SlotComponents<Slots>;

// @public
export function canUseDOM(): boolean;

Expand Down Expand Up @@ -104,6 +107,9 @@ export function isMouseEvent(event: TouchOrMouseEvent): event is MouseEvent | Re
// @public
export function isResolvedShorthand<Shorthand extends Slot<UnknownSlotProps>>(shorthand?: Shorthand): shorthand is ExtractSlotProps<Shorthand>;

// @public
export function isSlot<Props extends {}>(element: unknown): element is SlotComponent<Props>;

// @public
export function isTouchEvent(event: TouchOrMouseEvent): event is TouchEvent | React_2.TouchEvent;

Expand Down Expand Up @@ -154,7 +160,7 @@ export type RefObjectFunction<T> = React_2.RefObject<T> & ((value: T) => void);
export function resetIdsForTests(): void;

// @public
export const resolveShorthand: ResolveShorthandFunction;
export const resolveShorthand: ResolveShorthandFunction<UnknownSlotProps>;

// @public (undocumented)
export type ResolveShorthandFunction<Props extends UnknownSlotProps = UnknownSlotProps> = {
Expand Down Expand Up @@ -211,6 +217,24 @@ export type Slot<Type extends keyof JSX.IntrinsicElements | React_2.ComponentTyp
} & WithSlotRenderFunction<IntrinsicElementProps<As>>;
}[AlternateAs] | null : 'Error: First parameter to Slot must not be not a union of types. See documentation of Slot type.';

// @public
export function slot<Props extends UnknownSlotProps>(value: Props | SlotComponent<Props> | SlotShorthandValue | undefined, options: {
required: true;
} & SlotOptions<Props>): SlotComponent<Props>;

// @public (undocumented)
export function slot<Props extends UnknownSlotProps>(value: Props | SlotComponent<Props> | SlotShorthandValue | undefined | null, options: {
required?: boolean;
} & SlotOptions<Props>): SlotComponent<Props> | undefined;

// @public (undocumented)
export function slot<Props extends UnknownSlotProps>(value: SlotComponent<Props>, options?: {
required: true;
} & Partial<SlotOptions<Props>>): SlotComponent<Props>;

// @internal
export const SLOT_ELEMENT_TYPE_SYMBOL: unique symbol;

// @internal
export const SLOT_RENDER_FUNCTION_SYMBOL: unique symbol;

Expand All @@ -219,6 +243,19 @@ export type SlotClassNames<Slots> = {
[SlotName in keyof Slots]-?: string;
};

// @public
export type SlotComponent<Props extends UnknownSlotProps> = Props & {
(props: React_2.PropsWithChildren<{}>): React_2.ReactElement | null;
[SLOT_ELEMENT_TYPE_SYMBOL]: React_2.ComponentType<Props> | (Props extends AsIntrinsicElement<infer As> ? As : keyof JSX.IntrinsicElements);
[SLOT_RENDER_FUNCTION_SYMBOL]?: SlotRenderFunction<Props>;
};

// @public (undocumented)
export type SlotOptions<Props extends UnknownSlotProps> = {
elementType: React_2.ComponentType<Props> | (Props extends AsIntrinsicElement<infer As> ? As : keyof JSX.IntrinsicElements);
defaultProps?: Partial<Props>;
};

// @public
export type SlotPropsRecord = Record<string, UnknownSlotProps | SlotShorthandValue | null | undefined>;

Expand Down
Loading