diff --git a/package.json b/package.json index 9f2782ee..03915d66 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "react-router": "^4.2.0", "react-router-dom": "^4.2.2", "react-router-redux": "5.0.0-alpha.9", + "react-select": "2.0.0-beta.6", "react-smooth-scrollbar": "^8.0.6", "react-spring": "^4.2.1", "redux": "^4.0.0", diff --git a/src/components/BalanceSummary/stories.js b/src/components/BalanceSummary/stories.js index fbcbfc9a..a0141014 100644 --- a/src/components/BalanceSummary/stories.js +++ b/src/components/BalanceSummary/stories.js @@ -4,6 +4,7 @@ import React from 'react' import { storiesOf } from '@storybook/react' import { number } from '@storybook/addon-knobs' import { translate } from 'react-i18next' +import { getFiatCurrencyByTicker } from '@ledgerhq/live-common/lib/helpers/currencies' import BalanceInfos from './BalanceInfos' @@ -14,7 +15,7 @@ const BalanceInfosComp = translate()(BalanceInfos) stories.add('BalanceInfos', () => ( ({ accounts: getVisibleAccounts(state), }) -const renderItem = a => { - const Icon = getCryptoCurrencyIcon(a.currency) - const { color } = a.currency +const renderOption = a => { + const { data: account } = a + const Icon = getCryptoCurrencyIcon(account.currency) + const { color } = account.currency // FIXME: we need a non-hacky way to handle text ellipsis const nameOuterStyle = { width: 0 } @@ -38,11 +38,11 @@ const renderItem = a => { )} - {a.name} + {account.name} - + ) @@ -50,28 +50,27 @@ const renderItem = a => { type Props = { accounts: Account[], - onChange?: () => Account | void, - value?: Account | null, + onChange: Option => void, + value: ?Account, t: T, } -const RawSelectAccount = ({ accounts, onChange, value, t, ...props }: Props) => ( - + ) } export const SelectAccount = translate()(RawSelectAccount) diff --git a/src/components/SelectCurrency/index.js b/src/components/SelectCurrency/index.js index 35e55c83..47633469 100644 --- a/src/components/SelectCurrency/index.js +++ b/src/components/SelectCurrency/index.js @@ -4,32 +4,17 @@ import React from 'react' import { translate } from 'react-i18next' import { connect } from 'react-redux' -import noop from 'lodash/noop' - import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types' import type { T } from 'types/common' import { availableCurrencies } from 'reducers/settings' +import type { Option } from 'components/base/Select' import CryptoCurrencyIcon from 'components/CryptoCurrencyIcon' import Select from 'components/base/Select' import Box from 'components/base/Box' -const renderItem = (currency: CryptoCurrency) => { - const { color, name } = currency - return ( - - - - - - {name} - - - ) -} - type OwnProps = { - onChange: Function, + onChange: Option => void, currencies?: CryptoCurrency[], value?: CryptoCurrency, placeholder: string, @@ -44,23 +29,34 @@ const mapStateToProps = (state, props: OwnProps) => ({ currencies: props.currencies || availableCurrencies(state), }) -const SelectCurrency = ({ onChange, value, t, placeholder, currencies, ...props }: Props) => ( - + ) +} -SelectCurrency.defaultProps = { - onChange: noop, - value: undefined, +const renderOption = (option: Option) => { + const { data: currency } = option + const { color, name } = currency + return ( + + + + + + {name} + + + ) } export default translate()(connect(mapStateToProps)(SelectCurrency)) diff --git a/src/components/SelectExchange.js b/src/components/SelectExchange.js index 9fe264eb..8ae03f4c 100644 --- a/src/components/SelectExchange.js +++ b/src/components/SelectExchange.js @@ -2,7 +2,7 @@ import React, { Component } from 'react' import type { Currency } from '@ledgerhq/live-common/lib/types' import type { Exchange } from '@ledgerhq/live-common/lib/countervalues/types' -import Select from 'components/base/Select' +import Select from 'components/base/LegacySelect' import Box from 'components/base/Box' import Text from 'components/base/Text' import CounterValues from 'helpers/countervalues' diff --git a/src/components/SettingsPage/sections/Display.js b/src/components/SettingsPage/sections/Display.js index 343dc942..1b7093fe 100644 --- a/src/components/SettingsPage/sections/Display.js +++ b/src/components/SettingsPage/sections/Display.js @@ -23,16 +23,16 @@ import { const regions = Object.keys(regionsByKey).map(key => { const [language, region] = key.split('-') - return { key, language, region, name: regionsByKey[key] } + return { value: key, language, region, label: regionsByKey[key] } }) const fiats = listFiatCurrencies() .map(f => f.units[0]) // For now we take first unit, in the future we'll need to figure out something else .map(fiat => ({ - key: fiat.code, + value: fiat.code, + label: `${fiat.name} - ${fiat.code}${fiat.symbol ? ` (${fiat.symbol})` : ''}`, fiat, - name: `${fiat.name} - ${fiat.code}${fiat.symbol ? ` (${fiat.symbol})` : ''}`, })) type Props = { @@ -79,7 +79,7 @@ class TabProfile extends PureComponent { }) } - handleChangeLanguage = ({ key: languageKey }: *) => { + handleChangeLanguage = ({ value: languageKey }: *) => { const { i18n, saveSettings } = this.props this.setState({ cachedLanguageKey: languageKey }) window.requestIdleCallback(() => { @@ -117,12 +117,16 @@ class TabProfile extends PureComponent { cachedRegion, } = this.state - const languages = languageKeys.map(key => ({ key, name: t(`language:${key}`) })) - const currentLanguage = languages.find(l => l.key === cachedLanguageKey) + const languages = languageKeys.map(key => ({ value: key, label: t(`language:${key}`) })) + const currentLanguage = languages.find(l => l.value === cachedLanguageKey) const regionsFiltered = regions.filter(({ language }) => cachedLanguageKey === language) const currentRegion = regionsFiltered.find(({ region }) => cachedRegion === region) || regionsFiltered[0] + const cvOption = cachedCounterValue + ? fiats.find(f => f.value === cachedCounterValue.value) + : null + return (
{ desc={t('settings:display.counterValueDesc')} > item && item.name} value={currentLanguage} - items={languages} + options={languages} /> } + {...getInputProps({ placeholder })} + /> + + ) : ( + + + {renderSelectedItem({ selectedItem, renderSelected, placeholder })} + + + + )} + + + ) + }} + /> + ) + } +} + +export default LegacySelect diff --git a/src/components/base/LegacySelect/stories.js b/src/components/base/LegacySelect/stories.js new file mode 100644 index 00000000..a5b2d475 --- /dev/null +++ b/src/components/base/LegacySelect/stories.js @@ -0,0 +1,117 @@ +// @flow + +import React, { PureComponent } from 'react' +import { storiesOf } from '@storybook/react' +import { boolean } from '@storybook/addon-knobs' + +import Box from 'components/base/Box' +import LegacySelect from 'components/base/LegacySelect' +import Text from 'components/base/Text' + +const stories = storiesOf('Components/base/LegacySelect', module) + +const itemsChessPlayers = [ + { key: 'aleksandr-grichtchouk', name: 'Aleksandr Grichtchouk' }, + { key: 'fabiano-caruana', name: 'Fabiano Caruana' }, + { key: 'garry-kasparov', name: 'Garry Kasparov' }, + { key: 'hikaru-nakamura', name: 'Hikaru Nakamura' }, + { key: 'levon-aronian', name: 'Levon Aronian' }, + { key: 'magnus-carlsen', name: 'Magnus Carlsen' }, + { key: 'maxime-vachier-lagrave', name: 'Maxime Vachier-Lagrave' }, + { key: 'shakhriyar-mamedyarov', name: 'Shakhriyar Mamedyarov' }, + { key: 'veselin-topalov', name: 'Veselin Topalov' }, + { key: 'viswanathan-anand', name: 'Viswanathan Anand' }, + { key: 'vladimir-kramnik', name: 'Vladimir Kramnik' }, +] + +type State = { + item: Object | null, +} + +class Wrapper extends PureComponent { + state = { + item: null, + } + + handleChange = item => this.setState({ item }) + + render() { + const { children } = this.props + const { item } = this.state + return ( +
+ {children(this.handleChange)} + {item && ( + +
+              {'You selected:'}
+              {JSON.stringify(item)}
+            
+
+ )} +
+ ) + } +} + +stories.add('basic', () => ( + + {onChange => ( + item.name} + onChange={onChange} + /> + )} + +)) + +stories.add('searchable', () => ( + (item ? item.name : '')} + renderHighlight={(text, key) => ( + + {text} + + )} + /> +)) + +const itemsColors = [ + { key: 'absolute zero', name: 'Absolute Zero', color: '#0048BA' }, + { key: 'acid green', name: 'Acid Green', color: '#B0BF1A' }, + { key: 'aero', name: 'Aero', color: '#7CB9E8' }, + { key: 'aero blue', name: 'Aero Blue', color: '#C9FFE5' }, + { key: 'african violet', name: 'African Violet', color: '#B284BE' }, + { key: 'air force blue (usaf)', name: 'Air Force Blue (USAF)', color: '#00308F' }, + { key: 'air superiority blue', name: 'Air Superiority Blue', color: '#72A0C1' }, +] + +stories.add('custom render', () => ( + (item ? item.name : '')} + renderHighlight={(text, key) => ( + + {text} + + )} + renderItem={item => ( + + + {item.name_highlight || item.name} + + )} + /> +)) diff --git a/src/components/base/Select/createRenderers.js b/src/components/base/Select/createRenderers.js new file mode 100644 index 00000000..6292c41b --- /dev/null +++ b/src/components/base/Select/createRenderers.js @@ -0,0 +1,71 @@ +// @flow + +import React from 'react' +import styled from 'styled-components' +import { components } from 'react-select' + +import type { OptionProps } from 'react-select/lib/types' + +import Box from 'components/base/Box' +import IconCheck from 'icons/Check' +import IconAngleDown from 'icons/AngleDown' +import IconCross from 'icons/Cross' + +import type { Option } from './index' + +export default ({ + renderOption, + renderValue, +}: { + renderOption: Option => Node, + renderValue: Option => Node, +}) => ({ + ...STYLES_OVERRIDE, + Option: (props: OptionProps) => { + const { data, isSelected } = props + return ( + + + {renderOption ? renderOption(props) : data.label} + {isSelected && ( + + + + )} + + + ) + }, + SingleValue: (props: OptionProps) => { + const { data } = props + return ( + + {renderValue ? renderValue(props) : data.label} + + ) + }, +}) + +const STYLES_OVERRIDE = { + DropdownIndicator: (props: OptionProps) => ( + + + + ), + ClearIndicator: (props: OptionProps) => ( + + + + ), +} + +const CheckContainer = styled(Box).attrs({ + align: 'center', + justify: 'center', +})` + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 10px; +` diff --git a/src/components/base/Select/createStyles.js b/src/components/base/Select/createStyles.js new file mode 100644 index 00000000..0760bd74 --- /dev/null +++ b/src/components/base/Select/createStyles.js @@ -0,0 +1,65 @@ +// @flow + +import { colors } from 'styles/theme' +import { ff } from 'styles/helpers' + +export default ({ width, minWidth }: { width: number, minWidth: number }) => ({ + control: (styles: Object, { isFocused }: Object) => ({ + ...styles, + width, + minWidth, + ...ff('Open Sans|SemiBold'), + height: 40, + backgroundColor: 'white', + cursor: 'pointer', + ...(isFocused + ? { + borderColor: colors.wallet, + boxShadow: 'rgba(0, 0, 0, 0.05) 0 2px 2px', + } + : {}), + }), + valueContainer: (styles: Object) => ({ + ...styles, + paddingLeft: 15, + color: colors.graphite, + }), + indicatorSeparator: (styles: Object) => ({ + ...styles, + background: 'none', + }), + option: (styles: Object, { isFocused, isSelected }: Object) => ({ + ...styles, + ...ff('Open Sans|Regular'), + color: colors.dark, + padding: '10px 15px 10px 15px', + ...(isFocused + ? { + background: colors.lightGrey, + color: colors.dark, + } + : {}), + ...(isSelected + ? { + background: 'unset !important', + ...ff('Open Sans|SemiBold'), + } + : { + cursor: 'pointer', + }), + }), + menu: (styles: Object) => ({ + ...styles, + border: `1px solid ${colors.fog}`, + boxShadow: 'rgba(0, 0, 0, 0.05) 0 2px 2px', + }), + menuList: (styles: Object) => ({ + ...styles, + background: 'white', + borderRadius: 3, + }), + container: (styles: Object) => ({ + ...styles, + fontSize: 13, + }), +}) diff --git a/src/components/base/Select/customRenders.js b/src/components/base/Select/customRenders.js new file mode 100644 index 00000000..e69de29b diff --git a/src/components/base/Select/index.js b/src/components/base/Select/index.js index f5604443..5b458bd0 100644 --- a/src/components/base/Select/index.js +++ b/src/components/base/Select/index.js @@ -1,343 +1,83 @@ // @flow -import React, { PureComponent } from 'react' -import Downshift from 'downshift' -import styled from 'styled-components' -import { space } from 'styled-system' +import React, { Component } from 'react' +import ReactSelect from 'react-select' +import { translate } from 'react-i18next' -import Box from 'components/base/Box' -import GrowScroll from 'components/base/GrowScroll' -import Input from 'components/base/Input' -import Search from 'components/base/Search' -import Text from 'components/base/Text' - -import IconCheck from 'icons/Check' +import createStyles from './createStyles' +import createRenderers from './createRenderers' type Props = { - bg?: string, - flatLeft?: boolean, - flatRight?: boolean, - fakeFocusRight?: boolean, - fuseOptions?: Object, - highlight?: boolean, - items: Array, - itemToString?: Function, - keyProp?: string, - maxHeight?: number, - onChange?: Function, - placeholder?: string, - renderHighlight?: string => React$Node, - renderItem?: (*) => React$Node, - renderSelected?: any => React$Node, - searchable?: boolean, - value?: *, - disabled: boolean, - small?: boolean, -} - -const Container = styled(Box).attrs({ relative: true, color: 'graphite' })`` - -const TriggerBtn = styled(Box).attrs({ - alignItems: 'center', - ff: p => (p.small ? 'Open Sans' : 'Open Sans|SemiBold'), - flow: 2, - fontSize: p => (p.small ? 3 : 4), - horizontal: true, - px: 3, -})` - ${space}; - height: ${p => (p.small ? '34' : '40')}px; - background: ${p => (p.disabled ? p.theme.colors.lightGrey : p.bg || p.theme.colors.white)}; - border-bottom-left-radius: ${p => (p.flatLeft ? 0 : p.theme.radii[1])}px; - border-bottom-right-radius: ${p => (p.flatRight ? 0 : p.theme.radii[1])}px; - border-top-left-radius: ${p => (p.flatLeft ? 0 : p.theme.radii[1])}px; - border-top-right-radius: ${p => (p.flatRight ? 0 : p.theme.radii[1])}px; - border: 1px solid ${p => p.theme.colors.fog}; - color: ${p => p.theme.colors.graphite}; - cursor: ${p => (p.disabled ? 'cursor' : 'pointer')}; - display: flex; - width: 100%; - - &:focus { - outline: none; - ${p => - p.disabled - ? '' - : ` - border-color: ${p.theme.colors.wallet}; - box-shadow: rgba(0, 0, 0, 0.05) 0 2px 2px;`}; - } - - ${p => { - const c = p.theme.colors.wallet - return p.fakeFocusRight - ? ` - border-top: 1px solid ${c}; - border-right: 1px solid ${c}; - border-bottom: 1px solid ${c}; - ` - : '' - }}; -` + // required + value: ?Option, + options: Option[], + onChange: Option => void, -const Item = styled(Box).attrs({ - alignItems: 'center', - fontSize: 4, - ff: p => `Open Sans|${p.selected ? 'SemiBold' : 'Regular'}`, - px: 3, - py: 2, - color: 'dark', -})` - background: ${p => (p.highlighted ? p.theme.colors.lightGrey : p.theme.colors.white)}; + // custom renders + renderOption: Option => Node, + renderValue: Option => Node, - ${p => - p.first && - ` - border-top-left-radius: ${p.theme.radii[1]}px; - border-top-right-radius: ${p.theme.radii[1]}px; - `} ${p => - p.last && - ` - border-bottom-left-radius: ${p.theme.radii[1]}px; - border-bottom-right-radius: ${p.theme.radii[1]}px; - `}; -` - -const Dropdown = styled(Box).attrs({ - mt: 1, -})` - border-radius: ${p => p.theme.radii[1]}px; - border: 1px solid ${p => p.theme.colors.fog}; - box-shadow: rgba(0, 0, 0, 0.05) 0 2px 2px; - left: 0; - position: absolute; - right: 0; - top: 100%; - z-index: 1; -` - -const IconSelected = styled(Box).attrs({ - color: 'wallet', - alignItems: 'center', - justifyContent: 'center', -})` - height: 12px; - width: 12px; - opacity: ${p => (p.selected ? 1 : 0)}; -` - -const AngleDown = props => ( - - - - - -) - -const renderSelectedItem = ({ selectedItem, renderSelected, placeholder }: any) => - selectedItem && renderSelected ? ( - renderSelected(selectedItem) - ) : ( - {placeholder} - ) - -class Select extends PureComponent { - static defaultProps = { - bg: undefined, - disabled: false, - small: false, - fakeFocusRight: false, - flatLeft: false, - flatRight: false, - itemToString: (item: Object) => item && item.name, - keyProp: undefined, - maxHeight: 300, - } - - _scrollToSelectedItem = true - _oldHighlightedIndex = 0 - _useKeyboard = false - _children = {} - - renderItems = (items: Array, selectedItem: any, downshiftProps: Object) => { - const { renderItem, maxHeight, keyProp } = this.props - const { getItemProps, highlightedIndex } = downshiftProps - - const selectedItemIndex = items.indexOf(selectedItem) - - return ( - - {items.length ? ( - { - const currentHighlighted = this._children[highlightedIndex] - const currentSelectedItem = this._children[selectedItemIndex] - - if (this._useKeyboard && currentHighlighted) { - scrollbar.scrollIntoView(currentHighlighted, { - alignToTop: highlightedIndex < this._oldHighlightedIndex, - offsetTop: -1, - onlyScrollIfNeeded: true, - }) - } else if (this._scrollToSelectedItem && currentSelectedItem) { - window.requestAnimationFrame(() => - scrollbar.scrollIntoView(currentSelectedItem, { - offsetTop: -1, - }), - ) + // optional + placeholder?: string, + isClearable?: boolean, + isDisabled?: boolean, + isLoading?: boolean, + isSearchable?: boolean, + width: number, + minWidth: number, +} - this._scrollToSelectedItem = false - } +export type Option = { + value: 'string', + label: 'string', + data: any, +} - this._oldHighlightedIndex = highlightedIndex - }} - > - {items.map((item, i) => ( - (this._children[i] = n)} - {...getItemProps({ item })} - > - - - {renderItem ? ( - renderItem(item) - ) : ( - {item.name_highlight || item.name} - )} - - - - - - - - - ))} - - ) : ( - - {'No results'} - - )} - - ) +class Select extends Component { + handleChange = (value, { action }) => { + const { onChange } = this.props + if (action === 'select-option') { + onChange(value) + } } render() { const { - disabled, - fakeFocusRight, - flatLeft, - flatRight, - fuseOptions, - highlight, - items, - itemToString, - onChange, - placeholder, - renderHighlight, - renderSelected, - searchable, value, - small, + isClearable, + isSearchable, + isDisabled, + isLoading, + placeholder, + options, + renderOption, + renderValue, + width, + minWidth, ...props } = this.props return ( - { - if (!isOpen) { - this._scrollToSelectedItem = true - } - - if (disabled) { - return ( - - - {renderSelectedItem({ selectedItem, renderSelected, placeholder })} - - - ) - } - - return ( - (this._useKeyboard = true)} - onKeyUp={() => (this._useKeyboard = false)} - > - {searchable ? ( - - } - {...getInputProps({ placeholder })} - /> - - ) : ( - - - {renderSelectedItem({ selectedItem, renderSelected, placeholder })} - - - - )} - - - ) - }} + ) } } -export default Select +export default translate()(Select) diff --git a/src/components/base/Select/stories.js b/src/components/base/Select/stories.js index 75b0c414..c48e914c 100644 --- a/src/components/base/Select/stories.js +++ b/src/components/base/Select/stories.js @@ -6,28 +6,69 @@ import { boolean } from '@storybook/addon-knobs' import Box from 'components/base/Box' import Select from 'components/base/Select' -import Text from 'components/base/Text' const stories = storiesOf('Components/base/Select', module) const itemsChessPlayers = [ - { key: 'aleksandr-grichtchouk', name: 'Aleksandr Grichtchouk' }, - { key: 'fabiano-caruana', name: 'Fabiano Caruana' }, - { key: 'garry-kasparov', name: 'Garry Kasparov' }, - { key: 'hikaru-nakamura', name: 'Hikaru Nakamura' }, - { key: 'levon-aronian', name: 'Levon Aronian' }, - { key: 'magnus-carlsen', name: 'Magnus Carlsen' }, - { key: 'maxime-vachier-lagrave', name: 'Maxime Vachier-Lagrave' }, - { key: 'shakhriyar-mamedyarov', name: 'Shakhriyar Mamedyarov' }, - { key: 'veselin-topalov', name: 'Veselin Topalov' }, - { key: 'viswanathan-anand', name: 'Viswanathan Anand' }, - { key: 'vladimir-kramnik', name: 'Vladimir Kramnik' }, + { value: 'aleksandr-grichtchouk', label: 'Aleksandr Grichtchouk' }, + { value: 'fabiano-caruana', label: 'Fabiano Caruana' }, + { value: 'garry-kasparov', label: 'Garry Kasparov' }, + { value: 'hikaru-nakamura', label: 'Hikaru Nakamura' }, + { value: 'levon-aronian', label: 'Levon Aronian' }, + { value: 'magnus-carlsen', label: 'Magnus Carlsen' }, + { value: 'maxime-vachier-lagrave', label: 'Maxime Vachier-Lagrave' }, + { value: 'shakhriyar-mamedyarov', label: 'Shakhriyar Mamedyarov' }, + { value: 'veselin-topalov', label: 'Veselin Topalov' }, + { value: 'viswanathan-anand', label: 'Viswanathan Anand' }, + { value: 'vladimir-kramnik', label: 'Vladimir Kramnik' }, ] type State = { item: Object | null, } +stories.add('basic', () => ( + + {onChange => ( + ( + + + {item.label} + + )} + /> + )} + +)) + class Wrapper extends PureComponent { state = { item: null, @@ -53,65 +94,3 @@ class Wrapper extends PureComponent { ) } } - -stories.add('basic', () => ( - - {onChange => ( - (item ? item.name : '')} - renderHighlight={(text, key) => ( - - {text} - - )} - /> -)) - -const itemsColors = [ - { key: 'absolute zero', name: 'Absolute Zero', color: '#0048BA' }, - { key: 'acid green', name: 'Acid Green', color: '#B0BF1A' }, - { key: 'aero', name: 'Aero', color: '#7CB9E8' }, - { key: 'aero blue', name: 'Aero Blue', color: '#C9FFE5' }, - { key: 'african violet', name: 'African Violet', color: '#B284BE' }, - { key: 'air force blue (usaf)', name: 'Air Force Blue (USAF)', color: '#00308F' }, - { key: 'air superiority blue', name: 'Air Superiority Blue', color: '#72A0C1' }, -] - -stories.add('custom render', () => ( -