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/etc/react-jsx-runtime.api.md b/packages/react-components/react-jsx-runtime/etc/react-jsx-runtime.api.md index 85ea2e96e04b47..9ec8f93f3f9b8c 100644 --- a/packages/react-components/react-jsx-runtime/etc/react-jsx-runtime.api.md +++ b/packages/react-components/react-jsx-runtime/etc/react-jsx-runtime.api.md @@ -4,6 +4,14 @@ ```ts +import { Fragment } from 'react'; +import * as React_2 from 'react'; + +// @public (undocumented) +export function createElement

(type: React_2.ElementType

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

| null; + +export { Fragment } + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/react-components/react-jsx-runtime/package.json b/packages/react-components/react-jsx-runtime/package.json index 8ad4f1b06971dc..d43692a59d23fb 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.4", "@swc/helpers": "^0.4.14" }, "peerDependencies": { diff --git a/packages/react-components/react-jsx-runtime/src/createElement.test.tsx b/packages/react-components/react-jsx-runtime/src/createElement.test.tsx new file mode 100644 index 00000000000000..ee4e3221aa7726 --- /dev/null +++ b/packages/react-components/react-jsx-runtime/src/createElement.test.tsx @@ -0,0 +1,168 @@ +/* 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 { createElement } from './createElement'; + +describe('createElement', () => { + 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 = { slot: Slot<'div'> }; + type TestComponentState = ComponentState; + type TestComponentProps = ComponentProps>; + + const TestComponent = (props: TestComponentProps) => { + const state: TestComponentState = { + components: { slot: 'div' }, + + slot: resolveShorthand(props.slot, { + defaultProps: { children: 'Default Children', id: 'slot' }, + }), + }; + const { slots, slotProps } = getSlotsNext(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: Slot<'div'>; inner: Slot<'div'> }; + type TestComponentState = ComponentState; + type TestComponentProps = ComponentProps>; + + const TestComponent = (props: TestComponentProps) => { + const state: TestComponentState = { + components: { inner: 'div', outer: 'div' }, + + inner: resolveShorthand(props.inner, { defaultProps: { id: 'inner' } }), + outer: resolveShorthand(props.outer, { defaultProps: { id: 'outer' } }), + }; + const { slots, slotProps } = getSlotsNext(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 +
+
+
+ `); + }); + }); +}); diff --git a/packages/react-components/react-jsx-runtime/src/createElement.ts b/packages/react-components/react-jsx-runtime/src/createElement.ts new file mode 100644 index 00000000000000..a891075365f04c --- /dev/null +++ b/packages/react-components/react-jsx-runtime/src/createElement.ts @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { SlotRenderFunction, UnknownSlotProps, SLOT_RENDER_FUNCTION_SYMBOL } from '@fluentui/react-utilities'; + +type WithMetadata = Props & { + [SLOT_RENDER_FUNCTION_SYMBOL]: SlotRenderFunction; +}; + +export function createElement

