@tamagui/tailwind 3.0.0-beta.924.1 → 3.0.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.
Files changed (56) hide show
  1. package/dist/cjs/candidate.cjs +312 -137
  2. package/dist/cjs/candidate.native.cjs +342 -153
  3. package/dist/cjs/candidate.native.js +342 -153
  4. package/dist/cjs/candidate.native.js.map +1 -1
  5. package/dist/cjs/compose.cjs +383 -0
  6. package/dist/cjs/compose.native.cjs +389 -0
  7. package/dist/cjs/compose.native.js +391 -0
  8. package/dist/cjs/compose.native.js.map +1 -0
  9. package/dist/cjs/composedResolver.cjs +226 -0
  10. package/dist/cjs/composedResolver.native.cjs +255 -0
  11. package/dist/cjs/composedResolver.native.js +257 -0
  12. package/dist/cjs/composedResolver.native.js.map +1 -0
  13. package/dist/cjs/frontend.cjs +4 -11
  14. package/dist/cjs/frontend.native.cjs +5 -16
  15. package/dist/cjs/frontend.native.js +5 -16
  16. package/dist/cjs/frontend.native.js.map +1 -1
  17. package/dist/cjs/index.cjs +2 -0
  18. package/dist/cjs/index.native.cjs +2 -0
  19. package/dist/cjs/index.native.js +2 -0
  20. package/dist/cjs/index.native.js.map +1 -1
  21. package/dist/esm/candidate.mjs +314 -140
  22. package/dist/esm/candidate.mjs.map +1 -1
  23. package/dist/esm/candidate.native.js +344 -156
  24. package/dist/esm/candidate.native.js.map +1 -1
  25. package/dist/esm/compose.mjs +359 -0
  26. package/dist/esm/compose.mjs.map +1 -0
  27. package/dist/esm/compose.native.js +363 -0
  28. package/dist/esm/compose.native.js.map +1 -0
  29. package/dist/esm/composedResolver.mjs +206 -0
  30. package/dist/esm/composedResolver.mjs.map +1 -0
  31. package/dist/esm/composedResolver.native.js +233 -0
  32. package/dist/esm/composedResolver.native.js.map +1 -0
  33. package/dist/esm/frontend.mjs +4 -11
  34. package/dist/esm/frontend.mjs.map +1 -1
  35. package/dist/esm/frontend.native.js +5 -16
  36. package/dist/esm/frontend.native.js.map +1 -1
  37. package/dist/esm/index.mjs +3 -2
  38. package/dist/esm/index.mjs.map +1 -1
  39. package/dist/esm/index.native.js +3 -2
  40. package/dist/esm/index.native.js.map +1 -1
  41. package/package.json +18 -15
  42. package/src/candidate.ts +396 -60
  43. package/src/frontend.ts +3 -16
  44. package/src/index.tsx +14 -2
  45. package/src/types.ts +32 -4
  46. package/types/candidate.d.ts +2 -1
  47. package/types/candidate.d.ts.map +1 -1
  48. package/types/compose.d.ts +19 -0
  49. package/types/compose.d.ts.map +1 -0
  50. package/types/composedResolver.d.ts +19 -0
  51. package/types/composedResolver.d.ts.map +1 -0
  52. package/types/frontend.d.ts.map +1 -1
  53. package/types/index.d.ts +11 -1
  54. package/types/index.d.ts.map +1 -1
  55. package/types/types.d.ts +18 -3
  56. package/types/types.d.ts.map +1 -1
package/src/candidate.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  import { isWeb } from '@tamagui/constants'
2
+ import { stylePropsUnitless } from '@tamagui/helpers'
2
3
  import {
4
+ mergeFrontendCondition,
3
5
  plainValueToPayload,
4
6
  type FrontendClassPlan,
5
7
  type FrontendClassPlanEntry,
8
+ type FrontendClassSink,
6
9
  type StyleFrontendConfig,
7
10
  } from '@tamagui/core/internal-runtime'
