From 4b762ef5c930b02bab16bbef34ea4ce7d6de7809 Mon Sep 17 00:00:00 2001 From: Zach Pomerantz Date: Tue, 1 Feb 2022 15:03:55 -0800 Subject: [PATCH] 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 --- src/lib/components/Popover.tsx | 33 +++-- .../Swap/Settings/MaxSlippageSelect.tsx | 127 ++++++++++++++---- .../Swap/Settings/TransactionTtlInput.tsx | 7 +- .../components/Swap/Settings/components.tsx | 4 + src/lib/components/Swap/Summary/Details.tsx | 23 ++-- src/lib/components/Swap/Summary/index.tsx | 9 +- src/lib/components/Tooltip.tsx | 38 ++++-- src/lib/hooks/useTransactionDeadline.ts | 4 +- src/lib/icons/index.tsx | 11 +- src/lib/state/settings.ts | 5 +- 10 files changed, 185 insertions(+), 76 deletions(-) diff --git a/src/lib/components/Popover.tsx b/src/lib/components/Popover.tsx index 5519c18588..d86b4c82ae 100644 --- a/src/lib/components/Popover.tsx +++ b/src/lib/components/Popover.tsx @@ -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(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) diff --git a/src/lib/components/Swap/Settings/MaxSlippageSelect.tsx b/src/lib/components/Swap/Settings/MaxSlippageSelect.tsx index e73ff7f14d..e4f3182408 100644 --- a/src/lib/components/Swap/Settings/MaxSlippageSelect.tsx +++ b/src/lib/components/Swap/Settings/MaxSlippageSelect.tsx @@ -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 = ( Your transaction will revert if the price changes unfavorably by more than this percentage. ) +const highSlippage = High slippage increases the risk of price movement +const invalidSlippage = Please enter a valid slippage % + +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 { wrapper: typeof Button | typeof Custom selected: boolean onSelect: () => void + icon?: ReactNode } -function Option({ wrapper: Wrapper, children, selected, onSelect }: PropsWithChildren) { +function Option({ + wrapper: Wrapper, + children, + selected, + onSelect, + icon, + ...tooltipHandlers +}: PropsWithChildren) { return ( - + {children} - {selected && } + {icon ? icon : } ) } +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 ( + {content}} + show={show} + placement="top" + offset={16} + contained + > + + + ) +}) + export default function MaxSlippageSelect() { const [maxSlippage, setMaxSlippage] = useAtom(maxSlippageAtom) @@ -51,22 +104,33 @@ export default function MaxSlippageSelect() { const input = useRef(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 ( @@ -77,9 +141,22 @@ export default function MaxSlippageSelect() { Auto -