Skip to content

Commit

Permalink
experimental: support selecting style source and state
Browse files Browse the repository at this point in the history
Ref #3399 #1536

Here added support for selecting specific style source and state
when computing styles. Matching states are now treated as less specific.
  • Loading branch information
TrySound committed Aug 20, 2024
1 parent 8832396 commit d299a46
Show file tree
Hide file tree
Showing 2 changed files with 241 additions and 30 deletions.
223 changes: 212 additions & 11 deletions apps/builder/app/shared/style-object-model.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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"]),
Expand All @@ -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 {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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"],
Expand All @@ -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", () => {
Expand Down Expand Up @@ -787,3 +802,189 @@ 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" });
});
});

// @todo need to take deepest instance
// and latest breakpoint
48 changes: 29 additions & 19 deletions apps/builder/app/shared/style-object-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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({
Expand Down Expand Up @@ -212,11 +216,15 @@ const customPropertyData = {
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
Expand Down Expand Up @@ -250,6 +258,8 @@ export const getComputedStyleDecl = ({
const { cascadedValue } = getCascadedValue({
model,
instanceId,
styleSourceId,
state,
property,
});

Expand Down

0 comments on commit d299a46

Please sign in to comment.