From 02711b390f6d0b8896f06ab188034a9e776dac1b Mon Sep 17 00:00:00 2001 From: William O'Beirne Date: Fri, 23 Mar 2018 12:25:44 -0400 Subject: [PATCH] Shapeshift Token Box UI (#1371) * Convert to box style, fix TRST * Disabled option, better responsive behavior. * Removed unnecessary logic of splitting out origin and destination option arrays. --- common/api/shapeshift.ts | 3 +- common/components/ui/SwapDropdown.scss | 217 ++++++++++-------- common/components/ui/SwapDropdown.tsx | 214 ++++++++++++----- .../Tabs/Swap/components/CurrencySwap.scss | 3 +- .../Tabs/Swap/components/CurrencySwap.tsx | 77 ++----- 5 files changed, 302 insertions(+), 212 deletions(-) diff --git a/common/api/shapeshift.ts b/common/api/shapeshift.ts index 84244709..66b3bc83 100644 --- a/common/api/shapeshift.ts +++ b/common/api/shapeshift.ts @@ -193,8 +193,7 @@ class ShapeshiftService { private mapMarketInfo(marketInfo: (IPairData & IAvailablePairData)[]) { const tokenMap: TokenMap = {}; marketInfo.forEach(m => { - const originKind = m.pair.substring(0, 3); - const destinationKind = m.pair.substring(4, 7); + const [originKind, destinationKind] = m.pair.split('_'); if (this.isWhitelisted(originKind) && this.isWhitelisted(destinationKind)) { const pairName = originKind + destinationKind; const { rate, limit, min } = m; diff --git a/common/components/ui/SwapDropdown.scss b/common/components/ui/SwapDropdown.scss index 4dd32445..2e7b6e5d 100644 --- a/common/components/ui/SwapDropdown.scss +++ b/common/components/ui/SwapDropdown.scss @@ -1,124 +1,139 @@ @import 'common/sass/variables'; +@import 'common/sass/mixins'; + +$menu-max-width: 540px; +$menu-padding: 10px; +$menu-triangle-size: 14px; +$menu-offset: 40px; +$option-width: ($menu-max-width - $menu-padding * 2) / 3; +$option-width-main: ($menu-max-width - $menu-padding * 2) / 2; +$option-width-small: $option-width-main; +$option-width-main-small: 100%; .SwapDropdown { position: relative; - button { - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); - border: 1px solid #ccc; - padding: 0.4rem 1rem; - border-radius: 2px; - height: 2.5rem; - &:active, - &:hover { - opacity: 0.8; + margin-left: $space-xs; + + &-button { + height: 3rem; + padding-left: $space; + padding-right: $space; + + &-logo { + height: 1.4rem; + margin: -.2rem .3rem 0 -.2rem; + width: auto; } - > li { - margin: 0; - &:first-child { - padding-top: 4px; - } - &:last-child { - padding-bottom: 4px; - } - > a { - font-weight: 300; - &.active { - color: $link-color; - } + + &-label { + padding-right: .75rem; + + &:after { + content: ''; + position: absolute; + top: 50%; + right: .75rem; + // transform: translateY(-50%); + @include triangle(8px, $text-color, down); } } } -} -.SwapDropdown-grid { - position: absolute; - display: none; - padding: 0; - margin-bottom: 0; - min-width: 500px; - left: 50%; - - top: 50px; - transform: translateX(-50%); - list-style: none; - font-size: 0.8rem; - text-align: left; - z-index: 500; - background: white; - box-shadow: 2px 1px 60px rgba(0, 0, 0, 0.175); - - &::before { - content: ''; + &-menu { position: absolute; - top: -20px; - left: 50%; - transform: translateX(-50%); - border-right: 10px solid transparent; - border-left: 10px solid transparent; - border-top: 10px solid transparent; - border-bottom: 10px solid #fff; - } + top: 100%; + right: -$menu-offset; + z-index: $zindex-popover; - &.open { - display: block; - } - li { - display: inline-block; - width: 33.3%; - margin-bottom: 0; - } - li > a { - display: block; - clear: both; - padding: 5px 20px; - color: #163151; - &:hover { - opacity: 0.8; - background-color: #163151; - color: #fff; + &-content { + display: flex; + flex-wrap: wrap; + flex-direction: row; + width: calc(100vw - 30px); + max-width: $menu-max-width; + padding: $menu-padding; + background: #FFF; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + border-radius: 4px; + font-size: 0.8rem; } - } - .inactive { - a { - color: grey; - &:hover { - background-color: #fff; - color: #163151; - cursor: not-allowed; - } + + &-triangle { + position: absolute; + top: -($menu-triangle-size / 2); + right: $menu-offset + 40px; + width: $menu-triangle-size; + height: $menu-triangle-size; + background: #FFF; + box-shadow: -1px -1px 1px rgba(0, 0, 0, 0.1); + transform: rotate(45deg); } - img { - filter: grayscale(100%); - } - } - strong { - margin-left: 5px; - } - @media screen and (max-width: 800px) { - min-width: 300px; } } -.SwapDropdown-desc { - display: inline-block; -} +.SwapOption { + @include reset-button; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: $option-width; + padding: $space-sm $space-md; + text-align: left; + background: rgba($brand-info, 0); + color: $text-color; + transition: $transition; -.SwapDropdown-item { - position: relative; - img { - padding-right: 1px; + @media (max-width: $screen-sm) { + width: 50%; + max-width: none; } -} -.swap-option { - &-wrapper { - font-size: 1rem; + &:hover { + color: #FFF; + background: rgba($brand-info, 0.9); + } + + &-top { display: flex; - align-items: center; - padding: 0.75rem 1rem; + flex-direction: row; + margin-bottom: $space-xs; } - &-img { - width: 1rem; - margin-right: 8px; + + &-logo { + height: 1.2rem; + width: auto; + margin-right: $space-xs; + + .is-main > & { + height: 2.6rem; + margin-right: $space-md; + } + } + + &-ticker { + } + + &-name { + font-weight: 300; + } + + &.is-main { + flex-direction: row; + justify-content: center; + max-width: $option-width-main; + padding: $space $space-md; + font-size: 1rem; + + @media (max-width: $screen-sm) { + width: 50%; + max-width: none; + } + } + + &.is-disabled { + filter: grayscale(1); + opacity: 0.3; + pointer-events: none; } } diff --git a/common/components/ui/SwapDropdown.tsx b/common/components/ui/SwapDropdown.tsx index 94ebc05f..d1f42194 100644 --- a/common/components/ui/SwapDropdown.tsx +++ b/common/components/ui/SwapDropdown.tsx @@ -1,6 +1,7 @@ import React, { PureComponent } from 'react'; +import classnames from 'classnames'; +import { Option } from 'react-select'; import './SwapDropdown.scss'; -import { DropDown } from 'components/ui'; export interface SingleCoin { id: string; @@ -9,71 +10,178 @@ export interface SingleCoin { status: string; } -interface Props { +interface Props { options: SingleCoin[]; + disabledOption?: string; value: string; - onChange(value: T): void; + onChange(value: SingleCoin): void; } -const ValueComp: React.SFC = (props: any) => { - return ( -
- {props.value.label - {props.value.label} -
- ); -}; +interface State { + isOpen: boolean; + mainOptions: SingleCoin[]; + otherOptions: SingleCoin[]; +} -const OptionComp: React.SFC = (props: any) => { - const handleMouseDown = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - props.onSelect(props.option, event); +const MAIN_OPTIONS = ['ETH', 'BTC']; + +class SwapDropdown extends PureComponent { + public state: State = { + isOpen: false, + mainOptions: [], + otherOptions: [] }; - const handleMouseEnter = (event: React.MouseEvent) => { - props.onFocus(props.option, event); + + public dropdown: HTMLDivElement | null; + + public componentWillMount() { + this.buildOptions(this.props.options); + document.addEventListener('click', this.handleBodyClick); + } + + public componentWillUnmount() { + document.removeEventListener('click', this.handleBodyClick); + } + + public componentWillReceiveProps(nextProps: Props) { + if (this.props.options !== nextProps.options) { + this.buildOptions(nextProps.options); + } + } + + public render() { + const { options, value, disabledOption } = this.props; + const { isOpen, mainOptions, otherOptions } = this.state; + + const selectedOption = options.find(opt => opt.name === value); + + return ( +
(this.dropdown = el)}> + + + {isOpen && ( +
+ +
+ {mainOptions.map(opt => ( + + ))} + {otherOptions.map(opt => ( + + ))} +
+
+ )} +
+ ); + } + + private toggleMenu = () => { + this.setState({ isOpen: !this.state.isOpen }); }; - const handleMouseMove = (event: React.MouseEvent) => { - if (props.isFocused) { + + private handleChange = (coin: SingleCoin) => { + this.props.onChange(coin); + if (this.state.isOpen) { + this.toggleMenu(); + } + }; + + private handleBodyClick = (ev: MouseEvent) => { + if (!this.state.isOpen || !this.dropdown) { return; } - props.onFocus(props.option, event); - }; - return ( -
- {props.option.label - {props.option.label} -
- ); -}; -class SwapDropdown extends PureComponent> { - public render() { - const { options, value, onChange } = this.props; - const mappedOptions = options.map(opt => { - return { label: opt.id, value: opt.name, img: opt.image, status: opt.status }; + if ( + ev.target !== this.dropdown && + ev.target instanceof HTMLElement && + !this.dropdown.contains(ev.target) + ) { + this.toggleMenu(); + } + }; + + private buildOptions(options: Props['options']) { + const mainOptions: SingleCoin[] = []; + let otherOptions: SingleCoin[] = []; + + options.forEach(opt => { + if (MAIN_OPTIONS.includes(opt.id)) { + mainOptions.push(opt); + } else { + otherOptions.push(opt); + } }); - return ( - { - return ; - }} - value={value} - clearable={false} - onChange={onChange} - valueComponent={(props: any) => { - return ; - }} - /> + + // Sort non-main coins alphabetically + otherOptions = otherOptions.sort( + (opt1, opt2) => (opt1.id.toLowerCase() > opt2.id.toLowerCase() ? 1 : -1) ); + + this.setState({ mainOptions, otherOptions }); } } +interface SwapOptionProps { + option: SingleCoin; + isMain?: boolean; + isDisabled?: boolean; + onChange(opt: Option): void; +} + +const SwapOption: React.SFC = ({ option, isMain, isDisabled, onChange }) => { + const handleChange = (ev: React.MouseEvent) => { + ev.preventDefault(); + onChange({ + label: option.id, + value: option.name + }); + }; + + const classNames = classnames('SwapOption', isMain && 'is-main', isDisabled && 'is-disabled'); + + return ( + + ); +}; + export default SwapDropdown; diff --git a/common/containers/Tabs/Swap/components/CurrencySwap.scss b/common/containers/Tabs/Swap/components/CurrencySwap.scss index bd308d80..b7cf1f1e 100644 --- a/common/containers/Tabs/Swap/components/CurrencySwap.scss +++ b/common/containers/Tabs/Swap/components/CurrencySwap.scss @@ -27,10 +27,11 @@ justify-content: center; margin: 0 -8px; margin-bottom: 2rem; + > .input-group-wrapper { margin: 0 8px; width: 100%; - max-width: 400px; + max-width: 320px; } } @media (max-width: $screen-sm) { diff --git a/common/containers/Tabs/Swap/components/CurrencySwap.tsx b/common/containers/Tabs/Swap/components/CurrencySwap.tsx index 4a9387bb..501c4178 100644 --- a/common/containers/Tabs/Swap/components/CurrencySwap.tsx +++ b/common/containers/Tabs/Swap/components/CurrencySwap.tsx @@ -12,7 +12,7 @@ import translate, { translateRaw } from 'translations'; import { combineAndUpper } from 'utils/formatters'; import { SwapDropdown, Input } from 'components/ui'; import Spinner from 'components/ui/Spinner'; -import { merge, reject, debounce } from 'lodash'; +import { merge, debounce } from 'lodash'; import './CurrencySwap.scss'; export interface StateProps { @@ -29,11 +29,10 @@ export interface ActionProps { } interface State { + options: any[]; disabled: boolean; origin: SwapOpt; destination: SwapOpt; - originKindOptions: any[]; - destinationKindOptions: any[]; originErr: string; destinationErr: string; timeout: boolean; @@ -50,6 +49,7 @@ interface SwapOpt extends SwapInput { export default class CurrencySwap extends PureComponent { public state: State = { + options: [], disabled: true, origin: { label: 'BTC', @@ -65,8 +65,6 @@ export default class CurrencySwap extends PureComponent { image: 'https://shapeshift.io/images/coins/ether.png', amount: NaN }, - originKindOptions: [], - destinationKindOptions: [], originErr: '', destinationErr: '', timeout: false @@ -110,20 +108,7 @@ export default class CurrencySwap extends PureComponent { }); }, 10000); - const { origin } = this.state; - const { options } = this.props; - - if (options.allIds && options.byId) { - const originKindOptions: any[] = Object.values(options.byId); - const destinationKindOptions: any[] = Object.values( - reject(options.byId, o => o.id === origin.label) - ); - - this.setState({ - originKindOptions, - destinationKindOptions - }); - } + this.setState({ options: Object.values(this.props.options.byId) }); } public componentWillUnmount() { @@ -132,24 +117,17 @@ export default class CurrencySwap extends PureComponent { } } - public componentDidUpdate(prevProps: Props, prevState: State) { + public componentWillReceiveProps(nextProps: Props) { + if (nextProps.options !== this.props.options) { + this.setState({ options: Object.values(nextProps.options.byId) }); + } + } + + public componentDidUpdate(_: Props, prevState: State) { const { origin, destination } = this.state; - const { options } = this.props; if (origin !== prevState.origin) { this.setDisabled(origin, destination); } - - if (options.allIds !== prevProps.options.allIds && options.byId) { - const originKindOptions: any[] = Object.values(options.byId); - const destinationKindOptions: any[] = Object.values( - reject(options.byId, o => o.id === origin.label) - ); - - this.setState({ - originKindOptions, - destinationKindOptions - }); - } } public rateMixer = () => { @@ -280,8 +258,8 @@ export default class CurrencySwap extends PureComponent { }; public onChangeOriginKind = (newOption: any) => { - const { origin, destination, destinationKindOptions } = this.state; - const { options, initSwap } = this.props; + const { origin, destination } = this.state; + const { initSwap } = this.props; const newOrigin = { ...origin, label: newOption.label, value: newOption.value, amount: 0 }; const newDest = { @@ -294,11 +272,7 @@ export default class CurrencySwap extends PureComponent { this.setState({ origin: newOrigin, - destination: newDest, - destinationKindOptions: reject( - [...destinationKindOptions, options.byId[origin.label]], - o => o.id === newOption.label - ) + destination: newDest }); initSwap({ origin: newOrigin, destination: newDest }); @@ -324,15 +298,7 @@ export default class CurrencySwap extends PureComponent { public render() { const { bityRates, shapeshiftRates, provider } = this.props; - const { - origin, - destination, - originKindOptions, - destinationKindOptions, - originErr, - destinationErr, - timeout - } = this.state; + const { options, origin, destination, originErr, destinationErr, timeout } = this.state; const pairName = combineAndUpper(origin.label, destination.label); const bityLoaded = bityRates.byId && bityRates.byId[pairName] ? true : false; const shapeshiftLoaded = shapeshiftRates.byId && shapeshiftRates.byId[pairName] ? true : false; @@ -347,7 +313,7 @@ export default class CurrencySwap extends PureComponent {
{translate('SWAP_DEPOSIT_INPUT_LABEL')}
-
-