classname-variants 1.6.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 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,11 +313,7 @@ const button = variants({
270
313
  });
271
314
  ```
272
315
 
273
- You can then add the following line to your `settings.json`:
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).
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
+ }
@@ -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"));
@@ -1,11 +1,17 @@
1
1
  /** @jsx h */
2
- import { styled } from "../preact";
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, "bg-white p-4 border-2 rounded-lg");
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, { size: "medium", color: "foo" }, "Invalid variant"),
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", onClick: console.log }, "Neutral"),
67
- h(Button, { size: "medium", rounded: true }, "Neutral + Rounded"),
68
- h(Button, { size: "medium", color: "accent", outlined: true }, "Accent + Outlined"),
69
- h(Button, { size: "medium", color: "accent", disabled: true }, "Disabled"),
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"),
@@ -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;
@@ -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 TitleCard = styled(CustomComponent, "bg-white p-4 border-2 rounded-lg");
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, { size: "medium", color: "foo" }, "Invalid variant"),
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", onClick: console.log }, "Neutral"),
73
- React.createElement(Button, { size: "medium", rounded: true }, "Neutral + Rounded"),
74
- React.createElement(Button, { size: "medium", color: "accent", outlined: true }, "Accent + Outlined"),
75
- React.createElement(Button, { size: "medium", color: "accent", disabled: true }, "Disabled"),
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 (let name in variants) {
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 (let { variants, className } of compoundVariants !== null && compoundVariants !== void 0 ? compoundVariants : []) {
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 { JSX, ComponentType, ComponentProps, VNode } from "preact";
1
+ import type { ComponentProps, ComponentType, JSX, VNode } from "preact";
2
2
  type ElementType<P = any> = keyof JSX.IntrinsicElements | ComponentType<P>;
3
- import { Variants, VariantsConfig, VariantOptions, Simplify } from "./index.js";
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, keyof C["variants"]>;
18
- type VariantsOf<T, V> = T extends VariantsConfig ? V : {};
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, C["variants"]>>(type: T, config: string | {
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
- } | Simplify<C>): <As extends ElementType = T>(props: PolymorphicComponentProps<typeof config extends string ? {} : typeof config extends {
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 { variants, classNames, } from "./index.js";
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 (let prop in props) {
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({ base: config.base, variants: {} });
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
- return h(as !== null && as !== void 0 ? as : type, { ...styledProps(props), ref });
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 { Variants, VariantsConfig, VariantOptions, Simplify } from "./index.js";
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, keyof C["variants"]>;
16
- type VariantsOf<T, V> = T extends VariantsConfig ? V : {};
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, C["variants"]>>(type: T, config: string | {
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
- } | Simplify<C>): <As extends ElementType = T>(props: PolymorphicComponentProps<typeof config extends string ? {} : typeof config extends {
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 { variants, classNames, } from "./index.js";
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 (let prop in props) {
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({ base: config.base, variants: {} });
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
- return createElement(as !== null && as !== void 0 ? as : type, { ...styledProps(props), ref });
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,8 +15,8 @@ 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 equivalent `defaultProps` support
20
20
 
21
21
  ### Key Features
22
22
  - Type-safe variant definitions with full TypeScript inference
@@ -24,7 +24,9 @@ This library provides a variant API for generating class names based on componen
24
24
  - Boolean variants (true/false)
25
25
  - Default variants
26
26
  - Compound variants (combinations of multiple variant states)
27
+ - Forwarding variant values via `forwardProps`
27
28
  - Polymorphic components with "as" prop
29
+ - Optional `defaultProps` with strong typing (defaulted props become optional while respecting polymorphic `as`)
28
30
  - Custom class name combination strategies
29
31
 
30
32
  ## File Structure
@@ -58,7 +60,9 @@ src/
58
60
  }
59
61
  },
60
62
  defaultVariants?: {...}, // Default values
63
+ defaultProps?: {...}, // Defaulted non-variant props (e.g. { type: "button" })
61
64
  compoundVariants?: [...] // Conditional class combinations
65
+ forwardProps?: ["disabled", ...] // Variant keys to pass through as real props
62
66
  }
63
67
  ```
64
68
 
@@ -67,7 +71,9 @@ The library uses advanced TypeScript features to:
67
71
  - Infer variant prop types from configuration
68
72
  - Make variants required unless they have defaults or are boolean
69
73
  - Convert "true"/"false" keys to boolean props
74
+ - Infer optionality for props supplied via `defaultProps` while preserving `as` polymorphism
70
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
71
77
 
72
78
  ## Usage Examples
73
79
 
@@ -99,6 +105,40 @@ const Card = styled("div", "bg-white p-4 border-2 rounded-lg");
99
105
  const CustomCard = styled(CustomComponent, "bg-white p-4 border-2 rounded-lg");
100
106
  ```
101
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
+
102
142
  ### Polymorphic Components with "as" Prop
103
143
  Change the underlying element/component while keeping the same styles:
104
144
 
@@ -160,4 +200,4 @@ Add to VS Code settings.json:
160
200
 
161
201
  - Zero runtime dependencies
162
202
  - Development dependencies: React, Preact, TypeScript
163
- - Designed to work with any CSS framework (Tailwind, CSS modules, etc.)
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.6.0",
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
- "prepublishOnly": "npm run build"
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
  }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "happy-dom",
6
+ globals: true,
7
+ include: ["src/**/*.{test,spec}.{ts,tsx}"],
8
+ },
9
+ });
@@ -1,12 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(npm run build:*)",
5
- "Bash(npx tsc:*)",
6
- "Bash(rm:*)",
7
- "Bash(node:*)"
8
- ],
9
- "deny": [],
10
- "ask": []
11
- }
12
- }