Grafana/UI: Add ColorPickerInput component (#52222)

* ColorPickerInput: Setup componnet

* ColorPickerInput: Add onChange callback

* ColorPickerInput: Conver ColorInput to functional component

* ColorPickerInput: Extend Input props

* ColorPickerInput: Upate value prop

* ColorPickerInput: Allow customising color format for onChange

* ColorPickerInput: Add docs

* ColorPickerInput: Update docs

* ColorPickerInput: Memoize debounced callback

* ColorPickerInput: Add tests and use theme for spacing

* Apply styles from SpectrumPalette

* Cleanup
This commit is contained in:
Alex Khomenko 2022-07-19 08:09:09 +03:00 committed by GitHub
parent 9a72ebcd99
commit ba543343c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 156 additions and 62 deletions

View File

@ -10,6 +10,7 @@ import {
getContrastRatio,
getLuminance,
lighten,
asRgbString,
} from './colorManipulator';
describe('utils/colorManipulator', () => {
@ -415,4 +416,11 @@ describe('utils/colorManipulator', () => {
expect(lighten('color(display-p3 1 0 0)', 0)).toEqual('color(display-p3 1 0 0)');
});
});
describe('asRgbString', () => {
it('should convert hex color to rgb', () => {
expect(asRgbString('#FFFFFF')).toEqual('rgb(255, 255, 255)');
expect(asRgbString('#000000')).toEqual('rgb(0, 0, 0)');
});
});
});

View File

