Skip to content

Commit 7986f20

Browse files
Merge pull request #374 from eccenca/feature/colorWheelInput-CMEM-7327
Provide ColorField input (CMEM-7327)
2 parents d5f36c7 + 92ccbfa commit 7986f20

12 files changed

Lines changed: 521 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
2323
- `paddingSize` property to add easily some white space
2424
- `<CodeEditor />`
2525
- toolbar in `markdown` mode provides a user config menu for the editor appearance
26+
- `<RadioButton />`
27+
- `hideIndicator` property: hide the radio inout indicator but click on children can be processed via `onChange` event
28+
- `<ColorField />`
29+
- input component for colors
30+
- uses a subset from the configured color palette by default, but it also allows to enter custom colors
2631
- CSS custom properties
2732
- beside the color palette we now mirror the most important layout configuration variables as CSS custom properties
2833
- new icons:
@@ -60,7 +65,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
6065
- `<EdgeDefault />`
6166
- reduce stroke width to only 1px
6267
- `<CodeMirror />`
63-
- `wrapLines` and `preventLineNumber` do use `false` default value but if not set then it will be interpreted as `false`
68+
- `wrapLines` and `preventLineNumber` do not use `false` default value but if not set then it will be interpreted as `false`
6469
- in this way it can be overwritten by new user config for the markdown mode
6570
- automatically hide user interaction elements in print view
6671
- all application header components except `<WorkspaceHeader />`

