Skip to content

Commit

Permalink
Add passThroughProps option
Browse files Browse the repository at this point in the history
  • Loading branch information
rodleviton committed Oct 1, 2024
1 parent ca8497c commit b10f28f
Show file tree
Hide file tree
Showing 14 changed files with 321 additions and 95 deletions.
6 changes: 4 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
{
"eslint.runtime": "node",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"css.lint.unknownAtRules": "ignore",
"tailwindCSS.experimental.classRegex": [
["recast\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
[
"recast\\(([^)]*)\\)",
"(?<!(?:breakpoints|defaults|passThroughProps):\\s*(?:\\[|\\{)[^\\]}]*)[\"'`]([^\"'`]*).*?[\"'`]"
]
],
"typescript.tsdk": "node_modules/typescript/lib",
"prettier.printWidth": 80
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
import { SectionWrapperPrimitive } from "@rpxl/recast-primitives";
import { recast } from "@rpxl/recast";

export const SectionWrapper = recast(SectionWrapperPrimitive, {
base: {
root: "flex w-full justify-center",
inner: "relative w-full max-w-2xl px-4",
},
});
// export const SectionWrapper = recast(SectionWrapperPrimitive, {
// base: {
// root: "flex w-full justify-center",
// inner: "relative w-full max-w-2xl px-4",
// },
// });
5 changes: 4 additions & 1 deletion packages/docs/src/pages/tailwind-css.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ To get Tailwind CSS IntelliSense working with Recast in VS Code:
```json
{
"tailwindCSS.experimental.classRegex": [
["recast\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
[
"recast\\(([^)]*)\\)",
"(?<!(?:breakpoints|defaults):\\s*(?:\\[|\\{)[^\\]}]*)[\"'`]([^\"'`]*).*?[\"'`]"
]
]
}
```
Expand Down
67 changes: 66 additions & 1 deletion packages/lib/src/__tests__/recast.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ describe("recast function", () => {
},
},
},
cn,
{ mergeFn: cn },
);

const { container: container1 } = render(<Button className="bg-red-500">Test</Button>);
Expand Down Expand Up @@ -692,4 +692,69 @@ describe("recast function", () => {
<ButtonWithoutBreakpoints size={{ default: "sm", md: "lg" }} />;
});
});

describe("passThroughProps functionality", () => {
type RecastClasses = {
className: string;
cls: Record<string, string>;
};

const TestComponent: React.FC<{
className?: string;
size?: string;
disabled?: boolean;
recastClasses?: RecastClasses;
children?: React.ReactNode;
}> = ({ className, size, disabled, recastClasses, children }) => (
<div className={className} data-testid="test-component" data-size={size} data-disabled={disabled}>
{children}
<span data-testid="recast-classes">{JSON.stringify(recastClasses)}</span>
</div>
);

const styles = {
variants: {
size: { sm: "text-sm", md: "text-md", lg: "text-lg" },
},
modifiers: {
disabled: "opacity-50",
},
};

it("does not pass through props by default", () => {
const ThemedComponent = recast(TestComponent, styles);
render(
<ThemedComponent size="lg" disabled>
Test
</ThemedComponent>,
);
const component = screen.getByTestId("test-component");
expect(component).not.toHaveAttribute("data-size");
expect(component).not.toHaveAttribute("data-disabled");
});

it("passes through specified props when configured", () => {
const ThemedComponent = recast(TestComponent, styles, { passThroughProps: ["size", "disabled"] });
render(
<ThemedComponent size="lg" disabled>
Test
</ThemedComponent>,
);
const component = screen.getByTestId("test-component");
expect(component).toHaveAttribute("data-size", "lg");
expect(component).toHaveAttribute("data-disabled", "true");
});

it("only passes through specified props", () => {
const ThemedComponent = recast(TestComponent, styles, { passThroughProps: ["size"] });
render(
<ThemedComponent size="lg" disabled>
Test
</ThemedComponent>,
);
const component = screen.getByTestId("test-component");
expect(component).toHaveAttribute("data-size", "lg");
expect(component).not.toHaveAttribute("data-disabled");
});
});
});
49 changes: 42 additions & 7 deletions packages/lib/src/recast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ import type {
import { getRecastClasses } from "./utils/getRecastClasses.js";
import { omit, isEmptyObject, isString, isNonNullObject } from "./utils/common.js";

/**
* Configuration options for the recast function.
*/
type RecastConfig<V, M> = {
/**
* An array of prop names that should be passed through to the base component.
* These can include both variant and modifier props.
*/
passThroughProps?: (keyof V | keyof M)[];

/**
* Optional function to merge classNames.
* If not provided, Recast uses a default merging strategy.
*/
mergeFn?: MergeFn;
};

/**
* Creates a new component with theming capabilities.
*
Expand All @@ -23,18 +40,20 @@ import { omit, isEmptyObject, isString, isNonNullObject } from "./utils/common.j
* @template B - The breakpoint options
* @param {React.ComponentType<P>} Component - The base component to add theming to
* @param {RecastStyles<V, M, Pick<P, "cls">, B>} styles - The styles to apply to the component
* @param {MergeFn} [mergeFn] - Optional function to merge props
* @param {RecastConfig<P, V, M>} [config] - Optional configuration for recast
* @returns {RecastComponent<P, V, M, B>} A new component with theming capabilities
*/
export function recast<
P extends RecastProps<P>,
V extends { [K in keyof V]: { [S in keyof V[K]]: string | string[] } },
M extends { [K in keyof M]: string | string[] },
B extends keyof RecastBreakpoints | never = never,
>(Component: React.ComponentType<P>, styles: RecastStyles<V, M, Pick<P, "cls">, B>, mergeFn?: MergeFn) {
type Props = Omit<P, keyof ExtractVariantProps<V, B> | keyof ExtractModifierProps<M>> &
ExtractVariantProps<V, B> &
ExtractModifierProps<M> & { className?: string };
>(Component: React.ComponentType<P>, styles: RecastStyles<V, M, Pick<P, "cls">, B>, config: RecastConfig<V, M> = {}) {
type BaseProps = Omit<P, keyof ExtractVariantProps<V, B> | keyof ExtractModifierProps<M>>;
type VariantProps = ExtractVariantProps<V, B>;
type ModifierProps = ExtractModifierProps<M>;

type Props = BaseProps & VariantProps & ModifierProps & { className?: string };

const processModifiers = (props: Record<string, unknown>): RelaxedModifierProps => {
const modifierKeys = Object.keys(styles.modifiers || {});
Expand Down Expand Up @@ -76,13 +95,29 @@ export function recast<
breakpoints: styles.breakpoints,
});

const mergedClassName = mergeFn
? mergeFn(recastClassesClassName, className)
const mergedClassName = config?.mergeFn
? config.mergeFn(recastClassesClassName, className)
: `${recastClassesClassName} ${className || ""}`.trim();

type PassThroughProp = keyof V | keyof M | keyof P;

function getPassThroughValue(prop: PassThroughProp): unknown {
return props[prop];
}

const passThroughProps = config.passThroughProps
? Object.fromEntries(
config.passThroughProps
.filter((prop) => prop in props)
.map((prop) => [prop, getPassThroughValue(prop)])
.filter(([, value]) => value !== undefined),
)
: {};

return (
<Component
{...(propsWithoutModifiersAndVariants as P)}
{...passThroughProps}
ref={ref}
className={mergedClassName}
cls={isEmptyObject(cls) ? undefined : cls}
Expand Down
5 changes: 4 additions & 1 deletion packages/recast-tailwind-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ To get Tailwind CSS IntelliSense working with Recast in VS Code:
```json
{
"tailwindCSS.experimental.classRegex": [
["recast\\(([^)]_)\\)", "[\"'`]([^\"'`]_).*?[\"'`]"]
[
"recast\\(([^)]*)\\)",
"(?<!(?:breakpoints|defaults):\\s*(?:\\[|\\{)[^\\]}]*)[\"'`]([^\"'`]*).*?[\"'`]"
]
]
}
```
Expand Down
84 changes: 67 additions & 17 deletions packages/sandbox/app/components/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,84 @@ import React, { ButtonHTMLAttributes, forwardRef } from "react";

type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
asChild?: boolean;
block?: boolean;
george?: string;
size?: string;
};

const Component = forwardRef<HTMLButtonElement, Props>(
({ asChild = false, ...props }, ref) => {
({ block, size, george, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";

console.log(block);
console.log(george);
console.log(size);

return <Comp ref={ref} {...props} />;
}
);

Component.displayName = "ButtonPrimitive";

export const Button = recast(Component, {
defaults: {
variants: { variant: "primary", size: "md" },
},
base: ["flex", "items-center", "justify-center", "p-8"],
variants: {
variant: {
primary: "bg-blue-500 text-white",
secondary: ["bg-red-500", "text-white"],
tertiary: ["bg-green-500", "text-white"],
export const Button = recast(
Component,
{
defaults: {
variants: { variant: "primary", size: "md" },
},
base: ["flex", "items-center", "justify-center", "p-8"],
variants: {
/**
* Defines the intent of the button.
*/
variant: {
primary: "bg-blue-500 text-white",
secondary: [
"bg-red-500",
"text-white",
"text-sm",
"flex",
"items-center",
"justify-center",
"p-8",
],
tertiary: ["bg-green-500", "text-white"],
},
/**
* Defines the size of the button.
*/
size: {
/** Small size - typically used for compact layouts */
tiny: "text-sm",
/** Medium size - the default size for most contexts */
md: "text-base",
/** Large size - used for emphasis or calls to action */
huge: "text-2xl",
},
george: {
/** Button A */
sms: "max-w-4xl",
/** B */
md: "max-w-6xl",
/** C */
lg: "max-w-7xl",
},
},
size: {
sm: "text-sm",
md: "text-md",
lg: "text-2xl",
modifiers: {
/** Large size - used for emphasis or calls to action */
block: "w-full",
},
conditionals: [
/** This condional is really cool. */
{
variants: { size: "tiny", variant: ["primary", "secondary"] },
modifiers: ["block"],
className: "border-4 border-blue-500 text-white",
},
],
breakpoints: ["sm", "md", "lg"],
},
breakpoints: ["sm", "md", "lg"],
});
{
passThroughProps: ["size", "george"],
}
);
1 change: 1 addition & 0 deletions packages/sandbox/app/components/section-wrapper/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./section-wrapper";
36 changes: 36 additions & 0 deletions packages/sandbox/app/components/section-wrapper/section-wrapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client";

import { SectionWrapperPrimitive } from "@rpxl/recast-primitives";
import { recast } from "@rpxl/recast";

export const SectionWrapperNew = recast(SectionWrapperPrimitive, {
defaults: { variants: { george: "md" } },
base: {
root: "flex w-full justify-center overflow-hidden",
inner: "relative w-full px-4",
},
variants: {
kevin: {
/** E */
arnold: { root: ["bg-red-500 text-sm"], inner: "max-w-4xl" },
/** F */
baxter: { root: ["bg-red-500 text-sm"], inner: "max-w-6xl" },
/** G */
smith: { root: ["bg-red-500 text-sm"], inner: "max-w-7xl" },
},
george: {
/** SectionWrapper A */
sms: { inner: "max-w-4xl" },
/** B */
md: { inner: "max-w-6xl" },
/** C */
lg: { inner: "max-w-7xl" },
},
},
modifiers: {
/** Slim container override (672px) */
slim: {
inner: "!max-w-2xl",
},
},
});
1 change: 1 addition & 0 deletions packages/sandbox/app/components/stack/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./stack";
Loading

0 comments on commit b10f28f

Please sign in to comment.