classname-variants 1.6.0 → 1.7.1
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/LICENSE +7 -0
- package/README.md +156 -14
- 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 +5 -5
- package/lib/example/react.js +25 -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 +25 -7
- package/lib/preact.js +36 -4
- package/lib/preact.test.d.ts +1 -0
- package/lib/preact.test.js +52 -0
- package/lib/react.d.ts +25 -7
- package/lib/react.js +35 -4
- package/lib/react.test.d.ts +1 -0
- package/lib/react.test.js +78 -0
- package/llms.txt +102 -10
- package/package.json +9 -3
- package/vitest.config.ts +9 -0
- package/.claude/settings.local.json +0 -12
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2022 Felix Gnass
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -12,7 +12,28 @@ Library to create type-safe components that render their class name based on a s
|
|
|
12
12
|
|
|
13
13
|

|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install classname-variants
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Import Paths
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// Core — vanilla DOM, no framework dependency
|
|
25
|
+
import { variants, classNames, tw } from "classname-variants";
|
|
26
|
+
|
|
27
|
+
// React — styled components, variantProps, type utilities
|
|
28
|
+
import { styled, variantProps, tw } from "classname-variants/react";
|
|
29
|
+
import type { VariantPropsOf } from "classname-variants/react";
|
|
30
|
+
|
|
31
|
+
// Preact — same API, accepts both `class` and `className`
|
|
32
|
+
import { styled, variantProps, tw } from "classname-variants/preact";
|
|
33
|
+
import type { VariantPropsOf } from "classname-variants/preact";
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Examples
|
|
16
37
|
|
|
17
38
|
Here is an example that uses React and Tailwind CSS:
|
|
18
39
|
|
|
@@ -36,11 +57,9 @@ function UsageExample() {
|
|
|
36
57
|
}
|
|
37
58
|
```
|
|
38
59
|
|
|
39
|
-
[](https://codesandbox.io/s/classname-variants-react-3bzjl?fontsize=14&hidenavigation=1&theme=dark)
|
|
40
|
-
|
|
41
60
|
While the library has been designed with tools like Tailwind in mind, it can be also used with custom classes or CSS modules:
|
|
42
61
|
|
|
43
|
-
|
|
62
|
+
### Preact + CSS modules
|
|
44
63
|
|
|
45
64
|
```tsx
|
|
46
65
|
import { styled } from "classname-variants/preact";
|
|
@@ -56,7 +75,7 @@ const Button = styled("button", {
|
|
|
56
75
|
});
|
|
57
76
|
```
|
|
58
77
|
|
|
59
|
-
|
|
78
|
+
### Vanilla DOM
|
|
60
79
|
|
|
61
80
|
The core of the library is completely framework-agnostic:
|
|
62
81
|
|
|
@@ -80,7 +99,7 @@ document.write(`
|
|
|
80
99
|
`);
|
|
81
100
|
```
|
|
82
101
|
|
|
83
|
-
|
|
102
|
+
## API
|
|
84
103
|
|
|
85
104
|
### Defining variants
|
|
86
105
|
|
|
@@ -121,6 +140,8 @@ Variants can be typed as `boolean` by using `true` / `false` as key:
|
|
|
121
140
|
<Button primary>Click Me!</Button>
|
|
122
141
|
```
|
|
123
142
|
|
|
143
|
+
### Compound variants
|
|
144
|
+
|
|
124
145
|
The `compoundVariants` option can be used to apply class names based on a combination of other variants:
|
|
125
146
|
|
|
126
147
|
```ts
|
|
@@ -188,6 +209,114 @@ import { styled } from "classname-variants/react";
|
|
|
188
209
|
const Button = styled("button", "bg-transparent border p-2");
|
|
189
210
|
```
|
|
190
211
|
|
|
212
|
+
### Default props
|
|
213
|
+
|
|
214
|
+
If your underlying element (or custom component) expects props that you want to
|
|
215
|
+
provide automatically, you can use the `defaultProps` option. All defaulted
|
|
216
|
+
props become optional in TypeScript – even when you later render the component
|
|
217
|
+
with a polymorphic `as` prop.
|
|
218
|
+
|
|
219
|
+
```tsx
|
|
220
|
+
const Button = styled("button", {
|
|
221
|
+
base: "inline-flex items-center gap-2",
|
|
222
|
+
defaultProps: {
|
|
223
|
+
type: "button",
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// `type` is optional but still overridable
|
|
228
|
+
<Button />;
|
|
229
|
+
<Button type="submit" />;
|
|
230
|
+
|
|
231
|
+
// Works together with `as`
|
|
232
|
+
<Button as="a" href="/docs" />;
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Forwarding props
|
|
236
|
+
|
|
237
|
+
When a variant mirrors an existing prop (such as `disabled` on a button), add
|
|
238
|
+
it to `forwardProps` so the resolved value is passed through to the rendered
|
|
239
|
+
element or custom component.
|
|
240
|
+
|
|
241
|
+
```tsx
|
|
242
|
+
const Button = styled("button", {
|
|
243
|
+
variants: {
|
|
244
|
+
disabled: {
|
|
245
|
+
true: "cursor-not-allowed",
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
forwardProps: ["disabled"],
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Renders with both the class name and the DOM `disabled` prop applied.
|
|
252
|
+
<Button disabled />;
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Chaining additional class names
|
|
256
|
+
|
|
257
|
+
Styled components accept a `className` prop that gets merged with the variant output. This is useful for one-off overrides:
|
|
258
|
+
|
|
259
|
+
```tsx
|
|
260
|
+
<Button className="mt-4" size="large">Submit</Button>
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
The Preact adapter accepts both `class` and `className` — use whichever you prefer:
|
|
264
|
+
|
|
265
|
+
```tsx
|
|
266
|
+
<Button class="mt-4" size="large">Submit</Button>
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Ref forwarding
|
|
270
|
+
|
|
271
|
+
All `styled()` components support refs via `React.forwardRef`:
|
|
272
|
+
|
|
273
|
+
```tsx
|
|
274
|
+
const Input = styled("input", {
|
|
275
|
+
base: "border rounded px-2",
|
|
276
|
+
variants: { ... },
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
280
|
+
<Input ref={ref} />;
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### `variantProps()`
|
|
284
|
+
|
|
285
|
+
The lower-level `variantProps()` function lets you separate variant logic from rendering. This is useful for headless components or when you need more control:
|
|
286
|
+
|
|
287
|
+
```tsx
|
|
288
|
+
import { variantProps } from "classname-variants/react";
|
|
289
|
+
|
|
290
|
+
const buttonProps = variantProps({
|
|
291
|
+
base: "rounded px-4 py-2",
|
|
292
|
+
variants: {
|
|
293
|
+
intent: {
|
|
294
|
+
primary: "bg-teal-500 text-white",
|
|
295
|
+
secondary: "bg-slate-200",
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
function Button(props) {
|
|
301
|
+
// Extracts variant props, returns { className, ...rest }
|
|
302
|
+
const { className, ...rest } = buttonProps(props);
|
|
303
|
+
return <button className={className} {...rest} />;
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### `VariantPropsOf<T>`
|
|
308
|
+
|
|
309
|
+
Use this utility type to extract the variant props accepted by a `variantProps` function — helpful when building wrapper components:
|
|
310
|
+
|
|
311
|
+
```tsx
|
|
312
|
+
import { variantProps, type VariantPropsOf } from "classname-variants/react";
|
|
313
|
+
|
|
314
|
+
const buttonProps = variantProps({ ... });
|
|
315
|
+
|
|
316
|
+
type ButtonProps = VariantPropsOf<typeof buttonProps>;
|
|
317
|
+
// { intent: "primary" | "secondary"; className?: string }
|
|
318
|
+
```
|
|
319
|
+
|
|
191
320
|
### Styling custom components
|
|
192
321
|
|
|
193
322
|
You can style any custom React/Preact component as long as they accept a `className` prop (or `class` in case of Preact).
|
|
@@ -250,7 +379,24 @@ import { twMerge } from "tailwind-merge";
|
|
|
250
379
|
classNames.combine = twMerge;
|
|
251
380
|
```
|
|
252
381
|
|
|
253
|
-
|
|
382
|
+
## Why classname-variants?
|
|
383
|
+
|
|
384
|
+
### vs clsx / classnames
|
|
385
|
+
|
|
386
|
+
- **Type safety** — full TypeScript inference for variant props instead of manual conditional logic
|
|
387
|
+
- **Variant system** — built-in support for default values, compound variants, and boolean variants
|
|
388
|
+
- **Framework bindings** — `styled()` creates ready-to-use React/Preact components
|
|
389
|
+
|
|
390
|
+
### vs class-variance-authority (cva)
|
|
391
|
+
|
|
392
|
+
- **Zero dependencies** — no external runtime dependencies
|
|
393
|
+
- **Framework integration** — built-in `styled()` API with polymorphic `as` prop, ref forwarding, and `defaultProps`
|
|
394
|
+
- **Prop forwarding** — `forwardProps` maps variant values to DOM attributes (e.g. `disabled`)
|
|
395
|
+
- **TypeScript-first** — designed around type inference rather than requiring manual `VariantProps` extraction
|
|
396
|
+
|
|
397
|
+
If you're coming from cva: `cva()` maps to `variants()`, and `VariantProps<typeof x>` maps to `VariantPropsOf<typeof x>`.
|
|
398
|
+
|
|
399
|
+
## Tailwind IntelliSense
|
|
254
400
|
|
|
255
401
|
In order to get auto-completion for the CSS classes themselves, you can use the [Tailwind CSS IntelliSense](https://github.com/tailwindlabs/tailwindcss-intellisense) plugin for VS Code. In order to make it recognize the strings inside your variants-config, you have to somehow mark them and configure the plugin accordingly.
|
|
256
402
|
|
|
@@ -270,21 +416,17 @@ const button = variants({
|
|
|
270
416
|
});
|
|
271
417
|
```
|
|
272
418
|
|
|
273
|
-
You can then
|
|
274
|
-
|
|
275
|
-
```
|
|
276
|
-
"tailwindCSS.experimental.classRegex": ["tw`(.+?)`"]
|
|
277
|
-
```
|
|
419
|
+
You can then set the _Tailwind CSS: Class Functions_ option to `tw`.
|
|
278
420
|
|
|
279
421
|
> [!NOTE]
|
|
280
422
|
> 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
423
|
|
|
282
424
|
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
425
|
|
|
284
|
-
|
|
426
|
+
## For AI Assistants
|
|
285
427
|
|
|
286
428
|
For comprehensive technical documentation optimized for LLMs, see [`llms.txt`](./llms.txt).
|
|
287
429
|
|
|
288
|
-
|
|
430
|
+
## License
|
|
289
431
|
|
|
290
432
|
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,14 +1,14 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
export declare const StyledWithoutVariants: <As extends React.ElementType<any> = "div">(props: {
|
|
2
|
+
export declare const StyledWithoutVariants: <As extends React.ElementType<any, keyof React.JSX.IntrinsicElements> = "div">(props: {
|
|
3
3
|
as?: As | undefined;
|
|
4
4
|
} & Omit<React.ComponentProps<As>, "as"> & {} & {}) => React.ReactElement<any, string | React.JSXElementConstructor<any>> | null;
|
|
5
|
-
export declare const TestBaseOnly: <As extends React.ElementType<any> = "div">(props: {
|
|
5
|
+
export declare const TestBaseOnly: <As extends React.ElementType<any, keyof React.JSX.IntrinsicElements> = "div">(props: {
|
|
6
6
|
as?: As | undefined;
|
|
7
7
|
} & Omit<React.ComponentProps<As>, "as"> & {} & {}) => React.ReactElement<any, string | React.JSXElementConstructor<any>> | null;
|
|
8
|
-
export declare const ExpectErrors: <As extends React.ElementType<any> = "div">(props: {
|
|
8
|
+
export declare const ExpectErrors: <As extends React.ElementType<any, keyof React.JSX.IntrinsicElements> = "div">(props: {
|
|
9
9
|
as?: As | undefined;
|
|
10
10
|
} & Omit<React.ComponentProps<As>, "as" | "color"> & {
|
|
11
11
|
color: "neutral" | "accent";
|
|
12
12
|
} & {}) => React.ReactElement<any, string | React.JSXElementConstructor<any>> | null;
|
|
13
|
-
export declare function WithErrors(): JSX.Element;
|
|
14
|
-
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,10 @@ const Button = styled("button", {
|
|
|
33
43
|
defaultVariants: {
|
|
34
44
|
color: "neutral",
|
|
35
45
|
},
|
|
46
|
+
defaultProps: {
|
|
47
|
+
type: "button",
|
|
48
|
+
},
|
|
49
|
+
forwardProps: ["disabled"],
|
|
36
50
|
});
|
|
37
51
|
export const StyledWithoutVariants = styled("div", {
|
|
38
52
|
base: "bg-white",
|
|
@@ -58,21 +72,26 @@ export const ExpectErrors = styled("div", {
|
|
|
58
72
|
//@ts-expect-error
|
|
59
73
|
outlined: true,
|
|
60
74
|
},
|
|
75
|
+
forwardProps: [
|
|
76
|
+
//@ts-expect-error
|
|
77
|
+
"outlined",
|
|
78
|
+
],
|
|
61
79
|
});
|
|
62
80
|
export function WithErrors() {
|
|
63
81
|
return (React.createElement("div", null,
|
|
64
82
|
React.createElement(Button, { foo: true, size: "medium" }, "unknown property"),
|
|
65
83
|
React.createElement(Card, { foo: true }, "Unknown property"),
|
|
66
|
-
React.createElement(Button, {
|
|
84
|
+
React.createElement(Button, { color: "foo", size: "medium" }, "Invalid variant"),
|
|
67
85
|
React.createElement(Button, null, "Missing size"),
|
|
68
86
|
React.createElement(Card, { as: "b", href: "https://example.com" }, "B tags don't have a href attribute")));
|
|
69
87
|
}
|
|
70
88
|
export function ReactApp() {
|
|
71
89
|
return (React.createElement("div", { className: "flex justify-center items-center pt-8 gap-4 flex-wrap" },
|
|
72
|
-
React.createElement(Button, { size: "medium"
|
|
73
|
-
React.createElement(Button, { size: "medium"
|
|
74
|
-
React.createElement(Button, {
|
|
75
|
-
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),
|
|
76
95
|
React.createElement(TitleCard, { title: "Hello" }),
|
|
77
96
|
React.createElement(Card, null,
|
|
78
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,22 +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 | {
|
|
24
39
|
base: string;
|
|
25
|
-
|
|
40
|
+
defaultProps?: Defaults;
|
|
41
|
+
} | (Simplify<C> & {
|
|
42
|
+
defaultProps?: Defaults;
|
|
43
|
+
})): <As extends ElementType = T>(props: WithDefaultProps<PolymorphicComponentProps<typeof config extends string ? {} : typeof config extends {
|
|
26
44
|
base: string;
|
|
27
45
|
variants?: undefined;
|
|
28
46
|
compoundVariants?: undefined;
|
|
29
47
|
defaultVariants?: undefined;
|
|
30
|
-
} ? {} : VariantOptions<C>, As>) => VNode | null;
|
|
48
|
+
} ? {} : VariantOptions<C>, As>, Defaults>) => VNode | null;
|
|
31
49
|
/**
|
|
32
50
|
* No-op function to mark template literals as tailwind strings.
|
|
33
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;
|
|
@@ -21,9 +36,26 @@ export function styled(type, config) {
|
|
|
21
36
|
? variantProps({ base: config, variants: {} })
|
|
22
37
|
: "variants" in config
|
|
23
38
|
? variantProps(config)
|
|
24
|
-
: variantProps({
|
|
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;
|
|
25
49
|
const Component = forwardRef(({ as, ...props }, ref) => {
|
|
26
|
-
|
|
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 });
|
|
27
59
|
});
|
|
28
60
|
return Component;
|
|
29
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,22 +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 | {
|
|
22
37
|
base: string;
|
|
23
|
-
|
|
38
|
+
defaultProps?: Defaults;
|
|
39
|
+
} | (Simplify<C> & {
|
|
40
|
+
defaultProps?: Defaults;
|
|
41
|
+
})): <As extends ElementType = T>(props: WithDefaultProps<PolymorphicComponentProps<typeof config extends string ? {} : typeof config extends {
|
|
24
42
|
base: string;
|
|
25
43
|
variants?: undefined;
|
|
26
44
|
compoundVariants?: undefined;
|
|
27
45
|
defaultVariants?: undefined;
|
|
28
|
-
} ? {} : VariantOptions<C>, As>) => ReactElement | null;
|
|
46
|
+
} ? {} : VariantOptions<C>, As>, Defaults>) => ReactElement | null;
|
|
29
47
|
/**
|
|
30
48
|
* No-op function to mark template literals as tailwind strings.
|
|
31
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;
|
|
@@ -20,9 +35,25 @@ export function styled(type, config) {
|
|
|
20
35
|
? variantProps({ base: config, variants: {} })
|
|
21
36
|
: "variants" in config
|
|
22
37
|
? variantProps(config)
|
|
23
|
-
: variantProps({
|
|
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;
|
|
24
48
|
const Component = forwardRef(({ as, ...props }, ref) => {
|
|
25
|
-
|
|
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 });
|
|
26
57
|
});
|
|
27
58
|
return Component;
|
|
28
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
CHANGED
|
@@ -15,17 +15,28 @@ This library provides a variant API for generating class names based on componen
|
|
|
15
15
|
- `tw` - Tagged template literal helper for Tailwind CSS IntelliSense
|
|
16
16
|
|
|
17
17
|
### Framework Integrations
|
|
18
|
-
- `src/react.ts` - React-specific bindings with `styled()` and `variantProps()` functions
|
|
19
|
-
- `src/preact.ts` - Preact-specific bindings (similar to React but uses Preact's JSX)
|
|
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 `class` prop) with equivalent `defaultProps` support
|
|
20
|
+
|
|
21
|
+
### Class Application Order
|
|
22
|
+
When generating class names, the library combines them in this order:
|
|
23
|
+
1. `base` classes (always applied)
|
|
24
|
+
2. Variant classes (based on selected or default variant values)
|
|
25
|
+
3. Compound variant classes (when all conditions match)
|
|
26
|
+
4. User-supplied `className` prop (React/Preact only, via chaining)
|
|
20
27
|
|
|
21
28
|
### Key Features
|
|
22
29
|
- Type-safe variant definitions with full TypeScript inference
|
|
23
30
|
- Support for required and optional variants
|
|
24
31
|
- Boolean variants (true/false)
|
|
25
|
-
- Default variants
|
|
32
|
+
- Default variants via `defaultVariants` (note: NOT "defaults")
|
|
26
33
|
- Compound variants (combinations of multiple variant states)
|
|
34
|
+
- Forwarding variant values via `forwardProps`
|
|
27
35
|
- Polymorphic components with "as" prop
|
|
28
|
-
-
|
|
36
|
+
- Optional `defaultProps` with strong typing (defaulted props become optional while respecting polymorphic `as`)
|
|
37
|
+
- `className` prop chaining on styled components (user classes merged with variant output)
|
|
38
|
+
- Ref forwarding — all `styled()` components use `forwardRef` internally
|
|
39
|
+
- Works with any CSS framework (Tailwind, CSS modules, etc.) without additional dependencies
|
|
29
40
|
|
|
30
41
|
## File Structure
|
|
31
42
|
|
|
@@ -57,8 +68,10 @@ src/
|
|
|
57
68
|
[optionName]: "class-names"
|
|
58
69
|
}
|
|
59
70
|
},
|
|
60
|
-
defaultVariants?: {...}, //
|
|
71
|
+
defaultVariants?: {...}, // IMPORTANT: Use "defaultVariants" for default variant values
|
|
72
|
+
defaultProps?: {...}, // DIFFERENT: Use "defaultProps" for non-variant props (e.g. { type: "button" })
|
|
61
73
|
compoundVariants?: [...] // Conditional class combinations
|
|
74
|
+
forwardProps?: ["disabled", ...] // Variant keys to pass through as real props
|
|
62
75
|
}
|
|
63
76
|
```
|
|
64
77
|
|
|
@@ -67,7 +80,9 @@ The library uses advanced TypeScript features to:
|
|
|
67
80
|
- Infer variant prop types from configuration
|
|
68
81
|
- Make variants required unless they have defaults or are boolean
|
|
69
82
|
- Convert "true"/"false" keys to boolean props
|
|
83
|
+
- Infer optionality for props supplied via `defaultProps` while preserving `as` polymorphism
|
|
70
84
|
- Provide full autocomplete and type checking
|
|
85
|
+
- Preserve variant prop ergonomics when using `forwardProps`; forwarded keys remain strongly typed and emitted alongside the generated className
|
|
71
86
|
|
|
72
87
|
## Usage Examples
|
|
73
88
|
|
|
@@ -86,9 +101,9 @@ import { styled, tw } from "classname-variants/react"
|
|
|
86
101
|
// Preact
|
|
87
102
|
import { styled, tw } from "classname-variants/preact"
|
|
88
103
|
|
|
89
|
-
// Low-level API
|
|
90
|
-
import { variantProps } from "classname-variants/react"
|
|
91
|
-
import { variantProps } from "classname-variants/preact"
|
|
104
|
+
// Low-level API and type utilities
|
|
105
|
+
import { variantProps, type VariantPropsOf } from "classname-variants/react"
|
|
106
|
+
import { variantProps, type VariantPropsOf } from "classname-variants/preact"
|
|
92
107
|
```
|
|
93
108
|
|
|
94
109
|
### Styled Components Without Variants
|
|
@@ -99,6 +114,78 @@ const Card = styled("div", "bg-white p-4 border-2 rounded-lg");
|
|
|
99
114
|
const CustomCard = styled(CustomComponent, "bg-white p-4 border-2 rounded-lg");
|
|
100
115
|
```
|
|
101
116
|
|
|
117
|
+
### Default Props in Styled Components
|
|
118
|
+
Provide defaults for non-variant props while keeping TypeScript happy:
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
const Button = styled("button", {
|
|
122
|
+
base: "inline-flex items-center gap-2",
|
|
123
|
+
defaultProps: {
|
|
124
|
+
type: "button",
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
<Button />; // `type` inferred as optional
|
|
129
|
+
<Button type="submit" />; // Still overridable
|
|
130
|
+
<Button as="a" href="/docs" />; // Works with polymorphic `as`
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Forwarding Variant Values
|
|
134
|
+
Mirror a variant onto the rendered element to toggle native behaviour:
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
const Button = styled("button", {
|
|
138
|
+
base: "inline-flex items-center gap-2",
|
|
139
|
+
variants: {
|
|
140
|
+
disabled: {
|
|
141
|
+
true: "opacity-50",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
forwardProps: ["disabled"],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Applies the class names *and* the DOM `disabled` prop
|
|
148
|
+
<Button disabled />;
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### className Prop Chaining
|
|
152
|
+
Styled components accept an additional `className` prop that gets merged with the variant output:
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
<Button className="mt-4" size="large">Submit</Button>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The Preact adapter accepts both `class` and `className` — they are merged together, so either works:
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
<Button class="mt-4" size="large">Submit</Button>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### variantProps() for Headless Components
|
|
165
|
+
The lower-level `variantProps()` function separates variant logic from rendering, useful for headless components or custom wrappers:
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
import { variantProps, type VariantPropsOf } from "classname-variants/react";
|
|
169
|
+
|
|
170
|
+
const buttonProps = variantProps({
|
|
171
|
+
base: "rounded px-4 py-2",
|
|
172
|
+
variants: {
|
|
173
|
+
intent: {
|
|
174
|
+
primary: "bg-teal-500 text-white",
|
|
175
|
+
secondary: "bg-slate-200",
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Extract the variant prop types for the wrapper component
|
|
181
|
+
type ButtonProps = VariantPropsOf<typeof buttonProps>;
|
|
182
|
+
|
|
183
|
+
function Button(props: ButtonProps) {
|
|
184
|
+
const { className, ...rest } = buttonProps(props);
|
|
185
|
+
return <button className={className} {...rest} />;
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
102
189
|
### Polymorphic Components with "as" Prop
|
|
103
190
|
Change the underlying element/component while keeping the same styles:
|
|
104
191
|
|
|
@@ -113,8 +200,13 @@ const Button = styled("button", { variants: {...} });
|
|
|
113
200
|
|
|
114
201
|
### Tailwind CSS Integration
|
|
115
202
|
|
|
116
|
-
|
|
203
|
+
The library works with Tailwind CSS out of the box without any additional dependencies.
|
|
204
|
+
|
|
205
|
+
#### OPTIONAL: Using tailwind-merge for Class Conflict Resolution
|
|
206
|
+
**This is completely optional.** Only consider using tailwind-merge if you need to resolve conflicting Tailwind classes (e.g., when allowing className overrides). Most use cases don't need this - design your variants to avoid conflicts instead to reduce complexity and overhead.
|
|
207
|
+
|
|
117
208
|
```ts
|
|
209
|
+
// Only if you really need class merging:
|
|
118
210
|
import { classNames } from "classname-variants";
|
|
119
211
|
import { twMerge } from "tailwind-merge";
|
|
120
212
|
|
|
@@ -160,4 +252,4 @@ Add to VS Code settings.json:
|
|
|
160
252
|
|
|
161
253
|
- Zero runtime dependencies
|
|
162
254
|
- Development dependencies: React, Preact, TypeScript
|
|
163
|
-
- Designed to work with any CSS framework (Tailwind, CSS modules, etc.)
|
|
255
|
+
- 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.1",
|
|
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 && vitest --run"
|
|
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
|
}
|
package/vitest.config.ts
ADDED