src/common/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { decode } from "he";
33
import { invisibleZeroWidthCharacters } from "./utils/characters";
44
import { colorCalculateDistance } from "./utils/colorCalculateDistance";
55
import decideContrastColorValue from "./utils/colorDecideContrastvalue";
6-
import { getEnabledColorsFromPalette, textToColorHash } from "./utils/colorHash";
6+
import { getEnabledColorPropertiesFromPalette, getEnabledColorsFromPalette, textToColorHash } from "./utils/colorHash";
77
import getColorConfiguration from "./utils/getColorConfiguration";
88
import { getScrollParent } from "./utils/getScrollParent";
99
import { getGlobalVar, setGlobalVar } from "./utils/globalVars";
@@ -22,6 +22,7 @@ export const utils = {
2222
setGlobalVar,
2323
getScrollParent,
2424
getEnabledColorsFromPalette,
25+
getEnabledColorPropertiesFromPalette,
2526
textToColorHash,
2627
reduceToText,
2728
decodeHtmlEntities: decode,

src/common/utils/CssCustomProperties.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export default class CssCustomProperties {
2828

2929
// Methods
3030

31-
customProperties = (props: getCustomPropertiesProps = {}): string[][] | Record<string, string> => {
31+
customProperties = (props: getCustomPropertiesProps = {}): [string, string][] | Record<string, string> => {
3232
// FIXME:
3333
// in case of performance issues results should get saved at least into intern variables
3434
// other cache strategies could be also tested
@@ -104,7 +104,9 @@ export default class CssCustomProperties {
104104
});
105105
};
106106

107-
static listCustomProperties = (props: getCustomPropertiesProps = {}): string[][] | Record<string, string> => {
107+
static listCustomProperties = (
108+
props: getCustomPropertiesProps = {}
109+
): [string, string][] | Record<string, string> => {
108110
const { removeDashPrefix = true, returnObject = true, filterName = () => true, ...filterProps } = props;
109111

110112
const customProperties = CssCustomProperties.listLocalCssStyleRuleProperties({
@@ -123,6 +125,6 @@ export default class CssCustomProperties {
123125

124126
return returnObject
125127
? (Object.fromEntries(customProperties) as Record<string, string>)
126-
: (customProperties as string[][]);
128+
: (customProperties as [string, string][]);
127129
};
128130
}

src/common/utils/colorHash.ts

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import { colorCalculateDistance } from "./colorCalculateDistance";
66
import CssCustomProperties from "./CssCustomProperties";
77

88
type ColorOrFalse = Color | false;
9-
type ColorWeight = 100 | 300 | 500 | 700 | 900;
10-
type PaletteGroup = "identity" | "semantic" | "layout" | "extra";
9+
export type ColorWeight = 100 | 300 | 500 | 700 | 900;
10+
export type PaletteGroup = "identity" | "semantic" | "layout" | "extra";
1111

12-
interface getEnabledColorsProps {
12+
export interface getEnabledColorsProps {
1313
/** Specify the palette groups used to define the set of colors. */
1414
includePaletteGroup?: PaletteGroup[];
1515
/** Use only some weights of a color tint. */
@@ -21,20 +21,43 @@ interface getEnabledColorsProps {
2121
}
2222

2323
const getEnabledColorsFromPaletteCache = new Map<string, Color[]>();
24+
const getEnabledColorPropertiesFromPaletteCache = new Map<string, [string, string][]>();
2425

25-
export function getEnabledColorsFromPalette({
26+
export function getEnabledColorsFromPalette(props: getEnabledColorsProps): Color[] {
27+
const configId = JSON.stringify({
28+
includePaletteGroup: props.includePaletteGroup,
29+
includeColorWeight: props.includeColorWeight,
30+
});
31+
32+
if (getEnabledColorsFromPaletteCache.has(configId)) {
33+
return getEnabledColorsFromPaletteCache.get(configId)!;
34+
}
35+
36+
const colorPropertiesFromPalette = Object.values(getEnabledColorPropertiesFromPalette(props));
37+
38+
getEnabledColorsFromPaletteCache.set(
39+
configId,
40+
colorPropertiesFromPalette.map((color) => {
41+
return Color(color[1]);
42+
})
43+
);
44+
45+
return getEnabledColorsFromPaletteCache.get(configId)!;
46+
}
47+
48+
export function getEnabledColorPropertiesFromPalette({
2649
includePaletteGroup = ["layout"],
2750
includeColorWeight = [100, 300, 500, 700, 900],
28-
// TODO (planned for later): includeMixedColors = false,
51+
// (planned for later): includeMixedColors = false,
2952
minimalColorDistance = COLORMINDISTANCE,
30-
}: getEnabledColorsProps): Color[] {
53+
}: getEnabledColorsProps): [string, string][] {
3154
const configId = JSON.stringify({
3255
includePaletteGroup,
3356
includeColorWeight,
3457
});
3558

36-
if (getEnabledColorsFromPaletteCache.has(configId)) {
37-
return getEnabledColorsFromPaletteCache.get(configId)!;
59+
if (getEnabledColorPropertiesFromPaletteCache.has(configId)) {
60+
return getEnabledColorPropertiesFromPaletteCache.get(configId)!;
3861
}
3962

4063
const colorsFromPalette = new CssCustomProperties({
@@ -50,18 +73,18 @@ export function getEnabledColorsFromPalette({
5073
const weight = parseInt(tint[2], 10) as ColorWeight;
5174
return includePaletteGroup.includes(group) && includeColorWeight.includes(weight);
5275
},
53-
removeDashPrefix: false,
76+
removeDashPrefix: true,
5477
returnObject: true,
5578
}).customProperties();
5679

57-
const colorsFromPaletteValues = Object.values(colorsFromPalette) as string[];
80+
const colorsFromPaletteValues = Object.entries(colorsFromPalette) as [string, string][];
5881

5982
const colorsFromPaletteWithEnoughDistance =
6083
minimalColorDistance > 0
61-
? colorsFromPaletteValues.reduce((enoughDistance: string[], color: string) => {
84+
? colorsFromPaletteValues.reduce((enoughDistance: [string, string][], color: [string, string]) => {
6285
if (enoughDistance.includes(color)) {
6386
return enoughDistance.filter((checkColor) => {
64-
const distance = colorCalculateDistance({ color1: color, color2: checkColor });
87+
const distance = colorCalculateDistance({ color1: color[1], color2: checkColor[1] });
6588
return checkColor === color || (distance && minimalColorDistance <= distance);
6689
});
6790
} else {
@@ -70,14 +93,9 @@ export function getEnabledColorsFromPalette({
7093
}, colorsFromPaletteValues)
7194
: colorsFromPaletteValues;
7295

73-
getEnabledColorsFromPaletteCache.set(
74-
configId,
75-
colorsFromPaletteWithEnoughDistance.map((color: string) => {
76-
return Color(color);
77-
})
78-
);
96+
getEnabledColorPropertiesFromPaletteCache.set(configId, colorsFromPaletteWithEnoughDistance);
7997

80-
return getEnabledColorsFromPaletteCache.get(configId)!;
98+
return getEnabledColorPropertiesFromPaletteCache.get(configId)!;
8199
}
82100

83101
function getColorcode(text: string): ColorOrFalse {
@@ -148,7 +166,7 @@ export function textToColorHash({
148166
}
149167

150168
function stringToIntegerHash(inputString: string): number {
151-
/* this function is idempotend, meaning it retrieves the same result for the same input
169+
/* this function is idempotent, meaning it retrieves the same result for the same input
152170
no matter how many times it's called */
153171
// Convert the string to a hash code
154172
let hashCode = 0;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React from "react";
2+
import { Meta, StoryFn } from "@storybook/react";
3+
4+
import { getEnabledColorsProps } from "../../common/utils/colorHash";
5+
import textFieldTest from "../TextField/stories/TextField.stories";
6+
7+
import { ColorField, ColorFieldProps } from "./ColorField";
8+
9+
export default {
10+
title: "Forms/ColorField",
11+
component: ColorField,
12+
argTypes: {
13+
...textFieldTest.argTypes,
14+
},
15+
} as Meta<typeof ColorField>;
16+
17+
const Template: StoryFn<typeof ColorField> = (args) => <ColorField {...args}></ColorField>;
18+
19+
export const Default = Template.bind({});
20+
Default.args = {
21+
onChange: (e) => {
22+
alert(e.target.value);
23+
},
24+
};
25+
26+
export const NoPalettePresets = Template.bind({});
27+
NoPalettePresets.args = {
28+
...Default.args,
29+
allowCustomColor: true,
30+
colorPresets: [],
31+
};
32+
33+
type TemplateColorHashProps = { stringForColorHashValue: string } & Pick<
34+
ColorFieldProps,
35+
"onChange" | "allowCustomColor"
36+
> &
37+
Pick<getEnabledColorsProps, "includeColorWeight" | "includePaletteGroup">;
38+
39+
const TemplateColorHash: StoryFn<TemplateColorHashProps> = (args: TemplateColorHashProps) => (
40+
<ColorField
41+
allowCustomColor={args.allowCustomColor}
42+
colorPresets={ColorField.listColorPalettePresets({
43+
includeColorWeight: args.includeColorWeight,
44+
includePaletteGroup: args.includePaletteGroup,
45+
})}
46+
value={ColorField.calculateColorHashValue(args.stringForColorHashValue, {
47+
allowCustomColor: args.allowCustomColor,
48+
includeColorWeight: args.includeColorWeight,
49+
includePaletteGroup: args.includePaletteGroup,
50+
})}
51+
/>
52+
);
53+
54+
/**
55+
* Component provides a helper function to calculate a color hash from a text,
56+
* that can be used as `value` or `defaultValue`.
57+
*
58+
* ```
59+
* <ColorField value={ColorField.calculateColorHashValue("MyText")} />
60+
* ```
61+
*
62+
* You can add `options` to set the config for the color palette filters.
63+
* The same default values like on `ColorField` are used for them.
64+
*/
65+
export const ColorHashValue = TemplateColorHash.bind({});
66+
ColorHashValue.args = {
67+
...Default.args,
68+
allowCustomColor: true,
69+
includeColorWeight: [300, 500, 700],
70+
includePaletteGroup: ["layout", "extra"],
71+
stringForColorHashValue: "My text that will used to create a color hash as initial value.",
72+
};
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import React from "react";
2+
import { render } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
5+
import "@testing-library/jest-dom";
6+
7+
import { CLASSPREFIX as eccgui } from "../../configuration/constants";
8+
9+
import { ColorField } from "./ColorField";
10+
11+
describe("ColorField", () => {
12+
describe("rendering", () => {
13+
it("renders without crashing, and correct component CSS class is applied", () => {
14+
const { container } = render(<ColorField />);
15+
expect(container).not.toBeEmptyDOMElement();
16+
expect(container.getElementsByClassName(`${eccgui}-colorfield`).length).toBe(1);
17+
});
18+
19+
it("renders a color input by default (no palette presets)", () => {
20+
const { container } = render(<ColorField colorPresets={[]} allowCustomColor={true} />);
21+
expect(container.querySelector("input[type='color']")).toBeInTheDocument();
22+
});
23+
24+
it("renders a readonly text input when palette colors are configured, and custom picker CSS class is applied", () => {
25+
const { container } = render(
26+
<ColorField
27+
className="my-custom-class"
28+
colorPresets={[
29+
["my-black", "#000000"],
30+
["my-white", "#ffffff"],
31+
]}
32+
/>
33+
);
34+
// With default palette settings, a text input with readOnly is shown
35+
expect(container.querySelector("input[type='text']")).toBeInTheDocument();
36+
expect(container.querySelector("input[readonly]")).toBeInTheDocument();
37+
expect(container.querySelector(`.${eccgui}-colorfield--custom-picker`)).toBeInTheDocument();
38+
});
39+
40+
it("applies additional className", () => {
41+
render(<ColorField className="my-custom-class" colorPresets={[]} allowCustomColor={true} />);
42+
expect(document.querySelector(".my-custom-class")).toBeInTheDocument();
43+
});
44+
});
45+
46+
describe("value handling", () => {
47+
it("uses defaultValue as initial color", () => {
48+
render(<ColorField defaultValue="#ff0000" colorPresets={[]} allowCustomColor={true} />);
49+
const input = document.querySelector("input") as HTMLInputElement;
50+
expect(input.value).toBe("#ff0000");
51+
});
52+
53+
it("uses value prop as initial color", () => {
54+
render(<ColorField value="#00ff00" colorPresets={[]} allowCustomColor={true} />);
55+
const input = document.querySelector("input") as HTMLInputElement;
56+
expect(input.value).toBe("#00ff00");
57+
});
58+
59+
it("falls back to #000000 when no value or defaultValue is provided", () => {
60+
render(<ColorField colorPresets={[]} allowCustomColor={true} />);
61+
const input = document.querySelector("input") as HTMLInputElement;
62+
expect(input.value).toBe("#000000");
63+
});
64+
65+
it("updates displayed value when value prop changes", () => {
66+
const { rerender } = render(<ColorField value="#ff0000" colorPresets={[]} allowCustomColor={true} />);
67+
let input = document.querySelector("input") as HTMLInputElement;
68+
expect(input.value).toBe("#ff0000");
69+
70+
rerender(<ColorField value="#0000ff" colorPresets={[]} allowCustomColor={true} />);
71+
input = document.querySelector("input") as HTMLInputElement;
72+
expect(input.value).toBe("#0000ff");
73+
});
74+
});
75+
76+
describe("disabled state", () => {
77+
it("is disabled when disabled prop is true", () => {
78+
render(<ColorField disabled colorPresets={[]} allowCustomColor={true} />);
79+
const input = document.querySelector("input") as HTMLInputElement;
80+
expect(input).toBeDisabled();
81+
});
82+
83+
it("is disabled when no palette colors and allowCustomColor is false", () => {
84+
render(<ColorField colorPresets={[]} allowCustomColor={false} />);
85+
const input = document.querySelector("input") as HTMLInputElement;
86+
expect(input).toBeDisabled();
87+
});
88+
});
89+
90+
describe("onChange callback", () => {
91+
it("calls onChange when native color input changes", async () => {
92+
const user = userEvent.setup();
93+
const onChange = jest.fn();
94+
render(<ColorField onChange={onChange} colorPresets={[]} allowCustomColor={true} />);
95+
const input = document.querySelector("input[type='color']") as HTMLInputElement;
96+
input.type = "text"; // for unknown reasons Jest seems not able to test it on color inputs
97+
await user.type(input, "#123456");
98+
expect(onChange).toHaveBeenCalled();
99+
});
100+
});
101+
});

0 commit comments

Comments
 (0)