@mgcrea/react-native-tailwind 0.12.0 → 0.12.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mgcrea/react-native-tailwind",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Compile-time Tailwind CSS for React Native with zero runtime overhead",
5
5
  "author": "Olivier Louvignes <olivier@mgcrea.io> (https://github.com/mgcrea)",
6
6
  "homepage": "https://github.com/mgcrea/react-native-tailwind#readme",
@@ -120,7 +120,7 @@ describe("config-loader", () => {
120
120
  vi.spyOn(fs, "existsSync").mockReturnValue(false);
121
121
 
122
122
  const result = extractCustomTheme("/project/src/file.ts");
123
- expect(result).toEqual({ colors: {}, fontFamily: {} });
123
+ expect(result).toEqual({ colors: {}, fontFamily: {}, fontSize: {} });
124
124
  });
125
125
 
126
126
  it("should return empty theme when config has no theme", () => {
@@ -134,7 +134,7 @@ describe("config-loader", () => {
134
134
  const result = extractCustomTheme("/project/src/file.ts");
135
135
 
136
136
  // Without actual config loading, this returns empty
137
- expect(result).toEqual({ colors: {}, fontFamily: {} });
137
+ expect(result).toEqual({ colors: {}, fontFamily: {}, fontSize: {} });
138
138
  });
139
139
 
140
140
  it("should extract colors and fontFamily from theme.extend", () => {
@@ -14,9 +14,11 @@ export type TailwindConfig = {
14
14
  extend?: {
15
15
  colors?: Record<string, string | Record<string, string>>;
16
16
  fontFamily?: Record<string, string | string[]>;
17
+ fontSize?: Record<string, string | number>;
17
18
  };
18
19
  colors?: Record<string, string | Record<string, string>>;
19
20
  fontFamily?: Record<string, string | string[]>;
21
+ fontSize?: Record<string, string | number>;
20
22
  };
21
23
  };
22
24
 
@@ -89,6 +91,7 @@ export function loadTailwindConfig(configPath: string): TailwindConfig | null {
89
91
  export type CustomTheme = {
90
92
  colors: Record<string, string>;
91
93
  fontFamily: Record<string, string>;
94
+ fontSize: Record<string, number>;
92
95
  };
93
96
 
94
97
  /**
@@ -100,12 +103,12 @@ export function extractCustomTheme(filename: string): CustomTheme {
100
103
  const configPath = findTailwindConfig(projectDir);
101
104
 
102
105
  if (!configPath) {
103
- return { colors: {}, fontFamily: {} };
106
+ return { colors: {}, fontFamily: {}, fontSize: {} };
104
107
  }
105
108
 
106
109
  const config = loadTailwindConfig(configPath);
107
110
  if (!config?.theme) {
108
- return { colors: {}, fontFamily: {} };
111
+ return { colors: {}, fontFamily: {}, fontSize: {} };
109
112
  }
110
113
 
111
114
  // Extract colors
@@ -139,8 +142,40 @@ export function extractCustomTheme(filename: string): CustomTheme {
139
142
  }
140
143
  }
141
144
 
145
+ // Extract fontSize
146
+ /* v8 ignore next 5 */
147
+ if (config.theme.fontSize && !config.theme.extend?.fontSize && process.env.NODE_ENV !== "production") {
148
+ console.warn(
149
+ "[react-native-tailwind] Using theme.fontSize will override all default font sizes. " +
150
+ "Use theme.extend.fontSize to add custom font sizes while keeping defaults.",
151
+ );
152
+ }
153
+ const fontSize = config.theme.extend?.fontSize ?? config.theme.fontSize ?? {};
154
+
155
+ // Convert fontSize values to numbers (handle string or number values)
156
+ const fontSizeResult: Record<string, number> = {};
157
+ for (const [key, value] of Object.entries(fontSize)) {
158
+ if (typeof value === "number") {
159
+ fontSizeResult[key] = value;
160
+ } else if (typeof value === "string") {
161
+ // Parse string values like "18px" or "18" to number
162
+ const parsed = parseFloat(value.replace(/px$/, ""));
163
+ if (!isNaN(parsed)) {
164
+ fontSizeResult[key] = parsed;
165
+ } else {
166
+ /* v8 ignore next 5 */
167
+ if (process.env.NODE_ENV !== "production") {
168
+ console.warn(
169
+ `[react-native-tailwind] Invalid fontSize value for "${key}": ${value}. Expected number or string like "18px".`,
170
+ );
171
+ }
172
+ }
173
+ }
174
+ }
175
+
142
176
  return {
143
177
  colors: flattenColors(colors),
144
178
  fontFamily: fontFamilyResult,
179
+ fontSize: fontSizeResult,
145
180
  };
146
181
  }
@@ -398,7 +398,7 @@ export default function reactNativeTailwindBabelPlugin(
398
398
  // Track the local name (could be renamed: import { tw as customTw })
399
399
  const localName = spec.local.name;
400
400
  state.twImportNames.add(localName);
401
- state.hasTwImport = true;
401
+ // Don't set hasTwImport yet - only set it when we successfully transform a call
402
402
  }
403
403
  }
404
404
  });
