Last active
August 22, 2024 14:22
-
-
Save CGamesPlay/5f563118283cce2273a6f89c87ddc12e to your computer and use it in GitHub Desktop.
Component factory that enables "as" props.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as React from "react"; | |
import { expect, test } from "vitest"; | |
import { makeDynamicComponent } from "./dynamicComponent"; | |
type NoProps = object; | |
const ComponentA1 = (_: { req: "a"; opt?: "a" }) => null; | |
const ComponentA2 = (_: { req: "a"; opt?: "a" }) => null; | |
const ComponentB = (_: { req: "b"; opt?: "a" }) => null; | |
const ComponentC = (_: { opt?: "a" }) => null; | |
const ComponentNoChildren = (_: { className: string; children?: never }) => | |
null; | |
function _tsPropOuterToInput() { | |
type Props = { reqComp: "comp" }; | |
const Comp = makeDynamicComponent( | |
"div", | |
({ reqComp, ...rest }: Props) => rest, | |
); | |
// @ts-expect-error: missing reqComp="comp" | |
<Comp />; | |
<Comp reqComp="comp" />; | |
// @ts-expect-error: missing req="a" | |
<Comp as={ComponentA1} reqComp="comp" />; | |
<Comp as={ComponentA1} req="a" reqComp="comp" />; | |
} | |
function _tsPropOutputToTarget() { | |
const Comp = makeDynamicComponent(ComponentA1, (p: NoProps) => ({ | |
...p, | |
req: "a" as const, | |
})); | |
<Comp />; | |
<Comp as={ComponentA2} />; | |
} | |
function _tsPropOuterToTarget() { | |
const Comp = makeDynamicComponent("div", (p: NoProps) => p); | |
// @ts-expect-error: missing req="a" | |
<Comp as={ComponentA1} />; | |
<Comp as={ComponentA1} req="a" />; | |
} | |
function _tsShadowBaseComponentProp() { | |
type Props = { req: "comp" }; | |
const Comp = makeDynamicComponent(ComponentA1, ({ req, ...rest }: Props) => ({ | |
...rest, | |
req: "a" as const, | |
})); | |
// @ts-expect-error: should be req="comp" | |
<Comp req="a" />; | |
<Comp req="comp" />; | |
<Comp as={ComponentA2} req="comp" />; | |
// @ts-expect-error: should be opt="a" | |
<Comp req="comp" opt="b" />; | |
<Comp req="comp" opt="a" />; | |
// @ts-expect-error: should be opt="a" | |
<Comp as={ComponentA2} req="comp" opt="b" />; | |
<Comp as={ComponentA2} req="comp" opt="a" />; | |
} | |
function _tsInvalidDefault() { | |
type Props = { req: "comp" }; | |
// @ts-expect-error: cannot assign "a" to "b" | |
const _Comp = makeDynamicComponent(ComponentB, ({ req, ...rest }: Props) => ({ | |
...rest, | |
req: "a" as const, | |
})); | |
} | |
function _tsRequiredPropOnDefault() { | |
type Props = { opt?: "a" }; | |
const Comp = makeDynamicComponent(ComponentA1, (p: Props) => p); | |
// @ts-expect-error: missing req="a" | |
<Comp />; | |
<Comp req="a" />; | |
// @ts-expect-error: missing req="a" | |
<Comp as={ComponentA2} />; | |
<Comp as={ComponentA2} req="a" />; | |
} | |
function _tsRequiredPropOnTarget() { | |
type Props = { opt?: "a" }; | |
const Comp = makeDynamicComponent(ComponentC, (p: Props) => p); | |
// @ts-expect-error: missing prop req | |
<Comp as={ComponentA1} />; | |
<Comp as={ComponentA1} req="a" />; | |
} | |
function _tsFullPassthrough() { | |
const Comp = makeDynamicComponent( | |
ComponentA1, | |
(p: React.ComponentProps<typeof ComponentA1>) => p, | |
); | |
// @ts-expect-error: missing req="a" | |
<Comp />; | |
<Comp req="a" />; | |
// @ts-expect-error: "b" should be "a" | |
<Comp req="a" opt="b" />; | |
<Comp req="a" opt="a" />; | |
} | |
function _tsNoChildren() { | |
type Props = { req: "comp" }; | |
const Comp = makeDynamicComponent("div", ({ req, ...rest }: Props) => ({ | |
...rest, | |
className: "comp", | |
})); | |
// @ts-expect-error: no children allowed | |
<Comp as={ComponentNoChildren} req="comp"> | |
a | |
</Comp>; | |
<Comp as={ComponentNoChildren} req="comp" />; | |
} | |
function _tsChildrenRequiredByMapper() { | |
// @ts-expect-error: default component does not accept children | |
const _Comp = makeDynamicComponent(ComponentNoChildren, (p: object) => ({ | |
...p, | |
children: "a", | |
})); | |
const Comp = makeDynamicComponent("div", (p: object) => ({ | |
...p, | |
children: "a", | |
})); | |
// @ts-expect-error: component does not accept children | |
<Comp as={ComponentNoChildren} />; | |
<Comp />; | |
} | |
test("dynamicComponent", () => { | |
type ButtonProps = { | |
intent?: "primary" | "secondary"; | |
}; | |
const Button = makeDynamicComponent( | |
"button", | |
({ intent = "primary", ...rest }: ButtonProps) => { | |
return { ...rest, className: intent }; | |
}, | |
); | |
// These are very hacky tests to avoid a dep on a renderer. | |
expect(Button({})).toMatchObject({ props: { className: "primary" } }); | |
expect(Button({ intent: "secondary" })).toMatchObject({ | |
props: { className: "secondary" }, | |
}); | |
expect(Button({ id: "btn" })).toMatchObject({ | |
props: { id: "btn", className: "primary" }, | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as React from "react"; | |
type SimpleMerge<Destination, Source> = { | |
[Key in keyof Destination as Key extends keyof Source | |
? never | |
: Key]: Destination[Key]; | |
} & Source; | |
type ValidComponentType<C extends React.ComponentType<any>, OutP> = | |
C extends React.ComponentType<infer P> | |
? OutP extends Partial<P> | |
? C | |
: never | |
: never; | |
type ValidIntrinsicElement<C extends keyof JSX.IntrinsicElements, OutP> = | |
OutP extends Partial<JSX.IntrinsicElements[C]> ? C : never; | |
type ValidElement<C, OutP> = C extends keyof JSX.IntrinsicElements | |
? ValidIntrinsicElement<C, OutP> | |
: C extends React.ComponentType<any> | |
? ValidComponentType<C, OutP> | |
: "invalid element type"; | |
interface PolymorphicComponent<InP, OutP, DefC extends React.ElementType> { | |
/** Overload which does not receive "as" */ | |
( | |
_: SimpleMerge< | |
Omit<React.ComponentPropsWithoutRef<DefC>, keyof OutP>, | |
Omit<InP, "as"> | |
>, | |
): JSX.Element; | |
/** Overload which receives "as" */ | |
<C extends React.ElementType>( | |
_: SimpleMerge< | |
Omit<React.ComponentPropsWithoutRef<C>, keyof OutP>, | |
SimpleMerge<InP, { as: ValidElement<C, OutP> }> | |
>, | |
): JSX.Element; | |
} | |
/** | |
* Component factory that uses an "as" prop to override the underlying | |
* component that will be generated. | |
* | |
* Care has been taken to ensure that this is fully type-checked: | |
* 1. The types of the prop mapper function are the main source of truth. | |
* 2. The target component always receives all props returned by the mapper. | |
* 3. Props not returned by the mapper are exposed on the outer component. | |
* 4. Props received by the mapper are exposed on the outer component. | |
*/ | |
export function makeDynamicComponent< | |
InP, | |
OutP, | |
DefC extends keyof JSX.IntrinsicElements, | |
>( | |
defaultComponent: ValidIntrinsicElement<DefC, OutP>, | |
mapper: (_: InP) => OutP, | |
): PolymorphicComponent<InP, OutP, DefC>; | |
export function makeDynamicComponent< | |
InP, | |
OutP, | |
DefC extends React.ComponentType<any>, | |
>( | |
defaultComponent: ValidComponentType<DefC, OutP>, | |
mapper: (_: InP) => OutP, | |
): PolymorphicComponent<InP, OutP, DefC>; | |
export function makeDynamicComponent(defaultComponent: any, mapper: any) { | |
return (props: any) => { | |
const Component = props.as ?? defaultComponent; | |
return <Component {...mapper(props)} />; | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment