diff --git a/CHANGELOG.md b/CHANGELOG.md index 78466ca43..c490e02c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `paddingSize` property to add easily some white space - `` - toolbar in `markdown` mode provides a user config menu for the editor appearance +- `` + - `hideIndicator` property: hide the radio inout indicator but click on children can be processed via `onChange` event +- `` + - 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: @@ -60,7 +65,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `` - reduce stroke width to only 1px - `` - - `wrapLines` and `preventLineNumber` do use `false` default value but if not set then it will be interpreted as `false` + - `wrapLines` and `preventLineNumber` do not use `false` default value but if not set then it will be interpreted as `false` - in this way it can be overwritten by new user config for the markdown mode - automatically hide user interaction elements in print view - all application header components except `` 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/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 45dab3daa..e8ba53076 100644 --- a/src/common/utils/colorHash.ts +++ b/src/common/utils/colorHash.ts @@ -6,10 +6,10 @@ 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 { +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,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, 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 { @@ -148,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 new file mode 100644 index 000000000..e86997f19 --- /dev/null +++ b/src/components/ColorField/ColorField.stories.tsx @@ -0,0 +1,72 @@ +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"; + +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 = { + ...Default.args, + allowCustomColor: true, + colorPresets: [], +}; + +type TemplateColorHashProps = { stringForColorHashValue: string } & Pick< + ColorFieldProps, + "onChange" | "allowCustomColor" +> & + Pick; + +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, + includeColorWeight: [300, 500, 700], + includePaletteGroup: ["layout", "extra"], + stringForColorHashValue: "My text that will used to create a color hash as initial value.", +}; diff --git a/src/components/ColorField/ColorField.test.tsx b/src/components/ColorField/ColorField.test.tsx new file mode 100644 index 000000000..b364cb9dd --- /dev/null +++ b/src/components/ColorField/ColorField.test.tsx @@ -0,0 +1,101 @@ +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(); + }); + + 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(); + }); + }); +}); diff --git a/src/components/ColorField/ColorField.tsx b/src/components/ColorField/ColorField.tsx new file mode 100644 index 000000000..2488cd255 --- /dev/null +++ b/src/components/ColorField/ColorField.tsx @@ -0,0 +1,200 @@ +import React, { CSSProperties } from "react"; +import classNames from "classnames"; +import Color from "color"; + +import { utils } from "../../common"; +import { getEnabledColorsProps } 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"; + +type ColorPresets = [string, string][] | [string, Color][]; +type ColorPresetConfiguration = Pick; + +export interface ColorFieldProps extends Omit { + /** + * Any color can be selected, not only from the color presets. + */ + allowCustomColor?: boolean; + /** + * List of named colors that are used a selectable color options. + */ + colorPresets?: ColorPresets; +} + +/** + * Color input field that provides resets from the configured color palette. + * Use `includeColorWeight` and `includePaletteGroup` to filter them. + */ +export const ColorField = ({ + className = "", + allowCustomColor = false, + colorPresets = listColorPalettePresets(), + defaultValue, + value, + onChange, + fullWidth = false, + ...otherTextFieldProps +}: ColorFieldProps) => { + const ref = React.useRef(null); + const [colorValue, setColorValue] = React.useState(defaultValue || value || "#000000"); + if (value && value !== colorValue) { + setColorValue(value); + } + + const disableNativePicker = colorPresets.length > 0; + const disabled = (!disableNativePicker && !allowCustomColor) || otherTextFieldProps.disabled; + + 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); + }} + /> + + + )} +
+ + {colorPresets!.map((color: [string, string | Color], 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) % 8 === 0 && ( + <> +
+ + ), + ])} +
+
+ + } + > + {colorInput} +
+ ) : ( + colorInput + ); +}; + +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. + */ +ColorField.calculateColorHashValue = ( + text: string, + options: calculateColorHashValueProps = { + ...defaultColorPaletteSet, + allowCustomColor: false, + } +) => { + const hash = utils.textToColorHash({ + text, + options: { + returnValidColorsDirectly: options.allowCustomColor as boolean, + enabledColors: utils.getEnabledColorsFromPalette({ + includePaletteGroup: options.includePaletteGroup, + includeColorWeight: options.includeColorWeight, + minimalColorDistance: 0, + }), + }, + }); + + return hash ? hash : undefined; +}; + +export default ColorField; diff --git a/src/components/ColorField/_colorfield.scss b/src/components/ColorField/_colorfield.scss new file mode 100644 index 000000000..56ae28bb1 --- /dev/null +++ b/src/components/ColorField/_colorfield.scss @@ -0,0 +1,67 @@ +.#{$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; + } + } + } + + &[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; + 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--disabled { + opacity: $eccgui-opacity-disabled; +} + +.#{$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/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; + } +} diff --git a/src/components/index.scss b/src/components/index.scss index f8cbf4fae..7d0bc7352 100644 --- a/src/components/index.scss +++ b/src/components/index.scss @@ -33,6 +33,7 @@ @import "./Tabs/tabs"; @import "./Tag/tag"; @import "./TextField/textfield"; +@import "./ColorField/colorfield"; @import "./TagInput/taginput"; @import "./Toolbar/toolbar"; @import "./Tooltip/tooltip"; 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";