rnwind 0.0.4 → 0.0.5

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 (127) hide show
  1. package/lib/cjs/core/normalize-classname.cjs +25 -0
  2. package/lib/cjs/core/normalize-classname.cjs.map +1 -0
  3. package/lib/cjs/core/normalize-classname.d.ts +10 -0
  4. package/lib/cjs/core/style-builder/build-style.cjs +258 -58
  5. package/lib/cjs/core/style-builder/build-style.cjs.map +1 -1
  6. package/lib/cjs/core/style-builder/build-style.d.ts +6 -1
  7. package/lib/cjs/core/style-builder/union-builder.cjs +37 -3
  8. package/lib/cjs/core/style-builder/union-builder.cjs.map +1 -1
  9. package/lib/cjs/core/style-builder/union-builder.d.ts +21 -1
  10. package/lib/cjs/metro/dts.cjs +7 -16
  11. package/lib/cjs/metro/dts.cjs.map +1 -1
  12. package/lib/cjs/metro/dts.d.ts +2 -4
  13. package/lib/cjs/metro/state.cjs +30 -78
  14. package/lib/cjs/metro/state.cjs.map +1 -1
  15. package/lib/cjs/metro/state.d.ts +8 -25
  16. package/lib/cjs/metro/transformer.cjs +193 -34
  17. package/lib/cjs/metro/transformer.cjs.map +1 -1
  18. package/lib/cjs/metro/with-config.cjs +2 -2
  19. package/lib/cjs/metro/with-config.cjs.map +1 -1
  20. package/lib/cjs/metro/with-config.d.ts +11 -26
  21. package/lib/cjs/metro/wrap-imports.cjs +273 -0
  22. package/lib/cjs/metro/wrap-imports.cjs.map +1 -0
  23. package/lib/cjs/metro/wrap-imports.d.ts +26 -0
  24. package/lib/cjs/runtime/components/rnwind-provider.cjs +0 -17
  25. package/lib/cjs/runtime/components/rnwind-provider.cjs.map +1 -1
  26. package/lib/cjs/runtime/components/rnwind-provider.d.ts +0 -14
  27. package/lib/cjs/runtime/hooks/use-css.cjs +16 -10
  28. package/lib/cjs/runtime/hooks/use-css.cjs.map +1 -1
  29. package/lib/cjs/runtime/hooks/use-css.d.ts +15 -9
  30. package/lib/cjs/runtime/index.cjs +11 -13
  31. package/lib/cjs/runtime/index.cjs.map +1 -1
  32. package/lib/cjs/runtime/index.d.ts +4 -9
  33. package/lib/cjs/runtime/lookup-css.cjs +10 -0
  34. package/lib/cjs/runtime/lookup-css.cjs.map +1 -1
  35. package/lib/cjs/runtime/lookup-css.d.ts +7 -0
  36. package/lib/cjs/runtime/resolve.cjs +348 -0
  37. package/lib/cjs/runtime/resolve.cjs.map +1 -0
  38. package/lib/cjs/runtime/resolve.d.ts +61 -0
  39. package/lib/cjs/runtime/wrap.cjs +254 -0
  40. package/lib/cjs/runtime/wrap.cjs.map +1 -0
  41. package/lib/cjs/runtime/wrap.d.ts +37 -0
  42. package/lib/cjs/testing/index.cjs +81 -50
  43. package/lib/cjs/testing/index.cjs.map +1 -1
  44. package/lib/esm/core/normalize-classname.d.ts +10 -0
  45. package/lib/esm/core/normalize-classname.mjs +23 -0
  46. package/lib/esm/core/normalize-classname.mjs.map +1 -0
  47. package/lib/esm/core/style-builder/build-style.d.ts +6 -1
  48. package/lib/esm/core/style-builder/build-style.mjs +258 -58
  49. package/lib/esm/core/style-builder/build-style.mjs.map +1 -1
  50. package/lib/esm/core/style-builder/union-builder.d.ts +21 -1
  51. package/lib/esm/core/style-builder/union-builder.mjs +37 -3
  52. package/lib/esm/core/style-builder/union-builder.mjs.map +1 -1
  53. package/lib/esm/metro/dts.d.ts +2 -4
  54. package/lib/esm/metro/dts.mjs +7 -16
  55. package/lib/esm/metro/dts.mjs.map +1 -1
  56. package/lib/esm/metro/state.d.ts +8 -25
  57. package/lib/esm/metro/state.mjs +30 -76
  58. package/lib/esm/metro/state.mjs.map +1 -1
  59. package/lib/esm/metro/transformer.mjs +194 -35
  60. package/lib/esm/metro/transformer.mjs.map +1 -1
  61. package/lib/esm/metro/with-config.d.ts +11 -26
  62. package/lib/esm/metro/with-config.mjs +2 -2
  63. package/lib/esm/metro/with-config.mjs.map +1 -1
  64. package/lib/esm/metro/wrap-imports.d.ts +26 -0
  65. package/lib/esm/metro/wrap-imports.mjs +250 -0
  66. package/lib/esm/metro/wrap-imports.mjs.map +1 -0
  67. package/lib/esm/runtime/components/rnwind-provider.d.ts +0 -14
  68. package/lib/esm/runtime/components/rnwind-provider.mjs +1 -17
  69. package/lib/esm/runtime/components/rnwind-provider.mjs.map +1 -1
  70. package/lib/esm/runtime/hooks/use-css.d.ts +15 -9
  71. package/lib/esm/runtime/hooks/use-css.mjs +16 -10
  72. package/lib/esm/runtime/hooks/use-css.mjs.map +1 -1
  73. package/lib/esm/runtime/index.d.ts +4 -9
  74. package/lib/esm/runtime/index.mjs +4 -4
  75. package/lib/esm/runtime/index.mjs.map +1 -1
  76. package/lib/esm/runtime/lookup-css.d.ts +7 -0
  77. package/lib/esm/runtime/lookup-css.mjs +10 -1
  78. package/lib/esm/runtime/lookup-css.mjs.map +1 -1
  79. package/lib/esm/runtime/resolve.d.ts +61 -0
  80. package/lib/esm/runtime/resolve.mjs +341 -0
  81. package/lib/esm/runtime/resolve.mjs.map +1 -0
  82. package/lib/esm/runtime/wrap.d.ts +37 -0
  83. package/lib/esm/runtime/wrap.mjs +251 -0
  84. package/lib/esm/runtime/wrap.mjs.map +1 -0
  85. package/lib/esm/testing/index.mjs +84 -53
  86. package/lib/esm/testing/index.mjs.map +1 -1
  87. package/package.json +2 -1
  88. package/src/core/normalize-classname.ts +19 -0
  89. package/src/core/style-builder/build-style.ts +286 -55
  90. package/src/core/style-builder/union-builder.ts +36 -3
  91. package/src/metro/dts.ts +7 -19
  92. package/src/metro/state.ts +29 -74
  93. package/src/metro/transformer.ts +190 -34
  94. package/src/metro/with-config.ts +13 -28
  95. package/src/metro/wrap-imports.ts +260 -0
  96. package/src/runtime/components/rnwind-provider.tsx +0 -17
  97. package/src/runtime/hooks/use-css.ts +17 -11
  98. package/src/runtime/index.ts +3 -26
  99. package/src/runtime/lookup-css.ts +10 -0
  100. package/src/runtime/resolve.ts +381 -0
  101. package/src/runtime/wrap.tsx +267 -0
  102. package/src/testing/index.ts +106 -56
  103. package/lib/cjs/core/parser/text-truncate.cjs +0 -78
  104. package/lib/cjs/core/parser/text-truncate.cjs.map +0 -1
  105. package/lib/cjs/metro/transform-ast.cjs +0 -1472
  106. package/lib/cjs/metro/transform-ast.cjs.map +0 -1
  107. package/lib/cjs/metro/transform-ast.d.ts +0 -88
  108. package/lib/cjs/runtime/haptics.cjs +0 -113
  109. package/lib/cjs/runtime/haptics.cjs.map +0 -1
  110. package/lib/cjs/runtime/haptics.d.ts +0 -48
  111. package/lib/cjs/runtime/interactive-box.cjs +0 -35
  112. package/lib/cjs/runtime/interactive-box.cjs.map +0 -1
  113. package/lib/cjs/runtime/interactive-box.d.ts +0 -40
  114. package/lib/esm/core/parser/text-truncate.mjs +0 -75
  115. package/lib/esm/core/parser/text-truncate.mjs.map +0 -1
  116. package/lib/esm/metro/transform-ast.d.ts +0 -88
  117. package/lib/esm/metro/transform-ast.mjs +0 -1451
  118. package/lib/esm/metro/transform-ast.mjs.map +0 -1
  119. package/lib/esm/runtime/haptics.d.ts +0 -48
  120. package/lib/esm/runtime/haptics.mjs +0 -110
  121. package/lib/esm/runtime/haptics.mjs.map +0 -1
  122. package/lib/esm/runtime/interactive-box.d.ts +0 -40
  123. package/lib/esm/runtime/interactive-box.mjs +0 -33
  124. package/lib/esm/runtime/interactive-box.mjs.map +0 -1
  125. package/src/metro/transform-ast.ts +0 -1729
  126. package/src/runtime/haptics.ts +0 -120
  127. package/src/runtime/interactive-box.tsx +0 -57
