Skip to content

Commit 8ec5f6f

Browse files
authored
feat: add cx function and refactor cn to use tailwind-merge (#278)
- Add cx function for simple class name concatenation (like clsx) - Refactor cn to only support tailwind-merge (from tw-merge.js) - Remove cnBase export, replaced with cx - Update all internal references from cnBase to cx - Add comprehensive tests for cx and cn functions - Add cx benchmarks to benchmark.js - Update type definitions for all entry points
1 parent 1b67c1a commit 8ec5f6f

File tree

10 files changed

+255
-39
lines changed

10 files changed

+255
-39
lines changed

benchmark.js

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Benchmark from "benchmark";
33
import {cva} from "class-variance-authority";
44
import {extendTailwindMerge} from "tailwind-merge";
55

6-
import {tv} from "./src/index.js";
6+
import {tv, cx, cn} from "./src/index.js";
77

88
const suite = new Benchmark.Suite();
99

@@ -375,6 +375,38 @@ const cvaNoMerge = {
375375

376376
const cvaMerge = extendTailwindMerge({extend: twMergeConfig});
377377

378+
// Test data for cx benchmarks
379+
const simpleClasses = ["text-xl", "font-bold", "text-center", "px-4", "py-2"];
380+
const arrayClasses = [["px-4", "py-2"], "bg-blue-500", ["rounded-lg", "shadow-md"]];
381+
const objectClasses = {
382+
"text-sm": true,
383+
"font-bold": false,
384+
"bg-green-200": 1,
385+
"m-0": 0,
386+
"px-2": true,
387+
"py-2": true,
388+
};
389+
const mixedClasses = [
390+
"text-lg",
391+
["px-3", {"hover:bg-yellow-300": true, "focus:outline-none": false}],
392+
{"rounded-md": true, "shadow-md": null},
393+
"leading-tight",
394+
];
395+
const nestedArrays = [
396+
"px-4",
397+
["py-2", ["bg-blue-500", ["rounded-lg", false, ["shadow-md", ["text-white"]]]]],
398+
];
399+
const withFalsy = [
400+
"text-xl",
401+
false && "font-bold",
402+
"text-center",
403+
undefined,
404+
null,
405+
0,
406+
"",
407+
"px-4",
408+
];
409+
378410
// add tests
379411
suite
380412
.add("TV without slots & tw-merge (enabled)", function () {
@@ -423,6 +455,45 @@ suite
423455
cvaNoMerge.fallback();
424456
cvaNoMerge.image();
425457
})
458+
.add("cx - simple strings", function () {
459+
cx(...simpleClasses);
460+
})
461+
.add("cx - arrays", function () {
462+
cx(...arrayClasses);
463+
})
464+
.add("cx - objects", function () {
465+
cx(objectClasses);
466+
})
467+
.add("cx - mixed arguments", function () {
468+
cx(...mixedClasses);
469+
})
470+
.add("cx - nested arrays", function () {
471+
cx(...nestedArrays);
472+
})
473+
.add("cx - with falsy values", function () {
474+
cx(...withFalsy);
475+
})
476+
.add("cn - simple strings (with tw-merge)", function () {
477+
cn(...simpleClasses)({twMerge: true});
478+
})
479+
.add("cn - arrays (with tw-merge)", function () {
480+
cn(...arrayClasses)({twMerge: true});
481+
})
482+
.add("cn - objects (with tw-merge)", function () {
483+
cn(objectClasses)({twMerge: true});
484+
})
485+
.add("cn - mixed arguments (with tw-merge)", function () {
486+
cn(...mixedClasses)({twMerge: true});
487+
})
488+
.add("cn - nested arrays (with tw-merge)", function () {
489+
cn(...nestedArrays)({twMerge: true});
490+
})
491+
.add("cn - with falsy values (with tw-merge)", function () {
492+
cn(...withFalsy)({twMerge: true});
493+
})
494+
.add("cn - simple strings (without tw-merge)", function () {
495+
cn(...simpleClasses)({twMerge: false});
496+
})
426497

427498
// add listeners
428499
.on("cycle", function (event) {

src/__tests__/cn.test.ts

Lines changed: 167 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,179 @@
11
import {expect, describe, test} from "@jest/globals";
22

3-
import {cnBase as cnFull} from "../index";
4-
import {cn as cnLite} from "../lite";
5-
import {cn as cnUtils} from "../utils";
6-
7-
const variants = [
8-
{name: "full - tailwind-merge", cn: cnFull},
9-
{name: "lite - without tailwind-merge", cn: cnLite},
10-
{name: "utils - without tailwind-merge", cn: cnUtils},
3+
import {cn as cnWithMerge, cx as cxFull} from "../index";
4+
import {cn as cnLite, cx as cxLite} from "../lite";
5+
import {cx as cxUtils} from "../utils";
6+
7+
const cxVariants = [
8+
{name: "main index", cx: cxFull},
9+
{name: "lite", cx: cxLite},
10+
{name: "utils", cx: cxUtils},
1111
];
1212

13-
describe.each(variants)("cn function - $name", ({cn}) => {
13+
describe("cn function from lite (simple concatenation)", () => {
14+
test("should join strings and ignore falsy values", () => {
15+
expect(cnLite("text-xl", false && "font-bold", "text-center")()).toBe("text-xl text-center");
16+
expect(cnLite("text-xl", undefined, null, 0, "")()).toBe("text-xl 0");
17+
});
18+
19+
test("should join arrays of class names", () => {
20+
expect(cnLite(["px-4", "py-2"], "bg-blue-500")()).toBe("px-4 py-2 bg-blue-500");
21+
expect(cnLite(["px-4", false, ["hover:bg-red-500", null, "rounded-lg"]])()).toBe(
22+
"px-4 hover:bg-red-500 rounded-lg",
23+
);
24+
});
25+
26+
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+
);
30+
});
31+
32+
test("should join objects with truthy values as keys", () => {
33+
expect(cnLite({"text-sm": true, "font-bold": false, "bg-green-200": 1, "m-0": 0})()).toBe(
34+
"text-sm bg-green-200",
35+
);
36+
});
37+
38+
test("should handle mixed arguments correctly", () => {
39+
expect(
40+
cnLite(
41+
"text-lg",
42+
["px-3", {"hover:bg-yellow-300": true, "focus:outline-none": false}],
43+
{"rounded-md": true, "shadow-md": null},
44+
"leading-tight",
45+
)(),
46+
).toBe("text-lg px-3 hover:bg-yellow-300 rounded-md leading-tight");
47+
});
48+
49+
test("should handle numbers and bigint", () => {
50+
expect(cnLite(123, "text-base", 0n, {border: true})()).toBe("123 text-base 0 border");
51+
});
52+
53+
test("should return undefined for no input", () => {
54+
expect(cnLite()()).toBeUndefined();
55+
});
56+
57+
test("should return '0' for zero and ignore other falsy", () => {
58+
expect(cnLite(false, null, undefined, "", 0)()).toBe("0");
59+
});
60+
61+
test("should normalize template strings with irregular whitespace", () => {
62+
const input = `
63+
px-4
64+
py-2
65+
66+
bg-blue-500
67+
rounded-lg
68+
`;
69+
70+
expect(cnLite(input)()).toBe("px-4 py-2 bg-blue-500 rounded-lg");
71+
72+
expect(
73+
cnLite(
74+
` text-center
75+
font-semibold `,
76+
["text-sm", ` uppercase `],
77+
{"shadow-lg": true, "opacity-50": false},
78+
)(),
79+
).toBe("text-center font-semibold text-sm uppercase shadow-lg");
80+
});
81+
82+
test("should handle empty and falsy values correctly", () => {
83+
expect(cnLite("", null, undefined, false, NaN, 0, "0")()).toBe("0 0");
84+
});
85+
});
86+
87+
describe("cn function with tailwind-merge (main index)", () => {
88+
test("should merge conflicting tailwind classes when twMerge is true", () => {
89+
const result = cnWithMerge("px-2", "px-4", "py-2")({twMerge: true});
90+
91+
expect(result).toBe("px-4 py-2");
92+
});
93+
94+
test("should not merge classes when twMerge is false", () => {
95+
const result = cnWithMerge("px-2", "px-4", "py-2")({twMerge: false});
96+
97+
expect(result).toBe("px-2 px-4 py-2");
98+
});
99+
100+
test("should merge text color classes", () => {
101+
const result = cnWithMerge("text-red-500", "text-blue-500")({twMerge: true});
102+
103+
expect(result).toBe("text-blue-500");
104+
});
105+
106+
test("should merge background color classes", () => {
107+
const result = cnWithMerge("bg-red-500", "bg-blue-500")({twMerge: true});
108+
109+
expect(result).toBe("bg-blue-500");
110+
});
111+
112+
test("should merge multiple conflicting classes", () => {
113+
const result = cnWithMerge("px-2 py-1 text-sm", "px-4 py-2 text-lg")({twMerge: true});
114+
115+
expect(result).toBe("px-4 py-2 text-lg");
116+
});
117+
118+
test("should handle non-conflicting classes", () => {
119+
const result = cnWithMerge("px-2", "py-2", "text-sm")({twMerge: true});
120+
121+
expect(result).toBe("px-2 py-2 text-sm");
122+
});
123+
124+
test("should return undefined when no classes provided", () => {
125+
const result = cnWithMerge()({twMerge: true});
126+
127+
expect(result).toBeUndefined();
128+
});
129+
130+
test("should handle arrays with tailwind-merge", () => {
131+
const result = cnWithMerge(["px-2", "px-4"], "py-2")({twMerge: true});
132+
133+
expect(result).toBe("px-4 py-2");
134+
});
135+
136+
test("should handle objects with tailwind-merge", () => {
137+
const result = cnWithMerge({"px-2": true, "px-4": true, "py-2": true})({twMerge: true});
138+
139+
expect(result).toBe("px-4 py-2");
140+
});
141+
142+
test("should merge when config is undefined (default behavior)", () => {
143+
const result = cnWithMerge("px-2", "px-4")({twMerge: true});
144+
145+
expect(result).toBe("px-4");
146+
});
147+
});
148+
149+
describe.each(cxVariants)("cx function - $name", ({cx}) => {
14150
test("should join strings and ignore falsy values", () => {
15-
expect(cn("text-xl", false && "font-bold", "text-center")).toBe("text-xl text-center");
16-
expect(cn("text-xl", undefined, null, 0, "")).toBe("text-xl 0");
151+
expect(cx("text-xl", false && "font-bold", "text-center")).toBe("text-xl text-center");
152+
expect(cx("text-xl", undefined, null, 0, "")).toBe("text-xl 0");
17153
});
18154

19155
test("should join arrays of class names", () => {
20-
expect(cn(["px-4", "py-2"], "bg-blue-500")).toBe("px-4 py-2 bg-blue-500");
21-
expect(cn(["px-4", false, ["hover:bg-red-500", null, "rounded-lg"]])).toBe(
156+
expect(cx(["px-4", "py-2"], "bg-blue-500")).toBe("px-4 py-2 bg-blue-500");
157+
expect(cx(["px-4", false, ["hover:bg-red-500", null, "rounded-lg"]])).toBe(
22158
"px-4 hover:bg-red-500 rounded-lg",
23159
);
24160
});
25161

26162
test("should handle nested arrays", () => {
27-
expect(cn(["px-4", ["py-2", ["bg-blue-500", ["rounded-lg", false, ["shadow-md"]]]]])).toBe(
163+
expect(cx(["px-4", ["py-2", ["bg-blue-500", ["rounded-lg", false, ["shadow-md"]]]]])).toBe(
28164
"px-4 py-2 bg-blue-500 rounded-lg shadow-md",
29165
);
30166
});
31167

32168
test("should join objects with truthy values as keys", () => {
33-
expect(cn({"text-sm": true, "font-bold": false, "bg-green-200": 1, "m-0": 0})).toBe(
169+
expect(cx({"text-sm": true, "font-bold": false, "bg-green-200": 1, "m-0": 0})).toBe(
34170
"text-sm bg-green-200",
35171
);
36172
});
37173

38174
test("should handle mixed arguments correctly", () => {
39175
expect(
40-
cn(
176+
cx(
41177
"text-lg",
42178
["px-3", {"hover:bg-yellow-300": true, "focus:outline-none": false}],
43179
{"rounded-md": true, "shadow-md": null},
@@ -47,15 +183,15 @@ describe.each(variants)("cn function - $name", ({cn}) => {
47183
});
48184

49185
test("should handle numbers and bigint", () => {
50-
expect(cn(123, "text-base", 0n, {border: true})).toBe("123 text-base 0 border");
186+
expect(cx(123, "text-base", 0n, {border: true})).toBe("123 text-base 0 border");
51187
});
52188

53189
test("should return undefined for no input", () => {
54-
expect(cn()).toBeUndefined();
190+
expect(cx()).toBeUndefined();
55191
});
56192

57193
test("should return '0' for zero and ignore other falsy", () => {
58-
expect(cn(false, null, undefined, "", 0)).toBe("0");
194+
expect(cx(false, null, undefined, "", 0)).toBe("0");
59195
});
60196

61197
test("should normalize template strings with irregular whitespace", () => {
@@ -67,10 +203,10 @@ describe.each(variants)("cn function - $name", ({cn}) => {
67203
rounded-lg
68204
`;
69205

70-
expect(cn(input)).toBe("px-4 py-2 bg-blue-500 rounded-lg");
206+
expect(cx(input)).toBe("px-4 py-2 bg-blue-500 rounded-lg");
71207

72208
expect(
73-
cn(
209+
cx(
74210
` text-center
75211
font-semibold `,
76212
["text-sm", ` uppercase `],
@@ -80,6 +216,15 @@ describe.each(variants)("cn function - $name", ({cn}) => {
80216
});
81217

82218
test("should handle empty and falsy values correctly", () => {
83-
expect(cn("", null, undefined, false, NaN, 0, "0")).toBe("0 0");
219+
expect(cx("", null, undefined, false, NaN, 0, "0")).toBe("0 0");
220+
});
221+
222+
test("should NOT merge conflicting classes (simple concatenation)", () => {
223+
// cx should just concatenate, not merge
224+
expect(cx("px-2", "px-4", "py-2")).toBe("px-2 px-4 py-2");
225+
});
226+
227+
test("should handle conflicting classes without merging", () => {
228+
expect(cx("text-red-500", "text-blue-500")).toBe("text-red-500 text-blue-500");
84229
});
85230
});

src/core.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
removeExtraSpaces,
77
flatMergeArrays,
88
joinObjects,
9-
cn as cnBase,
9+
cx,
1010
} from "./utils.js";
1111
import {defaultConfig} from "./config.js";
1212
import {state} from "./state.js";
@@ -24,7 +24,7 @@ export const getTailwindVariants = (cn) => {
2424

2525
const config = {...defaultConfig, ...configProp};
2626

27-
const base = extend?.base ? cnBase(extend.base, options?.base) : options?.base;
27+
const base = extend?.base ? cx(extend.base, options?.base) : options?.base;
2828
const variants =
2929
extend?.variants && !isEmptyObject(extend.variants)
3030
? mergeObjects(variantsProps, extend.variants)
@@ -47,7 +47,7 @@ export const getTailwindVariants = (cn) => {
4747
const componentSlots = !isEmptyObject(slotProps)
4848
? {
4949
// add "base" to the slots object
50-
base: cnBase(options?.base, isExtendedSlotsEmpty && extend?.base),
50+
base: cx(options?.base, isExtendedSlotsEmpty && extend?.base),
5151
...slotProps,
5252
}
5353
: {};

src/index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {CnOptions, CnReturn, TV} from "./types.d.ts";
44
export type * from "./types.d.ts";
55

66
// util function
7-
export declare const cnBase: <T extends CnOptions>(...classes: T) => CnReturn;
7+
export declare const cx: <T extends CnOptions>(...classes: T) => CnReturn;
88

99
export declare const cn: <T extends CnOptions>(...classes: T) => (config?: TWMConfig) => CnReturn;
1010

src/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {getTailwindVariants} from "./core.js";
22
import {cn} from "./tw-merge.js";
3-
import {cn as cnBase} from "./utils.js";
3+
import {cx} from "./utils.js";
44
import {defaultConfig} from "./config.js";
55

66
export const {createTV, tv} = getTailwindVariants(cn);
77

8-
export {cn, cnBase, defaultConfig};
8+
export {cn, cx, defaultConfig};

src/lite.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type {CnOptions, CnReturn, TVLite} from "./types.d.ts";
33
export type * from "./types.d.ts";
44

55
// util function
6-
export declare const cnBase: <T extends CnOptions>(...classes: T) => CnReturn;
6+
export declare const cx: <T extends CnOptions>(...classes: T) => CnReturn;
77

88
export declare const cn: <T extends CnOptions>(...classes: T) => CnReturn;
99

0 commit comments

Comments
 (0)