Skip to content

Commit 36f462b

Browse files
committed
feat: add additional props support
1 parent fafca5d commit 36f462b

9 files changed

+185
-45
lines changed

.eslintrc.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,8 @@
55
},
66
"parser": "@typescript-eslint/parser",
77
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
8-
"plugins": ["@typescript-eslint"]
8+
"plugins": ["@typescript-eslint"],
9+
"rules": {
10+
"@typescript-eslint/ban-types": "off"
11+
}
912
}

.npmignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
/*
2-
!/dist/**/*.{ts,js}
2+
!/dist/index.d.ts
3+
!/dist/index.mjs

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
## Features
1010

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

rollup.config.js

+26-10
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import { swc } from "rollup-plugin-swc3";
22
import ts from "rollup-plugin-ts";
33

4-
const swcPlugin = swc({
5-
tsconfig: false,
6-
jsc: {
7-
parser: {
8-
syntax: "typescript",
4+
const swcPlugin = (minify) =>
5+
swc({
6+
tsconfig: false,
7+
minify,
8+
jsc: {
9+
parser: {
10+
syntax: "typescript",
11+
},
12+
target: "es2018",
913
},
10-
target: "es2018",
11-
},
12-
});
14+
});
1315

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

@@ -26,7 +28,21 @@ const buildEs = ({
2628
file: output,
2729
format: "es",
2830
},
29-
plugins: [swcPlugin],
31+
plugins: [swcPlugin(false)],
32+
});
33+
34+
const buildMin = ({
35+
input = "src/index.tsx",
36+
output = "dist/index.min.mjs",
37+
external = ignoreRelative,
38+
} = {}) => ({
39+
input,
40+
external,
41+
output: {
42+
file: output,
43+
format: "es",
44+
},
45+
plugins: [swcPlugin(true)],
3046
});
3147

