Fix color input does not accept CSS color names (#1089)

## Description

1. What is this PR about (link the issue and add a short description)

Found few bugs.

- [x] - White color have border
- [x] - Fix transparent - all is parsed well its `react-color` feature
<img width="237" alt="image"
src="https://user-images.githubusercontent.com/5077042/220187124-fe8ddc9a-1cc0-4ad3-9979-4037e4a69b5a.png">

- [x] - Synchronize picker color and color shown in input



closes #1081

`+` Added transparent color (need be in design, looks better for most
inputs if anything is shown)

<img width="212" alt="image"
src="https://user-images.githubusercontent.com/5077042/220186642-8cc0bcad-ab35-4444-b773-da652cafb734.png">

<img width="217" alt="image"
src="https://user-images.githubusercontent.com/5077042/220186773-071e6176-066a-4d37-807d-cd6a2db70642.png">


Accessibility is not done fully for the color picker
https://github.com/webstudio-is/webstudio-builder/issues/1092


## Steps for reproduction

1. You are now able to select colors by name
<img width="243" alt="image"
src="https://user-images.githubusercontent.com/5077042/220174790-7e33219d-e955-4718-bad1-0c1ac79ebaf3.png">

2. Colors with non-1 alpha will be shown on chessboard

<img width="225" alt="image"
src="https://user-images.githubusercontent.com/5077042/220174949-2c0c4a45-d330-4a3c-a769-f30bd33b2154.png">


## Code Review

- [ ] hi @kof, I need you to do
  - test it on preview

- [ ] hi @TrySound 
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)


## Before requesting a review

- [x] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [x] tested locally and on preview environment (preview dev login:
5de6)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio-builder/blob/main/apps/builder/docs/test-cases.md)
document
- [x] added tests
- [ ] if any new env variables are added, added them to `.env.example`
and the `builder/env-check.js` if mandatory
This commit is contained in:
Ivan Starkov authored and GitHub committed 2023-02-21 02:48:49 +04:00
1 parent c916c2f7ca
commit 1076d6efd0
8 files changed
+320 -129

No files matched your search

