Skip to content

Commit

Permalink
feat: slippage warning ux (#3211)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
zzmp authored Feb 1, 2022
1 parent c82b4fa commit 4b762ef
Show file tree
Hide file tree
Showing 10 changed files with 185 additions and 76 deletions.
33 changes: 15 additions & 18 deletions src/lib/components/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,51 +23,47 @@ const Reference = styled.div`
display: inline-block;
`

const SQRT_8 = Math.sqrt(8)

const Arrow = styled.div`
height: 8px;
width: 8px;
z-index: ${Layer.TOOLTIP};
::before {
background: ${({ theme }) => theme.dialog};
border: 1px solid ${({ theme }) => theme.outline};
content: '';
height: 8px;
position: absolute;
transform: rotate(45deg);
width: 8px;
}
&.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;
}
}
`
Expand All @@ -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)

Expand All @@ -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(
Expand All @@ -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)

Expand Down
127 changes: 102 additions & 25 deletions src/lib/components/Swap/Settings/MaxSlippageSelect.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)}
Expand All @@ -23,50 +29,108 @@ 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)

const [custom, setCustom] = useState('')
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}>
Expand All @@ -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>
Expand Down
7 changes: 5 additions & 2 deletions src/lib/components/Swap/Settings/TransactionTtlInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
`
Expand All @@ -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>
Expand Down
4 changes: 4 additions & 0 deletions src/lib/components/Swap/Settings/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
23 changes: 14 additions & 9 deletions src/lib/components/Swap/Summary/Details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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>
Expand All @@ -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}`],
Expand All @@ -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} />
))}
</>
)
Expand Down
Loading

0 comments on commit 4b762ef

Please sign in to comment.