diff --git a/change/@fluentui-react-jsx-runtime-d2a505fc-6ffc-4679-aa63-5b29dcc83f7b.json b/change/@fluentui-react-jsx-runtime-d2a505fc-6ffc-4679-aa63-5b29dcc83f7b.json new file mode 100644 index 0000000000000..56f2e269d1579 --- /dev/null +++ b/change/@fluentui-react-jsx-runtime-d2a505fc-6ffc-4679-aa63-5b29dcc83f7b.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "chore: update createElement to support new slot methods", + "packageName": "@fluentui/react-jsx-runtime", + "email": "bernardo.sunderhus@gmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-utilities-18c147c2-15a4-428f-89a7-049632ab27fe.json b/change/@fluentui-react-utilities-18c147c2-15a4-428f-89a7-049632ab27fe.json new file mode 100644 index 0000000000000..c1a2fbc452415 --- /dev/null +++ b/change/@fluentui-react-utilities-18c147c2-15a4-428f-89a7-049632ab27fe.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: implement new slot methods (slot and assertSlots)", + "packageName": "@fluentui/react-utilities", + "email": "bernardo.sunderhus@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-jsx-runtime/src/createElement.test.tsx b/packages/react-components/react-jsx-runtime/src/createElement.test.tsx index ee4e3221aa772..75c605fd98131 100644 --- a/packages/react-components/react-jsx-runtime/src/createElement.test.tsx +++ b/packages/react-components/react-jsx-runtime/src/createElement.test.tsx @@ -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(
Hello world
); @@ -166,3 +172,182 @@ describe('createElement', () => { }); }); }); + +describe('createElement with assertSlots', () => { + describe('general behavior tests', () => { + it('handles a string', () => { + const result = render(
Hello world
); + + expect(result.container.firstChild).toMatchInlineSnapshot(` +
+ Hello world +
+ `); + }); + + it('handles an array', () => { + const result = render( +
+ {Array.from({ length: 3 }, (_, i) => ( +
{i}
+ ))} +
, + ); + + expect(result.container.firstChild).toMatchInlineSnapshot(` +
+
+ 0 +
+
+ 1 +
+
+ 2 +
+
+ `); + }); + + it('handles an array of children', () => { + const result = render( +
+
1
+
2
+
, + ); + + expect(result.container.firstChild).toMatchInlineSnapshot(` +
+
+ 1 +
+
+ 2 +
+
+ `); + }); + }); + + describe('custom behavior tests', () => { + it('keeps children from "defaultProps" in a render callback', () => { + type TestComponentSlots = { + someSlot: NonNullable>; + }; + type TestComponentProps = ComponentProps>; + type TestComponentState = ComponentState; + + const TestComponent = (props: TestComponentProps) => { + const state: TestComponentState = { + components: { someSlot: 'div' }, + someSlot: slot.always(props.someSlot, { + elementType: 'div', + defaultProps: { children: 'Default Children', id: 'slot' }, + }), + }; + assertSlots(state); + return ; + }; + + const children = jest.fn().mockImplementation((Component, props) => ( +
+ +
+ )); + const result = render(); + + expect(children).toHaveBeenCalledTimes(1); + expect(children).toHaveBeenCalledWith('div', { children: 'Default Children', id: 'slot' }); + + expect(result.container.firstChild).toMatchInlineSnapshot(` +
+
+ Default Children +
+
+ `); + }); + + it('keeps children from a render template in a render callback', () => { + type TestComponentSlots = { outer: NonNullable>; inner: NonNullable> }; + type TestComponentState = ComponentState; + type TestComponentProps = ComponentProps>; + + 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(state); + return ( + + + + ); + }; + + const children = jest.fn().mockImplementation((Component, props) => ( +
+ +
+ )); + const result = render(); + + 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(` + +
+ Inner children +
+
+ `); + + expect(result.container.firstChild).toMatchInlineSnapshot(` +
+
+
+ Inner children +
+
+
+ `); + }); + + it("should support 'as' property to opt-out of base element type", () => { + type TestComponentSlots = { slot: NonNullable> }; + type TestComponentState = ComponentState; + type TestComponentProps = ComponentProps>; + + const TestComponent = (props: TestComponentProps) => { + const state: TestComponentState = { + components: { slot: 'div' }, + slot: slot.always(props.slot, { elementType: 'div' }), + }; + assertSlots(state); + return ; + }; + + const result = render(); + + expect(result.container.firstChild).toMatchInlineSnapshot(``); + }); + + it.todo("should pass 'as' property to base element that aren't html element"); + }); +}); diff --git a/packages/react-components/react-jsx-runtime/src/createElement.ts b/packages/react-components/react-jsx-runtime/src/createElement.ts index 368920c38b564..a234a7eec2c10 100644 --- a/packages/react-components/react-jsx-runtime/src/createElement.ts +++ b/packages/react-components/react-jsx-runtime/src/createElement.ts @@ -1,38 +1,61 @@ import * as React from 'react'; -import { SlotRenderFunction, UnknownSlotProps, SLOT_RENDER_FUNCTION_SYMBOL } from '@fluentui/react-utilities'; - -type WithMetadata = Props & { - [SLOT_RENDER_FUNCTION_SYMBOL]: SlotRenderFunction; -}; +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 {