3248
const buildTypes = ({
@@ -43,4 +59,4 @@ const buildTypes = ({
4359
plugins: [tsPlugin],
4460
});
4561

46-
export default [buildEs(), buildTypes()];
62+
export default [buildEs(), buildMin(), buildTypes()];

src/index.test.tsx

+46-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, test, beforeEach } from "vitest";
22
import { render, screen, cleanup } from "@testing-library/react";
33
import { cva, VariantProps } from "class-variance-authority";
4-
import { twc, createTwc } from "./index";
4+
import { twc, createTwc, TwcComponentProps } from "./index";
55
import * as React from "react";
66
import { twMerge } from "tailwind-merge";
77

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

28+
test("supports attrs", () => {
29+
const Checkbox = twc.input.attrs({ type: "checkbox" })`text-xl`;
30+
render(<Checkbox data-testid="checkbox" />);
31+
const checkbox = screen.getByTestId("checkbox");
32+
expect(checkbox).toBeDefined();
33+
expect(checkbox.getAttribute("type")).toBe("checkbox");
34+
});
35+
36+
test("supports attrs from props", () => {
37+
const Checkbox = twc.input.attrs<{ $type?: string }>((props) => ({
38+
type: props.$type || "checkbox",
39+
"data-testid": "checkbox",
40+
}))`text-xl`;
41+
render(<Checkbox />);
42+
const checkbox = screen.getByTestId("checkbox");
43+
expect(checkbox).toBeDefined();
44+
expect(checkbox.getAttribute("type")).toBe("checkbox");
45+
46+
cleanup();
47+
48+
render(<Checkbox $type="radio" />);
49+
const radio = screen.getByTestId("checkbox");
50+
expect(radio).toBeDefined();
51+
expect(radio.getAttribute("type")).toBe("radio");
52+
});
53+
54+
test("complex attrs support", () => {
55+
type LinkProps = TwcComponentProps<"a"> & { $external?: boolean };
56+
57+
// Accept an $external prop that adds `target` and `rel` attributes if present
58+
const Link = twc.a.attrs<LinkProps>((props) =>
59+
props.$external ? { target: "_blank", rel: "noopener noreferrer" } : {},
60+
)`appearance-none size-4 border-2 border-blue-500 rounded-sm bg-white`;
61+
62+
render(
63+
<Link $external href="https://example.com">
64+
My link
65+
</Link>,
66+
);
67+
const link = screen.getByText("My link");
68+
expect(link).toBeDefined();
69+
expect(link.getAttribute("target")).toBe("_blank");
70+
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
71+
});
72+
2873
test("merges classes", () => {
2974
const Title = twc.h1`text-xl`;
3075
render(<Title className="font-medium">Title</Title>);

src/index.tsx

+63-30
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ type Template<
2626
TComponent extends React.ElementType,
2727
TCompose extends AbstractCompose,
2828
TExtraProps,
29-
> = <TProps = undefined>(
29+
TParentProps = undefined,
30+
> = <TProps = TParentProps>(
3031
strings:
3132
| TemplateStringsArray
3233
| ((
@@ -37,10 +38,24 @@ type Template<
3738
ResultProps<TComponent, TProps, TExtraProps, TCompose>
3839
>;
3940

41+
type FirstLevelTemplate<
42+
TComponent extends React.ElementType,
43+
TCompose extends AbstractCompose,
44+
TExtraProps,
45+
> = Template<TComponent, TCompose, TExtraProps> & {
46+
attrs: <TProps = undefined>(
47+
attrs:
48+
| Record<string, any>
49+
| ((
50+
props: ResultProps<TComponent, TProps, TExtraProps, TCompose>,
51+
) => Record<string, any>),
52+
) => Template<TComponent, TCompose, TExtraProps, TProps>;
53+
};
54+
4055
type Twc<TCompose extends AbstractCompose> = (<T extends React.ElementType>(
4156
component: T,
42-
) => Template<T, TCompose, undefined>) & {
43-
[Key in keyof HTMLElementTagNameMap]: Template<
57+
) => FirstLevelTemplate<T, TCompose, undefined>) & {
58+
[Key in keyof HTMLElementTagNameMap]: FirstLevelTemplate<
4459
Key,
4560
TCompose,
4661
{ asChild?: boolean }
@@ -81,44 +96,62 @@ function filterProps(
8196
return filteredProps;
8297
}
8398

99+
type Attributes = Record<string, any> | ((props: any) => Record<string, any>);
100+
84101
export const createTwc = <TCompose extends AbstractCompose = typeof clsx>(
85102
config: Config<TCompose> = {},
86103
) => {
87-
const compose = config.compose ?? clsx;
104+
const compose = config.compose || clsx;
88105
const shouldForwardProp =
89-
config.shouldForwardProp ?? ((prop) => prop[0] !== "$");
90-
const template =
91-
(Component: React.ElementType) =>
92-
// eslint-disable-next-line @typescript-eslint/ban-types
93-
(stringsOrFn: TemplateStringsArray | Function, ...values: any[]) => {
94-
const isFn = typeof stringsOrFn === "function";
95-
const twClassName = isFn
96-
? ""
97-
: String.raw({ raw: stringsOrFn }, ...values);
98-
return React.forwardRef((props: any, ref) => {
99-
const { className, asChild, ...rest } = props;
100-
const filteredProps = filterProps(rest, shouldForwardProp);
101-
const Comp = asChild ? Slot : Component;
102-
return (
103-
<Comp
104-
ref={ref}
105-
className={compose(
106-
isFn ? stringsOrFn(props) : twClassName,
107-
className,
108-
)}
109-
{...filteredProps}
110-
/>
111-
);
112-
});
106+
config.shouldForwardProp || ((prop) => prop[0] !== "$");
107+
const wrap = (Component: React.ElementType) => {
108+
const createTemplate = (attrs?: Attributes) => {
109+
const template = (
110+
stringsOrFn: TemplateStringsArray | Function,
111+
...values: any[]
112+
) => {
113+
const isFn = typeof stringsOrFn === "function";
114+
const twClassName = isFn
115+
? ""
116+
: String.raw({ raw: stringsOrFn }, ...values);
117+
return React.forwardRef((p: any, ref) => {
118+
const { className, asChild, ...rest } = p;
119+
const rp =
120+
typeof attrs === "function" ? attrs(p) : attrs ? attrs : {};
121+
const fp = filterProps({ ...rp, ...rest }, shouldForwardProp);
122+
const Comp = asChild ? Slot : Component;
123+
return (
124+
<Comp
125+
ref={ref}
126+
className={compose(
127+
isFn ? stringsOrFn(p) : twClassName,
128+
className,
129+
)}
130+
{...fp}
131+
/>
132+
);
133+
});
134+
};
135+
136+
if (attrs === undefined) {
137+
template.attrs = (attrs: Attributes) => {
138+
return createTemplate(attrs);
139+
};
140+
}
141+
142+
return template;
113143
};
114144

145+
return createTemplate();
146+
};
147+
115148
return new Proxy(
116149
(component: React.ComponentType) => {
117-
return template(component);
150+
return wrap(component);
118151
},
119152
{
120153
get(_, name) {
121-
return template(name as keyof JSX.IntrinsicElements);
154+
return wrap(name as keyof JSX.IntrinsicElements);
122155
},
123156
},
124157
) as any as Twc<TCompose>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Additional props
2+
3+
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.
4+
5+
## Usage
6+
7+
Create an `input` of type "checkbox" with `twc`:
8+
9+
```ts
10+
const Checkbox = twc.input.attrs({
11+
type: "checkbox",
12+
})`appearance-none size-4 border-2 border-blue-500 rounded-sm bg-white`;
13+
```
14+
15+
## Adapting attributes based on props
16+
17+
`attrs` accepts a function to generate attributes based on input props.
18+
19+
In this example, we create an anchor that accepts an `$external` prop and adds `target` and `rel` attributes based on its value.
20+
21+
```ts
22+
import { twc, TwcComponentProps } from "react-twc";
23+
24+
type AnchorProps = TwcComponentProps<"a"> & { $external?: boolean };
25+
26+
// Accept an $external prop that adds `target` and `rel` attributes if present
27+
const Anchor = twc.a.attrs<AnchorProps>((props) =>
28+
props.$external ? { target: "_blank", rel: "noopener noreferrer" } : {},
29+
)`appearance-none size-4 border-2 border-blue-500 rounded-sm bg-white`;
30+
31+
render(
32+
<Anchor $external href="https://cva.style">Class Variance Authority</Link>
33+
);
34+
```

website/pages/docs/guides/styling-any-component.mdx

+8
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,11 @@ export default () => (
2525
</HoverCard.Root>
2626
);
2727
```
28+
29+
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:
30+
31+
```tsx
32+
const HoverCardContent = twc(HoverCard.Content).attrs({
33+
sideOffset: 5,
34+
})`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`;
35+
```

website/pages/index.mdx

+1-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const Card = twc.div`rounded-lg border bg-slate-100 text-white shadow-sm`;
4747

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

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

0 commit comments

Comments
 (0)