diff --git a/api/charts.api.md b/api/charts.api.md index 89941fe149..93f0312839 100644 --- a/api/charts.api.md +++ b/api/charts.api.md @@ -767,6 +767,8 @@ export type LegendAction = ComponentType; // @public export interface LegendActionProps { + color: string; + label: string; series: SeriesIdentifier; } diff --git a/integration/tests/__image_snapshots__/legend-stories-test-ts-legend-stories-should-render-color-picker-on-mouse-click-1-snap.png b/integration/tests/__image_snapshots__/legend-stories-test-ts-legend-stories-should-render-color-picker-on-mouse-click-1-snap.png index 41e766b874..ef838c2799 100644 Binary files a/integration/tests/__image_snapshots__/legend-stories-test-ts-legend-stories-should-render-color-picker-on-mouse-click-1-snap.png and b/integration/tests/__image_snapshots__/legend-stories-test-ts-legend-stories-should-render-color-picker-on-mouse-click-1-snap.png differ diff --git a/src/components/__snapshots__/chart.test.tsx.snap b/src/components/__snapshots__/chart.test.tsx.snap index a49d816bfd..96699c9d8b 100644 --- a/src/components/__snapshots__/chart.test.tsx.snap +++ b/src/components/__snapshots__/chart.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Chart should render the legend name test 1`] = `"
  • test
"`; +exports[`Chart should render the legend name test 1`] = `"
  • test
