From eb275e8a7d1931cbb7a7d0362877d261e247eeff Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 6 Apr 2023 12:42:27 +0000 Subject: [PATCH] feat: implements custom JSX pragma --- ...-92a3c6b2-e767-4128-8320-7e237ef69eaa.json | 7 + ...-6ec7d77e-2bd1-4bcc-a0fb-0f71642d43bd.json | 7 + .../react-jsx-runtime/package.json | 2 +- .../react-jsx-runtime/src/index.ts | 4 +- .../react-jsx-runtime/src/jsx-dev-runtime.ts | 18 +++ .../react-jsx-runtime/src/jsx-runtime.ts | 24 +++ .../react-jsx-runtime/src/jsx.test.tsx | 84 ++++++++++ .../react-jsx-runtime/src/jsx.ts | 89 +++++++++++ .../react-jsx-runtime/src/types.d.ts | 31 ++++ .../etc/react-utilities.api.md | 17 ++ .../react-utilities/src/compose/constants.ts | 10 ++ .../react-utilities/src/compose/getSlots.ts | 20 ++- .../src/compose/getSlotsNext.test.tsx | 148 ++++++++++++++++++ .../src/compose/getSlotsNext.ts | 47 ++++++ .../react-utilities/src/compose/index.ts | 2 + .../src/compose/resolveShorthand.test.tsx | 19 ++- .../src/compose/resolveShorthand.ts | 11 +- .../react-utilities/src/index.ts | 10 +- tsconfig.base.json | 1 + 19 files changed, 536 insertions(+), 15 deletions(-) create mode 100644 change/@fluentui-react-jsx-runtime-92a3c6b2-e767-4128-8320-7e237ef69eaa.json create mode 100644 change/@fluentui-react-utilities-6ec7d77e-2bd1-4bcc-a0fb-0f71642d43bd.json create mode 100644 packages/react-components/react-jsx-runtime/src/jsx-dev-runtime.ts create mode 100644 packages/react-components/react-jsx-runtime/src/jsx-runtime.ts create mode 100644 packages/react-components/react-jsx-runtime/src/jsx.test.tsx create mode 100644 packages/react-components/react-jsx-runtime/src/jsx.ts create mode 100644 packages/react-components/react-jsx-runtime/src/types.d.ts create mode 100644 packages/react-components/react-utilities/src/compose/constants.ts create mode 100644 packages/react-components/react-utilities/src/compose/getSlotsNext.test.tsx create mode 100644 packages/react-components/react-utilities/src/compose/getSlotsNext.ts diff --git a/change/@fluentui-react-jsx-runtime-92a3c6b2-e767-4128-8320-7e237ef69eaa.json b/change/@fluentui-react-jsx-runtime-92a3c6b2-e767-4128-8320-7e237ef69eaa.json new file mode 100644 index 00000000000000..3579190fc7d420 --- /dev/null +++ b/change/@fluentui-react-jsx-runtime-92a3c6b2-e767-4128-8320-7e237ef69eaa.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat: implements custom JSX pragma", + "packageName": "@fluentui/react-jsx-runtime", + "email": "bernardo.sunderhus@gmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-utilities-6ec7d77e-2bd1-4bcc-a0fb-0f71642d43bd.json b/change/@fluentui-react-utilities-6ec7d77e-2bd1-4bcc-a0fb-0f71642d43bd.json new file mode 100644 index 00000000000000..9b7eeb1cf33652 --- /dev/null +++ b/change/@fluentui-react-utilities-6ec7d77e-2bd1-4bcc-a0fb-0f71642d43bd.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: ensure compatibility with custom JSX pragma", + "packageName": "@fluentui/react-utilities", + "email": "bernardo.sunderhus@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-jsx-runtime/package.json b/packages/react-components/react-jsx-runtime/package.json index 8ad4f1b06971dc..59ebdd0eb36666 100644 --- a/packages/react-components/react-jsx-runtime/package.json +++ b/packages/react-components/react-jsx-runtime/package.json @@ -1,7 +1,6 @@ { "name": "@fluentui/react-jsx-runtime", "version": "9.0.0-alpha.0", - "private": true, "description": "React components for building web experiences", "main": "lib-commonjs/index.js", "module": "lib/index.js", @@ -30,6 +29,7 @@ "@fluentui/scripts-tasks": "*" }, "dependencies": { + "@fluentui/react-utilities": "^9.7.3", "@swc/helpers": "^0.4.14" }, "peerDependencies": { diff --git a/packages/react-components/react-jsx-runtime/src/index.ts b/packages/react-components/react-jsx-runtime/src/index.ts index aacbad0068e241..388451abd35029 100644 --- a/packages/react-components/react-jsx-runtime/src/index.ts +++ b/packages/react-components/react-jsx-runtime/src/index.ts @@ -1,2 +1,2 @@ -// TODO: replace with real exports -export {}; +export { jsx } from './jsx'; +export { Fragment } from 'react'; diff --git a/packages/react-components/react-jsx-runtime/src/jsx-dev-runtime.ts b/packages/react-components/react-jsx-runtime/src/jsx-dev-runtime.ts new file mode 100644 index 00000000000000..85b668ae011b31 --- /dev/null +++ b/packages/react-components/react-jsx-runtime/src/jsx-dev-runtime.ts @@ -0,0 +1,18 @@ +/// + +import * as React from 'react'; +import { jsx as createElement, extractChildrenFromProps } from './jsx'; + +export function jsxDEV

( + type: React.ElementType

, + props?: P | null, + key?: string, + isStaticChildren?: boolean, + source?: unknown, + self?: unknown, +): React.ReactElement

| null { + // extractChildrenFromProps is required since jsxDev signature differs from React.createElement signature + return createElement(type, props, ...extractChildrenFromProps(props)); +} + +export { Fragment } from 'react/jsx-dev-runtime'; diff --git a/packages/react-components/react-jsx-runtime/src/jsx-runtime.ts b/packages/react-components/react-jsx-runtime/src/jsx-runtime.ts new file mode 100644 index 00000000000000..bae0a1772ecdd1 --- /dev/null +++ b/packages/react-components/react-jsx-runtime/src/jsx-runtime.ts @@ -0,0 +1,24 @@ +/// + +import * as React from 'react'; +import { jsx as createElement, extractChildrenFromProps } from './jsx'; + +export function jsx

( + type: React.ElementType

, + props?: P | null, + key?: string, +): React.ReactElement

| null { + // extractChildrenFromProps is required since jsx signature differs from React.createElement signature + return createElement(type, props, ...extractChildrenFromProps(props)); +} + +export function jsxs

( + type: React.ElementType

, + props?: P | null, + key?: string, +): React.ReactElement

| null { + // extractChildrenFromProps is required since jsxs signature differs from React.createElement signature + return createElement(type, props, ...extractChildrenFromProps(props)); +} + +export { Fragment } from 'react/jsx-runtime'; diff --git a/packages/react-components/react-jsx-runtime/src/jsx.test.tsx b/packages/react-components/react-jsx-runtime/src/jsx.test.tsx new file mode 100644 index 00000000000000..729cf02315cff2 --- /dev/null +++ b/packages/react-components/react-jsx-runtime/src/jsx.test.tsx @@ -0,0 +1,84 @@ +/* eslint-disable jsdoc/check-tag-names */ +/** @jsxRuntime classic */ +/** @jsxFrag Fragment */ +/** @jsx jsx */ +/* eslint-enable jsdoc/check-tag-names */ + +import { + ComponentProps, + ComponentState, + Slot, + getSlots, + getSlotsNext, + resolveShorthand, +} from '@fluentui/react-utilities'; +import { jsx, Fragment } from './index'; +import { render } from '@testing-library/react'; + +type TestComponentSlots = { slot: Slot<'div'> }; +type TestComponentState = ComponentState; +type TestComponentProps = ComponentProps> & { + getSlots: typeof getSlots; +}; + +const TestComponent = (props: TestComponentProps) => { + const state: TestComponentState = { + components: { + slot: 'div', + }, + slot: resolveShorthand( + props.slot ?? { + children: (C, p) => ( + <> +

before
+ +
after
+ + ), + }, + { + required: true, + defaultProps: { + children:
this is internal children
, + }, + }, + ), + }; + const { slots, slotProps } = props.getSlots(state); + return ; +}; + +describe('jsx', () => { + it('should lose internal children while using getSlots', () => { + const result = render(); + expect(result.container).toMatchInlineSnapshot(` +
+
+ before +
+
+
+ after +
+
+ `); + }); + it('should keep internal children while using getSlotsNext', () => { + const result = render(); + expect(result.container).toMatchInlineSnapshot(` +
+
+ before +
+
+
+ this is internal children +
+
+
+ after +
+
+ `); + }); +}); diff --git a/packages/react-components/react-jsx-runtime/src/jsx.ts b/packages/react-components/react-jsx-runtime/src/jsx.ts new file mode 100644 index 00000000000000..47811383c2e7b6 --- /dev/null +++ b/packages/react-components/react-jsx-runtime/src/jsx.ts @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { + SlotRenderFunction, + UnknownSlotProps, + SLOT_EXTERNAL_CHILDREN_SYMBOL, + SLOT_INTERNAL_CHILDREN_SYMBOL, +} from '@fluentui/react-utilities'; + +type WithMetadata = Props & { + [SLOT_EXTERNAL_CHILDREN_SYMBOL]: React.ReactNode | SlotRenderFunction; + [SLOT_INTERNAL_CHILDREN_SYMBOL]: React.ReactNode | SlotRenderFunction; +}; + +/** + * Equivalent to React.createElement but supporting v9 slot API + */ +export function jsx

