Skip to content

Commit e4ea442

Browse files
committed
replace-sass-transition migration
1 parent 8c19896 commit e4ea442

File tree

8 files changed

+1260
-4
lines changed

8 files changed

+1260
-4
lines changed

.changeset/famous-ghosts-march.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/polaris-migrator': minor
3+
---
4+
5+
Introduce `migrate-motion` migration for migrating `transition`, `transition-duration`, and `transition-delay` usages of duration values.
Lines changed: 389 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,389 @@
1+
import {Declaration} from 'postcss';
2+
import valueParser, {
3+
ParsedValue,
4+
Node,
5+
FunctionNode,
6+
} from 'postcss-value-parser';
7+
8+
import {
9+
namespace,
10+
isSassFunction,
11+
isSassVariable,
12+
hasNumericOperator,
13+
isTransformableDuration,
14+
isPolarisVar,
15+
createSassMigrator,
16+
} from '../../utilities/sass';
17+
import {isKeyOf} from '../../utilities/type-guards';
18+
19+
const DEFAULT_DURATION = 'base';
20+
const DEFAULT_FUNCTION = 'base';
21+
22+
const durationFuncMap = {
23+
none: '--p-duration-0',
24+
fast: '--p-duration-100',
25+
base: '--p-duration-200',
26+
slow: '--p-duration-300',
27+
slower: '--p-duration-400',
28+
slowest: '--p-duration-500',
29+
};
30+
31+
const durationConstantsMap = {
32+
'0': '--p-duration-0',
33+
'0s': '--p-duration-0',
34+
'0ms': '--p-duration-0',
35+
'50ms': '--p-duration-50',
36+
'0.05s': '--p-duration-50',
37+
'100ms': '--p-duration-100',
38+
'0.1s': '--p-duration-100',
39+
'150ms': '--p-duration-150',
40+
'0.15s': '--p-duration-150',
41+
'200ms': '--p-duration-200',
42+
'0.2s': '--p-duration-200',
43+
'250ms': '--p-duration-250',
44+
'0.25s': '--p-duration-250',
45+
'300ms': '--p-duration-300',
46+
'0.3s': '--p-duration-300',
47+
'350ms': '--p-duration-350',
48+
'0.35s': '--p-duration-350',
49+
'400ms': '--p-duration-400',
50+
'0.4s': '--p-duration-400',
51+
'450ms': '--p-duration-450',
52+
'0.45s': '--p-duration-450',
53+
'500ms': '--p-duration-500',
54+
'0.5s': '--p-duration-500',
55+
'5s': '--p-duration-5000',
56+
};
57+
58+
const easingFuncMap = {
59+
base: '--p-ease',
60+
in: '--p-ease-in',
61+
out: '--p-ease-out',
62+
};
63+
64+
const easingFuncConstantsMap = {
65+
linear: '--p-linear',
66+
ease: '--p-ease',
67+
'ease-in': '--p-ease-in',
68+
'ease-out': '--p-ease-out',
69+
'ease-in-out': '--p-ease-in-out',
70+
};
71+
72+
const deprecatedEasingFuncs = ['anticipate', 'excite', 'overshoot'];
73+
74+
// Per the spec for transition easing functions:
75+
// https://w3c.github.io/csswg-drafts/css-easing/#easing-functions
76+
const cssEasingBuiltinFuncs = [
77+
'linear',
78+
'ease',
79+
'ease-in',
80+
'ease-out',
81+
'ease-in-out',
82+
'cubic-bezier',
83+
'step-start',
84+
'step-end',
85+
'steps',
86+
];
87+
88+
function normaliseStringifiedNumber(number: string): string {
89+
return Number(number).toString();
90+
}
91+
92+
function setNodeValue(node: Node, value: string): void {
93+
const {sourceIndex} = node;
94+
const parsedValue = valueParser(value).nodes[0];
95+
Object.assign(node, parsedValue);
96+
// The node we're replacing might be mid-way through a higher-level value
97+
// string. Eg; 'border: 1px solid', the 'solid' node is 5 characters into the
98+
// higher-level value, so we need to correct the index here.
99+
node.sourceIndex += sourceIndex;
100+
node.sourceEndIndex += sourceIndex;
101+
}
102+
103+
export default createSassMigrator(
104+
'replace-sass-transition',
105+
(_, {methods, options}, context) => {
106+
const durationFunc = namespace('duration', options);
107+
108+
function migrateLegacySassEasingFunction(
109+
node: FunctionNode,
110+
decl: Declaration,
111+
) {
112+
const easingFunc = node.nodes[0]?.value ?? DEFAULT_FUNCTION;
113+
114+
if (!isKeyOf(easingFuncMap, easingFunc)) {
115+
const comment = deprecatedEasingFuncs.includes(easingFunc)
116+
? `The ${easingFunc} easing function is no longer available in Polaris. See https://polaris.shopify.com/tokens/motion for possible values.`
117+
: `Unexpected easing function '${easingFunc}'.`;
118+
119+
methods.report({
120+
severity: 'warning',
121+
node: decl,
122+
message: comment,
123+
});
124+
125+
return;
126+
}
127+
128+
const easingCustomProperty = easingFuncMap[easingFunc];
129+
const targetValue = `var(${easingCustomProperty})`;
130+
131+
if (context.fix) {
132+
setNodeValue(node, targetValue);
133+
} else {
134+
methods.report({
135+
severity: 'error',
136+
node: decl,
137+
message: `Replace easing function with token: ${targetValue}`,
138+
});
139+
}
140+
}
141+
142+
function insertUnexpectedEasingFunctionComment(
143+
node: Node,
144+
decl: Declaration,
145+
) {
146+
methods.report({
147+
severity: 'warning',
148+
node: decl,
149+
message: `Unexpected easing function '${node.value}'. See https://polaris.shopify.com/tokens/motion for possible values.`,
150+
});
151+
}
152+
153+
function mutateTransitionDurationValue(
154+
node: Node,
155+
decl: Declaration,
156+
): void {
157+
if (isPolarisVar(node)) {
158+
return;
159+
}
160+
161+
if (isSassVariable(node)) {
162+
methods.report({
163+
severity: 'warning',
164+
node: decl,
165+
message: `Cannot statically analyse SASS variable ${node.value}.`,
166+
});
167+
return;
168+
}
169+
170+
if (isSassFunction(durationFunc, node)) {
171+
const duration = node.nodes[0]?.value ?? DEFAULT_DURATION;
172+
173+
if (!isKeyOf(durationFuncMap, duration)) {
174+
methods.report({
175+
severity: 'warning',
176+
node: decl,
177+
message: `Unknown duration key '${duration}'.`,
178+
});
179+
return;
180+
}
181+
182+
const durationCustomProperty = durationFuncMap[duration];
183+
const targetValue = `var(${durationCustomProperty})`;
184+
185+
if (context.fix) {
186+
setNodeValue(node, targetValue);
187+
} else {
188+
methods.report({
189+
severity: 'error',
190+
node: decl,
191+
message: `Replace duration with token: ${targetValue}`,
192+
});
193+
}
194+
195+
return;
196+
}
197+
198+
const unit = valueParser.unit(node.value);
199+
if (unit) {
200+
const constantDuration = `${normaliseStringifiedNumber(unit.number)}${
201+
unit.unit
202+
}`;
203+
204+
if (!isKeyOf(durationConstantsMap, constantDuration)) {
205+
methods.report({
206+
severity: 'warning',
207+
node: decl,
208+
message: `No matching duration token for '${constantDuration}'.`,
209+
});
210+
211+
return;
212+
}
213+
214+
const durationCustomProperty = durationConstantsMap[constantDuration];
215+
const targetValue = `var(${durationCustomProperty})`;
216+
217+
if (context.fix) {
218+
setNodeValue(node, targetValue);
219+
} else {
220+
methods.report({
221+
severity: 'error',
222+
node: decl,
223+
message: `Replace duration value with token: ${targetValue}`,
224+
});
225+
}
226+
}
227+
}
228+
229+
function mutateTransitionFunctionValue(
230+
node: Node,
231+
decl: Declaration,
232+
): void {
233+
if (isPolarisVar(node)) {
234+
return;
235+
}
236+
237+
if (isSassVariable(node)) {
238+
methods.report({
239+
severity: 'warning',
240+
node: decl,
241+
message: `Cannot statically analyse SASS variable ${node.value}.`,
242+
});
243+
return;
244+
}
245+
246+
if (node.type === 'function') {
247+
const easingFuncHandlers = {
248+
[namespace('easing', options)]: migrateLegacySassEasingFunction,
249+
// Per the spec, these can all be functions:
250+
// https://w3c.github.io/csswg-drafts/css-easing/#easing-functions
251+
linear: insertUnexpectedEasingFunctionComment,
252+
'cubic-bezier': insertUnexpectedEasingFunctionComment,
253+
steps: insertUnexpectedEasingFunctionComment,
254+
};
255+
256+
if (isKeyOf(easingFuncHandlers, node.value)) {
257+
easingFuncHandlers[node.value](node, decl);
258+
return;
259+
}
260+
}
261+
262+
if (node.type === 'word') {
263+
if (isKeyOf(easingFuncConstantsMap, node.value)) {
264+
const targetValue = `var(${easingFuncConstantsMap[node.value]})`;
265+
266+
if (context.fix) {
267+
setNodeValue(node, targetValue);
268+
} else {
269+
methods.report({
270+
severity: 'error',
271+
node: decl,
272+
message: `Replace easing function with token: ${targetValue}`,
273+
});
274+
}
275+
276+
return;
277+
}
278+
279+
if (cssEasingBuiltinFuncs.includes(node.value)) {
280+
insertUnexpectedEasingFunctionComment(node, decl);
281+
}
282+
}
283+
}
284+
285+
function mutateTransitionDelayValue(node: Node, decl: Declaration): void {
286+
// For now, we treat delays like durations
287+
return mutateTransitionDurationValue(node, decl);
288+
}
289+
290+
function mutateTransitionShorthandValue(
291+
decl: Declaration,
292+
parsedValue: ParsedValue,
293+
): void {
294+
const splitValues: Node[][] = [[]];
295+
296+
// Gathering up references of nodes into groups. Important to note that
297+
// we're dealing with mutable structures here, so we are purposefully
298+
// NOT making copies.
299+
parsedValue.nodes.forEach((node) => {
300+
if (node.type === 'div') {
301+
splitValues.push([]);
302+
} else {
303+
splitValues[splitValues.length - 1].push(node);
304+
}
305+
});
306+
307+
splitValues.forEach((nodes) => {
308+
// From the spec:
309+
//
310+
// Note that order is important within the items in this property: the
311+
// first value that can be parsed as a time is assigned to the
312+
// transition-duration, and the second value that can be parsed as a
313+
// time is assigned to transition-delay.
314+
// https://w3c.github.io/csswg-drafts/css-transitions-1/#transition-shorthand-property
315+
//
316+
// That sounds like an array to me! [0] is duration, [1] is delay.
317+
const timings: Node[] = [];
318+
319+
nodes.forEach((node) => {
320+
const unit = valueParser.unit(node.value);
321+
if (
322+
isTransformableDuration(unit) ||
323+
isSassFunction(durationFunc, node)
324+
) {
325+
timings.push(node);
326+
} else {
327+
// This node could be either the property to animate, or an easing
328+
// function. We try mutate the easing function, but if not we assume
329+
// it's the property to animate and therefore do not leave a comment.
330+
mutateTransitionFunctionValue(node, decl);
331+
}
332+
});
333+
334+
if (timings[0]) {
335+
mutateTransitionDurationValue(timings[0], decl);
336+
}
337+
338+
if (timings[1]) {
339+
mutateTransitionDelayValue(timings[1], decl);
340+
}
341+
});
342+
}
343+
344+
return (root) => {
345+
methods.walkDecls(root, (decl) => {
346+
const handlers: {[key: string]: () => void} = {
347+
'transition-duration': () => {
348+
parsedValue.nodes.forEach((node) => {
349+
mutateTransitionDurationValue(node, decl);
350+
});
351+
},
352+
'transition-delay': () => {
353+
parsedValue.nodes.forEach((node) => {
354+
mutateTransitionDelayValue(node, decl);
355+
});
356+
},
357+
'transition-timing-function': () => {
358+
parsedValue.nodes.forEach((node) => {
359+
mutateTransitionFunctionValue(node, decl);
360+
});
361+
},
362+
transition: () => {
363+
mutateTransitionShorthandValue(decl, parsedValue);
364+
},
365+
};
366+
367+
if (!handlers[decl.prop]) {
368+
return;
369+
}
370+
371+
const parsedValue = valueParser(decl.value);
372+
373+
if (hasNumericOperator(parsedValue)) {
374+
methods.report({
375+
node: decl,
376+
severity: 'warning',
377+
message: 'Numeric operator detected.',
378+
});
379+
}
380+
381+
handlers[decl.prop]();
382+
383+
if (context.fix) {
384+
decl.value = parsedValue.toString();
385+
}
386+
});
387+
};
388+
},
389+
);

0 commit comments

Comments
 (0)