( + type: React.ElementType

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

| null { + if (!isSlotComponent(props)) { + return React.createElement(type, props, ...children); + } + + const result = normalizeRenderFunction(props, children); + return React.createElement( + React.Fragment, + {}, + result.renderFunction(type, { ...result.props, children: result.children }), + ) as React.ReactElement

; +} + +function normalizeRenderFunction( + propsWithMetadata: WithMetadata, + overrideChildren?: React.ReactNode[], +): { + props: Props; + children: React.ReactNode; + renderFunction: SlotRenderFunction; +} { + const { [SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction, children: externalChildren, ...props } = propsWithMetadata; + + const children: React.ReactNode = + Array.isArray(overrideChildren) && overrideChildren.length > 0 + ? React.createElement(React.Fragment, {}, ...overrideChildren) + : externalChildren; + + return { + children, + renderFunction, + props: props as UnknownSlotProps as Props, + }; +} + +export function isSlotComponent(props?: Props | null): props is WithMetadata { + return Boolean(props?.hasOwnProperty(SLOT_RENDER_FUNCTION_SYMBOL)); +} diff --git a/packages/react-components/react-jsx-runtime/src/index.ts b/packages/react-components/react-jsx-runtime/src/index.ts index aacbad0068e241..0276e539054a37 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 { createElement } from './createElement'; +export { Fragment } from 'react'; 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..a03ffd720af387 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,9 @@ 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_RENDER_FUNCTION_SYMBOL: unique symbol; + // @public export type SlotClassNames = { [SlotName in keyof Slots]-?: string; @@ -175,6 +184,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..f5c1593f9203ae --- /dev/null +++ b/packages/react-components/react-utilities/src/compose/constants.ts @@ -0,0 +1,5 @@ +/** + * @internal + * Internal reference for the render function + */ +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 01f3dcef509058..2799542d263088 100644 --- a/packages/react-components/react-utilities/src/compose/getSlots.ts +++ b/packages/react-components/react-utilities/src/compose/getSlots.ts @@ -8,6 +8,7 @@ import type { SlotPropsRecord, SlotRenderFunction, UnionToIntersection, + UnknownSlotProps, } from './types'; export type Slots = { @@ -19,7 +20,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 +69,13 @@ 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]!; + + const { children, as: asProp, ...rest } = props; const slot = ( state.components?.[slotName] === undefined || typeof state.components[slotName] === 'string' @@ -89,8 +93,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..4d564af2496234 100644 --- a/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx +++ b/packages/react-components/react-utilities/src/compose/resolveShorthand.test.tsx @@ -15,21 +15,27 @@ describe('resolveShorthand', () => { const props: TestProps = { slotA: 'hello' }; const resolvedProps = resolveShorthand(props.slotA); - expect(resolvedProps).toEqual({ children: 'hello' }); + expect(resolvedProps).toEqual({ + children: 'hello', + }); }); 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
, + }); }); it('resolves a number', () => { const props: TestProps = { slotA: 42 }; const resolvedProps = resolveShorthand(props.slotA); - expect(resolvedProps).toEqual({ children: 42 }); + expect(resolvedProps).toEqual({ + children: 42, + }); }); 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..76b109240de9f9 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 type { SlotRenderFunction, SlotShorthandValue, UnknownSlotProps } from './types'; +import { SLOT_RENDER_FUNCTION_SYMBOL } from './constants'; export type ResolveShorthandOptions = Required extends true ? { required: true; defaultProps?: Props } @@ -24,13 +25,26 @@ export const resolveShorthand: ResolveShorthandFunction = (value, options) => { return undefined; } - let resolvedShorthand = {} as UnknownSlotProps; + let resolvedShorthand: UnknownSlotProps & { + [SLOT_RENDER_FUNCTION_SYMBOL]?: SlotRenderFunction; + } = {}; - 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 }; + resolvedShorthand = value; } - return defaultProps ? { ...defaultProps, ...resolvedShorthand } : resolvedShorthand; + resolvedShorthand = { + ...defaultProps, + ...resolvedShorthand, + }; + + if (typeof resolvedShorthand.children === 'function') { + resolvedShorthand[SLOT_RENDER_FUNCTION_SYMBOL] = resolvedShorthand.children as SlotRenderFunction; + resolvedShorthand.children = defaultProps?.children; + } + + return resolvedShorthand; }; diff --git a/packages/react-components/react-utilities/src/index.ts b/packages/react-components/react-utilities/src/index.ts index 743ee85e64d6d1..c2d0023253e0b3 100644 --- a/packages/react-components/react-utilities/src/index.ts +++ b/packages/react-components/react-utilities/src/index.ts @@ -1,4 +1,10 @@ -export { getSlots, resolveShorthand, isResolvedShorthand } from './compose/index'; +export { + getSlots, + getSlotsNext, + resolveShorthand, + isResolvedShorthand, + SLOT_RENDER_FUNCTION_SYMBOL, +} from './compose/index'; export type { ExtractSlotProps, ComponentProps, @@ -12,6 +18,7 @@ export type { SlotPropsRecord, SlotRenderFunction, SlotShorthandValue, + UnknownSlotProps, } from './compose/index'; export {