;
-};
+import {
+ UnknownSlotProps,
+ isSlot,
+ SlotComponentType,
+ SLOT_ELEMENT_TYPE_SYMBOL,
+ SLOT_RENDER_FUNCTION_SYMBOL,
+} from '@fluentui/react-utilities';
export function createElement(
type: React.ElementType
,
props?: P | null,
...children: React.ReactNode[]
): React.ReactElement
| 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
(props)) {
+ return createElementFromSlotComponent(
+ { ...props, [SLOT_ELEMENT_TYPE_SYMBOL]: type } as SlotComponentType
,
+ children,
+ );
+ }
+ if (isSlot
(type)) {
+ return createElementFromSlotComponent(type, children);
+ }
+ return React.createElement(type, props, ...children);
}
-function createElementFromRenderFunction
(
- type: React.ElementType
,
- props: WithMetadata
,
+function createElementFromSlotComponent(
+ type: SlotComponentType,
overrideChildren: React.ReactNode[],
-): React.ReactElement | null {
- const { [SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction, ...renderProps } = props;
+): React.ReactElement | 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;
-}
+ if (renderFunction) {
+ if (overrideChildren.length > 0) {
+ props.children = React.createElement(React.Fragment, {}, ...overrideChildren);
+ }
+
+ return React.createElement(
+ React.Fragment,
+ {},
+ renderFunction(elementType as React.ElementType, props),
+ ) as React.ReactElement;
+ }
-export function hasRenderFunction(props?: Props | null): props is WithMetadata {
- return Boolean(props?.hasOwnProperty(SLOT_RENDER_FUNCTION_SYMBOL));
+ return React.createElement(elementType, props, ...overrideChildren);
}
diff --git a/packages/react-components/react-utilities/etc/react-utilities.api.md b/packages/react-components/react-utilities/etc/react-utilities.api.md
index 2def011e5a643..0c237a9665b02 100644
--- a/packages/react-components/react-utilities/etc/react-utilities.api.md
+++ b/packages/react-components/react-utilities/etc/react-utilities.api.md
@@ -7,9 +7,15 @@
import { DispatchWithoutAction } from 'react';
import * as React_2 from 'react';
+// @public
+function always(value: Props | SlotShorthandValue | undefined, options: SlotOptions): SlotComponentType;
+
// @internal
export function applyTriggerPropsToChildren(children: TriggerProps['children'], triggerChildProps: TriggerChildProps): React_2.ReactElement | null;
+// @internal
+export function assertSlots(state: unknown): asserts state is SlotComponents;
+
// @public
export function canUseDOM(): boolean;
@@ -104,6 +110,9 @@ export function isMouseEvent(event: TouchOrMouseEvent): event is MouseEvent | Re
// @public
export function isResolvedShorthand>(shorthand?: Shorthand): shorthand is ExtractSlotProps;
+// @public
+export function isSlot(element: unknown): element is SlotComponentType;
+
// @public
export function isTouchEvent(event: TouchOrMouseEvent): event is TouchEvent | React_2.TouchEvent;
@@ -124,6 +133,11 @@ export type OnSelectionChangeData = {
selectedItems: Set;
};
+// @public
+function optional(value: Props | SlotShorthandValue | undefined | null, options: {
+ renderByDefault?: boolean;
+} & SlotOptions): SlotComponentType | undefined;
+
// @internal (undocumented)
export interface PriorityQueue {
// (undocumented)
@@ -154,7 +168,10 @@ export type RefObjectFunction = React_2.RefObject & ((value: T) => void);
export function resetIdsForTests(): void;
// @public
-export const resolveShorthand: ResolveShorthandFunction;
+export const resolveShorthand: ResolveShorthandFunction;
+
+// @public
+function resolveShorthand_2(value: Props | SlotShorthandValue): Props;
// @public (undocumented)
export type ResolveShorthandFunction = {
@@ -211,6 +228,19 @@ export type Slot>;
}[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;
@@ -219,6 +249,19 @@ export type SlotClassNames = {
[SlotName in keyof Slots]-?: string;
};
+// @public
+export type SlotComponentType = Props & {
+ (props: React_2.PropsWithChildren<{}>): React_2.ReactElement | null;
+ [SLOT_RENDER_FUNCTION_SYMBOL]?: SlotRenderFunction;
+ [SLOT_ELEMENT_TYPE_SYMBOL]: React_2.ComponentType | (Props extends AsIntrinsicElement ? As : keyof JSX.IntrinsicElements);
+};
+
+// @public (undocumented)
+export type SlotOptions = {
+ elementType: React_2.ComponentType | (Props extends AsIntrinsicElement ? As : keyof JSX.IntrinsicElements);
+ defaultProps?: Partial;
+};
+
// @public
export type SlotPropsRecord = Record;
diff --git a/packages/react-components/react-utilities/src/compose/assertSlots.test.tsx b/packages/react-components/react-utilities/src/compose/assertSlots.test.tsx
new file mode 100644
index 0000000000000..b22cbdecf3cd8
--- /dev/null
+++ b/packages/react-components/react-utilities/src/compose/assertSlots.test.tsx
@@ -0,0 +1,53 @@
+import { assertSlots } from './assertSlots';
+import * as slot from './slot';
+import { ComponentProps, ComponentState, Slot } from './types';
+
+type TestSlots = {
+ slotA?: Slot<'div', 'a'>;
+ slotB?: Slot<'div'>;
+ slotC?: Slot<'div'>;
+};
+
+type TestProps = ComponentProps & {
+ notASlot?: string;
+ alsoNotASlot?: number;
+};
+type TestState = ComponentState;
+
+describe('assertSlots', () => {
+ it('should not throw if all slots are properly declared', () => {
+ const props: TestProps = { slotA: 'hello' };
+ const state: TestState = {
+ components: {
+ slotA: 'div',
+ slotB: 'div',
+ slotC: 'div',
+ },
+ slotA: slot.optional(props.slotA, { elementType: 'div' }),
+ };
+ expect(() => assertSlots(state)).not.toThrow();
+ });
+ it('should throw if a slot is not declared with the `slot` function', () => {
+ const state: TestState = {
+ components: {
+ slotA: 'div',
+ slotB: 'div',
+ slotC: 'div',
+ },
+ slotA: {},
+ };
+ expect(() => assertSlots(state)).toThrow();
+ });
+ it('should throw if a state.components.SLOT_NAME is not equivalent to the slot elementType', () => {
+ const props: TestProps = { slotA: 'hello' };
+ const state: TestState = {
+ components: {
+ slotA: 'a',
+ slotB: 'div',
+ slotC: 'div',
+ },
+ slotA: slot.optional(props.slotA, { elementType: 'div' }),
+ };
+ expect(() => assertSlots(state)).toThrow();
+ });
+});
diff --git a/packages/react-components/react-utilities/src/compose/assertSlots.ts b/packages/react-components/react-utilities/src/compose/assertSlots.ts
new file mode 100644
index 0000000000000..8280b51bba4b4
--- /dev/null
+++ b/packages/react-components/react-utilities/src/compose/assertSlots.ts
@@ -0,0 +1,56 @@
+import { SLOT_ELEMENT_TYPE_SYMBOL } from './constants';
+import { isSlot } from './isSlot';
+import { ComponentState, ExtractSlotProps, SlotComponentType, SlotPropsRecord } from './types';
+
+type SlotComponents = {
+ [K in keyof Slots]: SlotComponentType>;
+};
+
+/**
+ * @internal
+ * Assertion method to ensure state slots properties are properly declared.
+ * A properly declared slot must be declared by using the `slot` method.
+ *
+ * @example
+ * ```tsx
+ * export const renderInput_unstable = (state: InputState) => {
+ assertSlots(state);
+ return (
+
+ {state.contentBefore && }
+
+ {state.contentAfter && }
+
+ );
+ };
+ * ```
+ */
+export function assertSlots(state: unknown): asserts state is SlotComponents {
+ /**
+ * This verification is not necessary in production
+ * as we're verifying static properties that will not change between environments
+ */
+ if (process.env.NODE_ENV !== 'production') {
+ const typedState = state as ComponentState;
+ for (const slotName of Object.keys(typedState.components)) {
+ const slotElement = typedState[slotName];
+ if (slotElement === undefined) {
+ continue;
+ }
+ if (!isSlot(slotElement)) {
+ throw new Error(
+ `${assertSlots.name} error: state.${slotName} is not a slot.\n` +
+ `Be sure to create slots properly by using 'slot.always' or 'slot.optional'.`,
+ );
+ } else {
+ const { [SLOT_ELEMENT_TYPE_SYMBOL]: elementType } = slotElement;
+ if (elementType !== typedState.components[slotName]) {
+ throw new TypeError(
+ `${assertSlots.name} error: state.${slotName} element type differs from state.components.${slotName}, ${elementType} !== ${typedState.components[slotName]}. \n` +
+ `Be sure to create slots properly by using 'slot.always' or 'slot.optional' with the correct elementType`,
+ );
+ }
+ }
+ }
+ }
+}
diff --git a/packages/react-components/react-utilities/src/compose/constants.ts b/packages/react-components/react-utilities/src/compose/constants.ts
index f5c1593f9203a..29a010ba8fd8e 100644
--- a/packages/react-components/react-utilities/src/compose/constants.ts
+++ b/packages/react-components/react-utilities/src/compose/constants.ts
@@ -3,3 +3,8 @@
* Internal reference for the render function
*/
export const SLOT_RENDER_FUNCTION_SYMBOL = Symbol('fui.slotRenderFunction');
+/**
+ * @internal
+ * Internal reference for the render function
+ */
+export const SLOT_ELEMENT_TYPE_SYMBOL = Symbol('fui.slotElementType');
diff --git a/packages/react-components/react-utilities/src/compose/getSlots.ts b/packages/react-components/react-utilities/src/compose/getSlots.ts
index 8c24dd77422ef..dbaf5e40364b5 100644
--- a/packages/react-components/react-utilities/src/compose/getSlots.ts
+++ b/packages/react-components/react-utilities/src/compose/getSlots.ts
@@ -1,7 +1,5 @@
import * as React from 'react';
-
import { omit } from '../utils/omit';
-import { SLOT_RENDER_FUNCTION_SYMBOL } from './constants';
import type {
AsIntrinsicElement,
ComponentState,
@@ -11,6 +9,8 @@ import type {
UnionToIntersection,
UnknownSlotProps,
} from './types';
+import { isSlot } from './isSlot';
+import { SLOT_RENDER_FUNCTION_SYMBOL } from './constants';
export type Slots = {
[K in keyof S]: ExtractSlotProps extends AsIntrinsicElement
@@ -76,12 +76,11 @@ function getSlot(
return [null, undefined as R[K]];
}
- const {
- children,
- as: asProp,
- [SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction,
- ...rest
- } = props as typeof props & { [SLOT_RENDER_FUNCTION_SYMBOL]?: SlotRenderFunction };
+ type NonUndefined = T extends undefined ? never : T;
+ // TS Error: Property 'as' does not exist on type 'UnknownSlotProps | undefined'.ts(2339)
+ const { as: asProp, children, ...rest } = props as NonUndefined;
+
+ const renderFunction = isSlot(props) ? props[SLOT_RENDER_FUNCTION_SYMBOL] : undefined;
const slot = (
state.components?.[slotName] === undefined || typeof state.components[slotName] === 'string'
@@ -90,7 +89,7 @@ function getSlot(
) as React.ElementType;
if (renderFunction || typeof children === 'function') {
- const render = renderFunction || (children as SlotRenderFunction);
+ const render = (renderFunction || children) as SlotRenderFunction;
return [
React.Fragment,
{
diff --git a/packages/react-components/react-utilities/src/compose/getSlotsNext.test.tsx b/packages/react-components/react-utilities/src/compose/getSlotsNext.test.tsx
index a66534b278698..184f906e1abf0 100644
--- a/packages/react-components/react-utilities/src/compose/getSlotsNext.test.tsx
+++ b/packages/react-components/react-utilities/src/compose/getSlotsNext.test.tsx
@@ -1,16 +1,26 @@
import * as React from 'react';
import { getSlotsNext } from './getSlotsNext';
-import type { Slot } from './types';
+import type { ExtractSlotProps, Slot, SlotComponentType, UnknownSlotProps } from './types';
import { resolveShorthand } from './resolveShorthand';
import { SLOT_RENDER_FUNCTION_SYMBOL } from './constants';
+const resolveShorthandMock = (props: Props): SlotComponentType => {
+ // casting is required here as SlotComponent is a callable
+ return { ...props } as SlotComponentType;
+};
+
describe('getSlotsNext', () => {
type FooProps = { id?: string; children?: React.ReactNode };
const Foo = (props: FooProps) => ;
it('returns provided component type for root if the as prop is not provided', () => {
type Slots = { root: Slot<'div'> };
- expect(getSlotsNext({ root: {}, components: { root: 'div' } })).toEqual({
+ expect(
+ getSlotsNext({
+ root: resolveShorthandMock>({}),
+ components: { root: 'div' },
+ }),
+ ).toEqual({
slots: { root: 'div' },
slotProps: { root: {} },
});
@@ -18,7 +28,12 @@ describe('getSlotsNext', () => {
it('returns root slot as a span with no props', () => {
type Slots = { root: Slot<'div', 'span'> };
- expect(getSlotsNext({ root: { as: 'span' }, components: { root: 'div' } })).toEqual({
+ expect(
+ getSlotsNext({
+ root: resolveShorthandMock>({ as: 'span' }),
+ components: { root: 'div' },
+ }),
+ ).toEqual({
slots: { root: 'span' },
slotProps: { root: {} },
});
@@ -28,7 +43,10 @@ describe('getSlotsNext', () => {
type Slots = { root: Slot<'button'> };
const invalidProp = { href: 'href' } as React.ButtonHTMLAttributes;
expect(
- getSlotsNext({ root: { as: 'button', id: 'id', ...invalidProp }, components: { root: 'button' } }),
+ getSlotsNext({
+ root: resolveShorthandMock>({ as: 'button', id: 'id', ...invalidProp }),
+ components: { root: 'button' },
+ }),
).toEqual({
slots: { root: 'button' },
slotProps: { root: { id: 'id', href: 'href' } },
@@ -37,7 +55,12 @@ describe('getSlotsNext', () => {
it('returns root slot as an anchor, leaving the href intact', () => {
type Slots = { root: Slot<'a'> };
- expect(getSlotsNext({ root: { as: 'a', id: 'id', href: 'href' }, components: { root: 'a' } })).toEqual({
+ expect(
+ getSlotsNext({
+ root: resolveShorthandMock>({ as: 'a', id: 'id', href: 'href' }),
+ components: { root: 'a' },
+ }),
+ ).toEqual({
slots: { root: 'a' },
slotProps: { root: { id: 'id', href: 'href' } },
});
@@ -50,13 +73,16 @@ describe('getSlotsNext', () => {
};
expect(
getSlotsNext({
- icon: {},
+ icon: resolveShorthandMock>({}),
components: { root: 'div', icon: Foo },
- root: { as: 'div' },
+ root: resolveShorthandMock>({ as: 'div' }),
}),
).toEqual({
slots: { root: 'div', icon: Foo },
- slotProps: { root: {}, icon: {} },
+ slotProps: {
+ root: {},
+ icon: {},
+ },
});
});
@@ -68,12 +94,15 @@ describe('getSlotsNext', () => {
expect(
getSlotsNext({
components: { icon: 'button', root: 'div' },
- root: { as: 'span' },
- icon: { id: 'id', children: 'children' },
+ root: resolveShorthandMock>({ as: 'span' }),
+ icon: resolveShorthandMock>({ id: 'id', children: 'children' }),
}),
).toEqual({
slots: { root: 'span', icon: 'button' },
- slotProps: { root: {}, icon: { id: 'id', children: 'children' } },
+ slotProps: {
+ root: {},
+ icon: { id: 'id', children: 'children' },
+ },
});
});
@@ -84,13 +113,20 @@ describe('getSlotsNext', () => {
};
expect(
getSlotsNext({
- root: { as: 'div' },
+ root: resolveShorthandMock>({ as: 'div' }),
components: { root: 'div', icon: 'a' },
- icon: { id: 'id', href: 'href', children: 'children' },
+ icon: resolveShorthandMock>({ id: 'id', href: 'href', children: 'children' }),
}),
).toEqual({
slots: { root: 'div', icon: 'a' },
- slotProps: { root: {}, icon: { id: 'id', href: 'href', children: 'children' } },
+ slotProps: {
+ root: {},
+ icon: {
+ id: 'id',
+ href: 'href',
+ children: 'children',
+ },
+ },
});
});
@@ -102,12 +138,19 @@ describe('getSlotsNext', () => {
expect(
getSlotsNext({
components: { root: 'div', icon: Foo },
- root: { as: 'div' },
- icon: { id: 'id', href: 'href', children: 'children' },
+ root: resolveShorthandMock>({ as: 'div' }),
+ icon: resolveShorthandMock>({ id: 'id', href: 'href', children: 'children' }),
}),
).toEqual({
slots: { root: 'div', icon: Foo },
- slotProps: { root: {}, icon: { id: 'id', href: 'href', children: 'children' } },
+ slotProps: {
+ root: {},
+ icon: {
+ id: 'id',
+ href: 'href',
+ children: 'children',
+ },
+ },
});
});
@@ -120,30 +163,40 @@ describe('getSlotsNext', () => {
expect(
getSlotsNext({
components: { root: 'div', icon: Foo },
- root: { as: 'div' },
- icon: { id: 'bar', children: renderIcon },
+ root: resolveShorthandMock>({ as: 'div' }),
+ icon: resolveShorthandMock>({ id: 'bar', children: renderIcon }),
}),
).toEqual({
slots: { root: 'div', icon: Foo },
- slotProps: { root: {}, icon: { id: 'bar', children: renderIcon } },
+ slotProps: {
+ root: {},
+ icon: { id: 'bar', children: renderIcon },
+ },
});
});
it('can use slot children functions from resolveShorthand to replace default slot rendering', () => {
type Slots = {
root: Slot<'div'>;
- icon: Slot<'a'>;
+ icon?: Slot<'a'>;
};
const renderFunction = (C: React.ElementType, p: {}) => ;
expect(
getSlotsNext({
components: { root: 'div', icon: Foo },
- root: resolveShorthand({ as: 'div' }, { required: true }),
- icon: resolveShorthand({ id: 'bar', children: renderFunction }),
+ root: resolveShorthand>({ as: 'div' }, { required: true }),
+ icon: resolveShorthand>({ id: 'bar', children: renderFunction }),
}),
).toEqual({
slots: { root: 'div', icon: Foo },
- slotProps: { root: {}, icon: { children: undefined, id: 'bar', [SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction } },
+ slotProps: {
+ root: {},
+ icon: {
+ children: undefined,
+ id: 'bar',
+ [SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction,
+ },
+ },
});
});
@@ -155,14 +208,18 @@ describe('getSlotsNext', () => {
};
expect(
getSlotsNext({
- root: { as: 'div' },
+ root: resolveShorthandMock>({ as: 'div' }),
components: { root: 'div', input: 'input', icon: 'a' },
- input: {},
+ input: resolveShorthandMock>({}),
icon: undefined,
}),
).toEqual({
slots: { root: 'div', input: 'input', icon: null },
- slotProps: { root: {}, input: {}, icon: undefined },
+ slotProps: {
+ root: {},
+ input: {},
+ icon: undefined,
+ },
});
});
});
diff --git a/packages/react-components/react-utilities/src/compose/index.ts b/packages/react-components/react-utilities/src/compose/index.ts
index fec1ddf85adde..5d1bedacf2bca 100644
--- a/packages/react-components/react-utilities/src/compose/index.ts
+++ b/packages/react-components/react-utilities/src/compose/index.ts
@@ -1,6 +1,13 @@
+import * as slot from './slot';
+
export * from './getSlots';
export * from './resolveShorthand';
export * from './types';
export * from './isResolvedShorthand';
export * from './constants';
export * from './getSlotsNext';
+export * from './isSlot';
+export * from './assertSlots';
+
+export { slot };
+export type { SlotOptions } from './slot';
diff --git a/packages/react-components/react-utilities/src/compose/isSlot.test.tsx b/packages/react-components/react-utilities/src/compose/isSlot.test.tsx
index 2a4027266f912..e5797b89107bb 100644
--- a/packages/react-components/react-utilities/src/compose/isSlot.test.tsx
+++ b/packages/react-components/react-utilities/src/compose/isSlot.test.tsx
@@ -1,31 +1,37 @@
import * as React from 'react';
-import { isResolvedShorthand } from './isResolvedShorthand';
+import { isSlot } from './isSlot';
+import * as slot from './slot';
-describe('isResolvedShorthand', () => {
- it('resolves a string', () => {
- expect(isResolvedShorthand('hello')).toEqual(false);
+describe('isSlot', () => {
+ it('handles a string', () => {
+ expect(isSlot('hello')).toEqual(false);
});
- it('resolves a JSX element', () => {
- expect(isResolvedShorthand(hello
)).toEqual(false);
+ it('handles a JSX element', () => {
+ expect(isSlot(hello
)).toEqual(false);
});
- it('resolves a number', () => {
- expect(isResolvedShorthand(42)).toEqual(false);
+ it('handles a number', () => {
+ expect(isSlot(42)).toEqual(false);
});
- it('resolves null', () => {
- expect(isResolvedShorthand(null)).toEqual(false);
+ it('handles null', () => {
+ expect(isSlot(null)).toEqual(false);
});
- it('resolves undefined', () => {
- expect(isResolvedShorthand(undefined)).toEqual(false);
+ it('handles undefined', () => {
+ expect(isSlot(undefined)).toEqual(false);
});
- it('resolves object', () => {
- expect(isResolvedShorthand({})).toEqual(true);
+ it('handles object', () => {
+ expect(isSlot({})).toEqual(false);
});
- it('resolves array', () => {
- expect(isResolvedShorthand(['1', 2])).toEqual(false);
+
+ it('handles array', () => {
+ expect(isSlot(['1', 2])).toEqual(false);
+ });
+
+ it('handles actual slot', () => {
+ expect(isSlot(slot.optional({}, { elementType: 'div' }))).toEqual(true);
});
});
diff --git a/packages/react-components/react-utilities/src/compose/isSlot.ts b/packages/react-components/react-utilities/src/compose/isSlot.ts
new file mode 100644
index 0000000000000..6dfa0eef00f23
--- /dev/null
+++ b/packages/react-components/react-utilities/src/compose/isSlot.ts
@@ -0,0 +1,10 @@
+import { SLOT_ELEMENT_TYPE_SYMBOL } from './constants';
+import { SlotComponentType } from './types';
+
+/**
+ * Guard method to ensure a given element is a slot.
+ * This is mainly used internally to ensure a slot is being used as a component.
+ */
+export function isSlot(element: unknown): element is SlotComponentType {
+ return Boolean((element as {} | undefined)?.hasOwnProperty(SLOT_ELEMENT_TYPE_SYMBOL));
+}
diff --git a/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx b/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx
index 4d564af249623..c6b5542f1a170 100644
--- a/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx
+++ b/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx
@@ -43,7 +43,7 @@ describe('resolveShorthand', () => {
const props: TestProps = { slotA };
const resolvedProps = resolveShorthand(props.slotA);
- expect(resolvedProps).toEqual(slotA);
+ expect(resolvedProps).toEqual({});
expect(resolvedProps).not.toBe(slotA);
});
diff --git a/packages/react-components/react-utilities/src/compose/resolveShorthand.ts b/packages/react-components/react-utilities/src/compose/resolveShorthand.ts
index 76b109240de9f..e647c96ea4e94 100644
--- a/packages/react-components/react-utilities/src/compose/resolveShorthand.ts
+++ b/packages/react-components/react-utilities/src/compose/resolveShorthand.ts
@@ -1,6 +1,5 @@
-import { isValidElement } from 'react';
-import type { SlotRenderFunction, SlotShorthandValue, UnknownSlotProps } from './types';
-import { SLOT_RENDER_FUNCTION_SYMBOL } from './constants';
+import * as slot from './slot';
+import type { SlotShorthandValue, UnknownSlotProps } from './types';
export type ResolveShorthandOptions = Required extends true
? { required: true; defaultProps?: Props }
@@ -19,32 +18,11 @@ export type ResolveShorthandFunction {
- const { required = false, defaultProps } = options || {};
- if (value === null || (value === undefined && !required)) {
- return undefined;
- }
-
- let resolvedShorthand: UnknownSlotProps & {
- [SLOT_RENDER_FUNCTION_SYMBOL]?: SlotRenderFunction;
- } = {};
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- if (typeof value === 'string' || typeof value === 'number' || Array.isArray(value) || isValidElement(value)) {
- resolvedShorthand.children = value;
- } else if (typeof value === 'object') {
- resolvedShorthand = value;
- }
-
- resolvedShorthand = {
- ...defaultProps,
- ...resolvedShorthand,
- };
-
- if (typeof resolvedShorthand.children === 'function') {
- resolvedShorthand[SLOT_RENDER_FUNCTION_SYMBOL] = resolvedShorthand.children as SlotRenderFunction;
- resolvedShorthand.children = defaultProps?.children;
- }
-
- return resolvedShorthand;
-};
+export const resolveShorthand: ResolveShorthandFunction = (value, options) =>
+ slot.optional(value, {
+ ...options,
+ renderByDefault: options?.required,
+ // elementType as undefined is the way to identify between a slot and a resolveShorthand call
+ // in the case elementType is undefined assertSlots will fail, ensuring it'll only work with slot method.
+ elementType: undefined!,
+ });
diff --git a/packages/react-components/react-utilities/src/compose/slot.test.tsx b/packages/react-components/react-utilities/src/compose/slot.test.tsx
new file mode 100644
index 0000000000000..0f30b4c49105e
--- /dev/null
+++ b/packages/react-components/react-utilities/src/compose/slot.test.tsx
@@ -0,0 +1,87 @@
+import * as React from 'react';
+import * as slot from './slot';
+import type { ComponentProps, Slot } from './types';
+import { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants';
+
+type TestSlots = {
+ slotA?: Slot<'div'>;
+ slotB?: Slot<'div'>;
+ slotC?: Slot<'div'>;
+};
+
+type TestProps = ComponentProps & {
+ notASlot?: string;
+ alsoNotASlot?: number;
+};
+
+describe('slot', () => {
+ it('resolves a string', () => {
+ const props: TestProps = { slotA: 'hello' };
+ const resolvedProps = slot.optional(props.slotA, { elementType: 'div' });
+
+ expect(resolvedProps).toEqual({
+ children: 'hello',
+ [SLOT_ELEMENT_TYPE_SYMBOL]: 'div',
+ });
+ });
+
+ it('resolves a JSX element', () => {
+ const props: TestProps = { slotA: hello
};
+ const resolvedProps = slot.optional(props.slotA, { elementType: 'div' });
+
+ expect(resolvedProps).toEqual({
+ children: hello
,
+ [SLOT_ELEMENT_TYPE_SYMBOL]: 'div',
+ });
+ });
+
+ it('resolves a number', () => {
+ const props: TestProps = { slotA: 42 };
+ const resolvedProps = slot.optional(props.slotA, { elementType: 'div' });
+
+ expect(resolvedProps).toEqual({
+ children: 42,
+ [SLOT_ELEMENT_TYPE_SYMBOL]: 'div',
+ });
+ });
+
+ it('resolves an object as its copy', () => {
+ const slotA = {};
+ const props: TestProps = { slotA };
+ const resolvedProps = slot.optional(props.slotA, { elementType: 'div' });
+
+ expect(resolvedProps).toEqual({ [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' });
+ expect(resolvedProps).not.toBe(slotA);
+ });
+
+ it('resolves "null" without creating a child element', () => {
+ const props: TestProps = { slotA: null, slotB: null };
+
+ expect(slot.optional(props.slotA, { elementType: 'div' })).toEqual(undefined);
+ expect(slot.optional(null, { renderByDefault: true, elementType: 'div' })).toEqual(undefined);
+ });
+
+ it('resolves undefined without creating a child element', () => {
+ const props: TestProps = { slotA: undefined };
+ const resolvedProps = slot.optional(props.slotA, { elementType: 'div' });
+
+ expect(resolvedProps).toEqual(undefined);
+ });
+
+ it('resolves to empty object creating a child element', () => {
+ const props: TestProps = { slotA: undefined };
+ const resolvedProps = slot.optional(props.slotA, { renderByDefault: true, elementType: 'div' });
+
+ expect(resolvedProps).toEqual({ [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' });
+ });
+
+ it('handles render functions', () => {
+ const props: TestProps = { slotA: { children: () => null } };
+ const resolvedProps = slot.optional(props.slotA, { elementType: 'div' });
+
+ expect(resolvedProps).toEqual({
+ [SLOT_ELEMENT_TYPE_SYMBOL]: 'div',
+ [SLOT_RENDER_FUNCTION_SYMBOL]: expect.any(Function),
+ });
+ });
+});
diff --git a/packages/react-components/react-utilities/src/compose/slot.ts b/packages/react-components/react-utilities/src/compose/slot.ts
new file mode 100644
index 0000000000000..c6640ed2c2855
--- /dev/null
+++ b/packages/react-components/react-utilities/src/compose/slot.ts
@@ -0,0 +1,96 @@
+import type {
+ AsIntrinsicElement,
+ SlotComponentType,
+ SlotRenderFunction,
+ SlotShorthandValue,
+ UnknownSlotProps,
+} from './types';
+import * as React from 'react';
+import { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants';
+
+export type SlotOptions = {
+ elementType:
+ | React.ComponentType
+ | (Props extends AsIntrinsicElement ? As : keyof JSX.IntrinsicElements);
+ defaultProps?: Partial;
+};
+
+/**
+ * Creates a slot from a slot shorthand or properties (`props.SLOT_NAME` or `props` itself)
+ * @param value - the value of the slot, it can be a slot shorthand, a slot component or a slot properties
+ * @param options - values you can pass to alter the signature of a slot, those values are:
+ *
+ * * `elementType` - the base element type of a slot, defaults to `'div'`
+ * * `defaultProps` - similar to a React component declaration, you can provide a slot default properties to be merged with the shorthand/properties provided.
+ */
+export function always(
+ value: Props | SlotShorthandValue | undefined,
+ options: SlotOptions,
+): SlotComponentType {
+ const { defaultProps, elementType } = options;
+
+ const props = resolveShorthand(value);
+
+ /**
+ * 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 = {
+ ...defaultProps,
+ ...props,
+ [SLOT_ELEMENT_TYPE_SYMBOL]: elementType,
+ } as SlotComponentType;
+
+ if (props && typeof props.children === 'function') {
+ propsWithMetadata[SLOT_RENDER_FUNCTION_SYMBOL] = props.children as SlotRenderFunction;
+ propsWithMetadata.children = defaultProps?.children;
+ }
+
+ return propsWithMetadata;
+}
+
+/**
+ * Creates a slot from a slot shorthand or properties (`props.SLOT_NAME` or `props` itself)
+ * @param value - the value of the slot, it can be a slot shorthand, a slot component or a slot properties
+ * @param options - values you can pass to alter the signature of a slot, those values are:
+ *
+ * * `elementType` - the base element type of a slot, defaults to `'div'`
+ * * `defaultProps` - similar to a React component declaration, you can provide a slot default properties to be merged with the shorthand/properties provided
+ * * `renderByDefault` - a boolean that indicates if a slot will be rendered even if it's base value is `undefined`.
+ * By default if `props.SLOT_NAME` is `undefined` then `state.SLOT_NAME` becomes `undefined`
+ * and nothing will be rendered, but if `renderByDefault = true` then `state.SLOT_NAME` becomes an object
+ * with the values provided by `options.defaultProps` (or `{}`). This is useful for cases such as providing a default content
+ * in case no shorthand is provided, like the case of the `expandIcon` slot for the `AccordionHeader`
+ */
+export function optional(
+ value: Props | SlotShorthandValue | undefined | null,
+ options: { renderByDefault?: boolean } & SlotOptions,
+): SlotComponentType | undefined {
+ if (value === null || (value === undefined && !options.renderByDefault)) {
+ return undefined;
+ }
+ return always(value, options);
+}
+
+/**
+ * Helper function that converts a slot shorthand or properties to a slot properties object
+ * The main difference between this function and `slot` is that this function does not return the metadata required for a slot to be considered a properly renderable slot, it only converts the value to a slot properties object
+ * @param value - the value of the slot, it can be a slot shorthand or a slot properties object
+ */
+export function resolveShorthand(
+ value: Props | SlotShorthandValue,
+): Props {
+ if (
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ Array.isArray(value) ||
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ React.isValidElement(value)
+ ) {
+ return { children: value } as Props;
+ }
+
+ return value;
+}
diff --git a/packages/react-components/react-utilities/src/compose/types.ts b/packages/react-components/react-utilities/src/compose/types.ts
index 8169dad5cc4e9..4f4ec3f75094b 100644
--- a/packages/react-components/react-utilities/src/compose/types.ts
+++ b/packages/react-components/react-utilities/src/compose/types.ts
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants';
export type SlotRenderFunction = (
Component: React.ElementType,
@@ -233,3 +234,24 @@ export type ForwardRefComponent = ObscureEventName extends keyof Props
export type SlotClassNames = {
[SlotName in keyof Slots]-?: string;
};
+
+/**
+ * A definition of a slot, as a component, very similar to how a React component is declared,
+ * but with some additional metadata that is used to determine how to render the slot.
+ */
+export type SlotComponentType = Props & {
+ /**
+ * **NOTE**: Slot components are not callable.
+ */
+ (props: React.PropsWithChildren<{}>): React.ReactElement | null;
+ /**
+ * @internal
+ */
+ [SLOT_RENDER_FUNCTION_SYMBOL]?: SlotRenderFunction;
+ /**
+ * @internal
+ */
+ [SLOT_ELEMENT_TYPE_SYMBOL]:
+ | React.ComponentType
+ | (Props extends AsIntrinsicElement ? As : keyof JSX.IntrinsicElements);
+};
diff --git a/packages/react-components/react-utilities/src/index.ts b/packages/react-components/react-utilities/src/index.ts
index bb8b54b62d684..ca628ac61ce07 100644
--- a/packages/react-components/react-utilities/src/index.ts
+++ b/packages/react-components/react-utilities/src/index.ts
@@ -1,8 +1,12 @@
export {
+ slot,
+ isSlot,
getSlots,
getSlotsNext,
+ assertSlots,
resolveShorthand,
isResolvedShorthand,
+ SLOT_ELEMENT_TYPE_SYMBOL,
SLOT_RENDER_FUNCTION_SYMBOL,
} from './compose/index';
export type {
@@ -19,6 +23,8 @@ export type {
SlotRenderFunction,
SlotShorthandValue,
UnknownSlotProps,
+ SlotComponentType,
+ SlotOptions,
} from './compose/index';
export {