@@ -443,6 +443,8 @@ export default function reactNativeTailwindBabelPlugin(
443
443
  path.replaceWith(
444
444
  t.objectExpression([t.objectProperty(t.identifier("style"), t.objectExpression([]))]),
445
445
  );
446
+ // Mark as successfully transformed (even if empty)
447
+ state.hasTwImport = true;
446
448
  return;
447
449
  }
448
450
 
@@ -459,6 +461,9 @@ export default function reactNativeTailwindBabelPlugin(
459
461
  findComponentScope,
460
462
  t,
461
463
  );
464
+
465
+ // Mark as successfully transformed
466
+ state.hasTwImport = true;
462
467
  },
463
468
 
464
469
  // Handle twStyle('...') call expressions
@@ -502,6 +507,8 @@ export default function reactNativeTailwindBabelPlugin(
502
507
  if (!className) {
503
508
  // Replace with undefined
504
509
  path.replaceWith(t.identifier("undefined"));
510
+ // Mark as successfully transformed (even if empty)
511
+ state.hasTwImport = true;
505
512
  return;
506
513
  }
507
514
 
@@ -518,6 +525,9 @@ export default function reactNativeTailwindBabelPlugin(
518
525
  findComponentScope,
519
526
  t,
520
527
  );
528
+
529
+ // Mark as successfully transformed
530
+ state.hasTwImport = true;
521
531
  },
522
532
 