"`; diff --git a/src/components/icons/icon.tsx b/src/components/icons/icon.tsx index 1be30d8ddf..10db985732 100644 --- a/src/components/icons/icon.tsx +++ b/src/components/icons/icon.tsx @@ -18,7 +18,7 @@ */ import classNames from 'classnames'; -import React, { SVGAttributes } from 'react'; +import React, { SVGAttributes, memo } from 'react'; import { deepEqual } from '../../utils/fast_deep_equal'; import { AlertIcon } from './assets/alert'; @@ -56,34 +56,30 @@ interface IconProps { /** @internal */ export type IconComponentProps = Omit, 'color' | 'type'> & IconProps; -/** @internal */ -export class Icon extends React.Component { - shouldComponentUpdate(nextProps: IconComponentProps) { - return !deepEqual(this.props, nextProps); - } +function IconComponent({ type, color, className, tabIndex, ...rest }: IconComponentProps) { + let optionalCustomStyles = null; - render() { - const { type, color, className, tabIndex, ...rest } = this.props; - let optionalCustomStyles = null; - - if (color) { - optionalCustomStyles = { color }; - } + if (color) { + optionalCustomStyles = { color }; + } - const classes = classNames('echIcon', className); + const classes = classNames('echIcon', className); - const Svg = (type && typeToIconMap[type]) || EmptyIcon; + const Svg = (type && typeToIconMap[type]) || EmptyIcon; - /* - * This is a fix for IE and Edge, which ignores tabindex="-1" on an SVG, but respects - * focusable="false". - * - If there's no tab index specified, we'll default the icon to not be focusable, - * which is how SVGs behave in Chrome, Safari, and FF. - * - If tab index is -1, then the consumer wants the icon to not be focusable. - * - For all other values, the consumer wants the icon to be focusable. - */ - const focusable = tabIndex == null || tabIndex === -1 ? 'false' : 'true'; + /* + * This is a fix for IE and Edge, which ignores tabindex="-1" on an SVG, but respects + * focusable="false". + * - If there's no tab index specified, we'll default the icon to not be focusable, + * which is how SVGs behave in Chrome, Safari, and FF. + * - If tab index is -1, then the consumer wants the icon to not be focusable. + * - For all other values, the consumer wants the icon to be focusable. + */ + const focusable = tabIndex == null || tabIndex === -1 ? 'false' : 'true'; - return ; - } + return ; } +IconComponent.displayName = 'Icon'; + +/** @internal */ +export const Icon = memo(IconComponent, deepEqual); diff --git a/src/components/legend/__snapshots__/legend.test.tsx.snap b/src/components/legend/__snapshots__/legend.test.tsx.snap index b8832b2a73..2285a1fc35 100644 --- a/src/components/legend/__snapshots__/legend.test.tsx.snap +++ b/src/components/legend/__snapshots__/legend.test.tsx.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Legend #legendColorPicker should match snapshot after onChange is called 1`] = `"
  • splita
  • splitb
  • splitc
  • splitd
  • "`; +exports[`Legend #legendColorPicker should match snapshot after onChange is called 1`] = `"
  • splita
  • splitb
  • splitc
  • splitd
  • "`; -exports[`Legend #legendColorPicker should match snapshot after onClose is called 1`] = `"
  • splita
  • splitb
  • splitc
  • splitd
  • "`; +exports[`Legend #legendColorPicker should match snapshot after onClose is called 1`] = `"
  • splita
  • splitb
  • splitc
  • splitd
  • "`; exports[`Legend #legendColorPicker should render colorPicker when color is clicked 1`] = `"
    Custom Color Picker
    "`; -exports[`Legend #legendColorPicker should render colorPicker when color is clicked 2`] = `"
  • splita
  • Custom Color Picker
  • splitb
  • splitc
  • splitd
  • "`; +exports[`Legend #legendColorPicker should render colorPicker when color is clicked 2`] = `"
  • splita
  • Custom Color Picker
  • splitb
  • splitc
  • splitd
  • "`; diff --git a/src/components/legend/_legend_item.scss b/src/components/legend/_legend_item.scss index f6166bc155..990186b582 100644 --- a/src/components/legend/_legend_item.scss +++ b/src/components/legend/_legend_item.scss @@ -26,10 +26,16 @@ $legendItemVerticalPadding: $echLegendRowGap / 2; justify-content: center; align-items: center; margin-left: $euiSizeXS; + height: 16px; + width: 20px; } &__color { margin-right: $euiSizeXS; + + .euiPopover { + vertical-align: inherit; // prevents color dot from shifting + } } &__visibility { diff --git a/src/components/legend/color.tsx b/src/components/legend/color.tsx index 61a934f24d..653d75bae4 100644 --- a/src/components/legend/color.tsx +++ b/src/components/legend/color.tsx @@ -18,7 +18,7 @@ */ import classNames from 'classnames'; -import React, { MouseEventHandler } from 'react'; +import React, { MouseEventHandler, forwardRef, memo } from 'react'; import { Icon } from '../icons/icon'; @@ -33,28 +33,33 @@ interface ColorProps { * Color component used by the legend item * @internal */ -export function Color({ color, isSeriesHidden = false, hasColorPicker, onClick }: ColorProps) { - if (isSeriesHidden) { +export const Color = memo( + forwardRef(({ color, isSeriesHidden = false, hasColorPicker, onClick }, ref) => { + if (isSeriesHidden) { + return ( +
    + {/* changing the default viewBox for the eyeClosed icon to keep the same dimensions */} + +
    + ); + } + + const colorClasses = classNames('echLegendItem__color', { + 'echLegendItem__color--changable': hasColorPicker, + }); + return ( -
    - {/* changing the default viewBox for the eyeClosed icon to keep the same dimensions */} - +
    +
    + +
    ); - } - - const colorClasses = classNames('echLegendItem__color', { - 'echLegendItem__color--changable': hasColorPicker, - }); - - return ( -
    - -
    - ); -} + }), +); +Color.displayName = 'Color'; diff --git a/src/components/legend/legend_item.tsx b/src/components/legend/legend_item.tsx index a5ba3f4d3f..2da1b24ef8 100644 --- a/src/components/legend/legend_item.tsx +++ b/src/components/legend/legend_item.tsx @@ -111,7 +111,7 @@ interface LegendItemState { export class LegendListItem extends Component { static displayName = 'LegendItem'; - ref = createRef(); + colorRef = createRef(); state: LegendItemState = { isOpen: false, actionActive: false, @@ -187,10 +187,10 @@ export class LegendListItem extends Component clearTemporaryColorsAction(); this.toggleIsOpen(); }; - if (ColorPicker && this.state.isOpen && this.ref.current) { + if (ColorPicker && this.state.isOpen && this.colorRef.current) { return ( setTemporaryColorAction(seriesIdentifier.key, color)} @@ -217,13 +217,13 @@ export class LegendListItem extends Component return ( <>
  • {showExtra && extra != null && renderExtra(extra, isSeriesHidden)} {Action && (
    - +
    )}
  • diff --git a/src/specs/settings.tsx b/src/specs/settings.tsx index 9f100f238b..7d31bba51c 100644 --- a/src/specs/settings.tsx +++ b/src/specs/settings.tsx @@ -200,10 +200,20 @@ export interface LegendActionProps { * Series identifier for the given series */ series: SeriesIdentifier; + /** + * Resolved label/name of given series + */ + label: string; + /** + * Resolved color of given series + */ + color: string; } /** * Legend action component used to render actions next to legend items * + * render slot is constrained to 20px x 16px + * * @public */ export type LegendAction = ComponentType; diff --git a/src/state/selectors/get_legend_size.ts b/src/state/selectors/get_legend_size.ts index 9cab78d71f..90f3dad277 100644 --- a/src/state/selectors/get_legend_size.ts +++ b/src/state/selectors/get_legend_size.ts @@ -23,6 +23,7 @@ import { isVerticalAxis } from '../../chart_types/xy_chart/utils/axis_type_utils import { LEGEND_HIERARCHY_MARGIN } from '../../components/legend/legend_item'; import { BBox } from '../../utils/bbox/bbox_calculator'; import { CanvasTextBBoxCalculator } from '../../utils/bbox/canvas_text_bbox_calculator'; +import { isDefined } from '../../utils/commons'; import { GlobalChartState } from '../chart_state'; import { getChartIdSelector } from './get_chart_id'; import { getChartThemeSelector } from './get_chart_theme'; @@ -32,7 +33,6 @@ import { getSettingsSpecSelector } from './get_settings_specs'; const getParentDimensionSelector = (state: GlobalChartState) => state.parentDimensions; const MARKER_WIDTH = 16; -// const MARKER_HEIGHT = 16; const MARKER_LEFT_MARGIN = 4; const VALUE_LEFT_MARGIN = 4; const VERTICAL_PADDING = 4; @@ -65,19 +65,20 @@ export const getLegendSizeSelector = createCachedSelector( ); bboxCalculator.destroy(); - const { showLegend, showLegendExtra: showLegendDisplayValue, legendPosition } = settings; + const { showLegend, showLegendExtra: showLegendDisplayValue, legendPosition, legendAction } = settings; const { legend: { verticalWidth, spacingBuffer, margin }, } = theme; if (!showLegend) { return { width: 0, height: 0, margin: 0 }; } + const actionDimension = isDefined(legendAction) ? 24 : 0; // max width plus margin const legendItemWidth = MARKER_WIDTH + MARKER_LEFT_MARGIN + bbox.width + (showLegendDisplayValue ? VALUE_LEFT_MARGIN : 0); if (isVerticalAxis(legendPosition)) { const legendItemHeight = bbox.height + VERTICAL_PADDING * 2; return { - width: Math.floor(Math.min(legendItemWidth + spacingBuffer, verticalWidth)), + width: Math.floor(Math.min(legendItemWidth + spacingBuffer + actionDimension, verticalWidth)), height: legendItemHeight, margin, }; @@ -85,7 +86,7 @@ export const getLegendSizeSelector = createCachedSelector( const isSingleLine = (parentDimensions.width - 20) / 200 > labels.length; return { height: isSingleLine ? bbox.height + 16 : bbox.height * 2 + 24, - width: Math.floor(Math.min(legendItemWidth + spacingBuffer, verticalWidth)), + width: Math.floor(Math.min(legendItemWidth + spacingBuffer + actionDimension, verticalWidth)), margin, }; }, diff --git a/stories/legend/11_legend_actions.tsx b/stories/legend/11_legend_actions.tsx index 955042f068..86f94e7131 100644 --- a/stories/legend/11_legend_actions.tsx +++ b/stories/legend/11_legend_actions.tsx @@ -26,6 +26,7 @@ import { EuiColorPicker, EuiSpacer, EuiButton, + PopoverAnchorPosition, } from '@elastic/eui'; import { boolean } from '@storybook/addon-knobs'; import React, { useState } from 'react'; @@ -42,15 +43,18 @@ import { LegendColorPicker, } from '../../src'; import * as TestDatasets from '../../src/utils/data_samples/test_dataset'; -import { getPositionKnob } from '../utils/knobs'; +import { getPositionKnob, getEuiPopoverPositionKnob } from '../utils/knobs'; -const getAction = (hideActions: boolean): LegendAction => ({ series }) => { +const getAction = (hideActions: boolean, anchorPosition: PopoverAnchorPosition): LegendAction => ({ + series, + label, +}) => { const [popoverOpen, setPopoverOpen] = useState(false); const getPanels = (series: XYChartSeriesIdentifier): EuiContextMenuPanelDescriptor[] => [ { id: 0, - title: 'Legend Actions', + title: label, items: [ { name: 'Alert series specId', @@ -120,15 +124,20 @@ const getAction = (hideActions: boolean): LegendAction => ({ series }) => { closePopover={() => setPopoverOpen(false)} panelPaddingSize="none" withTitle - anchorPosition="upLeft" + anchorPosition={anchorPosition} > ); }; -const renderColorPicker: LegendColorPicker = ({ anchor, color, onClose, onChange }) => ( - +const renderColorPicker = (anchorPosition: PopoverAnchorPosition): LegendColorPicker => ({ + anchor, + color, + onClose, + onChange, +}) => ( + @@ -141,15 +150,17 @@ export const Example = () => { const hideActions = boolean('Hide legend action', false); const showLegendExtra = !boolean('Hide legend extra', false); const showColorPicker = !boolean('Hide color picker', true); + const legendPosition = getPositionKnob('Legend position'); + const euiPopoverPosition = getEuiPopoverPositionKnob(); return ( Number(d).toFixed(2)} /> @@ -171,7 +182,7 @@ Example.story = { parameters: { info: { text: - 'The `legendAction` action prop allows you to pass a render function/component that will render next to the legend item.', + 'The `legendAction` action prop allows you to pass a render function/component that will render next to the legend item.\n\n __Note:__ the context menu, color picker and popover are supplied by [eui](https://elastic.github.io/eui/#).', }, }, }; diff --git a/stories/legend/9_color_picker.tsx b/stories/legend/9_color_picker.tsx index 33bf7f22d8..dd8edcbe82 100644 --- a/stories/legend/9_color_picker.tsx +++ b/stories/legend/9_color_picker.tsx @@ -80,7 +80,7 @@ Example.story = { parameters: { info: { text: - 'Elastic charts will maintain the color selection in memory beyond chart updates. However, to persist colors beyond browser refresh the consumer would need to manage the color state and use the color prop on the SeriesSpec to assign a color via a SeriesColorAccessor.', + 'Elastic charts will maintain the color selection in memory beyond chart updates. However, to persist colors beyond browser refresh the consumer would need to manage the color state and use the color prop on the SeriesSpec to assign a color via a SeriesColorAccessor.\n\n __Note:__ the context menu, color picker and popover are supplied by [eui](https://elastic.github.io/eui/#).', }, }, }; diff --git a/stories/utils/knobs.ts b/stories/utils/knobs.ts index 2d369b4b7b..0f4223bf59 100644 --- a/stories/utils/knobs.ts +++ b/stories/utils/knobs.ts @@ -17,6 +17,7 @@ * under the License. */ +import { PopoverAnchorPosition } from '@elastic/eui'; import { select, array, number, optionsKnob } from '@storybook/addon-knobs'; import { Rotation, Position, Placement, TooltipProps } from '../../src'; @@ -96,6 +97,29 @@ export const getPlacementKnob = (name = 'placement', defaultValue?: Placement) = return value || undefined; }; +export const getEuiPopoverPositionKnob = ( + name = 'Popover position', + defaultValue: PopoverAnchorPosition = 'leftCenter', +) => + select( + name, + { + upCenter: 'upCenter', + upLeft: 'upLeft', + upRight: 'upRight', + downCenter: 'downCenter', + downLeft: 'downLeft', + downRight: 'downRight', + leftCenter: 'leftCenter', + leftUp: 'leftUp', + leftDown: 'leftDown', + rightCenter: 'rightCenter', + rightUp: 'rightUp', + rightDown: 'rightDown', + }, + defaultValue, + ); + export function arrayKnobs(name: string, values: (string | number)[]): (string | number)[] { const stringifiedValues = values.map((d) => `${d}`); return array(name, stringifiedValues).map((value: string) =>