rnwind 0.0.10 → 0.0.12
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/lib/cjs/core/normalize-classname.cjs +3 -1
- package/lib/cjs/core/normalize-classname.cjs.map +1 -1
- package/lib/cjs/core/parser/border-dispatcher.cjs +20 -10
- package/lib/cjs/core/parser/border-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/parser/color-properties-dispatcher.cjs +7 -5
- package/lib/cjs/core/parser/color-properties-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/parser/color.cjs +194 -10
- package/lib/cjs/core/parser/color.cjs.map +1 -1
- package/lib/cjs/core/parser/color.d.ts +18 -3
- package/lib/cjs/core/parser/declaration.cjs +62 -4
- package/lib/cjs/core/parser/declaration.cjs.map +1 -1
- package/lib/cjs/core/parser/layout-dispatcher.cjs +32 -2
- package/lib/cjs/core/parser/layout-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/parser/shorthand.cjs +10 -3
- package/lib/cjs/core/parser/shorthand.cjs.map +1 -1
- package/lib/cjs/core/parser/tokens.cjs +9 -0
- package/lib/cjs/core/parser/tokens.cjs.map +1 -1
- package/lib/cjs/core/parser/tw-parser.cjs +89 -2
- package/lib/cjs/core/parser/tw-parser.cjs.map +1 -1
- package/lib/cjs/core/parser/tw-parser.d.ts +2 -0
- package/lib/cjs/core/parser/typography-dispatcher.cjs +15 -8
- package/lib/cjs/core/parser/typography-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/style-builder/union-builder.cjs +81 -2
- package/lib/cjs/core/style-builder/union-builder.cjs.map +1 -1
- package/lib/cjs/core/style-builder/union-builder.d.ts +28 -0
- package/lib/cjs/metro/state.cjs +74 -13
- package/lib/cjs/metro/state.cjs.map +1 -1
- package/lib/cjs/metro/state.d.ts +18 -0
- package/lib/cjs/metro/transformer.cjs +10 -4
- package/lib/cjs/metro/transformer.cjs.map +1 -1
- package/lib/cjs/metro/with-config.cjs +57 -0
- package/lib/cjs/metro/with-config.cjs.map +1 -1
- package/lib/cjs/metro/with-config.d.ts +12 -0
- package/lib/cjs/metro/wrap-imports.cjs +36 -1
- package/lib/cjs/metro/wrap-imports.cjs.map +1 -1
- package/lib/cjs/runtime/hooks/use-scheme.cjs +14 -7
- package/lib/cjs/runtime/hooks/use-scheme.cjs.map +1 -1
- package/lib/cjs/runtime/resolve.cjs +6 -2
- package/lib/cjs/runtime/resolve.cjs.map +1 -1
- package/lib/cjs/runtime/resolve.d.ts +5 -1
- package/lib/esm/core/normalize-classname.mjs +3 -1
- package/lib/esm/core/normalize-classname.mjs.map +1 -1
- package/lib/esm/core/parser/border-dispatcher.mjs +21 -11
- package/lib/esm/core/parser/border-dispatcher.mjs.map +1 -1
- package/lib/esm/core/parser/color-properties-dispatcher.mjs +8 -6
- package/lib/esm/core/parser/color-properties-dispatcher.mjs.map +1 -1
- package/lib/esm/core/parser/color.d.ts +18 -3
- package/lib/esm/core/parser/color.mjs +195 -12
- package/lib/esm/core/parser/color.mjs.map +1 -1
- package/lib/esm/core/parser/declaration.mjs +63 -5
- package/lib/esm/core/parser/declaration.mjs.map +1 -1
- package/lib/esm/core/parser/layout-dispatcher.mjs +32 -2
- package/lib/esm/core/parser/layout-dispatcher.mjs.map +1 -1
- package/lib/esm/core/parser/shorthand.mjs +11 -4
- package/lib/esm/core/parser/shorthand.mjs.map +1 -1
- package/lib/esm/core/parser/tokens.mjs +10 -1
- package/lib/esm/core/parser/tokens.mjs.map +1 -1
- package/lib/esm/core/parser/tw-parser.d.ts +2 -0
- package/lib/esm/core/parser/tw-parser.mjs +69 -0
- package/lib/esm/core/parser/tw-parser.mjs.map +1 -1
- package/lib/esm/core/parser/typography-dispatcher.mjs +15 -8
- package/lib/esm/core/parser/typography-dispatcher.mjs.map +1 -1
- package/lib/esm/core/style-builder/union-builder.d.ts +28 -0
- package/lib/esm/core/style-builder/union-builder.mjs +82 -3
- package/lib/esm/core/style-builder/union-builder.mjs.map +1 -1
- package/lib/esm/metro/state.d.ts +18 -0
- package/lib/esm/metro/state.mjs +75 -14
- package/lib/esm/metro/state.mjs.map +1 -1
- package/lib/esm/metro/transformer.mjs +10 -4
- package/lib/esm/metro/transformer.mjs.map +1 -1
- package/lib/esm/metro/with-config.d.ts +12 -0
- package/lib/esm/metro/with-config.mjs +58 -2
- package/lib/esm/metro/with-config.mjs.map +1 -1
- package/lib/esm/metro/wrap-imports.mjs +36 -1
- package/lib/esm/metro/wrap-imports.mjs.map +1 -1
- package/lib/esm/runtime/hooks/use-scheme.mjs +14 -7
- package/lib/esm/runtime/hooks/use-scheme.mjs.map +1 -1
- package/lib/esm/runtime/resolve.d.ts +5 -1
- package/lib/esm/runtime/resolve.mjs +6 -2
- package/lib/esm/runtime/resolve.mjs.map +1 -1
- package/package.json +1 -1
- package/src/core/normalize-classname.ts +4 -1
- package/src/core/parser/border-dispatcher.ts +22 -11
- package/src/core/parser/color-properties-dispatcher.ts +7 -5
- package/src/core/parser/color.ts +182 -11
- package/src/core/parser/declaration.ts +61 -5
- package/src/core/parser/layout-dispatcher.ts +34 -2
- package/src/core/parser/shorthand.ts +9 -3
- package/src/core/parser/tokens.ts +10 -1
- package/src/core/parser/tw-parser.ts +71 -1
- package/src/core/parser/typography-dispatcher.ts +15 -6
- package/src/core/style-builder/union-builder.ts +83 -3
- package/src/metro/state.ts +117 -12
- package/src/metro/transformer.ts +9 -4
- package/src/metro/with-config.ts +59 -1
- package/src/metro/wrap-imports.ts +36 -1
- package/src/runtime/hooks/use-scheme.ts +13 -6
- package/src/runtime/resolve.ts +6 -2
package/src/core/parser/color.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CssColor, LABColor } from 'lightningcss'
|
|
2
|
-
import { formatHex, rgb as culoriRgb } from 'culori'
|
|
2
|
+
import { formatHex, interpolate, rgb as culoriRgb } from 'culori'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Clamp a 0-255 float to the integer byte range RN color strings accept.
|
|
@@ -136,35 +136,196 @@ function xyzToHex(color: { type: 'xyz-d50' | 'xyz-d65'; x: number; y: number; z:
|
|
|
136
136
|
return withAlpha(formatHex({ mode, x: color.x, y: color.y, z: color.z }) ?? null, color.alpha)
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Map a CSS `color-mix(in <space>, …)` interpolation space to the culori mode
|
|
141
|
+
* key culori's {@link interpolate} understands. `srgb` is culori's `rgb`;
|
|
142
|
+
* `srgb-linear` is `lrgb`; the lab/lch/oklab/oklch/hsl/hwb spaces share their
|
|
143
|
+
* CSS name. Unknown spaces fall back to `rgb` so a mix still resolves to a
|
|
144
|
+
* concrete color rather than leaking the raw expression.
|
|
145
|
+
* @param space Lowercased interpolation-space token (after `in `).
|
|
146
|
+
* @returns culori interpolation mode key.
|
|
147
|
+
*/
|
|
148
|
+
function colorMixModeFor(space: string): string {
|
|
149
|
+
if (space === 'srgb') return 'rgb'
|
|
150
|
+
if (space === 'srgb-linear') return 'lrgb'
|
|
151
|
+
const known = new Set(['oklab', 'oklch', 'lab', 'lch', 'hsl', 'hwb', 'xyz', 'xyz-d50', 'xyz-d65'])
|
|
152
|
+
return known.has(space) ? space : 'rgb'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Split a `color-mix()` argument list at top-level commas (parens-aware) so a
|
|
157
|
+
* nested `rgb(0, 0, 0)` color slot doesn't fragment the split.
|
|
158
|
+
* @param body Text between the outer `color-mix(` parentheses.
|
|
159
|
+
* @returns Comma-separated argument fragments (trimmed).
|
|
160
|
+
*/
|
|
161
|
+
function splitColorMixArgs(body: string): string[] {
|
|
162
|
+
const parts: string[] = []
|
|
163
|
+
let depth = 0
|
|
164
|
+
let start = 0
|
|
165
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
166
|
+
const ch = body[index]
|
|
167
|
+
if (ch === '(') depth += 1
|
|
168
|
+
else if (ch === ')') depth -= 1
|
|
169
|
+
else if (ch === ',' && depth === 0) {
|
|
170
|
+
parts.push(body.slice(start, index).trim())
|
|
171
|
+
start = index + 1
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
parts.push(body.slice(start).trim())
|
|
175
|
+
return parts
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Peel an optional trailing `<num>%` weight off a `color-mix()` color slot.
|
|
180
|
+
* `#ff0000 50%` → `{ color: '#ff0000', weight: 0.5 }`; a bare color → weight
|
|
181
|
+
* `null` (caller fills the complement / defaults to 50/50).
|
|
182
|
+
* @param slot One color argument (color text, optionally suffixed with a percentage).
|
|
183
|
+
* @returns Color text plus its 0–1 weight, or null weight when unspecified.
|
|
184
|
+
*/
|
|
185
|
+
function parseColorMixSlot(slot: string): { color: string; weight: number | null } {
|
|
186
|
+
const trimmed = slot.trim()
|
|
187
|
+
if (!trimmed.endsWith('%')) return { color: trimmed, weight: null }
|
|
188
|
+
// End-anchored `<num>%` matcher (no leading `.*?` — avoids the super-linear
|
|
189
|
+
// backtracking ESLint flags). Split the color off at the last whitespace
|
|
190
|
+
// before the percentage token.
|
|
191
|
+
const pct = COLOR_MIX_SLOT_PCT.exec(trimmed)
|
|
192
|
+
if (!pct) return { color: trimmed, weight: null }
|
|
193
|
+
const color = trimmed.slice(0, pct.index).trim()
|
|
194
|
+
if (color.length === 0) return { color: trimmed, weight: null }
|
|
195
|
+
const weight = Number(pct[1]) / 100
|
|
196
|
+
return { color, weight: Number.isFinite(weight) ? weight : null }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** End-anchored `<num>%` matcher for slicing a color-mix slot's weight off its right edge. No backtracking. */
|
|
200
|
+
|
|
201
|
+
const COLOR_MIX_SLOT_PCT = /\s(-?\d+(?:\.\d+)?)%$/
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Resolve a two-color CSS `color-mix(in <space>, A [p1%], B [p2%])` to a
|
|
205
|
+
* concrete sRGB color via culori's {@link interpolate}. CSS weight rules:
|
|
206
|
+
* with one percentage the other fills the complement; with none it is 50/50;
|
|
207
|
+
* with both, the interpolation point is `p2 / (p1 + p2)`. RN can't evaluate
|
|
208
|
+
* `color-mix()` at paint time, so this is the only path that lowers it.
|
|
209
|
+
* @param text Trimmed CSS value beginning with `color-mix(`.
|
|
210
|
+
* @returns sRGB hex/rgba string, or null when the shape/colors can't resolve.
|
|
211
|
+
*/
|
|
212
|
+
function resolveColorMix(text: string): string | null {
|
|
213
|
+
if (!text.endsWith(')')) return null
|
|
214
|
+
const open = text.indexOf('(')
|
|
215
|
+
if (open === -1) return null
|
|
216
|
+
const args = splitColorMixArgs(text.slice(open + 1, -1))
|
|
217
|
+
if (args.length !== 3) return null
|
|
218
|
+
const spaceClause = args[0]!.toLowerCase()
|
|
219
|
+
if (!spaceClause.startsWith('in ')) return null
|
|
220
|
+
const mode = colorMixModeFor(spaceClause.slice(3).trim())
|
|
221
|
+
const first = parseColorMixSlot(args[1]!)
|
|
222
|
+
const second = parseColorMixSlot(args[2]!)
|
|
223
|
+
if (first.color.length === 0 || second.color.length === 0) return null
|
|
224
|
+
const point = colorMixPoint(first.weight, second.weight)
|
|
225
|
+
if (point === null) return null
|
|
226
|
+
try {
|
|
227
|
+
const mixed = interpolate([first.color, second.color], mode as never)(point)
|
|
228
|
+
if (!mixed) return null
|
|
229
|
+
const back = culoriRgb(mixed) as { r?: number; g?: number; b?: number; alpha?: number } | undefined
|
|
230
|
+
if (!back || ![back.r, back.g, back.b].every((v) => typeof v === 'number' && Number.isFinite(v))) return null
|
|
231
|
+
const alpha = typeof back.alpha === 'number' ? back.alpha : 1
|
|
232
|
+
return rgbIntsToString(clampByte(back.r! * 255), clampByte(back.g! * 255), clampByte(back.b! * 255), alpha)
|
|
233
|
+
} catch {
|
|
234
|
+
// culori threw on an unparseable color slot — drop rather than leak the raw string.
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Compute the 0–1 interpolation point (weight of the SECOND color) from the
|
|
241
|
+
* two optional `color-mix()` weights, applying CSS normalization.
|
|
242
|
+
* @param firstWeight 0–1 weight of color A, or null when unspecified.
|
|
243
|
+
* @param secondWeight 0–1 weight of color B, or null when unspecified.
|
|
244
|
+
* @returns Interpolation point in `[0, 1]`, or null when both weights are 0.
|
|
245
|
+
*/
|
|
246
|
+
function colorMixPoint(firstWeight: number | null, secondWeight: number | null): number | null {
|
|
247
|
+
if (firstWeight === null && secondWeight === null) return 0.5
|
|
248
|
+
if (firstWeight !== null && secondWeight === null) return 1 - firstWeight
|
|
249
|
+
if (firstWeight === null && secondWeight !== null) return secondWeight
|
|
250
|
+
const sum = firstWeight! + secondWeight!
|
|
251
|
+
if (sum === 0) return null
|
|
252
|
+
return secondWeight! / sum
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* CSS-wide cascade keywords that resolve a property against the inherited /
|
|
257
|
+
* initial / previous-layer value at paint time. React Native has NO color
|
|
258
|
+
* cascade — there is no inherited `color` for an arbitrary style prop and no
|
|
259
|
+
* cascade layers — so as a `color` / `backgroundColor` / `borderColor` value
|
|
260
|
+
* every one of these reaches RN as an invalid color string. `currentColor`
|
|
261
|
+
* belongs here too: it resolves to the element's inherited `color`, which RN
|
|
262
|
+
* never threads into other color props. The color path must DROP these (omit
|
|
263
|
+
* the key) rather than leak the keyword. NOTE: `transparent` is NOT here — it
|
|
264
|
+
* is a real color that {@link cssColorToString} / {@link normalizeColorString}
|
|
265
|
+
* lower to `rgba(0, 0, 0, 0)`, which RN paints correctly.
|
|
266
|
+
*/
|
|
267
|
+
const CSS_WIDE_COLOR_KEYWORDS: ReadonlySet<string> = new Set([
|
|
268
|
+
'currentcolor',
|
|
269
|
+
'inherit',
|
|
270
|
+
'initial',
|
|
271
|
+
'unset',
|
|
272
|
+
'revert',
|
|
273
|
+
'revert-layer',
|
|
274
|
+
])
|
|
275
|
+
|
|
139
276
|
/**
|
|
140
277
|
* Modern CSS color functions RN's native view manager can't paint —
|
|
141
278
|
* everything else (hex, `rgb()`/`rgba()`, `hsl()`/`hsla()`, named colors,
|
|
142
|
-
* `transparent
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
279
|
+
* `transparent`) RN reads directly and must pass through untouched. The
|
|
280
|
+
* CSS-wide cascade keywords (`currentColor`, `inherit`, …) are NOT readable —
|
|
281
|
+
* they have no RN equivalent and are dropped via {@link isCssWideColorKeyword}.
|
|
282
|
+
* Custom `@theme` tokens reach the parser as `var(--color-x)` (only the default
|
|
283
|
+
* palette is `theme(inline)`-d), so they flow through the unparsed-string path
|
|
284
|
+
* where the typed {@link cssColorToString} never runs — this is the one place
|
|
285
|
+
* that lowers their wide-gamut values to sRGB. `color-mix(` is in the list too,
|
|
286
|
+
* but it takes the dedicated {@link resolveColorMix} path — culori's `rgb()`
|
|
287
|
+
* parser can't read it.
|
|
147
288
|
*/
|
|
148
|
-
const RN_UNREADABLE_COLOR_PREFIXES: readonly string[] = ['oklch(', 'oklab(', 'lab(', 'lch(', 'color(', 'hwb(']
|
|
289
|
+
const RN_UNREADABLE_COLOR_PREFIXES: readonly string[] = ['oklch(', 'oklab(', 'lab(', 'lch(', 'color(', 'hwb(', 'color-mix(']
|
|
149
290
|
|
|
150
291
|
/**
|
|
151
292
|
* Lower a wide-gamut / modern CSS color STRING (`oklch(…)`, `lab(…)`,
|
|
152
|
-
* `color(display-p3 …)`, …) to an sRGB hex/rgba string RN can
|
|
153
|
-
* `null` for anything RN already understands (hex, rgb, hsl,
|
|
154
|
-
* caller keeps the original text — only the unrepresentable
|
|
293
|
+
* `color(display-p3 …)`, `color-mix(…)`) to an sRGB hex/rgba string RN can
|
|
294
|
+
* paint. Returns `null` for anything RN already understands (hex, rgb, hsl,
|
|
295
|
+
* named) so the caller keeps the original text — only the unrepresentable
|
|
296
|
+
* forms convert. `color-mix()` is resolved via culori's interpolator; when it
|
|
297
|
+
* (or any other modern form) can't resolve, returns null so the caller DROPS
|
|
298
|
+
* the value rather than leaking the raw, RN-unreadable string.
|
|
155
299
|
* Mirrors {@link cssColorToString}'s culori lowering for the string path.
|
|
156
300
|
* @param text Resolved CSS color text (post theme-var substitution).
|
|
157
301
|
* @returns sRGB color string, or `null` when no conversion is needed/possible.
|
|
158
302
|
*/
|
|
159
303
|
export function normalizeColorString(text: string): string | null {
|
|
160
|
-
const
|
|
304
|
+
const trimmed = text.trim()
|
|
305
|
+
const lower = trimmed.toLowerCase()
|
|
161
306
|
if (!RN_UNREADABLE_COLOR_PREFIXES.some((prefix) => lower.startsWith(prefix))) return null
|
|
307
|
+
if (lower.startsWith('color-mix(')) return resolveColorMix(trimmed)
|
|
162
308
|
const parsed = culoriRgb(text)
|
|
163
309
|
if (!parsed || ![parsed.r, parsed.g, parsed.b].every((v) => typeof v === 'number' && Number.isFinite(v))) return null
|
|
164
310
|
const alpha = typeof parsed.alpha === 'number' ? parsed.alpha : 1
|
|
165
311
|
return rgbIntsToString(clampByte(parsed.r * 255), clampByte(parsed.g * 255), clampByte(parsed.b * 255), alpha)
|
|
166
312
|
}
|
|
167
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Whether a resolved color STRING is a CSS-wide cascade keyword
|
|
316
|
+
* (`currentColor`, `inherit`, `initial`, `unset`, `revert`, `revert-layer`)
|
|
317
|
+
* with no React Native equivalent. RN has no color cascade, so the color path
|
|
318
|
+
* must DROP (omit the key) when this is true rather than emit the keyword —
|
|
319
|
+
* RN would otherwise receive an invalid color string and render nothing.
|
|
320
|
+
* `transparent` is NOT a cascade keyword: it is a concrete color the converters
|
|
321
|
+
* lower to `rgba(0, 0, 0, 0)`, so it returns false here and resolves normally.
|
|
322
|
+
* @param text Resolved color text (post theme-var substitution / typed-color stringification).
|
|
323
|
+
* @returns True when the value is an RN-unrepresentable CSS-wide keyword.
|
|
324
|
+
*/
|
|
325
|
+
export function isCssWideColorKeyword(text: string): boolean {
|
|
326
|
+
return CSS_WIDE_COLOR_KEYWORDS.has(text.trim().toLowerCase())
|
|
327
|
+
}
|
|
328
|
+
|
|
168
329
|
/**
|
|
169
330
|
* Convert a lightningcss `CssColor` to an RN-safe color string. RGB
|
|
170
331
|
* passes through unchanged. LAB / LCH / OKLAB / OKLCH / `color(xyz-…)`
|
|
@@ -213,6 +374,16 @@ export function cssColorToString(color: CssColor): string {
|
|
|
213
374
|
return 'currentColor'
|
|
214
375
|
}
|
|
215
376
|
case 'light-dark': {
|
|
377
|
+
// `light-dark(L, D)` is a RUNTIME CSS function — the browser picks the
|
|
378
|
+
// branch from the element's `color-scheme`. rnwind has no runtime CSS
|
|
379
|
+
// evaluation, and the active scheme is NOT threaded into this typed
|
|
380
|
+
// converter (it takes a bare `CssColor`, and every one of its ~15 call
|
|
381
|
+
// sites — border / shorthand / gradient / declaration dispatchers — calls
|
|
382
|
+
// it without a scheme). Scheme resolution instead happens UPSTREAM, at the
|
|
383
|
+
// CSS-block walk (`@custom-variant` + `.dark {}` selectors in
|
|
384
|
+
// theme-vars.ts), which compiles a separate atom + var table per scheme.
|
|
385
|
+
// So the `.light` branch is the correct compile-time default here; the
|
|
386
|
+
// dark value is carried by the scheme-specific atom, not by this function.
|
|
216
387
|
return cssColorToString(color.light)
|
|
217
388
|
}
|
|
218
389
|
default: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* eslint-disable sonarjs/cognitive-complexity -- the main Declaration → RN-entries dispatcher is intentionally a flat switch so each branch keeps its narrowed value type */
|
|
2
2
|
import type { Declaration as LcDeclaration, TokenOrValue } from 'lightningcss'
|
|
3
3
|
import { kebabToCamel } from './case-convert'
|
|
4
|
-
import { cssColorToString, normalizeColorString } from './color'
|
|
4
|
+
import { cssColorToString, isCssWideColorKeyword, normalizeColorString } from './color'
|
|
5
5
|
import { dimensionPercentageToNumber, gapValueToValue, lengthPercentageOrAutoToValue, sizeLikeToValue } from './length'
|
|
6
6
|
import {
|
|
7
7
|
expandBorderColor,
|
|
@@ -57,6 +57,10 @@ const RN_UNSUPPORTED_PROPERTIES: ReadonlySet<string> = new Set([
|
|
|
57
57
|
'clear',
|
|
58
58
|
'table-layout',
|
|
59
59
|
'caption-side',
|
|
60
|
+
// Web table-model props with no RN equivalent. `border-spacing` otherwise
|
|
61
|
+
// reaches the generic fallback and leaks an unresolved `calc(0.25rem * N)`.
|
|
62
|
+
'border-spacing',
|
|
63
|
+
'border-collapse',
|
|
60
64
|
'transform-style',
|
|
61
65
|
'background-blend-mode',
|
|
62
66
|
'scroll-behavior',
|
|
@@ -73,6 +77,20 @@ const RN_UNSUPPORTED_PROPERTIES: ReadonlySet<string> = new Set([
|
|
|
73
77
|
'field-sizing',
|
|
74
78
|
'forced-color-adjust',
|
|
75
79
|
'text-shadow',
|
|
80
|
+
// Web-only KEYS RN has no style prop for. `order` leaks through the negative
|
|
81
|
+
// variant (`-order-1` → `order: calc(1 * -1)` unparsed → resolves to `-1`);
|
|
82
|
+
// the positive `order-*` already drops since no typed branch claims it. Adding
|
|
83
|
+
// it here drops BOTH signs. `isolation` (`isolate` / `isolation-auto`) reaches
|
|
84
|
+
// the `custom` path as `isolation: isolate|auto` — also no RN equivalent.
|
|
85
|
+
'order',
|
|
86
|
+
'isolation',
|
|
87
|
+
// `normal-nums` reaches the `custom` path as `font-variant-numeric: normal`
|
|
88
|
+
// and leaked the non-RN key `fontVariantNumeric`. RN expresses numeric
|
|
89
|
+
// variants via the `fontVariant` array, not this property — drop it. (The
|
|
90
|
+
// `tabular-nums`/`oldstyle-nums`/… utilities carry their token in dropped
|
|
91
|
+
// `--tw-numeric-*` vars and already resolve to {}; mapping those to
|
|
92
|
+
// `fontVariant` is a tracked future enhancement, not a leak.)
|
|
93
|
+
'font-variant-numeric',
|
|
76
94
|
'touch-action',
|
|
77
95
|
'backdrop-filter',
|
|
78
96
|
'-webkit-backdrop-filter',
|
|
@@ -82,6 +100,24 @@ const RN_UNSUPPORTED_PROPERTIES: ReadonlySet<string> = new Set([
|
|
|
82
100
|
'-moz-osx-font-smoothing',
|
|
83
101
|
])
|
|
84
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Valid value sets for RN enum style props (keyed by the camelCase RN key).
|
|
105
|
+
* A value outside its prop's set is RN-invalid even when the string itself
|
|
106
|
+
* looks clean — RN ignores or warns on it (`position: 'fixed'`, `display:
|
|
107
|
+
* 'contents'`, `justifyContent: 'stretch' | 'baseline'`, `alignContent:
|
|
108
|
+
* 'normal'`). This is the dimension the leak-shape (`var(`/`calc(`/NaN) check
|
|
109
|
+
* misses. Both the typed `display` / `position` branches AND the generic
|
|
110
|
+
* unparsed fallback consult this — Tailwind routes some keyword-only values
|
|
111
|
+
* (`justify-content: baseline`) through the unparsed channel, which would
|
|
112
|
+
* otherwise emit them via `kebabToCamel` with no enum awareness.
|
|
113
|
+
*/
|
|
114
|
+
const RN_ENUM_VALUES: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
115
|
+
position: new Set(['absolute', 'relative', 'static']),
|
|
116
|
+
display: new Set(['flex', 'none']),
|
|
117
|
+
justifyContent: new Set(['flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'space-evenly']),
|
|
118
|
+
alignContent: new Set(['flex-start', 'flex-end', 'center', 'stretch', 'space-between', 'space-around', 'space-evenly']),
|
|
119
|
+
}
|
|
120
|
+
|
|
85
121
|
/** CSS single-sided logical-inline property → RN writing-direction Yoga key. */
|
|
86
122
|
const LOGICAL_INLINE_TO_RN: Record<string, string> = {
|
|
87
123
|
'margin-inline-start': 'marginStart',
|
|
@@ -245,6 +281,10 @@ function unparsedToEntries(
|
|
|
245
281
|
// space (outside parens — `rgb(1 2 3)` keeps its inner spaces) means it's a
|
|
246
282
|
// multi-token shorthand, not a color: drop it.
|
|
247
283
|
if (hasTopLevelSpace(coerced)) return []
|
|
284
|
+
// CSS-wide cascade keywords (`inherit`, `currentColor`, `initial`, `unset`,
|
|
285
|
+
// `revert`, `revert-layer`) have no RN equivalent — RN has no color
|
|
286
|
+
// cascade. Drop rather than leak an invalid color string to RN.
|
|
287
|
+
if (isCssWideColorKeyword(coerced)) return []
|
|
248
288
|
// Lower modern color spaces (`oklch(…)`, `lab(…)`, `color(p3 …)`) that
|
|
249
289
|
// RN can't paint to sRGB; hex/rgb/hsl/named pass through unchanged.
|
|
250
290
|
const color = normalizeColorString(coerced) ?? coerced
|
|
@@ -254,7 +294,14 @@ function unparsedToEntries(
|
|
|
254
294
|
if (sides) return sides.map((key): RNEntry => [key, color])
|
|
255
295
|
return [[kebabToCamel(property), color]]
|
|
256
296
|
}
|
|
257
|
-
|
|
297
|
+
const camelKey = kebabToCamel(property)
|
|
298
|
+
// Enum props whose value Tailwind sometimes routes through the unparsed
|
|
299
|
+
// channel (`justify-content: baseline` → `justifyContent: 'baseline'`),
|
|
300
|
+
// bypassing the typed dispatcher's keyword map. RN rejects values outside
|
|
301
|
+
// the prop's set, so gate them here exactly like the typed branches do.
|
|
302
|
+
const enumValues = RN_ENUM_VALUES[camelKey]
|
|
303
|
+
if (enumValues && typeof coerced === 'string' && !enumValues.has(coerced)) return []
|
|
304
|
+
return [[camelKey, coerced]]
|
|
258
305
|
}
|
|
259
306
|
|
|
260
307
|
/**
|
|
@@ -294,7 +341,12 @@ export function declarationToRnEntries(decl: LcDeclaration, themeVars?: Readonly
|
|
|
294
341
|
// `background-color` narrows to `CssColor | 'background'` — the
|
|
295
342
|
// literal keyword means UA default. Skip the keyword.
|
|
296
343
|
if (typeof decl.value === 'string') return []
|
|
297
|
-
|
|
344
|
+
const colorString = cssColorToString(decl.value)
|
|
345
|
+
// `currentColor` (lightningcss `{type:'currentcolor'}`) and any other
|
|
346
|
+
// CSS-wide cascade keyword have no RN equivalent — drop instead of
|
|
347
|
+
// leaking the keyword string to RN.
|
|
348
|
+
if (isCssWideColorKeyword(colorString)) return []
|
|
349
|
+
return [[kebabToCamel(decl.property), colorString]]
|
|
298
350
|
}
|
|
299
351
|
case 'border-color': {
|
|
300
352
|
return expandBorderColor(decl.value)
|
|
@@ -370,10 +422,14 @@ export function declarationToRnEntries(decl: LcDeclaration, themeVars?: Readonly
|
|
|
370
422
|
return [['fontStyle', decl.value.type]]
|
|
371
423
|
}
|
|
372
424
|
case 'display': {
|
|
373
|
-
|
|
425
|
+
// `displayToEntries` can still emit `contents` (a CSS value RN rejects —
|
|
426
|
+
// only `flex` / `none` are valid). Gate the result on the RN-valid set.
|
|
427
|
+
return displayToEntries(decl.value).filter(([, value]) => typeof value === 'string' && RN_ENUM_VALUES.display.has(value))
|
|
374
428
|
}
|
|
375
429
|
case 'position': {
|
|
376
|
-
|
|
430
|
+
// RN `position` accepts only `absolute` / `relative` / `static`; CSS
|
|
431
|
+
// `fixed` / `sticky` are invalid for RN, so drop them.
|
|
432
|
+
return RN_ENUM_VALUES.position.has(decl.value.type) ? [['position', decl.value.type]] : []
|
|
377
433
|
}
|
|
378
434
|
case 'font-size': {
|
|
379
435
|
const px = fontSizeToPx(decl.value)
|
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
import type { Declaration as LcDeclaration } from 'lightningcss'
|
|
2
2
|
import type { RNEntry } from './types'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* `justify-content` keywords RN accepts. CSS adds `stretch` / `normal` /
|
|
6
|
+
* `left` / `right` (and `start`/`end`, which we lower to `flex-start`/`flex-end`
|
|
7
|
+
* BEFORE this gate). RN rejects anything outside this set — drop it.
|
|
8
|
+
*/
|
|
9
|
+
const RN_JUSTIFY_CONTENT_VALUES: ReadonlySet<string> = new Set([
|
|
10
|
+
'flex-start',
|
|
11
|
+
'flex-end',
|
|
12
|
+
'center',
|
|
13
|
+
'space-between',
|
|
14
|
+
'space-around',
|
|
15
|
+
'space-evenly',
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `align-content` keywords RN accepts. Differs from `justify-content`: RN
|
|
20
|
+
* allows `stretch` here but rejects `normal`. Drop values outside the set.
|
|
21
|
+
*/
|
|
22
|
+
const RN_ALIGN_CONTENT_VALUES: ReadonlySet<string> = new Set([
|
|
23
|
+
'flex-start',
|
|
24
|
+
'flex-end',
|
|
25
|
+
'center',
|
|
26
|
+
'stretch',
|
|
27
|
+
'space-between',
|
|
28
|
+
'space-around',
|
|
29
|
+
'space-evenly',
|
|
30
|
+
])
|
|
31
|
+
|
|
4
32
|
/**
|
|
5
33
|
* Lower a CSS `overflow` keyword to one RN's `overflow` prop accepts
|
|
6
34
|
* (`'visible' | 'hidden' | 'scroll'`). `auto` → `scroll` (auto means
|
|
@@ -105,12 +133,16 @@ export function dispatchLayoutDeclaration(decl: LcDeclaration): readonly RNEntry
|
|
|
105
133
|
return v === null ? [] : [['alignSelf', v]]
|
|
106
134
|
}
|
|
107
135
|
case 'align-content': {
|
|
136
|
+
// After CSS→RN lowering, gate on RN's valid set — drops `normal`
|
|
137
|
+
// (`content-normal`) which RN's `alignContent` rejects.
|
|
108
138
|
const v = mapAlignKeyword(decl.value)
|
|
109
|
-
return v === null ? [] : [['alignContent', v]]
|
|
139
|
+
return v === null || !RN_ALIGN_CONTENT_VALUES.has(v) ? [] : [['alignContent', v]]
|
|
110
140
|
}
|
|
111
141
|
case 'justify-content': {
|
|
142
|
+
// After CSS→RN lowering, gate on RN's valid set — drops `stretch`
|
|
143
|
+
// (`justify-stretch`) and any other keyword RN's `justifyContent` rejects.
|
|
112
144
|
const v = mapJustifyKeyword(decl.value)
|
|
113
|
-
return v === null ? [] : [['justifyContent', v]]
|
|
145
|
+
return v === null || !RN_JUSTIFY_CONTENT_VALUES.has(v) ? [] : [['justifyContent', v]]
|
|
114
146
|
}
|
|
115
147
|
case 'overflow': {
|
|
116
148
|
// Lightningcss splits CSS `overflow` into `{x, y}` axes; RN only
|
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
PaddingBlock,
|
|
11
11
|
PaddingInline,
|
|
12
12
|
} from 'lightningcss'
|
|
13
|
-
import { cssColorToString } from './color'
|
|
13
|
+
import { cssColorToString, isCssWideColorKeyword } from './color'
|
|
14
14
|
import { dimensionPercentageToNumber, gapValueToValue, lengthPercentageOrAutoToValue } from './length'
|
|
15
15
|
import type { RNEntry } from './types'
|
|
16
16
|
|
|
@@ -135,13 +135,19 @@ export function expandBorderColor(value: BorderColor): readonly RNEntry[] {
|
|
|
135
135
|
const right = cssColorToString(value.right)
|
|
136
136
|
const bottom = cssColorToString(value.bottom)
|
|
137
137
|
const left = cssColorToString(value.left)
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
// CSS-wide cascade keywords (`currentColor`, `inherit`, …) have no RN
|
|
139
|
+
// equivalent — drop any side that resolves to one rather than leak the
|
|
140
|
+
// keyword as a `borderColor`/`border*Color` value RN can't paint.
|
|
141
|
+
const sides: readonly (readonly [string, string])[] = [
|
|
140
142
|
['borderTopColor', top],
|
|
141
143
|
['borderRightColor', right],
|
|
142
144
|
['borderBottomColor', bottom],
|
|
143
145
|
['borderLeftColor', left],
|
|
144
146
|
]
|
|
147
|
+
const paintable = sides.filter(([, color]) => !isCssWideColorKeyword(color))
|
|
148
|
+
if (paintable.length === 0) return []
|
|
149
|
+
if (paintable.length === 4 && top === right && right === bottom && bottom === left) return [['borderColor', top]]
|
|
150
|
+
return paintable
|
|
145
151
|
}
|
|
146
152
|
|
|
147
153
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Token, TokenOrValue } from 'lightningcss'
|
|
2
2
|
import { rgb as culoriRgb } from 'culori'
|
|
3
3
|
import { BARE_NUMBER_REGEX, CALC_MUL_REGEX, CALC_RATIO_REGEX, LENGTH_PX_REGEX, LENGTH_REM_REGEX, REM_TO_PX } from './constants'
|
|
4
|
-
import { cssColorToString } from './color'
|
|
4
|
+
import { cssColorToString, normalizeColorString } from './color'
|
|
5
5
|
import type { RNStyleValue } from './types'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -247,6 +247,15 @@ export function coerceUnparsedValue(text: string): RNStyleValue | null {
|
|
|
247
247
|
if (rem) return Number(rem[1]) * REM_TO_PX
|
|
248
248
|
const colorMix = evaluateColorMixWithTransparent(trimmed)
|
|
249
249
|
if (colorMix !== null) return colorMix
|
|
250
|
+
// Real two-color `color-mix(in <space>, A, B)` (not the `, transparent)`
|
|
251
|
+
// opacity shape) — resolve it to a concrete sRGB color via culori so the
|
|
252
|
+
// raw, RN-unreadable `color-mix(...)` string never reaches the StyleSheet.
|
|
253
|
+
if (trimmed.toLowerCase().startsWith('color-mix(')) {
|
|
254
|
+
// Resolved → concrete color; unresolvable → null (DROP). Either way the
|
|
255
|
+
// raw `color-mix(...)` text must never fall through to the string path
|
|
256
|
+
// below, where RN would receive an unreadable value and render nothing.
|
|
257
|
+
return normalizeColorString(trimmed)
|
|
258
|
+
}
|
|
250
259
|
const fallback = extractVariableFallback(trimmed)
|
|
251
260
|
if (fallback !== null) return coerceUnparsedValue(fallback)
|
|
252
261
|
const calcRatio = CALC_RATIO_REGEX.exec(trimmed)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { compile } from '@tailwindcss/node'
|
|
2
|
+
import * as tailwindNode from '@tailwindcss/node'
|
|
2
3
|
import { Scanner, type SourceEntry } from '@tailwindcss/oxide'
|
|
3
4
|
import { formatHex as culoriFormatHex } from 'culori'
|
|
4
5
|
import { Features, transform, type TransformOptions } from 'lightningcss'
|
|
@@ -183,6 +184,8 @@ export interface ParsedOutput {
|
|
|
183
184
|
export class TailwindParser {
|
|
184
185
|
private readonly scanner: Scanner
|
|
185
186
|
private compiler: TailwindCompiler | undefined
|
|
187
|
+
/** Full resolved base theme (built-in palette + user `@theme`), colors lowered to sRGB. Source for `useColor` / `useToken`. */
|
|
188
|
+
private baseThemeTokens: ThemeTable | null = null
|
|
186
189
|
private readonly themeSchemes: ThemeSchemeTable
|
|
187
190
|
private readonly schemeAliases: ReadonlyMap<string, string>
|
|
188
191
|
/**
|
|
@@ -281,10 +284,16 @@ export class TailwindParser {
|
|
|
281
284
|
*/
|
|
282
285
|
private buildThemeTokens(resolver: ReadonlyMap<string, string>): ThemeTables {
|
|
283
286
|
const out: ThemeTables = {}
|
|
287
|
+
// BASE: the full resolved theme (built-in palette + user `@theme`). The
|
|
288
|
+
// runtime merges this under the active scheme, so `useColor('pink-500')`
|
|
289
|
+
// and `useColor('<your-token>')` both resolve in every scheme.
|
|
290
|
+
if (this.baseThemeTokens) out[BASE_SCHEME] = this.baseThemeTokens
|
|
291
|
+
// VARIANTS: only the per-scheme overrides the user wrote (`.dark { … }` /
|
|
292
|
+
// `@variant dark { … }`) — they layer on top of base at runtime.
|
|
284
293
|
for (const scheme of this.themeSchemes.keys()) {
|
|
294
|
+
if (scheme === BASE_SCHEME) continue
|
|
285
295
|
const userTable = this.themeSchemes.get(scheme)
|
|
286
296
|
if (!userTable || userTable.size === 0) continue
|
|
287
|
-
|
|
288
297
|
const schemeResolver = new Map(resolver)
|
|
289
298
|
for (const [k, v] of this.effectiveVars(scheme)) schemeResolver.set(k, v)
|
|
290
299
|
const table: ThemeTable = {}
|
|
@@ -313,6 +322,13 @@ export class TailwindParser {
|
|
|
313
322
|
} catch (error) {
|
|
314
323
|
throw wrapThemeError(error)
|
|
315
324
|
}
|
|
325
|
+
// Load the resolved design system ONCE to capture the FULL theme — the
|
|
326
|
+
// built-in palette (`pink-500`, …) plus the user's `@theme` tokens — so
|
|
327
|
+
// `useColor` / `useToken` resolve any theme value, not just the utilities
|
|
328
|
+
// a class happened to use (Tailwind tree-shakes `:root`, so the compiled
|
|
329
|
+
// CSS alone never carries the full palette). Best-effort: a load failure
|
|
330
|
+
// just narrows the hooks to the user's own tokens.
|
|
331
|
+
this.baseThemeTokens = await loadBaseThemeTokens(ready)
|
|
316
332
|
return this.compiler
|
|
317
333
|
}
|
|
318
334
|
|
|
@@ -501,6 +517,55 @@ function lowerColorToken(raw: string, resolver: ReadonlyMap<string, string>): st
|
|
|
501
517
|
return normalizeColorString(substituted) ?? substituted
|
|
502
518
|
}
|
|
503
519
|
|
|
520
|
+
/** Theme token families excluded from the registered base table — pure Tailwind internals with no `useColor`/`useToken` value. */
|
|
521
|
+
const INTERNAL_TOKEN_PREFIXES: readonly string[] = ['--tw-', '--default-']
|
|
522
|
+
|
|
523
|
+
/** Shape of a resolved design-system theme entry from `@tailwindcss/node`'s unstable loader. */
|
|
524
|
+
interface DesignSystemTheme {
|
|
525
|
+
theme?: { entries?: () => Iterable<[string, { value?: unknown }]> }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* `@tailwindcss/node`'s `__unstable__loadDesignSystem` — exists at runtime but
|
|
530
|
+
* isn't in the package's published types, so it's accessed through a narrowed
|
|
531
|
+
* cast rather than a named import. Returns `undefined` when the (unstable) API
|
|
532
|
+
* isn't present, so {@link loadBaseThemeTokens} degrades gracefully.
|
|
533
|
+
*/
|
|
534
|
+
const loadDesignSystem = (tailwindNode as unknown as {
|
|
535
|
+
__unstable__loadDesignSystem?: (css: string, options: { base: string }) => Promise<DesignSystemTheme>
|
|
536
|
+
}).__unstable__loadDesignSystem
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Load the FULL resolved Tailwind theme (built-in palette + the user's
|
|
540
|
+
* `@theme`) via the design-system API and flatten it to an RN-safe token
|
|
541
|
+
* table — `--color-*` values lowered to sRGB, everything else passed through.
|
|
542
|
+
* This is what lets `useColor` / `useToken` resolve ANY theme token, including
|
|
543
|
+
* built-ins a class never used (Tailwind tree-shakes the compiled `:root`, so
|
|
544
|
+
* the compiled CSS alone can't supply the full palette). Internal `--tw-*` /
|
|
545
|
+
* `--default-*` families are dropped. Returns `null` on any failure so the
|
|
546
|
+
* caller degrades to the user's own `@theme` tokens.
|
|
547
|
+
* @param themeCss Compile-ready theme CSS (variants stripped, custom-variants added).
|
|
548
|
+
* @returns Flattened base token table, or null.
|
|
549
|
+
*/
|
|
550
|
+
async function loadBaseThemeTokens(themeCss: string): Promise<ThemeTable | null> {
|
|
551
|
+
if (typeof loadDesignSystem !== 'function') return null
|
|
552
|
+
try {
|
|
553
|
+
const design = await loadDesignSystem(themeCss, { base: process.cwd() })
|
|
554
|
+
const entries = design.theme?.entries?.()
|
|
555
|
+
if (!entries) return null
|
|
556
|
+
const table: ThemeTable = {}
|
|
557
|
+
for (const [name, entry] of entries) {
|
|
558
|
+
const raw = entry?.value
|
|
559
|
+
if (typeof raw !== 'string') continue
|
|
560
|
+
if (INTERNAL_TOKEN_PREFIXES.some((prefix) => name.startsWith(prefix))) continue
|
|
561
|
+
table[name] = name.startsWith('--color-') ? (normalizeColorString(raw) ?? raw) : raw
|
|
562
|
+
}
|
|
563
|
+
return table
|
|
564
|
+
} catch {
|
|
565
|
+
return null
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
504
569
|
/**
|
|
505
570
|
* Wrap an error from `@tailwindcss/node`'s compiler or `lightningcss`'s
|
|
506
571
|
* transform with a `rnwind:` prefix so the user sees a clear "this came
|
|
@@ -1308,6 +1373,11 @@ function parseRgbaExpression(text: string): { color: string; opacity: number } |
|
|
|
1308
1373
|
if (typeof alphaText === 'string') {
|
|
1309
1374
|
opacity = alphaText.endsWith('%') ? Number(alphaText.slice(0, -1)) / 100 : Number(alphaText)
|
|
1310
1375
|
}
|
|
1376
|
+
// CSS Color 4: a `none` (or otherwise non-numeric) alpha parses to NaN here.
|
|
1377
|
+
// Its used value when compositing is 0 (fully transparent) — and crucially
|
|
1378
|
+
// RN throws on a NaN `shadowOpacity`, so collapse any non-finite alpha to 0
|
|
1379
|
+
// before it can reach a numeric style prop.
|
|
1380
|
+
if (!Number.isFinite(opacity)) opacity = 0
|
|
1311
1381
|
const hex = `#${[r!, g!, b!]
|
|
1312
1382
|
.map((n) =>
|
|
1313
1383
|
Math.max(0, Math.min(255, Math.round(Number(n))))
|
|
@@ -6,17 +6,26 @@ import type { RNEntry } from './types'
|
|
|
6
6
|
/** RN-supported `textDecorationStyle` values (`wavy` has no RN equivalent). */
|
|
7
7
|
const RN_DECORATION_STYLES: ReadonlySet<string> = new Set(['solid', 'double', 'dotted', 'dashed'])
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The only `textDecorationLine` keywords React Native renders. CSS `overline`
|
|
11
|
+
* has no RN analog, so any line string containing it (or any other unknown
|
|
12
|
+
* keyword) is dropped rather than leaked as a value RN warns on + ignores.
|
|
13
|
+
*/
|
|
14
|
+
const RN_DECORATION_LINES: ReadonlySet<string> = new Set(['none', 'underline', 'line-through', 'underline line-through'])
|
|
15
|
+
|
|
9
16
|
/**
|
|
10
17
|
* Build the RN `textDecorationLine` entry — string identity for the
|
|
11
|
-
* single-line cases, joined-string for the array shape.
|
|
18
|
+
* single-line cases, joined-string for the array shape. Drops any value
|
|
19
|
+
* outside RN's enum (`overline`, `overline underline`, …) so no invalid
|
|
20
|
+
* keyword reaches the StyleSheet.
|
|
12
21
|
* @param value Typed text-decoration-line.
|
|
13
|
-
* @returns Single-entry list with `textDecorationLine
|
|
22
|
+
* @returns Single-entry list with a valid `textDecorationLine`, or empty.
|
|
14
23
|
*/
|
|
15
24
|
function textDecorationLineToEntries(value: LcDeclaration['value']): readonly RNEntry[] {
|
|
16
|
-
if (value === '
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
return []
|
|
25
|
+
if (typeof value === 'string') return RN_DECORATION_LINES.has(value) ? [['textDecorationLine', value]] : []
|
|
26
|
+
if (!Array.isArray(value)) return []
|
|
27
|
+
const line = value.join(' ')
|
|
28
|
+
return RN_DECORATION_LINES.has(line) ? [['textDecorationLine', line]] : []
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
/**
|