[XY axis] Integrates legend color picker with the eui palette (#90589) (#94738)

* XY Axis, integrate legend color picker with the eui palette

* Fix functional test to work with the eui palette

* Order eui colors by group

* Add unit test for use color picker

* Add useMemo to getColorPicker

* Remove the grey background from the first focused circle

* Fix bug caused by comparing lowercase with uppercase characters

* Fix bug on complimentary palette

* Fix CI

* fix linter

* Use uppercase for hex color

* Use eui variable instead

* Changes on charts.json

* Make the color picker accessible

* Fix ci and tests

* Allow keyboard navigation

* Close the popover on mouse click event

* Fix ci

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Stratoula Kalafateli 2021-03-16 21:58:18 +02:00 committed by GitHub
parent f88b143e1f
commit da9e1c313d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 326 additions and 166 deletions

View file

@ -9,7 +9,7 @@
"children": [
{
"type": "Object",
"label": "{ onChange, color: selectedColor, id, label }",
"label": "{\n onChange,\n color: selectedColor,\n label,\n useLegacyColors = true,\n colorIsOverwritten = true,\n}",
"isRequired": true,
"signature": [
"ColorPickerProps"
@ -17,18 +17,18 @@
"description": [],
"source": {
"path": "src/plugins/charts/public/static/components/color_picker.tsx",
"lineNumber": 83
"lineNumber": 108
}
}
],
"signature": [
"({ onChange, color: selectedColor, id, label }: ColorPickerProps) => JSX.Element"
"({ onChange, color: selectedColor, label, useLegacyColors, colorIsOverwritten, }: ColorPickerProps) => JSX.Element"
],
"description": [],
"label": "ColorPicker",
"source": {
"path": "src/plugins/charts/public/static/components/color_picker.tsx",
"lineNumber": 83
"lineNumber": 108
},
"tags": [],
"returnComment": [],

View file

@ -4,6 +4,18 @@ $visColorPickerWidth: $euiSizeL * 8; // 8 columns
width: $visColorPickerWidth;
}
.visColorPicker__colorBtn {
position: relative;
input[type='radio'] {
position: absolute;
top: 50%;
left: 50%;
opacity: 0;
transform: translate(-50%, -50%);
}
}
.visColorPicker__valueDot {
cursor: pointer;

View file

@ -9,12 +9,19 @@
import classNames from 'classnames';
import React, { BaseSyntheticEvent } from 'react';
import { EuiButtonEmpty, EuiFlexItem, EuiIcon } from '@elastic/eui';
import {
EuiButtonEmpty,
EuiFlexItem,
EuiIcon,
euiPaletteColorBlind,
EuiScreenReaderOnly,
EuiFlexGroup,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import './color_picker.scss';
export const legendColors: string[] = [
export const legacyColors: string[] = [
'#3F6833',
'#967302',
'#2F575E',
@ -74,54 +81,91 @@ export const legendColors: string[] = [
];
interface ColorPickerProps {
id?: string;
/**
* Label that characterizes the color that is going to change
*/
label: string | number | null;
/**
* Callback on the color change
*/
onChange: (color: string | null, event: BaseSyntheticEvent) => void;
/**
* Initial color.
*/
color: string;
/**
* Defines if the compatibility (legacy) or eui palette is going to be used. Defauls to true.
*/
useLegacyColors?: boolean;
/**
* Defines if the default color is overwritten. Defaults to true.
*/
colorIsOverwritten?: boolean;
/**
* Callback for onKeyPress event
*/
onKeyDown?: (e: React.KeyboardEvent<HTMLElement>) => void;
}
const euiColors = euiPaletteColorBlind({ rotations: 4, order: 'group' });
export const ColorPicker = ({ onChange, color: selectedColor, id, label }: ColorPickerProps) => (
<div className="visColorPicker">
<span id={`${id}ColorPickerDesc`} className="euiScreenReaderOnly">
<FormattedMessage
id="charts.colorPicker.setColor.screenReaderDescription"
defaultMessage="Set color for value {legendDataLabel}"
values={{ legendDataLabel: label }}
/>
</span>
<div className="visColorPicker__value" role="listbox">
{legendColors.map((color) => (
<EuiIcon
role="option"
tabIndex={0}
type="dot"
size="l"
color={selectedColor}
key={color}
aria-label={color}
aria-describedby={`${id}ColorPickerDesc`}
aria-selected={color === selectedColor}
onClick={(e) => onChange(color, e)}
onKeyPress={(e) => onChange(color, e)}
className={classNames('visColorPicker__valueDot', {
// eslint-disable-next-line @typescript-eslint/naming-convention
'visColorPicker__valueDot-isSelected': color === selectedColor,
})}
style={{ color }}
data-test-subj={`visColorPickerColor-${color}`}
/>
))}
export const ColorPicker = ({
onChange,
color: selectedColor,
label,
useLegacyColors = true,
colorIsOverwritten = true,
onKeyDown,
}: ColorPickerProps) => {
const legendColors = useLegacyColors ? legacyColors : euiColors;
return (
<div className="visColorPicker">
<fieldset>
<EuiScreenReaderOnly>
<legend>
<FormattedMessage
id="charts.colorPicker.setColor.screenReaderDescription"
defaultMessage="Set color for value {legendDataLabel}"
values={{ legendDataLabel: label }}
/>
</legend>
</EuiScreenReaderOnly>
<EuiFlexGroup wrap={true} gutterSize="none" className="visColorPicker__value">
{legendColors.map((color) => (
<label key={color} className="visColorPicker__colorBtn">
<input
type="radio"
onChange={(e) => onChange(color, e)}
value={selectedColor}
name="visColorPicker__radio"
checked={color === selectedColor}
onKeyDown={onKeyDown}
/>
<EuiIcon
type="dot"
size="l"
color={selectedColor}
className={classNames('visColorPicker__valueDot', {
// eslint-disable-next-line @typescript-eslint/naming-convention
'visColorPicker__valueDot-isSelected': color === selectedColor,
})}
style={{ color }}
data-test-subj={`visColorPickerColor-${color}`}
/>
<EuiScreenReaderOnly>
<span>{color}</span>
</EuiScreenReaderOnly>
</label>
))}
</EuiFlexGroup>
</fieldset>
{legendColors.some((c) => c === selectedColor) && colorIsOverwritten && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty size="s" onClick={(e: any) => onChange(null, e)}>
<FormattedMessage id="charts.colorPicker.clearColor" defaultMessage="Reset color" />
</EuiButtonEmpty>
</EuiFlexItem>
)}
</div>
{legendColors.some((c) => c === selectedColor) && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
onClick={(e: any) => onChange(null, e)}
onKeyPress={(e: any) => onChange(null, e)}
>
<FormattedMessage id="charts.colorPicker.clearColor" defaultMessage="Clear color" />
</EuiButtonEmpty>
</EuiFlexItem>
)}
</div>
);
);
};

View file

@ -246,8 +246,8 @@ describe('VisLegend Component', () => {
first.simulate('click');
const popover = wrapper.find('.visColorPicker').first();
const firstColor = popover.find('.visColorPicker__valueDot').first();
firstColor.simulate('click');
const firstColor = popover.find('.visColorPicker__colorBtn input').first();
firstColor.simulate('change');
const colors = mockState.get('vis.colors');

View file

@ -233,7 +233,6 @@ export class VisLegend extends PureComponent<VisLegendProps, VisLegendState> {
canFilter={this.state.filterableLabels.has(item.label)}
onFilter={this.filter}
onSelect={this.toggleDetails}
legendId={this.legendId}
setColor={this.setColor}
getColor={this.getColor}
onHighlight={this.highlight}

View file

@ -25,7 +25,6 @@ import { ColorPicker } from '../../../../../charts/public';
interface Props {
item: LegendItem;
legendId: string;
selected: boolean;
canFilter: boolean;
anchorPosition: EuiPopoverProps['anchorPosition'];
@ -39,7 +38,6 @@ interface Props {
const VisLegendItemComponent = ({
item,
legendId,
selected,
canFilter,
anchorPosition,
@ -150,7 +148,6 @@ const VisLegendItemComponent = ({
{canFilter && renderFilterBar()}
<ColorPicker
id={legendId}
label={item.label}
color={getColor(item.label)}
onChange={(c, e) => setColor(item.label, c, e)}

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { LegendColorPickerProps, XYChartSeriesIdentifier } from '@elastic/charts';
import { EuiPopover } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test/jest';
import { ComponentType, ReactWrapper } from 'enzyme';
import { getColorPicker } from './get_color_picker';
import { ColorPicker } from '../../../charts/public';
import type { PersistedState } from '../../../visualizations/public';
jest.mock('@elastic/charts', () => {
const original = jest.requireActual('@elastic/charts');
return {
...original,
getSpecId: jest.fn(() => {}),
};
});
describe('getColorPicker', function () {
const mockState = new Map();
const uiState = ({
get: jest
.fn()
.mockImplementation((key, fallback) => (mockState.has(key) ? mockState.get(key) : fallback)),
set: jest.fn().mockImplementation((key, value) => mockState.set(key, value)),
emit: jest.fn(),
setSilent: jest.fn(),
} as unknown) as PersistedState;
let wrapperProps: LegendColorPickerProps;
const Component: ComponentType<LegendColorPickerProps> = getColorPicker(
'left',
jest.fn(),
jest.fn().mockImplementation((seriesIdentifier) => seriesIdentifier.seriesKeys[0]),
'default',
uiState
);
let wrapper: ReactWrapper<LegendColorPickerProps>;
beforeAll(() => {
wrapperProps = {
color: 'rgb(109, 204, 177)',
onClose: jest.fn(),
onChange: jest.fn(),
anchor: document.createElement('div'),
seriesIdentifiers: [
{
yAccessor: 'col-2-1',
splitAccessors: {},
seriesKeys: ['Logstash Airways', 'col-2-1'],
specId: 'histogram-col-2-1',
key:
'groupId{__pseudo_stacked_group-ValueAxis-1__}spec{histogram-col-2-1}yAccessor{col-2-1}splitAccessors{col-1-3-Logstash Airways}',
} as XYChartSeriesIdentifier,
],
};
});
it('renders the color picker', () => {
wrapper = mountWithIntl(<Component {...wrapperProps} />);
expect(wrapper.find(ColorPicker).length).toBe(1);
});
it('renders the color picker with the colorIsOverwritten prop set to false if color is not overwritten for the specific series', () => {
wrapper = mountWithIntl(<Component {...wrapperProps} />);
expect(wrapper.find(ColorPicker).prop('colorIsOverwritten')).toBe(false);
});
it('renders the color picker with the colorIsOverwritten prop set to true if color is overwritten for the specific series', () => {
uiState.set('vis.colors', { 'Logstash Airways': '#6092c0' });
wrapper = mountWithIntl(<Component {...wrapperProps} />);
expect(wrapper.find(ColorPicker).prop('colorIsOverwritten')).toBe(true);
});
it('renders the picker on the correct position', () => {
wrapper = mountWithIntl(<Component {...wrapperProps} />);
expect(wrapper.find(EuiPopover).prop('anchorPosition')).toEqual('rightCenter');
});
it('renders the picker for kibana palette with useLegacyColors set to true', () => {
const LegacyPaletteComponent: ComponentType<LegendColorPickerProps> = getColorPicker(
'left',
jest.fn(),
jest.fn(),
'kibana_palette',
uiState
);
wrapper = mountWithIntl(<LegacyPaletteComponent {...wrapperProps} />);
expect(wrapper.find(ColorPicker).prop('useLegacyColors')).toBe(true);
});
});

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useCallback } from 'react';
import { LegendColorPicker, Position, XYChartSeriesIdentifier, SeriesName } from '@elastic/charts';
import { PopoverAnchorPosition, EuiWrappingPopover, EuiOutsideClickDetector } from '@elastic/eui';
import type { PersistedState } from '../../../visualizations/public';
import { ColorPicker } from '../../../charts/public';
function getAnchorPosition(legendPosition: Position): PopoverAnchorPosition {
switch (legendPosition) {
case Position.Bottom:
return 'upCenter';
case Position.Top:
return 'downCenter';
case Position.Left:
return 'rightCenter';
default:
return 'leftCenter';
}
}
const KEY_CODE_ENTER = 13;
export const getColorPicker = (
legendPosition: Position,
setColor: (newColor: string | null, seriesKey: string | number) => void,
getSeriesName: (series: XYChartSeriesIdentifier) => SeriesName,
paletteName: string,
uiState: PersistedState
): LegendColorPicker => ({
anchor,
color,
onClose,
onChange,
seriesIdentifiers: [seriesIdentifier],
}) => {
const seriesName = getSeriesName(seriesIdentifier as XYChartSeriesIdentifier);
const overwriteColors: Record<string, string> = uiState?.get('vis.colors', {});
const colorIsOverwritten = Object.keys(overwriteColors).includes(seriesName as string);
let keyDownEventOn = false;
const handleChange = (newColor: string | null) => {
if (!seriesName) {
return;
}
if (newColor) {
onChange(newColor);
}
setColor(newColor, seriesName);
// close the popover if no color is applied or the user has clicked a color
if (!newColor || !keyDownEventOn) {
onClose();
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
if (e.keyCode === KEY_CODE_ENTER) {
onClose?.();
}
keyDownEventOn = true;
};
const handleOutsideClick = useCallback(() => {
onClose?.();
}, [onClose]);
return (
<EuiOutsideClickDetector onOutsideClick={handleOutsideClick}>
<EuiWrappingPopover
isOpen
ownFocus
display="block"
button={anchor}
anchorPosition={getAnchorPosition(legendPosition)}
closePopover={onClose}
panelPaddingSize="s"
>
<ColorPicker
color={paletteName === 'kibana_palette' ? color : color.toLowerCase()}
onChange={handleChange}
label={seriesName}
useLegacyColors={paletteName === 'kibana_palette'}
colorIsOverwritten={colorIsOverwritten}
onKeyDown={onKeyDown}
/>
</EuiWrappingPopover>
</EuiOutsideClickDetector>
);
};

View file

@ -11,6 +11,6 @@ export { getTimeZone } from './get_time_zone';
export { getLegendActions } from './get_legend_actions';
export { getSeriesNameFn } from './get_series_name_fn';
export { getXDomain, getAdjustedDomain } from './domain';
export { useColorPicker } from './use_color_picker';
export { getColorPicker } from './get_color_picker';
export { getXAccessor } from './accessors';
export { getAllSeries } from './get_all_series';

View file

@ -1,76 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { BaseSyntheticEvent, useCallback, useMemo } from 'react';
import { LegendColorPicker, Position, XYChartSeriesIdentifier, SeriesName } from '@elastic/charts';
import { PopoverAnchorPosition, EuiWrappingPopover, EuiOutsideClickDetector } from '@elastic/eui';
import { ColorPicker } from '../../../charts/public';
function getAnchorPosition(legendPosition: Position): PopoverAnchorPosition {
switch (legendPosition) {
case Position.Bottom:
return 'upCenter';
case Position.Top:
return 'downCenter';
case Position.Left:
return 'rightCenter';
default:
return 'leftCenter';
}
}
export const useColorPicker = (
legendPosition: Position,
setColor: (
newColor: string | null,
seriesKey: string | number,
event: BaseSyntheticEvent
) => void,
getSeriesName: (series: XYChartSeriesIdentifier) => SeriesName
): LegendColorPicker =>
useMemo(
() => ({ anchor, color, onClose, onChange, seriesIdentifiers: [seriesIdentifier] }) => {
const seriesName = getSeriesName(seriesIdentifier as XYChartSeriesIdentifier);
const handlChange = (newColor: string | null, event: BaseSyntheticEvent) => {
if (!seriesName) {
return;
}
if (newColor) {
onChange(newColor);
}
setColor(newColor, seriesName, event);
// must be called after onChange
onClose();
};
// rule doesn't know this is inside a functional component
// eslint-disable-next-line react-hooks/rules-of-hooks
const handleOutsideClick = useCallback(() => {
onClose?.();
}, [onClose]);
return (
<EuiOutsideClickDetector onOutsideClick={handleOutsideClick}>
<EuiWrappingPopover
isOpen
ownFocus
display="block"
button={anchor}
anchorPosition={getAnchorPosition(legendPosition)}
closePopover={onClose}
panelPaddingSize="s"
>
<ColorPicker color={color} onChange={handlChange} label={seriesName} />
</EuiWrappingPopover>
</EuiOutsideClickDetector>
);
},
[getSeriesName, legendPosition, setColor]
);

View file

@ -6,15 +6,7 @@
* Side Public License, v 1.
*/
import React, {
BaseSyntheticEvent,
KeyboardEvent,
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import {
Chart,
@ -28,7 +20,6 @@ import {
AccessorFn,
Accessor,
} from '@elastic/charts';
import { keys } from '@elastic/eui';
import { compact } from 'lodash';
import {
@ -50,7 +41,7 @@ import {
renderAllSeries,
getSeriesNameFn,
getLegendActions,
useColorPicker,
getColorPicker,
getXAccessor,
getAllSeries,
} from './utils';
@ -86,16 +77,6 @@ const VisComponent = (props: VisComponentProps) => {
return props.uiState?.get('vis.legendOpen', bwcLegendStateDefault) as boolean;
});
const [palettesRegistry, setPalettesRegistry] = useState<PaletteRegistry | null>(null);
useEffect(() => {
const fn = () => {
props?.uiState?.emit?.('reload');
};
props?.uiState?.on?.('change', fn);
return () => {
props?.uiState?.off?.('change', fn);
};
}, [props?.uiState]);
const onRenderChange = useCallback<RenderChangeListener>(
(isRendered) => {
@ -203,11 +184,7 @@ const VisComponent = (props: VisComponentProps) => {
}, [props.uiState]);
const setColor = useCallback(
(newColor: string | null, seriesLabel: string | number, event: BaseSyntheticEvent) => {
if ((event as KeyboardEvent).key && (event as KeyboardEvent).key !== keys.ENTER) {
return;
}
(newColor: string | null, seriesLabel: string | number) => {
const colors = props.uiState?.get('vis.colors') || {};
if (colors[seriesLabel] === newColor || !newColor) {
delete colors[seriesLabel];
@ -337,6 +314,18 @@ const VisComponent = (props: VisComponentProps) => {
xAccessor,
]
);
const legendColorPicker = useMemo(
() =>
getColorPicker(
legendPosition,
setColor,
getSeriesName,
visParams.palette.name,
props.uiState
),
[getSeriesName, legendPosition, props.uiState, setColor, visParams.palette.name]
);
return (
<div className="xyChart__container" data-test-subj="visTypeXyChart">
<LegendToggle
@ -355,7 +344,7 @@ const VisComponent = (props: VisComponentProps) => {
legendPosition={legendPosition}
xDomain={xDomain}
adjustedXDomain={adjustedXDomain}
legendColorPicker={useColorPicker(legendPosition, setColor, getSeriesName)}
legendColorPicker={legendColorPicker}
onElementClick={handleFilterClick(
visData,
xAccessor,

View file

@ -76,7 +76,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await queryBar.clickQuerySubmitButton();
await PageObjects.visChart.openLegendOptionColors('Count', `[data-title="${visName}"]`);
await PageObjects.visChart.selectNewLegendColorChoice('#EA6460');
const overwriteColor = isNewChartsLibraryEnabled ? '#d36086' : '#EA6460';
await PageObjects.visChart.selectNewLegendColorChoice(overwriteColor);
await PageObjects.dashboard.saveDashboard(dashboarName);
@ -89,7 +90,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
}
const colorChoiceRetained = await PageObjects.visChart.doesSelectedLegendColorExist(
'#EA6460'
overwriteColor
);
expect(colorChoiceRetained).to.be(true);

View file

@ -408,7 +408,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr
await this.waitForVisualizationRenderingStabilized();
// arbitrary color chosen, any available would do
const isOpen = await this.doesLegendColorChoiceExist('#EF843C');
const arbitraryColor = (await this.isVisTypeXYChart()) ? '#d36086' : '#EF843C';
const isOpen = await this.doesLegendColorChoiceExist(arbitraryColor);
if (!isOpen) {
throw new Error('legend color selector not open');
}

View file

@ -262,7 +262,6 @@
"charts.colormaps.greysText": "グレー",
"charts.colormaps.redsText": "赤",
"charts.colormaps.yellowToRedText": "黄色から赤",
"charts.colorPicker.clearColor": "色のクリア",
"charts.colorPicker.setColor.screenReaderDescription": "値 {legendDataLabel} の色を設定",
"charts.countText": "カウント",
"charts.functions.palette.args.colorHelpText": "パレットの色です。{html} カラー名、{hex}、{hsl}、{hsla}、{rgb}、または {rgba} を使用できます。",

View file

@ -265,7 +265,6 @@
"charts.colormaps.greysText": "灰色",
"charts.colormaps.redsText": "红色",
"charts.colormaps.yellowToRedText": "黄到红",
"charts.colorPicker.clearColor": "清除颜色",
"charts.colorPicker.setColor.screenReaderDescription": "为值 {legendDataLabel} 设置颜色",
"charts.countText": "计数",
"charts.functions.palette.args.colorHelpText": "调色板颜色。接受 {html} 颜色名称 {hex}、{hsl}、{hsla}、{rgb} 或 {rgba}。",