From c5e414e6ba89389884439829da905663251b6e20 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Mon, 16 Sep 2019 13:01:48 +0300 Subject: [PATCH] Color picker component (#4136) --- client/app/components/ColorBox.jsx | 19 +-- client/app/components/ColorPicker/Input.jsx | 93 +++++++++++++ client/app/components/ColorPicker/Swatch.jsx | 37 +++++ client/app/components/ColorPicker/index.jsx | 128 ++++++++++++++++++ client/app/components/ColorPicker/index.less | 40 ++++++ client/app/components/ColorPicker/input.less | 19 +++ client/app/components/ColorPicker/swatch.less | 30 ++++ client/app/components/color-box.less | 19 +-- package.json | 1 + 9 files changed, 357 insertions(+), 29 deletions(-) create mode 100644 client/app/components/ColorPicker/Input.jsx create mode 100644 client/app/components/ColorPicker/Swatch.jsx create mode 100644 client/app/components/ColorPicker/index.jsx create mode 100644 client/app/components/ColorPicker/index.less create mode 100644 client/app/components/ColorPicker/input.less create mode 100644 client/app/components/ColorPicker/swatch.less diff --git a/client/app/components/ColorBox.jsx b/client/app/components/ColorBox.jsx index 73b3f3681e..dd83c3f49a 100644 --- a/client/app/components/ColorBox.jsx +++ b/client/app/components/ColorBox.jsx @@ -1,23 +1,12 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +// ANGULAR_REMOVE_ME import { react2angular } from 'react2angular'; -import './color-box.less'; - -export function ColorBox({ color }) { - return ; -} +import ColorPicker from '@/components/ColorPicker'; -ColorBox.propTypes = { - color: PropTypes.string, -}; - -ColorBox.defaultProps = { - color: 'transparent', -}; +import './color-box.less'; export default function init(ngModule) { - ngModule.component('colorBox', react2angular(ColorBox)); + ngModule.component('colorBox', react2angular(ColorPicker.Swatch)); } init.init = true; diff --git a/client/app/components/ColorPicker/Input.jsx b/client/app/components/ColorPicker/Input.jsx new file mode 100644 index 0000000000..89af2ec5af --- /dev/null +++ b/client/app/components/ColorPicker/Input.jsx @@ -0,0 +1,93 @@ +import { isNil, isArray, chunk, map, filter, toPairs } from 'lodash'; +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import tinycolor from 'tinycolor2'; +import TextInput from 'antd/lib/input'; +import Typography from 'antd/lib/typography'; +import Swatch from './Swatch'; + +import './input.less'; + +function preparePresets(presetColors, presetColumns) { + presetColors = isArray(presetColors) ? map(presetColors, v => [null, v]) : toPairs(presetColors); + presetColors = map(presetColors, ([title, value]) => { + if (isNil(value)) { + return [title, null]; + } + value = tinycolor(value); + if (value.isValid()) { + return [title, '#' + value.toHex().toUpperCase()]; + } + return null; + }); + return chunk(filter(presetColors), presetColumns); +} + +function validateColor(value, callback, prefix = '#') { + if (isNil(value)) { + callback(null); + } + value = tinycolor(value); + if (value.isValid()) { + callback(prefix + value.toHex().toUpperCase()); + } +} + +export default function Input({ color, presetColors, presetColumns, onChange, onPressEnter }) { + const [inputValue, setInputValue] = useState(''); + const [isInputFocused, setIsInputFocused] = useState(false); + + const presets = preparePresets(presetColors, presetColumns); + + function handleInputChange(value) { + setInputValue(value); + validateColor(value, onChange); + } + + useEffect(() => { + if (!isInputFocused) { + validateColor(color, setInputValue, ''); + } + }, [color, isInputFocused]); + + return ( + + {map(presets, (group, index) => ( +
+ {map(group, ([title, value]) => ( + validateColor(value, onChange)} /> + ))} +
+ ))} +
+ #} + value={inputValue} + onChange={e => handleInputChange(e.target.value)} + onFocus={() => setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + onPressEnter={onPressEnter} + /> +
+
+ ); +} + +Input.propTypes = { + color: PropTypes.string, + presetColors: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.string), // array of colors (no tooltips) + PropTypes.objectOf(PropTypes.string), // color name => color value + ]), + presetColumns: PropTypes.number, + onChange: PropTypes.func, + onPressEnter: PropTypes.func, +}; + +Input.defaultProps = { + color: '#FFFFFF', + presetColors: null, + presetColumns: 8, + onChange: () => {}, + onPressEnter: () => {}, +}; diff --git a/client/app/components/ColorPicker/Swatch.jsx b/client/app/components/ColorPicker/Swatch.jsx new file mode 100644 index 0000000000..f0b510b612 --- /dev/null +++ b/client/app/components/ColorPicker/Swatch.jsx @@ -0,0 +1,37 @@ +import { isString } from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import Tooltip from 'antd/lib/tooltip'; + +import './swatch.less'; + +export default function Swatch({ className, color, title, size, ...props }) { + const result = ( + + ); + + if (isString(title) && (title !== '')) { + return ( + {result} + ); + } + return result; +} + +Swatch.propTypes = { + className: PropTypes.string, + title: PropTypes.string, + color: PropTypes.string, + size: PropTypes.number, +}; + +Swatch.defaultProps = { + className: '', + title: null, + color: 'transparent', + size: 12, +}; diff --git a/client/app/components/ColorPicker/index.jsx b/client/app/components/ColorPicker/index.jsx new file mode 100644 index 0000000000..946c3a5c33 --- /dev/null +++ b/client/app/components/ColorPicker/index.jsx @@ -0,0 +1,128 @@ +import { toString } from 'lodash'; +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import tinycolor from 'tinycolor2'; +import Popover from 'antd/lib/popover'; +import Card from 'antd/lib/card'; +import Tooltip from 'antd/lib/tooltip'; +import Icon from 'antd/lib/icon'; + +import ColorInput from './Input'; +import Swatch from './Swatch'; + +import './index.less'; + +function validateColor(value, fallback = null) { + value = tinycolor(value); + return value.isValid() ? '#' + value.toHex().toUpperCase() : fallback; +} + +export default function ColorPicker({ + color, placement, presetColors, presetColumns, triggerSize, interactive, children, onChange, +}) { + const [visible, setVisible] = useState(false); + const [currentColor, setCurrentColor] = useState(''); + + function handleApply() { + setVisible(false); + if (!interactive) { + onChange(currentColor); + } + } + + function handleCancel() { + setVisible(false); + } + + const actions = []; + if (!interactive) { + actions.push(( + + + + )); + actions.push(( + + + + )); + } + + function handleInputChange(newColor) { + setCurrentColor(newColor); + if (interactive) { + onChange(newColor); + } + } + + useEffect(() => { + if (visible) { + setCurrentColor(validateColor(color)); + } + }, [color, visible]); + + return ( + + + + )} + trigger="click" + placement={placement} + visible={visible} + onVisibleChange={setVisible} + > + {children || ()} + + ); +} + +ColorPicker.propTypes = { + color: PropTypes.string, + placement: PropTypes.oneOf([ + 'top', 'left', 'right', 'bottom', + 'topLeft', 'topRight', 'bottomLeft', 'bottomRight', + 'leftTop', 'leftBottom', 'rightTop', 'rightBottom', + ]), + presetColors: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.string), // array of colors (no tooltips) + PropTypes.objectOf(PropTypes.string), // color name => color value + ]), + presetColumns: PropTypes.number, + triggerSize: PropTypes.number, + interactive: PropTypes.bool, + children: PropTypes.node, + onChange: PropTypes.func, +}; + +ColorPicker.defaultProps = { + color: '#FFFFFF', + placement: 'top', + presetColors: null, + presetColumns: 8, + triggerSize: 30, + interactive: false, + children: null, + onChange: () => {}, +}; + +ColorPicker.Input = ColorInput; +ColorPicker.Swatch = Swatch; diff --git a/client/app/components/ColorPicker/index.less b/client/app/components/ColorPicker/index.less new file mode 100644 index 0000000000..00bf5768e2 --- /dev/null +++ b/client/app/components/ColorPicker/index.less @@ -0,0 +1,40 @@ +.color-picker { + &.color-picker-with-actions { + &.ant-popover-placement-top, + &.ant-popover-placement-topLeft, + &.ant-popover-placement-topRight, + &.ant-popover-placement-leftBottom, + &.ant-popover-placement-rightBottom { + > .ant-popover-content > .ant-popover-arrow { + border-color: #fafafa; // same as card actions + } + } + } + + &.ant-popover-placement-bottom, + &.ant-popover-placement-bottomLeft, + &.ant-popover-placement-bottomRight, + &.ant-popover-placement-leftTop, + &.ant-popover-placement-rightTop { + > .ant-popover-content > .ant-popover-arrow { + border-color: var(--color-picker-selected-color); + } + } + + .ant-popover-inner-content { + padding: 0; + } + + .ant-card-head { + text-align: center; + border-bottom-color: rgba(0, 0, 0, 0.1); + } + + .ant-card-body { + padding: 10px; + } +} + +.color-picker-trigger { + cursor: pointer; +} diff --git a/client/app/components/ColorPicker/input.less b/client/app/components/ColorPicker/input.less new file mode 100644 index 0000000000..56f9d7ec58 --- /dev/null +++ b/client/app/components/ColorPicker/input.less @@ -0,0 +1,19 @@ +.color-picker-input-swatches { + margin: 0 0 10px 0; + text-align: left; + white-space: nowrap; + + .color-swatch { + cursor: pointer; + margin: 0 10px 0 0; + + &:last-child { + margin-right: 0; + } + } +} + +.color-picker-input { + text-align: left; + white-space: nowrap; +} diff --git a/client/app/components/ColorPicker/swatch.less b/client/app/components/ColorPicker/swatch.less new file mode 100644 index 0000000000..4dea312c44 --- /dev/null +++ b/client/app/components/ColorPicker/swatch.less @@ -0,0 +1,30 @@ +.color-swatch { + display: inline-block; + box-sizing: border-box; + vertical-align: middle; + border-radius: 2px; + overflow: hidden; + width: 12px; + + @cell-size: 12px; + @cell-color: rgba(0, 0, 0, 0.1); + + background-color: transparent; + background-image: + linear-gradient(45deg, @cell-color 25%, transparent 25%), + linear-gradient(-45deg, @cell-color 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, @cell-color 75%), + linear-gradient(-45deg, transparent 75%, @cell-color 75%); + background-size: @cell-size @cell-size; + background-position: 0 0, 0 @cell-size/2, @cell-size/2 -@cell-size/2, -@cell-size/2 0px; + + &:before { + content: ""; + display: block; + padding-top: ~"calc(100% - 2px)"; + background-color: inherit; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 2px; + overflow: hidden; + } +} diff --git a/client/app/components/color-box.less b/client/app/components/color-box.less index 7a23fd1308..d1bac3afbf 100644 --- a/client/app/components/color-box.less +++ b/client/app/components/color-box.less @@ -1,19 +1,10 @@ -@import '../assets/less/inc/variables'; - +// ANGULAR_REMOVE_ME color-box { vertical-align: text-bottom; - display: inline; + display: inline-block; + margin-right: 5px; - span { - width: 12px !important; - height: 12px !important; - display: inline-block !important; - margin-right: 5px; - vertical-align: middle; - border: 1px solid rgba(0,0,0,0.1); - } - & ~ span { + & ~ span { vertical-align: bottom; - color: @input-color; - } + } } diff --git a/package.json b/package.json index c30a8f3b5c..eb01a227bb 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "react-grid-layout": "git+https://github.com/getredash/react-grid-layout.git", "react-sortable-hoc": "^1.9.1", "react2angular": "^3.2.1", + "tinycolor2": "^1.4.1", "ui-select": "^0.19.8" }, "devDependencies": {