Skip to content

Commit dd088b6

Browse files
committed
fix: add unset values to adapt tailwind breakpoints (#5303)
Ref #2651 When convert tailwind breakpoints to webstudio breakpoints our algorithm can find holes and will stop conversion. Though this will create mobile-first breakpoints which users do not expect. Here I added workaround to specify "unset" value on property when tailwind breakpoints has holes. Here's examples **sm:opacity-20** - here the hole is in the beginning, opacity: 0.2 can be defined on base breakpoint but we need to fill with something `max-width: 479` **max-sm:opacity-10 md:opacity-20** - here the holr is in the middle, we have `max-width: 479` and base breakpoint but no `max-width: 767`
1 parent 3aafd1c commit dd088b6

File tree

2 files changed

+112
-104
lines changed

2 files changed

+112
-104
lines changed

apps/builder/app/shared/tailwind/tailwind.test.tsx

Lines changed: 67 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,8 @@ test("override border opacity", async () => {
114114
ws:tag="div"
115115
ws:style={css`
116116
border-style: solid;
117-
border-width: 1px;
118117
border-color: rgb(229 231 235 / var(--tw-border-opacity));
118+
border-width: 1px;
119119
--tw-border-opacity: 0.6;
120120
`}
121121
></ws.element>
@@ -315,31 +315,6 @@ describe("extract breakpoints", () => {
315315
);
316316
});
317317

318-
test("base is first breakpoint", async () => {
319-
expect(
320-
await generateFragmentFromTailwind(
321-
renderTemplate(
322-
<ws.element
323-
ws:tag="div"
324-
class="opacity-10 sm:opacity-20"
325-
></ws.element>
326-
)
327-
)
328-
).toEqual(
329-
renderTemplate(
330-
<ws.element
331-
ws:tag="div"
332-
ws:style={css`
333-
@media (max-width: 479px) {
334-
opacity: 0.1;
335-
}
336-
opacity: 0.2;
337-
`}
338-
></ws.element>
339-
)
340-
);
341-
});
342-
343318
test("base is last breakpoint", async () => {
344319
expect(
345320
await generateFragmentFromTailwind(
@@ -393,27 +368,6 @@ describe("extract breakpoints", () => {
393368
);
394369
});
395370

396-
test("preserve breakpoint when no base breakpoint", async () => {
397-
expect(
398-
await generateFragmentFromTailwind(
399-
renderTemplate(
400-
<ws.element ws:tag="div" class="sm:opacity-10"></ws.element>
401-
)
402-
)
403-
).toEqual(
404-
renderTemplate(
405-
<ws.element
406-
ws:tag="div"
407-
ws:style={css`
408-
@media (min-width: 480px) {
409-
opacity: 0.1;
410-
}
411-
`}
412-
></ws.element>
413-
)
414-
);
415-
});
416-
417371
test("extract container class", async () => {
418372
expect(
419373
await generateFragmentFromTailwind(
@@ -427,7 +381,6 @@ describe("extract breakpoints", () => {
427381
@media (max-width: 479px) {
428382
max-width: none;
429383
}
430-
width: 100%;
431384
@media (max-width: 767px) {
432385
max-width: 640px;
433386
}
@@ -441,6 +394,7 @@ describe("extract breakpoints", () => {
441394
@media (min-width: 1440px) {
442395
max-width: 1536px;
443396
}
397+
width: 100%;
444398
`}
445399
></ws.element>
446400
)
@@ -501,11 +455,14 @@ describe("extract breakpoints", () => {
501455
ws:tag="div"
502456
ws:style={css`
503457
opacity: 0.1;
504-
@media (min-width: 480px) {
458+
@media (max-width: 479px) {
505459
&:hover {
506-
opacity: 0.2;
460+
opacity: unset;
507461
}
508462
}
463+
&:hover {
464+
opacity: 0.2;
465+
}
509466
`}
510467
></ws.element>
511468
)
@@ -527,21 +484,21 @@ describe("extract breakpoints", () => {
527484
<ws.element
528485
ws:tag="div"
529486
ws:style={css`
530-
@media (min-width: 1440px) {
531-
opacity: 0.6;
487+
@media (max-width: 479px) {
488+
opacity: 0.1;
532489
}
533-
@media (min-width: 1280px) {
534-
opacity: 0.5;
490+
@media (max-width: 767px) {
491+
opacity: 0.2;
535492
}
536-
opacity: 0.4;
537493
@media (max-width: 991px) {
538494
opacity: 0.3;
539495
}
540-
@media (max-width: 767px) {
541-
opacity: 0.2;
496+
opacity: 0.4;
497+
@media (min-width: 1280px) {
498+
opacity: 0.5;
542499
}
543-
@media (max-width: 479px) {
544-
opacity: 0.1;
500+
@media (min-width: 1440px) {
501+
opacity: 0.6;
545502
}
546503
`}
547504
></ws.element>
@@ -570,6 +527,56 @@ describe("extract breakpoints", () => {
570527
)
571528
);
572529
});
530+
531+
test("use unset for missing base breakpoint 1", async () => {
532+
expect(
533+
await generateFragmentFromTailwind(
534+
renderTemplate(
535+
<ws.element ws:tag="div" class="sm:opacity-10"></ws.element>
536+
)
537+
)
538+
).toEqual(
539+
renderTemplate(
540+
<ws.element
541+
ws:tag="div"
542+
ws:style={css`
543+
@media (max-width: 479px) {
544+
opacity: unset;
545+
}
546+
opacity: 0.1;
547+
`}
548+
></ws.element>
549+
)
550+
);
551+
});
552+
553+
test("use unset for missing base breakpoint 2", async () => {
554+
expect(
555+
await generateFragmentFromTailwind(
556+
renderTemplate(
557+
<ws.element
558+
ws:tag="div"
559+
class="max-sm:opacity-10 md:opacity-20"
560+
></ws.element>
561+
)
562+
)
563+
).toEqual(
564+
renderTemplate(
565+
<ws.element
566+
ws:tag="div"
567+
ws:style={css`
568+
@media (max-width: 479px) {
569+
opacity: 0.1;
570+
}
571+
@media (max-width: 767px) {
572+
opacity: unset;
573+
}
574+
opacity: 0.2;
575+
`}
576+
></ws.element>
577+
)
578+
);
579+
});
573580
});
574581

575582
test("generate space without display property", async () => {
@@ -635,8 +642,8 @@ test("generate space with display property", async () => {
635642
@media (max-width: 767px) {
636643
display: none;
637644
}
638-
row-gap: 1rem;
639645
display: flex;
646+
row-gap: 1rem;
640647
`}
641648
></ws.element>
642649
</>

apps/builder/app/shared/tailwind/tailwind.ts

Lines changed: 45 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -37,23 +37,25 @@ const tailwindToWebstudioMappings: Record<number, undefined | number> = {
3737
1536: 1440,
3838
};
3939

40+
type StyleDecl = Omit<ParsedStyleDecl, "selector">;
41+
4042
type Breakpoint = {
41-
key: string;
43+
styleDecl: StyleDecl;
4244
minWidth?: number;
4345
maxWidth?: number;
4446
};
4547

4648
type Range = {
47-
key: string;
49+
styleDecl: StyleDecl;
4850
start: number;
4951
end: number;
5052
};
5153

5254
const serializeBreakpoint = (breakpoint: Breakpoint) => {
53-
if (breakpoint?.minWidth) {
55+
if (breakpoint?.minWidth !== undefined) {
5456
return `(min-width: ${breakpoint.minWidth}px)`;
5557
}
56-
if (breakpoint?.maxWidth) {
58+
if (breakpoint?.maxWidth !== undefined) {
5759
return `(max-width: ${breakpoint.maxWidth}px)`;
5860
}
5961
};
@@ -63,17 +65,17 @@ const UPPER_BOUND = Number.MAX_SAFE_INTEGER;
6365
const breakpointsToRanges = (breakpoints: Breakpoint[]) => {
6466
// collect lower bounds and ids
6567
const values = new Set<number>([0]);
66-
const keys = new Map<undefined | number, string>();
68+
const styles = new Map<undefined | number, StyleDecl>();
6769
for (const breakpoint of breakpoints) {
6870
if (breakpoint.minWidth !== undefined) {
6971
values.add(breakpoint.minWidth);
70-
keys.set(breakpoint.minWidth, breakpoint.key);
72+
styles.set(breakpoint.minWidth, breakpoint.styleDecl);
7173
} else if (breakpoint.maxWidth !== undefined) {
7274
values.add(breakpoint.maxWidth + 1);
73-
keys.set(breakpoint.maxWidth, breakpoint.key);
75+
styles.set(breakpoint.maxWidth, breakpoint.styleDecl);
7476
} else {
7577
// base breakpoint
76-
keys.set(undefined, breakpoint.key);
78+
styles.set(undefined, breakpoint.styleDecl);
7779
}
7880
}
7981
const sortedValues = Array.from(values).sort((left, right) => left - right);
@@ -86,46 +88,58 @@ const breakpointsToRanges = (breakpoints: Breakpoint[]) => {
8688
} else {
8789
end = sortedValues[index + 1] - 1;
8890
}
89-
const key = keys.get(start) ?? keys.get(end) ?? keys.get(undefined);
90-
if (key) {
91-
ranges.push({ key, start, end });
91+
const styleDecl =
92+
styles.get(start) ?? styles.get(end) ?? styles.get(undefined);
93+
if (styleDecl) {
94+
ranges.push({ styleDecl, start, end });
95+
continue;
96+
}
97+
// when declaration is missing add new one with unset value
98+
// to fill the hole in breakpoints
99+
// for example
100+
// "sm:opacity-20" has a hole at the start
101+
// "max-sm:opacity-10 md:opacity-20" has a whole in the middle
102+
const example = Array.from(styles.values())[0];
103+
if (example) {
104+
const newStyleDecl: StyleDecl = {
105+
...example,
106+
value: { type: "keyword", value: "unset" },
107+
};
108+
ranges.push({ styleDecl: newStyleDecl, start, end });
92109
}
93110
}
94111
return ranges;
95112
};
96113

97114
const rangesToBreakpoints = (ranges: Range[]) => {
98115
const breakpoints: Breakpoint[] = [];
99-
for (const range of ranges) {
116+
for (const { styleDecl, start, end } of ranges) {
100117
let matchedBreakpoint;
101118
for (const breakpoint of availableBreakpoints) {
102-
if (breakpoint.minWidth === range.start) {
103-
matchedBreakpoint = { key: range.key, minWidth: range.start };
119+
if (breakpoint.minWidth === start) {
120+
matchedBreakpoint = { styleDecl, minWidth: start };
104121
}
105-
if (breakpoint.maxWidth === range.end) {
106-
matchedBreakpoint = { key: range.key, maxWidth: range.end };
122+
if (breakpoint.maxWidth === end) {
123+
matchedBreakpoint = { styleDecl, maxWidth: end };
107124
}
108125
if (
109126
breakpoint.minWidth === undefined &&
110127
breakpoint.maxWidth === undefined
111128
) {
112-
matchedBreakpoint ??= { key: range.key };
129+
matchedBreakpoint ??= { styleDecl };
113130
}
114131
}
115132
if (matchedBreakpoint) {
133+
styleDecl.breakpoint = serializeBreakpoint(matchedBreakpoint);
116134
breakpoints.push(matchedBreakpoint);
117135
}
118136
}
119137
return breakpoints;
120138
};
121139

122-
const adaptBreakpoints = (
123-
parsedStyles: Omit<ParsedStyleDecl, "selector">[]
124-
) => {
125-
const newStyles: typeof parsedStyles = [];
140+
const adaptBreakpoints = (parsedStyles: StyleDecl[]) => {
126141
const breakpointGroups = new Map<string, Breakpoint[]>();
127142
for (const styleDecl of parsedStyles) {
128-
newStyles.push(styleDecl);
129143
const mediaQuery = styleDecl.breakpoint
130144
? parseMediaQuery(styleDecl.breakpoint)
131145
: undefined;
@@ -143,27 +157,14 @@ const adaptBreakpoints = (
143157
group = [];
144158
breakpointGroups.set(groupKey, group);
145159
}
146-
const styleDeclKey = `${styleDecl.breakpoint ?? ""}:${styleDecl.property}:${styleDecl.state ?? ""}`;
147-
group.push({ key: styleDeclKey, ...mediaQuery });
160+
group.push({ styleDecl, ...mediaQuery });
148161
}
149-
const breakpointsByKey = new Map<string, Breakpoint>();
150-
for (let group of breakpointGroups.values()) {
162+
const newStyles: typeof parsedStyles = [];
163+
for (const group of breakpointGroups.values()) {
151164
const ranges = breakpointsToRanges(group);
152-
// adapt breakpoints only when first range is defined
153-
// for example opacity-10 sm:opacity-20 will work
154-
// but sm:opacity-20 alone does not have the base to switch to
155-
if (ranges[0].start === 0) {
156-
group = rangesToBreakpoints(ranges);
157-
}
158-
for (const breakpoint of group) {
159-
breakpointsByKey.set(breakpoint.key, breakpoint);
160-
}
161-
}
162-
for (const styleDecl of newStyles) {
163-
const styleDeclKey = `${styleDecl.breakpoint ?? ""}:${styleDecl.property}:${styleDecl.state ?? ""}`;
164-
const breakpoint = breakpointsByKey.get(styleDeclKey);
165-
if (breakpoint) {
166-
styleDecl.breakpoint = serializeBreakpoint(breakpoint);
165+
const newGroup = rangesToBreakpoints(ranges);
166+
for (const { styleDecl } of newGroup) {
167+
newStyles.push(styleDecl);
167168
}
168169
}
169170
return newStyles;
@@ -215,7 +216,7 @@ const parseTailwindClasses = async (classes: string) => {
215216
const generated = await generator.generate(classes);
216217
// use tailwind prefix instead of unocss one
217218
const css = generated.css.replaceAll("--un-", "--tw-");
218-
let parsedStyles: Omit<ParsedStyleDecl, "selector">[] = [];
219+
let parsedStyles: StyleDecl[] = [];
219220
// @todo probably builtin in v4
220221
if (css.includes("border")) {
221222
// Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
@@ -264,7 +265,7 @@ const parseTailwindClasses = async (classes: string) => {
264265
}
265266
);
266267
}
267-
adaptBreakpoints(parsedStyles);
268+
parsedStyles = adaptBreakpoints(parsedStyles);
268269
const newClasses = classes
269270
.split(" ")
270271
.filter((item) => !generated.matched.has(item))
@@ -353,7 +354,7 @@ export const generateFragmentFromTailwind = async (
353354
};
354355
const createOrMergeLocalStyles = (
355356
instanceId: Instance["id"],
356-
newStyles: Omit<ParsedStyleDecl, "selector">[]
357+
newStyles: StyleDecl[]
357358
) => {
358359
const localStyleSource =
359360
getLocalStyleSource(instanceId) ?? createLocalStyleSource(instanceId);

0 commit comments

Comments
 (0)