@cssxjs/css-to-react-native 3.2.0-0 → 3.2.0-2

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/src/index.js CHANGED
@@ -78,13 +78,141 @@ export const getPropertyName = propName => {
78
78
  return camelizeStyleName(propName)
79
79
  }
80
80
 
81
- export default (rules, shorthandBlacklist = []) =>
82
- rules.reduce((accum, rule) => {
81
+ /**
82
+ * Strip CSS comments from a string
83
+ * Handles both single-line and multi-line comments
84
+ */
85
+ const stripCssComments = css => css.replace(/\/\*[\s\S]*?\*\//g, '')
86
+
87
+ /**
88
+ * Parse CSS declarations into React Native styles (for keyframes)
89
+ */
90
+ const parseKeyframeDeclarations = declarationsStr => {
91
+ const declarations = []
92
+ const parts = declarationsStr.split(';')
93
+
94
+ for (const part of parts) {
95
+ const trimmed = part.trim()
96
+ if (!trimmed) continue
97
+
98
+ const colonIndex = trimmed.indexOf(':')
99
+ if (colonIndex === -1) continue
100
+
101
+ const property = trimmed.substring(0, colonIndex).trim()
102
+ const value = trimmed.substring(colonIndex + 1).trim()
103
+
104
+ if (property && value) {
105
+ declarations.push([property, value])
106
+ }
107
+ }
108
+
109
+ if (declarations.length === 0) {
110
+ return {}
111
+ }
112
+
113
+ // Transform each declaration
114
+ return declarations.reduce((accum, rule) => {
83
115
  const propertyName = getPropertyName(rule[0])
84
116
  const value = rule[1]
85
- const allowShorthand = shorthandBlacklist.indexOf(propertyName) === -1
86
- return Object.assign(
87
- accum,
88
- getStylesForProperty(propertyName, value, allowShorthand)
89
- )
117
+ return Object.assign(accum, getStylesForProperty(propertyName, value, true))
90
118
  }, {})
119
+ }
120
+
121
+ /**
122
+ * Parse keyframe body CSS into a keyframe object
123
+ * @param {string} body - CSS keyframe body
124
+ * @returns {Object} Keyframe object with selectors as keys
125
+ */
126
+ const parseKeyframeBody = body => {
127
+ // Strip CSS comments before parsing
128
+ const cleanBody = stripCssComments(body)
129
+
130
+ const keyframeObject = {}
131
+ const selectorRegex = /([a-zA-Z0-9%,\s]+)\s*\{\s*([^}]*)\s*\}/g
132
+ let selectorMatch
133
+
134
+ // eslint-disable-next-line no-cond-assign
135
+ while ((selectorMatch = selectorRegex.exec(cleanBody)) !== null) {
136
+ const selectors = selectorMatch[1]
137
+ .split(',')
138
+ .map(s => s.trim())
139
+ .filter(s => s)
140
+ const declarations = selectorMatch[2]
141
+
142
+ // Parse CSS declarations into style object
143
+ const styles = parseKeyframeDeclarations(declarations)
144
+
145
+ for (const selector of selectors) {
146
+ keyframeObject[selector] = styles
147
+ }
148
+ }
149
+
150
+ return keyframeObject
151
+ }
152
+
153
+ /**
154
+ * Check if a property name is a @keyframes rule
155
+ */
156
+ const isKeyframesRule = propName =>
157
+ propName.startsWith('@keyframes ') || propName.startsWith('@keyframes\t')
158
+
159
+ /**
160
+ * Extract keyframe name from @keyframes rule
161
+ */
162
+ const getKeyframeName = propName =>
163
+ propName.replace(/^@keyframes\s+/, '').trim()
164
+
165
+ export default (rules, shorthandBlacklist = []) => {
166
+ // First pass: collect @keyframes definitions
167
+ const keyframesMap = {}
168
+
169
+ for (const rule of rules) {
170
+ const propName = rule[0]
171
+ if (isKeyframesRule(propName)) {
172
+ const keyframeName = getKeyframeName(propName)
173
+ const keyframeBody = rule[1]
174
+ keyframesMap[keyframeName] = parseKeyframeBody(keyframeBody)
175
+ }
176
+ }
177
+
178
+ // Second pass: transform all non-keyframes rules
179
+ const result = {}
180
+
181
+ for (const rule of rules) {
182
+ const propName = rule[0]
183
+
184
+ // Skip @keyframes rules in the output
185
+ if (isKeyframesRule(propName)) {
186
+ continue
187
+ }
188
+
189
+ const propertyName = getPropertyName(propName)
190
+ const value = rule[1]
191
+ const allowShorthand = shorthandBlacklist.indexOf(propertyName) === -1
192
+ const propValues = getStylesForProperty(propertyName, value, allowShorthand)
193
+
194
+ Object.assign(result, propValues)
195
+ }
196
+
197
+ // Third pass: replace animationName strings with actual keyframe objects
198
+ if (result.animationName) {
199
+ if (Array.isArray(result.animationName)) {
200
+ result.animationName = result.animationName.map(name => {
201
+ if (typeof name === 'string' && name !== 'none' && keyframesMap[name]) {
202
+ return keyframesMap[name]
203
+ }
204
+ return name
205
+ })
206
+ } else if (typeof result.animationName === 'string') {
207
+ // Handle single value case
208
+ if (
209
+ result.animationName !== 'none' &&
210
+ keyframesMap[result.animationName]
211
+ ) {
212
+ result.animationName = keyframesMap[result.animationName]
213
+ }
214
+ }
215
+ }
216
+
217
+ return result
218
+ }
package/src/tokenTypes.js CHANGED
@@ -29,7 +29,8 @@ const matchColor = node => {
29
29
 
30
30
  const matchVariable = node => {
31
31
  if (
32
- (node.type !== 'function' && node.value !== 'var') ||
32
+ node.type !== 'function' ||
33
+ node.value !== 'var' ||
33
34
  node.nodes.length === 0
34
35
  )
35
36
  return null
@@ -73,6 +74,7 @@ const lengthRe = /^(0$|(?:[+-]?(?:\d*\.)?\d+(?:e[+-]?\d+)?)(?=px$))/i
73
74
  const unsupportedUnitRe = /^([+-]?(?:\d*\.)?\d+(?:e[+-]?\d+)?(ch|em|ex|rem|vh|vw|vmin|vmax|cm|mm|in|pc|pt))$/i
74
75
  const angleRe = /^([+-]?(?:\d*\.)?\d+(?:e[+-]?\d+)?(?:deg|rad|grad|turn))$/i
75
76
  const percentRe = /^([+-]?(?:\d*\.)?\d+(?:e[+-]?\d+)?%)$/i
77
+ const timeRe = /^([+-]?(?:\d*\.)?\d+(?:e[+-]?\d+)?(?:ms|s))$/i
76
78
 
77
79
  const noopToken = predicate => node => (predicate(node) ? '<token>' : null)
78
80
 
@@ -105,8 +107,28 @@ export const LENGTH = regExpToken(lengthRe, Number)
105
107
  export const UNSUPPORTED_LENGTH_UNIT = regExpToken(unsupportedUnitRe)
106
108
  export const ANGLE = regExpToken(angleRe, angle => angle.toLowerCase())
107
109
  export const PERCENT = regExpToken(percentRe)
110
+ export const TIME = regExpToken(timeRe)
108
111
  export const IDENT = regExpToken(identRe)
109
112
  export const STRING = matchString
110
113
  export const COLOR = matchColor
111
114
  export const LINE = regExpToken(/^(none|underline|line-through)$/i)
112
115
  export const VARIABLE = matchVariable
116
+
117
+ // Animation/Transition timing functions (keywords)
118
+ export const TIMING_FUNCTION = regExpToken(
119
+ /^(ease|linear|ease-in|ease-out|ease-in-out|step-start|step-end)$/i
120
+ )
121
+
122
+ // Animation iteration count
123
+ export const ITERATION_COUNT = regExpToken(/^(infinite)$/i)
124
+
125
+ // Animation direction
126
+ export const ANIMATION_DIRECTION = regExpToken(
127
+ /^(normal|reverse|alternate|alternate-reverse)$/i
128
+ )
129
+
130
+ // Animation fill mode
131
+ export const FILL_MODE = regExpToken(/^(none|forwards|backwards|both)$/i)
132
+
133
+ // Animation play state
134
+ export const PLAY_STATE = regExpToken(/^(running|paused)$/i)
@@ -0,0 +1,338 @@
1
+ import { SPACE, COMMA, IDENT, TIME, NUMBER, NONE, VARIABLE } from '../tokenTypes'
2
+
3
+ // Timing function keywords
4
+ const timingFunctionKeywords = [
5
+ 'ease',
6
+ 'linear',
7
+ 'ease-in',
8
+ 'ease-out',
9
+ 'ease-in-out',
10
+ 'step-start',
11
+ 'step-end',
12
+ ]
13
+
14
+ // Direction keywords
15
+ const directionKeywords = [
16
+ 'normal',
17
+ 'reverse',
18
+ 'alternate',
19
+ 'alternate-reverse',
20
+ ]
21
+
22
+ // Fill mode keywords
23
+ const fillModeKeywords = ['none', 'forwards', 'backwards', 'both']
24
+
25
+ // Play state keywords
26
+ const playStateKeywords = ['running', 'paused']
27
+
28
+ const isTimingFunction = value =>
29
+ timingFunctionKeywords.includes(value.toLowerCase())
30
+ const isDirection = value => directionKeywords.includes(value.toLowerCase())
31
+ const isFillMode = value => fillModeKeywords.includes(value.toLowerCase())
32
+ const isPlayState = value => playStateKeywords.includes(value.toLowerCase())
33
+ const isTime = value => /^[+-]?(?:\d*\.)?\d+(?:ms|s)$/i.test(value)
34
+ const isIterationCount = value =>
35
+ value.toLowerCase() === 'infinite' || /^[+-]?(?:\d*\.)?\d+$/.test(value)
36
+
37
+ // Helper to parse comma-separated values
38
+ // Returns single value if only one, array if multiple
39
+ const parseCommaSeparatedValues = (tokenStream, parseValue) => {
40
+ const values = []
41
+ let parsingFirst = true
42
+
43
+ while (tokenStream.hasTokens()) {
44
+ if (!parsingFirst) {
45
+ tokenStream.expect(COMMA)
46
+ }
47
+
48
+ // Skip leading/trailing spaces
49
+ if (tokenStream.matches(SPACE)) {
50
+ // continue
51
+ }
52
+
53
+ const value = parseValue(tokenStream)
54
+ values.push(value)
55
+
56
+ // Skip trailing spaces
57
+ if (tokenStream.matches(SPACE)) {
58
+ // continue
59
+ }
60
+
61
+ parsingFirst = false
62
+ }
63
+
64
+ return values.length === 1 ? values[0] : values
65
+ }
66
+
67
+ // Transform for animation-name property
68
+ export const animationName = tokenStream => {
69
+ const names = parseCommaSeparatedValues(tokenStream, ts =>
70
+ ts.expect(IDENT, NONE, VARIABLE)
71
+ )
72
+ return { animationName: names }
73
+ }
74
+
75
+ // Transform for animation-duration property
76
+ export const animationDuration = tokenStream => {
77
+ const durations = parseCommaSeparatedValues(tokenStream, ts =>
78
+ ts.expect(TIME, VARIABLE)
79
+ )
80
+ return { animationDuration: durations }
81
+ }
82
+
83
+ // Transform for animation-timing-function property
84
+ export const animationTimingFunction = tokenStream => {
85
+ const timingFunctions = parseCommaSeparatedValues(tokenStream, ts => {
86
+ // Check for function (cubic-bezier or steps)
87
+ const funcStream = ts.matchesFunction()
88
+ if (funcStream) {
89
+ const funcName = funcStream.functionName
90
+ const args = []
91
+ while (funcStream.hasTokens()) {
92
+ if (funcStream.matches(SPACE) || funcStream.matches(COMMA)) {
93
+ continue
94
+ }
95
+ const val = funcStream.expect(IDENT, TIME, NUMBER, node => {
96
+ if (node.type === 'word') return node.value
97
+ return null
98
+ })
99
+ args.push(val)
100
+ }
101
+ return `${funcName}(${args.join(', ')})`
102
+ }
103
+ return ts.expect(IDENT)
104
+ })
105
+ return { animationTimingFunction: timingFunctions }
106
+ }
107
+
108
+ // Transform for animation-delay property
109
+ export const animationDelay = tokenStream => {
110
+ const delays = parseCommaSeparatedValues(tokenStream, ts =>
111
+ ts.expect(TIME, VARIABLE)
112
+ )
113
+ return { animationDelay: delays }
114
+ }
115
+
116
+ // Transform for animation-iteration-count property
117
+ export const animationIterationCount = tokenStream => {
118
+ const counts = parseCommaSeparatedValues(tokenStream, ts => {
119
+ if (ts.matches(IDENT)) {
120
+ const value = ts.lastValue
121
+ return value.toLowerCase() === 'infinite' ? 'infinite' : Number(value)
122
+ }
123
+ if (ts.matches(NUMBER)) {
124
+ return ts.lastValue
125
+ }
126
+ const word = ts.expect(node => {
127
+ if (node.type === 'word') return node.value
128
+ return null
129
+ })
130
+ return word.toLowerCase() === 'infinite' ? 'infinite' : Number(word)
131
+ })
132
+ return { animationIterationCount: counts }
133
+ }
134
+
135
+ // Transform for animation-direction property
136
+ export const animationDirection = tokenStream => {
137
+ const directions = parseCommaSeparatedValues(tokenStream, ts =>
138
+ ts.expect(IDENT)
139
+ )
140
+ return { animationDirection: directions }
141
+ }
142
+
143
+ // Transform for animation-fill-mode property
144
+ export const animationFillMode = tokenStream => {
145
+ const fillModes = parseCommaSeparatedValues(tokenStream, ts =>
146
+ ts.expect(IDENT)
147
+ )
148
+ return { animationFillMode: fillModes }
149
+ }
150
+
151
+ // Transform for animation-play-state property
152
+ export const animationPlayState = tokenStream => {
153
+ const playStates = parseCommaSeparatedValues(tokenStream, ts =>
154
+ ts.expect(IDENT)
155
+ )
156
+ return { animationPlayState: playStates }
157
+ }
158
+
159
+ export default tokenStream => {
160
+ // Handle 'none'
161
+ if (tokenStream.matches(NONE)) {
162
+ tokenStream.expectEmpty()
163
+ return {
164
+ animationName: 'none',
165
+ animationDuration: '0s',
166
+ animationTimingFunction: 'ease',
167
+ animationDelay: '0s',
168
+ animationIterationCount: 1,
169
+ animationDirection: 'normal',
170
+ animationFillMode: 'none',
171
+ animationPlayState: 'running',
172
+ }
173
+ }
174
+
175
+ const names = []
176
+ const durations = []
177
+ const timingFunctions = []
178
+ const delays = []
179
+ const iterationCounts = []
180
+ const directions = []
181
+ const fillModes = []
182
+ const playStates = []
183
+
184
+ let parsingFirst = true
185
+
186
+ while (tokenStream.hasTokens()) {
187
+ if (!parsingFirst) {
188
+ tokenStream.expect(COMMA)
189
+ }
190
+
191
+ // Parse single animation
192
+ let name = null
193
+ let duration = null
194
+ let timingFunction = null
195
+ let delay = null
196
+ let iterationCount = null
197
+ let direction = null
198
+ let fillMode = null
199
+ let playState = null
200
+
201
+ // Skip leading space
202
+ if (tokenStream.matches(SPACE)) {
203
+ // continue
204
+ }
205
+
206
+ // Parse tokens for this animation
207
+ while (tokenStream.hasTokens()) {
208
+ // Check for comma (next animation)
209
+ tokenStream.saveRewindPoint()
210
+ if (tokenStream.matches(SPACE)) {
211
+ if (tokenStream.matches(COMMA)) {
212
+ tokenStream.rewind()
213
+ break
214
+ }
215
+ }
216
+ if (tokenStream.matches(COMMA)) {
217
+ tokenStream.rewind()
218
+ break
219
+ }
220
+ tokenStream.rewind()
221
+
222
+ // Skip spaces
223
+ if (tokenStream.matches(SPACE)) {
224
+ continue
225
+ }
226
+
227
+ // Match time or variable (for duration/delay) - check before functions
228
+ if (tokenStream.matches(TIME) || tokenStream.matches(VARIABLE)) {
229
+ const value = tokenStream.lastValue
230
+ if (duration === null) {
231
+ duration = value
232
+ } else {
233
+ delay = value
234
+ }
235
+ continue
236
+ }
237
+
238
+ // Check for timing function (cubic-bezier or steps)
239
+ const funcStream = tokenStream.matchesFunction()
240
+ if (funcStream) {
241
+ const funcName = funcStream.functionName
242
+ const args = []
243
+ while (funcStream.hasTokens()) {
244
+ if (funcStream.matches(SPACE) || funcStream.matches(COMMA)) {
245
+ continue
246
+ }
247
+ const val = funcStream.expect(IDENT, TIME, NUMBER, node => {
248
+ if (node.type === 'word') return node.value
249
+ return null
250
+ })
251
+ args.push(val)
252
+ }
253
+ timingFunction = `${funcName}(${args.join(', ')})`
254
+ continue
255
+ }
256
+
257
+ // Match number (iteration count)
258
+ if (tokenStream.matches(NUMBER)) {
259
+ iterationCount = tokenStream.lastValue
260
+ continue
261
+ }
262
+
263
+ // Match identifier
264
+ if (tokenStream.matches(IDENT)) {
265
+ const value = tokenStream.lastValue
266
+ if (isTimingFunction(value)) {
267
+ timingFunction = value
268
+ } else if (isDirection(value)) {
269
+ direction = value
270
+ } else if (isFillMode(value)) {
271
+ fillMode = value
272
+ } else if (isPlayState(value)) {
273
+ playState = value
274
+ } else if (value.toLowerCase() === 'infinite') {
275
+ iterationCount = 'infinite'
276
+ } else {
277
+ // It's the animation name
278
+ name = value
279
+ }
280
+ continue
281
+ }
282
+
283
+ // Try to match as generic word
284
+ const wordMatch = tokenStream.expect(node => {
285
+ if (node.type === 'word') return node.value
286
+ return null
287
+ })
288
+
289
+ if (isTime(wordMatch)) {
290
+ if (duration === null) {
291
+ duration = wordMatch
292
+ } else {
293
+ delay = wordMatch
294
+ }
295
+ } else if (isTimingFunction(wordMatch)) {
296
+ timingFunction = wordMatch
297
+ } else if (isDirection(wordMatch)) {
298
+ direction = wordMatch
299
+ } else if (isFillMode(wordMatch)) {
300
+ fillMode = wordMatch
301
+ } else if (isPlayState(wordMatch)) {
302
+ playState = wordMatch
303
+ } else if (isIterationCount(wordMatch)) {
304
+ iterationCount =
305
+ wordMatch.toLowerCase() === 'infinite'
306
+ ? 'infinite'
307
+ : Number(wordMatch)
308
+ } else {
309
+ name = wordMatch
310
+ }
311
+ }
312
+
313
+ // Apply defaults and push
314
+ names.push(name || 'none')
315
+ durations.push(duration || '0s')
316
+ timingFunctions.push(timingFunction || 'ease')
317
+ delays.push(delay || '0s')
318
+ iterationCounts.push(iterationCount !== null ? iterationCount : 1)
319
+ directions.push(direction || 'normal')
320
+ fillModes.push(fillMode || 'none')
321
+ playStates.push(playState || 'running')
322
+
323
+ parsingFirst = false
324
+ }
325
+
326
+ // Return single values if only one animation, arrays if multiple
327
+ const isSingle = names.length === 1
328
+ return {
329
+ animationName: isSingle ? names[0] : names,
330
+ animationDuration: isSingle ? durations[0] : durations,
331
+ animationTimingFunction: isSingle ? timingFunctions[0] : timingFunctions,
332
+ animationDelay: isSingle ? delays[0] : delays,
333
+ animationIterationCount: isSingle ? iterationCounts[0] : iterationCounts,
334
+ animationDirection: isSingle ? directions[0] : directions,
335
+ animationFillMode: isSingle ? fillModes[0] : fillModes,
336
+ animationPlayState: isSingle ? playStates[0] : playStates,
337
+ }
338
+ }
@@ -1,11 +1,10 @@
1
- import { parseShadow } from './util'
1
+ import { stringify } from 'postcss-value-parser'
2
2
 
3
3
  export default tokenStream => {
4
- const { offset, radius, color } = parseShadow(tokenStream)
4
+ // React Native now supports web-style box-shadow format directly
5
+ // Pass through the original CSS value as boxShadow
6
+ const value = stringify(tokenStream.nodes)
5
7
  return {
6
- shadowOffset: offset,
7
- shadowRadius: radius,
8
- shadowColor: color,
9
- shadowOpacity: 1,
8
+ boxShadow: value,
10
9
  }
11
10
  }
@@ -7,6 +7,16 @@ import {
7
7
  WORD,
8
8
  VARIABLE,
9
9
  } from '../tokenTypes'
10
+ import animation, {
11
+ animationName,
12
+ animationDuration,
13
+ animationTimingFunction,
14
+ animationDelay,
15
+ animationIterationCount,
16
+ animationDirection,
17
+ animationFillMode,
18
+ animationPlayState,
19
+ } from './animation'
10
20
  import aspectRatio from './aspectRatio'
11
21
  import border from './border'
12
22
  import boxShadow from './boxShadow'
@@ -20,6 +30,12 @@ import textDecoration from './textDecoration'
20
30
  import textDecorationLine from './textDecorationLine'
21
31
  import textShadow from './textShadow'
22
32
  import transform from './transform'
33
+ import transition, {
34
+ transitionProperty,
35
+ transitionDuration,
36
+ transitionTimingFunction,
37
+ transitionDelay,
38
+ } from './transition'
23
39
  import { directionFactory, parseShadowOffset } from './util'
24
40
 
25
41
  const background = tokenStream => ({
@@ -37,7 +53,7 @@ const borderRadius = directionFactory({
37
53
  })
38
54
  const borderWidth = directionFactory({ prefix: 'border', suffix: 'Width' })
39
55
  const margin = directionFactory({
40
- types: [LENGTH, UNSUPPORTED_LENGTH_UNIT, PERCENT, AUTO],
56
+ types: [LENGTH, UNSUPPORTED_LENGTH_UNIT, PERCENT, AUTO, VARIABLE],
41
57
  prefix: 'margin',
42
58
  })
43
59
  const padding = directionFactory({ prefix: 'padding' })
@@ -53,6 +69,15 @@ const textShadowOffset = tokenStream => ({
53
69
  })
54
70
 
55
71
  export default {
72
+ animation,
73
+ animationName,
74
+ animationDuration,
75
+ animationTimingFunction,
76
+ animationDelay,
77
+ animationIterationCount,
78
+ animationDirection,
79
+ animationFillMode,
80
+ animationPlayState,
56
81
  aspectRatio,
57
82
  background,
58
83
  border,
@@ -75,4 +100,9 @@ export default {
75
100
  textDecoration,
76
101
  textDecorationLine,
77
102
  transform,
103
+ transition,
104
+ transitionProperty,
105
+ transitionDuration,
106
+ transitionTimingFunction,
107
+ transitionDelay,
78
108
  }
@@ -1,4 +1,12 @@
1
- import { SPACE, COMMA, LENGTH, NUMBER, ANGLE, PERCENT } from '../tokenTypes'
1
+ import {
2
+ SPACE,
3
+ COMMA,
4
+ LENGTH,
5
+ NUMBER,
6
+ ANGLE,
7
+ PERCENT,
8
+ VARIABLE,
9
+ } from '../tokenTypes'
2
10
 
3
11
  const oneOfTypes = tokenTypes => functionStream => {
4
12
  const value = functionStream.expect(...tokenTypes)
@@ -6,9 +14,9 @@ const oneOfTypes = tokenTypes => functionStream => {
6
14
  return value
7
15
  }
8
16
 
9
- const singleNumber = oneOfTypes([NUMBER])
10
- const singleLengthOrPercent = oneOfTypes([LENGTH, PERCENT])
11
- const singleAngle = oneOfTypes([ANGLE])
17
+ const singleNumber = oneOfTypes([NUMBER, VARIABLE])
18
+ const singleLengthOrPercent = oneOfTypes([LENGTH, PERCENT, VARIABLE])
19
+ const singleAngle = oneOfTypes([ANGLE, VARIABLE])
12
20
  const xyTransformFactory = tokenTypes => (
13
21
  key,
14
22
  valueIfOmitted
@@ -31,9 +39,9 @@ const xyTransformFactory = tokenTypes => (
31
39
 
32
40
  return [{ [`${key}Y`]: y }, { [`${key}X`]: x }]
33
41
  }
34
- const xyNumber = xyTransformFactory([NUMBER])
35
- const xyLengthOrPercent = xyTransformFactory([LENGTH, PERCENT])
36
- const xyAngle = xyTransformFactory([ANGLE])
42
+ const xyNumber = xyTransformFactory([NUMBER, VARIABLE])
43
+ const xyLengthOrPercent = xyTransformFactory([LENGTH, PERCENT, VARIABLE])
44
+ const xyAngle = xyTransformFactory([ANGLE, VARIABLE])
37
45
 
38
46
  const partTransforms = {
39
47
  perspective: singleNumber,