@jsdevlab/classnames 0.0.0-stage → 1.0.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/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jsdevlab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,361 @@
1
- # Temporary Holding Version
1
+ # @jsdevlab/classnames
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@jsdevlab/classnames.svg)](https://www.npmjs.com/package/@jsdevlab/classnames)
4
+ [![CI](https://github.com/jsdevlab/classnames/actions/workflows/ci.yml/badge.svg)](https://github.com/jsdevlab/classnames/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE.txt)
6
+
7
+ **English** | [简体中文](./README.zh-CN.md)
8
+
9
+ Type-safe conditional class names for JavaScript and TypeScript — framework-agnostic,
10
+ classnames-compatible, with lazy evaluation and deduplication by default.
11
+
12
+ ## Features
13
+
14
+ - **Familiar API** — the same `classNames(...)` call shape you already know.
15
+ - **Deduplication by default** — `'a b a'` becomes `'a b'`; first-occurrence order is preserved.
16
+ - **Lazy function values** — `{ active: () => condition }` is evaluated when the class string is built.
17
+ - **Rich inputs** — strings, numbers, booleans, objects, nested arrays, and `Set`s.
18
+ - **Dedup opt-out factory** — `createClassNames({ unique: false })` keeps duplicates when you need them.
19
+ - **`cx` alias** — a short alias with identical behavior.
20
+ - **TypeScript-first** — complete declarations and exported types.
21
+ - **Zero runtime dependencies** — ESM + CJS builds, works in any modern JS runtime.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pnpm add @jsdevlab/classnames
27
+ # or
28
+ npm install @jsdevlab/classnames
29
+ # or
30
+ yarn add @jsdevlab/classnames
31
+ ```
32
+
33
+ ## Quick start
34
+
35
+ ```ts
36
+ import { classNames, cx, createClassNames } from '@jsdevlab/classnames';
37
+
38
+ classNames('btn', 'btn-primary'); // 'btn btn-primary'
39
+ classNames('btn', { 'btn-primary': true, 'is-loading': false }); // 'btn btn-primary'
40
+ classNames('btn', ['btn-lg', ['rounded']]); // 'btn btn-lg rounded'
41
+ classNames('btn', new Set(['btn-lg', 'rounded'])); // 'btn btn-lg rounded'
42
+ classNames('btn', 'btn'); // 'btn'
43
+
44
+ cx('btn', { disabled: true }); // 'btn disabled'
45
+ ```
46
+
47
+ There is **no default export** — use named imports:
48
+
49
+ ```ts
50
+ import { classNames } from '@jsdevlab/classnames'; // ✅
51
+ // import classNames from '@jsdevlab/classnames'; // ❌ not supported
52
+ ```
53
+
54
+ ## Usage
55
+
56
+ ### Conditional class names
57
+
58
+ Object keys are emitted only when the corresponding value is truthy:
59
+
60
+ ```ts
61
+ const isActive = true;
62
+
63
+ classNames('tab', {
64
+ 'is-active': isActive,
65
+ 'is-disabled': !isActive,
66
+ });
67
+ // 'tab is-active'
68
+ ```
69
+
70
+ ### Strings and numbers
71
+
72
+ Numbers are stringified; top-level `0` is kept:
73
+
74
+ ```ts
75
+ classNames('z', 0, 1, 2); // 'z 0 1 2'
76
+ ```
77
+
78
+ Note that object values use truthiness, so `0` is dropped there:
79
+
80
+ ```ts
81
+ classNames({ zero: 0, one: 1 }); // 'one'
82
+ ```
83
+
84
+ ### Nested arrays and Sets
85
+
86
+ Arrays are flattened recursively, and `Set`s are supported directly:
87
+
88
+ ```ts
89
+ classNames(['a', ['b', ['c']]]); // 'a b c'
90
+ classNames(new Set(['a', 'b'])); // 'a b'
91
+ classNames([new Set(['a']), { b: true }]); // 'a b'
92
+ ```
93
+
94
+ ### Lazy function values
95
+
96
+ A function used as an **object value** is invoked once at call time. Its return
97
+ value only decides whether the key is included — it is not converted to a class
98
+ name:
99
+
100
+ ```ts
101
+ const flags = {
102
+ 'is-active': () => user.active,
103
+ 'is-admin': () => user.roles.includes('admin'),
104
+ };
105
+
106
+ classNames('user', flags);
107
+ // Both functions are evaluated now; only truthy keys are emitted.
108
+ ```
109
+
110
+ This keeps call sites clean and defers expensive checks until the class string is
111
+ actually built. Each function runs once per `classNames` call; reusing the same
112
+ object across calls re-evaluates it.
113
+
114
+ ### Deduplication
115
+
116
+ Duplicates are removed by default, preserving the order of first appearance:
117
+
118
+ ```ts
119
+ classNames('a', 'b', 'a', ['b', { a: true }]); // 'a b'
120
+ ```
121
+
122
+ ### Keeping duplicates
123
+
124
+ Create a factory with deduplication turned off:
125
+
126
+ ```ts
127
+ import { createClassNames } from '@jsdevlab/classnames';
128
+
129
+ const join = createClassNames({ unique: false });
130
+
131
+ join('a', 'a'); // 'a a'
132
+ classNames('a', 'a'); // 'a'
133
+ ```
134
+
135
+ Options are resolved when the factory is created, so the returned function is
136
+ bound to them:
137
+
138
+ ```ts
139
+ const cls = createClassNames(); // unique defaults to true
140
+ cls('a', 'a'); // 'a'
141
+ ```
142
+
143
+ ### React
144
+
145
+ ```tsx
146
+ import { cx } from '@jsdevlab/classnames';
147
+ import type { ReactNode } from 'react';
148
+
149
+ interface ButtonProps {
150
+ primary?: boolean;
151
+ loading?: boolean;
152
+ className?: string;
153
+ children: ReactNode;
154
+ }
155
+
156
+ export function Button({
157
+ primary,
158
+ loading,
159
+ className,
160
+ children,
161
+ }: ButtonProps) {
162
+ return (
163
+ <button
164
+ className={cx(
165
+ 'btn',
166
+ { 'btn-primary': primary, 'is-loading': loading },
167
+ className,
168
+ )}
169
+ disabled={loading}
170
+ >
171
+ {children}
172
+ </button>
173
+ );
174
+ }
175
+ ```
176
+
177
+ ### Vue
178
+
179
+ ```vue
180
+ <script setup lang="ts">
181
+ import { classNames } from '@jsdevlab/classnames';
182
+
183
+ defineProps<{ active?: boolean; disabled?: boolean }>();
184
+ </script>
185
+
186
+ <template>
187
+ <button
188
+ :class="classNames('tab', { 'is-active': active, 'is-disabled': disabled })"
189
+ >
190
+ <slot />
191
+ </button>
192
+ </template>
193
+ ```
194
+
195
+ Because the library is framework-agnostic, the same functions work in Svelte,
196
+ Solid, Angular, plain DOM code, or anywhere else JavaScript runs.
197
+
198
+ ## API
199
+
200
+ ### `classNames(...values: ClassValue[]): string`
201
+
202
+ Accepts any mix of supported values and returns a single space-separated class
203
+ name string. Returns `''` when nothing matches:
204
+
205
+ ```ts
206
+ classNames(); // ''
207
+ classNames(null, false, undefined); // ''
208
+ ```
209
+
210
+ ### `cx`
211
+
212
+ An alias of `classNames` with the identical signature and behavior:
213
+
214
+ ```ts
215
+ cx('a', { b: true }); // 'a b'
216
+ ```
217
+
218
+ ### `createClassNames(options?: ClassNamesOptions): typeof classNames`
219
+
220
+ Returns a `classNames`-style function bound to the given options.
221
+
222
+ #### `ClassNamesOptions`
223
+
224
+ | Option | Type | Default | Description |
225
+ | -------- | --------- | ------- | ------------------------------------------------- |
226
+ | `unique` | `boolean` | `true` | Deduplicate class names, preserving first-occurrence order. |
227
+
228
+ ```ts
229
+ const defaultJoin = createClassNames(); // unique: true
230
+ const keepAll = createClassNames({ unique: false }); // keeps duplicates
231
+ ```
232
+
233
+ ## Types
234
+
235
+ All types are exported:
236
+
237
+ ```ts
238
+ export type ClassValue =
239
+ | string
240
+ | number
241
+ | boolean
242
+ | null
243
+ | undefined
244
+ | ClassObject
245
+ | ClassValue[]
246
+ | Set<ClassValue>;
247
+
248
+ export interface ClassObject {
249
+ [key: string]: ClassValue | (() => ClassValue);
250
+ }
251
+
252
+ export interface ClassNamesOptions {
253
+ unique?: boolean;
254
+ }
255
+ ```
256
+
257
+ ## Behavior reference
258
+
259
+ | Input | Output |
260
+ | -------------------------------------------------------------- | ---------- |
261
+ | `classNames('a', 'b')` | `'a b'` |
262
+ | `classNames('a', '', null, undefined, false, true)` | `'a'` |
263
+ | `classNames(0, 1)` | `'0 1'` |
264
+ | `classNames({ a: true, b: false, c: 1, d: 0 })` | `'a c'` |
265
+ | `classNames(['a', [{ b: true }]])` | `'a b'` |
266
+ | `classNames(new Set(['a', 'b']))` | `'a b'` |
267
+ | `classNames({ active: () => true, hidden: () => false })` | `'active'` |
268
+ | `classNames('a', 'b', 'a')` | `'a b'` |
269
+ | `createClassNames({ unique: false })('a', 'a')` | `'a a'` |
270
+ | `classNames()` | `''` |
271
+
272
+ ## Notes and edge cases
273
+
274
+ - Class names are always joined with a **single space**; there is no custom
275
+ separator option.
276
+ - Deduplication compares exact, case-sensitive strings and does **not** split
277
+ strings: `classNames('a b', 'b')` returns `'a b b'`.
278
+ - Top-level `0` is kept (`classNames(0)` → `'0'`), but `0` as an object value is
279
+ falsy and dropped (`{ a: 0 }` → `''`).
280
+ - `true` and `false` are ignored as values; only object keys produce output.
281
+ - Functions are supported **only as object values**. A function passed as a
282
+ top-level argument is ignored.
283
+ - A function's return value is tested for truthiness; it does not become the
284
+ class name (`{ a: () => 'foo' }` → `'a'`).
285
+ - Output order follows input order; object keys follow `Object.entries` order.
286
+ Deduplication keeps the first occurrence.
287
+ - `null`, `undefined`, and empty strings are ignored.
288
+
289
+ ## classnames compatibility
290
+
291
+ `@jsdevlab/classnames` is a drop-in replacement for `classnames` in most cases:
292
+
293
+ ```diff
294
+ -import classNames from 'classnames';
295
+ +import { classNames } from '@jsdevlab/classnames';
296
+ ```
297
+
298
+ | Capability | `classnames` | `@jsdevlab/classnames` |
299
+ | ----------------------------------- | ------------------------------- | ----------------------------------- |
300
+ | Strings / numbers | ✅ | ✅ |
301
+ | Objects with truthy keys | ✅ | ✅ |
302
+ | Nested arrays | ✅ | ✅ |
303
+ | Skips `null` / `undefined` / `false` / `''` | ✅ | ✅ |
304
+ | `Set` input | — | ✅ |
305
+ | Function values in objects | Not evaluated (functions are truthy) | Evaluated at call time |
306
+ | Duplicate removal | via `classnames/dedupe` | Default |
307
+ | Keep duplicates | Default | `createClassNames({ unique: false })` |
308
+ | Custom separator | — | — |
309
+ | Default export | ✅ | — (named exports only) |
310
+
311
+ If you relied on the default duplicate-preserving behavior of `classnames`, bind
312
+ your own instance:
313
+
314
+ ```ts
315
+ import { createClassNames } from '@jsdevlab/classnames';
316
+
317
+ const classNames = createClassNames({ unique: false });
318
+ ```
319
+
320
+ ## Module formats
321
+
322
+ | Format | File |
323
+ | ---------- | ------------------ |
324
+ | ESM | `dist/index.js` |
325
+ | CommonJS | `dist/index.cjs` |
326
+ | TypeScript | `dist/index.d.ts` |
327
+
328
+ ```ts
329
+ // ESM
330
+ import { classNames } from '@jsdevlab/classnames';
331
+
332
+ // CommonJS
333
+ const { classNames } = require('@jsdevlab/classnames');
334
+ ```
335
+
336
+ The package has no runtime dependencies and works in modern browsers, Node.js,
337
+ Bun, and Deno.
338
+
339
+ ## Development
340
+
341
+ Requires Node.js ≥ 24.19 and pnpm 12.18.
342
+
343
+ ```bash
344
+ pnpm install
345
+ pnpm test # tsc --noEmit + rstest
346
+ pnpm build # rslib build → dist/
347
+ pnpm check # Biome lint + fix
348
+ pnpm format # Biome format + fix
349
+ ```
350
+
351
+ Releases are managed with [changesets](https://github.com/changesets/changesets):
352
+
353
+ ```bash
354
+ pnpm changeset # describe the change
355
+ pnpm version # bump versions and changelog
356
+ pnpm release # publish to npm
357
+ ```
358
+
359
+ ## License
360
+
361
+ [MIT](./LICENSE.txt) © 2026 jsdevlab
@@ -0,0 +1,353 @@
1
+ # @jsdevlab/classnames
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@jsdevlab/classnames.svg)](https://www.npmjs.com/package/@jsdevlab/classnames)
4
+ [![CI](https://github.com/jsdevlab/classnames/actions/workflows/ci.yml/badge.svg)](https://github.com/jsdevlab/classnames/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE.txt)
6
+
7
+ [English](./README.md) | **简体中文**
8
+
9
+ 面向 JavaScript 与 TypeScript 的类型安全条件类名工具 —— 框架无关、兼容 classnames,
10
+ 默认去重,并支持惰性求值。
11
+
12
+ ## 特性
13
+
14
+ - **熟悉的 API** —— 与 `classnames` 相同的 `classNames(...)` 调用方式。
15
+ - **默认去重** —— `'a b a'` 会变成 `'a b'`,并保留首次出现的顺序。
16
+ - **惰性函数值** —— `{ active: () => condition }` 在构建类名字符串时才求值。
17
+ - **输入类型丰富** —— 字符串、数字、布尔值、对象、嵌套数组与 `Set`。
18
+ - **可关闭去重的工厂** —— 需要保留重复项时使用 `createClassNames({ unique: false })`。
19
+ - **`cx` 别名** —— 行为完全一致的简短别名。
20
+ - **TypeScript 优先** —— 完整的类型声明与导出的类型。
21
+ - **零运行时依赖** —— 同时提供 ESM 与 CJS 构建,可在任何现代 JS 运行时中使用。
22
+
23
+ ## 安装
24
+
25
+ ```bash
26
+ pnpm add @jsdevlab/classnames
27
+ # 或
28
+ npm install @jsdevlab/classnames
29
+ # 或
30
+ yarn add @jsdevlab/classnames
31
+ ```
32
+
33
+ ## 快速开始
34
+
35
+ ```ts
36
+ import { classNames, cx, createClassNames } from '@jsdevlab/classnames';
37
+
38
+ classNames('btn', 'btn-primary'); // 'btn btn-primary'
39
+ classNames('btn', { 'btn-primary': true, 'is-loading': false }); // 'btn btn-primary'
40
+ classNames('btn', ['btn-lg', ['rounded']]); // 'btn btn-lg rounded'
41
+ classNames('btn', new Set(['btn-lg', 'rounded'])); // 'btn btn-lg rounded'
42
+ classNames('btn', 'btn'); // 'btn'
43
+
44
+ cx('btn', { disabled: true }); // 'btn disabled'
45
+ ```
46
+
47
+ 本包**没有默认导出**,请使用具名导入:
48
+
49
+ ```ts
50
+ import { classNames } from '@jsdevlab/classnames'; // ✅
51
+ // import classNames from '@jsdevlab/classnames'; // ❌ 不支持
52
+ ```
53
+
54
+ ## 用法
55
+
56
+ ### 条件类名
57
+
58
+ 只有当对象的值为真值时,对应的键才会被输出:
59
+
60
+ ```ts
61
+ const isActive = true;
62
+
63
+ classNames('tab', {
64
+ 'is-active': isActive,
65
+ 'is-disabled': !isActive,
66
+ });
67
+ // 'tab is-active'
68
+ ```
69
+
70
+ ### 字符串与数字
71
+
72
+ 数字会被转换为字符串;顶层的 `0` 会被保留:
73
+
74
+ ```ts
75
+ classNames('z', 0, 1, 2); // 'z 0 1 2'
76
+ ```
77
+
78
+ 注意,对象值按真值判断,因此其中的 `0` 会被丢弃:
79
+
80
+ ```ts
81
+ classNames({ zero: 0, one: 1 }); // 'one'
82
+ ```
83
+
84
+ ### 嵌套数组与 Set
85
+
86
+ 数组会被递归展平,同时也直接支持 `Set`:
87
+
88
+ ```ts
89
+ classNames(['a', ['b', ['c']]]); // 'a b c'
90
+ classNames(new Set(['a', 'b'])); // 'a b'
91
+ classNames([new Set(['a']), { b: true }]); // 'a b'
92
+ ```
93
+
94
+ ### 惰性函数值
95
+
96
+ 作为**对象值**传入的函数会在调用时执行一次。它的返回值只用于判断是否输出对应
97
+ 的键 —— 返回值本身不会作为类名:
98
+
99
+ ```ts
100
+ const flags = {
101
+ 'is-active': () => user.active,
102
+ 'is-admin': () => user.roles.includes('admin'),
103
+ };
104
+
105
+ classNames('user', flags);
106
+ // 两个函数会在此时求值,只有结果为真值的键会被输出。
107
+ ```
108
+
109
+ 这样可以让调用处保持简洁,并把开销较大的判断推迟到真正构建类名字符串时。每个
110
+ 函数在每次 `classNames` 调用中执行一次;在多次调用间复用同一个对象会重新求值。
111
+
112
+ ### 去重
113
+
114
+ 默认会移除重复类名,并保留首次出现的顺序:
115
+
116
+ ```ts
117
+ classNames('a', 'b', 'a', ['b', { a: true }]); // 'a b'
118
+ ```
119
+
120
+ ### 保留重复项
121
+
122
+ 创建一个关闭去重的工厂函数:
123
+
124
+ ```ts
125
+ import { createClassNames } from '@jsdevlab/classnames';
126
+
127
+ const join = createClassNames({ unique: false });
128
+
129
+ join('a', 'a'); // 'a a'
130
+ classNames('a', 'a'); // 'a'
131
+ ```
132
+
133
+ 选项在创建工厂时解析,返回的函数会绑定这些选项:
134
+
135
+ ```ts
136
+ const cls = createClassNames(); // unique 默认为 true
137
+ cls('a', 'a'); // 'a'
138
+ ```
139
+
140
+ ### React
141
+
142
+ ```tsx
143
+ import { cx } from '@jsdevlab/classnames';
144
+ import type { ReactNode } from 'react';
145
+
146
+ interface ButtonProps {
147
+ primary?: boolean;
148
+ loading?: boolean;
149
+ className?: string;
150
+ children: ReactNode;
151
+ }
152
+
153
+ export function Button({
154
+ primary,
155
+ loading,
156
+ className,
157
+ children,
158
+ }: ButtonProps) {
159
+ return (
160
+ <button
161
+ className={cx(
162
+ 'btn',
163
+ { 'btn-primary': primary, 'is-loading': loading },
164
+ className,
165
+ )}
166
+ disabled={loading}
167
+ >
168
+ {children}
169
+ </button>
170
+ );
171
+ }
172
+ ```
173
+
174
+ ### Vue
175
+
176
+ ```vue
177
+ <script setup lang="ts">
178
+ import { classNames } from '@jsdevlab/classnames';
179
+
180
+ defineProps<{ active?: boolean; disabled?: boolean }>();
181
+ </script>
182
+
183
+ <template>
184
+ <button
185
+ :class="classNames('tab', { 'is-active': active, 'is-disabled': disabled })"
186
+ >
187
+ <slot />
188
+ </button>
189
+ </template>
190
+ ```
191
+
192
+ 由于本库与框架无关,同样的函数也可用于 Svelte、Solid、Angular、原生 DOM 代码,
193
+ 或任何其他 JavaScript 运行环境。
194
+
195
+ ## API
196
+
197
+ ### `classNames(...values: ClassValue[]): string`
198
+
199
+ 接收任意组合的受支持值,返回以单个空格连接的类名字符串。没有有效类名时返回
200
+ `''`:
201
+
202
+ ```ts
203
+ classNames(); // ''
204
+ classNames(null, false, undefined); // ''
205
+ ```
206
+
207
+ ### `cx`
208
+
209
+ `classNames` 的别名,签名与行为完全一致:
210
+
211
+ ```ts
212
+ cx('a', { b: true }); // 'a b'
213
+ ```
214
+
215
+ ### `createClassNames(options?: ClassNamesOptions): typeof classNames`
216
+
217
+ 返回一个绑定指定选项的 `classNames` 风格函数。
218
+
219
+ #### `ClassNamesOptions`
220
+
221
+ | 选项 | 类型 | 默认值 | 说明 |
222
+ | -------- | --------- | ------ | -------------------------------------- |
223
+ | `unique` | `boolean` | `true` | 对类名去重,保留首次出现的顺序。 |
224
+
225
+ ```ts
226
+ const defaultJoin = createClassNames(); // unique: true
227
+ const keepAll = createClassNames({ unique: false }); // 保留重复项
228
+ ```
229
+
230
+ ## 类型
231
+
232
+ 所有类型均已导出:
233
+
234
+ ```ts
235
+ export type ClassValue =
236
+ | string
237
+ | number
238
+ | boolean
239
+ | null
240
+ | undefined
241
+ | ClassObject
242
+ | ClassValue[]
243
+ | Set<ClassValue>;
244
+
245
+ export interface ClassObject {
246
+ [key: string]: ClassValue | (() => ClassValue);
247
+ }
248
+
249
+ export interface ClassNamesOptions {
250
+ unique?: boolean;
251
+ }
252
+ ```
253
+
254
+ ## 行为参考
255
+
256
+ | 输入 | 输出 |
257
+ | -------------------------------------------------------------- | ---------- |
258
+ | `classNames('a', 'b')` | `'a b'` |
259
+ | `classNames('a', '', null, undefined, false, true)` | `'a'` |
260
+ | `classNames(0, 1)` | `'0 1'` |
261
+ | `classNames({ a: true, b: false, c: 1, d: 0 })` | `'a c'` |
262
+ | `classNames(['a', [{ b: true }]])` | `'a b'` |
263
+ | `classNames(new Set(['a', 'b']))` | `'a b'` |
264
+ | `classNames({ active: () => true, hidden: () => false })` | `'active'` |
265
+ | `classNames('a', 'b', 'a')` | `'a b'` |
266
+ | `createClassNames({ unique: false })('a', 'a')` | `'a a'` |
267
+ | `classNames()` | `''` |
268
+
269
+ ## 注意事项与边界情况
270
+
271
+ - 类名始终使用**单个空格**连接,不提供自定义分隔符选项。
272
+ - 去重按完全相同的字符串比较,区分大小写,且**不会**拆分字符串:
273
+ `classNames('a b', 'b')` 返回 `'a b b'`。
274
+ - 顶层 `0` 会被保留(`classNames(0)` → `'0'`),但对象值中的 `0` 为假值,会被
275
+ 丢弃(`{ a: 0 }` → `''`)。
276
+ - `true` 和 `false` 作为值会被忽略;只有对象的键会产生输出。
277
+ - 函数**只能作为对象值**使用;直接作为顶层参数传入的函数会被忽略。
278
+ - 函数的返回值只用于判断真值,不会成为类名(`{ a: () => 'foo' }` → `'a'`)。
279
+ - 输出顺序与输入顺序一致;对象的键遵循 `Object.entries` 的顺序。去重保留首次
280
+ 出现的位置。
281
+ - `null`、`undefined` 和空字符串会被忽略。
282
+
283
+ ## 与 classnames 的兼容性
284
+
285
+ 在大多数场景下,`@jsdevlab/classnames` 可以直接替代 `classnames`:
286
+
287
+ ```diff
288
+ -import classNames from 'classnames';
289
+ +import { classNames } from '@jsdevlab/classnames';
290
+ ```
291
+
292
+ | 能力 | `classnames` | `@jsdevlab/classnames` |
293
+ | ----------------------------------- | ------------------------------------ | ----------------------------------------- |
294
+ | 字符串 / 数字 | ✅ | ✅ |
295
+ | 值为真值的对象 | ✅ | ✅ |
296
+ | 嵌套数组 | ✅ | ✅ |
297
+ | 跳过 `null` / `undefined` / `false` / `''` | ✅ | ✅ |
298
+ | `Set` 输入 | — | ✅ |
299
+ | 对象中的函数值 | 不求值(函数本身为真值) | 在调用时求值 |
300
+ | 移除重复项 | 通过 `classnames/dedupe` | 默认行为 |
301
+ | 保留重复项 | 默认行为 | `createClassNames({ unique: false })` |
302
+ | 自定义分隔符 | — | — |
303
+ | 默认导出 | ✅ | —(仅具名导出) |
304
+
305
+ 如果你的代码依赖 `classnames` 默认保留重复项的行为,可以绑定自己的实例:
306
+
307
+ ```ts
308
+ import { createClassNames } from '@jsdevlab/classnames';
309
+
310
+ const classNames = createClassNames({ unique: false });
311
+ ```
312
+
313
+ ## 模块格式
314
+
315
+ | 格式 | 文件 |
316
+ | ---------- | ------------------ |
317
+ | ESM | `dist/index.js` |
318
+ | CommonJS | `dist/index.cjs` |
319
+ | TypeScript | `dist/index.d.ts` |
320
+
321
+ ```ts
322
+ // ESM
323
+ import { classNames } from '@jsdevlab/classnames';
324
+
325
+ // CommonJS
326
+ const { classNames } = require('@jsdevlab/classnames');
327
+ ```
328
+
329
+ 本包没有运行时依赖,可在现代浏览器、Node.js、Bun 与 Deno 中使用。
330
+
331
+ ## 开发
332
+
333
+ 需要 Node.js ≥ 24.19 与 pnpm 12.18。
334
+
335
+ ```bash
336
+ pnpm install
337
+ pnpm test # tsc --noEmit + rstest
338
+ pnpm build # rslib 构建 → dist/
339
+ pnpm check # Biome 检查并修复
340
+ pnpm format # Biome 格式化并修复
341
+ ```
342
+
343
+ 发布流程使用 [changesets](https://github.com/changesets/changesets):
344
+
345
+ ```bash
346
+ pnpm changeset # 描述本次变更
347
+ pnpm version # 更新版本号与 CHANGELOG
348
+ pnpm release # 发布到 npm
349
+ ```
350
+
351
+ ## 许可证
352
+
353
+ [MIT](./LICENSE.txt) © 2026 jsdevlab
package/dist/index.cjs ADDED
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ var __webpack_require__ = {};
3
+ (()=>{
4
+ __webpack_require__.d = (exports1, getters, values)=>{
5
+ var define = (defs, kind)=>{
6
+ for(var key in defs)if (__webpack_require__.o(defs, key) && !__webpack_require__.o(exports1, key)) Object.defineProperty(exports1, key, {
7
+ enumerable: true,
8
+ [kind]: defs[key]
9
+ });
10
+ };
11
+ define(getters, "get");
12
+ define(values, "value");
13
+ };
14
+ })();
15
+ (()=>{
16
+ __webpack_require__.o = (obj, prop)=>Object.prototype.hasOwnProperty.call(obj, prop);
17
+ })();
18
+ (()=>{
19
+ __webpack_require__.r = (exports1)=>{
20
+ if ("u" > typeof Symbol && Symbol.toStringTag) Object.defineProperty(exports1, Symbol.toStringTag, {
21
+ value: 'Module'
22
+ });
23
+ Object.defineProperty(exports1, '__esModule', {
24
+ value: true
25
+ });
26
+ };
27
+ })();
28
+ var __webpack_exports__ = {};
29
+ __webpack_require__.r(__webpack_exports__);
30
+ const DEFAULT_OPTIONS = {
31
+ unique: true
32
+ };
33
+ const resolveOptions = (options)=>({
34
+ unique: options?.unique ?? DEFAULT_OPTIONS.unique
35
+ });
36
+ const collect = (value, parts)=>{
37
+ if (null == value || false === value || true === value) return;
38
+ if ('string' == typeof value || 'number' == typeof value) {
39
+ if ('' !== value) parts.push(String(value));
40
+ return;
41
+ }
42
+ if (Array.isArray(value)) {
43
+ for (const item of value)collect(item, parts);
44
+ return;
45
+ }
46
+ if (value instanceof Set) {
47
+ for (const item of value)collect(item, parts);
48
+ return;
49
+ }
50
+ for (const [key, raw] of Object.entries(value)){
51
+ const v = 'function' == typeof raw ? raw() : raw;
52
+ if (v) parts.push(key);
53
+ }
54
+ };
55
+ const classNamesImpl = (values, options)=>{
56
+ const parts = [];
57
+ for (const value of values)collect(value, parts);
58
+ if (!options.unique) return parts.join(' ');
59
+ const seen = new Set();
60
+ const unique = [];
61
+ for (const part of parts)if (!seen.has(part)) {
62
+ seen.add(part);
63
+ unique.push(part);
64
+ }
65
+ return unique.join(' ');
66
+ };
67
+ function classNames(...values) {
68
+ return classNamesImpl(values, resolveOptions());
69
+ }
70
+ const cx = classNames;
71
+ function createClassNames(options) {
72
+ const resolved = resolveOptions(options);
73
+ return (...values)=>classNamesImpl(values, resolved);
74
+ }
75
+ __webpack_require__.d(__webpack_exports__, {
76
+ classNames: ()=>classNames,
77
+ createClassNames: ()=>createClassNames
78
+ }, {
79
+ cx: cx
80
+ });
81
+ exports.classNames = __webpack_exports__.classNames;
82
+ exports.createClassNames = __webpack_exports__.createClassNames;
83
+ exports.cx = __webpack_exports__.cx;
84
+ for(var __rspack_i in __webpack_exports__)if (-1 === [
85
+ "classNames",
86
+ "createClassNames",
87
+ "cx"
88
+ ].indexOf(__rspack_i)) exports[__rspack_i] = __webpack_exports__[__rspack_i];
89
+ Object.defineProperty(exports, '__esModule', {
90
+ value: true
91
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @jsdevlab/classnames — a type-safe conditional class name utility (framework-agnostic).
3
+ *
4
+ * classnames-compatible, plus enhancements:
5
+ * - Basics: strings/numbers/objects (truthy keys)/nested arrays/Sets
6
+ * - Enhancements: lazily evaluated function values (`{ active: () => cond }`),
7
+ * deduplication by default (order preserved), dedup toggle factory (createClassNames)
8
+ */
9
+ /** Object value: a plain value or a lazily evaluated function. */
10
+ export type ClassValue = string | number | boolean | null | undefined | ClassObject | ClassValue[] | Set<ClassValue>;
11
+ export interface ClassObject {
12
+ [key: string]: ClassValue | (() => ClassValue);
13
+ }
14
+ /**
15
+ * Options for the class name factory.
16
+ *
17
+ * Class names always join with a space (the only separator CSS accepts),
18
+ * so only deduplication is configurable.
19
+ *
20
+ * @example
21
+ * createClassNames({ unique: false })
22
+ */
23
+ export interface ClassNamesOptions {
24
+ /** Whether to deduplicate (order preserved), defaults to true. */
25
+ unique?: boolean;
26
+ }
27
+ /**
28
+ * Conditional class names (classnames-compatible + enhanced); `cx` is an alias.
29
+ *
30
+ * @param values Class values: strings/numbers, objects with truthy keys, nested arrays,
31
+ * Sets, and lazily evaluated function values.
32
+ * @returns The joined class name string.
33
+ * @example
34
+ * classNames('a', 'b', 'c') // 'a b c'
35
+ * classNames('a', { b: true, c: false }, ['d']) // 'a b d'
36
+ * classNames({ active: () => isActive }) // 'active' when isActive is truthy
37
+ * classNames('a', 'b', 'a') // 'a b' (deduplicated, order preserved)
38
+ */
39
+ export declare function classNames(...values: ClassValue[]): string;
40
+ /**
41
+ * Short alias of `classNames` (identical signature and behavior).
42
+ *
43
+ * @example
44
+ * cx('a', { b: true }) // 'a b'
45
+ */
46
+ export declare const cx: typeof classNames;
47
+ /**
48
+ * Factory with a dedup toggle.
49
+ *
50
+ * @param options Dedup options.
51
+ * @returns A `classNames`-like function bound to the given options.
52
+ * @example
53
+ * const keep = createClassNames({ unique: false })
54
+ * keep('a', 'a') // 'a a'
55
+ */
56
+ export declare function createClassNames(options?: ClassNamesOptions): typeof classNames;
package/dist/index.js ADDED
@@ -0,0 +1,46 @@
1
+ const DEFAULT_OPTIONS = {
2
+ unique: true
3
+ };
4
+ const resolveOptions = (options)=>({
5
+ unique: options?.unique ?? DEFAULT_OPTIONS.unique
6
+ });
7
+ const collect = (value, parts)=>{
8
+ if (null == value || false === value || true === value) return;
9
+ if ('string' == typeof value || 'number' == typeof value) {
10
+ if ('' !== value) parts.push(String(value));
11
+ return;
12
+ }
13
+ if (Array.isArray(value)) {
14
+ for (const item of value)collect(item, parts);
15
+ return;
16
+ }
17
+ if (value instanceof Set) {
18
+ for (const item of value)collect(item, parts);
19
+ return;
20
+ }
21
+ for (const [key, raw] of Object.entries(value)){
22
+ const v = 'function' == typeof raw ? raw() : raw;
23
+ if (v) parts.push(key);
24
+ }
25
+ };
26
+ const classNamesImpl = (values, options)=>{
27
+ const parts = [];
28
+ for (const value of values)collect(value, parts);
29
+ if (!options.unique) return parts.join(' ');
30
+ const seen = new Set();
31
+ const unique = [];
32
+ for (const part of parts)if (!seen.has(part)) {
33
+ seen.add(part);
34
+ unique.push(part);
35
+ }
36
+ return unique.join(' ');
37
+ };
38
+ function classNames(...values) {
39
+ return classNamesImpl(values, resolveOptions());
40
+ }
41
+ const cx = classNames;
42
+ function createClassNames(options) {
43
+ const resolved = resolveOptions(options);
44
+ return (...values)=>classNamesImpl(values, resolved);
45
+ }
46
+ export { classNames, createClassNames, cx };
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@jsdevlab/classnames",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "description": "Type-safe classnames utility — conditional classes with lazy evaluation, dedup, and separator factory. Framework-agnostic.",
6
+ "keywords": [],
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/jsdevlab/classnames.git"
10
+ },
11
+ "license": "MIT",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js",
17
+ "require": "./dist/index.cjs"
18
+ }
19
+ },
20
+ "main": "./dist/index.cjs",
21
+ "types": "./dist/index.d.ts",
22
+ "files": ["dist", "README.md", "README.zh-CN.md"],
23
+ "scripts": {
24
+ "build": "rslib",
25
+ "changeset": "changeset",
26
+ "check": "biome check --write",
27
+ "dev": "rslib --watch",
28
+ "format": "biome format --write",
29
+ "prepare": "simple-git-hooks",
30
+ "release": "changeset publish",
31
+ "test": "tsc --noEmit && rstest",
32
+ "test:watch": "rstest --watch",
33
+ "version": "changeset version"
34
+ },
35
+ "simple-git-hooks": {
36
+ "pre-commit": "pnpm check",
37
+ "pre-push": "pnpm format"
38
+ },
39
+ "devDependencies": {
40
+ "@biomejs/biome": "^2.6.6",
41
+ "@changesets/changelog-github": "^1.0.1",
42
+ "@changesets/cli": "^3.0.1",
43
+ "@rslib/core": "^0.23.2",
44
+ "@rstest/adapter-rslib": "^0.11.9",
45
+ "@rstest/core": "^0.11.9",
46
+ "@rstest/coverage-v8": "^0.11.9",
47
+ "@types/node": "^24.13.3",
48
+ "simple-git-hooks": "^2.14.0",
49
+ "typescript": "^6.0.3"
50
+ },
51
+ "packageManager": "pnpm@12.18.0+sha512.9d7882d5dc82147ecfc4e062aa34834e0ad0296710a1837a1115ae012127d36b7109e321ac1598a1dd0a65597681b1e99917e12ed0d749eae91187943363beb3",
52
+ "devEngines": {
53
+ "runtime": {
54
+ "name": "node",
55
+ "version": ">=24.19.0 <27.0.0",
56
+ "onFail": "download"
57
+ }
58
+ },
59
+ "publishConfig": {
60
+ "access": "public",
61
+ "registry": "https://registry.npmjs.org/"
62
+ }
63
+ }