@ -81,6 +81,17 @@ export function asHexString(color: string): string {
return tColor.getAlpha() === 1 ? tColor.toHexString() : tColor.toHex8String();
}
/**
* Converts a color to rgb string
*/
export function asRgbString(color: string) {
if (color.startsWith('rgb')) {
return color;
}
return tinycolor(color).toRgbString();
}
/**
* Converts a color from hsl format to rgb format.
* @param color - HSL color values

View File

@ -1,87 +1,62 @@
import { cx, css } from '@emotion/css';
import { debounce } from 'lodash';
import React from 'react';
import React, { forwardRef, useState, useEffect, useMemo } from 'react';
import tinycolor from 'tinycolor2';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { Input } from '../Input/Input';
import { Input, Props as InputProps } from '../Input/Input';
import { ColorPickerProps } from './ColorPickerPopover';
interface ColorInputState {
previousColor: string;
value: string;
}
interface ColorInputProps extends ColorPickerProps, Omit<InputProps, 'color' | 'onChange'> {}
interface ColorInputProps extends ColorPickerProps {
style?: React.CSSProperties;
className?: string;
}
const ColorInput = forwardRef<HTMLInputElement, ColorInputProps>(({ color, onChange, ...inputProps }, ref) => {
const [value, setValue] = useState(color);
const [previousColor, setPreviousColor] = useState(color);
// eslint-disable-next-line react-hooks/exhaustive-deps
const updateColor = useMemo(() => debounce(onChange, 100), []);
class ColorInput extends React.PureComponent<ColorInputProps, ColorInputState> {
constructor(props: ColorInputProps) {
super(props);
this.state = {
previousColor: props.color,
value: props.color,
};
this.updateColor = debounce(this.updateColor, 100);
}
static getDerivedStateFromProps(props: ColorPickerProps, state: ColorInputState) {
const newColor = tinycolor(props.color);
if (newColor.isValid() && props.color !== state.previousColor) {
return {
...state,
previousColor: props.color,
value: newColor.toString(),
};
useEffect(() => {
const newColor = tinycolor(color);
if (newColor.isValid() && color !== previousColor) {
setValue(newColor.toString());
setPreviousColor(color);
}
}, [color, previousColor]);
return state;
}
updateColor = (color: string) => {
this.props.onChange(color);
};
onChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
const onChangeColor = (event: React.SyntheticEvent<HTMLInputElement>) => {
const newColor = tinycolor(event.currentTarget.value);
this.setState({
value: event.currentTarget.value,
});
setValue(event.currentTarget.value);
if (newColor.isValid()) {
this.updateColor(newColor.toString());
updateColor(newColor.toString());
}
};
onBlur = () => {
const newColor = tinycolor(this.state.value);
const onBlur = () => {
const newColor = tinycolor(value);
if (!newColor.isValid()) {
this.setState({
value: this.props.color,
});
setValue(color);
}
};
render() {
const { value } = this.state;
return (
<Input
className={this.props.className}
value={value}
onChange={this.onChange}
onBlur={this.onBlur}
addonBefore={<ColorPreview color={this.props.color} />}
/>
);
}
}
return (
<Input
{...inputProps}
value={value}
onChange={onChangeColor}
onBlur={onBlur}
addonBefore={<ColorPreview color={color} />}
ref={ref}
/>
);
});
ColorInput.displayName = 'ColorInput';
export default ColorInput;

View File

@ -1,5 +1,6 @@
import { Meta, Props } from '@storybook/addon-docs/blocks';
import { ColorPicker } from './ColorPicker';
import { ColorPickerInput } from './ColorPickerInput';
<Meta title="Pickers and Editors/ColorPicker/ColorPicker" />
@ -11,4 +12,10 @@ The `Popover` is a tabbed view where you can switch between `Palettes`. The `Nam
The `Pickers` are single circular color fields that show the currently picked color. On click, they open the `Popover`.
## ColorPickerInput
Color picker component, modified to be used inside forms. Supports all usual input props. Allows manually typing in color value as well as selecting it from the popover.
The format in which the color is returned to the `onChange` callback can be customised via `returnColorAs` prop.
<Props of={ColorPicker} />

View File

@ -9,12 +9,13 @@ import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
import mdx from './ColorPicker.mdx';
import { ColorPickerInput, ColorPickerInputProps } from './ColorPickerInput';
import { ColorPickerProps } from './ColorPickerPopover';
export default {
title: 'Pickers and Editors/ColorPicker',
component: ColorPicker,
subcomponents: { SeriesColorPicker },
subcomponents: { SeriesColorPicker, ColorPickerInput },
decorators: [withCenteredStory],
parameters: {
docs: {
@ -74,3 +75,13 @@ export const SeriesPicker: Story<ColorPickerProps> = ({ enableNamedColors }) =>
</UseState>
);
};
export const Input: Story<ColorPickerInputProps> = () => {
return (
<UseState initialState="#ffffff">
{(value, onChange) => {
return <ColorPickerInput value={value} onChange={onChange} />;
}}
</UseState>
);
};

View File

@ -0,0 +1,81 @@
import { css, cx } from '@emotion/css';
import React, { useState, forwardRef } from 'react';
import { RgbaStringColorPicker } from 'react-colorful';
import { useThrottleFn } from 'react-use';
import { colorManipulator, GrafanaTheme2 } from '@grafana/data';
import { useStyles2, useTheme2 } from '../../themes';
import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper';
import { Props as InputProps } from '../Input/Input';
import ColorInput from './ColorInput';
import { getStyles as getPaletteStyles } from './SpectrumPalette';
export interface ColorPickerInputProps extends Omit<InputProps, 'value' | 'onChange'> {
value?: string;
onChange: (color: string) => void;
/** Format for returning the color in onChange callback, defaults to 'rgb' */
returnColorAs?: 'rgb' | 'hex';
}
export const ColorPickerInput = forwardRef<HTMLInputElement, ColorPickerInputProps>(
({ value = '', onChange, returnColorAs = 'rgb', ...inputProps }, ref) => {
const [currentColor, setColor] = useState(value);
const [isOpen, setIsOpen] = useState(false);
const theme = useTheme2();
const styles = useStyles2(getStyles);
const paletteStyles = useStyles2(getPaletteStyles);
useThrottleFn(
(c) => {
const color = theme.visualization.getColorByName(c);
if (returnColorAs === 'rgb') {
onChange(colorManipulator.asRgbString(color));
} else {
onChange(colorManipulator.asHexString(color));
}
},
500,
[currentColor]
);
return (
<ClickOutsideWrapper onClick={() => setIsOpen(false)}>
<div className={styles.wrapper}>
{isOpen && (
<RgbaStringColorPicker
color={currentColor}
onChange={setColor}
className={cx(paletteStyles.root, styles.picker)}
/>
)}
<div onClick={() => setIsOpen(true)}>
<ColorInput {...inputProps} theme={theme} color={currentColor} onChange={setColor} ref={ref} />
</div>
</div>
</ClickOutsideWrapper>
);
}
);
ColorPickerInput.displayName = 'ColorPickerInput';
const getStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css`
position: relative;
`,
picker: css`
&.react-colorful {
position: absolute;
width: 100%;
z-index: 11;
bottom: 36px;
}
`,
inner: css`
position: absolute;
`,
};
};

View File

@ -1,4 +1,4 @@
import { css, cx } from '@emotion/css';
import { css } from '@emotion/css';
import React, { useMemo, useState } from 'react';
import { RgbaStringColorPicker } from 'react-colorful';
import { useThrottleFn } from 'react-use';
@ -37,13 +37,13 @@ const SpectrumPalette: React.FunctionComponent<SpectrumPaletteProps> = ({ color,
return (
<div className={styles.wrapper}>
<RgbaStringColorPicker className={cx(styles.root)} color={rgbaString} onChange={setColor} />
<RgbaStringColorPicker className={styles.root} color={rgbaString} onChange={setColor} />
<ColorInput theme={theme} color={rgbaString} onChange={setColor} className={styles.colorInput} />
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
export const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css`
flex-grow: 1;
`,

View File

@ -24,6 +24,7 @@ export { ButtonCascader } from './ButtonCascader/ButtonCascader';
export { LoadingPlaceholder, LoadingPlaceholderProps } from './LoadingPlaceholder/LoadingPlaceholder';
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
export { ColorPickerInput } from './ColorPicker/ColorPickerInput';
export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover';
export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
export { UnitPicker } from './UnitPicker/UnitPicker';