From d7cf9b54ceed3bbb251661ee542372082074e044 Mon Sep 17 00:00:00 2001 From: alstjr7375 Date: Sat, 22 Nov 2025 00:00:00 +0900 Subject: [PATCH] Feat: `cx` function from clsx #285 --- packages/css/package.json | 3 +- packages/css/src/classname/cx.ts | 130 ++++++++++++++++++++++++++++ packages/css/src/classname/index.ts | 3 + packages/css/src/classname/types.ts | 26 ++++++ packages/css/src/index.ts | 6 ++ yarn.lock | 8 ++ 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 packages/css/src/classname/cx.ts create mode 100644 packages/css/src/classname/index.ts create mode 100644 packages/css/src/classname/types.ts diff --git a/packages/css/package.json b/packages/css/package.json index 80ef40a9..fa98003a 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -84,7 +84,8 @@ "prettier": "prettier-config-custom", "dependencies": { "@fastify/deepmerge": "^3.1.0", - "@mincho-js/transform-to-vanilla": "workspace:^" + "@mincho-js/transform-to-vanilla": "workspace:^", + "clsx": "^2.1.1" }, "devDependencies": { "@vanilla-extract/css": "^1.17.4", diff --git a/packages/css/src/classname/cx.ts b/packages/css/src/classname/cx.ts new file mode 100644 index 00000000..6bb800db --- /dev/null +++ b/packages/css/src/classname/cx.ts @@ -0,0 +1,130 @@ +import { clsx } from "clsx"; + +/** + * Conditionally join class names into a single string + * + * @param inputs - Class values to merge (strings, objects, arrays, or falsy values) + * @returns Merged class name string with duplicates preserved + * + * @example + * // Strings (variadic) + * cx('foo', true && 'bar', 'baz'); + * // => 'foo bar baz' + * + * @example + * // Objects + * cx({ foo: true, bar: false, baz: isTrue() }); + * // => 'foo baz' + * + * @example + * // Arrays (with nesting) + * cx(['foo', 0, false, 'bar']); + * // => 'foo bar' + * + * @example + * // Kitchen sink + * cx('foo', [1 && 'bar', { baz: false }], ['hello', ['world']], 'cya'); + * // => 'foo bar hello world cya' + */ +export const cx = clsx; + +// == Tests ==================================================================== +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore error TS1343 +if (import.meta.vitest) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore error TS1343 + const { describe, it, expect, assertType } = import.meta.vitest; + + describe.concurrent("cx()", () => { + it("handles string inputs (variadic)", () => { + expect(cx("foo", "bar", "baz")).toBe("foo bar baz"); + expect(cx("foo")).toBe("foo"); + expect(cx("")).toBe(""); + }); + + it("handles conditional string inputs", () => { + const isActive = true; + const isHidden = false; + expect(cx("foo", isActive && "bar", "baz")).toBe("foo bar baz"); + expect(cx("foo", isHidden && "bar", "baz")).toBe("foo baz"); + expect(cx("foo", null, "baz")).toBe("foo baz"); + expect(cx("foo", undefined, "baz")).toBe("foo baz"); + }); + + it("handles object inputs", () => { + expect(cx({ foo: true, bar: false, baz: true })).toBe("foo baz"); + expect(cx({ foo: true })).toBe("foo"); + expect(cx({ foo: false })).toBe(""); + expect(cx({ foo: 1, bar: 0, baz: "truthy" })).toBe("foo baz"); + }); + + it("handles object inputs (variadic)", () => { + expect(cx({ foo: true }, { bar: false }, null, { baz: "hello" })).toBe( + "foo baz" + ); + }); + + it("handles array inputs", () => { + expect(cx(["foo", 0, false, "bar"])).toBe("foo bar"); + expect(cx(["foo"])).toBe("foo"); + expect(cx([null, undefined, false])).toBe(""); + }); + + it("handles array inputs (variadic)", () => { + expect( + cx(["foo"], ["", 0, false, "bar"], [["baz", [["hello"], "there"]]]) + ).toBe("foo bar baz hello there"); + }); + + it("handles nested arrays", () => { + expect(cx([["foo", [["bar"]]]])).toBe("foo bar"); + expect(cx(["foo", ["bar", ["baz"]]])).toBe("foo bar baz"); + }); + + it("handles kitchen sink (mixed inputs with nesting)", () => { + const isVisible = true; + expect( + cx( + "foo", + [isVisible && "bar", { baz: false, bat: null }, ["hello", ["world"]]], + "cya" + ) + ).toBe("foo bar hello world cya"); + }); + + it("handles number inputs", () => { + expect(cx(1, 2, 3)).toBe("1 2 3"); + expect(cx("foo", 42, "bar")).toBe("foo 42 bar"); + }); + + it("filters falsy values correctly", () => { + expect(cx(null)).toBe(""); + expect(cx(undefined)).toBe(""); + expect(cx(false)).toBe(""); + expect(cx(0)).toBe(""); + expect(cx("")).toBe(""); + expect(cx(null, undefined, false, 0, "")).toBe(""); + }); + + it("preserves whitespace in class names", () => { + expect(cx("foo bar")).toBe("foo bar"); + }); + + it("handles empty inputs", () => { + expect(cx()).toBe(""); + expect(cx([])).toBe(""); + expect(cx({})).toBe(""); + }); + + it("accepts valid input types", () => { + assertType(cx("foo")); + assertType(cx("foo", "bar")); + assertType(cx({ foo: true })); + assertType(cx(["foo", "bar"])); + assertType(cx("foo", { bar: true }, ["baz"])); + assertType(cx(null, undefined, false)); + assertType(cx(123)); + }); + }); +} diff --git a/packages/css/src/classname/index.ts b/packages/css/src/classname/index.ts new file mode 100644 index 00000000..e42896b6 --- /dev/null +++ b/packages/css/src/classname/index.ts @@ -0,0 +1,3 @@ +export { cx } from "./cx.js"; + +export type { ClassValue, ClassArray, ClassDictionary } from "./types.js"; diff --git a/packages/css/src/classname/types.ts b/packages/css/src/classname/types.ts new file mode 100644 index 00000000..7226c2a4 --- /dev/null +++ b/packages/css/src/classname/types.ts @@ -0,0 +1,26 @@ +// == cx() Types =============================================================== +// https://github.com/lukeed/clsx/blob/master/clsx.d.mts +/** + * Valid class value types for cx() + * Supports strings, numbers, objects, arrays, and falsy values + */ +export type ClassValue = + | ClassArray + | ClassDictionary + | string + | number + | bigint + | null + | boolean + | undefined; + +/** + * Object with class names as keys and boolean conditions as values + * @example { 'bg-blue-500': true, 'text-white': isActive } + */ +export type ClassDictionary = Record; + +/** + * Array of class values (supports nesting) + */ +export type ClassArray = ClassValue[]; diff --git a/packages/css/src/index.ts b/packages/css/src/index.ts index e3165e34..4facefc5 100644 --- a/packages/css/src/index.ts +++ b/packages/css/src/index.ts @@ -58,3 +58,9 @@ export type { TokenNumberDefinition, ResolveTheme } from "./theme/types.js"; +export { cx } from "./classname/index.js"; +export type { + ClassValue, + ClassArray, + ClassDictionary +} from "./classname/index.js"; diff --git a/yarn.lock b/yarn.lock index 99568be1..22c3d136 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1431,6 +1431,7 @@ __metadata: "@fastify/deepmerge": "npm:^3.1.0" "@mincho-js/transform-to-vanilla": "workspace:^" "@vanilla-extract/css": "npm:^1.17.4" + clsx: "npm:^2.1.1" eslint-config-custom: "workspace:^" prettier-config-custom: "workspace:^" tsconfig-custom: "workspace:^" @@ -3086,6 +3087,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^2.1.1": + version: 2.1.1 + resolution: "clsx@npm:2.1.1" + checksum: 10/cdfb57fa6c7649bbff98d9028c2f0de2f91c86f551179541cf784b1cfdc1562dcb951955f46d54d930a3879931a980e32a46b598acaea274728dbe068deca919 + languageName: node + linkType: hard + "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1"