@@ -0,0 +1,381 @@
1
+ import { getStyleVersion, lookupCss, type InteractState } from './lookup-css'
2
+ import type { RnwindState } from './components/rnwind-provider'
3
+ import { normalizeClassName } from '../core/normalize-classname'
4
+ import type { GradientAtomInfo, GradientDirection } from '../core/parser/gradient'
5
+ import type { HapticRequest, HapticTrigger } from '../core/parser/haptics'
6
+
7
+ /**
8
+ * Rich className resolver — the runtime heart of the wrap / `useCss`.
9
+ *
10
+ * Resolution order, per className string:
11
+ * 1. **Molecule** — a build-time PRE-MERGED single style object for the
12
+ * whole literal className (per scheme). One map lookup returns it by
13
+ * reference: no array, no merge, no per-atom loop. The common case.
14
+ * 2. **Atom fallback** — for a className the scanner never saw (a
15
+ * runtime-built string like `` `${className} px-2` ``) OR one that
16
+ * carries context-dependent atoms (`pt-safe`, `text-base`, `md:*`),
17
+ * fall back to per-atom resolution via `lookupCss`, which folds in
18
+ * insets / fontScale / breakpoint / scheme.
19
+ *
20
+ * Results are cached by `(normalized className, scheme, insets, fontScale,
21
+ * breakpoint)` so repeated renders return the SAME reference until the
22
+ * reactive context changes. Atoms / molecules / features all live in
23
+ * build-time registries the generated `.rnwind/*.js` modules populate.
24
+ */
25
+
26
+ /** Always-loaded fallback scheme key. */
27
+ const COMMON_SCHEME = 'common'
28
+
29
+ /** Empty style sentinel. */
30
+ const EMPTY: readonly unknown[] = []
31
+
32
+ /** scheme → normalized className → pre-merged style object. */
33
+ let molecules: Record<string, Record<string, unknown>> = Object.create(null)
34
+ /** atom name → gradient role + resolved colour. */
35
+ let gradients: Record<string, GradientAtomInfo> = Object.create(null)
36
+ /** atom name (incl. `active:`/`focus:` prefix) → haptic request. */
37
+ let haptics: Record<string, HapticRequest> = Object.create(null)
38
+ /** Bumps on any molecule/gradient/haptic registration. */
39
+ let registryVersion = 0
40
+
41
+ /** Per-(className·state) resolved cache — strong references between context changes. */
42
+ const resolvedCache = new Map<string, ResolvedCss>()
43
+ /** Version the cache was last valid for (`getStyleVersion()` + {@link registryVersion}). */
44
+ let cachedFor = -1
45
+
46
+ /** A unit-square gradient endpoint. */
47
+ interface GradientPoint {
48
+ readonly x: number
49
+ readonly y: number
50
+ }
51
+
52
+ /** Rich resolution: the RN `style` plus any className-derived props. */
53
+ export interface ResolvedCss {
54
+ /** RN `style` value — a single molecule object (by ref) or an atom array. */
55
+ readonly style: unknown
56
+ /** Gradient stop colours (when the className is a complete gradient). */
57
+ readonly colors?: readonly string[]
58
+ /** Gradient start point. */
59
+ readonly start?: GradientPoint
60
+ /** Gradient end point. */
61
+ readonly end?: GradientPoint
62
+ /** Text truncation line count. */
63
+ readonly numberOfLines?: number
64
+ /** Text ellipsize mode. */
65
+ readonly ellipsizeMode?: 'tail' | 'clip'
66
+ /** Haptic requests present on the className, for the wrap to dispatch. */
67
+ readonly haptics?: readonly { readonly request: HapticRequest; readonly trigger: HapticTrigger }[]
68
+ }
69
+
70
+ /** `GradientDirection` → expo-linear-gradient start/end points. */
71
+ const DIRECTION_POINTS: Record<GradientDirection, { start: GradientPoint; end: GradientPoint }> = {
72
+ 'to-t': { start: { x: 0.5, y: 1 }, end: { x: 0.5, y: 0 } },
73
+ 'to-b': { start: { x: 0.5, y: 0 }, end: { x: 0.5, y: 1 } },
74
+ 'to-l': { start: { x: 1, y: 0.5 }, end: { x: 0, y: 0.5 } },
75
+ 'to-r': { start: { x: 0, y: 0.5 }, end: { x: 1, y: 0.5 } },
76
+ 'to-tl': { start: { x: 1, y: 1 }, end: { x: 0, y: 0 } },
77
+ 'to-tr': { start: { x: 0, y: 1 }, end: { x: 1, y: 0 } },
78
+ 'to-bl': { start: { x: 1, y: 0 }, end: { x: 0, y: 1 } },
79
+ 'to-br': { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } },
80
+ unknown: { start: { x: 0, y: 0.5 }, end: { x: 1, y: 0.5 } },
81
+ }
82
+
83
+ /**
84
+ * Register one scheme's pre-merged molecules (atom-merged literal
85
+ * classNames). Merges onto any existing entries for the scheme.
86
+ * @param scheme Scheme name (or `'common'`).
87
+ * @param entries Normalized className → merged style object.
88
+ */
89
+ export function registerMolecules(scheme: string, entries: Record<string, unknown>): void {
90
+ molecules[scheme] = { ...molecules[scheme], ...entries }
91
+ registryVersion += 1
92
+ }
93
+
94
+ /**
95
+ * Register the gradient atom map (atom name → role + resolved colour).
96
+ * @param map Atom name → gradient info.
97
+ */
98
+ export function registerGradients(map: Record<string, GradientAtomInfo>): void {
99
+ gradients = map
100
+ registryVersion += 1
101
+ }
102
+
103
+ /**
104
+ * Register the haptic atom map (atom name → request).
105
+ * @param map Atom name → haptic request.
106
+ */
107
+ export function registerHaptics(map: Record<string, HapticRequest>): void {
108
+ haptics = map
109
+ registryVersion += 1
110
+ }
111
+
112
+
113
+
114
+ /**
115
+ * Per-state-object signature memo. `RnwindState` is created fresh (via the
116
+ * provider's `useMemo`) whenever any field changes, so its identity is a
117
+ * sound key — a new object means a new signature. Keyed weakly so states
118
+ * GC with their provider.
119
+ */
120
+ const stateSignatureCache = new WeakMap<RnwindState, string>()
121
+
122
+ /**
123
+ * Cache key dimension for the reactive context — everything that can
124
+ * change a resolved style.
125
+ * @param state Rnwind context.
126
+ * @returns Compact signature string.
127
+ */
128
+ function stateSignature(state: RnwindState): string {
129
+ const { insets } = state
130
+ return `${state.scheme}|${insets.top},${insets.right},${insets.bottom},${insets.left}|${state.fontScale}|${state.windowWidth}`
131
+ }
132
+
133
+ /**
134
+ * Memoised {@link stateSignature} — one `WeakMap.get` on the hot path
135
+ * instead of rebuilding the template string every resolve.
136
+ * @param state Rnwind context.
137
+ * @returns Cached compact signature.
138
+ */
139
+ function stateSignatureCached(state: RnwindState): string {
140
+ let signature = stateSignatureCache.get(state)
141
+ if (signature === undefined) {
142
+ signature = stateSignature(state)
143
+ stateSignatureCache.set(state, signature)
144
+ }
145
+ return signature
146
+ }
147
+
148
+ /**
149
+ * Compact signature of the live interactive state for the cache key.
150
+ * @param interactState Active/focus flags, or undefined for the plain path.
151
+ * @returns Two-bit signature (`''` when no interactive state).
152
+ */
153
+ function interactSignature(interactState?: InteractState): string {
154
+ if (!interactState) return ''
155
+ const active = interactState.active ? 1 : 0
156
+ const focus = interactState.focus ? 1 : 0
157
+ return `${active}${focus}`
158
+ }
159
+
160
+ /**
161
+ * Whether a token is a feature-ONLY utility (gradient stop/direction,
162
+ * haptic request, or text-truncate) that contributes NO RN `style`. These
163
+ * are folded in via {@link attachFeatures}, so they must be kept OUT of
164
+ * the `lookupCss` input — otherwise the atom resolver treats them as
165
+ * unknown style atoms and emits a spurious "unknown class" dev warning
166
+ * (e.g. for `active:haptic-rigid`).
167
+ * @param token Atom name.
168
+ * @returns True when the token carries no style.
169
+ */
170
+ function isFeatureOnlyToken(token: string): boolean {
171
+ return Boolean(gradients[token]) || Boolean(haptics[token]) || truncateForToken(token) !== null
172
+ }
173
+
174
+ /**
175
+ * Lifecycle trigger for a haptic atom from its variant prefix.
176
+ * @param token Atom name (maybe `active:`/`focus:`/`hover:` prefixed).
177
+ * @returns The trigger.
178
+ */
179
+ function hapticTriggerForToken(token: string): HapticTrigger {
180
+ const colon = token.indexOf(':')
181
+ if (colon === -1) return 'mount'
182
+ const prefix = token.slice(0, colon)
183
+ if (prefix === 'active') return 'pressIn'
184
+ if (prefix === 'focus') return 'focus'
185
+ if (prefix === 'hover') return 'hover'
186
+ return 'mount'
187
+ }
188
+
189
+ /**
190
+ * Syntactic text-truncate directive for one atom.
191
+ * @param token Atom name.
192
+ * @returns Partial truncate props, or null.
193
+ */
194
+ function truncateForToken(token: string): { numberOfLines?: number; ellipsizeMode?: 'tail' | 'clip' } | null {
195
+ if (token === 'truncate') return { numberOfLines: 1, ellipsizeMode: 'tail' }
196
+ if (token === 'text-ellipsis') return { ellipsizeMode: 'tail' }
197
+ if (token === 'text-clip') return { ellipsizeMode: 'clip' }
198
+ if (token === 'line-clamp-none') return { numberOfLines: 0 }
199
+ if (token.startsWith('line-clamp-')) {
200
+ const count = Number(token.slice('line-clamp-'.length))
201
+ if (Number.isInteger(count) && count >= 0) return { numberOfLines: count }
202
+ }
203
+ return null
204
+ }
205
+
206
+ /**
207
+ * Assemble gradient props from gradient roles present in the atom list.
208
+ * @param tokens Atom names.
209
+ * @returns `{colors, start, end}` or null when not a complete gradient.
210
+ */
211
+ function assembleGradient(tokens: readonly string[]): { colors: string[]; start: GradientPoint; end: GradientPoint } | null {
212
+ let from: string | undefined
213
+ let via: string | undefined
214
+ let to: string | undefined
215
+ let dir: GradientDirection | undefined
216
+ for (const token of tokens) {
217
+ const info = gradients[token]
218
+ if (!info) continue
219
+ switch (info.role) {
220
+ case 'from': {
221
+ from = info.color
222
+ break
223
+ }
224
+ case 'via': {
225
+ via = info.color
226
+ break
227
+ }
228
+ case 'to': {
229
+ to = info.color
230
+ break
231
+ }
232
+ default: {
233
+ ;({ dir } = info)
234
+ }
235
+ }
236
+ }
237
+ if (dir === undefined) return null
238
+ const colors = [from, via, to].filter((color): color is string => color !== undefined)
239
+ if (colors.length < 2) return null
240
+ const points = DIRECTION_POINTS[dir]
241
+ return { colors, start: points.start, end: points.end }
242
+ }
243
+
244
+ /**
245
+ * Fold every truncate directive across the atom list into one result —
246
+ * last token wins per prop (matches Tailwind last-wins).
247
+ * @param tokens Atom names.
248
+ * @returns Merged truncate props (empty when none apply).
249
+ */
250
+ function collectTruncate(tokens: readonly string[]): { numberOfLines?: number; ellipsizeMode?: 'tail' | 'clip' } {
251
+ const out: { numberOfLines?: number; ellipsizeMode?: 'tail' | 'clip' } = {}
252
+ for (const token of tokens) {
253
+ const truncate = truncateForToken(token)
254
+ if (!truncate) continue
255
+ if (truncate.numberOfLines !== undefined) out.numberOfLines = truncate.numberOfLines
256
+ if (truncate.ellipsizeMode !== undefined) out.ellipsizeMode = truncate.ellipsizeMode
257
+ }
258
+ return out
259
+ }
260
+
261
+ /**
262
+ * Collect every haptic request present in the atom list, tagged with the
263
+ * lifecycle trigger its variant prefix implies.
264
+ * @param tokens Atom names.
265
+ * @returns Haptic request list, or undefined when none apply.
266
+ */
267
+ function collectHaptics(tokens: readonly string[]): { request: HapticRequest; trigger: HapticTrigger }[] | undefined {
268
+ let collected: { request: HapticRequest; trigger: HapticTrigger }[] | undefined
269
+ for (const token of tokens) {
270
+ const request = haptics[token]
271
+ if (!request) continue
272
+ collected ??= []
273
+ collected.push({ request, trigger: hapticTriggerForToken(token) })
274
+ }
275
+ return collected
276
+ }
277
+
278
+ /**
279
+ * Scan tokens for the className-derived feature props (gradient,
280
+ * truncate, haptics) and fold them onto the base result.
281
+ * @param base Result carrying the resolved `style`.
282
+ * @param tokens Atom names.
283
+ * @returns The result with any feature props attached.
284
+ */
285
+ function attachFeatures(base: ResolvedCss, tokens: readonly string[]): ResolvedCss {
286
+ const { numberOfLines, ellipsizeMode } = collectTruncate(tokens)
287
+ const collected = collectHaptics(tokens)
288
+ const gradient = assembleGradient(tokens)
289
+ const result: Mutable<ResolvedCss> = { style: base.style }
290
+ if (gradient) {
291
+ result.colors = gradient.colors
292
+ result.start = gradient.start
293
+ result.end = gradient.end
294
+ }
295
+ // `numberOfLines: 0` is kept (RN reads it as "unlimited"): `line-clamp-none`
296
+ // must be able to explicitly reset an earlier `line-clamp-N` on the same
297
+ // element — dropping the 0 would silently leave the prior limit in place.
298
+ if (numberOfLines !== undefined) {
299
+ result.numberOfLines = numberOfLines
300
+ if (ellipsizeMode !== undefined) result.ellipsizeMode = ellipsizeMode
301
+ }
302
+ if (collected) result.haptics = collected
303
+ return result
304
+ }
305
+
306
+ /**
307
+ * Compose a resolved style with a caller-supplied inline style (user wins).
308
+ * @param style
309
+ * @param userStyle
310
+ */
311
+ function withUserStyle(style: unknown, userStyle: unknown): unknown {
312
+ return Array.isArray(style) ? [...style, userStyle] : [style, userStyle]
313
+ }
314
+
315
+ /**
316
+ * Resolve a className against the reactive context into a style plus any
317
+ * className-derived props. Molecule-first (one lookup, by reference),
318
+ * atom-fallback for unseen / context-dependent strings, cached per
319
+ * `(className, state)`.
320
+ * @param className Raw className string.
321
+ * @param state Rnwind context from `useRnwind()`.
322
+ * @param userStyle Optional inline style appended last (wins).
323
+ * @param interactState Live active/focus flags (for `active:`/`focus:` atoms).
324
+ * @returns The resolved style + feature props.
325
+ */
326
+ export function resolve(
327
+ className: string | null | undefined,
328
+ state: RnwindState,
329
+ userStyle?: unknown,
330
+ interactState?: InteractState,
331
+ ): ResolvedCss {
332
+ const version = getStyleVersion() + registryVersion
333
+ if (version !== cachedFor) {
334
+ resolvedCache.clear()
335
+ cachedFor = version
336
+ }
337
+ if (className == null) {
338
+ return { style: userStyle === undefined || userStyle === null ? EMPTY : [userStyle] }
339
+ }
340
+ // Key on the RAW className so the hot (cache-hit) path skips normalize
341
+ // entirely — normalization only runs on a miss. The state signature is
342
+ // memoised per state object, so the hit path is one WeakMap.get + one
343
+ // string concat + one Map.get.
344
+ const key = `${className}@${stateSignatureCached(state)}@${interactSignature(interactState)}`
345
+ const cached = resolvedCache.get(key)
346
+ if (cached !== undefined) {
347
+ return userStyle === undefined || userStyle === null ? cached : { ...cached, style: withUserStyle(cached.style, userStyle) }
348
+ }
349
+ const normalized = normalizeClassName(className)
350
+ if (normalized.length === 0) {
351
+ const empty: ResolvedCss = { style: EMPTY }
352
+ resolvedCache.set(key, empty)
353
+ return userStyle === undefined || userStyle === null ? empty : { style: [userStyle] }
354
+ }
355
+ // Molecules are static pre-merges; anything carrying `active:`/`focus:`
356
+ // is never registered as one, so the atom path handles interactive state.
357
+ const tokens = normalized.split(' ')
358
+ const molecule = interactState ? undefined : molecules[state.scheme]?.[normalized] ?? molecules[COMMON_SCHEME]?.[normalized]
359
+ // Feature-only tokens (gradient / haptic / truncate) carry no style — keep
360
+ // them out of the atom lookup so they don't warn as "unknown class".
361
+ const style =
362
+ molecule === undefined ? lookupCss(tokens.filter((token) => !isFeatureOnlyToken(token)).join(' '), state, undefined, interactState) : molecule
363
+ const base = attachFeatures({ style }, tokens)
364
+ resolvedCache.set(key, base)
365
+ return userStyle === undefined || userStyle === null ? base : { ...base, style: withUserStyle(base.style, userStyle) }
366
+ }
367
+
368
+ /** Local mutable view for building the frozen-shaped result. */
369
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] }
370
+
371
+ /** Test-only — clear the molecule / gradient / haptic registries + cache. */
372
+ export function __resetResolveState(): void {
373
+ molecules = Object.create(null)
374
+ gradients = Object.create(null)
375
+ haptics = Object.create(null)
376
+ resolvedCache.clear()
377
+ registryVersion += 1
378
+ cachedFor = -1
379
+ }
380
+
381
+ export {normalizeClassName} from '../core/normalize-classname'
@@ -0,0 +1,267 @@
1
+ import { createElement, useEffect, useRef, type ComponentType, type ReactElement } from 'react'
2
+ import { chainFocus, chainPress } from './chain-handlers'
3
+ import { useInteract } from './hooks/use-interact'
4
+ import { useRnwind } from './components/rnwind-provider'
5
+ import type { RnwindState } from './components/rnwind-provider'
6
+ import { resolve, type ResolvedCss } from './resolve'
7
+ import type { OnHaptics } from '../core/parser/haptics'
8
+
9
+ /** Matches a leading `active:` / `focus:` variant token (`\b` excludes `inactive:`). */
10
+ const INTERACTIVE_VARIANT = /\b(?:active|focus):/
11
+
12
+ /** One-shot guard so the missing-`onHaptics` warning logs once per session. */
13
+ let warnedMissingOnHaptics = false
14
+
15
+ /**
16
+ * Dev-only warning when a className carries a haptic utility but no
17
+ * `onHaptics` dispatcher is wired on the nearest `<RnwindProvider>` — the
18
+ * haptic would silently drop otherwise. Fires once per session.
19
+ * @param onHaptics The dispatcher from context (or undefined).
20
+ * @param haptics The resolved haptic requests (or undefined).
21
+ */
22
+ function warnIfHapticsUnwired(onHaptics: OnHaptics | undefined, haptics: ResolvedCss['haptics']): void {
23
+ if (onHaptics || !haptics || haptics.length === 0) return
24
+ const isDevelopment = typeof __DEV__ === 'undefined' || __DEV__
25
+ if (!isDevelopment || warnedMissingOnHaptics) return
26
+ warnedMissingOnHaptics = true
27
+ // eslint-disable-next-line no-console
28
+ console.warn(
29
+ 'rnwind: a `haptic-*` utility resolved but no `onHaptics` callback is wired on <RnwindProvider>. ' +
30
+ 'Pass `onHaptics` on the provider to forward the request to expo-haptics (or any library).',
31
+ )
32
+ }
33
+
34
+ /**
35
+ * Whether a className needs press/focus state tracking.
36
+ * @param className Raw className string.
37
+ * @returns True when an `active:` / `focus:` variant is present.
38
+ */
39
+ function hasInteractiveVariant(className: string): boolean {
40
+ return INTERACTIVE_VARIANT.test(className)
41
+ }
42
+
43
+ /**
44
+ * Best-effort display name for the wrapped component.
45
+ * @param component Component being wrapped.
46
+ * @returns Its `displayName`, `name`, or `'Component'`.
47
+ */
48
+ function displayNameOf(component: unknown): string {
49
+ const named = component as { displayName?: string; name?: string }
50
+ return named.displayName ?? named.name ?? 'Component'
51
+ }
52
+
53
+ /**
54
+ * Fire the `mount`-trigger haptics once, after the element mounts. Snaps
55
+ * the resolved requests + dispatcher at mount via a `useRef` initializer
56
+ * (evaluated only on the first render), so an unstable inline `onHaptics`
57
+ * doesn't re-fire them and no ref is written during render.
58
+ * @param resolved The resolved className (carries any haptic requests).
59
+ * @param onHaptics The dispatcher from context (or undefined).
60
+ */
61
+ function useMountHaptics(resolved: ResolvedCss, onHaptics: OnHaptics | undefined): void {
62
+ const mount = useRef({ resolved, onHaptics })
63
+ useEffect(() => {
64
+ const { resolved: current, onHaptics: dispatch } = mount.current
65
+ if (!dispatch || !current.haptics) return
66
+ for (const entry of current.haptics) if (entry.trigger === 'mount') dispatch(entry.request, 'mount')
67
+ }, [])
68
+ }
69
+
70
+ /** Suffix marking a secondary class-prop (`contentContainerClassName`, …). */
71
+ const CLASSNAME_SUFFIX = 'ClassName'
72
+
73
+ /**
74
+ * Resolve every secondary `<prefix>ClassName` prop (e.g.
75
+ * `contentContainerClassName` on a ScrollView / FlatList) into its
76
+ * matching `<prefix>Style`, in place. Any existing `<prefix>Style` is
77
+ * appended last (caller wins). The original `*ClassName` prop is deleted
78
+ * so RN never sees an unknown attribute. The primary `className` is
79
+ * handled separately by the leaf and never reaches here.
80
+ * @param props Mutable prop object being assembled for the host.
81
+ * @param state Rnwind context for resolution.
82
+ */
83
+ function applyContainerClassNames(props: Record<string, unknown>, state: RnwindState): void {
84
+ for (const key of Object.keys(props)) {
85
+ if (!key.endsWith(CLASSNAME_SUFFIX)) continue
86
+ const value = props[key]
87
+ if (typeof value !== 'string') continue
88
+ const styleKey = `${key.slice(0, -CLASSNAME_SUFFIX.length)}Style`
89
+ props[styleKey] = resolve(value, state, props[styleKey]).style
90
+ delete props[key]
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Build the props for the wrapped host: resolved `style`, gradient
96
+ * (`colors`/`start`/`end`), truncate (`numberOfLines`/`ellipsizeMode`),
97
+ * secondary `<prefix>ClassName` → `<prefix>Style`, and a chained
98
+ * `onPressIn` that fires press-trigger haptics. Unknown props on a host
99
+ * are simply ignored by RN, so this stays generic.
100
+ * @param rest Forwarded props (incl. `ref`).
101
+ * @param resolved Resolved className result.
102
+ * @param state Rnwind context — used to resolve secondary class props.
103
+ * @param onHaptics Dispatcher from context.
104
+ * @param userOnPressIn Caller-supplied onPressIn to chain after the haptic.
105
+ * @returns The merged prop object for `createElement`.
106
+ */
107
+ function buildProps(
108
+ rest: Record<string, unknown>,
109
+ resolved: ResolvedCss,
110
+ state: RnwindState,
111
+ onHaptics: OnHaptics | undefined,
112
+ userOnPressIn?: unknown,
113
+ ): Record<string, unknown> {
114
+ warnIfHapticsUnwired(onHaptics, resolved.haptics)
115
+ const props: Record<string, unknown> = { ...rest, style: resolved.style }
116
+ applyContainerClassNames(props, state)
117
+ if (resolved.colors) {
118
+ props.colors = resolved.colors
119
+ props.start = resolved.start
120
+ props.end = resolved.end
121
+ }
122
+ if (resolved.numberOfLines !== undefined) {
123
+ props.numberOfLines = resolved.numberOfLines
124
+ if (resolved.ellipsizeMode !== undefined) props.ellipsizeMode = resolved.ellipsizeMode
125
+ }
126
+ const pressHaptics = onHaptics && resolved.haptics?.filter((entry) => entry.trigger === 'pressIn')
127
+ if (pressHaptics && pressHaptics.length > 0) {
128
+ const previous = userOnPressIn as ((event: unknown) => void) | undefined
129
+ props.onPressIn = (event: unknown): void => {
130
+ for (const entry of pressHaptics) onHaptics(entry.request, 'pressIn')
131
+ previous?.(event)
132
+ }
133
+ } else if (userOnPressIn !== undefined) {
134
+ props.onPressIn = userOnPressIn
135
+ }
136
+ return props
137
+ }
138
+
139
+ /** Props a leaf receives — the wrapped `as` tag plus forwarded props. */
140
+ interface LeafProps {
141
+ readonly as: ComponentType<Record<string, unknown>>
142
+ readonly className?: string
143
+ readonly style?: unknown
144
+ readonly [key: string]: unknown
145
+ }
146
+
147
+ /**
148
+ * Non-interactive leaf: resolve className → style (+ features) and
149
+ * forward. One context read, one molecule/atom resolve.
150
+ * @param props Leaf props.
151
+ * @param props.as
152
+ * @param props.className
153
+ * @param props.style
154
+ * @param props.onPressIn
155
+ * @returns The rendered `as` element.
156
+ */
157
+ function PlainLeaf({ as: As, className, style, onPressIn, ...rest }: LeafProps): ReactElement {
158
+ const state = useRnwind()
159
+ const resolved = resolve(className, state, style)
160
+ useMountHaptics(resolved, state.onHaptics)
161
+ return createElement(As, buildProps(rest, resolved, state, state.onHaptics, onPressIn))
162
+ }
163
+
164
+ /**
165
+ * Interactive leaf: tracks press/focus via `useInteract()`, feeds it into
166
+ * `resolve` so `active:`/`focus:` atoms apply, and chains the
167
+ * press/focus handlers.
168
+ * @param props Leaf props.
169
+ * @param props.as
170
+ * @param props.className
171
+ * @param props.style
172
+ * @param props.onPressIn
173
+ * @param props.onPressOut
174
+ * @param props.onFocus
175
+ * @param props.onBlur
176
+ * @returns The rendered `as` element with interactive wiring.
177
+ */
178
+ function InteractiveLeaf({ as: As, className, style, onPressIn, onPressOut, onFocus, onBlur, ...rest }: LeafProps): ReactElement {
179
+ const state = useRnwind()
180
+ const interact = useInteract()
181
+ const resolved = resolve(className, state, style, interact.state)
182
+ useMountHaptics(resolved, state.onHaptics)
183
+ const props = buildProps(rest, resolved, state, state.onHaptics, onPressIn)
184
+ props.onPressIn = chainPress(props.onPressIn as Parameters<typeof chainPress>[0], interact.onPressIn)
185
+ props.onPressOut = chainPress(onPressOut as Parameters<typeof chainPress>[0], interact.onPressOut)
186
+ props.onFocus = chainFocus(onFocus as Parameters<typeof chainFocus>[0], interact.onFocus)
187
+ props.onBlur = chainFocus(onBlur as Parameters<typeof chainFocus>[0], interact.onBlur)
188
+ return createElement(As, props)
189
+ }
190
+
191
+ /**
192
+ * Wrap a component so its `className` prop resolves to RN `style` (plus
193
+ * gradient / truncate props and haptic dispatch) at render — no matter
194
+ * how className arrived: written directly, spread through `{...rest}`, or
195
+ * forwarded down custom wrappers. The returned component is hook-free; it
196
+ * dispatches to a plain or interactive leaf so non-interactive elements
197
+ * never pay for press/focus state. `ref` (a normal prop in React 19) and
198
+ * all other props forward untouched.
199
+ * @example
200
+ * ```tsx
201
+ * const Pressable = wrap(RNPressable)
202
+ * <Pressable className="active:bg-sky-700 px-4 haptic-light" onPress={fn} />
203
+ * ```
204
+ * @param Component Any component accepting a `style` prop.
205
+ * @returns A component accepting `className`.
206
+ */
207
+ export function wrap<P>(Component: ComponentType<P>): ComponentType<P & { className?: string }> {
208
+ const as = Component as unknown as ComponentType<Record<string, unknown>>
209
+ /**
210
+ * The wrapped component — hook-free dispatcher to a leaf.
211
+ * @param props Forwarded props with `className` intercepted.
212
+ * @param props.className
213
+ * @returns The rendered leaf.
214
+ */
215
+ function RnwindWrapped({ className, ...rest }: { className?: string; [key: string]: unknown }): ReactElement {
216
+ if (className !== undefined && hasInteractiveVariant(className)) {
217
+ return createElement(InteractiveLeaf, { as, className, ...rest })
218
+ }
219
+ return createElement(PlainLeaf, { as, className, ...rest })
220
+ }
221
+ RnwindWrapped.displayName = `wrap(${displayNameOf(Component)})`
222
+ return RnwindWrapped as unknown as ComponentType<P & { className?: string }>
223
+ }
224
+
225
+ /**
226
+ * Whether a namespace member name denotes a component to wrap —
227
+ * PascalCase and not a React context (`*Context`). Lowercase utilities /
228
+ * hooks (`createAnimatedComponent`, `spring`) pass through untouched.
229
+ * @param name Member key.
230
+ * @returns True when the member should be `wrap()`-ed.
231
+ */
232
+ function isComponentMember(name: string): boolean {
233
+ return /^[A-Z]/.test(name) && !name.endsWith('Context')
234
+ }
235
+
236
+ /**
237
+ * Wrap a component NAMESPACE (a default/namespace import like reanimated's
238
+ * `Animated`) so member access — `Animated.View`, `Animated.ScrollView` —
239
+ * returns a `wrap()`-ed component whose `className` resolves at render.
240
+ * Returns a Proxy: component members are wrapped lazily and memoised so
241
+ * each access yields the SAME wrapped component (stable identity — React
242
+ * would remount otherwise). Non-component members (`createAnimatedComponent`,
243
+ * config objects) pass straight through.
244
+ * @example
245
+ * ```tsx
246
+ * const Animated = wrapNamespace(RNReanimated)
247
+ * <Animated.View className="enter-fade" />
248
+ * ```
249
+ * @param namespace The imported namespace object.
250
+ * @returns A Proxy that wraps component members on access.
251
+ */
252
+ export function wrapNamespace<T extends object>(namespace: T): T {
253
+ const cache = new Map<string, unknown>()
254
+ return new Proxy(namespace, {
255
+ get(target, key, receiver): unknown {
256
+ const value = Reflect.get(target, key, receiver)
257
+ if (typeof key !== 'string' || !isComponentMember(key)) return value
258
+ if (!value || (typeof value !== 'function' && typeof value !== 'object')) return value
259
+ let wrapped = cache.get(key)
260
+ if (wrapped === undefined) {
261
+ wrapped = wrap(value as ComponentType<unknown>)
262
+ cache.set(key, wrapped)
263
+ }
264
+ return wrapped
265
+ },
266
+ })
267
+ }