From cfe9d596f3fa770345cd5ca6591b37d0ee978a23 Mon Sep 17 00:00:00 2001
From: Greg Thompson
Date: Wed, 2 Oct 2019 11:05:07 -0500
Subject: [PATCH] EuiColorStops (#2360)
* WIP
* WIP: scaling clean up
* reverse up/down keys
* add left/right movement
* update test
* doubleClick addStop; clean up; tests removal
* use more range components directly
* Firefox cleanup
* i18n; refactor
* WIP: add new stop via click
* clean up
* better keyboard interaction
* add stop styles
* empty set; backspace fix; drag handle fix
* readOnly and disabled
* screen reader
* more i18n
* cleanup of thumbs
* Update src/components/color_picker/color_stops/color_stop_thumb.tsx
Co-Authored-By: dave.snider@gmail.com
* thumb title
* required label
* misc feeback
* update zIndex for active thumb
* docs refactor
* util; basic snapshot tests
* color stops tests
* thumb snapshot tests
* CL
* use sorted array for add via keyboard location
---
CHANGELOG.md | 3 +-
.../color_picker/color_picker_example.js | 214 +++++-
.../src/views/color_picker/color_stops.js | 86 +++
src-docs/src/views/color_picker/containers.js | 181 ++---
.../src/views/color_picker/custom_swatches.js | 62 +-
.../src/views/color_picker/kitchen_sink.js | 48 +-
src-docs/src/views/color_picker/modes.js | 95 ++-
src-docs/src/views/color_picker/utils.js | 42 ++
.../color_picker/_color_picker.scss | 3 +-
src/components/color_picker/_index.scss | 1 +
src/components/color_picker/_variables.scss | 1 +
src/components/color_picker/color_picker.tsx | 4 -
.../color_stop_thumb.test.tsx.snap | 132 ++++
.../__snapshots__/color_stops.test.tsx.snap | 706 ++++++++++++++++++
.../color_stops/_color_stops.scss | 81 ++
.../color_picker/color_stops/_index.scss | 1 +
.../color_stops/color_stop_thumb.test.tsx | 102 +++
.../color_stops/color_stop_thumb.tsx | 352 +++++++++
.../color_stops/color_stops.test.tsx | 424 +++++++++++
.../color_picker/color_stops/color_stops.tsx | 351 +++++++++
.../color_picker/color_stops/index.ts | 1 +
.../color_picker/color_stops/utils.test.ts | 106 +++
.../color_picker/color_stops/utils.ts | 106 +++
src/components/color_picker/index.ts | 3 +-
src/components/color_picker/saturation.tsx | 43 +-
src/components/color_picker/utils.ts | 49 ++
src/components/form/range/range_highlight.tsx | 7 +-
src/components/form/range/range_thumb.tsx | 67 +-
src/components/form/range/range_wrapper.tsx | 41 +-
src/components/index.js | 1 +
30 files changed, 3007 insertions(+), 306 deletions(-)
create mode 100644 src-docs/src/views/color_picker/color_stops.js
create mode 100644 src-docs/src/views/color_picker/utils.js
create mode 100644 src/components/color_picker/color_stops/__snapshots__/color_stop_thumb.test.tsx.snap
create mode 100644 src/components/color_picker/color_stops/__snapshots__/color_stops.test.tsx.snap
create mode 100644 src/components/color_picker/color_stops/_color_stops.scss
create mode 100644 src/components/color_picker/color_stops/_index.scss
create mode 100644 src/components/color_picker/color_stops/color_stop_thumb.test.tsx
create mode 100644 src/components/color_picker/color_stops/color_stop_thumb.tsx
create mode 100644 src/components/color_picker/color_stops/color_stops.test.tsx
create mode 100644 src/components/color_picker/color_stops/color_stops.tsx
create mode 100644 src/components/color_picker/color_stops/index.ts
create mode 100644 src/components/color_picker/color_stops/utils.test.ts
create mode 100644 src/components/color_picker/color_stops/utils.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d68860295d..b46766cbf4d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,11 @@
## [`master`](https://github.com/elastic/eui/tree/master)
- Update Elastic-Charts to version 13.0.0 and updated the theme object accordingly ([#2381](https://github.com/elastic/eui/pull/2381))
+- Added new `EuiColorStops` component ([#2360](https://github.com/elastic/eui/pull/2360))
**Bug fixes**
-- Fix `EuiSelectable` to accept programmatic updates to its `options` prop ([#2390](https://github.com/elastic/eui/pull/2390))
+- Fix `EuiSelectable` to accept programmatic updates to its `options` prop ([#2390](https://github.com/elastic/eui/pull/2390))
## [`14.4.0`](https://github.com/elastic/eui/tree/v14.4.0)
diff --git a/src-docs/src/views/color_picker/color_picker_example.js b/src-docs/src/views/color_picker/color_picker_example.js
index 25006528e42..5cf265e2988 100644
--- a/src-docs/src/views/color_picker/color_picker_example.js
+++ b/src-docs/src/views/color_picker/color_picker_example.js
@@ -4,7 +4,13 @@ import { renderToHtml } from '../../services';
import { GuideSectionTypes } from '../../components';
-import { EuiCode, EuiColorPicker, EuiText } from '../../../../src/components';
+import {
+ EuiCode,
+ EuiColorPicker,
+ EuiColorStops,
+ EuiSpacer,
+ EuiText,
+} from '../../../../src/components';
import { ColorPicker } from './color_picker';
const colorPickerSource = require('!!raw-loader!./color_picker');
@@ -17,6 +23,36 @@ const colorPickerSnippet = `
`;
+import { ColorStops } from './color_stops';
+const colorStopsSource = require('!!raw-loader!./color_stops');
+const colorStopsHtml = renderToHtml(ColorStops);
+const colorStopsSnippetStandard = ``;
+
+const colorStopsSnippetAdd = ``;
+
+const colorStopsSnippetFixed = `
+`;
+
import { CustomSwatches } from './custom_swatches';
const customSwatchesSource = require('!!raw-loader!./custom_swatches');
const customSwatchesHtml = renderToHtml(CustomSwatches);
@@ -31,6 +67,20 @@ const customSwatchesSnippet = ``;
+
+const stopCustomSwatchesSnippet = `
`;
@@ -83,6 +133,26 @@ const modesPickerSnippet = `// Gradient map only
mode="picker"
/>
`;
+const stopModesSwatchSnippet = `// Swatches only
+
+`;
+const stopModesPickerSnippet = `// Gradient map only
+
+`;
import { Inline } from './inline';
const inlineSource = require('!!raw-loader!./inline');
@@ -120,30 +190,60 @@ const kitchenSinkSnippet = `
`;
+const stopKitchenSinkSnippet = `
+`;
export const ColorPickerExample = {
- title: 'Color Picker',
+ title: 'Color Selection',
intro: (
- Color input component allowing for multiple methods of entry and
- selection.
-
-
- Direct text entry will only match hexadecimal (hex) colors, and output
- values only return hex values. Spatial selection involves HSV
- manipulaton, which is converted to hex.
-
-
- Swatches allow consumers to predefine preferred or suggested choices.
- The swatches must also be entered in hex format.
+ Two components exist to aid color selection:{' '}
+ EuiColorPicker and EuiColorStops
+ .
+
),
sections: [
{
+ title: 'Color picker',
+ text: (
+
+
+
+ Color input component allowing for multiple methods of entry and
+ selection.
+
+
+ Direct text entry will only match hexadecimal (hex) colors, and
+ output values only return hex values. Spatial selection involves
+ HSV manipulaton, which is converted to hex.
+
+
+ Swatches allow consumers to predefine preferred or suggested
+ choices. The swatches must also be entered in hex format.
+
+
+
+ ),
source: [
{
type: GuideSectionTypes.JS,
@@ -158,6 +258,38 @@ export const ColorPickerExample = {
snippet: colorPickerSnippet,
demo: ,
},
+ {
+ title: 'Color stops',
+ text: (
+
+
+
+ Use EuiColorStops to define color stops for
+ data driven styling. Stops are numbers within the provided range.
+ The color segment spans from the given stop number (inclusive) to
+ the next stop number (exclusive).
+
+
+
+ ),
+ source: [
+ {
+ type: GuideSectionTypes.JS,
+ code: colorStopsSource,
+ },
+ {
+ type: GuideSectionTypes.HTML,
+ code: colorStopsHtml,
+ },
+ ],
+ props: { EuiColorStops },
+ snippet: [
+ colorStopsSnippetStandard,
+ colorStopsSnippetAdd,
+ colorStopsSnippetFixed,
+ ],
+ demo: ,
+ },
{
title: 'Custom color swatches',
source: [
@@ -177,54 +309,59 @@ export const ColorPickerExample = {
the swatches prop.
),
- snippet: customSwatchesSnippet,
+ snippet: [customSwatchesSnippet, stopCustomSwatchesSnippet],
demo: ,
},
{
- title: 'Custom button',
+ title: 'Limited selection modes',
source: [
{
type: GuideSectionTypes.JS,
- code: customButtonSource,
+ code: modesSource,
},
{
type: GuideSectionTypes.HTML,
- code: customButtonHtml,
+ code: modesHtml,
},
],
text: (
- You can optionally use a custom button as the trigger for selection
- using the button prop. Please remember to add
- accessibility to this component, using proper button markup and aria
- labeling.
+ By default, both swatch selection and the gradient color map will be
+ rendered. Use the mode prop to pass `swatch` for
+ swatch-only selection, or pass `picker` for gradient map and hue
+ slider selection without swatches.
),
- snippet: [customButtonSnippet, customBadgeSnippet],
- demo: ,
+ snippet: [
+ modesSwatchSnippet,
+ modesPickerSnippet,
+ stopModesSwatchSnippet,
+ stopModesPickerSnippet,
+ ],
+ demo: ,
},
{
- title: 'Limited selection modes',
+ title: 'Custom button',
source: [
{
type: GuideSectionTypes.JS,
- code: modesSource,
+ code: customButtonSource,
},
{
type: GuideSectionTypes.HTML,
- code: modesHtml,
+ code: customButtonHtml,
},
],
text: (
- By default, both swatch selection and the gradient color map will be
- rendered. Use the mode prop to pass `swatch` for
- swatch-only selection, or pass `picker` for gradient map and hue
- slider selection without swatches.
+ Available only in EuiColorPicker. You can
+ optionally use a custom button as the trigger for selection using the{' '}
+ button prop. Please remember to add accessibility
+ to this component, using proper button markup and aria labeling.
),
- snippet: [modesSwatchSnippet, modesPickerSnippet],
- demo: ,
+ snippet: [customButtonSnippet, customBadgeSnippet],
+ demo: ,
},
{
title: 'Inline',
@@ -240,8 +377,9 @@ export const ColorPickerExample = {
],
text: (
- Set the display prop to `inline` to display the
- color picker without an input or popover. Note that the{' '}
+ Available only in EuiColorPicker. Set the{' '}
+ display prop to `inline` to display the color
+ picker without an input or popover. Note that the{' '}
button prop will be ignored in this case.
),
@@ -262,15 +400,15 @@ export const ColorPickerExample = {
],
text: (
- Demonstrating that EuiColorPicker can exist in
- portal containers and that its popover position works in nested
+ Demonstrating that both color selection components can exist in portal
+ containers and that their popover positioning works in nested
contexts.
),
demo: ,
},
{
- title: 'Kitchen sink',
+ title: 'Option toggling',
source: [
{
type: GuideSectionTypes.JS,
@@ -281,7 +419,7 @@ export const ColorPickerExample = {
code: kitchenSinkHtml,
},
],
- snippet: kitchenSinkSnippet,
+ snippet: [kitchenSinkSnippet, stopKitchenSinkSnippet],
demo: ,
},
],
diff --git a/src-docs/src/views/color_picker/color_stops.js b/src-docs/src/views/color_picker/color_stops.js
new file mode 100644
index 00000000000..e92f36a631f
--- /dev/null
+++ b/src-docs/src/views/color_picker/color_stops.js
@@ -0,0 +1,86 @@
+import React, { useState } from 'react';
+
+import { EuiColorStops, EuiFormRow } from '../../../../src/components';
+
+import { useColorStop } from './utils';
+
+export const ColorStops = () => {
+ const [colorStops, setColorStops, addColor] = useColorStop(true);
+
+ const [extendedColorStops, setExtendedColorStops] = useState([
+ {
+ stop: 100,
+ color: '#00B3A4',
+ },
+ {
+ stop: 250,
+ color: '#DB1374',
+ },
+ {
+ stop: 350,
+ color: '#490092',
+ },
+ ]);
+
+ const handleExtendedChange = colorStops => {
+ setExtendedColorStops(colorStops);
+ };
+
+ const [emptyColorStops, setEmptyColorStops] = useState([]);
+
+ const handleEmptyChange = colorStops => {
+ setEmptyColorStops(colorStops);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src-docs/src/views/color_picker/containers.js b/src-docs/src/views/color_picker/containers.js
index ad38d3c1e01..7f6923ea65d 100644
--- a/src-docs/src/views/color_picker/containers.js
+++ b/src-docs/src/views/color_picker/containers.js
@@ -1,7 +1,8 @@
-import React, { Component, Fragment } from 'react';
+import React, { Fragment, useState } from 'react';
import {
EuiColorPicker,
+ EuiColorStops,
EuiButton,
EuiPopover,
EuiFormRow,
@@ -13,104 +14,104 @@ import {
EuiSpacer,
} from '../../../../src/components';
-export default class extends Component {
- constructor(props) {
- super(props);
+import { useColorPicker, useColorStop } from './utils';
- this.state = {
- color: '#FFF',
- isModalVisible: false,
- isPopoverOpen: false,
- };
- }
-
- closeModal = () => {
- this.setState({ isModalVisible: false });
- };
+export default () => {
+ const [color, setColor] = useColorPicker('#FFF');
+ const [colorStops, setColorStops] = useColorStop();
+ const [isModalVisible, setIsModalVisible] = useState(false);
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
- showModal = () => {
- this.setState({ isModalVisible: true });
+ const closeModal = () => {
+ setIsModalVisible(false);
};
- togglePopover = () => {
- this.setState(prevState => ({
- isPopoverOpen: !prevState.isPopoverOpen,
- }));
+ const showModal = () => {
+ setIsModalVisible(true);
};
- closePopover = () => {
- this.setState({
- isPopoverOpen: false,
- });
+ const togglePopover = () => {
+ setIsPopoverOpen(!isPopoverOpen);
};
- onChange = color => {
- this.setState({
- color,
- });
+ const closePopover = () => {
+ setIsPopoverOpen(false);
};
- render() {
- const { color, isModalVisible, isPopoverOpen } = this.state;
-
- const colorPicker = (
-
- );
-
- const button = (
-
- Open popover
-
- );
-
- let modal;
-
- if (isModalVisible) {
- modal = (
-
-
-
- Color picker in a modal
-
-
-
- {colorPicker}
-
-
-
- );
- }
-
- return (
-
-
- {colorPicker}
-
-
-
-
-
- {colorPicker}
-
-
-
-
-
-
- Show modal
-
- {modal}
-
+ const colorPicker = ;
+
+ const stops = (
+
+ );
+
+ const button = (
+
+ Open popover
+
+ );
+
+ let modal;
+
+ if (isModalVisible) {
+ modal = (
+
+
+
+ Color picker in a modal
+
+
+
+ {colorPicker}
+
+ {stops}
+
+
+
);
}
-}
+
+ return (
+
+
+ {colorPicker}
+
+
+
+
+
+ {stops}
+
+
+
+
+
+ {colorPicker}
+
+ {stops}
+
+
+
+
+
+
+ Show modal
+
+ {modal}
+
+ );
+};
diff --git a/src-docs/src/views/color_picker/custom_swatches.js b/src-docs/src/views/color_picker/custom_swatches.js
index 789a5225aa8..b8256d60b04 100644
--- a/src-docs/src/views/color_picker/custom_swatches.js
+++ b/src-docs/src/views/color_picker/custom_swatches.js
@@ -1,39 +1,43 @@
-import React, { Component } from 'react';
+import React from 'react';
-import { EuiColorPicker, EuiFormRow } from '../../../../src/components';
-import { isValidHex } from '../../../../src/services';
+import {
+ EuiColorPicker,
+ EuiColorStops,
+ EuiFormRow,
+ EuiSpacer,
+} from '../../../../src/components';
-export class CustomSwatches extends Component {
- constructor(props) {
- super(props);
- this.state = {
- color: '',
- };
- }
+import { useColorPicker, useColorStop } from './utils';
- handleChange = value => {
- this.setState({ color: value });
- };
+export const CustomSwatches = () => {
+ const [color, setColor, errors] = useColorPicker();
+ const [colorStops, setColorStops] = useColorStop();
- render() {
- const hasErrors = !isValidHex(this.state.color) && this.state.color !== '';
+ const customSwatches = ['#333', '#666', '#999', '#CCC'];
- let errors;
- if (hasErrors) {
- errors = ['Provide a valid hex value'];
- }
+ return (
+
+
+
+
- const customSwatches = ['#333', '#666', '#999', '#CCC'];
+
- return (
-
-
+
- );
- }
-}
+
+ );
+};
diff --git a/src-docs/src/views/color_picker/kitchen_sink.js b/src-docs/src/views/color_picker/kitchen_sink.js
index 3366f7617ad..258af45a6c7 100644
--- a/src-docs/src/views/color_picker/kitchen_sink.js
+++ b/src-docs/src/views/color_picker/kitchen_sink.js
@@ -1,26 +1,36 @@
-import React, { Component } from 'react';
+import React from 'react';
-import { EuiColorPicker } from '../../../../src/components';
+import {
+ EuiColorPicker,
+ EuiColorStops,
+ EuiSpacer,
+} from '../../../../src/components';
import { DisplayToggles } from '../form_controls/display_toggles';
-export class KitchenSink extends Component {
- constructor(props) {
- super(props);
- this.state = {
- color: '',
- };
- }
+import { useColorPicker, useColorStop } from './utils';
- handleChange = value => {
- this.setState({ color: value });
- };
+export const KitchenSink = () => {
+ const [color, setColor] = useColorPicker('#DB1374');
+ const [colorStops, setColorStops, addStop] = useColorStop(true);
- render() {
- return (
- /* DisplayToggles wrapper for Docs only */
+ return (
+
+ {/* DisplayToggles wrapper for Docs only */}
-
+
- );
- }
-}
+
+ {/* DisplayToggles wrapper for Docs only */}
+
+
+
+
+ );
+};
diff --git a/src-docs/src/views/color_picker/modes.js b/src-docs/src/views/color_picker/modes.js
index 17ec18086d1..762950fb9a1 100644
--- a/src-docs/src/views/color_picker/modes.js
+++ b/src-docs/src/views/color_picker/modes.js
@@ -1,47 +1,60 @@
-import React, { Component } from 'react';
+import React from 'react';
-import { EuiColorPicker, EuiFormRow } from '../../../../src/components';
-import { isValidHex } from '../../../../src/services';
+import {
+ EuiColorPicker,
+ EuiColorStops,
+ EuiFormRow,
+ EuiSpacer,
+} from '../../../../src/components';
-export class Modes extends Component {
- constructor(props) {
- super(props);
- this.state = {
- color: '#DB1374',
- };
- }
+import { useColorPicker, useColorStop } from './utils';
- handleChange = value => {
- this.setState({ color: value });
- };
+export const Modes = () => {
+ const [color, setColor, errors] = useColorPicker('#DB1374');
+ const [colorStops, setColorStops] = useColorStop();
- render() {
- const hasErrors = !isValidHex(this.state.color) && this.state.color !== '';
+ return (
+
+
+
+
+
+
+
- let errors;
- if (hasErrors) {
- errors = ['Provide a valid hex value'];
- }
+
- return (
-
-
-
-
-
-
-
-
- );
- }
-}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src-docs/src/views/color_picker/utils.js b/src-docs/src/views/color_picker/utils.js
new file mode 100644
index 00000000000..d375427c3d9
--- /dev/null
+++ b/src-docs/src/views/color_picker/utils.js
@@ -0,0 +1,42 @@
+import { useMemo, useState } from 'react';
+import { isValidHex } from '../../../../src/services';
+
+const generateRandomColor = () =>
+ // https://www.paulirish.com/2009/random-hex-color-code-snippets/
+ `#${Math.floor(Math.random() * 16777215).toString(16)}`;
+
+export const useColorStop = (useRandomColor = false) => {
+ const [addColor, setAddColor] = useState(generateRandomColor());
+ const [colorStops, setColorStops] = useState([
+ {
+ stop: 20,
+ color: '#00B3A4',
+ },
+ {
+ stop: 50,
+ color: '#DB1374',
+ },
+ {
+ stop: 65,
+ color: '#490092',
+ },
+ ]);
+
+ const updateColorStops = colorStops => {
+ setColorStops(colorStops);
+ if (useRandomColor) {
+ setAddColor(generateRandomColor());
+ }
+ };
+ return [colorStops, updateColorStops, addColor];
+};
+
+export const useColorPicker = (initialColor = '') => {
+ const [color, setColor] = useState(initialColor);
+ const errors = useMemo(() => {
+ const hasErrors = !isValidHex(color) && color !== '';
+ return hasErrors ? ['Provide a valid hex value'] : null;
+ }, [color]);
+
+ return [color, setColor, errors];
+};
diff --git a/src/components/color_picker/_color_picker.scss b/src/components/color_picker/_color_picker.scss
index 700b95c9780..b2521831ac1 100644
--- a/src/components/color_picker/_color_picker.scss
+++ b/src/components/color_picker/_color_picker.scss
@@ -2,8 +2,7 @@
.euiColorPicker {
position: relative;
- // 5 columns of swatches + margins + border
- width: ($euiSizeL * 5) + ($euiSizeS * 4);
+ width: $euiColorPickerWidth;
}
diff --git a/src/components/color_picker/_index.scss b/src/components/color_picker/_index.scss
index af6e9fd99cc..b42d746aef0 100644
--- a/src/components/color_picker/_index.scss
+++ b/src/components/color_picker/_index.scss
@@ -3,3 +3,4 @@
@import 'color_picker_swatch';
@import 'hue';
@import 'saturation';
+@import 'color_stops/index';
diff --git a/src/components/color_picker/_variables.scss b/src/components/color_picker/_variables.scss
index 08cd1445f7b..3bc15347438 100644
--- a/src/components/color_picker/_variables.scss
+++ b/src/components/color_picker/_variables.scss
@@ -3,3 +3,4 @@ $euiColorPickerValueRange1: rgba(255, 255, 255, 0);
$euiColorPickerSaturationRange0: rgba(0, 0, 0, 1);
$euiColorPickerSaturationRange1: rgba(0, 0, 0, 0);
$euiColorPickerIndicatorSize: $euiSizeM;
+$euiColorPickerWidth: ($euiSizeL * 5) + ($euiSizeS * 4); // 5 columns of swatches + margins + border
diff --git a/src/components/color_picker/color_picker.tsx b/src/components/color_picker/color_picker.tsx
index d60163f471e..070f9262057 100644
--- a/src/components/color_picker/color_picker.tsx
+++ b/src/components/color_picker/color_picker.tsx
@@ -65,10 +65,6 @@ export interface EuiColorPickerProps
* Custom validation flag
*/
isInvalid?: boolean;
- /**
- * Renders inline, without an input element or popover
- */
- inline?: boolean;
/**
* Choose between swatches with gradient picker (default), swatches only, or gradient picker only.
*/
diff --git a/src/components/color_picker/color_stops/__snapshots__/color_stop_thumb.test.tsx.snap b/src/components/color_picker/color_stops/__snapshots__/color_stop_thumb.test.tsx.snap
new file mode 100644
index 00000000000..0fc97c4c5d2
--- /dev/null
+++ b/src/components/color_picker/color_stops/__snapshots__/color_stop_thumb.test.tsx.snap
@@ -0,0 +1,132 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders EuiColorStopThumb 1`] = `
+
+`;
+
+exports[`renders disabled EuiColorStopThumb 1`] = `
+
+`;
+
+exports[`renders picker-only EuiColorStopThumb 1`] = `
+
+`;
+
+exports[`renders readOnly EuiColorStopThumb 1`] = `
+
+`;
+
+exports[`renders swatch-only EuiColorStopThumb 1`] = `
+
+`;
diff --git a/src/components/color_picker/color_stops/__snapshots__/color_stops.test.tsx.snap b/src/components/color_picker/color_stops/__snapshots__/color_stops.test.tsx.snap
new file mode 100644
index 00000000000..e01bd844ecf
--- /dev/null
+++ b/src/components/color_picker/color_stops/__snapshots__/color_stops.test.tsx.snap
@@ -0,0 +1,706 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders EuiColorStops 1`] = `
+
+
+ Test: Color stop picker. Each stop consists of a number and corresponding color value. Use the Down and Up arrow keys to select individual stops. Press the Enter key to create a new stop.
+
+
+
+`;
+
+exports[`renders compressed EuiColorStops 1`] = `
+
+
+ Test: Color stop picker. Each stop consists of a number and corresponding color value. Use the Down and Up arrow keys to select individual stops. Press the Enter key to create a new stop.
+
+
+
+`;
+
+exports[`renders disabled EuiColorStops 1`] = `
+
+
+ Test: Disabled. Color stop picker. Each stop consists of a number and corresponding color value. Use the Down and Up arrow keys to select individual stops. Press the Enter key to create a new stop.
+
+
+
+`;
+
+exports[`renders empty EuiColorStops 1`] = `
+
+
+ Test: Color stop picker. Each stop consists of a number and corresponding color value. Use the Down and Up arrow keys to select individual stops. Press the Enter key to create a new stop.
+
+
+
+`;
+
+exports[`renders fixed stop EuiColorStops 1`] = `
+
+
+ Test: Color stop picker. Each stop consists of a number and corresponding color value. Use the Down and Up arrow keys to select individual stops. Press the Enter key to create a new stop.
+
+
+
+`;
+
+exports[`renders fullWidth EuiColorStops 1`] = `
+
+
+ Test: Color stop picker. Each stop consists of a number and corresponding color value. Use the Down and Up arrow keys to select individual stops. Press the Enter key to create a new stop.
+
+
+
+`;
+
+exports[`renders readOnly EuiColorStops 1`] = `
+
+
+ Test: Read-only. Color stop picker. Each stop consists of a number and corresponding color value. Use the Down and Up arrow keys to select individual stops. Press the Enter key to create a new stop.
+
+
+
+`;
diff --git a/src/components/color_picker/color_stops/_color_stops.scss b/src/components/color_picker/color_stops/_color_stops.scss
new file mode 100644
index 00000000000..99fa4333d28
--- /dev/null
+++ b/src/components/color_picker/color_stops/_color_stops.scss
@@ -0,0 +1,81 @@
+@import '../../form/range/_variables';
+
+.euiColorStops:not(.euiColorStops-isDisabled) {
+ &:focus {
+ outline: 2px solid $euiFocusRingColor;
+ }
+}
+
+.euiColorStops__addContainer {
+ display: block;
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 50%;
+ height: $euiRangeThumbHeight;
+ margin-top: $euiRangeThumbHeight * -.5;
+
+ &:hover:not(.euiColorStops__addContainer-isDisabled) {
+ cursor: pointer;
+
+ .euiColorStops__addTarget {
+ opacity: .7;
+ }
+ }
+}
+
+.euiColorStops__addTarget {
+ @include euiCustomControl($type: 'round');
+ @include euiRangeThumbStyle;
+ position: absolute;
+ top: 0;
+ height: $euiRangeThumbHeight;
+ width: $euiRangeThumbHeight;
+ background-color: $euiColorLightestShade;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity $euiAnimSpeedFast;
+}
+
+.euiColorStop {
+ width: $euiColorPickerWidth;
+}
+
+.euiColorStopPopover.euiPopover {
+ position: absolute;
+ top: 50%;
+ width: $euiRangeThumbWidth;
+ height: $euiRangeThumbHeight;
+ margin-top: $euiRangeThumbHeight * -.5;
+}
+
+.euiColorStopPopover-hasFocus {
+ z-index: 1;
+}
+
+.euiColorStopPopover__anchor {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+}
+
+.euiColorStopThumb.euiRangeThumb:not(:disabled) {
+ // sass-lint:disable-block no-color-literals, indentation
+ top: 0;
+ margin-top: 0;
+ pointer-events: auto;
+ cursor: grab;
+ border: solid ($euiSizeXS - 1px) $euiColorEmptyShade;
+ box-shadow:
+ 0 0 0 1px $euiColorMediumShade,
+ 0 2px 2px -1px rgba($euiShadowColor, .2),
+ 0 1px 5px -2px rgba($euiShadowColor, .2);
+
+ &:active {
+ cursor: grabbing;
+ }
+}
+
+.euiColorStops.euiColorStops-isDragging:not(.euiColorStops-isDisabled):not(.euiColorStops-isReadOnly) {
+ cursor: grabbing;
+}
diff --git a/src/components/color_picker/color_stops/_index.scss b/src/components/color_picker/color_stops/_index.scss
new file mode 100644
index 00000000000..9338d35b24a
--- /dev/null
+++ b/src/components/color_picker/color_stops/_index.scss
@@ -0,0 +1 @@
+@import 'color_stops';
diff --git a/src/components/color_picker/color_stops/color_stop_thumb.test.tsx b/src/components/color_picker/color_stops/color_stop_thumb.test.tsx
new file mode 100644
index 00000000000..c91901fe892
--- /dev/null
+++ b/src/components/color_picker/color_stops/color_stop_thumb.test.tsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import { render } from 'enzyme';
+
+import { EuiColorStopThumb } from './color_stop_thumb';
+
+import { requiredProps } from '../../../test';
+
+jest.mock('../../portal', () => ({
+ // @ts-ignore
+ EuiPortal: ({ children }) => children,
+}));
+
+const onChange = jest.fn();
+
+// Note: Unit/interaction tests can be found in ./color_stops.test
+
+test('renders EuiColorStopThumb', () => {
+ const thumb = render(
+
+ );
+ expect(thumb).toMatchSnapshot();
+});
+
+test('renders swatch-only EuiColorStopThumb', () => {
+ const thumb = render(
+
+ );
+ expect(thumb).toMatchSnapshot();
+});
+
+test('renders picker-only EuiColorStopThumb', () => {
+ const thumb = render(
+
+ );
+ expect(thumb).toMatchSnapshot();
+});
+
+test('renders disabled EuiColorStopThumb', () => {
+ const thumb = render(
+
+ );
+ expect(thumb).toMatchSnapshot();
+});
+
+test('renders readOnly EuiColorStopThumb', () => {
+ const thumb = render(
+
+ );
+ expect(thumb).toMatchSnapshot();
+});
diff --git a/src/components/color_picker/color_stops/color_stop_thumb.tsx b/src/components/color_picker/color_stops/color_stop_thumb.tsx
new file mode 100644
index 00000000000..c803debf13a
--- /dev/null
+++ b/src/components/color_picker/color_stops/color_stop_thumb.tsx
@@ -0,0 +1,352 @@
+import React, {
+ FunctionComponent,
+ ReactChild,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import classNames from 'classnames';
+
+import { CommonProps } from '../../common';
+import {
+ getPositionFromStop,
+ getStopFromMouseLocation,
+ isColorInvalid,
+ isStopInvalid,
+} from './utils';
+import { useMouseMove } from '../utils';
+import { keyCodes } from '../../../services';
+
+import { EuiButtonIcon } from '../../button';
+import { EuiColorPicker, EuiColorPickerProps } from '../color_picker';
+import { EuiFlexGroup, EuiFlexItem } from '../../flex';
+// @ts-ignore
+import { EuiFieldNumber, EuiFieldText, EuiFormRow } from '../../form';
+import { EuiI18n } from '../../i18n';
+import { EuiRangeThumb } from '../../form/range/range_thumb';
+import { EuiPopover } from '../../popover';
+import { EuiScreenReaderOnly } from '../../accessibility';
+import { EuiSpacer } from '../../spacer';
+
+export interface ColorStop {
+ stop: number;
+ color: string;
+}
+
+interface EuiColorStopThumbProps extends CommonProps, ColorStop {
+ className?: string;
+ onChange: (colorStop: ColorStop) => void;
+ onFocus?: () => void;
+ onRemove?: () => void;
+ globalMin: number;
+ globalMax: number;
+ min: number;
+ max: number;
+ parentRef?: HTMLDivElement | null;
+ colorPickerMode: EuiColorPickerProps['mode'];
+ colorPickerSwatches?: EuiColorPickerProps['swatches'];
+ disabled?: boolean;
+ readOnly?: boolean;
+ 'data-index'?: string;
+ 'aria-valuetext'?: string;
+}
+
+export const EuiColorStopThumb: FunctionComponent = ({
+ className,
+ stop,
+ color,
+ onChange,
+ onFocus,
+ onRemove,
+ globalMin,
+ globalMax,
+ min,
+ max,
+ parentRef,
+ colorPickerMode,
+ colorPickerSwatches,
+ disabled,
+ readOnly,
+ 'data-index': dataIndex,
+ 'aria-valuetext': ariaValueText,
+}) => {
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+ const [hasFocus, setHasFocus] = useState(false);
+ const [colorIsInvalid, setColorIsInvalid] = useState(isColorInvalid(color));
+ const [stopIsInvalid, setStopIsInvalid] = useState(isStopInvalid(stop));
+ const [numberInputRef, setNumberInputRef] = useState();
+ const popoverRef = useRef(null);
+
+ useEffect(() => {
+ if (isPopoverOpen && popoverRef && popoverRef.current) {
+ popoverRef.current.positionPopoverFixed();
+ }
+ }, [stop]);
+
+ const getStopFromMouseLocationFn = (location: { x: number; y: number }) => {
+ // Guard against `null` ref in usage
+ return getStopFromMouseLocation(location, parentRef!, globalMin, globalMax);
+ };
+
+ const getPositionFromStopFn = (stop: ColorStop['stop']) => {
+ // Guard against `null` ref in usage
+ return getPositionFromStop(stop, parentRef!, globalMin, globalMax);
+ };
+
+ const openPopover = () => setIsPopoverOpen(true);
+
+ const closePopover = () => setIsPopoverOpen(false);
+
+ const handleOnRemove = () => {
+ if (onRemove) {
+ closePopover();
+ onRemove();
+ }
+ };
+
+ const handleFocus = () => {
+ setHasFocus(true);
+ if (onFocus) {
+ onFocus();
+ }
+ };
+
+ const handleColorChange = (value: ColorStop['color']) => {
+ setColorIsInvalid(isColorInvalid(value));
+ onChange({ stop, color: value });
+ };
+
+ const handleStopChange = (value: ColorStop['stop']) => {
+ const willBeInvalid = value > max || value < min;
+
+ if (willBeInvalid) {
+ if (value > max) {
+ value = max;
+ }
+ if (value < min) {
+ value = min;
+ }
+ }
+ setStopIsInvalid(isStopInvalid(value));
+ onChange({ stop: value, color });
+ };
+
+ const handleStopInputChange = (value: ColorStop['stop']) => {
+ const willBeInvalid = value > globalMax || value < globalMin;
+
+ if (willBeInvalid) {
+ if (value > globalMax) {
+ value = globalMax;
+ }
+ if (value < globalMin) {
+ value = globalMin;
+ }
+ }
+ setStopIsInvalid(isStopInvalid(value));
+ onChange({ stop: value, color });
+ };
+
+ const handlePointerChange = (
+ location: { x: number; y: number },
+ isFirstInteraction?: boolean
+ ) => {
+ if (isFirstInteraction) return; // Prevents change on the inital MouseDown event
+ if (parentRef == null) {
+ return;
+ }
+ const newStop = getStopFromMouseLocationFn(location);
+ handleStopChange(newStop);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ switch (e.keyCode) {
+ case keyCodes.LEFT:
+ e.preventDefault();
+ handleStopChange(stop - 1);
+ break;
+
+ case keyCodes.RIGHT:
+ e.preventDefault();
+ handleStopChange(stop + 1);
+ break;
+ }
+ };
+
+ const [handleMouseDown, handleInteraction] = useMouseMove(
+ handlePointerChange
+ );
+
+ const classes = classNames(
+ 'euiColorStopPopover',
+ {
+ 'euiColorStopPopover-hasFocus': hasFocus || isPopoverOpen,
+ },
+ className
+ );
+
+ return (
+
+ {([buttonAriaLabel, buttonTitle]: ReactChild[]) => {
+ const ariaLabel = buttonAriaLabel as string;
+ const title = buttonTitle as string;
+ return (
+ setHasFocus(false)}
+ onMouseOver={() => setHasFocus(true)}
+ onMouseOut={() => setHasFocus(false)}
+ onKeyDown={readOnly ? undefined : handleKeyDown}
+ onMouseDown={readOnly ? undefined : handleMouseDown}
+ onTouchStart={readOnly ? undefined : handleInteraction}
+ onTouchMove={readOnly ? undefined : handleInteraction}
+ aria-valuetext={ariaValueText}
+ aria-label={ariaLabel}
+ title={title}
+ className="euiColorStopThumb"
+ tabIndex={-1}
+ style={{
+ background: color,
+ }}
+ disabled={disabled}
+ />
+ );
+ }}
+
+ }>
+
+
+
+
+
+
+
+
+
+ {([stopLabel, stopErrorMessage]: React.ReactChild[]) => (
+
+ ) =>
+ handleStopInputChange(parseFloat(e.target.value))
+ }
+ />
+
+ )}
+
+
+
+
+
+ {(removeLabel: string) => (
+
+ )}
+
+
+
+
+ {!readOnly && (
+
+
+
+
+ )}
+ {colorPickerMode !== 'swatch' && (
+
+
+
+ {([hexLabel, hexErrorMessage]: React.ReactChild[]) => (
+
+ ) =>
+ handleColorChange(e.target.value)
+ }
+ />
+
+ )}
+
+
+ )}
+
+
+ );
+};
diff --git a/src/components/color_picker/color_stops/color_stops.test.tsx b/src/components/color_picker/color_stops/color_stops.test.tsx
new file mode 100644
index 00000000000..6ed215450b1
--- /dev/null
+++ b/src/components/color_picker/color_stops/color_stops.test.tsx
@@ -0,0 +1,424 @@
+import React from 'react';
+import { render, mount } from 'enzyme';
+
+import { EuiColorStops } from './color_stops';
+
+import {
+ VISUALIZATION_COLORS,
+ DEFAULT_VISUALIZATION_COLOR,
+ keyCodes,
+} from '../../../services';
+import { requiredProps, findTestSubject } from '../../../test';
+
+jest.mock('../../portal', () => ({
+ // @ts-ignore
+ EuiPortal: ({ children }) => children,
+}));
+
+const onChange = jest.fn();
+
+const colorStopsArray = [
+ { stop: 0, color: '#FF0000' },
+ { stop: 25, color: '#00FF00' },
+ { stop: 35, color: '#0000FF' },
+];
+
+// Note: A couple tests that would be nice, but can't be accomplished at the moment:
+// - Tab to bypass thumbs (tabindex="-1" not respected)
+// - Drag to reposition thumb (we can't get real page position info)
+
+test('renders EuiColorStops', () => {
+ const colorStops = render(
+
+ );
+ expect(colorStops).toMatchSnapshot();
+});
+
+test('renders compressed EuiColorStops', () => {
+ const colorStops = render(
+
+ );
+ expect(colorStops).toMatchSnapshot();
+});
+
+test('renders readOnly EuiColorStops', () => {
+ const colorStops = render(
+
+ );
+ expect(colorStops).toMatchSnapshot();
+});
+
+test('renders fullWidth EuiColorStops', () => {
+ const colorStops = render(
+
+ );
+ expect(colorStops).toMatchSnapshot();
+});
+
+test('renders disabled EuiColorStops', () => {
+ const colorStops = render(
+
+ );
+ expect(colorStops).toMatchSnapshot();
+});
+
+test('renders fixed stop EuiColorStops', () => {
+ const colorStops = render(
+
+ );
+ expect(colorStops).toMatchSnapshot();
+});
+
+test('renders empty EuiColorStops', () => {
+ const colorStops = render(
+
+ );
+ expect(colorStops).toMatchSnapshot();
+});
+
+test('popover color selector is shown when the thumb is clicked', () => {
+ const colorStops = mount(
+
+ );
+
+ findTestSubject(colorStops, 'euiColorStopThumb')
+ .first()
+ .simulate('click');
+ const colorSelector = findTestSubject(colorStops, 'euiColorStopPopover');
+ expect(colorSelector.length).toBe(1);
+});
+
+test('stop input updates stops', () => {
+ const colorStops = mount(
+
+ );
+
+ findTestSubject(colorStops, 'euiColorStopThumb')
+ .first()
+ .simulate('click');
+ const event = { target: { value: '10' } };
+ const inputs = colorStops.find('input[type="number"]');
+ expect(inputs.length).toBe(1);
+ inputs.simulate('change', event);
+ expect(onChange).toBeCalled();
+ expect(onChange).toBeCalledWith(
+ [
+ { color: '#FF0000', stop: 10 },
+ { color: '#00FF00', stop: 25 },
+ { color: '#0000FF', stop: 35 },
+ ],
+ false
+ );
+});
+
+test('stop input updates stops with error prevention (reset to bounds)', () => {
+ const colorStops = mount(
+
+ );
+
+ findTestSubject(colorStops, 'euiColorStopThumb')
+ .first()
+ .simulate('click');
+ const event = { target: { value: '1000' } };
+ const inputs = colorStops.find('input[type="number"]');
+ inputs.simulate('change', event);
+ expect(onChange).toBeCalled();
+ expect(onChange).toBeCalledWith(
+ [
+ { color: '#FF0000', stop: 100 },
+ { color: '#00FF00', stop: 25 },
+ { color: '#0000FF', stop: 35 },
+ ],
+ false
+ );
+});
+
+test('hex input updates stops', () => {
+ const colorStops = mount(
+
+ );
+
+ findTestSubject(colorStops, 'euiColorStopThumb')
+ .first()
+ .simulate('click');
+ const event = { target: { value: '#FFFFFF' } };
+ const inputs = colorStops.find('input[type="text"]');
+ expect(inputs.length).toBe(1);
+ inputs.simulate('change', event);
+ expect(onChange).toBeCalled();
+ expect(onChange).toBeCalledWith(
+ [
+ { color: '#FFFFFF', stop: 0 },
+ { color: '#00FF00', stop: 25 },
+ { color: '#0000FF', stop: 35 },
+ ],
+ false
+ );
+});
+
+test('hex input updates stops with error', () => {
+ const colorStops = mount(
+
+ );
+
+ findTestSubject(colorStops, 'euiColorStopThumb')
+ .first()
+ .simulate('click');
+ const event = { target: { value: '#FFFFF' } };
+ const inputs = colorStops.find('input[type="text"]');
+ inputs.simulate('change', event);
+ expect(onChange).toBeCalled();
+ expect(onChange).toBeCalledWith(
+ [
+ { color: '#FFFFF', stop: 0 },
+ { color: '#00FF00', stop: 25 },
+ { color: '#0000FF', stop: 35 },
+ ],
+ true // isInvalid
+ );
+});
+
+test('picker updates stops', () => {
+ const colorStops = mount(
+
+ );
+
+ findTestSubject(colorStops, 'euiColorStopThumb')
+ .first()
+ .simulate('click');
+ const swatches = colorStops.find('button.euiColorPicker__swatchSelect');
+ expect(swatches.length).toBe(VISUALIZATION_COLORS.length);
+ swatches.first().simulate('click');
+ expect(onChange).toBeCalled();
+ expect(onChange).toBeCalledWith(
+ [
+ { color: VISUALIZATION_COLORS[0], stop: 0 },
+ { color: '#00FF00', stop: 25 },
+ { color: '#0000FF', stop: 35 },
+ ],
+ false
+ );
+});
+
+test('thumb focus changes', () => {
+ const colorStops = mount(
+
+ );
+
+ const wrapper = findTestSubject(colorStops, 'euiColorStops');
+ const thumbs = findTestSubject(colorStops, 'euiColorStopThumb');
+ wrapper.simulate('focus');
+ wrapper.simulate('keydown', {
+ keyCode: keyCodes.DOWN,
+ });
+ expect(thumbs.first().getDOMNode()).toEqual(document.activeElement);
+ thumbs.first().simulate('keydown', {
+ keyCode: keyCodes.DOWN,
+ });
+ expect(thumbs.at(1).getDOMNode()).toEqual(document.activeElement);
+});
+
+test('thumb direction movement', () => {
+ const colorStops = mount(
+
+ );
+
+ const wrapper = findTestSubject(colorStops, 'euiColorStops');
+ const thumbs = findTestSubject(colorStops, 'euiColorStopThumb');
+ wrapper.simulate('focus');
+ wrapper.simulate('keydown', {
+ keyCode: keyCodes.DOWN,
+ });
+ expect(thumbs.first().getDOMNode()).toEqual(document.activeElement);
+ thumbs.first().simulate('keydown', {
+ keyCode: keyCodes.RIGHT,
+ });
+ expect(onChange).toBeCalledWith(
+ [
+ { color: '#FF0000', stop: 1 },
+ { color: '#00FF00', stop: 25 },
+ { color: '#0000FF', stop: 35 },
+ ],
+ false
+ );
+ thumbs.first().simulate('keydown', {
+ keyCode: keyCodes.LEFT,
+ });
+ expect(onChange).toBeCalledWith(
+ [
+ { color: '#FF0000', stop: 0 },
+ { color: '#00FF00', stop: 25 },
+ { color: '#0000FF', stop: 35 },
+ ],
+ false
+ );
+});
+
+test('add new thumb via keyboard', () => {
+ const colorStops = mount(
+
+ );
+
+ const wrapper = findTestSubject(colorStops, 'euiColorStops');
+ wrapper.simulate('focus');
+ wrapper.simulate('keydown', {
+ keyCode: keyCodes.ENTER,
+ });
+ expect(onChange).toBeCalled();
+ expect(onChange).toBeCalledWith(
+ [
+ { color: '#FF0000', stop: 0 },
+ { color: '#00FF00', stop: 25 },
+ { color: '#0000FF', stop: 35 },
+ { color: DEFAULT_VISUALIZATION_COLOR, stop: 45 },
+ ],
+ false
+ );
+});
+
+test('add new thumb via click', () => {
+ const colorStops = mount(
+
+ );
+
+ const wrapper = findTestSubject(colorStops, 'euiColorStopsAdd');
+ wrapper.simulate('click', { pageX: 45, pageY: 0 });
+ expect(onChange).toBeCalled();
+ // This is a very odd expecation.
+ // But we can't get actual page positions in this environment (no getBoundingClientRect)
+ // So we'll expect the _correct_ color and _incorrect_ stop value (NaN),
+ // with the `isInvalid` arg _correctly_ true as a result.
+ expect(onChange).toBeCalledWith(
+ [
+ { color: '#FF0000', stop: 0 },
+ { color: '#00FF00', stop: 25 },
+ { color: '#0000FF', stop: 35 },
+ { color: DEFAULT_VISUALIZATION_COLOR, stop: NaN },
+ ],
+ true // isInvalid
+ );
+});
diff --git a/src/components/color_picker/color_stops/color_stops.tsx b/src/components/color_picker/color_stops/color_stops.tsx
new file mode 100644
index 00000000000..07b72517f5e
--- /dev/null
+++ b/src/components/color_picker/color_stops/color_stops.tsx
@@ -0,0 +1,351 @@
+import React, { FunctionComponent, useEffect, useMemo, useState } from 'react';
+import classNames from 'classnames';
+
+import { CommonProps } from '../../common';
+import { keyCodes, DEFAULT_VISUALIZATION_COLOR } from '../../../services';
+import { EuiColorStopThumb, ColorStop } from './color_stop_thumb';
+import {
+ addStop,
+ addDefinedStop,
+ getPositionFromStop,
+ getStopFromMouseLocation,
+ isInvalid,
+ removeStop,
+} from './utils';
+
+import { EuiColorPickerProps } from '../';
+import { EuiI18n } from '../../i18n';
+import { EuiRangeHighlight } from '../../form/range/range_highlight';
+import { EuiRangeTrack } from '../../form/range/range_track';
+import { EuiRangeWrapper } from '../../form/range/range_wrapper';
+import { EuiScreenReaderOnly } from '../../accessibility';
+
+export interface EuiColorStopsProps extends CommonProps {
+ addColor?: ColorStop['color'];
+ colorStops: ColorStop[];
+ onChange: (stops?: ColorStop[], isInvalid?: boolean) => void;
+ fullWidth?: boolean;
+ disabled?: boolean;
+ readOnly?: boolean;
+ invalid?: boolean;
+ compressed?: boolean;
+ className?: string;
+ max: number;
+ min: number;
+ label: string;
+ stopType?: 'fixed' | 'gradient';
+ mode?: EuiColorPickerProps['mode'];
+ swatches?: EuiColorPickerProps['swatches'];
+}
+
+// Because of how the thumbs are rendered in the popover, using ref results in an infinite loop.
+// We'll instead use old fashioned namespaced DOM selectors to get references
+const STOP_ATTR = 'euiColorStop_';
+
+function isTargetAThumb(target: HTMLElement | EventTarget) {
+ const element = target as HTMLElement;
+ const attr = element.getAttribute('data-index');
+ return attr && attr.indexOf(STOP_ATTR) > -1;
+}
+
+function sortStops(colorStops: ColorStop[]) {
+ return colorStops
+ .map((el, index) => {
+ return {
+ ...el,
+ id: index,
+ };
+ })
+ .sort((a, b) => a.stop - b.stop);
+}
+
+export const EuiColorStops: FunctionComponent = ({
+ addColor = DEFAULT_VISUALIZATION_COLOR,
+ max,
+ min,
+ mode = 'default',
+ colorStops,
+ onChange,
+ disabled,
+ readOnly,
+ compressed,
+ fullWidth,
+ className,
+ label,
+ stopType = 'gradient',
+ swatches,
+}) => {
+ const sortedStops = useMemo(() => sortStops(colorStops), [colorStops]);
+ const [hasFocus, setHasFocus] = useState(false);
+ const [focusedStopIndex, setFocusedStopIndex] = useState(null);
+ const [wrapperRef, setWrapperRef] = useState(null);
+ const [addTargetPosition, setAddTargetPosition] = useState(0);
+ const [isHoverDisabled, setIsHoverDisabled] = useState(false);
+ const [focusStopOnUpdate, setFocusStopOnUpdate] = useState(
+ null
+ );
+
+ useEffect(() => {
+ if (focusStopOnUpdate !== null) {
+ const toFocus = sortedStops.map(el => el.stop).indexOf(focusStopOnUpdate);
+ onFocusStop(toFocus);
+ setFocusStopOnUpdate(null);
+ }
+ }, [sortedStops]);
+
+ const isNotInteractive = disabled || readOnly;
+
+ const classes = classNames(
+ 'euiColorStops',
+ {
+ 'euiColorStops-isDragging': isHoverDisabled,
+ 'euiColorStops-isDisabled': disabled,
+ 'euiColorStops-isReadOnly': readOnly,
+ },
+ className
+ );
+
+ const getStopFromMouseLocationFn = (location: { x: number; y: number }) => {
+ // Guard against `null` ref in usage
+ return getStopFromMouseLocation(location, wrapperRef!, min, max);
+ };
+
+ const getPositionFromStopFn = (stop: ColorStop['stop']) => {
+ // Guard against `null` ref in usage
+ return getPositionFromStop(stop, wrapperRef!, min, max);
+ };
+
+ const handleOnChange = (colorStops: ColorStop[]) => {
+ onChange(colorStops, isInvalid(colorStops));
+ };
+
+ const handleStopChange = (stop: ColorStop, id: number) => {
+ const newColorStops = [...colorStops];
+ newColorStops.splice(id, 1, stop);
+ handleOnChange(newColorStops);
+ };
+
+ const onFocusStop = (index: number) => {
+ if (disabled || !wrapperRef) return;
+ const toFocus = wrapperRef.querySelector(
+ `[data-index=${STOP_ATTR}${index}]`
+ );
+ if (toFocus) {
+ setHasFocus(false);
+ setFocusedStopIndex(index);
+ toFocus.focus();
+ }
+ };
+
+ const onFocusWrapper = () => {
+ setFocusedStopIndex(null);
+ if (wrapperRef) {
+ wrapperRef.focus();
+ }
+ };
+
+ const onAdd = () => {
+ const stops = sortedStops.map(el => {
+ return {
+ color: el.color,
+ stop: el.stop,
+ };
+ });
+ const newColorStops = addStop(stops, addColor, max);
+
+ setFocusStopOnUpdate(newColorStops[colorStops.length].stop);
+ handleOnChange(newColorStops);
+ };
+
+ const onRemove = (index: number) => {
+ const newColorStops = removeStop(colorStops, index);
+
+ onFocusWrapper();
+ handleOnChange(newColorStops);
+ };
+
+ const handleAddHover = (e: React.MouseEvent) => {
+ if (isNotInteractive || !wrapperRef) return;
+ const stop = getStopFromMouseLocationFn({ x: e.pageX, y: e.pageY });
+ const position = getPositionFromStopFn(stop);
+
+ setAddTargetPosition(position);
+ };
+
+ const handleAddClick = (e: React.MouseEvent) => {
+ if (isNotInteractive || isTargetAThumb(e.target) || !wrapperRef) return;
+ const newStop = getStopFromMouseLocationFn({ x: e.pageX, y: e.pageY });
+ const newColorStops = addDefinedStop(colorStops, newStop, addColor);
+
+ setFocusStopOnUpdate(newStop);
+ handleOnChange(newColorStops);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (disabled) return;
+ switch (e.keyCode) {
+ case keyCodes.ESCAPE:
+ onFocusWrapper();
+ break;
+
+ case keyCodes.ENTER:
+ if (readOnly || !hasFocus) return;
+ onAdd();
+ break;
+
+ case keyCodes.BACKSPACE:
+ if (readOnly || hasFocus || focusedStopIndex == null) return;
+ if (isTargetAThumb(e.target)) {
+ const index = sortedStops[focusedStopIndex].id;
+ onRemove(index);
+ }
+ break;
+
+ case keyCodes.DOWN:
+ if (e.target === wrapperRef || isTargetAThumb(e.target)) {
+ e.preventDefault();
+ if (focusedStopIndex == null) {
+ onFocusStop(0);
+ } else {
+ const next =
+ focusedStopIndex === sortedStops.length - 1
+ ? focusedStopIndex
+ : focusedStopIndex + 1;
+ onFocusStop(next);
+ }
+ }
+ break;
+
+ case keyCodes.UP:
+ if (e.target === wrapperRef || isTargetAThumb(e.target)) {
+ e.preventDefault();
+ if (focusedStopIndex == null) {
+ onFocusStop(0);
+ } else {
+ const next =
+ focusedStopIndex === 0 ? focusedStopIndex : focusedStopIndex - 1;
+ onFocusStop(next);
+ }
+ }
+ break;
+ }
+ };
+
+ const thumbs = sortedStops.map((colorStop, index) => (
+ 1 ? () => onRemove(colorStop.id) : undefined
+ }
+ onChange={stop => handleStopChange(stop, colorStop.id)}
+ onFocus={() => setFocusedStopIndex(index)}
+ parentRef={wrapperRef}
+ colorPickerMode={mode}
+ colorPickerSwatches={swatches}
+ disabled={disabled}
+ readOnly={readOnly}
+ aria-valuetext={`Stop: ${colorStop.stop}, Color: ${
+ colorStop.color
+ } (${index + 1} of ${colorStops.length})`}
+ />
+ ));
+
+ const positions = wrapperRef
+ ? sortedStops.map(({ stop }) => getPositionFromStopFn(stop))
+ : [];
+ const gradientStop = (colorStop: ColorStop, index: number) => {
+ return `${colorStop.color} ${positions[index]}%`;
+ };
+ const fixedStop = (colorStop: ColorStop, index: number) => {
+ if (index === 0) {
+ return `${colorStop.color}, ${gradientStop(colorStop, index + 1)}`;
+ } else if (index === sortedStops.length - 1) {
+ return gradientStop(colorStop, index);
+ } else {
+ return `${gradientStop(colorStop, index)}, ${gradientStop(
+ colorStop,
+ index + 1
+ )}`;
+ }
+ };
+ const linearGradient = sortedStops.map(
+ stopType === 'gradient' ? gradientStop : fixedStop
+ );
+ const singleColor = sortedStops[0] ? sortedStops[0].color : undefined;
+ const background =
+ sortedStops.length > 1
+ ? `linear-gradient(to right,${linearGradient})`
+ : singleColor;
+
+ return (
+ !disabled && setIsHoverDisabled(true)}
+ onMouseUp={() => !disabled && setIsHoverDisabled(false)}
+ onMouseLeave={() => !disabled && setIsHoverDisabled(false)}
+ onKeyDown={handleKeyDown}
+ onFocus={e => {
+ if (e.target === wrapperRef) {
+ setHasFocus(true);
+ }
+ }}
+ onBlur={() => setHasFocus(false)}>
+
+
+
+
+
+
+
+
+ {thumbs}
+
+
+ );
+};
diff --git a/src/components/color_picker/color_stops/index.ts b/src/components/color_picker/color_stops/index.ts
new file mode 100644
index 00000000000..db5c3481b1c
--- /dev/null
+++ b/src/components/color_picker/color_stops/index.ts
@@ -0,0 +1 @@
+export { EuiColorStops, EuiColorStopsProps } from './color_stops';
diff --git a/src/components/color_picker/color_stops/utils.test.ts b/src/components/color_picker/color_stops/utils.test.ts
new file mode 100644
index 00000000000..1b59f2953d1
--- /dev/null
+++ b/src/components/color_picker/color_stops/utils.test.ts
@@ -0,0 +1,106 @@
+import { addStop, addDefinedStop, removeStop, isInvalid } from './utils';
+
+const colorStops = [
+ { stop: 0, color: '#FF0000' },
+ { stop: 25, color: '#00FF00' },
+ { stop: 35, color: '#0000FF' },
+];
+
+describe('isInvalid', () => {
+ test('Should not mark valid colorStops as invalid', () => {
+ expect(isInvalid(colorStops)).toBe(false);
+ });
+
+ test('Should mark colorStops missing color as invalid', () => {
+ const colorStops = [{ stop: 0, color: '' }];
+ expect(isInvalid(colorStops)).toBe(true);
+ });
+
+ test('Should mark colorStops with invalid color as invalid', () => {
+ const colorStops = [{ stop: 0, color: 'not color' }];
+ expect(isInvalid(colorStops)).toBe(true);
+ });
+
+ test('Should mark colorStops missing stop as invalid', () => {
+ const colorStops = [{ stop: null, color: '#FF0000' }];
+ // Intentionally wrong
+ // @ts-ignore
+ expect(isInvalid(colorStops)).toBe(true);
+ });
+
+ test('Should mark colorStops with invalid stop as invalid', () => {
+ const colorStops = [{ stop: 'I am not a number', color: '#FF0000' }];
+ // Intentionally wrong
+ // @ts-ignore
+ expect(isInvalid(colorStops)).toBe(true);
+ });
+});
+
+describe('addStop', () => {
+ test('Should add stop when there is only a single stop', () => {
+ const colorStops = [{ stop: 0, color: '#FF0000' }];
+ expect(addStop(colorStops, '#FF0000', 100)).toEqual([
+ { stop: 0, color: '#FF0000' },
+ { stop: 1, color: '#FF0000' },
+ ]);
+ });
+
+ test('Should add stop to end of list', () => {
+ expect(addStop(colorStops, '#FF0000', 100)).toEqual([
+ { stop: 0, color: '#FF0000' },
+ { stop: 25, color: '#00FF00' },
+ { stop: 35, color: '#0000FF' },
+ { stop: 45, color: '#FF0000' },
+ ]);
+ });
+
+ test('Should add stop below the max if max is taken', () => {
+ expect(
+ addStop(
+ [{ stop: 0, color: '#FF0000' }, { stop: 100, color: '#FF0000' }],
+ '#FF0000',
+ 100
+ )
+ ).toEqual([
+ { stop: 0, color: '#FF0000' },
+ { stop: 100, color: '#FF0000' },
+ { stop: 99, color: '#FF0000' },
+ ]);
+ });
+});
+
+describe('addDefinedStop', () => {
+ const colorStops = [{ stop: 0, color: '#FF0000' }];
+ test('Should add stop', () => {
+ expect(addDefinedStop(colorStops, 1)).toEqual([
+ { stop: 0, color: '#FF0000' },
+ { stop: 1, color: '#3185FC' },
+ ]);
+ });
+
+ test('Should add stop with a specified color', () => {
+ expect(addDefinedStop(colorStops, 1, '#FFFFFF')).toEqual([
+ { stop: 0, color: '#FF0000' },
+ { stop: 1, color: '#FFFFFF' },
+ ]);
+ });
+});
+
+describe('removeStop', () => {
+ test('Should not remove only stop', () => {
+ const colorStops = [{ stop: 0, color: '#FF0000' }];
+ expect(removeStop(colorStops, 0)).toEqual(colorStops);
+ });
+
+ test('Should remove stop at index', () => {
+ const colorStops = [
+ { stop: 0, color: '#FF0000' },
+ { stop: 25, color: '#00FF00' },
+ { stop: 35, color: '#0000FF' },
+ ];
+ expect(removeStop(colorStops, 1)).toEqual([
+ { stop: 0, color: '#FF0000' },
+ { stop: 35, color: '#0000FF' },
+ ]);
+ });
+});
diff --git a/src/components/color_picker/color_stops/utils.ts b/src/components/color_picker/color_stops/utils.ts
new file mode 100644
index 00000000000..c8e94ad822e
--- /dev/null
+++ b/src/components/color_picker/color_stops/utils.ts
@@ -0,0 +1,106 @@
+import { getEventPosition } from '../utils';
+import { isValidHex, DEFAULT_VISUALIZATION_COLOR } from '../../../services';
+import { ColorStop } from './color_stop_thumb';
+
+const EUI_THUMB_SIZE = 16; // Same as $euiRangeThumbHeight & $euiRangeThumbWidth
+
+export const removeStop = (colorStops: ColorStop[], index: number) => {
+ if (colorStops.length === 1) {
+ return colorStops;
+ }
+
+ return [...colorStops.slice(0, index), ...colorStops.slice(index + 1)];
+};
+
+export const addDefinedStop = (
+ colorStops: ColorStop[],
+ stop: ColorStop['stop'],
+ color: ColorStop['color'] = DEFAULT_VISUALIZATION_COLOR
+) => {
+ const newStop = {
+ stop,
+ color,
+ };
+ return [...colorStops, newStop];
+};
+
+export const addStop = (
+ colorStops: ColorStop[],
+ color: ColorStop['color'] = DEFAULT_VISUALIZATION_COLOR,
+ max: number
+) => {
+ const index = colorStops.length - 1;
+ const stops = colorStops.map(el => el.stop);
+ const currentStop = stops[index];
+ let delta = 1;
+ if (index !== 0) {
+ const prevStop = stops[index - 1];
+ delta = currentStop - prevStop;
+ }
+
+ let stop = currentStop + delta;
+
+ if (stop > max) {
+ stop = max;
+ }
+
+ // We've reached the max, so start working backwards
+ while (stops.indexOf(stop) > -1) {
+ stop--;
+ }
+
+ const newStop = {
+ stop,
+ color,
+ };
+ return [
+ ...colorStops.slice(0, index + 1),
+ newStop,
+ ...colorStops.slice(index + 1),
+ ];
+};
+
+export const isColorInvalid = (color: string) => {
+ return !isValidHex(color) || color === '';
+};
+
+export const isStopInvalid = (stop: ColorStop['stop']) => {
+ return stop == null || isNaN(stop);
+};
+
+export const isInvalid = (colorStops: ColorStop[]) => {
+ return colorStops.some(colorStop => {
+ return isColorInvalid(colorStop.color) || isStopInvalid(colorStop.stop);
+ });
+};
+
+export const calculateScale = (trackWidth: number) => {
+ const thumbToTrackRatio = EUI_THUMB_SIZE / trackWidth;
+ return (1 - thumbToTrackRatio) * 100;
+};
+
+export const getStopFromMouseLocation = (
+ location: { x: number; y: number },
+ ref: HTMLDivElement,
+ min: number,
+ max: number
+) => {
+ const box = getEventPosition(location, ref);
+ return Math.round((box.left / box.width) * (max - min) + min);
+};
+
+export const getPositionFromStop = (
+ stop: ColorStop['stop'],
+ ref: HTMLDivElement,
+ min: number,
+ max: number
+) => {
+ // For wide implementations, integer percentages can be visually off.
+ // Use 1 decimal place for more accuracy
+ return parseFloat(
+ (
+ ((stop - min) / (max - min)) *
+ calculateScale(ref ? ref.clientWidth : 100)
+ ).toFixed(1)
+ );
+};
diff --git a/src/components/color_picker/index.ts b/src/components/color_picker/index.ts
index 382690c914e..102816c0855 100644
--- a/src/components/color_picker/index.ts
+++ b/src/components/color_picker/index.ts
@@ -1,4 +1,5 @@
-export { EuiColorPicker } from './color_picker';
+export { EuiColorPicker, EuiColorPickerProps } from './color_picker';
export { EuiColorPickerSwatch } from './color_picker_swatch';
export { EuiHue } from './hue';
export { EuiSaturation } from './saturation';
+export { EuiColorStops } from './color_stops';
diff --git a/src/components/color_picker/saturation.tsx b/src/components/color_picker/saturation.tsx
index d96fe3e0073..1bff224ee7d 100644
--- a/src/components/color_picker/saturation.tsx
+++ b/src/components/color_picker/saturation.tsx
@@ -1,8 +1,6 @@
import React, {
HTMLAttributes,
KeyboardEvent,
- MouseEvent as ReactMouseEvent,
- TouchEvent,
forwardRef,
useEffect,
useRef,
@@ -17,13 +15,7 @@ import { isNil } from '../../services/predicate';
import { EuiScreenReaderOnly } from '../accessibility';
import { EuiI18n } from '../i18n';
-import { getEventPosition, throttle } from './utils';
-
-function isMouseEvent(
- event: ReactMouseEvent | TouchEvent
-): event is ReactMouseEvent {
- return typeof event === 'object' && 'pageX' in event && 'pageY' in event;
-}
+import { getEventPosition, useMouseMove } from './utils';
export type SaturationClientRect = Pick<
ClientRect,
@@ -82,11 +74,6 @@ export const EuiSaturation = forwardRef(
}
}, [color]);
- useEffect(() => {
- // Mimic `componentWillUnmount`
- return unbindEventListeners;
- }, []);
-
const calculateColor = ({
top,
height,
@@ -113,30 +100,10 @@ export const EuiSaturation = forwardRef(
const box = getEventPosition(location, boxRef.current);
handleUpdate(box);
};
- const handleInteraction = (
- e: ReactMouseEvent | TouchEvent
- ) => {
- if (e && boxRef.current) {
- const x = isMouseEvent(e) ? e.pageX : e.touches[0].pageX;
- const y = isMouseEvent(e) ? e.pageY : e.touches[0].pageY;
- handleChange({ x, y });
- }
- };
- const handleMouseMove = throttle((e: MouseEvent) => {
- handleChange({ x: e.pageX, y: e.pageY });
- });
- const handleMouseUp = () => {
- unbindEventListeners();
- };
- const handleMouseDown = (e: ReactMouseEvent) => {
- handleInteraction(e);
- document.addEventListener('mousemove', handleMouseMove);
- document.addEventListener('mouseup', handleMouseUp);
- };
- const unbindEventListeners = () => {
- document.removeEventListener('mousemove', handleMouseMove);
- document.removeEventListener('mouseup', handleMouseUp);
- };
+ const [handleMouseDown, handleInteraction] = useMouseMove(
+ handleChange,
+ boxRef.current
+ );
const handleKeyDown = (e: KeyboardEvent) => {
if (isNil(boxRef) || isNil(boxRef.current)) {
return;
diff --git a/src/components/color_picker/utils.ts b/src/components/color_picker/utils.ts
index 7716d1a8dc0..c47c72047fe 100644
--- a/src/components/color_picker/utils.ts
+++ b/src/components/color_picker/utils.ts
@@ -1,3 +1,5 @@
+import { MouseEvent as ReactMouseEvent, TouchEvent, useEffect } from 'react';
+
export const getEventPosition = (
location: { x: number; y: number },
container: HTMLElement
@@ -31,3 +33,50 @@ export const throttle = (fn: (...args: any[]) => void, wait = 50) => {
}
};
};
+
+export function isMouseEvent(
+ event: ReactMouseEvent | TouchEvent
+): event is ReactMouseEvent {
+ return typeof event === 'object' && 'pageX' in event && 'pageY' in event;
+}
+
+export function useMouseMove(
+ handleChange: (
+ location: { x: number; y: number },
+ isFirstInteraction?: boolean
+ ) => void,
+ interactionConditional: any = true
+): [
+ (e: ReactMouseEvent) => void,
+ (e: ReactMouseEvent | TouchEvent, isFirstInteraction?: boolean) => void
+] {
+ useEffect(() => {
+ return unbindEventListeners;
+ }, []);
+ const handleInteraction = (
+ e: ReactMouseEvent | TouchEvent,
+ isFirstInteraction?: boolean
+ ) => {
+ if (e) {
+ if (interactionConditional) {
+ const x = isMouseEvent(e) ? e.pageX : e.touches[0].pageX;
+ const y = isMouseEvent(e) ? e.pageY : e.touches[0].pageY;
+ handleChange({ x, y }, isFirstInteraction);
+ }
+ }
+ };
+ const handleMouseMove = throttle((e: ReactMouseEvent) => {
+ handleChange({ x: e.pageX, y: e.pageY }, false);
+ });
+ const handleMouseDown = (e: ReactMouseEvent) => {
+ handleInteraction(e, true);
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', unbindEventListeners);
+ };
+ const unbindEventListeners = () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', unbindEventListeners);
+ };
+
+ return [handleMouseDown, handleInteraction];
+}
diff --git a/src/components/form/range/range_highlight.tsx b/src/components/form/range/range_highlight.tsx
index a93839f3d4e..e4eea407dd5 100644
--- a/src/components/form/range/range_highlight.tsx
+++ b/src/components/form/range/range_highlight.tsx
@@ -2,6 +2,7 @@ import React, { FunctionComponent } from 'react';
import classNames from 'classnames';
export interface EuiRangeHighlightProps {
+ background?: string;
compressed?: boolean;
hasFocus?: boolean;
showTicks?: boolean;
@@ -9,6 +10,7 @@ export interface EuiRangeHighlightProps {
upperValue: number;
max: number;
min: number;
+ onClick?: (e: React.MouseEvent) => void;
}
export const EuiRangeHighlight: FunctionComponent = ({
@@ -19,12 +21,15 @@ export const EuiRangeHighlight: FunctionComponent = ({
max,
min,
compressed,
+ background,
+ onClick,
}) => {
// Calculate the width the range based on value
// const rangeWidth = (value - min) / (max - min);
const leftPosition = (lowerValue - min) / (max - min);
const rangeWidth = (upperValue - lowerValue) / (max - min);
const rangeWidthStyle = {
+ background,
marginLeft: `${leftPosition * 100}%`,
width: `${rangeWidth * 100}%`,
};
@@ -39,7 +44,7 @@ export const EuiRangeHighlight: FunctionComponent = ({
});
return (
-
+
);
diff --git a/src/components/form/range/range_thumb.tsx b/src/components/form/range/range_thumb.tsx
index 1bcfa532644..e1b7c0196ff 100644
--- a/src/components/form/range/range_thumb.tsx
+++ b/src/components/form/range/range_thumb.tsx
@@ -1,40 +1,61 @@
import React, { FunctionComponent, HTMLAttributes } from 'react';
import classNames from 'classnames';
-import { CommonProps } from '../../common';
+import { CommonProps, ExclusiveUnion, Omit } from '../../common';
-export type EuiRangeThumbProps = HTMLAttributes
&
- CommonProps & {
- min: number;
- max: number;
- value?: number | string;
- disabled?: boolean;
- showInput?: boolean;
- showTicks?: boolean;
- };
+interface BaseProps extends CommonProps {
+ min: number;
+ max: number;
+ value?: number | string;
+ disabled?: boolean;
+ showInput?: boolean;
+ showTicks?: boolean;
+}
+
+interface ButtonLike extends BaseProps, HTMLAttributes {}
+interface DivLike
+ extends BaseProps,
+ Omit, 'onClick'> {}
+
+export type EuiRangeThumbProps = ExclusiveUnion;
export const EuiRangeThumb: FunctionComponent = ({
+ className,
min,
max,
value,
disabled,
showInput,
showTicks,
+ onClick,
+ tabIndex,
...rest
}) => {
- const classes = classNames('euiRangeThumb', {
- 'euiRangeThumb--hasTicks': showTicks,
- });
- return (
- }
/>
+ ) : (
+
} />
);
};
diff --git a/src/components/form/range/range_wrapper.tsx b/src/components/form/range/range_wrapper.tsx
index 8179bab0239..16bc0a9fc7f 100644
--- a/src/components/form/range/range_wrapper.tsx
+++ b/src/components/form/range/range_wrapper.tsx
@@ -1,26 +1,29 @@
-import React, { FunctionComponent } from 'react';
+import React, { HTMLAttributes, forwardRef } from 'react';
import classNames from 'classnames';
+import { CommonProps } from '../../common';
-export interface EuiRangeWrapperProps {
- className?: string;
+export interface EuiRangeWrapperProps
+ extends CommonProps,
+ HTMLAttributes
{
fullWidth?: boolean;
compressed?: boolean;
}
-export const EuiRangeWrapper: FunctionComponent = ({
- children,
- className,
- fullWidth,
- compressed,
-}) => {
- const classes = classNames(
- 'euiRangeWrapper',
- {
- 'euiRangeWrapper--fullWidth': fullWidth,
- 'euiRangeWrapper--compressed': compressed,
- },
- className
- );
+export const EuiRangeWrapper = forwardRef(
+ ({ children, className, fullWidth, compressed, ...rest }, ref) => {
+ const classes = classNames(
+ 'euiRangeWrapper',
+ {
+ 'euiRangeWrapper--fullWidth': fullWidth,
+ 'euiRangeWrapper--compressed': compressed,
+ },
+ className
+ );
- return {children}
;
-};
+ return (
+
+ {children}
+
+ );
+ }
+);
diff --git a/src/components/index.js b/src/components/index.js
index ece50e9899e..fd38b68bb40 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -29,6 +29,7 @@ export { EuiCodeEditor } from './code_editor';
export {
EuiColorPicker,
EuiColorPickerSwatch,
+ EuiColorStops,
EuiHue,
EuiSaturation,
} from './color_picker';