From 95c371d19a71c6a3c5bada8543493555776e6198 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 29 Jun 2023 09:34:59 +0000 Subject: [PATCH 1/9] feature: implements new slot methods (slot and assertSlots) --- ...-d2a505fc-6ffc-4679-aa63-5b29dcc83f7b.json | 7 + ...-18c147c2-15a4-428f-89a7-049632ab27fe.json | 7 + .../src/createElement.test.tsx | 197 +++++++++++++++++- .../react-jsx-runtime/src/createElement.ts | 68 ++++-- .../etc/react-utilities.api.md | 43 +++- .../react-utilities/src/compose/constants.ts | 5 +- .../react-utilities/src/compose/getSlots.ts | 19 +- .../src/compose/getSlotsNext.test.tsx | 123 ++++++++--- .../react-utilities/src/compose/index.ts | 1 + .../src/compose/resolveShorthand.test.tsx | 8 +- .../src/compose/resolveShorthand.ts | 40 +--- .../react-utilities/src/compose/slot.ts | 172 +++++++++++++++ .../react-utilities/src/compose/types.ts | 27 +++ .../react-utilities/src/index.ts | 8 +- 14 files changed, 617 insertions(+), 108 deletions(-) create mode 100644 change/@fluentui-react-jsx-runtime-d2a505fc-6ffc-4679-aa63-5b29dcc83f7b.json create mode 100644 change/@fluentui-react-utilities-18c147c2-15a4-428f-89a7-049632ab27fe.json create mode 100644 packages/react-components/react-utilities/src/compose/slot.ts 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 00000000000000..419f452f6e49d0 --- /dev/null +++ b/change/@fluentui-react-jsx-runtime-d2a505fc-6ffc-4679-aa63-5b29dcc83f7b.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "chore: updates createElement accordingly to 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 00000000000000..e36f393fa804d6 --- /dev/null +++ b/change/@fluentui-react-utilities-18c147c2-15a4-428f-89a7-049632ab27fe.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feature: implements 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 ee4e3221aa7726..916814c221520c 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,186 @@ 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(props.someSlot, { + required: true, + 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(props.inner, { required: true, defaultProps: { id: 'inner' }, elementType: 'div' }), + outer: slot(props.outer, { required: true, 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(props.slot, { + required: true, + 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 368920c38b5645..56d6b988f0c4b3 100644 --- a/packages/react-components/react-jsx-runtime/src/createElement.ts +++ b/packages/react-components/react-jsx-runtime/src/createElement.ts @@ -1,38 +1,60 @@ 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, + SlotComponent, + SLOT_COMPONENT_METADATA_SYMBOL, + slot, +} 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( + slot(props, { required: true, elementType: type as React.ComponentType

}), + children, + ); + } + if (isSlot

(type)) { + return createElementFromSlotComponent(type, children); + } + return React.createElement(type, props, ...children); } -function createElementFromRenderFunction

