Skip to content

Commit 95b8b25

Browse files
committed
feat: support multiple transform classes on single element
Add mergeStyles utility that concatenates transform arrays instead of overwriting them. This enables combining multiple transforms like 'rotate-45 scale-110' on a single element. Changes: - Add mergeStyles() utility with smart array concatenation - Update parseClassName() to use mergeStyles instead of Object.assign - Add 9 unit tests for mergeStyles utility - Add 10 integration tests for multiple transform combinations - Export mergeStyles from main index for advanced usage
1 parent eaff0f8 commit 95b8b25

File tree

5 files changed

+225
-1
lines changed

5 files changed

+225
-1
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export { tw, twStyle } from "./stubs/tw";
99
// Main parser functions
1010
export { parseClass, parseClassName } from "./parser";
1111
export { flattenColors } from "./utils/flattenColors";
12+
export { mergeStyles } from "./utils/mergeStyles";
1213
export { generateStyleKey } from "./utils/styleKey";
1314

1415
// Re-export types

src/parser/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import type { StyleObject } from "../types";
7+
import { mergeStyles } from "../utils/mergeStyles";
78
import { parseAspectRatio } from "./aspectRatio";
89
import { parseBorder } from "./borders";
910
import { parseColor } from "./colors";
@@ -36,7 +37,7 @@ export function parseClassName(className: string, customTheme?: CustomTheme): St
3637

3738
for (const cls of classes) {
3839
const parsedStyle = parseClass(cls, customTheme);
39-
Object.assign(style, parsedStyle);
40+
mergeStyles(style, parsedStyle);
4041
}
4142

4243
return style;

src/parser/transforms.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from "vitest";
2+
import { parseClassName } from "./index";
23
import { PERSPECTIVE_SCALE, ROTATE_MAP, SCALE_MAP, SKEW_MAP, parseTransform } from "./transforms";
34

45
describe("SCALE_MAP", () => {
@@ -373,3 +374,85 @@ describe("parseTransform - custom spacing", () => {
373374
expect(parseTransform("perspective-500", customSpacing)).toEqual({ transform: [{ perspective: 500 }] });
374375
});
375376
});
377+
378+
describe("parseClassName - multiple transforms", () => {
379+
it("should combine rotate and scale transforms", () => {
380+
const result = parseClassName("rotate-45 scale-110");
381+
expect(result).toEqual({
382+
transform: [{ rotate: "45deg" }, { scale: 1.1 }],
383+
});
384+
});
385+
386+
it("should combine scale and rotate with arbitrary values", () => {
387+
const result = parseClassName("rotate-45 scale-[0.2]");
388+
expect(result).toEqual({
389+
transform: [{ rotate: "45deg" }, { scale: 0.2 }],
390+
});
391+
});
392+
393+
it("should combine multiple different transforms", () => {
394+
const result = parseClassName("rotate-45 scale-110 translate-x-4");
395+
expect(result).toEqual({
396+
transform: [{ rotate: "45deg" }, { scale: 1.1 }, { translateX: 16 }],
397+
});
398+
});
399+
400+
it("should preserve order of transforms", () => {
401+
// Order matters in React Native transforms!
402+
const result1 = parseClassName("rotate-45 scale-110");
403+
const result2 = parseClassName("scale-110 rotate-45");
404+
405+
expect(result1.transform).toEqual([{ rotate: "45deg" }, { scale: 1.1 }]);
406+
expect(result2.transform).toEqual([{ scale: 1.1 }, { rotate: "45deg" }]);
407+
});
408+
409+
it("should combine transforms with other properties", () => {
410+
const result = parseClassName("m-4 rotate-45 scale-110 p-2");
411+
expect(result).toEqual({
412+
margin: 16,
413+
padding: 8,
414+
transform: [{ rotate: "45deg" }, { scale: 1.1 }],
415+
});
416+
});
417+
418+
it("should handle all transform types together", () => {
419+
const result = parseClassName("perspective-500 rotate-45 scale-110 translate-x-4 skew-x-6");
420+
expect(result).toEqual({
421+
transform: [
422+
{ perspective: 500 },
423+
{ rotate: "45deg" },
424+
{ scale: 1.1 },
425+
{ translateX: 16 },
426+
{ skewX: "6deg" },
427+
],
428+
});
429+
});
430+
431+
it("should handle negative transforms", () => {
432+
const result = parseClassName("-rotate-45 -translate-x-4");
433+
expect(result).toEqual({
434+
transform: [{ rotate: "-45deg" }, { translateX: -16 }],
435+
});
436+
});
437+
438+
it("should handle scale-x and scale-y together", () => {
439+
const result = parseClassName("scale-x-50 scale-y-150");
440+
expect(result).toEqual({
441+
transform: [{ scaleX: 0.5 }, { scaleY: 1.5 }],
442+
});
443+
});
444+
445+
it("should handle single transform (backward compatibility)", () => {
446+
const result = parseClassName("rotate-45");
447+
expect(result).toEqual({
448+
transform: [{ rotate: "45deg" }],
449+
});
450+
});
451+
452+
it("should handle arbitrary values combined", () => {
453+
const result = parseClassName("rotate-[37deg] scale-[0.2] translate-x-[50px]");
454+
expect(result).toEqual({
455+
transform: [{ rotate: "37deg" }, { scale: 0.2 }, { translateX: 50 }],
456+
});
457+
});
458+
});