@@ -1,27 +1,36 @@
import { Flex } from "@webstudio-is/design-system";
import { toValue } from "@webstudio-is/css-engine";
import type { ControlProps } from "../../style-sections";
import { ColorPicker } from "../../shared/color-picker";
import {
ColorPicker,
CssColorPickerValueInput,
} from "../../shared/color-picker";
import { colord } from "colord";
import { getStyleSource } from "../../shared/style-info";
import { styleConfigByName } from "../../shared/configs";
import { useState } from "react";
export const ColorControl = ({
property,
items,
currentStyle,
setProperty,
deleteProperty,
}: ControlProps) => {
const [intermediateValue, setIntermediateValue] =
useState<CssColorPickerValueInput>();
const { items: defaultItems } = styleConfigByName[property];
const styleInfo = currentStyle[property];
let value = currentStyle[property]?.value ?? {
// provide default value to avoid control hiding
// when value is recomputed
type: "rgb" as const,
r: 0,
g: 0,
b: 0,
alpha: 0,
type: "keyword" as const,
value: "black",
};
const setValue = setProperty(property);
if (value.type !== "rgb") {
if (value.type !== "rgb" && value.type !== "keyword") {
// Support previously set colors
const colordValue = colord(toValue(value));
@@ -50,12 +59,40 @@ export const ColorControl = ({
return (
<Flex align="center" css={{ gridColumn: "2/4" }} gap="1">
<ColorPicker
id={property}
property={property}
value={value}
onChange={(value) => {
setValue(value, { isEphemeral: true });
styleSource={getStyleSource(styleInfo)}
keywords={(items ?? defaultItems).map((item) => ({
type: "keyword",
value: item.name,
}))}
intermediateValue={intermediateValue}
onChange={(styleValue) => {
setIntermediateValue(styleValue);
if (styleValue === undefined) {
deleteProperty(property, { isEphemeral: true });
return;
}
if (styleValue.type !== "intermediate") {
setValue(styleValue, { isEphemeral: true });
}
}}
onHighlight={(styleValue) => {
if (styleValue !== undefined) {
setValue(styleValue, { isEphemeral: true });
} else {
deleteProperty(property, { isEphemeral: true });
}
}}
onChangeComplete={({ value }) => {
setValue(value);
setIntermediateValue(undefined);
}}
onAbort={() => {
deleteProperty(property, { isEphemeral: true });
}}
onChangeComplete={setValue}
/>
</Flex>
);
@@ -1,19 +1,30 @@
import { useState } from "react";
import { colord, type RgbaColor } from "colord";
import { colord, extend, type RgbaColor } from "colord";
import namesPlugin from "colord/plugins/names";
import { ColorResult, RGBColor, SketchPicker } from "react-color";
import type { RgbValue } from "@webstudio-is/css-data";
import type {
InvalidValue,
KeywordValue,
RgbValue,
StyleProperty,
StyleValue,
} from "@webstudio-is/css-data";
import {
Box,
DeprecatedPopover,
DeprecatedPopoverTrigger,
DeprecatedPopoverContent,
DeprecatedPopoverPortal,
TextField,
Popover,
PopoverTrigger,
PopoverContent,
css,
rawTheme,
} from "@webstudio-is/design-system";
import { toValue } from "@webstudio-is/css-engine";
import { theme } from "@webstudio-is/design-system";
import type { StyleSource } from "./style-info";
import { CssValueInput } from "./css-value-input";
import type { IntermediateStyleValue } from "./css-value-input/css-value-input";
// To support color names
extend([namesPlugin]);
const pickerStyle = css({
padding: theme.spacing[5],
@@ -33,11 +44,23 @@ const defaultPickerStyles = {
},
};
export type CssColorPickerValueInput =
| RgbValue
| KeywordValue
| IntermediateStyleValue;
type ColorPickerProps = {
onChange: (value: RgbValue) => void;
onChangeComplete: (value: RgbValue) => void;
value: RgbValue;
id: string;
onChange: (value: CssColorPickerValueInput | undefined) => void;
onChangeComplete: (event: {
value: RgbValue | KeywordValue | InvalidValue;
}) => void;
onHighlight: (value: StyleValue | undefined) => void;
onAbort: () => void;
intermediateValue: CssColorPickerValueInput | undefined;
value: RgbValue | KeywordValue;
styleSource: StyleSource;
keywords?: Array<KeywordValue>;
property: StyleProperty;
};
const colorResultToRgbValue = (rgb: RgbaColor | RGBColor): RgbValue => {
@@ -50,83 +73,108 @@ const colorResultToRgbValue = (rgb: RgbaColor | RGBColor): RgbValue => {
};
};
const rgbValueToRgbColor = (rgb: RgbValue): RGBColor => {
const styleValueToRgbaColor = (value: CssColorPickerValueInput): RgbaColor => {
const color = colord(
value.type === "intermediate" ? value.value : toValue(value)
).toRgb();
return {
r: rgb.r,
g: rgb.g,
b: rgb.b,
a: rgb.alpha,
r: color.r,
g: color.g,
b: color.b,
a: color.a,
};
};
const whiteColor = { r: 255, g: 255, b: 255, a: 1 };
const borderColorSwatch = colord(rawTheme.colors.borderColorSwatch).toRgb();
const transparent = colord("transparent").toRgb();
const distance = (a: RgbaColor, b: RgbaColor) => {
// Use Euclidian distance, if will not give good results use https://zschuessler.github.io/DeltaE/
return Math.sqrt(
Math.pow(a.r / 255 - b.r / 255, 2) +
Math.pow(a.g / 255 - b.g / 255, 2) +
Math.pow(a.b / 255 - b.b / 255, 2) +
Math.pow(a.a - b.a, 2)
);
};
const lerp = (a: number, b: number, t: number) => {
return a * (1 - t) + b * t;
};
const lerpColor = (a: RgbaColor, b: RgbaColor, t: number) => {
return {
r: lerp(a.r, b.r, t),
g: lerp(a.g, b.g, t),
b: lerp(a.b, b.b, t),
a: lerp(a.a, b.a, t),
};
};
const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max);
};
export const ColorPicker = ({
value,
intermediateValue,
onChange,
onChangeComplete,
id,
onHighlight,
onAbort,
styleSource,
keywords,
property,
}: ColorPickerProps) => {
const [displayColorPicker, setDisplayColorPicker] = useState(false);
const [intermediateValue, setIntermediateValue] = useState<
| {
/**
* TextField value
*/
stringValue: string;
/**
* Color picker value
*/
rgbValue: RGBColor;
state: "invalid" | undefined;
}
| undefined
>(undefined);
const onInputChangeComplete = (source: "enter" | "blur") => {
if (intermediateValue === undefined) {
return;
}
const currentValue = intermediateValue ?? value;
const colordValue = colord(intermediateValue.stringValue);
if (colordValue.isValid()) {
const rgb = colordValue.toRgb();
onChangeComplete({
type: "rgb",
r: rgb.r,
g: rgb.g,
b: rgb.b,
alpha: rgb.a,
});
setIntermediateValue(undefined);
return;
}
const rgbValue = styleValueToRgbaColor(currentValue);
// We don't store invalid values in the CSS data, below is the only way we can show errors.
// See for details: https://github.com/webstudio-is/webstudio-builder/issues/564
// Anyway same behavior has Webflow - shows an error state on "Enter", and resets on Blur
// Change prefix color in sync with color picker, don't change during input changed
const prefixColor =
currentValue.type === "keyword" || currentValue.type === "rgb"
? currentValue
: value;
// In case of "Enter" click show that the value is invalid
if (source === "enter") {
setIntermediateValue({
...intermediateValue,
state: "invalid",
});
return;
}
const prefixColorRgba = styleValueToRgbaColor(prefixColor);
// In case of blur, just reset to external value
setIntermediateValue(undefined);
};
// @todo transparent icon can be better
const background =
prefixColorRgba.a < 1
? // chessboard 5x5
`repeating-conic-gradient(rgba(0,0,0,0.22) 0% 25%, transparent 0% 50%) 0% 33.33% / 40% 40%, ${toValue(
prefixColor
)}`
: toValue(prefixColor);
const stringValue = intermediateValue?.stringValue ?? toValue(value);
const rgbValue = intermediateValue?.rgbValue ?? rgbValueToRgbColor(value);
const distanceToStartDrawBorder = 0.15;
// White color is invisible on white background, so we need to draw border
// the more color is white the more border is visible
const borderColor = colord(
lerpColor(
transparent,
borderColorSwatch,
clamp(
(distanceToStartDrawBorder - distance(whiteColor, prefixColorRgba)) /
distanceToStartDrawBorder,
0,
1
)
)
).toRgbString();
const prefix = (
<DeprecatedPopover
<Popover
modal
open={displayColorPicker}
onOpenChange={setDisplayColorPicker}
>
<DeprecatedPopoverTrigger
<PopoverTrigger
asChild
aria-label="Open color picker"
onClick={() => setDisplayColorPicker((shown) => !shown)}
@@ -136,61 +184,77 @@ export const ColorPicker = ({
margin: theme.spacing[3],
width: theme.spacing[10],
height: theme.spacing[10],
backgroundBlendMode: "difference",
borderRadius: 2,
background: toValue(value),
background,
borderColor,
borderStyle: "solid",
}}
/>
</DeprecatedPopoverTrigger>
<DeprecatedPopoverPortal>
<DeprecatedPopoverContent>
<SketchPicker
color={rgbValue}
onChange={(color: ColorResult) => {
setIntermediateValue({
stringValue: toValue(colorResultToRgbValue(color.rgb)),
rgbValue: color.rgb,
state: undefined,
});
onChange(colorResultToRgbValue(color.rgb));
}}
onChangeComplete={(color: ColorResult) => {
setIntermediateValue(undefined);
onChangeComplete(colorResultToRgbValue(color.rgb));
}}
// @todo to remove both when we have preset colors
presetColors={[]}
className={pickerStyle()}
styles={defaultPickerStyles}
/>
</DeprecatedPopoverContent>
</DeprecatedPopoverPortal>
</DeprecatedPopover>
</PopoverTrigger>
<PopoverContent>
<SketchPicker
color={rgbValue}
onChange={(color: ColorResult) => {
onChange(colorResultToRgbValue(color.rgb));
}}
onChangeComplete={(color: ColorResult) => {
onChangeComplete({
value: colorResultToRgbValue(color.rgb),
});
}}
// @todo to remove both when we have preset colors
presetColors={[]}
className={pickerStyle()}
styles={defaultPickerStyles}
/>
</PopoverContent>
</Popover>
);
return (
<TextField
onChange={(event) => {
setIntermediateValue({
stringValue: event.target.value,
rgbValue: rgbValue,
state: undefined,
});
}}
onBlur={() => {
if (displayColorPicker) {
<CssValueInput
styleSource={styleSource}
icon={prefix}
property={property}
value={value}
intermediateValue={intermediateValue}
keywords={keywords}
onChange={(styleValue) => {
if (
styleValue?.type === "rgb" ||
styleValue?.type === "keyword" ||
styleValue?.type === "intermediate" ||
styleValue === undefined
) {
onChange(styleValue);
return;
}
onInputChangeComplete("blur");
onChange({
type: "intermediate",
value: toValue(styleValue),
});
}}
value={stringValue}
state={intermediateValue?.state}
id={id}
prefix={prefix}
onKeyDown={(event) => {
if (event.key === "Enter") {
onInputChangeComplete("enter");
onHighlight={onHighlight}
onChangeComplete={({ value }) => {
if (
value.type === "rgb" ||
value.type === "keyword" ||
value.type === "invalid"
) {
onChangeComplete({ value });
}
onChangeComplete({
value: {
type: "invalid",
value: toValue(value),
},
});
}}
onAbort={onAbort}
/>
);
};
@@ -200,9 +200,9 @@ export type IntermediateStyleValue = {
unit?: Unit;
};
type CssValueInputValue = StyleValue | IntermediateStyleValue;
export type CssValueInputValue = StyleValue | IntermediateStyleValue;
type ChangeReason =
export type ChangeReason =
| "enter"
| "blur"
| "unit-select"
@@ -322,3 +322,36 @@ describe("Value ending with `-` should be considered unitless", () => {
});
});
});
describe("Colors", () => {
test("color with value rgba(0,0,0,0)", () => {
const result = parseIntermediateOrInvalidValue("color", {
type: "intermediate",
value: "rgba(10,20,30,0.5)",
});
expect(result).toEqual({
type: "rgb",
alpha: 0.5,
r: 10,
g: 20,
b: 30,
});
});
test("color with value rgba(0,0,0,0)", () => {
const result = parseIntermediateOrInvalidValue("color", {
type: "intermediate",
value: "rgba(10,20,30,0.5)",
unit: "px",
});
expect(result).toEqual({
type: "rgb",
alpha: 0.5,
r: 10,
g: 20,
b: 30,
});
});
});
@@ -104,4 +104,33 @@ describe("Parse CSS value", () => {
});
});
});
describe("Colors", () => {
test("Color rgba values", () => {
expect(parseCssValue("backgroundColor", "rgba(0,0,0,0)")).toEqual({
type: "rgb",
alpha: 0,
b: 0,
g: 0,
r: 0,
});
});
test("Color rgba values", () => {
expect(parseCssValue("backgroundColor", "#00220011")).toEqual({
type: "rgb",
alpha: 0.07,
b: 0,
g: 34,
r: 0,
});
});
test("Color rgba values", () => {
expect(parseCssValue("color", "red")).toEqual({
type: "keyword",
value: "red",
});
});
});
});
@@ -3,6 +3,7 @@ import hyphenate from "hyphenate-style-name";
import type { StyleProperty, StyleValue, Unit } from "@webstudio-is/css-data";
import { units, keywordValues } from "@webstudio-is/css-data";
import warnOnce from "warn-once";
import { colord } from "colord";
const cssTryParseValue = (input: string) => {
try {
@@ -122,6 +123,21 @@ export const parseCssValue = (
}
}
// Probably a color (we can use csstree.lexer.matchProperty(cssPropertyName, ast) to extract the type but this looks much simpler)
if (property.toLocaleLowerCase().includes("color")) {
const mayBeColor = colord(input);
if (mayBeColor.isValid()) {
const rgb = mayBeColor.toRgb();
return {
type: "rgb",
alpha: rgb.a,
r: rgb.r,
g: rgb.g,
b: rgb.b,
};
}
}
return {
type: "unparsed",
value: input,
+1
View File
@@ -52,6 +52,7 @@ module.exports = {
"@lexical/react/*",
"__generated__/*",
"react-hot-toast/headless",
"colord/plugins/*",
],
},
],
@@ -1,5 +1,5 @@
import { detectFont } from "detect-font";
import type { Style, StyleValue, Unit } from "@webstudio-is/css-data";
import { keywordValues, Style, StyleValue, Unit } from "@webstudio-is/css-data";
import { properties, units } from "@webstudio-is/css-data";
const unitsList = Object.values(units).flat();
@@ -16,9 +16,20 @@ const parseValue = (
value = "transparent";
}
if (Number.isNaN(number)) {
const values = keywordValues[
property as keyof typeof keywordValues
] as ReadonlyArray<string>;
if (values?.includes(value)) {
return {
type: "keyword",
value: value,
};
}
return {
type: "keyword",
value,
type: "unparsed",
value: value,
};
}