( + type: React.ElementType

, + props?: P | null, + ...children: React.ReactNode[] +): React.ReactElement

| null { + return isSlotComponent(props) + ? jsxFromSlotComponent(type, props, ...children) + : React.createElement(type, props, ...children); +} + +function jsxFromSlotComponent( + type: React.ElementType, + propsWithMetadata: WithMetadata, + ...overrideChildren: React.ReactNode[] +): React.ReactElement | null { + const { children, renderFn, props } = normalizeChildren(propsWithMetadata, overrideChildren); + if (renderFn) { + return React.createElement(React.Fragment, {}, renderFn(type, { ...props, children })) as React.ReactElement; + } + + return React.createElement(type, { ...props, children } as Props); +} + +export function extractChildrenFromProps>( + props?: Props | null, +): React.ReactNode[] { + if (!props) { + return []; + } + return Array.isArray(props.children) ? props.children : [props.children]; +} + +function normalizeChildren( + propsWithMetadata: WithMetadata, + overrideChildren: React.ReactNode[] = [], +): { + props: Props; + children: React.ReactNode; + renderFn?: SlotRenderFunction; +} { + const { + [SLOT_EXTERNAL_CHILDREN_SYMBOL]: slotExternalChildren, + [SLOT_INTERNAL_CHILDREN_SYMBOL]: slotInternalChildren, + ...props + } = propsWithMetadata; + const isRenderFn = typeof slotExternalChildren === 'function'; + const notNormalizedChildren: React.ReactNode | React.ReactNode[] = + (overrideChildren.length > 0 ? overrideChildren : undefined) ?? + (isRenderFn ? slotInternalChildren : slotExternalChildren) ?? + slotInternalChildren ?? + propsWithMetadata.children; + const normalizedChildren = Array.isArray(notNormalizedChildren) + ? React.createElement(React.Fragment, {}, ...notNormalizedChildren) + : notNormalizedChildren; + const renderFn = isRenderFn ? (slotExternalChildren as SlotRenderFunction) : undefined; + if (isRenderFn) { + const { children: _, ...propsWithoutChildren } = props as UnknownSlotProps; + return { + children: normalizedChildren, + renderFn, + props: propsWithoutChildren as Props, + }; + } + return { + children: normalizedChildren, + renderFn, + props: props as UnknownSlotProps as Props, + }; +} + +export function isSlotComponent(props?: Props | null): props is WithMetadata { + return Boolean(props?.hasOwnProperty(SLOT_INTERNAL_CHILDREN_SYMBOL)); +} diff --git a/packages/react-components/react-jsx-runtime/src/types.d.ts b/packages/react-components/react-jsx-runtime/src/types.d.ts new file mode 100644 index 00000000000000..922fbea70b0a04 --- /dev/null +++ b/packages/react-components/react-jsx-runtime/src/types.d.ts @@ -0,0 +1,31 @@ +declare module 'react/jsx-runtime' { + import type * as React from 'react'; + + export const Fragment: symbol; + + export function jsx

( + type: React.ElementType

, + props?: P | null, + key?: string, + ): React.ReactElement

| null; + + export function jsxs

( + type: React.ElementType

, + props?: P | null, + key?: string, + ): React.ReactElement

| null; +} +declare module 'react/jsx-dev-runtime' { + import type * as React from 'react'; + + export const Fragment: symbol; + + export function jsxDEV

( + type: React.ElementType

, + props?: P | null, + key?: string, + isStaticChildren?: boolean, + source?: unknown, + self?: unknown, + ): React.ReactElement

| null; +} 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 81c8e94469a4ab..985a481231f311 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -70,6 +70,12 @@ export function getSlots(state: ComponentState): { slotProps: ObjectSlotProps; }; +// @public +export function getSlotsNext(state: ComponentState): { + slots: Slots; + slotProps: ObjectSlotProps; +}; + // @internal export function getTriggerChild(children: TriggerProps['children']): (React_2.ReactElement> & { ref?: React_2.Ref; @@ -143,6 +149,12 @@ export type Slot>; }[AlternateAs] | null : 'Error: First parameter to Slot must not be not a union of types. See documentation of Slot type.'; +// @internal +export const SLOT_EXTERNAL_CHILDREN_SYMBOL: unique symbol; + +// @internal +export const SLOT_INTERNAL_CHILDREN_SYMBOL: unique symbol; + // @public export type SlotClassNames = { [SlotName in keyof Slots]-?: string; @@ -175,6 +187,11 @@ export type TriggerProps = { children?: React_2.ReactElement | ((props: TriggerChildProps) => React_2.ReactElement | null) | null; }; +// @public +export type UnknownSlotProps = Pick, 'children' | 'className' | 'style'> & { + as?: keyof JSX.IntrinsicElements; +}; + // @internal export const useControllableState: (options: UseControllableStateOptions) => [State, React_2.Dispatch>]; diff --git a/packages/react-components/react-utilities/src/compose/constants.ts b/packages/react-components/react-utilities/src/compose/constants.ts new file mode 100644 index 00000000000000..55e6c0a70eae3f --- /dev/null +++ b/packages/react-components/react-utilities/src/compose/constants.ts @@ -0,0 +1,10 @@ +/** + * @internal + * internal symbol used to keep defaultProps.children available + */ +export const SLOT_INTERNAL_CHILDREN_SYMBOL = Symbol('fui.slotInternalChildren'); +/** + * @internal + * internal symbol used to keep slot.children available + */ +export const SLOT_EXTERNAL_CHILDREN_SYMBOL = Symbol('fui.slotExternalChildren'); diff --git a/packages/react-components/react-utilities/src/compose/getSlots.ts b/packages/react-components/react-utilities/src/compose/getSlots.ts index 01f3dcef509058..561b7af0957466 100644 --- a/packages/react-components/react-utilities/src/compose/getSlots.ts +++ b/packages/react-components/react-utilities/src/compose/getSlots.ts @@ -8,7 +8,9 @@ import type { SlotPropsRecord, SlotRenderFunction, UnionToIntersection, + UnknownSlotProps, } from './types'; +import { SLOT_EXTERNAL_CHILDREN_SYMBOL, SLOT_INTERNAL_CHILDREN_SYMBOL } from './constants'; export type Slots = { [K in keyof S]: ExtractSlotProps extends AsIntrinsicElement @@ -19,7 +21,7 @@ export type Slots = { : React.ElementType>; }; -type ObjectSlotProps = { +export type ObjectSlotProps = { [K in keyof S]-?: ExtractSlotProps extends AsIntrinsicElement ? // For intrinsic element types, return the intersection of all possible // element's props, to be compatible with the As type returned by Slots<> @@ -68,10 +70,17 @@ function getSlot( state: ComponentState, slotName: K, ): readonly [React.ElementType | null, R[K]] { - if (state[slotName] === undefined) { + const props = state[slotName]; + + if (props === undefined) { return [null, undefined as R[K]]; } - const { children, as: asProp, ...rest } = state[slotName]!; + + // Symbols must be deleted to ensure new custom pragma won't recognize this as the new version element + delete (props as { [SLOT_EXTERNAL_CHILDREN_SYMBOL]: unknown })[SLOT_EXTERNAL_CHILDREN_SYMBOL]; + delete (props as { [SLOT_INTERNAL_CHILDREN_SYMBOL]: unknown })[SLOT_INTERNAL_CHILDREN_SYMBOL]; + + const { children, as: asProp, ...rest } = props; const slot = ( state.components?.[slotName] === undefined || typeof state.components[slotName] === 'string' @@ -89,8 +98,7 @@ function getSlot( ]; } - const shouldOmitAsProp = typeof slot === 'string' && state[slotName]?.as; - const slotProps = (shouldOmitAsProp ? omit(state[slotName]!, ['as']) : state[slotName]) as R[K]; - + const shouldOmitAsProp = typeof slot === 'string' && asProp; + const slotProps = (shouldOmitAsProp ? omit(props, ['as']) : (props as UnknownSlotProps)) as R[K]; return [slot, slotProps]; } diff --git a/packages/react-components/react-utilities/src/compose/getSlotsNext.test.tsx b/packages/react-components/react-utilities/src/compose/getSlotsNext.test.tsx new file mode 100644 index 00000000000000..fc824df2685416 --- /dev/null +++ b/packages/react-components/react-utilities/src/compose/getSlotsNext.test.tsx @@ -0,0 +1,148 @@ +import * as React from 'react'; +import { getSlotsNext } from './getSlotsNext'; +import type { Slot } from './types'; + +describe('getSlotsNext', () => { + type FooProps = { id?: string; children?: React.ReactNode }; + const Foo = (props: FooProps) =>

