Skip to content
Merged
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"
}
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,182 @@ 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.always(props.someSlot, {
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.always(props.inner, { defaultProps: { id: 'inner' }, elementType: 'div' }),
outer: slot.always(props.outer, { 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.always(props.slot, { 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");
});
});
69 changes: 46 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,61 @@
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 {
UnknownSlotProps,
isSlot,
SlotComponentType,
SLOT_ELEMENT_TYPE_SYMBOL,
SLOT_RENDER_FUNCTION_SYMBOL,
} 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(
{ ...props, [SLOT_ELEMENT_TYPE_SYMBOL]: type } as SlotComponentType<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>(
type: SlotComponentType<Props>,
overrideChildren: React.ReactNode[],
): React.ReactElement<P> | null {
const { [SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction, ...renderProps } = props;
): React.ReactElement<Props> | null {
const {
as,
[SLOT_ELEMENT_TYPE_SYMBOL]: baseElementType,
[SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction,
...propsWithoutMetadata
} = type;
const props = propsWithoutMetadata as UnknownSlotProps as Props;

if (overrideChildren.length > 0) {
renderProps.children = React.createElement(React.Fragment, {}, ...overrideChildren);
const elementType = typeof baseElementType === 'string' ? as ?? baseElementType : 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 @@ -7,9 +7,15 @@
import { DispatchWithoutAction } from 'react';
import * as React_2 from 'react';

// @public
function always<Props extends UnknownSlotProps>(value: Props | SlotShorthandValue | undefined, options: SlotOptions<Props>): SlotComponentType<Props>;

// @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 +110,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 SlotComponentType<Props>;

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

Expand All @@ -124,6 +133,11 @@ export type OnSelectionChangeData = {
selectedItems: Set<SelectionItemId>;
};

// @public
function optional<Props extends UnknownSlotProps>(value: Props | SlotShorthandValue | undefined | null, options: {
renderByDefault?: boolean;
} & SlotOptions<Props>): SlotComponentType<Props> | undefined;

// @internal (undocumented)
export interface PriorityQueue<T> {
// (undocumented)
Expand Down Expand Up @@ -154,7 +168,10 @@ 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
function resolveShorthand_2<Props extends UnknownSlotProps | null | undefined>(value: Props | SlotShorthandValue): Props;

// @public (undocumented)
export type ResolveShorthandFunction<Props extends UnknownSlotProps = UnknownSlotProps> = {
Expand Down Expand Up @@ -211,6 +228,19 @@ 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.';

declare namespace slot {
export {
always,
optional,
resolveShorthand_2 as resolveShorthand,
SlotOptions
}
}
export { slot }

// @internal
export const SLOT_ELEMENT_TYPE_SYMBOL: unique symbol;

// @internal
export const SLOT_RENDER_FUNCTION_SYMBOL: unique symbol;

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

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

// @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