11
+ import { noteBoxShadow, noteTailwindTransform, tryCompose } from './compose'
12
+ import { composedResolver } from './composedResolver'
8
13
  import {
9
14
  borderSideSuffix,
10
15
  configRevisionSymbol,
@@ -20,8 +25,12 @@ import {
20
25
  import {
21
26
  classifyCandidate,
22
27
  decodeArbitrary,
28
+ hasTokenName,
23
29
  type GrammarConfigView,
24
30
  type ParsedCandidate,
31
+ resolveTokenName,
32
+ tailwindLeadingRatios,
33
+ tailwindTrackingEms,
25
34
  } from '@tamagui/style-grammar/tooling/candidate'
26
35
 
27
36
  /**
@@ -54,25 +63,36 @@ export function isTokenValueProp(prop: string): boolean {
54
63
  }
55
64
 
56
65
  // CANONICAL arbitrary-value coercion. an arbitrary `[..]` whose inner is a UNITLESS number
57
- // (z-[400], aspect-[1.5], leading-[1.25]) or a PX length (p-[18px], text-[14px], border-[0.5px])
66
+ // (z-[400], aspect-[1.5]) or a PX length (p-[18px], text-[14px], border-[0.5px])
58
67
  // resolves to a NUMBER: React Native REQUIRES numbers for dimensional + typography props and
59
68
  // silently DROPS "Npx"/"400" strings (Yoga parseCSSProperty<CSSNumber,CSSPercentage> rejects px;
60
69
  // StyleSheetTypes types fontSize/lineHeight/letterSpacing as number). web accepts the number and
61
70
  // re-adds px via CSS. anything carrying a real unit/function (%, rem, vh, deg, calc(), var(),
62
71
  // #hex, colors) stays a STRING.
63
- function arbitraryValue(inner: string): number | string {
72
+ function arbitraryValue(inner: string, prop?: string): number | string {
64
73
  const px = /^(-?\d*\.?\d+)px$/.exec(inner)
65
- if (px) return Number(px[1])
74
+ // a unitless prop (grid-cols-[77px]) never gets px re-added, so the length stays a string
75
+ if (px) return prop && stylePropsUnitless[prop] ? inner : Number(px[1])
66
76
  if (/^-?\d*\.?\d+$/.test(inner)) return Number(inner)
67
77
  return inner
68
78
  }
69
79
 
80
+ /** the same coercion for a raw value that may still be wrapped in `[]` */
81
+ function unwrapArbitraryValue(raw: string): number | string {
82
+ return arbitraryValue(
83
+ raw.length > 1 && raw[0] === '[' && raw[raw.length - 1] === ']'
84
+ ? decodeArbitrary(raw.slice(1, -1))
85
+ : raw
86
+ )
87
+ }
88
+
70
89
  // tailwind sizing keywords / fractions for width/height/min/max props.
71
90
  // returns a CSS value, or null if `value` isn't a sizing keyword (falls through to normal parse).
72
91
  function tailwindSizingValue(prop: string, value: string): string | null {
73
92
  if (value === 'full') return '100%'
74
93
  if (value === 'auto') return 'auto'
75
- if (value === 'screen') return /[Hh]eight/.test(prop) ? '100vh' : '100vw'
94
+ if (value === 'screen') return /[Hh]eight|[Bb]lock/.test(prop) ? '100vh' : '100vw'
95
+ if (/^[dls]v[hw]$/.test(value)) return `100${value}`
76
96
  if (value === 'min') return 'min-content'
77
97
  if (value === 'max') return 'max-content'
78
98
  if (value === 'fit') return 'fit-content'
@@ -83,20 +103,6 @@ function tailwindSizingValue(prop: string, value: string): string | null {
83
103
  return null
84
104
  }
85
105
 
86
- // unwrap a possibly-arbitrary value ([2px] → "2px") and coerce a bare number / px length to
87
- // a NUMBER (2px → 2, 0.5 → 0.5) so borderWidth/radius match tamagui's numeric props; other
88
- // units (1em, calc(…)) stay strings.
89
- function borderDimValue(raw: string): number | string {
90
- let inner = raw
91
- if (raw.length > 1 && raw[0] === '[' && raw[raw.length - 1] === ']') {
92
- inner = decodeArbitrary(raw.slice(1, -1))
93
- }
94
- const px = /^(-?\d*\.?\d+)px$/.exec(inner)
95
- if (px) return Number.parseFloat(px[1])
96
- if (/^-?\d*\.?\d+$/.test(inner)) return Number(inner)
97
- return inner
98
- }
99
-
100
106
  /**
101
107
  * Prefixes that write more than one longhand from a single candidate. The registry
102
108
  * already selected the token category and width-vs-color meaning; this only names
@@ -107,6 +113,8 @@ function expansionProps(parsed: ParsedCandidate): readonly string[] | null {
107
113
  const prefix = parsed.prefix
108
114
  if (!prefix) return null
109
115
  if (prefix === 'size') return sizeUtilityProps
116
+ if (prefix === 'translate') return ['x', 'y']
117
+ if (prefix === 'skew') return ['skewX', 'skewY']
110
118
  if (prefix === 'inset-x' || prefix === 'inset-y') {
111
119
  return insetAxisProps[prefix.slice('inset-'.length)]
112
120
  }
@@ -125,14 +133,15 @@ function expansionProps(parsed: ParsedCandidate): readonly string[] | null {
125
133
  /**
126
134
  * Adapt a registry-parsed candidate into the ordinary props shared rendering consumes.
127
135
  * Examples:
128
- * "hover:bg-blue5" → backgroundColor + a `hover:blue5` program
136
+ * "hover:bg-blue-5" → backgroundColor + a `hover:blue-5` program
129
137
  * "sm:p-4" → padding + an `sm:4` program
130
138
  * "bg-[red]" → { key: "backgroundColor", value: "red" }
131
139
  * "w-100" → { key: "width", value: 100 }
132
140
  * "opacity-50" → { key: "opacity", value: 0.5 }
133
141
  */
134
142
  function tailwindClassToFlatProp(
135
- parsed: ParsedCandidate
143
+ parsed: ParsedCandidate,
144
+ grammarConfig?: GrammarConfigView
136
145
  ): { key: string; value: any } | null {
137
146
  if (parsed.kind !== 'dynamic' || !parsed.entry || parsed.rawValue === undefined) {
138
147
  return null
@@ -141,11 +150,83 @@ function tailwindClassToFlatProp(
141
150
  const category = parsed.entry.tokenCategory
142
151
  let value: any = parsed.rawValue
143
152
 
153
+ if (parsed.negative && value === '0' && parsed.convenience !== 'angle') {
154
+ return { key: prop, value: 0 }
155
+ }
156
+
157
+ if (parsed.convenience === 'zero') {
158
+ return { key: prop, value: 0 }
159
+ }
160
+
161
+ if (parsed.convenience === 'angle') {
162
+ return {
163
+ key: prop,
164
+ value: `${parsed.negative ? '-' : ''}${value}deg`,
165
+ }
166
+ }
167
+
168
+ // Tailwind's translate fractions and `full` are percentages, not configured
169
+ // spacing tokens. RN accepts percentage translate values, so preserve the
170
+ // exact CSS geometry for both axes and for negative candidates.
171
+ if ((prop === 'x' || prop === 'y') && parsed.valueKind !== 'token') {
172
+ const sized = tailwindSizingValue(prop, value)
173
+ if (sized != null) {
174
+ return {
175
+ key: prop,
176
+ value: parsed.negative && sized[0] !== '-' ? `-${sized}` : sized,
177
+ }
178
+ }
179
+ }
180
+
181
+ // named leading (leading-tight) is a unitless ratio, which v3 line height reads as such
182
+ if (parsed.convenience === 'leading-keyword') {
183
+ return { key: prop, value: tailwindLeadingRatios[value] }
184
+ }
185
+
186
+ // named tracking (tracking-wide) is em of the font size, which core lowers per host
187
+ if (parsed.convenience === 'tracking-keyword') {
188
+ const em = tailwindTrackingEms[value] * (parsed.negative ? -1 : 1)
189
+ return { key: prop, value: `${em}em` }
190
+ }
191
+
192
+ // Tailwind's numbered leading scale is the spacing scale (N * 0.25rem),
193
+ // represented as native points at the default 16px root size. Resolve it
194
+ // here instead of letting an application's same-named font token change the
195
+ // meaning of a Tailwind classname. Keep the unit until core lowers it for the
196
+ // target host, because a bare number is a V3 line-height ratio.
197
+ if (prop === 'lineHeight' && /^\d+(?:\.\d+)?$/.test(value)) {
198
+ return { key: prop, value: `${Number(value) * 4}px` }
199
+ }
200
+
201
+ // grid-cols-N → repeat(N, minmax(0, 1fr)), col-span-N → span N / span N
202
+ if (prop === 'gridTemplateColumns' && /^\d+$/.test(value)) {
203
+ return { key: prop, value: `repeat(${value}, minmax(0, 1fr))` }
204
+ }
205
+ if (prop === 'gridColumn' && /^\d+$/.test(value)) {
206
+ return { key: prop, value: `span ${value} / span ${value}` }
207
+ }
208
+ if (prop === 'gridRow' && /^\d+$/.test(value)) {
209
+ return { key: prop, value: `span ${value} / span ${value}` }
210
+ }
211
+ if (
212
+ (prop === 'gridColumnStart' ||
213
+ prop === 'gridColumnEnd' ||
214
+ prop === 'gridRowStart' ||
215
+ prop === 'gridRowEnd') &&
216
+ /^\d+$/.test(value)
217
+ ) {
218
+ return { key: prop, value: Number(value) }
219
+ }
220
+
144
221
  if (prop.endsWith('Width') && parsed.prefix?.startsWith('border')) {
145
222
  if (parsed.valueKind === 'token') {
146
223
  value = `${parsed.negative ? '-' : ''}${value}`
224
+ if (grammarConfig && parsed.entry?.tokenCategory) {
225
+ value =
226
+ resolveTokenName(grammarConfig, parsed.entry.tokenCategory, value) ?? value
227
+ }
147
228
  } else if (parsed.valueKind === 'arbitrary') {
148
- value = borderDimValue(value)
229
+ value = unwrapArbitraryValue(value)
149
230
  } else {
150
231
  return null
151
232
  }
@@ -188,10 +269,9 @@ function tailwindClassToFlatProp(
188
269
  let lhValue: any
189
270
  if (value.length > 2 && value[0] === '[' && value[value.length - 1] === ']') {
190
271
  const inner = decodeArbitrary(value.slice(1, -1))
191
- // px length → NUMBER (native-valid: leading-[20px] → 20). a UNITLESS value is a web
192
- // lineHeight MULTIPLIER and MUST stay a string (a number would be px-ified to "1.25px"
193
- // on web, breaking the multiplier). RN has no unitless multiplier, so this is web-only.
194
- lhValue = /^-?\d*\.?\d+px$/.test(inner) ? Number.parseFloat(inner) : inner
272
+ // preserve both forms until core's target lowering: px is absolute and a
273
+ // unitless numeric string is a ratio on web and native.
274
+ lhValue = inner
195
275
  } else {
196
276
  lhValue = value
197
277
  }
@@ -222,23 +302,47 @@ function tailwindClassToFlatProp(
222
302
  }
223
303
 
224
304
  // arbitrary values: p-[4px], w-[100px], rounded-[8px], min-h-[100vh], rotate-[-8deg],
225
- // h-[calc(100%-2px)], bg-[var(--color5)], bg-[#fff]. use the bracketed value directly as
305
+ // h-[calc(100%-2px)], bg-[var(--color-5)], bg-[#fff]. use the bracketed value directly as
226
306
  // CSS — no scaling/token resolution. tailwind encodes spaces inside [] as underscores.
227
307
  if (value.length > 2 && value[0] === '[' && value[value.length - 1] === ']') {
228
308
  const inner = decodeArbitrary(value.slice(1, -1))
229
309
  if (inner === '') return null
230
310
  // px-length + unitless arbitraries become NUMBERS (native requires numbers, drops "Npx"
231
311
  // strings); unit/function values stay strings. one canonical rule (arbitraryValue).
232
- return { key: prop, value: arbitraryValue(inner) }
312
+ let resolved = arbitraryValue(inner, prop)
313
+ // rotate requires a unit-bearing string on native — a bare number from rotate-[45]
314
+ // triggers a redbox. append deg when the arbitrary resolved to a unitless number.
315
+ if (
316
+ (prop === 'rotate' ||
317
+ prop === 'rotateX' ||
318
+ prop === 'rotateY' ||
319
+ prop === 'rotateZ' ||
320
+ prop === 'skewX' ||
321
+ prop === 'skewY') &&
322
+ typeof resolved === 'number'
323
+ ) {
324
+ resolved = `${resolved}deg`
325
+ }
326
+ if (parsed.negative) {
327
+ if (typeof resolved === 'number') resolved = -resolved
328
+ else if (resolved[0] !== '-') resolved = `-${resolved}`
329
+ }
330
+ return { key: prop, value: resolved }
233
331
  }
234
332
 
235
333
  // tailwind sizing keywords / fractions (w-full → 100%, w-1/2 → 50%, w-auto, w-screen).
236
334
  // handled before isValidTailwindValue since fractions/keywords aren't plain CSS values. An
237
335
  // exact configured size token with the same spelling stays a token.
238
- if (category === 'size' && parsed.valueKind !== 'token') {
336
+ if (
337
+ parsed.valueKind !== 'token' &&
338
+ (category === 'size' || parsed.convenience === 'sizing-keyword')
339
+ ) {
239
340
  const sized = tailwindSizingValue(prop, value)
240
341
  if (sized != null) {
241
- return { key: prop, value: sized }
342
+ return {
343
+ key: prop,
344
+ value: parsed.negative && sized[0] !== '-' ? `-${sized}` : sized,
345
+ }
242
346
  }
243
347
  }
244
348
 
@@ -264,9 +368,12 @@ function tailwindClassToFlatProp(
264
368
  value = Number(value) / 100
265
369
  } else if (/^\d+(\.\d+)?$/.test(value)) {
266
370
  if (category === 'zIndex' && parsed.valueKind === 'convenience') {
267
- value = Number(value)
371
+ value = Number(value) * (parsed.negative ? -1 : 1)
268
372
  } else if (category) {
269
373
  value = `${parsed.negative ? '-' : ''}${value}`
374
+ if (grammarConfig) {
375
+ value = resolveTokenName(grammarConfig, category, value) ?? value
376
+ }
270
377
  } else {
271
378
  value = Number(value)
272
379
  }
@@ -317,6 +424,71 @@ type TailwindParentPlan = {
317
424
  type CachedTailwindClassPlan = TailwindClassPlan | TailwindParentPlan
318
425
 
319
426
  const classPlanCache = new WeakMap<object, Map<string, CachedTailwindClassPlan>>()
427
+ const nativeTarget = !isWeb || process.env.TAMAGUI_TARGET === 'native'
428
+ const cssGridProps = new Set([
429
+ 'gridTemplateColumns',
430
+ 'gridColumn',
431
+ 'gridColumnStart',
432
+ 'gridColumnEnd',
433
+ 'gridRow',
434
+ 'gridRowStart',
435
+ 'gridRowEnd',
436
+ ])
437
+ const nativeUnsupportedProps = new Set([
438
+ 'objectFit',
439
+ 'order',
440
+ 'overflowX',
441
+ 'overflowY',
442
+ 'textOverflow',
443
+ 'whiteSpace',
444
+ 'visibility',
445
+ ])
446
+ const nativeUnsupportedSizingValues = new Set(['min', 'max', 'fit'])
447
+ // values outside react native's style unions, which native would drop
448
+ const nativeUnsupportedValues: Record<string, Set<string>> = {
449
+ display: new Set(['block', 'inline', 'inline-flex']),
450
+ position: new Set(['fixed', 'sticky']),
451
+ }
452
+
453
+ function shouldGateNative(parsed: ParsedCandidate): boolean {
454
+ if (!nativeTarget) return false
455
+ const properties = parsed.properties || {}
456
+ if (
457
+ process.env.TAMAGUI_CSS_GRID !== '1' &&
458
+ ((parsed.entry && cssGridProps.has(parsed.entry.prop)) ||
459
+ properties.display === 'grid' ||
460
+ Object.keys(properties).some((prop) => cssGridProps.has(prop)))
461
+ ) {
462
+ return true
463
+ }
464
+ if (
465
+ (parsed.entry && nativeUnsupportedProps.has(parsed.entry.prop)) ||
466
+ Object.keys(properties).some((prop) => nativeUnsupportedProps.has(prop))
467
+ ) {
468
+ return true
469
+ }
470
+ if (
471
+ parsed.convenience === 'sizing-keyword' &&
472
+ nativeUnsupportedSizingValues.has(parsed.rawValue || '')
473
+ ) {
474
+ return true
475
+ }
476
+ const entryProp = parsed.entry?.prop
477
+ if (
478
+ (entryProp && nativeUnsupportedValues[entryProp]?.has(parsed.rawValue || '')) ||
479
+ Object.keys(properties).some((prop) =>
480
+ nativeUnsupportedValues[prop]?.has(String(properties[prop]))
481
+ )
482
+ ) {
483
+ return true
484
+ }
485
+ return (
486
+ properties.overflow === 'auto' ||
487
+ (properties.cursor !== undefined &&
488
+ properties.cursor !== 'auto' &&
489
+ properties.cursor !== 'pointer')
490
+ )
491
+ }
320
492
 
321
493
  function getClassPlanCache(grammarConfig: object) {
322
494
  let cache = classPlanCache.get(grammarConfig)
@@ -343,6 +515,89 @@ function createPlanEntry(
343
515
  return [property, payload, condition, modifiers]
344
516
  }
345
517
 
518
+ // CSS shorthands reset every longhand, including logical ones. RN stores those
519
+ // under different keys (`paddingStart` vs `paddingLeft`, `columnGap` vs `gap`),
520
+ // so `ps-2 p-4` would otherwise keep the earlier logical value. Unmodified
521
+ // shorthand plans prepend `unset` entries so both the tokenizer and the
522
+ // renderer sink clear them. Conditional classes (`hover:p-4`) skip this.
523
+ const shorthandResets: Record<string, readonly string[]> = {
524
+ padding: [
525
+ 'paddingTop',
526
+ 'paddingRight',
527
+ 'paddingBottom',
528
+ 'paddingLeft',
529
+ 'paddingStart',
530
+ 'paddingEnd',
531
+ 'paddingInlineStart',
532
+ 'paddingInlineEnd',
533
+ 'paddingBlockStart',
534
+ 'paddingBlockEnd',
535
+ 'paddingHorizontal',
536
+ 'paddingVertical',
537
+ 'paddingInline',
538
+ 'paddingBlock',
539
+ ],
540
+ margin: [
541
+ 'marginTop',
542
+ 'marginRight',
543
+ 'marginBottom',
544
+ 'marginLeft',
545
+ 'marginStart',
546
+ 'marginEnd',
547
+ 'marginInlineStart',
548
+ 'marginInlineEnd',
549
+ 'marginBlockStart',
550
+ 'marginBlockEnd',
551
+ 'marginHorizontal',
552
+ 'marginVertical',
553
+ 'marginInline',
554
+ 'marginBlock',
555
+ ],
556
+ gap: ['rowGap', 'columnGap'],
557
+ borderRadius: [
558
+ 'borderTopLeftRadius',
559
+ 'borderTopRightRadius',
560
+ 'borderBottomLeftRadius',
561
+ 'borderBottomRightRadius',
562
+ 'borderTopStartRadius',
563
+ 'borderTopEndRadius',
564
+ 'borderBottomStartRadius',
565
+ 'borderBottomEndRadius',
566
+ 'borderStartStartRadius',
567
+ 'borderStartEndRadius',
568
+ 'borderEndStartRadius',
569
+ 'borderEndEndRadius',
570
+ ],
571
+ borderWidth: [
572
+ 'borderTopWidth',
573
+ 'borderRightWidth',
574
+ 'borderBottomWidth',
575
+ 'borderLeftWidth',
576
+ 'borderStartWidth',
577
+ 'borderEndWidth',
578
+ 'borderInlineStartWidth',
579
+ 'borderInlineEndWidth',
580
+ 'borderBlockStartWidth',
581
+ 'borderBlockEndWidth',
582
+ 'borderInlineWidth',
583
+ 'borderBlockWidth',
584
+ ],
585
+ borderColor: [
586
+ 'borderTopColor',
587
+ 'borderRightColor',
588
+ 'borderBottomColor',
589
+ 'borderLeftColor',
590
+ 'borderStartColor',
591
+ 'borderEndColor',
592
+ 'borderInlineStartColor',
593
+ 'borderInlineEndColor',
594
+ 'borderBlockStartColor',
595
+ 'borderBlockEndColor',
596
+ 'borderInlineColor',
597
+ 'borderBlockColor',
598
+ ],
599
+ }
600
+
346
601
  function computeClassPlan(
347
602
  cls: string,
348
603
  grammarConfig: GrammarConfigView
@@ -372,13 +627,30 @@ function computeClassPlan(
372
627
  return isWeb ? 'raw' : null
373
628
  }
374
629
  const parsed = classification.parsed
630
+ if (!nativeTarget && parsed.entry?.prop === 'perspective') return 'raw'
631
+ if (shouldGateNative(parsed)) {
632
+ return null
633
+ }
634
+ if (parsed.entry?.prop === 'flex' && parsed.rawValue) {
635
+ const fraction = /^(\d+)\/(\d+)$/.exec(parsed.rawValue)
636
+ if (fraction && Number(fraction[2]) !== 0) {
637
+ const basis = `${(Number(fraction[1]) / Number(fraction[2])) * 100}%`
638
+ const entries = [
639
+ createPlanEntry('flexGrow', 1, parsed.modifiers),
640
+ createPlanEntry('flexShrink', 1, parsed.modifiers),
641
+ createPlanEntry('flexBasis', basis, parsed.modifiers),
642
+ ]
643
+ return entries.every(Boolean) ? (entries as TailwindPlanEntry[]) : 'raw'
644
+ }
645
+ }
375
646
  // named utilities first (flex-row, flex-1, hidden, …) — whole class → fixed prop(s).
376
647
  // these may emit multiple props and may have no dash, so handle before the generic parse.
377
648
  const util = parsed.kind === 'utility' ? parsed.properties : null
378
649
  if (util) {
379
650
  const entries: TailwindPlanEntry[] = []
380
651
  for (const p in util) {
381
- const entry = createPlanEntry(p, util[p], parsed.modifiers)
652
+ const value = nativeTarget && p === 'transform' && util[p] === 'none' ? [] : util[p]
653
+ const entry = createPlanEntry(p, value, parsed.modifiers)
382
654
  if (!entry) return 'raw'
383
655
  entries.push(entry)
384
656
  }
@@ -390,10 +662,13 @@ function computeClassPlan(
390
662
  if (expandedProps) {
391
663
  const entries: TailwindPlanEntry[] = []
392
664
  for (const prop of expandedProps) {
393
- const flatProp = tailwindClassToFlatProp({
394
- ...parsed,
395
- entry: parsed.entry ? { ...parsed.entry, prop } : parsed.entry,
396
- })
665
+ const flatProp = tailwindClassToFlatProp(
666
+ {
667
+ ...parsed,
668
+ entry: parsed.entry ? { ...parsed.entry, prop } : parsed.entry,
669
+ },
670
+ grammarConfig
671
+ )
397
672
  if (!flatProp) return 'raw'
398
673
  const entry = createPlanEntry(flatProp.key, flatProp.value, parsed.modifiers)
399
674
  if (!entry) return 'raw'
@@ -401,7 +676,7 @@ function computeClassPlan(
401
676
  }
402
677
  return entries
403
678
  }
404
- const flatProp = tailwindClassToFlatProp(parsed)
679
+ const flatProp = tailwindClassToFlatProp(parsed, grammarConfig)
405
680
  if (flatProp) {
406
681
  const entry = createPlanEntry(flatProp.key, flatProp.value, parsed.modifiers)
407
682
  return entry ? [entry] : 'raw'
@@ -424,12 +699,85 @@ export function getTailwindClassPlan(
424
699
  return plan
425
700
  }
426
701
 
702
+ export function resolveTailwindCandidate(
703
+ candidate: string,
704
+ config: StyleFrontendConfig,
705
+ sink: FrontendClassSink
706
+ ): boolean | null {
707
+ const composed = tryCompose(candidate, getStyleGrammarConfig(config), sink)
708
+ if (composed !== undefined) return composed
709
+ const plan = getTailwindClassPlan(candidate, config)
710
+ if (plan === 'raw') return true
711
+ if (plan === null) return null
712
+ const parent = Array.isArray(plan) ? null : plan
713
+ const entries = parent ? parent.entries : plan
714
+ for (let index = 0; index < entries.length; index++) {
715
+ const entry = entries[index]
716
+ const transformTokenCategory =
717
+ entry[0] === 'x' || entry[0] === 'y'
718
+ ? 'space'
719
+ : entry[0] === 'perspective'
720
+ ? 'perspective'
721
+ : null
722
+ const transformValue =
723
+ transformTokenCategory && typeof entry[1] === 'string'
724
+ ? ((config as any).tokensParsed?.[transformTokenCategory]?.[entry[1]]?.val ??
725
+ entry[1])
726
+ : entry[1]
727
+ if (noteTailwindTransform(sink, [entry[0], transformValue, entry[2], entry[3]])) {
728
+ continue
729
+ }
730
+ // a text size carries its paired line height as a default that any leading
731
+ // utility overrides whatever the class order, as tailwind's --tw-leading does
732
+ if (entry[0] === 'lineHeight') {
733
+ sink(['__leading', entry[1], entry[2], entry[3]])
734
+ continue
735
+ }
736
+ if (
737
+ entry[0] === 'fontSize' &&
738
+ typeof entry[1] === 'string' &&
739
+ hasTokenName(getStyleGrammarConfig(config), 'lineHeight', entry[1])
740
+ ) {
741
+ sink(['__textLeading', entry[1], entry[2], entry[3]])
742
+ }
743
+ if (entry[0] === 'boxShadow' && typeof entry[1] === 'string') {
744
+ noteBoxShadow(
745
+ sink,
746
+ (config as any).tokensParsed?.boxShadow?.[entry[1]]?.val ?? entry[1],
747
+ entry[3] || []
748
+ )
749
+ }
750
+ sink(entry)
751
+ }
752
+ return parent?.preserveRawClass || false
753
+ }
754
+
427
755
  export function resolveTailwindClassName(
428
756
  className: string,
429
757
  config: StyleFrontendConfig
430
758
  ): Record<string, any> {
431
759
  const result: Record<string, any> = {}
760
+ // composed utilities collect under `__`-prefixed keys and are folded once at the
761
+ // end, the same shape and the same order the runtime walk uses
762
+ let composedProps: Record<string, any> | null = null
432
763
  let rawClassName = ''
764
+ const sink: FrontendClassSink = (entry) => {
765
+ const [key, value, condition] = entry
766
+ if (key.charCodeAt(0) === 95 && key.charCodeAt(1) === 95) {
767
+ composedProps ||= {}
768
+ composedProps[key] = mergeFrontendCondition(composedProps[key], value, condition)
769
+ return
770
+ }
771
+ if (condition === undefined) {
772
+ const resets = shorthandResets[key]
773
+ if (resets) {
774
+ for (let i = 0; i < resets.length; i++) {
775
+ delete result[resets[i]]
776
+ }
777
+ }
778
+ }
779
+ setInAuthoredOrder(result, key, mergeFrontendCondition(result[key], value, condition))
780
+ }
433
781
  let start = 0
434
782
  for (let index = 0; index <= className.length; index++) {
435
783
  if (index !== className.length && className.charCodeAt(index) > 32) continue
@@ -438,34 +786,18 @@ export function resolveTailwindClassName(
438
786
  continue
439
787
  }
440
788
  const candidate = className.slice(start, index)
441
- const plan = getTailwindClassPlan(candidate, config)
442
- if (plan === 'raw') {
789
+ const preserveRaw = resolveTailwindCandidate(candidate, config, sink)
790
+ if (preserveRaw) {
443
791
  rawClassName = rawClassName ? `${rawClassName} ${candidate}` : candidate
444
- } else if (plan) {
445
- const parentPlan = plan as TailwindParentPlan
446
- if (!Array.isArray(plan) && parentPlan.preserveRawClass) {
447
- rawClassName = rawClassName ? `${rawClassName} ${candidate}` : candidate
448
- }
449
- const entries = Array.isArray(plan) ? plan : parentPlan.entries
450
- for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
451
- const [key, value, condition] = entries[entryIndex]
452
- const previous = result[key]
453
- let next = value
454
- if (condition !== undefined) {
455
- next =
456
- previous && typeof previous === 'object' && !Array.isArray(previous)
457
- ? { ...previous, [condition]: value }
458
- : previous === undefined
459
- ? { [condition]: value }
460
- : { default: previous, [condition]: value }
461
- } else if (previous && typeof previous === 'object' && !Array.isArray(previous)) {
462
- next = { ...previous, default: value }
463
- }
464
- setInAuthoredOrder(result, key, next)
465
- }
466
792
  }
467
793
  start = index + 1
468
794
  }
795
+ if (composedProps) {
796
+ const composed = composedResolver(composedProps)
797
+ for (const key in composed) {
798
+ setInAuthoredOrder(result, key, composed[key])
799
+ }
800
+ }
469
801
  if (rawClassName) result.className = rawClassName
470
802
  return result
471
803
  }
@@ -484,6 +816,10 @@ export function setInAuthoredOrder(
484
816
  key: string,
485
817
  value: any
486
818
  ): void {
819
+ if (value === 'unset') {
820
+ delete target[key]
821
+ return
822
+ }
487
823
  if (key in target) delete target[key]
488
824
  target[key] = value
489
825
  }
package/src/frontend.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  type FrontendStaticConfig,
3
3
  type FrontendStaticConfigNormalization,
4
- type FrontendClassSink,
5
4
  type StyleFrontend,
6
5
  type StyleFrontendConfig,
7
6
  } from '@tamagui/core/internal-runtime'
8
- import { getTailwindClassPlan, resolveTailwindClassName } from './candidate'
7
+ import { resolveTailwindCandidate, resolveTailwindClassName } from './candidate'
8
+ import { composedResolver } from './composedResolver'
9
9
 
10
10
  /**
11
11
  * The Tailwind frontend descriptor.
@@ -66,21 +66,8 @@ function normalizeTailwindStaticConfig(
66
66
  return { baseStyle, passthroughClassName, variants }
67
67
  }
68
68
 
69
- function resolveTailwindCandidate(
70
- candidate: string,
71
- config: StyleFrontendConfig,
72
- sink: FrontendClassSink
73
- ): boolean | null {
74
- const plan = getTailwindClassPlan(candidate, config)
75
- if (plan === 'raw') return true
76
- if (plan === null) return null
77
- const parent = Array.isArray(plan) ? null : plan
78
- const entries = parent ? parent.entries : plan
79
- for (let index = 0; index < entries.length; index++) sink(entries[index])
80
- return parent?.preserveRawClass || false
81
- }
82
-
83
69
  export const tailwindStyleFrontend: StyleFrontend = {
84
70
  resolveClassName: resolveTailwindCandidate,
85
71
  normalizeStaticConfig: normalizeTailwindStaticConfig,
72
+ compose: composedResolver,
86
73
  }