Skip to content

Instantly share code, notes, and snippets.

@CGamesPlay
Last active August 22, 2024 14:22
Show Gist options
  • Save CGamesPlay/5f563118283cce2273a6f89c87ddc12e to your computer and use it in GitHub Desktop.
Save CGamesPlay/5f563118283cce2273a6f89c87ddc12e to your computer and use it in GitHub Desktop.
Component factory that enables "as" props.
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" },
});
});
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