Skip to content

Commit

Permalink
Simplify prop merging with slots (#18721)
Browse files Browse the repository at this point in the history
* Implement new prop merging

* Change files

* Update packages/react-utilities/src/compose/getSlots.test.tsx

Co-authored-by: ling1726 <lingfangao@hotmail.com>

* Update packages/react-utilities/src/compose/getSlots.test.tsx

Co-authored-by: ling1726 <lingfangao@hotmail.com>

Co-authored-by: ling1726 <lingfangao@hotmail.com>
  • Loading branch information
bsunderhus and ling1726 committed Jul 2, 2021
1 parent bf0ded4 commit 6c37a1c
Show file tree
Hide file tree
Showing 11 changed files with 434 additions and 112 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "Add new prop mergin mechanism",
"packageName": "@fluentui/react-utilities",
"email": "bsunderhus@microsoft.com",
"dependentChangeType": "patch"
}
61 changes: 60 additions & 1 deletion packages/react-utilities/etc/react-utilities.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export const colGroupProperties: Record<string, number>;
// @public (undocumented)
export const colProperties: Record<string, number>;

// @public (undocumented)
export type ComponentProps<Record extends SlotPropsRecord = {}> = DefaultComponentProps & ShorthandPropsRecord<Record>;

// @public (undocumented)
export interface ComponentPropsCompat {
// (undocumented)
Expand All @@ -49,6 +52,17 @@ export interface ComponentPropsCompat {
className?: string;
}

// @public (undocumented)
export type ComponentState<Record extends SlotPropsRecord = {}> = Pick<ComponentProps<Record>, keyof DefaultComponentProps> & {
components?: {
[K in keyof Record]?: React_2.ElementType<Record[K]>;
};
} & {
components?: {
root?: React_2.ElementType;
};
} & ObjectShorthandPropsRecord<Record>;

// @public
export type ComponentStateCompat<Props, ShorthandPropNames extends keyof Props = never, DefaultedPropNames extends keyof ResolvedShorthandPropsCompat<Props, ShorthandPropNames> = never> = RequiredPropsCompat<ResolvedShorthandPropsCompat<Props, ShorthandPropNames>, DefaultedPropNames>;

Expand All @@ -58,6 +72,12 @@ export function createDescendantContext<DescendantType extends Descendant>(name:
// @public (undocumented)
export function createNamedContext<ContextValueType>(name: string, defaultValue: ContextValueType): React_2.Context<ContextValueType>;

// @public (undocumented)
export interface DefaultComponentProps {
// (undocumented)
as?: keyof JSX.IntrinsicElements;
}

// Warning: (ae-internal-missing-underscore) The name "defaultSSRContextValue" should be prefixed with an underscore because the declaration is marked as @internal
//
// @internal
Expand Down Expand Up @@ -99,6 +119,16 @@ export function getNativeElementProps<TAttributes extends React_2.HTMLAttributes
// @public
export function getNativeProps<T extends Record<string, any>>(props: Record<string, any>, allowedPropNames: string[] | Record<string, number>, excludedPropNames?: string[]): T;

// @public
export function getSlots<SlotProps extends SlotPropsRecord = {}>(state: ComponentState<any>, slotNames?: string[]): {
readonly slots: { [K in keyof SlotProps]: React_2.ElementType<SlotProps[K]>; } & {
root: React_2.ElementType;
};
readonly slotProps: SlotProps & {
root: any;
};
};

// Warning: (ae-forgotten-export) The symbol "GenericDictionary" needs to be exported by the entry point index.d.ts
//
// @public
Expand Down Expand Up @@ -144,16 +174,28 @@ export type MergePropsOptions<TState> = {
// @public
export const nullRender: () => null;

// @public (undocumented)
export type ObjectShorthandProps<Props extends {
children?: React_2.ReactNode;
} = {}> = Props & Pick<ComponentProps, 'as'> & {
children?: Props['children'] | ShorthandRenderFunction<Props>;
};

// @public (undocumented)
export type ObjectShorthandPropsCompat<TProps extends ComponentPropsCompat = {}> = TProps & Omit<ComponentPropsCompat, 'children'> & {
children?: TProps['children'] | ShorthandRenderFunctionCompat<TProps>;
};

// @public (undocumented)
export type ObjectShorthandPropsRecord<Record extends SlotPropsRecord = SlotPropsRecord> = {
[K in keyof Record]: ObjectShorthandProps<NonNullable<Record[K]>>;
};

// @public
export const olProperties: Record<string, number>;

// @public
export function omit<TObj extends Record<string, any>>(obj: TObj, exclusions: (keyof TObj)[]): TObj;
export function omit<TObj extends Record<string, any>, Exclusions extends (keyof TObj)[]>(obj: TObj, exclusions: Exclusions): Omit<TObj, Exclusions[number]>;

// @public
export const onlyChild: (child: React_2.ReactNode) => React_2.ReactElement;
Expand All @@ -177,15 +219,29 @@ export type ResolvedShorthandPropsCompat<T, K extends keyof T> = Omit<T, K> & {
[P in K]: T[P] extends ShorthandPropsCompat<infer U> ? ObjectShorthandPropsCompat<U> : T[P];
};

// @public
export function resolveShorthand<Props extends Record<string, any>>(value: ShorthandProps<Props>, defaultProps?: Props): ObjectShorthandProps<Props>;

// @public
export const resolveShorthandProps: <TProps, TShorthandPropNames extends keyof TProps>(props: TProps, shorthandPropNames: readonly TShorthandPropNames[]) => ResolvedShorthandPropsCompat<TProps, TShorthandPropNames>;

// @public
export const selectProperties: Record<string, number>;

// @public (undocumented)
export type ShorthandProps<Props = {}> = React_2.ReactChild | React_2.ReactNodeArray | React_2.ReactPortal | number | null | undefined | ObjectShorthandProps<Props>;

// @public (undocumented)
export type ShorthandPropsCompat<TProps extends ComponentPropsCompat = {}> = React_2.ReactChild | React_2.ReactNodeArray | React_2.ReactPortal | number | null | undefined | ObjectShorthandPropsCompat<TProps>;

// @public (undocumented)
export type ShorthandPropsRecord<Record extends SlotPropsRecord = SlotPropsRecord> = {
[K in keyof Record]: ShorthandProps<NonNullable<Record[K]>>;
};

// @public (undocumented)
export type ShorthandRenderFunction<Props> = (Component: React_2.ElementType<Props>, props: Props) => React_2.ReactNode;

// @public (undocumented)
export type ShorthandRenderFunctionCompat<TProps> = (Component: React_2.ElementType<TProps>, props: TProps) => React_2.ReactNode;

Expand All @@ -199,6 +255,9 @@ export type SlotPropsCompat<TSlots extends BaseSlotsCompat, TProps, TRootProps e
root: TRootProps;
};

// @public
export type SlotPropsRecord = Record<string, Record<string, any> | undefined>;

// Warning: (ae-incompatible-release-tags) The symbol "SSRContext" is marked as @public, but its signature references "SSRContextValue" which is marked as @internal
//
// @public (undocumented)
Expand Down
148 changes: 148 additions & 0 deletions packages/react-utilities/src/compose/getSlots.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import * as React from 'react';
import { getSlots } from './getSlots';
import { nullRender } from './nullRender';
import { ComponentState } from './types';

describe('getSlots', () => {
const Foo = (props: { id?: string }) => <div />;

it('returns div for root if the as prop is not provided', () => {
expect(getSlots({})).toEqual({
slots: { root: 'div' },
slotProps: { root: {} },
});
});

it('returns root slot as a span with no props', () => {
expect(getSlots({ as: 'span' } as ComponentState)).toEqual({
slots: { root: 'span' },
slotProps: { root: {} },
});
});

it('omits invalid props for the rendered element', () => {
expect(
getSlots<{}>({ as: 'button', id: 'id', href: 'href' } as ComponentState),
).toEqual({
slots: { root: 'button' },
slotProps: { root: { id: 'id' } },
});
});

it('returns root slot as an anchor, leaving the href intact', () => {
expect(getSlots({ as: 'a', id: 'id', href: 'href' } as ComponentState)).toEqual({
slots: { root: 'a' },
slotProps: { root: { id: 'id', href: 'href' } },
});
});

it('retains all props, when root is a component,', () => {
expect(
getSlots({ as: 'div', id: 'id', href: 'href', blah: 1, components: { root: Foo } } as ComponentState),
).toEqual({
slots: { root: Foo },
slotProps: { root: { as: 'div', id: 'id', href: 'href', blah: 1, components: { root: Foo } } },
});
});

it('returns null for primitive slots with no children', () => {
expect(getSlots({ as: 'div', icon: { as: 'span' } } as ComponentState<{ icon: {} }>, ['icon'])).toEqual({
slots: { root: 'div', icon: nullRender },
slotProps: { root: {} },
});
});

it('returns a component slot with no children', () => {
type ShorthandProps = {
icon: React.HTMLAttributes<HTMLElement>;
};
expect(
getSlots<ShorthandProps>({ as: 'div', icon: {}, components: { icon: Foo } }, ['icon']),
).toEqual({
slots: { root: 'div', icon: Foo },
slotProps: { root: {}, icon: {} },
});
});

it('returns slot as button and omits unsupported props (href)', () => {
type ShorthandProps = {
icon: React.ButtonHTMLAttributes<HTMLElement>;
};
expect(
getSlots<ShorthandProps>(
{
as: 'div',
icon: { as: 'button', id: 'id', children: 'children' },
},
['icon'],
),
).toEqual({
slots: { root: 'div', icon: 'button' },
slotProps: { root: {}, icon: { id: 'id', children: 'children' } },
});
});

it('returns slot as anchor and includes supported props (href)', () => {
type ShorthandProps = {
icon: React.AnchorHTMLAttributes<HTMLElement>;
};
expect(
getSlots<ShorthandProps>({ as: 'div', icon: { as: 'a', id: 'id', href: 'href', children: 'children' } }, [
'icon',
]),
).toEqual({
slots: { root: 'div', icon: 'a' },
slotProps: { root: {}, icon: { id: 'id', href: 'href', children: 'children' } },
});
});

it('returns a component and includes all props', () => {
type ShorthandProps = {
icon: React.AnchorHTMLAttributes<HTMLElement>;
};
expect(
getSlots<ShorthandProps>(
{ components: { icon: Foo }, as: 'div', icon: { id: 'id', href: 'href', children: 'children' } },
['icon'],
),
).toEqual({
slots: { root: 'div', icon: Foo },
slotProps: { root: {}, icon: { id: 'id', href: 'href', children: 'children' } },
});
});

it('can use slot children functions to replace default slot rendering', () => {
expect(
getSlots(
{
components: { icon: Foo },
as: 'div',
icon: { id: 'bar', children: (C: React.ElementType, p: {}) => <C {...p} /> },
},
['icon'],
),
).toEqual({
slots: { root: 'div', icon: React.Fragment },
slotProps: { root: {}, icon: { children: <Foo id="bar" /> } },
});
});

it('can render a primitive input with no children', () => {
type ShorthandProps = {
input: React.AnchorHTMLAttributes<HTMLElement>;
};
expect(
getSlots<ShorthandProps>({ as: 'div', input: { as: 'input', children: null } }, ['input']),
).toEqual({
slots: { root: 'div', input: 'input' },
slotProps: { root: {}, input: { children: null } },
});
});

it('should use `div` as default root element', () => {
expect(getSlots({ icon: { children: 'foo' }, customProp: 'bar' }, ['icon'])).toEqual({
slots: { root: 'div', icon: 'div' },
slotProps: { root: {}, icon: { children: 'foo' } },
});
});
});
93 changes: 93 additions & 0 deletions packages/react-utilities/src/compose/getSlots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import * as React from 'react';

import { ComponentState, ShorthandRenderFunction, SlotPropsRecord } from './types';
import { nullRender } from './nullRender';
import { getNativeElementProps, omit } from '../utils/index';

function getSlot(
defaultComponent: React.ElementType | undefined,
userComponent: keyof JSX.IntrinsicElements | undefined,
) {
if (defaultComponent === undefined || typeof defaultComponent === 'string') {
return userComponent || defaultComponent || 'div';
}
return defaultComponent;
}

/**
* Given the state and an array of slot names, will break out `slots` and `slotProps`
* collections.
*
* The root is derived from a mix of `components` props and `as` prop.
*
* Slots will render as null if they are rendered as primitives with undefined children.
*
* The slotProps will always omit the `as` prop within them, and for slots that are string
* primitives, the props will be filtered according the the slot type. For example, if the
* slot is rendered `as: 'a'`, the props will be filtered for acceptable anchor props.
*
* @param state - State including slot definitions
* @param slotNames - Name of which props are slots
* @returns An object containing the `slots` map and `slotProps` map.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getSlots<SlotProps extends SlotPropsRecord = {}>(state: ComponentState<any>, slotNames?: string[]) {
/**
* force typings on state, this should not be added directly in parameters to avoid type inference
*/
const typedState = state as ComponentState<SlotProps>;
/**
* force typings on slotNames, this should not be added directly in parameters to avoid type inference
*/
const typedSlotNames = slotNames as Array<keyof SlotProps>;

type Slots = { [K in keyof SlotProps]: React.ElementType<SlotProps[K]> };

const slots = ({
root: getSlot(typedState.components ? typedState.components.root : undefined, typedState.as),
} as Slots & { root: React.ElementType }) as Slots;

const slotProps = ({
root: typeof slots.root === 'string' ? getNativeElementProps(slots.root, typedState) : typedState,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as SlotProps & { root: any }) as SlotProps;

if (typedSlotNames) {
for (const name of typedSlotNames) {
const { as, children } = typedState[name];

const slot = getSlot(typedState.components ? typedState.components[name] : undefined, as) as Slots[typeof name];

// TODO: rethink null rendering scenario. This fails in some cases, e.g: CompoundButton, AccordionHeader
if (typeof slot === 'string' && children === undefined) {
slots[name] = nullRender;
continue;
} else {
slots[name] = slot;
}

if (typeof children === 'function') {
const render = children as ShorthandRenderFunction<SlotProps[keyof SlotProps]>;
// TODO: converting to unknown might be harmful
slotProps[name] = ({
children: render(
slots[name],
omit(typedState[name], ['children']) as ComponentState<SlotProps>[keyof SlotProps],
),
} as unknown) as SlotProps[keyof SlotProps];
slots[name] = React.Fragment;
} else {
slotProps[name] =
typeof slots[name] === 'string'
? (omit(typedState[name], ['as']) as ComponentState<SlotProps>[keyof SlotProps])
: typedState[name];
}
}
}

return {
slots: slots as Slots & { root: React.ElementType },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
slotProps: slotProps as SlotProps & { root: any },
} as const;
}

0 comments on commit 6c37a1c

Please sign in to comment.