; + + it('returns provided component type for root if the as prop is not provided', () => { + type Slots = { root: Slot<'div'> }; + expect(getSlotsNext({ root: {}, components: { root: 'div' } })).toEqual({ + slots: { root: 'div' }, + slotProps: { root: {} }, + }); + }); + + 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({ + slots: { root: 'span' }, + slotProps: { root: {} }, + }); + }); + + it('does not omit invalid props for the rendered element', () => { + type Slots = { root: Slot<'button'> }; + const invalidProp = { href: 'href' } as React.ButtonHTMLAttributes; + expect( + getSlotsNext({ root: { as: 'button', id: 'id', ...invalidProp }, components: { root: 'button' } }), + ).toEqual({ + slots: { root: 'button' }, + slotProps: { root: { id: 'id', href: 'href' } }, + }); + }); + + 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({ + slots: { root: 'a' }, + slotProps: { root: { id: 'id', href: 'href' } }, + }); + }); + + it('returns a component slot with no children', () => { + type Slots = { + root: Slot<'div'>; + icon: Slot; + }; + expect( + getSlotsNext({ + icon: {}, + components: { root: 'div', icon: Foo }, + root: { as: 'div' }, + }), + ).toEqual({ + slots: { root: 'div', icon: Foo }, + slotProps: { root: {}, icon: {} }, + }); + }); + + it('returns slot as button', () => { + type Slots = { + root: Slot<'div', 'span'>; + icon: Slot<'button'>; + }; + expect( + getSlotsNext({ + components: { icon: 'button', root: 'div' }, + root: { as: 'span' }, + icon: { id: 'id', children: 'children' }, + }), + ).toEqual({ + slots: { root: 'span', icon: 'button' }, + slotProps: { root: {}, icon: { id: 'id', children: 'children' } }, + }); + }); + + it('returns slot as anchor and includes supported props (href)', () => { + type Slots = { + root: Slot<'div'>; + icon: Slot<'a'>; + }; + expect( + getSlotsNext({ + root: { as: 'div' }, + components: { root: 'div', icon: 'a' }, + icon: { id: 'id', href: 'href', children: 'children' }, + }), + ).toEqual({ + slots: { root: 'div', icon: 'a' }, + slotProps: { root: {}, icon: { id: 'id', href: 'href', children: 'children' } }, + }); + }); + + it('returns a component and includes all props', () => { + type Slots = { + root: Slot<'div'>; + icon: Slot<'a'> | Slot; + }; + expect( + getSlotsNext({ + components: { root: 'div', icon: Foo }, + root: { as: 'div' }, + icon: { id: 'id', href: 'href', children: 'children' }, + }), + ).toEqual({ + slots: { root: 'div', icon: Foo }, + slotProps: { root: {}, icon: { id: 'id', href: 'href', children: 'children' } }, + }); + }); + + it('slot children functions should just pass functions forward', () => { + type Slots = { + root: Slot<'div'>; + icon: Slot<'a'>; + }; + const renderIcon = (C: React.ElementType, p: {}) => ; + expect( + getSlotsNext({ + components: { root: 'div', icon: Foo }, + root: { as: 'div' }, + icon: { id: 'bar', children: renderIcon }, + }), + ).toEqual({ + slots: { root: 'div', icon: Foo }, + slotProps: { root: {}, icon: { id: 'bar', children: renderIcon } }, + }); + }); + + it('can render a primitive input with no children', () => { + type Slots = { + root: Slot<'div'>; + input: Slot<'input'>; + icon?: Slot<'a'>; + }; + expect( + getSlotsNext({ + root: { as: 'div' }, + components: { root: 'div', input: 'input', icon: 'a' }, + input: {}, + icon: undefined, + }), + ).toEqual({ + slots: { root: 'div', input: 'input', icon: null }, + slotProps: { root: {}, input: {}, icon: undefined }, + }); + }); +}); diff --git a/packages/react-components/react-utilities/src/compose/getSlotsNext.ts b/packages/react-components/react-utilities/src/compose/getSlotsNext.ts new file mode 100644 index 00000000000000..4f5e11b0e91964 --- /dev/null +++ b/packages/react-components/react-utilities/src/compose/getSlotsNext.ts @@ -0,0 +1,47 @@ +import * as React from 'react'; +import type { ComponentState, SlotPropsRecord, UnknownSlotProps } from './types'; +import { ObjectSlotProps, Slots } from './getSlots'; + +/** + * Similar to `getSlots`, main difference is that it's compatible with new custom jsx pragma + */ +export function getSlotsNext( + state: ComponentState, +): { + slots: Slots; + slotProps: ObjectSlotProps; +} { + const slots = {} as Slots; + const slotProps = {} as R; + + const slotNames: (keyof R)[] = Object.keys(state.components); + for (const slotName of slotNames) { + const [slot, props] = getSlotNext(state, slotName); + slots[slotName] = slot as Slots[typeof slotName]; + slotProps[slotName] = props; + } + return { slots, slotProps: slotProps as unknown as ObjectSlotProps }; +} + +function getSlotNext( + state: ComponentState, + slotName: K, +): readonly [React.ElementType | null, R[K]] { + const props = state[slotName]; + + if (props === undefined) { + return [null, undefined as R[K]]; + } + const { as: asProp, ...propsWithoutAs } = props; + + const slot = ( + state.components?.[slotName] === undefined || typeof state.components[slotName] === 'string' + ? asProp || state.components?.[slotName] || 'div' + : state.components[slotName] + ) as React.ElementType; + + const shouldOmitAsProp = typeof slot === 'string' && asProp; + const slotProps: UnknownSlotProps = shouldOmitAsProp ? propsWithoutAs : props; + + return [slot, slotProps as R[K]]; +} diff --git a/packages/react-components/react-utilities/src/compose/index.ts b/packages/react-components/react-utilities/src/compose/index.ts index 99571d50ab1ba9..fec1ddf85added 100644 --- a/packages/react-components/react-utilities/src/compose/index.ts +++ b/packages/react-components/react-utilities/src/compose/index.ts @@ -2,3 +2,5 @@ export * from './getSlots'; export * from './resolveShorthand'; export * from './types'; export * from './isResolvedShorthand'; +export * from './constants'; +export * from './getSlotsNext'; 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 1885cc5ad165d2..aa41fb44b50a53 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_EXTERNAL_CHILDREN_SYMBOL, SLOT_INTERNAL_CHILDREN_SYMBOL } from './constants'; type TestProps = { slotA?: Slot<'div'>; @@ -15,21 +16,33 @@ describe('resolveShorthand', () => { const props: TestProps = { slotA: 'hello' }; const resolvedProps = resolveShorthand(props.slotA); - expect(resolvedProps).toEqual({ children: 'hello' }); + expect(resolvedProps).toEqual({ + children: 'hello', + [SLOT_EXTERNAL_CHILDREN_SYMBOL]: 'hello', + [SLOT_INTERNAL_CHILDREN_SYMBOL]: undefined, + }); }); it('resolves a JSX element', () => { const props: TestProps = { slotA:
hello
}; const resolvedProps = resolveShorthand(props.slotA); - expect(resolvedProps).toEqual({ children:
hello
}); + expect(resolvedProps).toEqual({ + children:
hello
, + [SLOT_EXTERNAL_CHILDREN_SYMBOL]:
hello
, + [SLOT_INTERNAL_CHILDREN_SYMBOL]: undefined, + }); }); it('resolves a number', () => { const props: TestProps = { slotA: 42 }; const resolvedProps = resolveShorthand(props.slotA); - expect(resolvedProps).toEqual({ children: 42 }); + expect(resolvedProps).toEqual({ + children: 42, + [SLOT_EXTERNAL_CHILDREN_SYMBOL]: 42, + [SLOT_INTERNAL_CHILDREN_SYMBOL]: undefined, + }); }); it('resolves an object as its copy', () => { diff --git a/packages/react-components/react-utilities/src/compose/resolveShorthand.ts b/packages/react-components/react-utilities/src/compose/resolveShorthand.ts index 7cf5a91f21c4be..5f657879916636 100644 --- a/packages/react-components/react-utilities/src/compose/resolveShorthand.ts +++ b/packages/react-components/react-utilities/src/compose/resolveShorthand.ts @@ -1,5 +1,6 @@ import { isValidElement } from 'react'; import type { SlotShorthandValue, UnknownSlotProps } from './types'; +import { SLOT_EXTERNAL_CHILDREN_SYMBOL, SLOT_INTERNAL_CHILDREN_SYMBOL } from './constants'; export type ResolveShorthandOptions = Required extends true ? { required: true; defaultProps?: Props } @@ -26,11 +27,17 @@ export const resolveShorthand: ResolveShorthandFunction = (value, options) => { let resolvedShorthand = {} as UnknownSlotProps; - if (typeof value === 'string' || typeof value === 'number' || Array.isArray(value) || isValidElement(value)) { + // 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 }; } - return defaultProps ? { ...defaultProps, ...resolvedShorthand } : resolvedShorthand; + return { + ...defaultProps, + ...resolvedShorthand, + [SLOT_EXTERNAL_CHILDREN_SYMBOL]: resolvedShorthand.children, + [SLOT_INTERNAL_CHILDREN_SYMBOL]: defaultProps?.children, + }; }; diff --git a/packages/react-components/react-utilities/src/index.ts b/packages/react-components/react-utilities/src/index.ts index 743ee85e64d6d1..a95032278b593c 100644 --- a/packages/react-components/react-utilities/src/index.ts +++ b/packages/react-components/react-utilities/src/index.ts @@ -1,4 +1,11 @@ -export { getSlots, resolveShorthand, isResolvedShorthand } from './compose/index'; +export { + getSlots, + getSlotsNext, + resolveShorthand, + isResolvedShorthand, + SLOT_EXTERNAL_CHILDREN_SYMBOL, + SLOT_INTERNAL_CHILDREN_SYMBOL, +} from './compose/index'; export type { ExtractSlotProps, ComponentProps, @@ -12,6 +19,7 @@ export type { SlotPropsRecord, SlotRenderFunction, SlotShorthandValue, + UnknownSlotProps, } from './compose/index'; export { diff --git a/tsconfig.base.json b/tsconfig.base.json index b994a894ae86fd..ba88b1270f9964 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -48,6 +48,7 @@ "@fluentui/react-infobutton": ["packages/react-components/react-infobutton/src/index.ts"], "@fluentui/react-input": ["packages/react-components/react-input/src/index.ts"], "@fluentui/react-jsx-runtime": ["packages/react-components/react-jsx-runtime/src/index.ts"], + "@fluentui/react-jsx-runtime/jsx-runtime": ["packages/react-components/react-jsx-runtime/src/jsx-runtime.ts"], "@fluentui/react-label": ["packages/react-components/react-label/src/index.ts"], "@fluentui/react-link": ["packages/react-components/react-link/src/index.ts"], "@fluentui/react-menu": ["packages/react-components/react-menu/src/index.ts"],