Skip to content

Commit

Permalink
feat: add hover states (#253)
Browse files Browse the repository at this point in the history
* feat: add hover states

* don't change input opacity

* disable hover when InputColumn is disabled

* feat: consolidate Input/Output component (#255)

* fix: disable hover states with no currency

* feat: consolidate SwapInput

* fix: use exact amount for wrap

* fix: simplify InputWrapper

Co-authored-by: Connor McEwen <connor.mcewen@gmail.com>

* readability

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
  • Loading branch information
cmcewen and zzmp authored Oct 11, 2022
1 parent 95737b6 commit 3acf651
Show file tree
Hide file tree
Showing 4 changed files with 119 additions and 140 deletions.
164 changes: 101 additions & 63 deletions src/components/Swap/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,20 @@ import { Trans } from '@lingui/macro'
import { Currency, CurrencyAmount } from '@uniswap/sdk-core'
import { TextButton } from 'components/Button'
import { loadingTransitionCss } from 'css/loading'
import {
useIsSwapFieldIndependent,
useSwapAmount,
useSwapCurrency,
useSwapCurrencyAmount,
useSwapInfo,
} from 'hooks/swap'
import { useIsSwapFieldIndependent, useSwapAmount, useSwapCurrency, useSwapInfo } from 'hooks/swap'
import { useIsWrap } from 'hooks/swap/useWrapCallback'
import { usePrefetchCurrencyColor } from 'hooks/useCurrencyColor'
import { useCallback, useMemo, useState } from 'react'
import { PriceImpact } from 'hooks/usePriceImpact'
import { MouseEvent, useCallback, useMemo, useRef, useState } from 'react'
import { TradeState } from 'state/routing/types'
import { Field } from 'state/swap'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
import invariant from 'tiny-invariant'
import { formatCurrencyAmount } from 'utils/formatCurrencyAmount'
import { maxAmountSpend } from 'utils/maxAmountSpend'

import Column from '../Column'
import Row from '../Row'
import TokenImg from '../TokenImg'
import TokenInput, { TokenInputHandle } from './TokenInput'

export const USDC = styled(Row)`
Expand All @@ -32,33 +26,48 @@ export const Balance = styled(ThemedText.Body2)`
transition: color 0.25s ease-in-out;
`

export const InputColumn = styled(Column)<{ approved?: boolean; hasColor?: boolean | null }>`
const InputColumn = styled(Column)<{ approved?: boolean; disableHover?: boolean }>`
background-color: ${({ theme }) => theme.module};
border-radius: ${({ theme }) => theme.borderRadius - 0.25}em;
margin-bottom: 4px;
padding: 20px 0 24px 0;
position: relative;
// Set transitions to reduce color flashes when switching color/token.
// When color loads, transition the background so that it transitions from the empty or last state, but not _to_ the empty state.
transition: ${({ hasColor }) => (hasColor ? 'background-color 0.25s ease-out' : undefined)};
> {
// When color is loading, delay the color/stroke so that it seems to transition from the last state.
transition: ${({ hasColor }) => (hasColor === null ? 'color 0.25s ease-in, stroke 0.25s ease-in' : undefined)};
&:before {
background-size: 100%;
border: 1px solid ${({ theme }) => theme.module};
border-radius: inherit;
box-sizing: border-box;
content: '';
height: 100%;
left: 0;
pointer-events: none;
position: absolute;
top: 0;
transition: 125ms ease border-color;
width: 100%;
}
${TokenImg} {
filter: ${({ approved }) => (approved ? undefined : 'saturate(0) opacity(0.4)')};
transition: filter 0.25s;
}
${({ theme, disableHover }) =>
!disableHover &&
` &:hover:before {
border-color: ${theme.interactive};
}
&:focus-within:before {
border-color: ${theme.outline};
}`}
`

interface UseFormattedFieldAmountArguments {
export function useFormattedFieldAmount({
currencyAmount,
fieldAmount,
}: {
currencyAmount?: CurrencyAmount<Currency>
fieldAmount?: string
}

export function useFormattedFieldAmount({ currencyAmount, fieldAmount }: UseFormattedFieldAmountArguments) {
}) {
return useMemo(() => {
if (fieldAmount !== undefined) {
return fieldAmount
Expand All @@ -70,78 +79,85 @@ export function useFormattedFieldAmount({ currencyAmount, fieldAmount }: UseForm
}, [currencyAmount, fieldAmount])
}

export default function Input() {
interface InputWrapperProps {
field: Field
impact?: PriceImpact
maxAmount?: string
isSufficientBalance?: boolean
}

export function InputWrapper({
field,
impact,
maxAmount,
isSufficientBalance,
className,
}: InputWrapperProps & { className?: string }) {
const {
[Field.INPUT]: { balance, amount: tradeCurrencyAmount, usdc },
[field]: { balance, amount: currencyAmount, usdc },
error,
trade: { state: tradeState },
} = useSwapInfo()

const [inputAmount, updateInputAmount] = useSwapAmount(Field.INPUT)
const [inputCurrency, updateInputCurrency] = useSwapCurrency(Field.INPUT)
const inputCurrencyAmount = useSwapCurrencyAmount(Field.INPUT)
const [amount, updateAmount] = useSwapAmount(field)
const [currency, updateCurrency] = useSwapCurrency(field)

const wrapper = useRef<HTMLDivElement>(null)
const [input, setInput] = useState<TokenInputHandle | null>(null)
const onClick = useCallback(
(event: MouseEvent) => {
if (event.target === wrapper.current) {
input?.focus()
}
},
[input]
)

// extract eagerly in case of reversal
usePrefetchCurrencyColor(inputCurrency)
usePrefetchCurrencyColor(currency)

const isDisabled = error !== undefined
const isRouteLoading = isDisabled || tradeState === TradeState.LOADING
const isDependentField = !useIsSwapFieldIndependent(Field.INPUT)
const isLoading = isRouteLoading && isDependentField

const amount = useFormattedFieldAmount({
currencyAmount: tradeCurrencyAmount,
fieldAmount: inputAmount,
})

//TODO(ianlapham): mimic logic from app swap page
const mockApproved = true

const insufficientBalance = useMemo(
() =>
balance &&
(inputCurrencyAmount ? inputCurrencyAmount.greaterThan(balance) : tradeCurrencyAmount?.greaterThan(balance)),
[balance, inputCurrencyAmount, tradeCurrencyAmount]
)
const isWrap = useIsWrap()
const formattedAmount = useMemo(() => {
if (amount !== undefined) return amount
if (!currencyAmount) return ''
return isWrap ? currencyAmount.toExact() : formatCurrencyAmount({ amount: currencyAmount })
}, [amount, currencyAmount, isWrap])

const max = useMemo(() => {
// account for gas needed if using max on native token
const max = maxAmountSpend(balance)
if (!max || !balance) return
if (max.equalTo(0) || balance.lessThan(max)) return
if (inputCurrencyAmount && max.equalTo(inputCurrencyAmount)) return
return max.toExact()
}, [balance, inputCurrencyAmount])
const onClickMax = useCallback(() => {
invariant(max)
updateInputAmount(max)
if (!maxAmount) return
updateAmount(maxAmount)
input?.focus()
}, [input, max, updateInputAmount])
}, [input, maxAmount, updateAmount])

return (
<InputColumn gap={0.5} approved={mockApproved}>
<InputColumn disableHover={isDisabled || !currency} ref={wrapper} onClick={onClick} className={className}>
<TokenInput
ref={setInput}
amount={amount}
currency={inputCurrency}
amount={formattedAmount}
currency={currency}
disabled={isDisabled}
field={Field.INPUT}
onChangeInput={updateInputAmount}
onChangeCurrency={updateInputCurrency}
onChangeInput={updateAmount}
onChangeCurrency={updateCurrency}
loading={isLoading}
>
<ThemedText.Body2 color="secondary" userSelect>
<Row>
<USDC isLoading={isRouteLoading}>
{usdc && `${formatCurrencyAmount({ amount: usdc, isUsdPrice: true })}`}
{impact && <ThemedText.Body2 color={impact.warning}>({impact.toString()})</ThemedText.Body2>}
</USDC>
{balance && (
<Row gap={0.5}>
<Balance color={insufficientBalance ? 'error' : 'secondary'}>
<Balance color={isSufficientBalance === false ? 'error' : 'secondary'}>
<Trans>Balance:</Trans> {formatCurrencyAmount({ amount: balance })}
</Balance>
{max && (
{maxAmount && (
<TextButton onClick={onClickMax}>
<ThemedText.ButtonSmall>
<Trans>Max</Trans>
Expand All @@ -156,3 +172,25 @@ export default function Input() {
</InputColumn>
)
}

export default function Input() {
const {
[Field.INPUT]: { balance, amount: currencyAmount },
} = useSwapInfo()

const isSufficientBalance = useMemo(() => {
if (!balance || !currencyAmount) return undefined
return !currencyAmount.greaterThan(balance)
}, [balance, currencyAmount])

const maxAmount = useMemo(() => {
// account for gas needed if using max on native token
const max = maxAmountSpend(balance)
if (!max || !balance) return
if (max.equalTo(0) || balance.lessThan(max)) return
if (currencyAmount && max.equalTo(currencyAmount)) return
return max.toExact()
}, [balance, currencyAmount])

return <InputWrapper field={Field.INPUT} isSufficientBalance={isSufficientBalance} maxAmount={maxAmount} />
}
72 changes: 17 additions & 55 deletions src/components/Swap/Output.tsx
Original file line number Diff line number Diff line change
@@ -1,82 +1,44 @@
import { useIsSwapFieldIndependent, useSwapAmount, useSwapCurrency, useSwapInfo } from 'hooks/swap'
import { useSwapCurrency, useSwapInfo } from 'hooks/swap'
import useCurrencyColor from 'hooks/useCurrencyColor'
import { atom } from 'jotai'
import { useAtomValue } from 'jotai/utils'
import { TradeState } from 'state/routing/types'
import { Field } from 'state/swap'
import styled from 'styled-components/macro'
import { DynamicThemeProvider, ThemedText } from 'theme'
import { formatCurrencyAmount } from 'utils/formatCurrencyAmount'
import { DynamicThemeProvider } from 'theme'

import Row from '../Row'
import { Balance, InputColumn, USDC, useFormattedFieldAmount } from './Input'
import TokenInput from './TokenInput'
import { InputWrapper } from './Input'

export const colorAtom = atom<string | undefined>(undefined)

const StyledInputColumn = styled(InputColumn)`
const OutputWrapper = styled(InputWrapper)<{ hasColor?: boolean | null }>`
border-bottom: 1px solid ${({ theme }) => theme.container};
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
padding: 24px 0 20px 0;
// Set transitions to reduce color flashes when switching color/token.
// When color loads, transition the background so that it transitions from the empty or last state, but not _to_ the empty state.
transition: ${({ hasColor }) => (hasColor ? 'background-color 0.25s ease-out' : undefined)};
> {
// When color is loading, delay the color/stroke so that it seems to transition from the last state.
transition: ${({ hasColor }) => (hasColor === null ? 'color 0.25s ease-in, stroke 0.25s ease-in' : undefined)};
}
`

export default function Output() {
const {
[Field.OUTPUT]: { balance, amount: outputCurrencyAmount, usdc: outputUSDC },
error,
trade: { state: tradeState },
impact,
} = useSwapInfo()

const [swapOutputAmount, updateSwapOutputAmount] = useSwapAmount(Field.OUTPUT)
const [swapOutputCurrency, updateSwapOutputCurrency] = useSwapCurrency(Field.OUTPUT)

const isDisabled = error !== undefined
const isRouteLoading = isDisabled || tradeState === TradeState.LOADING
const isDependentField = !useIsSwapFieldIndependent(Field.OUTPUT)
const isLoading = isRouteLoading && isDependentField
const { impact } = useSwapInfo()

const [currency] = useSwapCurrency(Field.OUTPUT)
const overrideColor = useAtomValue(colorAtom)
const dynamicColor = useCurrencyColor(swapOutputCurrency)
const dynamicColor = useCurrencyColor(currency)
const color = overrideColor || dynamicColor

// different state true/null/false allow smoother color transition
const hasColor = swapOutputCurrency ? Boolean(color) || null : false

const amount = useFormattedFieldAmount({
currencyAmount: outputCurrencyAmount,
fieldAmount: swapOutputAmount,
})
const hasColor = currency ? Boolean(color) || null : false

return (
<DynamicThemeProvider color={color}>
<StyledInputColumn hasColor={hasColor} gap={0.5}>
<TokenInput
amount={amount}
currency={swapOutputCurrency}
disabled={isDisabled}
field={Field.OUTPUT}
onChangeInput={updateSwapOutputAmount}
onChangeCurrency={updateSwapOutputCurrency}
loading={isLoading}
>
<ThemedText.Body2 color="secondary" userSelect>
<Row>
<USDC gap={0.5} isLoading={isRouteLoading}>
{outputUSDC && `${formatCurrencyAmount({ amount: outputUSDC, isUsdPrice: true })} `}
{impact && <ThemedText.Body2 color={impact.warning}>({impact.toString()})</ThemedText.Body2>}
</USDC>
{balance && (
<Balance color="secondary">
Balance: <span>{formatCurrencyAmount({ amount: balance })}</span>
</Balance>
)}
</Row>
</ThemedText.Body2>
</TokenInput>
</StyledInputColumn>
<OutputWrapper field={Field.OUTPUT} impact={impact} hasColor={hasColor} />
</DynamicThemeProvider>
)
}
8 changes: 0 additions & 8 deletions src/components/Swap/TokenInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,6 @@ const ValueInput = styled(DecimalInput)`
height: 1.5em;
margin: -0.25em 0;
:hover:not(:focus-within) {
color: ${({ theme }) => theme.onHover(theme.primary)};
}
:hover:not(:focus-within)::placeholder {
color: ${({ theme }) => theme.onHover(theme.secondary)};
}
${loadingTransitionCss}
`

Expand Down
15 changes: 1 addition & 14 deletions src/hooks/swap/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { Currency, CurrencyAmount } from '@uniswap/sdk-core'
import { Currency } from '@uniswap/sdk-core'
import { useAtom } from 'jotai'
import { useAtomValue, useUpdateAtom } from 'jotai/utils'
import { useCallback, useMemo } from 'react'
import { pickAtom } from 'state/atoms'
import { Field, swapAtom, swapEventHandlersAtom } from 'state/swap'
import { invertTradeType, toTradeType } from 'utils/tradeType'
import tryParseCurrencyAmount from 'utils/tryParseCurrencyAmount'
export { ChainError, default as useSwapInfo } from './useSwapInfo'

function otherField(field: Field) {
Expand Down Expand Up @@ -89,15 +88,3 @@ export function useSwapAmount(field: Field): [string | undefined, (amount: strin
)
return [amount, updateAmount]
}

export function useSwapCurrencyAmount(field: Field): CurrencyAmount<Currency> | undefined {
const isFieldIndependent = useIsSwapFieldIndependent(field)
const isAmountPopulated = useIsAmountPopulated()
const [swapAmount] = useSwapAmount(field)
const [swapCurrency] = useSwapCurrency(field)
const currencyAmount = useMemo(() => tryParseCurrencyAmount(swapAmount, swapCurrency), [swapAmount, swapCurrency])
if (isFieldIndependent && isAmountPopulated) {
return currencyAmount
}
return
}

1 comment on commit 3acf651

@vercel
Copy link

@vercel vercel bot commented on 3acf651 Oct 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

widgets – ./

widgets-uniswap.vercel.app
widgets-seven-tau.vercel.app
widgets-git-main-uniswap.vercel.app

Please sign in to comment.