Skip to content

Commit dc50844

Browse files
authored
feat: Gradients UI (#5449)
## Description Closes #3448 ## Todo - [x] linear gradient - [x] without stops linear-gradient(red,green) - [x] delete stops - [x] support variables - [x] tweak design - [x] conic - [x] one-color - [x] radial
1 parent 0484d9c commit dc50844

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+8199
-1477
lines changed

apps/builder/app/builder/features/style-panel/controls/color/color-control.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { CssProperty } from "@webstudio-is/css-engine";
22
import { keywordValues } from "@webstudio-is/css-data";
3-
import { ColorPicker } from "../../shared/color-picker";
3+
import { ColorPickerControl } from "../../shared/color-picker";
44
import {
55
$availableColorVariables,
66
useComputedStyleDecl,
@@ -13,7 +13,7 @@ export const ColorControl = ({ property }: { property: CssProperty }) => {
1313
const currentColor = computedStyleDecl.usedValue;
1414
const setValue = setProperty(property);
1515
return (
16-
<ColorPicker
16+
<ColorPickerControl
1717
property={property}
1818
value={value}
1919
currentColor={currentColor}

apps/builder/app/builder/features/style-panel/property-label.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,12 @@ export const PropertyLabel = ({
230230
label,
231231
description,
232232
properties,
233+
disabled,
233234
}: {
234235
label: string;
235236
description?: string;
236237
properties: [CssProperty, ...CssProperty[]];
238+
disabled?: boolean;
237239
}) => {
238240
const styles = useComputedStyles(properties);
239241
const styleValueSourceColor = getPriorityStyleValueSource(styles);
@@ -279,7 +281,7 @@ export const PropertyLabel = ({
279281
}
280282
>
281283
<Flex shrink gap={1} align="center">
282-
<Label color={styleValueSourceColor} truncate>
284+
<Label color={styleValueSourceColor} truncate disabled={disabled}>
283285
{label}
284286
</Label>
285287
</Flex>
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import type { InvalidValue, StyleValue } from "@webstudio-is/css-engine";
2+
import { useCallback, useEffect, useRef, useState } from "react";
3+
import {
4+
CssFragmentEditor,
5+
CssFragmentEditorContent,
6+
getCodeEditorCssVars,
7+
parseCssFragment,
8+
} from "../../shared/css-fragment";
9+
import { PropertyInlineLabel } from "../../property-label";
10+
import { toValue } from "@webstudio-is/css-engine";
11+
import { setProperty } from "../../shared/use-style-data";
12+
import {
13+
editRepeatedStyleItem,
14+
setRepeatedStyleItem,
15+
} from "../../shared/repeated-style";
16+
import { useComputedStyleDecl } from "../../shared/model";
17+
import { InputErrorsTooltip } from "@webstudio-is/design-system";
18+
19+
type IntermediateValue = {
20+
type: "intermediate";
21+
value: string;
22+
};
23+
24+
const isTransparent = (color: StyleValue) =>
25+
color.type === "keyword" && color.value === "transparent";
26+
27+
type BackgroundCodeEditorProps = {
28+
index: number;
29+
/**
30+
* Optional custom validation and error handling
31+
*/
32+
onValidate?: (
33+
value: string,
34+
parsed: Map<string, StyleValue>
35+
) => string[] | undefined;
36+
};
37+
38+
export const BackgroundCodeEditor = ({
39+
index,
40+
onValidate,
41+
}: BackgroundCodeEditorProps) => {
42+
const styleDecl = useComputedStyleDecl("background-image");
43+
let styleValue = styleDecl.cascadedValue;
44+
if (styleValue.type === "layers") {
45+
styleValue = styleValue.value[index];
46+
}
47+
48+
const [intermediateValue, setIntermediateValue] = useState<
49+
IntermediateValue | InvalidValue | undefined
50+
>(undefined);
51+
52+
const [errors, setErrors] = useState<string[]>([]);
53+
54+
// Reset intermediate value when styleValue changes (e.g., from UI updates)
55+
useEffect(() => {
56+
setIntermediateValue(undefined);
57+
}, [styleValue]);
58+
59+
const textAreaValue = intermediateValue?.value ?? toValue(styleValue);
60+
61+
const handleChange = useCallback(
62+
(value: string) => {
63+
setIntermediateValue({
64+
type: "intermediate",
65+
value,
66+
});
67+
68+
const parsed = parseCssFragment(value, [
69+
"background-image",
70+
"background",
71+
]);
72+
const newValue = parsed.get("background-image");
73+
74+
if (newValue === undefined || newValue?.type === "invalid") {
75+
setIntermediateValue({
76+
type: "invalid",
77+
value: value,
78+
});
79+
return;
80+
}
81+
82+
// Run custom validation if provided
83+
if (onValidate) {
84+
const validationErrors = onValidate(value, parsed);
85+
if (validationErrors && validationErrors.length > 0) {
86+
setErrors(validationErrors);
87+
setIntermediateValue({
88+
type: "invalid",
89+
value: value,
90+
});
91+
return;
92+
}
93+
setErrors([]);
94+
}
95+
96+
setRepeatedStyleItem(styleDecl, index, newValue, { isEphemeral: true });
97+
},
98+
[index, onValidate, styleDecl]
99+
);
100+
101+
const handleOnComplete = useCallback(() => {
102+
if (intermediateValue === undefined) {
103+
return;
104+
}
105+
106+
const parsed = parseCssFragment(intermediateValue.value, [
107+
"background-image",
108+
"background",
109+
]);
110+
const backgroundImage = parsed.get("background-image");
111+
const backgroundColor = parsed.get("background-color");
112+
113+
if (backgroundColor?.type === "invalid" || backgroundImage === undefined) {
114+
setIntermediateValue({ type: "invalid", value: intermediateValue.value });
115+
if (styleValue) {
116+
setRepeatedStyleItem(styleDecl, index, styleValue, {
117+
isEphemeral: true,
118+
});
119+
}
120+
return;
121+
}
122+
setIntermediateValue(undefined);
123+
if (backgroundColor && isTransparent(backgroundColor) === false) {
124+
setProperty("background-color")(backgroundColor);
125+
}
126+
editRepeatedStyleItem(
127+
[styleDecl],
128+
index,
129+
new Map([["background-image", backgroundImage]])
130+
);
131+
}, [index, intermediateValue, styleDecl, styleValue]);
132+
133+
const handleOnCompleteRef = useRef(handleOnComplete);
134+
useEffect(() => {
135+
handleOnCompleteRef.current = handleOnComplete;
136+
}, [handleOnComplete]);
137+
138+
useEffect(() => {
139+
return () => {
140+
handleOnCompleteRef.current();
141+
};
142+
}, []);
143+
144+
return (
145+
<>
146+
<PropertyInlineLabel
147+
label="Code"
148+
description="Paste a CSS gradient or image, for example: linear-gradient(...) or url('image.jpg'). If pasting from Figma, remove the 'background' property name."
149+
/>
150+
<InputErrorsTooltip errors={errors}>
151+
<CssFragmentEditor
152+
css={getCodeEditorCssVars({ minHeight: "4lh", maxHeight: "4lh" })}
153+
content={
154+
<CssFragmentEditorContent
155+
invalid={intermediateValue?.type === "invalid"}
156+
autoFocus={styleValue.type === "var"}
157+
value={textAreaValue ?? ""}
158+
onChange={handleChange}
159+
onChangeComplete={handleOnComplete}
160+
/>
161+
}
162+
/>
163+
</InputErrorsTooltip>
164+
</>
165+
);
166+
};
Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useRef } from "react";
1+
import { useEffect, useRef } from "react";
22
import { getStyleDeclKey, type StyleDecl } from "@webstudio-is/sdk";
33
import {
44
FloatingPanel,
@@ -7,6 +7,7 @@ import {
77
import { createDefaultPages } from "@webstudio-is/project-build";
88
import {
99
$breakpoints,
10+
$instances,
1011
$pages,
1112
$selectedBreakpointId,
1213
$styles,
@@ -15,37 +16,56 @@ import {
1516
import { registerContainers } from "~/shared/sync";
1617
import { BackgroundContent } from "./background-content";
1718
import { $awareness } from "~/shared/awareness";
18-
19-
const backgroundImage: StyleDecl = {
20-
breakpointId: "base",
21-
styleSourceId: "local",
22-
property: "backgroundImage",
23-
value: {
24-
type: "layers",
25-
value: [{ type: "keyword", value: "none" }],
26-
},
27-
};
19+
import { useComputedStyleDecl } from "../../shared/model";
20+
import { setRepeatedStyleItem } from "../../shared/repeated-style";
21+
import { type StyleValue } from "@webstudio-is/css-engine";
2822

2923
registerContainers();
3024
$breakpoints.set(new Map([["base", { id: "base", label: "" }]]));
3125
$selectedBreakpointId.set("base");
32-
$styles.set(new Map([[getStyleDeclKey(backgroundImage), backgroundImage]]));
3326
$styleSourceSelections.set(
3427
new Map([["box", { instanceId: "box", values: ["local"] }]])
3528
);
29+
$instances.set(
30+
new Map([
31+
["box", { type: "instance", id: "box", component: "Box", children: [] }],
32+
])
33+
);
3634
$pages.set(
3735
createDefaultPages({
3836
homePageId: "homePageId",
3937
rootInstanceId: "box",
4038
})
4139
);
40+
41+
const defaultBackgroundImage: StyleDecl = {
42+
breakpointId: "base",
43+
styleSourceId: "local",
44+
property: "backgroundImage",
45+
value: {
46+
type: "layers",
47+
value: [{ type: "keyword", value: "none" }],
48+
},
49+
};
50+
51+
$styles.set(
52+
new Map([[getStyleDeclKey(defaultBackgroundImage), defaultBackgroundImage]])
53+
);
54+
4255
$awareness.set({
4356
pageId: "homePageId",
4457
instanceSelector: ["box"],
4558
});
4659

47-
export const BackgroundContentStory = () => {
60+
const BackgroundStory = ({ styleValue }: { styleValue: StyleValue }) => {
4861
const elementRef = useRef<HTMLDivElement>(null);
62+
const backgroundImage = useComputedStyleDecl("background-image");
63+
64+
useEffect(() => {
65+
setRepeatedStyleItem(backgroundImage, 0, styleValue);
66+
// eslint-disable-next-line react-hooks/exhaustive-deps
67+
}, []);
68+
4969
return (
5070
<>
5171
<div ref={elementRef} style={{ marginLeft: "400px" }}></div>
@@ -63,7 +83,48 @@ export const BackgroundContentStory = () => {
6383
);
6484
};
6585

86+
export const Image = () => {
87+
const styleValue: StyleValue = { type: "keyword", value: "none" };
88+
return <BackgroundStory styleValue={styleValue} />;
89+
};
90+
91+
export const LinearGradient = () => {
92+
const styleValue: StyleValue = {
93+
type: "unparsed",
94+
value:
95+
"linear-gradient(135deg, rgba(255,126,95,1) 0%, rgba(254,180,123,1) 35%, rgba(134,168,231,1) 100%)",
96+
};
97+
return <BackgroundStory styleValue={styleValue} />;
98+
};
99+
100+
export const ConicGradient = () => {
101+
const styleValue: StyleValue = {
102+
type: "unparsed",
103+
value:
104+
"conic-gradient(from 0deg at 50% 50%, rgba(255,126,95,1) 0deg, rgba(254,180,123,1) 120deg, rgba(134,168,231,1) 240deg, rgba(255,126,95,1) 360deg)",
105+
};
106+
return <BackgroundStory styleValue={styleValue} />;
107+
};
108+
109+
export const RadialGradient = () => {
110+
const styleValue: StyleValue = {
111+
type: "unparsed",
112+
value:
113+
"radial-gradient(circle at 50% 50%, rgba(255,126,95,1) 0%, rgba(254,180,123,1) 50%, rgba(134,168,231,1) 100%)",
114+
};
115+
return <BackgroundStory styleValue={styleValue} />;
116+
};
117+
118+
export const SolidColorGradient = () => {
119+
const styleValue: StyleValue = {
120+
type: "unparsed",
121+
value:
122+
"linear-gradient(0deg, rgba(56,189,248,1) 0%, rgba(56,189,248,1) 100%)",
123+
};
124+
return <BackgroundStory styleValue={styleValue} />;
125+
};
126+
66127
export default {
67128
title: "Style Panel/Background",
68-
component: BackgroundContent,
129+
component: Image,
69130
};

0 commit comments

Comments
 (0)