src/utils/mergeStyles.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expect, it } from "vitest";
2+
import { mergeStyles } from "./mergeStyles";
3+
4+
describe("mergeStyles", () => {
5+
describe("standard properties", () => {
6+
it("should merge non-array properties like Object.assign", () => {
7+
const target = { margin: 4 };
8+
const source = { padding: 8 };
9+
expect(mergeStyles(target, source)).toEqual({ margin: 4, padding: 8 });
10+
});
11+
12+
it("should overwrite non-array properties", () => {
13+
const target = { margin: 4 };
14+
const source = { margin: 8 };
15+
expect(mergeStyles(target, source)).toEqual({ margin: 8 });
16+
});
17+
18+
it("should handle shadowOffset as standard property (object, not array)", () => {
19+
const target = { shadowOffset: { width: 0, height: 1 } };
20+
const source = { shadowOffset: { width: 0, height: 2 } };
21+
expect(mergeStyles(target, source)).toEqual({
22+
shadowOffset: { width: 0, height: 2 },
23+
});
24+
});
25+
});
26+
27+
describe("transform array merging", () => {
28+
it("should concatenate transform arrays", () => {
29+
const target = { transform: [{ rotate: "45deg" }] };
30+
const source = { transform: [{ scale: 1.1 }] };
31+
expect(mergeStyles(target, source)).toEqual({
32+
transform: [{ rotate: "45deg" }, { scale: 1.1 }],
33+
});
34+
});
35+
36+
it("should handle multiple transforms in source", () => {
37+
const target = { transform: [{ rotate: "45deg" }] };
38+
const source = { transform: [{ scale: 1.1 }, { translateX: 10 }] };
39+
expect(mergeStyles(target, source)).toEqual({
40+
transform: [{ rotate: "45deg" }, { scale: 1.1 }, { translateX: 10 }],
41+
});
42+
});
43+
44+
it("should assign transform when target has no transform", () => {
45+
const target = { margin: 4 };
46+
const source = { transform: [{ scale: 1.1 }] };
47+
expect(mergeStyles(target, source)).toEqual({
48+
margin: 4,
49+
transform: [{ scale: 1.1 }],
50+
});
51+
});
52+
53+
it("should handle empty target transform array", () => {
54+
const target = { transform: [] as Array<{ scale?: number }> };
55+
const source = { transform: [{ scale: 1.1 }] };
56+
expect(mergeStyles(target, source)).toEqual({
57+
transform: [{ scale: 1.1 }],
58+
});
59+
});
60+
});
61+
62+
describe("mixed properties", () => {
63+
it("should handle mix of standard and transform properties", () => {
64+
const target = { margin: 4, transform: [{ rotate: "45deg" }] };
65+
const source = { padding: 8, transform: [{ scale: 1.1 }] };
66+
expect(mergeStyles(target, source)).toEqual({
67+
margin: 4,
68+
padding: 8,
69+
transform: [{ rotate: "45deg" }, { scale: 1.1 }],
70+
});
71+
});
72+
});
73+
74+
describe("mutation behavior", () => {
75+
it("should mutate and return target object", () => {
76+
const target = { margin: 4 };
77+
const source = { padding: 8 };
78+
const result = mergeStyles(target, source);
79+
expect(result).toBe(target);
80+
expect(target).toEqual({ margin: 4, padding: 8 });
81+
});
82+
});
83+
});

src/utils/mergeStyles.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Smart merge utility for StyleObject values
3+
* Handles array properties (like transform) by concatenating instead of overwriting
4+
*/
5+
6+
import type { StyleObject } from "../types/core";
7+
8+
/**
9+
* Properties that should be merged as arrays (concatenated) rather than overwritten
10+
*/
11+
const ARRAY_MERGE_PROPERTIES = new Set<string>(["transform"]);
12+
13+
/**
14+
* Merge two StyleObject instances, handling array properties specially
15+
*
16+
* @param target - The target object to merge into (mutated)
17+
* @param source - The source object to merge from
18+
* @returns The merged target object
19+
*
20+
* @example
21+
* // Standard properties are overwritten (like Object.assign)
22+
* mergeStyles({ margin: 4 }, { padding: 8 })
23+
* // => { margin: 4, padding: 8 }
24+
*
25+
* @example
26+
* // Array properties (transform) are concatenated
27+
* mergeStyles(
28+
* { transform: [{ rotate: '45deg' }] },
29+
* { transform: [{ scale: 1.1 }] }
30+
* )
31+
* // => { transform: [{ rotate: '45deg' }, { scale: 1.1 }] }
32+
*/
33+
export function mergeStyles(target: StyleObject, source: StyleObject): StyleObject {
34+
for (const key in source) {
35+
if (Object.prototype.hasOwnProperty.call(source, key)) {
36+
const sourceValue = source[key];
37+
38+
// Handle array merge properties (like transform)
39+
if (ARRAY_MERGE_PROPERTIES.has(key) && Array.isArray(sourceValue)) {
40+
const targetValue = target[key];
41+
if (Array.isArray(targetValue)) {
42+
// Concatenate arrays
43+
(target as Record<string, unknown>)[key] = [...targetValue, ...sourceValue];
44+
} else {
45+
// No existing array, just assign
46+
target[key] = sourceValue;
47+
}
48+
} else {
49+
// Standard Object.assign behavior for non-array properties
50+
target[key] = sourceValue;
51+
}
52+
}
53+
}
54+
55+
return target;
56+
}

0 commit comments

Comments
 (0)