Skip to content

Commit 464c2c6

Browse files
committed
feat: create media query transformer to ensure last query wins
1 parent 0d5a240 commit 464c2c6

File tree

3 files changed

+391
-1
lines changed

3 files changed

+391
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
*
8+
*/
9+
10+
import { lastMediaQueryWinsTransform } from '../media-query-transform.js';
11+
12+
const stylex = {
13+
create: (styles) => styles,
14+
};
15+
16+
describe('Media Query Transformer', () => {
17+
test('basic usage: multiple widths', () => {
18+
const originalStyles = {
19+
gridColumn: {
20+
default: '1 / 2',
21+
'@media (max-width: 1440px)': '1 / 4',
22+
'@media (max-width: 1024px)': '1 / 3',
23+
'@media (max-width: 768px)': '1 / -1',
24+
},
25+
};
26+
27+
const expectedStyles = {
28+
gridColumn: {
29+
default: '1 / 2',
30+
'@media (max-width: 1440px) and (not (max-width: 1024px)) and (not (max-width: 768px))':
31+
'1 / 4',
32+
'@media (max-width: 1024px) and (not (max-width: 768px))': '1 / 3',
33+
'@media (max-width: 768px)': '1 / -1',
34+
},
35+
};
36+
37+
const result = lastMediaQueryWinsTransform(originalStyles);
38+
expect(JSON.stringify(result)).toBe(JSON.stringify(expectedStyles));
39+
});
40+
41+
test('basic usage: nested query', () => {
42+
const originalStyles = {
43+
gridColumn: {
44+
default: '1 / 2',
45+
'@media (max-width: 1440px)': {
46+
'@media (max-width: 1024px)': '1 / 3',
47+
'@media (max-width: 768px)': '1 / -1',
48+
},
49+
'@media (max-width: 1024px)': '1 / 3',
50+
'@media (max-width: 768px)': '1 / -1',
51+
},
52+
};
53+
54+
const expectedStyles = {
55+
gridColumn: {
56+
default: '1 / 2',
57+
'@media (max-width: 1440px) and (not (max-width: 1024px)) and (not (max-width: 768px))':
58+
{
59+
'@media (max-width: 1024px) and (not (max-width: 768px))': '1 / 3',
60+
'@media (max-width: 768px)': '1 / -1',
61+
},
62+
'@media (max-width: 1024px) and (not (max-width: 768px))': '1 / 3',
63+
'@media (max-width: 768px)': '1 / -1',
64+
},
65+
};
66+
67+
const result = lastMediaQueryWinsTransform(originalStyles);
68+
expect(JSON.stringify(result)).toBe(JSON.stringify(expectedStyles));
69+
});
70+
71+
test('basic usage: nested query', () => {
72+
const originalStyles = {
73+
table: {
74+
gridColumn: {
75+
default: '1 / 2',
76+
'@media (max-width: 1440px)': {
77+
'@media (max-width: 1024px)': '1 / 3',
78+
'@media (max-width: 768px)': '1 / -1',
79+
},
80+
'@media (max-width: 1024px)': '1 / 3',
81+
'@media (max-width: 768px)': '1 / -1',
82+
},
83+
padding: '10px',
84+
},
85+
};
86+
87+
const expectedStyles = {
88+
table: {
89+
gridColumn: {
90+
default: '1 / 2',
91+
'@media (max-width: 1440px) and (not (max-width: 1024px)) and (not (max-width: 768px))':
92+
{
93+
'@media (max-width: 1024px) and (not (max-width: 768px))':
94+
'1 / 3',
95+
'@media (max-width: 768px)': '1 / -1',
96+
},
97+
'@media (max-width: 1024px) and (not (max-width: 768px))': '1 / 3',
98+
'@media (max-width: 768px)': '1 / -1',
99+
},
100+
padding: '10px',
101+
},
102+
};
103+
104+
const result = lastMediaQueryWinsTransform(originalStyles);
105+
106+
expect(JSON.stringify(result)).toBe(JSON.stringify(expectedStyles));
107+
});
108+
109+
test('basic usage: complex object', () => {
110+
const originalStyles = {
111+
gridColumn: {
112+
default: '1 / 2',
113+
'@media (max-width: 1440px)': '1 / 4',
114+
'@media (max-width: 1024px)': '1 / 3',
115+
'@media (max-width: 768px)': '1 / -1',
116+
},
117+
grid: {
118+
default: '1 / 2',
119+
'@media (max-width: 1440px)': '1 / 4',
120+
},
121+
gridRow: {
122+
default: '1 / 2',
123+
padding: '10px',
124+
},
125+
};
126+
127+
const expectedStyles = {
128+
gridColumn: {
129+
default: '1 / 2',
130+
'@media (max-width: 1440px) and (not (max-width: 1024px)) and (not (max-width: 768px))':
131+
'1 / 4',
132+
'@media (max-width: 1024px) and (not (max-width: 768px))': '1 / 3',
133+
'@media (max-width: 768px)': '1 / -1',
134+
},
135+
grid: {
136+
default: '1 / 2',
137+
'@media (max-width: 1440px)': '1 / 4',
138+
},
139+
gridRow: {
140+
default: '1 / 2',
141+
padding: '10px',
142+
},
143+
};
144+
145+
const result = lastMediaQueryWinsTransform(originalStyles);
146+
expect(JSON.stringify(result)).toBe(JSON.stringify(expectedStyles));
147+
});
148+
149+
test('basic usage: lots and lots of widths', () => {
150+
const originalStyles = {
151+
gridColumn: {
152+
default: '1 / 2',
153+
'@media (max-width: 1440px)': '1 / 4',
154+
'@media (max-width: 1024px)': '1 / 3',
155+
'@media (max-width: 768px)': '1 / -1',
156+
'@media (max-width: 458px)': '1 / -1',
157+
},
158+
};
159+
160+
const expectedStyles = {
161+
gridColumn: {
162+
default: '1 / 2',
163+
'@media (max-width: 1440px) and (not (max-width: 1024px)) and (not (max-width: 768px)) and (not (max-width: 458px))':
164+
'1 / 4',
165+
'@media (max-width: 1024px) and (not (max-width: 768px)) and (not (max-width: 458px))':
166+
'1 / 3',
167+
'@media (max-width: 768px) and (not (max-width: 458px))': '1 / -1',
168+
'@media (max-width: 458px)': '1 / -1',
169+
},
170+
};
171+
172+
const result = lastMediaQueryWinsTransform(originalStyles);
173+
expect(JSON.stringify(result)).toBe(JSON.stringify(expectedStyles));
174+
});
175+
176+
test('single word condition', () => {
177+
const originalStyles = stylex.create({
178+
colorMode: {
179+
'@media (color)': 'colorful',
180+
'@media (monochrome)': 'grayscale',
181+
},
182+
});
183+
184+
const expectedStyles = {
185+
colorMode: {
186+
'@media (color) and (not (monochrome))': 'colorful',
187+
'@media (monochrome)': 'grayscale',
188+
},
189+
};
190+
191+
const result = lastMediaQueryWinsTransform(originalStyles);
192+
expect(JSON.stringify(result)).toBe(JSON.stringify(expectedStyles));
193+
});
194+
195+
test('handles comma-separated (or) media queries', () => {
196+
const originalStyles = stylex.create({
197+
container: {
198+
default: 'width: 100%',
199+
'@media screen, (max-width: 800px)': 'width: 80%',
200+
'@media (max-width: 500px)': 'width: 60%',
201+
},
202+
});
203+
204+
const expectedStyles = {
205+
container: {
206+
default: 'width: 100%',
207+
'@media screen and (not (max-width: 500px)), (max-width: 800px) and (not (max-width: 500px))':
208+
'width: 80%',
209+
'@media (max-width: 500px)': 'width: 60%',
210+
},
211+
};
212+
213+
const result = lastMediaQueryWinsTransform(originalStyles);
214+
expect(JSON.stringify(result)).toBe(JSON.stringify(expectedStyles));
215+
});
216+
217+
test('handles and media queries', () => {
218+
const originalStyles = stylex.create({
219+
container: {
220+
default: 'width:100%',
221+
'@media (min-width: 900px)': 'width:80%',
222+
'@media (min-width: 500px) and (max-width: 899px) and (max-height: 300px)':
223+
'width:50%',
224+
},
225+
});
226+
227+
const expectedStyles = {
228+
container: {
229+
default: 'width:100%',
230+
'@media (min-width: 900px) and (not ((min-width: 500px) and (max-width: 899px) and (max-height: 300px)))':
231+
'width:80%',
232+
'@media (min-width: 500px) and (max-width: 899px) and (max-height: 300px)':
233+
'width:50%',
234+
},
235+
};
236+
237+
const result = lastMediaQueryWinsTransform(originalStyles);
238+
expect(JSON.stringify(result)).toBe(JSON.stringify(expectedStyles));
239+
});
240+
241+
test('combination of keywords and rules', () => {
242+
const originalStyles = stylex.create({
243+
container: {
244+
default: 'width:100%',
245+
'@media screen and (min-width: 900px)': 'width:80%',
246+
'@media print and (max-width: 500px)': 'width:50%',
247+
},
248+
});
249+
250+
const expectedStyles = {
251+
container: {
252+
default: 'width:100%',
253+
'@media screen and (min-width: 900px) and (not (print and (max-width: 500px)))':
254+
'width:80%',
255+
'@media print and (max-width: 500px)': 'width:50%',
256+
},
257+
};
258+
259+
const result = lastMediaQueryWinsTransform(originalStyles);
260+
expect(JSON.stringify(result)).toBe(JSON.stringify(expectedStyles));
261+
});
262+
});
263+
264+
// NotMediaQuery errors here due to all keyword
265+
// test('not rule logic', () => {
266+
// const originalStyles = stylex.create({
267+
// layout: {
268+
// default: 'grid',
269+
// '@media not all and (max-width: 700px)': 'flex',
270+
// '@media (max-width: 600px)': 'block',
271+
// },
272+
// });
273+
274+
// const expectedStyles = {
275+
// layout: {
276+
// default: 'grid',
277+
// '@media not all and (max-width: 700px) and (not (max-width: 600px))':
278+
// 'flex',
279+
// '@media (max-width: 600px)': 'block',
280+
// },
281+
// };
282+
283+
// const result = lastMediaQueryWinsTransform(originalStyles);
284+
// expect(result).toEqual(expectedStyles);
285+
// });
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict
8+
*/
9+
10+
import { MediaQuery } from './media-query.js';
11+
12+
export function lastMediaQueryWinsTransform(styles: Object): Object {
13+
return dfsProcessQueries(styles);
14+
}
15+
16+
function combineMediaQueryWithNegations(
17+
current: MediaQuery,
18+
negations: MediaQuery[],
19+
): MediaQuery {
20+
if (negations.length === 0) {
21+
return current;
22+
}
23+
24+
let combinedAst;
25+
26+
if (current.queries.type === 'or') {
27+
combinedAst = {
28+
type: 'or',
29+
rules: current.queries.rules.map((rule) => ({
30+
type: 'and',
31+
rules: [
32+
rule,
33+
...negations.map((mq) => ({ type: 'not', rule: mq.queries })),
34+
],
35+
})),
36+
};
37+
} else {
38+
combinedAst = {
39+
type: 'and',
40+
rules: [
41+
current.queries,
42+
...negations.map((mq) => ({ type: 'not', rule: mq.queries })),
43+
],
44+
};
45+
}
46+
47+
return new MediaQuery(combinedAst);
48+
}
49+
50+
function dfsProcessQueries(obj: { [key: string]: any }): {
51+
[key: string]: any,
52+
} {
53+
const result: { [key: string]: any } = {};
54+
55+
Object.entries(obj).forEach(([key, value]) => {
56+
if (typeof value === 'object' && value !== null) {
57+
result[key] = dfsProcessQueries(value);
58+
} else {
59+
result[key] = value;
60+
}
61+
});
62+
63+
if (Object.keys(result).some((key) => key.startsWith('@media '))) {
64+
const mediaKeys = Object.keys(result).filter((key) =>
65+
key.startsWith('@media '),
66+
);
67+
68+
const negations = [];
69+
const accumulatedNegations = [];
70+
71+
for (let i = mediaKeys.length - 1; i > 0; i--) {
72+
// Skip last iteration
73+
const mediaQuery = MediaQuery.parser.parseToEnd(mediaKeys[i]);
74+
negations.push(mediaQuery);
75+
accumulatedNegations.push([...negations]); // Clone array before pushing
76+
}
77+
accumulatedNegations.reverse();
78+
accumulatedNegations.push([]);
79+
console.log('accumulatedNegations', accumulatedNegations);
80+
81+
for (let i = 0; i < mediaKeys.length; i++) {
82+
const currentKey = mediaKeys[i];
83+
const currentValue = result[currentKey];
84+
85+
const baseMediaQuery = MediaQuery.parser.parseToEnd(currentKey);
86+
const reversedNegations = [...accumulatedNegations[i]].reverse();
87+
console.log('start');
88+
console.dir(baseMediaQuery, { depth: null });
89+
console.dir(reversedNegations, { depth: null });
90+
91+
const combinedQuery = combineMediaQueryWithNegations(
92+
baseMediaQuery,
93+
reversedNegations,
94+
);
95+
console.dir(combinedQuery, { depth: null });
96+
97+
const newMediaKey = combinedQuery.toString();
98+
99+
delete result[currentKey];
100+
result[newMediaKey] = currentValue;
101+
}
102+
}
103+
104+
return result;
105+
}

0 commit comments

Comments
 (0)