From db14876e186ff58a7b7dcac7b48984bc6ac68ecd Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Tue, 3 Mar 2026 10:41:50 +0100 Subject: [PATCH 01/13] add option to hide radio button indicator --- CHANGELOG.md | 12 +++++++----- src/components/RadioButton/RadioButton.tsx | 18 +++++++++++++++--- src/components/RadioButton/radiobutton.scss | 13 +++++++++++++ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f61ca14a7..4fd8ee70f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `` - component for hiding elements in specific media - `` - - force children to get displayed as inline content + - force children to get displayed as inline content - `` - - `useOnly` property: specify if only parts of the content should be used for the shortened preview, this property replaces `firstNonEmptyLineOnly` + - `useOnly` property: specify if only parts of the content should be used for the shortened preview, this property replaces `firstNonEmptyLineOnly` +- `` + - `hideIndicator` property: hide the radio inout indicator but click on children can be processed via `onChange` event ### Fixed @@ -21,11 +23,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - create more whitespace inside `small` tag - reduce visual impact of border - `` - - take Markdown rendering into account before testing the maximum preview length + - take Markdown rendering into account before testing the maximum preview length - `` - header-menu items are vertically centered now - `Typography` - - adjust displaying fallback symbols in different browsers + - adjust displaying fallback symbols in different browsers ### Changed @@ -43,7 +45,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Deprecated - `` - - `firstNonEmptyLineOnly` will be removed, is replaced by `useOnly="firstNonEmptyLine"` + - `firstNonEmptyLineOnly` will be removed, is replaced by `useOnly="firstNonEmptyLine"` ## [25.0.0] - 2025-12-01 diff --git a/src/components/RadioButton/RadioButton.tsx b/src/components/RadioButton/RadioButton.tsx index 477d10030..e4e3f5272 100644 --- a/src/components/RadioButton/RadioButton.tsx +++ b/src/components/RadioButton/RadioButton.tsx @@ -1,13 +1,25 @@ import React from "react"; import { Radio as BlueprintRadioButton, RadioProps as BlueprintRadioProps } from "@blueprintjs/core"; +import classNames from "classnames"; import { CLASSPREFIX as eccgui } from "../../configuration/constants"; -export type RadioButtonProps = BlueprintRadioProps; +export interface RadioButtonProps extends BlueprintRadioProps { + /** + * Hide the indicator. + * The element cannot be identified as radio input then but a click on the children can be easily processed via `onChange` event. + */ + hideIndicator?: boolean; +} -export const RadioButton = ({ children, className = "", ...restProps }: RadioButtonProps) => { +export const RadioButton = ({ children, className = "", hideIndicator = false, ...restProps }: RadioButtonProps) => { return ( - + {children} ); diff --git a/src/components/RadioButton/radiobutton.scss b/src/components/RadioButton/radiobutton.scss index ba6648323..0744acf3f 100644 --- a/src/components/RadioButton/radiobutton.scss +++ b/src/components/RadioButton/radiobutton.scss @@ -29,3 +29,16 @@ } } } + +.#{$eccgui}-radiobutton--hidden-indicator { + &:not(.#{$ns}-align-right) { + padding-inline-start: 0; + } + &:not(.#{$ns}-align-left) { + padding-inline-end: 0; + } + + input ~ .#{$ns}-control-indicator { + visibility: hidden; + } +} From bb814f497325ed8449e7126d053a106842740805 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Tue, 3 Mar 2026 12:31:44 +0100 Subject: [PATCH 02/13] add helper function to get palette CSS colors including its custom property name --- src/common/utils/colorHash.ts | 54 +++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/src/common/utils/colorHash.ts b/src/common/utils/colorHash.ts index 45dab3daa..87af57844 100644 --- a/src/common/utils/colorHash.ts +++ b/src/common/utils/colorHash.ts @@ -6,8 +6,8 @@ import { colorCalculateDistance } from "./colorCalculateDistance"; import CssCustomProperties from "./CssCustomProperties"; type ColorOrFalse = Color | false; -type ColorWeight = 100 | 300 | 500 | 700 | 900; -type PaletteGroup = "identity" | "semantic" | "layout" | "extra"; +export type ColorWeight = 100 | 300 | 500 | 700 | 900; +export type PaletteGroup = "identity" | "semantic" | "layout" | "extra"; interface getEnabledColorsProps { /** Specify the palette groups used to define the set of colors. */ @@ -21,20 +21,43 @@ interface getEnabledColorsProps { } const getEnabledColorsFromPaletteCache = new Map(); +const getEnabledColorPropertiesFromPaletteCache = new Map(); -export function getEnabledColorsFromPalette({ +export function getEnabledColorsFromPalette(props: getEnabledColorsProps): Color[] { + const configId = JSON.stringify({ + includePaletteGroup: props.includePaletteGroup, + includeColorWeight: props.includeColorWeight, + }); + + if (getEnabledColorsFromPaletteCache.has(configId)) { + return getEnabledColorsFromPaletteCache.get(configId)!; + } + + const colorPropertiesFromPalette = Object.values(getEnabledColorPropertiesFromPalette(props)); + + getEnabledColorsFromPaletteCache.set( + configId, + colorPropertiesFromPalette.map((color) => { + return Color(color[1]); + }) + ); + + return getEnabledColorsFromPaletteCache.get(configId)!; +} + +export function getEnabledColorPropertiesFromPalette({ includePaletteGroup = ["layout"], includeColorWeight = [100, 300, 500, 700, 900], - // TODO (planned for later): includeMixedColors = false, + // (planned for later): includeMixedColors = false, minimalColorDistance = COLORMINDISTANCE, -}: getEnabledColorsProps): Color[] { +}: getEnabledColorsProps): string[][] { const configId = JSON.stringify({ includePaletteGroup, includeColorWeight, }); - if (getEnabledColorsFromPaletteCache.has(configId)) { - return getEnabledColorsFromPaletteCache.get(configId)!; + if (getEnabledColorPropertiesFromPaletteCache.has(configId)) { + return getEnabledColorPropertiesFromPaletteCache.get(configId)!; } const colorsFromPalette = new CssCustomProperties({ @@ -50,18 +73,18 @@ export function getEnabledColorsFromPalette({ const weight = parseInt(tint[2], 10) as ColorWeight; return includePaletteGroup.includes(group) && includeColorWeight.includes(weight); }, - removeDashPrefix: false, + removeDashPrefix: true, returnObject: true, }).customProperties(); - const colorsFromPaletteValues = Object.values(colorsFromPalette) as string[]; + const colorsFromPaletteValues = Object.entries(colorsFromPalette) as [string, string][]; const colorsFromPaletteWithEnoughDistance = minimalColorDistance > 0 - ? colorsFromPaletteValues.reduce((enoughDistance: string[], color: string) => { + ? colorsFromPaletteValues.reduce((enoughDistance: [string, string][], color: [string, string]) => { if (enoughDistance.includes(color)) { return enoughDistance.filter((checkColor) => { - const distance = colorCalculateDistance({ color1: color, color2: checkColor }); + const distance = colorCalculateDistance({ color1: color[1], color2: checkColor[1] }); return checkColor === color || (distance && minimalColorDistance <= distance); }); } else { @@ -70,14 +93,9 @@ export function getEnabledColorsFromPalette({ }, colorsFromPaletteValues) : colorsFromPaletteValues; - getEnabledColorsFromPaletteCache.set( - configId, - colorsFromPaletteWithEnoughDistance.map((color: string) => { - return Color(color); - }) - ); + getEnabledColorPropertiesFromPaletteCache.set(configId, colorsFromPaletteWithEnoughDistance); - return getEnabledColorsFromPaletteCache.get(configId)!; + return getEnabledColorPropertiesFromPaletteCache.get(configId)!; } function getColorcode(text: string): ColorOrFalse { From 4f54ebf29abc357a4796b36d29e0c2a41c6d0ddb Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Tue, 3 Mar 2026 12:36:35 +0100 Subject: [PATCH 03/13] add ColorField component --- CHANGELOG.md | 2 + src/common/index.ts | 3 +- .../ColorField/ColorField.stories.tsx | 33 ++++ src/components/ColorField/ColorField.tsx | 167 ++++++++++++++++++ src/components/ColorField/_colorfield.scss | 56 ++++++ src/components/index.scss | 1 + 6 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 src/components/ColorField/ColorField.stories.tsx create mode 100644 src/components/ColorField/ColorField.tsx create mode 100644 src/components/ColorField/_colorfield.scss diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd8ee70f..eefb24285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `useOnly` property: specify if only parts of the content should be used for the shortened preview, this property replaces `firstNonEmptyLineOnly` - `` - `hideIndicator` property: hide the radio inout indicator but click on children can be processed via `onChange` event +- `` + - input component for colors, uses the configured palette by default but it also allows to enter custom colors ### Fixed diff --git a/src/common/index.ts b/src/common/index.ts index ab989fa3a..ed8eb78a9 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -3,7 +3,7 @@ import { decode } from "he"; import { invisibleZeroWidthCharacters } from "./utils/characters"; import { colorCalculateDistance } from "./utils/colorCalculateDistance"; import decideContrastColorValue from "./utils/colorDecideContrastvalue"; -import { getEnabledColorsFromPalette, textToColorHash } from "./utils/colorHash"; +import { getEnabledColorPropertiesFromPalette, getEnabledColorsFromPalette, textToColorHash } from "./utils/colorHash"; import getColorConfiguration from "./utils/getColorConfiguration"; import { getScrollParent } from "./utils/getScrollParent"; import { getGlobalVar, setGlobalVar } from "./utils/globalVars"; @@ -22,6 +22,7 @@ export const utils = { setGlobalVar, getScrollParent, getEnabledColorsFromPalette, + getEnabledColorPropertiesFromPalette, textToColorHash, reduceToText, decodeHtmlEntities: decode, diff --git a/src/components/ColorField/ColorField.stories.tsx b/src/components/ColorField/ColorField.stories.tsx new file mode 100644 index 000000000..e6058e279 --- /dev/null +++ b/src/components/ColorField/ColorField.stories.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { Meta, StoryFn } from "@storybook/react"; + +import textFieldTest from "../TextField/stories/TextField.stories"; + +import { ColorField } from "./ColorField"; + +export default { + title: "Forms/ColorField", + component: ColorField, + argTypes: { + ...textFieldTest.argTypes, + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + onChange: (e) => { + alert(e.target.value); + }, +}; + +export const NoPalettePresets = Template.bind({}); +NoPalettePresets.args = { + colorWeightFilter: [], + paletteGroupFilter: [], + allowCustomColor: true, + onChange: (e) => { + alert(e.target.value); + }, +}; diff --git a/src/components/ColorField/ColorField.tsx b/src/components/ColorField/ColorField.tsx new file mode 100644 index 000000000..ba5050669 --- /dev/null +++ b/src/components/ColorField/ColorField.tsx @@ -0,0 +1,167 @@ +import React, { CSSProperties } from "react"; +import classNames from "classnames"; + +import { utils } from "../../common"; +import { ColorWeight, PaletteGroup } from "../../common/utils/colorHash"; +import { CLASSPREFIX as eccgui } from "../../configuration/constants"; +import { ContextOverlay } from "../ContextOverlay"; +import { FieldSet } from "../Form"; +import { RadioButton } from "../RadioButton/RadioButton"; +import { Spacing } from "../Separation/Spacing"; +import { Tag, TagList } from "../Tag"; +import { TextField, TextFieldProps } from "../TextField"; +import { Tooltip } from "../Tooltip/Tooltip"; +import { WhiteSpaceContainer } from "../Typography"; + +export interface ColorFieldProps extends Omit { + /** + * Any color can be selected, not only from the configured color palette. + */ + allowCustomColor?: boolean; + /** + * What color weights should be included in the set of allowed colors. + */ + colorWeightFilter?: ColorWeight[]; + /** + * What palette groups should be included in the set of allowed colors. + */ + paletteGroupFilter?: PaletteGroup[]; +} + +/** + * Text input field. + */ +export const ColorField = ({ + className = "", + allowCustomColor = false, + colorWeightFilter = [100, 300, 700, 900], + paletteGroupFilter = ["layout"], + defaultValue, + value, + onChange, + fullWidth = false, + ...otherTextFieldProps +}: ColorFieldProps) => { + const ref = React.useRef(null); + const [colorValue, setColorValue] = React.useState(defaultValue || value || "#000000"); + + let allowedPaletteColors, disableNativePicker, disabled; + const updateConfig = () => { + allowedPaletteColors = utils.getEnabledColorPropertiesFromPalette({ + includePaletteGroup: paletteGroupFilter, + includeColorWeight: colorWeightFilter, + minimalColorDistance: 0, // we use all allowed colors, and do not check distances between them + }); + + disableNativePicker = + colorWeightFilter.length > 0 && paletteGroupFilter.length > 0 && allowedPaletteColors.length > 0; + disabled = (!disableNativePicker && !allowCustomColor) || otherTextFieldProps.disabled; + }; + updateConfig(); + React.useEffect(() => { + updateConfig(); + }, [allowCustomColor, colorWeightFilter, paletteGroupFilter, otherTextFieldProps]); + + React.useEffect(() => { + setColorValue(defaultValue || value || "#000000"); + }, [defaultValue, value]); + + const forwardOnChange = (forwardedEvent: React.ChangeEvent) => { + setColorValue(forwardedEvent.target.value); + if (onChange) { + onChange(forwardedEvent); + } + }; + + const colorInput = ( + ) => { + forwardOnChange(e); + } + : undefined + } + style={{ ...otherTextFieldProps.style, [`--eccgui-colorfield-background`]: colorValue } as CSSProperties} + /> + ); + + return disableNativePicker && !disabled ? ( + + {allowCustomColor && ( + <> + ) => { + forwardOnChange(e); + }} + /> + + + )} +
+ = 3 ? colorWeightFilter.length * 2 : "8" + }col`} + > + {allowedPaletteColors!.map((color: [string, string], idx: number) => [ + ) => { + forwardOnChange(e); + }} + > + + + {color[1]} + + + , + // Looks like we cannot force some type of line break in the tag list via CSS only + (idx + 1) % (colorWeightFilter.length >= 3 ? colorWeightFilter.length * 2 : 8) === + 0 && ( + <> +
+ + ), + ])} +
+
+ + } + > + {colorInput} +
+ ) : ( + colorInput + ); +}; + +export default ColorField; diff --git a/src/components/ColorField/_colorfield.scss b/src/components/ColorField/_colorfield.scss new file mode 100644 index 000000000..51e8b929a --- /dev/null +++ b/src/components/ColorField/_colorfield.scss @@ -0,0 +1,56 @@ +.#{$eccgui}-colorfield { + cursor: default; + + &:not(.#{$ns}-fill) { + width: 100%; + max-width: 4 * $eccgui-size-textfield-height-regular; + } + + .#{$ns}-input { + color: var(--#{$eccgui}-colorfield-background); + cursor: inherit; + background-color: var(--#{$eccgui}-colorfield-background); + + &[type="color"] { + &::-webkit-color-swatch-wrapper { + display: none; + } + + &::-moz-color-swatch { + display: none; + } + } + } + + .#{$ns}-input-left-container { + top: 1px; + left: 1px !important; + height: calc(100% - 2px); + background-color: $eccgui-color-textfield-background; + } + .#{$ns}-input-action { + top: 1px; + right: 1px !important; + height: calc(100% - 2px); + background-color: $eccgui-color-textfield-background; + } +} + +.#{$eccgui}-colorfield__palette { + & > li:has(.#{$eccgui}-colorfield__palette-linebreak) { + display: block; + width: 100%; + height: 0; + margin: 0; + overflow: hidden; + } +} + +.#{$eccgui}-colorfield__palette__color { + margin: 0; + .#{$eccgui}-tag__item { + width: 3rem; + color: var(--#{$eccgui}-colorfield-palette-color) !important; + background-color: var(--#{$eccgui}-colorfield-palette-color) !important; + } +} diff --git a/src/components/index.scss b/src/components/index.scss index edd0976c4..a373920be 100644 --- a/src/components/index.scss +++ b/src/components/index.scss @@ -32,6 +32,7 @@ @import "./Tabs/tabs"; @import "./Tag/tag"; @import "./TextField/textfield"; +@import "./ColorField/colorfield"; @import "./TagInput/taginput"; @import "./Toolbar/toolbar"; @import "./Tooltip/tooltip"; From 5f2efcd13e8b9d214dc60fe07127f1cb81a8c44a Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Thu, 5 Mar 2026 15:36:00 +0100 Subject: [PATCH 04/13] add color hash helper function/story and fix component description --- .../ColorField/ColorField.stories.tsx | 44 +++++++++++++++++-- src/components/ColorField/ColorField.tsx | 35 ++++++++++++++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/components/ColorField/ColorField.stories.tsx b/src/components/ColorField/ColorField.stories.tsx index e6058e279..5d61b641e 100644 --- a/src/components/ColorField/ColorField.stories.tsx +++ b/src/components/ColorField/ColorField.stories.tsx @@ -3,7 +3,7 @@ import { Meta, StoryFn } from "@storybook/react"; import textFieldTest from "../TextField/stories/TextField.stories"; -import { ColorField } from "./ColorField"; +import { ColorField, ColorFieldProps } from "./ColorField"; export default { title: "Forms/ColorField", @@ -24,10 +24,46 @@ Default.args = { export const NoPalettePresets = Template.bind({}); NoPalettePresets.args = { + ...Default.args, colorWeightFilter: [], paletteGroupFilter: [], allowCustomColor: true, - onChange: (e) => { - alert(e.target.value); - }, +}; + +interface TemplateColorHashProps + extends Pick { + stringForColorHashValue: string; +} + +const TemplateColorHash: StoryFn = (args: TemplateColorHashProps) => ( + +); + +/** + * Component provides a helper function to calculate a color hash from a text, + * that can be used as `value` or `defaultValue`. + * + * ``` + * + * ``` + * + * You can add `options` to set the config for the color palette filters. + * The same default values like on `ColorField` are used for them. + */ +export const ColorHashValue = TemplateColorHash.bind({}); +ColorHashValue.args = { + ...Default.args, + allowCustomColor: true, + colorWeightFilter: [300, 500, 700], + paletteGroupFilter: ["layout", "extra"], + stringForColorHashValue: "My text that will used to create a color hash as initial value.", }; diff --git a/src/components/ColorField/ColorField.tsx b/src/components/ColorField/ColorField.tsx index ba5050669..9638aaa30 100644 --- a/src/components/ColorField/ColorField.tsx +++ b/src/components/ColorField/ColorField.tsx @@ -29,7 +29,8 @@ export interface ColorFieldProps extends Omit; + +/** + * Simple helper function that provide simple access to color hash calculation. + * Using the same default values for the color palette filter. + */ +ColorField.calculateColorHashValue = ( + text: string, + options: calculateColorHashValueProps = { + allowCustomColor: false, + colorWeightFilter: [100, 300, 700, 900], + paletteGroupFilter: ["layout"], + } +) => { + const hash = utils.textToColorHash({ + text, + options: { + returnValidColorsDirectly: options.allowCustomColor as boolean, + enabledColors: utils.getEnabledColorsFromPalette({ + includePaletteGroup: options.paletteGroupFilter, + includeColorWeight: options.colorWeightFilter, + minimalColorDistance: 0, + }), + }, + }); + + return hash ? hash : undefined; +}; + export default ColorField; From 8a6a1ac8d666f80a393f9646169994d88ac0ff14 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Thu, 5 Mar 2026 17:02:42 +0100 Subject: [PATCH 05/13] fix ColorField export --- src/components/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/index.ts b/src/components/index.ts index 3ee70457e..372973482 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,6 +7,7 @@ export * from "./Card"; export * from "./Chat"; export * from "./Checkbox/Checkbox"; export * from "./CodeAutocompleteField"; +export * from "./ColorField/ColorField"; export * from "./ContentGroup/ContentGroup"; export * from "./ContextOverlay"; export * from "./DecoupledOverlay/DecoupledOverlay"; From aa9454b2838009f70e603f83ac7d284fdce85733 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Mon, 9 Mar 2026 08:51:43 +0100 Subject: [PATCH 06/13] align property names with color hash helper functions --- CHANGELOG.md | 6 ++--- src/components/ColorField/ColorField.tsx | 32 ++++++++++++------------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5942975bf..80d7740b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - component for hiding elements in specific media - `` - force children to get displayed as inline content - - `` - - similar to `ContextOverlay` component but not directly linked to a React element, it specifies the target in the DOM to get connected lazy +- `` +- similar to `ContextOverlay` component but not directly linked to a React element, it specifies the target in the DOM to get connected lazy - `` - `useOnly` property: specify if only parts of the content should be used for the shortened preview, this property replaces `firstNonEmptyLineOnly` - `` @@ -24,7 +24,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `` - `hideIndicator` property: hide the radio inout indicator but click on children can be processed via `onChange` event - `` - - input component for colors, uses the configured palette by default but it also allows to enter custom colors + - input component for colors, uses the configured palette by default, but it also allows to enter custom colors - CSS custom properties - beside the color palette we now mirror the most important layout configuration variables as CSS custom properties - new icons: diff --git a/src/components/ColorField/ColorField.tsx b/src/components/ColorField/ColorField.tsx index 9638aaa30..db0e18c8f 100644 --- a/src/components/ColorField/ColorField.tsx +++ b/src/components/ColorField/ColorField.tsx @@ -21,22 +21,22 @@ export interface ColorFieldProps extends Omit { allowedPaletteColors = utils.getEnabledColorPropertiesFromPalette({ - includePaletteGroup: paletteGroupFilter, - includeColorWeight: colorWeightFilter, + includePaletteGroup: includePaletteGroup, + includeColorWeight: includeColorWeight, minimalColorDistance: 0, // we use all allowed colors, and do not check distances between them }); disableNativePicker = - colorWeightFilter.length > 0 && paletteGroupFilter.length > 0 && allowedPaletteColors.length > 0; + includeColorWeight.length > 0 && includePaletteGroup.length > 0 && allowedPaletteColors.length > 0; disabled = (!disableNativePicker && !allowCustomColor) || otherTextFieldProps.disabled; }; updateConfig(); React.useEffect(() => { updateConfig(); - }, [allowCustomColor, colorWeightFilter, paletteGroupFilter, otherTextFieldProps]); + }, [allowCustomColor, includeColorWeight, includePaletteGroup, otherTextFieldProps]); React.useEffect(() => { setColorValue(defaultValue || value || "#000000"); @@ -124,7 +124,7 @@ export const ColorField = ({
= 3 ? colorWeightFilter.length * 2 : "8" + includeColorWeight.length >= 3 ? includeColorWeight.length * 2 : "8" }col`} > {allowedPaletteColors!.map((color: [string, string], idx: number) => [ @@ -146,7 +146,7 @@ export const ColorField = ({ , // Looks like we cannot force some type of line break in the tag list via CSS only - (idx + 1) % (colorWeightFilter.length >= 3 ? colorWeightFilter.length * 2 : 8) === + (idx + 1) % (includeColorWeight.length >= 3 ? includeColorWeight.length * 2 : 8) === 0 && ( <>
@@ -167,7 +167,7 @@ export const ColorField = ({ type calculateColorHashValueProps = Pick< ColorFieldProps, - "allowCustomColor" | "colorWeightFilter" | "paletteGroupFilter" + "allowCustomColor" | "includeColorWeight" | "includePaletteGroup" >; /** @@ -178,8 +178,8 @@ ColorField.calculateColorHashValue = ( text: string, options: calculateColorHashValueProps = { allowCustomColor: false, - colorWeightFilter: [100, 300, 700, 900], - paletteGroupFilter: ["layout"], + includeColorWeight: [100, 300, 700, 900], + includePaletteGroup: ["layout"], } ) => { const hash = utils.textToColorHash({ @@ -187,8 +187,8 @@ ColorField.calculateColorHashValue = ( options: { returnValidColorsDirectly: options.allowCustomColor as boolean, enabledColors: utils.getEnabledColorsFromPalette({ - includePaletteGroup: options.paletteGroupFilter, - includeColorWeight: options.colorWeightFilter, + includePaletteGroup: options.includePaletteGroup, + includeColorWeight: options.includeColorWeight, minimalColorDistance: 0, }), }, From b0df565190d8c32fa3aa00a408e378c36cb9d556 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Mon, 9 Mar 2026 08:52:40 +0100 Subject: [PATCH 07/13] fix markdown list --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d7740b0..619298597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `` - force children to get displayed as inline content - `` -- similar to `ContextOverlay` component but not directly linked to a React element, it specifies the target in the DOM to get connected lazy + - similar to `ContextOverlay` component but not directly linked to a React element, it specifies the target in the DOM to get connected lazy - `` - `useOnly` property: specify if only parts of the content should be used for the shortened preview, this property replaces `firstNonEmptyLineOnly` - `` From 9cf88abefdce1dd6a95b1579490dd14a62604d5b Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Mon, 9 Mar 2026 11:29:07 +0100 Subject: [PATCH 08/13] fix properties use din story --- .../ColorField/ColorField.stories.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/ColorField/ColorField.stories.tsx b/src/components/ColorField/ColorField.stories.tsx index 5d61b641e..552304ca9 100644 --- a/src/components/ColorField/ColorField.stories.tsx +++ b/src/components/ColorField/ColorField.stories.tsx @@ -25,25 +25,25 @@ Default.args = { export const NoPalettePresets = Template.bind({}); NoPalettePresets.args = { ...Default.args, - colorWeightFilter: [], - paletteGroupFilter: [], + includeColorWeight: [], + includePaletteGroup: [], allowCustomColor: true, }; interface TemplateColorHashProps - extends Pick { + extends Pick { stringForColorHashValue: string; } const TemplateColorHash: StoryFn = (args: TemplateColorHashProps) => ( ); @@ -63,7 +63,7 @@ export const ColorHashValue = TemplateColorHash.bind({}); ColorHashValue.args = { ...Default.args, allowCustomColor: true, - colorWeightFilter: [300, 500, 700], - paletteGroupFilter: ["layout", "extra"], + includeColorWeight: [300, 500, 700], + includePaletteGroup: ["layout", "extra"], stringForColorHashValue: "My text that will used to create a color hash as initial value.", }; From b7d57175fdae3de671bfe9ecb4263926aaef9628 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Mon, 9 Mar 2026 11:30:06 +0100 Subject: [PATCH 09/13] add Jest tests for ColorField --- src/components/ColorField/ColorField.test.tsx | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/components/ColorField/ColorField.test.tsx diff --git a/src/components/ColorField/ColorField.test.tsx b/src/components/ColorField/ColorField.test.tsx new file mode 100644 index 000000000..32678bf68 --- /dev/null +++ b/src/components/ColorField/ColorField.test.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import "@testing-library/jest-dom"; + +import { CLASSPREFIX as eccgui } from "../../configuration/constants"; + +import { ColorField } from "./ColorField"; + +describe("ColorField", () => { + describe("rendering", () => { + it("renders without crashing, and correct component CSS class is applied", () => { + const { container } = render(); + expect(container).not.toBeEmptyDOMElement(); + expect(container.getElementsByClassName(`${eccgui}-colorfield`).length).toBe(1); + }); + + it("renders a color input by default (no palette presets)", () => { + const { container } = render( + + ); + expect(container.querySelector("input[type='color']")).toBeInTheDocument(); + }); + + // Jest cannot test this because it does not (cannot) load Styles where the palette isconfigured + /* + it("renders a readonly text input when palette colors are configured, and custom picker CSS class is applied", () => { + const { container } = render(); + // With default palette settings, a text input with readOnly is shown + expect(container.querySelector("input[type='text']")).toBeInTheDocument(); + expect(container.querySelector("input[readonly]")).toBeInTheDocument(); + expect(container.querySelector(`.${eccgui}-colorfield--custom-picker`)).toBeInTheDocument(); + }); + */ + + it("applies additional className", () => { + render( + + ); + expect(document.querySelector(".my-custom-class")).toBeInTheDocument(); + }); + }); + + describe("value handling", () => { + it("uses defaultValue as initial color", () => { + render( + + ); + const input = document.querySelector("input") as HTMLInputElement; + expect(input.value).toBe("#ff0000"); + }); + + it("uses value prop as initial color", () => { + render( + + ); + const input = document.querySelector("input") as HTMLInputElement; + expect(input.value).toBe("#00ff00"); + }); + + it("falls back to #000000 when no value or defaultValue is provided", () => { + render(); + const input = document.querySelector("input") as HTMLInputElement; + expect(input.value).toBe("#000000"); + }); + + it("updates displayed value when value prop changes", () => { + const { rerender } = render( + + ); + let input = document.querySelector("input") as HTMLInputElement; + expect(input.value).toBe("#ff0000"); + + rerender( + + ); + input = document.querySelector("input") as HTMLInputElement; + expect(input.value).toBe("#0000ff"); + }); + }); + + describe("disabled state", () => { + it("is disabled when disabled prop is true", () => { + render(); + const input = document.querySelector("input") as HTMLInputElement; + expect(input).toBeDisabled(); + }); + + it("is disabled when no palette colors and allowCustomColor is false", () => { + render(); + const input = document.querySelector("input") as HTMLInputElement; + expect(input).toBeDisabled(); + }); + }); + + describe("onChange callback", () => { + it("calls onChange when native color input changes", async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render( + + ); + const input = document.querySelector("input[type='color']") as HTMLInputElement; + input.type = "text"; // for unknown reasons Jest seems not able to test it on color inputs + await user.type(input, "#123456"); + expect(onChange).toHaveBeenCalled(); + }); + }); +}); From b6ef073751624452237256f6050444f7d8a1c2be Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Mon, 16 Mar 2026 18:18:45 +0100 Subject: [PATCH 10/13] change the way to set palette presets, now ColorField works also with all other type of color sets as selectable options, fix/remove useEffect usage --- CHANGELOG.md | 3 +- src/common/utils/CssCustomProperties.ts | 8 +- src/common/utils/colorHash.ts | 8 +- .../ColorField/ColorField.stories.tsx | 18 ++-- src/components/ColorField/ColorField.test.tsx | 62 ++++--------- src/components/ColorField/ColorField.tsx | 93 +++++++++---------- 6 files changed, 86 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 619298597..deb653578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `` - `hideIndicator` property: hide the radio inout indicator but click on children can be processed via `onChange` event - `` - - input component for colors, uses the configured palette by default, but it also allows to enter custom colors + - input component for colors + - uses a subset from the configured color palette by default, but it also allows to enter custom colors - CSS custom properties - beside the color palette we now mirror the most important layout configuration variables as CSS custom properties - new icons: diff --git a/src/common/utils/CssCustomProperties.ts b/src/common/utils/CssCustomProperties.ts index 598dda86b..197af7495 100644 --- a/src/common/utils/CssCustomProperties.ts +++ b/src/common/utils/CssCustomProperties.ts @@ -28,7 +28,7 @@ export default class CssCustomProperties { // Methods - customProperties = (props: getCustomPropertiesProps = {}): string[][] | Record => { + customProperties = (props: getCustomPropertiesProps = {}): [string, string][] | Record => { // FIXME: // in case of performance issues results should get saved at least into intern variables // other cache strategies could be also tested @@ -104,7 +104,9 @@ export default class CssCustomProperties { }); }; - static listCustomProperties = (props: getCustomPropertiesProps = {}): string[][] | Record => { + static listCustomProperties = ( + props: getCustomPropertiesProps = {} + ): [string, string][] | Record => { const { removeDashPrefix = true, returnObject = true, filterName = () => true, ...filterProps } = props; const customProperties = CssCustomProperties.listLocalCssStyleRuleProperties({ @@ -123,6 +125,6 @@ export default class CssCustomProperties { return returnObject ? (Object.fromEntries(customProperties) as Record) - : (customProperties as string[][]); + : (customProperties as [string, string][]); }; } diff --git a/src/common/utils/colorHash.ts b/src/common/utils/colorHash.ts index 87af57844..e8ba53076 100644 --- a/src/common/utils/colorHash.ts +++ b/src/common/utils/colorHash.ts @@ -9,7 +9,7 @@ type ColorOrFalse = Color | false; export type ColorWeight = 100 | 300 | 500 | 700 | 900; export type PaletteGroup = "identity" | "semantic" | "layout" | "extra"; -interface getEnabledColorsProps { +export interface getEnabledColorsProps { /** Specify the palette groups used to define the set of colors. */ includePaletteGroup?: PaletteGroup[]; /** Use only some weights of a color tint. */ @@ -21,7 +21,7 @@ interface getEnabledColorsProps { } const getEnabledColorsFromPaletteCache = new Map(); -const getEnabledColorPropertiesFromPaletteCache = new Map(); +const getEnabledColorPropertiesFromPaletteCache = new Map(); export function getEnabledColorsFromPalette(props: getEnabledColorsProps): Color[] { const configId = JSON.stringify({ @@ -50,7 +50,7 @@ export function getEnabledColorPropertiesFromPalette({ includeColorWeight = [100, 300, 500, 700, 900], // (planned for later): includeMixedColors = false, minimalColorDistance = COLORMINDISTANCE, -}: getEnabledColorsProps): string[][] { +}: getEnabledColorsProps): [string, string][] { const configId = JSON.stringify({ includePaletteGroup, includeColorWeight, @@ -166,7 +166,7 @@ export function textToColorHash({ } function stringToIntegerHash(inputString: string): number { - /* this function is idempotend, meaning it retrieves the same result for the same input + /* this function is idempotent, meaning it retrieves the same result for the same input no matter how many times it's called */ // Convert the string to a hash code let hashCode = 0; diff --git a/src/components/ColorField/ColorField.stories.tsx b/src/components/ColorField/ColorField.stories.tsx index 552304ca9..292101070 100644 --- a/src/components/ColorField/ColorField.stories.tsx +++ b/src/components/ColorField/ColorField.stories.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Meta, StoryFn } from "@storybook/react"; +import { getEnabledColorsProps } from "../../common/utils/colorHash"; import textFieldTest from "../TextField/stories/TextField.stories"; import { ColorField, ColorFieldProps } from "./ColorField"; @@ -25,21 +26,22 @@ Default.args = { export const NoPalettePresets = Template.bind({}); NoPalettePresets.args = { ...Default.args, - includeColorWeight: [], - includePaletteGroup: [], allowCustomColor: true, }; -interface TemplateColorHashProps - extends Pick { - stringForColorHashValue: string; -} +type TemplateColorHashProps = { stringForColorHashValue: string } & Pick< + ColorFieldProps, + "onChange" | "allowCustomColor" +> & + Pick; const TemplateColorHash: StoryFn = (args: TemplateColorHashProps) => ( { }); it("renders a color input by default (no palette presets)", () => { - const { container } = render( - - ); + const { container } = render(); expect(container.querySelector("input[type='color']")).toBeInTheDocument(); }); - // Jest cannot test this because it does not (cannot) load Styles where the palette isconfigured - /* it("renders a readonly text input when palette colors are configured, and custom picker CSS class is applied", () => { - const { container } = render(); + const { container } = render( + + ); // With default palette settings, a text input with readOnly is shown expect(container.querySelector("input[type='text']")).toBeInTheDocument(); expect(container.querySelector("input[readonly]")).toBeInTheDocument(); expect(container.querySelector(`.${eccgui}-colorfield--custom-picker`)).toBeInTheDocument(); }); - */ it("applies additional className", () => { - render( - - ); + render(); expect(document.querySelector(".my-custom-class")).toBeInTheDocument(); }); }); describe("value handling", () => { it("uses defaultValue as initial color", () => { - render( - - ); + render(); const input = document.querySelector("input") as HTMLInputElement; expect(input.value).toBe("#ff0000"); }); it("uses value prop as initial color", () => { - render( - - ); + render(); const input = document.querySelector("input") as HTMLInputElement; expect(input.value).toBe("#00ff00"); }); it("falls back to #000000 when no value or defaultValue is provided", () => { - render(); + render(); const input = document.querySelector("input") as HTMLInputElement; expect(input.value).toBe("#000000"); }); it("updates displayed value when value prop changes", () => { - const { rerender } = render( - - ); + const { rerender } = render(); let input = document.querySelector("input") as HTMLInputElement; expect(input.value).toBe("#ff0000"); - rerender( - - ); + rerender(); input = document.querySelector("input") as HTMLInputElement; expect(input.value).toBe("#0000ff"); }); @@ -92,13 +75,13 @@ describe("ColorField", () => { describe("disabled state", () => { it("is disabled when disabled prop is true", () => { - render(); + render(); const input = document.querySelector("input") as HTMLInputElement; expect(input).toBeDisabled(); }); it("is disabled when no palette colors and allowCustomColor is false", () => { - render(); + render(); const input = document.querySelector("input") as HTMLInputElement; expect(input).toBeDisabled(); }); @@ -108,14 +91,7 @@ describe("ColorField", () => { it("calls onChange when native color input changes", async () => { const user = userEvent.setup(); const onChange = jest.fn(); - render( - - ); + render(); const input = document.querySelector("input[type='color']") as HTMLInputElement; input.type = "text"; // for unknown reasons Jest seems not able to test it on color inputs await user.type(input, "#123456"); diff --git a/src/components/ColorField/ColorField.tsx b/src/components/ColorField/ColorField.tsx index db0e18c8f..23e7f8a4c 100644 --- a/src/components/ColorField/ColorField.tsx +++ b/src/components/ColorField/ColorField.tsx @@ -1,8 +1,9 @@ import React, { CSSProperties } from "react"; import classNames from "classnames"; +import Color from "color"; import { utils } from "../../common"; -import { ColorWeight, PaletteGroup } from "../../common/utils/colorHash"; +import { getEnabledColorsProps } from "../../common/utils/colorHash"; import { CLASSPREFIX as eccgui } from "../../configuration/constants"; import { ContextOverlay } from "../ContextOverlay"; import { FieldSet } from "../Form"; @@ -13,19 +14,18 @@ import { TextField, TextFieldProps } from "../TextField"; import { Tooltip } from "../Tooltip/Tooltip"; import { WhiteSpaceContainer } from "../Typography"; +type ColorPresets = [string, string][] | [string, Color][]; +type ColorPresetConfiguration = Pick; + export interface ColorFieldProps extends Omit { /** - * Any color can be selected, not only from the configured color palette. + * Any color can be selected, not only from the color presets. */ allowCustomColor?: boolean; /** - * What color weights should be included in the set of allowed colors. - */ - includeColorWeight?: ColorWeight[]; - /** - * What palette groups should be included in the set of allowed colors. + * List of named colors that are used a selectable color options. */ - includePaletteGroup?: PaletteGroup[]; + colorPresets?: ColorPresets; } /** @@ -35,8 +35,7 @@ export interface ColorFieldProps extends Omit { const ref = React.useRef(null); const [colorValue, setColorValue] = React.useState(defaultValue || value || "#000000"); + if (value && value !== colorValue) { + setColorValue(value); + } - let allowedPaletteColors, disableNativePicker, disabled; - const updateConfig = () => { - allowedPaletteColors = utils.getEnabledColorPropertiesFromPalette({ - includePaletteGroup: includePaletteGroup, - includeColorWeight: includeColorWeight, - minimalColorDistance: 0, // we use all allowed colors, and do not check distances between them - }); - - disableNativePicker = - includeColorWeight.length > 0 && includePaletteGroup.length > 0 && allowedPaletteColors.length > 0; - disabled = (!disableNativePicker && !allowCustomColor) || otherTextFieldProps.disabled; - }; - updateConfig(); - React.useEffect(() => { - updateConfig(); - }, [allowCustomColor, includeColorWeight, includePaletteGroup, otherTextFieldProps]); - - React.useEffect(() => { - setColorValue(defaultValue || value || "#000000"); - }, [defaultValue, value]); + const disableNativePicker = colorPresets.length > 0; + const disabled = (!disableNativePicker && !allowCustomColor) || otherTextFieldProps.disabled; const forwardOnChange = (forwardedEvent: React.ChangeEvent) => { setColorValue(forwardedEvent.target.value); @@ -122,21 +106,18 @@ export const ColorField = ({ )}
- = 3 ? includeColorWeight.length * 2 : "8" - }col`} - > - {allowedPaletteColors!.map((color: [string, string], idx: number) => [ + + {colorPresets!.map((color: [string, string | Color], idx: number) => [ ) => { forwardOnChange(e); }} > - + , // Looks like we cannot force some type of line break in the tag list via CSS only - (idx + 1) % (includeColorWeight.length >= 3 ? includeColorWeight.length * 2 : 8) === - 0 && ( + (idx + 1) % 8 === 0 && ( <>
@@ -165,21 +145,40 @@ export const ColorField = ({ ); }; -type calculateColorHashValueProps = Pick< - ColorFieldProps, - "allowCustomColor" | "includeColorWeight" | "includePaletteGroup" ->; +const defaultColorPaletteSet: ColorPresetConfiguration = { + // on default, we only include color weights that can have enough contrasts to black/white + includeColorWeight: [100, 300, 700, 900], + // on default, we only include layout colors + includePaletteGroup: ["layout"], +}; + +/** + * Simple helper function to get a list of colors defined in the color palette. + */ +const listColorPalettePresets = (colorPaletteSet = defaultColorPaletteSet) => { + return utils + .getEnabledColorPropertiesFromPalette({ + ...colorPaletteSet, + minimalColorDistance: 0, // we use all allowed colors, and do not check distances between them + }) + .map((color: [string, string | Color]) => [ + color[0].replace(`${eccgui}-color-palette-`, ""), + color[1], + ]) as ColorPresets; +}; + +ColorField.listColorPalettePresets = listColorPalettePresets; + +type calculateColorHashValueProps = Pick & ColorPresetConfiguration; /** * Simple helper function that provide simple access to color hash calculation. - * Using the same default values for the color palette filter. */ ColorField.calculateColorHashValue = ( text: string, options: calculateColorHashValueProps = { + ...defaultColorPaletteSet, allowCustomColor: false, - includeColorWeight: [100, 300, 700, 900], - includePaletteGroup: ["layout"], } ) => { const hash = utils.textToColorHash({ From 34cec9662bb19e03f4c735941c1793cf1a42e3bc Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Tue, 17 Mar 2026 08:23:02 +0100 Subject: [PATCH 11/13] fix storybook example for no palette --- src/components/ColorField/ColorField.stories.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ColorField/ColorField.stories.tsx b/src/components/ColorField/ColorField.stories.tsx index 292101070..e86997f19 100644 --- a/src/components/ColorField/ColorField.stories.tsx +++ b/src/components/ColorField/ColorField.stories.tsx @@ -27,6 +27,7 @@ export const NoPalettePresets = Template.bind({}); NoPalettePresets.args = { ...Default.args, allowCustomColor: true, + colorPresets: [], }; type TemplateColorHashProps = { stringForColorHashValue: string } & Pick< From 3b53e0f908cd7987e5260c51f6a6862aab7f43f6 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Tue, 17 Mar 2026 08:23:45 +0100 Subject: [PATCH 12/13] fix visual indication of disabled state for ColorField, reduce opacity --- src/components/ColorField/ColorField.tsx | 1 + src/components/ColorField/_colorfield.scss | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/components/ColorField/ColorField.tsx b/src/components/ColorField/ColorField.tsx index 23e7f8a4c..2488cd255 100644 --- a/src/components/ColorField/ColorField.tsx +++ b/src/components/ColorField/ColorField.tsx @@ -63,6 +63,7 @@ export const ColorField = ({ inputRef={ref} className={classNames(`${eccgui}-colorfield`, className, { [`${eccgui}-colorfield--custom-picker`]: disableNativePicker, + [`${eccgui}-colorfield--disabled`]: disabled, })} // we cannot use `color` type for the custom picker because we do not have control over it then type={!disableNativePicker ? "color" : "text"} diff --git a/src/components/ColorField/_colorfield.scss b/src/components/ColorField/_colorfield.scss index 51e8b929a..75b72c12e 100644 --- a/src/components/ColorField/_colorfield.scss +++ b/src/components/ColorField/_colorfield.scss @@ -36,6 +36,10 @@ } } +.#{$eccgui}-colorfield--disabled { + opacity: $eccgui-opacity-disabled; +} + .#{$eccgui}-colorfield__palette { & > li:has(.#{$eccgui}-colorfield__palette-linebreak) { display: block; From 92ccbfa1b865635614e5a0a76ade695be3871bf6 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Tue, 17 Mar 2026 08:34:49 +0100 Subject: [PATCH 13/13] fix intent state, it blinks shortly but then it shows the color set by value --- src/components/ColorField/_colorfield.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/ColorField/_colorfield.scss b/src/components/ColorField/_colorfield.scss index 75b72c12e..56ae28bb1 100644 --- a/src/components/ColorField/_colorfield.scss +++ b/src/components/ColorField/_colorfield.scss @@ -22,6 +22,13 @@ } } + &[class*="#{$ns}-intent-"] { + // we need to remove normal intent indicators like colored bg or blinking + .#{$ns}-input { + background-color: var(--#{$eccgui}-colorfield-background); + } + } + .#{$ns}-input-left-container { top: 1px; left: 1px !important;