From 03aa922425aae9b6c2fa2c291bb66d21f647ba82 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Tue, 20 Aug 2024 14:55:36 +0300 Subject: [PATCH] experimental: support selecting style source and state (#3972) Ref https://github.com/webstudio-is/webstudio/issues/3399 https://github.com/webstudio-is/webstudio/issues/1536 Here added support for selecting specific style source and state when computing styles. Matching states are now treated as less specific. --- .../app/shared/style-object-model.test.tsx | 220 +++++++++++++++++- apps/builder/app/shared/style-object-model.ts | 53 +++-- 2 files changed, 238 insertions(+), 35 deletions(-) diff --git a/apps/builder/app/shared/style-object-model.test.tsx b/apps/builder/app/shared/style-object-model.test.tsx index 857e7b2791..b10e90c492 100644 --- a/apps/builder/app/shared/style-object-model.test.tsx +++ b/apps/builder/app/shared/style-object-model.test.tsx @@ -1,4 +1,4 @@ -import { expect, test } from "@jest/globals"; +import { describe, expect, test } from "@jest/globals"; import type { HtmlTags } from "html-tags"; import { type Breakpoint, @@ -561,15 +561,12 @@ test("cascade value from matching style sources", () => { ).toEqual({ type: "unit", unit: "px", value: 20 }); }); -test("cascade values with states as more specific", () => { +test("cascade values with matching states", () => { const model = createModel({ css: ` bodyLocal:hover { width: 20px; } - bodyLocal { - width: 10px; - } `, jsx: <$.Body ws:id="body" class="bodyLocal">, matchingStates: new Set([":hover"]), @@ -582,7 +579,7 @@ test("cascade values with states as more specific", () => { ).toEqual({ type: "unit", unit: "px", value: 20 }); }); -test("ignore values from not matching states", () => { +test("prefer stateless values over matching states", () => { const model = createModel({ css: ` bodyLocal { @@ -591,6 +588,24 @@ test("ignore values from not matching states", () => { bodyLocal:hover { width: 20px; } + `, + jsx: <$.Body ws:id="body" class="bodyLocal">, + matchingStates: new Set([":hover"]), + }); + const instanceSelector = ["body"]; + // value with state wins + expect( + getComputedStyleDecl({ model, instanceSelector, property: "width" }) + .usedValue + ).toEqual({ type: "unit", unit: "px", value: 10 }); +}); + +test("ignore values from not matching states", () => { + const model = createModel({ + css: ` + bodyLocal:hover { + width: 20px; + } bodyLocal:focus { width: 30px; } @@ -710,17 +725,17 @@ test("breakpoints are more specific than style sources", () => { ).toEqual({ type: "unit", unit: "px", value: 20 }); }); -test("states are more specific than breakpoints", () => { +test("breakpoints are more specific than matching states", () => { const model = createModel({ css: ` - bodyLocal:hover { - width: 20px; - } @media small { bodyLocal { width: 10px; } } + bodyLocal:hover { + width: 20px; + } `, jsx: <$.Body ws:id="body" class="bodyLocal">, matchingBreakpoints: ["base", "small"], @@ -733,7 +748,7 @@ test("states are more specific than breakpoints", () => { instanceSelector: ["body"], property: "width", }).usedValue - ).toEqual({ type: "unit", unit: "px", value: 20 }); + ).toEqual({ type: "unit", unit: "px", value: 10 }); }); test("user styles are more specific than preset styles", () => { @@ -787,3 +802,186 @@ test("preset styles are more specific than browser styles", () => { }).usedValue ).toEqual({ type: "keyword", value: "flex" }); }); + +describe("selected style", () => { + test("access selected style source value within cascade", () => { + const model = createModel({ + css: ` + token { + color: red; + } + local { + color: blue; + } + `, + jsx: <$.Body ws:id="body" class="token local">, + }); + expect( + getComputedStyleDecl({ + model, + instanceSelector: ["body"], + styleSourceId: "token", + property: "color", + }).usedValue + ).toEqual({ type: "keyword", value: "red" }); + }); + + test("fallback to previous style source", () => { + const model = createModel({ + css: ` + token { + color: red; + } + `, + jsx: <$.Body ws:id="body" class="token local">, + }); + expect( + getComputedStyleDecl({ + model, + instanceSelector: ["body"], + styleSourceId: "local", + property: "color", + }).usedValue + ).toEqual({ type: "keyword", value: "red" }); + }); + + test("fallback to final style source", () => { + const model = createModel({ + css: ` + first { + color: red; + } + local { + color: blue; + } + `, + jsx: <$.Body ws:id="body" class="first second local">, + }); + expect( + getComputedStyleDecl({ + model, + instanceSelector: ["body"], + styleSourceId: "second", + property: "color", + }).usedValue + ).toEqual({ type: "keyword", value: "blue" }); + }); + + test("access selected state value", () => { + const model = createModel({ + css: ` + local { + color: red; + } + local:hover { + color: green; + } + `, + jsx: <$.Body ws:id="body" class="local">, + }); + expect( + getComputedStyleDecl({ + model, + instanceSelector: ["body"], + property: "color", + }).usedValue + ).toEqual({ type: "keyword", value: "red" }); + expect( + getComputedStyleDecl({ + model, + instanceSelector: ["body"], + state: ":hover", + property: "color", + }).usedValue + ).toEqual({ type: "keyword", value: "green" }); + }); + + test("fallback to stateless value", () => { + const model = createModel({ + css: ` + local { + color: red; + } + `, + jsx: <$.Body ws:id="body" class="local">, + }); + expect( + getComputedStyleDecl({ + model, + instanceSelector: ["body"], + state: ":hover", + property: "color", + }).usedValue + ).toEqual({ type: "keyword", value: "red" }); + }); + + test("prefer stateless when states are matched but not selected", () => { + const model = createModel({ + css: ` + local { + color: red; + } + local:hover { + color: blue; + } + `, + jsx: <$.Body ws:id="body" class="local">, + matchingStates: new Set([":hover"]), + }); + expect( + getComputedStyleDecl({ + model, + instanceSelector: ["body"], + property: "color", + }).usedValue + ).toEqual({ type: "keyword", value: "red" }); + }); + + test("prefer selected state over matched one", () => { + const model = createModel({ + css: ` + local:hover { + color: green; + } + local:focus { + color: blue; + } + `, + jsx: <$.Body ws:id="body" class="local">, + matchingStates: new Set([":hover", ":focus"]), + }); + expect( + getComputedStyleDecl({ + model, + instanceSelector: ["body"], + state: ":hover", + property: "color", + }).usedValue + ).toEqual({ type: "keyword", value: "green" }); + }); + + test("prefer selected state over breakpoint", () => { + const model = createModel({ + css: ` + local:hover { + color: red; + } + @media small { + local { + color: blue; + } + } + `, + jsx: <$.Body ws:id="body" class="local">, + matchingBreakpoints: ["base", "small"], + }); + expect( + getComputedStyleDecl({ + model, + instanceSelector: ["body"], + state: ":hover", + property: "color", + }).usedValue + ).toEqual({ type: "keyword", value: "red" }); + }); +}); diff --git a/apps/builder/app/shared/style-object-model.ts b/apps/builder/app/shared/style-object-model.ts index 084b1d3841..18159320ef 100644 --- a/apps/builder/app/shared/style-object-model.ts +++ b/apps/builder/app/shared/style-object-model.ts @@ -4,6 +4,7 @@ import type { StyleValue, StyleProperty } from "@webstudio-is/css-engine"; import { type Breakpoint, type Instance, + type StyleDecl, type StyleSourceSelections, type Styles, getStyleDeclKey, @@ -96,10 +97,14 @@ export const getPresetStyleDeclKey = ({ const getCascadedValue = ({ model, instanceId, + styleSourceId: selectedStyleSourceId, + state: selectedState, property, }: { model: StyleObjectModel; instanceId: Instance["id"]; + styleSourceId?: StyleDecl["styleSourceId"]; + state?: StyleDecl["state"]; property: Property; }) => { const { @@ -127,6 +132,15 @@ const getCascadedValue = ({ } } + // sort first matching states + // then statesless + // and selected state + const states = new Set(matchingStates); + states.add(undefined); + // move selected state in the end if already present in matching states + states.delete(selectedState); + states.add(selectedState); + // preset component styles if (component && tag) { // stateless @@ -136,7 +150,7 @@ const getCascadedValue = ({ declaredValues.push({ value: styleValue }); } // stateful - for (const state of matchingStates) { + for (const state of states) { const key = getPresetStyleDeclKey({ component, tag, state, property }); const styleValue = presetStyles.get(key); if (styleValue) { @@ -146,25 +160,15 @@ const getCascadedValue = ({ } // user styles - - // stateless - const styleSourceIds = styleSourceSelections.get(instanceId)?.values ?? []; - for (const breakpointId of matchingBreakpoints) { - for (const styleSourceId of styleSourceIds) { - const key = getStyleDeclKey({ - styleSourceId, - breakpointId, - property: property as StyleProperty, - }); - const styleDecl = styles.get(key); - if (styleDecl) { - declaredValues.push({ value: styleDecl.value }); - } - } + const styleSourceIds = new Set( + styleSourceSelections.get(instanceId)?.values ?? [] + ); + if (selectedStyleSourceId) { + // move selected style source in the end + styleSourceIds.delete(selectedStyleSourceId); + styleSourceIds.add(selectedStyleSourceId); } - - // stateful - for (const state of matchingStates) { + for (const state of states) { for (const breakpointId of matchingBreakpoints) { for (const styleSourceId of styleSourceIds) { const key = getStyleDeclKey({ @@ -203,20 +207,19 @@ const customPropertyData = { /** * follow value processing specification * https://drafts.csswg.org/css-cascade-5/#value-stages - * - * @todo - * - selected style source - * - selected state - * */ export const getComputedStyleDecl = ({ model, instanceSelector, + styleSourceId, + state, property, customPropertiesGraph = new Map(), }: { model: StyleObjectModel; instanceSelector: InstanceSelector; + styleSourceId?: StyleDecl["styleSourceId"]; + state?: StyleDecl["state"]; property: Property; /** * for internal use only @@ -250,6 +253,8 @@ export const getComputedStyleDecl = ({ const { cascadedValue } = getCascadedValue({ model, instanceId, + styleSourceId, + state, property, });