Skip to content

Commit

Permalink
feat: add additional props support
Browse files Browse the repository at this point in the history
  • Loading branch information
gregberge committed Dec 23, 2023
1 parent fafca5d commit 36f462b
Show file tree
Hide file tree
Showing 9 changed files with 185 additions and 45 deletions.
5 changes: 4 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@
},
"parser": "@typescript-eslint/parser",
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"plugins": ["@typescript-eslint"]
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/ban-types": "off"
}
}
3 changes: 2 additions & 1 deletion .npmignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/*
!/dist/**/*.{ts,js}
!/dist/index.d.ts
!/dist/index.mjs
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

## Features

- ⚡️ Lightweight — only 0.42kb
- ⚡️ Lightweight — only 0.44kb
- ✨ Autocompletion in all editors
- 🎨 Adapt the style based on props
- ♻️ Reuse classes with `asChild` prop
Expand Down
36 changes: 26 additions & 10 deletions rollup.config.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { swc } from "rollup-plugin-swc3";
import ts from "rollup-plugin-ts";

const swcPlugin = swc({
tsconfig: false,
jsc: {
parser: {
syntax: "typescript",
const swcPlugin = (minify) =>
swc({
tsconfig: false,
minify,
jsc: {
parser: {
syntax: "typescript",
},
target: "es2018",
},
target: "es2018",
},
});
});

const tsPlugin = ts({ transpiler: "swc" });

Expand All @@ -26,7 +28,21 @@ const buildEs = ({
file: output,
format: "es",
},
plugins: [swcPlugin],
plugins: [swcPlugin(false)],
});

const buildMin = ({
input = "src/index.tsx",
output = "dist/index.min.mjs",
external = ignoreRelative,
} = {}) => ({
input,
external,
output: {
file: output,
format: "es",
},
plugins: [swcPlugin(true)],
});

