From 59173562dec87a379a3d22926afdcf951f7060f6 Mon Sep 17 00:00:00 2001 From: Danny Date: Sun, 27 Nov 2022 15:16:18 +0000 Subject: [PATCH] feat: re-add knobs to provide compatibility --- .eslintignore | 1 + addons/ondevice-controls/README.md | 2 +- addons/ondevice-knobs/README.md | 30 ++ addons/ondevice-knobs/package.json | 54 ++ addons/ondevice-knobs/register.js | 1 + addons/ondevice-knobs/src/GroupTabs.js | 72 +++ addons/ondevice-knobs/src/PropField.js | 50 ++ addons/ondevice-knobs/src/PropForm.js | 56 ++ .../src/components/RadioSelect.js | 91 ++++ .../color-picker/HoloColorPicker.js | 333 ++++++++++++ .../color-picker/TriangleColorPicker.js | 486 ++++++++++++++++++ .../src/components/color-picker/index.d.ts | 29 ++ .../src/components/color-picker/index.js | 3 + .../color-picker/resources/color-circle.png | Bin 0 -> 21485 bytes .../color-picker/resources/color-circle.xcf | Bin 0 -> 53281 bytes .../resources/hsv_triangle_mask.png | Bin 0 -> 15005 bytes .../src/components/color-picker/utils.js | 69 +++ addons/ondevice-knobs/src/index.js | 17 + addons/ondevice-knobs/src/panel.js | 203 ++++++++ addons/ondevice-knobs/src/types/Array.js | 57 ++ addons/ondevice-knobs/src/types/Boolean.js | 38 ++ addons/ondevice-knobs/src/types/Button.js | 31 ++ addons/ondevice-knobs/src/types/Color.js | 104 ++++ addons/ondevice-knobs/src/types/Date.js | 107 ++++ addons/ondevice-knobs/src/types/Number.js | 114 ++++ addons/ondevice-knobs/src/types/Object.js | 102 ++++ addons/ondevice-knobs/src/types/Radio.js | 52 ++ addons/ondevice-knobs/src/types/Select.js | 74 +++ addons/ondevice-knobs/src/types/Text.js | 41 ++ addons/ondevice-knobs/src/types/index.js | 23 + .../components/OnDeviceUI/OnDeviceUI.tsx | 6 +- examples/native/.storybook/main.js | 1 + .../components/KnobsExample/KnobsExample.js | 34 ++ .../KnobsExample/KnobsExample.stories.js | 65 +++ examples/native/package.json | 1 + yarn.lock | 94 +++- 36 files changed, 2431 insertions(+), 10 deletions(-) create mode 100644 addons/ondevice-knobs/README.md create mode 100644 addons/ondevice-knobs/package.json create mode 100644 addons/ondevice-knobs/register.js create mode 100644 addons/ondevice-knobs/src/GroupTabs.js create mode 100644 addons/ondevice-knobs/src/PropField.js create mode 100644 addons/ondevice-knobs/src/PropForm.js create mode 100644 addons/ondevice-knobs/src/components/RadioSelect.js create mode 100644 addons/ondevice-knobs/src/components/color-picker/HoloColorPicker.js create mode 100644 addons/ondevice-knobs/src/components/color-picker/TriangleColorPicker.js create mode 100644 addons/ondevice-knobs/src/components/color-picker/index.d.ts create mode 100644 addons/ondevice-knobs/src/components/color-picker/index.js create mode 100644 addons/ondevice-knobs/src/components/color-picker/resources/color-circle.png create mode 100644 addons/ondevice-knobs/src/components/color-picker/resources/color-circle.xcf create mode 100644 addons/ondevice-knobs/src/components/color-picker/resources/hsv_triangle_mask.png create mode 100644 addons/ondevice-knobs/src/components/color-picker/utils.js create mode 100644 addons/ondevice-knobs/src/index.js create mode 100644 addons/ondevice-knobs/src/panel.js create mode 100644 addons/ondevice-knobs/src/types/Array.js create mode 100644 addons/ondevice-knobs/src/types/Boolean.js create mode 100644 addons/ondevice-knobs/src/types/Button.js create mode 100644 addons/ondevice-knobs/src/types/Color.js create mode 100644 addons/ondevice-knobs/src/types/Date.js create mode 100644 addons/ondevice-knobs/src/types/Number.js create mode 100644 addons/ondevice-knobs/src/types/Object.js create mode 100644 addons/ondevice-knobs/src/types/Radio.js create mode 100644 addons/ondevice-knobs/src/types/Select.js create mode 100644 addons/ondevice-knobs/src/types/Text.js create mode 100644 addons/ondevice-knobs/src/types/index.js create mode 100644 examples/native/components/KnobsExample/KnobsExample.js create mode 100644 examples/native/components/KnobsExample/KnobsExample.stories.js diff --git a/.eslintignore b/.eslintignore index 90e4766b47..6615a08f85 100644 --- a/.eslintignore +++ b/.eslintignore @@ -26,3 +26,4 @@ examples/native/index.html storybook.requires.js jest.config.js app/react-native/scripts/mocks +addons/ondevice-knobs \ No newline at end of file diff --git a/addons/ondevice-controls/README.md b/addons/ondevice-controls/README.md index d10ea18968..86426a8884 100644 --- a/addons/ondevice-controls/README.md +++ b/addons/ondevice-controls/README.md @@ -26,4 +26,4 @@ The [web Controls Addon documentation](https://storybook.js.org/docs/react/essen ## Migrating from Knobs -See [examples for migrating from Knobs to Controls](https://github.com/storybookjs/storybook/tree/next/addons/controls#how-do-i-migrate-from-addon-knobs) in the web Controls Addon README. +See [examples for migrating from Knobs to Controls](https://github.com/storybookjs/storybook/blob/next/code/addons/controls/README.md#how-do-i-migrate-from-addon-knobs) in the web Controls Addon README. diff --git a/addons/ondevice-knobs/README.md b/addons/ondevice-knobs/README.md new file mode 100644 index 0000000000..244ec8bcfd --- /dev/null +++ b/addons/ondevice-knobs/README.md @@ -0,0 +1,30 @@ +# Storybook Knobs Addon for react-native + +This addon is depcrecated in favour of controls, please only use it for transitioning to CSF, Args and Controls + +See [examples for migrating from Knobs to Controls](https://github.com/storybookjs/storybook/blob/next/code/addons/controls/README.md#how-do-i-migrate-from-addon-knobs) in the web Controls Addon README. + +Storybook Knobs Addon allows you to edit react props using the Storybook UI using variables inside stories in [Storybook](https://storybook.js.org). + +[Framework Support](https://github.com/storybookjs/storybook/blob/master/ADDONS_SUPPORT.md) + +**This is a wrapper for the addon @storybook/addon-knobs Refer to its documentation to understand how to use knobs** + +## Installation + +```sh +yarn add -D @storybook/addon-ondevice-knobs @storybook/addon-knobs @react-native-community/datetimepicker @react-native-community/slider +``` + +## Configuration + +Add following content to `.storybook/main.js`: + +```js +module.exports = { + addons: ['@storybook/addon-ondevice-knobs'], +}; +``` + +Make sure to use the withKnobs decorator when using knobs `import { withKnobs } from '@storybook/addon-ondevice-knobs';` + diff --git a/addons/ondevice-knobs/package.json b/addons/ondevice-knobs/package.json new file mode 100644 index 0000000000..445b8f6518 --- /dev/null +++ b/addons/ondevice-knobs/package.json @@ -0,0 +1,54 @@ +{ + "name": "@storybook/addon-ondevice-knobs", + "version": "6.0.1-beta.10", + "description": "Display storybook story knobs on your deviced.", + "keywords": [ + "addon", + "knobs", + "ondevice", + "react-native", + "storybook" + ], + "repository": { + "type": "git", + "url": "https://github.com/storybookjs/react-native.git", + "directory": "addons/ondevice-knobs" + }, + "license": "MIT", + "main": "dist/index.js", + "files": [ + "dist/**/*", + "docs/**/*", + "README.md", + "*.js", + "*.d.ts" + ], + "scripts": { + "prepare": "node ../../scripts/prepare.js" + }, + "dependencies": { + "@emotion/native": "^10.0.14", + "@storybook/addons": "^6.5.3", + "@storybook/core-events": "^6.5.3", + "core-js": "^3.0.1", + "deep-equal": "^1.0.1", + "prop-types": "^15.7.2", + "react-native-modal-datetime-picker": "^14.0.0", + "react-native-modal-selector": "^2.1.1", + "tinycolor2": "^1.4.1" + }, + "devDependencies": { + "@storybook/addon-knobs": "^6" + }, + "peerDependencies": { + "@react-native-community/datetimepicker": "*", + "@react-native-community/slider": "*", + "@storybook/addon-knobs": "^6", + "react": "*", + "react-native": "*" + }, + "publishConfig": { + "access": "public" + }, + "gitHead": "4b9d901add9452525135caae98ae5f78dd8da9ff" +} diff --git a/addons/ondevice-knobs/register.js b/addons/ondevice-knobs/register.js new file mode 100644 index 0000000000..66453c4caf --- /dev/null +++ b/addons/ondevice-knobs/register.js @@ -0,0 +1 @@ +require('./dist/index').register(); diff --git a/addons/ondevice-knobs/src/GroupTabs.js b/addons/ondevice-knobs/src/GroupTabs.js new file mode 100644 index 0000000000..b45c0dde92 --- /dev/null +++ b/addons/ondevice-knobs/src/GroupTabs.js @@ -0,0 +1,72 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { ScrollView, Text, TouchableOpacity } from 'react-native'; +import styled from '@emotion/native'; + +const Label = styled.Text(({ theme, active }) => ({ + color: active ? theme.buttonActiveTextColor || '#444444' : theme.buttonTextColor || '#999999', + fontSize: 17, +})); + +class GroupTabs extends Component { + renderTab(name, group) { + let { title } = group; + if (typeof title === 'function') { + title = title(); + } + + const { onGroupSelect, selectedGroup } = this.props; + + return ( + onGroupSelect(name)} + > + + + ); + } + + render() { + const { groups } = this.props; + + const entries = groups ? Object.entries(groups) : null; + + return entries && entries.length ? ( + + {entries.map(([key, value]) => this.renderTab(key, value))} + + ) : ( + no groups available + ); + } +} + +GroupTabs.defaultProps = { + groups: {}, + onGroupSelect: () => {}, + selectedGroup: null, +}; + +GroupTabs.propTypes = { + // eslint-disable-next-line react/forbid-prop-types + groups: PropTypes.object, + onGroupSelect: PropTypes.func, + selectedGroup: PropTypes.string, +}; + +export default GroupTabs; diff --git a/addons/ondevice-knobs/src/PropField.js b/addons/ondevice-knobs/src/PropField.js new file mode 100644 index 0000000000..063d46a466 --- /dev/null +++ b/addons/ondevice-knobs/src/PropField.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import { View, Text } from 'react-native'; +import React from 'react'; +import styled from '@emotion/native'; +import TypeMap from './types'; + +const InvalidType = () => Invalid Type; + +const Label = styled.Text(({ theme }) => ({ + marginLeft: 10, + fontSize: 14, + color: theme.labelColor || 'black', + fontWeight: 'bold', +})); + +const PropField = ({ onChange, onPress, knob }) => { + const InputType = TypeMap[knob.type] || InvalidType; + + return ( + + {!knob.hideLabel ? : null} + + + ); +}; + +PropField.propTypes = { + knob: PropTypes.shape({ + name: PropTypes.string, + label: PropTypes.string, + value: PropTypes.any, + hideLabel: PropTypes.bool, + type: PropTypes.oneOf([ + 'text', + 'number', + 'color', + 'boolean', + 'object', + 'select', + 'array', + 'date', + 'button', + 'radios', + ]), + }).isRequired, + onChange: PropTypes.func.isRequired, + onPress: PropTypes.func.isRequired, +}; + +export default PropField; diff --git a/addons/ondevice-knobs/src/PropForm.js b/addons/ondevice-knobs/src/PropForm.js new file mode 100644 index 0000000000..c9be0459fc --- /dev/null +++ b/addons/ondevice-knobs/src/PropForm.js @@ -0,0 +1,56 @@ +/* eslint no-underscore-dangle: 0 */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { View } from 'react-native'; +import PropField from './PropField'; + +export default class PropForm extends React.Component { + makeChangeHandler(name, type) { + return (value) => { + const { onFieldChange } = this.props; + const change = { name, type, value }; + onFieldChange(change); + }; + } + + render() { + const { knobs, onFieldClick } = this.props; + + return ( + + {knobs.map((knob) => { + const changeHandler = this.makeChangeHandler(knob.name, knob.type); + return ( + + ); + })} + + ); + } +} + +PropForm.displayName = 'PropForm'; + +PropForm.defaultProps = { + knobs: [], +}; + +PropForm.propTypes = { + knobs: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string, + value: PropTypes.any, + }) + ), + onFieldChange: PropTypes.func.isRequired, + onFieldClick: PropTypes.func.isRequired, +}; diff --git a/addons/ondevice-knobs/src/components/RadioSelect.js b/addons/ondevice-knobs/src/components/RadioSelect.js new file mode 100644 index 0000000000..3d71875370 --- /dev/null +++ b/addons/ondevice-knobs/src/components/RadioSelect.js @@ -0,0 +1,91 @@ +import styled from '@emotion/native'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { StyleSheet } from 'react-native'; + +const RadioContainer = styled.View(({ isInline }) => ({ + flexDirection: isInline ? 'row' : 'column', + alignItems: isInline ? 'center' : 'flex-start', + flexWrap: 'wrap', + margin: 10, +})); + +const RadioTouchable = styled.TouchableOpacity(() => ({ + marginRight: 8, + alignItems: 'center', + flexDirection: 'row', + padding: 4, +})); + +const RadioCircle = styled.View(({ theme }) => ({ + width: 16, + height: 16, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + marginRight: 4, + borderWidth: StyleSheet.hairlineWidth, + borderColor: theme.borderColor || '#e6e6e6', +})); + +const RadioInnerCircle = styled.View(({ selected }) => ({ + position: 'absolute', + height: 12, + width: 12, + borderRadius: 6, + backgroundColor: selected ? '#66bf3c' : 'transparent', +})); + +const RadioLabel = styled.Text(() => ({ + fontSize: 13, +})); + +class RadioSelect extends React.Component { + constructor(props) { + super(props); + + const { initValue } = this.props; + + this.state = { + value: initValue, + }; + } + + render() { + const { data, onChange } = this.props; + const { value } = this.state; + return ( + + {data.map((item) => ( + { + onChange(item); + this.setState({ value: item.key }); + }} + > + + + + {item.label} + + ))} + + ); + } +} + +export default RadioSelect; + +RadioSelect.defaultProps = { + data: [], + onChange: (value) => value, + initValue: '', +}; + +RadioSelect.propTypes = { + data: PropTypes.arrayOf(PropTypes.object), + initValue: PropTypes.string, + onChange: PropTypes.func, +}; diff --git a/addons/ondevice-knobs/src/components/color-picker/HoloColorPicker.js b/addons/ondevice-knobs/src/components/color-picker/HoloColorPicker.js new file mode 100644 index 0000000000..fb4c4e1d4b --- /dev/null +++ b/addons/ondevice-knobs/src/components/color-picker/HoloColorPicker.js @@ -0,0 +1,333 @@ +/* eslint-disable react/require-default-props */ +/* eslint-disable global-require */ +/* eslint-disable react/destructuring-assignment */ +/* eslint-disable no-underscore-dangle */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + TouchableOpacity, + Slider, + View, + Image, + StyleSheet, + InteractionManager, + I18nManager, +} from 'react-native'; +import tinycolor from 'tinycolor2'; +import { createPanResponder } from './utils'; + +// TODO: Raise PR at react-native-color-picker with these fixes + +export class HoloColorPicker extends React.PureComponent { + constructor(props, ctx) { + super(props, ctx); + const state = { + color: { h: 0, s: 1, v: 1 }, + pickerSize: null, + }; + if (props.oldColor) { + state.color = tinycolor(props.oldColor).toHsv(); + } + if (props.defaultColor) { + state.color = tinycolor(props.defaultColor).toHsv(); + } + this.state = state; + this._layout = { width: 0, height: 0, x: 0, y: 0 }; + this._pageX = 0; + this._pageY = 0; + this._onLayout = this._onLayout.bind(this); + this._onSValueChange = this._onSValueChange.bind(this); + this._onVValueChange = this._onVValueChange.bind(this); + this._onColorSelected = this._onColorSelected.bind(this); + this._onOldColorSelected = this._onOldColorSelected.bind(this); + this._isRTL = I18nManager.isRTL; + this._pickerResponder = createPanResponder({ + onStart: this._handleColorChange, + onMove: this._handleColorChange, + }); + + this.pickerContainer = React.createRef(); + } + + getColor() { + return tinycolor(this._getColor()).toHexString(); + } + + _handleColorChange = ({ x, y }) => { + const { s, v } = this._getColor(); + const marginLeft = (this._layout.width - this.state.pickerSize) / 2; + const marginTop = (this._layout.height - this.state.pickerSize) / 2; + const relativeX = x - this._pageX - marginLeft; + const relativeY = y - this._pageY - marginTop; + const h = this._computeHValue(relativeX, relativeY); + this._onColorChange({ h, s, v }); + }; + + _onSValueChange(s) { + const { h, v } = this._getColor(); + this._onColorChange({ h, s, v }); + } + + _onVValueChange(v) { + const { h, s } = this._getColor(); + this._onColorChange({ h, s, v }); + } + + _onColorChange(color) { + this.setState({ color }); + if (this.props.onColorChange) { + this.props.onColorChange(color); + } + } + + _onLayout(l) { + this._layout = l.nativeEvent.layout; + const { width, height } = this._layout; + const pickerSize = Math.min(width, height); + if (this.state.pickerSize !== pickerSize) { + this.setState({ pickerSize }); + } + // layout.x, layout.y is always 0 + // we always measure because layout is the same even though picker is moved on the page + InteractionManager.runAfterInteractions( + () => + // measure only after (possible) animation ended + this.pickerContainer.current && + this.pickerContainer.current.measure((_x, _y, _width, _height, pageX, pageY) => { + // picker position in the screen + this._pageX = pageX; + this._pageY = pageY; + }) + ); + } + + _getColor() { + const passedColor = + typeof this.props.color === 'string' ? tinycolor(this.props.color).toHsv() : this.props.color; + return passedColor || this.state.color; + } + + _onColorSelected() { + const { onColorSelected } = this.props; + const color = tinycolor(this._getColor()).toHexString(); + if (onColorSelected) onColorSelected(color); + } + + _onOldColorSelected() { + const { oldColor, onOldColorSelected } = this.props; + const color = tinycolor(oldColor); + this.setState({ color: color.toHsv() }); + if (onOldColorSelected) onOldColorSelected(color.toHexString()); + } + + _computeHValue(x, y) { + const mx = this.state.pickerSize / 2; + const my = this.state.pickerSize / 2; + const dx = x - mx; + const dy = y - my; + const rad = Math.atan2(dx, dy) + Math.PI + Math.PI / 2; + return ((rad * 180) / Math.PI) % 360; + } + + _hValueToRad(deg) { + const rad = (deg * Math.PI) / 180; + return rad - Math.PI - Math.PI / 2; + } + + _getSlider() { + if (this.props.hideSliders) { + return undefined; + } + if (this.props.sliderComponent) { + return this.props.sliderComponent; + } + if (!Slider) { + throw new Error( + 'You need to install `@react-native-community/slider` and pass it (or any other Slider compatible component) as `sliderComponent` prop' + ); + } + return Slider; + } + + render() { + const { pickerSize } = this.state; + const { oldColor, style } = this.props; + const color = this._getColor(); + const { h, s, v } = color; + const angle = this._hValueToRad(h); + const selectedColor = tinycolor(color).toHexString(); + const indicatorColor = tinycolor({ h, s: 1, v: 1 }).toHexString(); + const computed = makeComputedStyles({ + pickerSize, + selectedColor, + indicatorColor, + oldColor, + angle, + isRTL: this._isRTL, + }); + const SliderComp = this._getSlider(); + return ( + + + {!pickerSize ? null : ( + + + + + + {oldColor && ( + + )} + {oldColor && ( + + )} + {!oldColor && ( + + )} + + )} + + {this.props.hideSliders ? null : ( + + + + + )} + + ); + } +} + +HoloColorPicker.propTypes = { + color: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ h: PropTypes.number, s: PropTypes.number, v: PropTypes.number }), + ]), + defaultColor: PropTypes.string, + oldColor: PropTypes.string, + onColorChange: PropTypes.func, + onColorSelected: PropTypes.func, + onOldColorSelected: PropTypes.func, + hideSliders: PropTypes.bool, + sliderComponent: PropTypes.elementType, + // eslint-disable-next-line react/forbid-prop-types + style: PropTypes.any, +}; + +const makeComputedStyles = ({ + indicatorColor, + selectedColor, + oldColor, + angle, + pickerSize, + isRTL, +}) => { + const summarySize = 0.5 * pickerSize; + const indicatorPickerRatio = 42 / 510; // computed from picker image + const indicatorSize = indicatorPickerRatio * pickerSize; + const pickerPadding = indicatorSize / 3; + const indicatorRadius = pickerSize / 2 - indicatorSize / 2 - pickerPadding; + const mx = pickerSize / 2; + const my = pickerSize / 2; + const dx = Math.cos(angle) * indicatorRadius; + const dy = Math.sin(angle) * indicatorRadius; + return { + picker: { + padding: pickerPadding, + width: pickerSize, + height: pickerSize, + }, + pickerIndicator: { + top: mx + dx - indicatorSize / 2, + [isRTL ? 'right' : 'left']: my + dy - indicatorSize / 2, + width: indicatorSize, + height: indicatorSize, + borderRadius: indicatorSize / 2, + backgroundColor: indicatorColor, + }, + selectedPreview: { + width: summarySize / 2, + height: summarySize, + top: pickerSize / 2 - summarySize / 2, + left: Math.floor(pickerSize / 2), + borderTopRightRadius: summarySize / 2, + borderBottomRightRadius: summarySize / 2, + backgroundColor: selectedColor, + }, + originalPreview: { + width: Math.ceil(summarySize / 2), + height: summarySize, + top: pickerSize / 2 - summarySize / 2, + left: pickerSize / 2 - summarySize / 2, + borderTopLeftRadius: summarySize / 2, + borderBottomLeftRadius: summarySize / 2, + backgroundColor: oldColor, + }, + selectedFullPreview: { + width: summarySize, + height: summarySize, + top: pickerSize / 2 - summarySize / 2, + left: pickerSize / 2 - summarySize / 2, + borderRadius: summarySize / 2, + backgroundColor: selectedColor, + }, + }; +}; + +const styles = StyleSheet.create({ + pickerContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + pickerImage: { + flex: 1, + width: null, + height: null, + }, + pickerIndicator: { + position: 'absolute', + // Shadow only works on iOS. + shadowColor: 'black', + shadowOpacity: 0.3, + shadowOffset: { width: 3, height: 3 }, + shadowRadius: 4, + + // This will elevate the view on Android, causing shadow to be drawn. + elevation: 5, + }, + selectedPreview: { + position: 'absolute', + borderLeftWidth: 0, + }, + originalPreview: { + position: 'absolute', + borderRightWidth: 0, + }, + selectedFullPreview: { + position: 'absolute', + }, + pickerAlignment: { + alignItems: 'center', + }, +}); diff --git a/addons/ondevice-knobs/src/components/color-picker/TriangleColorPicker.js b/addons/ondevice-knobs/src/components/color-picker/TriangleColorPicker.js new file mode 100644 index 0000000000..5264f3cf79 --- /dev/null +++ b/addons/ondevice-knobs/src/components/color-picker/TriangleColorPicker.js @@ -0,0 +1,486 @@ +/* eslint-disable react/require-default-props */ +/* eslint-disable global-require */ +/* eslint-disable react/destructuring-assignment */ +/* eslint-disable no-underscore-dangle */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + TouchableOpacity, + View, + Image, + StyleSheet, + InteractionManager, + I18nManager, +} from 'react-native'; +import tinycolor from 'tinycolor2'; +import { createPanResponder, rotatePoint } from './utils'; + +function makeRotationKey(props, angle) { + const { rotationHackFactor } = props; + if (rotationHackFactor < 1) { + return undefined; + } + const key = Math.floor(angle * rotationHackFactor); + return `r${key}`; +} + +export class TriangleColorPicker extends React.PureComponent { + constructor(props, ctx) { + super(props, ctx); + const state = { + color: { h: 0, s: 1, v: 1 }, + pickerSize: null, + }; + if (props.oldColor) { + state.color = tinycolor(props.oldColor).toHsv(); + } + if (props.defaultColor) { + state.color = tinycolor(props.defaultColor).toHsv(); + } + this.state = state; + this._layout = { width: 0, height: 0, x: 0, y: 0 }; + this._pageX = 0; + this._pageY = 0; + this._onLayout = this._onLayout.bind(this); + this._onSValueChange = this._onSValueChange.bind(this); + this._onVValueChange = this._onVValueChange.bind(this); + this._onColorSelected = this._onColorSelected.bind(this); + this._onOldColorSelected = this._onOldColorSelected.bind(this); + this._isRTL = I18nManager.isRTL; + + this._pickerResponder = createPanResponder({ + onStart: ({ x, y }) => { + const { s, v } = this._computeColorFromTriangle({ x, y }); + this._changingHColor = s > 1 || s < 0 || v > 1 || v < 0; + this._handleColorChange({ x, y }); + }, + onMove: this._handleColorChange, + }); + + this.pickerContainer = React.createRef(); + } + + getColor() { + return tinycolor(this._getColor()).toHexString(); + } + + _handleColorChange = ({ x, y }) => { + if (this._changingHColor) { + this._handleHColorChange({ x, y }); + } else { + this._handleSVColorChange({ x, y }); + } + }; + + _getColor() { + const passedColor = + typeof this.props.color === 'string' ? tinycolor(this.props.color).toHsv() : this.props.color; + return passedColor || this.state.color; + } + + _onColorSelected() { + const { onColorSelected } = this.props; + const color = tinycolor(this._getColor()).toHexString(); + if (onColorSelected) onColorSelected(color); + } + + _onOldColorSelected() { + const { oldColor, onOldColorSelected } = this.props; + const color = tinycolor(oldColor); + this.setState({ color: color.toHsv() }); + if (onOldColorSelected) onOldColorSelected(color.toHexString()); + } + + _onSValueChange(s) { + const { h, v } = this._getColor(); + this._onColorChange({ h, s, v }); + } + + _onVValueChange(v) { + const { h, s } = this._getColor(); + this._onColorChange({ h, s, v }); + } + + _onColorChange(color) { + this.setState({ color }); + if (this.props.onColorChange) { + this.props.onColorChange(color); + } + } + + _onLayout(l) { + this._layout = l.nativeEvent.layout; + const { width, height } = this._layout; + const pickerSize = Math.min(width, height); + if (this.state.pickerSize !== pickerSize) { + this.setState({ pickerSize }); + } + // layout.x, layout.y is always 0 + // we always measure because layout is the same even though picker is moved on the page + InteractionManager.runAfterInteractions(() => { + // measure only after (possible) animation ended + if (this.pickerContainer.current) { + this.pickerContainer.current.measure((_x, _y, _width, _height, pageX, pageY) => { + // picker position in the screen + this._pageX = pageX; + this._pageY = pageY; + }); + } + }); + } + + _computeHValue(x, y) { + const mx = this.state.pickerSize / 2; + const my = this.state.pickerSize / 2; + const dx = x - mx; + const dy = y - my; + const rad = Math.atan2(dx, dy) + Math.PI + Math.PI / 2; + return ((rad * 180) / Math.PI) % 360; + } + + _hValueToRad(deg) { + const rad = (deg * Math.PI) / 180; + return rad - Math.PI - Math.PI / 2; + } + + _handleHColorChange({ x, y }) { + const { s, v } = this._getColor(); + const marginLeft = (this._layout.width - this.state.pickerSize) / 2; + const marginTop = (this._layout.height - this.state.pickerSize) / 2; + const relativeX = x - this._pageX - marginLeft; + const relativeY = y - this._pageY - marginTop; + const h = this._computeHValue(relativeX, relativeY); + this._onColorChange({ h, s, v }); + } + + _handleSVColorChange({ x, y }) { + const { h, s: rawS, v: rawV } = this._computeColorFromTriangle({ x, y }); + const s = Math.min(Math.max(0, rawS), 1); + const v = Math.min(Math.max(0, rawV), 1); + this._onColorChange({ h, s, v }); + } + + _normalizeTriangleTouch(s, v, sRatio) { + const CORNER_ZONE_SIZE = 0.12; // relative size to be considered as corner zone + const NORMAL_MARGIN = 0.1; // relative triangle margin to be considered as touch in triangle + const CORNER_MARGIN = 0.05; // relative triangle margin to be considered as touch in triangle in corner zone + let margin = NORMAL_MARGIN; + + const posNS = v > 0 ? 1 - (1 - s) * sRatio : 1 - s * sRatio; + const negNS = v > 0 ? s * sRatio : (1 - s) * sRatio; + const ns = s > 1 ? posNS : negNS; // normalized s value according to ratio and s value + + const rightCorner = s > 1 - CORNER_ZONE_SIZE && v > 1 - CORNER_ZONE_SIZE; + const leftCorner = ns < 0 + CORNER_ZONE_SIZE && v > 1 - CORNER_ZONE_SIZE; + const topCorner = ns < 0 + CORNER_ZONE_SIZE && v < 0 + CORNER_ZONE_SIZE; + if (rightCorner) { + return { s, v }; + } + if (leftCorner || topCorner) { + margin = CORNER_MARGIN; + } + let s1 = s; + let v1 = v; + // color normalization according to margin + s1 = s1 < 0 && ns > 0 - margin ? 0 : s1; + s1 = s1 > 1 && ns < 1 + margin ? 1 : s1; + v1 = v1 < 0 && v1 > 0 - margin ? 0 : v1; + v1 = v1 > 1 && v1 < 1 + margin ? 1 : v1; + return { s1, v1 }; + } + + /** + * Computes s, v from position (x, y). If position is outside of triangle, + * it will return invalid values (greater than 1 or lower than 0) + */ + _computeColorFromTriangle({ x, y }) { + const { pickerSize } = this.state; + const { triangleHeight, triangleWidth } = getPickerProperties(pickerSize); + + const left = pickerSize / 2 - triangleWidth / 2; + const top = pickerSize / 2 - (2 * triangleHeight) / 3; + + // triangle relative coordinates + const marginLeft = (this._layout.width - this.state.pickerSize) / 2; + const marginTop = (this._layout.height - this.state.pickerSize) / 2; + const relativeX = x - this._pageX - marginLeft - left; + const relativeY = y - this._pageY - marginTop - top; + + // rotation + const { h } = this._getColor(); + const deg = (h - 330 + 360) % 360; // starting angle is 330 due to comfortable calculation + const rad = (deg * Math.PI) / 180; + const center = { + x: triangleWidth / 2, + y: (2 * triangleHeight) / 3, + }; + const rotated = rotatePoint({ x: relativeX, y: relativeY }, rad, center); + + const line = (triangleWidth * rotated.y) / triangleHeight; + const margin = triangleWidth / 2 - ((triangleWidth / 2) * rotated.y) / triangleHeight; + const s = (rotated.x - margin) / line; + const v = rotated.y / triangleHeight; + + // normalize + const normalized = this._normalizeTriangleTouch(s, v, line / triangleHeight); + + return { h, s: normalized.s, v: normalized.v }; + } + + render() { + const { pickerSize } = this.state; + const { oldColor, style } = this.props; + const color = this._getColor(); + const { h } = color; + const angle = this._hValueToRad(h); + const selectedColor = tinycolor(color).toHexString(); + const indicatorColor = tinycolor({ h, s: 1, v: 1 }).toHexString(); + const computed = makeComputedStyles({ + pickerSize, + selectedColor, + selectedColorHsv: color, + indicatorColor, + oldColor, + angle, + isRTL: this._isRTL, + }); + // Hack for https://github.com/instea/react-native-color-picker/issues/17 + const rotationHack = makeRotationKey(this.props, angle); + return ( + + + {!pickerSize ? null : ( + + + + + + + + + + + + + + )} + + + {oldColor && ( + + )} + + + + ); + } +} + +TriangleColorPicker.propTypes = { + color: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ h: PropTypes.number, s: PropTypes.number, v: PropTypes.number }), + ]), + defaultColor: PropTypes.string, + oldColor: PropTypes.string, + onColorChange: PropTypes.func, + onColorSelected: PropTypes.func, + onOldColorSelected: PropTypes.func, + rotationHackFactor: PropTypes.number, + style: {}, +}; + +TriangleColorPicker.defaultProps = { + rotationHackFactor: 100, +}; + +function getPickerProperties(pickerSize) { + const indicatorPickerRatio = 42 / 510; // computed from picker image + const originalIndicatorSize = indicatorPickerRatio * pickerSize; + const indicatorSize = originalIndicatorSize; + const pickerPadding = originalIndicatorSize / 3; + + const triangleSize = pickerSize - 6 * pickerPadding; + const triangleRadius = triangleSize / 2; + const triangleHeight = (triangleRadius * 3) / 2; + const triangleWidth = 2 * triangleRadius * Math.sqrt(3 / 4); // pythagorean theorem + + return { + triangleSize, + triangleRadius, + triangleHeight, + triangleWidth, + indicatorPickerRatio, + indicatorSize, + pickerPadding, + }; +} + +const makeComputedStyles = ({ indicatorColor, angle, pickerSize, selectedColorHsv, isRTL }) => { + const { + triangleSize, + triangleHeight, + triangleWidth, + indicatorSize, + pickerPadding, + } = getPickerProperties(pickerSize); + + /* ===== INDICATOR ===== */ + const indicatorRadius = pickerSize / 2 - indicatorSize / 2 - pickerPadding; + const mx = pickerSize / 2; + const my = pickerSize / 2; + const dx = Math.cos(angle) * indicatorRadius; + const dy = Math.sin(angle) * indicatorRadius; + + /* ===== TRIANGLE ===== */ + const triangleTop = pickerPadding * 3; + const triangleLeft = pickerPadding * 3; + const triangleAngle = -angle + Math.PI / 3; + + /* ===== SV INDICATOR ===== */ + const markerColor = 'rgba(0,0,0,0.8)'; + const { s, v, h } = selectedColorHsv; + const svIndicatorSize = 18; + const svY = v * triangleHeight; + const margin = triangleWidth / 2 - v * (triangleWidth / 2); + const svX = s * (triangleWidth - 2 * margin) + margin; + const svIndicatorMarginLeft = (pickerSize - triangleWidth) / 2; + const svIndicatorMarginTop = (pickerSize - (4 * triangleHeight) / 3) / 2; + + const deg = (h - 330 + 360) % 360; // starting angle is 330 due to comfortable calculation + const rad = (deg * Math.PI) / 180; + const center = { x: pickerSize / 2, y: pickerSize / 2 }; + const notRotatedPoint = { + x: svIndicatorMarginTop + svY, + y: svIndicatorMarginLeft + svX, + }; + const svIndicatorPoint = rotatePoint(notRotatedPoint, rad, center); + + return { + picker: { + padding: pickerPadding, + width: pickerSize, + height: pickerSize, + }, + pickerIndicator: { + top: mx + dx - indicatorSize / 2, + [isRTL ? 'right' : 'left']: my + dy - indicatorSize / 2, + width: indicatorSize, + height: indicatorSize, + transform: [ + { + rotate: `${-angle}rad`, + }, + ], + }, + pickerIndicatorTick: { + height: indicatorSize / 2, + backgroundColor: markerColor, + }, + svIndicator: { + top: svIndicatorPoint.x - svIndicatorSize / 2, + [isRTL ? 'right' : 'left']: svIndicatorPoint.y - svIndicatorSize / 2, + width: svIndicatorSize, + height: svIndicatorSize, + borderRadius: svIndicatorSize / 2, + borderColor: markerColor, + }, + triangleContainer: { + width: triangleSize, + height: triangleSize, + transform: [ + { + rotate: `${triangleAngle}rad`, + }, + ], + top: triangleTop, + left: triangleLeft, + }, + triangleImage: { + width: triangleWidth, + height: triangleHeight, + }, + triangleUnderlayingColor: { + left: (triangleSize - triangleWidth) / 2, + borderLeftWidth: triangleWidth / 2, + borderRightWidth: triangleWidth / 2, + borderBottomWidth: triangleHeight, + borderBottomColor: indicatorColor, + }, + colorPreviews: { + height: pickerSize * 0.1, // responsive height + }, + }; +}; + +const styles = StyleSheet.create({ + pickerContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + pickerImage: { + flex: 1, + width: null, + height: null, + }, + pickerIndicator: { + position: 'absolute', + alignItems: 'center', + justifyContent: 'center', + }, + triangleContainer: { + position: 'absolute', + alignItems: 'center', + }, + triangleUnderlayingColor: { + position: 'absolute', + top: 0, + width: 0, + height: 0, + backgroundColor: 'transparent', + borderStyle: 'solid', + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + }, + pickerAlignment: { + alignItems: 'center', + }, + svIndicator: { + position: 'absolute', + borderWidth: 4, + }, + pickerIndicatorTick: { + width: 5, + }, + colorPreviews: { + flexDirection: 'row', + }, + colorPreview: { + flex: 1, + }, +}); diff --git a/addons/ondevice-knobs/src/components/color-picker/index.d.ts b/addons/ondevice-knobs/src/components/color-picker/index.d.ts new file mode 100644 index 0000000000..6962c5319d --- /dev/null +++ b/addons/ondevice-knobs/src/components/color-picker/index.d.ts @@ -0,0 +1,29 @@ +declare module 'react-native-color-picker' { + import * as React from 'react'; + + type HsvColor = { h: number; s: number; v: number }; + + export interface IPicker { + color: string | HsvColor; + defaultColor: string | HsvColor; + oldColor?: string; + style?: object; + onColorSelected: (selectedColor: string) => void; + onColorChange: (selectedColor: HsvColor) => void; + onOldColorSelected?: (oldColor: string) => void; + hideSliders?: boolean; + } + + export interface SliderProps { + onValueChange?: (value: number) => void; + value?: number; + } + export interface IHoloPicker extends IPicker { + sliderComponent: React.Component; + } + + export const ColorPicker: React.ComponentType; + export const TriangleColorPicker: React.ComponentType; + export const toHsv: (color: string) => HsvColor; + export const fromHsv: (hsv: HsvColor) => string; +} diff --git a/addons/ondevice-knobs/src/components/color-picker/index.js b/addons/ondevice-knobs/src/components/color-picker/index.js new file mode 100644 index 0000000000..26ae80dd49 --- /dev/null +++ b/addons/ondevice-knobs/src/components/color-picker/index.js @@ -0,0 +1,3 @@ +export { fromHsv, toHsv } from './utils'; +export { HoloColorPicker as ColorPicker } from './HoloColorPicker'; +export { TriangleColorPicker } from './TriangleColorPicker'; diff --git a/addons/ondevice-knobs/src/components/color-picker/resources/color-circle.png b/addons/ondevice-knobs/src/components/color-picker/resources/color-circle.png new file mode 100644 index 0000000000000000000000000000000000000000..1b71bb2e9c0897b257a94ce57bfdd2860a89eb7c GIT binary patch literal 21485 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 literal 0 HcmV?d00001 diff --git a/addons/ondevice-knobs/src/components/color-picker/resources/color-circle.xcf b/addons/ondevice-knobs/src/components/color-picker/resources/color-circle.xcf new file mode 100644 index 0000000000000000000000000000000000000000..f1c0e5124d3baa1cfae4395e9cf6c23d8c8fb1dd GIT binary patch literal 53281 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 C+CU8e literal 0 HcmV?d00001 diff --git a/addons/ondevice-knobs/src/components/color-picker/resources/hsv_triangle_mask.png b/addons/ondevice-knobs/src/components/color-picker/resources/hsv_triangle_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..79dce670cf6ff8c3571597c8102299da111c9987 GIT binary patch literal 15005 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 literal 0 HcmV?d00001 diff --git a/addons/ondevice-knobs/src/components/color-picker/utils.js b/addons/ondevice-knobs/src/components/color-picker/utils.js new file mode 100644 index 0000000000..aa79560236 --- /dev/null +++ b/addons/ondevice-knobs/src/components/color-picker/utils.js @@ -0,0 +1,69 @@ +import tinycolor from 'tinycolor2'; +import { PanResponder } from 'react-native'; + +/** + * Converts color to hsv representation. + * @param {string} color any color represenation - name, hexa, rgb + * @return {object} { h: number, s: number, v: number } object literal + */ +export function toHsv(color) { + return tinycolor(color).toHsv(); +} + +/** + * Converts hsv object to hexa color string. + * @param {object} hsv { h: number, s: number, v: number } object literal + * @return {string} color in hexa representation + */ +export function fromHsv(hsv) { + return tinycolor(hsv).toHexString(); +} + +const fn = () => true; +/** + * Simplified pan responder wrapper. + */ +export function createPanResponder({ onStart = fn, onMove = fn, onEnd = fn }) { + return PanResponder.create({ + onStartShouldSetPanResponder: fn, + onStartShouldSetPanResponderCapture: fn, + onMoveShouldSetPanResponder: fn, + onMoveShouldSetPanResponderCapture: fn, + onPanResponderTerminationRequest: fn, + onPanResponderGrant: (evt, state) => { + return onStart({ x: evt.nativeEvent.pageX, y: evt.nativeEvent.pageY }, evt, state); + }, + onPanResponderMove: (evt, state) => { + return onMove({ x: evt.nativeEvent.pageX, y: evt.nativeEvent.pageY }, evt, state); + }, + onPanResponderRelease: (evt, state) => { + return onEnd({ x: evt.nativeEvent.pageX, y: evt.nativeEvent.pageY }, evt, state); + }, + }); +} + +/** + * Rotates point around given center in 2d. + * Point is object literal { x: number, y: number } + * @param {point} point to be rotated + * @param {number} angle in radians + * @param {point} center to be rotated around + * @return {point} rotated point + */ +export function rotatePoint(point, angle, center = { x: 0, y: 0 }) { + // translation to origin + const transOriginX = point.x - center.x; + const transOriginY = point.y - center.y; + + // rotation around origin + const rotatedX = transOriginX * Math.cos(angle) - transOriginY * Math.sin(angle); + const rotatedY = transOriginY * Math.cos(angle) + transOriginX * Math.sin(angle); + + // translate back from origin + const normalizedX = rotatedX + center.x; + const normalizedY = rotatedY + center.y; + return { + x: normalizedX, + y: normalizedY, + }; +} diff --git a/addons/ondevice-knobs/src/index.js b/addons/ondevice-knobs/src/index.js new file mode 100644 index 0000000000..34e4793351 --- /dev/null +++ b/addons/ondevice-knobs/src/index.js @@ -0,0 +1,17 @@ +import React from 'react'; +import addons from '@storybook/addons'; +import Panel from './panel'; + +export { withKnobs } from '@storybook/addon-knobs'; + +export function register() { + addons.register('RNKNOBS', () => { + const channel = addons.getChannel(); + addons.addPanel('RNKNOBS', { + title: 'Knobs', + // eslint-disable-next-line react/prop-types + render: ({ active, key }) => , + paramKey: 'knobs', + }); + }); +} diff --git a/addons/ondevice-knobs/src/panel.js b/addons/ondevice-knobs/src/panel.js new file mode 100644 index 0000000000..7d15ff7657 --- /dev/null +++ b/addons/ondevice-knobs/src/panel.js @@ -0,0 +1,203 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import PropTypes from 'prop-types'; +import { SET_CURRENT_STORY, FORCE_RE_RENDER } from '@storybook/core-events'; +import { SET, SET_OPTIONS, RESET, CHANGE, CLICK } from '@storybook/addon-knobs'; +import styled from '@emotion/native'; +import GroupTabs from './GroupTabs'; +import PropForm from './PropForm'; + +const getTimestamp = () => +new Date(); + +const DEFAULT_GROUP_ID = 'Other'; + +const Touchable = styled.TouchableOpacity(({ theme }) => ({ + borderRadius: 2, + borderWidth: 1, + borderColor: theme.borderColor || '#e6e6e6', + padding: 4, + margin: 10, + justifyContent: 'center', + alignItems: 'center', +})); + +const ResetButton = styled.Text(({ theme }) => ({ + color: theme.buttonTextColor || '#999999', +})); + +export default class Panel extends React.Component { + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + this.handleClick = this.handleClick.bind(this); + this.setKnobs = this.setKnobs.bind(this); + this.reset = this.reset.bind(this); + this.setOptions = this.setOptions.bind(this); + this.onGroupSelect = this.onGroupSelect.bind(this); + + this.state = { knobs: {}, groupId: DEFAULT_GROUP_ID }; + this.options = {}; + + this.lastEdit = getTimestamp(); + this.loadedFromUrl = false; + } + + componentDidMount() { + const { channel } = this.props; + + channel.on(SET, this.setKnobs); + channel.on(SET_OPTIONS, this.setOptions); + channel.on(SET_CURRENT_STORY, this.selectStory); + channel.emit(FORCE_RE_RENDER); + } + + componentWillUnmount() { + const { channel } = this.props; + channel.removeListener(SET, this.setKnobs); + channel.removeListener(SET_CURRENT_STORY, this.selectStory); + } + + onGroupSelect(name) { + this.setState({ groupId: name }); + } + + setOptions(options = { timestamps: false }) { + this.options = options; + } + + setKnobs({ knobs, timestamp }) { + if (!this.options.timestamps || !timestamp || this.lastEdit <= timestamp) { + this.setState({ knobs }); + } + } + + reset = () => { + const { channel } = this.props; + this.setState({ knobs: {} }); + channel.emit(RESET); + }; + + selectStory = () => { + const { channel } = this.props; + this.setState({ knobs: {}, groupId: DEFAULT_GROUP_ID }); + channel.emit(RESET); + }; + + emitChange(changedKnob) { + const { channel } = this.props; + channel.emit(CHANGE, changedKnob); + } + + handleChange(changedKnob) { + this.lastEdit = getTimestamp(); + const { knobs } = this.state; + const { name } = changedKnob; + const newKnobs = { ...knobs }; + newKnobs[name] = { + ...newKnobs[name], + ...changedKnob, + }; + + this.setState({ knobs: newKnobs }); + + this.setState( + { knobs: newKnobs }, + this.emitChange( + changedKnob.type === 'number' + ? { ...changedKnob, value: parseFloat(changedKnob.value) } + : changedKnob + ) + ); + } + + handleClick(knob) { + const { channel } = this.props; + + channel.emit(CLICK, knob); + } + + render() { + const { active } = this.props; + + if (!active) { + return null; + } + + const { knobs, groupId: stateGroupId } = this.state; + + const groups = {}; + const groupIds = []; + + let knobsArray = Object.keys(knobs); + + const knobsWithGroups = knobsArray.filter((key) => knobs[key].groupId); + + knobsWithGroups.forEach((key) => { + const knobKeyGroupId = knobs[key].groupId; + groupIds.push(knobKeyGroupId); + groups[knobKeyGroupId] = { + render: () => {knobKeyGroupId}, + title: knobKeyGroupId, + }; + }); + + const allHaveGroups = groupIds.length > 0 && knobsArray.length === knobsWithGroups.length; + + // If all of the knobs are assigned to a group, we don't need the default group. + const groupId = + stateGroupId === DEFAULT_GROUP_ID && allHaveGroups + ? knobs[knobsWithGroups[0]].groupId + : stateGroupId; + + if (groupIds.length > 0) { + if (!allHaveGroups) { + groups[DEFAULT_GROUP_ID] = { + render: () => {DEFAULT_GROUP_ID}, + title: DEFAULT_GROUP_ID, + }; + } + + if (groupId === DEFAULT_GROUP_ID) { + knobsArray = knobsArray.filter((key) => !knobs[key].groupId); + } + + if (groupId !== DEFAULT_GROUP_ID) { + knobsArray = knobsArray.filter((key) => knobs[key].groupId === groupId); + } + } + + knobsArray = knobsArray.map((key) => knobs[key]); + + if (knobsArray.length === 0) { + return NO KNOBS; + } + + return ( + + {groupIds.length > 0 && ( + + )} + + + + + RESET + + + ); + } +} + +Panel.propTypes = { + active: PropTypes.bool.isRequired, + channel: PropTypes.shape({ + emit: PropTypes.func, + on: PropTypes.func, + removeListener: PropTypes.func, + }).isRequired, + onReset: PropTypes.object, // eslint-disable-line +}; diff --git a/addons/ondevice-knobs/src/types/Array.js b/addons/ondevice-knobs/src/types/Array.js new file mode 100644 index 0000000000..f7b135b52e --- /dev/null +++ b/addons/ondevice-knobs/src/types/Array.js @@ -0,0 +1,57 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styled from '@emotion/native'; + +const Input = styled.TextInput(({ theme }) => ({ + borderWidth: 1, + borderColor: theme.borderColor || '#e6e6e6', + borderRadius: 2, + fontSize: 13, + padding: 5, + margin: 10, + color: theme.labelColor || 'black', +})); + +function formatArray(value, separator) { + if (value === '') { + return []; + } + return value.split(separator); +} + +const ArrayType = ({ knob, onChange }) => ( + onChange(formatArray(e, knob.separator))} + /> +); + +ArrayType.defaultProps = { + knob: {}, + onChange: (value) => value, +}; + +ArrayType.propTypes = { + knob: PropTypes.shape({ + name: PropTypes.string, + value: PropTypes.array, + separator: PropTypes.string, + }), + onChange: PropTypes.func, +}; + +ArrayType.serialize = (value) => value; +ArrayType.deserialize = (value) => { + if (Array.isArray(value)) { + return value; + } + + return Object.keys(value) + .sort() + .reduce((array, key) => [...array, value[key]], []); +}; + +export default ArrayType; diff --git a/addons/ondevice-knobs/src/types/Boolean.js b/addons/ondevice-knobs/src/types/Boolean.js new file mode 100644 index 0000000000..73b95bcc5d --- /dev/null +++ b/addons/ondevice-knobs/src/types/Boolean.js @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import { View, Switch } from 'react-native'; +import React from 'react'; + +class BooleanType extends React.Component { + onValueChange = () => { + const { onChange, knob } = this.props; + onChange(!knob.value); + }; + + render() { + const { knob } = this.props; + + return ( + + + + ); + } +} + +BooleanType.defaultProps = { + knob: {}, + onChange: (value) => value, +}; + +BooleanType.propTypes = { + knob: PropTypes.shape({ + name: PropTypes.string, + value: PropTypes.bool, + }), + onChange: PropTypes.func, +}; + +BooleanType.serialize = (value) => (value ? String(value) : null); +BooleanType.deserialize = (value) => value === 'true'; + +export default BooleanType; diff --git a/addons/ondevice-knobs/src/types/Button.js b/addons/ondevice-knobs/src/types/Button.js new file mode 100644 index 0000000000..ee794ca02e --- /dev/null +++ b/addons/ondevice-knobs/src/types/Button.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { TouchableOpacity } from 'react-native'; +import styled from '@emotion/native'; + +const Label = styled.Text(({ theme }) => ({ + fontSize: 17, + color: theme.labelColor || 'black', +})); + +const ButtonType = ({ knob, onPress }) => ( + onPress(knob)}> + + +); + +ButtonType.defaultProps = { + knob: {}, +}; + +ButtonType.propTypes = { + knob: PropTypes.shape({ + name: PropTypes.string, + }), + onPress: PropTypes.func.isRequired, +}; + +ButtonType.serialize = (value) => value; +ButtonType.deserialize = (value) => value; + +export default ButtonType; diff --git a/addons/ondevice-knobs/src/types/Color.js b/addons/ondevice-knobs/src/types/Color.js new file mode 100644 index 0000000000..1238611f20 --- /dev/null +++ b/addons/ondevice-knobs/src/types/Color.js @@ -0,0 +1,104 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Text, Modal, View, TouchableOpacity, TouchableWithoutFeedback } from 'react-native'; +import styled from '@emotion/native'; +import { ColorPicker, fromHsv } from '../components/color-picker'; + +const Touchable = styled.TouchableOpacity(({ theme, color }) => ({ + borderColor: theme.borderColor || '#e6e6e6', + width: 30, + height: 20, + borderRadius: 2, + borderWidth: 1, + margin: 10, + backgroundColor: color, +})); + +class ColorType extends React.Component { + constructor(props) { + super(props); + this.state = { + displayColorPicker: false, + }; + } + + openColorPicker = () => { + this.setState({ + displayColorPicker: true, + }); + }; + + closeColorPicker = () => { + this.setState({ + displayColorPicker: false, + }); + }; + + onChangeColor = (color) => { + const { onChange } = this.props; + + onChange(fromHsv(color)); + }; + + render() { + const { knob } = this.props; + const { displayColorPicker } = this.state; + return ( + + + + + + + + + X + + + + + + + + + ); + } +} + +ColorType.propTypes = { + knob: PropTypes.shape({ + name: PropTypes.string, + value: PropTypes.string, + }), + onChange: PropTypes.func, +}; +ColorType.defaultProps = { + knob: {}, + onChange: (value) => value, +}; + +ColorType.serialize = (value) => value; +ColorType.deserialize = (value) => value; + +export default ColorType; diff --git a/addons/ondevice-knobs/src/types/Date.js b/addons/ondevice-knobs/src/types/Date.js new file mode 100644 index 0000000000..f368124fa6 --- /dev/null +++ b/addons/ondevice-knobs/src/types/Date.js @@ -0,0 +1,107 @@ +import PropTypes from 'prop-types'; +import React, { PureComponent } from 'react'; +import { View } from 'react-native'; +import DateTimePicker from 'react-native-modal-datetime-picker'; +import styled from '@emotion/native'; + +const Touchable = styled.TouchableOpacity(({ theme }) => ({ + borderColor: theme.borderColor || '#e6e6e6', + borderWidth: 1, + borderRadius: 2, + padding: 5, +})); + +const Label = styled.Text(({ theme }) => ({ + fontSize: 13, + color: theme.labelColor || 'black', +})); + +// TODO seconds support +class DateType extends PureComponent { + constructor() { + super(); + this.state = { + isDateVisible: false, + isTimeVisible: false, + }; + } + + showDatePicker = () => { + this.setState({ isDateVisible: true }); + }; + + showTimePicker = () => { + this.setState({ isTimeVisible: true }); + }; + + hidePicker = () => { + this.setState({ isDateVisible: false, isTimeVisible: false }); + }; + + onDatePicked = (date) => { + const value = date.valueOf(); + const { onChange } = this.props; + onChange(value); + this.hidePicker(); + }; + + render() { + const { knob } = this.props; + + const { isTimeVisible, isDateVisible } = this.state; + let d = new Date(knob.value); + if (isNaN(d.valueOf())) { + d = new Date(); + } + + // https://stackoverflow.com/a/30272803 + const dateString = [ + `0${d.getDate()}`.slice(-2), + `0${d.getMonth() + 1}`.slice(-2), + d.getFullYear(), + ].join('-'); + const timeString = `${`0${d.getHours()}`.slice(-2)}:${`0${d.getMinutes()}`.slice(-2)}`; + + return ( + + + + + + + + + + + + ); + } +} +DateType.defaultProps = { + knob: {}, + onChange: (value) => value, +}; + +DateType.propTypes = { + knob: PropTypes.shape({ + name: PropTypes.string, + value: PropTypes.number, + }), + onChange: PropTypes.func, +}; + +DateType.serialize = (value) => String(value); +DateType.deserialize = (value) => parseFloat(value); + +export default DateType; diff --git a/addons/ondevice-knobs/src/types/Number.js b/addons/ondevice-knobs/src/types/Number.js new file mode 100644 index 0000000000..2eda66930a --- /dev/null +++ b/addons/ondevice-knobs/src/types/Number.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Slider from '@react-native-community/slider'; +import { View } from 'react-native'; +import styled from '@emotion/native'; + +const Input = styled.TextInput(({ theme }) => ({ + borderWidth: 1, + borderColor: theme.borderColor || '#e6e6e6', + borderRadius: 2, + fontSize: 13, + padding: 5, + color: theme.labelColor || 'black', +})); + +class NumberType extends React.Component { + constructor(props) { + super(props); + + const { knob } = this.props; + const initialInputValue = Number.isNaN(knob.value) ? '' : knob.value.toString(); + + this.state = { + inputValue: initialInputValue, + showError: false, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + const { knob } = this.props; + const { inputValue } = this.state; + + return nextProps.knob.value !== knob.value || nextState.inputValue !== inputValue; + } + + onChangeNormal = (text) => { + const { onChange } = this.props; + const inputValue = text.trim().replace(/,/, '.'); + if (inputValue === '') { + this.setState({ showError: false, inputValue }); + return; + } + + const parsedValue = Number(inputValue); + + this.setState({ inputValue }); + + if (!Number.isNaN(parsedValue)) { + onChange(parsedValue); + this.setState({ showError: false }); + } else { + this.setState({ showError: true }); + } + }; + + renderNormal = () => { + const { inputValue, showError } = this.state; + return ( + + ); + }; + + renderRange = () => { + const { knob, onChange } = this.props; + + return ( + onChange(parseFloat(val))} + /> + ); + }; + + render() { + const { knob } = this.props; + + return ( + {knob.range ? this.renderRange() : this.renderNormal()} + ); + } +} + +NumberType.defaultProps = { + knob: {}, + onChange: (value) => value, +}; + +NumberType.propTypes = { + knob: PropTypes.shape({ + name: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + step: PropTypes.number, + min: PropTypes.number, + max: PropTypes.number, + range: PropTypes.bool, + defaultValue: PropTypes.number, + }), + onChange: PropTypes.func, +}; + +NumberType.serialize = (value) => String(value); +NumberType.deserialize = (value) => parseFloat(value); + +export default NumberType; diff --git a/addons/ondevice-knobs/src/types/Object.js b/addons/ondevice-knobs/src/types/Object.js new file mode 100644 index 0000000000..ad8e82a18f --- /dev/null +++ b/addons/ondevice-knobs/src/types/Object.js @@ -0,0 +1,102 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import deepEqual from 'deep-equal'; +import styled from '@emotion/native'; + +const Input = styled.TextInput(({ theme }) => ({ + borderWidth: 1, + borderRadius: 2, + fontSize: 13, + padding: 5, + margin: 10, + borderColor: theme.borderColor || '#e6e6e6', + color: theme.labelColor || 'black', +})); + +class ObjectType extends React.Component { + constructor(...args) { + super(...args); + this.state = {}; + } + + getJSONString() { + const { json, jsonString } = this.state; + const { knob } = this.props; + + // If there is an error in the JSON, we need to give that errored JSON. + if (this.failed) return jsonString; + + // If the editor value and the knob value is the same, we need to return the + // editor value as it allow user to add new fields to the JSON. + if (deepEqual(json, knob.value)) return jsonString; + + // If the knob's value is different from the editor, it seems like + // there's a outside change and we need to get that. + return JSON.stringify(knob.value, null, 2); + } + + handleChange = (value) => { + const { onChange } = this.props; + + const withReplacedQuotes = value + .replace(/[\u2018\u2019]/g, "'") + .replace(/[\u201C\u201D]/g, '"'); + + const newState = { + jsonString: withReplacedQuotes, + }; + + try { + newState.json = JSON.parse(withReplacedQuotes.trim()); + + onChange(newState.json); + this.failed = false; + } catch (err) { + this.failed = true; + } + + this.setState(newState); + }; + + render() { + const { knob } = this.props; + const jsonString = this.getJSONString(); + const extraStyle = {}; + + if (this.failed) { + extraStyle.borderWidth = 1; + extraStyle.borderColor = '#fadddd'; + extraStyle.backgroundColor = '#fff5f5'; + } + + return ( + + ); + } +} + +ObjectType.defaultProps = { + knob: {}, + onChange: (value) => value, +}; + +ObjectType.propTypes = { + knob: PropTypes.shape({ + name: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), + }), + onChange: PropTypes.func, +}; + +ObjectType.serialize = (object) => JSON.stringify(object); +ObjectType.deserialize = (value) => (value ? JSON.parse(value) : {}); + +export default ObjectType; diff --git a/addons/ondevice-knobs/src/types/Radio.js b/addons/ondevice-knobs/src/types/Radio.js new file mode 100644 index 0000000000..0a13c20c3d --- /dev/null +++ b/addons/ondevice-knobs/src/types/Radio.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import { View } from 'react-native'; +import React from 'react'; +import RadioSelect from '../components/RadioSelect'; + +class SelectType extends React.Component { + getOptions = ({ options }) => { + if (Array.isArray(options)) { + return options.map((val) => ({ key: val, label: val })); + } + + return Object.keys(options).map((key) => ({ label: key, key: options[key] })); + }; + + render() { + const { knob, onChange, isInline } = this.props; + + const options = this.getOptions(knob); + + return ( + + onChange(option.key)} + inline={isInline} + /> + + ); + } +} + +SelectType.defaultProps = { + knob: {}, + onChange: (value) => value, + isInline: false, +}; + +SelectType.propTypes = { + knob: PropTypes.shape({ + name: PropTypes.string, + value: PropTypes.string, + options: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), + }), + onChange: PropTypes.func, + isInline: PropTypes.bool, +}; + +SelectType.serialize = (value) => value; +SelectType.deserialize = (value) => value; + +export default SelectType; diff --git a/addons/ondevice-knobs/src/types/Select.js b/addons/ondevice-knobs/src/types/Select.js new file mode 100644 index 0000000000..ca3d324408 --- /dev/null +++ b/addons/ondevice-knobs/src/types/Select.js @@ -0,0 +1,74 @@ +/* eslint no-underscore-dangle: 0 */ + +import PropTypes from 'prop-types'; +import { View } from 'react-native'; +import React from 'react'; +import ModalPicker from 'react-native-modal-selector'; +import styled from '@emotion/native'; + +const Input = styled.TextInput(({ theme }) => ({ + borderWidth: 1, + borderRadius: 2, + padding: 5, + margin: 10, + borderColor: theme.borderColor || '#e6e6e6', + color: theme.labelColor || 'black', +})); + +class SelectType extends React.Component { + getOptions = ({ options }) => { + if (Array.isArray(options)) { + return options.map((val) => ({ key: val, label: val })); + } + + return Object.keys(options).map((key) => ({ label: key, key: options[key] })); + }; + + render() { + const { knob, onChange } = this.props; + + const options = this.getOptions(knob); + + const active = options.filter(({ key }) => knob.value === key)[0]; + const selected = active && active.label; + + return ( + + onChange(option.key)} + animationType="none" + keyExtractor={({ key, label }) => `${label}-${key}`} + > + + + + ); + } +} + +SelectType.defaultProps = { + knob: {}, + onChange: (value) => value, +}; + +SelectType.propTypes = { + knob: PropTypes.shape({ + name: PropTypes.string, + value: PropTypes.string, + options: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), + selectV2: PropTypes.bool, + }), + onChange: PropTypes.func, +}; + +SelectType.serialize = (value) => value; +SelectType.deserialize = (value) => value; + +export default SelectType; diff --git a/addons/ondevice-knobs/src/types/Text.js b/addons/ondevice-knobs/src/types/Text.js new file mode 100644 index 0000000000..b0afeb51fc --- /dev/null +++ b/addons/ondevice-knobs/src/types/Text.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styled from '@emotion/native'; + +const Input = styled.TextInput(({ theme }) => ({ + borderWidth: 1, + borderColor: theme.borderColor || '#e6e6e6', + borderRadius: 2, + fontSize: 13, + padding: 5, + margin: 10, + color: theme.labelColor || 'black', +})); + +const TextType = ({ knob, onChange }) => ( + +); + +TextType.defaultProps = { + knob: {}, + onChange: (value) => value, +}; + +TextType.propTypes = { + knob: PropTypes.shape({ + name: PropTypes.string, + value: PropTypes.string, + }), + onChange: PropTypes.func, +}; + +TextType.serialize = (value) => value; +TextType.deserialize = (value) => value; + +export default TextType; diff --git a/addons/ondevice-knobs/src/types/index.js b/addons/ondevice-knobs/src/types/index.js new file mode 100644 index 0000000000..9fb7767131 --- /dev/null +++ b/addons/ondevice-knobs/src/types/index.js @@ -0,0 +1,23 @@ +import TextType from './Text'; +import NumberType from './Number'; +import ColorType from './Color'; +import BooleanType from './Boolean'; +import ObjectType from './Object'; +import SelectType from './Select'; +import ArrayType from './Array'; +import DateType from './Date'; +import ButtonType from './Button'; +import RadioType from './Radio'; + +export default { + text: TextType, + number: NumberType, + color: ColorType, + boolean: BooleanType, + object: ObjectType, + select: SelectType, + array: ArrayType, + date: DateType, + button: ButtonType, + radios: RadioType, +}; diff --git a/app/react-native/src/preview/components/OnDeviceUI/OnDeviceUI.tsx b/app/react-native/src/preview/components/OnDeviceUI/OnDeviceUI.tsx index 7763b56cde..e00d1dfaaa 100644 --- a/app/react-native/src/preview/components/OnDeviceUI/OnDeviceUI.tsx +++ b/app/react-native/src/preview/components/OnDeviceUI/OnDeviceUI.tsx @@ -166,8 +166,12 @@ const OnDeviceUI = ({ > + diff --git a/examples/native/.storybook/main.js b/examples/native/.storybook/main.js index c8ea770d5a..9133162c93 100644 --- a/examples/native/.storybook/main.js +++ b/examples/native/.storybook/main.js @@ -6,6 +6,7 @@ module.exports = { addons: [ '@storybook/addon-ondevice-notes', '@storybook/addon-ondevice-controls', + '@storybook/addon-ondevice-knobs', '@storybook/addon-ondevice-backgrounds', '@storybook/addon-ondevice-actions', ], diff --git a/examples/native/components/KnobsExample/KnobsExample.js b/examples/native/components/KnobsExample/KnobsExample.js new file mode 100644 index 0000000000..68c8f21723 --- /dev/null +++ b/examples/native/components/KnobsExample/KnobsExample.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { View, Text } from 'react-native'; + +export default ({ + backgroundColor, + name, + age, + fruit, + otherFruit, + birthday, + dollars, + items, + nice, + customStyles, +}) => { + const intro = `My name is ${name}, I'm ${age} years old, and my favorite fruit is ${fruit}. I also enjoy ${otherFruit}.`; + const style = { backgroundColor, ...customStyles }; + const salutation = nice ? 'Nice to meet you!' : 'Leave me alone!'; + const dateOptions = { year: 'numeric', month: 'long', day: 'numeric' }; + return ( + + {intro} + My birthday is: {new Date(birthday).toLocaleDateString('en-US', dateOptions)} + My wallet contains: ${dollars.toFixed(2)} + In my backpack, I have: + + {items.map((item) => ( + {item} + ))} + + {salutation} + + ); +}; diff --git a/examples/native/components/KnobsExample/KnobsExample.stories.js b/examples/native/components/KnobsExample/KnobsExample.stories.js new file mode 100644 index 0000000000..4eacc954cb --- /dev/null +++ b/examples/native/components/KnobsExample/KnobsExample.stories.js @@ -0,0 +1,65 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react-native'; +import { withKnobs } from '@storybook/addon-ondevice-knobs'; +import { + text, + number, + boolean, + color, + select, + radios, + array, + date, + object, +} from '@storybook/addon-knobs'; +import KnobsExample from './KnobsExample'; + +storiesOf('Knobs Example', module) + .addDecorator(withKnobs) + .add('with knobs', () => { + const name = text('Name', 'Storyteller'); + const age = number('Age', 70, { range: true, min: 0, max: 90, step: 5 }); + const fruits = { + Apple: 'apple', + Banana: 'banana', + Cherry: 'cherry', + }; + const fruit = select('Fruit', fruits, 'apple'); + + const otherFruits = { + Kiwi: 'kiwi', + Guava: 'guava', + Watermelon: 'watermelon', + }; + const otherFruit = radios('Other Fruit', otherFruits, 'watermelon'); + const dollars = number('Dollars', 12.5); + + // NOTE: color picker is currently broken + const backgroundColor = color('background', '#ffff00'); + const items = array('Items', ['Laptop', 'Book', 'Whiskey']); + const customStyles = object('Styles', { + borderWidth: 3, + borderColor: '#ff00ff', + padding: 10, + }); + const nice = boolean('Nice', true); + + const birthday = date('Birthday', new Date(2017, 0, 20)); + + return ( + + ); + }); diff --git a/examples/native/package.json b/examples/native/package.json index 4374664b2c..644380e46c 100644 --- a/examples/native/package.json +++ b/examples/native/package.json @@ -42,6 +42,7 @@ "@storybook/addon-ondevice-actions": "^6.0.1-beta.10", "@storybook/addon-ondevice-backgrounds": "^6.0.1-beta.10", "@storybook/addon-ondevice-controls": "^6.0.1-beta.10", + "@storybook/addon-ondevice-knobs": "^6.0.1-beta.10", "@storybook/addon-ondevice-notes": "^6.0.1-beta.10", "@storybook/addons": "^6.5", "@storybook/docs-tools": "^6.5", diff --git a/yarn.lock b/yarn.lock index 56f3ae4e81..ef81b733ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1168,7 +1168,7 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.7.6": +"@babel/runtime@^7.4.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.7": version "7.20.1" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.1.tgz#1148bb33ab252b165a06698fde7576092a78b4a9" integrity sha512-mrzLkl6U9YLF8qpqI7TB82PESyEGjm/0Ly91jG575eVxMMlb8fYfOXFZIJ8XfLrJZQbm7dlKry2bJmXBUEkdFg== @@ -1262,7 +1262,7 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== -"@emotion/cache@^10.0.27": +"@emotion/cache@^10.0.27", "@emotion/cache@^10.0.9": version "10.0.29" resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0" integrity sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ== @@ -1272,7 +1272,7 @@ "@emotion/utils" "0.11.3" "@emotion/weak-memoize" "0.2.5" -"@emotion/core@^10.0.20": +"@emotion/core@^10.0.20", "@emotion/core@^10.0.9": version "10.3.1" resolved "https://registry.yarnpkg.com/@emotion/core/-/core-10.3.1.tgz#4021b6d8b33b3304d48b0bb478485e7d7421c69d" integrity sha512-447aUEjPIm0MnE6QYIaFz9VQOHSXf4Iu6EWOIqq11EAPqinkSZmfymPTmlOE3QjLv846lH4JVZBUOtwGbuQoww== @@ -1284,7 +1284,7 @@ "@emotion/sheet" "0.9.4" "@emotion/utils" "0.11.3" -"@emotion/css@^10.0.27": +"@emotion/css@^10.0.27", "@emotion/css@^10.0.9": version "10.0.27" resolved "https://registry.yarnpkg.com/@emotion/css/-/css-10.0.27.tgz#3a7458198fbbebb53b01b2b87f64e5e21241e14c" integrity sha512-6wZjsvYeBhyZQYNrGoR5yPMYbMBNEnanDrqmsqS1mzDm1cOTu12shvl2j4QHNS36UaTE0USIJawCH9C8oW34Zw== @@ -3282,6 +3282,23 @@ lodash "^4.17.21" ts-dedent "^2.0.0" +"@storybook/addon-knobs@^6": + version "6.4.0" + resolved "https://registry.yarnpkg.com/@storybook/addon-knobs/-/addon-knobs-6.4.0.tgz#fa5943ef21826cdc2e20ded74edfdf5a6dc71dcf" + integrity sha512-DiH1/5e2AFHoHrncl1qLu18ZHPHzRMMPvOLFz8AWvvmc+VCqTdIaE+tdxKr3e8rYylKllibgvDOzrLjfTNjF+Q== + dependencies: + copy-to-clipboard "^3.3.1" + core-js "^3.8.2" + escape-html "^1.0.3" + fast-deep-equal "^3.1.3" + global "^4.4.0" + lodash "^4.17.20" + prop-types "^15.7.2" + qs "^6.10.0" + react-colorful "^5.1.2" + react-lifecycles-compat "^3.0.4" + react-select "^3.2.0" + "@storybook/addon-links@^6.5": version "6.5.13" resolved "https://registry.yarnpkg.com/@storybook/addon-links/-/addon-links-6.5.13.tgz#816816907e28ca1cccb58908360628d1b3914513" @@ -3300,7 +3317,7 @@ regenerator-runtime "^0.13.7" ts-dedent "^2.0.0" -"@storybook/addons@6.5.13", "@storybook/addons@^6.5": +"@storybook/addons@6.5.13", "@storybook/addons@^6.5", "@storybook/addons@^6.5.3": version "6.5.13" resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-6.5.13.tgz#61ec5eab07879400d423d60bb397880d10ee5e73" integrity sha512-18CqzNnrGMfeZtiKz+R/3rHtSNnfNwz6y6prIQIbWseK16jY8ELTfIFGviwO5V2OqpbHDQi5+xQQ63QAIb89YA== @@ -3556,7 +3573,7 @@ util-deprecate "^1.0.2" webpack "4" -"@storybook/core-events@6.5.13", "@storybook/core-events@^6.5": +"@storybook/core-events@6.5.13", "@storybook/core-events@^6.5", "@storybook/core-events@^6.5.3": version "6.5.13" resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-6.5.13.tgz#a8c0cc92694f09981ca6501d5c5ef328db18db8a" integrity sha512-kL745tPpRKejzHToA3/CoBNbI+NPRVk186vGxXBmk95OEg0TlwgQExP8BnqEtLlRZMbW08e4+6kilc1M1M4N5w== @@ -7418,6 +7435,13 @@ copy-descriptor@^0.1.0: resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw== +copy-to-clipboard@^3.3.1: + version "3.3.3" + resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz#55ac43a1db8ae639a4bd99511c148cdd1b83a1b0" + integrity sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA== + dependencies: + toggle-selection "^1.0.6" + core-js-compat@^3.25.1, core-js-compat@^3.8.1: version "3.26.0" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.26.0.tgz#94e2cf8ba3e63800c4956ea298a6473bc9d62b44" @@ -8307,6 +8331,14 @@ dom-converter@^0.2.0: dependencies: utila "~0.4" +dom-helpers@^5.0.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" + integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== + dependencies: + "@babel/runtime" "^7.8.7" + csstype "^3.0.2" + dom-serializer@0: version "0.2.2" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" @@ -8837,7 +8869,7 @@ escalade@^3.1.1: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== -escape-html@~1.0.3: +escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== @@ -16186,7 +16218,7 @@ prop-types-exact@^1.2.0: object.assign "^4.1.0" reflect.ownkeys "^0.2.0" -prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -16428,6 +16460,11 @@ raw-loader@^4.0.2: loader-utils "^2.0.0" schema-utils "^3.0.0" +react-colorful@^5.1.2: + version "5.6.1" + resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" + integrity sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw== + react-devtools-core@4.24.0: version "4.24.0" resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-4.24.0.tgz#7daa196bdc64f3626b3f54f2ff2b96f7c4fdf017" @@ -16479,6 +16516,13 @@ react-element-to-jsx-string@^14.3.4: is-plain-object "5.0.0" react-is "17.0.2" +react-input-autosize@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-3.0.0.tgz#6b5898c790d4478d69420b55441fcc31d5c50a85" + integrity sha512-nL9uS7jEs/zu8sqwFE5MAPx6pPkNAriACQ2rGLlqmKr2sPGtN7TXTyDdQt4lbNXVx7Uzadb40x8qotIuru6Rhg== + dependencies: + prop-types "^15.5.8" + react-inspector@^5.1.0: version "5.1.1" resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-5.1.1.tgz#58476c78fde05d5055646ed8ec02030af42953c8" @@ -16503,6 +16547,11 @@ react-is@^16.12.0, react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.4, react- resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== + react-native-codegen@^0.70.5: version "0.70.6" resolved "https://registry.yarnpkg.com/react-native-codegen/-/react-native-codegen-0.70.6.tgz#2ce17d1faad02ad4562345f8ee7cbe6397eda5cb" @@ -16603,6 +16652,20 @@ react-refresh@^0.4.0: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.4.3.tgz#966f1750c191672e76e16c2efa569150cc73ab53" integrity sha512-Hwln1VNuGl/6bVwnd0Xdn1e84gT/8T9aYNL+HAKDArLCS7LWjwr7StE30IEYbIkx0Vi3vs+coQxe+SQDbGbbpA== +react-select@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/react-select/-/react-select-3.2.0.tgz#de9284700196f5f9b5277c5d850a9ce85f5c72fe" + integrity sha512-B/q3TnCZXEKItO0fFN/I0tWOX3WJvi/X2wtdffmwSQVRwg5BpValScTO1vdic9AxlUgmeSzib2hAZAwIUQUZGQ== + dependencies: + "@babel/runtime" "^7.4.4" + "@emotion/cache" "^10.0.9" + "@emotion/core" "^10.0.9" + "@emotion/css" "^10.0.9" + memoize-one "^5.0.0" + prop-types "^15.6.0" + react-input-autosize "^3.0.0" + react-transition-group "^4.3.0" + react-shallow-renderer@^16.15.0: version "16.15.0" resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz#48fb2cf9b23d23cde96708fe5273a7d3446f4457" @@ -16630,6 +16693,16 @@ react-test-renderer@^16.0.0-0: react-is "^16.8.6" scheduler "^0.19.1" +react-transition-group@^4.3.0: + version "4.4.5" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" + integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" @@ -19065,6 +19138,11 @@ to-vfile@^6.0.0: is-buffer "^2.0.0" vfile "^4.0.0" +toggle-selection@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" + integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ== + toidentifier@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"