523
533
  JSXAttribute(path, state) {
@@ -10,24 +10,44 @@ import type { StyleObject } from "../../types/core.js";
10
10
  * Add StyleSheet import to the file or merge with existing react-native import
11
11
  */
12
12
  export function addStyleSheetImport(path: NodePath<BabelTypes.Program>, t: typeof BabelTypes): void {
13
- // Check if there's already a react-native import
13
+ // Check if there's already a value import from react-native
14
14
  const body = path.node.body;
15
- let reactNativeImport: BabelTypes.ImportDeclaration | null = null;
15
+ let existingValueImport: BabelTypes.ImportDeclaration | null = null;
16
16
 
17
17
  for (const statement of body) {
18
18
  if (t.isImportDeclaration(statement) && statement.source.value === "react-native") {
19
- reactNativeImport = statement;
20
- break;
19
+ // Skip type-only imports (they get erased at runtime)
20
+ if (statement.importKind === "type") {
21
+ continue;
22
+ }
23
+ // Skip namespace imports (import * as RN) - can't add named specifiers to them
24
+ const hasNamespaceImport = statement.specifiers.some((spec) => t.isImportNamespaceSpecifier(spec));
25
+ if (hasNamespaceImport) {
26
+ continue;
27
+ }
28
+ existingValueImport = statement;
29
+ break; // Found a value import, we can stop
21
30
  }
22
31
  }
23
32
 
24
- if (reactNativeImport) {
25
- // Add StyleSheet to existing react-native import
26
- reactNativeImport.specifiers.push(
27
- t.importSpecifier(t.identifier("StyleSheet"), t.identifier("StyleSheet")),
33
+ if (existingValueImport) {
34
+ // Check if StyleSheet is already imported
35
+ const hasStyleSheet = existingValueImport.specifiers.some(
36
+ (spec) =>
37
+ t.isImportSpecifier(spec) &&
38
+ spec.imported.type === "Identifier" &&
39
+ spec.imported.name === "StyleSheet",
28
40
  );
41
+
42
+ if (!hasStyleSheet) {
43
+ // Add StyleSheet to existing value import
44
+ existingValueImport.specifiers.push(
45
+ t.importSpecifier(t.identifier("StyleSheet"), t.identifier("StyleSheet")),
46
+ );
47
+ }
29
48
  } else {
30
- // Create new react-native import with StyleSheet
49
+ // No value import exists - create a new one
50
+ // (Don't merge with type-only or namespace imports)
31
51
  const importDeclaration = t.importDeclaration(
32
52
  [t.importSpecifier(t.identifier("StyleSheet"), t.identifier("StyleSheet"))],
33
53
  t.stringLiteral("react-native"),
@@ -40,22 +60,42 @@ export function addStyleSheetImport(path: NodePath<BabelTypes.Program>, t: typeo
40
60
  * Add Platform import to the file or merge with existing react-native import
41
61
  */
42
62
  export function addPlatformImport(path: NodePath<BabelTypes.Program>, t: typeof BabelTypes): void {
43
- // Check if there's already a react-native import
63
+ // Check if there's already a value import from react-native
44
64
  const body = path.node.body;
45
- let reactNativeImport: BabelTypes.ImportDeclaration | null = null;
65
+ let existingValueImport: BabelTypes.ImportDeclaration | null = null;
46
66
 
47
67
  for (const statement of body) {
48
68
  if (t.isImportDeclaration(statement) && statement.source.value === "react-native") {
49
- reactNativeImport = statement;
50
- break;
69
+ // Skip type-only imports (they get erased at runtime)
70
+ if (statement.importKind === "type") {
71
+ continue;
72
+ }
73
+ // Skip namespace imports (import * as RN) - can't add named specifiers to them
74
+ const hasNamespaceImport = statement.specifiers.some((spec) => t.isImportNamespaceSpecifier(spec));
75
+ if (hasNamespaceImport) {
76
+ continue;
77
+ }
78
+ existingValueImport = statement;
79
+ break; // Found a value import, we can stop
51
80
  }
52
81
  }
53
82
 
54
- if (reactNativeImport) {
55
- // Add Platform to existing react-native import
56
- reactNativeImport.specifiers.push(t.importSpecifier(t.identifier("Platform"), t.identifier("Platform")));
83
+ if (existingValueImport) {
84
+ // Check if Platform is already imported
85
+ const hasPlatform = existingValueImport.specifiers.some(
86
+ (spec) =>
87
+ t.isImportSpecifier(spec) && spec.imported.type === "Identifier" && spec.imported.name === "Platform",
88
+ );
89
+
90
+ if (!hasPlatform) {
91
+ // Add Platform to existing value import
92
+ existingValueImport.specifiers.push(
93
+ t.importSpecifier(t.identifier("Platform"), t.identifier("Platform")),
94
+ );
95
+ }
57
96
  } else {
58
- // Create new react-native import with Platform
97
+ // No value import exists - create a new one
98
+ // (Don't merge with type-only or namespace imports)
59
99
  const importDeclaration = t.importDeclaration(
60
100
  [t.importSpecifier(t.identifier("Platform"), t.identifier("Platform"))],
61
101
  t.stringLiteral("react-native"),
@@ -20,6 +20,7 @@ import { parseTypography } from "./typography";
20
20
  export type CustomTheme = {
21
21
  colors?: Record<string, string>;
22
22
  fontFamily?: Record<string, string>;
23
+ fontSize?: Record<string, number>;
23
24
  };
24
25
 
25
26
  /**
@@ -55,7 +56,7 @@ export function parseClass(cls: string, customTheme?: CustomTheme): StyleObject
55
56
  parseBorder,
56
57
  (cls: string) => parseColor(cls, customTheme?.colors),
57
58
  parseLayout,
58
- (cls: string) => parseTypography(cls, customTheme?.fontFamily),
59
+ (cls: string) => parseTypography(cls, customTheme?.fontFamily, customTheme?.fontSize),
59
60
  parseSizing,
60
61
  parseShadow,
61
62
  parseAspectRatio,
@@ -73,6 +73,67 @@ describe("parseLayout - flex grow/shrink utilities", () => {
73
73
  it("should parse shrink-0", () => {
74
74
  expect(parseLayout("shrink-0")).toEqual({ flexShrink: 0 });
75
75
  });
76
+
77
+ it("should parse grow with arbitrary values", () => {
78
+ expect(parseLayout("grow-[1.5]")).toEqual({ flexGrow: 1.5 });
79
+ expect(parseLayout("grow-[2]")).toEqual({ flexGrow: 2 });
80
+ expect(parseLayout("grow-[0.5]")).toEqual({ flexGrow: 0.5 });
81
+ expect(parseLayout("grow-[3]")).toEqual({ flexGrow: 3 });
82
+ expect(parseLayout("grow-[0]")).toEqual({ flexGrow: 0 });
83
+ });
84
+
85
+ it("should parse shrink with arbitrary values", () => {
86
+ expect(parseLayout("shrink-[0.5]")).toEqual({ flexShrink: 0.5 });
87
+ expect(parseLayout("shrink-[2]")).toEqual({ flexShrink: 2 });
88
+ expect(parseLayout("shrink-[1.5]")).toEqual({ flexShrink: 1.5 });
89
+ expect(parseLayout("shrink-[3]")).toEqual({ flexShrink: 3 });
90
+ expect(parseLayout("shrink-[0]")).toEqual({ flexShrink: 0 });
91
+ });
92
+
93
+ it("should parse CSS-style flex-grow aliases", () => {
94
+ expect(parseLayout("flex-grow")).toEqual({ flexGrow: 1 });
95
+ expect(parseLayout("flex-grow-0")).toEqual({ flexGrow: 0 });
96
+ });
97
+
98
+ it("should parse CSS-style flex-shrink aliases", () => {
99
+ expect(parseLayout("flex-shrink")).toEqual({ flexShrink: 1 });
100
+ expect(parseLayout("flex-shrink-0")).toEqual({ flexShrink: 0 });
101
+ });
102
+
103
+ it("should parse CSS-style flex-grow with arbitrary values", () => {
104
+ expect(parseLayout("flex-grow-[1.5]")).toEqual({ flexGrow: 1.5 });
105
+ expect(parseLayout("flex-grow-[2]")).toEqual({ flexGrow: 2 });
106
+ expect(parseLayout("flex-grow-[0.5]")).toEqual({ flexGrow: 0.5 });
107
+ });
108
+
109
+ it("should parse CSS-style flex-shrink with arbitrary values", () => {
110
+ expect(parseLayout("flex-shrink-[0.5]")).toEqual({ flexShrink: 0.5 });
111
+ expect(parseLayout("flex-shrink-[2]")).toEqual({ flexShrink: 2 });
112
+ expect(parseLayout("flex-shrink-[1.5]")).toEqual({ flexShrink: 1.5 });
113
+ });
114
+
115
+ it("should handle edge case values", () => {
116
+ expect(parseLayout("grow-[0.1]")).toEqual({ flexGrow: 0.1 });
117
+ expect(parseLayout("grow-[10]")).toEqual({ flexGrow: 10 });
118
+ expect(parseLayout("shrink-[0.01]")).toEqual({ flexShrink: 0.01 });
119
+ expect(parseLayout("shrink-[100]")).toEqual({ flexShrink: 100 });
120
+ });
121
+
122
+ it("should parse Tailwind shorthand decimals (no leading zero)", () => {
123
+ expect(parseLayout("grow-[.5]")).toEqual({ flexGrow: 0.5 });
124
+ expect(parseLayout("grow-[.75]")).toEqual({ flexGrow: 0.75 });
125
+ expect(parseLayout("shrink-[.5]")).toEqual({ flexShrink: 0.5 });
126
+ expect(parseLayout("shrink-[.25]")).toEqual({ flexShrink: 0.25 });
127
+ expect(parseLayout("flex-grow-[.5]")).toEqual({ flexGrow: 0.5 });
128
+ expect(parseLayout("flex-shrink-[.5]")).toEqual({ flexShrink: 0.5 });
129
+ });
130
+
131
+ it("should reject negative values", () => {
132
+ expect(parseLayout("grow-[-1]")).toBeNull();
133
+ expect(parseLayout("shrink-[-1]")).toBeNull();
134
+ expect(parseLayout("flex-grow-[-2]")).toBeNull();
135
+ expect(parseLayout("flex-shrink-[-0.5]")).toBeNull();
136
+ });
76
137
  });
77
138
 
78
139
  describe("parseLayout - justify content utilities", () => {
@@ -60,6 +60,31 @@ function parseArbitraryZIndex(value: string): number | null {
60
60
  return null;
61
61
  }
62
62
 
63
+ /**
64
+ * Parse arbitrary grow/shrink value: [1.5], [2], [0.5], [.5]
65
+ * Returns number for valid non-negative values, null otherwise
66
+ */
67
+ function parseArbitraryGrowShrink(value: string): number | null {
68
+ // Match: [1.5], [2], [0], [0.5], [.5] (non-negative decimals, optional leading digit)
69
+ const match = value.match(/^\[(\d+(?:\.\d+)?|\.\d+)\]$/);
70
+ if (match) {
71
+ return parseFloat(match[1]);
72
+ }
73
+
74
+ // Warn about invalid formats (negative values, unsupported formats)
75
+ if (value.startsWith("[") && value.endsWith("]")) {
76
+ /* v8 ignore next 5 */
77
+ if (process.env.NODE_ENV !== "production") {
78
+ console.warn(
79
+ `[react-native-tailwind] Invalid arbitrary grow/shrink value: ${value}. Only non-negative numbers are supported (e.g., [1.5], [2], [0.5], [.5]).`,
80
+ );
81
+ }
82
+ return null;
83
+ }
84
+
85
+ return null;
86
+ }
87
+
63
88
  // Display utilities
64
89
  const DISPLAY_MAP: Record<string, StyleObject> = {
65
90
  flex: { display: "flex" },
@@ -88,12 +113,17 @@ const FLEX_MAP: Record<string, StyleObject> = {
88
113
  "flex-none": { flex: 0 },
89
114
  };
90
115
 
91
- // Flex grow/shrink utilities
116
+ // Flex grow/shrink utilities (includes CSS-style aliases)
92
117
  const GROW_SHRINK_MAP: Record<string, StyleObject> = {
93
118
  grow: { flexGrow: 1 },
94
119
  "grow-0": { flexGrow: 0 },
95
120
  shrink: { flexShrink: 1 },
96
121
  "shrink-0": { flexShrink: 0 },
122
+ // CSS-style aliases
123
+ "flex-grow": { flexGrow: 1 },
124
+ "flex-grow-0": { flexGrow: 0 },
125
+ "flex-shrink": { flexShrink: 1 },
126
+ "flex-shrink-0": { flexShrink: 0 },
97
127
  };
98
128
 
99
129
  // Justify content utilities
@@ -357,6 +387,30 @@ export function parseLayout(cls: string): StyleObject | null {
357
387
  }
358
388
  }
359
389
 
390
+ // Flex grow: grow-[1.5], flex-grow-[2], etc. (arbitrary values)
391
+ if (cls.startsWith("grow-") || cls.startsWith("flex-grow-")) {
392
+ const prefix = cls.startsWith("flex-grow-") ? "flex-grow-" : "grow-";
393
+ const growKey = cls.substring(prefix.length);
394
+
395
+ // Arbitrary values: grow-[1.5], flex-grow-[2]
396
+ const arbitraryGrow = parseArbitraryGrowShrink(growKey);
397
+ if (arbitraryGrow !== null) {
398
+ return { flexGrow: arbitraryGrow };
399
+ }
400
+ }
401
+
402
+ // Flex shrink: shrink-[0.5], flex-shrink-[1], etc. (arbitrary values)
403
+ if (cls.startsWith("shrink-") || cls.startsWith("flex-shrink-")) {
404
+ const prefix = cls.startsWith("flex-shrink-") ? "flex-shrink-" : "shrink-";
405
+ const shrinkKey = cls.substring(prefix.length);
406
+
407
+ // Arbitrary values: shrink-[0.5], flex-shrink-[1]
408
+ const arbitraryShrink = parseArbitraryGrowShrink(shrinkKey);
409
+ if (arbitraryShrink !== null) {
410
+ return { flexShrink: arbitraryShrink };
411
+ }
412
+ }
413
+
360
414
  // Try each lookup table in order
361
415
  return (
362
416
  DISPLAY_MAP[cls] ??
@@ -39,6 +39,20 @@ describe("parseTypography - font size", () => {
39
39
  expect(parseTypography("text-[22]")).toEqual({ fontSize: 22 });
40
40
  expect(parseTypography("text-[100px]")).toEqual({ fontSize: 100 });
41
41
  });
42
+
43
+ it("should parse font size with decimal arbitrary values", () => {
44
+ expect(parseTypography("text-[13.5px]")).toEqual({ fontSize: 13.5 });
45
+ expect(parseTypography("text-[13.5]")).toEqual({ fontSize: 13.5 });
46
+ expect(parseTypography("text-[18.75px]")).toEqual({ fontSize: 18.75 });
47
+ expect(parseTypography("text-[18.75]")).toEqual({ fontSize: 18.75 });
48
+ expect(parseTypography("text-[22.5]")).toEqual({ fontSize: 22.5 });
49
+ });
50
+
51
+ it("should parse font size with Tailwind shorthand decimals", () => {
52
+ expect(parseTypography("text-[.5]")).toEqual({ fontSize: 0.5 });
53
+ expect(parseTypography("text-[.75px]")).toEqual({ fontSize: 0.75 });
54
+ expect(parseTypography("text-[.5px]")).toEqual({ fontSize: 0.5 });
55
+ });
42
56
  });
43
57
 
44
58
  describe("parseTypography - font family", () => {
@@ -136,6 +150,20 @@ describe("parseTypography - line height", () => {
136
150
  expect(parseTypography("leading-[30]")).toEqual({ lineHeight: 30 });
137
151
  expect(parseTypography("leading-[50px]")).toEqual({ lineHeight: 50 });
138
152
  });
153
+
154
+ it("should parse line height with decimal arbitrary values", () => {
155
+ expect(parseTypography("leading-[21.5px]")).toEqual({ lineHeight: 21.5 });
156
+ expect(parseTypography("leading-[21.5]")).toEqual({ lineHeight: 21.5 });
157
+ expect(parseTypography("leading-[28.75px]")).toEqual({ lineHeight: 28.75 });
158
+ expect(parseTypography("leading-[28.75]")).toEqual({ lineHeight: 28.75 });
159
+ expect(parseTypography("leading-[32.5]")).toEqual({ lineHeight: 32.5 });
160
+ });
161
+
162
+ it("should parse line height with Tailwind shorthand decimals", () => {
163
+ expect(parseTypography("leading-[.5]")).toEqual({ lineHeight: 0.5 });
164
+ expect(parseTypography("leading-[.75px]")).toEqual({ lineHeight: 0.75 });
165
+ expect(parseTypography("leading-[.5px]")).toEqual({ lineHeight: 0.5 });
166
+ });
139
167
  });
140
168
 
141
169
  describe("parseTypography - letter spacing", () => {
@@ -149,6 +177,80 @@ describe("parseTypography - letter spacing", () => {
149
177
  expect(parseTypography("tracking-wider")).toEqual({ letterSpacing: 0.8 });
150
178
  expect(parseTypography("tracking-widest")).toEqual({ letterSpacing: 1.6 });
151
179
  });
180
+
181
+ it("should parse letter spacing with arbitrary values", () => {
182
+ expect(parseTypography("tracking-[0.5px]")).toEqual({ letterSpacing: 0.5 });
183
+ expect(parseTypography("tracking-[0.5]")).toEqual({ letterSpacing: 0.5 });
184
+ expect(parseTypography("tracking-[0.3]")).toEqual({ letterSpacing: 0.3 });
185
+ expect(parseTypography("tracking-[1.2px]")).toEqual({ letterSpacing: 1.2 });
186
+ expect(parseTypography("tracking-[2]")).toEqual({ letterSpacing: 2 });
187
+ });
188
+
189
+ it("should parse letter spacing with Tailwind shorthand decimals", () => {
190
+ expect(parseTypography("tracking-[.5]")).toEqual({ letterSpacing: 0.5 });
191
+ expect(parseTypography("tracking-[.3px]")).toEqual({ letterSpacing: 0.3 });
192
+ expect(parseTypography("tracking-[.75]")).toEqual({ letterSpacing: 0.75 });
193
+ });
194
+
195
+ it("should parse negative letter spacing with arbitrary values", () => {
196
+ expect(parseTypography("tracking-[-0.4px]")).toEqual({ letterSpacing: -0.4 });
197
+ expect(parseTypography("tracking-[-0.4]")).toEqual({ letterSpacing: -0.4 });
198
+ expect(parseTypography("tracking-[-0.5]")).toEqual({ letterSpacing: -0.5 });
199
+ expect(parseTypography("tracking-[-1px]")).toEqual({ letterSpacing: -1 });
200
+ });
201
+
202
+ it("should parse negative letter spacing with shorthand decimals", () => {
203
+ expect(parseTypography("tracking-[-.4]")).toEqual({ letterSpacing: -0.4 });
204
+ expect(parseTypography("tracking-[-.5px]")).toEqual({ letterSpacing: -0.5 });
205
+ });
206
+
207
+ it("should handle edge case letter spacing values", () => {
208
+ expect(parseTypography("tracking-[0]")).toEqual({ letterSpacing: 0 });
209
+ expect(parseTypography("tracking-[0.1]")).toEqual({ letterSpacing: 0.1 });
210
+ expect(parseTypography("tracking-[10]")).toEqual({ letterSpacing: 10 });
211
+ });
212
+ });
213
+
214
+ describe("parseTypography - custom fontSize", () => {
215
+ const customFontSize = {
216
+ tiny: 10,
217
+ huge: 96,
218
+ xl: 22, // Override default (default is 20)
219
+ custom: 17,
220
+ };
221
+
222
+ it("should support custom font sizes", () => {
223
+ expect(parseTypography("text-tiny", undefined, customFontSize)).toEqual({ fontSize: 10 });
224
+ expect(parseTypography("text-huge", undefined, customFontSize)).toEqual({ fontSize: 96 });
225
+ expect(parseTypography("text-custom", undefined, customFontSize)).toEqual({ fontSize: 17 });
226
+ });
227
+
228
+ it("should allow custom fontSize to override preset sizes", () => {
229
+ expect(parseTypography("text-xl", undefined, customFontSize)).toEqual({ fontSize: 22 });
230
+ });
231
+
232
+ it("should fallback to preset sizes when custom fontSize not found", () => {
233
+ expect(parseTypography("text-base", undefined, customFontSize)).toEqual({ fontSize: 16 });
234
+ expect(parseTypography("text-lg", undefined, customFontSize)).toEqual({ fontSize: 18 });
235
+ expect(parseTypography("text-2xl", undefined, customFontSize)).toEqual({ fontSize: 24 });
236
+ });
237
+
238
+ it("should prefer arbitrary values over custom fontSize", () => {
239
+ expect(parseTypography("text-[15px]", undefined, customFontSize)).toEqual({ fontSize: 15 });
240
+ expect(parseTypography("text-[13.5]", undefined, customFontSize)).toEqual({ fontSize: 13.5 });
241
+ });
242
+
243
+ it("should work with both customFontFamily and customFontSize", () => {
244
+ const customFontFamily = { brand: "MyCustomFont" };
245
+ expect(parseTypography("text-tiny", customFontFamily, customFontSize)).toEqual({ fontSize: 10 });
246
+ expect(parseTypography("font-brand", customFontFamily, customFontSize)).toEqual({
247
+ fontFamily: "MyCustomFont",
248
+ });
249
+ });
250
+
251
+ it("should return null for unknown custom fontSize keys", () => {
252
+ expect(parseTypography("text-nonexistent", undefined, customFontSize)).toBeNull();
253
+ });
152
254
  });
153
255
 
154
256
  describe("parseTypography - edge cases", () => {