const buildTypes = ({
Expand All @@ -43,4 +59,4 @@ const buildTypes = ({
plugins: [tsPlugin],
});

export default [buildEs(), buildTypes()];
export default [buildEs(), buildMin(), buildTypes()];
47 changes: 46 additions & 1 deletion src/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, test, beforeEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { cva, VariantProps } from "class-variance-authority";
import { twc, createTwc } from "./index";
import { twc, createTwc, TwcComponentProps } from "./index";
import * as React from "react";
import { twMerge } from "tailwind-merge";

Expand All @@ -25,6 +25,51 @@ describe("twc", () => {
expect(title.dataset.foo).toBe("bar");
});

test("supports attrs", () => {
const Checkbox = twc.input.attrs({ type: "checkbox" })`text-xl`;
render(<Checkbox data-testid="checkbox" />);
const checkbox = screen.getByTestId("checkbox");
expect(checkbox).toBeDefined();
expect(checkbox.getAttribute("type")).toBe("checkbox");
});

test("supports attrs from props", () => {
const Checkbox = twc.input.attrs<{ $type?: string }>((props) => ({
type: props.$type || "checkbox",
"data-testid": "checkbox",
}))`text-xl`;
render(<Checkbox />);
const checkbox = screen.getByTestId("checkbox");
expect(checkbox).toBeDefined();
expect(checkbox.getAttribute("type")).toBe("checkbox");

cleanup();

render(<Checkbox $type="radio" />);
const radio = screen.getByTestId("checkbox");
expect(radio).toBeDefined();
expect(radio.getAttribute("type")).toBe("radio");
});

test("complex attrs support", () => {
type LinkProps = TwcComponentProps<"a"> & { $external?: boolean };

// Accept an $external prop that adds `target` and `rel` attributes if present
const Link = twc.a.attrs<LinkProps>((props) =>
props.$external ? { target: "_blank", rel: "noopener noreferrer" } : {},
)`appearance-none size-4 border-2 border-blue-500 rounded-sm bg-white`;

render(
<Link $external href="https://example.com">
My link
</Link>,
);
const link = screen.getByText("My link");
expect(link).toBeDefined();
expect(link.getAttribute("target")).toBe("_blank");
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
});

test("merges classes", () => {
const Title = twc.h1`text-xl`;
render(<Title className="font-medium">Title</Title>);
Expand Down
93 changes: 63 additions & 30 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ type Template<
TComponent extends React.ElementType,
TCompose extends AbstractCompose,
TExtraProps,
> = <TProps = undefined>(
TParentProps = undefined,
> = <TProps = TParentProps>(
strings:
| TemplateStringsArray
| ((
Expand All @@ -37,10 +38,24 @@ type Template<
ResultProps<TComponent, TProps, TExtraProps, TCompose>
>;

type FirstLevelTemplate<
TComponent extends React.ElementType,
TCompose extends AbstractCompose,
TExtraProps,
> = Template<TComponent, TCompose, TExtraProps> & {
attrs: <TProps = undefined>(
attrs:
| Record<string, any>
| ((
props: ResultProps<TComponent, TProps, TExtraProps, TCompose>,
) => Record<string, any>),
) => Template<TComponent, TCompose, TExtraProps, TProps>;
};

type Twc<TCompose extends AbstractCompose> = (<T extends React.ElementType>(
component: T,
) => Template<T, TCompose, undefined>) & {
[Key in keyof HTMLElementTagNameMap]: Template<
) => FirstLevelTemplate<T, TCompose, undefined>) & {
[Key in keyof HTMLElementTagNameMap]: FirstLevelTemplate<
Key,
TCompose,
{ asChild?: boolean }
Expand Down Expand Up @@ -81,44 +96,62 @@ function filterProps(
return filteredProps;
}

type Attributes = Record<string, any> | ((props: any) => Record<string, any>);

export const createTwc = <TCompose extends AbstractCompose = typeof clsx>(
config: Config<TCompose> = {},
) => {
const compose = config.compose ?? clsx;
const compose = config.compose || clsx;
const shouldForwardProp =
config.shouldForwardProp ?? ((prop) => prop[0] !== "$");
const template =
(Component: React.ElementType) =>
// eslint-disable-next-line @typescript-eslint/ban-types
(stringsOrFn: TemplateStringsArray | Function, ...values: any[]) => {
const isFn = typeof stringsOrFn === "function";
const twClassName = isFn
? ""
: String.raw({ raw: stringsOrFn }, ...values);
return React.forwardRef((props: any, ref) => {
const { className, asChild, ...rest } = props;
const filteredProps = filterProps(rest, shouldForwardProp);
const Comp = asChild ? Slot : Component;
return (
<Comp
ref={ref}
className={compose(
isFn ? stringsOrFn(props) : twClassName,
className,
)}
{...filteredProps}
/>
);
});
config.shouldForwardProp || ((prop) => prop[0] !== "$");
const wrap = (Component: React.ElementType) => {
const createTemplate = (attrs?: Attributes) => {
const template = (
stringsOrFn: TemplateStringsArray | Function,
...values: any[]
) => {
const isFn = typeof stringsOrFn === "function";
const twClassName = isFn
? ""
: String.raw({ raw: stringsOrFn }, ...values);
return React.forwardRef((p: any, ref) => {
const { className, asChild, ...rest } = p;
const rp =
typeof attrs === "function" ? attrs(p) : attrs ? attrs : {};
const fp = filterProps({ ...rp, ...rest }, shouldForwardProp);
const Comp = asChild ? Slot : Component;
return (
<Comp
ref={ref}
className={compose(
isFn ? stringsOrFn(p) : twClassName,
className,
)}
{...fp}
/>
);
});
};

if (attrs === undefined) {
template.attrs = (attrs: Attributes) => {
return createTemplate(attrs);
};
}

return template;
};

return createTemplate();
};

return new Proxy(
(component: React.ComponentType) => {
return template(component);
return wrap(component);
},
{
get(_, name) {
return template(name as keyof JSX.IntrinsicElements);
return wrap(name as keyof JSX.IntrinsicElements);
},
},
) as any as Twc<TCompose>;
Expand Down
34 changes: 34 additions & 0 deletions website/pages/docs/guides/additional-props.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Additional props

To avoid unnecessary wrappers that just pass on some props to the rendered component or element, you can use the `.attrs` constructor. It allows you to attach additional props (or "attributes") to a component.

## Usage

Create an `input` of type "checkbox" with `twc`:

```ts
const Checkbox = twc.input.attrs({
type: "checkbox",
})`appearance-none size-4 border-2 border-blue-500 rounded-sm bg-white`;
```

## Adapting attributes based on props

`attrs` accepts a function to generate attributes based on input props.

In this example, we create an anchor that accepts an `$external` prop and adds `target` and `rel` attributes based on its value.

```ts
import { twc, TwcComponentProps } from "react-twc";

type AnchorProps = TwcComponentProps<"a"> & { $external?: boolean };

// Accept an $external prop that adds `target` and `rel` attributes if present
const Anchor = twc.a.attrs<AnchorProps>((props) =>
props.$external ? { target: "_blank", rel: "noopener noreferrer" } : {},
)`appearance-none size-4 border-2 border-blue-500 rounded-sm bg-white`;

render(
<Anchor $external href="https://cva.style">Class Variance Authority</Link>
);
```
8 changes: 8 additions & 0 deletions website/pages/docs/guides/styling-any-component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,11 @@ export default () => (
</HoverCard.Root>
);
```

If you need to specify some default props, you can use [additional props](/guides/additional-props). For example you can define the `sideOffset` by default using `attrs` constructor:

```tsx
const HoverCardContent = twc(HoverCard.Content).attrs({
sideOffset: 5,
})`data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] rounded-md bg-white p-5 shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] data-[state=open]:transition-all`;
```
2 changes: 1 addition & 1 deletion website/pages/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const Card = twc.div`rounded-lg border bg-slate-100 text-white shadow-sm`;

With just one single line of code, you can create a reusable component with all these amazing features out-of-the-box:

- ⚡️ Lightweight — only 0.42kb
- ⚡️ Lightweight — only 0.44kb
- ✨ Autocompletion in all editors
- 🎨 Adapt the style based on props
- ♻️ Reuse classes with `asChild` prop
Expand Down

0 comments on commit 36f462b

Please sign in to comment.