classname-variants 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -5
- package/biome.json +49 -0
- package/lib/example/index.js +2 -2
- package/lib/example/preact.js +25 -7
- package/lib/example/react.d.ts +9 -3
- package/lib/example/react.js +31 -6
- package/lib/index.d.ts +1 -0
- package/lib/index.js +2 -2
- package/lib/index.test.d.ts +1 -0
- package/lib/index.test.js +93 -0
- package/lib/preact.d.ts +30 -5
- package/lib/preact.js +38 -4
- package/lib/preact.test.d.ts +1 -0
- package/lib/preact.test.js +52 -0
- package/lib/react.d.ts +30 -5
- package/lib/react.js +37 -4
- package/lib/react.test.d.ts +1 -0
- package/lib/react.test.js +78 -0
- package/llms.txt +203 -0
- package/package.json +9 -3
- package/vitest.config.ts +9 -0
package/README.md
CHANGED
|
@@ -188,6 +188,49 @@ import { styled } from "classname-variants/react";
|
|
|
188
188
|
const Button = styled("button", "bg-transparent border p-2");
|
|
189
189
|
```
|
|
190
190
|
|
|
191
|
+
### Default props
|
|
192
|
+
|
|
193
|
+
If your underlying element (or custom component) expects props that you want to
|
|
194
|
+
provide automatically, you can use the `defaultProps` option. All defaulted
|
|
195
|
+
props become optional in TypeScript – even when you later render the component
|
|
196
|
+
with a polymorphic `as` prop.
|
|
197
|
+
|
|
198
|
+
```tsx
|
|
199
|
+
const Button = styled("button", {
|
|
200
|
+
base: "inline-flex items-center gap-2",
|
|
201
|
+
defaultProps: {
|
|
202
|
+
type: "button",
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// `type` is optional but still overridable
|
|
207
|
+
<Button />;
|
|
208
|
+
<Button type="submit" />;
|
|
209
|
+
|
|
210
|
+
// Works together with `as`
|
|
211
|
+
<Button as="a" href="/docs" />;
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Forwarding props
|
|
215
|
+
|
|
216
|
+
When a variant mirrors an existing prop (such as `disabled` on a button), add
|
|
217
|
+
it to `forwardProps` so the resolved value is passed through to the rendered
|
|
218
|
+
element or custom component.
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
const Button = styled("button", {
|
|
222
|
+
variants: {
|
|
223
|
+
disabled: {
|
|
224
|
+
true: "cursor-not-allowed",
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
forwardProps: ["disabled"],
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Renders with both the class name and the DOM `disabled` prop applied.
|
|
231
|
+
<Button disabled />;
|
|
232
|
+
```
|
|
233
|
+
|
|
191
234
|
### Styling custom components
|
|
192
235
|
|
|
193
236
|
You can style any custom React/Preact component as long as they accept a `className` prop (or `class` in case of Preact).
|
|
@@ -270,17 +313,17 @@ const button = variants({
|
|
|
270
313
|
});
|
|
271
314
|
```
|
|
272
315
|
|
|
273
|
-
You can then
|
|
274
|
-
|
|
275
|
-
```
|
|
276
|
-
"tailwindCSS.experimental.classRegex": ["tw`(.+?)`"]
|
|
277
|
-
```
|
|
316
|
+
You can then set the _Tailwind CSS: Class Functions_ option to `tw`.
|
|
278
317
|
|
|
279
318
|
> [!NOTE]
|
|
280
319
|
> The `tw` helper function is just an alias for [`String.raw()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/raw) which has the nice side effect backslashes are not treated as [escape character in JSX](https://tailwindcss.com/docs/adding-custom-styles#handling-whitespace).
|
|
281
320
|
|
|
282
321
|
In order to get type coverage even for your Tailwind classes, you can use a tool like [tailwind-ts](https://github.com/mathieutu/tailwind-ts).
|
|
283
322
|
|
|
323
|
+
# For AI Assistants
|
|
324
|
+
|
|
325
|
+
For comprehensive technical documentation optimized for LLMs, see [`llms.txt`](./llms.txt).
|
|
326
|
+
|
|
284
327
|
# License
|
|
285
328
|
|
|
286
329
|
MIT
|
package/biome.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
|
3
|
+
"vcs": {
|
|
4
|
+
"enabled": true,
|
|
5
|
+
"clientKind": "git",
|
|
6
|
+
"useIgnoreFile": true
|
|
7
|
+
},
|
|
8
|
+
"formatter": {
|
|
9
|
+
"enabled": true,
|
|
10
|
+
"indentStyle": "space",
|
|
11
|
+
"indentWidth": 2
|
|
12
|
+
},
|
|
13
|
+
"linter": {
|
|
14
|
+
"enabled": true,
|
|
15
|
+
"rules": {
|
|
16
|
+
"recommended": true,
|
|
17
|
+
"correctness": {
|
|
18
|
+
"noUnusedImports": {
|
|
19
|
+
"options": {},
|
|
20
|
+
"level": "warn",
|
|
21
|
+
"fix": "safe"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"complexity": {
|
|
25
|
+
"noBannedTypes": "off"
|
|
26
|
+
},
|
|
27
|
+
"style": {
|
|
28
|
+
"noNonNullAssertion": "off"
|
|
29
|
+
},
|
|
30
|
+
"suspicious": {
|
|
31
|
+
"noExplicitAny": "off"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"javascript": {
|
|
36
|
+
"formatter": {
|
|
37
|
+
"quoteStyle": "double"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"assist": {
|
|
41
|
+
"enabled": true,
|
|
42
|
+
"actions": {
|
|
43
|
+
"source": {
|
|
44
|
+
"organizeImports": "on",
|
|
45
|
+
"useSortedAttributes": "on"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
package/lib/example/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { h, render } from "preact";
|
|
1
2
|
import { createElement } from "react";
|
|
2
3
|
import { createRoot } from "react-dom/client";
|
|
3
|
-
import { render, h } from "preact";
|
|
4
|
-
import { ReactApp } from "./react";
|
|
5
4
|
import { PreactApp } from "./preact";
|
|
5
|
+
import { ReactApp } from "./react";
|
|
6
6
|
const root = createRoot(document.getElementById("react-root"));
|
|
7
7
|
root.render(createElement(ReactApp));
|
|
8
8
|
render(h(PreactApp, {}), document.getElementById("preact-root"));
|
package/lib/example/preact.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/** @jsx h */
|
|
2
|
-
|
|
2
|
+
// biome-ignore lint/correctness/noUnusedImports: Preact JSX pragma requires importing h
|
|
3
3
|
import { h } from "preact";
|
|
4
|
+
import { styled } from "../preact";
|
|
4
5
|
function CustomComponent({ title, ...props }) {
|
|
5
6
|
return h("div", { ...props }, title);
|
|
6
7
|
}
|
|
7
8
|
const Card = styled("div", "bg-white p-4 border-2 rounded-lg");
|
|
8
|
-
const TitleCard = styled(CustomComponent,
|
|
9
|
+
const TitleCard = styled(CustomComponent, {
|
|
10
|
+
base: "bg-white p-4 border-2 rounded-lg",
|
|
11
|
+
defaultProps: {
|
|
12
|
+
title: "Default Title",
|
|
13
|
+
},
|
|
14
|
+
});
|
|
9
15
|
const Button = styled("button", {
|
|
10
16
|
base: "px-5 py-2 text-white disabled:bg-gray-400 disabled:text-gray-300",
|
|
11
17
|
variants: {
|
|
@@ -24,6 +30,9 @@ const Button = styled("button", {
|
|
|
24
30
|
true: "rounded-full",
|
|
25
31
|
false: "rounded-sm",
|
|
26
32
|
},
|
|
33
|
+
disabled: {
|
|
34
|
+
true: "cursor-not-allowed",
|
|
35
|
+
},
|
|
27
36
|
},
|
|
28
37
|
compoundVariants: [
|
|
29
38
|
{
|
|
@@ -34,6 +43,10 @@ const Button = styled("button", {
|
|
|
34
43
|
defaultVariants: {
|
|
35
44
|
color: "neutral",
|
|
36
45
|
},
|
|
46
|
+
defaultProps: {
|
|
47
|
+
type: "button",
|
|
48
|
+
},
|
|
49
|
+
forwardProps: ["disabled"],
|
|
37
50
|
});
|
|
38
51
|
export const ExpectErrors = styled("div", {
|
|
39
52
|
variants: {
|
|
@@ -53,20 +66,25 @@ export const ExpectErrors = styled("div", {
|
|
|
53
66
|
//@ts-expect-error
|
|
54
67
|
outlined: true,
|
|
55
68
|
},
|
|
69
|
+
forwardProps: [
|
|
70
|
+
//@ts-expect-error
|
|
71
|
+
"outlined",
|
|
72
|
+
],
|
|
56
73
|
});
|
|
57
74
|
export function WithErrors() {
|
|
58
75
|
return (h("div", null,
|
|
59
76
|
h(Button, { foo: true, size: "medium" }, "unknown property"),
|
|
60
77
|
h(Card, { foo: true }, "Unknown property"),
|
|
61
|
-
h(Button, {
|
|
78
|
+
h(Button, { color: "foo", size: "medium" }, "Invalid variant"),
|
|
62
79
|
h(Button, null, "Missing size")));
|
|
63
80
|
}
|
|
64
81
|
export function PreactApp() {
|
|
65
82
|
return (h("div", { className: "flex justify-center items-center pt-8 gap-4 flex-wrap" },
|
|
66
|
-
h(Button, { size: "medium"
|
|
67
|
-
h(Button, { size: "medium"
|
|
68
|
-
h(Button, {
|
|
69
|
-
h(Button, {
|
|
83
|
+
h(Button, { onClick: console.log, size: "medium" }, "Neutral"),
|
|
84
|
+
h(Button, { rounded: true, size: "medium" }, "Neutral + Rounded"),
|
|
85
|
+
h(Button, { color: "accent", outlined: true, size: "medium" }, "Accent + Outlined"),
|
|
86
|
+
h(Button, { color: "accent", disabled: true, size: "medium" }, "Disabled"),
|
|
87
|
+
h(TitleCard, null),
|
|
70
88
|
h(TitleCard, { title: "Hello" }),
|
|
71
89
|
h(Card, null,
|
|
72
90
|
h("h1", null, "Hello"),
|
package/lib/example/react.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
export declare const
|
|
2
|
+
export declare const StyledWithoutVariants: <As extends React.ElementType<any, keyof React.JSX.IntrinsicElements> = "div">(props: {
|
|
3
|
+
as?: As | undefined;
|
|
4
|
+
} & Omit<React.ComponentProps<As>, "as"> & {} & {}) => React.ReactElement<any, string | React.JSXElementConstructor<any>> | null;
|
|
5
|
+
export declare const TestBaseOnly: <As extends React.ElementType<any, keyof React.JSX.IntrinsicElements> = "div">(props: {
|
|
6
|
+
as?: As | undefined;
|
|
7
|
+
} & Omit<React.ComponentProps<As>, "as"> & {} & {}) => React.ReactElement<any, string | React.JSXElementConstructor<any>> | null;
|
|
8
|
+
export declare const ExpectErrors: <As extends React.ElementType<any, keyof React.JSX.IntrinsicElements> = "div">(props: {
|
|
3
9
|
as?: As | undefined;
|
|
4
10
|
} & Omit<React.ComponentProps<As>, "as" | "color"> & {
|
|
5
11
|
color: "neutral" | "accent";
|
|
6
12
|
} & {}) => React.ReactElement<any, string | React.JSXElementConstructor<any>> | null;
|
|
7
|
-
export declare function WithErrors(): JSX.Element;
|
|
8
|
-
export declare function ReactApp(): JSX.Element;
|
|
13
|
+
export declare function WithErrors(): React.JSX.Element;
|
|
14
|
+
export declare function ReactApp(): React.JSX.Element;
|
package/lib/example/react.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
+
// biome-ignore lint/correctness/noUnusedImports: React import required for JSX runtime configuration
|
|
1
2
|
import React from "react";
|
|
2
3
|
import { styled } from "../react";
|
|
3
4
|
function CustomComponent({ title, ...props }) {
|
|
4
5
|
return React.createElement("div", { ...props }, title);
|
|
5
6
|
}
|
|
6
7
|
const Card = styled("div", "bg-white p-4 border-2 rounded-lg");
|
|
7
|
-
const
|
|
8
|
+
const _cardAsShouldBeGeneric = "a";
|
|
9
|
+
const TitleCard = styled(CustomComponent, {
|
|
10
|
+
base: "bg-white p-4 border-2 rounded-lg",
|
|
11
|
+
defaultProps: {
|
|
12
|
+
title: "Default Title",
|
|
13
|
+
},
|
|
14
|
+
});
|
|
8
15
|
const Button = styled("button", {
|
|
9
16
|
base: "px-5 py-2 text-white disabled:bg-gray-400 disabled:text-gray-300",
|
|
10
17
|
variants: {
|
|
@@ -23,6 +30,9 @@ const Button = styled("button", {
|
|
|
23
30
|
true: "rounded-full",
|
|
24
31
|
false: "rounded-sm",
|
|
25
32
|
},
|
|
33
|
+
disabled: {
|
|
34
|
+
true: "cursor-not-allowed",
|
|
35
|
+
},
|
|
26
36
|
},
|
|
27
37
|
compoundVariants: [
|
|
28
38
|
{
|
|
@@ -33,6 +43,16 @@ const Button = styled("button", {
|
|
|
33
43
|
defaultVariants: {
|
|
34
44
|
color: "neutral",
|
|
35
45
|
},
|
|
46
|
+
defaultProps: {
|
|
47
|
+
type: "button",
|
|
48
|
+
},
|
|
49
|
+
forwardProps: ["disabled"],
|
|
50
|
+
});
|
|
51
|
+
export const StyledWithoutVariants = styled("div", {
|
|
52
|
+
base: "bg-white",
|
|
53
|
+
});
|
|
54
|
+
export const TestBaseOnly = styled("div", {
|
|
55
|
+
base: "text-red-500 font-bold",
|
|
36
56
|
});
|
|
37
57
|
export const ExpectErrors = styled("div", {
|
|
38
58
|
variants: {
|
|
@@ -52,21 +72,26 @@ export const ExpectErrors = styled("div", {
|
|
|
52
72
|
//@ts-expect-error
|
|
53
73
|
outlined: true,
|
|
54
74
|
},
|
|
75
|
+
forwardProps: [
|
|
76
|
+
//@ts-expect-error
|
|
77
|
+
"outlined",
|
|
78
|
+
],
|
|
55
79
|
});
|
|
56
80
|
export function WithErrors() {
|
|
57
81
|
return (React.createElement("div", null,
|
|
58
82
|
React.createElement(Button, { foo: true, size: "medium" }, "unknown property"),
|
|
59
83
|
React.createElement(Card, { foo: true }, "Unknown property"),
|
|
60
|
-
React.createElement(Button, {
|
|
84
|
+
React.createElement(Button, { color: "foo", size: "medium" }, "Invalid variant"),
|
|
61
85
|
React.createElement(Button, null, "Missing size"),
|
|
62
86
|
React.createElement(Card, { as: "b", href: "https://example.com" }, "B tags don't have a href attribute")));
|
|
63
87
|
}
|
|
64
88
|
export function ReactApp() {
|
|
65
89
|
return (React.createElement("div", { className: "flex justify-center items-center pt-8 gap-4 flex-wrap" },
|
|
66
|
-
React.createElement(Button, { size: "medium"
|
|
67
|
-
React.createElement(Button, { size: "medium"
|
|
68
|
-
React.createElement(Button, {
|
|
69
|
-
React.createElement(Button, {
|
|
90
|
+
React.createElement(Button, { onClick: console.log, size: "medium" }, "Neutral"),
|
|
91
|
+
React.createElement(Button, { rounded: true, size: "medium" }, "Neutral + Rounded"),
|
|
92
|
+
React.createElement(Button, { color: "accent", outlined: true, size: "medium" }, "Accent + Outlined"),
|
|
93
|
+
React.createElement(Button, { color: "accent", disabled: true, size: "medium" }, "Disabled"),
|
|
94
|
+
React.createElement(TitleCard, null),
|
|
70
95
|
React.createElement(TitleCard, { title: "Hello" }),
|
|
71
96
|
React.createElement(Card, null,
|
|
72
97
|
React.createElement("h1", null, "Hello"),
|
package/lib/index.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface VariantsConfig<V extends Variants = {}> {
|
|
|
21
21
|
variants: V;
|
|
22
22
|
compoundVariants?: CompoundVariant<V>[];
|
|
23
23
|
defaultVariants?: Partial<OptionsOf<V>>;
|
|
24
|
+
forwardProps?: ReadonlyArray<Extract<keyof V, string>>;
|
|
24
25
|
}
|
|
25
26
|
/**
|
|
26
27
|
* Rules for class names that are applied for certain variant combinations.
|
package/lib/index.js
CHANGED
|
@@ -14,12 +14,12 @@ export function variants(config) {
|
|
|
14
14
|
var _a, _b;
|
|
15
15
|
return (_b = (_a = props[name]) !== null && _a !== void 0 ? _a : defaultVariants === null || defaultVariants === void 0 ? void 0 : defaultVariants[name]) !== null && _b !== void 0 ? _b : (isBooleanVariant(name) ? false : undefined);
|
|
16
16
|
};
|
|
17
|
-
for (
|
|
17
|
+
for (const name in variants) {
|
|
18
18
|
const selected = getSelected(name);
|
|
19
19
|
if (selected !== undefined)
|
|
20
20
|
classes.push((_a = variants[name]) === null || _a === void 0 ? void 0 : _a[selected]);
|
|
21
21
|
}
|
|
22
|
-
for (
|
|
22
|
+
for (const { variants, className } of compoundVariants !== null && compoundVariants !== void 0 ? compoundVariants : []) {
|
|
23
23
|
const isSelected = (name) => getSelected(name) === variants[name];
|
|
24
24
|
if (Object.keys(variants).every(isSelected)) {
|
|
25
25
|
classes.push(className);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { classNames, tw, variants } from "./index.js";
|
|
3
|
+
describe("classNames.combine", () => {
|
|
4
|
+
it("joins truthy values with spaces", () => {
|
|
5
|
+
expect(classNames.combine("foo", undefined, "bar", false, "baz")).toBe("foo bar baz");
|
|
6
|
+
});
|
|
7
|
+
it("returns empty string for falsy input", () => {
|
|
8
|
+
expect(classNames.combine(undefined, null, false, 0)).toBe("");
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
describe("tw helper", () => {
|
|
12
|
+
it("returns raw template strings", () => {
|
|
13
|
+
expect(tw `foo`).toBe("foo");
|
|
14
|
+
const dynamic = "bar";
|
|
15
|
+
expect(tw `foo-${dynamic}`).toBe("foo-bar");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
describe("variants factory", () => {
|
|
19
|
+
const config = {
|
|
20
|
+
base: "btn",
|
|
21
|
+
variants: {
|
|
22
|
+
tone: {
|
|
23
|
+
neutral: "text-neutral",
|
|
24
|
+
accent: "text-accent",
|
|
25
|
+
},
|
|
26
|
+
size: {
|
|
27
|
+
small: "text-sm",
|
|
28
|
+
large: "text-lg",
|
|
29
|
+
},
|
|
30
|
+
disabled: {
|
|
31
|
+
true: "opacity-50",
|
|
32
|
+
false: "opacity-100",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
defaultVariants: {
|
|
36
|
+
tone: "neutral",
|
|
37
|
+
size: "small",
|
|
38
|
+
},
|
|
39
|
+
compoundVariants: [
|
|
40
|
+
{
|
|
41
|
+
variants: { tone: "accent", size: "large" },
|
|
42
|
+
className: "text-accent-large",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
variants: { disabled: true },
|
|
46
|
+
className: "pointer-events-none",
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
const button = variants(config);
|
|
51
|
+
it("includes base class", () => {
|
|
52
|
+
expect(button({ tone: "neutral", size: "small" })).toContain("btn");
|
|
53
|
+
});
|
|
54
|
+
it("uses default variants when omitted", () => {
|
|
55
|
+
const result = button({});
|
|
56
|
+
expect(result).toContain("text-neutral");
|
|
57
|
+
expect(result).toContain("text-sm");
|
|
58
|
+
expect(result).not.toContain("text-lg");
|
|
59
|
+
});
|
|
60
|
+
it("applies boolean variants with false default", () => {
|
|
61
|
+
const result = button({});
|
|
62
|
+
expect(result).toContain("opacity-100");
|
|
63
|
+
});
|
|
64
|
+
it("prefers explicit props over defaults", () => {
|
|
65
|
+
const result = button({ tone: "accent", size: "large" });
|
|
66
|
+
expect(result).toContain("text-accent");
|
|
67
|
+
expect(result).toContain("text-lg");
|
|
68
|
+
expect(result).not.toContain("text-neutral");
|
|
69
|
+
expect(result).not.toContain("text-sm");
|
|
70
|
+
});
|
|
71
|
+
it("handles boolean true variants", () => {
|
|
72
|
+
const result = button({ disabled: true });
|
|
73
|
+
expect(result).toContain("opacity-50");
|
|
74
|
+
expect(result).toContain("pointer-events-none");
|
|
75
|
+
});
|
|
76
|
+
it("runs compound variants only when all predicates match", () => {
|
|
77
|
+
const accentLarge = button({ tone: "accent", size: "large" });
|
|
78
|
+
expect(accentLarge).toContain("text-accent-large");
|
|
79
|
+
const accentSmall = button({ tone: "accent", size: "small" });
|
|
80
|
+
expect(accentSmall).not.toContain("text-accent-large");
|
|
81
|
+
});
|
|
82
|
+
it("accepts boolean variants via explicit false", () => {
|
|
83
|
+
const result = button({ disabled: false });
|
|
84
|
+
expect(result).toContain("opacity-100");
|
|
85
|
+
expect(result).not.toContain("pointer-events-none");
|
|
86
|
+
});
|
|
87
|
+
it("keeps default class when variant explicitly undefined", () => {
|
|
88
|
+
const result = button({ tone: undefined, size: "large" });
|
|
89
|
+
expect(result).toContain("text-lg");
|
|
90
|
+
expect(result).toContain("text-neutral");
|
|
91
|
+
expect(result).not.toContain("text-accent");
|
|
92
|
+
});
|
|
93
|
+
});
|
package/lib/preact.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ComponentProps, ComponentType, JSX, VNode } from "preact";
|
|
2
2
|
type ElementType<P = any> = keyof JSX.IntrinsicElements | ComponentType<P>;
|
|
3
|
-
import {
|
|
3
|
+
import { type Simplify, type VariantOptions, type Variants, type VariantsConfig } from "./index.js";
|
|
4
4
|
/**
|
|
5
5
|
* Utility type to infer the first argument of a variantProps function.
|
|
6
6
|
*/
|
|
@@ -12,15 +12,40 @@ type VariantProps<C extends VariantsConfig<V>, V extends Variants = C["variants"
|
|
|
12
12
|
class?: string;
|
|
13
13
|
className?: string;
|
|
14
14
|
};
|
|
15
|
+
type ForwardedVariantKeys<C> = C extends {
|
|
16
|
+
forwardProps?: ReadonlyArray<infer K>;
|
|
17
|
+
variants: infer V;
|
|
18
|
+
} ? Extract<K, keyof V> : never;
|
|
19
|
+
type VariantPropKeys<C> = C extends {
|
|
20
|
+
variants: infer V;
|
|
21
|
+
} ? keyof V : never;
|
|
22
|
+
type ForwardedVariantProps<C extends VariantsConfig<any>> = Pick<VariantOptions<C>, ForwardedVariantKeys<C>>;
|
|
15
23
|
export declare function variantProps<C extends VariantsConfig<V>, V extends Variants = C["variants"]>(config: Simplify<C>): <P extends VariantProps<C, C["variants"]>>(props: P) => {
|
|
16
24
|
class: string;
|
|
17
|
-
} & Omit<P,
|
|
18
|
-
type VariantsOf<T
|
|
25
|
+
} & Omit<P, VariantPropKeys<C>> & ForwardedVariantProps<C>;
|
|
26
|
+
type VariantsOf<T> = T extends {
|
|
27
|
+
variants: infer TV;
|
|
28
|
+
} ? TV extends Variants ? TV : {} : {};
|
|
19
29
|
type AsProps<T extends ElementType = ElementType> = {
|
|
20
30
|
as?: T;
|
|
21
31
|
};
|
|
32
|
+
type CleanDefaults<Defaults> = Exclude<Defaults, undefined>;
|
|
33
|
+
type DefaultedKeys<Props, Defaults> = Extract<keyof CleanDefaults<Defaults>, keyof Props>;
|
|
34
|
+
type WithDefaultProps<Props, Defaults> = [CleanDefaults<Defaults>] extends [
|
|
35
|
+
never
|
|
36
|
+
] ? Props : Omit<Props, DefaultedKeys<Props, Defaults>> & Partial<Pick<Props, DefaultedKeys<Props, Defaults>>>;
|
|
22
37
|
type PolymorphicComponentProps<V, T extends ElementType> = AsProps<T> & Omit<ComponentProps<T>, "as" | keyof V> & V;
|
|
23
|
-
export declare function styled<T extends ElementType, C extends VariantsConfig<V>, V extends Variants = VariantsOf<C
|
|
38
|
+
export declare function styled<T extends ElementType, C extends VariantsConfig<V>, V extends Variants = VariantsOf<C>, Defaults extends Partial<ComponentProps<T>> | undefined = undefined>(type: T, config: string | {
|
|
39
|
+
base: string;
|
|
40
|
+
defaultProps?: Defaults;
|
|
41
|
+
} | (Simplify<C> & {
|
|
42
|
+
defaultProps?: Defaults;
|
|
43
|
+
})): <As extends ElementType = T>(props: WithDefaultProps<PolymorphicComponentProps<typeof config extends string ? {} : typeof config extends {
|
|
44
|
+
base: string;
|
|
45
|
+
variants?: undefined;
|
|
46
|
+
compoundVariants?: undefined;
|
|
47
|
+
defaultVariants?: undefined;
|
|
48
|
+
} ? {} : VariantOptions<C>, As>, Defaults>) => VNode | null;
|
|
24
49
|
/**
|
|
25
50
|
* No-op function to mark template literals as tailwind strings.
|
|
26
51
|
*/
|
package/lib/preact.js
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
import { h } from "preact";
|
|
2
2
|
import { forwardRef } from "preact/compat";
|
|
3
|
-
import {
|
|
3
|
+
import { classNames, variants, } from "./index.js";
|
|
4
4
|
export function variantProps(config) {
|
|
5
5
|
const variantClassName = variants(config);
|
|
6
6
|
return (props) => {
|
|
7
7
|
const result = {};
|
|
8
8
|
// Pass-through all unrelated props
|
|
9
|
-
for (
|
|
9
|
+
for (const prop in props) {
|
|
10
10
|
if (config.variants && !(prop in config.variants)) {
|
|
11
11
|
result[prop] = props[prop];
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
+
if (config.forwardProps) {
|
|
15
|
+
for (const name of config.forwardProps) {
|
|
16
|
+
if (!config.variants || !(name in config.variants))
|
|
17
|
+
continue;
|
|
18
|
+
if (Object.hasOwn(props, name)) {
|
|
19
|
+
result[name] = props[name];
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (config.defaultVariants &&
|
|
23
|
+
Object.hasOwn(config.defaultVariants, name)) {
|
|
24
|
+
result[name] =
|
|
25
|
+
config.defaultVariants[name];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
14
29
|
// Add the optionally passed class/className prop for chaining
|
|
15
30
|
result.class = classNames.combine(variantClassName(props), props.class, props.className);
|
|
16
31
|
return result;
|
|
@@ -19,9 +34,28 @@ export function variantProps(config) {
|
|
|
19
34
|
export function styled(type, config) {
|
|
20
35
|
const styledProps = typeof config === "string"
|
|
21
36
|
? variantProps({ base: config, variants: {} })
|
|
22
|
-
:
|
|
37
|
+
: "variants" in config
|
|
38
|
+
? variantProps(config)
|
|
39
|
+
: variantProps({
|
|
40
|
+
base: config
|
|
41
|
+
.base,
|
|
42
|
+
variants: {},
|
|
43
|
+
});
|
|
44
|
+
const defaultProps = typeof config === "string" ? undefined : config.defaultProps;
|
|
45
|
+
const toRecord = (value) => value && typeof value === "object"
|
|
46
|
+
? value
|
|
47
|
+
: {};
|
|
48
|
+
const readClass = (value) => typeof value === "string" ? value : undefined;
|
|
23
49
|
const Component = forwardRef(({ as, ...props }, ref) => {
|
|
24
|
-
|
|
50
|
+
const defaultsRecord = toRecord(defaultProps);
|
|
51
|
+
const propsRecord = props;
|
|
52
|
+
const merged = {
|
|
53
|
+
...defaultsRecord,
|
|
54
|
+
...propsRecord,
|
|
55
|
+
};
|
|
56
|
+
merged.class = classNames.combine(readClass(defaultsRecord.class), readClass(propsRecord.class));
|
|
57
|
+
merged.className = classNames.combine(readClass(defaultsRecord.className), readClass(propsRecord.className));
|
|
58
|
+
return h(as !== null && as !== void 0 ? as : type, { ...styledProps(merged), ref });
|
|
25
59
|
});
|
|
26
60
|
return Component;
|
|
27
61
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { variantProps } from "./preact.js";
|
|
3
|
+
describe("preact adapter forwardProps", () => {
|
|
4
|
+
it("forwards explicit variant values", () => {
|
|
5
|
+
const config = {
|
|
6
|
+
base: "btn",
|
|
7
|
+
variants: {
|
|
8
|
+
disabled: {
|
|
9
|
+
true: "opacity-50",
|
|
10
|
+
},
|
|
11
|
+
size: {
|
|
12
|
+
small: "text-sm",
|
|
13
|
+
large: "text-lg",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
defaultVariants: {
|
|
17
|
+
size: "small",
|
|
18
|
+
},
|
|
19
|
+
forwardProps: ["disabled"],
|
|
20
|
+
};
|
|
21
|
+
const mapProps = variantProps(config);
|
|
22
|
+
const result = mapProps({
|
|
23
|
+
disabled: true,
|
|
24
|
+
size: "large",
|
|
25
|
+
class: "custom",
|
|
26
|
+
});
|
|
27
|
+
expect(result.disabled).toBe(true);
|
|
28
|
+
expect(result.class).toContain("btn");
|
|
29
|
+
expect(result.class).toContain("opacity-50");
|
|
30
|
+
expect(result.class).toContain("text-lg");
|
|
31
|
+
expect(result.class).toContain("custom");
|
|
32
|
+
expect("size" in result).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
it("uses default variant values when forwarding", () => {
|
|
35
|
+
const config = {
|
|
36
|
+
base: "btn",
|
|
37
|
+
variants: {
|
|
38
|
+
disabled: {
|
|
39
|
+
true: "opacity-50",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
defaultVariants: {
|
|
43
|
+
disabled: true,
|
|
44
|
+
},
|
|
45
|
+
forwardProps: ["disabled"],
|
|
46
|
+
};
|
|
47
|
+
const mapProps = variantProps(config);
|
|
48
|
+
const result = mapProps({});
|
|
49
|
+
expect(result.disabled).toBe(true);
|
|
50
|
+
expect(result.class).toContain("opacity-50");
|
|
51
|
+
});
|
|
52
|
+
});
|
package/lib/react.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ComponentProps, ElementType, ReactElement } from "react";
|
|
2
|
-
import {
|
|
1
|
+
import { type ComponentProps, type ElementType, type ReactElement } from "react";
|
|
2
|
+
import { type Simplify, type VariantOptions, type Variants, type VariantsConfig } from "./index.js";
|
|
3
3
|
/**
|
|
4
4
|
* Utility type to infer the first argument of a variantProps function.
|
|
5
5
|
*/
|
|
@@ -10,15 +10,40 @@ export type VariantPropsOf<T> = T extends (props: infer P) => any ? P : never;
|
|
|
10
10
|
type VariantProps<C extends VariantsConfig<V>, V extends Variants = C["variants"]> = VariantOptions<C> & {
|
|
11
11
|
className?: string;
|
|
12
12
|
};
|
|
13
|
+
type ForwardedVariantKeys<C> = C extends {
|
|
14
|
+
forwardProps?: ReadonlyArray<infer K>;
|
|
15
|
+
variants: infer V;
|
|
16
|
+
} ? Extract<K, keyof V> : never;
|
|
17
|
+
type VariantPropKeys<C> = C extends {
|
|
18
|
+
variants: infer V;
|
|
19
|
+
} ? keyof V : never;
|
|
20
|
+
type ForwardedVariantProps<C extends VariantsConfig<any>> = Pick<VariantOptions<C>, ForwardedVariantKeys<C>>;
|
|
13
21
|
export declare function variantProps<C extends VariantsConfig<V>, V extends Variants = C["variants"]>(config: Simplify<C>): <P extends VariantProps<C, C["variants"]>>(props: P) => {
|
|
14
22
|
className: string;
|
|
15
|
-
} & Omit<P,
|
|
16
|
-
type VariantsOf<T
|
|
23
|
+
} & Omit<P, VariantPropKeys<C>> & ForwardedVariantProps<C>;
|
|
24
|
+
type VariantsOf<T> = T extends {
|
|
25
|
+
variants: infer TV;
|
|
26
|
+
} ? TV extends Variants ? TV : {} : {};
|
|
17
27
|
type AsProps<T extends ElementType = ElementType> = {
|
|
18
28
|
as?: T;
|
|
19
29
|
};
|
|
30
|
+
type CleanDefaults<Defaults> = Exclude<Defaults, undefined>;
|
|
31
|
+
type DefaultedKeys<Props, Defaults> = Extract<keyof CleanDefaults<Defaults>, keyof Props>;
|
|
32
|
+
type WithDefaultProps<Props, Defaults> = [CleanDefaults<Defaults>] extends [
|
|
33
|
+
never
|
|
34
|
+
] ? Props : Omit<Props, DefaultedKeys<Props, Defaults>> & Partial<Pick<Props, DefaultedKeys<Props, Defaults>>>;
|
|
20
35
|
type PolymorphicComponentProps<V, T extends ElementType> = AsProps<T> & Omit<ComponentProps<T>, "as" | keyof V> & V;
|
|
21
|
-
export declare function styled<T extends ElementType, C extends VariantsConfig<V>, V extends Variants = VariantsOf<C
|
|
36
|
+
export declare function styled<T extends ElementType, C extends VariantsConfig<V>, V extends Variants = VariantsOf<C>, Defaults extends Partial<ComponentProps<T>> | undefined = undefined>(type: T, config: string | {
|
|
37
|
+
base: string;
|
|
38
|
+
defaultProps?: Defaults;
|
|
39
|
+
} | (Simplify<C> & {
|
|
40
|
+
defaultProps?: Defaults;
|
|
41
|
+
})): <As extends ElementType = T>(props: WithDefaultProps<PolymorphicComponentProps<typeof config extends string ? {} : typeof config extends {
|
|
42
|
+
base: string;
|
|
43
|
+
variants?: undefined;
|
|
44
|
+
compoundVariants?: undefined;
|
|
45
|
+
defaultVariants?: undefined;
|
|
46
|
+
} ? {} : VariantOptions<C>, As>, Defaults>) => ReactElement | null;
|
|
22
47
|
/**
|
|
23
48
|
* No-op function to mark template literals as tailwind strings.
|
|
24
49
|
*/
|
package/lib/react.js
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import { createElement, forwardRef, } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { classNames, variants, } from "./index.js";
|
|
3
3
|
export function variantProps(config) {
|
|
4
4
|
const variantClassName = variants(config);
|
|
5
5
|
return (props) => {
|
|
6
6
|
const result = {};
|
|
7
7
|
// Pass-through all unrelated props
|
|
8
|
-
for (
|
|
8
|
+
for (const prop in props) {
|
|
9
9
|
if (config.variants && !(prop in config.variants)) {
|
|
10
10
|
result[prop] = props[prop];
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
if (config.forwardProps) {
|
|
14
|
+
for (const name of config.forwardProps) {
|
|
15
|
+
if (!config.variants || !(name in config.variants))
|
|
16
|
+
continue;
|
|
17
|
+
if (Object.hasOwn(props, name)) {
|
|
18
|
+
result[name] = props[name];
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (config.defaultVariants &&
|
|
22
|
+
Object.hasOwn(config.defaultVariants, name)) {
|
|
23
|
+
result[name] =
|
|
24
|
+
config.defaultVariants[name];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
13
28
|
// Add the optionally passed className prop for chaining
|
|
14
29
|
result.className = classNames.combine(variantClassName(props), props.className);
|
|
15
30
|
return result;
|
|
@@ -18,9 +33,27 @@ export function variantProps(config) {
|
|
|
18
33
|
export function styled(type, config) {
|
|
19
34
|
const styledProps = typeof config === "string"
|
|
20
35
|
? variantProps({ base: config, variants: {} })
|
|
21
|
-
:
|
|
36
|
+
: "variants" in config
|
|
37
|
+
? variantProps(config)
|
|
38
|
+
: variantProps({
|
|
39
|
+
base: config
|
|
40
|
+
.base,
|
|
41
|
+
variants: {},
|
|
42
|
+
});
|
|
43
|
+
const defaultProps = typeof config === "string" ? undefined : config.defaultProps;
|
|
44
|
+
const toRecord = (value) => value && typeof value === "object"
|
|
45
|
+
? value
|
|
46
|
+
: {};
|
|
47
|
+
const readClassName = (value) => typeof value === "string" ? value : undefined;
|
|
22
48
|
const Component = forwardRef(({ as, ...props }, ref) => {
|
|
23
|
-
|
|
49
|
+
const defaultsRecord = toRecord(defaultProps);
|
|
50
|
+
const propsRecord = props;
|
|
51
|
+
const merged = {
|
|
52
|
+
...defaultsRecord,
|
|
53
|
+
...propsRecord,
|
|
54
|
+
};
|
|
55
|
+
merged.className = classNames.combine(readClassName(defaultsRecord.className), readClassName(propsRecord.className));
|
|
56
|
+
return createElement(as !== null && as !== void 0 ? as : type, { ...styledProps(merged), ref });
|
|
24
57
|
});
|
|
25
58
|
return Component;
|
|
26
59
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { styled, variantProps } from "./react.js";
|
|
5
|
+
describe("react adapter forwardProps", () => {
|
|
6
|
+
it("forwards explicit variant values", () => {
|
|
7
|
+
const config = {
|
|
8
|
+
base: "btn",
|
|
9
|
+
variants: {
|
|
10
|
+
disabled: {
|
|
11
|
+
true: "opacity-50",
|
|
12
|
+
},
|
|
13
|
+
tone: {
|
|
14
|
+
neutral: "text-slate-700",
|
|
15
|
+
accent: "text-teal-600",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
defaultVariants: {
|
|
19
|
+
tone: "neutral",
|
|
20
|
+
},
|
|
21
|
+
forwardProps: ["disabled"],
|
|
22
|
+
};
|
|
23
|
+
const mapProps = variantProps(config);
|
|
24
|
+
const result = mapProps({
|
|
25
|
+
disabled: true,
|
|
26
|
+
tone: "accent",
|
|
27
|
+
className: "custom",
|
|
28
|
+
});
|
|
29
|
+
expect(result.disabled).toBe(true);
|
|
30
|
+
expect(result.className).toContain("btn");
|
|
31
|
+
expect(result.className).toContain("opacity-50");
|
|
32
|
+
expect(result.className).toContain("text-teal-600");
|
|
33
|
+
expect(result.className).toContain("custom");
|
|
34
|
+
expect("tone" in result).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
it("uses default variant values when forwarding", () => {
|
|
37
|
+
const config = {
|
|
38
|
+
base: "btn",
|
|
39
|
+
variants: {
|
|
40
|
+
disabled: {
|
|
41
|
+
true: "opacity-50",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
defaultVariants: {
|
|
45
|
+
disabled: true,
|
|
46
|
+
},
|
|
47
|
+
forwardProps: ["disabled"],
|
|
48
|
+
};
|
|
49
|
+
const mapProps = variantProps(config);
|
|
50
|
+
const result = mapProps({});
|
|
51
|
+
expect(result.disabled).toBe(true);
|
|
52
|
+
expect(result.className).toContain("opacity-50");
|
|
53
|
+
});
|
|
54
|
+
it("renders forwarded props through styled components", () => {
|
|
55
|
+
const buttonConfig = {
|
|
56
|
+
base: "btn",
|
|
57
|
+
variants: {
|
|
58
|
+
disabled: {
|
|
59
|
+
true: "opacity-50",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
forwardProps: ["disabled"],
|
|
63
|
+
};
|
|
64
|
+
const Button = styled("button", buttonConfig);
|
|
65
|
+
const markup = renderToStaticMarkup(React.createElement(Button, {
|
|
66
|
+
className: "custom",
|
|
67
|
+
disabled: true,
|
|
68
|
+
}));
|
|
69
|
+
const host = document.createElement("div");
|
|
70
|
+
host.innerHTML = markup;
|
|
71
|
+
const button = host.querySelector("button");
|
|
72
|
+
expect(button).not.toBeNull();
|
|
73
|
+
expect(button.hasAttribute("disabled")).toBe(true);
|
|
74
|
+
expect(button.className).toContain("btn");
|
|
75
|
+
expect(button.className).toContain("opacity-50");
|
|
76
|
+
expect(button.className).toContain("custom");
|
|
77
|
+
});
|
|
78
|
+
});
|
package/llms.txt
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# classname-variants
|
|
2
|
+
|
|
3
|
+
A TypeScript library for creating type-safe variant-based className generators for React, Preact, and vanilla DOM.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
This library provides a variant API for generating class names based on component props. It's designed to work with CSS frameworks like Tailwind CSS but supports any class naming system. The core concept is defining variants (different states/styles) and having the library generate the appropriate class names based on provided props.
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
### Core Module (src/index.ts)
|
|
12
|
+
- `variants()` - Core function that creates a className generator based on variant configuration
|
|
13
|
+
- `VariantsConfig` - TypeScript interface defining the structure of variant configurations
|
|
14
|
+
- `classNames.combine` - Default strategy for combining class names (can be overridden with tools like tailwind-merge)
|
|
15
|
+
- `tw` - Tagged template literal helper for Tailwind CSS IntelliSense
|
|
16
|
+
|
|
17
|
+
### Framework Integrations
|
|
18
|
+
- `src/react.ts` - React-specific bindings with `styled()` and `variantProps()` functions, including runtime merging and type inference for `defaultProps`
|
|
19
|
+
- `src/preact.ts` - Preact-specific bindings (similar to React but uses Preact's JSX) with equivalent `defaultProps` support
|
|
20
|
+
|
|
21
|
+
### Key Features
|
|
22
|
+
- Type-safe variant definitions with full TypeScript inference
|
|
23
|
+
- Support for required and optional variants
|
|
24
|
+
- Boolean variants (true/false)
|
|
25
|
+
- Default variants
|
|
26
|
+
- Compound variants (combinations of multiple variant states)
|
|
27
|
+
- Forwarding variant values via `forwardProps`
|
|
28
|
+
- Polymorphic components with "as" prop
|
|
29
|
+
- Optional `defaultProps` with strong typing (defaulted props become optional while respecting polymorphic `as`)
|
|
30
|
+
- Custom class name combination strategies
|
|
31
|
+
|
|
32
|
+
## File Structure
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
src/
|
|
36
|
+
├── index.ts # Core variant logic and types
|
|
37
|
+
├── react.ts # React bindings (styled, variantProps)
|
|
38
|
+
├── preact.ts # Preact bindings (similar to React)
|
|
39
|
+
└── example/ # Example implementations
|
|
40
|
+
├── index.ts # Vanilla example
|
|
41
|
+
├── react.tsx # React example
|
|
42
|
+
└── preact.tsx # Preact example
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Package Structure
|
|
46
|
+
|
|
47
|
+
- ESM modules only
|
|
48
|
+
- Multiple entry points: main, react, preact
|
|
49
|
+
- Fully typed with TypeScript declarations
|
|
50
|
+
|
|
51
|
+
## Key Concepts
|
|
52
|
+
|
|
53
|
+
### Variant Configuration
|
|
54
|
+
```ts
|
|
55
|
+
{
|
|
56
|
+
base?: string, // Always applied classes
|
|
57
|
+
variants: { // Variant definitions
|
|
58
|
+
[variantName]: {
|
|
59
|
+
[optionName]: "class-names"
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
defaultVariants?: {...}, // Default values
|
|
63
|
+
defaultProps?: {...}, // Defaulted non-variant props (e.g. { type: "button" })
|
|
64
|
+
compoundVariants?: [...] // Conditional class combinations
|
|
65
|
+
forwardProps?: ["disabled", ...] // Variant keys to pass through as real props
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### TypeScript Magic
|
|
70
|
+
The library uses advanced TypeScript features to:
|
|
71
|
+
- Infer variant prop types from configuration
|
|
72
|
+
- Make variants required unless they have defaults or are boolean
|
|
73
|
+
- Convert "true"/"false" keys to boolean props
|
|
74
|
+
- Infer optionality for props supplied via `defaultProps` while preserving `as` polymorphism
|
|
75
|
+
- Provide full autocomplete and type checking
|
|
76
|
+
- Preserve variant prop ergonomics when using `forwardProps`; forwarded keys remain strongly typed and emitted alongside the generated className
|
|
77
|
+
|
|
78
|
+
## Usage Examples
|
|
79
|
+
|
|
80
|
+
### NPM Installation & Imports
|
|
81
|
+
```bash
|
|
82
|
+
npm install classname-variants
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// Vanilla/core
|
|
87
|
+
import { variants, tw } from "classname-variants"
|
|
88
|
+
|
|
89
|
+
// React
|
|
90
|
+
import { styled, tw } from "classname-variants/react"
|
|
91
|
+
|
|
92
|
+
// Preact
|
|
93
|
+
import { styled, tw } from "classname-variants/preact"
|
|
94
|
+
|
|
95
|
+
// Low-level API (rarely used directly)
|
|
96
|
+
import { variantProps } from "classname-variants/react"
|
|
97
|
+
import { variantProps } from "classname-variants/preact"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Styled Components Without Variants
|
|
101
|
+
Simple string-based styling for components that don't need variants:
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
const Card = styled("div", "bg-white p-4 border-2 rounded-lg");
|
|
105
|
+
const CustomCard = styled(CustomComponent, "bg-white p-4 border-2 rounded-lg");
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Default Props in Styled Components
|
|
109
|
+
Provide defaults for non-variant props while keeping TypeScript happy:
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
const Button = styled("button", {
|
|
113
|
+
base: "inline-flex items-center gap-2",
|
|
114
|
+
defaultProps: {
|
|
115
|
+
type: "button",
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
<Button />; // `type` inferred as optional
|
|
120
|
+
<Button type="submit" />; // Still overridable
|
|
121
|
+
<Button as="a" href="/docs" />; // Works with polymorphic `as`
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Forwarding Variant Values
|
|
125
|
+
Mirror a variant onto the rendered element to toggle native behaviour:
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
const Button = styled("button", {
|
|
129
|
+
base: "inline-flex items-center gap-2",
|
|
130
|
+
variants: {
|
|
131
|
+
disabled: {
|
|
132
|
+
true: "opacity-50",
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
forwardProps: ["disabled"],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Applies the class names *and* the DOM `disabled` prop
|
|
139
|
+
<Button disabled />;
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Polymorphic Components with "as" Prop
|
|
143
|
+
Change the underlying element/component while keeping the same styles:
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
const Button = styled("button", { variants: {...} });
|
|
147
|
+
|
|
148
|
+
// Usage
|
|
149
|
+
<Button>I'm a button</Button>
|
|
150
|
+
<Button as="a" href="/">I'm a link!</Button>
|
|
151
|
+
<Button as={Link} to="/">I'm a router Link</Button>
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Tailwind CSS Integration
|
|
155
|
+
|
|
156
|
+
#### Using tailwind-merge for Conflict Resolution
|
|
157
|
+
```ts
|
|
158
|
+
import { classNames } from "classname-variants";
|
|
159
|
+
import { twMerge } from "tailwind-merge";
|
|
160
|
+
|
|
161
|
+
// Override default combination strategy
|
|
162
|
+
classNames.combine = twMerge;
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
#### IDE Support with Tagged Templates
|
|
166
|
+
```ts
|
|
167
|
+
import { variants, tw } from "classname-variants";
|
|
168
|
+
|
|
169
|
+
const button = variants({
|
|
170
|
+
base: tw`px-5 py-2 text-white`,
|
|
171
|
+
variants: {
|
|
172
|
+
color: {
|
|
173
|
+
neutral: tw`bg-slate-500 hover:bg-slate-400`,
|
|
174
|
+
accent: tw`bg-teal-500 hover:bg-teal-400`,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Add to VS Code settings.json:
|
|
181
|
+
```json
|
|
182
|
+
"tailwindCSS.experimental.classRegex": ["tw`(.+?)`"]
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Advantages vs Alternatives
|
|
186
|
+
|
|
187
|
+
### vs clsx/classnames
|
|
188
|
+
- **Type Safety**: Full TypeScript inference for variant props
|
|
189
|
+
- **Variant System**: Built-in support for component variants vs manual conditional logic
|
|
190
|
+
- **Default Values**: Automatic handling of default variants
|
|
191
|
+
- **Compound Variants**: Built-in support for variant combinations
|
|
192
|
+
|
|
193
|
+
### vs class-variance-authority (cva)
|
|
194
|
+
- **Zero Dependencies**: No external dependencies vs cva's clsx dependency
|
|
195
|
+
- **Framework Integration**: Built-in React/Preact components with `styled()` API
|
|
196
|
+
- **Polymorphic Support**: Native "as" prop support for component flexibility
|
|
197
|
+
- **TypeScript Focus**: Designed TypeScript-first with advanced type inference
|
|
198
|
+
|
|
199
|
+
## Dependencies
|
|
200
|
+
|
|
201
|
+
- Zero runtime dependencies
|
|
202
|
+
- Development dependencies: React, Preact, TypeScript
|
|
203
|
+
- Designed to work with any CSS framework (Tailwind, CSS modules, etc.)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "classname-variants",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Variant API for plain class names",
|
|
5
5
|
"author": "Felix Gnass <fgnass@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,8 +37,11 @@
|
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "tsc",
|
|
40
|
+
"lint": "biome check .",
|
|
41
|
+
"lint:fix": "biome check --write --unsafe .",
|
|
40
42
|
"start": "npx vite",
|
|
41
|
-
"
|
|
43
|
+
"test": "vitest",
|
|
44
|
+
"prepublishOnly": "npm run build && npm test"
|
|
42
45
|
},
|
|
43
46
|
"keywords": [
|
|
44
47
|
"tailwind",
|
|
@@ -49,11 +52,14 @@
|
|
|
49
52
|
"preact"
|
|
50
53
|
],
|
|
51
54
|
"devDependencies": {
|
|
55
|
+
"@biomejs/biome": "^2.2.6",
|
|
52
56
|
"@types/react": "^18.0.26",
|
|
53
57
|
"@types/react-dom": "^18.0.9",
|
|
58
|
+
"happy-dom": "^20.0.5",
|
|
54
59
|
"preact": "^10.20.2",
|
|
55
60
|
"react": "^18.2.0",
|
|
56
61
|
"react-dom": "^18.2.0",
|
|
57
|
-
"typescript": "^4.9.4"
|
|
62
|
+
"typescript": "^4.9.4",
|
|
63
|
+
"vitest": "^3.2.4"
|
|
58
64
|
}
|
|
59
65
|
}
|