( - type: React.ElementType

, - props: WithMetadata

, +function createElementFromSlotComponent( + slotComponent: SlotComponent, overrideChildren: React.ReactNode[], -): React.ReactElement

| null { - const { [SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction, ...renderProps } = props; +): React.ReactElement | null { + const { [SLOT_COMPONENT_METADATA_SYMBOL]: metadata, as, ...propsWithoutMetadata } = slotComponent; + const props = propsWithoutMetadata as UnknownSlotProps as Props; + const { elementType: baseElementType, renderFunction } = metadata; - 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

; -} + 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 2def011e5a6434..07aaa27921f714 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -10,6 +10,9 @@ import * as React_2 from 'react'; // @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 +107,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 SlotComponent; + // @public export function isTouchEvent(event: TouchOrMouseEvent): event is TouchEvent | React_2.TouchEvent; @@ -154,7 +160,7 @@ export type RefObjectFunction = React_2.RefObject & ((value: T) => void); export function resetIdsForTests(): void; // @public -export const resolveShorthand: ResolveShorthandFunction; +export const resolveShorthand: ResolveShorthandFunction; // @public (undocumented) export type ResolveShorthandFunction = { @@ -211,14 +217,47 @@ export type Slot>; }[AlternateAs] | null : 'Error: First parameter to Slot must not be not a union of types. See documentation of Slot type.'; +// @public +export function slot(value: Props | SlotComponent | SlotShorthandValue | undefined, options: { + required: true; +} & SlotOptions): SlotComponent; + +// @public (undocumented) +export function slot(value: Props | SlotComponent | SlotShorthandValue | undefined | null, options: { + required?: boolean; +} & SlotOptions): SlotComponent | undefined; + +// @public (undocumented) +export function slot(value: SlotComponent, options?: { + required: true; +} & Partial>): SlotComponent; + // @internal -export const SLOT_RENDER_FUNCTION_SYMBOL: unique symbol; +export const SLOT_COMPONENT_METADATA_SYMBOL: unique symbol; // @public export type SlotClassNames = { [SlotName in keyof Slots]-?: string; }; +// @public +export type SlotComponent = Props & { + (props: React_2.PropsWithChildren<{}>): React_2.ReactElement | null; + [SLOT_COMPONENT_METADATA_SYMBOL]: Readonly>; +}; + +// @public +export type SlotComponentMetadata = { + renderFunction?: SlotRenderFunction; + elementType: 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/constants.ts b/packages/react-components/react-utilities/src/compose/constants.ts index f5c1593f9203ae..e20d57f5a5a3aa 100644 --- a/packages/react-components/react-utilities/src/compose/constants.ts +++ b/packages/react-components/react-utilities/src/compose/constants.ts @@ -1,5 +1,6 @@ /** * @internal - * Internal reference for the render function + * Internal value that indicates a given component is a slot component + * It is used to hold internal metadata about the slot component */ -export const SLOT_RENDER_FUNCTION_SYMBOL = Symbol('fui.slotRenderFunction'); +export const SLOT_COMPONENT_METADATA_SYMBOL = Symbol('fui.slotComponentMetadata'); diff --git a/packages/react-components/react-utilities/src/compose/getSlots.ts b/packages/react-components/react-utilities/src/compose/getSlots.ts index 8c24dd77422ef1..584b3a1dd48302 100644 --- a/packages/react-components/react-utilities/src/compose/getSlots.ts +++ b/packages/react-components/react-utilities/src/compose/getSlots.ts @@ -1,7 +1,6 @@ import * as React from 'react'; - import { omit } from '../utils/omit'; -import { SLOT_RENDER_FUNCTION_SYMBOL } from './constants'; +import { SLOT_COMPONENT_METADATA_SYMBOL } from './constants'; import type { AsIntrinsicElement, ComponentState, @@ -11,6 +10,7 @@ import type { UnionToIntersection, UnknownSlotProps, } from './types'; +import { isSlot } from './slot'; 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 metadata = isSlot(props) ? props[SLOT_COMPONENT_METADATA_SYMBOL] : undefined; const slot = ( state.components?.[slotName] === undefined || typeof state.components[slotName] === 'string' @@ -89,8 +88,8 @@ function getSlot( : state.components[slotName] ) as React.ElementType; - if (renderFunction || typeof children === 'function') { - const render = renderFunction || (children as SlotRenderFunction); + if (metadata?.renderFunction || typeof children === 'function') { + const render = (metadata?.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 a66534b2786982..00ec7ed45c9c24 100644 --- a/packages/react-components/react-utilities/src/compose/getSlotsNext.test.tsx +++ b/packages/react-components/react-utilities/src/compose/getSlotsNext.test.tsx @@ -1,8 +1,13 @@ import * as React from 'react'; import { getSlotsNext } from './getSlotsNext'; -import type { Slot } from './types'; +import type { ExtractSlotProps, Slot, SlotComponent, UnknownSlotProps } from './types'; import { resolveShorthand } from './resolveShorthand'; -import { SLOT_RENDER_FUNCTION_SYMBOL } from './constants'; +import { SLOT_COMPONENT_METADATA_SYMBOL } from './constants'; + +const resolveShorthandMock = (props: Props): SlotComponent => { + // casting is required here as SlotComponent is a callable + return { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, ...props } as SlotComponent; +}; describe('getSlotsNext', () => { type FooProps = { id?: string; children?: React.ReactNode }; @@ -10,17 +15,27 @@ describe('getSlotsNext', () => { 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: {} }, + slotProps: { root: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } } }, }); }); 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: {} }, + slotProps: { root: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } } }, }); }); @@ -28,18 +43,26 @@ 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' } }, + slotProps: { root: { id: 'id', href: 'href', [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } } }, }); }); 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' } }, + slotProps: { root: { id: 'id', href: 'href', [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } } }, }); }); @@ -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: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + icon: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + }, }); }); @@ -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: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + icon: { id: 'id', children: 'children', [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + }, }); }); @@ -84,13 +113,21 @@ 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: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + icon: { + id: 'id', + href: 'href', + children: 'children', + [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, + }, + }, }); }); @@ -102,12 +139,20 @@ 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: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + icon: { + id: 'id', + href: 'href', + children: 'children', + [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, + }, + }, }); }); @@ -120,30 +165,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: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + icon: { id: 'bar', children: renderIcon, [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + }, }); }); 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: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + icon: { + children: undefined, + id: 'bar', + [SLOT_COMPONENT_METADATA_SYMBOL]: { renderFunction, elementType: 'div' }, + }, + }, }); }); @@ -155,14 +210,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: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + input: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + 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 fec1ddf85added..fc39aa1a92ea96 100644 --- a/packages/react-components/react-utilities/src/compose/index.ts +++ b/packages/react-components/react-utilities/src/compose/index.ts @@ -4,3 +4,4 @@ export * from './types'; export * from './isResolvedShorthand'; export * from './constants'; export * from './getSlotsNext'; +export * from './slot'; 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 4d564af2496234..24ae758d6bc5b0 100644 --- a/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx +++ b/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { resolveShorthand } from './resolveShorthand'; import type { Slot } from './types'; +import { SLOT_COMPONENT_METADATA_SYMBOL } from './constants'; type TestProps = { slotA?: Slot<'div'>; @@ -17,6 +18,7 @@ describe('resolveShorthand', () => { expect(resolvedProps).toEqual({ children: 'hello', + [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, }); }); @@ -26,6 +28,7 @@ describe('resolveShorthand', () => { expect(resolvedProps).toEqual({ children:

hello
, + [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, }); }); @@ -35,6 +38,7 @@ describe('resolveShorthand', () => { expect(resolvedProps).toEqual({ children: 42, + [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, }); }); @@ -43,7 +47,7 @@ describe('resolveShorthand', () => { const props: TestProps = { slotA }; const resolvedProps = resolveShorthand(props.slotA); - expect(resolvedProps).toEqual(slotA); + expect(resolvedProps).toEqual({ [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }); expect(resolvedProps).not.toBe(slotA); }); @@ -65,6 +69,6 @@ describe('resolveShorthand', () => { const props: TestProps = { slotA: undefined }; const resolvedProps = resolveShorthand(props.slotA, { required: true }); - expect(resolvedProps).toEqual({}); + expect(resolvedProps).toEqual({ [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }); }); }); diff --git a/packages/react-components/react-utilities/src/compose/resolveShorthand.ts b/packages/react-components/react-utilities/src/compose/resolveShorthand.ts index 76b109240de9f9..3a3a9d94d40a1b 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 { slot } from './slot'; +import type { SlotShorthandValue, UnknownSlotProps } from './types'; export type ResolveShorthandOptions = Required extends true ? { required: true; defaultProps?: Props } @@ -19,32 +18,9 @@ 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(value, { + ...options, + // elementType is required here although it'll be ignored by getSlotsNext + elementType: 'div', + }); 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 00000000000000..cd3e59db4e5bb7 --- /dev/null +++ b/packages/react-components/react-utilities/src/compose/slot.ts @@ -0,0 +1,172 @@ +import { isValidElement } from 'react'; +import type { + AsIntrinsicElement, + ComponentState, + ExtractSlotProps, + SlotComponent, + SlotComponentMetadata, + SlotPropsRecord, + SlotRenderFunction, + SlotShorthandValue, + UnknownSlotProps, +} from './types'; +import { SLOT_COMPONENT_METADATA_SYMBOL } from './constants'; +import * as React from 'react'; + +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 options - optional 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 + * * `required` - 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 `required = 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` + * + * @example of a required nullable slot + * ```tsx + * // AccordionHeader.types.ts + * type AccordionHeaderSlots = { + * expandIcon?: Slot<'span'> + * } + * // useAccordionHeader.ts + * const state = { + * expandIcon: slot(expandIconShorthand, { + * required: true, + * elementType: 'span', + * defaultProps: { children: , 'aria-hidden': true} + * }) + * } + * // renderAccordionHeader + * state.expandIcon && + * ``` + */ +export function slot( + value: Props | SlotComponent | SlotShorthandValue | undefined, + options: { required: true } & SlotOptions, +): SlotComponent; +export function slot( + value: Props | SlotComponent | SlotShorthandValue | undefined | null, + options: { required?: boolean } & SlotOptions, +): SlotComponent | undefined; +export function slot( + value: SlotComponent, + options?: { required: true } & Partial>, +): SlotComponent; +export function slot( + value: Props | SlotComponent | SlotShorthandValue | undefined | null, + options: { required?: boolean } & Partial> = {}, +): SlotComponent | undefined { + const { required = false, defaultProps, elementType } = options; + + if (value === null || (value === undefined && !required)) { + return undefined; + } + + let metadata: SlotComponentMetadata; + if (isSlot(value)) { + metadata = value[SLOT_COMPONENT_METADATA_SYMBOL]; + if (elementType !== undefined) { + metadata.elementType = elementType; + } + } else if (elementType !== undefined) { + metadata = { elementType }; + } else { + throw new Error("[react-utilities]: slot options.elementType is required when value isn't a slot itself"); + } + + /** + * Casting is required here as SlotComponent is a function, not an object. + * Although SlotComponent 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, + [SLOT_COMPONENT_METADATA_SYMBOL]: metadata, + } as SlotComponent; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (typeof value === 'string' || typeof value === 'number' || Array.isArray(value) || isValidElement(value)) { + propsWithMetadata.children = value; + } else if (typeof value === 'object') { + Object.assign(propsWithMetadata, value); + if (typeof value.children === 'function') { + metadata.renderFunction = value.children as SlotRenderFunction; + propsWithMetadata.children = defaultProps?.children; + } + } + + return propsWithMetadata; +} + +/** + * 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 SlotComponent { + return Boolean((element as {} | undefined)?.hasOwnProperty(SLOT_COMPONENT_METADATA_SYMBOL)); +} + +type SlotComponents = { + [K in keyof Slots]: SlotComponent>; +}; + +/** + * @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.name}`, + ); + } else { + const metadata = slotElement[SLOT_COMPONENT_METADATA_SYMBOL]; + if (metadata.elementType !== typedState.components[slotName]) { + throw new TypeError( + `${assertSlots.name} error: state.${slotName} element type differs from state.components.${slotName}, ${metadata.elementType} !== ${typedState.components[slotName]}. \n` + + `Be sure to create slots properly by using ${slot.name} with the correct elementType`, + ); + } + } + } + } +} diff --git a/packages/react-components/react-utilities/src/compose/types.ts b/packages/react-components/react-utilities/src/compose/types.ts index 8169dad5cc4e95..8229024502aed6 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_COMPONENT_METADATA_SYMBOL } from './constants'; export type SlotRenderFunction = ( Component: React.ElementType, @@ -233,3 +234,29 @@ 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 SlotComponent = Props & { + /** + * **NOTE**: Slot components are not callable. + */ + (props: React.PropsWithChildren<{}>): React.ReactElement | null; + /** + * @internal + */ + [SLOT_COMPONENT_METADATA_SYMBOL]: Readonly>; +}; + +/** + * Metadata stored on a slot component to describe its behavior. + * This is used to determine how to render the slot. + */ +export type SlotComponentMetadata = { + renderFunction?: SlotRenderFunction; + elementType: + | 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 bb8b54b62d6848..aa61cdbec7c0a1 100644 --- a/packages/react-components/react-utilities/src/index.ts +++ b/packages/react-components/react-utilities/src/index.ts @@ -1,9 +1,12 @@ export { + slot, + isSlot, getSlots, getSlotsNext, + assertSlots, resolveShorthand, isResolvedShorthand, - SLOT_RENDER_FUNCTION_SYMBOL, + SLOT_COMPONENT_METADATA_SYMBOL, } from './compose/index'; export type { ExtractSlotProps, @@ -19,6 +22,9 @@ export type { SlotRenderFunction, SlotShorthandValue, UnknownSlotProps, + SlotComponent, + SlotComponentMetadata, + SlotOptions, } from './compose/index'; export { From b313bab7adc1e2af7d09c0a93a659c60473e6823 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Tue, 4 Jul 2023 14:40:44 +0200 Subject: [PATCH 2/9] Update change/@fluentui-react-jsx-runtime-d2a505fc-6ffc-4679-aa63-5b29dcc83f7b.json Co-authored-by: Oleksandr Fediashov --- ...-react-jsx-runtime-d2a505fc-6ffc-4679-aa63-5b29dcc83f7b.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 419f452f6e49d0..56f2e269d1579f 100644 --- a/change/@fluentui-react-jsx-runtime-d2a505fc-6ffc-4679-aa63-5b29dcc83f7b.json +++ b/change/@fluentui-react-jsx-runtime-d2a505fc-6ffc-4679-aa63-5b29dcc83f7b.json @@ -1,6 +1,6 @@ { "type": "prerelease", - "comment": "chore: updates createElement accordingly to new slot methods", + "comment": "chore: update createElement to support new slot methods", "packageName": "@fluentui/react-jsx-runtime", "email": "bernardo.sunderhus@gmail.com", "dependentChangeType": "patch" From 7e6ae249de53bffa48c50e2e5a4a16c402cb3f46 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Tue, 4 Jul 2023 14:40:54 +0200 Subject: [PATCH 3/9] Update change/@fluentui-react-utilities-18c147c2-15a4-428f-89a7-049632ab27fe.json Co-authored-by: Oleksandr Fediashov --- ...ui-react-utilities-18c147c2-15a4-428f-89a7-049632ab27fe.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/change/@fluentui-react-utilities-18c147c2-15a4-428f-89a7-049632ab27fe.json b/change/@fluentui-react-utilities-18c147c2-15a4-428f-89a7-049632ab27fe.json index e36f393fa804d6..c1a2fbc4524157 100644 --- a/change/@fluentui-react-utilities-18c147c2-15a4-428f-89a7-049632ab27fe.json +++ b/change/@fluentui-react-utilities-18c147c2-15a4-428f-89a7-049632ab27fe.json @@ -1,6 +1,6 @@ { "type": "minor", - "comment": "feature: implements new slot methods (slot and assertSlots)", + "comment": "feat: implement new slot methods (slot and assertSlots)", "packageName": "@fluentui/react-utilities", "email": "bernardo.sunderhus@gmail.com", "dependentChangeType": "patch" From 78e18845c882df50590f90fec3a28c0d44b20165 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Wed, 5 Jul 2023 11:51:58 +0000 Subject: [PATCH 4/9] chore: adds tests to slot, isSlot and assertSlots --- .../etc/react-utilities.api.md | 3 + .../src/compose/assertSlots.test.tsx | 53 +++++++++++++ .../src/compose/assertSlots.ts | 57 ++++++++++++++ .../react-utilities/src/compose/constants.ts | 7 ++ .../react-utilities/src/compose/getSlots.ts | 2 +- .../react-utilities/src/compose/index.ts | 2 + .../src/compose/isSlot.test.tsx | 24 +++--- .../react-utilities/src/compose/isSlot.ts | 10 +++ .../react-utilities/src/compose/slot.test.tsx | 77 +++++++++++++++++++ .../react-utilities/src/compose/slot.ts | 77 +++---------------- .../react-utilities/src/index.ts | 2 + 11 files changed, 236 insertions(+), 78 deletions(-) create mode 100644 packages/react-components/react-utilities/src/compose/assertSlots.test.tsx create mode 100644 packages/react-components/react-utilities/src/compose/assertSlots.ts create mode 100644 packages/react-components/react-utilities/src/compose/isSlot.ts create mode 100644 packages/react-components/react-utilities/src/compose/slot.test.tsx 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 07aaa27921f714..1e4db8f582ea6e 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -235,6 +235,9 @@ export function slot(value: SlotComponent // @internal export const SLOT_COMPONENT_METADATA_SYMBOL: unique symbol; +// @internal @deprecated +export const SLOT_RENDER_FUNCTION_SYMBOL: unique symbol; + // @public export type SlotClassNames = { [SlotName in keyof Slots]-?: string; 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 00000000000000..849eccbaee6724 --- /dev/null +++ b/packages/react-components/react-utilities/src/compose/assertSlots.test.tsx @@ -0,0 +1,53 @@ +import { assertSlots } from './assertSlots'; +import { 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(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(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 00000000000000..e8e66d0e47a9d3 --- /dev/null +++ b/packages/react-components/react-utilities/src/compose/assertSlots.ts @@ -0,0 +1,57 @@ +import { SLOT_COMPONENT_METADATA_SYMBOL } from './constants'; +import { isSlot } from './isSlot'; +import { slot } from './slot'; +import { ComponentState, ExtractSlotProps, SlotComponent, SlotPropsRecord } from './types'; + +type SlotComponents = { + [K in keyof Slots]: SlotComponent>; +}; + +/** + * @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.name}`, + ); + } else { + const metadata = slotElement[SLOT_COMPONENT_METADATA_SYMBOL]; + if (metadata.elementType !== typedState.components[slotName]) { + throw new TypeError( + `${assertSlots.name} error: state.${slotName} element type differs from state.components.${slotName}, ${metadata.elementType} !== ${typedState.components[slotName]}. \n` + + `Be sure to create slots properly by using ${slot.name} 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 e20d57f5a5a3aa..29785f8367d99a 100644 --- a/packages/react-components/react-utilities/src/compose/constants.ts +++ b/packages/react-components/react-utilities/src/compose/constants.ts @@ -4,3 +4,10 @@ * It is used to hold internal metadata about the slot component */ export const SLOT_COMPONENT_METADATA_SYMBOL = Symbol('fui.slotComponentMetadata'); + +/** + * @internal + * Internal reference for the render function + * @deprecated `SLOT_COMPONENT_METADATA_SYMBOL` is used instead + */ +export const SLOT_RENDER_FUNCTION_SYMBOL = Symbol('fui.slotRenderFunction'); diff --git a/packages/react-components/react-utilities/src/compose/getSlots.ts b/packages/react-components/react-utilities/src/compose/getSlots.ts index 584b3a1dd48302..95332807108c4b 100644 --- a/packages/react-components/react-utilities/src/compose/getSlots.ts +++ b/packages/react-components/react-utilities/src/compose/getSlots.ts @@ -10,7 +10,7 @@ import type { UnionToIntersection, UnknownSlotProps, } from './types'; -import { isSlot } from './slot'; +import { isSlot } from './isSlot'; export type Slots = { [K in keyof S]: ExtractSlotProps extends AsIntrinsicElement diff --git a/packages/react-components/react-utilities/src/compose/index.ts b/packages/react-components/react-utilities/src/compose/index.ts index fc39aa1a92ea96..e2c5540efcc6ad 100644 --- a/packages/react-components/react-utilities/src/compose/index.ts +++ b/packages/react-components/react-utilities/src/compose/index.ts @@ -5,3 +5,5 @@ export * from './isResolvedShorthand'; export * from './constants'; export * from './getSlotsNext'; export * from './slot'; +export * from './isSlot'; +export * from './assertSlots'; 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 2a4027266f9126..42b0aa18027fb7 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 { slot } from './slot'; -describe('isResolvedShorthand', () => { +describe('isSlot', () => { it('resolves a string', () => { - expect(isResolvedShorthand('hello')).toEqual(false); + expect(isSlot('hello')).toEqual(false); }); it('resolves a JSX element', () => { - expect(isResolvedShorthand(
hello
)).toEqual(false); + expect(isSlot(
hello
)).toEqual(false); }); it('resolves a number', () => { - expect(isResolvedShorthand(42)).toEqual(false); + expect(isSlot(42)).toEqual(false); }); it('resolves null', () => { - expect(isResolvedShorthand(null)).toEqual(false); + expect(isSlot(null)).toEqual(false); }); it('resolves undefined', () => { - expect(isResolvedShorthand(undefined)).toEqual(false); + expect(isSlot(undefined)).toEqual(false); }); it('resolves object', () => { - expect(isResolvedShorthand({})).toEqual(true); + expect(isSlot({})).toEqual(false); }); + it('resolves array', () => { - expect(isResolvedShorthand(['1', 2])).toEqual(false); + expect(isSlot(['1', 2])).toEqual(false); + }); + + it('resolves actual slot', () => { + expect(isSlot(slot({}, { 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 00000000000000..9898c07d45f0b6 --- /dev/null +++ b/packages/react-components/react-utilities/src/compose/isSlot.ts @@ -0,0 +1,10 @@ +import { SLOT_COMPONENT_METADATA_SYMBOL } from './constants'; +import { SlotComponent } 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 SlotComponent { + return Boolean((element as {} | undefined)?.hasOwnProperty(SLOT_COMPONENT_METADATA_SYMBOL)); +} 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 00000000000000..a39fee8eacd048 --- /dev/null +++ b/packages/react-components/react-utilities/src/compose/slot.test.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import { slot } from './slot'; +import type { ComponentProps, Slot } from './types'; +import { SLOT_COMPONENT_METADATA_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(props.slotA, { elementType: 'div' }); + + expect(resolvedProps).toEqual({ + children: 'hello', + [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, + }); + }); + + it('resolves a JSX element', () => { + const props: TestProps = { slotA:
hello
}; + const resolvedProps = slot(props.slotA, { elementType: 'div' }); + + expect(resolvedProps).toEqual({ + children:
hello
, + [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, + }); + }); + + it('resolves a number', () => { + const props: TestProps = { slotA: 42 }; + const resolvedProps = slot(props.slotA, { elementType: 'div' }); + + expect(resolvedProps).toEqual({ + children: 42, + [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, + }); + }); + + it('resolves an object as its copy', () => { + const slotA = {}; + const props: TestProps = { slotA }; + const resolvedProps = slot(props.slotA, { elementType: 'div' }); + + expect(resolvedProps).toEqual({ [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }); + expect(resolvedProps).not.toBe(slotA); + }); + + it('resolves "null" without creating a child element', () => { + const props: TestProps = { slotA: null, slotB: null }; + + expect(slot(props.slotA, { elementType: 'div' })).toEqual(undefined); + expect(slot(null, { required: true, elementType: 'div' })).toEqual(undefined); + }); + + it('resolves undefined without creating a child element', () => { + const props: TestProps = { slotA: undefined }; + const resolvedProps = slot(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(props.slotA, { required: true, elementType: 'div' }); + + expect(resolvedProps).toEqual({ [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }); + }); +}); diff --git a/packages/react-components/react-utilities/src/compose/slot.ts b/packages/react-components/react-utilities/src/compose/slot.ts index cd3e59db4e5bb7..712211ace74ab5 100644 --- a/packages/react-components/react-utilities/src/compose/slot.ts +++ b/packages/react-components/react-utilities/src/compose/slot.ts @@ -1,17 +1,14 @@ -import { isValidElement } from 'react'; import type { AsIntrinsicElement, - ComponentState, - ExtractSlotProps, SlotComponent, SlotComponentMetadata, - SlotPropsRecord, SlotRenderFunction, SlotShorthandValue, UnknownSlotProps, } from './types'; import { SLOT_COMPONENT_METADATA_SYMBOL } from './constants'; import * as React from 'react'; +import { isSlot } from './isSlot'; export type SlotOptions = { elementType: @@ -22,7 +19,7 @@ export type SlotOptions = { /** * 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 - optional 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'` @@ -96,8 +93,13 @@ export function slot( [SLOT_COMPONENT_METADATA_SYMBOL]: metadata, } as SlotComponent; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (typeof value === 'string' || typeof value === 'number' || Array.isArray(value) || isValidElement(value)) { + if ( + typeof value === 'string' || + typeof value === 'number' || + Array.isArray(value) || + // eslint-disable-next-line @typescript-eslint/no-explicit-any + React.isValidElement(value) + ) { propsWithMetadata.children = value; } else if (typeof value === 'object') { Object.assign(propsWithMetadata, value); @@ -109,64 +111,3 @@ export function slot( return propsWithMetadata; } - -/** - * 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 SlotComponent { - return Boolean((element as {} | undefined)?.hasOwnProperty(SLOT_COMPONENT_METADATA_SYMBOL)); -} - -type SlotComponents = { - [K in keyof Slots]: SlotComponent>; -}; - -/** - * @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.name}`, - ); - } else { - const metadata = slotElement[SLOT_COMPONENT_METADATA_SYMBOL]; - if (metadata.elementType !== typedState.components[slotName]) { - throw new TypeError( - `${assertSlots.name} error: state.${slotName} element type differs from state.components.${slotName}, ${metadata.elementType} !== ${typedState.components[slotName]}. \n` + - `Be sure to create slots properly by using ${slot.name} with the correct elementType`, - ); - } - } - } - } -} diff --git a/packages/react-components/react-utilities/src/index.ts b/packages/react-components/react-utilities/src/index.ts index aa61cdbec7c0a1..0f7f161de63654 100644 --- a/packages/react-components/react-utilities/src/index.ts +++ b/packages/react-components/react-utilities/src/index.ts @@ -7,6 +7,8 @@ export { resolveShorthand, isResolvedShorthand, SLOT_COMPONENT_METADATA_SYMBOL, + // eslint-disable-next-line deprecation/deprecation + SLOT_RENDER_FUNCTION_SYMBOL, } from './compose/index'; export type { ExtractSlotProps, From e8f9e582122be5c0f0f2d13f82a5054cd63260e2 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Fri, 7 Jul 2023 10:24:24 +0000 Subject: [PATCH 5/9] chore: updates tests & ensures it doesn't throw in production --- .../react-utilities/src/compose/isSlot.test.tsx | 16 ++++++++-------- .../react-utilities/src/compose/slot.test.tsx | 12 ++++++++++++ .../react-utilities/src/compose/slot.ts | 4 +++- 3 files changed, 23 insertions(+), 9 deletions(-) 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 42b0aa18027fb7..55e173e3c9d3fe 100644 --- a/packages/react-components/react-utilities/src/compose/isSlot.test.tsx +++ b/packages/react-components/react-utilities/src/compose/isSlot.test.tsx @@ -3,35 +3,35 @@ import { isSlot } from './isSlot'; import { slot } from './slot'; describe('isSlot', () => { - it('resolves a string', () => { + it('handles a string', () => { expect(isSlot('hello')).toEqual(false); }); - it('resolves a JSX element', () => { + it('handles a JSX element', () => { expect(isSlot(
hello
)).toEqual(false); }); - it('resolves a number', () => { + it('handles a number', () => { expect(isSlot(42)).toEqual(false); }); - it('resolves null', () => { + it('handles null', () => { expect(isSlot(null)).toEqual(false); }); - it('resolves undefined', () => { + it('handles undefined', () => { expect(isSlot(undefined)).toEqual(false); }); - it('resolves object', () => { + it('handles object', () => { expect(isSlot({})).toEqual(false); }); - it('resolves array', () => { + it('handles array', () => { expect(isSlot(['1', 2])).toEqual(false); }); - it('resolves actual slot', () => { + it('handles actual slot', () => { expect(isSlot(slot({}, { elementType: 'div' }))).toEqual(true); }); }); diff --git a/packages/react-components/react-utilities/src/compose/slot.test.tsx b/packages/react-components/react-utilities/src/compose/slot.test.tsx index a39fee8eacd048..d184ea0c970d00 100644 --- a/packages/react-components/react-utilities/src/compose/slot.test.tsx +++ b/packages/react-components/react-utilities/src/compose/slot.test.tsx @@ -74,4 +74,16 @@ describe('slot', () => { expect(resolvedProps).toEqual({ [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }); }); + + it('handles render functions', () => { + const props: TestProps = { slotA: { children: () => null } }; + const resolvedProps = slot(props.slotA, { elementType: 'div' }); + + expect(resolvedProps).toEqual({ + [SLOT_COMPONENT_METADATA_SYMBOL]: { + elementType: 'div', + renderFunction: 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 index 712211ace74ab5..f01c7e1ff7c491 100644 --- a/packages/react-components/react-utilities/src/compose/slot.ts +++ b/packages/react-components/react-utilities/src/compose/slot.ts @@ -78,8 +78,10 @@ export function slot( } } else if (elementType !== undefined) { metadata = { elementType }; - } else { + } else if (process.env.NODE_ENV !== 'production') { throw new Error("[react-utilities]: slot options.elementType is required when value isn't a slot itself"); + } else { + metadata = { elementType: 'div' as React.ElementType as React.ComponentType }; } /** From 8c1cdb7cda25b4b11a02df5fcffe102ce0211bda Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 13 Jul 2023 15:03:01 +0000 Subject: [PATCH 6/9] chore: removes metadata symbol --- .../react-jsx-runtime/src/createElement.ts | 16 ++++---- .../etc/react-utilities.api.md | 13 ++---- .../src/compose/assertSlots.ts | 8 ++-- .../react-utilities/src/compose/constants.ts | 9 ++-- .../react-utilities/src/compose/getSlots.ts | 8 ++-- .../src/compose/getSlotsNext.test.tsx | 41 ++++++++++--------- .../react-utilities/src/compose/isSlot.ts | 4 +- .../src/compose/resolveShorthand.test.tsx | 12 +++--- .../react-utilities/src/compose/slot.test.tsx | 18 ++++---- .../react-utilities/src/compose/slot.ts | 40 ++++++++++-------- .../react-utilities/src/compose/types.ts | 17 +++----- .../react-utilities/src/index.ts | 4 +- 12 files changed, 92 insertions(+), 98 deletions(-) diff --git a/packages/react-components/react-jsx-runtime/src/createElement.ts b/packages/react-components/react-jsx-runtime/src/createElement.ts index 56d6b988f0c4b3..ea2aa78ce02ba6 100644 --- a/packages/react-components/react-jsx-runtime/src/createElement.ts +++ b/packages/react-components/react-jsx-runtime/src/createElement.ts @@ -3,7 +3,8 @@ import { UnknownSlotProps, isSlot, SlotComponent, - SLOT_COMPONENT_METADATA_SYMBOL, + SLOT_ELEMENT_TYPE_SYMBOL, + SLOT_RENDER_FUNCTION_SYMBOL, slot, } from '@fluentui/react-utilities'; @@ -31,14 +32,15 @@ function createElementFromSlotComponent( slotComponent: SlotComponent, overrideChildren: React.ReactNode[], ): React.ReactElement | null { - const { [SLOT_COMPONENT_METADATA_SYMBOL]: metadata, as, ...propsWithoutMetadata } = slotComponent; + const { + as, + [SLOT_ELEMENT_TYPE_SYMBOL]: baseElementType, + [SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction, + ...propsWithoutMetadata + } = slotComponent; const props = propsWithoutMetadata as UnknownSlotProps as Props; - const { elementType: baseElementType, renderFunction } = metadata; - const elementType = - baseElementType === undefined || typeof baseElementType === 'string' - ? as ?? baseElementType ?? 'div' - : baseElementType; + const elementType = typeof baseElementType === 'string' ? as ?? baseElementType : baseElementType; if (typeof elementType !== 'string' && as) { props.as = as; 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 1e4db8f582ea6e..d073a1e997c18e 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -233,9 +233,9 @@ export function slot(value: SlotComponent } & Partial>): SlotComponent; // @internal -export const SLOT_COMPONENT_METADATA_SYMBOL: unique symbol; +export const SLOT_ELEMENT_TYPE_SYMBOL: unique symbol; -// @internal @deprecated +// @internal export const SLOT_RENDER_FUNCTION_SYMBOL: unique symbol; // @public @@ -246,13 +246,8 @@ export type SlotClassNames = { // @public export type SlotComponent = Props & { (props: React_2.PropsWithChildren<{}>): React_2.ReactElement | null; - [SLOT_COMPONENT_METADATA_SYMBOL]: Readonly>; -}; - -// @public -export type SlotComponentMetadata = { - renderFunction?: SlotRenderFunction; - elementType: React_2.ComponentType | (Props extends AsIntrinsicElement ? As : keyof JSX.IntrinsicElements); + [SLOT_RENDER_FUNCTION_SYMBOL]?: SlotRenderFunction; + [SLOT_ELEMENT_TYPE_SYMBOL]: React_2.ComponentType | (Props extends AsIntrinsicElement ? As : keyof JSX.IntrinsicElements); }; // @public (undocumented) diff --git a/packages/react-components/react-utilities/src/compose/assertSlots.ts b/packages/react-components/react-utilities/src/compose/assertSlots.ts index e8e66d0e47a9d3..a864b2eae99bc4 100644 --- a/packages/react-components/react-utilities/src/compose/assertSlots.ts +++ b/packages/react-components/react-utilities/src/compose/assertSlots.ts @@ -1,4 +1,4 @@ -import { SLOT_COMPONENT_METADATA_SYMBOL } from './constants'; +import { SLOT_ELEMENT_TYPE_SYMBOL } from './constants'; import { isSlot } from './isSlot'; import { slot } from './slot'; import { ComponentState, ExtractSlotProps, SlotComponent, SlotPropsRecord } from './types'; @@ -44,10 +44,10 @@ export function assertSlots(state: unknown): asse `Be sure to create slots properly by using ${slot.name}`, ); } else { - const metadata = slotElement[SLOT_COMPONENT_METADATA_SYMBOL]; - if (metadata.elementType !== typedState.components[slotName]) { + 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}, ${metadata.elementType} !== ${typedState.components[slotName]}. \n` + + `${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.name} 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 29785f8367d99a..29a010ba8fd8ee 100644 --- a/packages/react-components/react-utilities/src/compose/constants.ts +++ b/packages/react-components/react-utilities/src/compose/constants.ts @@ -1,13 +1,10 @@ /** * @internal - * Internal value that indicates a given component is a slot component - * It is used to hold internal metadata about the slot component + * Internal reference for the render function */ -export const SLOT_COMPONENT_METADATA_SYMBOL = Symbol('fui.slotComponentMetadata'); - +export const SLOT_RENDER_FUNCTION_SYMBOL = Symbol('fui.slotRenderFunction'); /** * @internal * Internal reference for the render function - * @deprecated `SLOT_COMPONENT_METADATA_SYMBOL` is used instead */ -export const SLOT_RENDER_FUNCTION_SYMBOL = Symbol('fui.slotRenderFunction'); +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 95332807108c4b..dbaf5e40364b5d 100644 --- a/packages/react-components/react-utilities/src/compose/getSlots.ts +++ b/packages/react-components/react-utilities/src/compose/getSlots.ts @@ -1,6 +1,5 @@ import * as React from 'react'; import { omit } from '../utils/omit'; -import { SLOT_COMPONENT_METADATA_SYMBOL } from './constants'; import type { AsIntrinsicElement, ComponentState, @@ -11,6 +10,7 @@ import type { UnknownSlotProps, } from './types'; import { isSlot } from './isSlot'; +import { SLOT_RENDER_FUNCTION_SYMBOL } from './constants'; export type Slots = { [K in keyof S]: ExtractSlotProps extends AsIntrinsicElement @@ -80,7 +80,7 @@ function getSlot( // TS Error: Property 'as' does not exist on type 'UnknownSlotProps | undefined'.ts(2339) const { as: asProp, children, ...rest } = props as NonUndefined; - const metadata = isSlot(props) ? props[SLOT_COMPONENT_METADATA_SYMBOL] : undefined; + const renderFunction = isSlot(props) ? props[SLOT_RENDER_FUNCTION_SYMBOL] : undefined; const slot = ( state.components?.[slotName] === undefined || typeof state.components[slotName] === 'string' @@ -88,8 +88,8 @@ function getSlot( : state.components[slotName] ) as React.ElementType; - if (metadata?.renderFunction || typeof children === 'function') { - const render = (metadata?.renderFunction || children) as SlotRenderFunction; + if (renderFunction || typeof children === 'function') { + 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 00ec7ed45c9c24..744c624ba3fa85 100644 --- a/packages/react-components/react-utilities/src/compose/getSlotsNext.test.tsx +++ b/packages/react-components/react-utilities/src/compose/getSlotsNext.test.tsx @@ -2,11 +2,11 @@ import * as React from 'react'; import { getSlotsNext } from './getSlotsNext'; import type { ExtractSlotProps, Slot, SlotComponent, UnknownSlotProps } from './types'; import { resolveShorthand } from './resolveShorthand'; -import { SLOT_COMPONENT_METADATA_SYMBOL } from './constants'; +import { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants'; const resolveShorthandMock = (props: Props): SlotComponent => { // casting is required here as SlotComponent is a callable - return { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, ...props } as SlotComponent; + return { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', ...props } as SlotComponent; }; describe('getSlotsNext', () => { @@ -22,7 +22,7 @@ describe('getSlotsNext', () => { }), ).toEqual({ slots: { root: 'div' }, - slotProps: { root: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } } }, + slotProps: { root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' } }, }); }); @@ -35,7 +35,7 @@ describe('getSlotsNext', () => { }), ).toEqual({ slots: { root: 'span' }, - slotProps: { root: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } } }, + slotProps: { root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' } }, }); }); @@ -49,7 +49,7 @@ describe('getSlotsNext', () => { }), ).toEqual({ slots: { root: 'button' }, - slotProps: { root: { id: 'id', href: 'href', [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } } }, + slotProps: { root: { id: 'id', href: 'href', [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' } }, }); }); @@ -62,7 +62,7 @@ describe('getSlotsNext', () => { }), ).toEqual({ slots: { root: 'a' }, - slotProps: { root: { id: 'id', href: 'href', [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } } }, + slotProps: { root: { id: 'id', href: 'href', [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' } }, }); }); @@ -80,8 +80,8 @@ describe('getSlotsNext', () => { ).toEqual({ slots: { root: 'div', icon: Foo }, slotProps: { - root: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, - icon: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, + icon: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, }, }); }); @@ -100,8 +100,8 @@ describe('getSlotsNext', () => { ).toEqual({ slots: { root: 'span', icon: 'button' }, slotProps: { - root: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, - icon: { id: 'id', children: 'children', [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, + icon: { id: 'id', children: 'children', [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, }, }); }); @@ -120,12 +120,12 @@ describe('getSlotsNext', () => { ).toEqual({ slots: { root: 'div', icon: 'a' }, slotProps: { - root: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, icon: { id: 'id', href: 'href', children: 'children', - [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, + [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', }, }, }); @@ -145,12 +145,12 @@ describe('getSlotsNext', () => { ).toEqual({ slots: { root: 'div', icon: Foo }, slotProps: { - root: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, icon: { id: 'id', href: 'href', children: 'children', - [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, + [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', }, }, }); @@ -171,8 +171,8 @@ describe('getSlotsNext', () => { ).toEqual({ slots: { root: 'div', icon: Foo }, slotProps: { - root: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, - icon: { id: 'bar', children: renderIcon, [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, + icon: { id: 'bar', children: renderIcon, [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, }, }); }); @@ -192,11 +192,12 @@ describe('getSlotsNext', () => { ).toEqual({ slots: { root: 'div', icon: Foo }, slotProps: { - root: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, icon: { children: undefined, id: 'bar', - [SLOT_COMPONENT_METADATA_SYMBOL]: { renderFunction, elementType: 'div' }, + [SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction, + [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', }, }, }); @@ -218,8 +219,8 @@ describe('getSlotsNext', () => { ).toEqual({ slots: { root: 'div', input: 'input', icon: null }, slotProps: { - root: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, - input: { [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }, + root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, + input: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, icon: undefined, }, }); diff --git a/packages/react-components/react-utilities/src/compose/isSlot.ts b/packages/react-components/react-utilities/src/compose/isSlot.ts index 9898c07d45f0b6..8486cbdc5ce139 100644 --- a/packages/react-components/react-utilities/src/compose/isSlot.ts +++ b/packages/react-components/react-utilities/src/compose/isSlot.ts @@ -1,4 +1,4 @@ -import { SLOT_COMPONENT_METADATA_SYMBOL } from './constants'; +import { SLOT_ELEMENT_TYPE_SYMBOL } from './constants'; import { SlotComponent } from './types'; /** @@ -6,5 +6,5 @@ import { SlotComponent } from './types'; * This is mainly used internally to ensure a slot is being used as a component. */ export function isSlot(element: unknown): element is SlotComponent { - return Boolean((element as {} | undefined)?.hasOwnProperty(SLOT_COMPONENT_METADATA_SYMBOL)); + 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 24ae758d6bc5b0..7a1baea4259fc9 100644 --- a/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx +++ b/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { resolveShorthand } from './resolveShorthand'; import type { Slot } from './types'; -import { SLOT_COMPONENT_METADATA_SYMBOL } from './constants'; +import { SLOT_ELEMENT_TYPE_SYMBOL } from './constants'; type TestProps = { slotA?: Slot<'div'>; @@ -18,7 +18,7 @@ describe('resolveShorthand', () => { expect(resolvedProps).toEqual({ children: 'hello', - [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, + [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', }); }); @@ -28,7 +28,7 @@ describe('resolveShorthand', () => { expect(resolvedProps).toEqual({ children:
hello
, - [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, + [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', }); }); @@ -38,7 +38,7 @@ describe('resolveShorthand', () => { expect(resolvedProps).toEqual({ children: 42, - [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, + [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', }); }); @@ -47,7 +47,7 @@ describe('resolveShorthand', () => { const props: TestProps = { slotA }; const resolvedProps = resolveShorthand(props.slotA); - expect(resolvedProps).toEqual({ [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }); + expect(resolvedProps).toEqual({ [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }); expect(resolvedProps).not.toBe(slotA); }); @@ -69,6 +69,6 @@ describe('resolveShorthand', () => { const props: TestProps = { slotA: undefined }; const resolvedProps = resolveShorthand(props.slotA, { required: true }); - expect(resolvedProps).toEqual({ [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }); + expect(resolvedProps).toEqual({ [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }); }); }); diff --git a/packages/react-components/react-utilities/src/compose/slot.test.tsx b/packages/react-components/react-utilities/src/compose/slot.test.tsx index d184ea0c970d00..761cac66fe6843 100644 --- a/packages/react-components/react-utilities/src/compose/slot.test.tsx +++ b/packages/react-components/react-utilities/src/compose/slot.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { slot } from './slot'; import type { ComponentProps, Slot } from './types'; -import { SLOT_COMPONENT_METADATA_SYMBOL } from './constants'; +import { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants'; type TestSlots = { slotA?: Slot<'div'>; @@ -21,7 +21,7 @@ describe('slot', () => { expect(resolvedProps).toEqual({ children: 'hello', - [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, + [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', }); }); @@ -31,7 +31,7 @@ describe('slot', () => { expect(resolvedProps).toEqual({ children:
hello
, - [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, + [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', }); }); @@ -41,7 +41,7 @@ describe('slot', () => { expect(resolvedProps).toEqual({ children: 42, - [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' }, + [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', }); }); @@ -50,7 +50,7 @@ describe('slot', () => { const props: TestProps = { slotA }; const resolvedProps = slot(props.slotA, { elementType: 'div' }); - expect(resolvedProps).toEqual({ [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }); + expect(resolvedProps).toEqual({ [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }); expect(resolvedProps).not.toBe(slotA); }); @@ -72,7 +72,7 @@ describe('slot', () => { const props: TestProps = { slotA: undefined }; const resolvedProps = slot(props.slotA, { required: true, elementType: 'div' }); - expect(resolvedProps).toEqual({ [SLOT_COMPONENT_METADATA_SYMBOL]: { elementType: 'div' } }); + expect(resolvedProps).toEqual({ [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }); }); it('handles render functions', () => { @@ -80,10 +80,8 @@ describe('slot', () => { const resolvedProps = slot(props.slotA, { elementType: 'div' }); expect(resolvedProps).toEqual({ - [SLOT_COMPONENT_METADATA_SYMBOL]: { - elementType: 'div', - renderFunction: expect.any(Function), - }, + [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 index f01c7e1ff7c491..18c1e3614df783 100644 --- a/packages/react-components/react-utilities/src/compose/slot.ts +++ b/packages/react-components/react-utilities/src/compose/slot.ts @@ -1,14 +1,13 @@ import type { AsIntrinsicElement, SlotComponent, - SlotComponentMetadata, SlotRenderFunction, SlotShorthandValue, UnknownSlotProps, } from './types'; -import { SLOT_COMPONENT_METADATA_SYMBOL } from './constants'; import * as React from 'react'; import { isSlot } from './isSlot'; +import { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants'; export type SlotOptions = { elementType: @@ -64,24 +63,31 @@ export function slot( value: Props | SlotComponent | SlotShorthandValue | undefined | null, options: { required?: boolean } & Partial> = {}, ): SlotComponent | undefined { - const { required = false, defaultProps, elementType } = options; + const { required = false, defaultProps } = options; if (value === null || (value === undefined && !required)) { return undefined; } - let metadata: SlotComponentMetadata; + let renderFunction: SlotRenderFunction | undefined; + let elementType: + | React.ComponentType + | (Props extends AsIntrinsicElement ? As : keyof JSX.IntrinsicElements); + if (isSlot(value)) { - metadata = value[SLOT_COMPONENT_METADATA_SYMBOL]; - if (elementType !== undefined) { - metadata.elementType = elementType; + renderFunction = value[SLOT_RENDER_FUNCTION_SYMBOL]; + elementType = value[SLOT_ELEMENT_TYPE_SYMBOL]; + if (options.elementType !== undefined) { + elementType = options.elementType; } - } else if (elementType !== undefined) { - metadata = { elementType }; + } else if (options.elementType !== undefined) { + elementType = options.elementType; } else if (process.env.NODE_ENV !== 'production') { throw new Error("[react-utilities]: slot options.elementType is required when value isn't a slot itself"); - } else { - metadata = { elementType: 'div' as React.ElementType as React.ComponentType }; + } + // in case of production, don't throw an error, just fallback to a div + else { + elementType = 'div' as React.ElementType as React.ComponentType; } /** @@ -90,10 +96,7 @@ export function slot( * 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, - [SLOT_COMPONENT_METADATA_SYMBOL]: metadata, - } as SlotComponent; + const propsWithMetadata = { ...defaultProps } as SlotComponent; if ( typeof value === 'string' || @@ -106,10 +109,15 @@ export function slot( } else if (typeof value === 'object') { Object.assign(propsWithMetadata, value); if (typeof value.children === 'function') { - metadata.renderFunction = value.children as SlotRenderFunction; + renderFunction = value.children as SlotRenderFunction; propsWithMetadata.children = defaultProps?.children; } } + Object.assign(propsWithMetadata, { + [SLOT_ELEMENT_TYPE_SYMBOL]: elementType, + [SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction, + }); + return propsWithMetadata; } diff --git a/packages/react-components/react-utilities/src/compose/types.ts b/packages/react-components/react-utilities/src/compose/types.ts index 8229024502aed6..4ecac9aecd7158 100644 --- a/packages/react-components/react-utilities/src/compose/types.ts +++ b/packages/react-components/react-utilities/src/compose/types.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import { SLOT_COMPONENT_METADATA_SYMBOL } from './constants'; +import { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants'; export type SlotRenderFunction = ( Component: React.ElementType, @@ -247,16 +247,11 @@ export type SlotComponent = Props & { /** * @internal */ - [SLOT_COMPONENT_METADATA_SYMBOL]: Readonly>; -}; - -/** - * Metadata stored on a slot component to describe its behavior. - * This is used to determine how to render the slot. - */ -export type SlotComponentMetadata = { - renderFunction?: SlotRenderFunction; - elementType: + [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 0f7f161de63654..e7aa4cbc1160f2 100644 --- a/packages/react-components/react-utilities/src/index.ts +++ b/packages/react-components/react-utilities/src/index.ts @@ -6,8 +6,7 @@ export { assertSlots, resolveShorthand, isResolvedShorthand, - SLOT_COMPONENT_METADATA_SYMBOL, - // eslint-disable-next-line deprecation/deprecation + SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL, } from './compose/index'; export type { @@ -25,7 +24,6 @@ export type { SlotShorthandValue, UnknownSlotProps, SlotComponent, - SlotComponentMetadata, SlotOptions, } from './compose/index'; From dfcc54b0a379722b984b5bc2dfd74d8e3e45d476 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Mon, 17 Jul 2023 11:53:32 +0000 Subject: [PATCH 7/9] chore: renames SlotComponent to SlotComponentType + removes re-declaring a slot as an option for slot method --- .../react-jsx-runtime/src/createElement.ts | 9 ++-- .../etc/react-utilities.api.md | 17 +++---- .../src/compose/assertSlots.ts | 4 +- .../src/compose/getSlotsNext.test.tsx | 41 ++++++++-------- .../react-utilities/src/compose/isSlot.ts | 4 +- .../src/compose/resolveShorthand.test.tsx | 8 +--- .../src/compose/resolveShorthand.ts | 5 +- .../react-utilities/src/compose/slot.ts | 48 +++++-------------- .../react-utilities/src/compose/types.ts | 2 +- .../react-utilities/src/index.ts | 2 +- 10 files changed, 52 insertions(+), 88 deletions(-) diff --git a/packages/react-components/react-jsx-runtime/src/createElement.ts b/packages/react-components/react-jsx-runtime/src/createElement.ts index ea2aa78ce02ba6..a234a7eec2c10a 100644 --- a/packages/react-components/react-jsx-runtime/src/createElement.ts +++ b/packages/react-components/react-jsx-runtime/src/createElement.ts @@ -2,10 +2,9 @@ import * as React from 'react'; import { UnknownSlotProps, isSlot, - SlotComponent, + SlotComponentType, SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL, - slot, } from '@fluentui/react-utilities'; export function createElement

( @@ -18,7 +17,7 @@ export function createElement

( // it should be removed once getSlotsNext is obsolete if (isSlot

(props)) { return createElementFromSlotComponent( - slot(props, { required: true, elementType: type as React.ComponentType

}), + { ...props, [SLOT_ELEMENT_TYPE_SYMBOL]: type } as SlotComponentType

, children, ); } @@ -29,7 +28,7 @@ export function createElement

( } function createElementFromSlotComponent( - slotComponent: SlotComponent, + type: SlotComponentType, overrideChildren: React.ReactNode[], ): React.ReactElement | null { const { @@ -37,7 +36,7 @@ function createElementFromSlotComponent( [SLOT_ELEMENT_TYPE_SYMBOL]: baseElementType, [SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction, ...propsWithoutMetadata - } = slotComponent; + } = type; const props = propsWithoutMetadata as UnknownSlotProps as Props; const elementType = typeof baseElementType === 'string' ? as ?? baseElementType : baseElementType; 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 d073a1e997c18e..f1c4887568e0b8 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -108,7 +108,7 @@ export function isMouseEvent(event: TouchOrMouseEvent): event is MouseEvent | Re export function isResolvedShorthand>(shorthand?: Shorthand): shorthand is ExtractSlotProps; // @public -export function isSlot(element: unknown): element is SlotComponent; +export function isSlot(element: unknown): element is SlotComponentType; // @public export function isTouchEvent(event: TouchOrMouseEvent): event is TouchEvent | React_2.TouchEvent; @@ -218,19 +218,14 @@ export type Slot(value: Props | SlotComponent | SlotShorthandValue | undefined, options: { +export function slot(value: Props | SlotShorthandValue | undefined, options: { required: true; -} & SlotOptions): SlotComponent; +} & SlotOptions): SlotComponentType; // @public (undocumented) -export function slot(value: Props | SlotComponent | SlotShorthandValue | undefined | null, options: { +export function slot(value: Props | SlotShorthandValue | undefined | null, options: { required?: boolean; -} & SlotOptions): SlotComponent | undefined; - -// @public (undocumented) -export function slot(value: SlotComponent, options?: { - required: true; -} & Partial>): SlotComponent; +} & SlotOptions): SlotComponentType | undefined; // @internal export const SLOT_ELEMENT_TYPE_SYMBOL: unique symbol; @@ -244,7 +239,7 @@ export type SlotClassNames = { }; // @public -export type SlotComponent = Props & { +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); diff --git a/packages/react-components/react-utilities/src/compose/assertSlots.ts b/packages/react-components/react-utilities/src/compose/assertSlots.ts index a864b2eae99bc4..ceaf43b18177df 100644 --- a/packages/react-components/react-utilities/src/compose/assertSlots.ts +++ b/packages/react-components/react-utilities/src/compose/assertSlots.ts @@ -1,10 +1,10 @@ import { SLOT_ELEMENT_TYPE_SYMBOL } from './constants'; import { isSlot } from './isSlot'; import { slot } from './slot'; -import { ComponentState, ExtractSlotProps, SlotComponent, SlotPropsRecord } from './types'; +import { ComponentState, ExtractSlotProps, SlotComponentType, SlotPropsRecord } from './types'; type SlotComponents = { - [K in keyof Slots]: SlotComponent>; + [K in keyof Slots]: SlotComponentType>; }; /** 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 744c624ba3fa85..184f906e1abf0b 100644 --- a/packages/react-components/react-utilities/src/compose/getSlotsNext.test.tsx +++ b/packages/react-components/react-utilities/src/compose/getSlotsNext.test.tsx @@ -1,12 +1,12 @@ import * as React from 'react'; import { getSlotsNext } from './getSlotsNext'; -import type { ExtractSlotProps, Slot, SlotComponent, UnknownSlotProps } from './types'; +import type { ExtractSlotProps, Slot, SlotComponentType, UnknownSlotProps } from './types'; import { resolveShorthand } from './resolveShorthand'; -import { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants'; +import { SLOT_RENDER_FUNCTION_SYMBOL } from './constants'; -const resolveShorthandMock = (props: Props): SlotComponent => { +const resolveShorthandMock = (props: Props): SlotComponentType => { // casting is required here as SlotComponent is a callable - return { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', ...props } as SlotComponent; + return { ...props } as SlotComponentType; }; describe('getSlotsNext', () => { @@ -22,7 +22,7 @@ describe('getSlotsNext', () => { }), ).toEqual({ slots: { root: 'div' }, - slotProps: { root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' } }, + slotProps: { root: {} }, }); }); @@ -35,7 +35,7 @@ describe('getSlotsNext', () => { }), ).toEqual({ slots: { root: 'span' }, - slotProps: { root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' } }, + slotProps: { root: {} }, }); }); @@ -49,7 +49,7 @@ describe('getSlotsNext', () => { }), ).toEqual({ slots: { root: 'button' }, - slotProps: { root: { id: 'id', href: 'href', [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' } }, + slotProps: { root: { id: 'id', href: 'href' } }, }); }); @@ -62,7 +62,7 @@ describe('getSlotsNext', () => { }), ).toEqual({ slots: { root: 'a' }, - slotProps: { root: { id: 'id', href: 'href', [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' } }, + slotProps: { root: { id: 'id', href: 'href' } }, }); }); @@ -80,8 +80,8 @@ describe('getSlotsNext', () => { ).toEqual({ slots: { root: 'div', icon: Foo }, slotProps: { - root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, - icon: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, + root: {}, + icon: {}, }, }); }); @@ -100,8 +100,8 @@ describe('getSlotsNext', () => { ).toEqual({ slots: { root: 'span', icon: 'button' }, slotProps: { - root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, - icon: { id: 'id', children: 'children', [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, + root: {}, + icon: { id: 'id', children: 'children' }, }, }); }); @@ -120,12 +120,11 @@ describe('getSlotsNext', () => { ).toEqual({ slots: { root: 'div', icon: 'a' }, slotProps: { - root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, + root: {}, icon: { id: 'id', href: 'href', children: 'children', - [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', }, }, }); @@ -145,12 +144,11 @@ describe('getSlotsNext', () => { ).toEqual({ slots: { root: 'div', icon: Foo }, slotProps: { - root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, + root: {}, icon: { id: 'id', href: 'href', children: 'children', - [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', }, }, }); @@ -171,8 +169,8 @@ describe('getSlotsNext', () => { ).toEqual({ slots: { root: 'div', icon: Foo }, slotProps: { - root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, - icon: { id: 'bar', children: renderIcon, [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, + root: {}, + icon: { id: 'bar', children: renderIcon }, }, }); }); @@ -192,12 +190,11 @@ describe('getSlotsNext', () => { ).toEqual({ slots: { root: 'div', icon: Foo }, slotProps: { - root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, + root: {}, icon: { children: undefined, id: 'bar', [SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction, - [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', }, }, }); @@ -219,8 +216,8 @@ describe('getSlotsNext', () => { ).toEqual({ slots: { root: 'div', input: 'input', icon: null }, slotProps: { - root: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, - input: { [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }, + root: {}, + input: {}, icon: undefined, }, }); diff --git a/packages/react-components/react-utilities/src/compose/isSlot.ts b/packages/react-components/react-utilities/src/compose/isSlot.ts index 8486cbdc5ce139..6dfa0eef00f234 100644 --- a/packages/react-components/react-utilities/src/compose/isSlot.ts +++ b/packages/react-components/react-utilities/src/compose/isSlot.ts @@ -1,10 +1,10 @@ import { SLOT_ELEMENT_TYPE_SYMBOL } from './constants'; -import { SlotComponent } from './types'; +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 SlotComponent { +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 7a1baea4259fc9..c6b5542f1a1708 100644 --- a/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx +++ b/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { resolveShorthand } from './resolveShorthand'; import type { Slot } from './types'; -import { SLOT_ELEMENT_TYPE_SYMBOL } from './constants'; type TestProps = { slotA?: Slot<'div'>; @@ -18,7 +17,6 @@ describe('resolveShorthand', () => { expect(resolvedProps).toEqual({ children: 'hello', - [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', }); }); @@ -28,7 +26,6 @@ describe('resolveShorthand', () => { expect(resolvedProps).toEqual({ children:

hello
, - [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', }); }); @@ -38,7 +35,6 @@ describe('resolveShorthand', () => { expect(resolvedProps).toEqual({ children: 42, - [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', }); }); @@ -47,7 +43,7 @@ describe('resolveShorthand', () => { const props: TestProps = { slotA }; const resolvedProps = resolveShorthand(props.slotA); - expect(resolvedProps).toEqual({ [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }); + expect(resolvedProps).toEqual({}); expect(resolvedProps).not.toBe(slotA); }); @@ -69,6 +65,6 @@ describe('resolveShorthand', () => { const props: TestProps = { slotA: undefined }; const resolvedProps = resolveShorthand(props.slotA, { required: true }); - expect(resolvedProps).toEqual({ [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }); + expect(resolvedProps).toEqual({}); }); }); diff --git a/packages/react-components/react-utilities/src/compose/resolveShorthand.ts b/packages/react-components/react-utilities/src/compose/resolveShorthand.ts index 3a3a9d94d40a1b..0ebb0a9f451dfc 100644 --- a/packages/react-components/react-utilities/src/compose/resolveShorthand.ts +++ b/packages/react-components/react-utilities/src/compose/resolveShorthand.ts @@ -21,6 +21,7 @@ export type ResolveShorthandFunction = (value, options) => slot(value, { ...options, - // elementType is required here although it'll be ignored by getSlotsNext - elementType: 'div', + // 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.ts b/packages/react-components/react-utilities/src/compose/slot.ts index 18c1e3614df783..610f7df3317b34 100644 --- a/packages/react-components/react-utilities/src/compose/slot.ts +++ b/packages/react-components/react-utilities/src/compose/slot.ts @@ -1,12 +1,11 @@ import type { AsIntrinsicElement, - SlotComponent, + SlotComponentType, SlotRenderFunction, SlotShorthandValue, UnknownSlotProps, } from './types'; import * as React from 'react'; -import { isSlot } from './isSlot'; import { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants'; export type SlotOptions = { @@ -48,55 +47,32 @@ export type SlotOptions = { * ``` */ export function slot( - value: Props | SlotComponent | SlotShorthandValue | undefined, + value: Props | SlotShorthandValue | undefined, options: { required: true } & SlotOptions, -): SlotComponent; +): SlotComponentType; export function slot( - value: Props | SlotComponent | SlotShorthandValue | undefined | null, + value: Props | SlotShorthandValue | undefined | null, options: { required?: boolean } & SlotOptions, -): SlotComponent | undefined; +): SlotComponentType | undefined; export function slot( - value: SlotComponent, - options?: { required: true } & Partial>, -): SlotComponent; -export function slot( - value: Props | SlotComponent | SlotShorthandValue | undefined | null, - options: { required?: boolean } & Partial> = {}, -): SlotComponent | undefined { - const { required = false, defaultProps } = options; + value: Props | SlotShorthandValue | undefined | null, + options: { required?: boolean } & SlotOptions, +): SlotComponentType | undefined { + const { required = false, defaultProps, elementType } = options; if (value === null || (value === undefined && !required)) { return undefined; } let renderFunction: SlotRenderFunction | undefined; - let elementType: - | React.ComponentType - | (Props extends AsIntrinsicElement ? As : keyof JSX.IntrinsicElements); - - if (isSlot(value)) { - renderFunction = value[SLOT_RENDER_FUNCTION_SYMBOL]; - elementType = value[SLOT_ELEMENT_TYPE_SYMBOL]; - if (options.elementType !== undefined) { - elementType = options.elementType; - } - } else if (options.elementType !== undefined) { - elementType = options.elementType; - } else if (process.env.NODE_ENV !== 'production') { - throw new Error("[react-utilities]: slot options.elementType is required when value isn't a slot itself"); - } - // in case of production, don't throw an error, just fallback to a div - else { - elementType = 'div' as React.ElementType as React.ComponentType; - } /** - * Casting is required here as SlotComponent is a function, not an object. - * Although SlotComponent has a function signature, it is still just an object. + * 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 } as SlotComponent; + const propsWithMetadata = { ...defaultProps } as SlotComponentType; if ( typeof value === 'string' || diff --git a/packages/react-components/react-utilities/src/compose/types.ts b/packages/react-components/react-utilities/src/compose/types.ts index 4ecac9aecd7158..4f4ec3f75094b3 100644 --- a/packages/react-components/react-utilities/src/compose/types.ts +++ b/packages/react-components/react-utilities/src/compose/types.ts @@ -239,7 +239,7 @@ export type SlotClassNames = { * 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 SlotComponent = Props & { +export type SlotComponentType = Props & { /** * **NOTE**: Slot components are not callable. */ diff --git a/packages/react-components/react-utilities/src/index.ts b/packages/react-components/react-utilities/src/index.ts index e7aa4cbc1160f2..ca628ac61ce07b 100644 --- a/packages/react-components/react-utilities/src/index.ts +++ b/packages/react-components/react-utilities/src/index.ts @@ -23,7 +23,7 @@ export type { SlotRenderFunction, SlotShorthandValue, UnknownSlotProps, - SlotComponent, + SlotComponentType, SlotOptions, } from './compose/index'; From a3aa2ea363e614286e9df4f98103f2d69e34f486 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 20 Jul 2023 09:57:39 +0000 Subject: [PATCH 8/9] chore: renames required to renderByDefault --- .../src/createElement.test.tsx | 8 +-- .../etc/react-utilities.api.md | 4 +- .../src/compose/resolveShorthand.ts | 1 + .../react-utilities/src/compose/slot.test.tsx | 4 +- .../react-utilities/src/compose/slot.ts | 55 +++++++++++-------- 5 files changed, 41 insertions(+), 31 deletions(-) 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 916814c221520c..05e87854443103 100644 --- a/packages/react-components/react-jsx-runtime/src/createElement.test.tsx +++ b/packages/react-components/react-jsx-runtime/src/createElement.test.tsx @@ -242,7 +242,7 @@ describe('createElement with assertSlots', () => { const state: TestComponentState = { components: { someSlot: 'div' }, someSlot: slot(props.someSlot, { - required: true, + renderByDefault: true, elementType: 'div', defaultProps: { children: 'Default Children', id: 'slot' }, }), @@ -282,8 +282,8 @@ describe('createElement with assertSlots', () => { 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' }), + inner: slot(props.inner, { renderByDefault: true, defaultProps: { id: 'inner' }, elementType: 'div' }), + outer: slot(props.outer, { renderByDefault: true, defaultProps: { id: 'outer' }, elementType: 'div' }), }; assertSlots(state); return ( @@ -339,7 +339,7 @@ describe('createElement with assertSlots', () => { const state: TestComponentState = { components: { slot: 'div' }, slot: slot(props.slot, { - required: true, + renderByDefault: true, elementType: 'div', }), }; 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 f1c4887568e0b8..587e34e324f9cb 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -219,12 +219,12 @@ export type Slot(value: Props | SlotShorthandValue | undefined, options: { - required: true; + renderByDefault: true; } & SlotOptions): SlotComponentType; // @public (undocumented) export function slot(value: Props | SlotShorthandValue | undefined | null, options: { - required?: boolean; + renderByDefault?: boolean; } & SlotOptions): SlotComponentType | undefined; // @internal diff --git a/packages/react-components/react-utilities/src/compose/resolveShorthand.ts b/packages/react-components/react-utilities/src/compose/resolveShorthand.ts index 0ebb0a9f451dfc..10dbebed81faf3 100644 --- a/packages/react-components/react-utilities/src/compose/resolveShorthand.ts +++ b/packages/react-components/react-utilities/src/compose/resolveShorthand.ts @@ -21,6 +21,7 @@ export type ResolveShorthandFunction = (value, options) => slot(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 index 761cac66fe6843..6ccf269e772a96 100644 --- a/packages/react-components/react-utilities/src/compose/slot.test.tsx +++ b/packages/react-components/react-utilities/src/compose/slot.test.tsx @@ -58,7 +58,7 @@ describe('slot', () => { const props: TestProps = { slotA: null, slotB: null }; expect(slot(props.slotA, { elementType: 'div' })).toEqual(undefined); - expect(slot(null, { required: true, elementType: 'div' })).toEqual(undefined); + expect(slot(null, { renderByDefault: true, elementType: 'div' })).toEqual(undefined); }); it('resolves undefined without creating a child element', () => { @@ -70,7 +70,7 @@ describe('slot', () => { it('resolves to empty object creating a child element', () => { const props: TestProps = { slotA: undefined }; - const resolvedProps = slot(props.slotA, { required: true, elementType: 'div' }); + const resolvedProps = slot(props.slotA, { renderByDefault: true, elementType: 'div' }); expect(resolvedProps).toEqual({ [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }); }); diff --git a/packages/react-components/react-utilities/src/compose/slot.ts b/packages/react-components/react-utilities/src/compose/slot.ts index 610f7df3317b34..b00935d2e7677c 100644 --- a/packages/react-components/react-utilities/src/compose/slot.ts +++ b/packages/react-components/react-utilities/src/compose/slot.ts @@ -22,9 +22,9 @@ export type SlotOptions = { * * * `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 - * * `required` - a boolean that indicates if a slot will be rendered even if it's base value is `undefined`. + * * `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 `required = true` then `state.SLOT_NAME` becomes an object + * 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` * @@ -37,7 +37,7 @@ export type SlotOptions = { * // useAccordionHeader.ts * const state = { * expandIcon: slot(expandIconShorthand, { - * required: true, + * renderByDefault: true, * elementType: 'span', * defaultProps: { children: , 'aria-hidden': true} * }) @@ -48,23 +48,23 @@ export type SlotOptions = { */ export function slot( value: Props | SlotShorthandValue | undefined, - options: { required: true } & SlotOptions, + options: { renderByDefault: true } & SlotOptions, ): SlotComponentType; export function slot( value: Props | SlotShorthandValue | undefined | null, - options: { required?: boolean } & SlotOptions, + options: { renderByDefault?: boolean } & SlotOptions, ): SlotComponentType | undefined; export function slot( value: Props | SlotShorthandValue | undefined | null, - options: { required?: boolean } & SlotOptions, + options: { renderByDefault?: boolean } & SlotOptions, ): SlotComponentType | undefined { - const { required = false, defaultProps, elementType } = options; + const { renderByDefault = false, defaultProps, elementType } = options; - if (value === null || (value === undefined && !required)) { + if (value === null || (value === undefined && !renderByDefault)) { return undefined; } - let renderFunction: SlotRenderFunction | undefined; + const props = slotShorthandToProps(value); /** * Casting is required here as SlotComponentType is a function, not an object. @@ -72,8 +72,28 @@ export function slot( * 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 } as SlotComponentType; + 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; +} +/** + * Helper function that converts a slot shorthand or properties to a slot properties object or undefined + * 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 + */ +function slotShorthandToProps( + value: Props | SlotShorthandValue, +): Props { if ( typeof value === 'string' || typeof value === 'number' || @@ -81,19 +101,8 @@ export function slot( // eslint-disable-next-line @typescript-eslint/no-explicit-any React.isValidElement(value) ) { - propsWithMetadata.children = value; - } else if (typeof value === 'object') { - Object.assign(propsWithMetadata, value); - if (typeof value.children === 'function') { - renderFunction = value.children as SlotRenderFunction; - propsWithMetadata.children = defaultProps?.children; - } + return { children: value } as Props; } - Object.assign(propsWithMetadata, { - [SLOT_ELEMENT_TYPE_SYMBOL]: elementType, - [SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction, - }); - - return propsWithMetadata; + return value; } From b3e7f76390c11598e573c8f9c9bc33ba0a7d6a2d Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 3 Aug 2023 10:46:13 +0000 Subject: [PATCH 9/9] chore: converts slot to a namespace --- .../src/createElement.test.tsx | 12 +-- .../etc/react-utilities.api.md | 29 ++++--- .../src/compose/assertSlots.test.tsx | 6 +- .../src/compose/assertSlots.ts | 5 +- .../react-utilities/src/compose/index.ts | 6 +- .../src/compose/isSlot.test.tsx | 4 +- .../src/compose/resolveShorthand.ts | 4 +- .../react-utilities/src/compose/slot.test.tsx | 20 ++--- .../react-utilities/src/compose/slot.ts | 76 ++++++++----------- 9 files changed, 80 insertions(+), 82 deletions(-) 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 05e87854443103..75c605fd981310 100644 --- a/packages/react-components/react-jsx-runtime/src/createElement.test.tsx +++ b/packages/react-components/react-jsx-runtime/src/createElement.test.tsx @@ -241,8 +241,7 @@ describe('createElement with assertSlots', () => { const TestComponent = (props: TestComponentProps) => { const state: TestComponentState = { components: { someSlot: 'div' }, - someSlot: slot(props.someSlot, { - renderByDefault: true, + someSlot: slot.always(props.someSlot, { elementType: 'div', defaultProps: { children: 'Default Children', id: 'slot' }, }), @@ -282,8 +281,8 @@ describe('createElement with assertSlots', () => { const TestComponent = (props: TestComponentProps) => { const state: TestComponentState = { components: { outer: 'div', inner: 'div' }, - inner: slot(props.inner, { renderByDefault: true, defaultProps: { id: 'inner' }, elementType: 'div' }), - outer: slot(props.outer, { renderByDefault: true, defaultProps: { id: 'outer' }, elementType: 'div' }), + inner: slot.always(props.inner, { defaultProps: { id: 'inner' }, elementType: 'div' }), + outer: slot.always(props.outer, { defaultProps: { id: 'outer' }, elementType: 'div' }), }; assertSlots(state); return ( @@ -338,10 +337,7 @@ describe('createElement with assertSlots', () => { const TestComponent = (props: TestComponentProps) => { const state: TestComponentState = { components: { slot: 'div' }, - slot: slot(props.slot, { - renderByDefault: true, - elementType: 'div', - }), + slot: slot.always(props.slot, { elementType: 'div' }), }; assertSlots(state); return ; 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 587e34e324f9cb..0c237a9665b025 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -7,6 +7,9 @@ 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; @@ -130,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) @@ -162,6 +170,9 @@ export function resetIdsForTests(): void; // @public export const resolveShorthand: ResolveShorthandFunction; +// @public +function resolveShorthand_2(value: Props | SlotShorthandValue): Props; + // @public (undocumented) export type ResolveShorthandFunction = {

(value: P | SlotShorthandValue | undefined, options: ResolveShorthandOptions): P; @@ -217,15 +228,15 @@ export type Slot>; }[AlternateAs] | null : 'Error: First parameter to Slot must not be not a union of types. See documentation of Slot type.'; -// @public -export function slot(value: Props | SlotShorthandValue | undefined, options: { - renderByDefault: true; -} & SlotOptions): SlotComponentType; - -// @public (undocumented) -export function slot(value: Props | SlotShorthandValue | undefined | null, options: { - renderByDefault?: boolean; -} & SlotOptions): SlotComponentType | undefined; +declare namespace slot { + export { + always, + optional, + resolveShorthand_2 as resolveShorthand, + SlotOptions + } +} +export { slot } // @internal export const SLOT_ELEMENT_TYPE_SYMBOL: unique symbol; diff --git a/packages/react-components/react-utilities/src/compose/assertSlots.test.tsx b/packages/react-components/react-utilities/src/compose/assertSlots.test.tsx index 849eccbaee6724..b22cbdecf3cd8f 100644 --- a/packages/react-components/react-utilities/src/compose/assertSlots.test.tsx +++ b/packages/react-components/react-utilities/src/compose/assertSlots.test.tsx @@ -1,5 +1,5 @@ import { assertSlots } from './assertSlots'; -import { slot } from './slot'; +import * as slot from './slot'; import { ComponentProps, ComponentState, Slot } from './types'; type TestSlots = { @@ -23,7 +23,7 @@ describe('assertSlots', () => { slotB: 'div', slotC: 'div', }, - slotA: slot(props.slotA, { elementType: 'div' }), + slotA: slot.optional(props.slotA, { elementType: 'div' }), }; expect(() => assertSlots(state)).not.toThrow(); }); @@ -46,7 +46,7 @@ describe('assertSlots', () => { slotB: 'div', slotC: 'div', }, - slotA: slot(props.slotA, { elementType: '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 index ceaf43b18177df..8280b51bba4b4a 100644 --- a/packages/react-components/react-utilities/src/compose/assertSlots.ts +++ b/packages/react-components/react-utilities/src/compose/assertSlots.ts @@ -1,6 +1,5 @@ import { SLOT_ELEMENT_TYPE_SYMBOL } from './constants'; import { isSlot } from './isSlot'; -import { slot } from './slot'; import { ComponentState, ExtractSlotProps, SlotComponentType, SlotPropsRecord } from './types'; type SlotComponents = { @@ -41,14 +40,14 @@ export function assertSlots(state: unknown): asse 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.name}`, + `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.name} with the correct elementType`, + `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/index.ts b/packages/react-components/react-utilities/src/compose/index.ts index e2c5540efcc6ad..5d1bedacf2bca0 100644 --- a/packages/react-components/react-utilities/src/compose/index.ts +++ b/packages/react-components/react-utilities/src/compose/index.ts @@ -1,9 +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 './slot'; 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 55e173e3c9d3fe..e5797b89107bb6 100644 --- a/packages/react-components/react-utilities/src/compose/isSlot.test.tsx +++ b/packages/react-components/react-utilities/src/compose/isSlot.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { isSlot } from './isSlot'; -import { slot } from './slot'; +import * as slot from './slot'; describe('isSlot', () => { it('handles a string', () => { @@ -32,6 +32,6 @@ describe('isSlot', () => { }); it('handles actual slot', () => { - expect(isSlot(slot({}, { elementType: 'div' }))).toEqual(true); + expect(isSlot(slot.optional({}, { elementType: 'div' }))).toEqual(true); }); }); diff --git a/packages/react-components/react-utilities/src/compose/resolveShorthand.ts b/packages/react-components/react-utilities/src/compose/resolveShorthand.ts index 10dbebed81faf3..e647c96ea4e948 100644 --- a/packages/react-components/react-utilities/src/compose/resolveShorthand.ts +++ b/packages/react-components/react-utilities/src/compose/resolveShorthand.ts @@ -1,4 +1,4 @@ -import { slot } from './slot'; +import * as slot from './slot'; import type { SlotShorthandValue, UnknownSlotProps } from './types'; export type ResolveShorthandOptions = Required extends true @@ -19,7 +19,7 @@ export type ResolveShorthandFunction = (value, options) => - slot(value, { + slot.optional(value, { ...options, renderByDefault: options?.required, // elementType as undefined is the way to identify between a slot and a resolveShorthand call diff --git a/packages/react-components/react-utilities/src/compose/slot.test.tsx b/packages/react-components/react-utilities/src/compose/slot.test.tsx index 6ccf269e772a96..0f30b4c49105ec 100644 --- a/packages/react-components/react-utilities/src/compose/slot.test.tsx +++ b/packages/react-components/react-utilities/src/compose/slot.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { slot } from './slot'; +import * as slot from './slot'; import type { ComponentProps, Slot } from './types'; import { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants'; @@ -17,7 +17,7 @@ type TestProps = ComponentProps & { describe('slot', () => { it('resolves a string', () => { const props: TestProps = { slotA: 'hello' }; - const resolvedProps = slot(props.slotA, { elementType: 'div' }); + const resolvedProps = slot.optional(props.slotA, { elementType: 'div' }); expect(resolvedProps).toEqual({ children: 'hello', @@ -27,7 +27,7 @@ describe('slot', () => { it('resolves a JSX element', () => { const props: TestProps = { slotA:

hello
}; - const resolvedProps = slot(props.slotA, { elementType: 'div' }); + const resolvedProps = slot.optional(props.slotA, { elementType: 'div' }); expect(resolvedProps).toEqual({ children:
hello
, @@ -37,7 +37,7 @@ describe('slot', () => { it('resolves a number', () => { const props: TestProps = { slotA: 42 }; - const resolvedProps = slot(props.slotA, { elementType: 'div' }); + const resolvedProps = slot.optional(props.slotA, { elementType: 'div' }); expect(resolvedProps).toEqual({ children: 42, @@ -48,7 +48,7 @@ describe('slot', () => { it('resolves an object as its copy', () => { const slotA = {}; const props: TestProps = { slotA }; - const resolvedProps = slot(props.slotA, { elementType: 'div' }); + const resolvedProps = slot.optional(props.slotA, { elementType: 'div' }); expect(resolvedProps).toEqual({ [SLOT_ELEMENT_TYPE_SYMBOL]: 'div' }); expect(resolvedProps).not.toBe(slotA); @@ -57,27 +57,27 @@ describe('slot', () => { it('resolves "null" without creating a child element', () => { const props: TestProps = { slotA: null, slotB: null }; - expect(slot(props.slotA, { elementType: 'div' })).toEqual(undefined); - expect(slot(null, { renderByDefault: true, elementType: 'div' })).toEqual(undefined); + 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(props.slotA, { elementType: 'div' }); + 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(props.slotA, { renderByDefault: true, elementType: 'div' }); + 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(props.slotA, { elementType: 'div' }); + const resolvedProps = slot.optional(props.slotA, { elementType: 'div' }); expect(resolvedProps).toEqual({ [SLOT_ELEMENT_TYPE_SYMBOL]: 'div', diff --git a/packages/react-components/react-utilities/src/compose/slot.ts b/packages/react-components/react-utilities/src/compose/slot.ts index b00935d2e7677c..c6640ed2c2855e 100644 --- a/packages/react-components/react-utilities/src/compose/slot.ts +++ b/packages/react-components/react-utilities/src/compose/slot.ts @@ -18,53 +18,18 @@ export type SlotOptions = { /** * 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 - optional values you can pass to alter the signature of a slot, those values are: + * @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` - * - * @example of a required nullable slot - * ```tsx - * // AccordionHeader.types.ts - * type AccordionHeaderSlots = { - * expandIcon?: Slot<'span'> - * } - * // useAccordionHeader.ts - * const state = { - * expandIcon: slot(expandIconShorthand, { - * renderByDefault: true, - * elementType: 'span', - * defaultProps: { children: , 'aria-hidden': true} - * }) - * } - * // renderAccordionHeader - * state.expandIcon && - * ``` + * * `defaultProps` - similar to a React component declaration, you can provide a slot default properties to be merged with the shorthand/properties provided. */ -export function slot( +export function always( value: Props | SlotShorthandValue | undefined, - options: { renderByDefault: true } & SlotOptions, -): SlotComponentType; -export function slot( - value: Props | SlotShorthandValue | undefined | null, - options: { renderByDefault?: boolean } & SlotOptions, -): SlotComponentType | undefined; -export function slot( - value: Props | SlotShorthandValue | undefined | null, - options: { renderByDefault?: boolean } & SlotOptions, -): SlotComponentType | undefined { - const { renderByDefault = false, defaultProps, elementType } = options; - - if (value === null || (value === undefined && !renderByDefault)) { - return undefined; - } + options: SlotOptions, +): SlotComponentType { + const { defaultProps, elementType } = options; - const props = slotShorthandToProps(value); + const props = resolveShorthand(value); /** * Casting is required here as SlotComponentType is a function, not an object. @@ -87,11 +52,34 @@ export function slot( } /** - * Helper function that converts a slot shorthand or properties to a slot properties object or undefined + * 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 */ -function slotShorthandToProps( +export function resolveShorthand( value: Props | SlotShorthandValue, ): Props { if (