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.
This commit is contained in:
Bogdan Chadkin authored and GitHub committed 2024-08-20 13:55:36 +02:00
1 parent f60b9bddb1
commit 03aa922425
2 files changed
+238 -35

No files matched your search

@@ -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"></$.Body>,
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"></$.Body>,
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"></$.Body>,
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"></$.Body>,
});
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"></$.Body>,
});
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"></$.Body>,
});
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"></$.Body>,
});
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"></$.Body>,
});
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"></$.Body>,
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"></$.Body>,
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"></$.Body>,
matchingBreakpoints: ["base", "small"],
});
expect(
getComputedStyleDecl({
model,
instanceSelector: ["body"],
state: ":hover",
property: "color",
}).usedValue
).toEqual({ type: "keyword", value: "red" });
});
});
+29 -24
View File
@@ -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<undefined | string>(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,
});