Skip to content

Commit 1659aa7

Browse files
authored
fix: make twMerge default to true in cn function (#283)
* fix: make twMerge default to true in cn function * fix: make cn work directly without () and recommend cx for simple concatenation
1 parent d85ad99 commit 1659aa7

File tree

4 files changed

+193
-8
lines changed

4 files changed

+193
-8
lines changed

src/__tests__/cn.test.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ describe("cn function from lite (simple concatenation)", () => {
2424
});
2525

2626
test("should handle nested arrays", () => {
27-
expect(cnLite(["px-4", ["py-2", ["bg-blue-500", ["rounded-lg", false, ["shadow-md"]]]]])()).toBe(
28-
"px-4 py-2 bg-blue-500 rounded-lg shadow-md",
29-
);
27+
expect(
28+
cnLite(["px-4", ["py-2", ["bg-blue-500", ["rounded-lg", false, ["shadow-md"]]]]])(),
29+
).toBe("px-4 py-2 bg-blue-500 rounded-lg shadow-md");
3030
});
3131

3232
test("should join objects with truthy values as keys", () => {
@@ -144,6 +144,68 @@ describe("cn function with tailwind-merge (main index)", () => {
144144

145145
expect(result).toBe("px-4");
146146
});
147+
148+
test("should merge classes by default when called directly without ()", () => {
149+
const result = cnWithMerge("px-2", "px-4", "py-2");
150+
151+
// Should work as a string in template literals and string coercion
152+
expect(String(result)).toBe("px-4 py-2");
153+
expect(`${result}`).toBe("px-4 py-2");
154+
});
155+
156+
test("should merge classes by default when no config is provided", () => {
157+
const result = cnWithMerge("px-2", "px-4", "py-2")();
158+
159+
expect(result).toBe("px-4 py-2");
160+
});
161+
162+
test("should merge text color classes by default when called directly", () => {
163+
const result = cnWithMerge("text-red-500", "text-blue-500");
164+
165+
expect(String(result)).toBe("text-blue-500");
166+
});
167+
168+
test("should merge text color classes by default when no config is provided", () => {
169+
const result = cnWithMerge("text-red-500", "text-blue-500")();
170+
171+
expect(result).toBe("text-blue-500");
172+
});
173+
174+
test("should merge background color classes by default when called directly", () => {
175+
const result = cnWithMerge("bg-red-500", "bg-blue-500");
176+
177+
expect(String(result)).toBe("bg-blue-500");
178+
});
179+
180+
test("should merge background color classes by default when no config is provided", () => {
181+
const result = cnWithMerge("bg-red-500", "bg-blue-500")();
182+
183+
expect(result).toBe("bg-blue-500");
184+
});
185+
186+
test("should not merge classes when twMerge is explicitly false", () => {
187+
const result = cnWithMerge("px-2", "px-4", "py-2")({twMerge: false});
188+
189+
expect(result).toBe("px-2 px-4 py-2");
190+
});
191+
192+
test("should merge classes when twMerge is explicitly true (backward compatibility)", () => {
193+
const result = cnWithMerge("px-2", "px-4", "py-2")({twMerge: true});
194+
195+
expect(result).toBe("px-4 py-2");
196+
});
197+
198+
test("should merge classes when config is empty object (defaults to true)", () => {
199+
const result = cnWithMerge("px-2", "px-4", "py-2")({});
200+
201+
expect(result).toBe("px-4 py-2");
202+
});
203+
204+
test("should merge classes when config is undefined", () => {
205+
const result = cnWithMerge("px-2", "px-4", "py-2")(undefined);
206+
207+
expect(result).toBe("px-4 py-2");
208+
});
147209
});
148210

149211
describe.each(cxVariants)("cx function - $name", ({cx}) => {

src/index.d.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,102 @@ import type {CnOptions, CnReturn, TV} from "./types.d.ts";
33

44
export type * from "./types.d.ts";
55

6-
// util function
6+
/**
7+
* Combines class names into a single string. Similar to `clsx` - simple concatenation without merging conflicting classes.
8+
* @param classes - Class names to combine. Accepts strings, numbers, arrays, objects, and nested structures.
9+
* @returns A space-separated string of class names, or `undefined` if no valid classes are provided
10+
* @example
11+
* ```ts
12+
* // Simple concatenation
13+
* cx('text-xl', 'font-bold') // => 'text-xl font-bold'
14+
*
15+
* // Handles arrays and objects
16+
* cx(['px-4', 'py-2'], { 'bg-blue-500': true, 'text-white': false }) // => 'px-4 py-2 bg-blue-500'
17+
*
18+
* // Ignores falsy values (except 0)
19+
* cx('text-xl', false && 'font-bold', null, undefined) // => 'text-xl'
20+
* ```
21+
*/
722
export declare const cx: <T extends CnOptions>(...classes: T) => CnReturn;
823

24+
/**
25+
* Combines class names and merges conflicting Tailwind CSS classes using `tailwind-merge`.
26+
* @param classes - Class names to combine (strings, arrays, objects, etc.)
27+
* @returns A callable function that returns the merged class string. Works directly in template literals (coerces to string) or can be called with optional config.
28+
* @example
29+
* ```ts
30+
* // twMerge defaults to true - works directly in template literals
31+
* `${cn('bg-red-500', 'bg-blue-500')}` // => 'bg-blue-500'
32+
* String(cn('bg-red-500', 'bg-blue-500')) // => 'bg-blue-500'
33+
*
34+
* // Can still be called with config for explicit control
35+
* cn('bg-red-500', 'bg-blue-500')() // => 'bg-blue-500'
36+
*
37+
* // Note: If you need simple concatenation without merging, use `cx` instead:
38+
* // Instead of: cn('bg-red-500', 'bg-blue-500')({ twMerge: false })
39+
* // Use: cx('bg-red-500', 'bg-blue-500') // => 'bg-red-500 bg-blue-500'
40+
* ```
41+
*/
942
export declare const cn: <T extends CnOptions>(...classes: T) => (config?: TWMConfig) => CnReturn;
1043

11-
// main function
44+
/**
45+
* Creates a variant-aware component function with Tailwind CSS classes.
46+
* Supports variants, slots, compound variants, and component composition.
47+
* @example
48+
* ```ts
49+
* const button = tv({
50+
* base: "font-medium rounded-full",
51+
* variants: {
52+
* color: {
53+
* primary: "bg-blue-500 text-white",
54+
* secondary: "bg-purple-500 text-white",
55+
* },
56+
* size: {
57+
* sm: "text-sm px-3 py-1",
58+
* md: "text-base px-4 py-2",
59+
* },
60+
* },
61+
* defaultVariants: {
62+
* color: "primary",
63+
* size: "md",
64+
* },
65+
* });
66+
*
67+
* button({ color: "secondary", size: "sm" }) // => 'font-medium rounded-full bg-purple-500 text-white text-sm px-3 py-1'
68+
* ```
69+
* @see https://www.tailwind-variants.org/docs/getting-started
70+
*/
1271
export declare const tv: TV;
1372

73+
/**
74+
* Creates a configured `tv` instance with custom default configuration.
75+
* Useful when you want to set default `twMerge` or `twMergeConfig` options for all components.
76+
* @param config - Configuration object with default settings for `twMerge` and `twMergeConfig`
77+
* @returns A configured `tv` function that uses the provided defaults
78+
* @example
79+
* ```ts
80+
* // Create a tv instance with twMerge disabled by default
81+
* const tv = createTV({ twMerge: false });
82+
*
83+
* const button = tv({
84+
* base: "px-4 py-2",
85+
* variants: {
86+
* color: {
87+
* primary: "bg-blue-500",
88+
* },
89+
* },
90+
* });
91+
*
92+
* // Can still override config per component
93+
* const buttonWithMerge = tv(
94+
* {
95+
* base: "px-4 py-2",
96+
* variants: { color: { primary: "bg-blue-500" } },
97+
* },
98+
* { twMerge: true }
99+
* );
100+
* ```
101+
*/
14102
export declare function createTV(config: TVConfig): TV;
15103

16104
// types

src/lite.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export type * from "./types.d.ts";
55
// util function
66
export declare const cx: <T extends CnOptions>(...classes: T) => CnReturn;
77

8-
export declare const cn: <T extends CnOptions>(...classes: T) => CnReturn;
8+
export declare const cn: <T extends CnOptions>(...classes: T) => (config?: any) => CnReturn;
99

1010
// main function
1111
export declare const tv: TVLite;

src/tw-merge.js

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ export const createTwMerge = (cachedTwMergeConfig) => {
1919
};
2020

2121
export const cn = (...classnames) => {
22-
return (config) => {
22+
const execute = (config) => {
2323
const base = cx(classnames);
2424

25-
if (!base || !config.twMerge) return base;
25+
if (!base || !(config?.twMerge ?? true)) return base;
2626

2727
if (!state.cachedTwMerge || state.didTwMergeConfigChange) {
2828
state.didTwMergeConfigChange = false;
@@ -32,4 +32,39 @@ export const cn = (...classnames) => {
3232

3333
return state.cachedTwMerge(base) || undefined;
3434
};
35+
36+
// Execute immediately with default config
37+
const defaultResult = execute({});
38+
39+
// Create a function that can be called with config
40+
const fn = (config) => execute(config);
41+
42+
// Make the function work as both a function and a value
43+
// When used directly (e.g., in template literals), return the default result
44+
// When called as a function, execute with the provided config
45+
return new Proxy(fn, {
46+
apply(target, thisArg, args) {
47+
// Called as function: fn() or fn(config)
48+
return target(...args);
49+
},
50+
get(target, prop) {
51+
if (prop === Symbol.toPrimitive) {
52+
return (hint) => {
53+
if (hint === "string" || hint === "default") {
54+
return defaultResult ?? "";
55+
}
56+
57+
return defaultResult;
58+
};
59+
}
60+
if (prop === "valueOf") {
61+
return () => defaultResult;
62+
}
63+
if (prop === "toString") {
64+
return () => String(defaultResult ?? "");
65+
}
66+
67+
return target[prop];
68+
},
69+
});
3570
};

0 commit comments

Comments
 (0)