feat: slippage warning ux (#3211)

* feat: setting input spacings

* feat: popover icon props

* fix: slippage input border

* feat: slippage input warning ux

* feat: slippage summary warning ux

* fix: summary layout

* fix: large icon compatibility

* fix: input option style

* fix: large icon compatibility

* fix: popover dimensions

* feat: tooltip hook

* fix: better max slippage popovers

* feat: error color input on invalid slippage

* fix: use default tx ttl

* fix: type userDeadline
This commit is contained in:
Zach Pomerantz 2022-02-01 15:03:55 -08:00 committed by GitHub
parent c82b4fae64
commit 4b762ef5c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 185 additions and 76 deletions

@ -23,6 +23,8 @@ const Reference = styled.div`
display: inline-block;
`
const SQRT_8 = Math.sqrt(8)
const Arrow = styled.div`
height: 8px;
width: 8px;
@ -30,9 +32,7 @@ const Arrow = styled.div`
::before {
background: ${({ theme }) => theme.dialog};
border: 1px solid ${({ theme }) => theme.outline};
content: '';
height: 8px;
position: absolute;
transform: rotate(45deg);
@ -40,34 +40,30 @@ const Arrow = styled.div`
}
&.arrow-top {
bottom: -5px;
bottom: -${SQRT_8}px;
::before {
border-left: none;
border-top: none;
border-bottom-right-radius: 1px;
}
}
&.arrow-bottom {
top: -5px;
top: -${SQRT_8}px;
::before {
border-bottom: none;
border-right: none;
border-top-left-radius: 1px;
}
}
&.arrow-left {
right: -5px;
right: -${SQRT_8}px;
::before {
border-bottom: none;
border-left: none;
border-top-right-radius: 1px;
}
}
&.arrow-right {
left: -5px;
left: -${SQRT_8}px;
::before {
border-right: none;
border-top: none;
border-bottom-left-radius: 1px;
}
}
`
@ -77,10 +73,11 @@ export interface PopoverProps {
show: boolean
children: React.ReactNode
placement: Placement
offset?: number
contained?: true
}
export default function Popover({ content, show, children, placement, contained }: PopoverProps) {
export default function Popover({ content, show, children, placement, offset, contained }: PopoverProps) {
const boundary = useContext(BoundaryContext)
const reference = useRef<HTMLDivElement>(null)
@ -90,8 +87,8 @@ export default function Popover({ content, show, children, placement, contained
const options = useMemo((): Options => {
const modifiers: Options['modifiers'] = [
{ name: 'offset', options: { offset: [5, 5] } },
{ name: 'arrow', options: { element: arrow, padding: 6 } },
{ name: 'offset', options: { offset: [4, offset || 4] } },
{ name: 'arrow', options: { element: arrow, padding: 4 } },
]
if (contained) {
modifiers.push(
@ -118,7 +115,7 @@ export default function Popover({ content, show, children, placement, contained
strategy: 'absolute',
modifiers,
}
}, [arrow, boundary, placement, contained])
}, [offset, arrow, contained, placement, boundary])
const { styles, attributes } = usePopper(reference.current, popover, options)

@ -1,10 +1,12 @@
import { t, Trans } from '@lingui/macro'
import { Trans } from '@lingui/macro'
import { Percent } from '@uniswap/sdk-core'
import { useAtom } from 'jotai'
import { Check, LargeIcon } from 'lib/icons'
import { maxSlippageAtom } from 'lib/state/settings'
import styled, { ThemedText } from 'lib/theme'
import { PropsWithChildren, useCallback, useRef, useState } from 'react'
import Popover from 'lib/components/Popover'
import { TooltipHandlers, useTooltip } from 'lib/components/Tooltip'
import { AlertTriangle, Check, Icon, LargeIcon, XOctagon } from 'lib/icons'
import { MAX_VALID_SLIPPAGE, maxSlippageAtom, MIN_HIGH_SLIPPAGE } from 'lib/state/settings'
import styled, { Color, ThemedText } from 'lib/theme'
import { memo, PropsWithChildren, ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { BaseButton, TextButton } from '../../Button'
import Column from '../../Column'
@ -15,6 +17,10 @@ import { Label, optionCss } from './components'
const tooltip = (
<Trans>Your transaction will revert if the price changes unfavorably by more than this percentage.</Trans>
)
const highSlippage = <Trans>High slippage increases the risk of price movement</Trans>
const invalidSlippage = <Trans>Please enter a valid slippage %</Trans>
const placeholder = '0.10'
const Button = styled(TextButton)<{ selected: boolean }>`
${({ selected }) => optionCss(selected)}
@ -23,27 +29,74 @@ const Button = styled(TextButton)<{ selected: boolean }>`
const Custom = styled(BaseButton)<{ selected: boolean }>`
${({ selected }) => optionCss(selected)}
${inputCss}
border-color: ${({ selected, theme }) => (selected ? theme.active : 'transparent')} !important;
padding: calc(0.75em - 3px) 0.625em;
`
interface OptionProps {
interface OptionProps extends Partial<TooltipHandlers> {
wrapper: typeof Button | typeof Custom
selected: boolean
onSelect: () => void
icon?: ReactNode
}
function Option({ wrapper: Wrapper, children, selected, onSelect }: PropsWithChildren<OptionProps>) {
function Option({
wrapper: Wrapper,
children,
selected,
onSelect,
icon,
...tooltipHandlers
}: PropsWithChildren<OptionProps>) {
return (
<Wrapper selected={selected} onClick={onSelect}>
<Wrapper selected={selected} onClick={onSelect} {...tooltipHandlers}>
<Row gap={0.5}>
{children}
<span style={{ width: '1.2em' }}>{selected && <LargeIcon icon={Check} />}</span>
{icon ? icon : <LargeIcon icon={selected ? Check : undefined} size={1.25} />}
</Row>
</Wrapper>
)
}
enum WarningState {
NONE,
HIGH_SLIPPAGE,
INVALID_SLIPPAGE,
}
const Warning = memo(function Warning({ state, showTooltip }: { state: WarningState; showTooltip: boolean }) {
let icon: Icon
let color: Color
let content: ReactNode
let show = showTooltip
switch (state) {
case WarningState.INVALID_SLIPPAGE:
icon = XOctagon
color = 'error'
content = invalidSlippage
show = true
break
case WarningState.HIGH_SLIPPAGE:
icon = AlertTriangle
color = 'warning'
content = highSlippage
break
case WarningState.NONE:
return null
}
return (
<Popover
key={state}
content={<ThemedText.Caption>{content}</ThemedText.Caption>}
show={show}
placement="top"
offset={16}
contained
>
<LargeIcon icon={icon} color={color} size={1.25} />
</Popover>
)
})
export default function MaxSlippageSelect() {
const [maxSlippage, setMaxSlippage] = useAtom(maxSlippageAtom)
@ -51,22 +104,33 @@ export default function MaxSlippageSelect() {
const input = useRef<HTMLInputElement>(null)
const focus = useCallback(() => input.current?.focus(), [input])
const onInputChange = useCallback(
(custom: string) => {
setCustom(custom)
const numerator = Math.floor(+custom * 100)
if (numerator) {
setMaxSlippage(new Percent(numerator, 10_000))
} else {
const [warning, setWarning] = useState(WarningState.NONE)
const [showTooltip, setShowTooltip, tooltipProps] = useTooltip()
useEffect(() => setShowTooltip(true), [warning, setShowTooltip]) // enables the tooltip if a warning is set
const processInput = useCallback(() => {
const numerator = Math.floor(+custom * 100)
if (numerator) {
const percent = new Percent(numerator, 10_000)
if (percent.greaterThan(MAX_VALID_SLIPPAGE)) {
setWarning(WarningState.INVALID_SLIPPAGE)
setMaxSlippage('auto')
} else if (percent.greaterThan(MIN_HIGH_SLIPPAGE)) {
setWarning(WarningState.HIGH_SLIPPAGE)
setMaxSlippage(percent)
} else {
setWarning(WarningState.NONE)
setMaxSlippage(percent)
}
},
[setMaxSlippage]
)
} else {
setMaxSlippage('auto')
}
}, [custom, setMaxSlippage])
useEffect(processInput, [processInput])
const onInputSelect = useCallback(() => {
focus()
onInputChange(custom)
}, [custom, focus, onInputChange])
processInput()
}, [focus, processInput])
return (
<Column gap={0.75}>
@ -77,9 +141,22 @@ export default function MaxSlippageSelect() {
<Trans>Auto</Trans>
</ThemedText.ButtonMedium>
</Option>
<Option wrapper={Custom} onSelect={onInputSelect} selected={maxSlippage !== 'auto'}>
<Row>
<DecimalInput value={custom} onChange={onInputChange} placeholder={t`Custom`} ref={input} />%
<Option
wrapper={Custom}
selected={maxSlippage !== 'auto'}
onSelect={onInputSelect}
icon={<Warning state={warning} showTooltip={showTooltip} />}
{...tooltipProps}
>
<Row color={warning === WarningState.INVALID_SLIPPAGE ? 'error' : undefined}>
<DecimalInput
size={Math.max(custom.length, 3)}
value={custom}
onChange={setCustom}
placeholder={placeholder}
ref={input}
/>
%
</Row>
</Option>
</Row>

@ -11,6 +11,8 @@ import { Label } from './components'
const tooltip = <Trans>Your transaction will revert if it has been pending for longer than this period of time.</Trans>
const placeholder = TRANSACTION_TTL_DEFAULT.toString()
const Input = styled(Row)`
${inputCss}
`
@ -22,11 +24,12 @@ export default function TransactionTtlInput() {
<Column gap={0.75}>
<Label name={<Trans>Transaction deadline</Trans>} tooltip={tooltip} />
<ThemedText.Body1>
<Input onClick={() => input.current?.focus()}>
<Input justify="start" onClick={() => input.current?.focus()}>
<IntegerInput
placeholder={TRANSACTION_TTL_DEFAULT.toString()}
placeholder={placeholder}
value={transactionTtl?.toString() ?? ''}
onChange={(value) => setTransactionTtl(value ? parseFloat(value) : 0)}
size={Math.max(transactionTtl?.toString().length || 0, placeholder.length)}
ref={input}
/>
<Trans>minutes</Trans>

@ -17,6 +17,10 @@ export const optionCss = (selected: boolean) => css`
:enabled:hover {
border-color: ${({ theme }) => theme.onHover(selected ? theme.active : theme.outline)};
}
:enabled:focus-within {
border-color: ${({ theme }) => theme.active};
}
`
export function value(Value: AnyStyledComponent) {

@ -2,8 +2,8 @@ import { t } from '@lingui/macro'
import { Trade } from '@uniswap/router-sdk'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { useAtom } from 'jotai'
import { integratorFeeAtom } from 'lib/state/settings'
import { ThemedText } from 'lib/theme'
import { integratorFeeAtom, MIN_HIGH_SLIPPAGE } from 'lib/state/settings'
import { Color, ThemedText } from 'lib/theme'
import { useMemo } from 'react'
import { currencyId } from 'utils/currencyId'
import { computeRealizedLPFeePercent } from 'utils/prices'
@ -13,11 +13,12 @@ import Row from '../../Row'
interface DetailProps {
label: string
value: string
color?: Color
}
function Detail({ label, value }: DetailProps) {
function Detail({ label, value, color }: DetailProps) {
return (
<ThemedText.Caption>
<ThemedText.Caption color={color}>
<Row gap={2}>
<span>{label}</span>
<span style={{ whiteSpace: 'nowrap' }}>{value}</span>
@ -44,7 +45,7 @@ export default function Details({ trade, allowedSlippage }: DetailsProps) {
return trade.priceImpact.subtract(realizedLpFeePercent)
}, [trade])
const details = useMemo((): [string, string][] => {
const details = useMemo(() => {
// @TODO(ianlapham): Check that provider fee is even a valid list item
return [
// [t`Liquidity provider fee`, `${swap.lpFee} ${inputSymbol}`],
@ -56,17 +57,21 @@ export default function Details({ trade, allowedSlippage }: DetailsProps) {
trade.tradeType === TradeType.EXACT_OUTPUT
? [t`Minimum received`, `${trade.minimumAmountOut(allowedSlippage).toSignificant(6)} ${outputCurrency.symbol}`]
: [],
[t`Slippage tolerance`, `${allowedSlippage.toFixed(2)}%`],
[
t`Slippage tolerance`,
`${allowedSlippage.toFixed(2)}%`,
allowedSlippage.greaterThan(MIN_HIGH_SLIPPAGE) && 'warning',
],
].filter(isDetail)
function isDetail(detail: unknown[]): detail is [string, string] {
function isDetail(detail: unknown[]): detail is [string, string, Color | undefined] {
return Boolean(detail[1])
}
}, [allowedSlippage, inputCurrency, integrator, integratorFee, outputCurrency.symbol, priceImpact, trade])
return (
<>
{details.map(([label, detail]) => (
<Detail key={label} label={label} value={detail} />
{details.map(([label, detail, color]) => (
<Detail key={label} label={label} value={detail} color={color} />
))}
</>
)

@ -4,7 +4,8 @@ import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { useAtomValue } from 'jotai/utils'
import { IconButton } from 'lib/components/Button'
import useScrollbar from 'lib/hooks/useScrollbar'
import { Expando, Info } from 'lib/icons'
import { AlertTriangle, Expando, Info } from 'lib/icons'
import { MIN_HIGH_SLIPPAGE } from 'lib/state/settings'
import { Field, independentFieldAtom } from 'lib/state/swap'
import styled, { ThemedText } from 'lib/theme'
import { useMemo, useState } from 'react'
@ -44,6 +45,7 @@ const Body = styled(Column)<{ open: boolean }>`
${Column} {
height: 100%;
grid-template-rows: repeat(auto-fill, 1em);
padding: ${({ open }) => (open ? '0.5em 0' : 0)};
transition: padding 0.25s;
@ -61,6 +63,7 @@ const Body = styled(Column)<{ open: boolean }>`
${Estimate} {
max-height: ${({ open }) => (open ? 0 : 56 / 12)}em; // 2 * line-height + padding
min-height: 0;
overflow-y: hidden;
padding: ${({ open }) => (open ? 0 : '1em 0')};
transition: ${({ open }) =>
@ -87,6 +90,8 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial
const independentField = useAtomValue(independentFieldAtom)
const slippageWarning = useMemo(() => allowedSlippage.greaterThan(MIN_HIGH_SLIPPAGE), [allowedSlippage])
const [confirmedTrade, setConfirmedTrade] = useState(trade)
const doesTradeDiffer = useMemo(
() => Boolean(trade && confirmedTrade && tradeMeaningfullyDiffers(trade, confirmedTrade)),
@ -115,7 +120,7 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial
<Rule />
<Row>
<Row gap={0.5}>
<Info color="secondary" />
{slippageWarning ? <AlertTriangle color="warning" /> : <Info color="secondary" />}
<ThemedText.Subhead2 color="secondary">
<Trans>Swap details</Trans>
</ThemedText.Subhead2>

@ -1,40 +1,52 @@
import { Placement } from '@popperjs/core'
import { HelpCircle, Icon } from 'lib/icons'
import styled from 'lib/theme'
import { ReactNode, useState } from 'react'
import { ComponentProps, ReactNode, useCallback, useState } from 'react'
import { IconButton } from './Button'
import Popover from './Popover'
export interface TooltipHandlers {
onMouseEnter: () => void
onMouseLeave: () => void
onFocus: () => void
onBlur: () => void
}
export function useTooltip(): [boolean, (show: boolean) => void, TooltipHandlers] {
const [show, setShow] = useState(false)
const enable = useCallback(() => setShow(true), [])
const disable = useCallback(() => setShow(false), [])
return [show, setShow, { onMouseEnter: enable, onMouseLeave: disable, onFocus: enable, onBlur: disable }]
}
const IconTooltip = styled(IconButton)`
:hover {
cursor: help;
}
`
interface TooltipInterface {
interface TooltipProps {
icon?: Icon
iconProps?: ComponentProps<Icon>
children: ReactNode
placement: Placement
placement?: Placement
offset?: number
contained?: true
}
export default function Tooltip({
icon: Icon = HelpCircle,
iconProps,
children,
placement = 'auto',
offset,
contained,
}: TooltipInterface) {
const [show, setShow] = useState(false)
}: TooltipProps) {
const [showTooltip, , tooltipProps] = useTooltip()
return (
<Popover content={children} show={show} placement={placement} contained={contained}>
<IconTooltip
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
onFocus={() => setShow(true)}
onBlur={() => setShow(false)}
icon={Icon}
/>
<Popover content={children} show={showTooltip} placement={placement} offset={offset} contained={contained}>
<IconTooltip icon={Icon} iconProps={iconProps} {...tooltipProps} />
</Popover>
)
}

@ -3,7 +3,7 @@ import { L2_CHAIN_IDS } from 'constants/chains'
import { L2_DEADLINE_FROM_NOW } from 'constants/misc'
import useCurrentBlockTimestamp from 'hooks/useCurrentBlockTimestamp'
import { useAtomValue } from 'jotai/utils'
import { transactionTtlAtom } from 'lib/state/settings'
import { TRANSACTION_TTL_DEFAULT, transactionTtlAtom } from 'lib/state/settings'
import { useMemo } from 'react'
import useActiveWeb3React from './useActiveWeb3React'
@ -11,7 +11,7 @@ import useActiveWeb3React from './useActiveWeb3React'
// combines the block timestamp with the user setting to give the deadline that should be used for any submitted transaction
export default function useTransactionDeadline(): BigNumber | undefined {
const { chainId } = useActiveWeb3React()
const userDeadline = useAtomValue(transactionTtlAtom)
const userDeadline: number = useAtomValue(transactionTtlAtom) || TRANSACTION_TTL_DEFAULT
const blockTimestamp = useCurrentBlockTimestamp()
return useMemo(() => {
if (blockTimestamp && chainId && L2_CHAIN_IDS.includes(chainId)) return blockTimestamp.add(L2_DEADLINE_FROM_NOW)

@ -18,6 +18,7 @@ import {
Slash as SlashIcon,
Trash2 as Trash2Icon,
X as XIcon,
XOctagon as XOctagonIcon,
} from 'react-feather'
import { ReactComponent as CheckIcon } from '../assets/svg/check.svg'
@ -36,8 +37,6 @@ function icon(Icon: FeatherIcon | SVGIcon) {
`
}
export type Icon = ReturnType<typeof icon>
export const largeIconCss = css<{ iconSize: number }>`
display: flex;
@ -50,11 +49,14 @@ export const largeIconCss = css<{ iconSize: number }>`
const LargeWrapper = styled.div<{ iconSize: number }>`
height: 1em;
width: ${({ iconSize }) => iconSize}em;
${largeIconCss}
`
export type Icon = ReturnType<typeof icon> | typeof LargeIcon
interface LargeIconProps {
icon: Icon
icon?: Icon
color?: Color
size?: number
className?: string
@ -63,7 +65,7 @@ interface LargeIconProps {
export function LargeIcon({ icon: Icon, color, size = 1.2, className }: LargeIconProps) {
return (
<LargeWrapper color={color} iconSize={size} className={className}>
<Icon color={color} />
{Icon && <Icon color={color} />}
</LargeWrapper>
)
}
@ -83,6 +85,7 @@ export const Settings = icon(SettingsIcon)
export const Slash = icon(SlashIcon)
export const Trash2 = icon(Trash2Icon)
export const X = icon(XIcon)
export const XOctagon = icon(XOctagonIcon)
export const Check = styled(icon(CheckIcon))`
circle {

@ -3,6 +3,9 @@ import { atomWithReset } from 'jotai/utils'
import { pickAtom, setTogglable } from './atoms'
export const MAX_VALID_SLIPPAGE = new Percent(1, 2)
export const MIN_HIGH_SLIPPAGE = new Percent(1, 100)
// transaction deadline in minutes, needs to be adjusted to seconds before use
// @TODO(ianlapham): update this to be stored as seconds
export const TRANSACTION_TTL_DEFAULT = 30
@ -17,7 +20,7 @@ interface Settings {
const initialSettings: Settings = {
maxSlippage: 'auto',
transactionTtl: TRANSACTION_TTL_DEFAULT,
transactionTtl: undefined,
integratorFee: undefined,
mockTogglable: true,
clientSideRouter: false,