diff --git a/apps/builder/app/builder/features/style-panel/shared/model.tsx b/apps/builder/app/builder/features/style-panel/shared/model.tsx index 545ab28f604d..bc5b27430cfc 100644 --- a/apps/builder/app/builder/features/style-panel/shared/model.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/model.tsx @@ -6,6 +6,7 @@ import { propertiesData } from "@webstudio-is/css-data"; import { compareMedia, hyphenateProperty, + matchMedia, toVarFallback, type CssProperty, type StyleValue, @@ -41,6 +42,7 @@ import { type InstancePath, } from "~/shared/awareness"; import type { InstanceSelector } from "~/shared/tree-utils"; +import { $canvasWidth } from "~/builder/shared/nano-states"; const $presetStyles = computed($registeredComponentMetas, (metas) => { const presetStyles = new Map(); @@ -111,16 +113,28 @@ const $instanceComponents = computed( ); export const $matchingBreakpoints = computed( - [$breakpoints, $selectedBreakpoint], - (breakpoints, selectedBreakpoint) => { - const sortedBreakpoints = Array.from(breakpoints.values()).sort( - compareMedia - ); + [$breakpoints, $selectedBreakpoint, $canvasWidth], + (breakpoints, selectedBreakpoint, canvasWidth) => { + // zero is not correct, need to use current width for base breakpoint + // add always add base + const selectedWidth = + selectedBreakpoint?.minWidth ?? + selectedBreakpoint?.maxWidth ?? + canvasWidth ?? + 0; + const sortedBreakpoints = Array.from(breakpoints.values()) + .sort(compareMedia) + .sort((left, right) => { + // put selected breakpoint always in the end + // to make style from matching breakpoints remote + const leftScore = left.id === selectedBreakpoint?.id ? 1 : 0; + const rightScore = right.id === selectedBreakpoint?.id ? 1 : 0; + return leftScore - rightScore; + }); const matchingBreakpoints: Breakpoint["id"][] = []; for (const breakpoint of sortedBreakpoints) { - matchingBreakpoints.push(breakpoint.id); - if (breakpoint.id === selectedBreakpoint?.id) { - break; + if (matchMedia(breakpoint, selectedWidth)) { + matchingBreakpoints.push(breakpoint.id); } } return matchingBreakpoints; @@ -240,6 +254,8 @@ const $model = computed( } ); +export { $model as _$model }; + export const $computedStyleDeclarations = computed( [ $model, diff --git a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx index c830c3e951d4..29ba5ce332db 100644 --- a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx +++ b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx @@ -3003,6 +3003,11 @@ describe("Styles", () => { background-color: rgba(0, 208, 255, 1) } } + @media all and (min-width: 1280px) { + Div Block 2 { + background-color: rgba(0, 255, 128, 1) + } + } @media all and (max-width: 991px) { Div Block 2 { background-color: rgba(68, 0, 255, 1) @@ -3017,11 +3022,6 @@ describe("Styles", () => { Div Block 2 { background-color: rgba(255, 0, 4, 1) } - } - @media all and (min-width: 1280px) { - Div Block 2 { - background-color: rgba(0, 255, 128, 1) - } }" `); }); diff --git a/apps/builder/app/shared/style-object-model.test.tsx b/apps/builder/app/shared/style-object-model.test.tsx index d9fb2d0f636d..c7c0b7efddf2 100644 --- a/apps/builder/app/shared/style-object-model.test.tsx +++ b/apps/builder/app/shared/style-object-model.test.tsx @@ -17,6 +17,13 @@ import { getComputedStyleDecl, getPresetStyleDeclKey, } from "./style-object-model"; +import { + $breakpoints, + $selectedBreakpointId, + $styles, + $styleSourceSelections, +} from "./nano-states"; +import { _$model } from "~/builder/features/style-panel/shared/model"; /** * Create model fixture with a few features @@ -1608,3 +1615,155 @@ describe("style value source", () => { }); }); }); + +describe("compute matching breakpoints", () => { + test("min-width only breakpoints", () => { + const model = createModel({ + css: ` + @media mobile { + bodyLocal { + color: red; + } + } + `, + jsx: <$.Body ws:id="body" ws:tag="body" class="bodyLocal">, + }); + $styles.set(model.styles); + $styleSourceSelections.set(model.styleSourceSelections); + $breakpoints.set( + new Map([ + ["desktop", { id: "desktop", label: "", minWidth: 991 }], + ["mobile", { id: "mobile", label: "", minWidth: 479 }], + ["base", { id: "base", label: "" }], + ]) + ); + $selectedBreakpointId.set("desktop"); + expect( + getComputedStyleDecl({ + model: _$model.get(), + instanceSelector: ["body"], + property: "color", + }).computedValue + ).toEqual({ + type: "keyword", + value: "red", + }); + }); + + test("max-width only breakpoints", () => { + const model = createModel({ + css: ` + @media mobile { + bodyLocal { + color: red; + } + } + `, + jsx: <$.Body ws:id="body" ws:tag="body" class="bodyLocal">, + }); + $styles.set(model.styles); + $styleSourceSelections.set(model.styleSourceSelections); + $breakpoints.set( + new Map([ + ["base", { id: "base", label: "" }], + ["mobile", { id: "mobile", label: "", maxWidth: 479 }], + ["desktop", { id: "desktop", label: "", maxWidth: 991 }], + ]) + ); + $selectedBreakpointId.set("desktop"); + expect( + getComputedStyleDecl({ + model: _$model.get(), + instanceSelector: ["body"], + property: "color", + }).computedValue + ).toEqual({ + type: "keyword", + value: "black", + }); + }); + + test("mixed min-width and max-width with selected min-width", () => { + const model = createModel({ + css: ` + @media mobile { + bodyLocal { + color: red; + } + } + `, + jsx: <$.Body ws:id="body" ws:tag="body" class="bodyLocal">, + }); + $styles.set(model.styles); + $styleSourceSelections.set(model.styleSourceSelections); + $breakpoints.set( + new Map([ + ["desktop", { id: "desktop", label: "", minWidth: 991 }], + ["base", { id: "base", label: "" }], + ["mobile", { id: "mobile", label: "", maxWidth: 479 }], + ]) + ); + $selectedBreakpointId.set("desktop"); + expect( + getComputedStyleDecl({ + model: _$model.get(), + instanceSelector: ["body"], + property: "color", + }).computedValue + ).toEqual({ + type: "keyword", + value: "black", + }); + $selectedBreakpointId.set("base"); + expect( + getComputedStyleDecl({ + model: _$model.get(), + instanceSelector: ["body"], + property: "color", + }).source + ).toEqual( + expect.objectContaining({ name: "remote", breakpointId: "mobile" }) + ); + }); + + test("mixed min-width and max-width with selected max-width", () => { + const model = createModel({ + css: ` + @media desktop { + bodyLocal { + color: red; + } + } + `, + jsx: <$.Body ws:id="body" ws:tag="body" class="bodyLocal">, + }); + $styles.set(model.styles); + $styleSourceSelections.set(model.styleSourceSelections); + $breakpoints.set( + new Map([ + ["desktop", { id: "desktop", label: "", minWidth: 991 }], + ["base", { id: "base", label: "" }], + ["mobile", { id: "mobile", label: "", maxWidth: 479 }], + ]) + ); + $selectedBreakpointId.set("mobile"); + expect( + getComputedStyleDecl({ + model: _$model.get(), + instanceSelector: ["body"], + property: "color", + }).computedValue + ).toEqual({ + type: "keyword", + value: "black", + }); + $selectedBreakpointId.set("base"); + expect( + getComputedStyleDecl({ + model: _$model.get(), + instanceSelector: ["body"], + property: "color", + }).source + ).toEqual(expect.objectContaining({ name: "default" })); + }); +}); diff --git a/packages/css-engine/src/core/compare-media.test.ts b/packages/css-engine/src/core/compare-media.test.ts index f9ca5f832e0e..f453dcb01e96 100644 --- a/packages/css-engine/src/core/compare-media.test.ts +++ b/packages/css-engine/src/core/compare-media.test.ts @@ -41,25 +41,31 @@ describe("Compare media", () => { }); test("mixed max and min", () => { - const initial = [ + expect( + [ + {}, + { maxWidth: 991 }, + { maxWidth: 479 }, + { maxWidth: 767 }, + { minWidth: 1440 }, + { minWidth: 1280 }, + { minWidth: 1920 }, + ].toSorted(compareMedia) + ).toStrictEqual([ {}, - { maxWidth: 991 }, - { maxWidth: 479 }, - { maxWidth: 767 }, - { minWidth: 1440 }, { minWidth: 1280 }, + { minWidth: 1440 }, { minWidth: 1920 }, - ]; - const expected = [ - {}, { maxWidth: 991 }, { maxWidth: 767 }, { maxWidth: 479 }, - { minWidth: 1280 }, - { minWidth: 1440 }, - { minWidth: 1920 }, - ]; - const sorted = initial.sort(compareMedia); - expect(sorted).toStrictEqual(expected); + ]); + // test both directions of sorting + expect( + [{ maxWidth: 479 }, { minWidth: 991 }, {}].toSorted(compareMedia) + ).toStrictEqual([{}, { minWidth: 991 }, { maxWidth: 479 }]); + expect( + [{ minWidth: 991 }, {}, { maxWidth: 479 }].toSorted(compareMedia) + ).toStrictEqual([{}, { minWidth: 991 }, { maxWidth: 479 }]); }); }); diff --git a/packages/css-engine/src/core/compare-media.ts b/packages/css-engine/src/core/compare-media.ts index fa6aad791d10..1ec22712ecc2 100644 --- a/packages/css-engine/src/core/compare-media.ts +++ b/packages/css-engine/src/core/compare-media.ts @@ -25,6 +25,13 @@ export const compareMedia = ( return optionB.maxWidth - optionA.maxWidth; } + if (optionA.minWidth !== undefined && optionB.maxWidth !== undefined) { + return optionB.maxWidth - optionA.minWidth; + } + if (optionA.maxWidth !== undefined && optionB.minWidth !== undefined) { + return optionB.minWidth - optionA.maxWidth; + } + // Media with maxWith should render before minWith just to have the same sorting visually in the UI as in CSSOM. return "minWidth" in optionA ? 1 : -1; }; diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json index aded5149fd25..f68e89f6f328 100644 --- a/packages/tsconfig/base.json +++ b/packages/tsconfig/base.json @@ -3,7 +3,7 @@ "display": "Default", "compilerOptions": { "module": "ES2022", - "target": "ES2022", + "target": "ES2023", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "inlineSources": false,