clava 0.2.2 → 0.2.4

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/src/types.ts CHANGED
@@ -248,11 +248,13 @@ type ExtractVariantValue<T> = T extends null
248
248
  ? never
249
249
  : T extends (value: infer V) => any
250
250
  ? V
251
- : T extends Record<string, any>
252
- ? StringToBoolean<NonNullKeys<T>>
253
- : T extends ClassValue
254
- ? boolean
255
- : never;
251
+ : T extends readonly unknown[]
252
+ ? boolean
253
+ : T extends Record<string, any>
254
+ ? StringToBoolean<NonNullKeys<T>>
255
+ : T extends ClassValue
256
+ ? boolean
257
+ : never;
256
258
 
257
259
  export type VariantValues<V> = {
258
260
  [K in keyof V]?: ExtractVariantValue<V[K]>;
@@ -131,6 +131,45 @@ for (const config of Object.values(CONFIGS)) {
131
131
  expect(variants).toEqual({ color: "blue" });
132
132
  });
133
133
 
134
+ test("getVariants picks up setDefaultVariants from extended component", () => {
135
+ const base = cv({
136
+ variants: { size: { sm: "sm", lg: "lg" } },
137
+ computed: ({ setDefaultVariants }) => {
138
+ setDefaultVariants({ size: "lg" });
139
+ },
140
+ });
141
+ const component = getModeComponent(
142
+ mode,
143
+ cv({
144
+ extend: [base],
145
+ variants: { color: { red: "red", blue: "blue" } },
146
+ defaultVariants: { color: "red" },
147
+ }),
148
+ );
149
+ const variants = component.getVariants();
150
+ expect(variants).toEqual({ size: "lg", color: "red" });
151
+ });
152
+
153
+ test("getVariants picks up setDefaultVariants from grandparent component", () => {
154
+ const grandparent = cv({
155
+ variants: { size: { sm: "sm", lg: "lg" } },
156
+ computed: ({ setDefaultVariants }) => {
157
+ setDefaultVariants({ size: "lg" });
158
+ },
159
+ });
160
+ const parent = cv({ extend: [grandparent] });
161
+ const component = getModeComponent(
162
+ mode,
163
+ cv({
164
+ extend: [parent],
165
+ variants: { color: { red: "red", blue: "blue" } },
166
+ defaultVariants: { color: "red" },
167
+ }),
168
+ );
169
+ const variants = component.getVariants();
170
+ expect(variants).toEqual({ size: "lg", color: "red" });
171
+ });
172
+
134
173
  test("keys returns props keys", () => {
135
174
  const component = getModeComponent(
136
175
  mode,
@@ -267,6 +267,63 @@ for (const config of Object.values(CONFIGS)) {
267
267
  expect(getStyleClass(props)).toEqual({ class: cls("sm red") });
268
268
  });
269
269
 
270
+ test("computed in extended component does not see foreign variant keys", () => {
271
+ const base = cv({
272
+ variants: { size: { sm: "sm", lg: "lg" } },
273
+ defaultVariants: { size: "sm" },
274
+ computed: ({ variants, addClass }) => {
275
+ // `color` is added by the parent component below — it must not
276
+ // leak into base's `ctx.variants`, whose shape is declared as
277
+ // `{ size }` only.
278
+ if ("color" in (variants as Record<string, unknown>)) {
279
+ addClass("base-saw-foreign");
280
+ } else {
281
+ addClass("base-no-foreign");
282
+ }
283
+ },
284
+ });
285
+ const component = getModeComponent(
286
+ mode,
287
+ cv({
288
+ extend: [base],
289
+ variants: { color: { red: "red", blue: "blue" } },
290
+ }),
291
+ );
292
+ const props = component({ color: "red" });
293
+ expect(getStyleClass(props)).toEqual({
294
+ class: cls("sm base-no-foreign red"),
295
+ });
296
+ });
297
+
298
+ test("setDefaultVariants in extended component does not branch on foreign variant keys", () => {
299
+ // Same shape as the test above, but the base's `computed` reaches
300
+ // `setDefaultVariants` — covers the resolveDefaults pass (driving
301
+ // both class output and `getVariants`) rather than the render-time
302
+ // compute path.
303
+ const base = cv({
304
+ variants: { size: { sm: "sm", lg: "lg" } },
305
+ defaultVariants: { size: "sm" },
306
+ computed: ({ variants, setDefaultVariants }) => {
307
+ if ("color" in (variants as Record<string, unknown>)) {
308
+ setDefaultVariants({ size: "lg" });
309
+ }
310
+ },
311
+ });
312
+ const component = getModeComponent(
313
+ mode,
314
+ cv({
315
+ extend: [base],
316
+ variants: { color: { red: "red", blue: "blue" } },
317
+ }),
318
+ );
319
+ const props = component({ color: "red" });
320
+ expect(getStyleClass(props)).toEqual({ class: cls("sm red") });
321
+ expect(component.getVariants({ color: "red" })).toEqual({
322
+ size: "sm",
323
+ color: "red",
324
+ });
325
+ });
326
+
270
327
  test("child setDefaultVariants receives computed variants from parent", () => {
271
328
  const base = cv({
272
329
  variants: { size: { sm: "sm", lg: "lg" }, small: "" },
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
+ import { create } from "../src/index.ts";
2
3
  import {
3
4
  CONFIGS,
4
5
  createCVFromConfig,
@@ -299,3 +300,83 @@ for (const config of Object.values(CONFIGS)) {
299
300
  });
300
301
  });
301
302
  }
303
+
304
+ describe("non-idempotent transformClass", () => {
305
+ // A non-idempotent transform: prefixing each word with `tw-`. Applying it
306
+ // twice yields `tw-tw-foo`, so the engine must invoke it exactly once per
307
+ // class word — even when extend chains pipe extended base classes back into
308
+ // a parent's `clsx` and through the same transform at render time.
309
+ const { cv } = create({
310
+ transformClass: (className) =>
311
+ className
312
+ .split(" ")
313
+ .filter(Boolean)
314
+ .map((word) => `tw-${word}`)
315
+ .join(" "),
316
+ });
317
+
318
+ test("base class is transformed exactly once across single extend", () => {
319
+ const base = cv({ class: "base" });
320
+ const component = cv({ extend: [base], class: "extended" });
321
+ expect(component().class).toBe("tw-base tw-extended");
322
+ });
323
+
324
+ test("base class is transformed exactly once across multi-level extend", () => {
325
+ const base = cv({ class: "base" });
326
+ const middle = cv({ extend: [base], class: "middle" });
327
+ const top = cv({
328
+ extend: [middle],
329
+ class: "top",
330
+ variants: { size: { sm: "sm" } },
331
+ });
332
+ expect(top({ size: "sm" }).class).toBe("tw-base tw-middle tw-top tw-sm");
333
+ });
334
+ });
335
+
336
+ const toUpperCase = (className: string) => className.toUpperCase();
337
+ const toLowerCase = (className: string) => className.toLowerCase();
338
+
339
+ describe("extend across `create()` factories", () => {
340
+ // The extend's own `transformClass` must apply to its own classes even when
341
+ // the extending component comes from a different `create()` call. The
342
+ // optimized compute path bypasses the public-component round-trip, so it
343
+ // detects mixed-factory extends by reference identity and runs the extend's
344
+ // transform on its contribution before joining.
345
+ const { cv: cvUpper } = create({ transformClass: toUpperCase });
346
+ const { cv: cvDefault } = create();
347
+
348
+ test("extend's transformClass applies to its base class", () => {
349
+ const base = cvUpper({ class: "base" });
350
+ const component = cvDefault({ extend: [base], class: "extended" });
351
+ expect(component().class).toBe("BASE extended");
352
+ });
353
+
354
+ test("extend's transformClass applies to its variant classes", () => {
355
+ const base = cvUpper({
356
+ class: "base",
357
+ variants: { size: { sm: "sm", lg: "lg" } },
358
+ });
359
+ const component = cvDefault({ extend: [base], class: "extended" });
360
+ expect(component({ size: "sm" }).class).toBe("BASE extended SM");
361
+ });
362
+
363
+ test("extend's transformClass cascades through grandparent chain", () => {
364
+ const grandparent = cvUpper({ class: "grandparent" });
365
+ const parent = cvUpper({ extend: [grandparent], class: "parent" });
366
+ const component = cvDefault({ extend: [parent], class: "child" });
367
+ expect(component().class).toBe("GRANDPARENT PARENT child");
368
+ });
369
+
370
+ test("parent's transformClass applies on top of extend's transformed output", () => {
371
+ const { cv: cvLower } = create({ transformClass: toLowerCase });
372
+ const base = cvUpper({
373
+ class: "base",
374
+ variants: { size: { sm: "sm" } },
375
+ });
376
+ const component = cvLower({ extend: [base], class: "child" });
377
+ // The extend uppercases its own contribution, then the parent's
378
+ // transformClass runs on the joined string and lowercases everything —
379
+ // mirrors main's `parentTransform(clsx(extTransform(extOutput), …))`.
380
+ expect(component({ size: "sm" }).class).toBe("base child sm");
381
+ });
382
+ });
@@ -0,0 +1,47 @@
1
+ import { afterEach, expect, test } from "vitest";
2
+ import { cv } from "../src/index.ts";
3
+
4
+ const proto = Object.prototype as Record<string, unknown>;
5
+
6
+ afterEach(() => {
7
+ for (const key of Object.keys(proto)) {
8
+ delete proto[key];
9
+ }
10
+ });
11
+
12
+ test("getVariants ignores keys inherited from Object.prototype", () => {
13
+ proto.size = "lg";
14
+ const component = cv({
15
+ variants: { size: { sm: "sm", lg: "lg" } },
16
+ defaultVariants: { size: "sm" },
17
+ });
18
+ expect(component.getVariants({})).toEqual({ size: "sm" });
19
+ });
20
+
21
+ test("render ignores keys inherited from Object.prototype", () => {
22
+ proto.size = "lg";
23
+ const component = cv({
24
+ variants: { size: { sm: "sm", lg: "lg" } },
25
+ defaultVariants: { size: "sm" },
26
+ });
27
+ expect(component({}).class).toBe("sm");
28
+ });
29
+
30
+ test("extend's resolveDefaults ignores polluted prototype on parent's computed", () => {
31
+ // Base's computed branches on its own variants.size — if the polluted
32
+ // "size" key leaks into resolveDefaultsFn's resolvedVariants, the computed
33
+ // callback would see size = "lg" instead of the staticDefault "sm" and
34
+ // emit the lg-specific class.
35
+ proto.size = "lg";
36
+ const base = cv({
37
+ variants: { size: { sm: "sm", lg: "lg" } },
38
+ defaultVariants: { size: "sm" },
39
+ computed: ({ variants, addClass }) => {
40
+ if (variants.size === "lg") {
41
+ addClass("base-lg-detected");
42
+ }
43
+ },
44
+ });
45
+ const child = cv({ extend: [base], class: "child" });
46
+ expect(child({}).class).not.toContain("base-lg-detected");
47
+ });
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "vitest";
1
+ import { describe, expect, expectTypeOf, test } from "vitest";
2
2
  import {
3
3
  CONFIGS,
4
4
  createCVFromConfig,
@@ -211,6 +211,34 @@ for (const config of Object.values(CONFIGS)) {
211
211
  expect(getStyleClass(props)).toEqual({ class: "" });
212
212
  });
213
213
 
214
+ test("array variant shorthand", () => {
215
+ const component = getModeComponent(
216
+ mode,
217
+ cv({
218
+ variants: {
219
+ interactive: ["interactive", "focusable"],
220
+ },
221
+ defaultVariants: { interactive: true },
222
+ }),
223
+ );
224
+ expectTypeOf(component.getVariants()).branded.toEqualTypeOf<{
225
+ interactive?: boolean;
226
+ }>();
227
+ expect(getStyleClass(component())).toEqual({
228
+ class: cls("interactive focusable"),
229
+ });
230
+ expect(getStyleClass(component({ interactive: false }))).toEqual({
231
+ class: "",
232
+ });
233
+ const props = component({
234
+ // @ts-expect-error array shorthand variants are boolean
235
+ interactive:
236
+ // no error
237
+ "interactive",
238
+ });
239
+ expect(getStyleClass(props)).toEqual({ class: "" });
240
+ });
241
+
214
242
  test("variant style does not accept numbers", () => {
215
243
  const component = getModeComponent(
216
244
  mode,
@@ -1,233 +0,0 @@
1
- import { bench, describe } from "vitest";
2
- import { cv, splitProps } from "../src/index.ts";
3
-
4
- const options = {
5
- time: 2000,
6
- warmupTime: 500,
7
- };
8
-
9
- let sink: unknown;
10
-
11
- function consume(value: unknown) {
12
- sink = value;
13
- }
14
-
15
- const buttonConfig = {
16
- class: "button inline-flex items-center justify-center",
17
- style: {
18
- borderRadius: "6px",
19
- fontWeight: "600",
20
- },
21
- variants: {
22
- size: {
23
- sm: {
24
- class: "button-sm",
25
- style: { fontSize: "12px", paddingBlock: "4px", paddingInline: "8px" },
26
- },
27
- md: {
28
- class: "button-md",
29
- style: {
30
- fontSize: "14px",
31
- paddingBlock: "6px",
32
- paddingInline: "12px",
33
- },
34
- },
35
- lg: {
36
- class: "button-lg",
37
- style: {
38
- fontSize: "16px",
39
- paddingBlock: "8px",
40
- paddingInline: "16px",
41
- },
42
- },
43
- },
44
- intent: {
45
- primary: {
46
- class: "button-primary",
47
- style: { color: "white", backgroundColor: "blue" },
48
- },
49
- danger: {
50
- class: "button-danger",
51
- style: { color: "white", backgroundColor: "red" },
52
- },
53
- neutral: {
54
- class: "button-neutral",
55
- style: { color: "black", backgroundColor: "gray" },
56
- },
57
- },
58
- disabled: {
59
- true: {
60
- class: "button-disabled",
61
- style: { opacity: "0.5", pointerEvents: "none" },
62
- },
63
- false: "button-enabled",
64
- },
65
- },
66
- defaultVariants: {
67
- size: "md",
68
- intent: "primary",
69
- },
70
- } as const;
71
-
72
- const fieldConfig = {
73
- class: "field",
74
- variants: {
75
- tone: {
76
- plain: "field-plain",
77
- invalid: {
78
- class: "field-invalid",
79
- style: { borderColor: "red", color: "red" },
80
- },
81
- },
82
- compact: "field-compact",
83
- },
84
- defaultVariants: {
85
- tone: "plain",
86
- },
87
- } as const;
88
-
89
- const button = cv(buttonConfig);
90
- const field = cv(fieldConfig);
91
- const toolbarButton = cv({
92
- extend: [button],
93
- class: "toolbar-button",
94
- variants: {
95
- active: {
96
- true: "toolbar-button-active",
97
- false: "toolbar-button-idle",
98
- },
99
- },
100
- computedVariants: {
101
- intent: (value: unknown) => {
102
- if (value === "danger") {
103
- return {
104
- class: "toolbar-button-danger",
105
- style: { boxShadow: "0 0 0 1px red" },
106
- };
107
- }
108
- return null;
109
- },
110
- },
111
- defaultVariants: {
112
- active: false,
113
- },
114
- computed: ({ variants, addClass, addStyle, setDefaultVariants }) => {
115
- if (variants.active) {
116
- addClass("toolbar-button-pressed");
117
- addStyle({ transform: "translateY(1px)" });
118
- }
119
- if (variants.size === "lg") {
120
- setDefaultVariants({ intent: "neutral" });
121
- }
122
- },
123
- });
124
-
125
- const splitPropsInput = {
126
- id: "save",
127
- type: "button",
128
- size: "lg",
129
- intent: "danger",
130
- disabled: true,
131
- tone: "invalid",
132
- compact: true,
133
- active: true,
134
- className: "custom",
135
- style: "margin-top: 4px; --accent-color: red;",
136
- "data-testid": "save",
137
- };
138
-
139
- describe("cv", () => {
140
- bench(
141
- "create component with variants",
142
- () => {
143
- consume(cv(buttonConfig));
144
- },
145
- options,
146
- );
147
-
148
- bench(
149
- "resolve component props",
150
- () => {
151
- consume(
152
- button({
153
- size: "lg",
154
- intent: "danger",
155
- disabled: true,
156
- className: "custom",
157
- style: { marginTop: 4 },
158
- }),
159
- );
160
- },
161
- options,
162
- );
163
-
164
- bench(
165
- "resolve html props from string style",
166
- () => {
167
- consume(
168
- button.html({
169
- size: "sm",
170
- intent: "neutral",
171
- style: "margin-top: 4px; --accent-color: blue;",
172
- }),
173
- );
174
- },
175
- options,
176
- );
177
-
178
- bench(
179
- "resolve extended computed props",
180
- () => {
181
- consume(
182
- toolbarButton({
183
- size: "lg",
184
- intent: "danger",
185
- active: true,
186
- class: "custom",
187
- style: { marginInlineStart: 4 },
188
- }),
189
- );
190
- },
191
- options,
192
- );
193
-
194
- bench(
195
- "get variant values",
196
- () => {
197
- consume(
198
- toolbarButton.getVariants({
199
- size: "lg",
200
- active: true,
201
- }),
202
- );
203
- },
204
- options,
205
- );
206
- });
207
-
208
- describe("splitProps", () => {
209
- bench(
210
- "split props across components",
211
- () => {
212
- consume(splitProps(splitPropsInput, toolbarButton, field));
213
- },
214
- options,
215
- );
216
-
217
- bench(
218
- "split props across arrays and components",
219
- () => {
220
- consume(
221
- splitProps(
222
- splitPropsInput,
223
- ["id", "type", "data-testid"],
224
- toolbarButton,
225
- field,
226
- ),
227
- );
228
- },
229
- options,
230
- );
231
- });
232
-
233
- export { sink };
File without changes
File without changes