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';